diff --git a/streamrip/client/tidal.py b/streamrip/client/tidal.py index 92455d20..7f175e91 100644 --- a/streamrip/client/tidal.py +++ b/streamrip/client/tidal.py @@ -9,7 +9,7 @@ import aiohttp from ..config import Config -from ..exceptions import NonStreamableError +from ..exceptions import ItemNotFoundError, NonStreamableError from .client import Client from .downloadable import TidalDownloadable @@ -124,7 +124,13 @@ async def get_metadata(self, item_id: str, media_type: str) -> dict: item["lyrics"] = resp.get("lyrics") or "" else: item["lyrics"] = resp.get("subtitles") or resp.get("lyrics") or "" - except TypeError as e: + except ItemNotFoundError: + # Most tracks simply have no lyrics. That is the expected + # answer, not a problem worth reporting. + logger.debug("No lyrics available for track %s", item_id) + except (NonStreamableError, TypeError) as e: + # Lyrics that should have been there but could not be + # fetched -- worth knowing about. logger.warning(f"Failed to get lyrics for {item_id}: {e}") logger.debug(item) @@ -353,7 +359,10 @@ async def _api_request(self, path: str, params=None, base: str = BASE) -> dict: async with self.rate_limiter: async with self.session.get(f"{base}/{path}", params=params) as resp: if resp.status == 404: - logger.warning("TIDAL: track not found", resp) - raise NonStreamableError("TIDAL: Track not found") + # Logged at debug, not warning: some callers ask for + # optional things (lyrics) where a 404 is the normal + # answer. Callers that do care log it themselves. + logger.debug("TIDAL: item not found (404): %s", resp.url) + raise ItemNotFoundError(f"TIDAL: item not found: {resp.url}") resp.raise_for_status() return await resp.json() diff --git a/streamrip/exceptions.py b/streamrip/exceptions.py index 687b1a14..e1c5d522 100644 --- a/streamrip/exceptions.py +++ b/streamrip/exceptions.py @@ -66,5 +66,14 @@ def print_msg(self, item) -> str: return " ".join(base_msg) +class ItemNotFoundError(NonStreamableError): + """The API returned 404 for an item. + + A subclass of NonStreamableError so existing handlers are unaffected, but + distinguishable for callers fetching something optional -- "this does not + exist" and "this failed to download" deserve different log levels. + """ + + class ConversionError(Exception): """ConversionError.""" diff --git a/streamrip/rip/parse_url.py b/streamrip/rip/parse_url.py index bdfa0b03..172214ba 100644 --- a/streamrip/rip/parse_url.py +++ b/streamrip/rip/parse_url.py @@ -20,6 +20,7 @@ URL_REGEX = re.compile( r"https?://(?:www|open|play|listen)?\.?(qobuz|tidal|deezer)\.com?(?:(?:/(album|artist|track|playlist|video|label))|(?:\/[-\w]+?))+\/([-\w]+)", ) +TIDAL_SHARE_SUFFIX_REGEX = re.compile(r"^(https?://[^/]*tidal\.com/.+?)/u/?$") SOUNDCLOUD_URL_REGEX = re.compile(r"https://soundcloud.com/[-\w:/]+") LASTFM_URL_REGEX = re.compile(r"https://www.last.fm/user/\w+/playlists/\w+") QOBUZ_INTERPRETER_URL_REGEX = re.compile( @@ -54,6 +55,12 @@ async def into_pending( class GenericURL(URL): @classmethod def from_str(cls, url: str) -> URL | None: + # Tidal's share sheet produces links ending in "/u". URL_REGEX takes + # the last path segment as the item id -- it has to, because Qobuz + # album urls look like //album// -- so that suffix + # would be parsed as an id of "u" and the API would 404. + url = TIDAL_SHARE_SUFFIX_REGEX.sub(r"\1", url) + generic_url = URL_REGEX.match(url) if generic_url is None: return None diff --git a/tests/test_parse_url.py b/tests/test_parse_url.py index 9048cd64..87f67779 100644 --- a/tests/test_parse_url.py +++ b/tests/test_parse_url.py @@ -51,6 +51,28 @@ def test_tidal_track_url(self): self.assertEqual(groups[1], "track") # media_type self.assertEqual(groups[2], "3083287") # item_id + def test_tidal_share_url_with_u_suffix(self): + """Test that Tidal share links ending in /u parse to the real item id. + + The share sheet appends "/u", which would otherwise be taken as the + item id and 404 against the API. + """ + for url in ( + "https://tidal.com/album/152697662/u", + "https://tidal.com/album/152697662/u/", + "https://tidal.com/browse/album/152697662/u", + ): + with self.subTest(url=url): + result = parse_url(url) + + self.assertIsNotNone(result) + self.assertIsInstance(result, GenericURL) + self.assertEqual(result.source, "tidal") + + groups = result.match.groups() + self.assertEqual(groups[1], "album") # media_type + self.assertEqual(groups[2], "152697662") # item_id + def test_deezer_track_url(self): """Test that Deezer track URLs are matched correctly.""" url = "https://www.deezer.com/track/4195713"