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