Files
streamrip-ui/backend/app/services/streamrip_service.py
T
loki 99cfda2b08
CI / backend-tests (push) Successful in 8s
Docker images / backend (push) Successful in 7s
Docker images / frontend (push) Successful in 22s
CI / docker (push) Successful in 3s
feat: mehr-laden paging (suche + entdecken, totals, fallback-skip)
2026-10-04 16:39:16 +02:00

757 lines
26 KiB
Python

"""streamrip library wrapper (Deezer only for now)."""
from pathlib import Path
from app.core import settings
def _ensure_config_exists() -> bool:
"""Create a default config.toml if missing. Returns True if created."""
import logging
from streamrip.config import set_user_defaults
p = Path(settings.CONFIG_PATH)
if p.exists():
return False
p.parent.mkdir(parents=True, exist_ok=True)
set_user_defaults(str(p))
# Point the download folder at our environment (e.g. /downloads in Docker).
try:
from tomlkit.api import dumps as toml_dumps
from tomlkit.api import parse as toml_parse
doc = toml_parse(p.read_text())
doc["downloads"]["folder"] = str(Path(settings.DOWNLOAD_DIR).expanduser())
p.write_text(toml_dumps(doc))
except Exception as e:
logging.getLogger("streamrip-ui").warning("could not set download folder: %s", e)
return True
def _load_config():
"""Load streamrip Config with DB paths rewritten to the config dir.
The mounted config.toml may contain host-absolute DB paths
(e.g. /home/lokman/...) that don't exist inside the container.
Rewriting them to the config dir keeps history working everywhere.
A missing config.toml is created from streamrip defaults (ARL stays
empty and must be added via the UI).
"""
from streamrip.config import Config
_ensure_config_exists()
config = Config(settings.CONFIG_PATH)
cfg_dir = Path(settings.CONFIG_PATH).parent
try:
config.session.database.downloads_path = str(cfg_dir / "downloads.db")
config.session.database.failed_downloads_path = str(
cfg_dir / "failed_downloads.db"
)
except Exception:
pass
return config
def _extract_common(item: dict) -> dict:
cover = (
item.get("cover_big")
or item.get("cover_medium")
or item.get("cover")
or (item.get("album") or {}).get("cover_big")
or (item.get("album") or {}).get("cover_medium")
or item.get("picture_big")
or item.get("picture_medium")
)
preview = item.get("preview")
duration = item.get("duration")
explicit = item.get("explicit_lyrics")
return {"cover": cover, "preview_url": preview, "duration": duration, "explicit": explicit}
def _summarize(media_type: str, item: dict) -> dict:
from streamrip.metadata import SearchResults
summary = SearchResults.from_pages("deezer", media_type, [{"data": [item]}]).results[0]
base = {"id": summary.id, "desc": summary.summarize()}
if media_type == "track":
base.update({"title": summary.name, "artist": summary.artist})
elif media_type == "album":
base.update({"title": summary.name, "artist": summary.artist})
elif media_type == "artist":
base.update({"title": summary.name, "artist": summary.name})
elif media_type == "playlist":
base.update({"title": summary.name, "artist": summary.creator})
base.update(_extract_common(item))
base.update({"source": "deezer", "media_type": media_type})
return base
async def search_deezer(
media_type: str, query: str, limit: int = 20, index: int = 0
) -> tuple[list[dict], int | None]:
"""Live search via Deezer API (paged). Returns (UI-ready dicts, total)."""
import asyncio
from streamrip.rip.main import Main
config = _load_config()
main = Main(config)
try:
client = await main.get_logged_in_client("deezer")
try:
search_fn = getattr(client.client.api, f"search_{media_type}")
except AttributeError:
raise Exception(f"Invalid media type {media_type}")
resp = await asyncio.to_thread(search_fn, query, False, None, index, limit)
finally:
await main.__aexit__()
if not resp or not resp.get("data"):
return [], 0
total = resp.get("total")
items = resp.get("data", [])[:limit]
return [_summarize(media_type, item) for item in items], total
def _group_artist_albums(items: list[dict]) -> list[dict]:
"""Map raw Deezer album items to UI dicts with group album|ep|single."""
out = []
for item in items:
record_type = str(item.get("record_type") or "").lower()
if record_type == "ep":
group = "ep"
elif record_type == "single":
group = "single"
else:
# Deezer marks LPs as "album"; unknown/missing falls back to album
# only when tracks suggest a full release, else single
nb = item.get("nb_tracks") or 0
try:
nb = int(nb)
except (TypeError, ValueError):
nb = 0
group = "album" if (record_type == "album" or nb > 6) else "single"
cover = (
item.get("cover_big")
or item.get("cover_medium")
or item.get("cover")
)
out.append(
{
"id": str(item.get("id")),
"title": item.get("title") or "Unknown",
"artist": (item.get("artist") or {}).get("name", "")
if isinstance(item.get("artist"), dict)
else str(item.get("artist") or ""),
"cover": cover,
"release_date": item.get("release_date") or item.get("record_release_date") or "",
"nb_tracks": nb,
"group": group,
"source": "deezer",
"media_type": "album",
}
)
return out
async def get_artist_details(artist_id: str) -> dict:
"""Artist info + grouped albums + featuring tracks (track-search approx)."""
from streamrip.rip.main import Main
config = _load_config()
main = Main(config)
try:
client = await main.get_logged_in_client("deezer")
meta = await client.get_metadata(artist_id, "artist")
finally:
await main.__aexit__()
artist = meta if isinstance(meta, dict) else {}
# get_metadata("artist") returns artist dict with "albums" attached
albums_raw = artist.get("albums") or []
if isinstance(albums_raw, dict):
albums_raw = albums_raw.get("data", [])
name = artist.get("name") or "Unknown"
cover = artist.get("picture_big") or artist.get("picture_medium")
featuring = await _find_featuring(name)
return {
"id": str(artist_id),
"name": name,
"cover": cover,
"nb_albums": artist.get("nb_album"),
"albums": _group_artist_albums(albums_raw),
"featuring": featuring,
}
def _is_featuring(name: str, item: dict) -> bool:
"""True if track item is a guest appearance of `name` (not main artist)."""
lname = name.lower()
main_artist = item.get("artist")
aname = (
main_artist.get("name", "")
if isinstance(main_artist, dict)
else str(main_artist or "")
)
return aname.lower() != lname
async def _find_featuring(name: str, limit: int = 20) -> list[dict]:
"""Track-search approximation of guest appearances (labeled as such in UI)."""
from streamrip.rip.main import Main
queries = [name, f"feat. {name}"]
featuring: list[dict] = []
seen: set[str] = set()
try:
for q in queries:
if len(featuring) >= limit:
break
config = _load_config()
main = Main(config)
try:
client = await main.get_logged_in_client("deezer")
pages = await client.search("track", q, limit=50)
finally:
await main.__aexit__()
if not pages:
continue
for item in pages[0].get("data", []):
tid = str(item.get("id"))
if tid in seen:
continue
seen.add(tid)
if _is_featuring(name, item):
featuring.append(_summarize("track", item))
if len(featuring) >= limit:
break
except Exception:
pass
return featuring
def _map_track_items(items: list[dict]) -> list[dict]:
"""Map raw Deezer album-track items to UI dicts (track order preserved)."""
out = []
for pos, item in enumerate(items, start=1):
artist = item.get("artist")
aname = (
artist.get("name", "")
if isinstance(artist, dict)
else str(artist or "")
)
album = item.get("album") or {}
cover = (
item.get("cover_big")
or item.get("cover_medium")
or album.get("cover_big")
or album.get("cover_medium")
)
try:
track_number = int(item.get("track_position") or pos)
except (TypeError, ValueError):
track_number = pos
out.append(
{
"id": str(item.get("id")),
"title": item.get("title") or "Unknown",
"artist": aname,
"track_number": track_number,
"disk_number": item.get("disk_number") or 1,
"duration": item.get("duration"),
"preview_url": item.get("preview"),
"explicit": item.get("explicit_lyrics"),
"cover": cover,
"source": "deezer",
"media_type": "track",
}
)
return out
async def _fetch_with_tracks(
client, media_type: str, item_id: str, methods: tuple[str, str]
) -> dict:
"""Fetch item metadata; fall back to embedded tracks if the
dedicated tracks endpoint has no data (some uploads lack a listing)."""
import asyncio
get_one, get_tracks = methods
api = client.client.api
meta = await asyncio.to_thread(getattr(api, get_one), item_id)
try:
tracks_resp = await asyncio.to_thread(getattr(api, get_tracks), item_id)
meta["tracks"] = tracks_resp.get("data", [])
except Exception:
embedded = meta.get("tracks")
if isinstance(embedded, dict):
meta["tracks"] = embedded.get("data", [])
elif not isinstance(embedded, list):
meta["tracks"] = []
meta["track_total"] = len(meta.get("tracks") or [])
return meta
async def _fetch_album_meta(client, album_id: str) -> dict:
return await _fetch_with_tracks(client, "album", album_id, ("get_album", "get_album_tracks"))
async def get_album_details(album_id: str) -> dict:
"""Album info + track list via streamrip DeezerClient."""
from streamrip.rip.main import Main
config = _load_config()
main = Main(config)
try:
client = await main.get_logged_in_client("deezer")
meta = await _fetch_album_meta(client, album_id)
finally:
await main.__aexit__()
if not isinstance(meta, dict):
raise RuntimeError("Empty album metadata")
artist = meta.get("artist")
aname = (
artist.get("name", "")
if isinstance(artist, dict)
else str(artist or "")
)
tracks_raw = meta.get("tracks") or []
if isinstance(tracks_raw, dict):
tracks_raw = tracks_raw.get("data", [])
return {
"id": str(meta.get("id") or album_id),
"title": meta.get("title") or "Unknown",
"artist": aname,
"cover": meta.get("cover_big") or meta.get("cover_medium"),
"release_date": meta.get("release_date") or "",
"nb_tracks": meta.get("nb_tracks") or len(tracks_raw),
"tracks": _map_track_items(tracks_raw),
}
async def get_playlist_details(playlist_id: str) -> dict:
"""Playlist info + track list via streamrip DeezerClient."""
from streamrip.rip.main import Main
config = _load_config()
main = Main(config)
try:
client = await main.get_logged_in_client("deezer")
meta = await _fetch_with_tracks(
client, "playlist", playlist_id, ("get_playlist", "get_playlist_tracks")
)
finally:
await main.__aexit__()
if not isinstance(meta, dict):
raise RuntimeError("Empty playlist metadata")
creator = meta.get("creator") or meta.get("owner") or {}
cname = (
creator.get("name", "")
if isinstance(creator, dict)
else str(creator or "")
)
tracks_raw = meta.get("tracks") or []
if isinstance(tracks_raw, dict):
tracks_raw = tracks_raw.get("data", [])
return {
"id": str(meta.get("id") or playlist_id),
"title": meta.get("title") or meta.get("name") or "Unknown",
"creator": cname,
"description": meta.get("description") or "",
"cover": meta.get("picture_big") or meta.get("picture_medium"),
"nb_tracks": meta.get("nb_tracks") or len(tracks_raw),
"tracks": _map_track_items(tracks_raw),
}
async def download_by_url(
url: str,
quality: int | None = None,
codec: str | None = None,
folder: str | None = None,
no_db: bool = False,
on_log=None,
) -> None:
"""Resolve + download one item from a Deezer URL. Raises on failure."""
from streamrip.rip.main import Main
from streamrip.rip.parse_url import parse_url
def log(line: str):
if on_log:
on_log(line)
if parse_url(url) is None:
raise ValueError(f"Unable to parse url {url}")
config = _load_config()
effective_folder = _apply_job_overrides(config, quality, codec, folder)
log(f"Target folder: {effective_folder}")
main = Main(config)
try:
if no_db:
try:
from streamrip import db as dbmod
main.database = dbmod.Database(dbmod.Dummy(), main.database.failed)
except Exception:
pass
log(f"Resolving {url} ...")
await main.add(url)
await main.resolve()
if not main.media:
raise RuntimeError("Nothing resolved (already downloaded or invalid url)")
log(f"Downloading {len(main.media)} item(s) ...")
await main.rip()
log("Download finished.")
finally:
await main.__aexit__()
async def get_related_artists(artist_id: str, limit: int = 12) -> list[dict]:
"""Similar artists via Deezer related endpoint."""
import asyncio
from streamrip.rip.main import Main
config = _load_config()
main = Main(config)
try:
client = await main.get_logged_in_client("deezer")
api = client.client.api
resp = await asyncio.to_thread(api.get_artist_related, artist_id, 0, limit)
finally:
await main.__aexit__()
items = resp.get("data", []) if isinstance(resp, dict) else []
return [_summarize("artist", item) for item in items[:limit]]
async def get_genres() -> list[dict]:
"""Deezer genre catalog."""
import asyncio
from streamrip.rip.main import Main
config = _load_config()
main = Main(config)
try:
client = await main.get_logged_in_client("deezer")
resp = await asyncio.to_thread(client.client.api.get_genres, 0, 100)
finally:
await main.__aexit__()
items = resp.get("data", []) if isinstance(resp, dict) else []
return [
{
"id": item.get("id"),
"name": item.get("name") or "Unknown",
"cover": item.get("picture_big") or item.get("picture_medium"),
}
for item in items
]
async def _playlist_top_tracks(api, genre_id: int, limit: int, skip: int = 0) -> list[dict]:
"""Raw track items from the top chart playlists (fallback source)."""
import asyncio
resp = await asyncio.to_thread(api.get_chart_playlists, genre_id, 0, 5)
playlists = resp.get("data", []) if isinstance(resp, dict) else []
tracks: list[dict] = []
seen: set[str] = set()
skipped = 0
for pl in playlists[:3]:
try:
tresp = await asyncio.to_thread(api.get_playlist_tracks, pl.get("id"), 0, limit + skip)
except Exception:
continue
for item in tresp.get("data", []) if isinstance(tresp, dict) else []:
tid = str(item.get("id"))
if tid in seen:
continue
seen.add(tid)
if skipped < skip:
skipped += 1
continue
tracks.append(item)
if len(tracks) >= limit:
return tracks
return tracks
def _artists_from_track_items(items: list[dict]) -> list[dict]:
"""Unique artist stubs from raw track items (for _summarize)."""
artists: list[dict] = []
seen: set[str] = set()
for item in items:
artist = item.get("artist")
if isinstance(artist, dict):
aid, aname = str(artist.get("id")), artist.get("name") or "Unknown"
else:
aid, aname = str(artist or ""), str(artist or "Unknown")
if not aid or aid in seen:
continue
seen.add(aid)
artists.append({"id": aid, "name": aname})
return artists
async def _discover_albums_fallback(api, genre_id: int, limit: int, primary: list[dict]) -> list[dict]:
"""Editorial selection first, then fill up with chart albums (newest
first, deduplicated) — Deezer caps editorial selection at 10 items."""
import asyncio
seen = {str(i.get("id")) for i in primary}
out = list(primary)
if len(out) < limit:
resp = await asyncio.to_thread(api.get_chart_albums, genre_id, 0, limit)
items = resp.get("data", []) if isinstance(resp, dict) else []
items = sorted(items, key=lambda i: i.get("release_date") or "", reverse=True)
for item in items:
if str(item.get("id")) not in seen:
seen.add(str(item.get("id")))
out.append(item)
if len(out) >= limit:
break
return out[:limit]
async def get_discover(kind: str, genre_id: int = 0, limit: int = 20, index: int = 0) -> tuple[list[dict], int | None]:
"""Charts per kind with fallbacks (several Deezer endpoints are empty)."""
import asyncio
from streamrip.rip.main import Main
config = _load_config()
main = Main(config)
try:
client = await main.get_logged_in_client("deezer")
api = client.client.api
if kind == "tracks":
resp = await asyncio.to_thread(api.get_chart_tracks, genre_id, index, limit)
items = resp.get("data", [])
total = resp.get("total")
if not items:
items = await _playlist_top_tracks(api, genre_id, limit, skip=index)
total = None
return [_summarize("track", i) for i in items[:limit]], total
if kind == "albums":
resp = await asyncio.to_thread(api.get_chart_albums, genre_id, index, limit)
items = resp.get("data", [])
return [_summarize("album", i) for i in items[:limit]], resp.get("total")
if kind == "artists":
resp = await asyncio.to_thread(api.get_chart_artists, genre_id, index, limit)
items = resp.get("data", [])
total = resp.get("total")
if not items:
raw = await _playlist_top_tracks(api, genre_id, limit * 2, skip=index * 2)
items = _artists_from_track_items(raw)[:limit]
total = None
return [_summarize("artist", i) for i in items[:limit]], total
if kind == "playlists":
resp = await asyncio.to_thread(api.get_chart_playlists, genre_id, index, limit)
items = resp.get("data", [])
return [_summarize("playlist", i) for i in items[:limit]], resp.get("total")
# kind == "new": editorial selection, chart albums (newest first) as fallback
resp = await asyncio.to_thread(api.get_editorial_selection, genre_id, index, limit)
items = resp.get("data", []) if isinstance(resp, dict) else []
items = await _discover_albums_fallback(api, genre_id, limit, items)
return [_summarize("album", i) for i in items[:limit]], resp.get("total") if isinstance(resp, dict) else None
finally:
await main.__aexit__()
async def get_genre_radios(genre_id: int) -> list[dict]:
"""Radio stations for a genre (0 = all)."""
import asyncio
from streamrip.rip.main import Main
config = _load_config()
main = Main(config)
try:
client = await main.get_logged_in_client("deezer")
if genre_id:
resp = await asyncio.to_thread(client.client.api.get_genre_radios, genre_id, 0, 25)
else:
resp = await asyncio.to_thread(client.client.api.get_radios_top, 50)
finally:
await main.__aexit__()
items = resp.get("data", []) if isinstance(resp, dict) else []
return [
{
"id": item.get("id"),
"title": item.get("title") or "Unknown",
"cover": item.get("picture_big") or item.get("picture_medium"),
}
for item in items
]
async def get_radio_tracks(radio_id: int, limit: int = 25, index: int = 0) -> tuple[list[dict], int | None]:
"""Top tracks of a radio station (loadable via batch download)."""
import asyncio
from streamrip.rip.main import Main
config = _load_config()
main = Main(config)
try:
client = await main.get_logged_in_client("deezer")
resp = await asyncio.to_thread(client.client.api.get_radio_tracks, radio_id, index, limit)
finally:
await main.__aexit__()
items = resp.get("data", []) if isinstance(resp, dict) else []
total = resp.get("total") if isinstance(resp, dict) else None
return [_summarize("track", i) for i in items[:limit]], total
def _apply_job_overrides(config, quality: int | None, codec: str | None, folder: str | None) -> str:
"""Apply per-job overrides onto a loaded Config.
None/empty means 'server default from config.toml'. Returns the
effective download folder. Single worker -> mutation is safe.
"""
try:
if folder:
config.session.downloads.folder = folder
else:
# Prefer the config file's folder, but fall back to the environment
# default when it doesn't exist here (e.g. host path in container).
cfg_folder = config.session.downloads.folder
if not cfg_folder or not Path(cfg_folder).expanduser().exists():
config.session.downloads.folder = str(
Path(settings.DOWNLOAD_DIR).expanduser()
)
effective_folder = config.session.downloads.folder or settings.DOWNLOAD_DIR
except Exception:
effective_folder = folder or settings.DOWNLOAD_DIR
try:
if quality is not None:
config.session.deezer.quality = quality
except Exception:
pass
try:
if codec:
config.session.conversion.enabled = True
config.session.conversion.codec = codec.upper()
except Exception:
pass
return effective_folder
async def search_podcasts(query: str, limit: int = 20, index: int = 0) -> tuple[list[dict], int | None]:
"""Search Deezer shows/podcasts (listening only, no download support)."""
import asyncio
from streamrip.rip.main import Main
config = _load_config()
main = Main(config)
try:
client = await main.get_logged_in_client("deezer")
resp = await asyncio.to_thread(
client.client.api.api_call, "search/podcast", {"q": query, "index": index, "limit": limit}
)
finally:
await main.__aexit__()
items = resp.get("data", []) if isinstance(resp, dict) else []
total = resp.get("total") if isinstance(resp, dict) else None
out = []
for item in items[:limit]:
out.append(
{
"source": "deezer",
"media_type": "podcast",
"id": str(item.get("id")),
"title": item.get("title") or "Unknown",
"artist": "",
"desc": (item.get("description") or "")[:120],
"cover": item.get("picture_big") or item.get("picture_medium"),
"fans": item.get("fans"),
}
)
return out, total
async def get_podcast_details(show_id: str) -> dict:
"""Show info + episode list (no download support in streamrip)."""
import asyncio
from streamrip.rip.main import Main
config = _load_config()
main = Main(config)
try:
client = await main.get_logged_in_client("deezer")
api = client.client.api
show, eps = await asyncio.gather(
asyncio.to_thread(api.api_call, f"podcast/{show_id}", {}),
asyncio.to_thread(api.api_call, f"podcast/{show_id}/episodes", {"limit": 100}),
)
finally:
await main.__aexit__()
if not isinstance(show, dict) or show.get("error"):
raise RuntimeError("Show not found")
episodes = []
raw = eps.get("data", []) if isinstance(eps, dict) else []
for ep in raw:
episodes.append(
{
"id": str(ep.get("id")),
"title": ep.get("title") or "Unknown",
"duration": ep.get("duration"),
"release_date": ep.get("release_date") or "",
"cover": ep.get("picture_big") or ep.get("picture_medium"),
}
)
return {
"id": str(show.get("id") or show_id),
"title": show.get("title") or "Unknown",
"description": show.get("description") or "",
"cover": show.get("picture_big") or show.get("picture_medium"),
"fans": show.get("fans"),
"link": show.get("link") or f"https://www.deezer.com/show/{show_id}",
"episodes": episodes,
}
async def download_by_id(
source: str,
media_type: str,
item_id: str,
quality: int | None = None,
codec: str | None = None,
folder: str | None = None,
no_db: bool = False,
on_log=None,
) -> None:
"""Resolve + download one item. Raises on failure."""
from streamrip.rip.main import Main
def log(line: str):
if on_log:
on_log(line)
config = _load_config()
effective_folder = _apply_job_overrides(config, quality, codec, folder)
log(f"Target folder: {effective_folder}")
main = Main(config)
try:
if no_db:
try:
from streamrip import db as dbmod
main.database = dbmod.Database(dbmod.Dummy(), main.database.failed)
except Exception:
pass
log(f"Resolving {source} {media_type} {item_id} ...")
await main.add_by_id(source, media_type, item_id)
await main.resolve()
if not main.media:
raise RuntimeError("Nothing resolved (already downloaded or invalid id)")
log(f"Downloading {len(main.media)} item(s) ...")
await main.rip()
log("Download finished.")
finally:
await main.__aexit__()