From 7d803a929d2c9b80f47a79e5fd762685d7df9112 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Wed, 1 Jan 2025 21:20:03 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20add=20`TidalResource`=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_utils.py | 46 +++++++++++++++++++++++++++++++++++++++ tiddl/cli/download/url.py | 46 ++++++++++++++++++++++----------------- tiddl/utils.py | 41 ++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 20 deletions(-) create mode 100644 tests/test_utils.py create mode 100644 tiddl/utils.py diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..3236daa --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,46 @@ +import unittest + +from tiddl.utils import TidalResource + + +class TestTidalResource(unittest.TestCase): + + def test_resource_parsing(self): + positive_cases = [ + ("https://tidal.com/browse/track/12345678", "track", "12345678"), + ("track/12345678", "track", "12345678"), + ("https://tidal.com/browse/album/12345678", "album", "12345678"), + ("album/12345678", "album", "12345678"), + ("https://tidal.com/browse/playlist/12345678", "playlist", "12345678"), + ("playlist/12345678", "playlist", "12345678"), + ("https://tidal.com/browse/artist/12345678", "artist", "12345678"), + ("artist/12345678", "artist", "12345678"), + ] + + for resource, expected_type, expected_id in positive_cases: + with self.subTest(resource=resource): + tidal_url = TidalResource(resource) + self.assertEqual(tidal_url.resource_type, expected_type) + self.assertEqual(tidal_url.resource_id, expected_id) + + def test_failing_cases(self): + failing_cases = [ + "https://tidal.com/browse/invalid/12345678", + "invalid/12345678", + "https://tidal.com/browse/track/invalid", + "track/invalid", + "", + "invalid", + "https://tidal.com/browse/track/", + "track/", + "/12345678", + ] + + for resource in failing_cases: + with self.subTest(resource=resource): + with self.assertRaises(ValueError): + TidalResource(resource) + + +if __name__ == "__main__": + unittest.main() diff --git a/tiddl/cli/download/url.py b/tiddl/cli/download/url.py index d89c4f8..f3b31af 100644 --- a/tiddl/cli/download/url.py +++ b/tiddl/cli/download/url.py @@ -1,36 +1,42 @@ import click from ..ctx import Context, passContext + from tiddl.types import Track -from urllib import parse as urlparse +from tiddl.utils import TidalResource -class URL(click.ParamType): - # TODO: create correct Tidal URL parsing, maybe with regex - name = "url" - - def convert(self, value, param, ctx): - if not isinstance(value, tuple): - value = urlparse.urlparse(value) - if value.scheme not in ("http", "https"): - self.fail( - f"invalid URL scheme ({value.scheme}). Only HTTP URLs are allowed", - param, - ctx, - ) - return value +class TidalURL(click.ParamType): + def convert(self, value: str, param, ctx) -> TidalResource: + try: + return TidalResource(value) + except ValueError as e: + self.fail(message=str(e), param=param, ctx=ctx) @click.group("url") -@click.argument("url", type=URL()) +@click.argument("url", type=TidalURL()) @passContext -def UrlGroup(ctx: Context, url: URL, filename): - """Get Tidal URLs""" +def UrlGroup(ctx: Context, url: TidalResource): + """ + Get Tidal URL. - print(url, filename) + It can be Tidal link or `resource_type/resource_id` format. + The resource can be a track, album, playlist or artist. + """ tracks: list[Track] = [] - # TODO: parse the URL list + # TODO: fetch api + + match url.resource_type: + case "track": + pass + case "album": + pass + case "playlist": + pass + case "artist": + pass ctx.obj.tracks.extend(tracks) diff --git a/tiddl/utils.py b/tiddl/utils.py new file mode 100644 index 0000000..033c1bb --- /dev/null +++ b/tiddl/utils.py @@ -0,0 +1,41 @@ +from urllib.parse import urlparse +from typing import Literal, get_args + + +ResourceTypeLiteral = Literal["track", "album", "playlist", "artist"] + + +class TidalResource: + """ + A parser for Tidal resource URLs or strings. + + Extracts the resource type (e.g., "track", "album") and resource ID + from a given input string. The input string can either be a full URL or a + shorthand string in the format "resource_type/resource_id" (e.g., "track/12345678"). + """ + + resource: str + resource_type: ResourceTypeLiteral + resource_id: str + url: str + + def __init__(self, resource: str) -> None: + self.resource = resource + + path = urlparse(self.resource).path + resource_type, resource_id = path.split("/")[-2:] + + if resource_type not in get_args(ResourceTypeLiteral): + raise ValueError(f"Invalid resource type: {resource_type}") + + self.resource_type = resource_type # type: ignore + + if not resource_id.isdigit() and self.resource_type != "playlist": + raise ValueError(f"Invalid resource id: {resource_id}") + + self.resource_id = resource_id + + self.url = f"https://listen.tidal.com/{self.resource_type}/{self.resource_id}" + + def __str__(self) -> str: + return f"{self.resource_type}/{self.resource_id}"