feat: downloads-seite, verlauf mit titeln, datei-löschen, ordner-fallback
CI / backend-tests (push) Successful in 9s
Docker images / version (push) Successful in 1s
CI / docker (push) Successful in 22s
Docker images / backend (push) Successful in 5s
Docker images / frontend (push) Successful in 6s

This commit is contained in:
loki committed 2026-10-04 14:39:29 +02:00
1 parent f2761549f9
commit 4d9912ff96
17 files changed
+393 -105

No files matched your search

+18
View File
@@ -20,6 +20,8 @@ class DownloadRequest(BaseModel):
media_type: Literal["track", "album", "artist", "playlist"] | None = None
id: str | None = None
url: str | None = None
title: str | None = None
artist: str | None = None
quality: int | None = Field(default=None, ge=0, le=2)
codec: str | None = None
folder: str | None = None
@@ -79,6 +81,18 @@ async def _run_job(job_id: str, params: dict):
)
jobstore.update_job(job_id, status="done", progress=100)
jobstore.append_log(job_id, "Done.")
try:
from app.core import history_store
history_store.record(
params.get("source", "deezer"),
params.get("media_type", ""),
params.get("url") or params.get("id", ""),
params.get("title") or "",
params.get("artist") or "",
)
except Exception:
pass
except asyncio.CancelledError:
jobstore.update_job(job_id, status="cancelled")
jobstore.append_log(job_id, "Abgebrochen.")
@@ -128,6 +142,8 @@ class BatchItem(BaseModel):
media_type: str | None = None
id: str | None = None
url: str | None = None
title: str | None = None
artist: str | None = None
class BatchRequest(BaseModel):
@@ -161,6 +177,8 @@ async def create_batch(req: BatchRequest):
"source": "deezer",
"media_type": item.media_type,
"id": item.id,
"title": item.title,
"artist": item.artist,
}
params.update(
quality=req.quality, codec=req.codec, folder=req.folder, no_db=req.no_db
+19
View File
@@ -32,6 +32,25 @@ def download_file(path: str = Query(default="")):
)
@router.delete("")
def delete_file(path: str = Query(default="")):
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():
if target.exists():
raise HTTPException(status_code=422, detail="Only files can be deleted")
raise HTTPException(status_code=404, detail="Not found")
try:
target.unlink()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return {"ok": True, "deleted": path}
@router.get("")
def list_files(path: str = Query(default="")):
root = _root()
+4 -1
View File
@@ -38,8 +38,11 @@ def _default_db_paths() -> tuple[str, str]:
@router.get("")
def get_history():
from app.core import history_store
dl_path, failed_path = _default_db_paths()
return {
"downloads": _read_ids(dl_path, "downloads"),
"entries": history_store.list_entries(),
"legacy": _read_ids(dl_path, "downloads"),
"failed": _read_ids(failed_path, "failed_downloads"),
}
+56
View File
@@ -0,0 +1,56 @@
"""App-owned download history (with titles), separate from streamrip's ID-only DBs."""
import sqlite3
import time
from pathlib import Path
from app.core import settings
def _db_path() -> str:
return str(Path(settings.CONFIG_PATH).parent / "app_history.db")
def _connect() -> sqlite3.Connection:
conn = sqlite3.connect(_db_path())
conn.execute(
"CREATE TABLE IF NOT EXISTS downloads "
"(id TEXT, source TEXT, media_type TEXT, title TEXT, artist TEXT, created_at REAL)"
)
return conn
def record(source: str, media_type: str, item_id: str, title: str = "", artist: str = "") -> None:
try:
conn = _connect()
try:
conn.execute(
"INSERT INTO downloads (id, source, media_type, title, artist, created_at)"
" VALUES (?, ?, ?, ?, ?, ?)",
(item_id, source, media_type, title, artist, time.time()),
)
conn.commit()
finally:
conn.close()
except Exception:
pass
def list_entries(limit: int = 200) -> list[dict]:
try:
conn = _connect()
try:
rows = conn.execute(
"SELECT id, source, media_type, title, artist, created_at"
" FROM downloads ORDER BY created_at DESC LIMIT ?",
(limit,),
).fetchall()
return [
{"id": r[0], "source": r[1], "media_type": r[2], "title": r[3],
"artist": r[4], "created_at": r[5]}
for r in rows
]
finally:
conn.close()
except Exception:
return []
@@ -587,6 +587,14 @@ def _apply_job_overrides(config, quality: int | None, codec: str | None, folder:
try:
if folder:
config.session.downloads.folder = folder
else:
# Prefer the config file's folder, but fall back to the environment
# default when it doesn't exist here (e.g. host path in container).
cfg_folder = config.session.downloads.folder
if not cfg_folder or not Path(cfg_folder).expanduser().exists():
config.session.downloads.folder = str(
Path(settings.DOWNLOAD_DIR).expanduser()
)
effective_folder = config.session.downloads.folder or settings.DOWNLOAD_DIR
except Exception:
effective_folder = folder or settings.DOWNLOAD_DIR
+46
View File
@@ -0,0 +1,46 @@
from fastapi.testclient import TestClient
from app.main import app
c = TestClient(app)
def test_history_records_title_on_done(tmp_path, monkeypatch):
from app.core import history_store, settings
from app.services import streamrip_service as svc
cfg = tmp_path / "config.toml"
cfg.write_text("[x]\n")
monkeypatch.setattr(settings, "CONFIG_PATH", str(cfg))
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": "99", "title": "My Song", "artist": "Me"},
).json()["job_id"]
import time
for _ in range(50):
if c.get(f"/api/downloads/{job_id}").json()["status"] == "done":
break
time.sleep(0.2)
entries = history_store.list_entries()
match = [e for e in entries if e["id"] == "99"]
assert match and match[0]["title"] == "My Song"
def test_history_endpoint_has_titled_and_legacy(tmp_path, monkeypatch):
from app.core import history_store, settings
cfg = tmp_path / "config.toml"
cfg.write_text("[x]\n")
monkeypatch.setattr(settings, "CONFIG_PATH", str(cfg))
history_store.record("deezer", "album", "5", "Album X", "Artist Y")
body = c.get("/api/history").json()
assert "entries" in body and "legacy" in body
assert any(e["title"] == "Album X" for e in body["entries"])
assert isinstance(body["legacy"], list)
+33
View File
@@ -0,0 +1,33 @@
from fastapi.testclient import TestClient
from app.main import app
def test_file_delete(tmp_path, monkeypatch):
from app.core import settings
root = tmp_path / "dl"
root.mkdir()
(root / "song.mp3").write_bytes(b"x")
(root / "sub").mkdir()
monkeypatch.setattr(settings, "DOWNLOAD_DIR", str(root))
c = TestClient(app)
r = c.delete("/api/files", params={"path": "song.mp3"})
assert r.status_code == 200
assert r.json()["ok"] is True
assert not (root / "song.mp3").exists()
def test_file_delete_blocked(tmp_path, monkeypatch):
from app.core import settings
root = tmp_path / "dl"
root.mkdir()
(root / "sub").mkdir()
monkeypatch.setattr(settings, "DOWNLOAD_DIR", str(root))
c = TestClient(app)
assert c.delete("/api/files", params={"path": "../../etc/passwd"}).status_code == 403
assert c.delete("/api/files", params={"path": "sub"}).status_code == 422
assert c.delete("/api/files", params={"path": "missing.mp3"}).status_code == 404
assert c.delete("/api/files").status_code == 404
+2 -1
View File
@@ -12,7 +12,8 @@ def test_path_traversal_blocked():
def test_history_endpoint_exists():
r = c.get("/api/history")
assert r.status_code == 200
assert "downloads" in r.json()
body = r.json()
assert "entries" in body and "legacy" in body and "failed" in body
def test_config_get_exists():
+21
View File
@@ -36,6 +36,27 @@ def test_overrides_fall_back_to_server_config(tmp_path, monkeypatch):
assert config.session.conversion.codec == "FLAC"
def test_missing_config_folder_falls_back_to_env(tmp_path, monkeypatch):
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"
import shutil
import streamrip
pkg = Path(streamrip.__file__).parent / "config.toml"
shutil.copy(pkg, cfg)
monkeypatch.setattr(settings, "CONFIG_PATH", str(cfg))
monkeypatch.setattr(settings, "DOWNLOAD_DIR", str(dl))
config = _load_config()
# package default folder does not exist here -> env fallback wins
assert _apply_job_overrides(config, None, None, None) == str(dl)
def test_config_arl_masked_roundtrip(tmp_path, monkeypatch):
from app.core import settings
+3 -1
View File
@@ -39,13 +39,15 @@ export default function DownloadDialog({ item, onClose }: { item: SearchItem; on
source: "deezer",
media_type: item.media_type,
id: item.id,
title: item.title,
artist: item.artist,
quality,
codec,
}),
});
if (!r.ok) throw new Error(`Start fehlgeschlagen (${r.status}).`);
const body = await r.json();
setMsg(`Angelegt: ${body.job_id} — siehe Queue.`);
setMsg(`Angelegt: ${body.job_id} — siehe Downloads.`);
} catch (e: unknown) {
setMsg(e instanceof Error ? e.message : "Fehler.");
} finally {
+3 -3
View File
@@ -27,7 +27,7 @@ export default function BasketPage() {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: items.map((i) => ({ media_type: i.media_type, id: i.id })),
items: items.map((i) => ({ media_type: i.media_type, id: i.id, title: i.title, artist: i.artist })),
}),
});
if (!r.ok) throw new Error(`Fehler (${r.status})`);
@@ -35,7 +35,7 @@ export default function BasketPage() {
clearBasket();
setItems([]);
setMsg(
`${body.job_ids.length} angelegt — siehe Queue.` +
`${body.job_ids.length} angelegt — siehe Downloads.` +
(body.rejected.length > 0 ? ` ${body.rejected.length} abgelehnt.` : "")
);
} catch (e: unknown) {
@@ -68,7 +68,7 @@ export default function BasketPage() {
>
Leeren
</button>
<Link href="/queue" style={{ alignSelf: "center" }}>Zur Queue →</Link>
<Link href="/downloads" style={{ alignSelf: "center" }}>Zu Downloads →</Link>
</div>
)}
<ol style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}>
+1 -2
View File
@@ -8,8 +8,7 @@ import { loadBasket } from "../lib/basket";
const NAV = [
{ href: "/", label: "Suche" },
{ href: "/discover", label: "Entdecken" },
{ href: "/queue", label: "Queue" },
{ href: "/history", label: "Verlauf" },
{ href: "/downloads", label: "Downloads" },
{ href: "/basket", label: "Korb" },
{ href: "/files", label: "Dateien" },
{ href: "/settings", label: "Einstellungen" },
+153
View File
@@ -0,0 +1,153 @@
"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 };
type Entry = { id: string; source: string; media_type: string; title: string; artist: string; created_at: number };
type Failed = { source: string; media_type: string; id: string };
function jobTitle(j: Job): string {
const p = j.params ?? {};
const t = typeof p.title === "string" && p.title ? p.title : null;
const a = typeof p.artist === "string" && p.artist ? ` — ${p.artist}` : "";
if (t) return `${t}${a}`;
if (typeof p.url === "string" && p.url) return p.url;
return `${String(p.media_type ?? "?")} ${String(p.id ?? "?")}`;
}
export default function DownloadsPage() {
const [tab, setTab] = useState<"aktiv" | "verlauf">("aktiv");
const [jobs, setJobs] = useState<Job[]>([]);
const [entries, setEntries] = useState<Entry[]>([]);
const [legacy, setLegacy] = useState<{ id: string }[]>([]);
const [failed, setFailed] = useState<Failed[]>([]);
const [msg, setMsg] = useState("");
async function refreshJobs() {
fetch("/api/downloads")
.then((r) => r.json())
.then((b) => setJobs(b.jobs ?? []))
.catch(() => {});
}
async function refreshHistory() {
fetch("/api/history")
.then((r) => r.json())
.then((b) => {
setEntries(b.entries ?? []);
setLegacy(b.legacy ?? []);
setFailed(b.failed ?? []);
})
.catch(() => {});
}
useEffect(() => {
refreshJobs();
refreshHistory();
const t = setInterval(() => {
refreshJobs();
refreshHistory();
}, 3000);
return () => clearInterval(t);
}, []);
async function stop(jobId: string) {
await fetch(`/api/downloads/${jobId}`, { method: "DELETE" }).catch(() => {});
refreshJobs();
}
async function redownload(source: string, media_type: string, id: string, title?: string, artist?: string) {
setMsg("");
const r = await fetch("/api/downloads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source, media_type, id, title, artist, no_db: true }),
});
setMsg(r.ok ? "Re-Download angelegt — siehe Aktive." : `Fehler (${r.status})`);
setTab("aktiv");
refreshJobs();
}
return (
<div>
<h1>Downloads</h1>
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<button onClick={() => setTab("aktiv")} className={tab === "aktiv" ? "btn btn-primary" : "btn"}>
Aktiv ({jobs.filter((j) => j.status === "queued" || j.status === "running").length})
</button>
<button onClick={() => setTab("verlauf")} className={tab === "verlauf" ? "btn btn-primary" : "btn"}>
Verlauf
</button>
</div>
{msg && <p>{msg}</p>}
{tab === "aktiv" && (
<div>
{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>{jobTitle(j)}</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>
)}
{tab === "verlauf" && (
<div>
<h2>Geladen ({entries.length})</h2>
{entries.length === 0 && <p className="muted">Noch nichts über die App geladen.</p>}
<ul>
{entries.map((e, i) => (
<li key={`${e.id}-${i}`}>
<strong>{e.title || e.id}</strong>
{e.artist ? ` — ${e.artist}` : ""}{" "}
<span className="muted">({e.media_type})</span>{" "}
<button onClick={() => redownload(e.source, e.media_type, e.id, e.title, e.artist)} className="btn" style={{ padding: "2px 8px" }}>
Erneut laden
</button>
</li>
))}
</ul>
{legacy.length > 0 && (
<>
<h2>Ältere Einträge ({legacy.length})</h2>
<p className="muted">Nur IDs vorhanden (vor Titel-Verlauf) — Titel nicht rekonstruierbar.</p>
<ul>
{legacy.slice(-50).reverse().map((d) => (
<li key={d.id} className="muted">{d.id}</li>
))}
</ul>
</>
)}
{failed.length > 0 && (
<>
<h2>Fehlgeschlagen ({failed.length})</h2>
<ul>
{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" style={{ padding: "2px 8px" }}>
Erneut laden
</button>
</li>
))}
</ul>
</>
)}
</div>
)}
</div>
);
}
+25 -1
View File
@@ -7,6 +7,7 @@ type Entry = { name: string; type: string; size?: number | null; rel?: string };
export default function FilesPage() {
const [path, setPath] = useState("");
const [entries, setEntries] = useState<Entry[]>([]);
const [confirm, setConfirm] = useState<Entry | null>(null);
async function load(p: string) {
const r = await fetch(`/api/files?path=${encodeURIComponent(p)}`);
@@ -20,6 +21,13 @@ export default function FilesPage() {
load("");
}, []);
async function remove(entry: Entry) {
const r = await fetch(`/api/files?path=${encodeURIComponent(entry.rel ?? entry.name)}`, { method: "DELETE" });
setConfirm(null);
if (r.ok) load(path);
else alert(`Löschen fehlgeschlagen (${r.status})`);
}
function up() {
if (!path) return;
const parts = path.split("/").filter(Boolean);
@@ -42,12 +50,28 @@ export default function FilesPage() {
🎵 {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>
</a>{" "}
<button onClick={() => setConfirm(e)} className="btn" style={{ padding: "2px 8px" }}>
Löschen
</button>
</span>
)}
</li>
))}
</ul>
{confirm && (
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.6)", display: "flex", alignItems: "center", justifyContent: "center" }} onClick={() => setConfirm(null)}>
<div className="card" style={{ padding: 20, maxWidth: 400, background: "var(--surface-2)" }} onClick={(ev) => ev.stopPropagation()}>
<h3 style={{ marginTop: 0 }}>Wirklich löschen?</h3>
<p><strong>{confirm.name}</strong>{typeof confirm.size === "number" ? ` (${Math.round(confirm.size / 1024 / 1024)} MB)` : ""}</p>
<p className="muted">Endgültig — kein Papierkorb.</p>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={() => remove(confirm)} className="btn btn-primary">Ja, löschen</button>
<button onClick={() => setConfirm(null)} className="btn">Abbrechen</button>
</div>
</div>
</div>
)}
</div>
);
}
-38
View File
@@ -1,38 +0,0 @@
"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>
);
}
+1 -1
View File
@@ -41,7 +41,7 @@ export default function SearchPage() {
throw new Error(body.detail || `Fehler (${r.status})`);
}
const body = await r.json();
setLinkMsg(`Angelegt: ${body.job_id} — siehe Queue.`);
setLinkMsg(`Angelegt: ${body.job_id} — siehe Downloads.`);
setLink("");
} catch (e: unknown) {
setLinkMsg(e instanceof Error ? e.message : "Fehler.");
-57
View File
@@ -1,57 +0,0 @@
"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>
);
}