mirror of
https://github.com/dbv111m/musicgrabber.git
synced 2026-10-19 01:31:29 -05:00
Tidal capped its embed player at the first 50 tracks and told nobody. Since
our scraper only ever complained when it found zero, a 204-track playlist
sailed in as 50 looking thoroughly pleased with itself, and mirror-mode
watches duly filed the missing 154 as "gone from upstream" and struck them
off the M3U.
Playlists now come from api.tidal.com, 100 at a time, using the token the
embed's own JavaScript has been carrying around in its pocket all along. The
bug report got a 401 chasing this because it knocked on tidal.com; the API
lives next door. No login, no cookies, no headless browser, which makes a
pleasant change from the Spotify equivalent.
Belt and braces: a 401 sends us back to the JS bundle for a fresh token, and
a dead API falls back to the old 50-track scrape, on the grounds that 50
beats nothing at all.
The fiddly bit worth remembering: the embed baked a track's version into its
title ("Enter Sandman (Remastered 2021)") while the API keeps the two apart.
Watched playlists hash those strings, so _track_title() glues them back
together, and carefully declines to say "(2022 Remaster)" twice. Verified as
zero drift across the 50 tracks both paths can see, so nobody's library
suddenly thinks it has been replaced wholesale.
Also swept out monochrome.fetch_tidal_playlist_tracks() and _hifi_api_get(),
dead since v4.0.1 and now dead twice over, plus the test keeping them warm.
Reported by console1982, who did the legwork finding the paginated endpoint.
Built with a human and a grumpy-AI combo, the grumpy half having read rather
more minified JavaScript today than it would have liked.
281 lines
10 KiB
Python
281 lines
10 KiB
Python
"""
|
|
MusicGrabber - Tidal playlist fetching
|
|
|
|
Tidal's main web app is a client-side SPA with no useful SSR data. The embed
|
|
player (embed.tidal.com/playlists/UUID) server-renders its tracks as custom
|
|
<list-item> web components, which was lovely right up until Tidal capped it at
|
|
the first 50; a 204-track playlist came back as 50 and looked perfectly healthy.
|
|
|
|
So playlists now come from the public API at api.tidal.com, paginated properly,
|
|
using the token the embed's own JS bundle carries around with it. The embed
|
|
scrape stays on as a fallback, because 50 tracks beats nothing if the API sulks.
|
|
Album lookups still use the embed; they only want two names, and it still has those.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from constants import (
|
|
TIDAL_API_BASE,
|
|
TIDAL_API_COUNTRY,
|
|
TIDAL_API_PAGE_SIZE,
|
|
TIDAL_API_TOKEN,
|
|
TIMEOUT_HTTP_SPOTIFY,
|
|
)
|
|
|
|
_TIMEOUT = TIMEOUT_HTTP_SPOTIFY
|
|
_HEADERS = {
|
|
"User-Agent": (
|
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
|
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
|
),
|
|
"Accept-Language": "en-GB,en;q=0.9",
|
|
}
|
|
_EMBED_URL = "https://embed.tidal.com/playlists/{uuid}"
|
|
_ALBUM_EMBED_URL = "https://embed.tidal.com/albums/{album_id}"
|
|
_EMBED_ORIGIN = "https://embed.tidal.com"
|
|
|
|
# The live API token, seeded from constants and swapped in place if Tidal ever
|
|
# rotates theirs and greets us with a 401. A plain rebind is atomic enough for
|
|
# our daemon threads; worst case two of them scrape the same token at once.
|
|
_api_token = TIDAL_API_TOKEN
|
|
|
|
|
|
class _ApiUnavailable(Exception):
|
|
"""The API path did not work out; fall back to scraping the embed."""
|
|
|
|
|
|
def fetch_tidal_album_info(album_id: str) -> dict:
|
|
"""Fetch just the artist and album title for a Tidal album, via the embed player.
|
|
|
|
Only the two names, deliberately: this feeds the album-URL resolver, which
|
|
hands them to MusicBrainz and takes the real tracklist from there. The album
|
|
embed does render its tracks, but with empty artist slots (every track on an
|
|
album shares the credit shown once at the top), so scraping them would give
|
|
us a list of titles with nobody attached to them. Not worth the bother when
|
|
MusicBrainz is about to supply a better tracklist anyway.
|
|
|
|
Returns {"artist": str|None, "album": str|None}.
|
|
"""
|
|
url = _ALBUM_EMBED_URL.format(album_id=album_id)
|
|
print(f"[Tidal] Fetching album embed: {url}")
|
|
html = _fetch_html(url)
|
|
return {"artist": _extract_album_artist(html), "album": _extract_album_title(html)}
|
|
|
|
|
|
def _extract_album_title(html: str) -> str | None:
|
|
# <h1 ...><a href="https://tidal.com/album/77640617" ...>Achtung Baby</a></h1>
|
|
match = re.search(r'<h1[^>]*>\s*<a[^>]*>([^<]+)</a>', html)
|
|
if match:
|
|
return match.group(1).strip() or None
|
|
|
|
# Older/alternative shape: <h1 class="media-album" title="Album: Achtung Baby">
|
|
match = re.search(r'<h1[^>]+class="media-album"[^>]+title="Album:\s*([^"]+)"', html)
|
|
return match.group(1).strip() if match else None
|
|
|
|
|
|
def _extract_album_artist(html: str) -> str | None:
|
|
# <span class="media-artist" title="Artist: U2"><a ...>U2</a></span>
|
|
match = re.search(r'class="media-artist"[^>]*title="Artist:\s*([^"]+)"', html)
|
|
if match:
|
|
return match.group(1).strip() or None
|
|
|
|
match = re.search(r'class="media-artist"[^>]*>\s*(?:<a[^>]*>)?([^<]+)<', html)
|
|
return match.group(1).strip() if match else None
|
|
|
|
|
|
def fetch_tidal_playlist(uuid: str) -> dict:
|
|
"""Fetch tracks from a Tidal playlist: API first, embed scrape as backup.
|
|
|
|
Returns dict with: tracks (list of "Artist - Title"), playlist_name, count.
|
|
"""
|
|
try:
|
|
print(f"[Tidal] Fetching playlist {uuid} from the API")
|
|
tracks, playlist_name = _fetch_playlist_via_api(uuid)
|
|
except _ApiUnavailable as exc:
|
|
print(
|
|
f"[Tidal] API unavailable ({exc}); falling back to the embed scrape, "
|
|
f"which only ever shows the first 50 tracks"
|
|
)
|
|
html = _fetch_html(_EMBED_URL.format(uuid=uuid))
|
|
playlist_name = _extract_playlist_name(html)
|
|
tracks = _extract_tracks(html)
|
|
|
|
if not tracks:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=(
|
|
"No tracks found in that Tidal playlist. "
|
|
"It may be empty, private, or the embed structure has changed."
|
|
),
|
|
)
|
|
|
|
print(f"[Tidal] Found {len(tracks)} tracks from '{playlist_name}'")
|
|
return {"tracks": tracks, "playlist_name": playlist_name, "count": len(tracks)}
|
|
|
|
|
|
def _fetch_playlist_via_api(uuid: str) -> tuple[list[str], str]:
|
|
"""Read a whole playlist from api.tidal.com, 100 tracks at a time."""
|
|
meta = _api_get(f"/playlists/{uuid}", {"countryCode": TIDAL_API_COUNTRY}, uuid)
|
|
playlist_name = (meta.get("title") or "").strip() or "Tidal Playlist"
|
|
expected = meta.get("numberOfTracks") or 0
|
|
|
|
tracks: list[str] = []
|
|
offset = 0
|
|
while True:
|
|
page = _api_get(
|
|
f"/playlists/{uuid}/items",
|
|
{
|
|
"countryCode": TIDAL_API_COUNTRY,
|
|
"offset": offset,
|
|
"limit": TIDAL_API_PAGE_SIZE,
|
|
},
|
|
uuid,
|
|
)
|
|
items = page.get("items") or []
|
|
if not items:
|
|
break
|
|
|
|
for wrapper in items:
|
|
# Playlists can hold videos as well; we are only after the audio.
|
|
if wrapper.get("type") != "track":
|
|
continue
|
|
item = wrapper.get("item") or {}
|
|
artist = ((item.get("artist") or {}).get("name") or "").strip()
|
|
title = _track_title(item)
|
|
if artist and title:
|
|
tracks.append(f"{artist} - {title}")
|
|
|
|
offset += len(items)
|
|
if offset >= (page.get("totalNumberOfItems") or 0):
|
|
break
|
|
|
|
# Silent truncation is the very bug we are fixing, so if the sums stop
|
|
# adding up again we would rather hear about it in the logs.
|
|
if expected and len(tracks) < expected:
|
|
print(
|
|
f"[Tidal] Playlist reports {expected} tracks but we collected {len(tracks)}; "
|
|
f"some may be unavailable or missing artist/title"
|
|
)
|
|
|
|
return tracks, playlist_name
|
|
|
|
|
|
def _track_title(item: dict) -> str:
|
|
"""Rebuild the title exactly as the embed used to render it.
|
|
|
|
The embed baked the version into its title slot ("Enter Sandman (Remastered
|
|
2021)") while the API keeps the two apart. Watched playlists hash these
|
|
strings, so drifting here would look like a pile of tracks leaving and an
|
|
identical pile arriving. Some titles already carry their version inline, so
|
|
take care not to say it twice.
|
|
"""
|
|
title = (item.get("title") or "").strip()
|
|
version = (item.get("version") or "").strip()
|
|
if version and version.lower() not in title.lower():
|
|
title = f"{title} ({version})"
|
|
return title
|
|
|
|
|
|
def _api_get(path: str, params: dict, probe_uuid: str) -> dict:
|
|
"""GET from Tidal's public API, refreshing the token once on a 401.
|
|
|
|
probe_uuid exists purely to give a token re-scrape an embed page to read:
|
|
any real embed page carries the JS bundle, the bare embed root does not.
|
|
"""
|
|
global _api_token
|
|
|
|
url = f"{TIDAL_API_BASE}{path}?{urllib.parse.urlencode(params)}"
|
|
for attempt in (1, 2):
|
|
token = _api_token
|
|
req = urllib.request.Request(url, headers={**_HEADERS, "X-Tidal-Token": token})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
|
|
return json.loads(resp.read().decode("utf-8", errors="ignore"))
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code == 401 and attempt == 1:
|
|
fresh = _scrape_api_token(probe_uuid)
|
|
if fresh and fresh != token:
|
|
print("[Tidal] API token had rotated; picked up the new one from the embed bundle")
|
|
_api_token = fresh
|
|
continue
|
|
raise _ApiUnavailable(f"HTTP {exc.code} from {path}")
|
|
except Exception as exc:
|
|
raise _ApiUnavailable(f"{type(exc).__name__}: {exc}")
|
|
|
|
raise _ApiUnavailable(f"401 from {path}, and no fresher token to try")
|
|
|
|
|
|
def _scrape_api_token(uuid: str) -> str | None:
|
|
"""Dig the current API token out of the embed player's JS bundle.
|
|
|
|
Only ever called when our stored token earns a 401, so the happy path pays
|
|
nothing for this bit of paranoia.
|
|
"""
|
|
try:
|
|
html = _fetch_html(_EMBED_URL.format(uuid=uuid))
|
|
bundle = re.search(r'<script[^>]+src="(/embed-resources/js/[^"]+\.js)"', html)
|
|
if not bundle:
|
|
return None
|
|
js = _fetch_html(_EMBED_ORIGIN + bundle.group(1))
|
|
match = re.search(r'X-Tidal-Token"\s*,\s*"([A-Za-z0-9_-]+)"', js)
|
|
return match.group(1) if match else None
|
|
except Exception as exc:
|
|
print(f"[Tidal] Could not re-scrape the API token: {exc}")
|
|
return None
|
|
|
|
|
|
def _fetch_html(url: str) -> str:
|
|
req = urllib.request.Request(url, headers=_HEADERS)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
|
|
return resp.read().decode("utf-8", errors="ignore")
|
|
except urllib.error.HTTPError as exc:
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"Tidal embed returned HTTP {exc.code}. The playlist may be private or deleted.",
|
|
)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=502, detail=f"Failed to fetch Tidal embed page: {exc}")
|
|
|
|
|
|
def _extract_playlist_name(html: str) -> str:
|
|
# <h1 class="media-album" title="Album: Indie Party Hits">
|
|
match = re.search(r'<h1[^>]+class="media-album"[^>]+title="Album:\s*([^"]+)"', html)
|
|
if match:
|
|
return match.group(1).strip()
|
|
|
|
# Fallback: og:title or <title>
|
|
og = re.search(r'property="og:title"\s+content="([^"]+)"', html)
|
|
if og:
|
|
return og.group(1).strip()
|
|
|
|
return "Tidal Playlist"
|
|
|
|
|
|
def _extract_tracks(html: str) -> list[str]:
|
|
# Each track is wrapped in a <list-item product-type="track"> block
|
|
blocks = re.findall(
|
|
r'<list-item[^>]*product-type="track"[^>]*>(.*?)</list-item>',
|
|
html,
|
|
re.DOTALL,
|
|
)
|
|
|
|
tracks = []
|
|
for block in blocks:
|
|
title_m = re.search(r'slot="title">([^<]+)<', block)
|
|
# Artist may be a plain text node or wrapped in an <a> tag
|
|
artist_m = re.search(r'slot="artist">(?:<[^>]+>)?([^<]+)<', block)
|
|
if title_m and artist_m:
|
|
title = title_m.group(1).strip()
|
|
artist = artist_m.group(1).strip()
|
|
if artist and title:
|
|
tracks.append(f"{artist} - {title}")
|
|
|
|
return tracks
|