- 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).
Fixing a missing-file or duplicate-import episode only deleted the
episode itself, leaving its episode_progress/subtitle rows behind for
the next scan to catch as separate orphaned_subtitles/dangling findings.
Consolidated movie/episode/track deletion (with all their dependent rows)
into one _delete_content() helper, used consistently by missing_files,
orphaned_episodes, and duplicate_paths.
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.
Setting -threads:v:i to the full TRANSCODE_THREADS on every variant let
each of the (up to 4) ABR renditions ask for the full budget independently
-- confirmed live on CT104: ffmpeg was still pulling ~7 of 8 cores despite
the "cap". Now divides the budget across however many variants are in
this job (6 threads / 4 variants = ~1-2 each), so the total stays near
the intended cap regardless of source resolution.
- nice level 19 -> 10 (was the absolute lowest CPU priority) and cap
ffmpeg to 6 threads on this 8-core host, leaving headroom for Mongo,
the *arr stack, and the API itself instead of an unbounded encode.
- Add POST /transcode/queue/retry-failed to re-queue every failed/
cancelled job at once; label the per-job Retry button with text
(was icon-only) and clarify in the page copy that failed/cancelled
jobs never delete the movie — retry just re-queues the same file.
New read-only scan (GET /api/library/cleanup/scan) across movies, episodes,
tracks, subtitles, HLS output, transcode queue, and watchlist/progress rows:
- Missing files: DB record exists but the file is gone from disk
- Orphaned episodes: show_id no longer exists
- Orphaned HLS output: transcoded folders left behind by deleted content
- Stuck transcode jobs: "running" 2+ hours with no completion (backend-restart artifact)
- Duplicate imports: same file imported into the library more than once
- Orphaned subtitles / dangling watchlist+progress rows
Nothing is deleted until an admin clicks Fix (per-item or Fix All per
category) via POST /api/library/cleanup/fix.
Switching tabs quickly (e.g. Movies -> Music) had no guard against an
in-flight response from the previous tab resolving late and overwriting
the new tab's results — showing movie/TV rips under the Music tab. Now
tracks a request sequence number and ignores stale responses, and clears
results immediately on tab switch so nothing renders under the wrong label.
Admin can now Approve a pending request as Movie or TV, which looks up
the title via Radarr/Sonarr's own lookup API, adds the best match with
search-on-add enabled, and tracks the resulting radarr_id/sonarr_id on
the request. A live status line (searching/queued/downloading/downloaded,
polled every 15s) shows next to each approved request.
mimetypes on the backend image doesn't recognize these extensions, so
every non-mp3 track streamed as audio/mpeg — wrong codec hint, breaks
playback in strict browsers. Add an explicit fallback map.
Reading embedded ID3/MP4 tags for every file during the scan itself was
taking minutes even for a small (146-track) library, since each mutagen
open is a slow round trip over the CIFS-mounted NAS share. Scan now uses
fast filename/path parsing only; tag reads happen once, only for the
tracks actually selected for import.
- New admin Library Scan page walks the NAS mounts directly for movies,
TV, and music not already in Radarr/Sonarr/local storage, and imports
them with storage_type=scan (fully integrated: streamable, transcodable,
listed in Admin) — Radarr/Sonarr import flows are untouched.
- New Music section (nav tab, album-grid browse, per-album track list,
persistent bottom player with queue/seek) backed by a Track model and
range-request audio streaming endpoint.
- MusicBrainz + Cover Art Archive integration (free, no API key) to fill
in album art for scanned tracks, paced to their 1 req/sec rate limit.
- Mounted the NAS music share into CT104 (new mp2 LXC mountpoint,
CIFS read-only) alongside the existing video mount.
- Optional Bluetooth/output-device picker on the player bar via the Audio
Output Devices API (setSinkId) for already-paired speakers — Chrome/Edge
only, since browsers can't pair new BT devices directly.
- Removed the "Who's Watching" sub-profile picker entirely: ProfileProvider now
auto-resolves the single profile tied to the account immediately after login
- Backend: removed POST/DELETE /profiles (no more creating/deleting extra
profiles — lifecycle is entirely driven by user create/delete, already wired
in the admin Users CRUD); kept GET (internal) and PATCH (edit your own
profile's avatar/kids-mode/rating cap)
- Deleted ProfileSelect page and the now-unused ProtectedRoute component
- Navbar: removed the "switch profile" action, account avatar is now just a
static display
- api.js: dropped the now-dead X-Profile-Id header logic (backend already
falls back to the account's single profile when the header is absent)
- New /admin/users page: create/delete users, toggle admin role, guarded against
self-demotion/self-deletion
- Backend: GET/POST/PATCH/DELETE /api/admin/users, admin-only
- Settings moved from /admin/settings to /settings (all logged-in users); the
password-change section shows for everyone, integration/API-key sections only
render (and only fetch) for admins, since those endpoints stay admin-gated
- Navbar: Settings link now visible to all users; added Users nav link for admins