Files
musicgrabber/spotify_browser.py
g33kphr33k 4b74f23152 v3.1.0 (DEV): ReplayGain, provenance audit, and Monochrome stops dawdling
Added:
- ReplayGain 2.0 track and album tags on new downloads, opt-in and
  non-destructive; nothing is re-encoded, the player does the turning down
- Audio Provenance Audit under Watched: a read-only sweep of the whole
  library that keeps stored format, recorded provenance and effective
  quality as three separate ideas rather than one hopeful guess
- MusicBrainz release and release-group URLs now go down the album pipeline
- Minimum-quality filter on search results, honest about which sources
  actually declare a quality before you download (spoiler: not YouTube)

Fixed:
- Monochrome downloads went from 37 seconds to 0.2. The shared qbdlx pool
  holds 28 tokens of which 3 work, and they sit at the bottom, so we now
  remember the one that delivered and try it first. A v9 migration retires
  two default Qobuz proxies that were beyond saving: one answered with a
  Cloudflare 522 after burning a 20-second timeout, the other has no DNS
  record left at all
- Monochrome downloads landing as 30-second previews: sample-only tokens
  are now skipped rather than treated as a finished lossless file
- Singles finally get their track number, search and fingerprint paths both
- Library scans stop counting everything you ever deleted, because NAS bins
  live inside the share and we were walking straight into them
- Downloads land on the album they actually came from: 3.7 out of 31 on the
  old raffle-ticket behaviour, 26 out of 31 now
- Romanised non-English titles find their metadata via aliases

Renamed "Results" to "Tracks", which is what it was always doing anyway.

452 passed, 2 skipped. Built with a human and a grumpy-AI combo, the latter
having spent an hour blaming Monochrome before noticing the connection pool
was the one dawdling.
2026-08-03 21:46:56 +01:00

316 lines
13 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
MusicGrabber - Spotify Headless Browser Script
Standalone script executed as a subprocess by spotify.py.
Receives spotify_type and spotify_id via environment variables.
Outputs JSON to stdout: {success, tracks, playlist_name, count} or {success, error}.
"""
import json
import os
import re
import sys
import time
from playwright.sync_api import sync_playwright
spotify_type = os.environ["SPOTIFY_TYPE"]
spotify_id = os.environ["SPOTIFY_ID"]
url = f"https://open.spotify.com/{spotify_type}/{spotify_id}"
SELECTOR = '[data-testid="tracklist-row"]'
is_liked_songs = os.environ.get("SPOTIFY_IS_LIKED_SONGS", "") == "1"
tracks = []
playlist_name = f"Spotify {spotify_type.title()}"
stall_timeout_seconds = 30
stall_raw = (os.environ.get("SPOTIFY_BROWSER_STALL_SECONDS") or "").strip()
if stall_raw.isdigit():
stall_timeout_seconds = max(5, min(300, int(stall_raw)))
sp_dc = (os.environ.get("SPOTIFY_SP_DC") or "").strip() or None
# Spotify's embed trackList tops out here, so any real total worth chasing is larger.
# We only trust a DOM-read count as a hard completion target when it clears this, so a
# misread small number can never cut a big playlist short.
EMBED_TRACK_LIMIT = 100
def _float_env(name: str, default: float) -> float:
try:
return float((os.environ.get(name) or "").strip())
except (TypeError, ValueError):
return default
def _int_env(name: str) -> int | None:
raw = (os.environ.get(name) or "").strip()
return int(raw) if raw.isdigit() else None
# The parent tells us when it'll pull the plug; we bow out a touch before then with
# whatever we've gathered. seconds_per_track + overhead let us guesstimate how long the
# full scroll should take from the count we read off the page.
hard_deadline_seconds = _int_env("SPOTIFY_BROWSER_DEADLINE_SECONDS")
seconds_per_track = _float_env("SPOTIFY_BROWSER_SECONDS_PER_TRACK", 0.12)
base_overhead = _float_env("SPOTIFY_BROWSER_BASE_OVERHEAD", 120.0)
def _detect_total_from_dom(page) -> int | None:
"""Read the playlist's real track count from the rendered page.
Spotify's server HTML is now a JS shell, but the live page still shows the count
in its header as 'N songs' / 'N items'. We take the largest such number we find in
the header region (the total dwarfs any incidental match), so we know when we've
got the lot and roughly how long to keep scrolling.
"""
selectors = [
'[data-testid="playlist-page"]',
'[data-testid="entityTitle"]',
'[data-testid="action-bar-row"]',
"header",
]
for sel in selectors:
try:
el = page.query_selector(sel)
if not el:
continue
text = el.inner_text()
except Exception:
continue
best = None
for m in re.finditer(r"(\d[\d.,   ]*)\s+(?:songs?|tracks?|items?)\b", text, re.IGNORECASE):
digits = re.sub(r"\D", "", m.group(1))
if digits and (best is None or int(digits) > best):
best = int(digits)
if best:
return best
return None
_script_start = time.monotonic()
try:
with sync_playwright() as p:
browser = p.chromium.launch(
headless=True,
args=["--disable-blink-features=AutomationControlled"],
)
context = browser.new_context(
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
viewport={"width": 1280, "height": 800},
)
# Stop Spotify (and other sites) detecting Playwright via navigator.webdriver
context.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
if sp_dc:
context.add_cookies([{
"name": "sp_dc",
"value": sp_dc,
"domain": ".spotify.com",
"path": "/",
"httpOnly": True,
"secure": True,
}])
page = context.new_page()
page.goto(url, timeout=60000)
# If Spotify redirects to the login page the cookie has expired
if "accounts.spotify.com" in page.url or "/login" in page.url:
print(json.dumps({"success": False, "error": "spotify_cookies_expired"}))
sys.exit(0)
time.sleep(3)
expected_total = None
expected_total_env = (os.environ.get("SPOTIFY_EXPECTED_TOTAL") or "").strip()
if expected_total_env.isdigit():
expected_total = int(expected_total_env)
try:
if expected_total is None:
total_meta = page.locator('meta[name="music:song_count"]').first
total_raw = total_meta.get_attribute("content")
if total_raw and total_raw.isdigit():
expected_total = int(total_raw)
except Exception:
pass
if expected_total is None:
try:
og_desc = page.locator('meta[property="og:description"]').first.get_attribute("content") or ""
m = re.search(r"(\d[\d,]*)\s+items?", og_desc, re.IGNORECASE)
if m:
expected_total = int(m.group(1).replace(",", ""))
except Exception:
expected_total = None
# Accept cookie consent if present; this can block page rendering
cookie_selectors = [
"button:has-text('Accept cookies')",
"button:has-text('Accept Cookies')",
"button:has-text('ACCEPT COOKIES')",
"[data-testid='cookie-policy-manage-dialog-accept-button']",
"button.onetrust-close-btn-handler"
]
for sel in cookie_selectors:
try:
btn = page.locator(sel).first
if btn.count() == 0:
continue
if not btn.is_visible(timeout=750):
continue
print(f"DEBUG: Found cookie button with selector: {sel}", file=sys.stderr)
btn.click(timeout=2000, force=True)
time.sleep(0.5)
break
except Exception as e:
print(f"DEBUG: Cookie selector {sel} failed: {e}", file=sys.stderr)
# Wait for track list to load
page.wait_for_selector(SELECTOR, timeout=30000)
try:
# Get playlist name from the page
title_elem = page.query_selector('[data-testid="playlist-page"] h1')
if not title_elem:
title_elem = page.query_selector('[data-testid="entityTitle"] h1')
if not title_elem:
title_elem = page.query_selector('h1')
if title_elem:
name = title_elem.inner_text().strip()
if name and name != "Your Library":
playlist_name = name
except Exception:
pass
# Liked songs page uses the same row structure as a regular playlist.
if is_liked_songs:
playlist_name = "Liked Songs"
# If we still don't know the count (the parent's HTTP probe comes back empty
# now that the public page is a JS shell), read it off the rendered header.
if not expected_total:
expected_total = _detect_total_from_dom(page)
if expected_total:
print(f"DEBUG: detected {expected_total} tracks from rendered page", file=sys.stderr)
# Guesstimate how long the full scroll should take from the count, with generous
# slack for slow hosts, and never run past the parent's hard deadline. This stops
# us spinning at the outer ceiling on a known-size list while still giving a huge
# playlist plenty of room.
working_deadline = hard_deadline_seconds
if expected_total:
est_with_slack = (base_overhead + expected_total * seconds_per_track) * 1.5
working_deadline = min(hard_deadline_seconds or est_with_slack, est_with_slack)
# Spotify uses virtualised scrolling; tracks get unloaded as you scroll.
# Extract tracks incrementally while scrolling.
seen_tracks_by_index = {}
last_seen_count = 0
last_progress_at = time.monotonic()
def extract_visible_tracks():
for row in page.query_selector_all(SELECTOR):
try:
text = row.inner_text().strip()
parts = text.split(chr(10))
parts = [pt.strip() for pt in parts if pt.strip()]
# Only extract tracks that start with a numeric row index.
if not parts:
continue
track_index_raw = re.sub(r"[^\d]", "", parts[0])
if not track_index_raw:
continue
track_index = int(track_index_raw)
# Skip the track number
parts = parts[1:]
# Skip "E" for Explicit marker
if parts and parts[0] == "E":
parts = parts[1:]
if len(parts) >= 2:
track_name = parts[0].strip()
artist = parts[1].strip()
if artist == "E" and len(parts) >= 3:
artist = parts[2].strip()
if not track_name or artist == "E":
continue
# Music video rows show "Music Video" as the artist.
# The real artist follows a bullet (•) separator in the row parts,
# or may be extractable from the title field ("Artist - Title" pattern).
if artist.lower() == "music video":
# Scan parts for the bullet separator; artist is the element after it.
bullet_idx = next((i for i, p in enumerate(parts) if p == "\u2022"), None)
if bullet_idx is not None and bullet_idx + 1 < len(parts):
real_artist = parts[bullet_idx + 1].strip()
if real_artist and real_artist.lower() != "music video":
seen_tracks_by_index[track_index] = f"{real_artist} - {track_name}"
continue
# Fallback: try splitting title on a dash
dash = re.search(r"\s+[-\u2013\u2014]\s+", track_name)
if dash:
salvaged_artist = track_name[:dash.start()].strip()
salvaged_title = track_name[dash.end():].strip()
if salvaged_artist and salvaged_title:
seen_tracks_by_index[track_index] = f"{salvaged_artist} - {salvaged_title}"
continue
seen_tracks_by_index[track_index] = f"{artist} - {track_name}"
except Exception:
continue
# First extraction before scrolling
extract_visible_tracks()
while True:
# Out of time: hand back whatever we've got rather than let the parent
# hard-kill us (which loses everything and drops back to the 100-track embed).
if working_deadline and (time.monotonic() - _script_start) > working_deadline:
if expected_total and len(seen_tracks_by_index) < expected_total:
print(
f"DEBUG: stopping at deadline ({working_deadline:.0f}s) with "
f"{len(seen_tracks_by_index)}/{expected_total} tracks",
file=sys.stderr,
)
break
# Reached the known total. Only trust counts above the embed cap as a hard
# stop, so a misread small number can't truncate a big playlist.
if expected_total and expected_total > EMBED_TRACK_LIMIT and len(seen_tracks_by_index) >= expected_total:
break
if time.monotonic() - last_progress_at > stall_timeout_seconds:
break
rows = page.query_selector_all(SELECTOR)
if rows:
rows[-1].scroll_into_view_if_needed()
else:
page.mouse.wheel(0, 2000)
time.sleep(0.3)
extract_visible_tracks()
current_seen = len(seen_tracks_by_index)
if current_seen > last_seen_count:
last_seen_count = current_seen
last_progress_at = time.monotonic()
tracks = [seen_tracks_by_index[idx] for idx in sorted(seen_tracks_by_index)]
if expected_total and len(tracks) < expected_total:
print(
f"DEBUG: Track extraction incomplete ({len(tracks)}/{expected_total})",
file=sys.stderr,
)
browser.close()
print(json.dumps({
"success": True,
"tracks": tracks,
"playlist_name": playlist_name,
"count": len(tracks),
"expected_total": expected_total,
}))
except Exception as e:
print(json.dumps({"success": False, "error": str(e)}))