mirror of
https://github.com/dbv111m/musicgrabber.git
synced 2026-10-19 01:31:29 -05:00
- Amazon Music album URLs finally resolve. Playwright never got past twenty empty <music-image-row> skeletons on an album page, so amazonalbum.py drives a SeleniumBase undetected browser instead and just reads the document title Amazon kindly sets: "Play <Album> by <Artist> on Amazon Music Unlimited". That's all the album pipeline ever needed; MusicBrainz supplies the real tracklist. - Amazon playlist scraper: strips a redundant featured-artist suffix properly, and stops mistaking a cold container's empty first scrape for an emptied playlist by reloading once before giving up. - YouTube Music album links MusicBrainz can't confidently place now fall back to importing as a playlist, same as they did before album matching existed, instead of just dead-ending. - delete_job_file's M3U rebuild now carries custom_subdir and user_id through properly. "Add to album..." singles pick up a year from .albuminfo instead of going tagless. Built with a human and a suspiciously enthusiastic AI.
160 lines
5.0 KiB
Python
160 lines
5.0 KiB
Python
"""
|
|
MusicGrabber - Amazon Music Playlist Fetching
|
|
|
|
Runs amazonpl.py as a subprocess (Playwright headless browser) to scrape
|
|
public Amazon Music playlist pages. Amazon's pages are fully JS-rendered
|
|
so there's no fast HTML-only path like Spotify -- it's headless or nothing.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from constants import (
|
|
AMAZON_ALBUM_TITLE_WAIT,
|
|
TIMEOUT_AMAZON_ALBUM_BROWSER,
|
|
TIMEOUT_AMAZON_BROWSER,
|
|
)
|
|
|
|
_BROWSER_SCRIPT = Path(__file__).parent / "amazonpl.py"
|
|
_ALBUM_SCRIPT = Path(__file__).parent / "amazonalbum.py"
|
|
|
|
|
|
def fetch_amazon_playlist(url: str) -> dict:
|
|
"""Scrape an Amazon Music playlist via headless browser.
|
|
|
|
Runs amazonpl.py in a separate subprocess to keep Playwright's
|
|
event loop away from uvicorn's.
|
|
|
|
Returns dict with: tracks (list of "Artist - Title"), playlist_name, count
|
|
"""
|
|
print(f"Fetching Amazon Music playlist via headless browser: {url}")
|
|
|
|
env = {**os.environ, "AMAZON_URL": url}
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["python3", str(_BROWSER_SCRIPT)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=TIMEOUT_AMAZON_BROWSER,
|
|
env=env,
|
|
)
|
|
print(f"Amazon script return code: {result.returncode}")
|
|
if result.stdout:
|
|
print(f"Amazon script stdout: {result.stdout[:500]}")
|
|
if result.stderr:
|
|
print(f"Amazon script stderr: {result.stderr[:500]}")
|
|
except subprocess.TimeoutExpired:
|
|
raise HTTPException(
|
|
status_code=504,
|
|
detail="Timeout fetching Amazon Music playlist; the page took too long to load"
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
error_msg = result.stderr or "Unknown error"
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"Failed to fetch Amazon Music playlist: {error_msg}"
|
|
)
|
|
|
|
try:
|
|
data = json.loads(result.stdout)
|
|
except json.JSONDecodeError:
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"Invalid response from Amazon browser script: {result.stdout[:200]}"
|
|
)
|
|
|
|
if not data.get("success"):
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"Failed to scrape Amazon playlist: {data.get('error', 'Unknown error')}"
|
|
)
|
|
|
|
tracks = data["tracks"]
|
|
playlist_name = data["playlist_name"]
|
|
|
|
print(f"Successfully extracted {len(tracks)} tracks from Amazon playlist")
|
|
|
|
if not tracks:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail="No tracks found in Amazon playlist. The page structure may have changed."
|
|
)
|
|
|
|
return {
|
|
"tracks": tracks,
|
|
"playlist_name": playlist_name,
|
|
"count": len(tracks)
|
|
}
|
|
|
|
|
|
def fetch_amazon_album_info(url: str) -> dict:
|
|
"""Name an Amazon Music album page: {"artist": ..., "album": ...}.
|
|
|
|
Runs amazonalbum.py as a subprocess, same as the playlist path, but that
|
|
script drives SeleniumBase rather than Playwright. Album pages hand
|
|
Playwright a shell of skeleton rows that never fill in; under undetected
|
|
mode Amazon at least sets a document title naming the record, which is all
|
|
the album pipeline wants. See amazonalbum.py for the full story.
|
|
|
|
Raises HTTPException on failure, so a dead ASIN surfaces as an honest
|
|
error rather than a confidently wrong album.
|
|
"""
|
|
print(f"Naming Amazon Music album via headless browser: {url}")
|
|
|
|
env = {
|
|
**os.environ,
|
|
"AMAZON_ALBUM_URL": url,
|
|
"AMAZON_ALBUM_TIMEOUT": str(AMAZON_ALBUM_TITLE_WAIT),
|
|
}
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["python3", str(_ALBUM_SCRIPT)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=TIMEOUT_AMAZON_ALBUM_BROWSER,
|
|
env=env,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
raise HTTPException(
|
|
status_code=504,
|
|
detail="Timeout naming Amazon Music album; the page took too long to load",
|
|
)
|
|
|
|
if result.stderr:
|
|
print(f"Amazon album script stderr: {result.stderr[:500]}")
|
|
|
|
# SeleniumBase chatters on stdout, so take the last line that parses as the
|
|
# JSON payload rather than assuming the script had the stream to itself.
|
|
data = None
|
|
for line in reversed((result.stdout or "").strip().splitlines()):
|
|
line = line.strip()
|
|
if not line.startswith("{"):
|
|
continue
|
|
try:
|
|
data = json.loads(line)
|
|
break
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
if data is None:
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"Invalid response from Amazon album script: {(result.stdout or '')[:200]}",
|
|
)
|
|
|
|
if not data.get("success"):
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"Failed to name Amazon album: {data.get('error', 'Unknown error')}",
|
|
)
|
|
|
|
print(f"Amazon album resolved: {data.get('artist')} - {data.get('album')}")
|
|
return {"artist": data.get("artist"), "album": data.get("album")}
|