feat: streamrip-ui – Next.js + FastAPI Webapp für streamrip (Deezer)

This commit is contained in:
loki committed 2026-10-04 10:09:06 +02:00
commit 0537fa435a
69 files changed
+5169

No files matched your search

+22
View File
@@ -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/
+43
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
.venv
__pycache__
*.pyc
.pytest_cache
+28
View File
@@ -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"]
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+13
View File
@@ -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}")
+13
View File
@@ -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}")
+278
View File
@@ -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}
+57
View File
@@ -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}")
+203
View File
@@ -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")
+55
View File
@@ -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}
+45
View File
@@ -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"),
}
+13
View File
@@ -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}")
+13
View File
@@ -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}")
+28
View File
@@ -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}
View File
Whitespace-only changes.
+101
View File
@@ -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")
+14
View File
@@ -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"]
+36
View File
@@ -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)
View File
Whitespace-only changes.
+694
View File
@@ -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__()
+7
View File
@@ -0,0 +1,7 @@
fastapi
uvicorn
pydantic
tomlkit
aiofiles
httpx
pytest
View File
Whitespace-only changes.
+67
View File
@@ -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"}]
+54
View File
@@ -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)
+40
View File
@@ -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
+101
View File
@@ -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
)
)
+43
View File
@@ -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
+85
View File
@@ -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
+46
View File
@@ -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
+42
View File
@@ -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"
+47
View File
@@ -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"]
+30
View File
@@ -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
+30
View File
@@ -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
+16
View File
@@ -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"]
@@ -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()
+50
View File
@@ -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"] == []
+37
View File
@@ -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
+109
View File
@@ -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)
+41
View File
@@ -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"
+30
View File
@@ -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"
+25
View File
@@ -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
@@ -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**
+2
View File
@@ -0,0 +1,2 @@
node_modules
.next
+21
View File
@@ -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"]
+90
View File
@@ -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 (
<div
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.65)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 }}
onClick={onClose}
>
<div
className="card"
style={{ minWidth: 320, maxWidth: 420, width: "90%", padding: 20, background: "var(--surface-2)" }}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ marginTop: 0 }}>{item.title}</h3>
<p className="muted" style={{ marginTop: 0 }}>{item.artist}</p>
<label>
Qualität (0=MP3 128, 1=MP3 320, 2=FLAC)
<select value={quality} onChange={(e) => setQuality(Number(e.target.value))} className="select" style={{ margin: "6px 0 12px" }}>
<option value={0}>0 — MP3 128</option>
<option value={1}>1 — MP3 320</option>
<option value={2}>2 — FLAC</option>
</select>
</label>
<label>
Codec (optional, z. B. MP3/FLAC/OGG)
<input value={codec} onChange={(e) => setCodec(e.target.value)} placeholder="Leer = kein Convert" className="input" style={{ margin: "6px 0 12px" }} />
</label>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={start} disabled={sending} className="btn btn-primary" style={{ flex: 1 }}>
{sending ? "…" : "Download starten"}
</button>
<button onClick={onClose} className="btn">Schließen</button>
</div>
{msg && <p>{msg}</p>}
</div>
</div>
);
}
+98
View File
@@ -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<AlbumDetail | null>(null);
const [error, setError] = useState("");
const [selected, setSelected] = useState<SearchItem | null>(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 <p className="error">{error}</p>;
if (!data) return <p className="muted">Lädt …</p>;
return (
<div>
<div style={{ display: "flex", gap: 20, alignItems: "center", marginBottom: 20 }}>
{data.cover && (
<Image src={data.cover} alt={data.title} width={180} height={180} style={{ borderRadius: 12 }} />
)}
<div>
<h1 style={{ margin: 0, fontSize: 34, letterSpacing: "-0.02em" }}>{data.title}</h1>
<p className="muted" style={{ margin: "4px 0 12px" }}>
{data.artist}
{data.release_date ? ` · ${data.release_date.slice(0, 4)}` : ""}
{typeof data.nb_tracks === "number" ? ` · ${data.nb_tracks} Tracks` : ""}
</p>
<button
onClick={() => setSelected({ id: data.id, media_type: "album", title: data.title, artist: data.artist, cover: data.cover })}
className="btn btn-primary"
>
Ganzes Album laden
</button>
</div>
</div>
{data.tracks.length === 0 && <p className="muted">Keine Tracks gefunden.</p>}
<ol style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}>
{data.tracks.map((t) => (
<li
key={t.id}
className="card"
style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 14px" }}
>
<span className="muted" style={{ width: 28, textAlign: "right", flexShrink: 0 }}>{t.track_number ?? "–"}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600 }}>
{t.title} {t.explicit ? <span className="muted" style={{ fontSize: 12 }}>(explicit)</span> : null}
</div>
<div className="muted" style={{ fontSize: 13 }}>{fmtDuration(t.duration)}</div>
</div>
{t.preview_url && <audio controls preload="none" src={t.preview_url} style={{ width: 220, height: 32 }} />}
<BasketButton item={{ source: "deezer", media_type: "track", id: t.id, title: t.title, artist: t.artist }} />
<button onClick={() => setSelected(t)} className="btn" style={{ whiteSpace: "nowrap" }}>
Download
</button>
</li>
))}
</ol>
{selected && <DownloadDialog item={selected} onClose={() => setSelected(null)} />}
</div>
);
}
+170
View File
@@ -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<Detail | null>(null);
const [related, setRelated] = useState<SearchItem[]>([]);
const [error, setError] = useState("");
const [sort, setSort] = useState<SortDir>("desc");
const [selected, setSelected] = useState<SearchItem | null>(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 <p className="error">{error}</p>;
if (!data) return <p className="muted">Lädt …</p>;
return (
<div>
<div style={{ display: "flex", gap: 20, alignItems: "center", marginBottom: 8 }}>
{data.cover && (
<Image src={data.cover} alt={data.name} width={160} height={160} style={{ borderRadius: 12 }} />
)}
<div>
<h1 style={{ margin: 0, fontSize: 36, letterSpacing: "-0.02em" }}>{data.name}</h1>
<p className="muted" style={{ margin: "4px 0 12px" }}>
{data.albums.length} Releases{typeof data.nb_albums === "number" ? ` (${data.nb_albums} bei Deezer)` : ""}
</p>
<button
onClick={() => setSelected({ id: data.id, media_type: "artist", title: data.name, artist: data.name })}
className="btn btn-primary"
>
Ganzen Artist laden
</button>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, margin: "16px 0 20px" }}>
<label htmlFor="sort" className="muted">Sortieren nach Datum:</label>
<select id="sort" value={sort} onChange={(e) => setSort(e.target.value as SortDir)} className="select" style={{ width: "auto" }}>
<option value="desc">Neueste zuerst</option>
<option value="asc">Älteste zuerst</option>
</select>
</div>
{GROUPS.map((g) => {
const items = sortByDate(data.albums.filter((a) => a.group === g.key), sort);
if (items.length === 0) return null;
return (
<section key={g.key} style={{ marginBottom: 28 }}>
<h2 style={{ fontSize: 24 }}>{g.label} ({items.length})</h2>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))", gap: 14 }}>
{items.map((a) => (
<div key={a.id} className="card">
{a.cover ? (
<a href={`/album/${a.id}`} title="Trackliste ansehen">
<Image src={a.cover} alt={a.title} width={300} height={300} style={{ width: "100%", height: "auto", borderRadius: 6 }} />
</a>
) : (
<div style={{ width: "100%", aspectRatio: "1", background: "var(--surface-2)", borderRadius: 6 }} />
)}
<div style={{ fontWeight: 600, marginTop: 8 }}>
<a href={`/album/${a.id}`} style={{ color: "inherit", textDecoration: "none" }}>{a.title}</a>
</div>
<div className="muted" style={{ fontSize: 13 }}>
{a.release_date ? a.release_date.slice(0, 4) + " · " : ""}{a.nb_tracks ? `${a.nb_tracks} Tracks` : ""}
</div>
<button
onClick={() => setSelected({ id: a.id, media_type: "album", title: a.title, artist: a.artist, cover: a.cover })}
className="btn"
style={{ marginTop: 8, width: "100%" }}
>
Download
</button>
<div style={{ marginTop: 8 }}>
<BasketButton item={{ source: "deezer", media_type: "album", id: a.id, title: a.title, artist: a.artist }} />
</div>
</div>
))}
</div>
</section>
);
})}
{data.featuring.length > 0 && (
<section style={{ marginBottom: 28 }}>
<h2 style={{ fontSize: 24 }}>Featuring ({data.featuring.length}) <span className="muted" style={{ fontSize: 13 }}>— Näherung via Track-Suche</span></h2>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))", gap: 14 }}>
{data.featuring.map((t) => (
<div key={`feat-${t.id}`} className="card">
{t.cover ? (
<Image src={t.cover} alt={t.title} width={300} height={300} style={{ width: "100%", height: "auto", borderRadius: 6 }} />
) : (
<div style={{ width: "100%", aspectRatio: "1", background: "var(--surface-2)", borderRadius: 6 }} />
)}
<div style={{ fontWeight: 600, marginTop: 8 }}>{t.title}</div>
<div className="muted" style={{ fontSize: 13 }}>{t.artist}</div>
{t.preview_url && <audio controls preload="none" src={t.preview_url} style={{ width: "100%", marginTop: 8 }} />}
<button onClick={() => setSelected(t)} className="btn" style={{ marginTop: 8, width: "100%" }}>
Download
</button>
</div>
))}
</div>
</section>
)}
{related.length > 0 && (
<section style={{ marginBottom: 28 }}>
<h2 style={{ fontSize: 24 }}>Ähnliche Artists</h2>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}>
{related.map((a) => (
<a key={a.id} href={`/artist/${a.id}`} className="card" style={{ textDecoration: "none", color: "inherit", display: "block" }}>
{a.cover ? (
<Image src={a.cover} alt={a.title} width={200} height={200} style={{ width: "100%", height: "auto", borderRadius: 6 }} />
) : (
<div style={{ width: "100%", aspectRatio: "1", background: "var(--surface-2)", borderRadius: 6 }} />
)}
<div style={{ fontWeight: 600, marginTop: 6, fontSize: 14 }}>{a.title}</div>
</a>
))}
</div>
</section>
)}
{selected && <DownloadDialog item={selected} onClose={() => setSelected(null)} />}
</div>
);
}
+88
View File
@@ -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<BasketItem[]>([]);
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 (
<div>
<h1>Sammelkorb ({items.length})</h1>
{msg && <p>{msg}</p>}
{items.length === 0 && (
<p className="muted">
Leer — lege Treffer über „+ Korb“ auf Suche, Artist-, Album- oder Playlist-Seite hier ab.
</p>
)}
{items.length > 0 && (
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<button onClick={checkout} disabled={sending} className="btn btn-primary">
{sending ? "…" : `Alle ${items.length} laden`}
</button>
<button
onClick={() => {
clearBasket();
setItems([]);
}}
className="btn"
>
Leeren
</button>
<Link href="/queue" style={{ alignSelf: "center" }}>Zur Queue →</Link>
</div>
)}
<ol style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}>
{items.map((i) => (
<li key={`${i.media_type}-${i.id}`} className="card" style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 14px" }}>
<span className="muted" style={{ fontSize: 13, width: 60, flexShrink: 0 }}>{i.media_type}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600 }}>{i.title}</div>
<div className="muted" style={{ fontSize: 13 }}>{i.artist}</div>
</div>
<button onClick={() => remove(i)} className="btn">Entfernen</button>
</li>
))}
</ol>
</div>
);
}
+31
View File
@@ -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 (
<button
onClick={() => setInside(toggleBasket(item))}
className="btn"
title={inside ? "Aus dem Korb entfernen" : "In den Korb legen"}
style={{ whiteSpace: "nowrap" }}
>
{inside ? "✓ Korb" : "+ Korb"}
</button>
);
}
+149
View File
@@ -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<boolean | null>(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 (
<header
style={{
position: "sticky",
top: 0,
zIndex: 50,
background: "color-mix(in srgb, var(--bg) 92%, transparent)",
backdropFilter: "blur(8px)",
borderBottom: "1px solid var(--border)",
}}
>
<div
style={{
maxWidth: 1200,
margin: "0 auto",
padding: "12px 20px",
display: "flex",
alignItems: "center",
gap: 24,
}}
>
<Link href="/" style={{ textDecoration: "none", color: "var(--text)", display: "flex", alignItems: "center", gap: 10 }}>
<span
aria-hidden
style={{
width: 28,
height: 28,
borderRadius: "50%",
background: "radial-gradient(circle at center, var(--accent) 0 3px, #2a2a31 3px 5px, #0c0c0e 5px)",
border: "2px solid var(--accent)",
flexShrink: 0,
}}
/>
<span style={{ fontWeight: 700, fontSize: 18, letterSpacing: "-0.01em" }}>streamrip-ui</span>
</Link>
<nav style={{ display: "flex", gap: 4, flex: 1 }}>
{NAV.map((item) => {
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
const label = item.href === "/basket" && basketCount > 0 ? `Korb (${basketCount})` : item.label;
return (
<Link
key={item.href}
href={item.href}
style={{
textDecoration: "none",
color: active ? "var(--text)" : "var(--muted)",
padding: "6px 12px",
borderRadius: 6,
borderBottom: active ? "2px solid var(--accent)" : "2px solid transparent",
fontWeight: active ? 600 : 400,
}}
>
{label}
</Link>
);
})}
</nav>
<button
onClick={toggleTheme}
className="btn"
title={theme === "dark" ? "Hellmodus einschalten" : "Dunkelmodus einschalten"}
style={{ padding: "6px 10px" }}
>
{theme === "dark" ? "☀" : "☾"}
</button>
<span
title={backendOk === null ? "Backend-Status wird geprüft" : backendOk ? "Backend erreichbar" : "Backend nicht erreichbar — Server prüfen"}
style={{
width: 10,
height: 10,
borderRadius: "50%",
background: backendOk === null ? "var(--muted)" : backendOk ? "var(--ok)" : "var(--danger)",
flexShrink: 0,
}}
/>
</div>
</header>
);
}
+186
View File
@@ -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<Genre[]>([]);
const [genreId, setGenreId] = useState(0);
const [kind, setKind] = useState<Kind>("new");
const [results, setResults] = useState<SearchItem[]>([]);
const [radios, setRadios] = useState<{ id: number; title: string; cover?: string | null }[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [selected, setSelected] = useState<SearchItem | null>(null);
const [radioMsg, setRadioMsg] = useState("");
const [nonce, setNonce] = useState(0);
const [openRadio, setOpenRadio] = useState<number | null>(null);
const [radioTracks, setRadioTracks] = useState<SearchItem[]>([]);
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 (
<div>
<h1 style={{ fontSize: 36, margin: "8px 0 4px", letterSpacing: "-0.02em" }}>Entdecken</h1>
<p className="muted" style={{ marginTop: 0 }}>Neuheiten und Charts nach Genre — direkt laden oder in den Korb legen.</p>
<div style={{ display: "flex", gap: 8, margin: "16px 0", flexWrap: "wrap" }}>
{KINDS.map((k) => (
<button key={k.key} onClick={() => setKind(k.key)} className={kind === k.key ? "btn btn-primary" : "btn"}>
{k.label}
</button>
))}
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 20, flexWrap: "wrap" }}>
<button onClick={() => setGenreId(0)} className={genreId === 0 ? "btn btn-primary" : "btn"}>
Alle Genres
</button>
{genres.map((g) => (
<button key={g.id} onClick={() => setGenreId(g.id)} className={genreId === g.id ? "btn btn-primary" : "btn"}>
{g.name}
</button>
))}
</div>
{error && <p className="error">{error}</p>}
{loading && <p className="muted">Lädt …</p>}
{!loading && results.length === 0 && !error && (
<div>
<p className="muted">Derzeit keine Daten von Deezer für diese Auswahl.</p>
<button onClick={() => setNonce((n) => n + 1)} className="btn">
Erneut versuchen
</button>
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 14 }}>
{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 ? (
<Image src={item.cover} alt={item.title} width={300} height={300} style={{ width: "100%", height: "auto", borderRadius: 6 }} />
) : (
<div style={{ width: "100%", aspectRatio: "1", background: "var(--surface-2)", borderRadius: 6 }} />
);
return (
<div key={`${item.media_type}-${item.id}`} className="card">
{href ? <a href={href} title="Details ansehen">{cover}</a> : cover}
<div style={{ fontWeight: 600, marginTop: 8 }}>{item.title}</div>
<div className="muted" style={{ fontSize: 13 }}>{item.artist}</div>
{item.preview_url && <audio controls preload="none" src={item.preview_url} style={{ width: "100%", marginTop: 8 }} />}
<button onClick={() => setSelected(item)} className="btn" style={{ marginTop: 8, width: "100%" }}>
Download
</button>
<div style={{ marginTop: 8 }}>
<BasketButton item={{ source: "deezer", media_type: item.media_type, id: item.id, title: item.title, artist: item.artist }} />
</div>
</div>
);
})}
</div>
{selected && <DownloadDialog item={selected} onClose={() => setSelected(null)} />}
{radios.length > 0 && (
<section style={{ marginTop: 28 }}>
<h2 style={{ fontSize: 24 }}>Sender {genreId !== 0 ? "in diesem Genre" : ""}</h2>
{radioMsg && <p>{radioMsg}</p>}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))", gap: 12 }}>
{radios.map((radio) => (
<div key={radio.id} className="card">
{radio.cover ? (
<Image src={radio.cover} alt={radio.title} width={200} height={200} style={{ width: "100%", height: "auto", borderRadius: 6 }} />
) : (
<div style={{ width: "100%", aspectRatio: "1", background: "var(--surface-2)", borderRadius: 6 }} />
)}
<div style={{ fontWeight: 600, marginTop: 6, fontSize: 14 }}>{radio.title}</div>
<button onClick={() => toggleRadio(radio.id)} className="btn" style={{ marginTop: 8, width: "100%" }}>
{openRadio === radio.id ? "Schließen" : "Lieder ansehen"}
</button>
</div>
))}
</div>
{openRadio !== null && (
<div className="card" style={{ marginTop: 12, padding: 12 }}>
{radioLoading && <p className="muted">Lädt …</p>}
{!radioLoading && radioTracks.length === 0 && <p className="muted">Keine Tracks gefunden.</p>}
<ol style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}>
{radioTracks.map((t) => (
<li key={t.id} style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600 }}>{t.title}</div>
<div className="muted" style={{ fontSize: 13 }}>{t.artist}</div>
</div>
{t.preview_url && <audio controls preload="none" src={t.preview_url} style={{ width: 180, height: 32 }} />}
<BasketButton item={{ source: "deezer", media_type: "track", id: t.id, title: t.title, artist: t.artist }} />
<button onClick={() => setSelected(t)} className="btn" style={{ whiteSpace: "nowrap" }}>
Download
</button>
</li>
))}
</ol>
</div>
)}
</section>
)}
</div>
);
}
+53
View File
@@ -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<Entry[]>([]);
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 (
<div>
<h1>Dateien</h1>
<p className="muted">/{path}</p>
{path && <button onClick={up} className="btn" style={{ marginBottom: 10 }}>↑ Ebene hoch</button>}
<ul>
{entries.map((e) => (
<li key={e.rel ?? e.name}>
{e.type === "dir" ? (
<button onClick={() => load(e.rel ?? e.name)} className="btn">📁 {e.name}</button>
) : (
<span>
🎵 {e.name}{typeof e.size === "number" ? ` (${Math.round(e.size / 1024 / 1024)} MB)` : ""}{" "}
<a href={`/api/files/download?path=${encodeURIComponent(e.rel ?? e.name)}`} download>
Herunterladen ↓
</a>
</span>
)}
</li>
))}
</ul>
</div>
);
}
+122
View File
@@ -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);
}
+38
View File
@@ -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 <p className="muted">Lädt …</p>;
return (
<div>
<h1>Verlauf</h1>
<h2>Downloads ({data.downloads.length})</h2>
<ul>{data.downloads.slice(-50).reverse().map((d) => <li key={d.id}>{d.id}</li>)}</ul>
<h2>Fehlgeschlagen ({data.failed.length})</h2>
<ul>
{data.failed.map((f) => (
<li key={`${f.source}-${f.media_type}-${f.id}`}>
{f.source} {f.media_type} {f.id}{" "}
<button onClick={() => redownload(f.source, f.media_type, f.id)} className="btn">Erneut laden</button>
</li>
))}
</ul>
</div>
);
}
+31
View File
@@ -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 (
<html lang="de">
<body className={grotesk.variable} suppressHydrationWarning>
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem("srip-theme");if(t==="light"||(!t&&matchMedia("(prefers-color-scheme: light)").matches))document.documentElement.dataset.theme="light"}catch(e){}})()`,
}}
/>
<Header />
<main style={{ maxWidth: 1200, margin: "0 auto", padding: "24px 20px 60px" }}>{children}</main>
</body>
</html>
);
}
+49
View File
@@ -0,0 +1,49 @@
export type BasketItem = {
source: string;
media_type: string;
id: string;
title: string;
artist: string;
};
const KEY = "srip-basket";
export function loadBasket(): BasketItem[] {
try {
const raw = JSON.parse(localStorage.getItem(KEY) ?? "[]");
return Array.isArray(raw) ? raw : [];
} catch {
return [];
}
}
function save(items: BasketItem[]) {
localStorage.setItem(KEY, JSON.stringify(items));
window.dispatchEvent(new Event("basket-change"));
}
export function basketKey(item: Pick<BasketItem, "media_type" | "id">): string {
return `${item.media_type}:${item.id}`;
}
export function inBasket(item: Pick<BasketItem, "media_type" | "id">): boolean {
return loadBasket().some((b) => basketKey(b) === basketKey(item));
}
export function toggleBasket(item: BasketItem): boolean {
const items = loadBasket();
const k = basketKey(item);
const next = items.some((b) => basketKey(b) === k)
? items.filter((b) => basketKey(b) !== k)
: [...items, item];
save(next);
return next.some((b) => basketKey(b) === k);
}
export function removeFromBasket(item: Pick<BasketItem, "media_type" | "id">) {
save(loadBasket().filter((b) => basketKey(b) !== basketKey(item)));
}
export function clearBasket() {
save([]);
}
+12
View File
@@ -0,0 +1,12 @@
export type SortDir = "desc" | "asc";
export function sortByDate<T extends { release_date?: string }>(items: T[], dir: SortDir): T[] {
return [...items].sort((a, b) => {
const da = a.release_date || "";
const db = b.release_date || "";
if (!da && !db) return 0;
if (!da) return 1;
if (!db) return -1;
return dir === "desc" ? db.localeCompare(da) : da.localeCompare(db);
});
}
+187
View File
@@ -0,0 +1,187 @@
"use client";
import Image from "next/image";
import { useState } from "react";
import DownloadDialog, { type SearchItem } from "./DownloadDialog";
import BasketButton from "./components/BasketButton";
const TYPES = ["track", "album", "artist", "playlist", "podcast"] as const;
type MediaType = (typeof TYPES)[number];
const TYPE_LABEL: Record<MediaType, string> = {
track: "Titel",
album: "Alben",
artist: "Artists",
playlist: "Playlists",
podcast: "Podcasts",
};
export default function SearchPage() {
const [q, setQ] = useState("");
const [tab, setTab] = useState<MediaType>("track");
const [results, setResults] = useState<SearchItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [searched, setSearched] = useState(false);
const [selected, setSelected] = useState<SearchItem | null>(null);
const [link, setLink] = useState("");
const [linkMsg, setLinkMsg] = useState("");
async function importLink() {
if (!link.trim()) return;
setLinkMsg("");
try {
const r = await fetch("/api/downloads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: link.trim() }),
});
if (!r.ok) {
const body = await r.json().catch(() => ({}));
throw new Error(body.detail || `Fehler (${r.status})`);
}
const body = await r.json();
setLinkMsg(`Angelegt: ${body.job_id} — siehe Queue.`);
setLink("");
} catch (e: unknown) {
setLinkMsg(e instanceof Error ? e.message : "Fehler.");
}
}
async function runSearch() {
if (!q.trim()) return;
setLoading(true);
setError("");
setSearched(true);
try {
const r = await fetch(`/api/search?type=${tab}&q=${encodeURIComponent(q)}&limit=24`);
if (!r.ok) throw new Error(hintFor(r.status));
const body = await r.json();
setResults(body.results ?? []);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Suche fehlgeschlagen.");
setResults([]);
} finally {
setLoading(false);
}
}
return (
<div>
<h1 style={{ fontSize: 40, margin: "8px 0 4px", letterSpacing: "-0.02em" }}>Was willst du laden?</h1>
<p className="muted" style={{ marginTop: 0 }}>Deezer-Suche — Treffer direkt als Download starten.</p>
<div style={{ display: "flex", gap: 8, margin: "16px 0" }}>
{TYPES.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={tab === t ? "btn btn-primary" : "btn"}
>
{TYPE_LABEL[t]}
</button>
))}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
runSearch();
}}
style={{ display: "flex", gap: 8, marginBottom: 20 }}
>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Titel, Artist, Album …"
className="input"
style={{ fontSize: 17, padding: "12px 14px" }}
/>
<button type="submit" className="btn btn-primary" style={{ padding: "12px 22px", whiteSpace: "nowrap" }}>
{loading ? "Sucht …" : "Suchen"}
</button>
</form>
{error && <p className="error">{error}</p>}
<div className="card" style={{ display: "flex", gap: 8, marginBottom: 20, padding: 12, alignItems: "center" }}>
<input
value={link}
onChange={(e) => setLink(e.target.value)}
placeholder="Deezer-Link einfügen … (Album/Track/Artist/Playlist)"
className="input"
style={{ fontFamily: "monospace", fontSize: 13 }}
/>
<button onClick={importLink} className="btn" style={{ whiteSpace: "nowrap" }}>
Link laden
</button>
</div>
{linkMsg && <p>{linkMsg}</p>}
{!loading && searched && results.length === 0 && !error && (
<p className="muted">Keine Treffer — anders formulieren oder anderen Typ wählen.</p>
)}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 14 }}>
{results.map((item) => {
const detailHref =
item.media_type === "artist" ? `/artist/${item.id}` :
item.media_type === "album" ? `/album/${item.id}` :
item.media_type === "playlist" ? `/playlist/${item.id}` :
item.media_type === "podcast" ? `/podcast/${item.id}` : null;
const cover = item.cover ? (
<Image src={item.cover} alt={item.title} width={300} height={300} style={{ width: "100%", height: "auto", borderRadius: 6 }} />
) : (
<div style={{ width: "100%", aspectRatio: "1", background: "var(--surface-2)", borderRadius: 6 }} />
);
return (
<div key={`${item.media_type}-${item.id}`} className="card">
{detailHref ? <a href={detailHref} title="Details ansehen">{cover}</a> : cover}
<div style={{ fontWeight: 600, marginTop: 8 }}>
{detailHref ? <a href={detailHref} style={{ color: "inherit", textDecoration: "none" }}>{item.title}</a> : item.title}
</div>
<div className="muted" style={{ fontSize: 13 }}>{item.artist}</div>
{item.media_type === "artist" && (
<a href={`/artist/${item.id}`} style={{ display: "block", marginTop: 8 }}>
Alben, EPs & Singles ansehen →
</a>
)}
{item.media_type === "album" && (
<a href={`/album/${item.id}`} style={{ display: "block", marginTop: 8 }}>
Trackliste ansehen →
</a>
)}
{item.media_type === "playlist" && (
<a href={`/playlist/${item.id}`} style={{ display: "block", marginTop: 8 }}>
Trackliste ansehen →
</a>
)}
{item.media_type === "podcast" && (
<a href={`/podcast/${item.id}`} style={{ display: "block", marginTop: 8 }}>
Episoden ansehen →
</a>
)}
{item.preview_url && (
<audio controls preload="none" src={item.preview_url} style={{ width: "100%", marginTop: 8 }} />
)}
{item.media_type === "podcast" ? (
<p className="muted" style={{ fontSize: 13, marginTop: 8 }}>
Nur Übersicht — Download nicht möglich.
</p>
) : (
<>
<button onClick={() => setSelected(item)} className="btn" style={{ marginTop: 8, width: "100%" }}>
Download
</button>
<div style={{ marginTop: 8 }}>
<BasketButton item={{ source: "deezer", media_type: item.media_type, id: item.id, title: item.title, artist: item.artist }} />
</div>
</>
)}
</div>
);
})}
</div>
{selected && <DownloadDialog item={selected} onClose={() => setSelected(null)} />}
</div>
);
}
function hintFor(status: number): string {
if (status === 502) return "Suche fehlgeschlagen — Backend prüfen (ARL in Einstellungen kontrollieren).";
return `Suche fehlgeschlagen (${status}).`;
}
+94
View File
@@ -0,0 +1,94 @@
"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 PlaylistDetail = {
id: string;
title: string;
creator: string;
description: string;
cover?: string | null;
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 PlaylistPage({ params }: { params: { id: string } }) {
const { id } = params;
const [data, setData] = useState<PlaylistDetail | null>(null);
const [error, setError] = useState("");
const [selected, setSelected] = useState<SearchItem | null>(null);
useEffect(() => {
fetch(`/api/playlist/${id}`)
.then((r) => {
if (!r.ok) throw new Error(`Playlist nicht gefunden (${r.status}).`);
return r.json();
})
.then(setData)
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Fehler."));
}, [id]);
if (error) return <p className="error">{error}</p>;
if (!data) return <p className="muted">Lädt …</p>;
return (
<div>
<div style={{ display: "flex", gap: 20, alignItems: "center", marginBottom: 20 }}>
{data.cover && (
<Image src={data.cover} alt={data.title} width={180} height={180} style={{ borderRadius: 12 }} />
)}
<div>
<h1 style={{ margin: 0, fontSize: 34, letterSpacing: "-0.02em" }}>{data.title}</h1>
<p className="muted" style={{ margin: "4px 0 12px" }}>
von {data.creator}
{typeof data.nb_tracks === "number" ? ` · ${data.nb_tracks} Tracks` : ""}
</p>
{data.description && <p className="muted">{data.description.slice(0, 200)}</p>}
<button
onClick={() => setSelected({ id: data.id, media_type: "playlist", title: data.title, artist: data.creator, cover: data.cover })}
className="btn btn-primary"
>
Ganze Playlist laden
</button>
</div>
</div>
{data.tracks.length === 0 && <p className="muted">Keine Tracks gefunden.</p>}
<ol style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}>
{data.tracks.map((t) => (
<li key={t.id} className="card" style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 14px" }}>
<span className="muted" style={{ width: 28, textAlign: "right", flexShrink: 0 }}>{t.track_number ?? "–"}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600 }}>
{t.title} {t.explicit ? <span className="muted" style={{ fontSize: 12 }}>(explicit)</span> : null}
</div>
<div className="muted" style={{ fontSize: 13 }}>{t.artist} · {fmtDuration(t.duration)}</div>
</div>
{t.preview_url && <audio controls preload="none" src={t.preview_url} style={{ width: 220, height: 32 }} />}
<BasketButton item={{ source: "deezer", media_type: "track", id: t.id, title: t.title, artist: t.artist }} />
<button onClick={() => setSelected(t)} className="btn" style={{ whiteSpace: "nowrap" }}>
Download
</button>
</li>
))}
</ol>
{selected && <DownloadDialog item={selected} onClose={() => setSelected(null)} />}
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
type Episode = {
id: string;
title: string;
duration?: number | null;
release_date?: string;
cover?: string | null;
};
type Show = {
id: string;
title: string;
description: string;
cover?: string | null;
fans?: number | null;
link: string;
episodes: Episode[];
};
function fmtDuration(sec?: number | null): string {
if (typeof sec !== "number") return "";
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
return h > 0 ? `${h} Std. ${m} Min.` : `${m} Min.`;
}
export default function PodcastPage({ params }: { params: { id: string } }) {
const { id } = params;
const [data, setData] = useState<Show | null>(null);
const [error, setError] = useState("");
useEffect(() => {
fetch(`/api/podcast/${id}`)
.then((r) => {
if (!r.ok) throw new Error(`Podcast nicht gefunden (${r.status}).`);
return r.json();
})
.then(setData)
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Fehler."));
}, [id]);
if (error) return <p className="error">{error}</p>;
if (!data) return <p className="muted">Lädt …</p>;
return (
<div>
<div style={{ display: "flex", gap: 20, alignItems: "center", marginBottom: 8 }}>
{data.cover && (
<Image src={data.cover} alt={data.title} width={180} height={180} style={{ borderRadius: 12 }} />
)}
<div>
<h1 style={{ margin: 0, fontSize: 34, letterSpacing: "-0.02em" }}>{data.title}</h1>
<p className="muted" style={{ margin: "4px 0 12px" }}>
Podcast{typeof data.fans === "number" ? ` · ${data.fans} Fans` : ""} · {data.episodes.length} Episoden
</p>
<a href={data.link} target="_blank" rel="noreferrer" className="btn btn-primary" style={{ textDecoration: "none", display: "inline-block" }}>
In Deezer anhören →
</a>
</div>
</div>
{data.description && <p style={{ maxWidth: 800 }}>{data.description.slice(0, 500)}</p>}
<p className="muted">Hinweis: Podcasts lassen sich mit streamrip nicht herunterladen — hier nur Übersicht.</p>
<h2 style={{ fontSize: 24 }}>Episoden</h2>
{data.episodes.length === 0 && <p className="muted">Keine Episoden gefunden.</p>}
<ol style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}>
{data.episodes.map((ep) => (
<li key={ep.id} className="card" style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 14px" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600 }}>{ep.title}</div>
<div className="muted" style={{ fontSize: 13 }}>
{ep.release_date ? ep.release_date.slice(0, 10) + " · " : ""}{fmtDuration(ep.duration)}
</div>
</div>
</li>
))}
</ol>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { useEffect, useState } from "react";
type Job = { job_id: string; status: string; progress: number; params: Record<string, unknown>; logs: string[]; error?: string | null };
export default function QueuePage() {
const [jobs, setJobs] = useState<Job[]>([]);
useEffect(() => {
fetch("/api/downloads")
.then((r) => r.json())
.then((b) => setJobs(b.jobs ?? []))
.catch(() => {});
const t = setInterval(() => {
fetch("/api/downloads")
.then((r) => r.json())
.then((b) => setJobs(b.jobs ?? []))
.catch(() => {});
}, 2000);
return () => clearInterval(t);
}, []);
async function stop(jobId: string) {
await fetch(`/api/downloads/${jobId}`, { method: "DELETE" }).catch(() => {});
fetch("/api/downloads")
.then((r) => r.json())
.then((b) => setJobs(b.jobs ?? []))
.catch(() => {});
}
return (
<div>
<h1>Queue</h1>
{jobs.length === 0 && <p className="muted">Keine Jobs — starte einen Download über die Suche.</p>}
{jobs.map((j) => (
<div key={j.job_id} className="card" style={{ marginBottom: 10, padding: 12 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<strong>{j.job_id}</strong>
<span style={{ display: "flex", gap: 8, alignItems: "center" }}>
<span>{j.status}{j.progress ? ` — ${j.progress}%` : ""}</span>
{(j.status === "queued" || j.status === "running") && (
<button onClick={() => stop(j.job_id)} className="btn">
Stoppen
</button>
)}
</span>
</div>
<pre style={{ whiteSpace: "pre-wrap", color: "var(--muted)", fontSize: 12, maxHeight: 160, overflow: "auto" }}>
{(j.logs ?? []).join("\n")}
</pre>
{j.error && <p className="error">{j.error}</p>}
</div>
))}
</div>
);
}
+228
View File
@@ -0,0 +1,228 @@
"use client";
import { useEffect, useState } from "react";
type UiConfig = {
deezer: {
arl_set: boolean;
arl_preview: string;
quality: number;
lower_quality_if_not_available: boolean;
use_deezloader: boolean;
deezloader_warnings: boolean;
};
downloads: { folder: string; max_connections: number; requests_per_minute: number; concurrency: boolean };
conversion: { enabled: boolean; codec: string };
behavior: { skip_downloaded: boolean };
};
async function saveUi(payload: object): Promise<string> {
const r = await fetch("/api/config/ui", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) {
const body = await r.json().catch(() => ({}));
throw new Error(body.detail || `Fehler (${r.status})`);
}
return "Gespeichert (Backup angelegt).";
}
function Check({ label, hint, checked, onChange }: { label: string; hint: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<label style={{ display: "block", marginBottom: 10 }}>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} style={{ marginRight: 8 }} />
{label}
<div className="muted" style={{ fontSize: 13, marginLeft: 26 }}>{hint}</div>
</label>
);
}
export default function SettingsPage() {
const [cfg, setCfg] = useState<UiConfig | null>(null);
const [toml, setToml] = useState("");
const [arl, setArl] = useState("");
const [msg, setMsg] = useState("");
const [err, setErr] = useState("");
async function reload() {
const b = await fetch("/api/config").then((r) => r.json()).catch(() => ({}));
setToml(b.toml ?? "");
const u = await fetch("/api/config/ui").then((r) => r.json()).catch(() => null);
setCfg(u);
}
useEffect(() => {
reload();
}, []);
async function run(fn: () => Promise<string>) {
setMsg("");
setErr("");
try {
setMsg(await fn());
await reload();
} catch (e: unknown) {
setErr(e instanceof Error ? e.message : "Fehler.");
}
}
function patch(path: string[], value: unknown): UiConfig | null {
if (!cfg) return cfg;
const next = JSON.parse(JSON.stringify(cfg));
let obj = next;
for (const k of path.slice(0, -1)) obj = obj[k];
obj[path[path.length - 1]] = value;
setCfg(next);
return next;
}
async function saveArl() {
await run(async () => {
const r = await fetch("/api/config/arl", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ arl }),
});
if (!r.ok) throw new Error(`ARL-Fehler (${r.status})`);
setArl("");
return "ARL gespeichert (Backup angelegt).";
});
}
if (!cfg) return <p className="muted">Lädt …</p>;
return (
<div>
<h1>Einstellungen</h1>
{msg && <p>{msg}</p>}
{err && <p className="error">{err}</p>}
<section className="card" style={{ marginBottom: 16, padding: 16 }}>
<h2 style={{ marginTop: 0 }}>1 · Deezer-Konto</h2>
<p className="muted" style={{ marginTop: 0 }}>
Status: {cfg.deezer.arl_set ? `gesetzt (${cfg.deezer.arl_preview})` : "nicht gesetzt — ohne ARL geht nur Deezloader (unzuverlässig)"}
</p>
<div style={{ display: "flex", gap: 8 }}>
<input
type="password"
value={arl}
onChange={(e) => setArl(e.target.value)}
placeholder="Neue ARL einfügen …"
className="input"
/>
<button onClick={saveArl} className="btn">ARL speichern</button>
</div>
<Check label="Deezloader als Fallback nutzen" hint="Kostenlose 320-kbps-MP3s ohne ARL — oft unzuverlässig." checked={cfg.deezer.use_deezloader} onChange={(v) => patch(["deezer", "use_deezloader"], v)} />
<Check label="Deezloader-Warnung zeigen" hint="Warnt, wenn auf Deezloader zurückgegriffen wird." checked={cfg.deezer.deezloader_warnings} onChange={(v) => patch(["deezer", "deezloader_warnings"], v)} />
<button onClick={() => run(() => saveUi({ deezer: { use_deezloader: cfg.deezer.use_deezloader, deezloader_warnings: cfg.deezer.deezloader_warnings } }))} className="btn">
Konto speichern
</button>
</section>
<section className="card" style={{ marginBottom: 16, padding: 16 }}>
<h2 style={{ marginTop: 0 }}>2 · Download-Defaults</h2>
<p className="muted" style={{ marginTop: 0 }}>Diese Werte füllen den Download-Dialog vor.</p>
<label style={{ display: "block", marginBottom: 10 }}>
Qualität
<select value={cfg.deezer.quality} onChange={(e) => patch(["deezer", "quality"], Number(e.target.value))} className="select" style={{ marginTop: 4 }}>
<option value={0}>0 — MP3 128</option>
<option value={1}>1 — MP3 320</option>
<option value={2}>2 — FLAC (beste)</option>
</select>
</label>
<label style={{ display: "block", marginBottom: 10 }}>
Ziel-Codec (Konvertierung)
<select
value={cfg.conversion.enabled ? cfg.conversion.codec : ""}
onChange={(e) => { patch(["conversion", "enabled"], e.target.value !== ""); patch(["conversion", "codec"], e.target.value || "MP3"); }}
className="select"
style={{ marginTop: 4 }}
>
<option value="">Keine (Original behalten)</option>
<option value="FLAC">FLAC</option>
<option value="ALAC">ALAC</option>
<option value="OPUS">OPUS</option>
<option value="MP3">MP3</option>
<option value="VORBIS">VORBIS</option>
<option value="AAC">AAC</option>
</select>
</label>
<label style={{ display: "block", marginBottom: 10 }}>
Download-Ordner (muss existieren)
<input value={cfg.downloads.folder} onChange={(e) => patch(["downloads", "folder"], e.target.value)} className="input" style={{ marginTop: 4, fontFamily: "monospace" }} />
</label>
<button
onClick={() => run(() => saveUi({
deezer: { quality: cfg.deezer.quality },
conversion: { enabled: cfg.conversion.enabled, codec: cfg.conversion.codec },
downloads: { folder: cfg.downloads.folder },
}))}
className="btn btn-primary"
>
Downloads speichern
</button>
</section>
<section className="card" style={{ marginBottom: 16, padding: 16 }}>
<h2 style={{ marginTop: 0 }}>3 · Verhalten</h2>
<Check label="Fallback auf beste verfügbare Qualität" hint="Wenn die Wunsch-Qualität fehlt, wird das Beste genommen statt abzubrechen." checked={cfg.deezer.lower_quality_if_not_available} onChange={(v) => patch(["deezer", "lower_quality_if_not_available"], v)} />
<Check label="Bereits geladene überspringen" hint="Tracks aus der Download-Datenbank werden ausgelassen (pro Download über „trotzdem laden“ umgehbar)." checked={cfg.behavior.skip_downloaded} onChange={(v) => patch(["behavior", "skip_downloaded"], v)} />
<Check label="Parallel laden + konvertieren" hint="Mehrere Tracks gleichzeitig — bei schneller Leitung deutlich schneller." checked={cfg.downloads.concurrency} onChange={(v) => patch(["downloads", "concurrency"], v)} />
<button
onClick={() => run(() => saveUi({
deezer: { lower_quality_if_not_available: cfg.deezer.lower_quality_if_not_available },
behavior: { skip_downloaded: cfg.behavior.skip_downloaded },
downloads: { concurrency: cfg.downloads.concurrency },
}))}
className="btn"
>
Verhalten speichern
</button>
</section>
<section className="card" style={{ marginBottom: 16, padding: 16 }}>
<h2 style={{ marginTop: 0 }}>4 · Netzwerk</h2>
<div style={{ display: "flex", gap: 12 }}>
<label style={{ flex: 1 }}>
Max. parallele Tracks (−1 = unbegrenzt)
<input type="number" value={cfg.downloads.max_connections} onChange={(e) => patch(["downloads", "max_connections"], Number(e.target.value))} className="input" style={{ marginTop: 4 }} />
</label>
<label style={{ flex: 1 }}>
API-Anfragen pro Minute (−1 = unbegrenzt)
<input type="number" value={cfg.downloads.requests_per_minute} onChange={(e) => patch(["downloads", "requests_per_minute"], Number(e.target.value))} className="input" style={{ marginTop: 4 }} />
</label>
</div>
<button
onClick={() => run(() => saveUi({ downloads: { max_connections: cfg.downloads.max_connections, requests_per_minute: cfg.downloads.requests_per_minute } }))}
className="btn"
style={{ marginTop: 10 }}
>
Netzwerk speichern
</button>
</section>
<section className="card" style={{ marginBottom: 16, padding: 16 }}>
<h2 style={{ marginTop: 0 }}>5 · Erweitert (config.toml)</h2>
<p className="muted" style={{ marginTop: 0 }}>Für alles andere — inklusive Qobuz/Tidal/SoundCloud.</p>
<textarea value={toml} onChange={(e) => setToml(e.target.value)} rows={20} className="textarea" style={{ fontFamily: "monospace" }} />
<button
onClick={() => run(async () => {
const r = await fetch("/api/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ toml }),
});
if (!r.ok) throw new Error(`Fehler (${r.status})`);
return "config.toml gespeichert (Backup angelegt).";
})}
className="btn"
style={{ marginTop: 8 }}
>
config.toml speichern
</button>
</section>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+19
View File
@@ -0,0 +1,19 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
images: {
remotePatterns: [
{ protocol: "https", hostname: "**.dzcdn.net" },
{ protocol: "https", hostname: "e-cdns-images.dzcdn.net" },
{ protocol: "https", hostname: "cdn-images.dzcdn.net" },
],
},
async rewrites() {
const backend = process.env.BACKEND_URL || "http://127.0.0.1:8000";
return [
{ source: "/api/:path*", destination: `${backend}/api/:path*` },
];
},
};
module.exports = nextConfig;
+497
View File
@@ -0,0 +1,497 @@
{
"name": "streamrip-ui-frontend",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "streamrip-ui-frontend",
"dependencies": {
"next": "14",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"typescript": "^5"
}
},
"node_modules/@next/env": {
"version": "14.2.35",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz",
"integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz",
"integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz",
"integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz",
"integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz",
"integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz",
"integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz",
"integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz",
"integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz",
"integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz",
"integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"license": "Apache-2.0"
},
"node_modules/@swc/helpers": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz",
"integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"tslib": "^2.4.0"
}
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001812",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001812.tgz",
"integrity": "sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/nanoid": {
"version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/next": {
"version": "14.2.35",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz",
"integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==",
"license": "MIT",
"dependencies": {
"@next/env": "14.2.35",
"@swc/helpers": "0.5.5",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
"graceful-fs": "^4.2.11",
"postcss": "8.4.31",
"styled-jsx": "5.1.1"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": ">=18.17.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.33",
"@next/swc-darwin-x64": "14.2.33",
"@next/swc-linux-arm64-gnu": "14.2.33",
"@next/swc-linux-arm64-musl": "14.2.33",
"@next/swc-linux-x64-gnu": "14.2.33",
"@next/swc-linux-x64-musl": "14.2.33",
"@next/swc-win32-arm64-msvc": "14.2.33",
"@next/swc-win32-ia32-msvc": "14.2.33",
"@next/swc-win32-x64-msvc": "14.2.33"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.41.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@playwright/test": {
"optional": true
},
"sass": {
"optional": true
}
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/styled-jsx": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
"integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
"license": "MIT",
"dependencies": {
"client-only": "0.0.1"
},
"engines": {
"node": ">= 12.0.0"
},
"peerDependencies": {
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "streamrip-ui-frontend",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "14",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"typescript": "^5"
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}