mirror of
https://github.com/myronblair/kino-app
synced 2026-07-29 14:02:50 -05:00
Add live progress status to bulk TMDB movie enrichment
Was a single synchronous request with no visibility into progress — large libraries gave no indication whether it was working or just slow. Now runs as a background job (matching the existing music-enrich pattern) with GET /api/tmdb/enrich-all/status polled every 2s; Settings shows "Enriching... N/total" while running and a summary once done.
This commit is contained in:
+53
-26
@@ -1798,37 +1798,64 @@ async def opensubs_download(payload: dict, user: dict = Depends(require_admin)):
|
|||||||
|
|
||||||
|
|
||||||
# ============ BULK TMDB ENRICHMENT ============
|
# ============ BULK TMDB ENRICHMENT ============
|
||||||
|
_movie_enrich_status = {"running": False, "total": 0, "done": 0, "enriched": 0, "skipped": 0, "failed": 0, "error": ""}
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_movie_enrich(key: str):
|
||||||
|
_movie_enrich_status.update(running=True, total=0, done=0, enriched=0, skipped=0, failed=0, error="")
|
||||||
|
try:
|
||||||
|
movies = await db.movies.find({}, {"_id": 0}).to_list(20000)
|
||||||
|
_movie_enrich_status["total"] = len(movies)
|
||||||
|
for m in movies:
|
||||||
|
needs = not m.get("cast") or not m.get("director") or not m.get("description") or not m.get("poster_url")
|
||||||
|
if not needs:
|
||||||
|
_movie_enrich_status["skipped"] += 1
|
||||||
|
_movie_enrich_status["done"] += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if m.get("tmdb_id"):
|
||||||
|
data = await asyncio.to_thread(tmdb_client.get_movie, key, m["tmdb_id"])
|
||||||
|
else:
|
||||||
|
results = await asyncio.to_thread(tmdb_client.search_movies, key, m["title"])
|
||||||
|
if not results:
|
||||||
|
_movie_enrich_status["failed"] += 1
|
||||||
|
_movie_enrich_status["done"] += 1
|
||||||
|
continue
|
||||||
|
data = await asyncio.to_thread(tmdb_client.get_movie, key, results[0]["tmdb_id"])
|
||||||
|
updates = {}
|
||||||
|
for field in ("description", "poster_url", "backdrop_url", "director", "duration_minutes", "rating"):
|
||||||
|
if data.get(field) and not m.get(field): updates[field] = data[field]
|
||||||
|
if data.get("cast") and not m.get("cast"): updates["cast"] = data["cast"]
|
||||||
|
if data.get("genres") and not m.get("genres"): updates["genres"] = data["genres"]
|
||||||
|
if data.get("tmdb_id"): updates["tmdb_id"] = data["tmdb_id"]
|
||||||
|
if updates:
|
||||||
|
await db.movies.update_one({"id": m["id"]}, {"$set": updates})
|
||||||
|
_movie_enrich_status["enriched"] += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Enrich failed for {m.get('title')}: {e}")
|
||||||
|
_movie_enrich_status["failed"] += 1
|
||||||
|
_movie_enrich_status["done"] += 1
|
||||||
|
except Exception as e:
|
||||||
|
_movie_enrich_status["error"] = str(e)
|
||||||
|
finally:
|
||||||
|
_movie_enrich_status["running"] = False
|
||||||
|
|
||||||
|
|
||||||
@api.post("/tmdb/enrich-all")
|
@api.post("/tmdb/enrich-all")
|
||||||
async def tmdb_enrich_all(user: dict = Depends(require_admin)):
|
async def tmdb_enrich_all(user: dict = Depends(require_admin)):
|
||||||
"""Sweep movies with missing metadata and fill from TMDB (by title match)."""
|
"""Kicks off a background sweep of movies with missing metadata, filled from TMDB (by title match)."""
|
||||||
|
if _movie_enrich_status["running"]:
|
||||||
|
raise HTTPException(status_code=409, detail="Enrichment already running")
|
||||||
s = await get_settings()
|
s = await get_settings()
|
||||||
key = s.get("tmdb_api_key", "")
|
key = s.get("tmdb_api_key", "")
|
||||||
if not key: raise HTTPException(status_code=400, detail="TMDB not configured")
|
if not key: raise HTTPException(status_code=400, detail="TMDB not configured")
|
||||||
enriched = 0; skipped = 0; failed = 0
|
asyncio.create_task(_run_movie_enrich(key))
|
||||||
async for m in db.movies.find({}, {"_id": 0}):
|
return {"ok": True}
|
||||||
needs = not m.get("cast") or not m.get("director") or not m.get("description") or not m.get("poster_url")
|
|
||||||
if not needs:
|
|
||||||
skipped += 1; continue
|
@api.get("/tmdb/enrich-all/status")
|
||||||
try:
|
async def tmdb_enrich_all_status(user: dict = Depends(require_admin)):
|
||||||
if m.get("tmdb_id"):
|
return _movie_enrich_status
|
||||||
data = await asyncio.to_thread(tmdb_client.get_movie, key, m["tmdb_id"])
|
|
||||||
else:
|
|
||||||
results = await asyncio.to_thread(tmdb_client.search_movies, key, m["title"])
|
|
||||||
if not results: failed += 1; continue
|
|
||||||
data = await asyncio.to_thread(tmdb_client.get_movie, key, results[0]["tmdb_id"])
|
|
||||||
updates = {}
|
|
||||||
for field in ("description", "poster_url", "backdrop_url", "director", "duration_minutes", "rating"):
|
|
||||||
if data.get(field) and not m.get(field): updates[field] = data[field]
|
|
||||||
if data.get("cast") and not m.get("cast"): updates["cast"] = data["cast"]
|
|
||||||
if data.get("genres") and not m.get("genres"): updates["genres"] = data["genres"]
|
|
||||||
if data.get("tmdb_id"): updates["tmdb_id"] = data["tmdb_id"]
|
|
||||||
if updates:
|
|
||||||
await db.movies.update_one({"id": m["id"]}, {"$set": updates})
|
|
||||||
enriched += 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Enrich failed for {m.get('title')}: {e}")
|
|
||||||
failed += 1
|
|
||||||
return {"ok": True, "enriched": enriched, "skipped": skipped, "failed": failed}
|
|
||||||
|
|
||||||
|
|
||||||
async def _auto_fetch_subtitle(content_type: str, content_id: str, tmdb_id: Optional[int],
|
async def _auto_fetch_subtitle(content_type: str, content_id: str, tmdb_id: Optional[int],
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export default function Settings() {
|
|||||||
const [pw, setPw] = useState({ current_password: "", new_password: "", confirm: "" });
|
const [pw, setPw] = useState({ current_password: "", new_password: "", confirm: "" });
|
||||||
const [pwShow, setPwShow] = useState(false);
|
const [pwShow, setPwShow] = useState(false);
|
||||||
const [pwSaving, setPwSaving] = useState(false);
|
const [pwSaving, setPwSaving] = useState(false);
|
||||||
|
const [enrich, setEnrich] = useState(null);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!isAdmin) return;
|
if (!isAdmin) return;
|
||||||
@@ -119,10 +120,32 @@ export default function Settings() {
|
|||||||
|
|
||||||
const enrichAll = async () => {
|
const enrichAll = async () => {
|
||||||
if (!window.confirm("Sweep all movies with missing metadata and fill from TMDB? This may take a while.")) return;
|
if (!window.confirm("Sweep all movies with missing metadata and fill from TMDB? This may take a while.")) return;
|
||||||
try { const { data } = await api.post("/tmdb/enrich-all"); toast.success(`Enriched ${data.enriched}, skipped ${data.skipped}, failed ${data.failed}`); }
|
try {
|
||||||
catch (err) { toast.error(err.response?.data?.detail || "Enrichment failed"); }
|
await api.post("/tmdb/enrich-all");
|
||||||
|
const { data } = await api.get("/tmdb/enrich-all/status");
|
||||||
|
setEnrich(data);
|
||||||
|
toast.success("Enrichment started");
|
||||||
|
} catch (err) { toast.error(err.response?.data?.detail || "Enrichment failed"); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin) return;
|
||||||
|
api.get("/tmdb/enrich-all/status").then(({ data }) => setEnrich(data)).catch(() => {});
|
||||||
|
}, [isAdmin]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enrich?.running) return;
|
||||||
|
const t = setInterval(async () => {
|
||||||
|
const { data } = await api.get("/tmdb/enrich-all/status");
|
||||||
|
setEnrich(data);
|
||||||
|
if (!data.running) {
|
||||||
|
clearInterval(t);
|
||||||
|
toast.success(`Enrichment done — ${data.enriched} enriched, ${data.skipped} already complete, ${data.failed} not found`);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [enrich?.running]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldCtx.Provider value={{ s, setS, show, setShow }}>
|
<FieldCtx.Provider value={{ s, setS, show, setShow }}>
|
||||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="settings-page">
|
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="settings-page">
|
||||||
@@ -183,9 +206,16 @@ export default function Settings() {
|
|||||||
<p className="text-sm text-[#8A8A8A] mb-4">Free key at <a className="text-[#D9381E]" href="https://www.themoviedb.org/settings/api" target="_blank" rel="noreferrer">themoviedb.org</a></p>
|
<p className="text-sm text-[#8A8A8A] mb-4">Free key at <a className="text-[#D9381E]" href="https://www.themoviedb.org/settings/api" target="_blank" rel="noreferrer">themoviedb.org</a></p>
|
||||||
<Field label="API key (v3)" k="tmdb_api_key" mask />
|
<Field label="API key (v3)" k="tmdb_api_key" mask />
|
||||||
{info.tmdb_configured && (
|
{info.tmdb_configured && (
|
||||||
<button type="button" onClick={enrichAll} className="mt-4 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 transition-colors" data-testid="enrich-all-button">
|
<div className="mt-4 flex items-center gap-4 flex-wrap">
|
||||||
Bulk enrich all movies →
|
<button type="button" onClick={enrichAll} disabled={enrich?.running} className="text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 transition-colors disabled:opacity-50" data-testid="enrich-all-button">
|
||||||
</button>
|
{enrich?.running ? `Enriching… ${enrich.done}/${enrich.total}` : "Bulk enrich all movies →"}
|
||||||
|
</button>
|
||||||
|
{enrich && !enrich.running && enrich.total > 0 && (
|
||||||
|
<span className="text-xs text-[#8A8A8A]" data-testid="enrich-all-summary">
|
||||||
|
Last run: {enrich.enriched} enriched · {enrich.skipped} already complete · {enrich.failed} not found on TMDB
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user