commit 0537fa435adfdedc6139966bd6d86f238becea6c Author: Lokman Caliskan Date: Sun Oct 4 10:09:06 2026 +0200 feat: streamrip-ui – Next.js + FastAPI Webapp für streamrip (Deezer) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1483325 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +.pytest_cache/ +*.toml.bak + +# Node / Next.js +node_modules/ +.next/ +out/ +*.log + +# Local env / secrets +.env +.env.* +!.env.example + +# OS / editor +.DS_Store +.vscode/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b836f6c --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# streamrip-ui + +Webapp für das CLI-Tool `streamrip` (nur **Deezer**, lokal Single-User ohne Login). + +## Struktur + +- `backend/` — FastAPI (nutzt `streamrip` als Library, kein Subprocess) + - `GET /api/health`, `GET /api/sources` + - `GET /api/search?type=track|album|artist|playlist&q=..&limit=..` + - `POST /api/downloads` `{source, media_type, id, quality, codec, folder, no_db}` + - `GET /api/downloads`, `GET /api/downloads/{id}`, `GET /api/downloads/{id}/events` (SSE) + - `GET /api/history` (downloads.db + failed_downloads.db, read-only) + - `GET/PUT /api/config` (volle config.toml, mit `.bak`-Backup + TOML-Validierung) + - `GET /api/files?path=..` (Download-Root, Path-Traversal blockiert) +- `frontend/` — Next.js 14 (Suche mit 4 Tabs, Cover-Grid + Preview-Player, Download-Dialog, Queue, Verlauf mit Re-Download, Dateien, Einstellungen) + +## Start (lokal) + +```bash +cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt +.venv/bin/python -m uvicorn app.main:app --port 8000 +cd ../frontend && npm install && npm run dev +``` + +Frontend: http://localhost:3002, Backend: http://localhost:8000 (`/api/*` wird per Rewrite proxied, lokal via `127.0.0.1:8000`, in Docker via `BACKEND_URL=http://backend:8000`). + +## Start (Docker) + +```bash +docker compose up --build +``` + +Volumes: `~/.config/streamrip:/config` (enthält `config.toml` + DBs), `~/StreamripDownloads:/downloads`. + +## ARL + +Einstellungen-Seite → „Deezer ARL“-Feld (zeigt nur Status + letzte 4 Zeichen, nie den vollen Wert). Speichern schreibt nur die `arl`-Zeile mit Backup. Alternativ weiter unten im vollen `config.toml`-Editor. + +## Hinweise + +- `~/.local/bin/rip` (pipx-Shim) ist auf diesem Rechner kaputt; das Backend importiert `streamrip` direkt als Library und braucht den Shim nicht. +- Fortschritt v1: Job-Status + Logs via SSE (Polling 0.5s im Backend, 2s-Refresh in der Queue-Seite). Echte %-Werte pro Track erfordern Hooks in streamrips Downloadable-Layer (Follow-up). +- Defaults (Qualität/Codec) liegen im Browser-LocalStorage (`srip-defaults`), Qualität Default 2 (FLAC) + Fallback laut config. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..7c80e8a --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,4 @@ +.venv +__pycache__ +*.pyc +.pytest_cache diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..34e2d96 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.12-slim + +ARG UID=1000 +ARG GID=1000 + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + STREAMRIP_CONFIG=/config/config.toml \ + STREAMRIP_DOWNLOAD_DIR=/downloads + +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg git \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd -g "$GID" appuser \ + && useradd -u "$UID" -g "$GID" -m -s /bin/false appuser + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt && pip install --no-cache-dir "git+https://github.com/nathom/streamrip.git@v2.2.0" + +COPY app ./app +RUN chown -R appuser:appuser /app + +USER appuser + +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/album.py b/backend/app/api/album.py new file mode 100644 index 0000000..af04845 --- /dev/null +++ b/backend/app/api/album.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter, HTTPException + +from app.services.streamrip_service import get_album_details + +router = APIRouter(prefix="/api/album", tags=["album"]) + + +@router.get("/{album_id}") +async def album_detail(album_id: str): + try: + return await get_album_details(album_id) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Album lookup failed: {e}") diff --git a/backend/app/api/artist.py b/backend/app/api/artist.py new file mode 100644 index 0000000..118758e --- /dev/null +++ b/backend/app/api/artist.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter, HTTPException + +from app.services.streamrip_service import get_artist_details + +router = APIRouter(prefix="/api/artist", tags=["artist"]) + + +@router.get("/{artist_id}") +async def artist_detail(artist_id: str): + try: + return await get_artist_details(artist_id) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Artist lookup failed: {e}") diff --git a/backend/app/api/config.py b/backend/app/api/config.py new file mode 100644 index 0000000..cf2647b --- /dev/null +++ b/backend/app/api/config.py @@ -0,0 +1,278 @@ +"""Full config.toml read/write with backup + validation.""" + +import shutil +from pathlib import Path + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from app.core import settings + +router = APIRouter(prefix="/api/config", tags=["config"]) + +#: Placeholder returned instead of the real ARL. If a PUT contains it, +#: the stored ARL is preserved instead of being overwritten. +MASKED_ARL = "__MASKED__" + + +class ConfigUpdate(BaseModel): + toml: str + + +class ArlUpdate(BaseModel): + arl: str + + +CODECS = ["", "FLAC", "ALAC", "OPUS", "MP3", "VORBIS", "AAC"] + + +class DeezerUi(BaseModel): + quality: int | None = None + lower_quality_if_not_available: bool | None = None + use_deezloader: bool | None = None + deezloader_warnings: bool | None = None + + +class DownloadsUi(BaseModel): + folder: str | None = None + max_connections: int | None = None + requests_per_minute: int | None = None + concurrency: bool | None = None + + +class ConversionUi(BaseModel): + enabled: bool | None = None + codec: str | None = None + + +class BehaviorUi(BaseModel): + skip_downloaded: bool | None = None + + +class UiConfigUpdate(BaseModel): + deezer: DeezerUi | None = None + downloads: DownloadsUi | None = None + conversion: ConversionUi | None = None + behavior: BehaviorUi | None = None + + +def _read_ui_config(): + from tomlkit.api import parse as toml_parse + + p = Path(settings.CONFIG_PATH) + if not p.exists(): + raise HTTPException(status_code=404, detail="config.toml not found") + try: + doc = toml_parse(p.read_text()) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Cannot parse config: {e}") + dz = doc.get("deezer", {}) + dl = doc.get("downloads", {}) + cv = doc.get("conversion", {}) + db = doc.get("database", {}) + arl = dz.get("arl", "") + return { + "deezer": { + "arl_set": bool(arl), + "arl_preview": f"***{arl[-4:]}" if arl else "", + "quality": dz.get("quality", 1), + "lower_quality_if_not_available": dz.get("lower_quality_if_not_available", True), + "use_deezloader": dz.get("use_deezloader", True), + "deezloader_warnings": dz.get("deezloader_warnings", True), + }, + "downloads": { + "folder": dl.get("folder", ""), + "max_connections": dl.get("max_connections", 2), + "requests_per_minute": dl.get("requests_per_minute", 60), + "concurrency": dl.get("concurrency", True), + }, + "conversion": { + "enabled": cv.get("enabled", False), + "codec": cv.get("codec", "MP3"), + }, + "behavior": { + "skip_downloaded": db.get("downloads_enabled", True), + }, + } + + +@router.get("/ui") +def get_ui_config(): + return _read_ui_config() + + +@router.put("/ui") +def put_ui_config(update: UiConfigUpdate): + import os + + from tomlkit.api import dumps as toml_dumps + from tomlkit.api import parse as toml_parse + + p = Path(settings.CONFIG_PATH) + if not p.exists(): + raise HTTPException(status_code=404, detail="config.toml not found") + try: + doc = toml_parse(p.read_text()) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Cannot parse config: {e}") + + def section(name: str): + if name not in doc: + raise HTTPException(status_code=500, detail=f"No [{name}] section in config") + return doc[name] + + if update.deezer: + dz = section("deezer") + q = update.deezer.quality + if q is not None: + if q not in (0, 1, 2): + raise HTTPException(status_code=422, detail="quality must be 0, 1 or 2") + dz["quality"] = q + for key in ("lower_quality_if_not_available", "use_deezloader", "deezloader_warnings"): + val = getattr(update.deezer, key) + if val is not None: + dz[key] = val + + if update.downloads: + dl = section("downloads") + folder = update.downloads.folder + if folder is not None: + fp = Path(folder).expanduser() + if not fp.is_dir() or not os.access(fp, os.W_OK): + raise HTTPException(status_code=422, detail="folder must exist and be writable") + dl["folder"] = str(fp) + mc = update.downloads.max_connections + if mc is not None: + if mc != -1 and not 1 <= mc <= 32: + raise HTTPException(status_code=422, detail="max_connections must be -1 or 1..32") + dl["max_connections"] = mc + rpm = update.downloads.requests_per_minute + if rpm is not None: + if rpm != -1 and not 1 <= rpm <= 10000: + raise HTTPException(status_code=422, detail="requests_per_minute must be -1 or 1..10000") + dl["requests_per_minute"] = rpm + if update.downloads.concurrency is not None: + dl["concurrency"] = update.downloads.concurrency + + if update.conversion: + cv = section("conversion") + if update.conversion.codec is not None: + if update.conversion.codec not in CODECS: + raise HTTPException(status_code=422, detail=f"codec must be one of {CODECS}") + cv["codec"] = update.conversion.codec + if update.conversion.enabled is not None: + cv["enabled"] = update.conversion.enabled + + if update.behavior: + db = section("database") + if update.behavior.skip_downloaded is not None: + db["downloads_enabled"] = update.behavior.skip_downloaded + + backup = _write_config(doc) + return {"ok": True, "backup": backup, "config": _read_ui_config()} + + +@router.get("") +def get_config(): + from tomlkit.api import parse as toml_parse + + p = Path(settings.CONFIG_PATH) + if not p.exists(): + raise HTTPException(status_code=404, detail="config.toml not found") + text = p.read_text() + try: + doc = toml_parse(text) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Cannot parse config: {e}") + if doc.get("deezer", {}).get("arl"): + doc["deezer"]["arl"] = MASKED_ARL + from tomlkit.api import dumps as toml_dumps + + text = toml_dumps(doc) + return {"toml": text, "path": str(p), "arl_masked": True} + + +def _write_config(doc) -> str: + """Write TOML doc atomically: backup, temp file in same dir, replace.""" + import os + import tempfile + + from tomlkit.api import dumps as toml_dumps + + p = Path(settings.CONFIG_PATH) + backup = p.with_suffix(".toml.bak") + try: + shutil.copy2(p, backup) + fd, tmp = tempfile.mkstemp(dir=str(p.parent), suffix=".toml") + try: + with os.fdopen(fd, "w") as f: + f.write(toml_dumps(doc)) + os.replace(tmp, p) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + return str(backup) + + +@router.put("") +def put_config(update: ConfigUpdate): + from tomlkit.api import parse as toml_parse + + try: + new_doc = toml_parse(update.toml) + except Exception as e: + raise HTTPException(status_code=422, detail=f"Invalid TOML: {e}") + p = Path(settings.CONFIG_PATH) + # Preserve the real ARL when the client sends back the mask. + try: + if new_doc.get("deezer", {}).get("arl") == MASKED_ARL: + current = toml_parse(p.read_text()) + new_doc["deezer"]["arl"] = current.get("deezer", {}).get("arl", "") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + backup = _write_config(new_doc) + return {"ok": True, "backup": backup} + + +@router.get("/arl") +def get_arl(): + from tomlkit.api import parse as toml_parse + + p = Path(settings.CONFIG_PATH) + if not p.exists(): + raise HTTPException(status_code=404, detail="config.toml not found") + try: + doc = toml_parse(p.read_text()) + arl = doc.get("deezer", {}).get("arl", "") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + return {"arl_set": bool(arl), "arl_preview": f"***{arl[-4:]}" if arl else ""} + + +@router.put("/arl") +def put_arl(update: ArlUpdate): + from tomlkit.api import parse as toml_parse + from tomlkit.items import Table + + arl = update.arl.strip() + if not arl: + raise HTTPException(status_code=422, detail="Empty ARL") + p = Path(settings.CONFIG_PATH) + if not p.exists(): + raise HTTPException(status_code=404, detail="config.toml not found") + try: + doc = toml_parse(p.read_text()) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Cannot parse config: {e}") + if "deezer" not in doc or not isinstance(doc["deezer"], Table): + raise HTTPException(status_code=500, detail="No [deezer] section in config") + doc["deezer"]["arl"] = arl + backup = _write_config(doc) + return {"ok": True, "backup": backup} diff --git a/backend/app/api/discover.py b/backend/app/api/discover.py new file mode 100644 index 0000000..7274f84 --- /dev/null +++ b/backend/app/api/discover.py @@ -0,0 +1,57 @@ +from typing import Literal + +from fastapi import APIRouter, HTTPException, Query + +from app.services.streamrip_service import ( + get_discover, + get_genre_radios, + get_genres, + get_radio_tracks, + get_related_artists, +) + +router = APIRouter(tags=["discover"]) + + +@router.get("/api/artist/{artist_id}/related") +async def artist_related(artist_id: str, limit: int = Query(12, ge=1, le=30)): + try: + return {"artists": await get_related_artists(artist_id, limit)} + except Exception as e: + raise HTTPException(status_code=502, detail=f"Related lookup failed: {e}") + + +@router.get("/api/discover/genres") +async def discover_genres(): + try: + return {"genres": await get_genres()} + except Exception as e: + raise HTTPException(status_code=502, detail=f"Genre lookup failed: {e}") + + +@router.get("/api/discover/radios") +async def discover_radios(genre_id: int = Query(0, ge=0)): + try: + return {"radios": await get_genre_radios(genre_id)} + except Exception as e: + raise HTTPException(status_code=502, detail=f"Radio lookup failed: {e}") + + +@router.get("/api/discover/radios/{radio_id}/tracks") +async def discover_radio_tracks(radio_id: int, limit: int = Query(25, ge=1, le=50)): + try: + return {"tracks": await get_radio_tracks(radio_id, limit)} + except Exception as e: + raise HTTPException(status_code=502, detail=f"Radio tracks failed: {e}") + + +@router.get("/api/discover") +async def discover( + kind: Literal["tracks", "albums", "artists", "playlists", "new"] = Query(...), + genre_id: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=50), +): + try: + return {"kind": kind, "results": await get_discover(kind, genre_id, limit)} + except Exception as e: + raise HTTPException(status_code=502, detail=f"Discover failed: {e}") diff --git a/backend/app/api/downloads.py b/backend/app/api/downloads.py new file mode 100644 index 0000000..c185f4d --- /dev/null +++ b/backend/app/api/downloads.py @@ -0,0 +1,203 @@ +"""Download queue with SSE progress.""" + +import asyncio +import json +from pathlib import Path +from typing import Literal + +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +from app.core import jobstore, settings +from app.services import streamrip_service + +router = APIRouter(prefix="/api/downloads", tags=["downloads"]) + + +class DownloadRequest(BaseModel): + source: Literal["deezer"] = "deezer" + media_type: Literal["track", "album", "artist", "playlist"] | None = None + id: str | None = None + url: str | None = None + quality: int | None = Field(default=None, ge=0, le=2) + codec: str | None = None + folder: str | None = None + no_db: bool = False + + +def _validate_codec(codec: str | None) -> None: + if codec and codec.upper() not in ("FLAC", "ALAC", "OPUS", "MP3", "VORBIS", "AAC"): + raise HTTPException(status_code=422, detail="codec must be one of FLAC, ALAC, OPUS, MP3, VORBIS, AAC") + + +def _validate_folder(folder: str | None) -> None: + import os + + if not folder: + return + fp = Path(folder).expanduser() + if not fp.is_dir() or not os.access(fp, os.W_OK): + raise HTTPException(status_code=422, detail="folder must exist and be writable") + + +def _validate_url(url: str) -> str: + """Return normalized URL or raise 422 (Deezer only).""" + from streamrip.rip.parse_url import parse_url + + parsed = parse_url(url) + if parsed is None: + raise HTTPException(status_code=422, detail="URL nicht erkannt (Deezer-Album/Track/Artist/Playlist erwartet)") + if parsed.source != "deezer": + raise HTTPException(status_code=422, detail=f"Nur Deezer wird unterstützt (erkannt: {parsed.source})") + return url + + +async def _run_job(job_id: str, params: dict): + async with jobstore._worker_sem: + if jobstore.get_job(job_id)["status"] == "cancelled": + return + label = params.get("url") or f"{params['media_type']} {params['id']}" + jobstore.update_job(job_id, status="running") + jobstore.append_log(job_id, f"Starting {label}") + try: + common = dict( + quality=params.get("quality"), + codec=params.get("codec"), + folder=params.get("folder"), + no_db=params.get("no_db", False), + on_log=lambda line: jobstore.append_log(job_id, line), + ) + if params.get("url"): + await streamrip_service.download_by_url(url=params["url"], **common) + else: + await streamrip_service.download_by_id( + source=params["source"], + media_type=params["media_type"], + item_id=params["id"], + **common, + ) + jobstore.update_job(job_id, status="done", progress=100) + jobstore.append_log(job_id, "Done.") + except asyncio.CancelledError: + jobstore.update_job(job_id, status="cancelled") + jobstore.append_log(job_id, "Abgebrochen.") + raise + except Exception as e: + jobstore.update_job(job_id, status="failed", error=str(e)) + jobstore.append_log(job_id, f"FAILED: {e}") + finally: + jobstore.pop_task(job_id) + + +async def enqueue_download(params: dict) -> dict: + job = jobstore.new_job(params) + task = asyncio.create_task(_run_job(job["job_id"], params)) + jobstore.set_task(job["job_id"], task) + return {"job_id": job["job_id"], "status": job["status"]} + + +@router.delete("/{job_id}") +def cancel_download(job_id: str): + job, task = jobstore.try_cancel(job_id) + if job is None: + raise HTTPException(status_code=404, detail="Unknown job") + if task is None and job["status"] in jobstore.TERMINAL and "Abbruch angefordert." not in job["logs"]: + raise HTTPException(status_code=409, detail=f"Job already {job['status']}") + if task is not None: + task.cancel() + return jobstore.get_job(job_id) + + +@router.post("") +async def create_download(req: DownloadRequest): + data = req.model_dump() + _validate_codec(data.get("codec")) + _validate_folder(data.get("folder")) + if data.get("url"): + data["url"] = _validate_url(data["url"]) + data["media_type"] = data.get("media_type") or "url" + data["id"] = data.get("id") or data["url"] + else: + if not data.get("media_type") or not data.get("id"): + raise HTTPException(status_code=422, detail="media_type + id oder url erforderlich") + return await enqueue_download(data) + + +class BatchItem(BaseModel): + media_type: str | None = None + id: str | None = None + url: str | None = None + + +class BatchRequest(BaseModel): + items: list[BatchItem] = Field(..., min_length=1, max_length=50) + quality: int | None = Field(default=None, ge=0, le=2) + codec: str | None = None + folder: str | None = None + no_db: bool = False + + +@router.post("/batch") +async def create_batch(req: BatchRequest): + _validate_codec(req.codec) + _validate_folder(req.folder) + job_ids: list[str] = [] + rejected: list[dict] = [] + for i, item in enumerate(req.items): + try: + if item.url: + url = _validate_url(item.url) + params = { + "source": "deezer", + "media_type": "url", + "id": url, + "url": url, + } + else: + if item.media_type not in ("track", "album", "artist", "playlist") or not item.id: + raise HTTPException(status_code=422, detail="media_type + id oder url erforderlich") + params = { + "source": "deezer", + "media_type": item.media_type, + "id": item.id, + } + params.update( + quality=req.quality, codec=req.codec, folder=req.folder, no_db=req.no_db + ) + res = await enqueue_download(params) + job_ids.append(res["job_id"]) + except HTTPException as e: + rejected.append({"index": i, "reason": e.detail}) + return {"job_ids": job_ids, "rejected": rejected} + + +@router.get("") +def get_downloads(): + return {"jobs": jobstore.list_jobs()} + + +@router.get("/{job_id}") +def get_download(job_id: str): + job = jobstore.get_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="Unknown job") + return job + + +@router.get("/{job_id}/events") +async def download_events(job_id: str): + if not jobstore.get_job(job_id): + raise HTTPException(status_code=404, detail="Unknown job") + + async def gen(): + while True: + job = jobstore.get_job(job_id) + if not job: + break + yield f"data: {json.dumps(job)}\n\n" + if job["status"] in jobstore.TERMINAL: + break + await asyncio.sleep(0.5) + + return StreamingResponse(gen(), media_type="text/event-stream") diff --git a/backend/app/api/files.py b/backend/app/api/files.py new file mode 100644 index 0000000..f2ed7af --- /dev/null +++ b/backend/app/api/files.py @@ -0,0 +1,55 @@ +"""File browser rooted at download dir (no escape).""" + +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Query + +from app.core import settings + +router = APIRouter(prefix="/api/files", tags=["files"]) + + +def _root() -> Path: + return Path(settings.DOWNLOAD_DIR).expanduser().resolve() + + +@router.get("/download") +def download_file(path: str = Query(default="")): + from fastapi.responses import FileResponse + + root = _root() + if not path: + raise HTTPException(status_code=404, detail="Not found") + target = (root / path).resolve() + if target != root and root not in target.parents: + raise HTTPException(status_code=403, detail="Path escape blocked") + if not target.is_file(): + raise HTTPException(status_code=404, detail="Not found") + return FileResponse( + path=str(target), + filename=target.name, + media_type="application/octet-stream", + ) + + +@router.get("") +def list_files(path: str = Query(default="")): + root = _root() + target = (root / path).resolve() if path else root + if target != root and root not in target.parents: + raise HTTPException(status_code=403, detail="Path escape blocked") + if not target.exists(): + raise HTTPException(status_code=404, detail="Not found") + if target.is_file(): + return {"type": "file", "name": target.name, "size": target.stat().st_size} + entries = [] + for child in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())): + entries.append( + { + "name": child.name, + "type": "file" if child.is_file() else "dir", + "size": child.stat().st_size if child.is_file() else None, + "rel": str(child.relative_to(root)), + } + ) + return {"type": "dir", "path": str(target.relative_to(root)) if target != root else "", "entries": entries} diff --git a/backend/app/api/history.py b/backend/app/api/history.py new file mode 100644 index 0000000..bf1d40e --- /dev/null +++ b/backend/app/api/history.py @@ -0,0 +1,45 @@ +"""History from streamrip sqlite DBs (read-only).""" + +import sqlite3 +from pathlib import Path + +from fastapi import APIRouter + +from app.core import settings + +router = APIRouter(prefix="/api/history", tags=["history"]) + + +def _read_ids(db_path: str, table: str) -> list: + p = Path(db_path) + if not p.exists(): + return [] + try: + conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + try: + if table == "downloads": + rows = conn.execute("SELECT id FROM downloads").fetchall() + return [{"id": r[0]} for r in rows] + rows = conn.execute("SELECT source, media_type, id FROM failed_downloads").fetchall() + return [{"source": r[0], "media_type": r[1], "id": r[2]} for r in rows] + finally: + conn.close() + except Exception: + return [] + + +def _default_db_paths() -> tuple[str, str]: + cfg_dir = Path(settings.CONFIG_PATH).parent + return ( + str(cfg_dir / "downloads.db"), + str(cfg_dir / "failed_downloads.db"), + ) + + +@router.get("") +def get_history(): + dl_path, failed_path = _default_db_paths() + return { + "downloads": _read_ids(dl_path, "downloads"), + "failed": _read_ids(failed_path, "failed_downloads"), + } diff --git a/backend/app/api/playlist.py b/backend/app/api/playlist.py new file mode 100644 index 0000000..cc7580c --- /dev/null +++ b/backend/app/api/playlist.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter, HTTPException + +from app.services.streamrip_service import get_playlist_details + +router = APIRouter(prefix="/api/playlist", tags=["playlist"]) + + +@router.get("/{playlist_id}") +async def playlist_detail(playlist_id: str): + try: + return await get_playlist_details(playlist_id) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Playlist lookup failed: {e}") diff --git a/backend/app/api/podcast.py b/backend/app/api/podcast.py new file mode 100644 index 0000000..0b31a0e --- /dev/null +++ b/backend/app/api/podcast.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter, HTTPException + +from app.services.streamrip_service import get_podcast_details + +router = APIRouter(prefix="/api/podcast", tags=["podcast"]) + + +@router.get("/{show_id}") +async def podcast_detail(show_id: str): + try: + return await get_podcast_details(show_id) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Podcast lookup failed: {e}") diff --git a/backend/app/api/search.py b/backend/app/api/search.py new file mode 100644 index 0000000..0cbd454 --- /dev/null +++ b/backend/app/api/search.py @@ -0,0 +1,28 @@ +from typing import Literal + +from fastapi import APIRouter, HTTPException, Query + +from app.services import streamrip_service + +router = APIRouter(prefix="/api/search", tags=["search"]) + +MediaType = Literal["track", "album", "artist", "playlist", "podcast"] + + +@router.get("") +async def search( + type: MediaType = Query(...), + q: str = Query(..., min_length=0, max_length=200), + limit: int = Query(20, ge=1, le=100), +): + query = q.strip() + if not query: + raise HTTPException(status_code=400, detail="Empty query") + try: + if type == "podcast": + results = await streamrip_service.search_podcasts(query, limit) + else: + results = await streamrip_service.search_deezer(type, query, limit) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Search failed: {e}") + return {"results": results, "type": type, "query": query} diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/jobstore.py b/backend/app/core/jobstore.py new file mode 100644 index 0000000..6e551e9 --- /dev/null +++ b/backend/app/core/jobstore.py @@ -0,0 +1,101 @@ +"""In-memory download job store (single-user, single-worker).""" + +import asyncio +import threading +import time +import uuid + +MAX_LOG_LINES = 500 + +_jobs: dict[str, dict] = {} +_tasks: dict[str, object] = {} +_guard = threading.Lock() +_worker_sem = asyncio.Semaphore(1) + + +def new_job(params: dict) -> dict: + job_id = uuid.uuid4().hex[:12] + job = { + "job_id": job_id, + "status": "queued", + "progress": 0, + "params": params, + "logs": [], + "error": None, + "created_at": time.time(), + "updated_at": time.time(), + } + with _guard: + _jobs[job_id] = job + return job + + +def get_job(job_id: str) -> dict | None: + with _guard: + return _jobs.get(job_id) + + +def list_jobs() -> list[dict]: + with _guard: + jobs = list(_jobs.values()) + return sorted(jobs, key=lambda j: j["created_at"], reverse=True) + + +def update_job(job_id: str, **fields): + with _guard: + job = _jobs.get(job_id) + if job: + job.update(fields) + job["updated_at"] = time.time() + if fields.get("status") in TERMINAL: + _prune() + + +def _prune(keep: int = 200) -> None: + """Drop oldest terminal jobs; active ones are never touched.""" + with _guard: + terminal = sorted( + (j for j in _jobs.values() if j["status"] in TERMINAL), + key=lambda j: j["created_at"], + ) + victims = terminal[:-keep] if len(terminal) > keep else [] + for job in victims: + _jobs.pop(job["job_id"], None) + _tasks.pop(job["job_id"], None) + + +def append_log(job_id: str, line: str): + with _guard: + job = _jobs.get(job_id) + if job: + job["logs"].append(line) + del job["logs"][:-MAX_LOG_LINES] + job["updated_at"] = time.time() + + +def set_task(job_id: str, task) -> None: + with _guard: + _tasks[job_id] = task + + +def pop_task(job_id: str): + with _guard: + return _tasks.pop(job_id, None) + + +def try_cancel(job_id: str): + """Atomically mark a non-terminal job cancelled. Returns (job, task).""" + with _guard: + job = _jobs.get(job_id) + if job is None: + return None, None + if job["status"] in TERMINAL: + return job, None + job["status"] = "cancelled" + job["logs"].append("Abbruch angefordert.") + del job["logs"][:-MAX_LOG_LINES] + job["updated_at"] = time.time() + return job, _tasks.pop(job_id, None) + + +TERMINAL = ("done", "failed", "cancelled") diff --git a/backend/app/core/settings.py b/backend/app/core/settings.py new file mode 100644 index 0000000..08fc0e5 --- /dev/null +++ b/backend/app/core/settings.py @@ -0,0 +1,14 @@ +import os +from pathlib import Path + +APP_NAME = "streamrip-ui" +# streamrip nutzt ~/.config/streamrip/config.toml +CONFIG_PATH = os.environ.get( + "STREAMRIP_CONFIG", str(Path.home() / ".config" / "streamrip" / "config.toml") +) +DOWNLOAD_DIR = os.environ.get( + "STREAMRIP_DOWNLOAD_DIR", str(Path.home() / "StreamripDownloads") +) +DEFAULT_QUALITY = int(os.environ.get("STREAMRIP_DEFAULT_QUALITY", "2")) +DEFAULT_CODEC = os.environ.get("STREAMRIP_DEFAULT_CODEC", "") +SOURCES = ["deezer"] diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..2bd0b1e --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,36 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.core import settings +from app.api import search, downloads, history, config, files, artist, album, playlist, discover, podcast + +app = FastAPI(title="streamrip-ui") + +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/api/health") +def health(): + return {"status": "ok"} + + +@app.get("/api/sources") +def sources(): + return {"sources": settings.SOURCES} + + +app.include_router(search.router) +app.include_router(downloads.router) +app.include_router(history.router) +app.include_router(config.router) +app.include_router(files.router) +app.include_router(artist.router) +app.include_router(album.router) +app.include_router(playlist.router) +app.include_router(discover.router) +app.include_router(podcast.router) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/streamrip_service.py b/backend/app/services/streamrip_service.py new file mode 100644 index 0000000..a79d09a --- /dev/null +++ b/backend/app/services/streamrip_service.py @@ -0,0 +1,694 @@ +"""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__() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..3e66e53 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +fastapi +uvicorn +pydantic +tomlkit +aiofiles +httpx +pytest diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_album.py b/backend/tests/test_album.py new file mode 100644 index 0000000..3fbb658 --- /dev/null +++ b/backend/tests/test_album.py @@ -0,0 +1,67 @@ +from fastapi.testclient import TestClient +from app.main import app + +c = TestClient(app) + + +def test_album_detail_mocked(monkeypatch): + from app.api import album as al + + async def fake_details(album_id): + return { + "id": album_id, + "title": "Rumours", + "artist": "Fleetwood Mac", + "cover": "https://cdn-images.dzcdn.net/cover.jpg", + "release_date": "1977-02-04", + "tracks": [ + {"id": "1", "title": "Second Hand News", "track_number": 1, "duration": 172}, + {"id": "2", "title": "Dreams", "track_number": 2, "duration": 257}, + ], + } + + monkeypatch.setattr(al, "get_album_details", fake_details) + r = c.get("/api/album/123") + assert r.status_code == 200 + body = r.json() + assert body["title"] == "Rumours" + assert len(body["tracks"]) == 2 + assert body["tracks"][0]["track_number"] == 1 + + +def test_album_track_mapping_unit(): + from app.services.streamrip_service import _map_track_items + + items = [ + {"id": 11, "title": "A", "track_position": 2, "disk_number": 1, "duration": 200, + "preview": "http://x", "explicit_lyrics": True, + "artist": {"name": "X"}, "album": {"cover_big": "http://c"}}, + {"id": 12, "title": "B"}, # sparse -> defaults + ] + tracks = _map_track_items(items) + assert tracks[0]["id"] == "11" + assert tracks[0]["track_number"] == 2 + assert tracks[0]["preview_url"] == "http://x" + assert tracks[0]["explicit"] is True + assert tracks[1]["track_number"] == 2 # fallback: position in list + assert tracks[1]["duration"] is None + + +def test_fetch_album_meta_falls_back_to_embedded_tracks(): + import asyncio + + from app.services.streamrip_service import _fetch_album_meta + + class FakeApi: + def get_album(self, _id): + return {"id": _id, "title": "X", "tracks": {"data": [{"id": 1, "title": "T"}]}} + + def get_album_tracks(self, _id): + raise Exception("no data") + + class FakeClient: + class client: + api = FakeApi() + + meta = asyncio.run(_fetch_album_meta(FakeClient(), "5")) + assert meta["tracks"] == [{"id": 1, "title": "T"}] diff --git a/backend/tests/test_artist.py b/backend/tests/test_artist.py new file mode 100644 index 0000000..d47d606 --- /dev/null +++ b/backend/tests/test_artist.py @@ -0,0 +1,54 @@ +from fastapi.testclient import TestClient +from app.main import app + +c = TestClient(app) + + +def test_artist_detail_mocked(monkeypatch): + from app.api import artist as art + + async def fake_details(artist_id): + return { + "id": artist_id, + "name": "Fleetwood Mac", + "cover": "https://cdn-images.dzcdn.net/cover.jpg", + "albums": [ + {"id": "1", "title": "Rumours", "group": "album"}, + {"id": "2", "title": "EP One", "group": "ep"}, + {"id": "3", "title": "Single One", "group": "single"}, + ], + "featuring": [ + {"id": "9", "title": "Guest Verse", "artist": "Someone"}, + ], + } + + monkeypatch.setattr(art, "get_artist_details", fake_details) + r = c.get("/api/artist/169") + assert r.status_code == 200 + body = r.json() + assert body["name"] == "Fleetwood Mac" + assert len(body["albums"]) == 3 + assert len(body["featuring"]) == 1 + + +def test_is_featuring_unit(): + from app.services.streamrip_service import _is_featuring + + assert _is_featuring("Fleetwood Mac", {"artist": {"name": "Fleetwood Mac"}}) is False + assert _is_featuring("Fleetwood Mac", {"artist": {"name": "Someone Else"}}) is True + assert _is_featuring("Fleetwood Mac", {"artist": "fleetwood mac"}) is False + + +def test_artist_grouping_unit(): + from app.services.streamrip_service import _group_artist_albums + + items = [ + {"id": 1, "title": "A", "record_type": "album"}, + {"id": 2, "title": "B", "record_type": "ep"}, + {"id": 3, "title": "C", "record_type": "single"}, + {"id": 4, "title": "D"}, # no record_type -> fallback single? album? + ] + albums = _group_artist_albums(items) + groups = {a["group"] for a in albums} + assert {"album", "ep", "single"} <= groups + assert all(a["id"] for a in albums) diff --git a/backend/tests/test_batch.py b/backend/tests/test_batch.py new file mode 100644 index 0000000..fe712e8 --- /dev/null +++ b/backend/tests/test_batch.py @@ -0,0 +1,40 @@ +from fastapi.testclient import TestClient +from app.main import app + +c = TestClient(app) + + +def test_batch_mixed_valid_invalid(monkeypatch): + from app.api import downloads as dl + + seen = [] + + async def fake_enqueue(params): + seen.append(params) + return {"job_id": f"job-{len(seen)}", "status": "queued"} + + monkeypatch.setattr(dl, "enqueue_download", fake_enqueue) + r = c.post( + "/api/downloads/batch", + json={ + "items": [ + {"media_type": "track", "id": "111"}, + {"media_type": "nope", "id": "x"}, + {"media_type": "album", "id": ""}, + {"url": "https://www.deezer.com/album/302127"}, + {"url": "https://example.com/nope"}, + ] + }, + ) + assert r.status_code == 200 + body = r.json() + assert len(body["job_ids"]) == 2 + assert len(body["rejected"]) == 3 + + +def test_batch_limit(): + r = c.post( + "/api/downloads/batch", + json={"items": [{"media_type": "track", "id": str(i)} for i in range(60)]}, + ) + assert r.status_code == 422 diff --git a/backend/tests/test_cancel.py b/backend/tests/test_cancel.py new file mode 100644 index 0000000..a2c3783 --- /dev/null +++ b/backend/tests/test_cancel.py @@ -0,0 +1,101 @@ +import time + +from fastapi.testclient import TestClient +from app.main import app + +c = TestClient(app) + + +def test_cancel_running_job(monkeypatch): + """Drive worker + cancel on one loop (TestClient spawns a loop per + request, which would cancel background tasks on teardown).""" + import asyncio + + from app.api import downloads as dl + from app.core import jobstore + from app.services import streamrip_service as svc + + async def slow_download(**kwargs): + await asyncio.sleep(30) + + monkeypatch.setattr(svc, "download_by_id", slow_download) + + async def scenario(): + job = jobstore.new_job({"source": "deezer", "media_type": "track", "id": "1"}) + task = asyncio.create_task( + dl._run_job(job["job_id"], {"source": "deezer", "media_type": "track", "id": "1"}) + ) + jobstore.set_task(job["job_id"], task) + await asyncio.sleep(0.3) + assert jobstore.get_job(job["job_id"])["status"] == "running" + res = dl.cancel_download(job["job_id"]) + assert res["status"] == "cancelled" + await asyncio.sleep(0.3) + assert jobstore.get_job(job["job_id"])["status"] == "cancelled" + assert "Abgebrochen." in jobstore.get_job(job["job_id"])["logs"] + + asyncio.run(scenario()) + + +def test_cancel_queued_http(): + from app.core import jobstore + + job = jobstore.new_job({"source": "deezer", "media_type": "track", "id": "x"}) + r = c.delete(f"/api/downloads/{job['job_id']}") + assert r.status_code == 200 + assert r.json()["status"] == "cancelled" + + +def test_cancel_unknown_404(): + assert c.delete("/api/downloads/nope").status_code == 404 + + +def test_cancel_finished_409(monkeypatch): + from app.services import streamrip_service as svc + + async def fast_download(**kwargs): + return None + + monkeypatch.setattr(svc, "download_by_id", fast_download) + job_id = c.post("/api/downloads", json={"media_type": "track", "id": "2"}).json()["job_id"] + for _ in range(50): + if c.get(f"/api/downloads/{job_id}").json()["status"] == "done": + break + time.sleep(0.2) + assert c.delete(f"/api/downloads/{job_id}").status_code == 409 + + +def test_no_db_replaces_database_with_dummy(tmp_path, monkeypatch): + import asyncio + + from streamrip import db as dbmod + from app.services import streamrip_service as svc + + class FakeMain: + def __init__(self, config): + self.database = dbmod.Database( + dbmod.Downloads(str(tmp_path / "d.db")), + dbmod.Failed(str(tmp_path / "f.db")), + ) + self.media = [object()] + + async def add_by_id(self, *a): + pass + + async def resolve(self): + pass + + async def rip(self): + pass + + async def __aexit__(self, *_): + pass + + import streamrip.rip.main as mainmod + + monkeypatch.setattr(mainmod, "Main", FakeMain) + asyncio.run( + svc.download_by_id( + source="deezer", media_type="track", item_id="1", no_db=True + ) + ) diff --git a/backend/tests/test_config_arl.py b/backend/tests/test_config_arl.py new file mode 100644 index 0000000..f4e1e7f --- /dev/null +++ b/backend/tests/test_config_arl.py @@ -0,0 +1,43 @@ +import pytest +from fastapi.testclient import TestClient + +from app.core import settings + + +@pytest.fixture +def tmp_config(tmp_path, monkeypatch): + cfg = tmp_path / "config.toml" + cfg.write_text('[deezer]\narl = "old-arl-value"\nquality = 1\n') + monkeypatch.setattr(settings, "CONFIG_PATH", str(cfg)) + return cfg + + +def test_arl_get_masked(tmp_config): + from app.main import app + + c = TestClient(app) + r = c.get("/api/config/arl") + assert r.status_code == 200 + body = r.json() + assert body["arl_set"] is True + assert "old-arl-value" not in body["arl_preview"] + assert body["arl_preview"].endswith("alue") + + +def test_arl_put_updates_only_arl(tmp_config): + from app.main import app + + c = TestClient(app) + r = c.put("/api/config/arl", json={"arl": "new-arl-123"}) + assert r.status_code == 200 + text = tmp_config.read_text() + assert "new-arl-123" in text + assert "quality = 1" in text # rest untouched + assert tmp_config.with_suffix(".toml.bak").exists() + + +def test_arl_put_rejects_empty(tmp_config): + from app.main import app + + c = TestClient(app) + assert c.put("/api/config/arl", json={"arl": " "}).status_code == 422 diff --git a/backend/tests/test_config_ui.py b/backend/tests/test_config_ui.py new file mode 100644 index 0000000..33749cd --- /dev/null +++ b/backend/tests/test_config_ui.py @@ -0,0 +1,85 @@ +import pytest +from fastapi.testclient import TestClient + +from app.core import settings + +BASE_TOML = """[downloads] +folder = "/tmp/srip-test-dl" +max_connections = 2 +requests_per_minute = 60 +concurrency = true +verify_ssl = true + +[deezer] +arl = "test-arl" +quality = 1 +lower_quality_if_not_available = true +use_deezloader = true +deezloader_warnings = true + +[conversion] +enabled = false +codec = "MP3" + +[database] +downloads_enabled = true +""" + + +@pytest.fixture +def tmp_config(tmp_path, monkeypatch): + import os + + dl = tmp_path / "dl" + dl.mkdir() + cfg = tmp_path / "config.toml" + cfg.write_text(BASE_TOML.replace("/tmp/srip-test-dl", str(dl))) + monkeypatch.setattr(settings, "CONFIG_PATH", str(cfg)) + monkeypatch.setattr(settings, "DOWNLOAD_DIR", str(dl)) + os.environ["X"] = "1" + return cfg + + +def test_ui_config_get(tmp_config): + from app.main import app + + c = TestClient(app) + r = c.get("/api/config/ui") + assert r.status_code == 200 + body = r.json() + assert body["deezer"]["quality"] == 1 + assert body["deezer"]["arl_set"] is True + assert body["downloads"]["max_connections"] == 2 + assert body["conversion"]["codec"] == "MP3" + assert body["behavior"]["skip_downloaded"] is True + + +def test_ui_config_put_roundtrip(tmp_config): + from app.main import app + + c = TestClient(app) + r = c.put("/api/config/ui", json={ + "deezer": {"quality": 2, "lower_quality_if_not_available": False}, + "downloads": {"max_connections": 4}, + "conversion": {"enabled": True, "codec": "FLAC"}, + "behavior": {"skip_downloaded": False}, + }) + assert r.status_code == 200 + body = c.get("/api/config/ui").json() + assert body["deezer"]["quality"] == 2 + assert body["deezer"]["lower_quality_if_not_available"] is False + assert body["downloads"]["max_connections"] == 4 + assert body["conversion"] == {"enabled": True, "codec": "FLAC"} + assert body["behavior"]["skip_downloaded"] is False + # untouched values preserved + assert body["deezer"]["use_deezloader"] is True + + +def test_ui_config_put_rejects_bad_values(tmp_config): + from app.main import app + + c = TestClient(app) + assert c.put("/api/config/ui", json={"deezer": {"quality": 9}}).status_code == 422 + assert c.put("/api/config/ui", json={"conversion": {"codec": "WAV"}}).status_code == 422 + assert c.put("/api/config/ui", json={"downloads": {"folder": "/does/not/exist"}}).status_code == 422 + assert c.put("/api/config/ui", json={"downloads": {"max_connections": 0}}).status_code == 422 diff --git a/backend/tests/test_discover.py b/backend/tests/test_discover.py new file mode 100644 index 0000000..1311f16 --- /dev/null +++ b/backend/tests/test_discover.py @@ -0,0 +1,46 @@ +from fastapi.testclient import TestClient +from app.main import app + +c = TestClient(app) + + +def test_related_artists_mocked(monkeypatch): + from app.api import discover as disc + + async def fake_related(artist_id, limit): + return [{"id": "2", "title": "Similar One", "artist": "Similar One", "media_type": "artist"}] + + monkeypatch.setattr(disc, "get_related_artists", fake_related) + r = c.get("/api/artist/169/related") + assert r.status_code == 200 + assert r.json()["artists"][0]["id"] == "2" + + +def test_genres_mocked(monkeypatch): + from app.api import discover as disc + + async def fake_genres(): + return [{"id": 132, "name": "Pop"}] + + monkeypatch.setattr(disc, "get_genres", fake_genres) + r = c.get("/api/discover/genres") + assert r.status_code == 200 + assert r.json()["genres"][0]["name"] == "Pop" + + +def test_discover_mocked(monkeypatch): + from app.api import discover as disc + + async def fake_discover(kind, genre_id, limit): + assert kind in ("tracks", "albums", "artists", "new") + return [{"id": "1", "title": "X"}] + + monkeypatch.setattr(disc, "get_discover", fake_discover) + r = c.get("/api/discover", params={"kind": "tracks", "genre_id": 132, "limit": 5}) + assert r.status_code == 200 + assert len(r.json()["results"]) == 1 + + +def test_discover_rejects_bad_kind(): + r = c.get("/api/discover", params={"kind": "nope"}) + assert r.status_code == 422 diff --git a/backend/tests/test_discover_extra.py b/backend/tests/test_discover_extra.py new file mode 100644 index 0000000..ad7bc76 --- /dev/null +++ b/backend/tests/test_discover_extra.py @@ -0,0 +1,42 @@ +from fastapi.testclient import TestClient +from app.main import app + +c = TestClient(app) + + +def test_discover_playlists_mocked(monkeypatch): + from app.api import discover as disc + + async def fake_discover(kind, genre_id, limit): + assert kind == "playlists" + return [{"id": "11", "title": "Mix", "media_type": "playlist"}] + + monkeypatch.setattr(disc, "get_discover", fake_discover) + r = c.get("/api/discover", params={"kind": "playlists"}) + assert r.status_code == 200 + assert r.json()["results"][0]["id"] == "11" + + +def test_radios_mocked(monkeypatch): + from app.api import discover as disc + + async def fake_radios(genre_id): + assert genre_id == 132 + return [{"id": 1, "title": "Pop Hits", "cover": None}] + + monkeypatch.setattr(disc, "get_genre_radios", fake_radios) + r = c.get("/api/discover/radios", params={"genre_id": 132}) + assert r.status_code == 200 + assert r.json()["radios"][0]["title"] == "Pop Hits" + + +def test_radio_tracks_mocked(monkeypatch): + from app.api import discover as disc + + async def fake_tracks(radio_id, limit): + return [{"id": "5", "title": "Song", "media_type": "track"}] + + monkeypatch.setattr(disc, "get_radio_tracks", fake_tracks) + r = c.get("/api/discover/radios/1/tracks") + assert r.status_code == 200 + assert r.json()["tracks"][0]["id"] == "5" diff --git a/backend/tests/test_discover_fallback.py b/backend/tests/test_discover_fallback.py new file mode 100644 index 0000000..26c9b6e --- /dev/null +++ b/backend/tests/test_discover_fallback.py @@ -0,0 +1,47 @@ +import asyncio + +from app.services.streamrip_service import ( + _artists_from_track_items, + _discover_albums_fallback, + _playlist_top_tracks, +) + + +class FakeApi: + def __init__(self, empty_charts=True): + self.empty_charts = empty_charts + + def get_chart_tracks(self, *a): + return {"data": [], "total": 0} if self.empty_charts else {"data": [{"id": 1, "title": "T"}], "total": 1} + + def get_chart_playlists(self, *a): + return {"data": [{"id": 9, "title": "Mix"}], "total": 1} + + def get_playlist_tracks(self, *a): + return {"data": [ + {"id": 1, "title": "T1", "artist": {"id": 11, "name": "A1"}, "preview": None, "duration": 10}, + {"id": 2, "title": "T2", "artist": {"id": 11, "name": "A1"}, "preview": None, "duration": 10}, + {"id": 3, "title": "T3", "artist": {"id": 12, "name": "A2"}, "preview": None, "duration": 10}, + ], "total": 3} + + def get_chart_albums(self, *a): + return {"data": [ + {"id": 1, "title": "Old", "release_date": "2020-01-01"}, + {"id": 2, "title": "New", "release_date": "2026-09-01"}, + ], "total": 2} + + +def test_playlist_top_tracks_dedupes(): + tracks = asyncio.run(_playlist_top_tracks(FakeApi(), 0, 10)) + assert [t["id"] for t in tracks] == [1, 2, 3] + + +def test_artists_from_track_items_unique(): + tracks = asyncio.run(_playlist_top_tracks(FakeApi(), 0, 10)) + artists = _artists_from_track_items(tracks) + assert [(a["id"], a["name"]) for a in artists] == [("11", "A1"), ("12", "A2")] + + +def test_new_falls_back_to_chart_albums_sorted(): + albums = asyncio.run(_discover_albums_fallback(FakeApi(), 0, 10, primary=[])) + assert [a["title"] for a in albums] == ["New", "Old"] diff --git a/backend/tests/test_downloads.py b/backend/tests/test_downloads.py new file mode 100644 index 0000000..ffae7ac --- /dev/null +++ b/backend/tests/test_downloads.py @@ -0,0 +1,30 @@ +from fastapi.testclient import TestClient +from app.main import app + +c = TestClient(app) + + +def test_create_download_validation(): + r = c.post("/api/downloads", json={"media_type": "nope", "id": "x"}) + assert r.status_code == 422 + + +def test_create_download_queued(monkeypatch): + from app.api import downloads as dl + + async def fake_enqueue(params): + return {"job_id": "test-id", "status": "queued"} + + monkeypatch.setattr(dl, "enqueue_download", fake_enqueue) + r = c.post( + "/api/downloads", + json={"media_type": "track", "id": "123", "quality": 2}, + ) + assert r.status_code == 200 + assert r.json()["job_id"] == "test-id" + + +def test_unknown_job_sse_404(): + r = c.get("/api/downloads/does-not-exist/events") + # SSE endpoint must not hang on unknown id + assert r.status_code == 404 diff --git a/backend/tests/test_file_download.py b/backend/tests/test_file_download.py new file mode 100644 index 0000000..644c30f --- /dev/null +++ b/backend/tests/test_file_download.py @@ -0,0 +1,30 @@ +from fastapi.testclient import TestClient +from app.main import app + + +def test_file_download(tmp_path, monkeypatch): + from app.core import settings + + root = tmp_path / "dl" + root.mkdir() + (root / "song.flac").write_bytes(b"FAKEDATA") + monkeypatch.setattr(settings, "DOWNLOAD_DIR", str(root)) + + c = TestClient(app) + r = c.get("/api/files/download", params={"path": "song.flac"}) + assert r.status_code == 200 + assert r.content == b"FAKEDATA" + assert "attachment" in r.headers.get("content-disposition", "") + + +def test_file_download_blocked(tmp_path, monkeypatch): + from app.core import settings + + root = tmp_path / "dl" + root.mkdir() + monkeypatch.setattr(settings, "DOWNLOAD_DIR", str(root)) + + c = TestClient(app) + assert c.get("/api/files/download", params={"path": "../../etc/passwd"}).status_code == 403 + assert c.get("/api/files/download", params={"path": "missing.mp3"}).status_code == 404 + assert c.get("/api/files/download", params={"path": ""}).status_code == 404 diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..7c66d77 --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,16 @@ +from fastapi.testclient import TestClient +from app.main import app + + +def test_health(): + c = TestClient(app) + r = c.get("/api/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" + + +def test_sources_deezer_only(): + c = TestClient(app) + r = c.get("/api/sources") + assert r.status_code == 200 + assert r.json()["sources"] == ["deezer"] diff --git a/backend/tests/test_history_config_files.py b/backend/tests/test_history_config_files.py new file mode 100644 index 0000000..be8a06f --- /dev/null +++ b/backend/tests/test_history_config_files.py @@ -0,0 +1,21 @@ +from fastapi.testclient import TestClient +from app.main import app + +c = TestClient(app) + + +def test_path_traversal_blocked(): + r = c.get("/api/files", params={"path": "../../etc"}) + assert r.status_code == 403 + + +def test_history_endpoint_exists(): + r = c.get("/api/history") + assert r.status_code == 200 + assert "downloads" in r.json() + + +def test_config_get_exists(): + r = c.get("/api/config") + assert r.status_code == 200 + assert "toml" in r.json() diff --git a/backend/tests/test_playlist.py b/backend/tests/test_playlist.py new file mode 100644 index 0000000..8b1a903 --- /dev/null +++ b/backend/tests/test_playlist.py @@ -0,0 +1,50 @@ +from fastapi.testclient import TestClient +from app.main import app + +c = TestClient(app) + + +def test_playlist_detail_mocked(monkeypatch): + from app.api import playlist as pl + + async def fake_details(playlist_id): + return { + "id": playlist_id, + "title": "My Mix", + "creator": "Someone", + "description": "desc", + "tracks": [ + {"id": "1", "title": "A", "track_number": 1}, + {"id": "2", "title": "B", "track_number": 2}, + ], + } + + monkeypatch.setattr(pl, "get_playlist_details", fake_details) + r = c.get("/api/playlist/777") + assert r.status_code == 200 + body = r.json() + assert body["title"] == "My Mix" + assert len(body["tracks"]) == 2 + + +def test_playlist_meta_fallback(): + import asyncio + + from app.services.streamrip_service import _fetch_with_tracks + + class FakeApi: + def get_playlist(self, _id): + return {"id": _id, "title": "P"} + + def get_playlist_tracks(self, _id): + raise Exception("no data") + + class FakeClient: + class client: + api = FakeApi() + + meta = asyncio.run( + _fetch_with_tracks(FakeClient(), "playlist", "777", ("get_playlist", "get_playlist_tracks")) + ) + assert meta["title"] == "P" + assert meta["tracks"] == [] diff --git a/backend/tests/test_podcast.py b/backend/tests/test_podcast.py new file mode 100644 index 0000000..ce77463 --- /dev/null +++ b/backend/tests/test_podcast.py @@ -0,0 +1,37 @@ +from fastapi.testclient import TestClient +from app.main import app + +c = TestClient(app) + + +def test_podcast_search_mocked(monkeypatch): + from app.services import streamrip_service as svc + + async def fake_podcasts(query, limit): + assert query == "flauschig" + return [{ + "source": "deezer", "media_type": "podcast", "id": "100", + "title": "Fest & Flauschig", "artist": "", + "desc": "Fest & Flauschig", "cover": None, + }] + + monkeypatch.setattr(svc, "search_podcasts", fake_podcasts) + r = c.get("/api/search", params={"type": "podcast", "q": "flauschig"}) + assert r.status_code == 200 + assert r.json()["results"][0]["id"] == "100" + + +def test_podcast_detail_mocked(monkeypatch): + from app.api import podcast as pc + + async def fake_details(show_id): + return { + "id": show_id, "title": "Fest & Flauschig", "description": "d", + "cover": None, "link": "https://www.deezer.com/show/1", "fans": 5, + "episodes": [{"id": "9", "title": "Ep 1", "duration": 600, "release_date": "2026-01-01"}], + } + + monkeypatch.setattr(pc, "get_podcast_details", fake_details) + r = c.get("/api/podcast/1") + assert r.status_code == 200 + assert len(r.json()["episodes"]) == 1 diff --git a/backend/tests/test_review_fixes.py b/backend/tests/test_review_fixes.py new file mode 100644 index 0000000..79e9f5c --- /dev/null +++ b/backend/tests/test_review_fixes.py @@ -0,0 +1,109 @@ +from fastapi.testclient import TestClient +from pathlib import Path +from app.main import app + +c = TestClient(app) + + +def test_overrides_fall_back_to_server_config(tmp_path, monkeypatch): + import shutil + + import streamrip + from app.core import settings + from app.services.streamrip_service import _apply_job_overrides, _load_config + + dl = tmp_path / "dl" + dl.mkdir() + cfg = tmp_path / "config.toml" + pkg = Path(streamrip.__file__).parent / "config.toml" + text = pkg.read_text() + # point folder + quality at test values (keep all other sections intact) + import re + + text = re.sub(r'folder = ".*"', f'folder = "{dl}"', text, count=1) + cfg.write_text(text) + monkeypatch.setattr(settings, "CONFIG_PATH", str(cfg)) + + config = _load_config() + folder = _apply_job_overrides(config, None, None, None) + assert folder == str(dl) + assert config.session.conversion.enabled is False + + folder2 = _apply_job_overrides(config, 0, "flac", str(dl)) + assert folder2 == str(dl) + assert config.session.deezer.quality == 0 + assert config.session.conversion.enabled is True + assert config.session.conversion.codec == "FLAC" + + +def test_config_arl_masked_roundtrip(tmp_path, monkeypatch): + from app.core import settings + + cfg = tmp_path / "config.toml" + cfg.write_text('[deezer]\narl = "super-secret"\nquality = 1\n') + monkeypatch.setattr(settings, "CONFIG_PATH", str(cfg)) + + body = c.get("/api/config").json() + assert "super-secret" not in body["toml"] + assert "__MASKED__" in body["toml"] + + # PUT back the masked text: real ARL must survive + r = c.put("/api/config", json={"toml": body["toml"]}) + assert r.status_code == 200 + assert 'arl = "super-secret"' in cfg.read_text() + + +def test_jobstore_prunes_old_terminal(): + from app.core import jobstore + + for _ in range(5): + j = jobstore.new_job({"x": 1}) + jobstore.update_job(j["job_id"], status="done") + jobstore._prune(keep=3) + remaining = [j for j in jobstore.list_jobs() if j["params"] == {"x": 1}] + assert len(remaining) == 3 + # cleanup test jobs + for j in remaining: + jobstore._jobs.pop(j["job_id"], None) + + +def test_download_rejects_bad_codec_and_folder(): + assert c.post("/api/downloads", json={"media_type": "track", "id": "1", "codec": "WAV"}).status_code == 422 + assert c.post("/api/downloads", json={"media_type": "track", "id": "1", "folder": "/does/not/exist"}).status_code == 422 + assert c.post("/api/downloads/batch", json={"items": [{"media_type": "track", "id": "1"}], "codec": "WAV"}).status_code == 422 + + +def test_corrupt_config_never_leaks_arl(tmp_path, monkeypatch): + from app.core import settings + + cfg = tmp_path / "config.toml" + cfg.write_text('[deezer]\narl = "live-secret"\nBROKEN = = =\n') + monkeypatch.setattr(settings, "CONFIG_PATH", str(cfg)) + r = c.get("/api/config") + assert r.status_code == 500 + assert "live-secret" not in r.text + + +def test_failed_write_keeps_original(tmp_path, monkeypatch): + import os + + from app.api import config as cfgmod + from app.core import settings + + cfg = tmp_path / "config.toml" + cfg.write_text('[deezer]\narl = "keep-me"\n') + monkeypatch.setattr(settings, "CONFIG_PATH", str(cfg)) + monkeypatch.setattr(os, "replace", lambda *a, **k: (_ for _ in ()).throw(OSError("disk"))) + with __import__("pytest").raises(Exception): + cfgmod._write_config({}) + assert 'arl = "keep-me"' in cfg.read_text() + + +def test_log_lines_capped(): + from app.core import jobstore + + job = jobstore.new_job({"x": 2}) + for i in range(600): + jobstore.append_log(job["job_id"], f"line {i}") + assert len(jobstore.get_job(job["job_id"])["logs"]) == 500 + jobstore._jobs.pop(job["job_id"], None) diff --git a/backend/tests/test_search.py b/backend/tests/test_search.py new file mode 100644 index 0000000..4554851 --- /dev/null +++ b/backend/tests/test_search.py @@ -0,0 +1,41 @@ +from fastapi.testclient import TestClient +from app.main import app + +c = TestClient(app) + + +def test_search_rejects_empty_query(): + r = c.get("/api/search", params={"type": "track", "q": ""}) + assert r.status_code == 400 + + +def test_search_rejects_invalid_type(): + r = c.get("/api/search", params={"type": "foo", "q": "x"}) + assert r.status_code == 422 + + +def test_search_track_mocked(monkeypatch): + from app.services import streamrip_service as svc + + async def fake_search(media_type, query, limit): + assert media_type == "track" + return [ + { + "source": "deezer", + "media_type": "track", + "id": "123", + "title": "Test Song", + "artist": "Test Artist", + "desc": "Test Song by Test Artist", + "cover": None, + "preview_url": None, + "duration": None, + } + ] + + monkeypatch.setattr(svc, "search_deezer", fake_search) + r = c.get("/api/search", params={"type": "track", "q": "test"}) + assert r.status_code == 200 + body = r.json() + assert body["results"][0]["id"] == "123" + assert body["results"][0]["title"] == "Test Song" diff --git a/backend/tests/test_url_import.py b/backend/tests/test_url_import.py new file mode 100644 index 0000000..8daed40 --- /dev/null +++ b/backend/tests/test_url_import.py @@ -0,0 +1,30 @@ +from fastapi.testclient import TestClient +from app.main import app + +c = TestClient(app) + + +def test_url_import_rejects_garbage(): + r = c.post("/api/downloads", json={"url": "not a url"}) + assert r.status_code == 422 + + +def test_url_import_rejects_wrong_host(): + r = c.post("/api/downloads", json={"url": "https://example.com/foo"}) + assert r.status_code == 422 + + +def test_url_import_queued(monkeypatch): + from app.api import downloads as dl + + async def fake_enqueue(params): + assert params["url"].startswith("https://www.deezer.com/") + return {"job_id": "url-job", "status": "queued"} + + monkeypatch.setattr(dl, "enqueue_download", fake_enqueue) + r = c.post( + "/api/downloads", + json={"url": "https://www.deezer.com/album/302127"}, + ) + assert r.status_code == 200 + assert r.json()["job_id"] == "url-job" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..38284f7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + backend: + build: + context: ./backend + args: + UID: ${UID:-1000} + GID: ${GID:-1000} + ports: + - "8000:8000" + environment: + - STREAMRIP_CONFIG=/config/config.toml + - STREAMRIP_DOWNLOAD_DIR=/downloads + volumes: + - ~/.config/streamrip:/config + - ~/StreamripDownloads:/downloads + frontend: + build: ./frontend + ports: + - "3002:3000" + environment: + - BACKEND_URL=http://backend:8000 + - HOSTNAME=0.0.0.0 + - PORT=3000 + depends_on: + - backend diff --git a/docs/superpowers/plans/2026-09-24-streamrip-ui.md b/docs/superpowers/plans/2026-09-24-streamrip-ui.md new file mode 100644 index 0000000..52f3ca4 --- /dev/null +++ b/docs/superpowers/plans/2026-09-24-streamrip-ui.md @@ -0,0 +1,237 @@ +# streamrip-ui Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Webapp (Next.js + FastAPI) für streamrip (nur Deezer, lokal Single-User) mit Suche über alle 4 Medientypen, Download-Dialog mit Defaults, echter Fortschritt via SSE, Verlauf mit Re-Download, volle config.toml-Verwaltung, Dateibrowser. + +**Architecture:** FastAPI-Backend importiert streamrip als Library (kein Subprocess, umgeht kaputten pipx-Shim); Next.js-Frontend redet nur mit Backend-REST/SSE. Jobs laufen als asyncio-Tasks mit In-Memory-Store. + +**Tech Stack:** Python 3.14 + FastAPI + streamrip 2.2.0 (Library); Next.js 14 + React + `next/image`; SSE für Fortschritt; Docker-Compose für Betrieb. + +**Spec:** Grill-Ergebnisse dieser Session (Stack Next.js+Python API; Quelle nur Deezer; Vollausbau; lokal Single-User; alle 4 Suchtypen; Download-Dialog mit Defaults aus Einstellungen; echter Fortschritt; volle config.toml; Cover-Grid + Preview-Player; Defaults FLAC+Fallback; Verlauf mit Re-Download). + +## Global Constraints + +- Nur Quelle `deezer` (Qobuz/Tidal/SoundCloud ausgeblendet, Code aber erweiterbar). +- Single-User lokal, kein Auth. +- Download-Ordner Default `~/StreamripDownloads`, Qualität Default FLAC (2) + Fallback. +- Backend nutzt `/usr/bin/rip`-Umgebung bzw. direkten Library-Import (nicht `~/.local/bin/rip`). + +## Review Focus + +- Suche mit leerem Query darf Backend nicht crashen (400 erwartet). +- Download mit ungültiger ID/Type gibt 422 mit klarer Message. +- config.toml-Write erstellt Backup und validiert TOML vor dem Schreiben. +- SSE-Stream bricht sauber ab wenn Job-ID unbekannt (404, kein Hängen). +- Dateibrowser kann nicht aus Download-Root ausbrechen (Path-Traversal → 403). + +--- + +### Task 1: Backend-Gerüst + Health + +**Files:** +- Create: `backend/requirements.txt` +- Create: `backend/app/main.py` +- Create: `backend/app/core/settings.py` +- Test: `backend/tests/test_health.py` + +**Interfaces:** +- Consumes: nichts +- Produces: `GET /api/health -> {status: ok}`, `GET /api/sources -> [deezer]` + +- [ ] **Step 1: Write the failing test** + +```python +from fastapi.testclient import TestClient +from app.main import app + +def test_health(): + c = TestClient(app) + r = c.get("/api/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && python3 -m pytest tests/test_health.py -v` +Expected: FAIL with "No module named 'app'" / collection error + +- [ ] **Step 3: Write minimal implementation** + +requirements: fastapi, uvicorn, pydantic, tomlkit, aiofiles, httpx, pytest +main.py: FastAPI-App mit /api/health + /api/sources +settings.py: DOWNLOAD_DIR, CONFIG_PATH, DEFAULT_QUALITY=2 + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && python3 -m pytest tests/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: backend scaffold with health check" || true +``` + +### Task 2: Suche (alle 4 Typen) + +**Files:** +- Create: `backend/app/api/search.py` +- Create: `backend/app/services/streamrip_service.py` +- Modify: `backend/app/main.py` +- Test: `backend/tests/test_search.py` + +**Interfaces:** +- Consumes: settings.py +- Produces: `GET /api/search?type=track|album|artist|playlist&q=..&limit=.. -> {results: [{source, media_type, id, title, artist, album, cover, preview_url, duration, desc}]}` + +- [ ] **Step 1: Write the failing test** + +```python +def test_search_validation(client): + assert client.get("/api/search", params={"type": "track", "q": ""}).status_code == 400 + assert client.get("/api/search", params={"type": "foo", "q": "x"}).status_code == 422 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && python3 -m pytest tests/test_search.py -v` +Expected: FAIL (route missing → 404) + +- [ ] **Step 3: Write minimal implementation** + +streamrip_service.search(source="deezer", media_type, query, limit): nutzt `DeezerClient.search()` + `SearchResults.from_pages()`, mappt Summary-Felder + Roh-Item (cover, preview, duration) durch. search.py: validiert type/q/limit, 400 bei leerem q. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && python3 -m pytest tests/ -v` +Expected: PASS (Service per Mock getestet, kein Netzwerk nötig) + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: deezer search endpoint" || true +``` + +### Task 3: Download-Queue + SSE-Fortschritt + +**Files:** +- Create: `backend/app/api/downloads.py` +- Create: `backend/app/core/jobstore.py` +- Modify: `backend/app/main.py` +- Test: `backend/tests/test_downloads.py` + +**Interfaces:** +- Consumes: streamrip_service.download_by_id() +- Produces: `POST /api/downloads -> {job_id}`, `GET /api/downloads`, `GET /api/downloads/{id}`, `GET /api/downloads/{id}/events (SSE)` + +- [ ] **Step 1: Write the failing test** + +```python +def test_create_download_validation(client): + r = client.post("/api/downloads", json={"media_type": "nope", "id": "x"}) + assert r.status_code == 422 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && python3 -m pytest tests/test_downloads.py -v` +Expected: FAIL (404 route missing) + +- [ ] **Step 3: Write minimal implementation** + +jobstore: In-Memory-Dict {job_id: {status, progress, logs, params}}, asyncio-Queue (sequenziell, max 1 parallel weil streamrip concurrency selbst regelt). Worker: `Main.add_by_id()` → `resolve()` → `download()`, fängt Exceptions → status failed + failed_downloads.db-Eintrag sichtbar. SSE: polling-basiert (0.5s) mit `data: {json}`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && python3 -m pytest tests/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: download queue with SSE progress" || true +``` + +### Task 4: Verlauf + Config + Dateien + +**Files:** +- Create: `backend/app/api/history.py` +- Create: `backend/app/api/config.py` +- Create: `backend/app/api/files.py` +- Test: `backend/tests/test_history_config_files.py` + +**Interfaces:** +- Consumes: settings (DB paths, config path, download dir) +- Produces: `GET /api/history`, `POST /api/history/redownload`, `GET/PUT /api/config`, `GET /api/files?path=..` + +- [ ] **Step 1: Write the failing test** + +```python +def test_path_traversal_blocked(client): + r = client.get("/api/files", params={"path": "../../etc"}) + assert r.status_code == 403 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && python3 -m pytest tests/test_history_config_files.py -v` +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +history: liest downloads.db/failed_downloads.db via streamrip.db-Klassen (read-only). config: GET gibt TOML-Text + geparste Deezer-Sektion; PUT validiert TOML, schreibt Backup `config.toml.bak`. files: listet Download-Root, blockt `..`-Escape. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && python3 -m pytest tests/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: history, config, files endpoints" || true +``` + +### Task 5: Frontend (Next.js) + +**Files:** +- Create: `frontend/package.json`, `frontend/next.config.js`, `frontend/app/layout.tsx`, `frontend/app/page.tsx` (Suche mit 4 Tabs, Cover-Grid, Preview-Audio), `frontend/components/DownloadDialog.tsx`, `frontend/app/queue/page.tsx` (SSE), `frontend/app/history/page.tsx`, `frontend/app/settings/page.tsx`, `frontend/app/files/page.tsx` + +**Interfaces:** +- Consumes: alle Backend-Endpoints +- Produces: UI + +- [ ] **Step 1: Write failing check** + +Run: `cd frontend && npm run build` +Expected: FAIL (noch kein Scaffold) + +- [ ] **Step 2: Scaffold** + +```bash +cd frontend && npm init -y && npm i next@14 react react-dom && npm i -D typescript @types/react @types/node +``` + +- [ ] **Step 3: Implement minimal pages** (Suche → Dialog → Queue per SSE → Rest) +- [ ] **Step 4: Verify** + +Run: `cd frontend && npm run build` +Expected: PASS (exit 0) + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: next.js frontend" || true +``` + +### Task 6: Compose + README + E2E-Smoke + +**Files:** +- Create: `docker-compose.yml`, `README.md` + +- [ ] **Step 1:** Backend-Tests grün: `cd backend && python3 -m pytest tests/ -v` +- [ ] **Step 2:** Backend-Boot: `cd backend && python3 -m uvicorn app.main:app --port 8000 &` + `curl localhost:8000/api/health` +- [ ] **Step 3:** Frontend-Build grün: `cd frontend && npm run build` +- [ ] **Step 4: Commit** diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..b90a368 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,2 @@ +node_modules +.next diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..905e576 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,21 @@ +FROM node:22-alpine AS builder +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install --no-audit --no-fund +COPY . . +ENV BACKEND_URL=http://backend:8000 +RUN npm run build + +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production \ + BACKEND_URL=http://backend:8000 +COPY --from=builder --chown=node:node /app/.next/standalone ./ +COPY --from=builder --chown=node:node /app/.next/static ./.next/static + +USER node + +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1:3000/ || exit 1 +CMD ["node", "server.js"] diff --git a/frontend/app/DownloadDialog.tsx b/frontend/app/DownloadDialog.tsx new file mode 100644 index 0000000..bc6f72b --- /dev/null +++ b/frontend/app/DownloadDialog.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export type SearchItem = { + id: string; + media_type: string; + title: string; + artist: string; + cover?: string | null; + preview_url?: string | null; + desc?: string; +}; + +export default function DownloadDialog({ item, onClose }: { item: SearchItem; onClose: () => void }) { + const [quality, setQuality] = useState(2); + const [codec, setCodec] = useState(""); + const [sending, setSending] = useState(false); + const [msg, setMsg] = useState(""); + + useEffect(() => { + fetch("/api/config/ui") + .then((r) => r.json()) + .then((b) => { + if (typeof b?.deezer?.quality === "number") setQuality(b.deezer.quality); + if (b?.conversion?.enabled && typeof b.conversion.codec === "string") setCodec(b.conversion.codec); + }) + .catch(() => {}); + }, []); + + async function start() { + setSending(true); + setMsg(""); + try { + const r = await fetch("/api/downloads", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + source: "deezer", + media_type: item.media_type, + id: item.id, + quality, + codec, + }), + }); + if (!r.ok) throw new Error(`Start fehlgeschlagen (${r.status}).`); + const body = await r.json(); + setMsg(`Angelegt: ${body.job_id} — siehe Queue.`); + } catch (e: unknown) { + setMsg(e instanceof Error ? e.message : "Fehler."); + } finally { + setSending(false); + } + } + + return ( +
+
e.stopPropagation()} + > +

{item.title}

+

{item.artist}

+ + +
+ + +
+ {msg &&

{msg}

} +
+
+ ); +} diff --git a/frontend/app/album/[id]/page.tsx b/frontend/app/album/[id]/page.tsx new file mode 100644 index 0000000..cf1581f --- /dev/null +++ b/frontend/app/album/[id]/page.tsx @@ -0,0 +1,98 @@ +"use client"; + +import Image from "next/image"; +import { useEffect, useState } from "react"; +import DownloadDialog, { type SearchItem } from "../../DownloadDialog"; +import BasketButton from "../../components/BasketButton"; + +type Track = SearchItem & { + track_number?: number; + duration?: number | null; + explicit?: boolean | null; +}; + +type AlbumDetail = { + id: string; + title: string; + artist: string; + cover?: string | null; + release_date?: string; + nb_tracks?: number | null; + tracks: Track[]; +}; + +function fmtDuration(sec?: number | null): string { + if (typeof sec !== "number") return ""; + const m = Math.floor(sec / 60); + const s = String(sec % 60).padStart(2, "0"); + return `${m}:${s}`; +} + +export default function AlbumPage({ params }: { params: { id: string } }) { + const { id } = params; + const [data, setData] = useState(null); + const [error, setError] = useState(""); + const [selected, setSelected] = useState(null); + + useEffect(() => { + fetch(`/api/album/${id}`) + .then((r) => { + if (!r.ok) throw new Error(`Album nicht gefunden (${r.status}).`); + return r.json(); + }) + .then(setData) + .catch((e: unknown) => setError(e instanceof Error ? e.message : "Fehler.")); + }, [id]); + + if (error) return

{error}

; + if (!data) return

Lädt …

; + + return ( +
+
+ {data.cover && ( + {data.title} + )} +
+

{data.title}

+

+ {data.artist} + {data.release_date ? ` · ${data.release_date.slice(0, 4)}` : ""} + {typeof data.nb_tracks === "number" ? ` · ${data.nb_tracks} Tracks` : ""} +

+ +
+
+ + {data.tracks.length === 0 &&

Keine Tracks gefunden.

} +
    + {data.tracks.map((t) => ( +
  1. + {t.track_number ?? "–"} +
    +
    + {t.title} {t.explicit ? (explicit) : null} +
    +
    {fmtDuration(t.duration)}
    +
    + {t.preview_url &&
  2. + ))} +
+ {selected && setSelected(null)} />} +
+ ); +} diff --git a/frontend/app/artist/[id]/page.tsx b/frontend/app/artist/[id]/page.tsx new file mode 100644 index 0000000..34a50b9 --- /dev/null +++ b/frontend/app/artist/[id]/page.tsx @@ -0,0 +1,170 @@ +"use client"; + +import Image from "next/image"; +import { useEffect, useState } from "react"; +import DownloadDialog, { type SearchItem } from "../../DownloadDialog"; +import BasketButton from "../../components/BasketButton"; +import { sortByDate, type SortDir } from "../../lib/sort"; + +type Album = { + id: string; + title: string; + artist: string; + cover?: string | null; + release_date?: string; + nb_tracks?: number; + group: string; + source: string; + media_type: string; +}; + +type Detail = { + id: string; + name: string; + cover?: string | null; + nb_albums?: number | null; + albums: Album[]; + featuring: SearchItem[]; +}; + +const GROUPS: { key: string; label: string }[] = [ + { key: "album", label: "Alben" }, + { key: "ep", label: "EPs" }, + { key: "single", label: "Singles" }, +]; + +export default function ArtistPage({ params }: { params: { id: string } }) { + const { id } = params; + const [data, setData] = useState(null); + const [related, setRelated] = useState([]); + const [error, setError] = useState(""); + const [sort, setSort] = useState("desc"); + const [selected, setSelected] = useState(null); + + useEffect(() => { + fetch(`/api/artist/${id}`) + .then((r) => { + if (!r.ok) throw new Error(`Artist nicht gefunden (${r.status}).`); + return r.json(); + }) + .then(setData) + .catch((e: unknown) => setError(e instanceof Error ? e.message : "Fehler.")); + fetch(`/api/artist/${id}/related?limit=12`) + .then((r) => r.json()) + .then((b) => setRelated(b.artists ?? [])) + .catch(() => {}); + }, [id]); + + if (error) return

{error}

; + if (!data) return

Lädt …

; + + return ( +
+
+ {data.cover && ( + {data.name} + )} +
+

{data.name}

+

+ {data.albums.length} Releases{typeof data.nb_albums === "number" ? ` (${data.nb_albums} bei Deezer)` : ""} +

+ +
+
+ +
+ + +
+ + {GROUPS.map((g) => { + const items = sortByDate(data.albums.filter((a) => a.group === g.key), sort); + if (items.length === 0) return null; + return ( +
+

{g.label} ({items.length})

+
+ {items.map((a) => ( +
+ {a.cover ? ( + + {a.title} + + ) : ( +
+ )} +
+ {a.title} +
+
+ {a.release_date ? a.release_date.slice(0, 4) + " · " : ""}{a.nb_tracks ? `${a.nb_tracks} Tracks` : ""} +
+ +
+ +
+
+ ))} +
+
+ ); + })} + + {data.featuring.length > 0 && ( +
+

Featuring ({data.featuring.length}) — Näherung via Track-Suche

+
+ {data.featuring.map((t) => ( +
+ {t.cover ? ( + {t.title} + ) : ( +
+ )} +
{t.title}
+
{t.artist}
+ {t.preview_url &&
+ ))} +
+
+ )} + {related.length > 0 && ( +
+

Ähnliche Artists

+
+ {related.map((a) => ( + + {a.cover ? ( + {a.title} + ) : ( + +
+ )} + {selected && setSelected(null)} />} +
+ ); +} diff --git a/frontend/app/basket/page.tsx b/frontend/app/basket/page.tsx new file mode 100644 index 0000000..72c0346 --- /dev/null +++ b/frontend/app/basket/page.tsx @@ -0,0 +1,88 @@ +"use client"; + +import Link from "next/link"; +import { clearBasket, loadBasket, removeFromBasket, type BasketItem } from "../lib/basket"; +import { useEffect, useState } from "react"; + +export default function BasketPage() { + const [items, setItems] = useState([]); + const [msg, setMsg] = useState(""); + const [sending, setSending] = useState(false); + + useEffect(() => { + setItems(loadBasket()); + }, []); + + function remove(item: BasketItem) { + removeFromBasket(item); + setItems(loadBasket()); + } + + async function checkout() { + if (items.length === 0) return; + setSending(true); + setMsg(""); + try { + const r = await fetch("/api/downloads/batch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + items: items.map((i) => ({ media_type: i.media_type, id: i.id })), + }), + }); + if (!r.ok) throw new Error(`Fehler (${r.status})`); + const body = await r.json(); + clearBasket(); + setItems([]); + setMsg( + `${body.job_ids.length} angelegt — siehe Queue.` + + (body.rejected.length > 0 ? ` ${body.rejected.length} abgelehnt.` : "") + ); + } catch (e: unknown) { + setMsg(e instanceof Error ? e.message : "Fehler."); + } finally { + setSending(false); + } + } + + return ( +
+

Sammelkorb ({items.length})

+ {msg &&

{msg}

} + {items.length === 0 && ( +

+ Leer — lege Treffer über „+ Korb“ auf Suche, Artist-, Album- oder Playlist-Seite hier ab. +

+ )} + {items.length > 0 && ( +
+ + + Zur Queue → +
+ )} +
    + {items.map((i) => ( +
  1. + {i.media_type} +
    +
    {i.title}
    +
    {i.artist}
    +
    + +
  2. + ))} +
+
+ ); +} diff --git a/frontend/app/components/BasketButton.tsx b/frontend/app/components/BasketButton.tsx new file mode 100644 index 0000000..cb9c2e6 --- /dev/null +++ b/frontend/app/components/BasketButton.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { inBasket, toggleBasket, type BasketItem } from "../lib/basket"; + +export default function BasketButton({ item }: { item: BasketItem }) { + const [inside, setInside] = useState(false); + + useEffect(() => { + const update = () => { + try { + setInside(inBasket(item)); + } catch {} + }; + update(); + window.addEventListener("basket-change", update); + return () => window.removeEventListener("basket-change", update); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [item.media_type, item.id]); + + return ( + + ); +} diff --git a/frontend/app/components/Header.tsx b/frontend/app/components/Header.tsx new file mode 100644 index 0000000..9835c06 --- /dev/null +++ b/frontend/app/components/Header.tsx @@ -0,0 +1,149 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useEffect, useState } from "react"; +import { loadBasket } from "../lib/basket"; + +const NAV = [ + { href: "/", label: "Suche" }, + { href: "/discover", label: "Entdecken" }, + { href: "/queue", label: "Queue" }, + { href: "/history", label: "Verlauf" }, + { href: "/basket", label: "Korb" }, + { href: "/files", label: "Dateien" }, + { href: "/settings", label: "Einstellungen" }, +]; + +export default function Header() { + const pathname = usePathname(); + const [backendOk, setBackendOk] = useState(null); + const [theme, setTheme] = useState<"dark" | "light">("dark"); + const [basketCount, setBasketCount] = useState(0); + + useEffect(() => { + const update = () => { + try { + setBasketCount(loadBasket().length); + } catch {} + }; + update(); + window.addEventListener("basket-change", update); + return () => window.removeEventListener("basket-change", update); + }, [pathname]); + + useEffect(() => { + try { + const stored = localStorage.getItem("srip-theme"); + if (stored === "light" || stored === "dark") { + setTheme(stored); + document.documentElement.dataset.theme = stored === "light" ? "light" : ""; + } else if (window.matchMedia("(prefers-color-scheme: light)").matches) { + setTheme("light"); + document.documentElement.dataset.theme = "light"; + } + } catch {} + }, []); + + function toggleTheme() { + const next = theme === "dark" ? "light" : "dark"; + setTheme(next); + try { + localStorage.setItem("srip-theme", next); + } catch {} + document.documentElement.dataset.theme = next === "light" ? "light" : ""; + } + + useEffect(() => { + let alive = true; + fetch("/api/health") + .then((r) => { + if (alive) setBackendOk(r.ok); + }) + .catch(() => { + if (alive) setBackendOk(false); + }); + return () => { + alive = false; + }; + }, [pathname]); + + return ( +
+
+ + + streamrip-ui + + + + +
+
+ ); +} diff --git a/frontend/app/discover/page.tsx b/frontend/app/discover/page.tsx new file mode 100644 index 0000000..e0108b8 --- /dev/null +++ b/frontend/app/discover/page.tsx @@ -0,0 +1,186 @@ +"use client"; + +import Image from "next/image"; +import { useEffect, useState } from "react"; +import DownloadDialog, { type SearchItem } from "../DownloadDialog"; +import BasketButton from "../components/BasketButton"; + +type Genre = { id: number; name: string; cover?: string | null }; + +const KINDS = [ + { key: "new", label: "Neuheiten" }, + { key: "tracks", label: "Top-Titel" }, + { key: "albums", label: "Top-Alben" }, + { key: "artists", label: "Top-Artists" }, + { key: "playlists", label: "Playlists" }, +] as const; +type Kind = (typeof KINDS)[number]["key"]; + +export default function DiscoverPage() { + const [genres, setGenres] = useState([]); + const [genreId, setGenreId] = useState(0); + const [kind, setKind] = useState("new"); + const [results, setResults] = useState([]); + const [radios, setRadios] = useState<{ id: number; title: string; cover?: string | null }[]>([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [selected, setSelected] = useState(null); + const [radioMsg, setRadioMsg] = useState(""); + const [nonce, setNonce] = useState(0); + const [openRadio, setOpenRadio] = useState(null); + const [radioTracks, setRadioTracks] = useState([]); + const [radioLoading, setRadioLoading] = useState(false); + + useEffect(() => { + fetch("/api/discover/genres").then((r) => r.json()).then((b) => setGenres(b.genres ?? [])).catch(() => {}); + }, []); + + useEffect(() => { + setLoading(true); + setError(""); + fetch(`/api/discover?kind=${kind}&genre_id=${genreId}&limit=24`) + .then((r) => { + if (!r.ok) throw new Error(`Fehler (${r.status})`); + return r.json(); + }) + .then((b) => setResults(b.results ?? [])) + .catch((e: unknown) => setError(e instanceof Error ? e.message : "Fehler.")) + .finally(() => setLoading(false)); + fetch(`/api/discover/radios?genre_id=${genreId}`) + .then((r) => r.json()) + .then((b) => setRadios(b.radios ?? [])) + .catch(() => {}); + }, [kind, genreId, nonce]); + + async function toggleRadio(radioId: number) { + if (openRadio === radioId) { + setOpenRadio(null); + setRadioTracks([]); + return; + } + setOpenRadio(radioId); + setRadioTracks([]); + setRadioMsg(""); + setRadioLoading(true); + try { + const t = await fetch(`/api/discover/radios/${radioId}/tracks?limit=25`).then((r) => { + if (!r.ok) throw new Error(`Fehler (${r.status})`); + return r.json(); + }); + setRadioTracks(t.tracks ?? []); + } catch (e: unknown) { + setRadioMsg(e instanceof Error ? e.message : "Fehler."); + } finally { + setRadioLoading(false); + } + } + + return ( +
+

Entdecken

+

Neuheiten und Charts nach Genre — direkt laden oder in den Korb legen.

+ +
+ {KINDS.map((k) => ( + + ))} +
+ +
+ + {genres.map((g) => ( + + ))} +
+ + {error &&

{error}

} + {loading &&

Lädt …

} + {!loading && results.length === 0 && !error && ( +
+

Derzeit keine Daten von Deezer für diese Auswahl.

+ +
+ )} + +
+ {results.map((item) => { + const href = + item.media_type === "artist" ? `/artist/${item.id}` : + item.media_type === "album" ? `/album/${item.id}` : + item.media_type === "playlist" ? `/playlist/${item.id}` : null; + const cover = item.cover ? ( + {item.title} + ) : ( +
+ ); + return ( +
+ {href ? {cover} : cover} +
{item.title}
+
{item.artist}
+ {item.preview_url &&
+ ); + })} +
+ {selected && setSelected(null)} />} + + {radios.length > 0 && ( +
+

Sender {genreId !== 0 ? "in diesem Genre" : ""}

+ {radioMsg &&

{radioMsg}

} +
+ {radios.map((radio) => ( +
+ {radio.cover ? ( + {radio.title} + ) : ( +
+ )} +
{radio.title}
+ +
+ ))} +
+ {openRadio !== null && ( +
+ {radioLoading &&

Lädt …

} + {!radioLoading && radioTracks.length === 0 &&

Keine Tracks gefunden.

} +
    + {radioTracks.map((t) => ( +
  1. +
    +
    {t.title}
    +
    {t.artist}
    +
    + {t.preview_url &&
  2. + ))} +
+
+ )} +
+ )} +
+ ); +} diff --git a/frontend/app/files/page.tsx b/frontend/app/files/page.tsx new file mode 100644 index 0000000..8a320f7 --- /dev/null +++ b/frontend/app/files/page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect, useState } from "react"; + +type Entry = { name: string; type: string; size?: number | null; rel?: string }; + +export default function FilesPage() { + const [path, setPath] = useState(""); + const [entries, setEntries] = useState([]); + + async function load(p: string) { + const r = await fetch(`/api/files?path=${encodeURIComponent(p)}`); + if (!r.ok) return; + const b = await r.json(); + setPath(b.path ?? ""); + setEntries(b.entries ?? []); + } + + useEffect(() => { + load(""); + }, []); + + function up() { + if (!path) return; + const parts = path.split("/").filter(Boolean); + parts.pop(); + load(parts.join("/")); + } + + return ( +
+

Dateien

+

/{path}

+ {path && } +
    + {entries.map((e) => ( +
  • + {e.type === "dir" ? ( + + ) : ( + + 🎵 {e.name}{typeof e.size === "number" ? ` (${Math.round(e.size / 1024 / 1024)} MB)` : ""}{" "} + + Herunterladen ↓ + + + )} +
  • + ))} +
+
+ ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..dc7b05d --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,122 @@ +:root { + --bg: #0c0c0e; + --surface: #161619; + --surface-2: #1e1e23; + --border: #2a2a31; + --text: #f2f0ea; + --muted: #8a8a93; + --accent: #f5b83d; + --accent-ink: #1a1206; + --danger: #e5484d; + --ok: #46a758; + --radius: 10px; +} + +:root[data-theme="light"] { + --bg: #f4f1ea; + --surface: #ffffff; + --surface-2: #ece7da; + --border: #d8d2c2; + --text: #1c1a15; + --muted: #6f6a5e; + --accent: #b97e0c; + --accent-ink: #ffffff; + --danger: #c93a3e; + --ok: #2f7d3b; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); +} + +body { + font-family: var(--font-grotesk), system-ui, -apple-system, "Segoe UI", sans-serif; + font-size: 15px; + line-height: 1.5; +} + +a { + color: var(--accent); +} + +button, +input, +select, +textarea { + font: inherit; + color: var(--text); +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + animation: none !important; + } +} + +.btn { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 14px; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} + +.btn:hover { + border-color: var(--accent); +} + +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-ink); + font-weight: 600; +} + +.btn-primary:hover { + background: #ffc95c; + border-color: #ffc95c; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px; +} + +.input, +.select, +.textarea { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + width: 100%; +} + +.input::placeholder { + color: var(--muted); +} + +.muted { + color: var(--muted); +} + +.error { + color: var(--danger); +} diff --git a/frontend/app/history/page.tsx b/frontend/app/history/page.tsx new file mode 100644 index 0000000..792b748 --- /dev/null +++ b/frontend/app/history/page.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export default function HistoryPage() { + const [data, setData] = useState<{ downloads: { id: string }[]; failed: { source: string; media_type: string; id: string }[] } | null>(null); + + useEffect(() => { + fetch("/api/history").then((r) => r.json()).then(setData).catch(() => {}); + }, []); + + async function redownload(source: string, media_type: string, id: string) { + await fetch("/api/downloads", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source, media_type, id, no_db: true }), + }); + alert("Re-Download queued — siehe Queue"); + } + + if (!data) return

Lädt …

; + return ( +
+

Verlauf

+

Downloads ({data.downloads.length})

+
    {data.downloads.slice(-50).reverse().map((d) =>
  • {d.id}
  • )}
+

Fehlgeschlagen ({data.failed.length})

+
    + {data.failed.map((f) => ( +
  • + {f.source} {f.media_type} {f.id}{" "} + +
  • + ))} +
+
+ ); +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..e2106da --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from "next"; +import { Space_Grotesk } from "next/font/google"; +import Header from "./components/Header"; +import "./globals.css"; + +const grotesk = Space_Grotesk({ + subsets: ["latin"], + variable: "--font-grotesk", + display: "swap", +}); + +export const metadata: Metadata = { + title: "streamrip-ui", + description: "Web-Oberfläche für streamrip (Deezer)", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + +