- itunes.py: zero-auth fallback for album artwork when MusicBrainz/Cover Art
Archive has no match — kicks in automatically during music enrichment,
toggleable in Settings (on by default, no key required).
- acoustid.py: audio fingerprint identification (Chromaprint fpcalc + the
AcoustID API) for tracks with missing/unknown artist or album — nothing
to text-search on, so this identifies from the actual audio content
instead. Requires a free user-supplied API key from acoustid.org/api-key,
off by default. New /music/identify endpoint + admin trigger button.
- Dockerfile: added libchromaprint-tools for fpcalc.
- Settings page: new Music Metadata & Artwork section with both toggles.
Host CPU is an old Xeon E5530 (2010-era, no hardware encode available at all)
already fairly loaded, so raw compute is the real bottleneck. Two changes:
- Cap ABR renditions to the 2 highest applicable instead of up to 4 — roughly
halves total encode work per job while still giving a real high/low
adaptive-bitrate pair.
- veryfast -> superfast x264 preset, a meaningful further speed win at the
cost of somewhat larger files and slightly softer quality per bitrate.
Combined, remaining variants also get more threads each since fewer variants
now share the same TRANSCODE_THREADS budget.
The main queue list sorts by created_at desc and caps at 500 rows, but the FIFO
worker processes oldest-first — with a large backlog the actually-running job
was routinely outside that window and never reached the frontend at all.
Fetch it via a dedicated /transcode/queue/running endpoint instead.
-progress pipe:1 -nostats belong to ffmpeg, not nice — they were landing right
after cmd[0] (nice) instead of after the ffmpeg token, which made nice choke
on an unrecognized option and fail before ffmpeg ever started.
- ffmpeg progress streamed via -progress pipe:1, persisted on the job as a percent
- retry/retry-all now mark the old row as superseded instead of deleting it,
preserving the failed/cancelled audit trail
- frontend shows the running job in its own panel above the list with a live
progress bar instead of burying it as just another row
New backend/VERSION file (starts at 1.0.0) served via GET /api/version,
displayed as a small "v1.0.0" next to the StreamHoard wordmark. Going
forward I'll bump this on meaningful changes -- patch for fixes, minor
for new features, major for big/breaking redesigns.
Root cause of "movies have no volume control / can't hear anything":
0 of 200 movies had finished transcoding, so every movie streamed its
raw source file — and BDRip/AVI sources very commonly carry AC3/DTS/EAC3
audio, which no browser can decode natively. Browsers respond by hiding
the volume control entirely when they can't find a playable audio track,
which is exactly the symptom reported.
New "audio" transcode quality: video is stream-copied untouched, only
the audio track is re-encoded to AAC — dramatically faster than a full
ABR pass since no video re-encoding happens. Added:
- transcode_audio_fix() in transcode.py
- "audio" as a valid quality value everywhere quick/abr were validated
- POST /transcode/queue/fix-audio-all: cancels every still-pending job
and replaces it with the fast audio fix for the same content (running
jobs are left alone), so a library-wide backlog of slow ABR jobs can
be swapped for something that actually restores sound soon
- Per-movie "Audio" button in Admin, "Fix Audio (Fast, All)" button on
the Transcode Queue page
Note: this doesn't help HEVC/x265 sources, whose video (not just audio)
isn't natively browser-playable either — those still need a full ABR
re-encode to H.264.
- Library Cleanup scan is now a real background job (POST .../start,
GET .../status, POST .../stop) instead of one long synchronous request,
so a Stop button can actually abandon it instead of just waiting.
Frontend confirms before starting (explains it can take a minute+)
and polls status while running.
- New shared AlbumArt component: shows real art when available, otherwise
a branded "Streamhoard" placeholder instead of a bare icon in an empty
box. Used in the Music grid, album detail header, and the player bar —
swaps to real art automatically once enrichment/a manual edit sets it.
- Navbar: drop the Users link (already reachable from Admin's quick-links)
- Admin quick-links: drop Settings (already reachable from the navbar
for every user)
- library_cleanup.py: parallelize the missing-file stat checks across a
small thread pool instead of one file at a time — sequential NAS round
trips were taking minutes for a library of any real size, even after
moving them off the event loop.
scan() was doing hundreds of blocking Path.is_file()/stat() calls directly
in an async function -- movie/episode/track files often live on the
NFS/CIFS-mounted NAS, so each stat call has real network latency. Since
the backend runs a single-threaded asyncio event loop, this froze every
other request (confirmed live: a plain GET /api/movies hung for 60s+ while
a cleanup scan was in flight) for as long as the scan took.
Moved every filesystem-touching check (missing files, orphaned HLS dirs)
into a plain sync function run via asyncio.to_thread, so the scan can no
longer block anything else.
- 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.
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