feat: downloads-seite, verlauf mit titeln, datei-löschen, ordner-fallback
This commit is contained in:
1 parent
f2761549f9
commit
4d9912ff96
17 files changed
+393
-105
No files matched your search
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user