diff --git a/funkwhale_cli/cli.py b/funkwhale_cli/cli.py
index e16db3cd1d54fef1de021753fefba823d17e2bd6..2feefef7fcbabf73be95791165bb569bbd0843d3 100644
--- a/funkwhale_cli/cli.py
+++ b/funkwhale_cli/cli.py
@@ -45,6 +45,7 @@ except ImportError:
 else:
     SSL_PROTOCOLS = (*SSL_PROTOCOLS, uvloop.loop.SSLProtocol)
 
+
 def ignore_aiohttp_ssl_eror(loop):
     """Ignore aiohttp #3535 / cpython #13548 issue with SSL data after close
 
@@ -73,15 +74,15 @@ def ignore_aiohttp_ssl_eror(loop):
             "Fatal error on transport",
         }:
             # validate we have the right exception, transport and protocol
-            exception = context.get('exception')
-            protocol = context.get('protocol')
+            exception = context.get("exception")
+            protocol = context.get("protocol")
             if (
                 isinstance(exception, ssl.SSLError)
-                and exception.reason == 'KRB5_S_INIT'
+                and exception.reason == "KRB5_S_INIT"
                 and isinstance(protocol, SSL_PROTOCOLS)
             ):
                 if loop.get_debug():
-                    asyncio.log.logger.debug('Ignoring asyncio SSL KRB5_S_INIT error')
+                    asyncio.log.logger.debug("Ignoring asyncio SSL KRB5_S_INIT error")
                 return
         if orig_handler is not None:
             orig_handler(loop, context)
@@ -129,8 +130,10 @@ def async_command(f):
             if _async_reraise:
                 raise
             message = str(e)
-            if hasattr(e, 'status') and e.status == 401:
-                message = "Remote answered with {}, ensure your are logged in first".format(e.status)
+            if hasattr(e, "status") and e.status == 401:
+                message = "Remote answered with {}, ensure your are logged in first".format(
+                    e.status
+                )
             raise click.ClickException(message)
         except (exceptions.FunkwhaleError) as e:
             if _async_reraise:
@@ -161,11 +164,13 @@ RAW_DECORATOR = click.option(
     "--raw", is_flag=True, help="Directly output JSON returned by the happy"
 )
 
-class lazy_credential():
+
+class lazy_credential:
     """
     A proxy object to request access to the proxy object at the later possible point,
     cf #4
     """
+
     def __init__(self, *args):
         self.args = args
         self._cached_value = None
@@ -177,9 +182,15 @@ class lazy_credential():
         try:
             v = keyring.get_password(*self.args)
         except ValueError as e:
-            raise click.ClickException("Error while retrieving password from keyring: {}. Your password may be incorrect.".format(e.args[0]))
+            raise click.ClickException(
+                "Error while retrieving password from keyring: {}. Your password may be incorrect.".format(
+                    e.args[0]
+                )
+            )
         except Exception as e:
-            raise click.ClickException("Error while retrieving password from keyring: {}".format(e.args[0]))
+            raise click.ClickException(
+                "Error while retrieving password from keyring: {}".format(e.args[0])
+            )
         self._cached_value = v
         return v
 
@@ -265,9 +276,15 @@ async def login(ctx, username, password):
     try:
         keyring.set_password(ctx.obj["SERVER_URL"], "_", token)
     except ValueError as e:
-        raise click.ClickException("Error while retrieving password from keyring: {}. Your password may be incorrect.".format(e.args[0]))
+        raise click.ClickException(
+            "Error while retrieving password from keyring: {}. Your password may be incorrect.".format(
+                e.args[0]
+            )
+        )
     except Exception as e:
-        raise click.ClickException("Error while retrieving password from keyring: {}".format(e.args[0]))
+        raise click.ClickException(
+            "Error while retrieving password from keyring: {}".format(e.args[0])
+        )
     click.echo("Login successfull!")
 
 
@@ -443,7 +460,7 @@ def get_ls_command(group, endpoint, output_conf):
                 result = await ctx.obj["remote"].request("get", url, params=params)
                 result.raise_for_status()
                 payload = await result.json()
-                next_page_url = payload['next']
+                next_page_url = payload["next"]
                 page_count += 1
             if raw:
                 click.echo(json.dumps(payload, sort_keys=True, indent=4))
@@ -766,7 +783,9 @@ def flatten(d, parent_key="", sep="_"):
 )
 @click.pass_context
 @async_command
-async def track_download(ctx, id, format, directory, template, overwrite, ignore_errors, skip_existing):
+async def track_download(
+    ctx, id, format, directory, template, overwrite, ignore_errors, skip_existing
+):
     async with ctx.obj["remote"]:
         progressbar = tqdm.tqdm(id, unit="Files")
         for i in progressbar:
@@ -776,8 +795,8 @@ async def track_download(ctx, id, format, directory, template, overwrite, ignore
             logs.logger.info("Downloading from {}".format(download_url))
 
             filename_params = flatten(track_data)
-            filename_params["album"] = filename_params['album_title']
-            filename_params["artist"] = filename_params['artist_name']
+            filename_params["album"] = filename_params["album_title"]
+            filename_params["artist"] = filename_params["artist_name"]
             filename_params["extension"] = format
             filename_params["year"] = (
                 filename_params["album_release_date"][:4]
@@ -792,7 +811,11 @@ async def track_download(ctx, id, format, directory, template, overwrite, ignore
                 full_path = os.path.join(directory, filename)
                 existing = os.path.exists(full_path)
                 if skip_existing and existing:
-                    logs.logger.info("'{}' already exists on disk, skipping download".format(full_path))
+                    logs.logger.info(
+                        "'{}' already exists on disk, skipping download".format(
+                            full_path
+                        )
+                    )
                     continue
                 elif not overwrite and existing:
                     raise click.ClickException(
@@ -801,15 +824,25 @@ async def track_download(ctx, id, format, directory, template, overwrite, ignore
                         )
                     )
 
-            async with ctx.obj["remote"].request("get", download_url, timeout=0) as response:
+            async with ctx.obj["remote"].request(
+                "get", download_url, timeout=0
+            ) as response:
                 try:
                     response.raise_for_status()
                 except aiohttp.ClientResponseError as e:
                     if response.status in ignore_errors:
-                        logs.logger.warning("Remote answered with {} for url {}, skipping".format(response.status, download_url))
+                        logs.logger.warning(
+                            "Remote answered with {} for url {}, skipping".format(
+                                response.status, download_url
+                            )
+                        )
                         continue
                     else:
-                        raise click.ClickException("Remote answered with {} for url {}, exiting".format(response.status, download_url))
+                        raise click.ClickException(
+                            "Remote answered with {} for url {}, exiting".format(
+                                response.status, download_url
+                            )
+                        )
                 if directory:
                     final_directory = os.path.dirname(full_path)
                     pathlib.Path(final_directory).mkdir(parents=True, exist_ok=True)
@@ -930,7 +963,7 @@ async def favorites_tracks_create(ctx, id):
 
     async with ctx.obj["remote"]:
         for i in id:
-            data = {'track': i}
+            data = {"track": i}
             async with ctx.obj["remote"].request(
                 "post", "api/v1/favorites/tracks/", data=data
             ) as response:
@@ -947,7 +980,7 @@ async def favorites_tracks_rm(ctx, id):
 
     async with ctx.obj["remote"]:
         for i in id:
-            data = {'track': i}
+            data = {"track": i}
             async with ctx.obj["remote"].request(
                 "delete", "api/v1/favorites/tracks/remove/", data=data
             ) as response:
@@ -965,6 +998,60 @@ favorites_tracks_ls = get_ls_command(  # noqa
 )
 
 
+@cli.group()
+@click.pass_context
+def playlists(ctx):
+    pass
+
+
+playlists_ls = get_ls_command(
+    playlists,
+    "api/v1/playlists/",
+    output_conf={
+        "labels": [
+            "ID",
+            "Name",
+            "Visibility",
+            "Tracks Count",
+            "User",
+            "Created",
+            "Modified",
+        ],
+        "type": "PLAYLIST",
+    },
+)
+playlists_rm = get_delete_command(playlists, "api/v1/playlists/{}/")
+
+
+@playlists.command("create")
+@click.option("--name", prompt=True)
+@click.option(
+    "--visibility",
+    type=click.Choice(["me", "instance", "everyone"]),
+    default="me",
+    prompt=True,
+)
+@click.option("--raw", is_flag=True)
+@click.pass_context
+@async_command
+async def playlists_create(ctx, raw, name, visibility):
+    async with ctx.obj["remote"]:
+        result = await ctx.obj["remote"].request(
+            "post", "api/v1/playlists/", data={"name": name, "visibility": visibility}
+        )
+        result.raise_for_status()
+        payload = await result.json()
+    if raw:
+        click.echo(json.dumps(payload, sort_keys=True, indent=4))
+    else:
+        click.echo("Playlist created:")
+        click.echo(
+            output.table(
+                [payload], ["ID", "Name", "Visibility", "Tracks Count"], type="PLAYLIST"
+            )
+        )
+
+
 
 @cli.group()
 @click.pass_context
diff --git a/funkwhale_cli/output.py b/funkwhale_cli/output.py
index a2413b6bbd739a3d116f559923d5a89bd7eca97a..21ab2d5044ed7da95a730fb4e1b0e4b1412590dc 100644
--- a/funkwhale_cli/output.py
+++ b/funkwhale_cli/output.py
@@ -37,7 +37,6 @@ FIELDS = {
     },
     "LIBRARY": {
         "Name": {"field": "name"},
-        "Visibility": {"field": "privacy_level"},
         "Description": {"field": "description"},
         "Uploads": {"field": "uploads_count"},
     },
@@ -47,6 +46,10 @@ FIELDS = {
         "Artist": {"field": "track.artist.name"},
         "Favorite Date": {"field": "creation_date"},
     },
+    "PLAYLIST": {
+        "Tracks Count": {"field": "tracks_count"},
+        "User": {"field": "user.username"},
+    },
     "USER": {
         "Username": {"field": "username"},
         "Federation ID": {"field": "full_username"},
diff --git a/tests/test_cli.py b/tests/test_cli.py
index a45b5c8a579f4c07b0a9046be0bef824d84fcb5b..bcf8764dcacd744b00895b8d43b8c5ac41ecf85c 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -288,3 +288,52 @@ def test_albums_ls(cli_ctx, session, responses, get_requests):
 
     requests = get_requests("get", url)
     assert len(requests) == 1
+
+
+def test_playlists_create(cli_ctx, session, responses, get_requests):
+    command = cli.playlists_create
+    url = "https://test.funkwhale/api/v1/playlists/"
+    responses.post(url)
+
+    command.callback(name="test", visibility="public", raw=False, _async_reraise=True)
+
+    requests = get_requests("post", url)
+    assert len(requests) == 1
+    assert requests[0].kwargs["data"] == {"name": "test", "visibility": "public"}
+
+
+def test_playlists_ls(cli_ctx, session, responses, get_requests):
+    command = cli.playlists_ls
+    url = "https://test.funkwhale/api/v1/playlists/?ordering=-creation_date&page=1&page_size=5&q=hello"
+    responses.get(
+        url, payload={"results": [], "next": None, "previous": None, "count": 0}
+    )
+
+    command.callback(
+        raw=False,
+        page=1,
+        page_size=5,
+        ordering="-creation_date",
+        filter="favorites=true",
+        query=["hello"],
+        column=None,
+        format=None,
+        no_headers=False,
+        ids=False,
+        limit=1,
+    )
+
+    requests = get_requests("get", url)
+    assert len(requests) == 1
+
+
+def test_playlists_rm(cli_ctx, session, responses, get_requests):
+    command = cli.playlists_rm
+    url = "https://test.funkwhale/api/v1/playlists/"
+    responses.delete(url + "1/")
+    responses.delete(url + "42/")
+
+    command.callback(id=[1, 42], raw=False, no_input=True, _async_reraise=True)
+
+    assert len(get_requests("delete", url + "1/")) == 1
+    assert len(get_requests("delete", url + "42/")) == 1