feat: streamrip-ui – Next.js + FastAPI Webapp für streamrip (Deezer)
This commit is contained in:
commit
0537fa435a
69 files changed
+5169
No files matched your search
+22
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache
|
||||||
@@ -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"]
|
||||||
Whitespace-only changes.
Whitespace-only changes.
@@ -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}")
|
||||||
@@ -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}")
|
||||||
@@ -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}
|
||||||
@@ -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}")
|
||||||
@@ -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")
|
||||||
@@ -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}
|
||||||
@@ -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"),
|
||||||
|
}
|
||||||
@@ -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}")
|
||||||
@@ -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}")
|
||||||
@@ -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}
|
||||||
Whitespace-only changes.
@@ -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")
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
Whitespace-only changes.
@@ -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__()
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
pydantic
|
||||||
|
tomlkit
|
||||||
|
aiofiles
|
||||||
|
httpx
|
||||||
|
pytest
|
||||||
Whitespace-only changes.
@@ -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"}]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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"] == []
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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**
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
@@ -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"]
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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}).`;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+5
@@ -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.
|
||||||
@@ -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;
|
||||||
Generated
+497
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user