Files
streamrip-ui/backend/app/services/streamrip_service.py
T

695 lines
24 KiB
Python

"""streamrip library wrapper (Deezer only for now)."""
from pathlib import Path
from app.core import settings
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.
"""
from streamrip.config import Config
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) -> list[dict]:
"""Live search via streamrip DeezerClient. Returns UI-ready dicts."""
from streamrip.rip.main import Main
config = _load_config()
main = Main(config)
try:
client = await main.get_logged_in_client("deezer")
pages = await client.search(media_type, query, limit=limit)
finally:
await main.__aexit__()
if not pages:
return []
items = pages[0].get("data", [])[:limit]
return [_summarize(media_type, item) for item in items]
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) -> 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()
for pl in playlists[:3]:
try:
tresp = await asyncio.to_thread(api.get_playlist_tracks, pl.get("id"), 0, limit)
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)
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, chart albums (newest first) as fallback."""
if primary:
return primary
import asyncio
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)
return items
async def get_discover(kind: str, genre_id: int = 0, limit: int = 20) -> list[dict]:
"""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, 0, limit)
items = resp.get("data", [])
if not items:
items = await _playlist_top_tracks(api, genre_id, limit)
return [_summarize("track", i) for i in items[:limit]]
if kind == "albums":
resp = await asyncio.to_thread(api.get_chart_albums, genre_id, 0, limit)
items = resp.get("data", [])
return [_summarize("album", i) for i in items[:limit]]
if kind == "artists":
resp = await asyncio.to_thread(api.get_chart_artists, genre_id, 0, limit)
items = resp.get("data", [])
if not items:
raw = await _playlist_top_tracks(api, genre_id, limit * 2)
items = _artists_from_track_items(raw)[:limit]
return [_summarize("artist", i) for i in items[:limit]]
if kind == "playlists":
resp = await asyncio.to_thread(api.get_chart_playlists, genre_id, 0, limit)
items = resp.get("data", [])
return [_summarize("playlist", i) for i in items[:limit]]
# kind == "new": editorial selection, chart albums (newest first) as fallback
resp = await asyncio.to_thread(api.get_editorial_selection, genre_id, 0, 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]]
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) -> list[dict]:
"""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, limit)
finally:
await main.__aexit__()
items = resp.get("data", []) if isinstance(resp, dict) else []
return [_summarize("track", i) for i in items[:limit]]
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
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) -> list[dict]:
"""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, "limit": limit}
)
finally:
await main.__aexit__()
items = resp.get("data", []) if isinstance(resp, dict) else []
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
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__()