Repository navigation
feat: v3 — multi-instance Radarr/Sonarr support, web UI redesign, setup wizard - #131
Merged
Merged
Conversation
Introduces RadarrInstance and SonarrInstance dataclasses and env var discovery. RADARR_URL remains the default instance; RADARR_4K_URL, RADARR_REMUX_URL etc. register additional instances automatically. Each instance gets its own webhook path derived from the name segment (RADARR_4K_URL -> /radarr_4k/webhook). Backward compatible — single-instance setups need no config changes.
Introduces InstanceRegistry — built at startup from config.radarr_instances and config.sonarr_instances. Tries DB client first per instance, falls back to API client. Lookup by instance name returns the right client for that instance. Missing name returns None rather than erroring.
Each movie and series row now carries an instance name derived from the webhook URL (e.g. 'radarr', 'radarr_4k'). Primary keys are updated to composite (imdb_id, instance) so the same title managed by two separate Radarr instances stores as two independent records without collision. Migration block handles existing installations: drops old FK before altering series, rebuilds all three PKs, then re-adds the FK from episodes to series on the new composite key. Existing records default to 'radarr' / 'sonarr'. All upsert, read, delete, and migrate methods updated with an instance parameter (defaults preserve backward compat for single-instance users). get_all_movie_records and get_all_series_records now include instance in the result set for use by library sync.
PathMapper now takes explicit root_folders and container_paths in its constructor — one per configured instance, built at startup rather than re-reading env vars on every call. Longest-prefix match avoids collisions when multiple root folders share a path prefix. InstanceRegistry builds and stores one PathMapper per Radarr and Sonarr instance alongside the existing clients. radarr_mapper(name) / sonarr_mapper(name) look up the right one for a given webhook. file_utils: replace the hasattr duck-typing for Radarr vs. Sonarr method names with a single path_mapper.map() call. The old method names remain as aliases on PathMapper for any other call sites.
Dynamic webhook routes are now registered at startup from the InstanceRegistry. Each configured Radarr/Sonarr instance gets its own route (e.g. /radarr_4k/webhook, /sonarr_anime/webhook). The legacy /webhook/radarr and /webhook/sonarr paths stay as backward-compat aliases for the default instances. The instance name flows from the URL into the webhook handler, into the batcher's webhook data dict, and into the processor's process_movie / process_series / process_webhook_episodes calls, which pass it to every DB write — completing the path from URL to instance column. MovieProcessor and TVProcessor now accept an optional pre-built client from InstanceRegistry and fall back to the env-var construction for single-instance setups. main.py builds the registry at startup and wires the default instance's client into each processor. should_skip_movie refactored from raw SQL to self.db.get_movie_dates (removes the dead SQLite branch). should_skip_series_fast and should_skip_series share a new _episode_completion_counts helper that filters by instance.
Feature/v3 phase2
…stry-based connection status process_season, process_episode, async_process_series, and async_batch_episode_processing now accept instance param (default sonarr) and pass it through all DB writes. test_database_connections refactored to use the registry built at startup — iterates over configured instances instead of reading env vars directly. initialize_components accepts an optional registry so main() can build it once and share it.
InstanceRegistry now records connection status for every instance at build
time — {connected, method} for direct_db or api clients, {connected: False}
when no client is available.
radarr_status(name) / sonarr_status(name) expose per-instance status; the
bulk variants all_radarr_statuses() / all_sonarr_statuses() return all of
them. The /setup page will use these to display instance health without
re-testing connections.
test_database_connections() now reads from the registry status instead of
doing isinstance checks, and emits a plain-English summary warning when any
instance has no working client. Unused RadarrDbClient / SonarrDbClient
imports in main.py removed.
Adds a /setup page to the web interface showing every configured Radarr and Sonarr instance with its webhook URL and connection status. Users can copy the full URL directly from the page. New core endpoint GET /api/instances returns instance names, webhook paths (/radarr/webhook, /radarr_4k/webhook, etc.), connected flag, and client method (direct_db / api). Reads from the registry built at startup. Web container proxies /api/instances to the core and serves /setup at static/setup.html. The page pre-fills the base URL from the browser's current hostname (swapping port to 8080) so the user gets a usable URL without any manual input. All webhook URLs update live when the base URL field is edited.
Movies and TV series views now support instance filtering: Backend: - /api/movies and /api/series accept ?instance= query param; results are filtered to that instance when provided - instance column included in movie/series SELECT so it appears in responses - Series JOIN fixed: LEFT JOIN episodes e ON ... AND s.instance = e.instance ensures episode counts are per-instance in multi-instance setups - .env.example notes /setup page for webhook URL lookup Frontend: - Instance dropdown added to Movies and TV filters; populated from /api/instances (proxied from core); hidden for single-instance setups - Instance column added to both tables (shown only when >1 instance configured) - Setup link added to nav bar
Emby-DLL/CHANGELOG.md contains the full Emby plugin version history. The GET /emby/changelog endpoint on the core API serves this file as markdown/plain text so the plugin config page can link to it directly using the webhook URL the user has already configured.
feat: v3 Phase 3 — multi-instance UI, setup page, Emby changelog endpoint
…sode edits now scope correctly to the specific instance
…stances DatabasePopulator gains from_radarr_instance() and from_sonarr_instance() class methods that build the right DB or API client from a discovered instance config, bypassing the env-var-only from_env() path. populate_movies() and populate_tv_episodes() now accept an instance param and pass it through to every upsert_*, get_*, and mark_* DB call so records are tagged with the correct instance name. _populate_worker_process loops over config.radarr_instances and config.sonarr_instances rather than reading only RADARR_URL/SONARR_URL. Stats are merged across all instances before writing the status file.
- README: add multi-instance support to features, sidebar nav to web interface section, multi-instance env var pattern to configuration, instance-specific webhook URLs, DELETE /api/series endpoint, v3.0.0 changelog entry, Jellyfin plugin in privacy section - CHANGELOG: add core app section with v3.0.0 entry - QUICKSTART: update populate step to sidebar Tools (was Admin tab)
- correct webhook URLs: /webhook/radarr → /radarr/webhook (both files) - remove reference to docker-compose.dev.yml which does not exist - replace macOS-only 'open' command with plain prose - update troubleshooting to say 'Movies or TV Shows sections' not 'tabs' - add static/ to project structure listing
- .env.example: replace internal IP 192.168.255.50 with placeholder hostnames in all four instance blocks (radarr, sonarr, radarr_4k, sonarr_4k) - .env.secrets.example: remove stale NFO/v0.6.0 version references, add missing SONARR_DB_PASSWORD, add commented named-instance password and API key slots, clean up TVDB comment - docker-compose.yml.example: fix 'Admin → Populate Database' to sidebar Tools - README: add v2 to v3 upgrade section covering webhook aliases, database migration, and multi-instance opt-in
Feat/sidebar nav instances
start_web.py serves from chronarr-web/static/ not root static/ — the sidebar nav changes were only applied to root static/ so the web container was still showing the old interface
fix: sync sidebar nav static files to chronarr-web/static
- add Skipped Items stat card (was present in old UI, missing in v3) wired to movies_skipped/episodes_skipped from dashboard API - fix .chart-container CSS: simple-chart items were being centered inside a fixed-height flex box, appearing clipped to one side; add .simple-chart/.chart-item/.chart-label/.chart-value styles so the source list fills the card properly - sync changes to both static/ and chronarr-web/static/
JSON.stringify(title) produces double-quoted strings which break the onclick="..." HTML attribute — the browser ends the attribute value at the first quote character in the title, leaving truncated JS that throws SyntaxError: Unexpected end of input at char 37. Replace " with " in the rendered onclick so the HTML parser round-trips the string correctly before the JS executes.
Feat/sidebar nav instances
Each Radarr/Sonarr instance now defines its own paths variable
(RADARR_{NAME}_MOVIE_PATHS, SONARR_{NAME}_TV_PATHS). The global
MOVIE_PATHS and TV_PATHS are no longer required at startup; they
remain as optional fallbacks for single-instance setups.
When multiple instances share a global key in Docker env_file,
last-definition-wins silently drops all earlier values. Per-instance
vars are scoped to their block, so no instance can accidentally
overwrite another's paths.
- settings.py: _load_paths() no longer raises on missing global vars;
adds _warn_missing_instance_paths() to log clearly at startup if
any configured instance has no paths at all
- .env.example: MOVIE_PATHS → RADARR_MOVIE_PATHS / TV_PATHS →
SONARR_TV_PATHS; prominent warning in multi-instance blocks
- docker-compose.yml.example: per-instance SQLite volume mount
examples for all instance types
- README.md: multi-instance config section rewritten to show
complete per-instance blocks with paths and DB vars together
fix: per-instance paths — remove global MOVIE_PATHS/TV_PATHS requirement
sqlite3.connect() silently creates an empty file at the given path if it doesn't exist, so a broken or misconfigured volume mount previously looked like a successful connection to an empty database instead of failing. RadarrDbClient/SonarrDbClient now check the path exists before connecting. DatabasePopulator.from_radarr_instance/from_sonarr_instance no longer catch that failure and fall back to the API client when db_type is explicitly configured — they let it raise, since silent fallback degraded every date to a *_fallback source with no indication the DB access was broken. Only instances with no db_type at all use the API client. The per-instance populate worker now catches this per instance so one misconfigured instance fails loudly without aborting population for the other configured instances.
fix: Emby plugin date lookup and cross-instance path-scan isolation
Same root cause as the episode delete fix: DELETE /api/movies/{imdb_id}
and GET /api/debug/movie/{imdb_id}/raw called db.delete_movie()/
get_movie_dates() with no instance, silently defaulting to the radarr
instance. Deleting or debugging a movie that lives under a named
instance (e.g. radarr_strm) 404'd, because the lookup was always
scoped to the default instance's row instead.
Both endpoints now take an instance param; the Movies Database table's
Delete and Debug Data buttons thread the row's own instance through,
matching the Edit button which already did.
…scope fix: movie delete and debug-data lookup ignored instance
Network/FUSE-backed mounts (e.g. decypharr's DFS backend for debrid/ usenet libraries) can report a just-created file as briefly unreadable (ENOTCONN) for a few seconds right after it's symlinked in, while the mount's own backing downloader for that file finishes initializing. That's a timing race in the mount layer, not a real missing-file condition, but it was failing the whole webhook outright on the first hit - confirmed live against a real decypharr mount (a movie import failed with [Errno 107] Transport endpoint is not connected seconds after decypharr's own log said the file was ready). Added list_video_files_with_retry() to utils/file_utils.py: retries the whole directory listing + stat pass on OSError instead of letting it propagate immediately. Starting point: 5 attempts, 3s apart (~15s of patience before giving up, on top of the existing 5s batch delay). Re-raises the last error once retries are exhausted, so a genuinely broken mount still fails loudly rather than being silently treated as "no video files found". Applied at both places that scan a directory for video files as part of webhook processing: MovieProcessor.process_movie()'s has_video check and TVProcessor.process_webhook_episodes()'s episode-file matching. Verified with a mocked Path.iterdir(): recovers after transient failures within the retry budget, and still raises once persistent failures exhaust it.
fix: retry on transient filesystem errors during webhook processing
…n the web UI
Readability:
- LOG_LEVEL is now configurable (default INFO, was hardcoded DEBUG) -
applies to both the file logger and the console print() that
docker compose logs actually shows, which wasn't gated by level at
all before. Set LOG_LEVEL=DEBUG to get the old verbosity back.
- The full Radarr webhook payload (a multi-KB JSON blob) is no longer
dumped at DEBUG by default - replaced with a compact one-line
summary (title, imdb, year, path). Set LOG_RAW_WEBHOOK_PAYLOADS=true
to get the old full-dump behavior back for deep-debugging a specific
webhook.
- Every webhook now gets a short request id (6 hex chars) at receipt,
tagged alongside the instance name on every log line through the
synchronous receive/validate path in api/routes.py and the
batch/process boundaries in webhooks/webhook_batcher.py (e.g.
[radarr_strm:a3f9c1]) - makes it possible to tell which log lines
belong to the same webhook when several land close together. Not
threaded into the processors' own internal methods (would roughly
double tonight's diff for one line of tagging depth); the instance
tag alone still applies there.
Web UI log viewer (Tools tab):
- GET /api/logs lists the current log file and its rotated backups
(size, modified time).
- GET /api/logs/{filename}/tail?lines=N returns the last N lines as
JSON, for an in-browser tail view with manual refresh and an
optional 5s auto-refresh.
- GET /api/logs/{filename}/download serves the raw file.
- All three live on chronarr-core (the only container with LOG_DIR
mounted) and are proxied through chronarr-web the same way the
wizard's env-backup download already is. Every filename is checked
against a whitelist regex before touching the filesystem.
Verified: LOG_LEVEL gating (DEBUG suppressed by default, shown with
LOG_LEVEL=DEBUG), the log-file regex accepts only chronarr.log(.N)?
and rejects path traversal attempts, tail-from-file logic returns the
correct last N lines, and all three endpoints work end-to-end
(list/tail/download) including a live 404 on a path-traversal or
non-whitelisted filename attempt.
Feat/readable logs and log viewer
The Tools tab crammed five unrelated cards (bulk source update, manual scan, database stats, populate database, log viewer) into one grid, so a five-field form and a full log tail view were fighting each other for space - the log viewer in particular was unreadable. Tools is now a sidebar dropdown group, same pattern as Movies/TV Series, with each tool getting its own full tab-content page: - tool-bulk-source, tool-manual-scan, tool-stats, tool-populate, tool-logs - New selectToolsPage() mirrors selectMovieInstance()/selectTvInstance() for sidebar active-state handling - switchTab()'s per-tab load dispatch and the log-tail auto-refresh guard updated for the new tab ids (was gated on the single 'tools' id) - Log viewer page gets a taller tail view (65vh) now that it isn't sharing a grid cell with four other cards Old .tools-grid/.tool-card CSS replaced with .tool-page (single-column, capped width for the form-only tools, uncapped via .tool-page-wide for the log viewer). static/ synced to match chronarr-web/static/ per the existing dual-copy requirement. Frontend-only change, no Python touched. Verified: node --check on app.js, and every sub-tool-* sidebar id has a matching tool-* tab-content id. Not yet checked in a live browser - do that before merging.
feat: split Tools into its own sidebar dropdown, one page per tool
refreshLogTail() unconditionally did output.scrollTop = output.scrollHeight on every refresh, including the 5s auto-refresh timer. Scrolling up to read older lines got undone every single cycle - there was no way to actually read anything with auto-refresh on. Now only auto-scrolls to the bottom if the viewer was already there (or within 24px of it) before the refresh. If you've scrolled up, a refresh updates the content but leaves your scroll position alone. Verified: node --check on app.js. Same live-browser caveat as the rest of this branch - worth confirming by hand before merging.
fix: log tail auto-refresh yanked the viewer back to bottom every cycle
Populate Database was running entirely inside chronarr-web, which has no LOG_DIR volume mount - every _log() call DatabasePopulator made went to a path invisible on the host and lost on restart. docker compose logs showed it live (print() doesn't care about mounts), but the persisted chronarr.log never got it. Moved the real implementation (_populate_worker_process, populate_database, get_populate_status, multi-instance handling, status file) into api/routes.py (chronarr-core, which has the mount); chronarr-web's populate endpoints are now thin proxies, same pattern /manual/scan already used. Emby/Jellyfin plugin lookups (/api/lookup/episode, /api/lookup/movie - same code path for a scheduled full-library scan and a single real-time item add) only logged at DEBUG or on error. Since LOG_LEVEL now defaults to INFO, a normal lookup produced zero lines on Chronarr's side - only the plugin's own Emby/Jellyfin-side log showed it happened. Added one INFO line per lookup (found/not-found + source), matching the convention webhooks already use. Verified: both files parse and import cleanly, function signatures match their call sites, no dangling references to removed globals. Not yet tested against a real running container - needs an actual populate run and a real plugin scan to confirm chronarr.log gets the lines this time.
fix: two log-visibility gaps found while testing the log viewer
Emby/Jellyfin plugin lookups only ever logged an IMDb ID, and a
not-found line gave no way to tell whether Chronarr has never seen
that title at all or has a row for it that just hasn't resolved a
date yet - one of those two is a real thing to go check, the other
is more likely a date-pipeline bug worth investigating.
- lookup_movie/lookup_episode now log a title when the DB has one
(movies.title, or series.metadata['title'] for episodes via a new
get_series_title_any_instance helper), and tag not-found lines with
reason=no_db_record vs reason=no_resolved_date.
- Every not-found lookup is also persisted to a new unresolved_lookups
table (upserted on imdb_id/media_type/season/episode, bumping
miss_count/last_seen on repeat misses) instead of only existing as a
log line that scrolls past during a full-library scan. A later
successful lookup for the same item clears its row automatically.
- New GET /api/unresolved-lookups and POST
/api/unresolved-lookups/{id}/dismiss endpoints (core), proxied
through chronarr-web same as the existing log endpoints.
- New Tools > Unresolved Lookups page: IMDb-linked table of misses
with reason/instance/miss count/last seen and a per-row Dismiss
(snoozes a row; it resurfaces if the same item misses again later).
feat: title and reason on plugin lookup misses, unresolved lookups page
get_series_title_any_instance() read series.metadata['title'] on the assumption TVProcessor's sync code sets it - it doesn't, every upsert_series() call site omits the metadata kwarg, so the column is always NULL and every episode lookup's title came back empty, including on 'found'. Real production logs from PR #126 confirmed this live. Derive the title from the series directory name instead (path is always set), using the existing extract_title_from_directory_name() helper - the same '<Title> [imdb-ttXXXXXXX]' convention the directory scan in find_media_path_by_imdb_and_title() already relies on.
…oads #126 shipped loadUnresolvedLookups() and the new switchTab() case for it, but never bumped the ?v= on the app.js <script> tag. Browsers (and any edge cache in front of the stack) kept serving the pre-#126 cached bundle, which has neither the function nor the case - so opening Tools > Unresolved Lookups just sat on the static 'Loading...' placeholder from the HTML forever, with no network request ever firing and nothing to see in chronarr's own logs because the request never reached it.
fix: series title fix that missed #126, and stale-cached app.js
…lk dismiss - populate_movies/populate_tv_episodes now track added_items alongside the existing skipped_items (same shape/convention), merged through the multi-instance worker and logged the same way (first 20 + '...and N more'). Surfaced in the Populate Database results panel as collapsed <details> lists for both Added and Skipped - previously only counts were shown even though skipped_items was already being collected and thrown away. - The 'exists but updating file info' log lines (movies and episodes) now include the title - they only ever printed the raw file path before. - lookup_movie's title now falls back to parsing the movie's directory name (same '[imdb-ttXXXXXXX]' convention as the series-title fix) when movies.title is NULL - a row created by the bare upsert_movie() path (still waiting on a date) never gets a title until the date-resolution pipeline runs, so 'in DB, no date yet' misses were showing as unknown even though a usable title was sitting right there in the path. - Unresolved Lookups: added a Dismiss All button (dismiss_all_unresolved_lookups() + POST /api/unresolved-lookups/dismiss-all, proxied same as the per-row dismiss) - dismissing a long list one row at a time wasn't practical.
Real root cause of the flood of 'not found' movie lookups: libraries
checked off in Library Exclusions (e.g. a 4K/VOD library) were being
scanned anyway. The config page saves a library's ID in the dashed
GUID format Emby's Library/VirtualFolders API returns; the real-time
sync and scheduled task compare against ILibraryManager's own IDs
formatted with no dashes. Those never matched, so every exclusion
checkbox was silently a no-op regardless of which library it was.
Fixed in PluginConfiguration.GetExcludedLibraryIdSet() by normalizing
every stored ID through Guid.TryParse/ToString('N') before the
comparison set is built, so it no longer matters which format either
side hands us.
DLL built from chronarr-emby-plugin @ fix/library-exclusion-guid-mismatch,
version bumped 2.0.20 -> 2.0.21 (all 4 locations), SHA256 verified
matching the built artifact.
feat: populate added/skipped lists, movie title fallback, Emby plugin 2.0.21 exclusion fix
Sonarr can re-map a series' IMDb ID after the fact - common for very new/upcoming titles whose metadata hasn't settled on a canonical ID yet. series' PK is (imdb_id, instance), so a changed ID just created a second row for the same show on the next sync, silently forking its episode history across two entries (user-reported, reproduced: same show, same path, two imdb_ids, 2 episodes under one and 1 under the other). upsert_series() now checks first whether a series already exists at this (instance, path) under a different imdb_id - the path is the stable signal here, it doesn't change just because a metadata ID got corrected - and if so merges the two the same way the existing manual migrate_series_imdb_id() does: episodes move to the new ID, any season/episode that collides between the two old/new rows (a genuine clash, not just happenstance) is dropped rather than crashing on the primary key, and the stale series row is removed. Self-heals existing duplicates on their next sync, no manual action needed. Scoped to TV only for now - movies have the same underlying risk (Radarr can remap an IMDb ID too) but two separate write paths with different signatures, only one of which has a path to match on; left as a deliberate follow-up rather than folding it in here.
The DLL merged in PR #128 was the first, incomplete v2.0.21 build (GUID dash-format normalization only, SHA256 bf6bca18...) - real root cause turned out to be config.js capturing the wrong VirtualFolderInfo field (Id, a legacy numeric string on some Emby versions, not ItemId or the actual Guid field). Fixed and confirmed live against a real server (excluded library -> excluded=True, non-excluded -> excluded=False) at v2.0.22, SHA256 de996550... - see chronarr-plans/v3-plan.md Part 7 for the full story. Swapping in the DLL that actually works.
…-emby-dll feat: auto-migrate series IMDb ID changes + verified Emby plugin 2.0.22 DLL
PR #129 bundled 2.0.22 before the NFOG-/CHRO- license key prefix fix was found and added on top of it (same DLL version, no bump needed - pure backend logic). This is the DLL the user has confirmed working live for both the library-exclusions fix and the license fix. SHA256 e0b5830c... (was de996550...).
fix: update bundled Emby plugin DLL with license checksum fix
# Conflicts: # Emby-DLL/Chronarr.Emby.Plugin.dll
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Promotes
feature/v3todev— 67 commits of work developed and iterated on this branch over ~6 weeks, beta-tested live by two users (myself in production, plus a second real dual-instance setup).What's in this
(imdb_id, instance)..env/.env.secretsgeneration, per-instance connection testing, env backup/restore.LOG_LEVEL, per-webhook request IDs, compact webhook payload summaries, in-browser log tail/download (Tools tab)..env.example, anddocker-compose.yml.exampleupdated throughout; no internal IPs/hostnames.Testing
docker-compose.local-test.yml— clean startup, no traceback, health endpoint responds.Not included here
main(dead code, not user-facing, kept separate per the repo's cleanup-pacing convention).chronarr-license-server), independent timeline.Next steps after this merges
dev→mainwith a VERSION bump to2.1.0, once smoke-tested again atdev's new tip.