Fix 8 code-review findings across transcode queue, requests, cleanup, music

- Episode transcode (single + bulk) now accepts storage_type=scan, matching
  stream_episode and the movie-transcode endpoint — Library-Scan-imported
  TV episodes can finally be transcoded, not just streamed.
- retry_job now forwards content_type, fixing retry for failed/cancelled
  episode jobs (was silently treating them as movies and failing again).
- cancel_job now updates the correct collection (movies vs episodes) based
  on content_type instead of always writing to db.movies.
- approve_request atomically claims the request (pending -> approving)
  before contacting Radarr/Sonarr, so a double-click or two concurrent
  admins can't both add the same content; releases the claim back to
  pending on any failure so a fixable error can be retried.
- Music enrichment's update_many now re-checks album_art_url is still
  empty, so it no longer clobbers art an admin manually set via PATCH.
- Library Cleanup's dangling-refs scan now also checks episode_progress
  for rows referencing a deleted episode (previously only watchlist/
  progress against movie_id were checked).
- Settings now surfaces a failed bulk-TMDB-enrich run as an error instead
  of a false "Enrichment done" success summary.
- Music player's isPlaying now syncs from the audio element's native
  play/pause events instead of only being set inside togglePlay/track-
  change, so it can't drift from actual playback state (buffering stalls,
  OS media-key pauses, etc).
This commit is contained in:
Myron Blair
2026-07-26 22:24:09 -05:00
parent 5ac78654d4
commit 5fc37e46e4
4 changed files with 89 additions and 49 deletions
+56 -41
View File
@@ -692,8 +692,9 @@ async def cancel_job(job_id: str, user: dict = Depends(require_admin)):
raise HTTPException(status_code=400, detail="Cannot cancel a running job")
await db.transcode_queue.update_one({"id": job_id}, {"$set": {"status": "cancelled", "finished_at": datetime.now(timezone.utc).isoformat()}})
if job["status"] == "pending":
# Reset movie hls_status AND clear stale hls_path so the row no longer shows as transcoded
await db.movies.update_one({"id": job["movie_id"]}, {"$set": {"hls_status": None, "hls_path": None}})
# Reset hls_status AND clear stale hls_path so the row no longer shows as transcoded
coll = db.movies if job.get("content_type", "movie") == "movie" else db.episodes
await coll.update_one({"id": job["movie_id"]}, {"$set": {"hls_status": None, "hls_path": None}})
return {"ok": True}
@@ -703,7 +704,7 @@ async def retry_job(job_id: str, user: dict = Depends(require_admin)):
if not job: raise HTTPException(status_code=404, detail="Job not found")
if job["status"] not in ("failed", "cancelled"):
raise HTTPException(status_code=400, detail="Only failed/cancelled jobs can be retried")
new_job = await _enqueue_transcode(job["movie_id"], job["quality"], triggered_by=job.get("triggered_by", "manual"))
new_job = await _enqueue_transcode(job["movie_id"], job["quality"], triggered_by=job.get("triggered_by", "manual"), content_type=job.get("content_type", "movie"))
return {"ok": True, "job_id": new_job["id"]}
@@ -935,44 +936,58 @@ async def approve_request(request_id: str, payload: RequestApprove, user: dict =
"""Looks up the request's title in Radarr/Sonarr, adds the best match with an immediate
search enabled, and marks the request approved. Content actually lands via the normal
Radarr/Sonarr download pipeline — this only kicks that off."""
req = await db.requests.find_one({"id": request_id}, {"_id": 0})
if not req: raise HTTPException(status_code=404, detail="Request not found")
if payload.content_type not in ("movie", "tv"):
raise HTTPException(status_code=400, detail="content_type must be movie or tv")
# Atomically claim the request so two concurrent approve calls (double-click, two admins)
# can't both pass a check-then-act race and each add the content to Radarr/Sonarr.
req = await db.requests.find_one_and_update(
{"id": request_id, "status": "pending"}, {"$set": {"status": "approving"}},
projection={"_id": 0}, return_document=False,
)
if not req:
existing = await db.requests.find_one({"id": request_id}, {"_id": 0, "status": 1})
if not existing: raise HTTPException(status_code=404, detail="Request not found")
raise HTTPException(status_code=409, detail=f"Request is already {existing['status']}")
s = await get_settings()
if payload.content_type == "movie":
if not s.get("radarr_url") or not s.get("radarr_api_key"):
raise HTTPException(status_code=400, detail="Radarr not configured")
try:
candidates = await asyncio.to_thread(radarr_client.search_movie, s["radarr_url"], s["radarr_api_key"], req["title"])
except Exception as e:
raise HTTPException(status_code=502, detail=f"Radarr lookup error: {e}")
match = next((c for c in candidates if req.get("year") and c.get("year") == req["year"]), candidates[0] if candidates else None)
if not match:
raise HTTPException(status_code=404, detail="No match found in Radarr")
try:
added = await asyncio.to_thread(radarr_client.add_movie, s["radarr_url"], s["radarr_api_key"], match["tmdb_id"], match["title"], match.get("year"))
except Exception as e:
raise HTTPException(status_code=502, detail=f"Radarr add error: {e}")
updates = {"status": "approved", "content_type": "movie", "radarr_id": added["radarr_id"],
"matched_title": added.get("title"), "matched_year": added.get("year")}
else:
if not s.get("sonarr_url") or not s.get("sonarr_api_key"):
raise HTTPException(status_code=400, detail="Sonarr not configured")
try:
candidates = await asyncio.to_thread(sonarr_client.search_series, s["sonarr_url"], s["sonarr_api_key"], req["title"])
except Exception as e:
raise HTTPException(status_code=502, detail=f"Sonarr lookup error: {e}")
match = next((c for c in candidates if req.get("year") and c.get("year") == req["year"]), candidates[0] if candidates else None)
if not match:
raise HTTPException(status_code=404, detail="No match found in Sonarr")
try:
added = await asyncio.to_thread(sonarr_client.add_series, s["sonarr_url"], s["sonarr_api_key"], match["tvdb_id"], match["title"], match.get("year"))
except Exception as e:
raise HTTPException(status_code=502, detail=f"Sonarr add error: {e}")
updates = {"status": "approved", "content_type": "tv", "sonarr_id": added["sonarr_id"],
"matched_title": added.get("title"), "matched_year": added.get("year")}
try:
if payload.content_type == "movie":
if not s.get("radarr_url") or not s.get("radarr_api_key"):
raise HTTPException(status_code=400, detail="Radarr not configured")
try:
candidates = await asyncio.to_thread(radarr_client.search_movie, s["radarr_url"], s["radarr_api_key"], req["title"])
except Exception as e:
raise HTTPException(status_code=502, detail=f"Radarr lookup error: {e}")
match = next((c for c in candidates if req.get("year") and c.get("year") == req["year"]), candidates[0] if candidates else None)
if not match:
raise HTTPException(status_code=404, detail="No match found in Radarr")
try:
added = await asyncio.to_thread(radarr_client.add_movie, s["radarr_url"], s["radarr_api_key"], match["tmdb_id"], match["title"], match.get("year"))
except Exception as e:
raise HTTPException(status_code=502, detail=f"Radarr add error: {e}")
updates = {"status": "approved", "content_type": "movie", "radarr_id": added["radarr_id"],
"matched_title": added.get("title"), "matched_year": added.get("year")}
else:
if not s.get("sonarr_url") or not s.get("sonarr_api_key"):
raise HTTPException(status_code=400, detail="Sonarr not configured")
try:
candidates = await asyncio.to_thread(sonarr_client.search_series, s["sonarr_url"], s["sonarr_api_key"], req["title"])
except Exception as e:
raise HTTPException(status_code=502, detail=f"Sonarr lookup error: {e}")
match = next((c for c in candidates if req.get("year") and c.get("year") == req["year"]), candidates[0] if candidates else None)
if not match:
raise HTTPException(status_code=404, detail="No match found in Sonarr")
try:
added = await asyncio.to_thread(sonarr_client.add_series, s["sonarr_url"], s["sonarr_api_key"], match["tvdb_id"], match["title"], match.get("year"))
except Exception as e:
raise HTTPException(status_code=502, detail=f"Sonarr add error: {e}")
updates = {"status": "approved", "content_type": "tv", "sonarr_id": added["sonarr_id"],
"matched_title": added.get("title"), "matched_year": added.get("year")}
except HTTPException:
# Release the claim so a fixable failure (e.g. Radarr temporarily down) can be retried,
# instead of leaving the request stuck in "approving" forever.
await db.requests.update_one({"id": request_id}, {"$set": {"status": "pending"}})
raise
res = await db.requests.find_one_and_update(
{"id": request_id}, {"$set": updates}, projection={"_id": 0}, return_document=True,
@@ -1212,7 +1227,7 @@ async def bulk_transcode_eps(payload: dict, user: dict = Depends(require_admin))
for eid in ids:
ep = await db.episodes.find_one({"id": eid}, {"_id": 0})
if not ep or ep.get("hls_status") in ("running", "pending"): continue
if ep.get("storage_type") not in ("local", "sonarr") or not ep.get("storage_path"): continue
if ep.get("storage_type") not in ("local", "sonarr", "scan") or not ep.get("storage_path"): continue
await _enqueue_transcode(eid, quality, triggered_by="manual", content_type="episode")
queued += 1
return {"ok": True, "queued": queued}
@@ -1244,8 +1259,8 @@ async def transcode_episode(episode_id: str, payload: Optional[dict] = None, use
if quality not in ("quick", "abr"): raise HTTPException(status_code=400, detail="Invalid quality")
ep = await db.episodes.find_one({"id": episode_id}, {"_id": 0})
if not ep: raise HTTPException(status_code=404, detail="Episode not found")
if ep.get("storage_type") not in ("local", "sonarr") or not ep.get("storage_path"):
raise HTTPException(status_code=400, detail="Only local/sonarr episodes can be transcoded")
if ep.get("storage_type") not in ("local", "sonarr", "scan") or not ep.get("storage_path"):
raise HTTPException(status_code=400, detail="Only local/sonarr/scanned episodes can be transcoded")
if ep.get("hls_status") in ("running", "pending"):
raise HTTPException(status_code=409, detail="Already transcoding or queued")
job = await _enqueue_transcode(episode_id, quality, triggered_by="manual", content_type="episode")
@@ -1591,7 +1606,7 @@ async def _run_music_enrich():
art_url = await asyncio.to_thread(musicbrainz_client.cover_art_url, release["mbid"])
if art_url:
res = await db.tracks.update_many(
{"artist": artist, "album": album},
{"artist": artist, "album": album, "album_art_url": {"$in": [None, ""]}},
{"$set": {"album_art_url": art_url}},
)
_music_enrich_status["updated"] += res.modified_count