From 3bb4b76aba604412c173e6badea219e809197e74 Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Tue, 18 Aug 2026 16:46:46 -0400 Subject: [PATCH 01/45] feat: named instance config for Radarr and Sonarr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 250 ++++++++++++++++++++++++++++----------------- config/settings.py | 210 ++++++++++++++++++++++++++++++++++--- 2 files changed, 352 insertions(+), 108 deletions(-) diff --git a/.env.example b/.env.example index 7244df9..7537f42 100644 --- a/.env.example +++ b/.env.example @@ -1,172 +1,230 @@ # =========================================== -# Chronarr Configuration - CORRECTED VERSION +# Chronarr Configuration # =========================================== -# Main configuration file - safe to share for debugging -# Sensitive data (API keys, passwords) are in .env.secrets +# Copy this file to .env and fill in your values. +# Sensitive values (API keys, passwords) belong in .env.secrets — keep that file out of git. +# +# WEBHOOK URLS for your Radarr/Sonarr instances: +# Single instance (default): http://chronarr:8080/radarr/webhook +# http://chronarr:8080/sonarr/webhook +# Named instances: http://chronarr:8080/radarr_4k/webhook +# http://chronarr:8080/sonarr_4k/webhook +# (Webhook URLs are built automatically from instance names in this file) # =========================================== -# MEDIA PATHS (REQUIRED) - FIXED +# CHRONARR DATABASE # =========================================== -# Container paths (what Chronarr sees inside container) -MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6 -TV_PATHS=/media/TV/tv,/media/TV/tv6 +# Internal Chronarr database — stores your media dates and metadata. +# PostgreSQL is strongly recommended for production. -# Radarr paths (what Radarr sees on the host system) -RADARR_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies,/mnt/unionfs/Media/Movies/movies6 - -# Sonarr paths (what Sonarr sees on the host system) - FIXED VARIABLE NAME AND PATHS -SONARR_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv,/mnt/unionfs/Media/TV/tv6 +DB_TYPE=postgresql +DB_HOST=chronarr-db +DB_PORT=5432 +DB_NAME=chronarr +DB_USER=chronarr +# DB_PASSWORD= → set in .env.secrets -# Download detection paths (for identifying downloads vs existing files) -DOWNLOAD_PATH_INDICATORS=/downloads/,/nzbget/,/completed/,/sabnzbd/,/torrents/ +# SQLite alternative (not recommended for production): +# DB_TYPE=sqlite +# DB_PATH=/app/data/media_dates.db # =========================================== # SERVER CONFIGURATION # =========================================== -# Timezone for container logging and timestamps TZ=America/New_York -# Chronarr Core API (webhooks, processing, database management) +# Core API — handles webhooks and processing CORE_API_HOST=0.0.0.0 CORE_API_PORT=8080 -# Chronarr Web Interface (dashboard, series/movie management) -WEB_API_HOST=0.0.0.0 -WEB_API_PORT=8081 - -# External port where web interface is accessible (for dynamic port reference) +# Web dashboard +WEB_HOST=0.0.0.0 +WEB_PORT=8081 +WEB_WORKERS=1 WEB_EXTERNAL_PORT=8081 # =========================================== -# DATABASE CONFIGURATION +# RADARR — MAIN INSTANCE # =========================================== -# Chronarr Database Configuration (PostgreSQL - v2.6+) -# NOTE: For new installations, PostgreSQL is strongly recommended -# Set these values in .env.secrets for security: -# - DB_PASSWORD=your_secure_password -# - DB_USER=chronarr (default) - -# PostgreSQL Database Settings -DB_TYPE=postgresql -DB_HOST=chronarr-db # Container name from docker-compose -DB_PORT=5432 -DB_NAME=chronarr -DB_USER=chronarr - -# Legacy SQLite Configuration (Deprecated in v2.6+) -# Only use for existing SQLite installations -# DB_PATH=/app/data/media_dates.db +# Paths: RADARR_ROOT_FOLDERS is what Radarr sees on the host (or UNC path on Windows). +# MOVIE_PATHS is what Chronarr sees inside the container. +# Both are comma-separated and positionally paired. -# Log file directory -LOG_DIR=/app/data/logs +RADARR_URL=http://radarr:7878 +# RADARR_API_KEY= → set in .env.secrets +# RADARR_WEBHOOK_SECRET= → set in .env.secrets (optional, for webhook validation) +RADARR_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies +MOVIE_PATHS=/media/movies -# =========================================== -# RADARR DATABASE CONNECTION (RECOMMENDED) -# =========================================== -# Direct database access for better performance +# Radarr database (direct DB access — faster than API for large libraries) RADARR_DB_TYPE=postgresql RADARR_DB_HOST=192.168.255.50 RADARR_DB_PORT=5432 RADARR_DB_NAME=radarr-main RADARR_DB_USER=postgres +# RADARR_DB_PASSWORD= → set in .env.secrets -# Alternative: SQLite (if Radarr uses SQLite) +# SQLite alternative: # RADARR_DB_TYPE=sqlite # RADARR_DB_PATH=/path/to/radarr.db # =========================================== -# SONARR DATABASE CONNECTION (RECOMMENDED) +# RADARR — ADDITIONAL INSTANCES (OPTIONAL) +# =========================================== +# Add a block per extra Radarr instance. The name between RADARR_ and _URL +# becomes part of the webhook URL: RADARR_4K_URL → /radarr_4k/webhook +# Names must be uppercase letters and underscores only. + +# RADARR_4K_URL=http://radarr4k:7878 +# RADARR_4K_API_KEY= → set in .env.secrets +# RADARR_4K_WEBHOOK_SECRET= → set in .env.secrets (optional) +# RADARR_4K_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies4k +# RADARR_4K_MOVIE_PATHS=/media/movies4k +# RADARR_4K_DB_TYPE=postgresql +# RADARR_4K_DB_HOST=192.168.255.50 +# RADARR_4K_DB_PORT=5432 +# RADARR_4K_DB_NAME=radarr-4k +# RADARR_4K_DB_USER=postgres +# RADARR_4K_DB_PASSWORD= → set in .env.secrets +# SQLite: RADARR_4K_DB_TYPE=sqlite / RADARR_4K_DB_PATH=/path/to/radarr4k.db + +# =========================================== +# SONARR — MAIN INSTANCE # =========================================== -# Direct database access for better performance -# Phase 7: High-performance TV episode import date detection +SONARR_URL=http://sonarr:8989 +# SONARR_API_KEY= → set in .env.secrets +# SONARR_WEBHOOK_SECRET= → set in .env.secrets (optional) +SONARR_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv +TV_PATHS=/media/tv + +# Sonarr database SONARR_DB_TYPE=postgresql SONARR_DB_HOST=192.168.255.50 SONARR_DB_PORT=5432 SONARR_DB_NAME=sonarr-main SONARR_DB_USER=postgres +# SONARR_DB_PASSWORD= → set in .env.secrets -# Alternative: SQLite (if Sonarr uses SQLite) +# SQLite alternative: # SONARR_DB_TYPE=sqlite # SONARR_DB_PATH=/path/to/sonarr.db # =========================================== -# API CONNECTIONS (OPTIONAL) +# SONARR — ADDITIONAL INSTANCES (OPTIONAL) # =========================================== -# API keys are stored in .env.secrets for security -RADARR_URL=http://radarr:7878 -SONARR_URL=http://sonarr:8989 +# SONARR_4K_URL=http://sonarr4k:8989 +# SONARR_4K_API_KEY= → set in .env.secrets +# SONARR_4K_WEBHOOK_SECRET= → set in .env.secrets (optional) +# SONARR_4K_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv4k +# SONARR_4K_TV_PATHS=/media/tv4k +# SONARR_4K_DB_TYPE=postgresql +# SONARR_4K_DB_HOST=192.168.255.50 +# SONARR_4K_DB_PORT=5432 +# SONARR_4K_DB_NAME=sonarr-4k +# SONARR_4K_DB_USER=postgres +# SONARR_4K_DB_PASSWORD= → set in .env.secrets # =========================================== -# RELEASE DATE PROCESSING +# EXTERNAL APIs (OPTIONAL) # =========================================== -# Priority order for release date fallbacks (digital, physical, theatrical) +# Jellyseerr — for request tracking +# JELLYSEERR_URL=http://jellyseerr:5055 +# JELLYSEERR_API_KEY= → set in .env.secrets + +# TMDB — for release date and metadata lookup +# TMDB_API_KEY= → set in .env.secrets +TMDB_COUNTRY=US +# TMDB_TYPE_PRIORITY=movie,tv + +# TVDB — for TV episode air dates +# TVDB_API_KEY= → set in .env.secrets +# DISABLE_TVDB=false + +# OMDB — fallback metadata source +# OMDB_API_KEY= → set in .env.secrets + +# =========================================== +# MOVIE PROCESSING +# =========================================== +# Priority for determining which date to use +# Options: import_then_digital, digital_only, theatrical_only +MOVIE_PRIORITY=import_then_digital + +# Order to check release date types RELEASE_DATE_PRIORITY=digital,physical,theatrical -#RELEASE_DATE_PRIORITY=digital,theatrical,physical -ENABLE_SMART_DATE_VALIDATION=true -MAX_RELEASE_DATE_GAP_YEARS=10 -# Prefer API release dates over file modification dates for manual imports +# Prefer API release dates over file modification dates PREFER_RELEASE_DATES_OVER_FILE_DATES=true -# Disable file date fallback completely (recommended for clean imports) +# Allow file modification date as a last-resort fallback ALLOW_FILE_DATE_FALLBACK=false -# TMDB country for regional release date preferences -TMDB_COUNTRY=US +# How to handle existing dates in the database +# Options: backfill_only (fill missing), overwrite (always update) +MOVIE_DATE_UPDATE_MODE=backfill_only +# When to poll for movie date data +# Options: always, missing_only, never +MOVIE_POLL_MODE=always + +# Smart date validation — rejects dates that seem implausible +ENABLE_SMART_DATE_VALIDATION=true +MAX_RELEASE_DATE_GAP_YEARS=10 + +# Remove movies from DB that have been missing for N days (0 = disabled) +PURGE_MISSING_MOVIES_DAYS=0 # =========================================== -# PROCESSING BEHAVIOR +# TV PROCESSING # =========================================== -# Movie date update strategy -MOVIE_DATE_UPDATE_MODE=overwrite -MOVIE_PRESERVE_EXISTING=false - -# When to update existing dates (always, missing_only, never) -UPDATE_MODE=always +TV_WEBHOOK_PROCESSING_MODE=targeted -# File modification time behavior (update, leave_alone) -MTIME_BEHAVIOR=update +# Season directory naming — must match your actual folder names +TV_SEASON_DIR_FORMAT=Season {season:02d} +TV_SEASON_DIR_PATTERN=season -# Manual scan priority: use existing NFO dates for speed vs check APIs for accuracy -# false (default) = Check external APIs first, use NFO as fallback (slower but accurate) -# true = Use NFO dates immediately without API checks (faster but may use wrong dates) -MANUAL_SCAN_PRIORITIZE_NFO=false +# Remove TV shows from DB that have been missing for N days (0 = disabled) +PURGE_MISSING_TV_DAYS=0 # =========================================== # PERFORMANCE & BATCHING # =========================================== -# Delay before processing batched events (seconds) BATCH_DELAY=5.0 - -# Maximum concurrent series processing MAX_CONCURRENT_SERIES=3 - -# Sequential processing delay for bulk downloads (seconds) -# When multiple episodes are downloaded at once, process them one by one with this delay -# Set to 0 to disable sequential processing (parallel mode) SEQUENTIAL_DELAY=20.0 - -# API timeout in seconds TIMEOUT_SECONDS=45 +# =========================================== +# WEB INTERFACE +# =========================================== +WEB_AUTH_ENABLED=false +# WEB_AUTH_USERNAME=admin +# WEB_AUTH_PASSWORD= → set in .env.secrets +WEB_AUTH_SESSION_TIMEOUT=3600 + +# Dashboard display +# APP_TITLE=Chronarr +# APP_SUBTITLE=Database Management & Reporting +PAGINATION_LIMIT=50 +REFRESH_INTERVAL=30 + # =========================================== # DEBUGGING # =========================================== -# Enable verbose logging (true/false) DEBUG=false - -# Enable path mapping debug output (true/false) PATH_DEBUG=false - -# Suppress TVDB API warnings (true/false) - TVDB failures are common and non-critical -SUPPRESS_TVDB_WARNINGS=true +LOG_DIR=/app/data/logs # =========================================== -# WEB INTERFACE AUTHENTICATION +# LEGACY — PENDING REMOVAL # =========================================== -# Enable web interface authentication (default: false) -WEB_AUTH_ENABLED=false - -# Session timeout in seconds (default: 3600 = 1 hour) -WEB_AUTH_SESSION_TIMEOUT=3600 \ No newline at end of file +# These settings controlled NFO file writing, which was the original mechanism +# for pushing dates into Emby/Jellyfin. The Emby/Jellyfin plugins now read +# directly from Chronarr's database, making this code path obsolete. +# These vars and the code behind them will be removed in a future release. +# Changing these values currently has no meaningful effect. +# +# MANAGE_NFO=true +# FIX_DIR_MTIMES=true +# LOCK_METADATA=true +# MANUAL_SCAN_PRIORITIZE_NFO=false diff --git a/config/settings.py b/config/settings.py index 86ae966..88c0697 100644 --- a/config/settings.py +++ b/config/settings.py @@ -5,6 +5,7 @@ import os import sys import logging +from dataclasses import dataclass, field from pathlib import Path from typing import List, Optional, Dict, Any @@ -14,6 +15,47 @@ logger = logging.getLogger(__name__) +# Reserved env var segments that are NOT instance names +_RADARR_RESERVED = {"DB", "WEBHOOK"} +_SONARR_RESERVED = {"DB", "WEBHOOK"} + + +@dataclass +class RadarrInstance: + """Configuration for one Radarr instance""" + name: str # "radarr" for default, "radarr_4k" for RADARR_4K_* + url: str + api_key: str + root_folders: List[str] + movie_paths: List[str] + webhook_path: str # e.g. "/radarr/webhook" or "/radarr_4k/webhook" + db_type: str = "" # "postgresql", "sqlite", or "" (API-only) + db_host: str = "" + db_port: int = 5432 + db_name: str = "" + db_user: str = "" + db_password: str = "" + db_path: str = "" # sqlite only + + +@dataclass +class SonarrInstance: + """Configuration for one Sonarr instance""" + name: str # "sonarr" for default, "sonarr_4k" for SONARR_4K_* + url: str + api_key: str + root_folders: List[str] + tv_paths: List[str] + webhook_path: str # e.g. "/sonarr/webhook" or "/sonarr_4k/webhook" + db_type: str = "" + db_host: str = "" + db_port: int = 5432 + db_name: str = "" + db_user: str = "" + db_password: str = "" + db_path: str = "" # sqlite only + + def _bool_env(name: str, default: bool) -> bool: """Convert environment variable to boolean""" v = os.environ.get(name) @@ -134,21 +176,165 @@ def _load_server_settings(self) -> None: def _load_external_connections(self) -> None: """Load external API and database connection settings""" - # API URLs - self.radarr_url = os.environ.get("RADARR_URL", "") - self.sonarr_url = os.environ.get("SONARR_URL", "") + # Discover all Radarr and Sonarr instances from env + self.radarr_instances: List[RadarrInstance] = self._discover_radarr_instances() + self.sonarr_instances: List[SonarrInstance] = self._discover_sonarr_instances() + + # Backward-compat shims — point at the first (default) instance if present + self.radarr_url = self.radarr_instances[0].url if self.radarr_instances else "" + self.sonarr_url = self.sonarr_instances[0].url if self.sonarr_instances else "" + self.radarr_db_type = self.radarr_instances[0].db_type if self.radarr_instances else "" + self.jellyseerr_url = os.environ.get("JELLYSEERR_URL", "") - - # Radarr database settings - self.radarr_db_type = os.environ.get("RADARR_DB_TYPE", "").lower() - self.radarr_db_host = os.environ.get("RADARR_DB_HOST", "") - self.radarr_db_port = self._get_int_env("RADARR_DB_PORT", 5432, 1, 65535) - self.radarr_db_name = os.environ.get("RADARR_DB_NAME", "") - self.radarr_db_user = os.environ.get("RADARR_DB_USER", "") - + # Timeout settings self.timeout_seconds = self._get_int_env("TIMEOUT_SECONDS", 45, 10, 300) - + + # ------------------------------------------------------------------ + # Instance discovery helpers + # ------------------------------------------------------------------ + + def _load_radarr_instance(self, name_segment: str, url: str) -> RadarrInstance: + """Build a RadarrInstance from env vars for the given name segment. + + name_segment is the part between RADARR_ and _URL, e.g. "4K" for + RADARR_4K_URL. Empty string means the default (RADARR_URL) instance. + """ + prefix = f"RADARR_{name_segment}_" if name_segment else "RADARR_" + instance_name = f"radarr_{name_segment.lower()}" if name_segment else "radarr" + webhook_path = f"/{instance_name}/webhook" + + root_folders = [ + p.strip() + for p in os.environ.get(f"{prefix}ROOT_FOLDERS", "").split(",") + if p.strip() + ] + movie_paths = [ + p.strip() + for p in os.environ.get(f"{prefix}MOVIE_PATHS", + os.environ.get("MOVIE_PATHS", "")).split(",") + if p.strip() + ] + + db_type = os.environ.get(f"{prefix}DB_TYPE", "").lower() + db_port_str = os.environ.get(f"{prefix}DB_PORT", "5432") + try: + db_port = int(db_port_str) + except ValueError: + db_port = 5432 + + return RadarrInstance( + name=instance_name, + url=url, + api_key=os.environ.get(f"{prefix}API_KEY", ""), + root_folders=root_folders, + movie_paths=movie_paths, + webhook_path=webhook_path, + db_type=db_type, + db_host=os.environ.get(f"{prefix}DB_HOST", ""), + db_port=db_port, + db_name=os.environ.get(f"{prefix}DB_NAME", ""), + db_user=os.environ.get(f"{prefix}DB_USER", ""), + db_password=os.environ.get(f"{prefix}DB_PASSWORD", ""), + db_path=os.environ.get(f"{prefix}DB_PATH", ""), + ) + + def _discover_radarr_instances(self) -> List[RadarrInstance]: + """Discover all configured Radarr instances from environment variables. + + Looks for RADARR_URL (default instance) and RADARR_{NAME}_URL (named + instances). The default instance is always first in the returned list. + """ + instances = [] + + # Default instance + default_url = os.environ.get("RADARR_URL", "") + if default_url: + instances.append(self._load_radarr_instance("", default_url)) + + # Named instances — scan for RADARR_*_URL + seen = set() + for key in sorted(os.environ.keys()): + if not key.startswith("RADARR_") or not key.endswith("_URL"): + continue + if key == "RADARR_URL": + continue + name_segment = key[len("RADARR_"):-len("_URL")] + # Skip reserved segments (DB, WEBHOOK) and duplicates + if name_segment in _RADARR_RESERVED or name_segment in seen: + continue + seen.add(name_segment) + url = os.environ[key] + if url: + instances.append(self._load_radarr_instance(name_segment, url)) + + return instances + + def _load_sonarr_instance(self, name_segment: str, url: str) -> SonarrInstance: + """Build a SonarrInstance from env vars for the given name segment.""" + prefix = f"SONARR_{name_segment}_" if name_segment else "SONARR_" + instance_name = f"sonarr_{name_segment.lower()}" if name_segment else "sonarr" + webhook_path = f"/{instance_name}/webhook" + + root_folders = [ + p.strip() + for p in os.environ.get(f"{prefix}ROOT_FOLDERS", "").split(",") + if p.strip() + ] + tv_paths = [ + p.strip() + for p in os.environ.get(f"{prefix}TV_PATHS", + os.environ.get("TV_PATHS", "")).split(",") + if p.strip() + ] + + db_type = os.environ.get(f"{prefix}DB_TYPE", "").lower() + db_port_str = os.environ.get(f"{prefix}DB_PORT", "5432") + try: + db_port = int(db_port_str) + except ValueError: + db_port = 5432 + + return SonarrInstance( + name=instance_name, + url=url, + api_key=os.environ.get(f"{prefix}API_KEY", ""), + root_folders=root_folders, + tv_paths=tv_paths, + webhook_path=webhook_path, + db_type=db_type, + db_host=os.environ.get(f"{prefix}DB_HOST", ""), + db_port=db_port, + db_name=os.environ.get(f"{prefix}DB_NAME", ""), + db_user=os.environ.get(f"{prefix}DB_USER", ""), + db_password=os.environ.get(f"{prefix}DB_PASSWORD", ""), + db_path=os.environ.get(f"{prefix}DB_PATH", ""), + ) + + def _discover_sonarr_instances(self) -> List[SonarrInstance]: + """Discover all configured Sonarr instances from environment variables.""" + instances = [] + + default_url = os.environ.get("SONARR_URL", "") + if default_url: + instances.append(self._load_sonarr_instance("", default_url)) + + seen = set() + for key in sorted(os.environ.keys()): + if not key.startswith("SONARR_") or not key.endswith("_URL"): + continue + if key == "SONARR_URL": + continue + name_segment = key[len("SONARR_"):-len("_URL")] + if name_segment in _SONARR_RESERVED or name_segment in seen: + continue + seen.add(name_segment) + url = os.environ[key] + if url: + instances.append(self._load_sonarr_instance(name_segment, url)) + + return instances + def _load_movie_settings(self) -> None: """Load movie processing settings""" self.movie_priority = os.environ.get("MOVIE_PRIORITY", "import_then_digital").lower() From fbc8454b79d302703236348edc48f537f53901bc Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Tue, 18 Aug 2026 16:51:51 -0400 Subject: [PATCH 02/45] feat: build one Radarr/Sonarr client per configured instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/instance_registry.py | 138 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 core/instance_registry.py diff --git a/core/instance_registry.py b/core/instance_registry.py new file mode 100644 index 0000000..bb8472a --- /dev/null +++ b/core/instance_registry.py @@ -0,0 +1,138 @@ +""" +Instance registry for Radarr and Sonarr clients. + +Builds one client per configured instance at startup and provides a central +lookup by instance name. Processors and webhook handlers resolve the right +client from here rather than each constructing their own from env vars. +""" +from typing import Dict, Optional, Union + +from clients.radarr_client import RadarrClient +from clients.radarr_db_client import RadarrDbClient +from clients.sonarr_client import SonarrClient +from clients.sonarr_db_client import SonarrDbClient +from config.settings import RadarrInstance, SonarrInstance +from core.logging import _log + +# Type aliases +AnyRadarrClient = Union[RadarrClient, RadarrDbClient] +AnySonarrClient = Union[SonarrClient, SonarrDbClient] + + +def _build_radarr_client(instance: RadarrInstance) -> Optional[AnyRadarrClient]: + """Return the best available client for a Radarr instance. + + Tries DB client first (faster, no API rate limits). Falls back to API + client if no DB is configured or the connection fails. + """ + if instance.db_type: + try: + if instance.db_type == "sqlite": + client = RadarrDbClient( + db_type="sqlite", + db_path=instance.db_path or None, + ) + else: + client = RadarrDbClient( + db_type=instance.db_type, + db_host=instance.db_host or None, + db_port=instance.db_port, + db_name=instance.db_name or None, + db_user=instance.db_user or None, + db_password=instance.db_password or None, + ) + _log("INFO", f"[{instance.name}] Using Radarr direct database access") + return client + except Exception as e: + _log("WARNING", f"[{instance.name}] Radarr DB connection failed, falling back to API: {e}") + + if instance.url and instance.api_key: + _log("INFO", f"[{instance.name}] Using Radarr API client") + return RadarrClient( + base_url=instance.url, + api_key=instance.api_key, + ) + + _log("WARNING", f"[{instance.name}] No Radarr client available — no DB or API configured") + return None + + +def _build_sonarr_client(instance: SonarrInstance) -> Optional[AnySonarrClient]: + """Return the best available client for a Sonarr instance.""" + if instance.db_type: + try: + if instance.db_type == "sqlite": + client = SonarrDbClient( + db_type="sqlite", + db_path=instance.db_path or None, + ) + else: + client = SonarrDbClient( + db_type=instance.db_type, + db_host=instance.db_host or None, + db_port=instance.db_port, + db_name=instance.db_name or None, + db_user=instance.db_user or None, + db_password=instance.db_password or None, + ) + _log("INFO", f"[{instance.name}] Using Sonarr direct database access") + return client + except Exception as e: + _log("WARNING", f"[{instance.name}] Sonarr DB connection failed, falling back to API: {e}") + + if instance.url and instance.api_key: + _log("INFO", f"[{instance.name}] Using Sonarr API client") + return SonarrClient( + base_url=instance.url, + api_key=instance.api_key, + ) + + _log("WARNING", f"[{instance.name}] No Sonarr client available — no DB or API configured") + return None + + +class InstanceRegistry: + """Central store of all Radarr and Sonarr clients, keyed by instance name.""" + + def __init__(self, radarr_instances, sonarr_instances): + self._radarr: Dict[str, AnyRadarrClient] = {} + self._sonarr: Dict[str, AnySonarrClient] = {} + + for inst in radarr_instances: + client = _build_radarr_client(inst) + if client: + self._radarr[inst.name] = client + + for inst in sonarr_instances: + client = _build_sonarr_client(inst) + if client: + self._sonarr[inst.name] = client + + _log("INFO", f"Instance registry: {len(self._radarr)} Radarr, {len(self._sonarr)} Sonarr") + + def radarr(self, name: str = "radarr") -> Optional[AnyRadarrClient]: + """Look up a Radarr client by instance name.""" + return self._radarr.get(name) + + def sonarr(self, name: str = "sonarr") -> Optional[AnySonarrClient]: + """Look up a Sonarr client by instance name.""" + return self._sonarr.get(name) + + def all_radarr(self) -> Dict[str, AnyRadarrClient]: + return dict(self._radarr) + + def all_sonarr(self) -> Dict[str, AnySonarrClient]: + return dict(self._sonarr) + + @property + def radarr_names(self): + return list(self._radarr.keys()) + + @property + def sonarr_names(self): + return list(self._sonarr.keys()) + + +def build_registry(config) -> InstanceRegistry: + """Build the instance registry from a ChronarrConfig object.""" + return InstanceRegistry(config.radarr_instances, config.sonarr_instances) From 7f6176288769c9a539575986d6cbef5b6ec3ace9 Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Fri, 21 Aug 2026 08:34:10 -0400 Subject: [PATCH 03/45] =?UTF-8?q?ci:=20add=20v3=20Docker=20build=20workflo?= =?UTF-8?q?w=20=E2=80=94=20pushes=20sbcrumb/chronarr:v3=20on=20feature/v3?= =?UTF-8?q?=20push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/v3.yml | 60 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/workflows/v3.yml diff --git a/.github/workflows/v3.yml b/.github/workflows/v3.yml new file mode 100644 index 0000000..47da256 --- /dev/null +++ b/.github/workflows/v3.yml @@ -0,0 +1,60 @@ +name: Build & Push V3 to DockerHub + +on: + push: + branches: [ feature/v3 ] + +permissions: + contents: read + +jobs: + docker: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Read VERSION + run: | + if [[ ! -f VERSION ]]; then echo "VERSION file missing"; exit 1; fi + RAW=$(tr -d ' \n' < VERSION) + if [[ -z "$RAW" ]]; then echo "VERSION empty"; exit 1; fi + if [[ "$RAW" =~ ^v ]]; then NUM="${RAW#v}"; else NUM="$RAW"; fi + REPO_NAME=$(echo "${GITHUB_REPOSITORY}" | cut -d'/' -f2) + echo "DOCKER_REPO=${{ secrets.DOCKER_HUB_USERNAME }}/${REPO_NAME}" >> $GITHUB_ENV + echo "VERSION_NUM=$NUM" >> $GITHUB_ENV + + - name: Set up QEMU + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + + - name: Set up Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Log in to DockerHub + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Build & push + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ env.DOCKER_REPO }}:v3 + build-args: | + APP_VERSION=${{ env.VERSION_NUM }}-v3 + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false + + - name: Summary + run: | + echo "✅ V3 image pushed!" + echo "Tag: ${{ env.DOCKER_REPO }}:v3" + echo "" + echo "To test, update docker-compose.yml:" + echo " image: ${{ env.DOCKER_REPO }}:v3" From de001dcd840154843c6188fbbaa2affe4409717d Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Tue, 25 Aug 2026 08:54:36 -0400 Subject: [PATCH 04/45] feat: add instance column to Chronarr DB 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. --- core/database.py | 604 ++++++++++++++++------------------------------- 1 file changed, 202 insertions(+), 402 deletions(-) diff --git a/core/database.py b/core/database.py index 0049c7b..74a5d48 100644 --- a/core/database.py +++ b/core/database.py @@ -4,6 +4,7 @@ Handles database operations for tracking media dates and processing history """ import json +import os import threading from datetime import datetime from typing import Optional, Dict, List, Any @@ -16,12 +17,6 @@ class ChronarrDatabase: """PostgreSQL database manager for Chronarr media tracking and processing history""" def __init__(self, config): - """ - Initialize PostgreSQL database connection - - Args: - config: Configuration object with database settings - """ if not config: raise ValueError("PostgreSQL configuration is required") self.db_host = config.db_host @@ -72,32 +67,29 @@ def get_connection(self): raise def _init_database(self): - """Initialize PostgreSQL database tables""" with self.get_connection() as conn: cursor = conn.cursor() self._init_postgresql_tables(cursor) - - # Test the connection works and verify autocommit - cursor.execute("SELECT 1") - print(f"✅ PostgreSQL database initialized and connection verified") - print(f"🔍 Autocommit status: {conn.autocommit}") def _init_postgresql_tables(self, cursor): - """Initialize database tables""" - # Series table + # Series — instance is part of the PK so two different Sonarr instances + # can track the same show independently. cursor.execute(""" CREATE TABLE IF NOT EXISTS series ( - imdb_id VARCHAR(20) PRIMARY KEY, + imdb_id VARCHAR(20) NOT NULL, + instance VARCHAR(100) NOT NULL DEFAULT 'sonarr', path TEXT NOT NULL, last_updated TIMESTAMP NOT NULL, - metadata JSONB + metadata JSONB, + missing_from_source_since TIMESTAMP DEFAULT NULL, + PRIMARY KEY (imdb_id, instance) ) """) - - # Episodes table + cursor.execute(""" CREATE TABLE IF NOT EXISTS episodes ( imdb_id VARCHAR(20) NOT NULL, + instance VARCHAR(100) NOT NULL DEFAULT 'sonarr', season INTEGER NOT NULL, episode INTEGER NOT NULL, aired DATE, @@ -105,69 +97,70 @@ def _init_postgresql_tables(self, cursor): source VARCHAR(100), last_updated TIMESTAMP NOT NULL, has_video_file BOOLEAN DEFAULT FALSE, - PRIMARY KEY (imdb_id, season, episode), - FOREIGN KEY (imdb_id) REFERENCES series(imdb_id) + skipped BOOLEAN DEFAULT FALSE, + skip_reason TEXT, + PRIMARY KEY (imdb_id, season, episode, instance), + FOREIGN KEY (imdb_id, instance) REFERENCES series(imdb_id, instance) ) """) - - # Movies table + cursor.execute(""" CREATE TABLE IF NOT EXISTS movies ( - imdb_id VARCHAR(20) PRIMARY KEY, + imdb_id VARCHAR(20) NOT NULL, + instance VARCHAR(100) NOT NULL DEFAULT 'radarr', title TEXT, year INTEGER, - path TEXT NOT NULL, + path TEXT NOT NULL DEFAULT 'unknown', released DATE, dateadded TIMESTAMP, source VARCHAR(100), last_updated TIMESTAMP NOT NULL, - has_video_file BOOLEAN DEFAULT FALSE + has_video_file BOOLEAN DEFAULT FALSE, + skipped BOOLEAN DEFAULT FALSE, + skip_reason TEXT, + missing_from_source_since TIMESTAMP DEFAULT NULL, + PRIMARY KEY (imdb_id, instance) ) """) - # Add title and year columns if they don't exist (migration for existing databases) + # Migrate existing single-instance databases to the composite-PK schema. + # Runs only when the instance column is absent (first upgrade to v3). cursor.execute(""" DO $$ BEGIN + -- Series: drop the old FK from episodes before touching the series PK. IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name='movies' AND column_name='title') THEN - ALTER TABLE movies ADD COLUMN title TEXT; - END IF; - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name='movies' AND column_name='year') THEN - ALTER TABLE movies ADD COLUMN year INTEGER; + WHERE table_name='series' AND column_name='instance') THEN + ALTER TABLE episodes DROP CONSTRAINT IF EXISTS episodes_imdb_id_fkey; + ALTER TABLE series ADD COLUMN instance VARCHAR(100) NOT NULL DEFAULT 'sonarr'; + ALTER TABLE series ADD COLUMN IF NOT EXISTS missing_from_source_since TIMESTAMP DEFAULT NULL; + ALTER TABLE series DROP CONSTRAINT series_pkey; + ALTER TABLE series ADD PRIMARY KEY (imdb_id, instance); END IF; - END $$; - """) - # Add skipped and skip_reason columns if they don't exist (migration for skip tracking) - cursor.execute(""" - DO $$ - BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name='movies' AND column_name='skipped') THEN - ALTER TABLE movies ADD COLUMN skipped BOOLEAN DEFAULT FALSE; - END IF; - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name='movies' AND column_name='skip_reason') THEN - ALTER TABLE movies ADD COLUMN skip_reason TEXT; - END IF; + -- Movies: independent of series. IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name='episodes' AND column_name='skipped') THEN - ALTER TABLE episodes ADD COLUMN skipped BOOLEAN DEFAULT FALSE; - END IF; - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name='episodes' AND column_name='skip_reason') THEN - ALTER TABLE episodes ADD COLUMN skip_reason TEXT; - END IF; - -- Library sync: track when items were first noticed missing from Radarr/Sonarr - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name='movies' AND column_name='missing_from_source_since') THEN - ALTER TABLE movies ADD COLUMN missing_from_source_since TIMESTAMP DEFAULT NULL; + WHERE table_name='movies' AND column_name='instance') THEN + ALTER TABLE movies ADD COLUMN instance VARCHAR(100) NOT NULL DEFAULT 'radarr'; + ALTER TABLE movies ADD COLUMN IF NOT EXISTS title TEXT; + ALTER TABLE movies ADD COLUMN IF NOT EXISTS year INTEGER; + ALTER TABLE movies ADD COLUMN IF NOT EXISTS skipped BOOLEAN DEFAULT FALSE; + ALTER TABLE movies ADD COLUMN IF NOT EXISTS skip_reason TEXT; + ALTER TABLE movies ADD COLUMN IF NOT EXISTS missing_from_source_since TIMESTAMP DEFAULT NULL; + ALTER TABLE movies DROP CONSTRAINT movies_pkey; + ALTER TABLE movies ADD PRIMARY KEY (imdb_id, instance); END IF; + + -- Episodes: needs series to have its composite PK in place first. IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name='series' AND column_name='missing_from_source_since') THEN - ALTER TABLE series ADD COLUMN missing_from_source_since TIMESTAMP DEFAULT NULL; + WHERE table_name='episodes' AND column_name='instance') THEN + ALTER TABLE episodes ADD COLUMN instance VARCHAR(100) NOT NULL DEFAULT 'sonarr'; + ALTER TABLE episodes ADD COLUMN IF NOT EXISTS skipped BOOLEAN DEFAULT FALSE; + ALTER TABLE episodes ADD COLUMN IF NOT EXISTS skip_reason TEXT; + ALTER TABLE episodes DROP CONSTRAINT episodes_pkey; + ALTER TABLE episodes ADD PRIMARY KEY (imdb_id, season, episode, instance); + ALTER TABLE episodes ADD CONSTRAINT episodes_series_fk + FOREIGN KEY (imdb_id, instance) REFERENCES series(imdb_id, instance); END IF; END $$; """) @@ -303,73 +296,62 @@ def _init_postgresql_tables(self, cursor): cursor.execute("CREATE INDEX IF NOT EXISTS idx_cleanup_executions_schedule ON cleanup_executions(schedule_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_cleanup_executions_status ON cleanup_executions(status)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_cleanup_executions_started ON cleanup_executions(started_at)") - def upsert_series(self, imdb_id: str, path: str, metadata: Optional[Dict] = None): - """Insert or update series record""" + def upsert_series(self, imdb_id: str, path: str, instance: str = 'sonarr', metadata: Optional[Dict] = None): with self.get_connection() as conn: cursor = conn.cursor() timestamp = datetime.utcnow() - cursor.execute(""" - INSERT INTO series (imdb_id, path, last_updated, metadata) - VALUES (%s, %s, %s, %s) - ON CONFLICT (imdb_id) DO UPDATE SET + INSERT INTO series (imdb_id, instance, path, last_updated, metadata) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (imdb_id, instance) DO UPDATE SET path = EXCLUDED.path, last_updated = EXCLUDED.last_updated, metadata = EXCLUDED.metadata - """, (imdb_id, path, timestamp, json.dumps(metadata) if metadata else None)) + """, (imdb_id, instance, path, timestamp, json.dumps(metadata) if metadata else None)) - def upsert_episode_date(self, imdb_id: str, season: int, episode: int, - aired: Optional[str], dateadded: Optional[str], - source: str, has_video_file: bool = False): - """Insert or update episode date record""" + def upsert_episode_date(self, imdb_id: str, season: int, episode: int, + aired: Optional[str], dateadded: Optional[str], + source: str, has_video_file: bool = False, instance: str = 'sonarr'): with self.get_connection() as conn: cursor = conn.cursor() timestamp = datetime.utcnow() - cursor.execute(""" - INSERT INTO episodes - (imdb_id, season, episode, aired, dateadded, source, has_video_file, last_updated) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) - ON CONFLICT (imdb_id, season, episode) DO UPDATE SET + INSERT INTO episodes + (imdb_id, instance, season, episode, aired, dateadded, source, has_video_file, last_updated) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (imdb_id, season, episode, instance) DO UPDATE SET aired = COALESCE(EXCLUDED.aired, episodes.aired), dateadded = COALESCE(EXCLUDED.dateadded, episodes.dateadded), source = COALESCE(EXCLUDED.source, episodes.source), has_video_file = EXCLUDED.has_video_file, last_updated = EXCLUDED.last_updated - """, (imdb_id, season, episode, aired, dateadded, source, has_video_file, timestamp)) - import os - if os.environ.get("DEBUG", "false").lower() == "true": - print(f"🔍 DEBUG: PostgreSQL upsert executed for {imdb_id} S{season:02d}E{episode:02d}, rows affected: {cursor.rowcount}") + """, (imdb_id, instance, season, episode, aired, dateadded, source, has_video_file, timestamp)) - def upsert_movie(self, imdb_id: str, path: str): - """Insert or update movie record""" + def upsert_movie(self, imdb_id: str, path: str, instance: str = 'radarr'): with self.get_connection() as conn: cursor = conn.cursor() timestamp = datetime.utcnow() - cursor.execute(""" - INSERT INTO movies (imdb_id, path, last_updated) - VALUES (%s, %s, %s) - ON CONFLICT (imdb_id) DO UPDATE SET + INSERT INTO movies (imdb_id, instance, path, last_updated) + VALUES (%s, %s, %s, %s) + ON CONFLICT (imdb_id, instance) DO UPDATE SET path = EXCLUDED.path, last_updated = EXCLUDED.last_updated - """, (imdb_id, path, timestamp)) + """, (imdb_id, instance, path, timestamp)) def upsert_movie_dates(self, imdb_id: str, released: Optional[str], dateadded: Optional[str], source: str, has_video_file: bool = False, - title: Optional[str] = None, year: Optional[int] = None): - """Insert or update movie date record""" - import os - if os.environ.get("DEBUG", "false").lower() == "true": - print(f"🔍 DATABASE UPSERT: imdb_id={imdb_id}, title={title}, dateadded={dateadded}, source={source}") + title: Optional[str] = None, year: Optional[int] = None, + instance: str = 'radarr'): with self.get_connection() as conn: cursor = conn.cursor() timestamp = datetime.utcnow() - cursor.execute(""" - INSERT INTO movies (imdb_id, title, year, path, released, dateadded, source, has_video_file, last_updated) - VALUES (%s, %s, %s, COALESCE((SELECT path FROM movies WHERE imdb_id = %s), 'unknown'), %s, %s, %s, %s, %s) - ON CONFLICT (imdb_id) DO UPDATE SET + INSERT INTO movies (imdb_id, instance, title, year, path, released, dateadded, source, has_video_file, last_updated) + VALUES (%s, %s, %s, %s, + COALESCE((SELECT path FROM movies WHERE imdb_id = %s AND instance = %s), 'unknown'), + %s, %s, %s, %s, %s) + ON CONFLICT (imdb_id, instance) DO UPDATE SET title = COALESCE(EXCLUDED.title, movies.title), year = COALESCE(EXCLUDED.year, movies.year), released = COALESCE(EXCLUDED.released, movies.released), @@ -377,114 +359,91 @@ def upsert_movie_dates(self, imdb_id: str, released: Optional[str], source = COALESCE(EXCLUDED.source, movies.source), has_video_file = EXCLUDED.has_video_file, last_updated = EXCLUDED.last_updated - """, (imdb_id, title, year, imdb_id, released, dateadded, source, has_video_file, timestamp)) - - # Debug: Check what was actually saved - cursor.execute("SELECT dateadded, source FROM movies WHERE imdb_id = %s", (imdb_id,)) - result = cursor.fetchone() - import os - if os.environ.get("DEBUG", "false").lower() == "true": - print(f"🔍 DATABASE VERIFY: After upsert, found dateadded={result['dateadded'] if result else 'NOT_FOUND'}, source={result['source'] if result else 'NOT_FOUND'}") - - def mark_movie_skipped(self, imdb_id: str, title: str, year: int, path: str, reason: str): - """Mark a movie as skipped with reason""" + """, (imdb_id, instance, title, year, imdb_id, instance, released, dateadded, source, has_video_file, timestamp)) + + def mark_movie_skipped(self, imdb_id: str, title: str, year: int, path: str, reason: str, instance: str = 'radarr'): with self.get_connection() as conn: cursor = conn.cursor() timestamp = datetime.utcnow() cursor.execute(""" - INSERT INTO movies (imdb_id, title, year, path, skipped, skip_reason, has_video_file, last_updated) - VALUES (%s, %s, %s, %s, TRUE, %s, FALSE, %s) - ON CONFLICT (imdb_id) DO UPDATE SET + INSERT INTO movies (imdb_id, instance, title, year, path, skipped, skip_reason, has_video_file, last_updated) + VALUES (%s, %s, %s, %s, %s, TRUE, %s, FALSE, %s) + ON CONFLICT (imdb_id, instance) DO UPDATE SET title = EXCLUDED.title, year = EXCLUDED.year, path = EXCLUDED.path, skipped = TRUE, skip_reason = EXCLUDED.skip_reason, last_updated = EXCLUDED.last_updated - """, (imdb_id, title, year, path, reason, timestamp)) + """, (imdb_id, instance, title, year, path, reason, timestamp)) - def mark_episode_skipped(self, imdb_id: str, season: int, episode: int, reason: str): - """Mark an episode as skipped with reason""" + def mark_episode_skipped(self, imdb_id: str, season: int, episode: int, reason: str, instance: str = 'sonarr'): with self.get_connection() as conn: cursor = conn.cursor() timestamp = datetime.utcnow() cursor.execute(""" - INSERT INTO episodes (imdb_id, season, episode, skipped, skip_reason, has_video_file, last_updated) - VALUES (%s, %s, %s, TRUE, %s, FALSE, %s) - ON CONFLICT (imdb_id, season, episode) DO UPDATE SET + INSERT INTO episodes (imdb_id, instance, season, episode, skipped, skip_reason, has_video_file, last_updated) + VALUES (%s, %s, %s, %s, TRUE, %s, FALSE, %s) + ON CONFLICT (imdb_id, season, episode, instance) DO UPDATE SET skipped = TRUE, skip_reason = EXCLUDED.skip_reason, last_updated = EXCLUDED.last_updated - """, (imdb_id, season, episode, reason, timestamp)) + """, (imdb_id, instance, season, episode, reason, timestamp)) - def clear_movie_skipped(self, imdb_id: str): - """Clear skipped flag for a movie""" + def clear_movie_skipped(self, imdb_id: str, instance: str = 'radarr'): with self.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" UPDATE movies SET skipped = FALSE, skip_reason = NULL - WHERE imdb_id = %s - """, (imdb_id,)) + WHERE imdb_id = %s AND instance = %s + """, (imdb_id, instance)) - def clear_episode_skipped(self, imdb_id: str, season: int, episode: int): - """Clear skipped flag for an episode""" + def clear_episode_skipped(self, imdb_id: str, season: int, episode: int, instance: str = 'sonarr'): with self.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" UPDATE episodes SET skipped = FALSE, skip_reason = NULL - WHERE imdb_id = %s AND season = %s AND episode = %s - """, (imdb_id, season, episode)) + WHERE imdb_id = %s AND season = %s AND episode = %s AND instance = %s + """, (imdb_id, season, episode, instance)) def get_skipped_counts(self) -> Dict: - """Get counts of skipped movies and episodes""" + """Total skipped counts across all instances — for dashboard summary.""" with self.get_connection() as conn: cursor = conn.cursor() - - # Count skipped movies cursor.execute("SELECT COUNT(*) as count FROM movies WHERE skipped = TRUE") skipped_movies = cursor.fetchone()['count'] - - # Count skipped episodes cursor.execute("SELECT COUNT(*) as count FROM episodes WHERE skipped = TRUE") skipped_episodes = cursor.fetchone()['count'] - return { 'movies': skipped_movies, 'episodes': skipped_episodes, 'total': skipped_movies + skipped_episodes } - def get_series_episodes(self, imdb_id: str, has_video_file_only: bool = False) -> List[Dict]: - """Get all episodes for a series""" + def get_series_episodes(self, imdb_id: str, instance: str = 'sonarr', has_video_file_only: bool = False) -> List[Dict]: with self.get_connection() as conn: cursor = conn.cursor() - - query = "SELECT * FROM episodes WHERE imdb_id = %s" - params = [imdb_id] + query = "SELECT * FROM episodes WHERE imdb_id = %s AND instance = %s" + params = [imdb_id, instance] if has_video_file_only: query += " AND has_video_file = TRUE" - cursor.execute(query, params) return [dict(row) for row in cursor.fetchall()] - - def get_episode_date(self, imdb_id: str, season: int, episode: int) -> Optional[Dict]: - """Get episode date record""" + + def get_episode_date(self, imdb_id: str, season: int, episode: int, instance: str = 'sonarr') -> Optional[Dict]: with self.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" - SELECT * FROM episodes - WHERE imdb_id = %s AND season = %s AND episode = %s - """, (imdb_id, season, episode)) - + SELECT * FROM episodes + WHERE imdb_id = %s AND season = %s AND episode = %s AND instance = %s + """, (imdb_id, season, episode, instance)) row = cursor.fetchone() return dict(row) if row else None - - def get_movie_dates(self, imdb_id: str) -> Optional[Dict]: - """Get movie date record""" + + def get_movie_dates(self, imdb_id: str, instance: str = 'radarr') -> Optional[Dict]: with self.get_connection() as conn: cursor = conn.cursor() - cursor.execute("SELECT * FROM movies WHERE imdb_id = %s", (imdb_id,)) - + cursor.execute("SELECT * FROM movies WHERE imdb_id = %s AND instance = %s", (imdb_id, instance)) row = cursor.fetchone() return dict(row) if row else None @@ -498,79 +457,65 @@ def add_processing_history(self, imdb_id: str, media_type: str, event_type: str, """, (imdb_id, media_type, event_type, datetime.utcnow().isoformat(), json.dumps(details) if details else None)) - def migrate_movie_imdb_id(self, old_imdb_id: str, new_imdb_id: str) -> bool: - """Migrate a movie from placeholder IMDb ID to real IMDb ID""" + def migrate_movie_imdb_id(self, old_imdb_id: str, new_imdb_id: str, instance: str = 'radarr') -> bool: + """Replace a placeholder IMDb ID with the real one, preserving all other data.""" with self.get_connection() as conn: cursor = conn.cursor() - # Check if old record exists - cursor.execute("SELECT * FROM movies WHERE imdb_id = %s", (old_imdb_id,)) + cursor.execute("SELECT * FROM movies WHERE imdb_id = %s AND instance = %s", (old_imdb_id, instance)) old_record = cursor.fetchone() if not old_record: return False old_data = dict(old_record) - # Check if new IMDb ID already exists - cursor.execute("SELECT * FROM movies WHERE imdb_id = %s", (new_imdb_id,)) + cursor.execute("SELECT * FROM movies WHERE imdb_id = %s AND instance = %s", (new_imdb_id, instance)) if cursor.fetchone(): - # New IMDb ID already exists, just delete the old one - cursor.execute("DELETE FROM movies WHERE imdb_id = %s", (old_imdb_id,)) + cursor.execute("DELETE FROM movies WHERE imdb_id = %s AND instance = %s", (old_imdb_id, instance)) return True - # Create new record with correct IMDb ID cursor.execute(""" - INSERT INTO movies (imdb_id, title, year, path, released, dateadded, source, + INSERT INTO movies (imdb_id, instance, title, year, path, released, dateadded, source, has_video_file, last_updated, skipped, skip_reason) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, (new_imdb_id, old_data.get('title'), old_data.get('year'), + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, NULL) + """, (new_imdb_id, instance, old_data.get('title'), old_data.get('year'), old_data.get('path'), old_data.get('released'), old_data.get('dateadded'), - old_data.get('source'), old_data.get('has_video_file'), - datetime.utcnow(), False, None)) # Clear skipped status + old_data.get('source'), old_data.get('has_video_file'), datetime.utcnow())) - # Delete old placeholder record - cursor.execute("DELETE FROM movies WHERE imdb_id = %s", (old_imdb_id,)) + cursor.execute("DELETE FROM movies WHERE imdb_id = %s AND instance = %s", (old_imdb_id, instance)) return True - def migrate_series_imdb_id(self, old_imdb_id: str, new_imdb_id: str) -> bool: - """Migrate a series and all its episodes from placeholder IMDb ID to real IMDb ID""" + def migrate_series_imdb_id(self, old_imdb_id: str, new_imdb_id: str, instance: str = 'sonarr') -> bool: + """Replace a placeholder series IMDb ID with the real one, migrating all episodes.""" with self.get_connection() as conn: cursor = conn.cursor() - # Check if old series exists - cursor.execute("SELECT * FROM series WHERE imdb_id = %s", (old_imdb_id,)) + cursor.execute("SELECT * FROM series WHERE imdb_id = %s AND instance = %s", (old_imdb_id, instance)) old_series = cursor.fetchone() if not old_series: return False old_series_data = dict(old_series) - # Check if new series IMDb ID already exists - cursor.execute("SELECT * FROM series WHERE imdb_id = %s", (new_imdb_id,)) + cursor.execute("SELECT * FROM series WHERE imdb_id = %s AND instance = %s", (new_imdb_id, instance)) if cursor.fetchone(): - # New series already exists, migrate episodes and delete old series - cursor.execute(""" - UPDATE episodes SET imdb_id = %s WHERE imdb_id = %s - """, (new_imdb_id, old_imdb_id)) - cursor.execute("DELETE FROM series WHERE imdb_id = %s", (old_imdb_id,)) + cursor.execute("UPDATE episodes SET imdb_id = %s WHERE imdb_id = %s AND instance = %s", + (new_imdb_id, old_imdb_id, instance)) + cursor.execute("DELETE FROM series WHERE imdb_id = %s AND instance = %s", (old_imdb_id, instance)) return True - # Create new series record cursor.execute(""" - INSERT INTO series (imdb_id, path, last_updated, metadata) - VALUES (%s, %s, %s, %s) - """, (new_imdb_id, old_series_data.get('path'), + INSERT INTO series (imdb_id, instance, path, last_updated, metadata) + VALUES (%s, %s, %s, %s, %s) + """, (new_imdb_id, instance, old_series_data.get('path'), datetime.utcnow(), old_series_data.get('metadata'))) - # Migrate all episodes to new series IMDb ID and clear skipped status cursor.execute(""" - UPDATE episodes - SET imdb_id = %s, skipped = FALSE, skip_reason = NULL - WHERE imdb_id = %s - """, (new_imdb_id, old_imdb_id)) + UPDATE episodes SET imdb_id = %s, skipped = FALSE, skip_reason = NULL + WHERE imdb_id = %s AND instance = %s + """, (new_imdb_id, old_imdb_id, instance)) - # Delete old placeholder series - cursor.execute("DELETE FROM series WHERE imdb_id = %s", (old_imdb_id,)) + cursor.execute("DELETE FROM series WHERE imdb_id = %s AND instance = %s", (old_imdb_id, instance)) return True def get_stats(self) -> Dict[str, Any]: @@ -616,62 +561,27 @@ def get_stats(self) -> Dict[str, Any]: "database_type": "postgresql" } - def delete_episode(self, imdb_id: str, season: int, episode: int) -> bool: - """ - Delete a specific episode from the database - - Args: - imdb_id: Series IMDb ID - season: Season number - episode: Episode number - - Returns: - True if episode was deleted, False if not found - """ + def delete_episode(self, imdb_id: str, season: int, episode: int, instance: str = 'sonarr') -> bool: with self.get_connection() as conn: cursor = conn.cursor() - cursor.execute(""" - DELETE FROM episodes - WHERE imdb_id = %s AND season = %s AND episode = %s - """, (imdb_id, season, episode)) - + DELETE FROM episodes + WHERE imdb_id = %s AND season = %s AND episode = %s AND instance = %s + """, (imdb_id, season, episode, instance)) deleted_count = cursor.rowcount conn.commit() - return deleted_count > 0 - - def delete_series_episodes(self, imdb_id: str) -> int: - """ - Delete all episodes for a series from the database - - Args: - imdb_id: Series IMDb ID - - Returns: - Number of episodes deleted - """ + + def delete_series_episodes(self, imdb_id: str, instance: str = 'sonarr') -> int: with self.get_connection() as conn: cursor = conn.cursor() - - cursor.execute(""" - DELETE FROM episodes - WHERE imdb_id = %s - """, (imdb_id,)) - + cursor.execute("DELETE FROM episodes WHERE imdb_id = %s AND instance = %s", (imdb_id, instance)) deleted_count = cursor.rowcount conn.commit() - return deleted_count def delete_orphaned_episodes(self) -> List[Dict]: - """ - Find and delete episodes that don't have corresponding video files on disk - This requires checking filesystem for each episode, so use carefully - - Returns: - List of deleted episodes with their details - """ + """Delete DB episode rows that have no matching file on disk. Checks filesystem.""" from utils.file_utils import find_episodes_on_disk from pathlib import Path @@ -679,119 +589,60 @@ def delete_orphaned_episodes(self) -> List[Dict]: with self.get_connection() as conn: cursor = conn.cursor() - - # Get all series with their paths - cursor.execute(""" - SELECT DISTINCT imdb_id, path FROM series - """) - + cursor.execute("SELECT DISTINCT imdb_id, path FROM series") series_list = cursor.fetchall() - + for series in series_list: imdb_id = series['imdb_id'] series_path = Path(series['path']) - if not series_path.exists(): continue - - # Get episodes on disk + disk_episodes = find_episodes_on_disk(series_path) disk_episode_keys = set(disk_episodes.keys()) - - # Get episodes in database - cursor.execute(""" - SELECT season, episode, dateadded, source - FROM episodes - WHERE imdb_id = %s - """, (imdb_id,)) - - db_episodes = cursor.fetchall() - - # Find orphaned episodes (in DB but not on disk) - for db_episode in db_episodes: - season = db_episode['season'] - episode = db_episode['episode'] - episode_key = (season, episode) - - if episode_key not in disk_episode_keys: - # Episode is orphaned - delete it - cursor.execute(""" - DELETE FROM episodes - WHERE imdb_id = %s AND season = %s AND episode = %s - """, (imdb_id, season, episode)) - + + cursor.execute( + "SELECT season, episode, dateadded, source FROM episodes WHERE imdb_id = %s", + (imdb_id,) + ) + for ep in cursor.fetchall(): + season, episode = ep['season'], ep['episode'] + if (season, episode) not in disk_episode_keys: + cursor.execute( + "DELETE FROM episodes WHERE imdb_id = %s AND season = %s AND episode = %s", + (imdb_id, season, episode) + ) deleted_episodes.append({ 'imdb_id': imdb_id, 'season': season, 'episode': episode, - 'dateadded': db_episode['dateadded'], - 'source': db_episode['source'], + 'dateadded': ep['dateadded'], + 'source': ep['source'], 'series_path': str(series_path) }) - + conn.commit() - + return deleted_episodes - def delete_movie(self, imdb_id: str) -> bool: - """ - Delete a specific movie from the database - - Args: - imdb_id: Movie IMDb ID - - Returns: - True if movie was deleted, False if not found - """ + def delete_movie(self, imdb_id: str, instance: str = 'radarr') -> bool: with self.get_connection() as conn: cursor = conn.cursor() - - cursor.execute(""" - DELETE FROM movies - WHERE imdb_id = %s - """, (imdb_id,)) - + cursor.execute("DELETE FROM movies WHERE imdb_id = %s AND instance = %s", (imdb_id, instance)) deleted_count = cursor.rowcount conn.commit() - return deleted_count > 0 - - def delete_series(self, imdb_id: str) -> bool: - """ - Delete a specific series from the database - - Args: - imdb_id: Series IMDb ID - - Returns: - True if series was deleted, False if not found - """ + + def delete_series(self, imdb_id: str, instance: str = 'sonarr') -> bool: with self.get_connection() as conn: cursor = conn.cursor() - - cursor.execute(""" - DELETE FROM series - WHERE imdb_id = %s - """, (imdb_id,)) - + cursor.execute("DELETE FROM series WHERE imdb_id = %s AND instance = %s", (imdb_id, instance)) deleted_count = cursor.rowcount conn.commit() - return deleted_count > 0 def update_movie_file_info(self, imdb_id: str, path: str, has_video_file: bool = True) -> bool: - """ - Update path and video file status for an existing movie - Used when population finds a video file for a manually-added movie - - Args: - imdb_id: Movie IMDb ID - path: File path to the video file - has_video_file: Whether a video file exists (default True) - - Returns: - True if movie was updated, False if not found - """ + """Update path and file status — used when a population scan finds a video for a queued movie.""" with self.get_connection() as conn: cursor = conn.cursor() @@ -842,24 +693,22 @@ def update_episode_file_info(self, imdb_id: str, season: int, episode: int, # ── Library sync helpers ────────────────────────────────────────────────── def get_all_movie_records(self) -> List[Dict]: - """Return all movies with id, title, path, and missing_from_source_since.""" + """Return all movie records including instance — used by library sync.""" with self.get_connection() as conn: cursor = conn.cursor() cursor.execute( - "SELECT imdb_id, title, path, missing_from_source_since FROM movies" + "SELECT imdb_id, instance, title, path, missing_from_source_since FROM movies" ) - rows = cursor.fetchall() - return [dict(r) for r in rows] + return [dict(r) for r in cursor.fetchall()] def get_all_series_records(self) -> List[Dict]: - """Return all series with imdb_id, path, metadata, and missing_from_source_since.""" + """Return all series records including instance — used by library sync.""" with self.get_connection() as conn: cursor = conn.cursor() cursor.execute( - "SELECT imdb_id, path, metadata, missing_from_source_since FROM series" + "SELECT imdb_id, instance, path, metadata, missing_from_source_since FROM series" ) - rows = cursor.fetchall() - return [dict(r) for r in rows] + return [dict(r) for r in cursor.fetchall()] def mark_movies_missing_from_source(self, imdb_ids: List[str], timestamp) -> None: """Set missing_from_source_since for movies not yet marked.""" @@ -938,112 +787,63 @@ def get_series_missing_before(self, cutoff) -> List[Dict]: return [dict(r) for r in cursor.fetchall()] def delete_orphaned_movies(self) -> List[Dict]: - """ - Find and delete movies that don't have corresponding video files on disk - This requires checking filesystem for each movie, so use carefully - - Returns: - List of deleted movies with their details - """ + """Delete DB movie rows whose directory or video files no longer exist on disk.""" from pathlib import Path - + deleted_movies = [] - + video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") + with self.get_connection() as conn: cursor = conn.cursor() - - # Get all movies with their paths - cursor.execute(""" - SELECT imdb_id, path, dateadded, source - FROM movies - """) - - movies_list = cursor.fetchall() - - for movie in movies_list: + cursor.execute("SELECT imdb_id, path, dateadded, source FROM movies") + + for movie in cursor.fetchall(): imdb_id = movie['imdb_id'] movie_path = Path(movie['path']) - + if not movie_path.exists(): - # Movie directory doesn't exist - delete it - cursor.execute(""" - DELETE FROM movies - WHERE imdb_id = %s - """, (imdb_id,)) - + cursor.execute("DELETE FROM movies WHERE imdb_id = %s", (imdb_id,)) deleted_movies.append({ - 'imdb_id': imdb_id, - 'reason': 'directory_not_found', - 'path': str(movie_path), - 'dateadded': movie['dateadded'], + 'imdb_id': imdb_id, 'reason': 'directory_not_found', + 'path': str(movie_path), 'dateadded': movie['dateadded'], 'source': movie['source'] }) continue - - # Check for video files - video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") - has_video = any(f.is_file() and f.suffix.lower() in video_exts - for f in movie_path.iterdir() if f.is_file()) - + + has_video = any( + f.is_file() and f.suffix.lower() in video_exts + for f in movie_path.iterdir() if f.is_file() + ) if not has_video: - # No video files found - delete this movie - cursor.execute(""" - DELETE FROM movies - WHERE imdb_id = %s - """, (imdb_id,)) - + cursor.execute("DELETE FROM movies WHERE imdb_id = %s", (imdb_id,)) deleted_movies.append({ - 'imdb_id': imdb_id, - 'reason': 'no_video_files', - 'path': str(movie_path), - 'dateadded': movie['dateadded'], + 'imdb_id': imdb_id, 'reason': 'no_video_files', + 'path': str(movie_path), 'dateadded': movie['dateadded'], 'source': movie['source'] }) - + conn.commit() - + return deleted_movies def delete_orphaned_series(self) -> List[Dict]: - """ - Find and delete TV series that don't have corresponding directories on disk - This requires checking filesystem for each series, so use carefully - - Returns: - List of deleted series with their details - """ + """Delete DB series (and all their episodes) whose directory no longer exists on disk.""" from pathlib import Path - + deleted_series = [] - + with self.get_connection() as conn: cursor = conn.cursor() - - # Get all series with their paths - cursor.execute(""" - SELECT imdb_id, path, last_updated, metadata - FROM series - """) - - series_list = cursor.fetchall() - - for series in series_list: + cursor.execute("SELECT imdb_id, path, last_updated, metadata FROM series") + + for series in cursor.fetchall(): imdb_id = series['imdb_id'] series_path = Path(series['path']) - + if not series_path.exists(): - # Series directory doesn't exist - delete the series and all its episodes - cursor.execute(""" - DELETE FROM episodes - WHERE imdb_id = %s - """, (imdb_id,)) + cursor.execute("DELETE FROM episodes WHERE imdb_id = %s", (imdb_id,)) episodes_deleted = cursor.rowcount - - cursor.execute(""" - DELETE FROM series - WHERE imdb_id = %s - """, (imdb_id,)) - + cursor.execute("DELETE FROM series WHERE imdb_id = %s", (imdb_id,)) deleted_series.append({ 'imdb_id': imdb_id, 'reason': 'directory_not_found', @@ -1051,9 +851,9 @@ def delete_orphaned_series(self) -> List[Dict]: 'last_updated': series['last_updated'], 'episodes_deleted': episodes_deleted }) - + conn.commit() - + return deleted_series def add_missing_imdb(self, file_path: str, media_type: str, folder_name: str = None, filename: str = None, notes: str = None): From 6e21bdb68404506a8db03848f5ff4b3986bb0111 Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Tue, 25 Aug 2026 08:56:30 -0400 Subject: [PATCH 05/45] refactor: per-instance PathMapper, simplify file_utils path dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/instance_registry.py | 27 ++++++- core/path_mapper.py | 149 ++++++++++---------------------------- utils/file_utils.py | 87 ++++------------------ 3 files changed, 75 insertions(+), 188 deletions(-) diff --git a/core/instance_registry.py b/core/instance_registry.py index bb8472a..a44d147 100644 --- a/core/instance_registry.py +++ b/core/instance_registry.py @@ -1,9 +1,9 @@ """ Instance registry for Radarr and Sonarr clients. -Builds one client per configured instance at startup and provides a central -lookup by instance name. Processors and webhook handlers resolve the right -client from here rather than each constructing their own from env vars. +Builds one client and one PathMapper per configured instance at startup. +Processors and webhook handlers resolve the right client/mapper from here +rather than each constructing their own from env vars. """ from typing import Dict, Optional, Union @@ -13,6 +13,7 @@ from clients.sonarr_db_client import SonarrDbClient from config.settings import RadarrInstance, SonarrInstance from core.logging import _log +from core.path_mapper import PathMapper # Type aliases AnyRadarrClient = Union[RadarrClient, RadarrDbClient] @@ -92,21 +93,31 @@ def _build_sonarr_client(instance: SonarrInstance) -> Optional[AnySonarrClient]: class InstanceRegistry: - """Central store of all Radarr and Sonarr clients, keyed by instance name.""" + """Central store of all Radarr and Sonarr clients and path mappers, keyed by instance name.""" def __init__(self, radarr_instances, sonarr_instances): self._radarr: Dict[str, AnyRadarrClient] = {} self._sonarr: Dict[str, AnySonarrClient] = {} + self._radarr_mappers: Dict[str, PathMapper] = {} + self._sonarr_mappers: Dict[str, PathMapper] = {} for inst in radarr_instances: client = _build_radarr_client(inst) if client: self._radarr[inst.name] = client + self._radarr_mappers[inst.name] = PathMapper( + root_folders=inst.root_folders, + container_paths=inst.movie_paths, + ) for inst in sonarr_instances: client = _build_sonarr_client(inst) if client: self._sonarr[inst.name] = client + self._sonarr_mappers[inst.name] = PathMapper( + root_folders=inst.root_folders, + container_paths=inst.tv_paths, + ) _log("INFO", f"Instance registry: {len(self._radarr)} Radarr, {len(self._sonarr)} Sonarr") @@ -118,6 +129,14 @@ def sonarr(self, name: str = "sonarr") -> Optional[AnySonarrClient]: """Look up a Sonarr client by instance name.""" return self._sonarr.get(name) + def radarr_mapper(self, name: str = "radarr") -> Optional[PathMapper]: + """Look up the PathMapper for a Radarr instance.""" + return self._radarr_mappers.get(name) + + def sonarr_mapper(self, name: str = "sonarr") -> Optional[PathMapper]: + """Look up the PathMapper for a Sonarr instance.""" + return self._sonarr_mappers.get(name) + def all_radarr(self) -> Dict[str, AnyRadarrClient]: return dict(self._radarr) diff --git a/core/path_mapper.py b/core/path_mapper.py index b7567ae..9224c8a 100644 --- a/core/path_mapper.py +++ b/core/path_mapper.py @@ -1,120 +1,49 @@ -#!/usr/bin/env python3 -""" -Path mapping utilities for Chronarr -Handles conversion between external service paths and container paths -""" -import os -import re from pathlib import Path -class PathMapper: - """Handles path mapping between different environments""" - - def __init__(self, config): - """Initialize path mapper with configuration""" - # Use environment variables directly since config attribute names are unclear - import os - - radarr_roots_str = os.getenv('RADARR_ROOT_FOLDERS', '') - sonarr_roots_str = os.getenv('SONARR_ROOT_FOLDERS', '') - movie_paths_str = os.getenv('MOVIE_PATHS', '') - tv_paths_str = os.getenv('TV_PATHS', '') - - self.radarr_roots = [path.strip() for path in radarr_roots_str.split(',') if path.strip()] - self.sonarr_roots = [path.strip() for path in sonarr_roots_str.split(',') if path.strip()] - self.movie_paths = [path.strip() for path in movie_paths_str.split(',') if path.strip()] - self.tv_paths = [path.strip() for path in tv_paths_str.split(',') if path.strip()] - - # Check if path debugging is enabled - self.path_debug = os.getenv('PATH_DEBUG', 'false').lower() == 'true' - - if self.path_debug: - print(f"PATH_DEBUG: PathMapper initialized with:") - print(f"PATH_DEBUG: radarr_roots: {self.radarr_roots}") - print(f"PATH_DEBUG: sonarr_roots: {self.sonarr_roots}") - print(f"PATH_DEBUG: movie_paths: {self.movie_paths}") - print(f"PATH_DEBUG: tv_paths: {self.tv_paths}") - @staticmethod - def _normalize_separators(path: str) -> str: - """Normalize path separators to forward slashes — handles UNC paths from Windows/Radarr""" - return path.replace('\\', '/') +class PathMapper: + """Maps filesystem paths between what Radarr/Sonarr report and what the container sees. - def sonarr_path_to_container_path(self, sonarr_path: str) -> str: - """Convert Sonarr path to container path using environment mappings""" - # Normalize separators so UNC paths (\\server\share\...) match correctly - normalized_path = self._normalize_separators(sonarr_path) + One PathMapper is created per configured instance at startup, built from + that instance's root_folders and container_paths. The mapping logic is + identical for Radarr and Sonarr — only the data differs. + """ - if self.path_debug: - print(f"PATH_DEBUG: sonarr_path_to_container_path input: {sonarr_path}") - print(f"PATH_DEBUG: normalized input: {normalized_path}") - print(f"PATH_DEBUG: sonarr_roots: {self.sonarr_roots}") - print(f"PATH_DEBUG: tv_paths: {self.tv_paths}") + def __init__(self, root_folders: list, container_paths: list, debug: bool = False): + self.root_folders = root_folders + self.container_paths = container_paths + self.debug = debug - # Sort roots by length (longest first) to avoid substring matching issues - indexed_roots = [(i, root) for i, root in enumerate(self.sonarr_roots)] - indexed_roots.sort(key=lambda x: len(x[1]), reverse=True) + @staticmethod + def _normalize(path: str) -> str: + """Backslash to forward slash — handles UNC paths from Windows/Radarr.""" + return path.replace('\\', '/') - # Try to match against configured Sonarr root folders (longest first) - for original_index, sonarr_root in indexed_roots: - normalized_root = self._normalize_separators(sonarr_root) - if self.path_debug: - print(f"PATH_DEBUG: Checking sonarr_root[{original_index}]: {normalized_root}") - if normalized_path.startswith(normalized_root + '/') or normalized_path == normalized_root: - if self.path_debug: - print(f"PATH_DEBUG: Match found! Index {original_index}") - # Map to corresponding TV path - if original_index < len(self.tv_paths): - container_root = self.tv_paths[original_index] - relative_path = normalized_path[len(normalized_root):].lstrip('/') - result = str(Path(container_root) / relative_path) if relative_path else container_root - if self.path_debug: - print(f"PATH_DEBUG: Mapped to: {result}") + def map(self, path: str) -> str: + """Translate an external path to its container equivalent. + + Tries each configured root folder longest-first to avoid prefix collisions + (e.g. /movies matching before /movies/4k). Returns the original path + unchanged if nothing matches — let the caller decide what to do. + """ + normalized = self._normalize(path) + roots = sorted(enumerate(self.root_folders), key=lambda x: len(x[1]), reverse=True) + for idx, root in roots: + norm_root = self._normalize(root) + if normalized.startswith(norm_root + '/') or normalized == norm_root: + if idx < len(self.container_paths): + rel = normalized[len(norm_root):].lstrip('/') + result = str(Path(self.container_paths[idx]) / rel) if rel else self.container_paths[idx] + if self.debug: + print(f"[path_mapper] {path!r} -> {result!r}") return result + if self.debug: + print(f"[path_mapper] no match for {path!r}, returning as-is") + return path - if self.path_debug: - print(f"PATH_DEBUG: No match found, returning original: {sonarr_path}") - # No fallback - if path mapping fails, return original and let validation catch it - return sonarr_path - - def radarr_path_to_container_path(self, radarr_path: str) -> str: - """Convert Radarr path to container path using environment mappings""" - # Normalize separators so UNC paths (\\server\share\...) match correctly - normalized_path = self._normalize_separators(radarr_path) - - if self.path_debug: - print(f"PATH_DEBUG: radarr_path_to_container_path input: {radarr_path}") - print(f"PATH_DEBUG: normalized input: {normalized_path}") - print(f"PATH_DEBUG: radarr_roots: {self.radarr_roots}") - print(f"PATH_DEBUG: movie_paths: {self.movie_paths}") - - # Sort roots by length (longest first) to avoid substring matching issues - indexed_roots = [(i, root) for i, root in enumerate(self.radarr_roots)] - indexed_roots.sort(key=lambda x: len(x[1]), reverse=True) - - # Try to match against configured Radarr root folders (longest first) - for original_index, radarr_root in indexed_roots: - normalized_root = self._normalize_separators(radarr_root) - if self.path_debug: - print(f"PATH_DEBUG: Checking radarr_root[{original_index}]: {normalized_root}") - if normalized_path.startswith(normalized_root + '/') or normalized_path == normalized_root: - if self.path_debug: - print(f"PATH_DEBUG: Match found! Index {original_index}") - # Map to corresponding movie path - if original_index < len(self.movie_paths): - container_root = self.movie_paths[original_index] - relative_path = normalized_path[len(normalized_root):].lstrip('/') - result = str(Path(container_root) / relative_path) if relative_path else container_root - if self.path_debug: - print(f"PATH_DEBUG: Mapped to: {result}") - return result + # Backward-compat aliases — existing call sites use these names. + def radarr_path_to_container_path(self, path: str) -> str: + return self.map(path) - if self.path_debug: - print(f"PATH_DEBUG: No match found, returning original: {radarr_path}") - # No fallback - if path mapping fails, return original and let validation catch it - return radarr_path - - def container_path_to_host_path(self, container_path: str) -> str: - """Convert container path back to host path if needed""" - # This might be needed for file operations - return container_path \ No newline at end of file + def sonarr_path_to_container_path(self, path: str) -> str: + return self.map(path) diff --git a/utils/file_utils.py b/utils/file_utils.py index 115d4b5..b986eb0 100644 --- a/utils/file_utils.py +++ b/utils/file_utils.py @@ -1,7 +1,4 @@ -""" -File utility functions for Chronarr -Common file operations to eliminate code duplication -""" +"""File utilities — path resolution, video file discovery, episode parsing.""" import glob import re from pathlib import Path @@ -10,65 +7,42 @@ from utils.logging import _log -# Video file extensions used throughout the application VIDEO_EXTENSIONS = {'.mkv', '.mp4', '.avi', '.m4v', '.mov', '.ts'} -# Episode pattern for TV series files EPISODE_PATTERN = re.compile( r'.*[sS](\d{1,2})[eE](\d{1,3}).*|.*(\d{1,2})x(\d{1,3}).*' ) def find_media_path_by_imdb_and_title( - title: str, - imdb_id: str, - search_paths: List[Path], + title: str, + imdb_id: str, + search_paths: List[Path], webhook_path: Optional[str] = None, - path_mapper = None + path_mapper=None ) -> Optional[Path]: - """ - Unified media path finder for both TV series and movies - - Args: - title: Media title to search for - imdb_id: IMDb ID to search for - search_paths: List of paths to search in (tv_paths or movie_paths) - webhook_path: Optional webhook path to try first - path_mapper: Optional path mapper for webhook path conversion - - Returns: - Path to media directory if found, None otherwise - """ - # Try webhook path first if provided + """Find a media directory by trying the webhook path first, then searching configured paths.""" + # The webhook payload often carries the exact path — try it before scanning directories. if webhook_path and path_mapper: try: - if hasattr(path_mapper, 'sonarr_path_to_container_path'): - container_path = path_mapper.sonarr_path_to_container_path(webhook_path) - elif hasattr(path_mapper, 'radarr_path_to_container_path'): - container_path = path_mapper.radarr_path_to_container_path(webhook_path) - else: - container_path = webhook_path - + container_path = path_mapper.map(webhook_path) path_obj = Path(container_path) if path_obj.exists(): return path_obj except Exception as e: _log("WARNING", f"Failed to process webhook path {webhook_path}: {e}") - # Search by IMDb ID or title in configured paths for media_path in search_paths: if not media_path.exists(): continue - - # Search by IMDb ID first (more reliable) + if imdb_id: - # Use proper glob pattern - escape brackets to match literal [imdb-ID] + # Escape brackets so glob treats them as literals, not character classes. pattern = str(media_path / f"*\\[imdb-{imdb_id}\\]*") matches = glob.glob(pattern) if matches: return Path(matches[0]) - - # Search by title as fallback + if title: title_clean = clean_title_for_search(title) for item in media_path.iterdir(): @@ -81,29 +55,11 @@ def find_media_path_by_imdb_and_title( def clean_title_for_search(title: str) -> str: - """ - Clean title for fuzzy matching - - Args: - title: Raw title string - - Returns: - Cleaned title for comparison - """ + """Strip spaces, dashes, and dots for fuzzy directory name matching.""" return title.lower().replace(" ", "").replace("-", "").replace(".", "") def find_video_files(directory: Path, recursive: bool = True) -> List[Path]: - """ - Find all video files in a directory - - Args: - directory: Directory to search - recursive: Whether to search recursively - - Returns: - List of video file paths - """ if not directory.exists(): return [] @@ -122,15 +78,6 @@ def find_video_files(directory: Path, recursive: bool = True) -> List[Path]: def extract_episode_info(filename: str) -> Optional[Tuple[int, int]]: - """ - Extract season and episode numbers from filename - - Args: - filename: Video filename to parse - - Returns: - Tuple of (season, episode) if found, None otherwise - """ match = EPISODE_PATTERN.match(filename) if not match: return None @@ -148,15 +95,7 @@ def extract_episode_info(filename: str) -> Optional[Tuple[int, int]]: def find_episodes_on_disk(series_path: Path) -> Dict[Tuple[int, int], List[Path]]: - """ - Find all episodes on disk and return mapping of (season, episode) -> [video_files] - - Args: - series_path: Path to series directory - - Returns: - Dictionary mapping (season, episode) tuples to lists of video files - """ + """Map (season, episode) tuples to their video files for a series directory.""" episodes = {} if not series_path.exists(): From 49fc08d10f0cbd3ad1b4f3186184ec8629d89379 Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Tue, 25 Aug 2026 09:09:54 -0400 Subject: [PATCH 06/45] feat: wire instance through webhook routing and processors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- api/routes.py | 89 +++++++----- main.py | 70 ++++----- processors/movie_processor.py | 154 +++++++------------- processors/tv_processor.py | 258 +++++++++++++--------------------- webhooks/webhook_batcher.py | 35 ++--- 5 files changed, 242 insertions(+), 364 deletions(-) diff --git a/api/routes.py b/api/routes.py index d1d3352..4416074 100644 --- a/api/routes.py +++ b/api/routes.py @@ -80,8 +80,8 @@ async def _verify_webhook_signature(request: Request, body: bytes, secret: str, # Route Handlers # --------------------------- -async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, dependencies: dict): - """Handle Sonarr webhooks""" +async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, dependencies: dict, instance: str = "sonarr"): + """Handle Sonarr webhooks — instance name comes from the URL path.""" tv_processor = dependencies["tv_processor"] batcher = dependencies["batcher"] config = dependencies["config"] @@ -267,14 +267,14 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de processing_mode = "targeted" _log("INFO", f"Forcing targeted mode for {len(episodes_data)} episode(s)") - # Add to batch queue with TV-prefixed key to avoid movie conflicts tv_batch_key = f"tv:{imdb_id}" webhook_dict = { 'path': str(series_path), 'series_info': series_info, 'event_type': webhook.eventType, - 'episodes': episodes_data, # Include enhanced episode data for targeted processing - 'processing_mode': processing_mode # Use forced targeted mode when appropriate + 'episodes': episodes_data, + 'processing_mode': processing_mode, + 'instance': instance, } batcher.add_webhook(tv_batch_key, webhook_dict, 'tv') @@ -285,9 +285,10 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de raise HTTPException(status_code=422, detail=f"Invalid webhook: {e}") -async def radarr_webhook(request: Request, background_tasks: BackgroundTasks, dependencies: dict): - """Handle Radarr webhooks""" - path_mapper = dependencies["path_mapper"] +async def radarr_webhook(request: Request, background_tasks: BackgroundTasks, dependencies: dict, instance: str = "radarr"): + """Handle Radarr webhooks — instance name comes from the URL path.""" + registry = dependencies.get("registry") + path_mapper = registry.radarr_mapper(instance) if registry else dependencies.get("path_mapper") batcher = dependencies["batcher"] config = dependencies["config"] @@ -333,17 +334,16 @@ async def radarr_webhook(request: Request, background_tasks: BackgroundTasks, de # IMDb ID won't be in the path for standard Radarr folder naming — omit the check - # Create movie-specific webhook data with proper path validation movie_webhook_data = { - 'path': container_path, # Use verified container path + 'path': container_path, 'movie_info': movie_data, 'event_type': payload.get('eventType'), - 'original_payload': payload + 'original_payload': payload, + 'instance': instance, } - - # Add to batch queue with movie-prefixed key to avoid TV conflicts + movie_batch_key = f"movie:{imdb_id}" - _log("DEBUG", f"Adding Radarr webhook to batch: key={movie_batch_key}, movie_title={movie_data.get('title', 'Unknown')}") + _log("DEBUG", f"Adding Radarr webhook to batch: key={movie_batch_key}, movie_title={movie_data.get('title', 'Unknown')}, instance={instance}") batcher.add_webhook(movie_batch_key, movie_webhook_data, "movie") return {"status": "success", "message": f"Radarr webhook queued for {movie_batch_key}"} @@ -2990,30 +2990,47 @@ async def get_populate_status(): def register_routes(app, dependencies: dict): - """ - Register all routes with the FastAPI app - - Args: - app: FastAPI application instance - dependencies: Dictionary containing: - - db: ChronarrDatabase instance - - nfo_manager: NFOManager instance - - path_mapper: PathMapper instance - - tv_processor: TVProcessor instance - - movie_processor: MovieProcessor instance - - batcher: WebhookBatcher instance - - start_time: Application start time - - config: ChronarrConfig instance - - version: Application version string - """ - + """Register all routes. Webhook routes are registered per-instance dynamically.""" + + # Backward-compat aliases for single-instance users: /webhook/sonarr, /webhook/radarr @app.post("/webhook/sonarr") - async def _sonarr_webhook(request: Request, background_tasks: BackgroundTasks): - return await sonarr_webhook(request, background_tasks, dependencies) + async def _sonarr_webhook_default(request: Request, background_tasks: BackgroundTasks): + return await sonarr_webhook(request, background_tasks, dependencies, instance="sonarr") + + @app.post("/webhook/radarr") + async def _radarr_webhook_default(request: Request, background_tasks: BackgroundTasks): + return await radarr_webhook(request, background_tasks, dependencies, instance="radarr") + + # Per-instance webhook routes derived from discovered instance names. + # Each instance named "radarr_4k" gets /radarr_4k/webhook; the default "radarr" + # instance gets /radarr/webhook in addition to the /webhook/radarr alias above. + registry = dependencies.get("registry") + if registry: + for name in registry.radarr_names: + _inst = name # capture for closure + + async def _radarr_instance_handler(request: Request, background_tasks: BackgroundTasks, _n=_inst): + return await radarr_webhook(request, background_tasks, dependencies, instance=_n) + + app.add_api_route( + f"/{name}/webhook", + _radarr_instance_handler, + methods=["POST"], + name=f"radarr_webhook_{name}", + ) - @app.post("/webhook/radarr") - async def _radarr_webhook(request: Request, background_tasks: BackgroundTasks): - return await radarr_webhook(request, background_tasks, dependencies) + for name in registry.sonarr_names: + _inst = name + + async def _sonarr_instance_handler(request: Request, background_tasks: BackgroundTasks, _n=_inst): + return await sonarr_webhook(request, background_tasks, dependencies, instance=_n) + + app.add_api_route( + f"/{name}/webhook", + _sonarr_instance_handler, + methods=["POST"], + name=f"sonarr_webhook_{name}", + ) @app.post("/webhook/maintainarr") async def _maintainarr_webhook(request: Request, background_tasks: BackgroundTasks): diff --git a/main.py b/main.py index c7c184b..d0f1720 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,5 @@ #!/usr/bin/env python3 -""" -Chronarr Core - Automated NFO file management and processing engine -Core processing container with webhooks, scanning, and database management -Web interface separated to chronarr-web container -""" +"""Chronarr core — webhook processor, scanner, and scheduling engine.""" import os import sys import signal @@ -22,13 +18,9 @@ # Import core components from core.database import ChronarrDatabase -# from core.nfo_manager import NFOManager # Phase 3: Removed - no longer needed -from core.path_mapper import PathMapper +from core.instance_registry import build_registry -# Import clients -from clients.external_clients import ExternalClientManager -from clients.radarr_db_client import RadarrDbClient -from clients.sonarr_db_client import SonarrDbClient +from core.path_mapper import PathMapper # Import processors from processors.tv_processor import TVProcessor @@ -87,45 +79,43 @@ async def lifespan(app: FastAPI): return app +def _noop_mapper() -> PathMapper: + """Return an empty PathMapper — used when a default instance isn't configured.""" + return PathMapper(root_folders=[], container_paths=[]) + + def initialize_components(): - """Initialize all application components""" start_time = datetime.now(timezone.utc) - # Initialize core components db = ChronarrDatabase(config=config) - # nfo_manager = NFOManager(config.manager_brand, config.debug) # Phase 3: Removed - path_mapper = PathMapper(config) - # Initialize processors (nfo_manager=None for backward compatibility) - tv_processor = TVProcessor(db, None, path_mapper) - movie_processor = MovieProcessor(db, None, path_mapper) + # Build the instance registry — one client + one path mapper per configured + # Radarr/Sonarr instance, derived from env vars at startup. + registry = build_registry(config) - # Initialize webhook batcher (no longer needs nfo_manager - Phase 3) - batcher = WebhookBatcher(nfo_manager=None) - batcher.set_processors(tv_processor, movie_processor) + # Processors get the default instance's client and mapper. Multi-instance + # requests pass instance name explicitly at process time; the processors look + # up the right client from the registry during their DB writes. + default_radarr_mapper = registry.radarr_mapper("radarr") + default_sonarr_mapper = registry.sonarr_mapper("sonarr") - # Initialize optional Radarr/Sonarr database clients for orphaned record cleanup - radarr_db_client = None - sonarr_db_client = None - - try: - radarr_db_client = RadarrDbClient.from_env() - if radarr_db_client: - _log("INFO", "Radarr database client initialized for orphaned record cleanup") - except Exception as e: - _log("WARNING", f"Could not initialize Radarr database client: {e}") + tv_processor = TVProcessor( + db, None, + default_sonarr_mapper or _noop_mapper(), + sonarr_client=registry.sonarr("sonarr"), + ) + movie_processor = MovieProcessor( + db, None, + default_radarr_mapper or _noop_mapper(), + radarr_client=registry.radarr("radarr"), + ) - try: - sonarr_db_client = SonarrDbClient.from_env() - if sonarr_db_client: - _log("INFO", "Sonarr database client initialized for orphaned record cleanup") - except Exception as e: - _log("WARNING", f"Could not initialize Sonarr database client: {e}") + batcher = WebhookBatcher(nfo_manager=None) + batcher.set_processors(tv_processor, movie_processor) return { "db": db, - # "nfo_manager": nfo_manager, # Phase 3: Removed - "path_mapper": path_mapper, + "registry": registry, "tv_processor": tv_processor, "movie_processor": movie_processor, "batcher": batcher, @@ -133,8 +123,6 @@ def initialize_components(): "config": config, "version": get_version(), "shutdown_event": shutdown_event, - "radarr_db_client": radarr_db_client, - "sonarr_db_client": sonarr_db_client } diff --git a/processors/movie_processor.py b/processors/movie_processor.py index 9a1c685..4f87004 100644 --- a/processors/movie_processor.py +++ b/processors/movie_processor.py @@ -1,7 +1,4 @@ -""" -Movie Processor for Chronarr -Handles movie processing and metadata management -""" +"""Movie processing — date discovery and DB writes for Radarr-managed movies.""" import os import re import xml.etree.ElementTree as ET @@ -67,34 +64,31 @@ def convert_utc_to_local(utc_iso_string: str) -> str: class MovieProcessor: - """Handles movie processing""" - def __init__(self, db: ChronarrDatabase, nfo_manager, path_mapper: PathMapper): - # nfo_manager parameter kept for backward compatibility but no longer used (Phase 3) + def __init__(self, db: ChronarrDatabase, nfo_manager, path_mapper: PathMapper, radarr_client=None): + # nfo_manager kept for call-site compat but is unused self.db = db self.path_mapper = path_mapper - # Try database client first, fall back to API client - self.radarr_db = None - self.radarr_api = None - self.using_db = False - - try: - self.radarr_db = RadarrDbClient.from_env() - if self.radarr_db: - _log("INFO", "Using Radarr direct database access") - self.radarr = self.radarr_db # Primary client + if radarr_client: + # Pre-built client from InstanceRegistry (preferred path) + self.radarr = radarr_client + self.using_db = isinstance(radarr_client, RadarrDbClient) + else: + # Fallback: construct from env vars (single-instance legacy path) + try: + self.radarr = RadarrDbClient.from_env() + if not self.radarr: + raise Exception("Database not configured") self.using_db = True - else: - raise Exception("Database not configured") - except Exception: - # Fall back to API client - self.radarr_api = RadarrClient( - os.environ.get("RADARR_URL", ""), - os.environ.get("RADARR_API_KEY", "") - ) - self.radarr = self.radarr_api # Primary client - _log("INFO", "Using Radarr API client (database not configured)") + _log("INFO", "Using Radarr direct database access") + except Exception: + self.radarr = RadarrClient( + os.environ.get("RADARR_URL", ""), + os.environ.get("RADARR_API_KEY", "") + ) + self.using_db = False + _log("INFO", "Using Radarr API client (database not configured)") self.external_clients = ExternalClientManager() @@ -108,70 +102,32 @@ def find_movie_path(self, movie_title: str, imdb_id: str, radarr_path: str = Non path_mapper=self.path_mapper ) - def should_skip_movie(self, imdb_id: str, movie_name: str = "") -> Tuple[bool, str]: - """ - Determine if we should skip processing this movie based on completion status - - Args: - imdb_id: Movie IMDb ID - movie_name: Movie name for logging - - Returns: - (should_skip: bool, reason: str) - """ + def should_skip_movie(self, imdb_id: str, movie_name: str = "", instance: str = 'radarr') -> Tuple[bool, str]: + """Return (should_skip, reason) — skip when date, source, and video file are all present.""" try: - with self.db.get_connection() as conn: - cursor = conn.cursor() - - if self.db.db_type == "postgresql": - cursor.execute(""" - SELECT dateadded, source, has_video_file - FROM movies - WHERE imdb_id = %s - """, (imdb_id,)) - else: - cursor.execute(""" - SELECT dateadded, source, has_video_file - FROM movies - WHERE imdb_id = ? - """, (imdb_id,)) - - result = cursor.fetchone() - if not result: - return False, "No database record found" - - if self.db.db_type == "postgresql": - dateadded = result['dateadded'] - source = result['source'] - has_video_file = result['has_video_file'] - else: - dateadded = result[0] if result[0] else None - source = result[1] if result[1] else None - has_video_file = result[2] if result[2] else False - - # Skip if: - # 1. Movie has a valid dateadded timestamp - # 2. Source is valid (not 'unknown' or 'no_valid_date_source') - # 3. Has video file on disk - if (dateadded and - source and - source not in ['unknown', 'no_valid_date_source'] and - has_video_file): - return True, f"Complete: Has valid date '{dateadded}' from source '{source}'" - elif not dateadded: - return False, "Missing dateadded" - elif not source or source in ['unknown', 'no_valid_date_source']: - return False, f"Invalid source: '{source}'" - elif not has_video_file: - return False, "No video file detected" - else: - return False, "Incomplete movie data" - + result = self.db.get_movie_dates(imdb_id, instance) + if not result: + return False, "No database record found" + + dateadded = result.get('dateadded') + source = result.get('source') + has_video_file = result.get('has_video_file', False) + + if dateadded and source and source not in ('unknown', 'no_valid_date_source') and has_video_file: + return True, f"Complete: Has valid date '{dateadded}' from source '{source}'" + if not dateadded: + return False, "Missing dateadded" + if not source or source in ('unknown', 'no_valid_date_source'): + return False, f"Invalid source: '{source}'" + if not has_video_file: + return False, "No video file detected" + return False, "Incomplete movie data" + except Exception as e: _log("ERROR", f"Error checking movie completion for {imdb_id}: {e}") return False, f"Error checking completion: {e}" - def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan: bool = False, scan_mode: str = "smart", shutdown_event=None, imdb_id: str = None) -> str: + def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan: bool = False, scan_mode: str = "smart", shutdown_event=None, imdb_id: str = None, instance: str = 'radarr') -> str: """Process a movie directory""" # Prefer the IMDb ID passed in (e.g. from webhook payload) over filesystem detection. # Filesystem detection only works when the ID is embedded in the folder/file name, @@ -192,11 +148,10 @@ def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan # Check if we should skip this movie (unless forced, webhook mode, or incomplete mode) # Skip database optimization for incomplete mode since we need to check NFO files first if not force_scan and not webhook_mode and scan_mode != "incomplete": - should_skip, reason = self.should_skip_movie(imdb_id, movie_path.name) + should_skip, reason = self.should_skip_movie(imdb_id, movie_path.name, instance=instance) if should_skip: _log("INFO", f"⏭️ SKIPPING MOVIE: {movie_path.name} [{imdb_id}] - {reason}") - # Still update the movie record to track that we've seen it - self.db.upsert_movie(imdb_id, str(movie_path)) + self.db.upsert_movie(imdb_id, str(movie_path), instance=instance) return "skipped" else: _log("INFO", f"🎬 PROCESSING MOVIE: {movie_path.name} [{imdb_id}] - {reason}") @@ -211,7 +166,7 @@ def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan return "shutdown" # Update database - self.db.upsert_movie(imdb_id, str(movie_path)) + self.db.upsert_movie(imdb_id, str(movie_path), instance=instance) # Check for video files video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") @@ -221,13 +176,12 @@ def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan _log("WARNING", f"No video files found in: {movie_path} - skipping database entry") return "no_video_files" - # For incomplete mode: Start with NFO check to find missing dateadded elements if scan_mode == "incomplete": - return self._process_movie_nfo_first(movie_path, imdb_id, shutdown_event) + return self._process_movie_nfo_first(movie_path, imdb_id, shutdown_event, instance=instance) # For smart/full modes: Use database-first optimization # TIER 1: Check database first (fastest - local lookup) - existing = self.db.get_movie_dates(imdb_id) + existing = self.db.get_movie_dates(imdb_id, instance) _log("DEBUG", f"Database lookup for {imdb_id}: {existing}") # Enhanced debug for database state @@ -307,7 +261,7 @@ def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan # Skip remaining processing if no valid date found and file dates disabled if final_dateadded is None: _log("WARNING", f"Movie {movie_path.name} - no valid date source available, but NFO was still processed") - self.db.upsert_movie_dates(imdb_id, released, None, source, True, title=title, year=year) + self.db.upsert_movie_dates(imdb_id, released, None, source, True, title=title, year=year, instance=instance) return "processed" # Update dateadded and source for the rest of processing @@ -325,7 +279,7 @@ def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan # Save to database _log("DEBUG", f"About to save to database: imdb_id={imdb_id}, dateadded={dateadded}") try: - self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True, title=title, year=year) + self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True, title=title, year=year, instance=instance) _log("DEBUG", f"Database save completed for {imdb_id}") except Exception as e: _log("ERROR", f"Database save failed for {imdb_id}: {e}") @@ -334,18 +288,15 @@ def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan _log("INFO", f"Completed processing movie: {movie_path.name} (source: {source})") return "processed" - def _process_movie_nfo_first(self, movie_path: Path, imdb_id: str, shutdown_event=None) -> str: + def _process_movie_nfo_first(self, movie_path: Path, imdb_id: str, shutdown_event=None, instance: str = 'radarr') -> str: """Process movie for incomplete mode: Database-first then API (NFO checks removed in Phase 2)""" _log("INFO", f"🔍 INCOMPLETE MODE: Checking movie for missing data: {movie_path.name}") - # Check for shutdown signal if shutdown_event and shutdown_event.is_set(): _log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie processing: {movie_path.name}") return "shutdown" - # STEP 1: Check database for existing data (Phase 2: NFO check removed) - _log("DEBUG", f"STEP 1 - Checking database for existing data") - existing = self.db.get_movie_dates(imdb_id) + existing = self.db.get_movie_dates(imdb_id, instance) if existing and existing.get("dateadded") and existing.get("source") != "no_valid_date_source": # Found in database - data is complete @@ -392,12 +343,11 @@ def _process_movie_nfo_first(self, movie_path: Path, imdb_id: str, shutdown_even dateadded, source, released = self._get_file_mtime_date(movie_path) _log("INFO", f"Using file mtime as fallback: {dateadded}") - # Save to database only (NFO operations removed in Phase 1) if dateadded: radarr_meta = self.radarr.movie_by_imdb(imdb_id) if self.radarr else None title = radarr_meta.get("title") if radarr_meta else None year = radarr_meta.get("year") if radarr_meta else None - self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True, title=title, year=year) + self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True, title=title, year=year, instance=instance) _log("INFO", f"🔍 INCOMPLETE MODE COMPLETE: {movie_path.name} (source: {source})") return "processed" diff --git a/processors/tv_processor.py b/processors/tv_processor.py index ca9c76c..c9f621c 100644 --- a/processors/tv_processor.py +++ b/processors/tv_processor.py @@ -1,7 +1,4 @@ -""" -TV Series Processor for Chronarr -Handles TV series processing and episode management with async I/O support -""" +"""TV series processing — episode date discovery and DB writes for Sonarr-managed series.""" import os import re import time @@ -30,34 +27,37 @@ class TVProcessor: - """Handles TV series processing""" - def __init__(self, db: ChronarrDatabase, nfo_manager, path_mapper: PathMapper): - # nfo_manager parameter kept for backward compatibility but no longer used (Phase 3) + def __init__(self, db: ChronarrDatabase, nfo_manager, path_mapper: PathMapper, sonarr_client=None): + # nfo_manager kept for call-site compat but is unused self.db = db self.path_mapper = path_mapper - # Try database client first, fall back to API client - self.sonarr_db = None - self.sonarr_api = None - self.using_db = False - - try: - self.sonarr_db = SonarrDbClient.from_env() - if self.sonarr_db: - _log("INFO", "Using Sonarr direct database access") - self.sonarr = self.sonarr_db # Primary client + if sonarr_client: + # Pre-built client from InstanceRegistry (preferred path) + self.sonarr = sonarr_client + self.sonarr_db = sonarr_client if isinstance(sonarr_client, SonarrDbClient) else None + self.sonarr_api = sonarr_client if isinstance(sonarr_client, SonarrClient) else None + self.using_db = isinstance(sonarr_client, SonarrDbClient) + else: + # Fallback: construct from env vars (single-instance legacy path) + self.sonarr_db = None + self.sonarr_api = None + self.using_db = False + try: + self.sonarr_db = SonarrDbClient.from_env() + if not self.sonarr_db: + raise Exception("Database not configured") + self.sonarr = self.sonarr_db self.using_db = True - else: - raise Exception("Database not configured") - except Exception: - # Fall back to API client - self.sonarr_api = SonarrClient( - os.environ.get("SONARR_URL", ""), - os.environ.get("SONARR_API_KEY", "") - ) - self.sonarr = self.sonarr_api # Primary client - _log("INFO", "Using Sonarr API client (database not configured)") + _log("INFO", "Using Sonarr direct database access") + except Exception: + self.sonarr_api = SonarrClient( + os.environ.get("SONARR_URL", ""), + os.environ.get("SONARR_API_KEY", "") + ) + self.sonarr = self.sonarr_api + _log("INFO", "Using Sonarr API client (database not configured)") self.external_clients = ExternalClientManager() @@ -86,99 +86,50 @@ def find_series_path(self, series_title: str, imdb_id: str, sonarr_path: str = N path_mapper=self.path_mapper ) - def should_skip_series_fast(self, imdb_id: str, series_name: str = "") -> Tuple[bool, str, int]: - """ - Fast preliminary check to skip series without filesystem scan - - Args: - imdb_id: Series IMDb ID - series_name: Series name for logging - - Returns: - (should_skip: bool, reason: str, episodes_in_db: int) - """ + def _episode_completion_counts(self, imdb_id: str, instance: str) -> Tuple[int, int]: + """Return (total_in_db, complete_episodes) for an (imdb_id, instance) pair.""" + with self.db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT + COUNT(*) AS total_in_db, + COUNT(CASE WHEN dateadded IS NOT NULL AND source IS NOT NULL + AND source NOT IN ('unknown', 'no_valid_date_source') THEN 1 END) AS complete_episodes + FROM episodes + WHERE imdb_id = %s AND instance = %s + """, (imdb_id, instance)) + row = cursor.fetchone() + if not row: + return 0, 0 + return row['total_in_db'], row['complete_episodes'] + + def should_skip_series_fast(self, imdb_id: str, series_name: str = "", instance: str = 'sonarr') -> Tuple[bool, str, int]: + """Prelim skip check without a filesystem scan — returns (skip, reason, count_in_db).""" try: - with self.db.get_connection() as conn: - cursor = conn.cursor() - - # Check if we have complete episodes in database - cursor.execute(""" - SELECT - COUNT(*) as total_in_db, - COUNT(CASE WHEN dateadded IS NOT NULL AND source IS NOT NULL AND source != 'unknown' AND source != 'no_valid_date_source' THEN 1 END) as complete_episodes - FROM episodes - WHERE imdb_id = %s - """, (imdb_id,)) - - result = cursor.fetchone() - if not result: - return False, "No database records found", 0 - - total_in_db = result['total_in_db'] - complete_episodes = result['complete_episodes'] - - # Skip if we have episodes and all are complete - # We'll verify disk count later if needed - if total_in_db > 0 and complete_episodes == total_in_db: - return True, f"Likely complete: {complete_episodes} episodes in DB all have valid dates", total_in_db - else: - return False, f"Needs checking: {complete_episodes}/{total_in_db} episodes complete in DB", total_in_db - + total_in_db, complete_episodes = self._episode_completion_counts(imdb_id, instance) + if total_in_db > 0 and complete_episodes == total_in_db: + return True, f"Likely complete: {complete_episodes} episodes in DB all have valid dates", total_in_db + return False, f"Needs checking: {complete_episodes}/{total_in_db} episodes complete in DB", total_in_db except Exception as e: _log("ERROR", f"Error in fast series check for {imdb_id}: {e}") return False, f"Error in fast check: {e}", 0 - def should_skip_series(self, imdb_id: str, episodes_on_disk: int, series_name: str = "") -> Tuple[bool, str]: - """ - Determine if we should skip processing this series based on completion status - - Args: - imdb_id: Series IMDb ID - episodes_on_disk: Number of episodes found on disk - series_name: Series name for logging - - Returns: - (should_skip: bool, reason: str) - """ + def should_skip_series(self, imdb_id: str, episodes_on_disk: int, series_name: str = "", instance: str = 'sonarr') -> Tuple[bool, str]: + """Return (should_skip, reason) — skip when DB count, disk count, and date completeness all match.""" try: - with self.db.get_connection() as conn: - cursor = conn.cursor() - - # PostgreSQL-only query - cursor.execute(""" - SELECT - COUNT(*) as total_in_db, - COUNT(CASE WHEN dateadded IS NOT NULL AND source IS NOT NULL AND source != 'unknown' AND source != 'no_valid_date_source' THEN 1 END) as complete_episodes - FROM episodes - WHERE imdb_id = %s - """, (imdb_id,)) - - result = cursor.fetchone() - if not result: - return False, "No database records found" - - # PostgreSQL RealDictCursor returns dict-like objects - total_in_db = result['total_in_db'] - complete_episodes = result['complete_episodes'] - - # Skip if: - # 1. We have episodes in database - # 2. Database count matches disk count (no missing episodes) - # 3. All episodes have valid dates and sources - if total_in_db > 0 and total_in_db == episodes_on_disk and complete_episodes == episodes_on_disk: - return True, f"Complete: {complete_episodes}/{episodes_on_disk} episodes have valid dates" - elif total_in_db == 0: - return False, f"New series: No episodes in database" - elif total_in_db != episodes_on_disk: - return False, f"Disk mismatch: {total_in_db} in DB vs {episodes_on_disk} on disk" - else: - return False, f"Incomplete: {complete_episodes}/{episodes_on_disk} episodes have valid dates" - + total_in_db, complete_episodes = self._episode_completion_counts(imdb_id, instance) + if total_in_db > 0 and total_in_db == episodes_on_disk and complete_episodes == episodes_on_disk: + return True, f"Complete: {complete_episodes}/{episodes_on_disk} episodes have valid dates" + if total_in_db == 0: + return False, "New series: No episodes in database" + if total_in_db != episodes_on_disk: + return False, f"Disk mismatch: {total_in_db} in DB vs {episodes_on_disk} on disk" + return False, f"Incomplete: {complete_episodes}/{episodes_on_disk} episodes have valid dates" except Exception as e: _log("ERROR", f"Error checking series completion for {imdb_id}: {e}") return False, f"Error checking completion: {e}" - def process_series(self, series_path: Path, force_scan: bool = False, scan_mode: str = "smart", imdb_id: str = None) -> str: + def process_series(self, series_path: Path, force_scan: bool = False, scan_mode: str = "smart", imdb_id: str = None, instance: str = 'sonarr') -> str: """Process a TV series directory""" if not imdb_id: imdb_id = parse_imdb_from_path(series_path) @@ -191,52 +142,39 @@ def process_series(self, series_path: Path, force_scan: bool = False, scan_mode: # Fast check first - avoid expensive filesystem scan if possible # Skip fast optimization for incomplete mode since we need to check NFO files first if not force_scan and scan_mode != "incomplete": - should_skip_fast, reason_fast, episodes_in_db = self.should_skip_series_fast(imdb_id, series_path.name) + should_skip_fast, reason_fast, episodes_in_db = self.should_skip_series_fast(imdb_id, series_path.name, instance=instance) if should_skip_fast: _log("INFO", f"⚡ FAST SKIP: {series_path.name} [{imdb_id}] - {reason_fast}") - # Still update the series record to track that we've seen it - self.db.upsert_series(imdb_id, str(series_path)) + self.db.upsert_series(imdb_id, str(series_path), instance=instance) return "skipped" - - # Need filesystem scan - either force_scan=True or series not complete in DB + disk_episodes = find_episodes_on_disk(series_path) _log("INFO", f"Found {len(disk_episodes)} episodes on disk") - - # Final skip check with actual episode count (unless forced) + if not force_scan: - should_skip, reason = self.should_skip_series(imdb_id, len(disk_episodes), series_path.name) + should_skip, reason = self.should_skip_series(imdb_id, len(disk_episodes), series_path.name, instance=instance) if should_skip: _log("INFO", f"⏭️ SKIPPING SERIES: {series_path.name} [{imdb_id}] - {reason}") - # Still update the series record to track that we've seen it - self.db.upsert_series(imdb_id, str(series_path)) + self.db.upsert_series(imdb_id, str(series_path), instance=instance) return "skipped" else: _log("INFO", f"📺 PROCESSING SERIES: {series_path.name} [{imdb_id}] - {reason}") else: _log("INFO", f"🔄 FORCE PROCESSING SERIES: {series_path.name} [{imdb_id}] - Force scan enabled") - - # Update database - self.db.upsert_series(imdb_id, str(series_path)) - - # Get episode dates - episode_dates = self._gather_episode_dates(series_path, imdb_id, disk_episodes) - - # Process episodes with periodic yielding for non-blocking operation + + self.db.upsert_series(imdb_id, str(series_path), instance=instance) + + episode_dates = self._gather_episode_dates(series_path, imdb_id, disk_episodes, instance=instance) + episode_count = 0 for (season, episode), (aired, dateadded, source) in episode_dates.items(): if (season, episode) in disk_episodes: episode_count += 1 - - # NFO file operations removed - database is now the single source of truth - # (Phase 1: Remove NFO file write operations) - - # Save to database try: - self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True) + self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True, instance=instance) _log("DEBUG", f"S{season:02d}E{episode:02d}: Database record saved successfully") except Exception as e: _log("ERROR", f"S{season:02d}E{episode:02d}: Database write failed: {e}") - # Continue processing other episodes # Skip season.nfo and tvshow.nfo creation - focus only on episode NFOs @@ -250,7 +188,7 @@ def _extract_series_title_from_path(self, series_path: Path) -> Optional[str]: return extract_title_from_directory_name(series_path.name) - def _gather_episode_dates(self, series_path: Path, imdb_id: str, disk_episodes: Dict[Tuple[int, int], List[Path]], scan_mode: str = "smart") -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]: + def _gather_episode_dates(self, series_path: Path, imdb_id: str, disk_episodes: Dict[Tuple[int, int], List[Path]], scan_mode: str = "smart", instance: str = 'sonarr') -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]: """Gather episode air dates and date added information with optimization based on scan mode""" _log("INFO", f"🎯 GATHERING EPISODE DATES for {imdb_id}: {len(disk_episodes)} episodes on disk (mode: {scan_mode})") episode_dates = {} @@ -258,7 +196,7 @@ def _gather_episode_dates(self, series_path: Path, imdb_id: str, disk_episodes: # For incomplete mode: Start with NFO check to find missing dateadded elements if scan_mode == "incomplete": - return self._gather_episode_dates_nfo_first(series_path, imdb_id, disk_episodes) + return self._gather_episode_dates_nfo_first(series_path, imdb_id, disk_episodes, instance=instance) # For smart/full modes: Use database-first optimization # TIER 1: Check database first for existing dates (fastest) @@ -267,8 +205,7 @@ def _gather_episode_dates(self, series_path: Path, imdb_id: str, disk_episodes: episodes_needing_nfo_check = [] for (season, episode) in disk_episodes: - # Try database first - this is much faster than API calls - db_result = self.db.get_episode_date(imdb_id, season, episode) + db_result = self.db.get_episode_date(imdb_id, season, episode, instance) if db_result and db_result.get('dateadded'): # Found in database - use cached data @@ -355,7 +292,7 @@ def _gather_episode_dates(self, series_path: Path, imdb_id: str, disk_episodes: return episode_dates - def _gather_episode_dates_nfo_first(self, series_path: Path, imdb_id: str, disk_episodes: Dict[Tuple[int, int], List[Path]]) -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]: + def _gather_episode_dates_nfo_first(self, series_path: Path, imdb_id: str, disk_episodes: Dict[Tuple[int, int], List[Path]], instance: str = 'sonarr') -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]: """Gather episode dates for incomplete mode: Database-first then API (NFO checks removed in Phase 2)""" _log("INFO", f"🔍 INCOMPLETE MODE: Checking {len(disk_episodes)} episodes for missing data") episode_dates = {} @@ -366,10 +303,9 @@ def _gather_episode_dates_nfo_first(self, series_path: Path, imdb_id: str, disk_ db_hits = 0 for (season, episode) in disk_episodes: - db_result = self.db.get_episode_date(imdb_id, season, episode) + db_result = self.db.get_episode_date(imdb_id, season, episode, instance) if db_result and db_result.get('dateadded'): - # Found in database - use cached data aired = db_result.get('aired') dateadded = db_result.get('dateadded') source = db_result.get('source', 'database_cache') @@ -377,7 +313,6 @@ def _gather_episode_dates_nfo_first(self, series_path: Path, imdb_id: str, disk_ db_hits += 1 _log("DEBUG", f"S{season:02d}E{episode:02d}: Database has dateadded={dateadded}") else: - # Not in database or incomplete - needs API lookup episodes_needing_api_lookup.append((season, episode)) _log("INFO", f"Database hits: {db_hits}/{len(disk_episodes)} episodes. Need API lookup: {len(episodes_needing_api_lookup)}") @@ -426,9 +361,8 @@ def _gather_episode_dates_nfo_first(self, series_path: Path, imdb_id: str, disk_ source = f"{source}_fallback" if source != "unknown" else "aired_fallback" _log("DEBUG", f"S{season:02d}E{episode:02d}: Using aired date as dateadded fallback: {dateadded}") - # Save to database for future lookups if dateadded or aired: - self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True) + self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True, instance=instance) episode_dates[(season, episode)] = (aired, dateadded, source) @@ -780,23 +714,22 @@ async def _process_series_with_semaphore(series_path: Path) -> Dict[str, Any]: return processed_results - def process_webhook_episodes(self, series_path: Path, webhook_episodes: List[Dict[str, Any]], imdb_id: str = None) -> None: - """Process only the specific episodes mentioned in a webhook (targeted mode)""" + def process_webhook_episodes(self, series_path: Path, webhook_episodes: List[Dict[str, Any]], imdb_id: str = None, instance: str = 'sonarr') -> None: + """Process only the specific episodes mentioned in a webhook (targeted mode).""" if not imdb_id: imdb_id = parse_imdb_from_path(series_path) if not imdb_id: _log("ERROR", f"No IMDb ID found in series path: {series_path}") return - + if not webhook_episodes: _log("WARNING", f"No episodes in webhook, falling back to series processing: {series_path}") - self.process_series(series_path) + self.process_series(series_path, instance=instance) return - + _log("INFO", f"Processing {len(webhook_episodes)} webhook episodes for: {series_path.name} (IMDb: {imdb_id})") - - # Update database - self.db.upsert_series(imdb_id, str(series_path)) + + self.db.upsert_series(imdb_id, str(series_path), instance=instance) # Get enhanced metadata from Sonarr series_metadata = self._get_sonarr_series_metadata(imdb_id) @@ -830,17 +763,15 @@ def process_webhook_episodes(self, series_path: Path, webhook_episodes: List[Dic # Get episode date information - webhook processing prioritizes existing DB entries _log("DEBUG", f"Processing webhook episode: IMDb={imdb_id}, S{season_num:02d}E{episode_num:02d}") - aired, dateadded, source = self._get_webhook_episode_date(imdb_id, season_num, episode_num, series_metadata, webhook_episode) + aired, dateadded, source = self._get_webhook_episode_date(imdb_id, season_num, episode_num, series_metadata, webhook_episode, instance=instance) enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num, season_dir) if series_metadata else self._get_episode_metadata(None, season_num, episode_num, season_dir) # NFO file operations removed - database is now the single source of truth # (Phase 1: Remove NFO file write operations) - # Save to database - self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True) - - # Verify database entry was saved (debug) - verification = self.db.get_episode_date(imdb_id, season_num, episode_num) + self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True, instance=instance) + + verification = self.db.get_episode_date(imdb_id, season_num, episode_num, instance) if verification: _log("DEBUG", f"Verified database entry saved: S{season_num:02d}E{episode_num:02d} -> {verification['dateadded']}") else: @@ -973,7 +904,7 @@ def _extract_title_from_filename(self, season_num: int, episode_num: int, season return None - def _get_webhook_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict[str, Any]] = None, webhook_episode: Optional[Dict[str, Any]] = None) -> Tuple[Optional[str], Optional[str], str]: + def _get_webhook_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict[str, Any]] = None, webhook_episode: Optional[Dict[str, Any]] = None, instance: str = 'sonarr') -> Tuple[Optional[str], Optional[str], str]: """ Get episode date for webhook processing - database-first approach. @@ -988,11 +919,11 @@ def _get_webhook_episode_date(self, imdb_id: str, season_num: int, episode_num: This prevents webhooks from overriding existing good data. """ - # STEP 1: Check Chronarr database first + # Database-first: use existing date to avoid re-processing existing_entry = None existing_date = None try: - existing_entry = self.db.get_episode_date(imdb_id, season_num, episode_num) + existing_entry = self.db.get_episode_date(imdb_id, season_num, episode_num, instance) if existing_entry and existing_entry.get('dateadded'): existing_date = existing_entry['dateadded'] _log("INFO", f"Found existing date in database for S{season_num:02d}E{episode_num:02d}: {existing_date}") @@ -1057,9 +988,8 @@ def _get_webhook_episode_date(self, imdb_id: str, season_num: int, episode_num: if not hasattr(self, 'sonarr'): _log("DEBUG", f"No sonarr client available") - # STEP 2: No import history found - this is likely a genuinely new show # Check our database to avoid duplicates - existing = self.db.get_episode_date(imdb_id, season_num, episode_num) + existing = self.db.get_episode_date(imdb_id, season_num, episode_num, instance) if existing and existing.get('dateadded'): _log("INFO", f"Episode S{season_num:02d}E{episode_num:02d} already exists in our database: {existing['dateadded']}") return existing.get('aired'), existing.get('dateadded'), existing.get('source', 'chronarr:database') diff --git a/webhooks/webhook_batcher.py b/webhooks/webhook_batcher.py index 49d071e..ec37917 100644 --- a/webhooks/webhook_batcher.py +++ b/webhooks/webhook_batcher.py @@ -139,30 +139,29 @@ def _process_sync(self, key: str, webhook_data: Dict): if not self.tv_processor: _log("ERROR", "TV processor not available") return - - # Check processing mode for TV webhooks + + instance = webhook_data.get('instance', 'sonarr') processing_mode = webhook_data.get('processing_mode', config.tv_webhook_processing_mode) episodes_data = webhook_data.get('episodes', []) - + if processing_mode == 'targeted' and episodes_data: _log("INFO", f"Using targeted episode processing for {len(episodes_data)} episodes") - - # Handle sequential processing for bulk downloads if len(episodes_data) > 1 and config.sequential_delay > 0: _log("INFO", f"Processing {len(episodes_data)} episodes sequentially with {config.sequential_delay}s delay") - self._process_episodes_sequentially(path_obj, episodes_data) + self._process_episodes_sequentially(path_obj, episodes_data, instance=instance) else: - self.tv_processor.process_webhook_episodes(path_obj, episodes_data, imdb_id=expected_imdb) + self.tv_processor.process_webhook_episodes(path_obj, episodes_data, imdb_id=expected_imdb, instance=instance) else: _log("INFO", f"Using series processing mode (fallback or configured)") - self.tv_processor.process_series(path_obj, imdb_id=expected_imdb) - + self.tv_processor.process_series(path_obj, imdb_id=expected_imdb, instance=instance) + elif media_type == 'movie': if not self.movie_processor: _log("ERROR", "Movie processor not available") return - - self.movie_processor.process_movie(path_obj, webhook_mode=True, imdb_id=expected_imdb) + + instance = webhook_data.get('instance', 'radarr') + self.movie_processor.process_movie(path_obj, webhook_mode=True, imdb_id=expected_imdb, instance=instance) else: _log("ERROR", f"Unknown media type: {media_type}") @@ -172,27 +171,21 @@ def _process_sync(self, key: str, webhook_data: Dict): with self.lock: self.processing.discard(key) - def _process_episodes_sequentially(self, path_obj: Path, episodes_data: list): - """Process episodes one by one with delays to avoid API spam""" + def _process_episodes_sequentially(self, path_obj: Path, episodes_data: list, instance: str = 'sonarr'): + """Process episodes one by one with delays to avoid Sonarr API spam.""" total_episodes = len(episodes_data) for i, episode in enumerate(episodes_data, 1): try: season = episode.get('seasonNumber', '?') episode_num = episode.get('episodeNumber', '?') _log("INFO", f"Processing episode {i}/{total_episodes}: S{season:02d}E{episode_num:02d}") - - # Process single episode - self.tv_processor.process_webhook_episodes(path_obj, [episode]) - - # Add delay between episodes (except for the last one) + self.tv_processor.process_webhook_episodes(path_obj, [episode], instance=instance) if i < total_episodes and config.sequential_delay > 0: _log("INFO", f"Waiting {config.sequential_delay}s before next episode...") time.sleep(config.sequential_delay) - except Exception as e: _log("ERROR", f"Error processing episode {i}/{total_episodes}: {e}") - # Continue with next episode even if one fails - + _log("INFO", f"Completed sequential processing of {total_episodes} episodes") def get_status(self) -> Dict: From 244fe4884c95e499151694e63508bd082ea56e65 Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Wed, 26 Aug 2026 08:50:42 -0400 Subject: [PATCH 07/45] chore: thread instance through remaining non-webhook processors, registry-based connection status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- main.py | 204 ++++++++++--------------------------- processors/tv_processor.py | 175 +++++++++++-------------------- 2 files changed, 115 insertions(+), 264 deletions(-) diff --git a/main.py b/main.py index d0f1720..91d61d9 100644 --- a/main.py +++ b/main.py @@ -19,8 +19,9 @@ # Import core components from core.database import ChronarrDatabase from core.instance_registry import build_registry - from core.path_mapper import PathMapper +from clients.radarr_db_client import RadarrDbClient +from clients.sonarr_db_client import SonarrDbClient # Import processors from processors.tv_processor import TVProcessor @@ -84,14 +85,14 @@ def _noop_mapper() -> PathMapper: return PathMapper(root_folders=[], container_paths=[]) -def initialize_components(): +def initialize_components(registry=None): start_time = datetime.now(timezone.utc) db = ChronarrDatabase(config=config) - # Build the instance registry — one client + one path mapper per configured - # Radarr/Sonarr instance, derived from env vars at startup. - registry = build_registry(config) + # Use the registry built at startup if passed in; otherwise build it now. + if registry is None: + registry = build_registry(config) # Processors get the default instance's client and mapper. Multi-instance # requests pass instance name explicitly at process time; the processors look @@ -169,171 +170,73 @@ def force_exit(): sys.exit(0) -def test_database_connections(): - """Test and report all database connections at startup with actual connection tests""" +def test_database_connections(registry=None): + """Report all database connection statuses at startup.""" import psycopg2 import sqlite3 - from pathlib import Path print("\n" + "="*70) print(" DATABASE CONNECTION STATUS") print("="*70) - # Test Chronarr internal database + # Chronarr's own DB — always present print(f"\n Chronarr Database:") if config.db_type == "postgresql": - print(f" Type: PostgreSQL") - print(f" Host: {config.db_host}:{config.db_port}") - print(f" Database: {config.db_name}") - print(f" User: {config.db_user}") - + print(f" Type: PostgreSQL Host: {config.db_host}:{config.db_port} Database: {config.db_name}") try: - # Attempt actual connection conn = psycopg2.connect( - host=config.db_host, - port=config.db_port, - database=config.db_name, - user=config.db_user, - password=config.db_password + host=config.db_host, port=config.db_port, + database=config.db_name, user=config.db_user, password=config.db_password ) - cursor = conn.cursor() - cursor.execute("SELECT 1") - cursor.close() + conn.cursor().execute("SELECT 1") conn.close() print(f" Status: ✅ CONNECTED") except Exception as e: - print(f" Status: ❌ ERROR - {str(e)[:50]}") + print(f" Status: ❌ ERROR - {str(e)[:60]}") else: - print(f" Type: SQLite") - print(f" Path: {config.db_path}") + print(f" Type: SQLite Path: {config.db_path}") try: if Path(config.db_path).exists(): conn = sqlite3.connect(config.db_path) - cursor = conn.cursor() - cursor.execute("SELECT 1") - cursor.close() + conn.cursor().execute("SELECT 1") conn.close() print(f" Status: ✅ CONNECTED") else: - print(f" Status: ⚠️ Database file will be created on first use") + print(f" Status: ⚠️ Will be created on first use") except Exception as e: - print(f" Status: ❌ ERROR - {str(e)[:50]}") - - # Test Radarr database - print(f"\n Radarr Database:") - radarr_db_type = os.environ.get("RADARR_DB_TYPE", "").lower() - - if radarr_db_type == "postgresql": - radarr_db_host = os.environ.get("RADARR_DB_HOST", "") - radarr_db_port = os.environ.get("RADARR_DB_PORT", "5432") - radarr_db_name = os.environ.get("RADARR_DB_NAME", "") - radarr_db_user = os.environ.get("RADARR_DB_USER", "") - radarr_db_password = os.environ.get("RADARR_DB_PASSWORD", "") - - print(f" Type: PostgreSQL") - print(f" Host: {radarr_db_host}:{radarr_db_port}") - print(f" Database: {radarr_db_name}") - print(f" User: {radarr_db_user}") - - if not radarr_db_host or not radarr_db_name: - print(f" Status: ⚠️ NOT CONFIGURED (missing host or database name)") - else: - try: - conn = psycopg2.connect( - host=radarr_db_host, - port=int(radarr_db_port), - database=radarr_db_name, - user=radarr_db_user, - password=radarr_db_password - ) - cursor = conn.cursor() - cursor.execute("SELECT 1") - cursor.close() - conn.close() - print(f" Status: ✅ CONNECTED - Using direct database access") - except Exception as e: - print(f" Status: ❌ ERROR - {str(e)[:50]}") - - elif radarr_db_type == "sqlite": - radarr_db_path = os.environ.get("RADARR_DB_PATH", "") - print(f" Type: SQLite") - print(f" Path: {radarr_db_path}") - - if not radarr_db_path: - print(f" Status: ⚠️ NOT CONFIGURED (missing database path)") - elif not Path(radarr_db_path).exists(): - print(f" Status: ❌ ERROR - Database file not found") - else: - try: - conn = sqlite3.connect(radarr_db_path) - cursor = conn.cursor() - cursor.execute("SELECT 1") - cursor.close() - conn.close() - print(f" Status: ✅ CONNECTED - Using direct database access") - except Exception as e: - print(f" Status: ❌ ERROR - {str(e)[:50]}") + print(f" Status: ❌ ERROR - {str(e)[:60]}") + + if not registry: + print("\n" + "="*70 + "\n") + return + + # Radarr instances — one line each + print(f"\n Radarr:") + if not config.radarr_instances: + print(f" No Radarr instances configured") else: - print(f" Type: Not configured") - print(f" Status: ⚠️ Will use Radarr API instead of direct database access") - - # Test Sonarr database - print(f"\n Sonarr Database:") - sonarr_db_type = os.environ.get("SONARR_DB_TYPE", "").lower() - - if sonarr_db_type == "postgresql": - sonarr_db_host = os.environ.get("SONARR_DB_HOST", "") - sonarr_db_port = os.environ.get("SONARR_DB_PORT", "5432") - sonarr_db_name = os.environ.get("SONARR_DB_NAME", "") - sonarr_db_user = os.environ.get("SONARR_DB_USER", "") - sonarr_db_password = os.environ.get("SONARR_DB_PASSWORD", "") - - print(f" Type: PostgreSQL") - print(f" Host: {sonarr_db_host}:{sonarr_db_port}") - print(f" Database: {sonarr_db_name}") - print(f" User: {sonarr_db_user}") - - if not sonarr_db_host or not sonarr_db_name: - print(f" Status: ⚠️ NOT CONFIGURED (missing host or database name)") - else: - try: - conn = psycopg2.connect( - host=sonarr_db_host, - port=int(sonarr_db_port), - database=sonarr_db_name, - user=sonarr_db_user, - password=sonarr_db_password - ) - cursor = conn.cursor() - cursor.execute("SELECT 1") - cursor.close() - conn.close() - print(f" Status: ✅ CONNECTED - Using direct database access") - except Exception as e: - print(f" Status: ❌ ERROR - {str(e)[:50]}") - - elif sonarr_db_type == "sqlite": - sonarr_db_path = os.environ.get("SONARR_DB_PATH", "") - print(f" Type: SQLite") - print(f" Path: {sonarr_db_path}") - - if not sonarr_db_path: - print(f" Status: ⚠️ NOT CONFIGURED (missing database path)") - elif not Path(sonarr_db_path).exists(): - print(f" Status: ❌ ERROR - Database file not found") - else: - try: - conn = sqlite3.connect(sonarr_db_path) - cursor = conn.cursor() - cursor.execute("SELECT 1") - cursor.close() - conn.close() - print(f" Status: ✅ CONNECTED - Using direct database access") - except Exception as e: - print(f" Status: ❌ ERROR - {str(e)[:50]}") + for inst in config.radarr_instances: + client = registry.radarr(inst.name) + if client is None: + print(f" [{inst.name}] ⚠️ No client — check URL and API key config") + elif isinstance(client, RadarrDbClient): + print(f" [{inst.name}] ✅ CONNECTED (direct DB)") + else: + print(f" [{inst.name}] ✅ CONNECTED (API)") + + # Sonarr instances — one line each + print(f"\n Sonarr:") + if not config.sonarr_instances: + print(f" No Sonarr instances configured") else: - print(f" Type: Not configured") - print(f" Status: ⚠️ Will use Sonarr API instead of direct database access") + for inst in config.sonarr_instances: + client = registry.sonarr(inst.name) + if client is None: + print(f" [{inst.name}] ⚠️ No client — check URL and API key config") + elif isinstance(client, SonarrDbClient): + print(f" [{inst.name}] ✅ CONNECTED (direct DB)") + else: + print(f" [{inst.name}] ✅ CONNECTED (API)") print("\n" + "="*70 + "\n") @@ -358,14 +261,17 @@ def main(): _log("INFO", f"Config: manage_nfo={config.manage_nfo}, fix_mtimes={config.fix_dir_mtimes}") _log("INFO", f"Movie priority: {config.movie_priority}") + # Build registry once — used for connection status display and component init + registry = build_registry(config) + # Test and display all database connections - test_database_connections() - + test_database_connections(registry) + # Create FastAPI app app = create_app() - + # Initialize components - dependencies = initialize_components() + dependencies = initialize_components(registry=registry) # Note: Authentication and web interface handled by separate chronarr-web container _log("INFO", "Core API: Authentication handled by separate web container") diff --git a/processors/tv_processor.py b/processors/tv_processor.py index c9f621c..6f3803a 100644 --- a/processors/tv_processor.py +++ b/processors/tv_processor.py @@ -486,58 +486,48 @@ def _get_sonarr_episodes(self, imdb_id: str, episodes_filter: List[Tuple[int, in _log("ERROR", f"Failed to get Sonarr episodes for {imdb_id}: {e}") return {} - def process_season(self, series_path: str, season_name: str) -> Dict[str, Any]: + def process_season(self, series_path: str, season_name: str, instance: str = 'sonarr') -> Dict[str, Any]: """Process a specific season""" series_path_obj = Path(series_path) if not series_path_obj.exists(): raise FileNotFoundError(f"Series path not found: {series_path}") - + season_path = series_path_obj / season_name if not season_path.exists(): raise FileNotFoundError(f"Season directory not found: {season_path}") - - # Extract season number from directory name + season_match = re.search(r'(\d+)', season_name) if not season_match: raise ValueError(f"Could not extract season number from: {season_name}") - + season_num = int(season_match.group(1)) - - # Get series IMDb ID - imdb_id = parse_imdb_from_path(series_path_obj) # Phase 3: Using imdb_utils + + imdb_id = parse_imdb_from_path(series_path_obj) if not imdb_id: raise ValueError(f"No IMDb ID found in series path: {series_path}") _log("INFO", f"Processing season {season_num} of series: {series_path_obj.name}") - - # Find episodes in this season + disk_episodes = find_episodes_on_disk(series_path_obj) season_episodes = {k: v for k, v in disk_episodes.items() if k[0] == season_num} - + if not season_episodes: return {"status": "no_episodes", "season": season_num, "episodes_found": 0} - - # Get episode dates - episode_dates = self._gather_episode_dates(series_path_obj, imdb_id, season_episodes) - - # Process episodes + + episode_dates = self._gather_episode_dates(series_path_obj, imdb_id, season_episodes, instance=instance) + processed_count = 0 for (season, episode), (aired, dateadded, source) in episode_dates.items(): if (season, episode) in season_episodes: - # NFO file operations removed - database is now the single source of truth - # (Phase 1: Remove NFO file write operations) - - # Save to database try: - self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True) + self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True, instance=instance) _log("DEBUG", f"S{season:02d}E{episode:02d}: Database record saved successfully") except Exception as e: _log("ERROR", f"S{season:02d}E{episode:02d}: Database write failed: {e}") - # Continue processing other episodes processed_count += 1 - + _log("INFO", f"Processed {processed_count} episodes in season {season_num}") - + return { "status": "success", "season": season_num, @@ -545,63 +535,56 @@ def process_season(self, series_path: str, season_name: str) -> Dict[str, Any]: "episodes_processed": processed_count } - def process_episode(self, series_path: str, season_name: str, episode_name: str) -> Dict[str, Any]: + def process_episode(self, series_path: str, season_name: str, episode_name: str, instance: str = 'sonarr') -> Dict[str, Any]: """Process a specific episode""" series_path_obj = Path(series_path) if not series_path_obj.exists(): raise FileNotFoundError(f"Series path not found: {series_path}") - + season_path = series_path_obj / season_name if not season_path.exists(): raise FileNotFoundError(f"Season directory not found: {season_path}") - + episode_path = season_path / episode_name if not episode_path.exists(): raise FileNotFoundError(f"Episode file not found: {episode_path}") - - # Extract season and episode numbers + season_match = re.search(r'(\d+)', season_name) episode_match = re.search(r'[sS](\d+)[eE](\d+)|(\d+)x(\d+)', episode_name) - + if not season_match: raise ValueError(f"Could not extract season number from: {season_name}") - + if not episode_match: raise ValueError(f"Could not extract episode number from: {episode_name}") - + season_num = int(season_match.group(1)) - - if episode_match.group(1) and episode_match.group(2): # SxxExx format + + if episode_match.group(1) and episode_match.group(2): episode_num = int(episode_match.group(2)) - elif episode_match.group(3) and episode_match.group(4): # NxNN format + elif episode_match.group(3) and episode_match.group(4): episode_num = int(episode_match.group(4)) else: raise ValueError(f"Could not parse episode number from: {episode_name}") - - # Get series IMDb ID - imdb_id = parse_imdb_from_path(series_path_obj) # Phase 3: Using imdb_utils + + imdb_id = parse_imdb_from_path(series_path_obj) if not imdb_id: raise ValueError(f"No IMDb ID found in series path: {series_path}") _log("INFO", f"Processing episode S{season_num:02d}E{episode_num:02d} of series: {series_path_obj.name}") - - # Get episode data + disk_episodes = {(season_num, episode_num): [episode_path]} - episode_dates = self._gather_episode_dates(series_path_obj, imdb_id, disk_episodes) - + episode_dates = self._gather_episode_dates(series_path_obj, imdb_id, disk_episodes, instance=instance) + if (season_num, episode_num) not in episode_dates: return {"status": "no_data", "season": season_num, "episode": episode_num} - + aired, dateadded, source = episode_dates[(season_num, episode_num)] - - # NFO file operations removed - database is now the single source of truth - # (Phase 1: Remove NFO file write operations) - - # Save to database - self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True) - + + self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True, instance=instance) + _log("INFO", f"Processed episode S{season_num:02d}E{episode_num:02d}") - + return { "status": "success", "season": season_num, @@ -613,61 +596,39 @@ def process_episode(self, series_path: str, season_name: str, episode_name: str) # ===== ASYNC METHODS ===== - async def async_process_series(self, series_path: Path) -> Dict[str, Any]: - """ - Async process a TV series directory with concurrent episode processing - - Args: - series_path: Path to series directory - - Returns: - Dictionary with processing results - """ - imdb_id = parse_imdb_from_path(series_path) # Phase 3: Using imdb_utils + async def async_process_series(self, series_path: Path, instance: str = 'sonarr') -> Dict[str, Any]: + """Async process a TV series directory with concurrent episode processing.""" + imdb_id = parse_imdb_from_path(series_path) if not imdb_id: return {"status": "error", "reason": f"No IMDb ID found in series path: {series_path}"} - + _log("INFO", f"Async processing TV series: {series_path.name}") - - # Update database - self.db.upsert_series(imdb_id, str(series_path)) - - # Find video files asynchronously + + self.db.upsert_series(imdb_id, str(series_path), instance=instance) + disk_episodes = await async_find_episodes_on_disk(series_path) _log("INFO", f"Found {len(disk_episodes)} episodes on disk") - - # Get episode dates (sync for now, could be made async later) - episode_dates = self._gather_episode_dates(series_path, imdb_id, disk_episodes) - - # Prepare episode data for concurrent processing - episode_data_list = [] - mtime_operations = [] - + + # sync for now — could be made async if episode date gathering becomes a bottleneck + episode_dates = self._gather_episode_dates(series_path, imdb_id, disk_episodes, instance=instance) + + processed_count = 0 for (season, episode), (aired, dateadded, source) in episode_dates.items(): if (season, episode) in disk_episodes: - # NFO file operations removed - database is now the single source of truth - # (Phase 1: Remove NFO file write operations) - - # Save to database try: - self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True) + self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True, instance=instance) _log("DEBUG", f"S{season:02d}E{episode:02d}: Database record saved successfully") + processed_count += 1 except Exception as e: _log("ERROR", f"S{season:02d}E{episode:02d}: Database write failed: {e}") - # Continue processing other episodes - - # NFO and mtime operations removed - database is now the single source of truth - # (Phase 1: Remove NFO file write operations) - results = {} - + _log("INFO", f"Completed async processing TV series: {series_path.name}") - + return { "status": "success", "imdb_id": imdb_id, "episodes_found": len(disk_episodes), - "episodes_processed": len(episode_data_list), - "results": results + "episodes_processed": processed_count, } async def async_process_multiple_series( @@ -1005,46 +966,30 @@ def _get_webhook_episode_date(self, imdb_id: str, season_num: int, episode_num: async def async_batch_episode_processing( self, episodes_data: List[Dict[str, Any]], - max_concurrent: int = 5 + max_concurrent: int = 5, + instance: str = 'sonarr', ) -> Dict[str, Any]: - """ - Process episodes from webhook data concurrently - - Args: - episodes_data: List of episode data from webhooks - max_concurrent: Maximum concurrent episode processing - - Returns: - Processing results summary - """ + """Process episodes from webhook data concurrently.""" async def _process_single_episode(episode_data: Dict[str, Any]) -> Dict[str, Any]: try: - # Extract episode information series_path = Path(episode_data.get('series_path')) season = episode_data.get('season') episode = episode_data.get('episode') aired = episode_data.get('aired') dateadded = episode_data.get('dateadded') - - # Get IMDb ID - imdb_id = parse_imdb_from_path(series_path) # Phase 3: Using imdb_utils + + imdb_id = parse_imdb_from_path(series_path) if not imdb_id: return {"status": "error", "reason": "No IMDb ID found"} - - # NFO file operations removed - database is now the single source of truth - # (Phase 1: Remove NFO file write operations) - nfo_success = True # Always True since we're not creating NFOs anymore - - # Update database - self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, "webhook", True) - + + self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, "webhook", True, instance=instance) + return { "status": "success", "season": season, "episode": episode, - "nfo_created": nfo_success } - + except Exception as e: return { "status": "error", From 07d0374056e8405505269575be0c313c0e254c6f Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Wed, 26 Aug 2026 09:28:32 -0400 Subject: [PATCH 08/45] feat: per-instance startup connection status stored on registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/instance_registry.py | 29 +++++++++++++++++++++++++++++ main.py | 27 +++++++++++++++++---------- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/core/instance_registry.py b/core/instance_registry.py index a44d147..7c8238d 100644 --- a/core/instance_registry.py +++ b/core/instance_registry.py @@ -100,11 +100,19 @@ def __init__(self, radarr_instances, sonarr_instances): self._sonarr: Dict[str, AnySonarrClient] = {} self._radarr_mappers: Dict[str, PathMapper] = {} self._sonarr_mappers: Dict[str, PathMapper] = {} + # Connection status keyed by instance name — used by /setup and startup reporting. + # {"connected": True, "method": "direct_db"|"api"} or {"connected": False} + self._radarr_status: Dict[str, dict] = {} + self._sonarr_status: Dict[str, dict] = {} for inst in radarr_instances: client = _build_radarr_client(inst) if client: self._radarr[inst.name] = client + method = "direct_db" if isinstance(client, RadarrDbClient) else "api" + self._radarr_status[inst.name] = {"connected": True, "method": method} + else: + self._radarr_status[inst.name] = {"connected": False} self._radarr_mappers[inst.name] = PathMapper( root_folders=inst.root_folders, container_paths=inst.movie_paths, @@ -114,6 +122,10 @@ def __init__(self, radarr_instances, sonarr_instances): client = _build_sonarr_client(inst) if client: self._sonarr[inst.name] = client + method = "direct_db" if isinstance(client, SonarrDbClient) else "api" + self._sonarr_status[inst.name] = {"connected": True, "method": method} + else: + self._sonarr_status[inst.name] = {"connected": False} self._sonarr_mappers[inst.name] = PathMapper( root_folders=inst.root_folders, container_paths=inst.tv_paths, @@ -137,6 +149,23 @@ def sonarr_mapper(self, name: str = "sonarr") -> Optional[PathMapper]: """Look up the PathMapper for a Sonarr instance.""" return self._sonarr_mappers.get(name) + def radarr_status(self, name: str = "radarr") -> dict: + """Return connection status for a Radarr instance by name. + + Returns {"connected": True, "method": "direct_db"|"api"} or {"connected": False}. + """ + return self._radarr_status.get(name, {"connected": False}) + + def sonarr_status(self, name: str = "sonarr") -> dict: + """Return connection status for a Sonarr instance by name.""" + return self._sonarr_status.get(name, {"connected": False}) + + def all_radarr_statuses(self) -> Dict[str, dict]: + return dict(self._radarr_status) + + def all_sonarr_statuses(self) -> Dict[str, dict]: + return dict(self._sonarr_status) + def all_radarr(self) -> Dict[str, AnyRadarrClient]: return dict(self._radarr) diff --git a/main.py b/main.py index 91d61d9..43ec588 100644 --- a/main.py +++ b/main.py @@ -20,8 +20,6 @@ from core.database import ChronarrDatabase from core.instance_registry import build_registry from core.path_mapper import PathMapper -from clients.radarr_db_client import RadarrDbClient -from clients.sonarr_db_client import SonarrDbClient # Import processors from processors.tv_processor import TVProcessor @@ -216,10 +214,10 @@ def test_database_connections(registry=None): print(f" No Radarr instances configured") else: for inst in config.radarr_instances: - client = registry.radarr(inst.name) - if client is None: - print(f" [{inst.name}] ⚠️ No client — check URL and API key config") - elif isinstance(client, RadarrDbClient): + status = registry.radarr_status(inst.name) + if not status["connected"]: + print(f" [{inst.name}] ⚠️ No client — check URL, API key, and DB settings in .env") + elif status["method"] == "direct_db": print(f" [{inst.name}] ✅ CONNECTED (direct DB)") else: print(f" [{inst.name}] ✅ CONNECTED (API)") @@ -230,14 +228,23 @@ def test_database_connections(registry=None): print(f" No Sonarr instances configured") else: for inst in config.sonarr_instances: - client = registry.sonarr(inst.name) - if client is None: - print(f" [{inst.name}] ⚠️ No client — check URL and API key config") - elif isinstance(client, SonarrDbClient): + status = registry.sonarr_status(inst.name) + if not status["connected"]: + print(f" [{inst.name}] ⚠️ No client — check URL, API key, and DB settings in .env") + elif status["method"] == "direct_db": print(f" [{inst.name}] ✅ CONNECTED (direct DB)") else: print(f" [{inst.name}] ✅ CONNECTED (API)") + # Summary warning — flag any instance without a working client + no_client = ( + [n for n, s in registry.all_radarr_statuses().items() if not s["connected"]] + + [n for n, s in registry.all_sonarr_statuses().items() if not s["connected"]] + ) + if no_client: + print(f"\n ⚠️ {len(no_client)} instance(s) have no client: {', '.join(no_client)}") + print(f" Webhooks received for these instances will be ignored until resolved.") + print("\n" + "="*70 + "\n") From 140ec4868218379b48e67e81ea4cd12d7d38875f Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Wed, 26 Aug 2026 09:33:26 -0400 Subject: [PATCH 09/45] =?UTF-8?q?feat:=20/setup=20page=20=E2=80=94=20insta?= =?UTF-8?q?nce=20webhook=20URLs=20and=20connection=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- api/routes.py | 30 +++- api/web_routes.py | 33 +++++ static/setup.html | 369 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 static/setup.html diff --git a/api/routes.py b/api/routes.py index 4416074..c9d6d89 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3036,10 +3036,38 @@ async def _sonarr_instance_handler(request: Request, background_tasks: Backgroun async def _maintainarr_webhook(request: Request, background_tasks: BackgroundTasks): return await maintainarr_webhook(request, background_tasks, dependencies) + @app.get("/api/instances") + async def _api_instances(): + """Return all configured instances with their webhook paths and connection status.""" + registry = dependencies.get("registry") + cfg = dependencies.get("config") + if not registry or not cfg: + raise HTTPException(status_code=503, detail="Registry not initialised") + + radarr_list = [ + { + "name": inst.name, + "webhook_path": f"/{inst.name}/webhook", + "connected": registry.radarr_status(inst.name).get("connected", False), + "method": registry.radarr_status(inst.name).get("method"), + } + for inst in cfg.radarr_instances + ] + sonarr_list = [ + { + "name": inst.name, + "webhook_path": f"/{inst.name}/webhook", + "connected": registry.sonarr_status(inst.name).get("connected", False), + "method": registry.sonarr_status(inst.name).get("method"), + } + for inst in cfg.sonarr_instances + ] + return {"radarr": radarr_list, "sonarr": sonarr_list} + @app.get("/health") async def _health() -> HealthResponse: return await health(dependencies) - + @app.get("/health/simple") async def _health_simple(): """Simple health check for Docker without external dependencies""" diff --git a/api/web_routes.py b/api/web_routes.py index 5858750..2187ba6 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -2434,6 +2434,39 @@ async def api_populate_status(): """Get database population status""" return await get_populate_status() + # /setup page and its data endpoint — proxy /api/instances to the core + @app.get("/api/instances") + async def api_instances(request: Request): + """Proxy instance list and connection status from the core container.""" + import urllib.request + import urllib.error + import json + import os + import socket + + core_host = os.environ.get("CORE_API_HOST", "chronarr") + core_port = os.environ.get("CORE_API_PORT", "8080") + core_url = f"http://{core_host}:{core_port}/api/instances" + + try: + req = urllib.request.Request(core_url) + with urllib.request.urlopen(req, timeout=5) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + raise HTTPException(status_code=e.code, detail=f"Core API error: {e.reason}") + except (urllib.error.URLError, socket.timeout) as e: + raise HTTPException(status_code=503, detail=f"Could not reach core container: {e}") + + @app.get("/setup") + async def setup_page(): + """Serve the instance setup / webhook URL reference page.""" + import os + setup_file = os.path.join(os.path.dirname(__file__), "..", "static", "setup.html") + from fastapi.responses import FileResponse + if os.path.exists(setup_file): + return FileResponse(os.path.normpath(setup_file)) + raise HTTPException(status_code=404, detail="Setup page not found") + # Scheduled Scans Functions diff --git a/static/setup.html b/static/setup.html new file mode 100644 index 0000000..5aa7c79 --- /dev/null +++ b/static/setup.html @@ -0,0 +1,369 @@ + + + + + + Chronarr — Setup + + + + + +
+
+
+

Chronarr

+

Setup & Webhook Configuration

+
+
+ +
+ + Back to Dashboard + + + +
+

Chronarr Core URL

+
+ + +
+

+ This is the address Radarr and Sonarr will use to reach Chronarr's core container. + It should be the hostname (or IP) your media apps can reach, on port + 8080 by default. Edit it once here and all webhook URLs below update automatically. +

+
+ + +
+

Radarr Instances

+
+ Loading… +
+
+ + +
+

Sonarr Instances

+
+ Loading… +
+
+
+
+ + + + From 301cb25f0b39cdbd8a17fa2281cf08d5beff3f07 Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Wed, 26 Aug 2026 09:39:46 -0400 Subject: [PATCH 10/45] feat: instance dropdown and combined view on content pages 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 --- .env.example | 3 +++ api/web_routes.py | 59 +++++++++++++++++++++++------------------- static/index.html | 11 ++++++++ static/js/app.js | 65 +++++++++++++++++++++++++++++++++++++++++------ 4 files changed, 104 insertions(+), 34 deletions(-) diff --git a/.env.example b/.env.example index 7537f42..3b652c0 100644 --- a/.env.example +++ b/.env.example @@ -74,6 +74,9 @@ RADARR_DB_USER=postgres # Add a block per extra Radarr instance. The name between RADARR_ and _URL # becomes part of the webhook URL: RADARR_4K_URL → /radarr_4k/webhook # Names must be uppercase letters and underscores only. +# +# After configuring instances, visit http://your-server:8081/setup +# for the exact webhook URLs to paste into each Radarr or Sonarr instance. # RADARR_4K_URL=http://radarr4k:7878 # RADARR_4K_API_KEY= → set in .env.secrets diff --git a/api/web_routes.py b/api/web_routes.py index 2187ba6..2cde6cc 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -91,13 +91,14 @@ async def get_movies_list(dependencies: dict, source_filter: Optional[str] = Query(None), search: Optional[str] = Query(None), imdb_search: Optional[str] = Query(None), - skipped: Optional[bool] = Query(None)): + skipped: Optional[bool] = Query(None), + instance_filter: Optional[str] = Query(None)): """Get paginated list of movies with filtering options""" db = dependencies["db"] - + with db.get_connection() as conn: cursor = conn.cursor() - + # Build dynamic query where_conditions = [] params = [] @@ -110,10 +111,8 @@ async def get_movies_list(dependencies: dict, if has_date is not None: if has_date: - # PostgreSQL - NULL handling where_conditions.append("dateadded IS NOT NULL") else: - # PostgreSQL - NULL handling where_conditions.append("dateadded IS NULL") if source_filter: @@ -127,17 +126,21 @@ async def get_movies_list(dependencies: dict, if imdb_search: where_conditions.append("imdb_id ILIKE %s") params.append(f"%{imdb_search}%") - + + if instance_filter: + where_conditions.append("instance = %s") + params.append(instance_filter) + where_clause = " AND ".join(where_conditions) if where_conditions else "1=1" - + # Get total count count_query = f"SELECT COUNT(*) FROM movies WHERE {where_clause}" cursor.execute(count_query, params) total_count = db._get_first_value(cursor.fetchone()) - - # Get paginated results - PostgreSQL + + # Get paginated results query = f""" - SELECT imdb_id, title, year, path, released, dateadded, source, has_video_file, last_updated, skipped, skip_reason + SELECT imdb_id, instance, title, year, path, released, dateadded, source, has_video_file, last_updated, skipped, skip_reason FROM movies WHERE {where_clause} ORDER BY last_updated DESC @@ -169,12 +172,13 @@ async def get_movies_list(dependencies: dict, async def get_tv_series_list(dependencies: dict, - skip: int = Query(0, ge=0), + skip: int = Query(0, ge=0), limit: int = Query(50, le=500), search: Optional[str] = Query(None), imdb_search: Optional[str] = Query(None), date_filter: Optional[str] = Query(None), - source_filter: Optional[str] = Query(None)): + source_filter: Optional[str] = Query(None), + instance_filter: Optional[str] = Query(None)): """Get paginated list of TV series with episode counts""" db = dependencies["db"] @@ -199,10 +203,13 @@ async def get_tv_series_list(dependencies: dict, params.append(f"%{imdb_search}%") if source_filter: - # Need to check episodes for source filter where_conditions.append("e.source = %s") params.append(source_filter) - + + if instance_filter: + where_conditions.append("s.instance = %s") + params.append(instance_filter) + if date_filter: if date_filter == "complete": # All episodes have dates @@ -225,14 +232,13 @@ async def get_tv_series_list(dependencies: dict, # Get total count with same filtering logic as main query if having_clause or needs_episode_join: - # When using HAVING clause or filtering by episode fields, need to count filtered results with JOIN count_query = f""" SELECT COUNT(*) FROM ( - SELECT s.imdb_id + SELECT s.imdb_id, s.instance FROM series s - LEFT JOIN episodes e ON s.imdb_id = e.imdb_id + LEFT JOIN episodes e ON s.imdb_id = e.imdb_id AND s.instance = e.instance WHERE {where_clause} - GROUP BY s.imdb_id + GROUP BY s.imdb_id, s.instance {('HAVING ' + having_clause) if having_clause else ''} ) filtered_series """ @@ -245,10 +251,10 @@ async def get_tv_series_list(dependencies: dict, # Get series with episode statistics having_part = f" HAVING {having_clause}" if having_clause else "" - # PostgreSQL query query = f""" SELECT s.imdb_id, + s.instance, s.path, s.last_updated, COUNT(e.episode) as total_episodes, @@ -256,9 +262,9 @@ async def get_tv_series_list(dependencies: dict, COUNT(CASE WHEN e.has_video_file = TRUE THEN 1 END) as episodes_with_video, COUNT(CASE WHEN e.skipped = TRUE THEN 1 END) as episodes_skipped FROM series s - LEFT JOIN episodes e ON s.imdb_id = e.imdb_id + LEFT JOIN episodes e ON s.imdb_id = e.imdb_id AND s.instance = e.instance WHERE {where_clause} - GROUP BY s.imdb_id, s.path, s.last_updated{having_part} + GROUP BY s.imdb_id, s.instance, s.path, s.last_updated{having_part} ORDER BY s.last_updated DESC LIMIT %s OFFSET %s """ @@ -1486,8 +1492,8 @@ async def api_dashboard_stats(): @app.get("/api/movies") async def api_movies_list(skip: int = 0, limit: int = 100, has_date: bool = None, source_filter: str = None, search: str = None, imdb_search: str = None, - skipped: bool = None): - return await get_movies_list(dependencies, skip, limit, has_date, source_filter, search, imdb_search, skipped) + skipped: bool = None, instance: str = None): + return await get_movies_list(dependencies, skip, limit, has_date, source_filter, search, imdb_search, skipped, instance) @app.post("/api/movies/{imdb_id}/update-date") async def api_update_movie_date(imdb_id: str, dateadded: str = None, source: str = "manual"): @@ -1599,9 +1605,10 @@ async def api_migrate_movie_imdb(old_imdb_id: str, request: Request): # TV series endpoints @app.get("/api/series") - async def api_series_list(skip: int = 0, limit: int = 50, search: str = None, - imdb_search: str = None, date_filter: str = None, source_filter: str = None): - return await get_tv_series_list(dependencies, skip, limit, search, imdb_search, date_filter, source_filter) + async def api_series_list(skip: int = 0, limit: int = 50, search: str = None, + imdb_search: str = None, date_filter: str = None, + source_filter: str = None, instance: str = None): + return await get_tv_series_list(dependencies, skip, limit, search, imdb_search, date_filter, source_filter, instance) @app.get("/api/series/{imdb_id}/episodes") async def api_series_episodes(imdb_id: str): diff --git a/static/index.html b/static/index.html index b99933f..bf1fd6b 100644 --- a/static/index.html +++ b/static/index.html @@ -39,6 +39,9 @@

Chronarr

+ + Setup + @@ -128,6 +131,9 @@

Movies Database

+ @@ -146,6 +152,7 @@

Movies Database

Source Date Type Video File + Instance Actions @@ -185,6 +192,9 @@

TV Series Database

+ @@ -201,6 +211,7 @@

TV Series Database

Episodes With Dates With Video + Instance Actions diff --git a/static/js/app.js b/static/js/app.js index 15e2f54..e931231 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -13,6 +13,7 @@ document.addEventListener('DOMContentLoaded', function() { checkAuthStatus(); // Check authentication status on page load loadDashboard(); loadSeriesSources(); + loadInstanceDropdowns(); }); // Tab management @@ -70,8 +71,10 @@ function initializeEventListeners() { // Filter dropdowns document.getElementById('movies-filter-date').addEventListener('change', loadMovies); document.getElementById('movies-filter-source').addEventListener('change', loadMovies); + document.getElementById('movies-filter-instance').addEventListener('change', loadMovies); document.getElementById('series-filter-date').addEventListener('change', loadSeries); document.getElementById('series-filter-source').addEventListener('change', loadSeries); + document.getElementById('series-filter-instance').addEventListener('change', loadSeries); // Forms document.getElementById('edit-form').addEventListener('submit', handleEditSubmit); @@ -198,19 +201,21 @@ async function loadMovies(page = 1) { const imdbSearch = document.getElementById('movies-imdb-search').value; const hasDate = document.getElementById('movies-filter-date').value; const sourceFilter = document.getElementById('movies-filter-source').value; - + const instanceFilter = document.getElementById('movies-filter-instance').value; + const skip = (page - 1) * 100; console.log(`DEBUG: loadMovies called with page=${page}, calculated skip=${skip}`); - + const params = new URLSearchParams({ skip: skip, limit: 100 }); - + if (search) params.append('search', search); if (imdbSearch) params.append('imdb_search', imdbSearch); if (hasDate) params.append('has_date', hasDate); if (sourceFilter) params.append('source_filter', sourceFilter); + if (instanceFilter) params.append('instance', instanceFilter); try { const data = await apiCall(`/api/movies?${params}`); @@ -276,6 +281,9 @@ function updateMoviesTable(data) { dateTypeBadge = 'badge-warning'; } + const instanceColStyle = document.getElementById('movies-instance-col') && + document.getElementById('movies-instance-col').style.display !== 'none' + ? '' : 'display:none'; return ` ${escapeHtml(movie.title)} @@ -285,6 +293,7 @@ function updateMoviesTable(data) { ${movie.source_description || movie.source || 'Unknown'} ${dateType} ${hasVideoBadge} + ${movie.instance || '-'} @@ -705,7 +705,7 @@ function updateReportTables(data) { ${episode.aired || '-'} ${episode.source_description || episode.source || 'Unknown'} - @@ -719,36 +719,39 @@ function refreshReport() { } // Smart fix functions -async function smartFixMovie(imdbId) { +async function smartFixMovie(imdbId, instance) { + instance = instance || 'radarr'; try { - console.log('🔍 SMART FIX: Loading options for movie', imdbId); - const options = await apiCall(`/api/movies/${imdbId}/date-options`); + console.log('🔍 SMART FIX: Loading options for movie', imdbId, 'instance', instance); + const options = await apiCall(`/api/movies/${imdbId}/date-options?instance=${encodeURIComponent(instance)}`); console.log('🔍 SMART FIX: Received options:', options); - showSmartFixModal('movie', options); + showSmartFixModal('movie', options, instance); } catch (error) { console.error('Failed to load movie options:', error); showToast('Failed to load movie options', 'error'); } } -async function smartFixEpisode(imdbId, season, episode) { +async function smartFixEpisode(imdbId, season, episode, instance) { // Validate parameters if (!imdbId || season === undefined || season === null || episode === undefined || episode === null) { console.error('smartFixEpisode: Invalid parameters:', {imdbId, season, episode}); showToast('Invalid episode parameters', 'error'); return; } - + + instance = instance || 'sonarr'; try { - const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`); - showSmartFixModal('episode', options); + const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options?instance=${encodeURIComponent(instance)}`); + showSmartFixModal('episode', options, instance); } catch (error) { console.error('Failed to load episode options:', error); showToast('Failed to load episode options', 'error'); } } -function showSmartFixModal(mediaType, options) { +function showSmartFixModal(mediaType, options, instance) { + instance = instance || (mediaType === 'movie' ? 'radarr' : 'sonarr'); console.log('🔍 SMART FIX: Showing modal for', mediaType, 'with options:', options); const modal = document.getElementById('smart-fix-modal'); @@ -800,7 +803,7 @@ function showSmartFixModal(mediaType, options) { optionsHtml += `
-
@@ -814,7 +817,8 @@ function closeSmartFixModal() { document.getElementById('smart-fix-modal').classList.remove('active'); } -async function applySmartFix(mediaType, options) { +async function applySmartFix(mediaType, options, instance) { + instance = instance || (mediaType === 'movie' ? 'radarr' : 'sonarr'); const selectedRadio = document.querySelector('input[name="date-option"]:checked'); if (!selectedRadio) { showToast('Please select a date option', 'warning'); @@ -876,9 +880,9 @@ async function applySmartFix(mediaType, options) { try { if (mediaType === 'movie') { - await updateMovieDate(options.imdb_id, dateadded, source); + await updateMovieDate(options.imdb_id, dateadded, source, instance); } else { - await updateEpisodeDate(options.imdb_id, options.season, options.episode, dateadded, source); + await updateEpisodeDate(options.imdb_id, options.season, options.episode, dateadded, source, instance); } closeSmartFixModal(); } catch (error) { @@ -965,19 +969,21 @@ async function handleBulkUpdate(event) { } // Edit modal functions -async function editMovie(imdbId, dateadded, source) { +async function editMovie(imdbId, dateadded, source, instance) { + instance = instance || 'radarr'; try { // Load movie options to populate available dates - const options = await apiCall(`/api/movies/${imdbId}/date-options`); - showEnhancedEditModal('movie', options, dateadded, source); + const options = await apiCall(`/api/movies/${imdbId}/date-options?instance=${encodeURIComponent(instance)}`); + showEnhancedEditModal('movie', options, dateadded, source, instance); } catch (error) { console.error('Failed to load movie options for edit:', error); // Fallback to basic edit modal - showBasicEditModal('movie', imdbId, dateadded, source); + showBasicEditModal('movie', imdbId, dateadded, source, null, null, instance); } } -function showEnhancedEditModal(mediaType, options, currentDateadded, currentSource) { +function showEnhancedEditModal(mediaType, options, currentDateadded, currentSource, instance) { + instance = instance || (mediaType === 'movie' ? 'radarr' : 'sonarr'); const modal = document.getElementById('edit-modal'); const title = document.getElementById('modal-title'); const modalBody = document.querySelector('#edit-modal .modal-body'); @@ -995,6 +1001,7 @@ function showEnhancedEditModal(mediaType, options, currentDateadded, currentSour let formHtml = ` + ${mediaType === 'episode' ? ` @@ -1077,11 +1084,13 @@ function showEnhancedEditModal(mediaType, options, currentDateadded, currentSour modal.classList.add('active'); } -function showBasicEditModal(mediaType, imdbId, dateadded, source) { +function showBasicEditModal(mediaType, imdbId, dateadded, source, season, episode, instance) { // Fallback to original basic edit modal + instance = instance || (mediaType === 'movie' ? 'radarr' : 'sonarr'); document.getElementById('modal-title').textContent = `Edit ${mediaType}: ${imdbId}`; document.getElementById('edit-imdb-id').value = imdbId; document.getElementById('edit-media-type').value = mediaType; + document.getElementById('edit-instance').value = instance; if (dateadded && dateadded !== '-') { try { @@ -1135,11 +1144,12 @@ function updateEditDateFromOption(optionIndex, option) { async function handleEnhancedEditSubmit(event) { event.preventDefault(); - + const modal = document.getElementById('edit-modal'); const options = JSON.parse(modal.dataset.options); const imdbId = options.imdb_id; const mediaType = document.getElementById('edit-media-type').value; + const instance = document.getElementById('edit-instance')?.value || (mediaType === 'movie' ? 'radarr' : 'sonarr'); const dateadded = document.getElementById('edit-dateadded').value; const source = document.getElementById('edit-source').value; @@ -1159,11 +1169,11 @@ async function handleEnhancedEditSubmit(event) { try { if (mediaType === 'movie') { - await updateMovieDate(imdbId, isoDateadded, source); + await updateMovieDate(imdbId, isoDateadded, source, instance); } else { - await updateEpisodeDate(imdbId, options.season, options.episode, isoDateadded, source); + await updateEpisodeDate(imdbId, options.season, options.episode, isoDateadded, source, instance); } - + closeModal(); } catch (error) { console.error('Enhanced edit failed:', error); @@ -1171,22 +1181,23 @@ async function handleEnhancedEditSubmit(event) { } } -async function editEpisode(imdbId, season, episode, dateadded, source) { +async function editEpisode(imdbId, season, episode, dateadded, source, instance) { // Validate parameters if (!imdbId || season === undefined || season === null || episode === undefined || episode === null) { console.error('editEpisode: Invalid parameters:', {imdbId, season, episode}); showToast('Invalid episode parameters', 'error'); return; } - + + instance = instance || 'sonarr'; try { // Load episode options to populate available dates - const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`); - showEnhancedEditModal('episode', options, dateadded, source); + const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options?instance=${encodeURIComponent(instance)}`); + showEnhancedEditModal('episode', options, dateadded, source, instance); } catch (error) { console.error('Failed to load episode options for edit:', error); // Fallback to basic edit modal - showBasicEditModal('episode', imdbId, dateadded, source, season, episode); + showBasicEditModal('episode', imdbId, dateadded, source, season, episode, instance); } } @@ -1196,24 +1207,25 @@ function closeModal() { async function handleEditSubmit(event) { event.preventDefault(); - + const imdbId = document.getElementById('edit-imdb-id').value; const mediaType = document.getElementById('edit-media-type').value; + const instance = document.getElementById('edit-instance')?.value || (mediaType === 'movie' ? 'radarr' : 'sonarr'); const season = document.getElementById('edit-season').value; const episode = document.getElementById('edit-episode').value; const dateadded = document.getElementById('edit-dateadded').value; const source = document.getElementById('edit-source').value; - + // Convert datetime-local to ISO string const isoDateadded = dateadded ? new Date(dateadded).toISOString() : null; - + try { if (mediaType === 'movie') { - await updateMovieDate(imdbId, isoDateadded, source); + await updateMovieDate(imdbId, isoDateadded, source, instance); } else { - await updateEpisodeDate(imdbId, parseInt(season), parseInt(episode), isoDateadded, source); + await updateEpisodeDate(imdbId, parseInt(season), parseInt(episode), isoDateadded, source, instance); } - + closeModal(); } catch (error) { console.error('Update failed:', error); @@ -1221,13 +1233,15 @@ async function handleEditSubmit(event) { } // Update functions -async function updateMovieDate(imdbId, dateadded, source) { +async function updateMovieDate(imdbId, dateadded, source, instance) { + instance = instance || 'radarr'; try { const result = await apiCall(`/api/movies/${imdbId}`, { method: 'PUT', body: JSON.stringify({ dateadded: dateadded, - source: source + source: source, + instance: instance }) }); @@ -1243,13 +1257,15 @@ async function updateMovieDate(imdbId, dateadded, source) { } } -async function updateEpisodeDate(imdbId, season, episode, dateadded, source) { +async function updateEpisodeDate(imdbId, season, episode, dateadded, source, instance) { + instance = instance || 'sonarr'; try { const result = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}`, { method: 'PUT', body: JSON.stringify({ dateadded: dateadded, - source: source + source: source, + instance: instance }) }); From c4edb8a523efcdef7c5833b012eebafc2cf89b75 Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Thu, 27 Aug 2026 09:56:35 -0400 Subject: [PATCH 13/45] fix: strip media-manager ID suffixes from series folder titles --- api/web_routes.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/api/web_routes.py b/api/web_routes.py index 759e5dc..8e3d3e7 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -4,6 +4,7 @@ """ import json import os +import re import tempfile import multiprocessing from datetime import datetime, timezone @@ -14,6 +15,13 @@ from api.models import * from utils.logging import _log +# Strip [imdb-tt...] or [tmdb-...] suffixes that media managers append to folder names +_FOLDER_ID_SUFFIX = re.compile(r'\s*\[[a-z]+-[^\]]+\]', re.IGNORECASE) + +def _clean_folder_title(raw: str) -> str: + """Remove media-manager ID suffixes from a folder name used as a display title.""" + return _FOLDER_ID_SUFFIX.sub('', raw).strip() + # Status file for cross-process communication # Use /tmp which is writable in both core and web containers (unlike /app/data, @@ -275,7 +283,7 @@ async def get_tv_series_list(dependencies: dict, series_data = dict(row) # Extract title from path try: - series_data['title'] = Path(series_data['path']).name if series_data['path'] else series_data['imdb_id'] + series_data['title'] = _clean_folder_title(Path(series_data['path']).name) if series_data['path'] else series_data['imdb_id'] except: series_data['title'] = series_data['imdb_id'] series.append(series_data) @@ -383,7 +391,7 @@ async def get_series_episodes(dependencies: dict, imdb_id: str): series_info = dict(series_row) try: - series_info['title'] = Path(series_info['path']).name if series_info['path'] else imdb_id + series_info['title'] = _clean_folder_title(Path(series_info['path']).name) if series_info['path'] else imdb_id except: series_info['title'] = imdb_id @@ -445,7 +453,7 @@ async def get_missing_dates_report(dependencies: dict): for row in cursor.fetchall(): episode = dict(row) try: - episode['series_title'] = Path(episode['path']).name if episode['path'] else episode['imdb_id'] + episode['series_title'] = _clean_folder_title(Path(episode['path']).name) if episode['path'] else episode['imdb_id'] except: episode['series_title'] = episode['imdb_id'] # Map source to user-friendly description From b3cebe3e9558d1eb85eb060b08b4e3b1a8796ec1 Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Thu, 27 Aug 2026 10:00:37 -0400 Subject: [PATCH 14/45] feat: sidebar nav with expandable instance sub-items, instance badges on rows --- static/css/styles.css | 324 +++++++++++++++++++++++++++++++----------- static/index.html | 109 ++++++++------ static/js/app.js | 187 +++++++++++++----------- 3 files changed, 413 insertions(+), 207 deletions(-) diff --git a/static/css/styles.css b/static/css/styles.css index 66f41c0..ab2038a 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -30,120 +30,257 @@ body { .app-container { min-height: 100vh; display: flex; - flex-direction: column; -} - -/* Header */ -.app-header { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - padding: 1rem 0; - box-shadow: var(--shadow-lg); - position: relative; -} - -.header-content { - max-width: 1200px; - margin: 0 auto; - padding: 0 1rem; - text-align: center; + flex-direction: row; + align-items: stretch; } -.header-content h1 { - font-size: 2rem; - font-weight: 300; - margin-bottom: 0.5rem; +/* Sidebar */ +.sidebar { + width: 220px; + min-width: 220px; + background: #1a1d23; + color: #8b95a1; + display: flex; + flex-direction: column; + min-height: 100vh; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; + flex-shrink: 0; } -.header-content h1 i { - margin-right: 0.5rem; +.sidebar-brand { + padding: 1.125rem 1rem; + display: flex; + align-items: center; + gap: 0.625rem; + color: #ffffff; + font-size: 1.1rem; + font-weight: 600; + border-bottom: 1px solid #2d3139; + text-decoration: none; } -.header-content p { - opacity: 0.9; - font-size: 1rem; +.sidebar-brand i { + color: #0096c7; + font-size: 1.2rem; } -/* Authentication Status */ -.auth-status { - position: absolute; - top: 1rem; - right: 1rem; +/* Auth block in sidebar */ +.sidebar-auth { + padding: 0.625rem 1rem; + border-bottom: 1px solid #2d3139; display: flex; - align-items: center; - gap: 1rem; - color: white; - font-size: 0.9rem; + flex-direction: column; + gap: 0.375rem; + font-size: 0.8rem; + color: #8b95a1; } .auth-user { display: flex; align-items: center; - gap: 0.5rem; - opacity: 0.9; + gap: 0.375rem; + opacity: 0.85; } .auth-logout { - background: rgba(255, 255, 255, 0.2); - color: white; - border: 1px solid rgba(255, 255, 255, 0.3); - padding: 0.5rem 1rem; + background: rgba(255, 255, 255, 0.08); + color: #8b95a1; + border: 1px solid #3a3f4a; + padding: 0.3rem 0.625rem; border-radius: 0.25rem; cursor: pointer; - font-size: 0.85rem; + font-size: 0.775rem; display: flex; align-items: center; - gap: 0.5rem; + gap: 0.375rem; transition: all 0.2s ease; + width: fit-content; } .auth-logout:hover { - background: rgba(255, 255, 255, 0.3); - border-color: rgba(255, 255, 255, 0.5); - transform: translateY(-1px); + background: rgba(255, 255, 255, 0.14); + color: #c9d1d9; } -.nav-tabs { - max-width: 1200px; - margin: 1rem auto 0; - padding: 0 1rem; +/* Sidebar nav */ +.sidebar-nav { + flex: 1; + padding: 0.5rem 0 1rem; display: flex; - gap: 0.5rem; - flex-wrap: wrap; - justify-content: center; + flex-direction: column; +} + +.nav-section-label { + padding: 0.875rem 1rem 0.25rem; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #4a5260; } -.nav-tab { - background: rgba(255, 255, 255, 0.1); +.sidebar-item { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.45rem 0.75rem; + margin: 0.1rem 0.5rem; + width: calc(100% - 1rem); + background: none; border: none; - color: white; - padding: 0.75rem 1.5rem; - border-radius: 0.5rem; + color: #8b95a1; cursor: pointer; - transition: all 0.3s ease; - font-size: 0.9rem; + font-size: 0.875rem; + text-align: left; + text-decoration: none; + border-radius: 0.375rem; + transition: background 0.15s, color 0.15s; + font-family: inherit; +} + +.sidebar-item i { + width: 1.1rem; + text-align: center; + font-size: 0.85rem; + flex-shrink: 0; +} + +.sidebar-item:hover { + background: #252930; + color: #c9d1d9; +} + +.sidebar-item.active { + background: rgba(0, 150, 199, 0.14); + color: #0096c7; +} + +/* Expandable group */ +.sidebar-group { + margin: 0.1rem 0; +} + +.sidebar-group-toggle { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.45rem 0.75rem; + margin: 0 0.5rem; + width: calc(100% - 1rem); + background: none; + border: none; + color: #8b95a1; + cursor: pointer; + font-size: 0.875rem; + border-radius: 0.375rem; + transition: background 0.15s, color 0.15s; + font-family: inherit; +} + +.sidebar-group-toggle:hover { + background: #252930; + color: #c9d1d9; +} + +.toggle-content { + display: flex; + align-items: center; + gap: 0.625rem; +} + +.toggle-content i { + width: 1.1rem; + text-align: center; + font-size: 0.85rem; +} + +.toggle-chevron { + font-size: 0.65rem; + transition: transform 0.2s ease; + opacity: 0.5; +} + +.sidebar-group-toggle[aria-expanded="true"] .toggle-chevron { + transform: rotate(180deg); +} + +/* Sub-items (collapsed by default, expand via .expanded) */ +.sidebar-sub-items { + overflow: hidden; + max-height: 0; + transition: max-height 0.2s ease; +} + +.sidebar-sub-items.expanded { + max-height: 400px; +} + +.sidebar-sub-item { display: flex; align-items: center; gap: 0.5rem; + padding: 0.35rem 0.75rem 0.35rem 2.5rem; + width: 100%; + background: none; + border: none; + color: #626c7a; + cursor: pointer; + font-size: 0.825rem; + text-align: left; + transition: color 0.15s, background 0.15s; + font-family: inherit; } -.nav-tab:hover { - background: rgba(255, 255, 255, 0.2); - transform: translateY(-1px); +.sidebar-sub-item:hover { + color: #c9d1d9; + background: #202328; } -.nav-tab.active { - background: rgba(255, 255, 255, 0.9); - color: var(--dark-color); +.sidebar-sub-item.active { + color: #0096c7; +} + +.instance-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + display: inline-block; +} + +.sidebar-divider { + height: 1px; + background: #2d3139; + margin: 0.625rem 0.75rem; +} + +/* Instance badge in table cells */ +.instance-badge { + padding: 0.2rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 500; + border: 1px solid transparent; + display: inline-block; + white-space: nowrap; +} + +/* badge-primary (used in source type display) */ +.badge-primary { + background-color: #cfe2ff; + color: #084298; } /* Main Content */ .main-content { flex: 1; - max-width: 1200px; - margin: 0 auto; - padding: 2rem 1rem; - width: 100%; + padding: 1.75rem 2rem; + overflow-y: auto; + min-height: 100vh; + background: #f5f5f5; } .tab-content { @@ -793,38 +930,61 @@ body { } /* Responsive */ -@media (max-width: 768px) { +@media (max-width: 900px) { + .sidebar { + width: 180px; + min-width: 180px; + } +} + +@media (max-width: 640px) { + .app-container { + flex-direction: column; + } + + .sidebar { + width: 100%; + min-width: unset; + height: auto; + min-height: unset; + position: static; + } + + .sidebar-sub-items.expanded { + max-height: 600px; + } + + .main-content { + min-height: unset; + padding: 1rem; + } + .content-header { flex-direction: column; align-items: stretch; } - + .content-controls { justify-content: center; } - + .search-box input { width: 200px; } - - .nav-tabs { - flex-direction: column; - gap: 0.25rem; - } - + .data-table { font-size: 0.8rem; } - + .data-table th, .data-table td { padding: 0.5rem 0.25rem; } - + .dashboard-grid { grid-template-columns: 1fr; } - + .tools-grid { grid-template-columns: 1fr; } diff --git a/static/index.html b/static/index.html index 01c1738..d8060a2 100644 --- a/static/index.html +++ b/static/index.html @@ -4,18 +4,19 @@ Chronarr - Database Management - +
- -
-
-

Chronarr

-

Database Management & Reporting

+ +
+
@@ -60,7 +93,7 @@

-

- with dates
- +
@@ -71,7 +104,7 @@

-

- episodes
- +
@@ -82,7 +115,7 @@

-

- no valid source
- +
@@ -131,16 +164,13 @@

Movies Database

-
- +
@@ -152,18 +182,18 @@

Movies Database

- + - +
Source Date Type Video FileInstance Actions
Loading movies...Loading movies...
- + @@ -192,16 +222,13 @@

TV Series Database

- - +
@@ -211,18 +238,18 @@

TV Series Database

- + - +
Episodes With Dates With VideoInstance Actions
Loading series...Loading series...
- + @@ -463,13 +490,13 @@ - +
Leave empty to clear date
- +
- +
@@ -494,6 +521,6 @@
- + - \ No newline at end of file + diff --git a/static/js/app.js b/static/js/app.js index 5788fd7..e710447 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -5,58 +5,118 @@ let currentTab = 'dashboard'; let currentMoviesPage = 1; let currentSeriesPage = 1; let dashboardData = null; +let currentMovieInstance = ''; +let currentTvInstance = ''; + +// Instance badge color palette — assigned in order as instances are discovered +const instanceColorMap = {}; +const instanceColors = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444']; +let instanceColorIndex = 0; + +function getInstanceColor(name) { + if (!name) return '#6b7280'; + if (!instanceColorMap[name]) { + instanceColorMap[name] = instanceColors[instanceColorIndex++ % instanceColors.length]; + } + return instanceColorMap[name]; +} + +function instanceBadge(name) { + if (!name) return '-'; + const c = getInstanceColor(name); + return `${escapeHtml(name)}`; +} // Initialize app document.addEventListener('DOMContentLoaded', function() { - initializeTabs(); initializeEventListeners(); - checkAuthStatus(); // Check authentication status on page load + checkAuthStatus(); loadDashboard(); loadSeriesSources(); - loadInstanceDropdowns(); + buildSidebarInstances(); }); // Tab management -function initializeTabs() { - const tabButtons = document.querySelectorAll('.nav-tab'); - const tabContents = document.querySelectorAll('.tab-content'); - - tabButtons.forEach(button => { - button.addEventListener('click', function() { - const tabName = this.dataset.tab; - switchTab(tabName); - }); - }); -} - function switchTab(tabName) { - // Update button states - document.querySelectorAll('.nav-tab').forEach(btn => btn.classList.remove('active')); - document.querySelector(`[data-tab="${tabName}"]`).classList.add('active'); - + // Update sidebar item active states + document.querySelectorAll('.sidebar-item[data-tab]').forEach(btn => btn.classList.remove('active')); + const activeBtn = document.querySelector(`.sidebar-item[data-tab="${tabName}"]`); + if (activeBtn) activeBtn.classList.add('active'); + // Update content document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active')); document.getElementById(tabName).classList.add('active'); - + currentTab = tabName; - - // Load tab-specific data + switch(tabName) { - case 'dashboard': - loadDashboard(); - break; - case 'movies': - loadMovies(); - break; - case 'tv': - loadSeries(); - break; - case 'reports': - loadReport(); - break; - case 'tools': - loadDetailedStats(); - break; + case 'dashboard': loadDashboard(); break; + case 'movies': loadMovies(); break; + case 'tv': loadSeries(); break; + case 'reports': loadReport(); break; + case 'tools': loadDetailedStats(); break; + } +} + +// Sidebar instance navigation +function toggleSidebarGroup(subId, tabName) { + const sub = document.getElementById(subId); + const isExpanded = sub.classList.toggle('expanded'); + const toggle = sub.closest('.sidebar-group').querySelector('.sidebar-group-toggle'); + toggle.setAttribute('aria-expanded', isExpanded ? 'true' : 'false'); + if (tabName) switchTab(tabName); +} + +function selectMovieInstance(name) { + currentMovieInstance = name; + currentMoviesPage = 1; + document.querySelectorAll('#movies-sub .sidebar-sub-item').forEach(btn => btn.classList.remove('active')); + const target = document.getElementById(name ? `sub-movies-${name}` : 'sub-movies-all'); + if (target) target.classList.add('active'); + switchTab('movies'); +} + +function selectTvInstance(name) { + currentTvInstance = name; + currentSeriesPage = 1; + document.querySelectorAll('#tv-sub .sidebar-sub-item').forEach(btn => btn.classList.remove('active')); + const target = document.getElementById(name ? `sub-tv-${name}` : 'sub-tv-all'); + if (target) target.classList.add('active'); + switchTab('tv'); +} + +async function buildSidebarInstances() { + try { + const data = await apiCall('/api/instances'); + const radarrNames = (data.radarr || []).map(i => i.name); + const sonarrNames = (data.sonarr || []).map(i => i.name); + + // Pre-assign colors so badges match sidebar dots + [...radarrNames, ...sonarrNames].forEach(n => getInstanceColor(n)); + + const moviesSub = document.getElementById('movies-sub'); + radarrNames.forEach(name => { + const color = getInstanceColor(name); + const btn = document.createElement('button'); + btn.className = 'sidebar-sub-item'; + btn.id = `sub-movies-${name}`; + btn.onclick = () => selectMovieInstance(name); + btn.innerHTML = `${escapeHtml(name)}`; + moviesSub.appendChild(btn); + }); + + const tvSub = document.getElementById('tv-sub'); + sonarrNames.forEach(name => { + const color = getInstanceColor(name); + const btn = document.createElement('button'); + btn.className = 'sidebar-sub-item'; + btn.id = `sub-tv-${name}`; + btn.onclick = () => selectTvInstance(name); + btn.innerHTML = `${escapeHtml(name)}`; + tvSub.appendChild(btn); + }); + } catch (error) { + console.error('Failed to build sidebar instances:', error); } } @@ -71,10 +131,8 @@ function initializeEventListeners() { // Filter dropdowns document.getElementById('movies-filter-date').addEventListener('change', loadMovies); document.getElementById('movies-filter-source').addEventListener('change', loadMovies); - document.getElementById('movies-filter-instance').addEventListener('change', loadMovies); document.getElementById('series-filter-date').addEventListener('change', loadSeries); document.getElementById('series-filter-source').addEventListener('change', loadSeries); - document.getElementById('series-filter-instance').addEventListener('change', loadSeries); // Forms document.getElementById('edit-form').addEventListener('submit', handleEditSubmit); @@ -201,7 +259,7 @@ async function loadMovies(page = 1) { const imdbSearch = document.getElementById('movies-imdb-search').value; const hasDate = document.getElementById('movies-filter-date').value; const sourceFilter = document.getElementById('movies-filter-source').value; - const instanceFilter = document.getElementById('movies-filter-instance').value; + const instanceFilter = currentMovieInstance; const skip = (page - 1) * 100; console.log(`DEBUG: loadMovies called with page=${page}, calculated skip=${skip}`); @@ -232,7 +290,7 @@ function updateMoviesTable(data) { const tbody = document.getElementById('movies-tbody'); if (!data.movies || data.movies.length === 0) { - tbody.innerHTML = 'No movies found'; + tbody.innerHTML = 'No movies found'; return; } @@ -281,9 +339,6 @@ function updateMoviesTable(data) { dateTypeBadge = 'badge-warning'; } - const instanceColStyle = document.getElementById('movies-instance-col') && - document.getElementById('movies-instance-col').style.display !== 'none' - ? '' : 'display:none'; return ` ${escapeHtml(movie.title)} @@ -293,7 +348,7 @@ function updateMoviesTable(data) { ${movie.source_description || movie.source || 'Unknown'} ${dateType} ${hasVideoBadge} - ${movie.instance || '-'} + ${instanceBadge(movie.instance)} + `; @@ -1480,6 +1483,33 @@ async function deleteMovie(imdbId) { } } +async function deleteSeries(imdbId, instance, title) { + if (!confirm(`⚠️ Delete Series?\n\n"${title}"\n\nThis will permanently remove the series and ALL its episodes from the database.\n\nAre you sure you want to continue?`)) { + return; + } + + try { + const response = await fetch(`/api/series/${imdbId}?instance=${encodeURIComponent(instance)}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' } + }); + + const result = await response.json(); + + if (response.ok && result.success) { + showToast(`✅ Series deleted (${result.episodes_deleted} episode(s) removed)`, 'success'); + loadSeries(currentSeriesPage); + } else { + const errorMsg = result.message || result.detail || result.error || 'Unknown error'; + showToast(`❌ Failed to delete series: ${errorMsg}`, 'error'); + } + + } catch (error) { + console.error('Delete series failed:', error); + showToast(`❌ Delete failed: ${error.message}`, 'error'); + } +} + // Update episode counts in modal after deletion function updateEpisodeModalCounts() { const remainingRows = document.querySelectorAll('#episodes-table-body tr'); From ca1955c2da0d8411d93c68f70fc435f2233281a1 Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Thu, 27 Aug 2026 10:23:46 -0400 Subject: [PATCH 16/45] fix: cold start populate now iterates all configured Radarr/Sonarr instances 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. --- api/web_routes.py | 73 +++++++++++----- core/database_populator.py | 174 ++++++++++++++++++++++++++++--------- 2 files changed, 182 insertions(+), 65 deletions(-) diff --git a/api/web_routes.py b/api/web_routes.py index 608cc37..1f4dc29 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -1333,47 +1333,72 @@ def update_status(status_data): # Import here (inside process) to avoid pickling issues from core.database import ChronarrDatabase from core.database_populator import DatabasePopulator - from clients.radarr_client import RadarrClient - from clients.sonarr_client import SonarrClient from config.settings import config - # Initialize components db = ChronarrDatabase(config) - radarr_client = RadarrClient( - os.environ.get("RADARR_URL", ""), - os.environ.get("RADARR_API_KEY", "") - ) - sonarr_client = SonarrClient( - os.environ.get("SONARR_URL", ""), - os.environ.get("SONARR_API_KEY", "") - ) - populator = DatabasePopulator(db, radarr_client, sonarr_client) + def _merge_movie_stats(all_stats): + merged = {'total': 0, 'added': 0, 'updated': 0, 'skipped': 0, 'errors': 0, + 'duration': 0.0, 'skipped_items': []} + for inst_name, s in all_stats: + for k in ('total', 'added', 'updated', 'skipped', 'errors'): + merged[k] += s.get(k, 0) + merged['duration'] += s.get('duration', 0.0) + merged['skipped_items'].extend(s.get('skipped_items', [])) + return merged + + def _merge_tv_stats(all_stats): + merged = {'total_series': 0, 'total_episodes': 0, 'added': 0, 'updated': 0, + 'skipped': 0, 'errors': 0, 'duration': 0.0, 'skipped_items': []} + for inst_name, s in all_stats: + for k in ('total_series', 'total_episodes', 'added', 'updated', 'skipped', 'errors'): + merged[k] += s.get(k, 0) + merged['duration'] += s.get('duration', 0.0) + merged['skipped_items'].extend(s.get('skipped_items', [])) + return merged print(f"INFO: [Worker Process] Starting database population: {media_type}") + radarr_instances = config.radarr_instances + sonarr_instances = config.sonarr_instances + print(f"INFO: [Worker Process] Radarr instances: {[i.name for i in radarr_instances]}") + print(f"INFO: [Worker Process] Sonarr instances: {[i.name for i in sonarr_instances]}") # Run population based on media type if media_type in ["movies", "both"]: - status["movies"]["status"] = "running" - update_status(status) - - movie_stats = populator.populate_movies() - + all_movie_stats = [] + for inst in radarr_instances: + status["movies"]["status"] = f"running ({inst.name})" + update_status(status) + print(f"INFO: [Worker Process] Populating movies for instance '{inst.name}'") + + pop = DatabasePopulator.from_radarr_instance(inst, db) + inst_stats = pop.populate_movies(instance=inst.name) + all_movie_stats.append((inst.name, inst_stats)) + print(f"INFO: [Worker Process] Movies done for '{inst.name}': {inst_stats}") + + movie_stats = _merge_movie_stats(all_movie_stats) status["movies"]["status"] = "completed" status["movies"]["stats"] = movie_stats update_status(status) - print(f"INFO: [Worker Process] Movie population completed: {movie_stats}") + print(f"INFO: [Worker Process] All movie population complete: {movie_stats}") if media_type in ["tv", "both"]: - status["tv"]["status"] = "running" - update_status(status) - - tv_stats = populator.populate_tv_episodes() - + all_tv_stats = [] + for inst in sonarr_instances: + status["tv"]["status"] = f"running ({inst.name})" + update_status(status) + print(f"INFO: [Worker Process] Populating TV for instance '{inst.name}'") + + pop = DatabasePopulator.from_sonarr_instance(inst, db) + inst_stats = pop.populate_tv_episodes(instance=inst.name) + all_tv_stats.append((inst.name, inst_stats)) + print(f"INFO: [Worker Process] TV done for '{inst.name}': {inst_stats}") + + tv_stats = _merge_tv_stats(all_tv_stats) status["tv"]["status"] = "completed" status["tv"]["stats"] = tv_stats update_status(status) - print(f"INFO: [Worker Process] TV population completed: {tv_stats}") + print(f"INFO: [Worker Process] All TV population complete: {tv_stats}") # Mark as completed status["completed"] = True diff --git a/core/database_populator.py b/core/database_populator.py index ec3bbbd..f1c693b 100644 --- a/core/database_populator.py +++ b/core/database_populator.py @@ -23,7 +23,16 @@ class DatabasePopulator: """Populates Chronarr database from Radarr/Sonarr sources""" - def __init__(self, db: ChronarrDatabase, radarr_client: RadarrClient = None, sonarr_client: SonarrClient = None): + def __init__(self, db: ChronarrDatabase, radarr_client: RadarrClient = None, + sonarr_client: SonarrClient = None, + _radarr_db_override=None, _sonarr_db_override=None): + """ + Build a DatabasePopulator. + + Normal callers pass nothing (uses env vars) or pass API client fallbacks. + Per-instance constructors use _radarr_db_override / _sonarr_db_override to + inject a pre-built DB or API client directly, bypassing from_env() discovery. + """ self.db = db # Try database clients first, fall back to API clients @@ -35,38 +44,110 @@ def __init__(self, db: ChronarrDatabase, radarr_client: RadarrClient = None, son self.using_sonarr_db = False # Radarr setup - try: - self.radarr_db = RadarrDbClient.from_env() - if self.radarr_db: - _log("INFO", "DatabasePopulator: Using Radarr direct database access") - self.radarr = self.radarr_db + if _radarr_db_override is not None: + # Pre-built client from from_radarr_instance() — skip env discovery + if isinstance(_radarr_db_override, RadarrDbClient): + self.radarr_db = _radarr_db_override + self.radarr = _radarr_db_override self.using_radarr_db = True + _log("INFO", "DatabasePopulator: Using pre-built Radarr DB client") else: - raise Exception("Database not configured") - except Exception: - self.radarr_api = radarr_client if radarr_client else RadarrClient( - os.environ.get("RADARR_URL", ""), - os.environ.get("RADARR_API_KEY", "") - ) - self.radarr = self.radarr_api - _log("INFO", "DatabasePopulator: Using Radarr API client") + self.radarr_api = _radarr_db_override + self.radarr = _radarr_db_override + _log("INFO", "DatabasePopulator: Using pre-built Radarr API client") + else: + try: + self.radarr_db = RadarrDbClient.from_env() + if self.radarr_db: + _log("INFO", "DatabasePopulator: Using Radarr direct database access") + self.radarr = self.radarr_db + self.using_radarr_db = True + else: + raise Exception("Database not configured") + except Exception: + self.radarr_api = radarr_client if radarr_client else RadarrClient( + os.environ.get("RADARR_URL", ""), + os.environ.get("RADARR_API_KEY", "") + ) + self.radarr = self.radarr_api + _log("INFO", "DatabasePopulator: Using Radarr API client") # Sonarr setup - try: - self.sonarr_db = SonarrDbClient.from_env() - if self.sonarr_db: - _log("INFO", "DatabasePopulator: Using Sonarr direct database access") - self.sonarr = self.sonarr_db + if _sonarr_db_override is not None: + # Pre-built client from from_sonarr_instance() — skip env discovery + if isinstance(_sonarr_db_override, SonarrDbClient): + self.sonarr_db = _sonarr_db_override + self.sonarr = _sonarr_db_override self.using_sonarr_db = True + _log("INFO", "DatabasePopulator: Using pre-built Sonarr DB client") else: - raise Exception("Database not configured") - except Exception: - self.sonarr_api = sonarr_client if sonarr_client else SonarrClient( - os.environ.get("SONARR_URL", ""), - os.environ.get("SONARR_API_KEY", "") - ) - self.sonarr = self.sonarr_api - _log("INFO", "DatabasePopulator: Using Sonarr API client") + self.sonarr_api = _sonarr_db_override + self.sonarr = _sonarr_db_override + _log("INFO", "DatabasePopulator: Using pre-built Sonarr API client") + else: + try: + self.sonarr_db = SonarrDbClient.from_env() + if self.sonarr_db: + _log("INFO", "DatabasePopulator: Using Sonarr direct database access") + self.sonarr = self.sonarr_db + self.using_sonarr_db = True + else: + raise Exception("Database not configured") + except Exception: + self.sonarr_api = sonarr_client if sonarr_client else SonarrClient( + os.environ.get("SONARR_URL", ""), + os.environ.get("SONARR_API_KEY", "") + ) + self.sonarr = self.sonarr_api + _log("INFO", "DatabasePopulator: Using Sonarr API client") + + @classmethod + def from_radarr_instance(cls, inst, db: ChronarrDatabase) -> 'DatabasePopulator': + """Build a populator wired to one specific RadarrInstance. + + Tries direct DB access using the instance's db_type/db_path/host config; + falls back to the HTTP API client if DB access isn't configured or fails. + """ + client = None + if inst.db_type: + try: + client = RadarrDbClient( + db_type=inst.db_type, + db_path=inst.db_path or None, + db_host=inst.db_host or None, + db_port=inst.db_port or None, + db_name=inst.db_name or None, + db_user=inst.db_user or None, + db_password=inst.db_password or None, + ) + _log("INFO", f"DatabasePopulator: DB access configured for Radarr instance '{inst.name}'") + except Exception as e: + _log("WARNING", f"DatabasePopulator: DB access failed for Radarr '{inst.name}', falling back to API: {e}") + if client is None: + client = RadarrClient(inst.url, inst.api_key) + return cls(db, _radarr_db_override=client) + + @classmethod + def from_sonarr_instance(cls, inst, db: ChronarrDatabase) -> 'DatabasePopulator': + """Build a populator wired to one specific SonarrInstance.""" + client = None + if inst.db_type: + try: + client = SonarrDbClient( + db_type=inst.db_type, + db_path=inst.db_path or None, + db_host=inst.db_host or None, + db_port=inst.db_port or None, + db_name=inst.db_name or None, + db_user=inst.db_user or None, + db_password=inst.db_password or None, + ) + _log("INFO", f"DatabasePopulator: DB access configured for Sonarr instance '{inst.name}'") + except Exception as e: + _log("WARNING", f"DatabasePopulator: DB access failed for Sonarr '{inst.name}', falling back to API: {e}") + if client is None: + client = SonarrClient(inst.url, inst.api_key) + return cls(db, _sonarr_db_override=client) def get_episode_import_history(self, episode_id: int) -> Optional[str]: """ @@ -83,9 +164,12 @@ def get_episode_import_history(self, episode_id: int) -> Optional[str]: else: return None - def populate_movies(self) -> Dict[str, any]: + def populate_movies(self, instance: str = 'radarr') -> Dict[str, any]: """ - Populate movies from Radarr database/API + Populate movies from Radarr database/API. + + Args: + instance: Instance name to tag records with (e.g. 'radarr', 'radarr_4k'). Returns: Dictionary with statistics: { @@ -97,7 +181,7 @@ def populate_movies(self) -> Dict[str, any]: 'duration': float } """ - _log("INFO", "Starting movie population from Radarr") + _log("INFO", f"Starting movie population from Radarr instance '{instance}'") start_time = time.time() stats = { @@ -164,13 +248,14 @@ def populate_movies(self) -> Dict[str, any]: title=movie.get('title', 'Unknown'), year=movie.get('year', 0), path=path, - reason=skip_reason + reason=skip_reason, + instance=instance, ) stats['skipped'] += 1 continue # Check if movie already exists in database - existing = self.db.get_movie_dates(imdb_id) + existing = self.db.get_movie_dates(imdb_id, instance=instance) if existing and existing.get('dateadded'): # Already in database - update file path and video status if needed existing_path = existing.get('path') @@ -242,7 +327,8 @@ def populate_movies(self) -> Dict[str, any]: title=movie.get('title', 'Unknown'), year=movie.get('year', 0), path=path or 'unknown', - reason=skip_reason + reason=skip_reason, + instance=instance, ) stats['skipped'] += 1 continue @@ -267,7 +353,8 @@ def populate_movies(self) -> Dict[str, any]: title=movie.get('title', 'Unknown'), year=movie.get('year', 0), path=path or 'unknown', - reason=skip_reason + reason=skip_reason, + instance=instance, ) stats['skipped'] += 1 continue @@ -277,7 +364,8 @@ def populate_movies(self) -> Dict[str, any]: year = movie.get('year') self.db.upsert_movie_dates( imdb_id, released, dateadded, source, - has_video_file=True, title=title, year=year + has_video_file=True, title=title, year=year, + instance=instance, ) # Add to processing history @@ -314,9 +402,12 @@ def populate_movies(self) -> Dict[str, any]: return stats - def populate_tv_episodes(self) -> Dict[str, any]: + def populate_tv_episodes(self, instance: str = 'sonarr') -> Dict[str, any]: """ - Populate TV episodes from Sonarr API + Populate TV episodes from Sonarr database/API. + + Args: + instance: Instance name to tag records with (e.g. 'sonarr', 'sonarr_4k'). Returns: Dictionary with statistics: { @@ -375,7 +466,7 @@ def populate_tv_episodes(self) -> Dict[str, any]: _log("DEBUG", f"Series without IMDb ID: {series_title} (path: {series_path}), using placeholder {imdb_id}") # Update series record - self.db.upsert_series(imdb_id, series_path) + self.db.upsert_series(imdb_id, series_path, instance=instance) # Try high-performance database bulk query first bulk_import_dates = {} @@ -412,7 +503,7 @@ def populate_tv_episodes(self) -> Dict[str, any]: stats['total_episodes'] += 1 # Check if episode already exists - existing = self.db.get_episode_date(imdb_id, season_num, episode_num) + existing = self.db.get_episode_date(imdb_id, season_num, episode_num, instance=instance) if existing and existing.get('dateadded'): # Already in database - update file path and video status if needed existing_path = existing.get('path') @@ -495,13 +586,14 @@ def populate_tv_episodes(self) -> Dict[str, any]: imdb_id=imdb_id, season=season_num, episode=episode_num, - reason=skip_reason + reason=skip_reason, + instance=instance, ) stats['skipped'] += 1 continue # Insert into database - self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, has_file) + self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, has_file, instance=instance) # Add to processing history try: From 3cbbe83ec93392d867876690dc2129b2e20194a5 Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Thu, 27 Aug 2026 12:54:57 -0400 Subject: [PATCH 17/45] docs: update README, CHANGELOG, and QUICKSTART for v3 - 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) --- CHANGELOG.md | 18 ++++++++++++- QUICKSTART.md | 2 +- README.md | 73 ++++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 78 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 301a14b..b3f7b8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,20 @@ -# Chronarr Plugin Changelog +# Chronarr Changelog + +## Core App + +### v3.0.0 + +- **Multi-instance support** — Configure multiple Radarr and/or Sonarr instances using a `NAME` segment in the env var (e.g., `RADARR_4K_URL`). Each instance is discovered automatically and tracked independently in the database +- **Sidebar navigation** — Replaced the horizontal tab bar with a collapsible dark sidebar; Movies and TV Shows expand to show per-instance sub-items with colored dots +- **Instance badges** — Table rows display a colored pill badge identifying which instance the record belongs to; colors are consistent between the sidebar dots and the table badges +- **Instance-aware edit and smart-fix** — Date edits, smart-fix actions, and skipped-item updates are scoped to the correct instance, preventing cross-instance updates in multi-instance setups +- **Cold-start populate fix** — Database population triggered at startup now loops all configured Radarr and Sonarr instances instead of only the first +- **Delete series** — New API endpoint (`DELETE /api/series/{imdb_id}?instance=sonarr`) and UI button to remove an entire TV series and all its episode records in one operation +- **Series title cleanup** — Series titles sourced from folder paths no longer include media-manager ID suffixes such as `[imdb-tt1234567]` + +--- + +## Emby and Jellyfin Plugin Full version history for the Emby and Jellyfin plugins. The config page inside each plugin shows the latest 5 entries only. diff --git a/QUICKSTART.md b/QUICKSTART.md index eb833bb..19acfda 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -89,7 +89,7 @@ docker-compose restart ### 4. Populate Database 1. Open web interface: `http://your-server:8081` -2. Click **Admin** tab +2. Click **Tools** in the sidebar 3. Click **Populate Database** 4. Select **Movies** and/or **TV Shows** 5. Click **Start Population** diff --git a/README.md b/README.md index 5160908..8f9b77a 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,8 @@ Chronarr tracks and manages "dateadded" timestamps for all your movies and TV ep - **Direct Database Access** - Required for Radarr, optional for Sonarr (PostgreSQL & SQLite support) - **API Integration** - Sonarr API support for metadata and series lookups - **Webhook Support** - Real-time updates on import, upgrade, and rename events -- **Bulk Import** - One-click population of entire Radarr/Sonarr libraries +- **Multi-Instance Support** - Run multiple Radarr instances (e.g., standard + 4K) and/or multiple Sonarr instances, each tracked independently with per-instance badges and filtering +- **Bulk Import** - One-click population of entire Radarr/Sonarr libraries across all instances - **Path Mapping** - Intelligent path translation for Docker/remote setups ### **Jellyfin Plugin** @@ -206,7 +207,7 @@ open http://your-server:8081 ``` Then in the web interface: -1. Click **Admin** tab +1. Open the sidebar and click **Tools** 2. Click **Populate Database** 3. Select **Movies** and/or **TV Shows** 4. Click **Start Population** @@ -335,35 +336,65 @@ SONARR_DB_PASSWORD=sonarr_pass # Sonarr database password (.env.secrets) # See docker-compose.yml.example for volume mount configuration ``` +**Multi-Instance Configuration (v3.0+):** + +Add additional Radarr or Sonarr instances using a `NAME` segment between the prefix and the variable suffix. The name becomes the instance identifier in Chronarr's UI and database. + +```bash +# Second Radarr instance (e.g., 4K library) +RADARR_4K_URL=http://radarr-4k:7878 +RADARR_4K_API_KEY=your_4k_api_key # (.env.secrets) +RADARR_4K_DB_TYPE=postgresql +RADARR_4K_DB_HOST=radarr-4k-db +RADARR_4K_DB_NAME=radarr-main +RADARR_4K_DB_USER=radarr +RADARR_4K_DB_PASSWORD=radarr_4k_pass # (.env.secrets) + +# Second Sonarr instance (e.g., anime library) +SONARR_ANIME_URL=http://sonarr-anime:8989 +SONARR_ANIME_API_KEY=your_anime_key # (.env.secrets) +SONARR_ANIME_DB_TYPE=postgresql +SONARR_ANIME_DB_HOST=sonarr-anime-db +SONARR_ANIME_DB_NAME=sonarr-main +SONARR_ANIME_DB_USER=sonarr +SONARR_ANIME_DB_PASSWORD=sonarr_anime_pass # (.env.secrets) +``` + +Each named instance gets its own webhook endpoint (`/radarr_4k/webhook`, `/sonarr_anime/webhook`) and appears as a separate entry in the sidebar. An "All" view aggregates across all instances with colored instance badges on each row. + **SQLite Docker Configuration:** When using SQLite databases with Docker, you must mount the database directories as read-only volumes. See the Quick Start guide (step 3) or check `docker-compose.yml.example` for detailed examples. ## Web Interface +The web interface uses a dark sidebar for navigation. Sections expand to show per-instance sub-items; clicking an instance filters the view to that instance only. An "All" sub-item shows the full library with colored instance badges on each row. + ### Dashboard - **Statistics** - Total movies, episodes, dates populated, missing dates - **Source Distribution** - Pie charts showing date source breakdown - **Recent Activity** - Last 7 days of processing history - **Skipped Items** - Items without valid dates -### Movies Tab +### Movies - **Search & Filter** - By title, path, IMDb ID, date status, source +- **Instance Filter** - Click an instance in the sidebar or view all with badges - **Bulk Actions** - Delete, edit dates, update IMDb IDs - **Smart Sorting** - By date added, release date, title - **Debug Tools** - Raw database inspection for troubleshooting -### TV Shows Tab +### TV Shows - **Series Management** - View all series with episode counts and progress - **Episode Browsing** - Detailed episode lists with dates and sources - **Season Filtering** - Filter episodes by season +- **Delete Series** - Remove an entire series and all its episode records +- **Instance Filter** - Click an instance in the sidebar or view all with badges - **Batch Updates** - Update multiple episodes at once -### Admin Tab -- **Database Population** - One-click import from Radarr/Sonarr +### Tools +- **Database Population** - One-click import from Radarr/Sonarr across all instances - **Progress Tracking** - Real-time progress bars and statistics - **Validation** - Pre-population checks for connectivity and permissions -- **Manual Scans** - Trigger ad-hoc scans (future feature) ## API Endpoints @@ -376,7 +407,8 @@ When using SQLite databases with Docker, you must mount the database directories - `POST /api/movies/{imdb_id}/migrate-imdb` - Migrate placeholder IMDb ID to real ID ### TV Shows -- `GET /api/series` - List all series with episode counts +- `GET /api/series` - List all series with episode counts (`?instance=sonarr` to filter) +- `DELETE /api/series/{imdb_id}` - Delete an entire series and all its episodes (`?instance=sonarr`) - `GET /api/series/{imdb_id}/episodes` - Get episodes for series - `GET /api/episodes/{imdb_id}/{season}/{episode}` - Get specific episode - `PUT /api/episodes/{imdb_id}/{season}/{episode}` - Update episode date @@ -384,30 +416,36 @@ When using SQLite databases with Docker, you must mount the database directories - `POST /api/series/{imdb_id}/migrate-imdb` - Migrate series IMDb ID ### Administration -- `POST /admin/populate-database` - Trigger database population +- `POST /admin/populate-database` - Trigger database population across all configured instances - `GET /api/dashboard` - Get dashboard statistics - `GET /health` - Health check endpoint ## Webhook Configuration +Each Radarr/Sonarr instance gets its own webhook endpoint. The URL path matches the instance name derived from the environment variable: `RADARR_URL` → `/radarr/webhook`, `RADARR_4K_URL` → `/radarr_4k/webhook`. + ### Radarr 1. Go to **Settings → Connect** 2. Add **Webhook** connection 3. Configure: - **Name**: Chronarr - - **URL**: `http://chronarr-core:8080/webhook/radarr` + - **URL**: `http://chronarr-core:8080/radarr/webhook` (default instance) - **Triggers**: On Import, On Upgrade, On Rename - **Tags**: (optional, leave blank for all movies) +For a second instance (e.g., `RADARR_4K_URL`), use `http://chronarr-core:8080/radarr_4k/webhook`. + ### Sonarr 1. Go to **Settings → Connect** 2. Add **Webhook** connection 3. Configure: - **Name**: Chronarr - - **URL**: `http://chronarr-core:8080/webhook/sonarr` + - **URL**: `http://chronarr-core:8080/sonarr/webhook` (default instance) - **Triggers**: On Import, On Upgrade, On Rename, On Episode File Delete - **Tags**: (optional, leave blank for all series) +For a second instance (e.g., `SONARR_ANIME_URL`), use `http://chronarr-core:8080/sonarr_anime/webhook`. + ## Jellyfin Integration Chronarr includes a Jellyfin plugin that automatically syncs episode and movie dates to Jellyfin, ensuring your media library displays the correct import dates from Chronarr instead of scan dates. @@ -529,11 +567,11 @@ A: Yes, Chronarr can read from both SQLite and PostgreSQL databases for both Rad Chronarr's core application does not collect personal data. It reads only from your local Radarr and Sonarr databases and stores date information in your own PostgreSQL instance. -The **Emby plugin** collects the following information during license registration: +The **Emby and Jellyfin plugins** collect the following information during license registration: - **Name** — used to identify your license - **Email address** — used to deliver your license key and send license status notifications -- **Server name** — used to associate a license with a specific Emby installation +- **Server name** — used to associate a license with a specific media server installation This data is transmitted to the Chronarr license server and is used solely for license management. It is never sold, shared with third parties, or used for any purpose other than validating and managing your license. @@ -556,6 +594,15 @@ This project is licensed under the MIT License - see the LICENSE file for detail ## Changelog +### v3.0.0 +- 🏠 **Sidebar navigation** - Replaced horizontal tab bar with a collapsible dark sidebar; instance sub-items expand under Movies and TV Shows +- 🔀 **Multi-instance support** - Configure multiple Radarr and/or Sonarr instances; each is tracked separately and shown with colored badges in the UI +- 🏷️ **Instance badges** - Every row in the Movies and TV tables shows a colored badge identifying its instance; badge colors are consistent between the sidebar and the table +- 🚀 **Cold-start populate fix** - Database population on startup now iterates all configured instances, not just the first +- 🗑️ **Delete series** - Remove an entire TV series and all its episode records from the database in one click +- 🔧 **Instance-aware edit** - Date edits, smart fixes, and skipped-item updates are now scoped to the correct instance +- 🧹 **Series title cleanup** - Series titles derived from folder paths no longer include media-manager ID suffixes (e.g., `[imdb-tt1234567]`) + ### v2.0.0 (2025-11-05) - 🎉 **Initial release** - Comprehensive date and chronology management for Radarr and Sonarr - 🎯 **Smart date tracking** - Multiple sources with intelligent fallback hierarchy From adce3999d9f72345ddd9efafe28bf9c25979b8be Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Thu, 27 Aug 2026 12:59:27 -0400 Subject: [PATCH 18/45] docs: fix stale references in README and QUICKSTART MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- QUICKSTART.md | 4 ++-- README.md | 25 +++++++++++++------------ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/QUICKSTART.md b/QUICKSTART.md index 19acfda..cbcfe67 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -121,14 +121,14 @@ docker-compose restart 1. Go to **Settings → Connect** 2. Add **Webhook** connection: - Name: `Chronarr` - - URL: `http://chronarr-core:8080/webhook/radarr` + - URL: `http://chronarr-core:8080/radarr/webhook` - Triggers: On Import, On Upgrade, On Rename ### Sonarr 1. Go to **Settings → Connect** 2. Add **Webhook** connection: - Name: `Chronarr` - - URL: `http://chronarr-core:8080/webhook/sonarr` + - URL: `http://chronarr-core:8080/sonarr/webhook` - Triggers: On Import, On Upgrade, On Rename, On Episode File Delete ## Useful Commands diff --git a/README.md b/README.md index 8f9b77a..eb4f9ba 100644 --- a/README.md +++ b/README.md @@ -201,11 +201,10 @@ SONARR_DB_PATH=/sonarr-config/sonarr.db ```bash # Apply configuration changes docker-compose restart - -# Access web interface -open http://your-server:8081 ``` +Browse to `http://your-server:8081` + Then in the web interface: 1. Open the sidebar and click **Tools** 2. Click **Populate Database** @@ -510,7 +509,7 @@ docker exec -it chronarr-core psql -h radarr-db -U radarr -d radarr-main ### Missing Dates - Check **Dashboard** → **Skipped Items** section -- Review **Source** column in Movies/TV tabs +- Review **Source** column in the Movies or TV Shows sections - Use **Debug** button to inspect raw database data - Verify Radarr/Sonarr have import history data @@ -524,15 +523,16 @@ docker exec -it chronarr-core psql -h radarr-db -U radarr -d radarr-main ### Project Structure ``` chronarr/ -├── api/ # API route handlers +├── api/ # Core API route handlers ├── clients/ # Radarr/Sonarr API clients ├── config/ # Configuration management ├── core/ # Core database and logic -├── chronarr-web/ # Web interface container -│ ├── static/ # HTML, CSS, JavaScript -│ └── api/ # Web-specific API routes -├── processors/ # Webhook processors (legacy) -├── utils/ # Utility functions +├── chronarr-web/ # Web interface container +│ ├── static/ # HTML, CSS, JavaScript (served by web container) +│ └── api/ # Web-specific API routes (web_routes.py) +├── static/ # Shared static assets +├── processors/ # Webhook processors +├── utils/ # Utility functions └── docker-compose.yml.example # Docker Compose configuration template ``` @@ -545,8 +545,9 @@ cd chronarr # Build Docker image docker build -t chronarr:dev . -# Run with development settings -docker-compose -f docker-compose.dev.yml up -d +# Run with the example compose file +cp docker-compose.yml.example docker-compose.yml +docker-compose up -d ``` ## FAQ From 90a4e7c439c4e4c537af4d057d5a2d0c33775dd9 Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Thu, 27 Aug 2026 13:08:54 -0400 Subject: [PATCH 19/45] docs: fix stale and sensitive content in env examples and compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .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 --- .env.example | 8 ++++---- .env.secrets.example | 22 +++++++++++++++------- README.md | 16 ++++++++++++++++ docker-compose.yml.example | 2 +- 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 3b652c0..49b5c09 100644 --- a/.env.example +++ b/.env.example @@ -58,7 +58,7 @@ MOVIE_PATHS=/media/movies # Radarr database (direct DB access — faster than API for large libraries) RADARR_DB_TYPE=postgresql -RADARR_DB_HOST=192.168.255.50 +RADARR_DB_HOST=your-radarr-db-host RADARR_DB_PORT=5432 RADARR_DB_NAME=radarr-main RADARR_DB_USER=postgres @@ -84,7 +84,7 @@ RADARR_DB_USER=postgres # RADARR_4K_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies4k # RADARR_4K_MOVIE_PATHS=/media/movies4k # RADARR_4K_DB_TYPE=postgresql -# RADARR_4K_DB_HOST=192.168.255.50 +# RADARR_4K_DB_HOST=your-radarr-4k-db-host # RADARR_4K_DB_PORT=5432 # RADARR_4K_DB_NAME=radarr-4k # RADARR_4K_DB_USER=postgres @@ -102,7 +102,7 @@ TV_PATHS=/media/tv # Sonarr database SONARR_DB_TYPE=postgresql -SONARR_DB_HOST=192.168.255.50 +SONARR_DB_HOST=your-sonarr-db-host SONARR_DB_PORT=5432 SONARR_DB_NAME=sonarr-main SONARR_DB_USER=postgres @@ -121,7 +121,7 @@ SONARR_DB_USER=postgres # SONARR_4K_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv4k # SONARR_4K_TV_PATHS=/media/tv4k # SONARR_4K_DB_TYPE=postgresql -# SONARR_4K_DB_HOST=192.168.255.50 +# SONARR_4K_DB_HOST=your-sonarr-4k-db-host # SONARR_4K_DB_PORT=5432 # SONARR_4K_DB_NAME=sonarr-4k # SONARR_4K_DB_USER=postgres diff --git a/.env.secrets.example b/.env.secrets.example index 5fd86e1..098b5f1 100644 --- a/.env.secrets.example +++ b/.env.secrets.example @@ -6,16 +6,21 @@ # =========================================== # CHRONARR DATABASE CREDENTIALS (REQUIRED) # =========================================== -# Chronarr PostgreSQL database password (REQUIRED for v2.6+) +# Chronarr PostgreSQL database password (required) DB_PASSWORD=your_secure_chronarr_password # Optional: Override default database user (defaults to 'chronarr') # DB_USER=chronarr # =========================================== -# RADARR DATABASE CREDENTIALS (OPTIONAL) +# RADARR / SONARR DATABASE CREDENTIALS # =========================================== -# Database password for external Radarr PostgreSQL connection (optional optimization) +# Database passwords for direct Radarr/Sonarr PostgreSQL access (optional, recommended) RADARR_DB_PASSWORD=your_radarr_db_password +SONARR_DB_PASSWORD=your_sonarr_db_password + +# Named instance passwords (uncomment if using additional instances) +# RADARR_4K_DB_PASSWORD=your_radarr_4k_db_password +# SONARR_4K_DB_PASSWORD=your_sonarr_4k_db_password # =========================================== # API KEYS (SENSITIVE DATA) @@ -29,15 +34,18 @@ OMDB_API_KEY=your_omdb_api_key # Radarr API key (optional - for fallback only) RADARR_API_KEY=your_radarr_api_key -# Sonarr API key (REQUIRED for v0.6.0+ Enhanced TV NFO Generation) +# Sonarr API key (required for series lookups and TV episode metadata) SONARR_API_KEY=your_sonarr_api_key +# Named instance API keys (uncomment if using additional instances) +# RADARR_4K_API_KEY=your_radarr_4k_api_key +# SONARR_4K_API_KEY=your_sonarr_4k_api_key + # Jellyseerr API key (optional) JELLYSEERR_API_KEY=your_jellyseerr_api_key -# TVDB API key (RECOMMENDED for v0.6.1+ Emby TV NFO Compatibility) -# Required to convert IMDB IDs to TVDB IDs for proper Emby metadata loading -# Get free API key at: https://thetvdb.com/api-information +# TVDB API key (optional — used for TV episode air date lookups) +# Get a free API key at: https://thetvdb.com/api-information TVDB_API_KEY=your_tvdb_api_key # =========================================== diff --git a/README.md b/README.md index eb4f9ba..87cb941 100644 --- a/README.md +++ b/README.md @@ -593,6 +593,22 @@ This project is licensed under the MIT License - see the LICENSE file for detail - **GitHub Issues**: [https://github.com/sbcrumb/chronarr/issues](https://github.com/sbcrumb/chronarr/issues) +## Upgrading from v2 to v3 + +v3 is a drop-in upgrade for single-instance setups. Pull the new image and restart — no config changes are required. + +**What you'll notice immediately:** +- The horizontal tab bar is replaced by a collapsible sidebar. Dashboard, Movies, TV Shows, Reports, and Tools are all still there. +- The "Admin" tab is now **Tools** in the sidebar. + +**Webhooks:** The old `/webhook/radarr` and `/webhook/sonarr` URLs still work — they're kept as aliases. The new canonical format is `/radarr/webhook` and `/sonarr/webhook`, and you can update your Radarr/Sonarr connections at any time, but there's no urgency. + +**Database:** Schema migrations run automatically on startup. Existing movie and episode records are preserved and tagged with the default instance name (`radarr` / `sonarr`). + +**Multi-instance (optional):** If you run a second Radarr (e.g., 4K) or second Sonarr, see the [Multi-Instance Configuration](#multi-instance-configuration-v30) block in the Configuration section. Each extra instance just needs a `RADARR_{NAME}_URL` block in your `.env` and its own webhook connection in Radarr/Sonarr. + +--- + ## Changelog ### v3.0.0 diff --git a/docker-compose.yml.example b/docker-compose.yml.example index 0b359b6..da536fb 100644 --- a/docker-compose.yml.example +++ b/docker-compose.yml.example @@ -158,7 +158,7 @@ networks: # 4. Access Web Interface: # http://your-server:8081 # -# Then click Admin → Populate Database +# Then open the sidebar → Tools → Populate Database # # ======================================== # Configuration Notes From 21524d9ad13b154d96b10485fa2ce54958c228b4 Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Thu, 27 Aug 2026 13:23:58 -0400 Subject: [PATCH 20/45] fix: sync sidebar nav static files to chronarr-web/static MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- chronarr-web/static/css/styles.css | 1279 ++++++++++++----------- chronarr-web/static/index.html | 691 ++----------- chronarr-web/static/js/app.js | 1536 +++++++--------------------- 3 files changed, 1086 insertions(+), 2420 deletions(-) diff --git a/chronarr-web/static/css/styles.css b/chronarr-web/static/css/styles.css index ab59410..ab2038a 100644 --- a/chronarr-web/static/css/styles.css +++ b/chronarr-web/static/css/styles.css @@ -1,101 +1,53 @@ -/* ── CSS Variables ───────────────────────────────────────── */ +/* Chronarr Web Interface Styles */ :root { - --bg: #f0f4f8; - --surface: #ffffff; - --surface-2: #f7f9fc; - --border: #e2e8f0; - --text: #1a202c; - --text-muted: #718096; - --accent: #0096c7; - --accent-hover: #007aab; - --accent-subtle: rgba(0, 150, 199, 0.08); - --sidebar-bg: #ffffff; - --sidebar-border: #e2e8f0; - --sidebar-text: #4a5568; - --sidebar-icon: #a0aec0; - --sidebar-active-bg: rgba(0, 150, 199, 0.1); - --sidebar-active-text: #0096c7; - --shadow: 0 1px 3px rgba(0, 0, 0, 0.08); - --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.10); - --success: #38a169; - --warning-color: #d69e2e; - --danger: #e53e3e; - --badge-success-bg: #c6f6d5; --badge-success-text: #276749; - --badge-warning-bg: #fefcbf; --badge-warning-text: #744210; - --badge-danger-bg: #fed7d7; --badge-danger-text: #9b2c2c; - --badge-info-bg: #bee3f8; --badge-info-text: #2c5282; - --badge-secondary-bg: #e2e8f0; --badge-secondary-text: #4a5568; - --progress-bg: #e2e8f0; - --code-bg: #edf2f7; - --input-bg: #ffffff; - --modal-overlay: rgba(0, 0, 0, 0.45); -} - -@media (prefers-color-scheme: dark) { - :root { - --bg: #0d0f1a; - --surface: #161927; - --surface-2: #1e2235; - --border: #2a2f4a; - --text: #e2e8f0; - --text-muted: #718096; - --accent: #00b4d8; - --accent-hover: #0096c7; - --accent-subtle: rgba(0, 180, 216, 0.12); - --sidebar-bg: #111425; - --sidebar-border: #1e2235; - --sidebar-text: #8892b0; - --sidebar-icon: #4a5568; - --sidebar-active-bg: rgba(0, 180, 216, 0.15); - --sidebar-active-text: #00b4d8; - --shadow: 0 1px 3px rgba(0, 0, 0, 0.3); - --shadow-lg: 0 4px 20px rgba(0, 0, 0, 0.45); - --success: #68d391; - --warning-color: #f6e05e; - --danger: #fc8181; - --badge-success-bg: #22543d; --badge-success-text: #9ae6b4; - --badge-warning-bg: #5f370e; --badge-warning-text: #fefcbf; - --badge-danger-bg: #742a2a; --badge-danger-text: #fed7d7; - --badge-info-bg: #2a4365; --badge-info-text: #bee3f8; - --badge-secondary-bg: #2d3748; --badge-secondary-text: #a0aec0; - --progress-bg: #2d3748; - --code-bg: #1e2235; - --input-bg: #1e2235; - --modal-overlay: rgba(0, 0, 0, 0.65); - } + --primary-color: #007bff; + --secondary-color: #6c757d; + --success-color: #28a745; + --warning-color: #ffc107; + --danger-color: #dc3545; + --dark-color: #343a40; + --light-color: #f8f9fa; + --border-color: #dee2e6; + --text-muted: #6c757d; + --shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + --shadow-lg: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; } -/* ── Reset ───────────────────────────────────────────────── */ -*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } -html, body { height: 100%; } - body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 14px; line-height: 1.5; - color: var(--text); - background: var(--bg); + color: var(--dark-color); + background-color: #f5f5f5; } -/* ── App Layout ──────────────────────────────────────────── */ -.app-layout { - display: flex; +.app-container { min-height: 100vh; + display: flex; + flex-direction: row; + align-items: stretch; } -/* ── Sidebar ─────────────────────────────────────────────── */ +/* Sidebar */ .sidebar { width: 220px; - flex-shrink: 0; - background: var(--sidebar-bg); - border-right: 1px solid var(--sidebar-border); + min-width: 220px; + background: #1a1d23; + color: #8b95a1; display: flex; flex-direction: column; + min-height: 100vh; position: sticky; top: 0; height: 100vh; overflow-y: auto; - z-index: 100; + flex-shrink: 0; } .sidebar-brand { @@ -103,264 +55,353 @@ body { display: flex; align-items: center; gap: 0.625rem; - border-bottom: 1px solid var(--sidebar-border); - flex-shrink: 0; + color: #ffffff; + font-size: 1.1rem; + font-weight: 600; + border-bottom: 1px solid #2d3139; + text-decoration: none; } -.sidebar-brand img { - width: 32px; - height: 32px; - border-radius: 6px; - flex-shrink: 0; - object-fit: cover; +.sidebar-brand i { + color: #0096c7; + font-size: 1.2rem; } -.sidebar-brand-text h1 { - font-size: 1.05rem; - font-weight: 700; - color: var(--text); - line-height: 1.2; +/* Auth block in sidebar */ +.sidebar-auth { + padding: 0.625rem 1rem; + border-bottom: 1px solid #2d3139; + display: flex; + flex-direction: column; + gap: 0.375rem; + font-size: 0.8rem; + color: #8b95a1; } -.sidebar-brand-text small { - font-size: 0.7rem; - color: var(--text-muted); +.auth-user { + display: flex; + align-items: center; + gap: 0.375rem; + opacity: 0.85; } -.sidebar-nav { - flex: 1; - padding: 0.625rem 0; - overflow-y: auto; +.auth-logout { + background: rgba(255, 255, 255, 0.08); + color: #8b95a1; + border: 1px solid #3a3f4a; + padding: 0.3rem 0.625rem; + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.775rem; + display: flex; + align-items: center; + gap: 0.375rem; + transition: all 0.2s ease; + width: fit-content; +} + +.auth-logout:hover { + background: rgba(255, 255, 255, 0.14); + color: #c9d1d9; } -.nav-section { - margin-bottom: 0.125rem; +/* Sidebar nav */ +.sidebar-nav { + flex: 1; + padding: 0.5rem 0 1rem; + display: flex; + flex-direction: column; } .nav-section-label { - padding: 0.625rem 1rem 0.25rem; + padding: 0.875rem 1rem 0.25rem; font-size: 0.65rem; font-weight: 700; - text-transform: uppercase; letter-spacing: 0.08em; - color: var(--text-muted); -} - -.nav-divider { - height: 1px; - background: var(--sidebar-border); - margin: 0.375rem 1rem; + text-transform: uppercase; + color: #4a5260; } -.nav-item { +.sidebar-item { display: flex; align-items: center; gap: 0.625rem; - width: 100%; - padding: 0.6rem 1rem; + padding: 0.45rem 0.75rem; + margin: 0.1rem 0.5rem; + width: calc(100% - 1rem); background: none; border: none; + color: #8b95a1; cursor: pointer; - color: var(--sidebar-text); font-size: 0.875rem; text-align: left; + text-decoration: none; + border-radius: 0.375rem; transition: background 0.15s, color 0.15s; + font-family: inherit; } -.nav-item i { - width: 16px; +.sidebar-item i { + width: 1.1rem; text-align: center; + font-size: 0.85rem; flex-shrink: 0; - color: var(--sidebar-icon); - transition: color 0.15s; - font-size: 0.875rem; } -.nav-item:hover { - background: var(--accent-subtle); - color: var(--text); +.sidebar-item:hover { + background: #252930; + color: #c9d1d9; } -.nav-item:hover i { color: var(--accent); } +.sidebar-item.active { + background: rgba(0, 150, 199, 0.14); + color: #0096c7; +} -.nav-item.active { - background: var(--sidebar-active-bg); - color: var(--sidebar-active-text); - font-weight: 600; +/* Expandable group */ +.sidebar-group { + margin: 0.1rem 0; } -.nav-item.active i { color: var(--sidebar-active-text); } +.sidebar-group-toggle { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.45rem 0.75rem; + margin: 0 0.5rem; + width: calc(100% - 1rem); + background: none; + border: none; + color: #8b95a1; + cursor: pointer; + font-size: 0.875rem; + border-radius: 0.375rem; + transition: background 0.15s, color 0.15s; + font-family: inherit; +} -.sidebar-footer { - padding: 0.75rem 1rem; - border-top: 1px solid var(--sidebar-border); - flex-shrink: 0; +.sidebar-group-toggle:hover { + background: #252930; + color: #c9d1d9; } -.auth-user { +.toggle-content { display: flex; align-items: center; - gap: 0.5rem; - color: var(--text-muted); - font-size: 0.8rem; - margin-bottom: 0.5rem; + gap: 0.625rem; } -.auth-logout { - width: 100%; - background: none; - border: 1px solid var(--border); - color: var(--text-muted); - padding: 0.375rem 0.75rem; - border-radius: 0.375rem; - cursor: pointer; - font-size: 0.8rem; +.toggle-content i { + width: 1.1rem; + text-align: center; + font-size: 0.85rem; +} + +.toggle-chevron { + font-size: 0.65rem; + transition: transform 0.2s ease; + opacity: 0.5; +} + +.sidebar-group-toggle[aria-expanded="true"] .toggle-chevron { + transform: rotate(180deg); +} + +/* Sub-items (collapsed by default, expand via .expanded) */ +.sidebar-sub-items { + overflow: hidden; + max-height: 0; + transition: max-height 0.2s ease; +} + +.sidebar-sub-items.expanded { + max-height: 400px; +} + +.sidebar-sub-item { display: flex; align-items: center; gap: 0.5rem; - transition: all 0.15s; + padding: 0.35rem 0.75rem 0.35rem 2.5rem; + width: 100%; + background: none; + border: none; + color: #626c7a; + cursor: pointer; + font-size: 0.825rem; + text-align: left; + transition: color 0.15s, background 0.15s; + font-family: inherit; } -.auth-logout:hover { - background: var(--accent-subtle); - color: var(--accent); - border-color: var(--accent); +.sidebar-sub-item:hover { + color: #c9d1d9; + background: #202328; } -/* ── Main area ───────────────────────────────────────────── */ -.main-wrapper { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; +.sidebar-sub-item.active { + color: #0096c7; +} + +.instance-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + display: inline-block; +} + +.sidebar-divider { + height: 1px; + background: #2d3139; + margin: 0.625rem 0.75rem; } +/* Instance badge in table cells */ +.instance-badge { + padding: 0.2rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 500; + border: 1px solid transparent; + display: inline-block; + white-space: nowrap; +} + +/* badge-primary (used in source type display) */ +.badge-primary { + background-color: #cfe2ff; + color: #084298; +} + +/* Main Content */ .main-content { flex: 1; - padding: 1.75rem; - width: 100%; - max-width: 1400px; + padding: 1.75rem 2rem; + overflow-y: auto; + min-height: 100vh; + background: #f5f5f5; +} + +.tab-content { + display: none; } -.tab-content { display: none; } -.tab-content.active { display: block; } +.tab-content.active { + display: block; +} -/* ── Dashboard ───────────────────────────────────────────── */ +/* Dashboard */ .dashboard-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 1.25rem; - margin-bottom: 1.5rem; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; } .stat-card { - background: var(--surface); - border-radius: 0.625rem; - padding: 1.25rem; + background: white; + border-radius: 0.5rem; + padding: 1.5rem; box-shadow: var(--shadow); - border: 1px solid var(--border); display: flex; align-items: center; gap: 1rem; } .stat-icon { - width: 52px; - height: 52px; - border-radius: 0.5rem; + width: 60px; + height: 60px; + border-radius: 50%; display: flex; align-items: center; justify-content: center; - font-size: 1.25rem; + font-size: 1.5rem; color: white; - flex-shrink: 0; } -.stat-icon.movies { background: linear-gradient(135deg, #0096c7, #0077b6); } -.stat-icon.tv { background: linear-gradient(135deg, #6c63ff, #4f46e5); } -.stat-icon.missing { background: linear-gradient(135deg, #f59e0b, #d97706); } -.stat-icon.activity{ background: linear-gradient(135deg, #10b981, #059669); } +.stat-icon.movies { background: linear-gradient(135deg, #667eea, #764ba2); } +.stat-icon.tv { background: linear-gradient(135deg, #f093fb, #f5576c); } +.stat-icon.missing { background: linear-gradient(135deg, #ffecd2, #fcb69f); } +.stat-icon.activity { background: linear-gradient(135deg, #a8edea, #fed6e3); } .stat-info h3 { - font-size: 1.75rem; + font-size: 2rem; font-weight: 700; - color: var(--text); - line-height: 1; margin-bottom: 0.25rem; } .stat-info p { font-weight: 500; - color: var(--text); - font-size: 0.875rem; - margin-bottom: 0.125rem; + margin-bottom: 0.25rem; } -.stat-info small { color: var(--text-muted); font-size: 0.8rem; } +.stat-info small { + color: var(--text-muted); + font-size: 0.85rem; +} .dashboard-charts { display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 1.25rem; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; } .chart-card { - background: var(--surface); - border-radius: 0.625rem; - padding: 1.25rem; + background: white; + border-radius: 0.5rem; + padding: 1.5rem; box-shadow: var(--shadow); - border: 1px solid var(--border); } .chart-card h3 { margin-bottom: 1rem; - color: var(--text); - font-size: 0.9375rem; - font-weight: 600; + color: var(--dark-color); } .chart-container { - min-height: 180px; + height: 200px; display: flex; align-items: center; justify-content: center; - padding: 0.5rem 0; + background: var(--light-color); + border-radius: 0.25rem; color: var(--text-muted); } -/* ── Content header ──────────────────────────────────────── */ +/* Content Header */ .content-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1.25rem; + margin-bottom: 1.5rem; flex-wrap: wrap; - gap: 0.75rem; + gap: 1rem; } .content-header h2 { - color: var(--text); - font-weight: 700; - font-size: 1.25rem; - display: flex; - align-items: center; - gap: 0.5rem; + color: var(--dark-color); + font-weight: 600; } -.content-header h2 i { color: var(--accent); } - .content-controls { display: flex; - gap: 0.75rem; + gap: 1rem; align-items: center; flex-wrap: wrap; } -.search-controls, .filter-controls { +.search-controls { display: flex; gap: 0.5rem; flex-wrap: wrap; +} + +.filter-controls { + display: flex; + gap: 0.5rem; align-items: center; + flex-wrap: wrap; } .search-box { @@ -371,99 +412,94 @@ body { .search-box i { position: absolute; - left: 0.625rem; + left: 0.75rem; color: var(--text-muted); - font-size: 0.8rem; - pointer-events: none; } .search-box input { - padding: 0.5rem 0.75rem 0.5rem 2.125rem; - border: 1px solid var(--border); - border-radius: 0.375rem; - font-size: 0.875rem; - width: 220px; - background: var(--input-bg); - color: var(--text); - transition: border-color 0.15s, box-shadow 0.15s; + padding: 0.5rem 0.75rem 0.5rem 2.5rem; + border: 1px solid var(--border-color); + border-radius: 0.25rem; + font-size: 0.9rem; + width: 250px; } .search-box input:focus { outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-subtle); + border-color: var(--primary-color); + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); } -.search-box input::placeholder { color: var(--text-muted); } - -/* ── Buttons ─────────────────────────────────────────────── */ +/* Buttons */ .btn { padding: 0.5rem 1rem; border: none; - border-radius: 0.375rem; + border-radius: 0.25rem; cursor: pointer; - font-size: 0.875rem; - font-weight: 500; - transition: all 0.15s; + font-size: 0.9rem; + transition: all 0.2s ease; display: inline-flex; align-items: center; - gap: 0.375rem; + gap: 0.5rem; text-decoration: none; - white-space: nowrap; } -.btn-primary { background: var(--accent); color: #fff; } -.btn-primary:hover { background: var(--accent-hover); transform: translateY(-1px); } +.btn-primary { + background-color: var(--primary-color); + color: white; +} -.btn-secondary { background: var(--surface-2); color: var(--text); border: 1px solid var(--border); } -.btn-secondary:hover { background: var(--border); } +.btn-primary:hover { + background-color: #0056b3; + transform: translateY(-1px); +} -.btn-success { background: var(--success); color: #fff; } -.btn-success:hover { filter: brightness(1.1); } +.btn-secondary { + background-color: var(--secondary-color); + color: white; +} -.btn-warning { background: #d97706; color: #fff; } -.btn-warning:hover { background: #b45309; } +.btn-secondary:hover { + background-color: #545b62; +} -.btn-danger { background: var(--danger); color: #fff; } -.btn-danger:hover { filter: brightness(1.1); } +.btn-success { + background-color: var(--success-color); + color: white; +} -.btn-outline { - background: none; - border: 1px solid var(--border); - color: var(--text-muted); +.btn-success:hover { + background-color: #1e7e34; } -.btn-outline:hover { - border-color: var(--accent); - color: var(--accent); - background: var(--accent-subtle); + +.btn-warning { + background-color: var(--warning-color); + color: var(--dark-color); } -.btn-sm { padding: 0.3rem 0.625rem; font-size: 0.8125rem; } +.btn-warning:hover { + background-color: #e0a800; +} -/* ── Select / inputs ─────────────────────────────────────── */ -select { - padding: 0.5rem 0.75rem; - border: 1px solid var(--border); - border-radius: 0.375rem; - font-size: 0.875rem; - background: var(--input-bg); - color: var(--text); - transition: border-color 0.15s; - cursor: pointer; +.btn-danger { + background-color: var(--danger-color); + color: white; } -select:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-subtle); +.btn-danger:hover { + background-color: #c82333; } -/* ── Tables ──────────────────────────────────────────────── */ +.btn-sm { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; +} + +/* Tables */ .table-container { - background: var(--surface); - border-radius: 0.625rem; + background: white; + border-radius: 0.5rem; box-shadow: var(--shadow); - border: 1px solid var(--border); overflow: hidden; margin-bottom: 1rem; } @@ -475,101 +511,104 @@ select:focus { .data-table th, .data-table td { - padding: 0.75rem 1rem; + padding: 0.75rem; text-align: left; - border-bottom: 1px solid var(--border); + border-bottom: 1px solid var(--border-color); } .data-table th { - background: var(--surface-2); + background-color: var(--light-color); font-weight: 600; - font-size: 0.8125rem; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--text-muted); - white-space: nowrap; + color: var(--dark-color); position: sticky; top: 0; - z-index: 1; } -.data-table th.sortable { cursor: pointer; user-select: none; } -.data-table th.sortable:hover { color: var(--accent); } - -.data-table tbody tr:last-child td { border-bottom: none; } - -.data-table tbody tr:hover { background: var(--accent-subtle); } +.data-table tr:hover { + background-color: rgba(0, 123, 255, 0.05); +} .data-table .loading { text-align: center; color: var(--text-muted); - padding: 2.5rem; - font-size: 0.9rem; + font-style: italic; + padding: 2rem; } -/* ── Badges ──────────────────────────────────────────────── */ +/* Status badges */ .badge { - padding: 0.2rem 0.5rem; + padding: 0.25rem 0.5rem; border-radius: 0.25rem; - font-size: 0.7rem; - font-weight: 700; + font-size: 0.75rem; + font-weight: 600; text-transform: uppercase; - letter-spacing: 0.04em; - white-space: nowrap; } -.badge-success { background: var(--badge-success-bg); color: var(--badge-success-text); } -.badge-warning { background: var(--badge-warning-bg); color: var(--badge-warning-text); } -.badge-danger { background: var(--badge-danger-bg); color: var(--badge-danger-text); } -.badge-info { background: var(--badge-info-bg); color: var(--badge-info-text); } -.badge-secondary { background: var(--badge-secondary-bg); color: var(--badge-secondary-text); } +.badge-success { + background-color: #d4edda; + color: #155724; +} + +.badge-warning { + background-color: #fff3cd; + color: #856404; +} + +.badge-danger { + background-color: #f8d7da; + color: #721c24; +} + +.badge-secondary { + background-color: #e9ecef; + color: #495057; +} -/* ── Pagination ──────────────────────────────────────────── */ +/* Pagination */ .pagination { display: flex; justify-content: center; align-items: center; - gap: 0.375rem; + gap: 0.5rem; margin-top: 1rem; - flex-wrap: wrap; } -.pagination .btn { padding: 0.4rem 0.75rem; min-width: 2.25rem; justify-content: center; } -.pagination .page-info { color: var(--text-muted); font-size: 0.875rem; padding: 0 0.5rem; } +.pagination .btn { + padding: 0.5rem 0.75rem; +} -/* ── Forms ───────────────────────────────────────────────── */ -.form-group { margin-bottom: 1rem; } +.pagination .page-info { + margin: 0 1rem; + color: var(--text-muted); +} + +/* Forms */ +.form-group { + margin-bottom: 1rem; +} .form-group label { display: block; - margin-bottom: 0.3rem; + margin-bottom: 0.25rem; font-weight: 500; - font-size: 0.875rem; - color: var(--text); } .form-group input, .form-group select, .form-group textarea { width: 100%; - padding: 0.5rem 0.75rem; - border: 1px solid var(--border); - border-radius: 0.375rem; - font-size: 0.875rem; - background: var(--input-bg); - color: var(--text); - transition: border-color 0.15s, box-shadow 0.15s; + padding: 0.5rem; + border: 1px solid var(--border-color); + border-radius: 0.25rem; + font-size: 0.9rem; } -.form-group input::placeholder, -.form-group textarea::placeholder { color: var(--text-muted); } - .form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-subtle); + border-color: var(--primary-color); + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); } .form-group small { @@ -579,501 +618,445 @@ select:focus { font-size: 0.8rem; } -.form-group small.help-text { color: var(--text-muted); } - -.form-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; -} - .form-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1.5rem; - padding-top: 1rem; - border-top: 1px solid var(--border); -} - -.checkbox-label { - display: flex; - align-items: center; - gap: 0.5rem; - cursor: pointer; - font-weight: 400 !important; -} - -.checkbox-label input[type="checkbox"] { - width: 16px; - height: 16px; - cursor: pointer; - accent-color: var(--accent); } -/* ── Modals ──────────────────────────────────────────────── */ +/* Modal */ .modal { display: none; position: fixed; z-index: 1000; - inset: 0; - background: var(--modal-overlay); - backdrop-filter: blur(2px); + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); } .modal.active { display: flex; align-items: center; justify-content: center; - padding: 1rem; } .modal-content { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 0.75rem; - max-width: 520px; - width: 100%; + background: white; + border-radius: 0.5rem; + max-width: 500px; + width: 90%; max-height: 90vh; overflow-y: auto; box-shadow: var(--shadow-lg); } .modal-header { - padding: 1rem 1.25rem; - border-bottom: 1px solid var(--border); + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; - position: sticky; - top: 0; - background: var(--surface); - z-index: 1; } -.modal-header h3 { font-size: 1rem; font-weight: 600; color: var(--text); } +.modal-header h3 { + margin: 0; +} .modal-close { background: none; border: none; - font-size: 1.25rem; + font-size: 1.5rem; cursor: pointer; color: var(--text-muted); - line-height: 1; - padding: 0.125rem 0.25rem; - border-radius: 0.25rem; - transition: color 0.15s, background 0.15s; } -.modal-close:hover { color: var(--text); background: var(--surface-2); } - -.modal-body { padding: 1.25rem; } - -#edit-modal, #smart-fix-modal { z-index: 1100; } - -/* ── Section cards ───────────────────────────────────────── */ -.section-card { - background: var(--surface); - border-radius: 0.625rem; - padding: 1.25rem; - border: 1px solid var(--border); - box-shadow: var(--shadow); - margin-bottom: 1.5rem; +.modal-close:hover { + color: var(--dark-color); } -.section-card h3 { - font-size: 0.9375rem; - font-weight: 600; - color: var(--text); - margin-bottom: 1rem; - display: flex; - align-items: center; - gap: 0.5rem; +.modal-body { + padding: 1.5rem; } -.section-card h3 i { color: var(--accent); } +/* Higher z-index for edit modals that appear on top of other modals */ +#edit-modal, #smart-fix-modal { + z-index: 1100 !important; +} -/* ── Reports ─────────────────────────────────────────────── */ +/* Reports */ .report-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; - margin-bottom: 1.5rem; + margin-bottom: 2rem; } .summary-card { - background: var(--surface); - padding: 1.25rem; - border-radius: 0.625rem; + background: white; + padding: 1.5rem; + border-radius: 0.5rem; box-shadow: var(--shadow); - border: 1px solid var(--border); text-align: center; } -.summary-card h3 { margin-bottom: 0.75rem; color: var(--text); font-size: 0.9375rem; } -.summary-card p { margin-bottom: 0.375rem; font-size: 1rem; color: var(--text); } -.summary-card span { font-weight: 700; color: var(--accent); } +.summary-card h3 { + margin-bottom: 1rem; + color: var(--dark-color); +} + +.summary-card p { + margin-bottom: 0.5rem; + font-size: 1.1rem; +} + +.summary-card span { + font-weight: 700; + color: var(--primary-color); +} -.report-section { margin-bottom: 2rem; } +.report-section { + margin-bottom: 2rem; +} .report-section h3 { - margin-bottom: 0.75rem; - color: var(--text); - font-size: 0.9375rem; - font-weight: 600; - display: flex; - align-items: center; - gap: 0.5rem; + margin-bottom: 1rem; + color: var(--dark-color); } -/* ── Tools ───────────────────────────────────────────────── */ +/* Tools */ .tools-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 1.25rem; + gap: 1.5rem; } .tool-card { - background: var(--surface); - border-radius: 0.625rem; - padding: 1.25rem; + background: white; + border-radius: 0.5rem; + padding: 1.5rem; box-shadow: var(--shadow); - border: 1px solid var(--border); } .tool-card h3 { - margin-bottom: 0.375rem; - color: var(--text); - font-size: 0.9375rem; - font-weight: 600; - display: flex; - align-items: center; - gap: 0.5rem; + margin-bottom: 0.5rem; + color: var(--dark-color); } -.tool-card h3 i { color: var(--accent); } - -.tool-card > p { - margin-bottom: 1.25rem; +.tool-card p { + margin-bottom: 1.5rem; color: var(--text-muted); - font-size: 0.875rem; } .stats-display { - background: var(--surface-2); - border: 1px solid var(--border); + background: var(--light-color); padding: 1rem; - border-radius: 0.375rem; + border-radius: 0.25rem; margin-bottom: 1rem; - min-height: 80px; - color: var(--text); - font-size: 0.875rem; + min-height: 100px; } -/* ── Toast notifications ─────────────────────────────────── */ +/* Toast notifications */ .toast-container { position: fixed; - top: 1.25rem; - right: 1.25rem; - z-index: 2000; - display: flex; - flex-direction: column; - gap: 0.5rem; - pointer-events: none; + top: 20px; + right: 20px; + z-index: 1050; } .toast { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 0.5rem; + background: white; + border-radius: 0.25rem; box-shadow: var(--shadow-lg); + margin-bottom: 0.5rem; padding: 0.75rem 1rem; - min-width: 280px; - max-width: 360px; - border-left: 3px solid var(--accent); - animation: toastIn 0.25s ease; - pointer-events: all; - color: var(--text); - font-size: 0.875rem; + min-width: 300px; + border-left: 4px solid var(--primary-color); + animation: slideIn 0.3s ease; } -.toast.success { border-left-color: var(--success); } -.toast.warning { border-left-color: #d97706; } -.toast.error { border-left-color: var(--danger); } - -@keyframes toastIn { - from { transform: translateX(110%); opacity: 0; } - to { transform: translateX(0); opacity: 1; } +.toast.success { + border-left-color: var(--success-color); } -/* ── Scan / progress ─────────────────────────────────────── */ -.scan-status { - margin-top: 1rem; - padding: 1rem; - background: var(--surface-2); - border: 1px solid var(--border); - border-radius: 0.5rem; +.toast.warning { + border-left-color: var(--warning-color); } -.progress-bar { - width: 100%; - height: 6px; - background: var(--progress-bg); - border-radius: 999px; - overflow: hidden; - margin-bottom: 0.5rem; +.toast.error { + border-left-color: var(--danger-color); } -.progress-fill { - height: 100%; - background: linear-gradient(90deg, var(--accent), var(--success)); - border-radius: 999px; - transition: width 0.3s ease; - width: 0%; -} - -.scan-info { - display: flex; - justify-content: space-between; - font-size: 0.8125rem; - color: var(--text-muted); +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } } -.scan-info span:last-child { color: var(--accent); font-weight: 600; } - -/* ── Smart Fix / Date option cards ───────────────────────── */ -.smart-fix-options, .date-options { +/* Smart Fix Modal */ +.smart-fix-options { display: flex; flex-direction: column; - gap: 0.625rem; + gap: 1rem; margin-bottom: 1rem; } -.option-card, .date-option-card { - border: 1px solid var(--border); +.option-card { + border: 2px solid var(--border-color); border-radius: 0.5rem; - transition: border-color 0.15s, box-shadow 0.15s; - background: var(--surface); + transition: all 0.2s ease; } -.option-card:hover, .date-option-card:hover { - border-color: var(--accent); - box-shadow: 0 0 0 2px var(--accent-subtle); +.option-card:hover { + border-color: var(--primary-color); + box-shadow: var(--shadow); } -.option-label, .date-option-label { - display: flex; - align-items: flex-start; - gap: 0.625rem; - padding: 0.875rem; +.option-label { + display: block; + padding: 1rem; cursor: pointer; margin: 0; } -.option-label input[type="radio"], -.date-option-label input[type="radio"] { - margin-top: 0.125rem; +.option-label input[type="radio"] { + margin-right: 0.75rem; + margin-top: 0.1rem; width: auto; - flex-shrink: 0; - accent-color: var(--accent); } -.option-content h4, .date-option-content h4 { - margin: 0 0 0.25rem; - font-size: 0.9rem; - font-weight: 600; - color: var(--text); +.option-content h4 { + margin: 0 0 0.5rem 0; + color: var(--dark-color); + font-size: 1rem; } -.option-content p, .date-option-content p { - margin: 0 0 0.25rem; +.option-content p { + margin: 0 0 0.5rem 0; color: var(--text-muted); - font-size: 0.8rem; + font-size: 0.9rem; } -.option-content small, .date-option-content small { +.option-content small { color: var(--text-muted); - font-size: 0.775rem; + font-size: 0.8rem; } -.option-card:has(input[type="radio"]:checked), -.date-option-card:has(input[type="radio"]:checked) { - border-color: var(--accent); - background: var(--accent-subtle); +.manual-date-input { + width: 100% !important; + margin-top: 0.5rem !important; } -.option-card:has(input[type="radio"]:checked) .option-content h4, -.date-option-card:has(input[type="radio"]:checked) .date-option-content h4 { - color: var(--accent); +.option-card input[type="radio"]:checked + .option-content { + color: var(--primary-color); } -/* ── Cron builder ────────────────────────────────────────── */ -.cron-input-container { - display: flex; - gap: 0.5rem; - align-items: center; +.option-card:has(input[type="radio"]:checked) { + border-color: var(--primary-color); + background-color: rgba(0, 123, 255, 0.05); } -.cron-input-container input { flex: 1; } - -.cron-presets { margin-bottom: 1.25rem; } -.cron-presets h4 { margin-bottom: 0.625rem; font-size: 0.875rem; color: var(--text); } - -.preset-buttons { display: flex; flex-wrap: wrap; gap: 0.375rem; } - -.cron-fields { margin-bottom: 1.25rem; } -.cron-fields h4 { margin-bottom: 0.75rem; font-size: 0.875rem; color: var(--text); } +/* Additional badge styles */ +.badge-info { + background-color: #d1ecf1; + color: #0c5460; +} -.field-group { +/* Enhanced Edit Modal Date Options */ +.date-options { display: flex; - align-items: center; + flex-direction: column; gap: 0.75rem; - margin-bottom: 0.5rem; + margin-bottom: 1rem; } -.field-group label { - width: 160px; - flex-shrink: 0; - font-size: 0.875rem; - color: var(--text-muted); - margin: 0; +.date-option-card { + border: 1px solid var(--border-color); + border-radius: 0.375rem; + transition: all 0.2s ease; } -.field-group input { - width: 100px; - padding: 0.375rem 0.625rem; - border: 1px solid var(--border); - border-radius: 0.375rem; - background: var(--input-bg); - color: var(--text); - font-size: 0.875rem; +.date-option-card:hover { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1); } -.cron-preview { margin-bottom: 1rem; } -.cron-preview h4 { margin-bottom: 0.5rem; font-size: 0.875rem; color: var(--text); } +.date-option-label { + display: block; + padding: 0.75rem; + cursor: pointer; + margin: 0; +} -.cron-expression { - background: var(--code-bg); - border: 1px solid var(--border); - border-radius: 0.375rem; - padding: 0.625rem 0.875rem; - margin-bottom: 0.5rem; +.date-option-label input[type="radio"] { + margin-right: 0.5rem; + margin-top: 0.1rem; + width: auto; } -.cron-expression code { font-family: monospace; font-size: 1rem; color: var(--accent); } -.cron-description { font-size: 0.875rem; color: var(--text-muted); } +.date-option-content h4 { + margin: 0 0 0.25rem 0; + color: var(--dark-color); + font-size: 0.9rem; + font-weight: 600; +} -/* ── Inline checkbox groups (Tools tab) ──────────────────── */ -.checkbox-group { - display: flex; - flex-direction: column; - gap: 0.375rem; - margin-top: 0.375rem; +.date-option-content p { + margin: 0 0 0.25rem 0; + color: var(--text-muted); + font-size: 0.8rem; } -.checkbox-group label { - display: flex; - align-items: center; - gap: 0.5rem; - cursor: pointer; - font-weight: 400; - color: var(--text); +.date-option-content small { + color: var(--primary-color); + font-size: 0.75rem; + font-weight: 500; } -.checkbox-group label input[type="checkbox"] { - width: 15px; - height: 15px; - accent-color: var(--accent); - cursor: pointer; +.date-option-card input[type="radio"]:checked + .date-option-content h4 { + color: var(--primary-color); } -/* ── Utility ─────────────────────────────────────────────── */ -.text-center { text-align: center; } -.text-muted { color: var(--text-muted); } -.mb-0 { margin-bottom: 0; } -.mb-1 { margin-bottom: 0.5rem; } -.mb-2 { margin-bottom: 1rem; } -.mt-1 { margin-top: 0.5rem; } -.mt-2 { margin-top: 1rem; } -.d-none { display: none; } -.d-block { display: block; } -.d-flex { display: flex; } -.justify-content-between { justify-content: space-between; } -.align-items-center { align-items: center; } +.date-option-card:has(input[type="radio"]:checked) { + border-color: var(--primary-color); + background-color: rgba(0, 123, 255, 0.03); +} -code { - font-family: monospace; - background: var(--code-bg); - padding: 0.1em 0.35em; - border-radius: 0.2em; - font-size: 0.875em; - color: var(--accent); +/* Responsive */ +@media (max-width: 900px) { + .sidebar { + width: 180px; + min-width: 180px; + } } -/* ── Mobile ──────────────────────────────────────────────── */ -@media (max-width: 768px) { +@media (max-width: 640px) { + .app-container { + flex-direction: column; + } + .sidebar { - position: fixed; - left: -220px; - top: 0; - height: 100vh; - transition: left 0.25s ease; - z-index: 200; + width: 100%; + min-width: unset; + height: auto; + min-height: unset; + position: static; } - .sidebar.open { left: 0; } - - .mobile-header { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.875rem 1rem; - background: var(--surface); - border-bottom: 1px solid var(--border); - position: sticky; - top: 0; - z-index: 150; + .sidebar-sub-items.expanded { + max-height: 600px; } - .mobile-menu-btn { - background: none; - border: none; - color: var(--text); - font-size: 1.125rem; - cursor: pointer; - padding: 0.25rem; + .main-content { + min-height: unset; + padding: 1rem; } - .mobile-title { - font-weight: 700; - color: var(--text); - font-size: 1rem; + .content-header { + flex-direction: column; + align-items: stretch; } - .sidebar-overlay { - display: none; - position: fixed; - inset: 0; - background: var(--modal-overlay); - z-index: 199; + .content-controls { + justify-content: center; } - .sidebar-overlay.active { display: block; } + .search-box input { + width: 200px; + } - .main-content { padding: 1rem; } + .data-table { + font-size: 0.8rem; + } + + .data-table th, + .data-table td { + padding: 0.5rem 0.25rem; + } + + .dashboard-grid { + grid-template-columns: 1fr; + } + + .tools-grid { + grid-template-columns: 1fr; + } +} - .content-header { flex-direction: column; align-items: stretch; } - .content-controls { justify-content: flex-start; } - .search-box input { width: 100%; } +/* Utility classes */ +.text-center { text-align: center; } +.text-muted { color: var(--text-muted); } +.mb-0 { margin-bottom: 0; } +.mb-1 { margin-bottom: 0.5rem; } +.mb-2 { margin-bottom: 1rem; } +.mt-1 { margin-top: 0.5rem; } +.mt-2 { margin-top: 1rem; } +.d-none { display: none; } +.d-block { display: block; } +.d-flex { display: flex; } +.justify-content-between { justify-content: space-between; } +.align-items-center { align-items: center; } - .dashboard-grid { grid-template-columns: 1fr 1fr; } - .tools-grid { grid-template-columns: 1fr; } - .form-row { grid-template-columns: 1fr; } +/* Manual Scan Styles */ +.scan-status { + margin-top: 1rem; + padding: 1rem; + background-color: var(--light-color); + border: 1px solid var(--border-color); + border-radius: 0.375rem; +} - .data-table { font-size: 0.8125rem; } - .data-table th, .data-table td { padding: 0.5rem 0.625rem; } +.scan-progress { + margin-bottom: 1rem; } -@media (max-width: 480px) { - .dashboard-grid { grid-template-columns: 1fr; } +.progress-bar { + width: 100%; + height: 1.5rem; + background-color: #e9ecef; + border-radius: 0.375rem; + overflow: hidden; + margin-bottom: 0.5rem; } + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--primary-color), var(--success-color)); + transition: width 0.3s ease; + width: 0%; +} + +.scan-info { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.875rem; +} + +.scan-info span:first-child { + color: var(--text-muted); +} + +.scan-info span:last-child { + font-weight: 600; + color: var(--primary-color); +} + +.form-group small { + display: block; + margin-top: 0.25rem; + color: var(--text-muted); + font-size: 0.875rem; +} + +.btn-sm { + padding: 0.375rem 0.75rem; + font-size: 0.875rem; +} \ No newline at end of file diff --git a/chronarr-web/static/index.html b/chronarr-web/static/index.html index a47adde..d8060a2 100644 --- a/chronarr-web/static/index.html +++ b/chronarr-web/static/index.html @@ -4,93 +4,82 @@ Chronarr - Database Management - + - -
- +
- - -
+ +
@@ -104,7 +93,7 @@

-

- with dates
- +
@@ -115,7 +104,7 @@

-

- episodes
- +
@@ -126,7 +115,7 @@

-

- no valid source
- +
@@ -137,17 +126,6 @@

-

Last 7 days
- -
-
- -
-
-

-

-

Skipped Items

- - movies, - episodes -
-
@@ -182,7 +160,6 @@

Movies Database

- - Check Movies - - -
-
-
- -
- - -
- Records removed only if missing from BOTH -
-
- -
- - - -
- -
-

Radarr/Sonarr Library Sync

-

Find movies and series in Chronarr that were removed from Radarr/Sonarr.

-
-
- - -
-
- -
- - -
- - -
-

Auto-Purge Settings

-

- Items missing from Radarr/Sonarr for longer than the configured days will be automatically removed. Set to 0 to disable. -

-
- - - -
-

-
-

Database Statistics

View detailed database information

@@ -727,9 +455,8 @@

Populate Database

- - - + + - - - - - - - - -
- - - + - \ No newline at end of file + diff --git a/chronarr-web/static/js/app.js b/chronarr-web/static/js/app.js index 4b13330..d9dc5e6 100644 --- a/chronarr-web/static/js/app.js +++ b/chronarr-web/static/js/app.js @@ -5,145 +5,142 @@ let currentTab = 'dashboard'; let currentMoviesPage = 1; let currentSeriesPage = 1; let dashboardData = null; +let currentMovieInstance = ''; +let currentTvInstance = ''; -// Initialize app -document.addEventListener('DOMContentLoaded', function() { - // Populate sidebar version from the backend — stays in sync with VERSION file - fetch('/health') - .then(function(r) { return r.json(); }) - .then(function(d) { - var el = document.getElementById('app-version'); - if (el && d.version) el.textContent = 'v' + d.version; - }) - .catch(function() { /* leave as-is on failure */ }); - - try { - initializeTabs(); - } catch (error) { - console.error('Error initializing tabs:', error); - } - - try { - initializeEventListeners(); - } catch (error) { - console.error('Error initializing event listeners:', error); - } +// Instance badge color palette — assigned in order as instances are discovered +const instanceColorMap = {}; +const instanceColors = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444']; +let instanceColorIndex = 0; - try { - checkAuthStatus(); // Check authentication status on page load - } catch (error) { - console.error('Error checking auth status:', error); +function getInstanceColor(name) { + if (!name) return '#6b7280'; + if (!instanceColorMap[name]) { + instanceColorMap[name] = instanceColors[instanceColorIndex++ % instanceColors.length]; } + return instanceColorMap[name]; +} - try { - loadDashboard(); - } catch (error) { - console.error('Error loading dashboard:', error); - } +function instanceBadge(name) { + if (!name) return '-'; + const c = getInstanceColor(name); + return `${escapeHtml(name)}`; +} - try { - loadSeriesSources(); - } catch (error) { - console.error('Error loading series sources:', error); - } +// Initialize app +document.addEventListener('DOMContentLoaded', function() { + initializeEventListeners(); + checkAuthStatus(); + loadDashboard(); + loadSeriesSources(); + buildSidebarInstances(); }); // Tab management -function initializeTabs() { - const tabButtons = document.querySelectorAll('.nav-item'); - const tabContents = document.querySelectorAll('.tab-content'); - - tabButtons.forEach(button => { - button.addEventListener('click', function() { - const tabName = this.dataset.tab; - if (tabName) switchTab(tabName); - // Close sidebar on mobile after nav - if (window.innerWidth <= 768 && typeof closeSidebar === 'function') closeSidebar(); - }); - }); -} - function switchTab(tabName) { - // Update button states - document.querySelectorAll('.nav-item').forEach(btn => btn.classList.remove('active')); - document.querySelector(`[data-tab="${tabName}"]`).classList.add('active'); - + // Update sidebar item active states + document.querySelectorAll('.sidebar-item[data-tab]').forEach(btn => btn.classList.remove('active')); + const activeBtn = document.querySelector(`.sidebar-item[data-tab="${tabName}"]`); + if (activeBtn) activeBtn.classList.add('active'); + // Update content document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active')); document.getElementById(tabName).classList.add('active'); - + currentTab = tabName; - - // Load tab-specific data + switch(tabName) { - case 'dashboard': - loadDashboard(); - break; - case 'movies': - loadMovies(); - break; - case 'tv': - loadSeries(); - break; - case 'reports': - loadReport(); - break; - case 'tools': - loadDetailedStats(); - break; + case 'dashboard': loadDashboard(); break; + case 'movies': loadMovies(); break; + case 'tv': loadSeries(); break; + case 'reports': loadReport(); break; + case 'tools': loadDetailedStats(); break; } } -// Event listeners -function initializeEventListeners() { - // Search inputs - const moviesSearch = document.getElementById('movies-search'); - const moviesImdbSearch = document.getElementById('movies-imdb-search'); - const seriesSearch = document.getElementById('series-search'); - const seriesImdbSearch = document.getElementById('series-imdb-search'); +// Sidebar instance navigation +function toggleSidebarGroup(subId, tabName) { + const sub = document.getElementById(subId); + const isExpanded = sub.classList.toggle('expanded'); + const toggle = sub.closest('.sidebar-group').querySelector('.sidebar-group-toggle'); + toggle.setAttribute('aria-expanded', isExpanded ? 'true' : 'false'); + if (tabName) switchTab(tabName); +} - if (moviesSearch) moviesSearch.addEventListener('input', debounce(loadMovies, 500)); - if (moviesImdbSearch) moviesImdbSearch.addEventListener('input', debounce(loadMovies, 500)); - if (seriesSearch) seriesSearch.addEventListener('input', debounce(loadSeries, 500)); - if (seriesImdbSearch) seriesImdbSearch.addEventListener('input', debounce(loadSeries, 500)); +function selectMovieInstance(name) { + currentMovieInstance = name; + currentMoviesPage = 1; + document.querySelectorAll('#movies-sub .sidebar-sub-item').forEach(btn => btn.classList.remove('active')); + const target = document.getElementById(name ? `sub-movies-${name}` : 'sub-movies-all'); + if (target) target.classList.add('active'); + switchTab('movies'); +} - // Filter dropdowns - const moviesFilterDate = document.getElementById('movies-filter-date'); - const moviesFilterSource = document.getElementById('movies-filter-source'); - const seriesFilterDate = document.getElementById('series-filter-date'); - const seriesFilterSource = document.getElementById('series-filter-source'); +function selectTvInstance(name) { + currentTvInstance = name; + currentSeriesPage = 1; + document.querySelectorAll('#tv-sub .sidebar-sub-item').forEach(btn => btn.classList.remove('active')); + const target = document.getElementById(name ? `sub-tv-${name}` : 'sub-tv-all'); + if (target) target.classList.add('active'); + switchTab('tv'); +} - if (moviesFilterDate) moviesFilterDate.addEventListener('change', loadMovies); - if (moviesFilterSource) moviesFilterSource.addEventListener('change', loadMovies); - if (seriesFilterDate) seriesFilterDate.addEventListener('change', loadSeries); - if (seriesFilterSource) seriesFilterSource.addEventListener('change', loadSeries); +async function buildSidebarInstances() { + try { + const data = await apiCall('/api/instances'); + const radarrNames = (data.radarr || []).map(i => i.name); + const sonarrNames = (data.sonarr || []).map(i => i.name); + + // Pre-assign colors so badges match sidebar dots + [...radarrNames, ...sonarrNames].forEach(n => getInstanceColor(n)); + + const moviesSub = document.getElementById('movies-sub'); + radarrNames.forEach(name => { + const color = getInstanceColor(name); + const btn = document.createElement('button'); + btn.className = 'sidebar-sub-item'; + btn.id = `sub-movies-${name}`; + btn.onclick = () => selectMovieInstance(name); + btn.innerHTML = `${escapeHtml(name)}`; + moviesSub.appendChild(btn); + }); - // Forms - const editForm = document.getElementById('edit-form'); - const bulkUpdateForm = document.getElementById('bulk-update-form'); - const manualScanForm = document.getElementById('manual-scan-form'); - const manualCleanupForm = document.getElementById('manual-cleanup-form'); - const populateForm = document.getElementById('populate-form'); - const librarySyncForm = document.getElementById('library-sync-form'); - - if (editForm) editForm.addEventListener('submit', handleEditSubmit); - if (bulkUpdateForm) bulkUpdateForm.addEventListener('submit', handleBulkUpdate); - if (manualScanForm) manualScanForm.addEventListener('submit', handleManualScan); - if (manualCleanupForm) manualCleanupForm.addEventListener('submit', handleManualCleanup); - if (populateForm) populateForm.addEventListener('submit', handlePopulateDatabase); - if (librarySyncForm) { - librarySyncForm.addEventListener('submit', handleLibrarySync); - const dryRunCb = document.getElementById('sync-dry-run'); - const immediateGroup = document.getElementById('sync-immediate-group'); - if (dryRunCb && immediateGroup) { - dryRunCb.addEventListener('change', () => { - immediateGroup.style.display = dryRunCb.checked ? 'none' : 'block'; - }); - } - loadSyncSettings(); + const tvSub = document.getElementById('tv-sub'); + sonarrNames.forEach(name => { + const color = getInstanceColor(name); + const btn = document.createElement('button'); + btn.className = 'sidebar-sub-item'; + btn.id = `sub-tv-${name}`; + btn.onclick = () => selectTvInstance(name); + btn.innerHTML = `${escapeHtml(name)}`; + tvSub.appendChild(btn); + }); + } catch (error) { + console.error('Failed to build sidebar instances:', error); } } +// Event listeners +function initializeEventListeners() { + // Search inputs + document.getElementById('movies-search').addEventListener('input', debounce(loadMovies, 500)); + document.getElementById('movies-imdb-search').addEventListener('input', debounce(loadMovies, 500)); + document.getElementById('series-search').addEventListener('input', debounce(loadSeries, 500)); + document.getElementById('series-imdb-search').addEventListener('input', debounce(loadSeries, 500)); + + // Filter dropdowns + document.getElementById('movies-filter-date').addEventListener('change', loadMovies); + document.getElementById('movies-filter-source').addEventListener('change', loadMovies); + document.getElementById('series-filter-date').addEventListener('change', loadSeries); + document.getElementById('series-filter-source').addEventListener('change', loadSeries); + + // Forms + document.getElementById('edit-form').addEventListener('submit', handleEditSubmit); + document.getElementById('bulk-update-form').addEventListener('submit', handleBulkUpdate); + document.getElementById('manual-scan-form').addEventListener('submit', handleManualScan); + document.getElementById('populate-form').addEventListener('submit', handlePopulateDatabase); +} + // API calls async function apiCall(endpoint, options = {}) { try { @@ -205,112 +202,50 @@ function updateDashboardStats() { document.getElementById('no-valid-source-total').textContent = `${moviesWithoutDates} movies, ${episodesWithoutDates} episodes without dates`; document.getElementById('recent-activity').textContent = dashboardData.recent_activity_count || 0; - - // Skipped items - const skippedMovies = dashboardData.movies_skipped || 0; - const skippedEpisodes = dashboardData.episodes_skipped || 0; - const skippedTotal = dashboardData.total_skipped || (skippedMovies + skippedEpisodes); - document.getElementById('skipped-total').textContent = skippedTotal; - document.getElementById('skipped-breakdown').textContent = `${skippedMovies} movies, ${skippedEpisodes} episodes`; -} - -function showSkippedItems() { - // Switch to Movies tab and filter to show only skipped items - switchTab('movies'); - // Set the filter dropdown to "skipped" - const moviesFilter = document.getElementById('movies-filter-date'); - if (moviesFilter) { - moviesFilter.value = 'skipped'; - refreshMovies(); - } -} - -// Chart instances — kept so we can destroy before re-rendering on refresh -const _chartInstances = {}; - -const CHART_COLORS = [ - '#00b4d8', '#6c63ff', '#10b981', '#f59e0b', - '#f43f5e', '#8b5cf6', '#06b6d4', '#84cc16' -]; - -function _isDark() { - return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; -} - -function _chartTextColor() { - return _isDark() ? '#8892b0' : '#718096'; } function updateDashboardCharts() { if (!dashboardData) return; - renderDonutChart('movie-sources-chart', dashboardData.movie_sources); - renderDonutChart('episode-sources-chart', dashboardData.episode_sources); -} - -function renderDonutChart(containerId, data) { - const container = document.getElementById(containerId); - if (!container) return; - - // Destroy previous instance if it exists - if (_chartInstances[containerId]) { - _chartInstances[containerId].destroy(); - delete _chartInstances[containerId]; + + // Movie sources chart + const movieChart = document.getElementById('movie-sources-chart'); + if (dashboardData.movie_sources && dashboardData.movie_sources.length > 0) { + movieChart.innerHTML = createSimpleChart(dashboardData.movie_sources); + } else { + movieChart.innerHTML = '

No movie source data available

'; } - - if (!data || data.length === 0) { - container.innerHTML = '

No data available

'; - return; + + // Episode sources chart + const episodeChart = document.getElementById('episode-sources-chart'); + if (dashboardData.episode_sources && dashboardData.episode_sources.length > 0) { + episodeChart.innerHTML = createSimpleChart(dashboardData.episode_sources); + } else { + episodeChart.innerHTML = '

No episode source data available

'; } +} - // Build canvas + legend wrapper - container.innerHTML = ` -
-
- +function createSimpleChart(data) { + const total = data.reduce((sum, item) => sum + item.count, 0); + let html = '
'; + + data.forEach((item, index) => { + const percentage = ((item.count / total) * 100).toFixed(1); + const color = getChartColor(index); + html += ` +
+ ${item.source} + ${item.count} (${percentage}%)
-
-
`; - - const total = data.reduce((sum, i) => sum + i.count, 0); - const labels = data.map(i => i.source); - const counts = data.map(i => i.count); - const colors = data.map((_, idx) => CHART_COLORS[idx % CHART_COLORS.length]); - - const ctx = document.getElementById(`${containerId}-canvas`).getContext('2d'); - _chartInstances[containerId] = new Chart(ctx, { - type: 'doughnut', - data: { labels, datasets: [{ data: counts, backgroundColor: colors, borderWidth: 0, hoverOffset: 4 }] }, - options: { - cutout: '68%', - animation: { duration: 400 }, - plugins: { - legend: { display: false }, - tooltip: { - callbacks: { - label: ctx => ` ${ctx.parsed.toLocaleString()} (${((ctx.parsed / total) * 100).toFixed(1)}%)` - }, - backgroundColor: _isDark() ? '#1e2235' : '#fff', - titleColor: _isDark() ? '#e2e8f0' : '#1a202c', - bodyColor: _isDark() ? '#8892b0' : '#718096', - borderColor: _isDark() ? '#2a2f4a' : '#e2e8f0', - borderWidth: 1, - } - } - } + `; }); + + html += '
'; + return html; +} - // Custom legend - const legend = document.getElementById(`${containerId}-legend`); - data.forEach((item, idx) => { - const pct = ((item.count / total) * 100).toFixed(1); - const el = document.createElement('div'); - el.style.cssText = 'display:flex;align-items:center;gap:0.5rem;min-width:0;'; - el.innerHTML = ` - - ${item.source} - ${pct}%`; - legend.appendChild(el); - }); +function getChartColor(index) { + const colors = ['#007bff', '#28a745', '#ffc107', '#dc3545', '#6c757d', '#17a2b8', '#6f42c1']; + return colors[index % colors.length]; } // Movies @@ -322,8 +257,9 @@ async function loadMovies(page = 1) { const search = document.getElementById('movies-search').value; const imdbSearch = document.getElementById('movies-imdb-search').value; - const dateFilter = document.getElementById('movies-filter-date').value; + const hasDate = document.getElementById('movies-filter-date').value; const sourceFilter = document.getElementById('movies-filter-source').value; + const instanceFilter = currentMovieInstance; const skip = (page - 1) * 100; console.log(`DEBUG: loadMovies called with page=${page}, calculated skip=${skip}`); @@ -335,15 +271,9 @@ async function loadMovies(page = 1) { if (search) params.append('search', search); if (imdbSearch) params.append('imdb_search', imdbSearch); - - // Handle different filter values - if (dateFilter === 'skipped') { - params.append('skipped', 'true'); - } else if (dateFilter) { - params.append('has_date', dateFilter); - } - + if (hasDate) params.append('has_date', hasDate); if (sourceFilter) params.append('source_filter', sourceFilter); + if (instanceFilter) params.append('instance', instanceFilter); try { const data = await apiCall(`/api/movies?${params}`); @@ -360,7 +290,7 @@ function updateMoviesTable(data) { const tbody = document.getElementById('movies-tbody'); if (!data.movies || data.movies.length === 0) { - tbody.innerHTML = 'No movies found'; + tbody.innerHTML = 'No movies found'; return; } @@ -409,35 +339,23 @@ function updateMoviesTable(data) { dateTypeBadge = 'badge-warning'; } - // Check if skipped and if IMDb ID is placeholder - const isSkipped = movie.skipped === true; - const isPlaceholder = movie.imdb_id && movie.imdb_id.startsWith('missing-'); - const skipReasonBadge = isSkipped ? `
Skipped` : ''; - - // Add Update IMDb ID button for placeholder IDs - const updateImdbButton = isPlaceholder ? ` - - ` : ''; - return ` ${escapeHtml(movie.title)} - ${movie.imdb_id}${skipReasonBadge} + ${movie.imdb_id} ${movie.released || '-'} ${dateadded || '-'} ${movie.source_description || movie.source || 'Unknown'} ${dateType} ${hasVideoBadge} + ${instanceBadge(movie.instance)} - - ${updateImdbButton} @@ -506,6 +424,7 @@ async function loadSeriesSources() { } } + function refreshMovies() { loadMovies(isNaN(currentMoviesPage) ? 1 : currentMoviesPage); } @@ -521,19 +440,21 @@ async function loadSeries(page = 1) { const imdbSearch = document.getElementById('series-imdb-search').value; const dateFilter = document.getElementById('series-filter-date').value; const sourceFilter = document.getElementById('series-filter-source').value; - + const instanceFilter = currentTvInstance; + const skip = (page - 1) * 50; console.log(`DEBUG: loadSeries called with page=${page}, calculated skip=${skip}`); - + const params = new URLSearchParams({ skip: skip, limit: 50 }); - + if (search) params.append('search', search); if (imdbSearch) params.append('imdb_search', imdbSearch); if (dateFilter) params.append('date_filter', dateFilter); if (sourceFilter) params.append('source_filter', sourceFilter); + if (instanceFilter) params.append('instance', instanceFilter); try { const data = await apiCall(`/api/series?${params}`); @@ -552,21 +473,11 @@ function updateSeriesTable(data) { tbody.innerHTML = 'No series found'; return; } - + tbody.innerHTML = data.series.map(series => { const progressPercent = series.total_episodes > 0 ? ((series.episodes_with_dates / series.total_episodes) * 100).toFixed(1) : 0; - // Check if IMDb ID is placeholder - const isPlaceholder = series.imdb_id && series.imdb_id.startsWith('missing-'); - - // Add Update IMDb ID button for placeholder IDs - const updateImdbButton = isPlaceholder ? ` - - ` : ''; - return ` ${escapeHtml(series.title)} @@ -577,12 +488,14 @@ function updateSeriesTable(data) { (${progressPercent}%) ${series.episodes_with_video} - ${series.episodes_skipped || 0} + ${instanceBadge(series.instance)} - ${updateImdbButton} + `; @@ -667,66 +580,22 @@ function showEpisodesModal(data) { -
- - -
- +
- - - - - + + + + + @@ -754,7 +623,7 @@ function showEpisodesModal(data) { @@ -858,7 +727,7 @@ function updateReportTables(data) { @@ -872,36 +741,39 @@ function refreshReport() { } // Smart fix functions -async function smartFixMovie(imdbId) { +async function smartFixMovie(imdbId, instance) { + instance = instance || 'radarr'; try { - console.log('🔍 SMART FIX: Loading options for movie', imdbId); - const options = await apiCall(`/api/movies/${imdbId}/date-options`); + console.log('🔍 SMART FIX: Loading options for movie', imdbId, 'instance', instance); + const options = await apiCall(`/api/movies/${imdbId}/date-options?instance=${encodeURIComponent(instance)}`); console.log('🔍 SMART FIX: Received options:', options); - showSmartFixModal('movie', options); + showSmartFixModal('movie', options, instance); } catch (error) { console.error('Failed to load movie options:', error); showToast('Failed to load movie options', 'error'); } } -async function smartFixEpisode(imdbId, season, episode) { +async function smartFixEpisode(imdbId, season, episode, instance) { // Validate parameters if (!imdbId || season === undefined || season === null || episode === undefined || episode === null) { console.error('smartFixEpisode: Invalid parameters:', {imdbId, season, episode}); showToast('Invalid episode parameters', 'error'); return; } - + + instance = instance || 'sonarr'; try { - const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`); - showSmartFixModal('episode', options); + const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options?instance=${encodeURIComponent(instance)}`); + showSmartFixModal('episode', options, instance); } catch (error) { console.error('Failed to load episode options:', error); showToast('Failed to load episode options', 'error'); } } -function showSmartFixModal(mediaType, options) { +function showSmartFixModal(mediaType, options, instance) { + instance = instance || (mediaType === 'movie' ? 'radarr' : 'sonarr'); console.log('🔍 SMART FIX: Showing modal for', mediaType, 'with options:', options); const modal = document.getElementById('smart-fix-modal'); @@ -953,7 +825,7 @@ function showSmartFixModal(mediaType, options) { optionsHtml += `
-
@@ -967,7 +839,8 @@ function closeSmartFixModal() { document.getElementById('smart-fix-modal').classList.remove('active'); } -async function applySmartFix(mediaType, options) { +async function applySmartFix(mediaType, options, instance) { + instance = instance || (mediaType === 'movie' ? 'radarr' : 'sonarr'); const selectedRadio = document.querySelector('input[name="date-option"]:checked'); if (!selectedRadio) { showToast('Please select a date option', 'warning'); @@ -1029,9 +902,9 @@ async function applySmartFix(mediaType, options) { try { if (mediaType === 'movie') { - await updateMovieDate(options.imdb_id, dateadded, source); + await updateMovieDate(options.imdb_id, dateadded, source, instance); } else { - await updateEpisodeDate(options.imdb_id, options.season, options.episode, dateadded, source); + await updateEpisodeDate(options.imdb_id, options.season, options.episode, dateadded, source, instance); } closeSmartFixModal(); } catch (error) { @@ -1118,23 +991,25 @@ async function handleBulkUpdate(event) { } // Edit modal functions -async function editMovie(imdbId, dateadded, source) { +async function editMovie(imdbId, dateadded, source, instance) { + instance = instance || 'radarr'; try { // Load movie options to populate available dates - const options = await apiCall(`/api/movies/${imdbId}/date-options`); - showEnhancedEditModal('movie', options, dateadded, source); + const options = await apiCall(`/api/movies/${imdbId}/date-options?instance=${encodeURIComponent(instance)}`); + showEnhancedEditModal('movie', options, dateadded, source, instance); } catch (error) { console.error('Failed to load movie options for edit:', error); // Fallback to basic edit modal - showBasicEditModal('movie', imdbId, dateadded, source); + showBasicEditModal('movie', imdbId, dateadded, source, null, null, instance); } } -function showEnhancedEditModal(mediaType, options, currentDateadded, currentSource) { +function showEnhancedEditModal(mediaType, options, currentDateadded, currentSource, instance) { + instance = instance || (mediaType === 'movie' ? 'radarr' : 'sonarr'); const modal = document.getElementById('edit-modal'); const title = document.getElementById('modal-title'); const modalBody = document.querySelector('#edit-modal .modal-body'); - + if (mediaType === 'movie') { title.textContent = `Edit Movie: ${options.imdb_id}`; } else { @@ -1143,12 +1018,12 @@ function showEnhancedEditModal(mediaType, options, currentDateadded, currentSour const episode = options.episode || 0; title.textContent = `Edit Episode: ${options.imdb_id} S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`; } - + // Build enhanced edit form with date options let formHtml = ` - + ${mediaType === 'episode' ? ` @@ -1156,21 +1031,21 @@ function showEnhancedEditModal(mediaType, options, currentDateadded, currentSour `} - +
`; - + // Add available date options options.options.forEach((option, index) => { const isSelected = option.source === currentSource ? 'checked' : ''; const optionId = `date-option-${index}`; - + formHtml += `
- +
Adjust the date/time as needed
- +
- +
- +
- `; - + modalBody.innerHTML = formHtml; - - + + // Set current values if (currentDateadded && currentDateadded !== '-') { try { @@ -1223,20 +1097,22 @@ function showEnhancedEditModal(mediaType, options, currentDateadded, currentSour document.getElementById('edit-dateadded').value = ''; } } - + document.getElementById('edit-source').value = currentSource || 'manual'; - + // Store options for later use modal.dataset.options = JSON.stringify(options); - + modal.classList.add('active'); } -function showBasicEditModal(mediaType, imdbId, dateadded, source) { +function showBasicEditModal(mediaType, imdbId, dateadded, source, season, episode, instance) { // Fallback to original basic edit modal + instance = instance || (mediaType === 'movie' ? 'radarr' : 'sonarr'); document.getElementById('modal-title').textContent = `Edit ${mediaType}: ${imdbId}`; document.getElementById('edit-imdb-id').value = imdbId; document.getElementById('edit-media-type').value = mediaType; + document.getElementById('edit-instance').value = instance; if (dateadded && dateadded !== '-') { try { @@ -1291,48 +1167,33 @@ function updateEditDateFromOption(optionIndex, option) { async function handleEnhancedEditSubmit(event) { event.preventDefault(); - console.log('🔍 Enhanced Edit Submit called'); - const modal = document.getElementById('edit-modal'); const options = JSON.parse(modal.dataset.options); const imdbId = options.imdb_id; const mediaType = document.getElementById('edit-media-type').value; + const instance = document.getElementById('edit-instance')?.value || (mediaType === 'movie' ? 'radarr' : 'sonarr'); const dateadded = document.getElementById('edit-dateadded').value; const source = document.getElementById('edit-source').value; - - console.log('🔍 Form values:', { - imdbId, - mediaType, - dateadded, - dateaddedType: typeof dateadded, - dateaddedLength: dateadded ? dateadded.length : 0, - source - }); - + if (!dateadded) { - console.log('❌ Date field is empty!'); showToast('Please enter a date', 'warning'); return; } - + // Convert datetime-local to ISO string with error handling let isoDateadded = null; try { isoDateadded = new Date(dateadded).toISOString(); - console.log('✅ Converted to ISO:', isoDateadded); } catch (e) { - console.error('❌ Date conversion error:', e); showToast('Invalid date format', 'error'); return; } - + try { if (mediaType === 'movie') { - console.log('📤 Calling updateMovieDate with:', { imdbId, isoDateadded, source }); - await updateMovieDate(imdbId, isoDateadded, source); + await updateMovieDate(imdbId, isoDateadded, source, instance); } else { - console.log('📤 Calling updateEpisodeDate with:', { imdbId, season: options.season, episode: options.episode, isoDateadded, source }); - await updateEpisodeDate(imdbId, options.season, options.episode, isoDateadded, source); + await updateEpisodeDate(imdbId, options.season, options.episode, isoDateadded, source, instance); } closeModal(); @@ -1342,22 +1203,23 @@ async function handleEnhancedEditSubmit(event) { } } -async function editEpisode(imdbId, season, episode, dateadded, source) { +async function editEpisode(imdbId, season, episode, dateadded, source, instance) { // Validate parameters if (!imdbId || season === undefined || season === null || episode === undefined || episode === null) { console.error('editEpisode: Invalid parameters:', {imdbId, season, episode}); showToast('Invalid episode parameters', 'error'); return; } - + + instance = instance || 'sonarr'; try { // Load episode options to populate available dates - const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`); - showEnhancedEditModal('episode', options, dateadded, source); + const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options?instance=${encodeURIComponent(instance)}`); + showEnhancedEditModal('episode', options, dateadded, source, instance); } catch (error) { console.error('Failed to load episode options for edit:', error); // Fallback to basic edit modal - showBasicEditModal('episode', imdbId, dateadded, source, season, episode); + showBasicEditModal('episode', imdbId, dateadded, source, season, episode, instance); } } @@ -1368,36 +1230,22 @@ function closeModal() { async function handleEditSubmit(event) { event.preventDefault(); - console.log('🔍 Basic Edit Submit called (OLD HANDLER)'); - const imdbId = document.getElementById('edit-imdb-id').value; const mediaType = document.getElementById('edit-media-type').value; + const instance = document.getElementById('edit-instance')?.value || (mediaType === 'movie' ? 'radarr' : 'sonarr'); const season = document.getElementById('edit-season').value; const episode = document.getElementById('edit-episode').value; const dateadded = document.getElementById('edit-dateadded').value; const source = document.getElementById('edit-source').value; - console.log('🔍 OLD Form values:', { - imdbId, - mediaType, - dateadded, - dateaddedType: typeof dateadded, - dateaddedLength: dateadded ? dateadded.length : 0, - source - }); - // Convert datetime-local to ISO string const isoDateadded = dateadded ? new Date(dateadded).toISOString() : null; - console.log('🔍 OLD isoDateadded:', isoDateadded); - try { if (mediaType === 'movie') { - console.log('📤 OLD calling updateMovieDate with:', { imdbId, isoDateadded, source }); - await updateMovieDate(imdbId, isoDateadded, source); + await updateMovieDate(imdbId, isoDateadded, source, instance); } else { - console.log('📤 OLD calling updateEpisodeDate'); - await updateEpisodeDate(imdbId, parseInt(season), parseInt(episode), isoDateadded, source); + await updateEpisodeDate(imdbId, parseInt(season), parseInt(episode), isoDateadded, source, instance); } closeModal(); @@ -1407,49 +1255,39 @@ async function handleEditSubmit(event) { } // Update functions -async function updateMovieDate(imdbId, dateadded, source) { - console.log('🔍 updateMovieDate called with:', { - imdbId, - dateadded, - dateaddedType: typeof dateadded, - dateaddedValue: dateadded, - source - }); - +async function updateMovieDate(imdbId, dateadded, source, instance) { + instance = instance || 'radarr'; try { - const payload = { - dateadded: dateadded, - source: source - }; - - console.log('📤 Sending API request with payload:', JSON.stringify(payload)); - const result = await apiCall(`/api/movies/${imdbId}`, { method: 'PUT', - body: JSON.stringify(payload) + body: JSON.stringify({ + dateadded: dateadded, + source: source, + instance: instance + }) }); - - console.log('✅ API response:', result); - + showToast(result.message, 'success'); - + // Refresh current view if (currentTab === 'movies') loadMovies(currentMoviesPage); if (currentTab === 'reports') loadReport(); if (currentTab === 'dashboard') loadDashboard(); - + } catch (error) { console.error('Movie update failed:', error); } } -async function updateEpisodeDate(imdbId, season, episode, dateadded, source) { +async function updateEpisodeDate(imdbId, season, episode, dateadded, source, instance) { + instance = instance || 'sonarr'; try { const result = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}`, { method: 'PUT', body: JSON.stringify({ dateadded: dateadded, - source: source + source: source, + instance: instance }) }); @@ -1645,89 +1483,30 @@ async function deleteMovie(imdbId) { } } -async function updateMovieImdbId(oldImdbId) { - // Prompt for new IMDb ID - const newImdbId = prompt(`Update IMDb ID\n\nCurrent: ${oldImdbId}\n\nEnter the correct IMDb ID (with or without 'tt' prefix):`); - - if (!newImdbId || newImdbId.trim() === '') { - return; - } - - const cleanImdbId = newImdbId.trim(); - - // Confirmation - if (!confirm(`Update IMDb ID?\n\nOld: ${oldImdbId}\nNew: ${cleanImdbId}\n\nThis will migrate the movie record to the new IMDb ID. Continue?`)) { - return; - } - - try { - const response = await fetch(`/api/movies/${oldImdbId}/migrate-imdb`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ new_imdb_id: cleanImdbId }) - }); - - const result = await response.json(); - - if (response.ok && result.success) { - showToast(`✅ IMDb ID updated successfully to ${result.new_imdb_id}`, 'success'); - - // Refresh the movies table - loadMovies(currentMoviesPage); - - } else { - const errorMsg = result.message || result.detail || 'Unknown error'; - showToast(`❌ Failed to update IMDb ID: ${errorMsg}`, 'error'); - } - - } catch (error) { - console.error('Update IMDb ID failed:', error); - showToast(`❌ Update failed: ${error.message}`, 'error'); - } -} - -async function updateSeriesImdbId(oldImdbId) { - // Prompt for new IMDb ID - const newImdbId = prompt(`Update Series IMDb ID\n\nCurrent: ${oldImdbId}\n\nEnter the correct IMDb ID (with or without 'tt' prefix):`); - - if (!newImdbId || newImdbId.trim() === '') { - return; - } - - const cleanImdbId = newImdbId.trim(); - - // Confirmation - if (!confirm(`Update Series IMDb ID?\n\nOld: ${oldImdbId}\nNew: ${cleanImdbId}\n\nThis will migrate the series and ALL its episodes to the new IMDb ID. Continue?`)) { +async function deleteSeries(imdbId, instance, title) { + if (!confirm(`⚠️ Delete Series?\n\n"${title}"\n\nThis will permanently remove the series and ALL its episodes from the database.\n\nAre you sure you want to continue?`)) { return; } try { - const response = await fetch(`/api/series/${oldImdbId}/migrate-imdb`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ new_imdb_id: cleanImdbId }) + const response = await fetch(`/api/series/${imdbId}?instance=${encodeURIComponent(instance)}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' } }); const result = await response.json(); if (response.ok && result.success) { - showToast(`✅ Series IMDb ID updated successfully to ${result.new_imdb_id}`, 'success'); - - // Refresh the series table + showToast(`✅ Series deleted (${result.episodes_deleted} episode(s) removed)`, 'success'); loadSeries(currentSeriesPage); - } else { - const errorMsg = result.message || result.detail || 'Unknown error'; - showToast(`❌ Failed to update IMDb ID: ${errorMsg}`, 'error'); + const errorMsg = result.message || result.detail || result.error || 'Unknown error'; + showToast(`❌ Failed to delete series: ${errorMsg}`, 'error'); } } catch (error) { - console.error('Update series IMDb ID failed:', error); - showToast(`❌ Update failed: ${error.message}`, 'error'); + console.error('Delete series failed:', error); + showToast(`❌ Delete failed: ${error.message}`, 'error'); } } @@ -1950,323 +1729,6 @@ function updateScanProgress(status) { } } -// Manual Cleanup Functions -async function handleManualCleanup(event) { - event.preventDefault(); - - const checkMovies = document.getElementById('cleanup-movies').checked; - const checkSeries = document.getElementById('cleanup-series').checked; - const checkFilesystem = document.getElementById('cleanup-filesystem').checked; - const checkDatabase = document.getElementById('cleanup-database').checked; - const dryRun = document.getElementById('cleanup-dry-run').checked; - - // Validate at least one media type is selected - if (!checkMovies && !checkSeries) { - showToast('❌ Please select at least one media type to check', 'error'); - return; - } - - // Validate at least one validation method is selected - if (!checkFilesystem && !checkDatabase) { - showToast('❌ Please select at least one validation method', 'error'); - return; - } - - try { - // Show cleanup status - showCleanupStatus(); - - // Start the cleanup - const mode = dryRun ? 'Preview' : 'Cleanup'; - showToast(`🧹 Starting ${mode.toLowerCase()}...`, 'info'); - - const response = await fetch('/manual/cleanup-orphaned', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ - check_movies: checkMovies, - check_series: checkSeries, - check_filesystem: checkFilesystem, - check_database: checkDatabase, - dry_run: dryRun - }) - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const result = await response.json(); - - if (result.success) { - showToast(`✅ ${mode} completed successfully`, 'success'); - displayCleanupResults(result.report, dryRun); - } else { - showToast(`❌ ${mode} failed: ${result.message || 'Unknown error'}`, 'error'); - hideCleanupStatus(); - } - - } catch (error) { - console.error('Manual cleanup failed:', error); - showToast(`❌ Cleanup failed: ${error.message}`, 'error'); - hideCleanupStatus(); - } -} - -function showCleanupStatus() { - const cleanupStatus = document.getElementById('cleanup-status'); - const cleanupResults = document.getElementById('cleanup-results'); - - // Reset and show - cleanupResults.innerHTML = '

Running cleanup...

'; - cleanupStatus.style.display = 'block'; -} - -function hideCleanupStatus() { - document.getElementById('cleanup-status').style.display = 'none'; -} - -function displayCleanupResults(report, dryRun) { - const cleanupResults = document.getElementById('cleanup-results'); - const accentColor = dryRun ? 'var(--warning-color,#d69e2e)' : 'var(--danger,#e53e3e)'; - - function statRow(label, value, highlight) { - const style = highlight ? `font-weight:700;color:${accentColor}` : 'color:var(--text-muted,#718096)'; - return `
- ${label}${value} -
`; - } - - // titles = removed_titles array from backend - function itemList(titles, label) { - if (!titles || titles.length === 0) return ''; - return `
- - ${label} (${titles.length}) - -
    - ${titles.map(t => `
  • ${escapeHtml(t)}
  • `).join('')} -
-
`; - } - - const moviesRemoved = report.movies?.removed || 0; - const seriesRemoved = report.series?.removed || 0; - const episodesRemoved = report.series?.removed_episodes || 0; - - let html = `
-
- - ${dryRun ? 'Preview Results' : 'Cleanup Results'} -
`; - - if (report.movies) { - html += `
-
- Movies -
- ${statRow('Orphaned found', report.movies.orphaned || 0)} - ${statRow(dryRun ? 'Would be removed' : 'Removed', moviesRemoved, moviesRemoved > 0)} - ${itemList(report.movies.removed_titles, dryRun ? 'View movies to remove' : 'View removed movies')} -
`; - } - - if (report.series) { - html += `
-
- TV Series -
- ${statRow('Orphaned found', report.series.orphaned || 0)} - ${statRow(dryRun ? 'Series would be removed' : 'Series removed', seriesRemoved, seriesRemoved > 0)} - ${episodesRemoved > 0 ? statRow(dryRun ? 'Episodes would be removed' : 'Episodes removed', episodesRemoved, true) : ''} - ${itemList(report.series.removed_titles, dryRun ? 'View series to remove' : 'View removed series')} -
`; - } - - const totalItems = moviesRemoved + seriesRemoved; - const summaryParts = []; - if (moviesRemoved > 0) summaryParts.push(`${moviesRemoved} movie${moviesRemoved !== 1 ? 's' : ''}`); - if (seriesRemoved > 0) summaryParts.push(`${seriesRemoved} series`); - if (episodesRemoved > 0) summaryParts.push(`${episodesRemoved} episode${episodesRemoved !== 1 ? 's' : ''}`); - const summaryText = summaryParts.length ? summaryParts.join(', ') : 'nothing'; - - html += `
- ${dryRun ? 'Would remove' : 'Removed'}: - ${summaryText} -
`; - - if (dryRun && totalItems > 0) { - html += `
- Dry run — no changes made. Uncheck Dry Run to apply. -
`; - } else if (!dryRun && totalItems === 0) { - html += `
- Nothing to remove — database is clean. -
`; - } - - html += `
`; - cleanupResults.innerHTML = html; -} - -// ── Library Sync ───────────────────────────────────────────────────────────── - -async function handleLibrarySync(event) { - event.preventDefault(); - const mediaType = document.getElementById('sync-media-type').value; - const dryRun = document.getElementById('sync-dry-run').checked; - const removeImmediately = !dryRun && (document.getElementById('sync-remove-immediately')?.checked || false); - - if (!dryRun && !confirm( - removeImmediately - ? 'This will permanently delete items from Chronarr that are no longer in Radarr/Sonarr. Continue?' - : 'This will mark items missing from Radarr/Sonarr (they will be auto-purged after your configured grace period). Continue?' - )) return; - - const statusEl = document.getElementById('sync-status'); - const resultsEl = document.getElementById('sync-results'); - statusEl.style.display = 'block'; - resultsEl.innerHTML = '

Running sync check…

'; - - try { - const response = await fetch('/api/cleanup/library-sync', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ media_type: mediaType, dry_run: dryRun, remove_immediately: removeImmediately }) - }); - if (!response.ok) { - const err = await response.json().catch(() => ({ detail: response.statusText })); - throw new Error(err.detail || 'Request failed'); - } - const data = await response.json(); - renderSyncResults(data, dryRun); - } catch (err) { - resultsEl.innerHTML = `

Error: ${err.message}

`; - } -} - -function renderSyncResults(data, dryRun) { - const resultsEl = document.getElementById('sync-results'); - const movies = data.movies_not_in_radarr || []; - const series = data.series_not_in_sonarr || []; - - let html = `
`; - - if (!data.radarr_available && !data.sonarr_available) { - html += `

Neither Radarr nor Sonarr DB is available. Check your DB connection settings.

`; - html += `
`; - resultsEl.innerHTML = html; - return; - } - - if (dryRun) { - html += `
- Dry run — no changes made. Uncheck "Dry Run" to apply. -
`; - } - - // Movies - if (data.radarr_available) { - html += `
- Movies not in Radarr: ${movies.length}`; - if (movies.length) { - html += `
    `; - movies.forEach(m => { - const since = m.missing_since ? ` (missing since ${new Date(m.missing_since).toLocaleDateString()})` : ''; - html += `
  • ${escapeHtml(m.title || m.imdb_id)}${since}
  • `; - }); - html += `
`; - } - html += `
`; - } else { - html += `

Radarr DB not available — skipped movies.

`; - } - - // Series - if (data.sonarr_available) { - html += `
- Series not in Sonarr: ${series.length}`; - if (series.length) { - html += `
    `; - series.forEach(s => { - const since = s.missing_since ? ` (missing since ${new Date(s.missing_since).toLocaleDateString()})` : ''; - html += `
  • ${escapeHtml(s.title || s.imdb_id)}${since}
  • `; - }); - html += `
`; - } - html += `
`; - } else { - html += `

Sonarr DB not available — skipped series.

`; - } - - // Deletion summary - if (!dryRun) { - const deleted = (data.movies_deleted || 0) + (data.series_deleted || 0); - if (deleted > 0) { - html += `
- Deleted ${data.movies_deleted || 0} movie(s) and ${data.series_deleted || 0} series from Chronarr. -
`; - } else if (movies.length || series.length) { - html += `
- Items marked as missing from source. They will be auto-purged after your configured grace period. -
`; - } - } - - if (movies.length === 0 && series.length === 0 && (data.radarr_available || data.sonarr_available)) { - html += `

Chronarr is in sync — no items to remove.

`; - } - - html += `
`; - resultsEl.innerHTML = html; -} - -function hideSyncStatus() { - document.getElementById('sync-status').style.display = 'none'; -} - -async function loadSyncSettings() { - try { - const res = await fetch('/api/settings/library-sync'); - if (!res.ok) return; - const s = await res.json(); - const movieEl = document.getElementById('sync-purge-movies-days'); - const tvEl = document.getElementById('sync-purge-tv-days'); - if (movieEl) movieEl.value = s.purge_missing_movies_days ?? 0; - if (tvEl) tvEl.value = s.purge_missing_tv_days ?? 0; - } catch (_) {} -} - -async function saveSyncSettings() { - const movieDays = parseInt(document.getElementById('sync-purge-movies-days')?.value || '0', 10); - const tvDays = parseInt(document.getElementById('sync-purge-tv-days')?.value || '0', 10); - const statusEl = document.getElementById('sync-settings-status'); - - try { - const res = await fetch('/api/settings/library-sync', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ purge_missing_movies_days: movieDays, purge_missing_tv_days: tvDays }) - }); - if (!res.ok) throw new Error('Save failed'); - if (statusEl) { - statusEl.style.color = 'var(--success)'; - statusEl.textContent = movieDays === 0 && tvDays === 0 - ? '✓ Auto-purge disabled' - : `✓ Saved — movies: ${movieDays === 0 ? 'never' : movieDays + 'd'}, TV: ${tvDays === 0 ? 'never' : tvDays + 'd'}`; - setTimeout(() => { statusEl.textContent = ''; }, 4000); - } - } catch (err) { - if (statusEl) { - statusEl.style.color = 'var(--danger)'; - statusEl.textContent = '✗ Failed to save settings'; - } - } -} - -// ── End Library Sync ────────────────────────────────────────────────────────── - async function logout() { if (!confirm('Are you sure you want to logout?')) { return; @@ -2312,26 +1774,16 @@ function updateBulkDeleteButton() { const selectedCheckboxes = document.querySelectorAll('.episode-checkbox:checked'); const selectedCount = selectedCheckboxes.length; const bulkDeleteButton = document.getElementById('bulk-delete-selected'); - const bulkUpdateButton = document.getElementById('bulk-update-dates'); const selectedCountSpan = document.getElementById('selected-count'); - const updateCountSpan = document.getElementById('update-count'); - + if (selectedCountSpan) { selectedCountSpan.textContent = selectedCount; } - - if (updateCountSpan) { - updateCountSpan.textContent = selectedCount; - } - + if (bulkDeleteButton) { bulkDeleteButton.disabled = selectedCount === 0; } - - if (bulkUpdateButton) { - bulkUpdateButton.disabled = selectedCount === 0; - } - + // Update select all checkbox state const selectAllCheckbox = document.getElementById('select-all-checkbox'); const allCheckboxes = document.querySelectorAll('.episode-checkbox'); @@ -2391,11 +1843,11 @@ async function bulkDeleteSelected() { // Update UI updateEpisodeModalCounts(); updateBulkDeleteButton(); - + // Reset button bulkDeleteButton.innerHTML = originalText; bulkDeleteButton.disabled = true; - + // Show results if (successCount > 0 && failCount === 0) { showToast(`✅ Successfully deleted ${successCount} episode(s)`, 'success'); @@ -2406,439 +1858,185 @@ async function bulkDeleteSelected() { } } -// Bulk update dates functions -function showBulkUpdateModal() { - const selectedCheckboxes = document.querySelectorAll('.episode-checkbox:checked'); - const selectedCount = selectedCheckboxes.length; - - if (selectedCount === 0) { - showToast('❌ No episodes selected', 'error'); - return; - } - - const modal = document.getElementById('bulk-update-modal'); - const modalSelectedCount = document.getElementById('modal-selected-count'); - const dateSourceSelect = document.getElementById('bulk-date-source'); - const customDateInput = document.getElementById('custom-date-input'); - - if (modalSelectedCount) { - modalSelectedCount.textContent = selectedCount; - } - - // Show/hide custom date input based on selection - if (dateSourceSelect) { - dateSourceSelect.onchange = function() { - if (customDateInput) { - customDateInput.style.display = this.value === 'custom' ? 'block' : 'none'; - } - }; - } - - if (modal) { - modal.style.display = 'flex'; - } -} - -function closeBulkUpdateModal() { - const modal = document.getElementById('bulk-update-modal'); - if (modal) { - modal.style.display = 'none'; - } -} - -async function executeBulkUpdate() { - const selectedCheckboxes = document.querySelectorAll('.episode-checkbox:checked'); - const selectedCount = selectedCheckboxes.length; - const dateSource = document.getElementById('bulk-date-source').value; - const customDate = document.getElementById('bulk-custom-date').value; - - if (selectedCount === 0) { - showToast('❌ No episodes selected', 'error'); - return; - } - - if (dateSource === 'custom' && !customDate) { - showToast('❌ Please enter a custom date', 'error'); - return; - } - - // Close modal - closeBulkUpdateModal(); - - // Collect selected episodes - const episodes = []; - selectedCheckboxes.forEach(checkbox => { - const row = checkbox.closest('tr'); - episodes.push({ - imdb_id: row.getAttribute('data-imdb'), - season: parseInt(row.getAttribute('data-season')), - episode: parseInt(row.getAttribute('data-episode')) - }); - }); - - // Show progress - showToast(`⏳ Updating ${selectedCount} episode(s)...`, 'info'); - - try { - const response = await apiCall('/api/episodes/bulk-update-dates', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - episodes: episodes, - date_source: dateSource, - custom_date: customDate || null - }) - }); - - if (response.success) { - showToast(`✅ Successfully updated ${response.updated || selectedCount} episode(s)`, 'success'); - - // Reload the current series to show updated dates - const currentImdbId = currentTVSeries; - if (currentImdbId) { - loadEpisodes(currentImdbId); - } - - // Clear selections - selectedCheckboxes.forEach(checkbox => checkbox.checked = false); - updateBulkDeleteButton(); - } else { - showToast(`❌ ${response.message || 'Failed to update episodes'}`, 'error'); - } - } catch (error) { - console.error('Bulk update error:', error); - showToast('❌ Error updating episodes', 'error'); - } -} - -// ==================== Database Population Functions ==================== - -let populatePollingInterval = null; +// --------------------------- +// Database Population Functions +// --------------------------- async function handlePopulateDatabase(event) { event.preventDefault(); const mediaType = document.getElementById('populate-media-type').value; - const submitButton = event.target.querySelector('button[type="submit"]'); - const originalText = submitButton.innerHTML; - // Confirm action - const confirmMsg = mediaType === 'both' - ? 'This will populate the database with ALL movies and TV episodes from Radarr/Sonarr. Continue?' - : mediaType === 'movies' - ? 'This will populate the database with ALL movies from Radarr. Continue?' - : 'This will populate the database with ALL TV episodes from Sonarr. Continue?'; + // Validate input + if (!mediaType) { + showToast('❌ Please select media type', 'error'); + return; + } + // Confirm with user + const confirmMsg = `Are you sure you want to populate the database with ${mediaType}? This will query Radarr/Sonarr and may take several minutes.`; if (!confirm(confirmMsg)) { return; } - // Disable button and show loading - submitButton.disabled = true; - submitButton.innerHTML = ' Starting...'; - try { - const response = await fetch('/admin/populate-database', { + // Show populate status + showPopulateStatus(); + + // Start the population + showToast('🚀 Starting database population...', 'info'); + const response = await fetch(`/admin/populate-database?media_type=${mediaType}`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ media_type: mediaType }) + credentials: 'include' }); if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - const data = await response.json(); + const result = await response.json(); - if (data.status === 'started') { - showPopulateStatus(); + if (result.status === 'started') { + showToast('✅ Population started successfully', 'success'); + // Start polling for status startPopulatePolling(); } else { - throw new Error('Failed to start population'); + showToast(`ℹ️ ${result.message || 'Population completed'}`, 'info'); + hidePopulateStatus(); } } catch (error) { - console.error('Error starting population:', error); - showToast('❌ Failed to start database population', 'error'); - submitButton.disabled = false; - submitButton.innerHTML = originalText; + console.error('Database population failed:', error); + showToast(`❌ Population failed: ${error.message}`, 'error'); + hidePopulateStatus(); } } function showPopulateStatus() { - const statusDiv = document.getElementById('populate-status'); - const resultsDiv = document.getElementById('populate-results'); + const populateStatus = document.getElementById('populate-status'); const progressBar = document.getElementById('populate-progress-bar'); + const operationText = document.getElementById('populate-current-operation'); const progressText = document.getElementById('populate-progress-text'); + const resultsDiv = document.getElementById('populate-results'); - if (statusDiv) { - statusDiv.style.display = 'block'; - } - - if (progressBar) { - progressBar.style.width = '0%'; - } - - if (progressText) { - progressText.textContent = 'Starting...'; - } - - if (resultsDiv) { - resultsDiv.style.display = 'none'; - } + populateStatus.style.display = 'block'; + progressBar.style.width = '0%'; + operationText.textContent = 'Initializing...'; + progressText.textContent = '0%'; + resultsDiv.innerHTML = ''; } function hidePopulateStatus() { - const statusDiv = document.getElementById('populate-status'); - if (statusDiv) { - statusDiv.style.display = 'none'; + const populateStatus = document.getElementById('populate-status'); + if (populateStatus) { + populateStatus.style.display = 'none'; } } function startPopulatePolling() { - // Clear any existing interval - if (populatePollingInterval) { - clearInterval(populatePollingInterval); - } - - // Poll every 2 seconds - populatePollingInterval = setInterval(async () => { + // Poll every 2 seconds for populate status + window.populatePollingInterval = setInterval(async () => { try { const response = await fetch('/api/populate/status'); if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + throw new Error('Failed to get populate status'); } const status = await response.json(); updatePopulateProgress(status); - // Stop polling if complete or error (API returns "completed": true/false and "error": string) - if (status.completed || status.error) { + // Stop polling if population is complete + if (!status.running && status.completed) { stopPopulatePolling(); - - // Re-enable submit button - const submitButton = document.querySelector('#populate-form button[type="submit"]'); - if (submitButton) { - submitButton.disabled = false; - submitButton.innerHTML = ' Start Population'; - } + showToast('✅ Database population completed!', 'success'); } } catch (error) { - console.error('Error polling populate status:', error); + console.error('Failed to poll populate status:', error); + // Don't show error toast repeatedly, just stop polling stopPopulatePolling(); - - // Re-enable submit button - const submitButton = document.querySelector('#populate-form button[type="submit"]'); - if (submitButton) { - submitButton.disabled = false; - submitButton.innerHTML = ' Start Population'; - } } }, 2000); } function stopPopulatePolling() { - if (populatePollingInterval) { - clearInterval(populatePollingInterval); - populatePollingInterval = null; + if (window.populatePollingInterval) { + clearInterval(window.populatePollingInterval); + window.populatePollingInterval = null; } } function updatePopulateProgress(status) { const progressBar = document.getElementById('populate-progress-bar'); + const operationText = document.getElementById('populate-current-operation'); const progressText = document.getElementById('populate-progress-text'); const resultsDiv = document.getElementById('populate-results'); - // Handle running status (API returns "running": true/false) - if (status.running && !status.completed) { - if (progressBar) { - // Show indeterminate progress - progressBar.style.width = '50%'; - } - if (progressText) { - progressText.textContent = 'Populating database...'; - } - } else if (status.completed) { - if (progressBar) { - progressBar.style.width = '100%'; - } - if (progressText) { - progressText.textContent = 'Complete!'; - } + if (status.error) { + progressBar.style.width = '100%'; + progressBar.style.backgroundColor = '#e74c3c'; + operationText.textContent = 'Error occurred'; + progressText.textContent = 'Failed'; + resultsDiv.innerHTML = `

Error: ${status.error}

`; + return; + } - // Show results (API returns movies/tv with nested stats) - if (resultsDiv && (status.movies || status.tv)) { - let html = '

Population Results

'; - - // Movies - if (status.movies && status.movies.stats) { - const m = status.movies.stats; - html += '
'; - html += '
Movies
'; - html += '
    '; - html += `
  • Total found: ${m.total || 0}
  • `; - html += `
  • Added: ${m.added || 0}
  • `; - html += `
  • Skipped: ${m.skipped || 0}
  • `; - html += `
  • Errors: ${m.errors || 0}
  • `; - html += `
  • Duration: ${(m.duration || 0).toFixed(2)}s
  • `; - html += '
'; - // Show skipped items if any - if (m.skipped_items && m.skipped_items.length > 0) { - html += '
Skipped Movies (' + m.skipped_items.length + ')
    '; - m.skipped_items.forEach(item => { - html += `
  • ${item.title || 'Unknown'} (${item.year || 'N/A'}) [${item.imdb_id || 'No IMDb'}] - ${item.reason}
  • `; - }); - html += '
'; - } - html += '
'; - } + if (!status.running && status.completed) { + progressBar.style.width = '100%'; + operationText.textContent = 'Population completed'; + progressText.textContent = '100%'; - // TV Episodes - if (status.tv && status.tv.stats) { - const tv = status.tv.stats; - html += '
'; - html += '
TV Shows
'; - html += '
    '; - html += `
  • Series: ${tv.total_series || 0}
  • `; - html += `
  • Episodes: ${tv.total_episodes || 0}
  • `; - html += `
  • Added: ${tv.added || 0}
  • `; - html += `
  • Skipped: ${tv.skipped || 0}
  • `; - html += `
  • Errors: ${tv.errors || 0}
  • `; - html += `
  • Duration: ${(tv.duration || 0).toFixed(2)}s
  • `; - html += '
'; - // Show skipped items if any - if (tv.skipped_items && tv.skipped_items.length > 0) { - html += '
Skipped Episodes (' + tv.skipped_items.length + ')
    '; - tv.skipped_items.forEach(item => { - html += `
  • ${item.title || 'Unknown'} S${String(item.season).padStart(2,'0')}E${String(item.episode).padStart(2,'0')} - ${item.reason}
  • `; - }); - html += '
'; - } - html += '
'; - } + // Display results + let resultsHTML = '

Population Results:

'; - resultsDiv.innerHTML = html; - resultsDiv.style.display = 'block'; + if (status.movies && status.movies.stats) { + const m = status.movies.stats; + resultsHTML += ` +
+ Movies:
+ Total: ${m.total || 0} | Added: ${m.added || 0} | Skipped: ${m.skipped || 0} | Errors: ${m.errors || 0}
+ Duration: ${m.duration ? m.duration.toFixed(2) : 0}s +
+ `; } - showToast('✅ Database population completed successfully', 'success'); - - } else if (status.status === 'error') { - if (progressFill && progressText) { - progressFill.style.width = '100%'; - progressFill.style.backgroundColor = '#dc3545'; - progressText.textContent = 'Error!'; + if (status.tv && status.tv.stats) { + const t = status.tv.stats; + resultsHTML += ` +
+ TV Shows:
+ Series: ${t.total_series || 0} | Episodes: ${t.total_episodes || 0}
+ Added: ${t.added || 0} | Skipped: ${t.skipped || 0} | Errors: ${t.errors || 0}
+ Duration: ${t.duration ? t.duration.toFixed(2) : 0}s +
+ `; } - const errorMsg = status.error || 'Unknown error occurred'; - showToast(`❌ Database population failed: ${errorMsg}`, 'error'); - - if (resultsDiv) { - resultsDiv.innerHTML = `

Error: ${errorMsg}

`; - resultsDiv.style.display = 'block'; - } + resultsDiv.innerHTML = resultsHTML; + return; } -} - -// ==================== Table Sorting Functions ==================== -let sortDirections = {}; // Track sort direction for each table column - -function sortTable(tableBodyId, columnIndex, dataType) { - const tbody = document.getElementById(tableBodyId); - if (!tbody) return; - - const sortKey = `${tableBodyId}-${columnIndex}`; + // Update progress based on status + let totalProgress = 0; + let progressDetails = ''; - // Toggle sort direction - if (!sortDirections[sortKey]) { - sortDirections[sortKey] = 'asc'; - } else { - sortDirections[sortKey] = sortDirections[sortKey] === 'asc' ? 'desc' : 'asc'; + if (status.movies && status.movies.status === 'running') { + totalProgress = 50; + progressDetails = 'Processing movies...'; + } else if (status.movies && status.movies.status === 'completed') { + totalProgress = 50; + progressDetails = 'Movies completed, processing TV...'; } - const rows = Array.from(tbody.querySelectorAll('tr')); - - rows.sort((a, b) => { - const aCell = a.cells[columnIndex]; - const bCell = b.cells[columnIndex]; - - if (!aCell || !bCell) return 0; - - let aValue = aCell.textContent.trim(); - let bValue = bCell.textContent.trim(); - - // Handle MISSING values - always sort to bottom - if (aValue === 'MISSING') return 1; - if (bValue === 'MISSING') return -1; - - // Handle different data types - if (dataType === 'date') { - // Parse dates for comparison - aValue = aValue === '-' ? '' : aValue; - bValue = bValue === '-' ? '' : bValue; - - if (!aValue && !bValue) return 0; - if (!aValue) return 1; - if (!bValue) return -1; - - const aDate = new Date(aValue); - const bDate = new Date(bValue); - - if (sortDirections[sortKey] === 'asc') { - return aDate - bDate; - } else { - return bDate - aDate; - } - } else if (dataType === 'number') { - const aNum = parseFloat(aValue) || 0; - const bNum = parseFloat(bValue) || 0; - - if (sortDirections[sortKey] === 'asc') { - return aNum - bNum; - } else { - return bNum - aNum; - } - } else { - // Text comparison - if (sortDirections[sortKey] === 'asc') { - return aValue.localeCompare(bValue); - } else { - return bValue.localeCompare(aValue); - } - } - }); - - // Re-append sorted rows - rows.forEach(row => tbody.appendChild(row)); - - // Update sort icons - updateSortIcons(tableBodyId, columnIndex, sortDirections[sortKey]); -} - -function updateSortIcons(tableBodyId, activeColumn, direction) { - // Find the table and update icons - const tbody = document.getElementById(tableBodyId); - if (!tbody) return; - - const table = tbody.closest('table'); - if (!table) return; - - const headers = table.querySelectorAll('th.sortable'); - headers.forEach((header, index) => { - const icon = header.querySelector('i'); - if (!icon) return; + if (status.tv && status.tv.status === 'running') { + totalProgress = 75; + progressDetails = 'Processing TV shows...'; + } else if (status.tv && status.tv.status === 'completed') { + totalProgress = 100; + progressDetails = 'Completed'; + } - // +1 to account for checkbox column - if (index + 1 === activeColumn) { - icon.className = direction === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down'; - } else { - icon.className = 'fas fa-sort'; - } - }); + progressBar.style.width = `${totalProgress}%`; + operationText.textContent = progressDetails; + progressText.textContent = `${totalProgress}%`; } From 0b6b5272817c8aaeaccc09243341d3d245327a80 Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Thu, 27 Aug 2026 13:30:48 -0400 Subject: [PATCH 21/45] fix: restore skipped items card and fix chart container layout - 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/ --- chronarr-web/static/css/styles.css | 30 +++++++++++++++++++++++++++++- chronarr-web/static/index.html | 11 +++++++++++ chronarr-web/static/js/app.js | 5 +++++ static/css/styles.css | 30 +++++++++++++++++++++++++++++- static/index.html | 11 +++++++++++ static/js/app.js | 5 +++++ 6 files changed, 90 insertions(+), 2 deletions(-) diff --git a/chronarr-web/static/css/styles.css b/chronarr-web/static/css/styles.css index ab2038a..cc7b7ef 100644 --- a/chronarr-web/static/css/styles.css +++ b/chronarr-web/static/css/styles.css @@ -324,6 +324,7 @@ body { .stat-icon.tv { background: linear-gradient(135deg, #f093fb, #f5576c); } .stat-icon.missing { background: linear-gradient(135deg, #ffecd2, #fcb69f); } .stat-icon.activity { background: linear-gradient(135deg, #a8edea, #fed6e3); } +.stat-icon.skipped { background: linear-gradient(135deg, #f7971e, #ffd200); } .stat-info h3 { font-size: 2rem; @@ -360,7 +361,7 @@ body { } .chart-container { - height: 200px; + min-height: 200px; display: flex; align-items: center; justify-content: center; @@ -369,6 +370,33 @@ body { color: var(--text-muted); } +.simple-chart { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 0.75rem; +} + +.chart-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.4rem 0.75rem; + border-radius: 0.25rem; + font-size: 0.875rem; +} + +.chart-label { + font-weight: 500; + color: var(--dark-color); +} + +.chart-value { + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + /* Content Header */ .content-header { display: flex; diff --git a/chronarr-web/static/index.html b/chronarr-web/static/index.html index d8060a2..55f2fea 100644 --- a/chronarr-web/static/index.html +++ b/chronarr-web/static/index.html @@ -126,6 +126,17 @@

-

Last 7 days + +
+
+ +
+
+

-

+

Skipped Items

+ - movies, - episodes +
+
diff --git a/chronarr-web/static/js/app.js b/chronarr-web/static/js/app.js index d9dc5e6..01b19d2 100644 --- a/chronarr-web/static/js/app.js +++ b/chronarr-web/static/js/app.js @@ -202,6 +202,11 @@ function updateDashboardStats() { document.getElementById('no-valid-source-total').textContent = `${moviesWithoutDates} movies, ${episodesWithoutDates} episodes without dates`; document.getElementById('recent-activity').textContent = dashboardData.recent_activity_count || 0; + + const moviesSkipped = dashboardData.movies_skipped || 0; + const episodesSkipped = dashboardData.episodes_skipped || 0; + document.getElementById('skipped-total').textContent = dashboardData.total_skipped || (moviesSkipped + episodesSkipped); + document.getElementById('skipped-breakdown').textContent = `${moviesSkipped} movies, ${episodesSkipped} episodes`; } function updateDashboardCharts() { diff --git a/static/css/styles.css b/static/css/styles.css index ab2038a..cc7b7ef 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -324,6 +324,7 @@ body { .stat-icon.tv { background: linear-gradient(135deg, #f093fb, #f5576c); } .stat-icon.missing { background: linear-gradient(135deg, #ffecd2, #fcb69f); } .stat-icon.activity { background: linear-gradient(135deg, #a8edea, #fed6e3); } +.stat-icon.skipped { background: linear-gradient(135deg, #f7971e, #ffd200); } .stat-info h3 { font-size: 2rem; @@ -360,7 +361,7 @@ body { } .chart-container { - height: 200px; + min-height: 200px; display: flex; align-items: center; justify-content: center; @@ -369,6 +370,33 @@ body { color: var(--text-muted); } +.simple-chart { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 0.75rem; +} + +.chart-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.4rem 0.75rem; + border-radius: 0.25rem; + font-size: 0.875rem; +} + +.chart-label { + font-weight: 500; + color: var(--dark-color); +} + +.chart-value { + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + /* Content Header */ .content-header { display: flex; diff --git a/static/index.html b/static/index.html index d8060a2..55f2fea 100644 --- a/static/index.html +++ b/static/index.html @@ -126,6 +126,17 @@

-

Last 7 days
+ +
+
+ +
+
+

-

+

Skipped Items

+ - movies, - episodes +
+
diff --git a/static/js/app.js b/static/js/app.js index d9dc5e6..01b19d2 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -202,6 +202,11 @@ function updateDashboardStats() { document.getElementById('no-valid-source-total').textContent = `${moviesWithoutDates} movies, ${episodesWithoutDates} episodes without dates`; document.getElementById('recent-activity').textContent = dashboardData.recent_activity_count || 0; + + const moviesSkipped = dashboardData.movies_skipped || 0; + const episodesSkipped = dashboardData.episodes_skipped || 0; + document.getElementById('skipped-total').textContent = dashboardData.total_skipped || (moviesSkipped + episodesSkipped); + document.getElementById('skipped-breakdown').textContent = `${moviesSkipped} movies, ${episodesSkipped} episodes`; } function updateDashboardCharts() { From 786bf6d8a556fe9f9dacf61f704119e3c6a40512 Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Thu, 27 Aug 2026 13:34:07 -0400 Subject: [PATCH 22/45] fix: escape double quotes in delete series onclick attribute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- chronarr-web/static/js/app.js | 2 +- static/js/app.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/chronarr-web/static/js/app.js b/chronarr-web/static/js/app.js index 01b19d2..9b9db2c 100644 --- a/chronarr-web/static/js/app.js +++ b/chronarr-web/static/js/app.js @@ -498,7 +498,7 @@ function updateSeriesTable(data) { - diff --git a/static/js/app.js b/static/js/app.js index 01b19d2..9b9db2c 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -498,7 +498,7 @@ function updateSeriesTable(data) { - From 4c899036ec200cc550ef45a325ab737442b885e3 Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Fri, 28 Aug 2026 18:02:33 -0400 Subject: [PATCH 23/45] =?UTF-8?q?fix:=20per-instance=20paths=20=E2=80=94?= =?UTF-8?q?=20remove=20global=20MOVIE=5FPATHS/TV=5FPATHS=20requirement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 30 ++++++++++---- README.md | 49 +++++++++++++++++------ config/settings.py | 82 +++++++++++++++++++++++--------------- docker-compose.yml.example | 55 +++++++++++++++++-------- 4 files changed, 147 insertions(+), 69 deletions(-) diff --git a/.env.example b/.env.example index 49b5c09..96bfbdd 100644 --- a/.env.example +++ b/.env.example @@ -47,14 +47,20 @@ WEB_EXTERNAL_PORT=8081 # RADARR — MAIN INSTANCE # =========================================== # Paths: RADARR_ROOT_FOLDERS is what Radarr sees on the host (or UNC path on Windows). -# MOVIE_PATHS is what Chronarr sees inside the container. -# Both are comma-separated and positionally paired. +# RADARR_MOVIE_PATHS is what Chronarr sees inside the container. +# Both are comma-separated. +# +# IMPORTANT — multi-instance users: +# Define paths per-instance (RADARR_MOVIE_PATHS, RADARR_4K_MOVIE_PATHS, etc.) +# Do NOT use the global MOVIE_PATHS key when running multiple instances — +# if it appears more than once in your env file, Docker only keeps the last +# definition and silently drops the others. RADARR_URL=http://radarr:7878 # RADARR_API_KEY= → set in .env.secrets # RADARR_WEBHOOK_SECRET= → set in .env.secrets (optional, for webhook validation) RADARR_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies -MOVIE_PATHS=/media/movies +RADARR_MOVIE_PATHS=/media/movies # Radarr database (direct DB access — faster than API for large libraries) RADARR_DB_TYPE=postgresql @@ -75,30 +81,37 @@ RADARR_DB_USER=postgres # becomes part of the webhook URL: RADARR_4K_URL → /radarr_4k/webhook # Names must be uppercase letters and underscores only. # +# Each instance defines ALL of its own settings here — URL, API key, paths, +# and database access. Do NOT set MOVIE_PATHS again in this block; use +# RADARR_4K_MOVIE_PATHS instead. If MOVIE_PATHS appears twice in your env +# file, Docker only keeps the last value and silently drops the first. +# # After configuring instances, visit http://your-server:8081/setup -# for the exact webhook URLs to paste into each Radarr or Sonarr instance. +# for the exact webhook URLs to paste into each Radarr instance. # RADARR_4K_URL=http://radarr4k:7878 # RADARR_4K_API_KEY= → set in .env.secrets # RADARR_4K_WEBHOOK_SECRET= → set in .env.secrets (optional) # RADARR_4K_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies4k -# RADARR_4K_MOVIE_PATHS=/media/movies4k +# RADARR_4K_MOVIE_PATHS=/media/movies4k ← per-instance paths, NOT MOVIE_PATHS # RADARR_4K_DB_TYPE=postgresql # RADARR_4K_DB_HOST=your-radarr-4k-db-host # RADARR_4K_DB_PORT=5432 # RADARR_4K_DB_NAME=radarr-4k # RADARR_4K_DB_USER=postgres # RADARR_4K_DB_PASSWORD= → set in .env.secrets -# SQLite: RADARR_4K_DB_TYPE=sqlite / RADARR_4K_DB_PATH=/path/to/radarr4k.db +# SQLite: RADARR_4K_DB_TYPE=sqlite / RADARR_4K_DB_PATH=/radarr-4k-data/radarr.db # =========================================== # SONARR — MAIN INSTANCE # =========================================== +# Same paths rule as Radarr: use SONARR_TV_PATHS here, not the global TV_PATHS, +# so each instance's paths stay self-contained. SONARR_URL=http://sonarr:8989 # SONARR_API_KEY= → set in .env.secrets # SONARR_WEBHOOK_SECRET= → set in .env.secrets (optional) SONARR_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv -TV_PATHS=/media/tv +SONARR_TV_PATHS=/media/tv # Sonarr database SONARR_DB_TYPE=postgresql @@ -115,11 +128,12 @@ SONARR_DB_USER=postgres # =========================================== # SONARR — ADDITIONAL INSTANCES (OPTIONAL) # =========================================== +# Same rule: use SONARR_4K_TV_PATHS, NOT TV_PATHS, in additional instance blocks. # SONARR_4K_URL=http://sonarr4k:8989 # SONARR_4K_API_KEY= → set in .env.secrets # SONARR_4K_WEBHOOK_SECRET= → set in .env.secrets (optional) # SONARR_4K_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv4k -# SONARR_4K_TV_PATHS=/media/tv4k +# SONARR_4K_TV_PATHS=/media/tv4k ← per-instance paths, NOT TV_PATHS # SONARR_4K_DB_TYPE=postgresql # SONARR_4K_DB_HOST=your-sonarr-4k-db-host # SONARR_4K_DB_PORT=5432 diff --git a/README.md b/README.md index 87cb941..2e7eb40 100644 --- a/README.md +++ b/README.md @@ -339,31 +339,56 @@ SONARR_DB_PASSWORD=sonarr_pass # Sonarr database password (.env.secrets) Add additional Radarr or Sonarr instances using a `NAME` segment between the prefix and the variable suffix. The name becomes the instance identifier in Chronarr's UI and database. +Each instance block is fully self-contained — define all settings including paths directly within it. **Do not use the global `MOVIE_PATHS` or `TV_PATHS` keys in additional instance blocks.** If those keys appear more than once in your env file, Docker only keeps the last value and silently drops the rest, corrupting path matching for every instance. + ```bash +# Main Radarr instance — use RADARR_MOVIE_PATHS, not MOVIE_PATHS +RADARR_URL=http://radarr:7878 +RADARR_API_KEY=your_api_key # (.env.secrets) +RADARR_ROOT_FOLDERS=/mnt/nas/Movies +RADARR_MOVIE_PATHS=/media/movies +RADARR_DB_TYPE=sqlite +RADARR_DB_PATH=/radarr-data/radarr.db + # Second Radarr instance (e.g., 4K library) RADARR_4K_URL=http://radarr-4k:7878 RADARR_4K_API_KEY=your_4k_api_key # (.env.secrets) -RADARR_4K_DB_TYPE=postgresql -RADARR_4K_DB_HOST=radarr-4k-db -RADARR_4K_DB_NAME=radarr-main -RADARR_4K_DB_USER=radarr -RADARR_4K_DB_PASSWORD=radarr_4k_pass # (.env.secrets) +RADARR_4K_ROOT_FOLDERS=/mnt/nas/Movies-4K +RADARR_4K_MOVIE_PATHS=/media/movies-4k # ← per-instance, NOT MOVIE_PATHS +RADARR_4K_DB_TYPE=sqlite +RADARR_4K_DB_PATH=/radarr-4k-data/radarr.db + +# Main Sonarr instance — use SONARR_TV_PATHS, not TV_PATHS +SONARR_URL=http://sonarr:8989 +SONARR_API_KEY=your_sonarr_key # (.env.secrets) +SONARR_ROOT_FOLDERS=/mnt/nas/TV +SONARR_TV_PATHS=/media/tv +SONARR_DB_TYPE=sqlite +SONARR_DB_PATH=/sonarr-data/sonarr.db # Second Sonarr instance (e.g., anime library) SONARR_ANIME_URL=http://sonarr-anime:8989 SONARR_ANIME_API_KEY=your_anime_key # (.env.secrets) -SONARR_ANIME_DB_TYPE=postgresql -SONARR_ANIME_DB_HOST=sonarr-anime-db -SONARR_ANIME_DB_NAME=sonarr-main -SONARR_ANIME_DB_USER=sonarr -SONARR_ANIME_DB_PASSWORD=sonarr_anime_pass # (.env.secrets) +SONARR_ANIME_ROOT_FOLDERS=/mnt/nas/Anime +SONARR_ANIME_TV_PATHS=/media/anime # ← per-instance, NOT TV_PATHS +SONARR_ANIME_DB_TYPE=sqlite +SONARR_ANIME_DB_PATH=/sonarr-anime-data/sonarr.db ``` Each named instance gets its own webhook endpoint (`/radarr_4k/webhook`, `/sonarr_anime/webhook`) and appears as a separate entry in the sidebar. An "All" view aggregates across all instances with colored instance badges on each row. -**SQLite Docker Configuration:** +**SQLite Docker Configuration (multi-instance):** + +Each SQLite database needs its own volume mount in `docker-compose.yml`. The container path you choose must match the `_DB_PATH` value in your `.env`: + +```yaml +# In docker-compose.yml, under chronarr-core volumes: +- /host/path/to/radarr/config:/radarr-data:ro # → RADARR_DB_PATH=/radarr-data/radarr.db +- /host/path/to/radarr-4k/config:/radarr-4k-data:ro # → RADARR_4K_DB_PATH=/radarr-4k-data/radarr.db +- /host/path/to/sonarr/config:/sonarr-data:ro # → SONARR_DB_PATH=/sonarr-data/sonarr.db +``` -When using SQLite databases with Docker, you must mount the database directories as read-only volumes. See the Quick Start guide (step 3) or check `docker-compose.yml.example` for detailed examples. +The `radarr.db` file is usually at the top level of Radarr's config directory. If the database fails to open, browse that directory on your host to find the actual filename and depth, then adjust `DB_PATH` accordingly. See `docker-compose.yml.example` for a complete annotated example. ## Web Interface diff --git a/config/settings.py b/config/settings.py index 88c0697..bbe3093 100644 --- a/config/settings.py +++ b/config/settings.py @@ -119,7 +119,10 @@ def _load_configuration(self) -> None: # External connections self._load_external_connections() - + + # Warn if any discovered instance has no paths at all + self._warn_missing_instance_paths() + # Movie processing self._load_movie_settings() @@ -130,39 +133,21 @@ def _load_configuration(self) -> None: self._load_auth_settings() def _load_paths(self) -> None: - """Load and validate path configuration""" - tv_paths_env = os.environ.get("TV_PATHS", "") + """Load global path fallbacks. + + MOVIE_PATHS and TV_PATHS are optional global fallbacks used when an + instance does not define its own per-instance paths variable + (RADARR_{NAME}_MOVIE_PATHS / SONARR_{NAME}_TV_PATHS). + + For single-instance setups these remain the simplest approach. + For multi-instance setups, define paths per-instance so each block + is fully self-contained and there is no risk of one definition + silently overriding another (Docker env_file last-definition-wins). + """ movie_paths_env = os.environ.get("MOVIE_PATHS", "") - - if not tv_paths_env: - raise ConfigurationError( - setting="TV_PATHS", - reason="TV_PATHS environment variable is required but not set" - ) - - if not movie_paths_env: - raise ConfigurationError( - setting="MOVIE_PATHS", - reason="MOVIE_PATHS environment variable is required but not set" - ) - - # Parse paths - self.tv_paths = [Path(p.strip()) for p in tv_paths_env.split(",") if p.strip()] + tv_paths_env = os.environ.get("TV_PATHS", "") self.movie_paths = [Path(p.strip()) for p in movie_paths_env.split(",") if p.strip()] - - if not self.tv_paths: - raise ConfigurationError( - setting="TV_PATHS", - reason="No valid TV paths found after parsing", - current_value=tv_paths_env - ) - - if not self.movie_paths: - raise ConfigurationError( - setting="MOVIE_PATHS", - reason="No valid movie paths found after parsing", - current_value=movie_paths_env - ) + self.tv_paths = [Path(p.strip()) for p in tv_paths_env.split(",") if p.strip()] def _load_server_settings(self) -> None: """Load server configuration""" @@ -335,6 +320,39 @@ def _discover_sonarr_instances(self) -> List[SonarrInstance]: return instances + def _warn_missing_instance_paths(self) -> None: + """Warn at startup if any configured instance has no paths. + + An instance with empty movie_paths / tv_paths cannot match file + paths to media items — date assignment from the DB will still work, + but any path-based lookup will silently find nothing. + + Each instance should define its own paths variable: + RADARR_{NAME}_MOVIE_PATHS (or RADARR_MOVIE_PATHS for the default) + SONARR_{NAME}_TV_PATHS (or SONARR_TV_PATHS for the default) + + MOVIE_PATHS / TV_PATHS act as a global fallback when no per-instance + variable is set. + """ + for inst in self.radarr_instances: + if not inst.movie_paths: + logger.warning( + "Radarr instance '%s' has no movie paths configured. " + "Set %s_MOVIE_PATHS in your .env (or MOVIE_PATHS as a " + "global fallback). Path matching will not work for this instance.", + inst.name, + inst.name.upper(), + ) + for inst in self.sonarr_instances: + if not inst.tv_paths: + logger.warning( + "Sonarr instance '%s' has no TV paths configured. " + "Set %s_TV_PATHS in your .env (or TV_PATHS as a " + "global fallback). Path matching will not work for this instance.", + inst.name, + inst.name.upper(), + ) + def _load_movie_settings(self) -> None: """Load movie processing settings""" self.movie_priority = os.environ.get("MOVIE_PRIORITY", "import_then_digital").lower() diff --git a/docker-compose.yml.example b/docker-compose.yml.example index da536fb..1f47664 100644 --- a/docker-compose.yml.example +++ b/docker-compose.yml.example @@ -67,12 +67,28 @@ services: - chronarr_logs:/app/data/logs # Optional: Radarr SQLite database (read-only access) # Required if using RADARR_DB_TYPE=sqlite in config - # - /path/to/radarr/config:/radarr-config:ro - # Then set RADARR_DB_PATH=/radarr-config/radarr.db in .env + # - /path/to/radarr/config:/radarr-data:ro + # Then set RADARR_DB_PATH=/radarr-data/radarr.db in .env + # + # Additional Radarr instances — one mount per instance: + # - /path/to/radarr-4k/config:/radarr-4k-data:ro + # Then set RADARR_4K_DB_PATH=/radarr-4k-data/radarr.db in .env + # + # Media paths — one mount per instance if libraries differ: + # - /path/to/movies:/media/movies:ro + # - /path/to/movies-4k:/media/movies-4k:ro + # Then set RADARR_MOVIE_PATHS=/media/movies and + # RADARR_4K_MOVIE_PATHS=/media/movies-4k in .env + # # Optional: Sonarr SQLite database (read-only access) # Required if using SONARR_DB_TYPE=sqlite in config - # - /path/to/sonarr/config:/sonarr-config:ro - # Then set SONARR_DB_PATH=/sonarr-config/sonarr.db in .env + # - /path/to/sonarr/config:/sonarr-data:ro + # Then set SONARR_DB_PATH=/sonarr-data/sonarr.db in .env + # + # Additional Sonarr instances: + # - /path/to/sonarr-anime/config:/sonarr-anime-data:ro + # Then set SONARR_ANIME_DB_PATH=/sonarr-anime-data/sonarr.db in .env + # # Optional: Emby plugin deployment # - /path/to/emby/plugins:/emby-plugins ports: @@ -176,20 +192,25 @@ networks: # # SQLite Database Access (for Radarr/Sonarr): # - If using SQLite databases, you MUST mount them read-only -# - Radarr example: -# 1. Uncomment: - /path/to/radarr/config:/radarr-config:ro -# 2. Update path to your Radarr config directory -# 3. In ./config/.env set: RADARR_DB_TYPE=sqlite -# 4. In ./config/.env set: RADARR_DB_PATH=/radarr-config/radarr.db +# - Each instance needs its own volume mount and its own DB_PATH variable +# - Radarr (default instance) example: +# 1. Add volume: - /path/to/radarr/config:/radarr-data:ro +# 2. In ./config/.env set: RADARR_DB_TYPE=sqlite +# 3. In ./config/.env set: RADARR_DB_PATH=/radarr-data/radarr.db +# - Radarr 4K instance example: +# 1. Add volume: - /path/to/radarr-4k/config:/radarr-4k-data:ro +# 2. In ./config/.env set: RADARR_4K_DB_TYPE=sqlite +# 3. In ./config/.env set: RADARR_4K_DB_PATH=/radarr-4k-data/radarr.db # - Sonarr example: -# 1. Uncomment: - /path/to/sonarr/config:/sonarr-config:ro -# 2. Update path to your Sonarr config directory -# 3. In ./config/.env set: SONARR_DB_TYPE=sqlite -# 4. In ./config/.env set: SONARR_DB_PATH=/sonarr-config/sonarr.db -# - Common paths: -# - Docker: /path/to/radarr/config (where radarr.db lives) -# - Windows: C:/ProgramData/Radarr or C:/ProgramData/Sonarr -# - Linux: /home/user/.config/Radarr or /var/lib/radarr +# 1. Add volume: - /path/to/sonarr/config:/sonarr-data:ro +# 2. In ./config/.env set: SONARR_DB_TYPE=sqlite +# 3. In ./config/.env set: SONARR_DB_PATH=/sonarr-data/sonarr.db +# - The radarr.db file is usually directly inside the Radarr config folder. +# If not, browse the folder on your host and find it, then adjust DB_PATH. +# - Common config locations: +# - Docker (LSIO/Hotio): the config directory you mapped to /config in Radarr +# - Windows (bare metal): C:/ProgramData/Radarr +# - Linux (bare metal): /home/user/.config/Radarr or /var/lib/radarr # # Emby Plugin Deployment: # - Uncomment the emby-plugins volume mount From cf95368acf8f6c35e6a6210b930a0f4cef63df36 Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Fri, 28 Aug 2026 22:41:48 -0400 Subject: [PATCH 24/45] fix: hard-fail Radarr/Sonarr populate on misconfigured SQLite path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- api/web_routes.py | 20 ++++++++-- clients/radarr_db_client.py | 7 ++++ clients/sonarr_db_client.py | 7 ++++ core/database_populator.py | 76 ++++++++++++++++++------------------- 4 files changed, 67 insertions(+), 43 deletions(-) diff --git a/api/web_routes.py b/api/web_routes.py index 1f4dc29..3faa12e 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -1371,8 +1371,14 @@ def _merge_tv_stats(all_stats): update_status(status) print(f"INFO: [Worker Process] Populating movies for instance '{inst.name}'") - pop = DatabasePopulator.from_radarr_instance(inst, db) - inst_stats = pop.populate_movies(instance=inst.name) + try: + pop = DatabasePopulator.from_radarr_instance(inst, db) + inst_stats = pop.populate_movies(instance=inst.name) + except Exception as e: + print(f"ERROR: [Worker Process] Movies failed for instance '{inst.name}': {e}") + inst_stats = {'total': 0, 'added': 0, 'updated': 0, 'skipped': 0, 'errors': 1, + 'duration': 0.0, + 'skipped_items': [f"Instance '{inst.name}' failed: {e}"]} all_movie_stats.append((inst.name, inst_stats)) print(f"INFO: [Worker Process] Movies done for '{inst.name}': {inst_stats}") @@ -1389,8 +1395,14 @@ def _merge_tv_stats(all_stats): update_status(status) print(f"INFO: [Worker Process] Populating TV for instance '{inst.name}'") - pop = DatabasePopulator.from_sonarr_instance(inst, db) - inst_stats = pop.populate_tv_episodes(instance=inst.name) + try: + pop = DatabasePopulator.from_sonarr_instance(inst, db) + inst_stats = pop.populate_tv_episodes(instance=inst.name) + except Exception as e: + print(f"ERROR: [Worker Process] TV failed for instance '{inst.name}': {e}") + inst_stats = {'total_series': 0, 'total_episodes': 0, 'added': 0, 'updated': 0, + 'skipped': 0, 'errors': 1, 'duration': 0.0, + 'skipped_items': [f"Instance '{inst.name}' failed: {e}"]} all_tv_stats.append((inst.name, inst_stats)) print(f"INFO: [Worker Process] TV done for '{inst.name}': {inst_stats}") diff --git a/clients/radarr_db_client.py b/clients/radarr_db_client.py index 077dc4c..b574cde 100644 --- a/clients/radarr_db_client.py +++ b/clients/radarr_db_client.py @@ -106,6 +106,13 @@ def _test_connection(self) -> None: def _get_connection(self) -> Union[sqlite3.Connection, psycopg2.extensions.connection]: """Get database connection""" if self.db_type == "sqlite": + # sqlite3.connect() silently creates an empty file at db_path if it + # doesn't exist — a missing/wrong volume mount would otherwise look + # like a successful connection to an empty database instead of failing. + if not self.db_path or not Path(self.db_path).exists(): + raise FileNotFoundError( + f"Radarr SQLite database not found at '{self.db_path}' — check volume mounts" + ) conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row return conn diff --git a/clients/sonarr_db_client.py b/clients/sonarr_db_client.py index 4b3d9fe..eb8119a 100644 --- a/clients/sonarr_db_client.py +++ b/clients/sonarr_db_client.py @@ -106,6 +106,13 @@ def _test_connection(self) -> None: def _get_connection(self) -> Union[sqlite3.Connection, psycopg2.extensions.connection]: """Get database connection""" if self.db_type == "sqlite": + # sqlite3.connect() silently creates an empty file at db_path if it + # doesn't exist — a missing/wrong volume mount would otherwise look + # like a successful connection to an empty database instead of failing. + if not self.db_path or not Path(self.db_path).exists(): + raise FileNotFoundError( + f"Sonarr SQLite database not found at '{self.db_path}' — check volume mounts" + ) conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row return conn diff --git a/core/database_populator.py b/core/database_populator.py index f1c693b..bba55e3 100644 --- a/core/database_populator.py +++ b/core/database_populator.py @@ -105,48 +105,46 @@ def __init__(self, db: ChronarrDatabase, radarr_client: RadarrClient = None, def from_radarr_instance(cls, inst, db: ChronarrDatabase) -> 'DatabasePopulator': """Build a populator wired to one specific RadarrInstance. - Tries direct DB access using the instance's db_type/db_path/host config; - falls back to the HTTP API client if DB access isn't configured or fails. + If db_type is explicitly configured, DB access is required — a broken + connection (e.g. a misconfigured or unmounted SQLite path) raises here + instead of silently falling back to the API client. Falling back would + degrade every date to a digital_fallback with no indication anything + was wrong. Only instances with no db_type at all use the API client. """ - client = None if inst.db_type: - try: - client = RadarrDbClient( - db_type=inst.db_type, - db_path=inst.db_path or None, - db_host=inst.db_host or None, - db_port=inst.db_port or None, - db_name=inst.db_name or None, - db_user=inst.db_user or None, - db_password=inst.db_password or None, - ) - _log("INFO", f"DatabasePopulator: DB access configured for Radarr instance '{inst.name}'") - except Exception as e: - _log("WARNING", f"DatabasePopulator: DB access failed for Radarr '{inst.name}', falling back to API: {e}") - if client is None: - client = RadarrClient(inst.url, inst.api_key) + client = RadarrDbClient( + db_type=inst.db_type, + db_path=inst.db_path or None, + db_host=inst.db_host or None, + db_port=inst.db_port or None, + db_name=inst.db_name or None, + db_user=inst.db_user or None, + db_password=inst.db_password or None, + ) + _log("INFO", f"DatabasePopulator: DB access configured for Radarr instance '{inst.name}'") + return cls(db, _radarr_db_override=client) + client = RadarrClient(inst.url, inst.api_key) return cls(db, _radarr_db_override=client) @classmethod def from_sonarr_instance(cls, inst, db: ChronarrDatabase) -> 'DatabasePopulator': - """Build a populator wired to one specific SonarrInstance.""" - client = None + """Build a populator wired to one specific SonarrInstance. + + Same DB-required contract as from_radarr_instance — see its docstring. + """ if inst.db_type: - try: - client = SonarrDbClient( - db_type=inst.db_type, - db_path=inst.db_path or None, - db_host=inst.db_host or None, - db_port=inst.db_port or None, - db_name=inst.db_name or None, - db_user=inst.db_user or None, - db_password=inst.db_password or None, - ) - _log("INFO", f"DatabasePopulator: DB access configured for Sonarr instance '{inst.name}'") - except Exception as e: - _log("WARNING", f"DatabasePopulator: DB access failed for Sonarr '{inst.name}', falling back to API: {e}") - if client is None: - client = SonarrClient(inst.url, inst.api_key) + client = SonarrDbClient( + db_type=inst.db_type, + db_path=inst.db_path or None, + db_host=inst.db_host or None, + db_port=inst.db_port or None, + db_name=inst.db_name or None, + db_user=inst.db_user or None, + db_password=inst.db_password or None, + ) + _log("INFO", f"DatabasePopulator: DB access configured for Sonarr instance '{inst.name}'") + return cls(db, _sonarr_db_override=client) + client = SonarrClient(inst.url, inst.api_key) return cls(db, _sonarr_db_override=client) def get_episode_import_history(self, episode_id: int) -> Optional[str]: @@ -202,11 +200,11 @@ def populate_movies(self, instance: str = 'radarr') -> Dict[str, any]: # Legacy API client with db_client attribute movies = self.radarr.db_client.get_all_movies() else: - _log("ERROR", "Radarr database/API client not available - cannot populate movies") + _log("ERROR", f"Radarr database/API client not available - cannot populate movies") stats['errors'] += 1 return stats if not movies: - _log("WARNING", "No movies found in Radarr database") + _log("WARNING", f"No movies found in Radarr database") return stats stats['total'] = len(movies) @@ -420,7 +418,7 @@ def populate_tv_episodes(self, instance: str = 'sonarr') -> Dict[str, any]: 'duration': float } """ - _log("INFO", "Starting TV episode population from Sonarr") + _log("INFO", f"Starting TV episode population from Sonarr") start_time = time.time() stats = { @@ -438,7 +436,7 @@ def populate_tv_episodes(self, instance: str = 'sonarr') -> Dict[str, any]: # Get all series from Sonarr all_series = self.sonarr.get_all_series() if not all_series: - _log("WARNING", "No series found in Sonarr") + _log("WARNING", f"No series found in Sonarr") return stats stats['total_series'] = len(all_series) From d02e7e8e716aec3adfde3791f85352f7c0fcaefa Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Fri, 28 Aug 2026 22:41:57 -0400 Subject: [PATCH 25/45] fix: tag per-movie and per-episode populate log lines with instance name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the block-boundary 'Populating movies for instance X' line (in the worker process) carried the instance name. Everything below it — Added movie..., Movie N history debug, skip/error lines — didn't, so multi-instance logs couldn't be traced back to which instance a given line belonged to. Every _log() call inside populate_movies()/populate_tv_episodes() is now prefixed with [instance]. --- core/database_populator.py | 74 +++++++++++++++++++------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/core/database_populator.py b/core/database_populator.py index bba55e3..912b001 100644 --- a/core/database_populator.py +++ b/core/database_populator.py @@ -200,15 +200,15 @@ def populate_movies(self, instance: str = 'radarr') -> Dict[str, any]: # Legacy API client with db_client attribute movies = self.radarr.db_client.get_all_movies() else: - _log("ERROR", f"Radarr database/API client not available - cannot populate movies") + _log("ERROR", f"[{instance}] Radarr database/API client not available - cannot populate movies") stats['errors'] += 1 return stats if not movies: - _log("WARNING", f"No movies found in Radarr database") + _log("WARNING", f"[{instance}] No movies found in Radarr database") return stats stats['total'] = len(movies) - _log("INFO", f"Found {stats['total']} movies in Radarr") + _log("INFO", f"[{instance}] Found {stats['total']} movies in Radarr") # Process each movie for movie in movies: @@ -223,7 +223,7 @@ def populate_movies(self, instance: str = 'radarr') -> Dict[str, any]: if not imdb_id and path: imdb_id = parse_imdb_from_path(Path(path)) if imdb_id: - _log("DEBUG", f"Extracted IMDb ID {imdb_id} from path for: {movie.get('title')}") + _log("DEBUG", f"[{instance}] Extracted IMDb ID {imdb_id} from path for: {movie.get('title')}") if not imdb_id: # Generate placeholder IMDb ID using hash of path @@ -238,7 +238,7 @@ def populate_movies(self, instance: str = 'radarr') -> Dict[str, any]: 'reason': skip_reason } stats['skipped_items'].append(skip_info) - _log("DEBUG", f"Movie without IMDb ID: {movie.get('title')} (path: {path}), using placeholder {imdb_id}") + _log("DEBUG", f"[{instance}] Movie without IMDb ID: {movie.get('title')} (path: {path}), using placeholder {imdb_id}") # Mark as skipped in database with placeholder IMDb ID self.db.mark_movie_skipped( @@ -258,7 +258,7 @@ def populate_movies(self, instance: str = 'radarr') -> Dict[str, any]: # Already in database - update file path and video status if needed existing_path = existing.get('path') if not existing_path or existing_path == 'unknown' or existing_path != path: - _log("INFO", f"Movie {imdb_id} exists but updating file info: {path}") + _log("INFO", f"[{instance}] Movie {imdb_id} exists but updating file info: {path}") self.db.update_movie_file_info(imdb_id, path, has_video_file=True) # Add to processing history @@ -270,11 +270,11 @@ def populate_movies(self, instance: str = 'radarr') -> Dict[str, any]: details={'path': path} ) except Exception as e: - _log("WARNING", f"Failed to add processing history for {imdb_id}: {e}") + _log("WARNING", f"[{instance}] Failed to add processing history for {imdb_id}: {e}") stats['updated'] += 1 else: - _log("DEBUG", f"Movie {imdb_id} already in database with correct path, skipping") + _log("DEBUG", f"[{instance}] Movie {imdb_id} already in database with correct path, skipping") continue # Get release date @@ -317,7 +317,7 @@ def populate_movies(self, instance: str = 'radarr') -> Dict[str, any]: 'reason': skip_reason } stats['skipped_items'].append(skip_info) - _log("DEBUG", f"No date available for movie {imdb_id}, skipping") + _log("DEBUG", f"[{instance}] No date available for movie {imdb_id}, skipping") # Mark as skipped in database for troubleshooting self.db.mark_movie_skipped( @@ -343,7 +343,7 @@ def populate_movies(self, instance: str = 'radarr') -> Dict[str, any]: 'reason': skip_reason } stats['skipped_items'].append(skip_info) - _log("DEBUG", f"No date available for movie {imdb_id}, skipping") + _log("DEBUG", f"[{instance}] No date available for movie {imdb_id}, skipping") # Mark as skipped in database for troubleshooting self.db.mark_movie_skipped( @@ -375,28 +375,28 @@ def populate_movies(self, instance: str = 'radarr') -> Dict[str, any]: details={'source': source, 'title': title} ) except Exception as e: - _log("WARNING", f"Failed to add processing history for {imdb_id}: {e}") + _log("WARNING", f"[{instance}] Failed to add processing history for {imdb_id}: {e}") stats['added'] += 1 - _log("DEBUG", f"Added movie {imdb_id}: {title} ({year}) (source: {source})") + _log("DEBUG", f"[{instance}] Added movie {imdb_id}: {title} ({year}) (source: {source})") except Exception as e: - _log("ERROR", f"Error processing movie {movie.get('title', 'unknown')}: {e}") + _log("ERROR", f"[{instance}] Error processing movie {movie.get('title', 'unknown')}: {e}") stats['errors'] += 1 continue except Exception as e: - _log("ERROR", f"Error during movie population: {e}") + _log("ERROR", f"[{instance}] Error during movie population: {e}") stats['errors'] += 1 stats['duration'] = time.time() - start_time - _log("INFO", f"Movie population complete: {stats['added']} added, {stats['skipped']} skipped, {stats['errors']} errors in {stats['duration']:.2f}s") + _log("INFO", f"[{instance}] Movie population complete: {stats['added']} added, {stats['skipped']} skipped, {stats['errors']} errors in {stats['duration']:.2f}s") # Log details of skipped items if stats['skipped_items']: - _log("INFO", f"Skipped items details ({len(stats['skipped_items'])} total):") + _log("INFO", f"[{instance}] Skipped items details ({len(stats['skipped_items'])} total):") for item in stats['skipped_items']: - _log("INFO", f" - {item['title']} ({item.get('year', 'N/A')}) [{item.get('imdb_id', 'No IMDb')}]: {item['reason']}") + _log("INFO", f"[{instance}] - {item['title']} ({item.get('year', 'N/A')}) [{item.get('imdb_id', 'No IMDb')}]: {item['reason']}") return stats @@ -418,7 +418,7 @@ def populate_tv_episodes(self, instance: str = 'sonarr') -> Dict[str, any]: 'duration': float } """ - _log("INFO", f"Starting TV episode population from Sonarr") + _log("INFO", f"[{instance}] Starting TV episode population from Sonarr") start_time = time.time() stats = { @@ -436,11 +436,11 @@ def populate_tv_episodes(self, instance: str = 'sonarr') -> Dict[str, any]: # Get all series from Sonarr all_series = self.sonarr.get_all_series() if not all_series: - _log("WARNING", f"No series found in Sonarr") + _log("WARNING", f"[{instance}] No series found in Sonarr") return stats stats['total_series'] = len(all_series) - _log("INFO", f"Found {stats['total_series']} series in Sonarr") + _log("INFO", f"[{instance}] Found {stats['total_series']} series in Sonarr") # Process each series for series in all_series: @@ -455,13 +455,13 @@ def populate_tv_episodes(self, instance: str = 'sonarr') -> Dict[str, any]: # Try to extract from path first imdb_id = parse_imdb_from_path(Path(series_path)) if imdb_id: - _log("DEBUG", f"Extracted IMDb ID {imdb_id} from path for {series_title}") + _log("DEBUG", f"[{instance}] Extracted IMDb ID {imdb_id} from path for {series_title}") if not imdb_id: # Generate placeholder IMDb ID using hash of path path_hash = hashlib.md5(series_path.encode()).hexdigest()[:12] imdb_id = f"missing-{path_hash}" - _log("DEBUG", f"Series without IMDb ID: {series_title} (path: {series_path}), using placeholder {imdb_id}") + _log("DEBUG", f"[{instance}] Series without IMDb ID: {series_title} (path: {series_path}), using placeholder {imdb_id}") # Update series record self.db.upsert_series(imdb_id, series_path, instance=instance) @@ -471,11 +471,11 @@ def populate_tv_episodes(self, instance: str = 'sonarr') -> Dict[str, any]: if self.using_sonarr_db and self.sonarr_db: try: - _log("DEBUG", f"Using DB bulk query for {series_title}") + _log("DEBUG", f"[{instance}] Using DB bulk query for {series_title}") bulk_import_dates = self.sonarr_db.bulk_import_dates_for_series(series_id) - _log("DEBUG", f"✅ Got {len(bulk_import_dates)} import dates from DB for {series_title}") + _log("DEBUG", f"[{instance}] ✅ Got {len(bulk_import_dates)} import dates from DB for {series_title}") except Exception as e: - _log("WARNING", f"DB bulk query failed for {series_title}, falling back to API: {e}") + _log("WARNING", f"[{instance}] DB bulk query failed for {series_title}, falling back to API: {e}") # Get all episodes for this series if self.using_sonarr_db and self.sonarr_db: @@ -485,7 +485,7 @@ def populate_tv_episodes(self, instance: str = 'sonarr') -> Dict[str, any]: if not episodes: continue - _log("DEBUG", f"Processing {len(episodes)} episodes for {series_title}") + _log("DEBUG", f"[{instance}] Processing {len(episodes)} episodes for {series_title}") # Process each episode for episode in episodes: @@ -507,7 +507,7 @@ def populate_tv_episodes(self, instance: str = 'sonarr') -> Dict[str, any]: existing_path = existing.get('path') episode_path = episode.get('path', 'unknown') if not existing_path or existing_path == 'unknown' or existing_path != episode_path: - _log("INFO", f"Episode {imdb_id} S{season_num:02d}E{episode_num:02d} exists but updating file info: {episode_path}") + _log("INFO", f"[{instance}] Episode {imdb_id} S{season_num:02d}E{episode_num:02d} exists but updating file info: {episode_path}") self.db.update_episode_file_info(imdb_id, season_num, episode_num, episode_path, has_video_file=True) # Add to processing history @@ -519,7 +519,7 @@ def populate_tv_episodes(self, instance: str = 'sonarr') -> Dict[str, any]: details={'season': season_num, 'episode': episode_num, 'path': episode_path} ) except Exception as e: - _log("WARNING", f"Failed to add processing history for {imdb_id} S{season_num:02d}E{episode_num:02d}: {e}") + _log("WARNING", f"[{instance}] Failed to add processing history for {imdb_id} S{season_num:02d}E{episode_num:02d}: {e}") stats['updated'] += 1 continue @@ -565,7 +565,7 @@ def populate_tv_episodes(self, instance: str = 'sonarr') -> Dict[str, any]: if file_date: dateadded = file_date source = 'sonarr:db.file.dateAdded' - _log("INFO", f"Using file date for {series_title} S{season_num:02d}E{episode_num:02d}: {file_date}") + _log("INFO", f"[{instance}] Using file date for {series_title} S{season_num:02d}E{episode_num:02d}: {file_date}") if not dateadded: # No date available @@ -602,34 +602,34 @@ def populate_tv_episodes(self, instance: str = 'sonarr') -> Dict[str, any]: details={'season': season_num, 'episode': episode_num, 'source': source, 'title': episode_title} ) except Exception as e: - _log("WARNING", f"Failed to add processing history for {imdb_id} S{season_num:02d}E{episode_num:02d}: {e}") + _log("WARNING", f"[{instance}] Failed to add processing history for {imdb_id} S{season_num:02d}E{episode_num:02d}: {e}") stats['added'] += 1 except Exception as e: - _log("ERROR", f"Error processing episode S{season_num:02d}E{episode_num:02d} of {series_title}: {e}") + _log("ERROR", f"[{instance}] Error processing episode S{season_num:02d}E{episode_num:02d} of {series_title}: {e}") stats['errors'] += 1 continue except Exception as e: - _log("ERROR", f"Error processing series {series.get('title', 'unknown')}: {e}") + _log("ERROR", f"[{instance}] Error processing series {series.get('title', 'unknown')}: {e}") stats['errors'] += 1 continue except Exception as e: - _log("ERROR", f"Error during TV episode population: {e}") + _log("ERROR", f"[{instance}] Error during TV episode population: {e}") stats['errors'] += 1 stats['duration'] = time.time() - start_time - _log("INFO", f"TV episode population complete: {stats['added']} added, {stats['skipped']} skipped, {stats['errors']} errors in {stats['duration']:.2f}s") + _log("INFO", f"[{instance}] TV episode population complete: {stats['added']} added, {stats['skipped']} skipped, {stats['errors']} errors in {stats['duration']:.2f}s") # Log details of skipped items if stats['skipped_items']: - _log("INFO", f"Skipped episodes details ({len(stats['skipped_items'])} total):") + _log("INFO", f"[{instance}] Skipped episodes details ({len(stats['skipped_items'])} total):") for item in stats['skipped_items'][:20]: # Only log first 20 to avoid spam - _log("INFO", f" - {item['title']} S{str(item['season']).zfill(2)}E{str(item['episode']).zfill(2)} ({item.get('episode_title', 'Unknown')}): {item['reason']}") + _log("INFO", f"[{instance}] - {item['title']} S{str(item['season']).zfill(2)}E{str(item['episode']).zfill(2)} ({item.get('episode_title', 'Unknown')}): {item['reason']}") if len(stats['skipped_items']) > 20: - _log("INFO", f" ... and {len(stats['skipped_items']) - 20} more (see web interface for full list)") + _log("INFO", f"[{instance}] ... and {len(stats['skipped_items']) - 20} more (see web interface for full list)") return stats From 7ba61d9fff414555166fb2d26246d340ba3a1411 Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Fri, 28 Aug 2026 22:42:00 -0400 Subject: [PATCH 26/45] docs: clarify multi-instance NAME segment naming rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NAME segment between the prefix and variable suffix (RADARR_4K_URL, SONARR_ANIME_URL) must be letters, digits, and underscores only — hyphens aren't valid in a shell/env-var name. That constraint wasn't stated, and it wasn't clear the URL *value* itself has no such restriction and can freely contain hyphens or a full domain name. --- .env.example | 5 ++++- README.md | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 96bfbdd..c1a4171 100644 --- a/.env.example +++ b/.env.example @@ -79,7 +79,10 @@ RADARR_DB_USER=postgres # =========================================== # Add a block per extra Radarr instance. The name between RADARR_ and _URL # becomes part of the webhook URL: RADARR_4K_URL → /radarr_4k/webhook -# Names must be uppercase letters and underscores only. +# Names must be uppercase letters, digits, and underscores only — no hyphens +# (a shell/env-var limitation). RADARR_4K and RADARR_STRM_BOX are fine; +# RADARR-4K is not. The URL *value* has no such restriction — it can contain +# hyphens and a full domain name, e.g. RADARR_STRM_URL=http://radarr-strm.yourdomain.com:7878 # # Each instance defines ALL of its own settings here — URL, API key, paths, # and database access. Do NOT set MOVIE_PATHS again in this block; use diff --git a/README.md b/README.md index 2e7eb40..461bd16 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,8 @@ SONARR_DB_PASSWORD=sonarr_pass # Sonarr database password (.env.secrets) Add additional Radarr or Sonarr instances using a `NAME` segment between the prefix and the variable suffix. The name becomes the instance identifier in Chronarr's UI and database. +The `NAME` segment must be letters, digits, and underscores only — no hyphens (`RADARR_4K` works, `RADARR-4K` doesn't; this is a shell/env-var limitation, not a Chronarr restriction). The *value* you assign to the URL variable has no such restriction and can freely contain hyphens or a full domain name, e.g. `SONARR_STRM_URL=http://sonarr-strm.yourdomain.com:8989`. + Each instance block is fully self-contained — define all settings including paths directly within it. **Do not use the global `MOVIE_PATHS` or `TV_PATHS` keys in additional instance blocks.** If those keys appear more than once in your env file, Docker only keeps the last value and silently drops the rest, corrupting path matching for every instance. ```bash From a71f6917d14d937a2f4829f16442db68bee305ae Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Fri, 28 Aug 2026 22:42:04 -0400 Subject: [PATCH 27/45] docs: add multi-instance SQLite mount examples to quickstart and compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QUICKSTART.md had no multi-instance coverage at all — users setting up a second Radarr/Sonarr instance for the first time had no example showing that each SQLite instance needs its own distinct volume mount and DB_PATH, and would silently fall back to API mode with no indication why. Added a Multi-Instance Setup section under Advanced Configuration. docker-compose.yml.example had a media-path mount example for additional Radarr instances but not the equivalent for Sonarr — added for symmetry. --- QUICKSTART.md | 34 ++++++++++++++++++++++++++++++++++ docker-compose.yml.example | 6 ++++++ 2 files changed, 40 insertions(+) diff --git a/QUICKSTART.md b/QUICKSTART.md index cbcfe67..40d8d44 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -210,6 +210,40 @@ SONARR_DB_PASSWORD=sonarr_password # Add to .env.secrets **Why does Radarr require database access?** The Radarr API doesn't expose all import history data needed for accurate date tracking. Direct database access allows Chronarr to query import timestamps, detect upgrades vs new imports, and handle complex scenarios. +### Multi-Instance Setup (Multiple Radarr/Sonarr) + +Running a standard + 4K library split (or any other multi-instance setup)? Add a +`NAME` segment between the prefix and variable suffix — e.g. `RADARR_4K_URL`. Each +named instance is fully self-contained: it needs its own `_URL`, `_API_KEY`, +`_ROOT_FOLDERS`, its own paths variable (`RADARR_4K_MOVIE_PATHS`, not the global +`MOVIE_PATHS`), and — if using SQLite — its own `_DB_TYPE`/`_DB_PATH` **and its own +volume mount**, distinct from the main instance's: + +```yaml +# docker-compose.yml — one mount per SQLite instance +chronarr: + volumes: + - /path/to/radarr/config:/radarr-data:ro # RADARR_DB_PATH=/radarr-data/radarr.db + - /path/to/radarr-4k/config:/radarr-4k-data:ro # RADARR_4K_DB_PATH=/radarr-4k-data/radarr.db +``` + +```bash +# ./config/.env +RADARR_4K_URL=http://radarr-4k:7878 +RADARR_4K_API_KEY=your_4k_api_key # → set in .env.secrets +RADARR_4K_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies4k +RADARR_4K_MOVIE_PATHS=/media/movies4k +RADARR_4K_DB_TYPE=sqlite +RADARR_4K_DB_PATH=/radarr-4k-data/radarr.db +``` + +Reusing the main instance's mount point or `_DB_PATH` for a second instance is the +most common cause of a "connected but returns no data" instance — each one needs +its own distinct container path. See the README's Multi-Instance Configuration +section and `docker-compose.yml.example` for the full annotated reference, and +visit `/setup` after restarting to confirm each instance's webhook URL and +connection status. + ### Emby Plugin Deployment To auto-deploy the Chronarr Emby plugin: diff --git a/docker-compose.yml.example b/docker-compose.yml.example index 1f47664..81f8558 100644 --- a/docker-compose.yml.example +++ b/docker-compose.yml.example @@ -89,6 +89,12 @@ services: # - /path/to/sonarr-anime/config:/sonarr-anime-data:ro # Then set SONARR_ANIME_DB_PATH=/sonarr-anime-data/sonarr.db in .env # + # Media paths — one mount per instance if libraries differ: + # - /path/to/tv:/media/tv:ro + # - /path/to/anime:/media/anime:ro + # Then set SONARR_TV_PATHS=/media/tv and + # SONARR_ANIME_TV_PATHS=/media/anime in .env + # # Optional: Emby plugin deployment # - /path/to/emby/plugins:/emby-plugins ports: From edc18c10d38c9413e466e292beef8196a54b3f3f Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Sat, 29 Aug 2026 09:10:26 -0400 Subject: [PATCH 28/45] fix: cast SQLite hasFile to bool before episode insert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite has no boolean type, so the CASE WHEN true/false expression in get_all_episodes_for_series comes back as integer 1/0. Postgres refuses to insert that into the boolean has_video_file column, failing every episode insert for users on direct SQLite Sonarr DB access. Movies were unaffected — radarr_db_client.py has no matching CASE WHEN pattern. --- clients/sonarr_db_client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/clients/sonarr_db_client.py b/clients/sonarr_db_client.py index eb8119a..26187e1 100644 --- a/clients/sonarr_db_client.py +++ b/clients/sonarr_db_client.py @@ -246,7 +246,9 @@ def get_all_episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]: rows = cursor.fetchall() if self.db_type == "sqlite": - return [dict(row) for row in rows] + # SQLite has no boolean type — the CASE WHEN above comes back as 1/0, + # which Postgres refuses to insert into the boolean has_video_file column + return [dict(row, hasFile=bool(row["hasFile"])) for row in rows] else: return rows From 01f9ab0e22e2b7fb3fc53df2b6c9e2c350c6ed22 Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Sun, 30 Aug 2026 14:32:12 -0400 Subject: [PATCH 29/45] feat: setup wizard, per-instance health, and multi-instance webhook fixes Setup Wizard (v3 plan Part 5): add, edit, and delete Radarr/Sonarr instances from /setup without hand-editing env files. - config/env_writer.py writes instance config into .env/.env.secrets, touching only the keys it's given; secrets always land in .env.secrets, never .env. Backup/restore endpoints for both files. - New wizard endpoints: test-connection, save-instance (add or edit), delete-instance, instance-details, env-backup, env-restore. - Root Folders auto-fill from Radarr/Sonarr's own API instead of requiring manual entry. - /setup shows per-instance health (connected/method) instead of one global status, with Edit/Delete on each row. - Populate Database's instance picker now derives Media Type from the selected instance (Radarr -> movies, Sonarr -> TV) instead of requiring both to be set in sync; the backend treats the instance's own type as authoritative so a mismatched media_type can't silently populate nothing. Multi-instance webhook processing fixes: - TVProcessor/MovieProcessor were built once at startup wired only to the default Radarr/Sonarr instance. The `instance` parameter threaded through every method only tagged Chronarr's own DB writes, never selected which server to actually query - so a named instance's webhook silently fell back to air-date/release-date instead of real import history. Both processors now resolve client, using_db, and path_mapper per call from the InstanceRegistry using that parameter. Also fixes the Sonarr webhook route not passing instance through to series-path resolution, which would have used the default instance's root folder mapping even after the processor-level fix. - Every log line in the webhook receive/batch/process path is now tagged with the instance name it belongs to. - Multi-episode webhook batches were losing their IMDb ID for series without an ID embedded in the folder name, falling back to a path parse that only works for ID-tagged folders. - Episode dates read via direct Sonarr DB access were mislabeled as coming from the API. - .env/.env.secrets are now loaded before the instance-discovery config is built, so a newly added instance is picked up without needing a full container recreate. - The web container's core-proxy hostname no longer collides with core's own bind-address setting. --- .env.example | 12 + api/models.py | 79 +++- api/routes.py | 483 +++++++++++++++++++--- api/web_routes.py | 212 +++++++++- chronarr-web/static/index.html | 9 + chronarr-web/static/js/app.js | 87 +++- clients/radarr_client.py | 103 ++--- clients/radarr_db_client.py | 72 ++-- clients/sonarr_client.py | 73 ++-- clients/sonarr_db_client.py | 48 ++- config/env_writer.py | 278 +++++++++++++ core/database_populator.py | 39 +- core/instance_registry.py | 6 + main.py | 25 +- processors/movie_processor.py | 162 +++++--- processors/tv_processor.py | 220 +++++----- start_web.py | 6 + static/index.html | 9 + static/js/app.js | 87 +++- static/setup.html | 732 ++++++++++++++++++++++++++++++++- webhooks/webhook_batcher.py | 83 ++-- 21 files changed, 2376 insertions(+), 449 deletions(-) create mode 100644 config/env_writer.py diff --git a/.env.example b/.env.example index c1a4171..e847ffa 100644 --- a/.env.example +++ b/.env.example @@ -34,9 +34,21 @@ DB_USER=chronarr TZ=America/New_York # Core API — handles webhooks and processing +# This is the interface Chronarr's own core container listens ON. Leave it +# at 0.0.0.0 unless you have a specific reason to change it — setting this +# to a hostname will stop core listening on the addresses Docker healthchecks +# and the web container's other internal calls use, breaking both. CORE_API_HOST=0.0.0.0 CORE_API_PORT=8080 +# Where the WEB container reaches CORE internally — a different setting from +# the pair above. Only needed if the default hostname "chronarr" is +# ambiguous, e.g. running more than one Chronarr deployment on the same +# Docker network (a production stack and a local-test stack, say) — point +# this at your core container's actual name/service in that situation. +# CORE_INTERNAL_HOST=chronarr +# CORE_INTERNAL_PORT=8080 + # Web dashboard WEB_HOST=0.0.0.0 WEB_PORT=8081 diff --git a/api/models.py b/api/models.py index 148aa88..38394f4 100644 --- a/api/models.py +++ b/api/models.py @@ -49,7 +49,8 @@ class HealthResponse(BaseModel): version: str uptime: str database_status: str - radarr_database: Optional[Dict[str, Any]] = None + radarr_database: Optional[Dict[str, Any]] = None # legacy — default Radarr instance only, kept for back-compat + instances: Optional[Dict[str, Dict[str, Any]]] = None # {"radarr": {name: {connected, method}}, "sonarr": {...}} — every configured instance class TVSeasonRequest(BaseModel): @@ -251,4 +252,78 @@ class CleanupExecutionResponse(BaseModel): execution_time_seconds: Optional[int] error_message: Optional[str] report_json: Optional[str] - triggered_by: Optional[str] \ No newline at end of file + triggered_by: Optional[str] + + +class WizardConnectionTestRequest(BaseModel): + """Setup wizard: test a Radarr/Sonarr URL+API key or a direct DB connection. + + Send either url+api_key, or a db_type block, or both — whatever the + form has filled in so far. Doesn't touch any files. + """ + media_type: str # "radarr" or "sonarr" + name: Optional[str] = None # not validated here — just used to label log lines during the test + url: Optional[str] = None + api_key: Optional[str] = None + db_type: Optional[str] = None # "sqlite" or "postgresql" + db_host: Optional[str] = None + db_port: Optional[int] = None + db_name: Optional[str] = None + db_user: Optional[str] = None + db_password: Optional[str] = None + db_path: Optional[str] = None + + +class WizardSaveInstanceRequest(BaseModel): + """Setup wizard: write a Radarr/Sonarr instance to .env / .env.secrets. + + `name` is the user-typed instance name (e.g. "4k" or "strm") — leave it + empty to configure the default instance. `force` skips the connection + test failing being a hard stop, for someone who knows the service is + just down right now and wants to save the config anyway. `edit_existing` + flips the name check: normally a name must NOT already exist (adding a + new instance); with edit_existing=True it must ALREADY exist instead — + editing something that was never configured isn't a valid request either. + """ + media_type: str # "radarr" or "sonarr" + name: str = "" + url: str + api_key: str + root_folders: Optional[List[str]] = None + movie_paths: Optional[List[str]] = None # radarr instances only + tv_paths: Optional[List[str]] = None # sonarr instances only + db_type: Optional[str] = None + db_host: Optional[str] = None + db_port: Optional[int] = None + db_name: Optional[str] = None + db_user: Optional[str] = None + db_password: Optional[str] = None + db_path: Optional[str] = None + force: bool = False + edit_existing: bool = False + + +class WizardDeleteInstanceRequest(BaseModel): + """Setup wizard: remove a Radarr/Sonarr instance from .env / .env.secrets. + + Removes every env var this instance's name segment owns. Same automatic + pre-write snapshot every other wizard write already takes. + """ + media_type: str # "radarr" or "sonarr" + name: str = "" # "" targets the default (unprefixed) instance + + +class WizardEnvRestoreRequest(BaseModel): + """Setup wizard: restore .env / .env.secrets from a backup file's contents. + + `env` and `env_secrets` are the raw file text, exactly as + GET /api/wizard/env-backup produced them — this overwrites both files + verbatim, not a merge. `chronarr_version`/`exported_at` are informational + only, from the backup's own header; nothing currently rejects a restore + over a version mismatch, but they're captured in case that's worth + warning about later. + """ + env: str + env_secrets: str + chronarr_version: Optional[str] = None + exported_at: Optional[str] = None \ No newline at end of file diff --git a/api/routes.py b/api/routes.py index 3bd6a56..8e945a4 100644 --- a/api/routes.py +++ b/api/routes.py @@ -16,7 +16,8 @@ from api.models import ( SonarrWebhook, RadarrWebhook, MaintainarrWebhook, HealthResponse, TVSeasonRequest, TVEpisodeRequest, MovieUpdateRequest, EpisodeUpdateRequest, BulkUpdateRequest, OrphanedCleanupRequest, - CreateScheduledCleanupRequest, UpdateScheduledCleanupRequest, ScheduledCleanupResponse, CleanupExecutionResponse + CreateScheduledCleanupRequest, UpdateScheduledCleanupRequest, ScheduledCleanupResponse, CleanupExecutionResponse, + WizardConnectionTestRequest, WizardSaveInstanceRequest, WizardEnvRestoreRequest, WizardDeleteInstanceRequest ) # Import logging utility from utils.logging import _log @@ -94,7 +95,7 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de raise HTTPException(status_code=422, detail="Empty Sonarr payload") webhook = SonarrWebhook(**payload) - _log("INFO", f"Received Sonarr webhook: {webhook.eventType}") + _log("INFO", f"[{instance}] Received Sonarr webhook: {webhook.eventType}") if webhook.eventType not in ["Download", "Upgrade", "Rename"]: return {"status": "ignored", "reason": f"Event type {webhook.eventType} not processed"} @@ -110,24 +111,25 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de sonarr_path = series_info.get("path", "") if not imdb_id: - _log("ERROR", f"No IMDb ID for series: {series_title}") + _log("ERROR", f"[{instance}] No IMDb ID for series: {series_title}") return {"status": "error", "reason": "No IMDb ID"} - - # Find series path - series_path = tv_processor.find_series_path(series_title, imdb_id, sonarr_path) + + # Find series path — instance-aware so a named instance's own root + # folder mapping is used, not always the default instance's. + series_path = tv_processor.find_series_path(series_title, imdb_id, sonarr_path, instance=instance) if not series_path: - print(f"ERROR: Could not find series directory: {series_title} ({imdb_id})") + _log("ERROR", f"[{instance}] Could not find series directory: {series_title} ({imdb_id})") return {"status": "error", "reason": "Series directory not found"} - + # Extract episode data for targeted processing episodes_data = webhook.episodes or [] - _log("DEBUG", f"Initial episodes_data from webhook.episodes: {len(episodes_data)} episodes") + _log("DEBUG", f"[{instance}] Initial episodes_data from webhook.episodes: {len(episodes_data)} episodes") # For all webhook events, if no episodes in webhook.episodes, try to extract from episodeFile # This ensures targeted processing for single episode operations (Download, Rename, Upgrade) - _log("DEBUG", f"webhook.episodeFile present: {webhook.episodeFile is not None}") + _log("DEBUG", f"[{instance}] webhook.episodeFile present: {webhook.episodeFile is not None}") if webhook.episodeFile: - _log("DEBUG", f"episodeFile content: {webhook.episodeFile}") + _log("DEBUG", f"[{instance}] episodeFile content: {webhook.episodeFile}") if not episodes_data and webhook.episodeFile: episode_file = webhook.episodeFile @@ -141,17 +143,17 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de # Try relativePath first, then path file_path = episode_file.get("relativePath") or episode_file.get("path", "") - _log("DEBUG", f"Parsing episode info from path: {file_path}") + _log("DEBUG", f"[{instance}] Parsing episode info from path: {file_path}") episode_info = extract_episode_info_from_filename(file_path) if episode_info: season_num = episode_info["season"] episode_num = episode_info["episode"] - _log("DEBUG", f"Extracted from filename - Season: {season_num}, Episode: {episode_num}") + _log("DEBUG", f"[{instance}] Extracted from filename - Season: {season_num}, Episode: {episode_num}") else: - _log("DEBUG", f"Could not extract season/episode from filename: {file_path}") + _log("DEBUG", f"[{instance}] Could not extract season/episode from filename: {file_path}") - _log("DEBUG", f"episodeFile seasonNumber: {season_num}, episodeNumber: {episode_num}") + _log("DEBUG", f"[{instance}] episodeFile seasonNumber: {season_num}, episodeNumber: {episode_num}") if season_num and episode_num: # Create episode data structure that matches what process_webhook_episodes expects episodes_data = [{ @@ -161,37 +163,37 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de "title": episode_file.get("title") # Note: Not including dateAdded - we use database-first approach with Sonarr fallback }] - _log("INFO", f"Extracted episode info from episodeFile for {webhook.eventType}: S{season_num:02d}E{episode_num:02d}") + _log("INFO", f"[{instance}] Extracted episode info from episodeFile for {webhook.eventType}: S{season_num:02d}E{episode_num:02d}") else: - _log("DEBUG", f"Missing season/episode numbers in episodeFile for {webhook.eventType}") + _log("DEBUG", f"[{instance}] Missing season/episode numbers in episodeFile for {webhook.eventType}") # Special handling for Rename events - Sonarr doesn't include episodeFile for renames # Try to find recently renamed episodes using Sonarr history API if not episodes_data and webhook.eventType == "Rename": - _log("DEBUG", f"Attempting to find recently renamed episode for series {imdb_id}") + _log("DEBUG", f"[{instance}] Attempting to find recently renamed episode for series {imdb_id}") try: # Get series info from Sonarr to find series ID series_lookup_url = f"{config.sonarr_url}/api/v3/series/lookup?term=imdbid:{imdb_id}" - _log("DEBUG", f"Sonarr lookup for rename: {series_lookup_url}") + _log("DEBUG", f"[{instance}] Sonarr lookup for rename: {series_lookup_url}") response = requests.get(series_lookup_url, headers={"X-Api-Key": os.environ.get("SONARR_API_KEY", "")}, timeout=10) if response.status_code == 200: series_results = response.json() if series_results: series_id = series_results[0].get("id") - _log("DEBUG", f"Found series ID {series_id} for rename lookup") + _log("DEBUG", f"[{instance}] Found series ID {series_id} for rename lookup") # Get recent history for the series and filter for rename events from datetime import datetime, timedelta since_date = (datetime.utcnow() - timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M:%SZ') history_url = f"{config.sonarr_url}/api/v3/history?seriesId={series_id}&sortKey=date&sortDir=desc&page=1&pageSize=50" - _log("DEBUG", f"Checking recent rename history: {history_url}") + _log("DEBUG", f"[{instance}] Checking recent rename history: {history_url}") history_response = requests.get(history_url, headers={"X-Api-Key": os.environ.get("SONARR_API_KEY", "")}, timeout=10) if history_response.status_code == 200: history_data = history_response.json() all_records = history_data.get("records", []) - _log("DEBUG", f"Got {len(all_records)} total history records") + _log("DEBUG", f"[{instance}] Got {len(all_records)} total history records") # Filter for recent rename events since_timestamp = datetime.utcnow() - timedelta(hours=1) @@ -211,16 +213,16 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de # If datetime parsing fails, include it anyway recent_renames.append(record) - _log("DEBUG", f"Found {len(recent_renames)} recent rename events") + _log("DEBUG", f"[{instance}] Found {len(recent_renames)} recent rename events") if recent_renames: # Take the most recent rename event latest_rename = recent_renames[0] - _log("DEBUG", f"Processing latest rename event") + _log("DEBUG", f"[{instance}] Processing latest rename event") # Extract episodeId directly from the rename event episode_id = latest_rename.get("episodeId") - _log("DEBUG", f"Found episodeId {episode_id} in rename event") + _log("DEBUG", f"[{instance}] Found episodeId {episode_id} in rename event") if episode_id: # Fetch episode details using the episodeId @@ -233,7 +235,7 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de episode_num = episode_detail.get("episodeNumber") episode_title = episode_detail.get("title") - _log("DEBUG", f"Episode details - Season: {season_num}, Episode: {episode_num}, Title: {episode_title}") + _log("DEBUG", f"[{instance}] Episode details - Season: {season_num}, Episode: {episode_num}, Title: {episode_title}") if season_num is not None and episode_num is not None: episodes_data = [{ @@ -242,30 +244,30 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de "id": episode_id, "title": episode_title }] - print(f"INFO: Successfully identified renamed episode: S{season_num:02d}E{episode_num:02d} - {episode_title}") + _log("INFO", f"[{instance}] Successfully identified renamed episode: S{season_num:02d}E{episode_num:02d} - {episode_title}") else: - _log("DEBUG", f"Episode details missing season/episode numbers") + _log("DEBUG", f"[{instance}] Episode details missing season/episode numbers") else: - _log("DEBUG", f"Failed to fetch episode details: {episode_response.status_code}") + _log("DEBUG", f"[{instance}] Failed to fetch episode details: {episode_response.status_code}") else: - _log("DEBUG", f"No episodeId found in rename event") + _log("DEBUG", f"[{instance}] No episodeId found in rename event") else: - _log("DEBUG", f"No recent rename events found in last hour") + _log("DEBUG", f"[{instance}] No recent rename events found in last hour") else: - _log("DEBUG", f"Failed to get rename history: {history_response.status_code}") + _log("DEBUG", f"[{instance}] Failed to get rename history: {history_response.status_code}") else: - _log("DEBUG", f"No series found for IMDb {imdb_id}") + _log("DEBUG", f"[{instance}] No series found for IMDb {imdb_id}") else: - _log("DEBUG", f"Series lookup failed: {response.status_code}") + _log("DEBUG", f"[{instance}] Series lookup failed: {response.status_code}") except Exception as e: - _log("DEBUG", f"Error finding renamed episode: {e}") + _log("DEBUG", f"[{instance}] Error finding renamed episode: {e}") # Continue with series processing as fallback # Force targeted mode for single-episode webhooks to prevent full series processing processing_mode = config.tv_webhook_processing_mode if episodes_data and len(episodes_data) <= 3: # Single episode or small batch processing_mode = "targeted" - _log("INFO", f"Forcing targeted mode for {len(episodes_data)} episode(s)") + _log("INFO", f"[{instance}] Forcing targeted mode for {len(episodes_data)} episode(s)") tv_batch_key = f"tv:{imdb_id}" webhook_dict = { @@ -279,9 +281,9 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de batcher.add_webhook(tv_batch_key, webhook_dict, 'tv') return {"status": "accepted", "message": f"Sonarr webhook queued for {tv_batch_key}"} - + except Exception as e: - _log("ERROR", f"Sonarr webhook error: {e}") + _log("ERROR", f"[{instance}] Sonarr webhook error: {e}") raise HTTPException(status_code=422, detail=f"Invalid webhook: {e}") @@ -296,8 +298,8 @@ async def radarr_webhook(request: Request, background_tasks: BackgroundTasks, de body = await request.body() await _verify_webhook_signature(request, body, config.radarr_webhook_secret, "X-Radarr-Signature") payload = await _read_payload(request) - _log("INFO", f"Received Radarr webhook: {payload.get('eventType', 'Unknown')}") - _log("DEBUG", f"Full Radarr webhook payload: {payload}") + _log("INFO", f"[{instance}] Received Radarr webhook: {payload.get('eventType', 'Unknown')}") + _log("DEBUG", f"[{instance}] Full Radarr webhook payload: {payload}") # Filter supported event types (same as Sonarr: Download, Upgrade, Rename) event_type = payload.get('eventType', '') @@ -307,29 +309,29 @@ async def radarr_webhook(request: Request, background_tasks: BackgroundTasks, de # Extract movie info movie_data = payload.get("movie", {}) if not movie_data: - _log("WARNING", "No movie data in Radarr webhook") + _log("WARNING", f"[{instance}] No movie data in Radarr webhook") return {"status": "error", "message": "No movie data"} # Get IMDb ID for batching key imdb_id = movie_data.get("imdbId", "").lower() if not imdb_id: - _log("WARNING", "No IMDb ID in Radarr webhook movie data") + _log("WARNING", f"[{instance}] No IMDb ID in Radarr webhook movie data") return {"status": "error", "message": "No IMDb ID"} # Get movie path and map it movie_path = movie_data.get("folderPath") or movie_data.get("path", "") if not movie_path: - _log("ERROR", "No movie path in Radarr webhook") + _log("ERROR", f"[{instance}] No movie path in Radarr webhook") return {"status": "error", "message": "No movie path provided"} # Map the path to container path container_path = path_mapper.radarr_path_to_container_path(movie_path) - _log("DEBUG", f"Mapped Radarr path {movie_path} -> {container_path}") + _log("DEBUG", f"[{instance}] Mapped Radarr path {movie_path} -> {container_path}") # CRITICAL: Verify the mapped path actually exists if not Path(container_path).exists(): - _log("ERROR", f"RADARR WEBHOOK REJECTED: Mapped path does not exist: {container_path}") - _log("ERROR", "This prevents processing wrong movies due to path mapping issues") + _log("ERROR", f"[{instance}] RADARR WEBHOOK REJECTED: Mapped path does not exist: {container_path}") + _log("ERROR", f"[{instance}] This prevents processing wrong movies due to path mapping issues") return {"status": "error", "message": f"Mapped movie path does not exist: {container_path}"} # IMDb ID won't be in the path for standard Radarr folder naming — omit the check @@ -343,13 +345,13 @@ async def radarr_webhook(request: Request, background_tasks: BackgroundTasks, de } movie_batch_key = f"movie:{imdb_id}" - _log("DEBUG", f"Adding Radarr webhook to batch: key={movie_batch_key}, movie_title={movie_data.get('title', 'Unknown')}, instance={instance}") + _log("DEBUG", f"[{instance}] Adding Radarr webhook to batch: key={movie_batch_key}, movie_title={movie_data.get('title', 'Unknown')}, instance={instance}") batcher.add_webhook(movie_batch_key, movie_webhook_data, "movie") return {"status": "success", "message": f"Radarr webhook queued for {movie_batch_key}"} except Exception as e: - _log("ERROR", f"Radarr webhook error: {e}") + _log("ERROR", f"[{instance}] Radarr webhook error: {e}") return {"status": "error", "message": str(e)} @@ -568,13 +570,27 @@ async def health(dependencies: dict) -> HealthResponse: except Exception as e: # If movie processor isn't available, skip database health check _log("DEBUG", f"Skipping Radarr database health check: {e}") - + + # Per-instance status for every configured Radarr/Sonarr — not just the + # default instance the block above checks. This is what /setup already + # shows as connected/disconnected badges; /health was never updated to + # report the same thing when multi-instance landed, so a dual-instance + # user had no way to see a second instance's connection state from here. + registry = dependencies.get("registry") + instances = None + if registry: + instances = { + "radarr": registry.all_radarr_statuses(), + "sonarr": registry.all_sonarr_statuses(), + } + return HealthResponse( status=overall_status, version=version, uptime=str(uptime), database_status=db_status, - radarr_database=radarr_db_health + radarr_database=radarr_db_health, + instances=instances, ) @@ -818,7 +834,7 @@ async def run_scan(): sonarr_path_index = {} def _unc_basename(p): - """Return the last path component of p, handling both POSIX and + r"""Return the last path component of p, handling both POSIX and Windows/UNC paths (pathlib on Linux treats backslash as a literal character, so Path(r'\\NAS\share\Movie (2013)').name returns the whole string rather than 'Movie (2013)').""" @@ -2988,6 +3004,79 @@ async def get_populate_status(): # Initialize global populate status _populate_status = {"running": False, "completed": False} +# Instance names the wizard has written to .env this process, but that aren't +# live yet because chronarr-core hasn't been restarted to pick them up. The +# InstanceRegistry alone can't catch "save the same new instance twice" — +# from its point of view neither save happened. This resets naturally on the +# next real restart, which is exactly when it should, since by then the +# registry knows about them for real. +_pending_wizard_instances: set = set() + + +def _wizard_test_api(url: str, api_key: str) -> dict: + """Hit Radarr/Sonarr's system status endpoint to check the URL+key work. + + Both apps expose the same path, so one function covers either. Returns + {"success": True} or {"success": False, "error": "..."} — never raises, + the wizard endpoints turn a failure into an HTTP error themselves. + """ + try: + base = url.rstrip("/") + resp = requests.get(f"{base}/api/v3/system/status", headers={"X-Api-Key": api_key}, timeout=10) + resp.raise_for_status() + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + +def _wizard_fetch_root_folders(url: str, api_key: str) -> list: + """Ask Radarr/Sonarr for its configured root folders. + + Both apps expose the same path. Only called once the API test above has + already passed, so a failure here is worth logging but not worth + failing the whole test over — the user typed a working URL+key, root + folders are a convenience on top of that, not a requirement. + """ + try: + base = url.rstrip("/") + resp = requests.get(f"{base}/api/v3/rootfolder", headers={"X-Api-Key": api_key}, timeout=10) + resp.raise_for_status() + return [rf["path"] for rf in resp.json() if rf.get("path")] + except Exception as e: + _log("WARNING", f"Setup wizard: could not fetch root folders from {url}: {e}") + return [] + + +def _wizard_test_db(media_type, db_type, db_host, db_port, db_name, db_user, db_password, db_path, label="") -> dict: + """Try building a direct DB client — its constructor tests the connection itself. + + Same {"success": ...} shape as _wizard_test_api(). The client is thrown + away either way; this only exists to find out if it *would* connect. + `label` (e.g. "sonarr_strm (testing)") is just for the log lines this + produces — the instance doesn't exist yet, so there's nothing to look up. + """ + try: + if media_type == "radarr": + from clients.radarr_db_client import RadarrDbClient as DbClient + else: + from clients.sonarr_db_client import SonarrDbClient as DbClient + + if db_type == "sqlite": + DbClient(db_type="sqlite", db_path=db_path, instance_name=label) + else: + DbClient( + db_type=db_type, + db_host=db_host, + db_port=db_port or 5432, + db_name=db_name, + db_user=db_user, + db_password=db_password, + instance_name=label, + ) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + def register_routes(app, dependencies: dict): """Register all routes. Webhook routes are registered per-instance dynamically.""" @@ -3064,6 +3153,298 @@ async def _api_instances(): ] return {"radarr": radarr_list, "sonarr": sonarr_list} + @app.get("/api/wizard/instance-details") + async def _wizard_instance_details(media_type: str, name: str = ""): + """Look up one instance's full config, for pre-filling the wizard's Edit form. + + Returns the same fields the wizard form collects (url, api_key, + root_folders, paths, DB block) straight from the live config object + — no need to re-read or re-parse the env files, config already has + it resolved. Includes the API key and DB password: this endpoint + exists specifically so Edit doesn't make someone retype secrets + they've already set, and they're already visible to anyone who can + reach /api/wizard/env-backup anyway. + """ + cfg = dependencies.get("config") + if not cfg: + raise HTTPException(status_code=503, detail="Config not initialised") + + if media_type not in ("radarr", "sonarr"): + raise HTTPException(status_code=400, detail="media_type must be 'radarr' or 'sonarr'") + + instance_name = f"{media_type}_{name.lower()}" if name else media_type + instances = cfg.radarr_instances if media_type == "radarr" else cfg.sonarr_instances + match = next((i for i in instances if i.name == instance_name), None) + if not match: + raise HTTPException(status_code=404, detail=f"No instance named '{instance_name}' found") + + return { + "media_type": media_type, + "name": name, + "url": match.url, + "api_key": match.api_key, + "root_folders": match.root_folders, + "movie_paths": getattr(match, "movie_paths", None), + "tv_paths": getattr(match, "tv_paths", None), + "db_type": match.db_type or None, + "db_host": match.db_host or None, + "db_port": match.db_port or None, + "db_name": match.db_name or None, + "db_user": match.db_user or None, + "db_password": match.db_password or None, + "db_path": match.db_path or None, + } + + @app.post("/api/wizard/test-connection") + async def _wizard_test_connection(payload: WizardConnectionTestRequest): + """Setup wizard: try connecting with whatever the user has typed so far. + + Doesn't write anything or touch the registry — just reports whether + the API URL+key and/or the DB block actually work, so the wizard + can show a real pass/fail before the user hits Save. + """ + label = f"{payload.media_type}_{payload.name.lower()} (testing)" if payload.name else f"{payload.media_type} (testing)" + result = {} + if payload.url and payload.api_key: + result["api"] = _wizard_test_api(payload.url, payload.api_key) + if result["api"]["success"]: + result["api"]["root_folders"] = _wizard_fetch_root_folders(payload.url, payload.api_key) + if payload.db_type: + result["db"] = _wizard_test_db( + payload.media_type, payload.db_type, payload.db_host, payload.db_port, + payload.db_name, payload.db_user, payload.db_password, payload.db_path, + label=label, + ) + return result + + @app.post("/api/wizard/save-instance") + async def _wizard_save_instance(payload: WizardSaveInstanceRequest): + """Setup wizard: validate, then write one instance to .env / .env.secrets. + + Either every check below passes and both files get written, or + nothing gets written at all — no half-applied instance sitting in + the config. Restarting chronarr-core is still required to actually + pick the new instance up; that limitation is explained in the docs + rather than hidden from the caller. + """ + from config.env_writer import ( + build_env_updates, upsert_env_file, validate_name_segment, InstanceNameError, backup_env_files, + ) + + registry = dependencies.get("registry") + if not registry: + raise HTTPException(status_code=503, detail="Registry not initialised") + + if payload.media_type not in ("radarr", "sonarr"): + raise HTTPException(status_code=400, detail="media_type must be 'radarr' or 'sonarr'") + + # Check against what's actually running, not just what's in the file — + # someone may have hand-edited .env since the last restart. + existing_names = set(registry.radarr_names) | set(registry.sonarr_names) + + if payload.name: + try: + name_segment = validate_name_segment( + payload.name, existing_names, payload.media_type, require_exists=payload.edit_existing + ) + except InstanceNameError as e: + raise HTTPException(status_code=400, detail=str(e)) + else: + # Empty name means "the default instance" — still needs the same + # existence check as a named one, just inline since there's no + # segment to run through validate_name_segment for. + name_segment = "" + default_exists = payload.media_type in existing_names + if payload.edit_existing and not default_exists: + raise HTTPException( + status_code=400, + detail=f"No default {payload.media_type} instance exists to edit.", + ) + if not payload.edit_existing and default_exists: + raise HTTPException( + status_code=400, + detail=( + f"The default {payload.media_type} instance is already configured. " + "Give this one a name to add it as an additional instance instead." + ), + ) + + instance_name = f"{payload.media_type}_{name_segment.lower()}" if name_segment else payload.media_type + # The pending-instance guard only matters for adds — it exists to + # stop the same brand-new name being saved twice before a restart. + # Editing an already-live instance repeatedly before restarting is + # the normal workflow (fix a typo, test again), not a collision. + if not payload.edit_existing and instance_name in _pending_wizard_instances: + raise HTTPException( + status_code=400, + detail=( + f"'{instance_name}' was already saved by the wizard this session but hasn't " + "gone live yet — restart chronarr-core to pick it up before adding it again." + ), + ) + + if payload.db_type == "sqlite" and payload.db_path and not Path(payload.db_path).exists(): + raise HTTPException( + status_code=400, + detail=f"SQLite path '{payload.db_path}' doesn't exist in this container — check the volume mount", + ) + + if not payload.force: + api_result = _wizard_test_api(payload.url, payload.api_key) + if not api_result["success"]: + raise HTTPException( + status_code=400, + detail=f"Connection test failed: {api_result['error']}. Save again with force=true to skip this check.", + ) + if payload.db_type: + db_result = _wizard_test_db( + payload.media_type, payload.db_type, payload.db_host, payload.db_port, + payload.db_name, payload.db_user, payload.db_password, payload.db_path, + label=f"{instance_name} (pre-save test)", + ) + if not db_result["success"]: + raise HTTPException( + status_code=400, + detail=f"Database connection test failed: {db_result['error']}. Save again with force=true to skip this check.", + ) + + fields = payload.dict(exclude={"media_type", "name", "force", "edit_existing"}) + env_updates, secret_updates = build_env_updates(payload.media_type, name_segment, fields) + + # Cheap insurance — every wizard write gets a snapshot of what the + # files looked like just before, so a bad save is never a dead end. + # Same format /api/wizard/env-backup produces, restorable the same way. + backup_env_files(Path(".env"), Path(".env.secrets"), Path("backups")) + + env_changed = upsert_env_file(Path(".env"), env_updates, instance_label=instance_name) + secrets_changed = upsert_env_file(Path(".env.secrets"), secret_updates, instance_label=instance_name) + _pending_wizard_instances.add(instance_name) + + _log("INFO", f"Setup wizard: wrote instance '{instance_name}' to .env/.env.secrets — restart required to apply") + + return { + "instance_name": instance_name, + "webhook_path": f"/{instance_name}/webhook", + "env_changed": env_changed, + "secrets_changed": secrets_changed, + "restart_required": True, + "restart_reason": ( + "chronarr-core builds its instance registry once at startup — " + "restart it to pick up this instance." + ), + } + + @app.post("/api/wizard/delete-instance") + async def _wizard_delete_instance(payload: WizardDeleteInstanceRequest): + """Setup wizard: remove one instance's env vars from .env / .env.secrets. + + Same "must exist" name validation as editing, same automatic + pre-write snapshot as every other wizard write. Removing the vars + doesn't remove the instance from Docker's own baked-in environment + (if it was there before, e.g. present when the container was + created) — restart_reason says so explicitly rather than implying + a restart alone is always enough. + """ + from config.env_writer import ( + remove_instance_keys, validate_name_segment, InstanceNameError, backup_env_files, + ) + + registry = dependencies.get("registry") + if not registry: + raise HTTPException(status_code=503, detail="Registry not initialised") + + if payload.media_type not in ("radarr", "sonarr"): + raise HTTPException(status_code=400, detail="media_type must be 'radarr' or 'sonarr'") + + existing_names = set(registry.radarr_names) | set(registry.sonarr_names) + + if payload.name: + try: + name_segment = validate_name_segment(payload.name, existing_names, payload.media_type, require_exists=True) + except InstanceNameError as e: + raise HTTPException(status_code=400, detail=str(e)) + else: + name_segment = "" + if payload.media_type not in existing_names: + raise HTTPException( + status_code=400, + detail=f"No default {payload.media_type} instance exists to delete.", + ) + + instance_name = f"{payload.media_type}_{name_segment.lower()}" if name_segment else payload.media_type + prefix = f"{payload.media_type.upper()}_{name_segment}_" if name_segment else f"{payload.media_type.upper()}_" + + backup_env_files(Path(".env"), Path(".env.secrets"), Path("backups")) + + env_changed = remove_instance_keys(Path(".env"), prefix, instance_label=instance_name) + secrets_changed = remove_instance_keys(Path(".env.secrets"), prefix, instance_label=instance_name) + _pending_wizard_instances.discard(instance_name) + + _log("INFO", f"Setup wizard: removed instance '{instance_name}' from .env/.env.secrets — restart required to apply") + + return { + "instance_name": instance_name, + "env_changed": env_changed, + "secrets_changed": secrets_changed, + "restart_required": True, + "restart_reason": ( + "chronarr-core builds its instance registry once at startup. A plain restart " + "isn't always enough — if this instance's env vars were present when the " + "container was first created, Docker's own env_file injection baked them into " + "the container and a restart alone won't drop them. Recreate the container " + "(docker compose up -d --force-recreate, or down + up) to be sure they're gone." + ), + } + + @app.get("/api/wizard/env-backup") + async def _wizard_env_backup(): + """Download the current .env + .env.secrets as one backup file. + + A literal snapshot of both files' raw text, comments and all — + restoring it writes them back byte-for-byte. This file contains real + API keys and database passwords in plain text, same as .env.secrets + itself does; store it somewhere as secure as that file. + """ + from config.env_writer import read_env_files + from version_utils import get_version + + content = read_env_files(Path(".env"), Path(".env.secrets")) + payload = { + "chronarr_version": get_version(), + "exported_at": datetime.now(timezone.utc).isoformat(), + **content, + } + filename = f"chronarr-env-backup-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}.json" + return Response( + content=json.dumps(payload, indent=2), + media_type="application/json", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + @app.post("/api/wizard/env-restore") + async def _wizard_env_restore(payload: WizardEnvRestoreRequest): + """Restore .env + .env.secrets from a backup produced by /api/wizard/env-backup. + + The current files are snapshotted first, the same as every wizard + save already does — restoring the wrong file, or one from an + incompatible version, isn't a dead end either. + """ + from config.env_writer import write_env_files, backup_env_files + + pre_restore_backup = backup_env_files(Path(".env"), Path(".env.secrets"), Path("backups")) + write_env_files(Path(".env"), Path(".env.secrets"), payload.env, payload.env_secrets) + + _log("INFO", f"Setup wizard: restored .env/.env.secrets from an uploaded backup (pre-restore snapshot: {pre_restore_backup})") + + return { + "restored": True, + "pre_restore_backup": str(pre_restore_backup) if pre_restore_backup else None, + "restart_required": True, + "restart_reason": ( + "Restored files only take effect after chronarr-core restarts and reloads its environment." + ), + } + @app.get("/health") async def _health() -> HealthResponse: return await health(dependencies) diff --git a/api/web_routes.py b/api/web_routes.py index 3faa12e..fa7e93f 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -66,9 +66,22 @@ def map_source_to_description(source: str) -> str: elif "omdb:" in source_lower: return "OMDb Release" - # Sonarr sources - elif "sonarr:" in source_lower: + # Sonarr sources — same granularity as Radarr above. This used to be a + # single catch-all ("Sonarr API") for every sonarr: source regardless of + # whether it actually came from direct DB history, the aired-date + # fallback, or a file date — genuinely misleading once instances started + # using direct DB access, since a DB-sourced date would still display + # as "Sonarr API". + elif "sonarr:db.history.import" in source_lower or "sonarr:db.bulk.import" in source_lower: + return "Sonarr Import History" + elif "sonarr:db.file.dateadded" in source_lower: + return "Sonarr File Date" + elif "sonarr:aired_fallback" in source_lower: + return "Sonarr Air Date" + elif "sonarr:api.import_history" in source_lower: return "Sonarr API" + elif "sonarr:" in source_lower: + return "Sonarr" # Manual and other sources elif "manual" in source_lower: @@ -681,10 +694,18 @@ async def update_episode_date(dependencies: dict, imdb_id: str, season: int, epi import json import os - # Get core container connection details - core_host = os.environ.get("CORE_API_HOST", "chronarr") - core_port = os.environ.get("CORE_API_PORT", "8080") - + # Where THIS (web) container reaches core — deliberately not named + # CORE_API_HOST/CORE_API_PORT, which are a different pre-existing + # pair that control what interface core's own uvicorn binds to + # (config/settings.py, main.py). Those two settings look like they + # should be the same thing and aren't — setting CORE_API_HOST to a + # specific hostname to fix cross-stack DNS collisions (two chronarr + # deployments sharing a Docker network) breaks core's own healthcheck + # instead, since it stops binding to 0.0.0.0. Learned this the hard + # way once already; keep these names distinct. + core_host = os.environ.get("CORE_INTERNAL_HOST", "chronarr") + core_port = os.environ.get("CORE_INTERNAL_PORT", "8080") + # Call core container to update NFO file nfo_update_url = f"http://{core_host}:{core_port}/api/episodes/{imdb_id}/{season}/{episode}/update-nfo" @@ -1296,7 +1317,7 @@ async def get_episode_date_options(dependencies: dict, imdb_id: str, season: int } -def _populate_worker_process(media_type: str, status_file: str): +def _populate_worker_process(media_type: str, status_file: str, instance: str = "all"): """ Worker process that runs database population in complete isolation. Runs in separate process - keeps web interface responsive. @@ -1321,6 +1342,7 @@ def update_status(status_data): status = { "running": True, "media_type": media_type, + "instance": instance, "start_time": datetime.now().isoformat(), "movies": {"status": "pending", "stats": None}, "tv": {"status": "pending", "stats": None}, @@ -1357,9 +1379,38 @@ def _merge_tv_stats(all_stats): merged['skipped_items'].extend(s.get('skipped_items', [])) return merged - print(f"INFO: [Worker Process] Starting database population: {media_type}") + print(f"INFO: [Worker Process] Starting database population: {media_type} (instance: {instance})") radarr_instances = config.radarr_instances sonarr_instances = config.sonarr_instances + + # A specific instance narrows the run to just that one, regardless of + # which media_type was picked — the instance's own type (it can only + # be a Radarr or a Sonarr instance) decides what actually runs. "all" + # (the default) keeps the original behavior: every configured + # instance of whichever media_type was selected. + if instance and instance != "all": + matched_radarr = [i for i in radarr_instances if i.name == instance] + matched_sonarr = [i for i in sonarr_instances if i.name == instance] + if not matched_radarr and not matched_sonarr: + known = [i.name for i in radarr_instances] + [i.name for i in sonarr_instances] + raise ValueError(f"Instance '{instance}' not found. Configured instances: {known}") + radarr_instances = matched_radarr + sonarr_instances = matched_sonarr + # The instance's own type is authoritative — a Radarr instance can + # only ever mean movies, a Sonarr instance only TV. Override + # whatever media_type the caller sent so a stale/mismatched value + # (e.g. the UI's Media Type dropdown left on the wrong setting) + # can't silently produce a no-op run: without this, an instance + # narrowed to (say) only Sonarr combined with media_type="movies" + # would iterate an empty radarr_instances list, skip the tv block + # entirely, and finish "successfully" having populated nothing. + effective_media_type = "movies" if matched_radarr else "tv" + if effective_media_type != media_type: + print(f"INFO: [Worker Process] media_type '{media_type}' overridden to '{effective_media_type}' — instance '{instance}' determines its own type") + media_type = effective_media_type + status["media_type"] = media_type + print(f"INFO: [Worker Process] Narrowed to instance '{instance}'") + print(f"INFO: [Worker Process] Radarr instances: {[i.name for i in radarr_instances]}") print(f"INFO: [Worker Process] Sonarr instances: {[i.name for i in sonarr_instances]}") @@ -1429,7 +1480,7 @@ def _merge_tv_stats(all_stats): update_status(status) -async def populate_database(background_tasks: BackgroundTasks, media_type: str = "both", dependencies: dict = None): +async def populate_database(background_tasks: BackgroundTasks, media_type: str = "both", dependencies: dict = None, instance: str = "all"): """ Populate Chronarr database from Radarr/Sonarr sources in separate process. This keeps the web interface responsive during population. @@ -1438,6 +1489,8 @@ async def populate_database(background_tasks: BackgroundTasks, media_type: str = background_tasks: FastAPI background tasks (not used - kept for compatibility) media_type: Type of media to populate ("movies", "tv", or "both") dependencies: Dictionary with dependencies (not used in multiprocessing mode) + instance: Specific instance name to populate (e.g. "sonarr_4k"), or "all" + (default) for every configured instance of the given media_type Returns: Status message indicating population has started @@ -1458,6 +1511,7 @@ async def populate_database(background_tasks: BackgroundTasks, media_type: str = initial_status = { "running": True, "media_type": media_type, + "instance": instance, "start_time": datetime.now().isoformat(), "movies": {"status": "pending", "stats": None}, "tv": {"status": "pending", "stats": None}, @@ -1475,16 +1529,17 @@ async def populate_database(background_tasks: BackgroundTasks, media_type: str = # Start population in separate process _populate_process = multiprocessing.Process( target=_populate_worker_process, - args=(media_type, POPULATE_STATUS_FILE), + args=(media_type, POPULATE_STATUS_FILE, instance), daemon=False # Keep process alive even if parent exits ) _populate_process.start() - _log("INFO", f"Database population process started for: {media_type}") + _log("INFO", f"Database population process started for: {media_type} (instance: {instance})") return { "status": "started", "media_type": media_type, - "message": f"Database population started for {media_type}" + "instance": instance, + "message": f"Database population started for {media_type}" + (f" ({instance})" if instance != "all" else "") } @@ -1909,8 +1964,8 @@ async def api_manual_scan(request: Request): import socket # Get core container URL - core_host = os.environ.get("CORE_API_HOST", "chronarr") - core_port = os.environ.get("CORE_API_PORT", "8080") + core_host = os.environ.get("CORE_INTERNAL_HOST", "chronarr") + core_port = os.environ.get("CORE_INTERNAL_PORT", "8080") # Forward query parameters from the request query_string = str(request.url.query) if request.url.query else "" @@ -1946,8 +2001,8 @@ async def api_manual_cleanup(request: Request): import socket # Get core container URL - core_host = os.environ.get("CORE_API_HOST", "chronarr") - core_port = os.environ.get("CORE_API_PORT", "8080") + core_host = os.environ.get("CORE_INTERNAL_HOST", "chronarr") + core_port = os.environ.get("CORE_INTERNAL_PORT", "8080") core_url = f"http://{core_host}:{core_port}/manual/cleanup-orphaned" try: @@ -2066,8 +2121,8 @@ async def api_scan_status(): import socket # Get core container connection details - core_host = os.environ.get("CORE_API_HOST", "chronarr") - core_port = os.environ.get("CORE_API_PORT", "8080") + core_host = os.environ.get("CORE_INTERNAL_HOST", "chronarr") + core_port = os.environ.get("CORE_INTERNAL_PORT", "8080") try: # Call core container's detailed scan status endpoint @@ -2505,10 +2560,12 @@ async def api_populate_database(request: Request, background_tasks: BackgroundTa try: data = await request.json() media_type = data.get("media_type", "both") + instance = data.get("instance", "all") except Exception: - # Fallback to query parameter if JSON parsing fails + # Fallback to query parameters if JSON parsing fails media_type = request.query_params.get("media_type", "both") - return await populate_database(background_tasks, media_type, dependencies) + instance = request.query_params.get("instance", "all") + return await populate_database(background_tasks, media_type, dependencies, instance) _log("DEBUG", "/admin/populate-database registered") @@ -2527,8 +2584,8 @@ async def api_instances(request: Request): import os import socket - core_host = os.environ.get("CORE_API_HOST", "chronarr") - core_port = os.environ.get("CORE_API_PORT", "8080") + core_host = os.environ.get("CORE_INTERNAL_HOST", "chronarr") + core_port = os.environ.get("CORE_INTERNAL_PORT", "8080") core_url = f"http://{core_host}:{core_port}/api/instances" try: @@ -2540,6 +2597,117 @@ async def api_instances(request: Request): except (urllib.error.URLError, socket.timeout) as e: raise HTTPException(status_code=503, detail=f"Could not reach core container: {e}") + @app.get("/api/wizard/instance-details") + async def wizard_instance_details(request: Request): + """Proxy an instance's full config (for pre-filling Edit) from the core container.""" + import urllib.request + import urllib.error + import json + import os + import socket + + core_host = os.environ.get("CORE_INTERNAL_HOST", "chronarr") + core_port = os.environ.get("CORE_INTERNAL_PORT", "8080") + query = request.url.query + core_url = f"http://{core_host}:{core_port}/api/wizard/instance-details" + (f"?{query}" if query else "") + + try: + req = urllib.request.Request(core_url) + with urllib.request.urlopen(req, timeout=5) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + detail = e.read().decode("utf-8") + try: + detail = json.loads(detail).get("detail", detail) + except ValueError: + pass + raise HTTPException(status_code=e.code, detail=detail) + except (urllib.error.URLError, socket.timeout) as e: + raise HTTPException(status_code=503, detail=f"Could not reach core container: {e}") + + async def _proxy_post_to_core(path: str, body: dict): + """Forward a wizard POST to the core container and hand back its response as-is. + + Same host/port lookup and error handling as the /api/instances GET + proxy above — core owns the InstanceRegistry and the env files, so + every wizard write goes through it rather than the web container + touching config directly. + """ + import urllib.request + import urllib.error + import json as _json + import os as _os + import socket + + core_host = _os.environ.get("CORE_INTERNAL_HOST", "chronarr") + core_port = _os.environ.get("CORE_INTERNAL_PORT", "8080") + core_url = f"http://{core_host}:{core_port}{path}" + + data = _json.dumps(body).encode("utf-8") + req = urllib.request.Request(core_url, data=data, headers={"Content-Type": "application/json"}, method="POST") + try: + with urllib.request.urlopen(req, timeout=15) as resp: + return _json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + # Core sends back a real error detail (bad name, failed test, etc.) — + # pass it through instead of flattening everything to a generic 500. + detail = e.read().decode("utf-8") + try: + detail = _json.loads(detail).get("detail", detail) + except ValueError: + pass + raise HTTPException(status_code=e.code, detail=detail) + except (urllib.error.URLError, socket.timeout) as e: + raise HTTPException(status_code=503, detail=f"Could not reach core container: {e}") + + @app.post("/api/wizard/test-connection") + async def wizard_test_connection(request: Request): + """Proxy the wizard's connection test to the core container.""" + return await _proxy_post_to_core("/api/wizard/test-connection", await request.json()) + + @app.post("/api/wizard/save-instance") + async def wizard_save_instance(request: Request): + """Proxy the wizard's save (writes .env/.env.secrets) to the core container.""" + return await _proxy_post_to_core("/api/wizard/save-instance", await request.json()) + + @app.post("/api/wizard/delete-instance") + async def wizard_delete_instance(request: Request): + """Proxy the wizard's delete (removes .env/.env.secrets entries) to the core container.""" + return await _proxy_post_to_core("/api/wizard/delete-instance", await request.json()) + + @app.get("/api/wizard/env-backup") + async def wizard_env_backup(): + """Proxy the .env/.env.secrets backup download from the core container. + + Passed through as raw bytes with the same Content-Disposition header + core set, rather than re-parsed as JSON — this is a file download, + not a data endpoint. + """ + import urllib.request + import urllib.error + import os as _os + import socket + + core_host = _os.environ.get("CORE_INTERNAL_HOST", "chronarr") + core_port = _os.environ.get("CORE_INTERNAL_PORT", "8080") + core_url = f"http://{core_host}:{core_port}/api/wizard/env-backup" + + try: + req = urllib.request.Request(core_url) + with urllib.request.urlopen(req, timeout=15) as resp: + body = resp.read() + disposition = resp.headers.get("Content-Disposition", "attachment; filename=chronarr-env-backup.json") + return Response(content=body, media_type="application/json", headers={"Content-Disposition": disposition}) + except urllib.error.HTTPError as e: + raise HTTPException(status_code=e.code, detail=e.reason) + except (urllib.error.URLError, socket.timeout) as e: + raise HTTPException(status_code=503, detail=f"Could not reach core container: {e}") + + @app.post("/api/wizard/env-restore") + async def wizard_env_restore(request: Request): + """Proxy the wizard's env-file restore (overwrites .env/.env.secrets) to core.""" + return await _proxy_post_to_core("/api/wizard/env-restore", await request.json()) + @app.get("/setup") async def setup_page(): """Serve the instance setup / webhook URL reference page.""" diff --git a/chronarr-web/static/index.html b/chronarr-web/static/index.html index 55f2fea..22cf329 100644 --- a/chronarr-web/static/index.html +++ b/chronarr-web/static/index.html @@ -444,6 +444,15 @@

Populate Database

+
+ + +

+ Picking a specific instance populates only that one and sets Media Type for you (a Radarr instance is always movies, a Sonarr instance always TV). +

+
diff --git a/chronarr-web/static/js/app.js b/chronarr-web/static/js/app.js index 9b9db2c..65c9c34 100644 --- a/chronarr-web/static/js/app.js +++ b/chronarr-web/static/js/app.js @@ -54,7 +54,7 @@ function switchTab(tabName) { case 'movies': loadMovies(); break; case 'tv': loadSeries(); break; case 'reports': loadReport(); break; - case 'tools': loadDetailedStats(); break; + case 'tools': loadDetailedStats(); loadPopulateInstanceOptions(); break; } } @@ -139,6 +139,10 @@ function initializeEventListeners() { document.getElementById('bulk-update-form').addEventListener('submit', handleBulkUpdate); document.getElementById('manual-scan-form').addEventListener('submit', handleManualScan); document.getElementById('populate-form').addEventListener('submit', handlePopulateDatabase); + const populateInstanceSelect = document.getElementById('populate-instance'); + if (populateInstanceSelect) { + populateInstanceSelect.addEventListener('change', syncPopulateMediaTypeToInstance); + } } // API calls @@ -1867,10 +1871,86 @@ async function bulkDeleteSelected() { // Database Population Functions // --------------------------- +// Fills the Instance dropdown on the Populate Database card from the same +// /api/instances data the sidebar already uses — "All Instances" plus one +// entry per configured Radarr/Sonarr instance, grouped by type so it's +// obvious which is which once there's more than a couple. +async function loadPopulateInstanceOptions() { + const select = document.getElementById('populate-instance'); + if (!select) return; + + try { + const data = await apiCall('/api/instances'); + const radarrNames = (data.radarr || []).map(i => i.name); + const sonarrNames = (data.sonarr || []).map(i => i.name); + + // Keep "All Instances", drop everything else and rebuild — this + // gets called every time the Tools tab is opened, so it has to be + // safe to run repeatedly without piling up duplicate options. + select.innerHTML = ''; + + if (radarrNames.length) { + const group = document.createElement('optgroup'); + group.label = 'Radarr'; + radarrNames.forEach(name => { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + opt.dataset.mediaType = 'movies'; + group.appendChild(opt); + }); + select.appendChild(group); + } + + if (sonarrNames.length) { + const group = document.createElement('optgroup'); + group.label = 'Sonarr'; + sonarrNames.forEach(name => { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + opt.dataset.mediaType = 'tv'; + group.appendChild(opt); + }); + select.appendChild(group); + } + + // Reset in case the last selection here no longer exists (e.g. an + // instance was deleted since this was last loaded). + syncPopulateMediaTypeToInstance(); + } catch (error) { + console.error('Failed to load instances for populate dropdown:', error); + // Leave just "All Instances" — populate still works, just without + // the ability to target a single instance until this loads. + } +} + +function syncPopulateMediaTypeToInstance() { + const instanceSelect = document.getElementById('populate-instance'); + const mediaTypeSelect = document.getElementById('populate-media-type'); + if (!instanceSelect || !mediaTypeSelect) return; + + const selectedOption = instanceSelect.options[instanceSelect.selectedIndex]; + const mediaType = selectedOption ? selectedOption.dataset.mediaType : null; + + if (instanceSelect.value !== 'all' && mediaType) { + // A specific instance can only ever be one media type — a Radarr + // instance is movies, a Sonarr instance is TV. Lock the Media Type + // dropdown to match instead of making the user keep the two in sync + // by hand (picking the wrong one used to silently populate nothing). + mediaTypeSelect.value = mediaType; + mediaTypeSelect.disabled = true; + } else { + mediaTypeSelect.disabled = false; + } +} + async function handlePopulateDatabase(event) { event.preventDefault(); const mediaType = document.getElementById('populate-media-type').value; + const instanceSelect = document.getElementById('populate-instance'); + const instance = instanceSelect ? instanceSelect.value : 'all'; // Validate input if (!mediaType) { @@ -1879,7 +1959,8 @@ async function handlePopulateDatabase(event) { } // Confirm with user - const confirmMsg = `Are you sure you want to populate the database with ${mediaType}? This will query Radarr/Sonarr and may take several minutes.`; + const target = instance !== 'all' ? `instance '${instance}'` : `${mediaType}`; + const confirmMsg = `Are you sure you want to populate the database with ${target}? This will query Radarr/Sonarr and may take several minutes.`; if (!confirm(confirmMsg)) { return; } @@ -1890,7 +1971,7 @@ async function handlePopulateDatabase(event) { // Start the population showToast('🚀 Starting database population...', 'info'); - const response = await fetch(`/admin/populate-database?media_type=${mediaType}`, { + const response = await fetch(`/admin/populate-database?media_type=${mediaType}&instance=${encodeURIComponent(instance)}`, { method: 'POST', credentials: 'include' }); diff --git a/clients/radarr_client.py b/clients/radarr_client.py index 71becaf..c83c13f 100644 --- a/clients/radarr_client.py +++ b/clients/radarr_client.py @@ -10,7 +10,7 @@ from urllib.request import Request as UrlRequest, urlopen from urllib.error import URLError, HTTPError -from core.logging import _log +from core.logging import _log as _log_raw # Import path mapper for proper path handling try: @@ -55,11 +55,12 @@ class RadarrClient: 'usenet', 'torrent', 'radarr', 'completed', 'processing' ] - def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int = 3): + def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int = 3, instance_name: str = ""): self.base_url = base_url.rstrip("/") self.api_key = api_key self.timeout = timeout self.retries = max(0, retries) + self.instance_name = instance_name # Initialize database client - REQUIRED for operation self.db_client = None @@ -67,14 +68,22 @@ def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int try: self.db_client = RadarrDbClient.from_env() if self.db_client: - _log("INFO", "✅ DATABASE ONLY MODE: Direct database access enabled") + self._log("INFO", "✅ DATABASE ONLY MODE: Direct database access enabled") else: - _log("ERROR", "❌ DATABASE ONLY MODE: Database configuration required - API mode disabled") + self._log("ERROR", "❌ DATABASE ONLY MODE: Database configuration required - API mode disabled") except Exception as e: - _log("ERROR", f"❌ DATABASE ONLY MODE: Failed to initialize database client: {e}") + self._log("ERROR", f"❌ DATABASE ONLY MODE: Failed to initialize database client: {e}") self.db_client = None else: - _log("ERROR", "❌ DATABASE ONLY MODE: RadarrDbClient not available - check dependencies") + self._log("ERROR", "❌ DATABASE ONLY MODE: RadarrDbClient not available - check dependencies") + + def _log(self, level: str, message: str) -> None: + """Log with this client's instance name prefixed, so a log line is + traceable back to which Radarr/Sonarr instance it came from — matters + as soon as there's more than one of the same type configured. + """ + prefix = f"[{self.instance_name}] " if self.instance_name else "" + _log_raw(level, f"{prefix}{message}") def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]: """Make GET request to Radarr API with retries""" @@ -93,7 +102,7 @@ def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]: if params: url = url + ("&" if "?" in url else "?") + urlencode(params) - _log("DEBUG", f"Radarr API Request: {url}") + self._log("DEBUG", f"Radarr API Request: {url}") req = UrlRequest(url, headers={"Accept": "application/json"}) with urlopen(req, timeout=self.timeout) as resp: @@ -103,34 +112,34 @@ def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]: except (URLError, HTTPError, json.JSONDecodeError) as e: last_err = e - _log("DEBUG", f"Radarr API attempt {attempt + 1} failed: {e}") + self._log("DEBUG", f"Radarr API attempt {attempt + 1} failed: {e}") time.sleep(min(2 ** attempt, 5)) # Exponential backoff attempt += 1 - _log("WARNING", f"Radarr GET {path} failed after {self.retries + 1} attempts: {last_err}") + self._log("WARNING", f"Radarr GET {path} failed after {self.retries + 1} attempts: {last_err}") return None def movie_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]: """Find movie by IMDb ID - DATABASE ONLY mode""" imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}" - _log("DEBUG", f"Looking up movie by IMDb ID: {imdb_id}") + self._log("DEBUG", f"Looking up movie by IMDb ID: {imdb_id}") # Database required - no API fallback if self.db_client: try: movie = self.db_client.get_movie_by_imdb(imdb_id) if movie: - _log("INFO", f"✅ Found via database: {movie.get('title')} (ID: {movie.get('id')})") + self._log("INFO", f"✅ Found via database: {movie.get('title')} (ID: {movie.get('id')})") return movie else: - _log("WARNING", f"Movie not found in database for IMDb ID: {imdb_id}") + self._log("WARNING", f"Movie not found in database for IMDb ID: {imdb_id}") return None except Exception as e: - _log("ERROR", f"Database lookup failed: {e}") + self._log("ERROR", f"Database lookup failed: {e}") return None # No database client available - _log("ERROR", "Database client required for movie lookup - API mode disabled") + self._log("ERROR", "Database client required for movie lookup - API mode disabled") return None def _analyze_event_for_import(self, event: Dict[str, Any], movie_info: Dict[str, Any] = None) -> Tuple[bool, str, Optional[str]]: @@ -202,15 +211,15 @@ def _analyze_event_for_import(self, event: Dict[str, Any], movie_info: Dict[str, # Check if both title and year are in the source if movie_title and movie_year: if movie_title in source_clean and movie_year in source_clean: - _log("DEBUG", f"✅ Match found - Title: {movie_title}, Year: {movie_year}") + self._log("DEBUG", f"✅ Match found - Title: {movie_title}, Year: {movie_year}") return True, "matched_title_and_year", date_iso # Also check for downloads path as secondary validation if path_mapper.is_download_path(source): - _log("DEBUG", f"Source is from downloads: {source}") + self._log("DEBUG", f"Source is from downloads: {source}") return True, "from_downloads_path", date_iso - _log("DEBUG", f"⚠️ No match found in sources: {source_items}") + self._log("DEBUG", f"⚠️ No match found in sources: {source_items}") return False, "no_title_year_match", date_iso # Fallback to basic path validation if no movie info @@ -225,12 +234,12 @@ def earliest_import_event_optimized(self, movie_id: int) -> Optional[str]: Find earliest real import event with optimized querying. Stops as soon as we find valid import events instead of loading everything. """ - _log("INFO", f"Finding earliest import for movie_id {movie_id}") + self._log("INFO", f"Finding earliest import for movie_id {movie_id}") # Get movie info for path validation movie_info = self._get(f"/api/v3/movie/{movie_id}") if not movie_info or not isinstance(movie_info, dict): - _log("ERROR", f"Could not get movie info for ID {movie_id}") + self._log("ERROR", f"Could not get movie info for ID {movie_id}") return None earliest_real_import = None @@ -256,7 +265,7 @@ def earliest_import_event_optimized(self, movie_id: int) -> Optional[str]: if not items: break - _log("DEBUG", f"Page {page}: Processing {len(items)} events") + self._log("DEBUG", f"Page {page}: Processing {len(items)} events") for event in items: total_processed += 1 @@ -280,7 +289,7 @@ def earliest_import_event_optimized(self, movie_id: int) -> Optional[str]: else: event_type = int(event_type) except (ValueError, TypeError): - _log("DEBUG", f"Unknown event type: {event_type}") + self._log("DEBUG", f"Unknown event type: {event_type}") continue # Check for grab events (type 1) - but validate it's a real download @@ -302,9 +311,9 @@ def earliest_import_event_optimized(self, movie_id: int) -> Optional[str]: # Only count grabs that have actual download metadata if source_title or indexer: first_grab = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds") - _log("DEBUG", f"Found real grab event with source '{source_title}' from '{indexer}' at {first_grab}") + self._log("DEBUG", f"Found real grab event with source '{source_title}' from '{indexer}' at {first_grab}") else: - _log("DEBUG", f"Skipping grab event without download info at {event.get('date')}") + self._log("DEBUG", f"Skipping grab event without download info at {event.get('date')}") except Exception: pass @@ -321,7 +330,7 @@ def earliest_import_event_optimized(self, movie_id: int) -> Optional[str]: try: event_data = json.loads(event_data) except (json.JSONDecodeError, AttributeError) as e: - _log("DEBUG", f"Failed to parse event data JSON: {e}") + self._log("DEBUG", f"Failed to parse event data JSON: {e}") continue elif not isinstance(event_data, dict): continue @@ -343,9 +352,9 @@ def earliest_import_event_optimized(self, movie_id: int) -> Optional[str]: f"[{movie_imdb}]" in imported_path or movie_imdb in imported_path ): - _log("INFO", f"Found potential IMDb match in {event_type} event: {imported_path}") + self._log("INFO", f"Found potential IMDb match in {event_type} event: {imported_path}") date_iso = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds") - _log("INFO", f"✅ FOUND IMPORT: exact IMDb match at {date_iso}") + self._log("INFO", f"✅ FOUND IMPORT: exact IMDb match at {date_iso}") earliest_real_import = date_iso break @@ -358,8 +367,8 @@ def earliest_import_event_optimized(self, movie_id: int) -> Optional[str]: # Look for both title and year in the path if clean_title in clean_path and movie_year in clean_path: date_iso = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds") - _log("INFO", f"Found potential title/year match for event type {event_type}: {clean_title} ({movie_year})") - _log("INFO", f"✅ FOUND IMPORT at {date_iso}") + self._log("INFO", f"Found potential title/year match for event type {event_type}: {clean_title} ({movie_year})") + self._log("INFO", f"✅ FOUND IMPORT at {date_iso}") earliest_real_import = date_iso break @@ -380,7 +389,7 @@ def earliest_import_event_optimized(self, movie_id: int) -> Optional[str]: f"[{movie_imdb}]" in source_path or movie_imdb in source_path ): - _log("INFO", f"✅ FOUND IMPORT: IMDb match in path at {date_iso}") + self._log("INFO", f"✅ FOUND IMPORT: IMDb match in path at {date_iso}") earliest_real_import = date_iso break @@ -388,11 +397,11 @@ def earliest_import_event_optimized(self, movie_id: int) -> Optional[str]: movie_title = (movie_info.get("title", "") or "").lower().replace(":", ".").replace(" ", ".") movie_year = str(movie_info.get("year", "")) if movie_title and movie_year and movie_title in source_path and movie_year in source_path: - _log("INFO", f"✅ FOUND IMPORT: Title/year match at {date_iso}") + self._log("INFO", f"✅ FOUND IMPORT: Title/year match at {date_iso}") earliest_real_import = date_iso break elif event_type == 3: - _log("DEBUG", f"⚠️ Skipped import event: {reason}") + self._log("DEBUG", f"⚠️ Skipped import event: {reason}") # If we found a real import, no need to continue if earliest_real_import: @@ -404,27 +413,27 @@ def earliest_import_event_optimized(self, movie_id: int) -> Optional[str]: page += 1 - _log("INFO", f"Processed {total_processed} events across {page-1} pages") + self._log("INFO", f"Processed {total_processed} events across {page-1} pages") if earliest_real_import: - _log("INFO", f"✅ Using earliest real import: {earliest_real_import}") + self._log("INFO", f"✅ Using earliest real import: {earliest_real_import}") return earliest_real_import if first_grab: - _log("WARNING", f"⚠️ No real imports found, using grab date: {first_grab}") + self._log("WARNING", f"⚠️ No real imports found, using grab date: {first_grab}") return first_grab - _log("ERROR", f"❌ No import or grab events found for movie_id {movie_id}") + self._log("ERROR", f"❌ No import or grab events found for movie_id {movie_id}") return None def movie_files(self, movie_id: int) -> List[Dict[str, Any]]: """Get movie files for a movie - DATABASE ONLY mode""" if self.db_client: - _log("INFO", "Using database for movie files lookup") + self._log("INFO", "Using database for movie files lookup") # Database handles this internally in get_movie_file_date() return [] - _log("ERROR", "Database client required for movie files - API mode disabled") + self._log("ERROR", "Database client required for movie files - API mode disabled") return [] def earliest_file_dateadded(self, movie_id: int) -> Optional[str]: @@ -433,10 +442,10 @@ def earliest_file_dateadded(self, movie_id: int) -> Optional[str]: try: return self.db_client.get_movie_file_date(movie_id) except Exception as e: - _log("ERROR", f"Database file date query failed: {e}") + self._log("ERROR", f"Database file date query failed: {e}") return None - _log("ERROR", "Database client required for file dates - API mode disabled") + self._log("ERROR", "Database client required for file dates - API mode disabled") return None def get_movie_import_date(self, movie_id: int, fallback_to_file_date: bool = True) -> Tuple[Optional[str], str]: @@ -453,19 +462,19 @@ def get_movie_import_date(self, movie_id: int, fallback_to_file_date: bool = Tru if date_iso: return date_iso, source else: - _log("WARNING", f"No import date found in database for movie_id {movie_id}") + self._log("WARNING", f"No import date found in database for movie_id {movie_id}") return None, "radarr:db.no_date_found" except Exception as e: - _log("ERROR", f"Database import date query failed: {e}") + self._log("ERROR", f"Database import date query failed: {e}") return None, "radarr:db.error" # No database client available - _log("ERROR", "Database client required for import date detection - API mode disabled") + self._log("ERROR", "Database client required for import date detection - API mode disabled") return None, "radarr:db.not_configured" def _get_earliest_import_date(self, movie_id: int, movie_info: Dict) -> Optional[str]: """Get the earliest import date from Radarr history.""" - _log("INFO", f"Finding earliest import for movie_id {movie_id}") + self._log("INFO", f"Finding earliest import for movie_id {movie_id}") earliest_real_import = None earliest_grab_date = None @@ -492,7 +501,7 @@ def _get_earliest_import_date(self, movie_id: int, movie_info: Dict) -> Optional # Track earliest grab date as fallback (EventType 1) if event_type == 1 and not earliest_grab_date: earliest_grab_date = event_date - _log("DEBUG", f"Found first grab event at {earliest_grab_date}") + self._log("DEBUG", f"Found first grab event at {earliest_grab_date}") continue # Look for import events (EventType 3) @@ -500,7 +509,7 @@ def _get_earliest_import_date(self, movie_id: int, movie_info: Dict) -> Optional try: data = json.loads(event.get("data", "{}")) if data.get("importedPath"): - _log("INFO", f"✅ FOUND IMPORT at {event_date}") + self._log("INFO", f"✅ FOUND IMPORT at {event_date}") earliest_real_import = event_date break except (json.JSONDecodeError, AttributeError): @@ -513,12 +522,12 @@ def _get_earliest_import_date(self, movie_id: int, movie_info: Dict) -> Optional total_events += len(history_data) page += 1 - _log("INFO", f"Processed {total_events} events across {page}") + self._log("INFO", f"Processed {total_events} events across {page}") if earliest_real_import: return earliest_real_import if earliest_grab_date: - _log("WARNING", f"⚠️ No EventType 3 (import) found, using grab date: {earliest_grab_date}") + self._log("WARNING", f"⚠️ No EventType 3 (import) found, using grab date: {earliest_grab_date}") return earliest_grab_date return None diff --git a/clients/radarr_db_client.py b/clients/radarr_db_client.py index b574cde..1f298fe 100644 --- a/clients/radarr_db_client.py +++ b/clients/radarr_db_client.py @@ -13,7 +13,7 @@ from typing import Dict, Any, List, Optional, Tuple, Union from urllib.parse import urlparse -from core.logging import _log +from core.logging import _log as _log_raw class RadarrDbClient: @@ -26,7 +26,8 @@ def __init__(self, db_port: Optional[int] = None, db_name: Optional[str] = None, db_user: Optional[str] = None, - db_password: Optional[str] = None): + db_password: Optional[str] = None, + instance_name: str = ""): """ Initialize Radarr database client @@ -38,6 +39,8 @@ def __init__(self, db_name: PostgreSQL database name db_user: PostgreSQL username db_password: PostgreSQL password + instance_name: label prefixed onto every log line from this client + (e.g. "radarr_4k") — leave blank for the unlabeled default instance """ self.db_type = db_type.lower() self.db_path = db_path @@ -46,7 +49,8 @@ def __init__(self, self.db_name = db_name self.db_user = db_user self.db_password = db_password - + self.instance_name = instance_name + self._test_connection() @classmethod @@ -60,10 +64,10 @@ def from_env(cls) -> Optional['RadarrDbClient']: if db_type == "sqlite": db_path = os.environ.get("RADARR_DB_PATH") if not db_path or not Path(db_path).exists(): - _log("WARNING", f"RADARR_DB_PATH not found or invalid: {db_path}") + _log_raw("WARNING", f"RADARR_DB_PATH not found or invalid: {db_path}") return None - return cls(db_type="sqlite", db_path=db_path) - + return cls(db_type="sqlite", db_path=db_path, instance_name="radarr") + elif db_type == "postgresql": # Support both individual vars and connection string db_url = os.environ.get("RADARR_DB_URL") @@ -75,7 +79,8 @@ def from_env(cls) -> Optional['RadarrDbClient']: db_port=parsed.port or 5432, db_name=parsed.path.lstrip('/'), db_user=parsed.username, - db_password=parsed.password + db_password=parsed.password, + instance_name="radarr", ) else: return cls( @@ -84,23 +89,32 @@ def from_env(cls) -> Optional['RadarrDbClient']: db_port=int(os.environ.get("RADARR_DB_PORT", "5432")), db_name=os.environ.get("RADARR_DB_NAME"), db_user=os.environ.get("RADARR_DB_USER"), - db_password=os.environ.get("RADARR_DB_PASSWORD") + db_password=os.environ.get("RADARR_DB_PASSWORD"), + instance_name="radarr", ) else: - _log("ERROR", f"Unsupported database type: {db_type}") + _log_raw("ERROR", f"Unsupported database type: {db_type}") return None + def _log(self, level: str, message: str) -> None: + """Log with this client's instance name prefixed, so a log line is + traceable back to which Radarr/Sonarr instance it came from — matters + as soon as there's more than one of the same type configured. + """ + prefix = f"[{self.instance_name}] " if self.instance_name else "" + _log_raw(level, f"{prefix}{message}") + def _test_connection(self) -> None: """Test database connection on initialization""" try: conn = self._get_connection() if conn: conn.close() - _log("INFO", f"Connected to Radarr {self.db_type} database successfully") + self._log("INFO", f"Connected to Radarr {self.db_type} database successfully") else: raise Exception("Failed to create connection") except Exception as e: - _log("ERROR", f"Failed to connect to Radarr database: {e}") + self._log("ERROR", f"Failed to connect to Radarr database: {e}") raise def _get_connection(self) -> Union[sqlite3.Connection, psycopg2.extensions.connection]: @@ -169,7 +183,7 @@ def get_movie_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]: return dict(row) if self.db_type == "sqlite" else row except Exception as e: - _log("ERROR", f"Database query error for IMDb {imdb_id}: {e}") + self._log("ERROR", f"Database query error for IMDb {imdb_id}: {e}") return None @@ -226,7 +240,7 @@ def get_all_movies(self) -> List[Dict[str, Any]]: return [] except Exception as e: - _log("ERROR", f"Database query error getting all movies: {e}") + self._log("ERROR", f"Database query error getting all movies: {e}") return [] def get_earliest_import_date(self, movie_id: int) -> Tuple[Optional[str], str]: @@ -241,7 +255,7 @@ def get_earliest_import_date(self, movie_id: int) -> Tuple[Optional[str], str]: """ # If first event is rename, all subsequent imports are upgrades - skip them if self.is_first_event_rename_based(movie_id): - _log("INFO", f"Movie {movie_id} has rename-first history - all imports are upgrades, skipping") + self._log("INFO", f"Movie {movie_id} has rename-first history - all imports are upgrades, skipping") return None, "radarr:db.upgrade_imports_skipped" # Query for earliest import event - PostgreSQL uses INTEGER EventType (3 = import) @@ -295,7 +309,7 @@ def get_earliest_import_date(self, movie_id: int) -> Tuple[Optional[str], str]: dt = event_date.replace(tzinfo=timezone.utc) date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds") - _log("INFO", f"✅ Found import event ({event_type}) for movie {movie_id} at {date_iso}") + self._log("INFO", f"✅ Found import event ({event_type}) for movie {movie_id} at {date_iso}") return date_iso, "radarr:db.history.import" # Fallback to grab events @@ -311,11 +325,11 @@ def get_earliest_import_date(self, movie_id: int) -> Tuple[Optional[str], str]: dt = event_date.replace(tzinfo=timezone.utc) date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds") - _log("WARNING", f"⚠️ Using grab event ({event_type}) for movie {movie_id} at {date_iso}") + self._log("WARNING", f"⚠️ Using grab event ({event_type}) for movie {movie_id} at {date_iso}") return date_iso, "radarr:db.history.grab" except Exception as e: - _log("ERROR", f"Database query error for movie {movie_id}: {e}") + self._log("ERROR", f"Database query error for movie {movie_id}: {e}") return None, "radarr:db.no_date_found" @@ -351,27 +365,27 @@ def is_first_event_rename_based(self, movie_id: int) -> bool: rows = cursor.fetchall() if rows: - _log("INFO", f"Movie {movie_id} history debug - first 5 events:") + self._log("INFO", f"Movie {movie_id} history debug - first 5 events:") for i, row in enumerate(rows): event_type = row['event_type'] if self.db_type == "postgresql" else row[0] event_date = row['event_date'] if self.db_type == "postgresql" else row[1] - _log("INFO", f" Event {i+1}: Type={event_type}, Date={event_date}") + self._log("INFO", f" Event {i+1}: Type={event_type}, Date={event_date}") first_event_type = rows[0]['event_type'] if self.db_type == "postgresql" else rows[0][0] # EventType 8 = movieFileRenamed # Also check for EventType 7 = movieFileRenamed in some Radarr versions is_rename_first = first_event_type in [7, 8] - _log("INFO", f"Movie {movie_id}: First event type={first_event_type}, is_rename_first={is_rename_first}") + self._log("INFO", f"Movie {movie_id}: First event type={first_event_type}, is_rename_first={is_rename_first}") if is_rename_first: - _log("INFO", f"🎯 Movie {movie_id} detected as rename-first scenario - will prefer release dates over import dates") + self._log("INFO", f"🎯 Movie {movie_id} detected as rename-first scenario - will prefer release dates over import dates") return is_rename_first else: - _log("WARNING", f"Movie {movie_id}: No history events found - this could indicate missing data") + self._log("WARNING", f"Movie {movie_id}: No history events found - this could indicate missing data") except Exception as e: - _log("ERROR", f"Error checking first event type for movie {movie_id}: {e}") + self._log("ERROR", f"Error checking first event type for movie {movie_id}: {e}") return False @@ -415,7 +429,7 @@ def get_movie_file_date(self, movie_id: int) -> Optional[str]: return dt.astimezone(timezone.utc).isoformat(timespec="seconds") except Exception as e: - _log("ERROR", f"Database query error for movie file date {movie_id}: {e}") + self._log("ERROR", f"Database query error for movie file date {movie_id}: {e}") return None @@ -437,14 +451,14 @@ def get_movie_import_date_optimized(self, movie_id: int, fallback_to_file_date: # Check if we skipped upgrades and should prefer release dates if source == "radarr:db.upgrade_imports_skipped": - _log("INFO", f"Movie {movie_id} upgrade scenario detected - signaling to prefer release dates") + self._log("INFO", f"Movie {movie_id} upgrade scenario detected - signaling to prefer release dates") return None, "radarr:db.prefer_release_dates" # Fallback to file date if requested if fallback_to_file_date: file_date = self.get_movie_file_date(movie_id) if file_date: - _log("WARNING", f"Using file dateAdded as fallback for movie_id {movie_id}") + self._log("WARNING", f"Using file dateAdded as fallback for movie_id {movie_id}") return file_date, "radarr:db.file.dateAdded" return None, "radarr:db.no_date_found" @@ -517,7 +531,7 @@ def bulk_import_dates(self, imdb_ids: List[str]) -> Dict[str, Tuple[Optional[str results[imdb_id] = (None, "radarr:db.bulk.no_import") except Exception as e: - _log("ERROR", f"Bulk query error: {e}") + self._log("ERROR", f"Bulk query error: {e}") # Return empty results for failed queries for imdb_id in clean_imdb_ids: if imdb_id not in results: @@ -547,7 +561,7 @@ def get_database_stats(self) -> Dict[str, Any]: stats[stat_name] = result[0] if result else 0 except Exception as e: - _log("ERROR", f"Stats query error: {e}") + self._log("ERROR", f"Stats query error: {e}") stats["error"] = str(e) return stats @@ -669,7 +683,7 @@ def health_check(self) -> Dict[str, Any]: health["status"] = "error" health["connection"] = "failed" health["issues"].append(f"Connection failed: {e}") - _log("ERROR", f"Database health check failed: {e}") + self._log("ERROR", f"Database health check failed: {e}") # Overall status determination if health["issues"]: diff --git a/clients/sonarr_client.py b/clients/sonarr_client.py index b149eed..03abe71 100644 --- a/clients/sonarr_client.py +++ b/clients/sonarr_client.py @@ -10,18 +10,27 @@ from urllib.request import Request as UrlRequest, urlopen from urllib.error import URLError, HTTPError -from core.logging import _log +from core.logging import _log as _log_raw class SonarrClient: """Enhanced Sonarr API client for TV series and episode management""" - def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int = 3): + def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int = 3, instance_name: str = ""): self.base_url = base_url.rstrip("/") self.api_key = api_key self.timeout = timeout self.retries = max(0, retries) self.enabled = bool(self.base_url and self.api_key) + self.instance_name = instance_name + + def _log(self, level: str, message: str) -> None: + """Log with this client's instance name prefixed, so a log line is + traceable back to which Radarr/Sonarr instance it came from — matters + as soon as there's more than one of the same type configured. + """ + prefix = f"[{self.instance_name}] " if self.instance_name else "" + _log_raw(level, f"{prefix}{message}") def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]: """Make GET request to Sonarr API with retries""" @@ -36,7 +45,7 @@ def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]: for attempt in range(self.retries): try: - _log("DEBUG", f"Sonarr API Request: {url}") + self._log("DEBUG", f"Sonarr API Request: {url}") req = UrlRequest(url, headers=headers) with urlopen(req, timeout=self.timeout) as resp: @@ -46,71 +55,71 @@ def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]: except HTTPError as e: if e.code == 401: - _log("ERROR", "Sonarr authentication failed - check API key") + self._log("ERROR", "Sonarr authentication failed - check API key") return None elif e.code == 429: wait_time = (attempt + 1) * 2 - _log("WARNING", f"Sonarr rate limited, waiting {wait_time}s (attempt {attempt+1}/{self.retries})") + self._log("WARNING", f"Sonarr rate limited, waiting {wait_time}s (attempt {attempt+1}/{self.retries})") time.sleep(wait_time) else: - _log("WARNING", f"Sonarr HTTP {e.code} error on attempt {attempt+1}/{self.retries}: {e.reason}") + self._log("WARNING", f"Sonarr HTTP {e.code} error on attempt {attempt+1}/{self.retries}: {e.reason}") except Exception as e: - _log("WARNING", f"Sonarr API attempt {attempt+1}/{self.retries} failed: {e}") + self._log("WARNING", f"Sonarr API attempt {attempt+1}/{self.retries} failed: {e}") if attempt < self.retries - 1: time.sleep(0.5 * (attempt + 1)) - _log("ERROR", f"Sonarr API failed after {self.retries} attempts: {url}") + self._log("ERROR", f"Sonarr API failed after {self.retries} attempts: {url}") return None def series_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]: """Find series by IMDb ID using lookup endpoint""" search_term = f"imdbid:{imdb_id}" - _log("DEBUG", f"Searching Sonarr with term: {search_term}") + self._log("DEBUG", f"Searching Sonarr with term: {search_term}") result = self._get("/series/lookup", {"term": search_term}) if not result: - _log("WARNING", f"No results from Sonarr lookup for: {search_term}") + self._log("WARNING", f"No results from Sonarr lookup for: {search_term}") return None - _log("DEBUG", f"Sonarr lookup returned {len(result)} results") + self._log("DEBUG", f"Sonarr lookup returned {len(result)} results") # Log all results for debugging for i, series in enumerate(result): series_imdb = series.get("imdbId", "") series_title = series.get("title", "") series_id = series.get("id", "") - _log("DEBUG", f"Result {i+1}: Title='{series_title}', IMDb='{series_imdb}', ID={series_id}") + self._log("DEBUG", f"Result {i+1}: Title='{series_title}', IMDb='{series_imdb}', ID={series_id}") # Find exact IMDb match (case insensitive) target_imdb = imdb_id.lower() for series in result: series_imdb = (series.get("imdbId") or "").lower() if series_imdb == target_imdb: - _log("INFO", f"Found exact IMDb match: {series.get('title')} (ID: {series.get('id')})") + self._log("INFO", f"Found exact IMDb match: {series.get('title')} (ID: {series.get('id')})") return series # Try partial match as fallback for series in result: series_imdb = (series.get("imdbId") or "").lower() if target_imdb in series_imdb or series_imdb in target_imdb: - _log("WARNING", f"Found partial IMDb match: {series.get('title')} (Expected: {imdb_id}, Found: {series.get('imdbId')})") + self._log("WARNING", f"Found partial IMDb match: {series.get('title')} (Expected: {imdb_id}, Found: {series.get('imdbId')})") return series - _log("WARNING", f"No IMDb match found in {len(result)} results for {imdb_id}") + self._log("WARNING", f"No IMDb match found in {len(result)} results for {imdb_id}") return None def series_by_title(self, title: str) -> Optional[Dict[str, Any]]: """Search for series by title as fallback when IMDb lookup fails""" - _log("DEBUG", f"Searching Sonarr by title: {title}") + self._log("DEBUG", f"Searching Sonarr by title: {title}") result = self._get("/series/lookup", {"term": title}) if not result: - _log("WARNING", f"No results from Sonarr title search for: {title}") + self._log("WARNING", f"No results from Sonarr title search for: {title}") return None - _log("DEBUG", f"Sonarr title search returned {len(result)} results") + self._log("DEBUG", f"Sonarr title search returned {len(result)} results") title_lower = title.lower() @@ -118,17 +127,17 @@ def series_by_title(self, title: str) -> Optional[Dict[str, Any]]: for series in result: series_title = (series.get("title") or "").lower() if series_title == title_lower: - _log("INFO", f"Found exact title match: {series.get('title')} (ID: {series.get('id')})") + self._log("INFO", f"Found exact title match: {series.get('title')} (ID: {series.get('id')})") return series # Look for partial title match for series in result: series_title = (series.get("title") or "").lower() if title_lower in series_title or series_title in title_lower: - _log("INFO", f"Found partial title match: '{series.get('title')}' for search '{title}' (ID: {series.get('id')})") + self._log("INFO", f"Found partial title match: '{series.get('title')}' for search '{title}' (ID: {series.get('id')})") return series - _log("WARNING", f"No title match found for: {title}") + self._log("WARNING", f"No title match found for: {title}") return None def get_all_series(self) -> List[Dict[str, Any]]: @@ -137,17 +146,17 @@ def get_all_series(self) -> List[Dict[str, Any]]: def series_by_imdb_direct(self, imdb_id: str) -> Optional[Dict[str, Any]]: """Find series by scanning all series for IMDb match (slower but more reliable)""" - _log("DEBUG", f"Direct series lookup for IMDb: {imdb_id}") + self._log("DEBUG", f"Direct series lookup for IMDb: {imdb_id}") all_series = self.get_all_series() target_imdb = imdb_id.lower() for series in all_series: series_imdb = (series.get("imdbId") or "").lower() if series_imdb == target_imdb: - _log("INFO", f"Found series via direct lookup: {series.get('title')} (ID: {series.get('id')})") + self._log("INFO", f"Found series via direct lookup: {series.get('title')} (ID: {series.get('id')})") return series - _log("WARNING", f"No series found with IMDb ID via direct lookup: {imdb_id}") + self._log("WARNING", f"No series found with IMDb ID via direct lookup: {imdb_id}") return None def get_series_by_id(self, series_id: int) -> Optional[Dict[str, Any]]: @@ -197,7 +206,7 @@ def get_episode_import_history(self, episode_id: int) -> Optional[str]: if page > 10: # Safety valve break - _log("DEBUG", f"Got {len(all_records)} history records for episode {episode_id}") + self._log("DEBUG", f"Got {len(all_records)} history records for episode {episode_id}") # Categorize events import_events = [] @@ -211,7 +220,7 @@ def get_episode_import_history(self, episode_id: int) -> Optional[str]: if not date: continue - _log("DEBUG", f"History event: {event_type} at {date}") + self._log("DEBUG", f"History event: {event_type} at {date}") if event_type == "downloadfolderimported": import_events.append({"date": date, "event": event}) @@ -224,7 +233,7 @@ def get_episode_import_history(self, episode_id: int) -> Optional[str]: if import_events: earliest_import = min(import_events, key=lambda x: x["date"]) import_date = earliest_import["date"] - _log("INFO", f"Found import date: {import_date} for episode {episode_id}") + self._log("INFO", f"Found import date: {import_date} for episode {episode_id}") # Check chronological order of events if rename_events: @@ -237,26 +246,26 @@ def get_episode_import_history(self, episode_id: int) -> Optional[str]: # If import happened BEFORE rename, it's valid original import if import_dt <= rename_dt: - _log("INFO", f"Import {import_date} happened before/during rename {rename_date} - using import date") + self._log("INFO", f"Import {import_date} happened before/during rename {rename_date} - using import date") return import_date # If rename happened BEFORE import - always use aired date fallback else: - _log("WARNING", f"Rename {rename_date} happened before import {import_date} - using aired date fallback") + self._log("WARNING", f"Rename {rename_date} happened before import {import_date} - using aired date fallback") return None # Trigger aired date fallback except Exception as e: - _log("DEBUG", f"Error comparing dates: {e}") + self._log("DEBUG", f"Error comparing dates: {e}") return import_date # Fallback to grab event if grabbed_events: earliest_grab = min(grabbed_events, key=lambda x: x["date"]) - _log("WARNING", f"No import events, using grab date: {earliest_grab['date']} for episode {episode_id}") + self._log("WARNING", f"No import events, using grab date: {earliest_grab['date']} for episode {episode_id}") return earliest_grab["date"] - _log("WARNING", f"No reliable import events found for episode {episode_id} - should use air date instead") + self._log("WARNING", f"No reliable import events found for episode {episode_id} - should use air date instead") return None diff --git a/clients/sonarr_db_client.py b/clients/sonarr_db_client.py index 26187e1..8cc54af 100644 --- a/clients/sonarr_db_client.py +++ b/clients/sonarr_db_client.py @@ -13,7 +13,7 @@ from typing import Dict, Any, List, Optional, Tuple, Union from urllib.parse import urlparse -from core.logging import _log +from core.logging import _log as _log_raw class SonarrDbClient: @@ -26,7 +26,8 @@ def __init__(self, db_port: Optional[int] = None, db_name: Optional[str] = None, db_user: Optional[str] = None, - db_password: Optional[str] = None): + db_password: Optional[str] = None, + instance_name: str = ""): """ Initialize Sonarr database client @@ -38,6 +39,8 @@ def __init__(self, db_name: PostgreSQL database name db_user: PostgreSQL username db_password: PostgreSQL password + instance_name: label prefixed onto every log line from this client + (e.g. "sonarr_strm") — leave blank for the unlabeled default instance """ self.db_type = db_type.lower() self.db_path = db_path @@ -46,6 +49,7 @@ def __init__(self, self.db_name = db_name self.db_user = db_user self.db_password = db_password + self.instance_name = instance_name self._test_connection() @@ -60,9 +64,9 @@ def from_env(cls) -> Optional['SonarrDbClient']: if db_type == "sqlite": db_path = os.environ.get("SONARR_DB_PATH") if not db_path or not Path(db_path).exists(): - _log("WARNING", f"SONARR_DB_PATH not found or invalid: {db_path}") + _log_raw("WARNING", f"SONARR_DB_PATH not found or invalid: {db_path}") return None - return cls(db_type="sqlite", db_path=db_path) + return cls(db_type="sqlite", db_path=db_path, instance_name="sonarr") elif db_type == "postgresql": # Support both individual vars and connection string @@ -75,7 +79,8 @@ def from_env(cls) -> Optional['SonarrDbClient']: db_port=parsed.port or 5432, db_name=parsed.path.lstrip('/'), db_user=parsed.username, - db_password=parsed.password + db_password=parsed.password, + instance_name="sonarr", ) else: return cls( @@ -84,23 +89,32 @@ def from_env(cls) -> Optional['SonarrDbClient']: db_port=int(os.environ.get("SONARR_DB_PORT", "5432")), db_name=os.environ.get("SONARR_DB_NAME"), db_user=os.environ.get("SONARR_DB_USER"), - db_password=os.environ.get("SONARR_DB_PASSWORD") + db_password=os.environ.get("SONARR_DB_PASSWORD"), + instance_name="sonarr", ) else: - _log("ERROR", f"Unsupported database type: {db_type}") + _log_raw("ERROR", f"Unsupported database type: {db_type}") return None + def _log(self, level: str, message: str) -> None: + """Log with this client's instance name prefixed, so a log line is + traceable back to which Radarr/Sonarr instance it came from — matters + as soon as there's more than one of the same type configured. + """ + prefix = f"[{self.instance_name}] " if self.instance_name else "" + _log_raw(level, f"{prefix}{message}") + def _test_connection(self) -> None: """Test database connection on initialization""" try: conn = self._get_connection() if conn: conn.close() - _log("INFO", f"Connected to Sonarr {self.db_type} database successfully") + self._log("INFO", f"Connected to Sonarr {self.db_type} database successfully") else: raise Exception("Failed to create connection") except Exception as e: - _log("ERROR", f"Failed to connect to Sonarr database: {e}") + self._log("ERROR", f"Failed to connect to Sonarr database: {e}") raise def _get_connection(self) -> Union[sqlite3.Connection, psycopg2.extensions.connection]: @@ -166,7 +180,7 @@ def get_series_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]: return dict(row) if self.db_type == "sqlite" else row except Exception as e: - _log("ERROR", f"Database query error for IMDb {imdb_id}: {e}") + self._log("ERROR", f"Database query error for IMDb {imdb_id}: {e}") return None @@ -205,7 +219,7 @@ def get_all_series(self) -> List[Dict[str, Any]]: return rows except Exception as e: - _log("ERROR", f"Database query error getting all series: {e}") + self._log("ERROR", f"Database query error getting all series: {e}") return [] def get_all_episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]: @@ -253,7 +267,7 @@ def get_all_episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]: return rows except Exception as e: - _log("ERROR", f"Database query error for series {series_id}: {e}") + self._log("ERROR", f"Database query error for series {series_id}: {e}") return [] def get_episode_import_date(self, episode_id: int) -> Tuple[Optional[str], str]: @@ -303,7 +317,7 @@ def get_episode_import_date(self, episode_id: int) -> Tuple[Optional[str], str]: return date_iso, "sonarr:db.history.import" except Exception as e: - _log("ERROR", f"Database query error for episode {episode_id}: {e}") + self._log("ERROR", f"Database query error for episode {episode_id}: {e}") return None, "sonarr:db.no_import_found" @@ -368,7 +382,7 @@ def get_episode_file_date(self, series_id: int, season: int, episode: int) -> Op return dt.astimezone(timezone.utc).isoformat(timespec="seconds") except Exception as e: - _log("ERROR", f"Database query error for episode file: {e}") + self._log("ERROR", f"Database query error for episode file: {e}") return None @@ -428,7 +442,7 @@ def bulk_import_dates_for_series(self, series_id: int) -> Dict[Tuple[int, int], results[(season, episode)] = (None, "sonarr:db.bulk.no_import") except Exception as e: - _log("ERROR", f"Bulk query error for series {series_id}: {e}") + self._log("ERROR", f"Bulk query error for series {series_id}: {e}") return results @@ -455,7 +469,7 @@ def get_database_stats(self) -> Dict[str, Any]: stats[stat_name] = result[0] if result else 0 except Exception as e: - _log("ERROR", f"Stats query error: {e}") + self._log("ERROR", f"Stats query error: {e}") stats["error"] = str(e) return stats @@ -578,7 +592,7 @@ def health_check(self) -> Dict[str, Any]: health["status"] = "error" health["connection"] = "failed" health["issues"].append(f"Connection failed: {e}") - _log("ERROR", f"Database health check failed: {e}") + self._log("ERROR", f"Database health check failed: {e}") # Overall status if health["issues"]: diff --git a/config/env_writer.py b/config/env_writer.py new file mode 100644 index 0000000..17fb718 --- /dev/null +++ b/config/env_writer.py @@ -0,0 +1,278 @@ +""" +Env file writer for the setup wizard. + +Everything the wizard adds goes through here — turning a form submission +into env var names, and writing those into .env / .env.secrets a few keys +at a time without disturbing anything else already in either file. Nothing +in this module talks to the network or a database; it's just the +file-writing half of "add an instance". The actual connection testing and +name-collision checks against the live registry happen in the route +handler, before build_env_updates()/upsert_env_file() are ever called — +by the time we're writing, everything should already be validated. +""" +import json +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, List, Optional, Tuple + + +# Env var suffixes that are secrets and always belong in .env.secrets, +# never .env. Keep this in sync with what _load_radarr_instance / +# _load_sonarr_instance in config/settings.py actually read — if a new +# secret-shaped field gets added there, it needs to be added here too. +SECRET_SUFFIXES = ("API_KEY", "DB_PASSWORD") + +# Same reserved segments settings.py already refuses to treat as instance +# names (RADARR_DB_* and RADARR_WEBHOOK_* aren't instances called "DB" or +# "WEBHOOK"). Duplicated here rather than imported so this module has no +# dependency on settings.py loading environment variables at import time. +RESERVED_NAME_SEGMENTS = {"DB", "WEBHOOK"} + +# Letters, digits, underscores only — no hyphens. This becomes part of an +# env var name and a URL path segment (the webhook route), so it has to be +# shell- and URL-safe. Same rule the multi-instance docs already spell out +# for people naming instances by hand. +_NAME_SEGMENT_RE = re.compile(r"^[A-Za-z0-9_]+$") + + +class InstanceNameError(ValueError): + """A user-supplied instance name isn't safe to use as an env var segment.""" + + +def validate_name_segment(raw_name: str, existing_names: set, media_type: str, require_exists: bool = False) -> str: + """Turn a user-typed instance name into a validated env var name segment. + + Uppercases it (env vars are conventionally upper-case), then checks the + character set, the reserved-word list, and whether it collides with an + instance that's already running. + + existing_names should come from the live InstanceRegistry, not just + whatever's currently in .env — someone may have hand-edited the file + since the last restart, and the registry is what's actually in use. + Those are full instance names (e.g. "sonarr_strm"), not bare segments + (e.g. "strm") — media_type is what lets this function build the same + full name to compare against, the way instance_name is built everywhere + else in the wizard (f"{media_type}_{segment.lower()}"). + + require_exists flips the collision check: normally a name must NOT + already exist (adding a new instance is the default assumption); with + require_exists=True it must ALREADY exist instead — used when editing + or deleting a specific instance, where the opposite mistake (a typo'd + name matching nothing) is the one worth catching. + """ + name_segment = raw_name.strip().upper() + + if not name_segment: + raise InstanceNameError("Instance name can't be empty") + + if not _NAME_SEGMENT_RE.match(name_segment): + raise InstanceNameError( + "Instance name can only contain letters, numbers, and underscores — " + "no hyphens or spaces. This becomes part of an env var name and a " + "webhook URL, so it has to stay simple." + ) + + if name_segment in RESERVED_NAME_SEGMENTS: + raise InstanceNameError(f"'{name_segment}' is a reserved word and can't be used as an instance name") + + full_name = f"{media_type}_{name_segment.lower()}" + exists = full_name in existing_names + if require_exists and not exists: + raise InstanceNameError(f"No instance named '{full_name}' exists to edit or delete") + if not require_exists and exists: + raise InstanceNameError(f"An instance named '{full_name}' already exists") + + return name_segment + + +def build_env_updates( + media_type: str, name_segment: str, fields: Dict[str, object] +) -> Tuple[Dict[str, str], Dict[str, str]]: + """Turn a wizard form submission into the env vars it becomes. + + media_type is "radarr" or "sonarr". fields is whatever the form + collected — only the known field names below are ever read, so a stray + key in the request body can't sneak an arbitrary env var into either + file. name_segment == "" means the default instance (RADARR_URL rather + than RADARR_4K_URL). + + Returns (env_updates, secret_updates), two plain {KEY: value} dicts + ready to hand to upsert_env_file() — one per file. + """ + prefix = f"{media_type.upper()}_{name_segment}_" if name_segment else f"{media_type.upper()}_" + + # Radarr and Sonarr each only have one of these — asking for both would + # be a caller bug, not something worth silently tolerating. + path_field = "movie_paths" if media_type == "radarr" else "tv_paths" + path_var = "MOVIE_PATHS" if media_type == "radarr" else "TV_PATHS" + + field_to_suffix = { + "url": "URL", + "api_key": "API_KEY", + "root_folders": "ROOT_FOLDERS", + path_field: path_var, + "db_type": "DB_TYPE", + "db_host": "DB_HOST", + "db_port": "DB_PORT", + "db_name": "DB_NAME", + "db_user": "DB_USER", + "db_password": "DB_PASSWORD", + "db_path": "DB_PATH", + } + + env_updates: Dict[str, str] = {} + secret_updates: Dict[str, str] = {} + + for field_name, suffix in field_to_suffix.items(): + value = fields.get(field_name) + if value in (None, ""): + continue + if isinstance(value, list): + value = ",".join(str(v).strip() for v in value if str(v).strip()) + if not value: + continue + key = f"{prefix}{suffix}" + target = secret_updates if suffix in SECRET_SUFFIXES else env_updates + target[key] = str(value) + + return env_updates, secret_updates + + +def upsert_env_file(path: Path, updates: Dict[str, str], instance_label: str = "") -> bool: + """Write `updates` into the env file at `path`, touching only those keys. + + An existing KEY=value line gets its value replaced in place — every + comment, blank line, and unrelated var is left exactly as it was. Keys + that don't already exist are appended at the end under a small header + comment, so it's obvious later which lines the wizard added versus + what was hand-written. + + Returns True if the file was created or changed, False if every + requested value already matched what was on disk. + """ + if not updates: + return False + + remaining = dict(updates) # don't mutate the caller's dict as we consume it + lines: List[str] = path.read_text().splitlines() if path.exists() else [] + changed = False + + for i, line in enumerate(lines): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in line: + continue + key = line.split("=", 1)[0].strip() + if key in remaining: + new_line = f"{key}={remaining.pop(key)}" + if new_line != line: + lines[i] = new_line + changed = True + + # Anything still left in `remaining` wasn't in the file at all — append it. + if remaining: + if lines and lines[-1].strip(): + lines.append("") + if instance_label: + lines.append(f"# --- {instance_label} (added via setup wizard) ---") + for key, value in remaining.items(): + lines.append(f"{key}={value}") + changed = True + + if changed: + path.write_text("\n".join(lines) + "\n") + + return changed + + +def remove_instance_keys(path: Path, prefix: str, instance_label: str = "") -> bool: + """Remove every KEY=value line whose key starts with `prefix` from the file. + + This is the delete half of the wizard — the mirror of upsert_env_file(). + `prefix` is the same "SONARR_STRM_" shape build_env_updates() already + builds keys with, so a delete removes exactly what an add would have + written, no more. Also drops the wizard's own "# --- name (added via + setup wizard) ---" header line for this instance if present — anything + else in the file, including other comments, is left untouched. + + Returns True if anything was actually removed, False if the file had + nothing matching (deleting an instance that was never in this file, or + was hand-written under a different naming convention). + """ + if not path.exists(): + return False + + lines = path.read_text().splitlines() + kept: List[str] = [] + changed = False + header_marker = f"# --- {instance_label} (" if instance_label else None + + for line in lines: + stripped = line.strip() + if header_marker and stripped.startswith(header_marker): + changed = True + continue + if stripped and not stripped.startswith("#") and "=" in line: + key = line.split("=", 1)[0].strip() + if key.startswith(prefix): + changed = True + continue + kept.append(line) + + if changed: + path.write_text("\n".join(kept) + ("\n" if kept else "")) + + return changed + + +def read_env_files(env_path: Path, secrets_path: Path) -> Dict[str, str]: + """Read both files' raw text, for a backup. A missing file comes back as "". + + Deliberately just the raw text, not parsed key/value pairs — a backup + should round-trip exactly, comments and formatting included, not just + the values. + """ + return { + "env": env_path.read_text() if env_path.exists() else "", + "env_secrets": secrets_path.read_text() if secrets_path.exists() else "", + } + + +def write_env_files(env_path: Path, secrets_path: Path, env_content: str, secrets_content: str) -> None: + """Overwrite both files verbatim — this is what restoring a backup does. + + Unlike upsert_env_file(), nothing is merged here: whatever text is + passed in becomes the entire file. Call backup_env_files() first if + the current content is worth keeping, which the wizard's restore + endpoint always does. + """ + env_path.write_text(env_content) + secrets_path.write_text(secrets_content) + + +def backup_env_files(env_path: Path, secrets_path: Path, backups_dir: Path) -> Optional[Path]: + """Snapshot both files' current content into backups_dir, before something overwrites them. + + Same JSON shape a manual backup download uses, so a snapshot taken here + can be fed straight back into a restore if needed. Returns the path to + the snapshot, or None if neither file exists yet — nothing to back up + on a fresh install. + """ + if not env_path.exists() and not secrets_path.exists(): + return None + + backups_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + snapshot_path = backups_dir / f"env-backup-{timestamp}.json" + + # Two snapshots requested in the same second would collide — add a + # counter suffix rather than silently overwriting an earlier one. + counter = 1 + while snapshot_path.exists(): + snapshot_path = backups_dir / f"env-backup-{timestamp}-{counter}.json" + counter += 1 + + snapshot = {"exported_at": datetime.now(timezone.utc).isoformat(), **read_env_files(env_path, secrets_path)} + snapshot_path.write_text(json.dumps(snapshot, indent=2)) + + return snapshot_path diff --git a/core/database_populator.py b/core/database_populator.py index 912b001..c111d1e 100644 --- a/core/database_populator.py +++ b/core/database_populator.py @@ -67,7 +67,8 @@ def __init__(self, db: ChronarrDatabase, radarr_client: RadarrClient = None, except Exception: self.radarr_api = radarr_client if radarr_client else RadarrClient( os.environ.get("RADARR_URL", ""), - os.environ.get("RADARR_API_KEY", "") + os.environ.get("RADARR_API_KEY", ""), + instance_name="radarr", ) self.radarr = self.radarr_api _log("INFO", "DatabasePopulator: Using Radarr API client") @@ -96,7 +97,8 @@ def __init__(self, db: ChronarrDatabase, radarr_client: RadarrClient = None, except Exception: self.sonarr_api = sonarr_client if sonarr_client else SonarrClient( os.environ.get("SONARR_URL", ""), - os.environ.get("SONARR_API_KEY", "") + os.environ.get("SONARR_API_KEY", ""), + instance_name="sonarr", ) self.sonarr = self.sonarr_api _log("INFO", "DatabasePopulator: Using Sonarr API client") @@ -120,10 +122,11 @@ def from_radarr_instance(cls, inst, db: ChronarrDatabase) -> 'DatabasePopulator' db_name=inst.db_name or None, db_user=inst.db_user or None, db_password=inst.db_password or None, + instance_name=inst.name, ) _log("INFO", f"DatabasePopulator: DB access configured for Radarr instance '{inst.name}'") return cls(db, _radarr_db_override=client) - client = RadarrClient(inst.url, inst.api_key) + client = RadarrClient(inst.url, inst.api_key, instance_name=inst.name) return cls(db, _radarr_db_override=client) @classmethod @@ -141,26 +144,32 @@ def from_sonarr_instance(cls, inst, db: ChronarrDatabase) -> 'DatabasePopulator' db_name=inst.db_name or None, db_user=inst.db_user or None, db_password=inst.db_password or None, + instance_name=inst.name, ) _log("INFO", f"DatabasePopulator: DB access configured for Sonarr instance '{inst.name}'") return cls(db, _sonarr_db_override=client) - client = SonarrClient(inst.url, inst.api_key) + client = SonarrClient(inst.url, inst.api_key, instance_name=inst.name) return cls(db, _sonarr_db_override=client) - def get_episode_import_history(self, episode_id: int) -> Optional[str]: + def get_episode_import_history(self, episode_id: int) -> Tuple[Optional[str], Optional[str]]: """ - Get episode import history from either database or API - Wraps both SonarrDbClient.get_episode_import_date and SonarrClient.get_episode_import_history + Get episode import history from either database or API. + Wraps both SonarrDbClient.get_episode_import_date and SonarrClient.get_episode_import_history. + + Always returns (date_iso, source) now — this used to discard the DB + client's real source string ("sonarr:db.history.import") and let the + caller hardcode "sonarr:api.import_history" regardless of which path + actually ran, mislabeling every DB-sourced date as API-sourced. """ if self.using_sonarr_db and self.sonarr_db: - # Database client returns (date_iso, source) - date_iso, source = self.sonarr_db.get_episode_import_date(episode_id) - return date_iso + # Database client already returns (date_iso, source) + return self.sonarr_db.get_episode_import_date(episode_id) elif self.sonarr_api: - # API client returns Optional[str] - return self.sonarr_api.get_episode_import_history(episode_id) + # API client returns just the date — the source is always the same for it + date_iso = self.sonarr_api.get_episode_import_history(episode_id) + return date_iso, ('sonarr:api.import_history' if date_iso else None) else: - return None + return None, None def populate_movies(self, instance: str = 'radarr') -> Dict[str, any]: """ @@ -547,10 +556,10 @@ def populate_tv_episodes(self, instance: str = 'sonarr') -> Dict[str, any]: else: episode_id = episode.get('id') if episode_id: - import_date = self.get_episode_import_history(episode_id) + import_date, import_source = self.get_episode_import_history(episode_id) if import_date: dateadded = import_date - source = 'sonarr:api.import_history' + source = import_source or 'sonarr:api.import_history' # Fallback to air date if no import date if not dateadded and aired: diff --git a/core/instance_registry.py b/core/instance_registry.py index 7c8238d..226f434 100644 --- a/core/instance_registry.py +++ b/core/instance_registry.py @@ -32,6 +32,7 @@ def _build_radarr_client(instance: RadarrInstance) -> Optional[AnyRadarrClient]: client = RadarrDbClient( db_type="sqlite", db_path=instance.db_path or None, + instance_name=instance.name, ) else: client = RadarrDbClient( @@ -41,6 +42,7 @@ def _build_radarr_client(instance: RadarrInstance) -> Optional[AnyRadarrClient]: db_name=instance.db_name or None, db_user=instance.db_user or None, db_password=instance.db_password or None, + instance_name=instance.name, ) _log("INFO", f"[{instance.name}] Using Radarr direct database access") return client @@ -52,6 +54,7 @@ def _build_radarr_client(instance: RadarrInstance) -> Optional[AnyRadarrClient]: return RadarrClient( base_url=instance.url, api_key=instance.api_key, + instance_name=instance.name, ) _log("WARNING", f"[{instance.name}] No Radarr client available — no DB or API configured") @@ -66,6 +69,7 @@ def _build_sonarr_client(instance: SonarrInstance) -> Optional[AnySonarrClient]: client = SonarrDbClient( db_type="sqlite", db_path=instance.db_path or None, + instance_name=instance.name, ) else: client = SonarrDbClient( @@ -75,6 +79,7 @@ def _build_sonarr_client(instance: SonarrInstance) -> Optional[AnySonarrClient]: db_name=instance.db_name or None, db_user=instance.db_user or None, db_password=instance.db_password or None, + instance_name=instance.name, ) _log("INFO", f"[{instance.name}] Using Sonarr direct database access") return client @@ -86,6 +91,7 @@ def _build_sonarr_client(instance: SonarrInstance) -> Optional[AnySonarrClient]: return SonarrClient( base_url=instance.url, api_key=instance.api_key, + instance_name=instance.name, ) _log("WARNING", f"[{instance.name}] No Sonarr client available — no DB or API configured") diff --git a/main.py b/main.py index 43ec588..75fb0fa 100644 --- a/main.py +++ b/main.py @@ -10,12 +10,18 @@ import uvicorn from fastapi import FastAPI -# Import configuration first -from config.settings import config - -# Authentication removed - handled by separate web container +# utils.logging has to be imported before config.settings — importing it is +# what actually loads .env/.env.secrets into the process (a side effect at +# the bottom of that module). config.settings builds its module-level +# `config` singleton, including instance discovery, the moment it's +# imported — if that happens first, any instance whose env vars only live +# in the file (not already in the container's OS environment from Docker's +# own env_file injection at container creation) gets silently missed. from utils.logging import _log +# Import configuration — after logging, see above +from config.settings import config + # Import core components from core.database import ChronarrDatabase from core.instance_registry import build_registry @@ -92,9 +98,12 @@ def initialize_components(registry=None): if registry is None: registry = build_registry(config) - # Processors get the default instance's client and mapper. Multi-instance - # requests pass instance name explicitly at process time; the processors look - # up the right client from the registry during their DB writes. + # Processors are constructed with the default instance's client and mapper + # (used as a fallback when no registry is available, e.g. in tests), but + # also get the InstanceRegistry itself — every per-call `instance` param + # they take is resolved against it, so a named instance actually gets its + # own client and path mapper instead of silently falling back to whatever + # was wired in here. default_radarr_mapper = registry.radarr_mapper("radarr") default_sonarr_mapper = registry.sonarr_mapper("sonarr") @@ -102,11 +111,13 @@ def initialize_components(registry=None): db, None, default_sonarr_mapper or _noop_mapper(), sonarr_client=registry.sonarr("sonarr"), + registry=registry, ) movie_processor = MovieProcessor( db, None, default_radarr_mapper or _noop_mapper(), radarr_client=registry.radarr("radarr"), + registry=registry, ) batcher = WebhookBatcher(nfo_manager=None) diff --git a/processors/movie_processor.py b/processors/movie_processor.py index 4f87004..04eaeb5 100644 --- a/processors/movie_processor.py +++ b/processors/movie_processor.py @@ -65,10 +65,14 @@ def convert_utc_to_local(utc_iso_string: str) -> str: class MovieProcessor: - def __init__(self, db: ChronarrDatabase, nfo_manager, path_mapper: PathMapper, radarr_client=None): + def __init__(self, db: ChronarrDatabase, nfo_manager, path_mapper: PathMapper, radarr_client=None, registry=None): # nfo_manager kept for call-site compat but is unused self.db = db self.path_mapper = path_mapper + # InstanceRegistry — lets per-call `instance` params resolve the right + # client/mapper instead of always using the default instance below. + # None in the legacy single-instance/test construction path. + self.registry = registry if radarr_client: # Pre-built client from InstanceRegistry (preferred path) @@ -91,15 +95,33 @@ def __init__(self, db: ChronarrDatabase, nfo_manager, path_mapper: PathMapper, r _log("INFO", "Using Radarr API client (database not configured)") self.external_clients = ExternalClientManager() - - def find_movie_path(self, movie_title: str, imdb_id: str, radarr_path: str = None) -> Optional[Path]: + + def _resolve_radarr(self, instance: str): + """Return (client, using_db, path_mapper) for a named instance. + + Looks the instance up in the InstanceRegistry so per-call `instance` + params actually pick the right Radarr server instead of every call + silently using whichever instance was wired in at construction time. + Falls back to the default-instance attributes when there's no + registry (legacy/test construction) or the instance isn't found. + """ + if self.registry: + client = self.registry.radarr(instance) + if client is not None: + mapper = self.registry.radarr_mapper(instance) or self.path_mapper + return client, isinstance(client, RadarrDbClient), mapper + _log("WARNING", f"[{instance}] No Radarr client in registry — falling back to default instance") + return self.radarr, self.using_db, self.path_mapper + + def find_movie_path(self, movie_title: str, imdb_id: str, radarr_path: str = None, instance: str = 'radarr') -> Optional[Path]: """Find movie directory path using unified file utilities""" + _, _, path_mapper = self._resolve_radarr(instance) return find_media_path_by_imdb_and_title( title=movie_title, imdb_id=imdb_id, search_paths=config.movie_paths, webhook_path=radarr_path, - path_mapper=self.path_mapper + path_mapper=path_mapper ) def should_skip_movie(self, imdb_id: str, movie_name: str = "", instance: str = 'radarr') -> Tuple[bool, str]: @@ -135,34 +157,34 @@ def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan if not imdb_id: imdb_id = find_imdb_in_directory(movie_path) if not imdb_id: - _log("ERROR", f"No IMDb ID found in movie directory, filenames, or NFO file: {movie_path}") + _log("ERROR", f"[{instance}] No IMDb ID found in movie directory, filenames, or NFO file: {movie_path}") return "error" # Handle TMDB ID fallback case is_tmdb_fallback = imdb_id.startswith("tmdb-") if is_tmdb_fallback: - _log("INFO", f"Processing movie: {movie_path.name} (TMDB: {imdb_id})") + _log("INFO", f"[{instance}] Processing movie: {movie_path.name} (TMDB: {imdb_id})") else: - _log("INFO", f"Processing movie: {movie_path.name} (IMDb: {imdb_id})") + _log("INFO", f"[{instance}] Processing movie: {movie_path.name} (IMDb: {imdb_id})") # Check if we should skip this movie (unless forced, webhook mode, or incomplete mode) # Skip database optimization for incomplete mode since we need to check NFO files first if not force_scan and not webhook_mode and scan_mode != "incomplete": should_skip, reason = self.should_skip_movie(imdb_id, movie_path.name, instance=instance) if should_skip: - _log("INFO", f"⏭️ SKIPPING MOVIE: {movie_path.name} [{imdb_id}] - {reason}") + _log("INFO", f"[{instance}] ⏭️ SKIPPING MOVIE: {movie_path.name} [{imdb_id}] - {reason}") self.db.upsert_movie(imdb_id, str(movie_path), instance=instance) return "skipped" else: - _log("INFO", f"🎬 PROCESSING MOVIE: {movie_path.name} [{imdb_id}] - {reason}") + _log("INFO", f"[{instance}] 🎬 PROCESSING MOVIE: {movie_path.name} [{imdb_id}] - {reason}") elif force_scan: - _log("INFO", f"🔄 FORCE PROCESSING MOVIE: {movie_path.name} [{imdb_id}] - Force scan enabled") + _log("INFO", f"[{instance}] 🔄 FORCE PROCESSING MOVIE: {movie_path.name} [{imdb_id}] - Force scan enabled") else: - _log("INFO", f"📥 WEBHOOK PROCESSING MOVIE: {movie_path.name} [{imdb_id}] - Webhook mode") + _log("INFO", f"[{instance}] 📥 WEBHOOK PROCESSING MOVIE: {movie_path.name} [{imdb_id}] - Webhook mode") # Check for shutdown signal early in processing if shutdown_event and shutdown_event.is_set(): - _log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie processing: {movie_path.name}") + _log("INFO", f"[{instance}] ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie processing: {movie_path.name}") return "shutdown" # Update database @@ -173,7 +195,7 @@ def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan has_video = any(f.is_file() and f.suffix.lower() in video_exts for f in movie_path.iterdir()) if not has_video: - _log("WARNING", f"No video files found in: {movie_path} - skipping database entry") + _log("WARNING", f"[{instance}] No video files found in: {movie_path} - skipping database entry") return "no_video_files" if scan_mode == "incomplete": @@ -182,19 +204,19 @@ def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan # For smart/full modes: Use database-first optimization # TIER 1: Check database first (fastest - local lookup) existing = self.db.get_movie_dates(imdb_id, instance) - _log("DEBUG", f"Database lookup for {imdb_id}: {existing}") + _log("DEBUG", f"[{instance}] Database lookup for {imdb_id}: {existing}") # Enhanced debug for database state if existing: has_dateadded = bool(existing.get("dateadded")) source_value = existing.get("source") - _log("INFO", f"🔍 TIER 1 DEBUG - {imdb_id}: has_dateadded={has_dateadded}, source='{source_value}', dateadded='{existing.get('dateadded')}'") + _log("INFO", f"[{instance}] 🔍 TIER 1 DEBUG - {imdb_id}: has_dateadded={has_dateadded}, source='{source_value}', dateadded='{existing.get('dateadded')}'") else: - _log("INFO", f"🔍 TIER 1 DEBUG - {imdb_id}: No database record found") + _log("INFO", f"[{instance}] 🔍 TIER 1 DEBUG - {imdb_id}: No database record found") # If we have complete data in database, use it and skip all other checks if existing and existing.get("dateadded") and existing.get("source") != "no_valid_date_source": - _log("INFO", f"✅ TIER 1 - Using complete database data for {imdb_id}: {existing['dateadded']} (source: {existing['source']})") + _log("INFO", f"[{instance}] ✅ TIER 1 - Using complete database data for {imdb_id}: {existing['dateadded']} (source: {existing['source']})") dateadded, source, released = existing["dateadded"], existing["source"], existing.get("released") # Convert datetime objects to strings for NFO manager @@ -206,43 +228,44 @@ def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan # NFO file operations removed - database is now the single source of truth # (Phase 1: Remove NFO file write operations) - _log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [database-cached]") + _log("INFO", f"[{instance}] Completed processing movie: {movie_path.name} (source: {source}) [database-cached]") return "processed" else: - _log("INFO", f"🔍 TIER 1 SKIP - {imdb_id}: Database incomplete, proceeding to Tier 2") + _log("INFO", f"[{instance}] 🔍 TIER 1 SKIP - {imdb_id}: Database incomplete, proceeding to Tier 2") # TIER 2: Query external APIs directly (NFO layer removed in Phase 2) - _log("INFO", f"🔍 TIER 2 - No database cache, querying external APIs") + _log("INFO", f"[{instance}] 🔍 TIER 2 - No database cache, querying external APIs") # Fetch title/year from Radarr now so we can store them alongside dates - radarr_meta = self.radarr.movie_by_imdb(imdb_id) if self.radarr else None + radarr_client, _, _ = self._resolve_radarr(instance) + radarr_meta = radarr_client.movie_by_imdb(imdb_id) if radarr_client else None title = radarr_meta.get("title") if radarr_meta else None year = radarr_meta.get("year") if radarr_meta else None # Check for shutdown signal before expensive API operations if shutdown_event and shutdown_event.is_set(): - _log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie processing before API calls: {movie_path.name}") + _log("INFO", f"[{instance}] ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie processing before API calls: {movie_path.name}") return "shutdown" # TIER 3: No cached data found - determine if we should query APIs if webhook_mode: - _log("INFO", f"Webhook processing - no cached data found, using full date decision logic") + _log("INFO", f"[{instance}] Webhook processing - no cached data found, using full date decision logic") should_query = True # Always query for webhooks when no cached data exists else: # Manual scan mode - determine if we should query APIs should_query = config.movie_poll_mode == "always" - _log("DEBUG", f"Movie {imdb_id}: should_query={should_query}, poll_mode={config.movie_poll_mode}") + _log("DEBUG", f"[{instance}] Movie {imdb_id}: should_query={should_query}, poll_mode={config.movie_poll_mode}") # Use existing movie date decision logic # Pass NFO fallback data if available for cases where external APIs don't have import history nfo_fallback = locals().get('nfo_fallback_data', None) - dateadded, source, released = self._decide_movie_dates(imdb_id, movie_path, should_query, nfo_fallback) + dateadded, source, released = self._decide_movie_dates(imdb_id, movie_path, should_query, nfo_fallback, instance=instance) # Webhook fallback: if ALL date sources fail, use current timestamp if webhook_mode and dateadded is None: local_tz = _get_local_timezone() current_time = datetime.now(local_tz).isoformat(timespec="seconds") - _log("INFO", f"Webhook processing - all date sources failed, using current timestamp as last resort: {current_time}") + _log("INFO", f"[{instance}] Webhook processing - all date sources failed, using current timestamp as last resort: {current_time}") dateadded, source = current_time, "webhook:fallback_timestamp" # If we don't have an import/download date but we have a release date, use it as dateadded @@ -253,14 +276,14 @@ def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan if dateadded is None and released is not None: final_dateadded = released final_source = f"{source}_as_dateadded" if source else "release_date_fallback" - _log("INFO", f"Using release date as dateadded: {final_dateadded} (source: {final_source})") + _log("INFO", f"[{instance}] Using release date as dateadded: {final_dateadded} (source: {final_source})") # NFO file operations removed - database is now the single source of truth # (Phase 1: Remove NFO file write operations) # Skip remaining processing if no valid date found and file dates disabled if final_dateadded is None: - _log("WARNING", f"Movie {movie_path.name} - no valid date source available, but NFO was still processed") + _log("WARNING", f"[{instance}] Movie {movie_path.name} - no valid date source available, but NFO was still processed") self.db.upsert_movie_dates(imdb_id, released, None, source, True, title=title, year=year, instance=instance) return "processed" @@ -268,39 +291,39 @@ def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan dateadded = final_dateadded source = final_source - _log("DEBUG", f"Movie {movie_path.name} proceeding to save: dateadded={dateadded}, source={source}") + _log("DEBUG", f"[{instance}] Movie {movie_path.name} proceeding to save: dateadded={dateadded}, source={source}") # File mtime operations removed - database is now the single source of truth # (Phase 1: Remove NFO file write operations) - _log("DEBUG", f"Movie processing reached file mtime section: fix_dir_mtimes={config.fix_dir_mtimes}, dateadded={dateadded}") + _log("DEBUG", f"[{instance}] Movie processing reached file mtime section: fix_dir_mtimes={config.fix_dir_mtimes}, dateadded={dateadded}") # Save to database - _log("DEBUG", f"About to save to database: imdb_id={imdb_id}, dateadded={dateadded}") + _log("DEBUG", f"[{instance}] About to save to database: imdb_id={imdb_id}, dateadded={dateadded}") try: self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True, title=title, year=year, instance=instance) - _log("DEBUG", f"Database save completed for {imdb_id}") + _log("DEBUG", f"[{instance}] Database save completed for {imdb_id}") except Exception as e: - _log("ERROR", f"Database save failed for {imdb_id}: {e}") + _log("ERROR", f"[{instance}] Database save failed for {imdb_id}: {e}") raise - _log("INFO", f"Completed processing movie: {movie_path.name} (source: {source})") + _log("INFO", f"[{instance}] Completed processing movie: {movie_path.name} (source: {source})") return "processed" def _process_movie_nfo_first(self, movie_path: Path, imdb_id: str, shutdown_event=None, instance: str = 'radarr') -> str: """Process movie for incomplete mode: Database-first then API (NFO checks removed in Phase 2)""" - _log("INFO", f"🔍 INCOMPLETE MODE: Checking movie for missing data: {movie_path.name}") + _log("INFO", f"[{instance}] 🔍 INCOMPLETE MODE: Checking movie for missing data: {movie_path.name}") if shutdown_event and shutdown_event.is_set(): - _log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie processing: {movie_path.name}") + _log("INFO", f"[{instance}] ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie processing: {movie_path.name}") return "shutdown" existing = self.db.get_movie_dates(imdb_id, instance) if existing and existing.get("dateadded") and existing.get("source") != "no_valid_date_source": # Found in database - data is complete - _log("INFO", f"✅ Database has dateadded={existing['dateadded']}") + _log("INFO", f"[{instance}] ✅ Database has dateadded={existing['dateadded']}") dateadded, source, released = existing["dateadded"], existing["source"], existing.get("released") # Convert datetime objects to strings @@ -309,15 +332,15 @@ def _process_movie_nfo_first(self, movie_path: Path, imdb_id: str, shutdown_even if released and hasattr(released, 'isoformat'): released = released.isoformat() - _log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [database-cached]") + _log("INFO", f"[{instance}] Completed processing movie: {movie_path.name} (source: {source}) [database-cached]") return "processed" # STEP 2: Database incomplete or missing, query APIs - _log("DEBUG", f"STEP 2 - Querying APIs for missing data") + _log("DEBUG", f"[{instance}] STEP 2 - Querying APIs for missing data") # Check for shutdown signal before API calls if shutdown_event and shutdown_event.is_set(): - _log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping before API calls: {movie_path.name}") + _log("INFO", f"[{instance}] ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping before API calls: {movie_path.name}") return "shutdown" # Handle TMDB ID fallback case @@ -325,9 +348,9 @@ def _process_movie_nfo_first(self, movie_path: Path, imdb_id: str, shutdown_even if is_tmdb_fallback: # TMDB fallback processing - use file modification time - _log("INFO", f"🔍 TMDB fallback processing for {imdb_id}") + _log("INFO", f"[{instance}] 🔍 TMDB fallback processing for {imdb_id}") dateadded, source, released = self._get_file_mtime_date(movie_path) - _log("INFO", f"Using file mtime for TMDB movie: {dateadded}") + _log("INFO", f"[{instance}] Using file mtime for TMDB movie: {dateadded}") else: # Standard IMDb processing # Try to get digital release date from external APIs @@ -337,38 +360,41 @@ def _process_movie_nfo_first(self, movie_path: Path, imdb_id: str, shutdown_even dateadded = digital_date source = digital_source released = digital_date # For movies, digital release is often the key date - _log("INFO", f"Got digital release date from APIs: {dateadded} (source: {source})") + _log("INFO", f"[{instance}] Got digital release date from APIs: {dateadded} (source: {source})") else: # Last resort: file modification time dateadded, source, released = self._get_file_mtime_date(movie_path) - _log("INFO", f"Using file mtime as fallback: {dateadded}") + _log("INFO", f"[{instance}] Using file mtime as fallback: {dateadded}") if dateadded: - radarr_meta = self.radarr.movie_by_imdb(imdb_id) if self.radarr else None + radarr_client, _, _ = self._resolve_radarr(instance) + radarr_meta = radarr_client.movie_by_imdb(imdb_id) if radarr_client else None title = radarr_meta.get("title") if radarr_meta else None year = radarr_meta.get("year") if radarr_meta else None self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True, title=title, year=year, instance=instance) - _log("INFO", f"🔍 INCOMPLETE MODE COMPLETE: {movie_path.name} (source: {source})") + _log("INFO", f"[{instance}] 🔍 INCOMPLETE MODE COMPLETE: {movie_path.name} (source: {source})") return "processed" else: - _log("WARNING", f"Could not determine dateadded for movie: {movie_path.name}") + _log("WARNING", f"[{instance}] Could not determine dateadded for movie: {movie_path.name}") return "error" # NFO helper methods removed in Phase 2 - database is the single source of truth - def _decide_movie_dates(self, imdb_id: str, movie_path: Path, should_query: bool, existing: Optional[Dict]) -> Tuple[str, str, Optional[str]]: + def _decide_movie_dates(self, imdb_id: str, movie_path: Path, should_query: bool, existing: Optional[Dict], instance: str = 'radarr') -> Tuple[str, str, Optional[str]]: """Decide movie dates based on configuration and available data""" - _log("DEBUG", f"_decide_movie_dates for {imdb_id}: should_query={should_query}, existing={existing}") - + _log("DEBUG", f"[{instance}] _decide_movie_dates for {imdb_id}: should_query={should_query}, existing={existing}") + if not should_query and existing: - _log("DEBUG", f"Using existing data without querying: dateadded={existing.get('dateadded')}, source={existing.get('source')}") + _log("DEBUG", f"[{instance}] Using existing data without querying: dateadded={existing.get('dateadded')}, source={existing.get('source')}") return existing["dateadded"], existing["source"], existing.get("released") - + + radarr_client, _, _ = self._resolve_radarr(instance) + # Query Radarr for movie info (database or API client) radarr_movie = None - if should_query and self.radarr: - radarr_movie = self.radarr.movie_by_imdb(imdb_id) + if should_query and radarr_client: + radarr_movie = radarr_client.movie_by_imdb(imdb_id) released = None if radarr_movie: @@ -380,50 +406,50 @@ def _decide_movie_dates(self, imdb_id: str, movie_path: Path, should_query: bool if radarr_movie: movie_id = radarr_movie.get("id") if movie_id: - import_date, import_source = self.radarr.get_movie_import_date(movie_id, fallback_to_file_date=config.allow_file_date_fallback) - _log("INFO", f"Movie {imdb_id}: Radarr import result: date={import_date}, source={import_source}") + import_date, import_source = radarr_client.get_movie_import_date(movie_id, fallback_to_file_date=config.allow_file_date_fallback) + _log("INFO", f"[{instance}] Movie {imdb_id}: Radarr import result: date={import_date}, source={import_source}") # Check for special case: rename-first scenario (should prefer release dates) if import_source == "radarr:db.prefer_release_dates": - _log("INFO", f"🎯 Movie {imdb_id} has rename-first history - skipping import, preferring release dates") + _log("INFO", f"[{instance}] 🎯 Movie {imdb_id} has rename-first history - skipping import, preferring release dates") # Fall through to release date logic below # Check if we got a real import date or just file date fallback elif import_date and import_source != "radarr:db.file.dateAdded": # Convert import date to local timezone for NFO files local_import_date = convert_utc_to_local(import_date) - _log("INFO", f"✅ Movie {imdb_id}: Using import date {local_import_date} from {import_source}") + _log("INFO", f"[{instance}] ✅ Movie {imdb_id}: Using import date {local_import_date} from {import_source}") return local_import_date, import_source, released # Get digital release date for comparison/fallback - _log("INFO", f"🔍 Movie {imdb_id}: Trying digital release date fallback...") + _log("INFO", f"[{instance}] 🔍 Movie {imdb_id}: Trying digital release date fallback...") digital_date, digital_source = self._get_digital_release_date(imdb_id) - _log("INFO", f"Movie {imdb_id}: Digital release result: date={digital_date}, source={digital_source}") + _log("INFO", f"[{instance}] Movie {imdb_id}: Digital release result: date={digital_date}, source={digital_source}") # If we only have file date and release date exists, prefer it if reasonable and enabled if import_date and import_source == "radarr:db.file.dateAdded" and digital_date and config.prefer_release_dates_over_file_dates: # Compare dates - prefer release date if it's reasonable if self._should_prefer_release_over_file_date(digital_date, digital_source, released, imdb_id): - _log("INFO", f"✅ Movie {imdb_id}: Preferring digital release date {digital_date} over file date") + _log("INFO", f"[{instance}] ✅ Movie {imdb_id}: Preferring digital release date {digital_date} over file date") # When using digital release date, store it as both dateadded and released return digital_date, digital_source, digital_date else: # Convert file date to local timezone for NFO files local_file_date = convert_utc_to_local(import_date) - _log("INFO", f"✅ Movie {imdb_id}: Keeping file date {local_file_date} - digital date not reasonable") + _log("INFO", f"[{instance}] ✅ Movie {imdb_id}: Keeping file date {local_file_date} - digital date not reasonable") return local_file_date, import_source, released # Use whichever we have if import_date: # Convert import date to local timezone for NFO files local_import_date = convert_utc_to_local(import_date) - _log("INFO", f"✅ Movie {imdb_id}: Using import date {local_import_date} from {import_source}") + _log("INFO", f"[{instance}] ✅ Movie {imdb_id}: Using import date {local_import_date} from {import_source}") return local_import_date, import_source, released elif digital_date: - _log("INFO", f"✅ Movie {imdb_id}: Using digital release date {digital_date} from {digital_source}") + _log("INFO", f"[{instance}] ✅ Movie {imdb_id}: Using digital release date {digital_date} from {digital_source}") # When using digital release date, store it as both dateadded and released return digital_date, digital_source, digital_date else: - _log("WARNING", f"⚠️ Movie {imdb_id}: No import date OR digital release date found") + _log("WARNING", f"[{instance}] ⚠️ Movie {imdb_id}: No import date OR digital release date found") else: # digital_then_import # Try digital release first @@ -436,7 +462,7 @@ def _decide_movie_dates(self, imdb_id: str, movie_path: Path, should_query: bool if radarr_movie: movie_id = radarr_movie.get("id") if movie_id: - import_date, import_source = self.radarr.get_movie_import_date(movie_id, fallback_to_file_date=config.allow_file_date_fallback) + import_date, import_source = radarr_client.get_movie_import_date(movie_id, fallback_to_file_date=config.allow_file_date_fallback) if import_date: # Convert import date to local timezone for NFO files local_import_date = convert_utc_to_local(import_date) @@ -444,14 +470,14 @@ def _decide_movie_dates(self, imdb_id: str, movie_path: Path, should_query: bool # Last resort: check if we have NFO fallback data (when external APIs don't have import history) if existing and existing.get('dateadded'): - _log("INFO", f"✅ Movie {imdb_id}: External APIs don't have import history, using NFO fallback date: {existing['dateadded']} (source: {existing['source']})") + _log("INFO", f"[{instance}] ✅ Movie {imdb_id}: External APIs don't have import history, using NFO fallback date: {existing['dateadded']} (source: {existing['source']})") return existing["dateadded"], f"nfo_fallback:{existing['source']}", existing.get("released") # Last resort: file mtime (if allowed) if config.allow_file_date_fallback: return self._get_file_mtime_date(movie_path) else: - _log("INFO", f"No valid dates found for {imdb_id} and file date fallback disabled - skipping NFO creation") + _log("INFO", f"[{instance}] No valid dates found for {imdb_id} and file date fallback disabled - skipping NFO creation") # Log to failed movies debug file for troubleshooting self._log_failed_movie(movie_path, imdb_id, "No import date, no release date, file date fallback disabled") diff --git a/processors/tv_processor.py b/processors/tv_processor.py index 6f3803a..fca79c8 100644 --- a/processors/tv_processor.py +++ b/processors/tv_processor.py @@ -28,10 +28,14 @@ class TVProcessor: - def __init__(self, db: ChronarrDatabase, nfo_manager, path_mapper: PathMapper, sonarr_client=None): + def __init__(self, db: ChronarrDatabase, nfo_manager, path_mapper: PathMapper, sonarr_client=None, registry=None): # nfo_manager kept for call-site compat but is unused self.db = db self.path_mapper = path_mapper + # InstanceRegistry — lets per-call `instance` params resolve the right + # client/mapper instead of always using the default instance below. + # None in the legacy single-instance/test construction path. + self.registry = registry if sonarr_client: # Pre-built client from InstanceRegistry (preferred path) @@ -61,29 +65,48 @@ def __init__(self, db: ChronarrDatabase, nfo_manager, path_mapper: PathMapper, s self.external_clients = ExternalClientManager() - def get_episode_import_history(self, episode_id: int) -> Optional[str]: + def _resolve_sonarr(self, instance: str): + """Return (client, using_db, path_mapper) for a named instance. + + Looks the instance up in the InstanceRegistry so per-call `instance` + params actually pick the right Sonarr server instead of every call + silently using whichever instance was wired in at construction time. + Falls back to the default-instance attributes when there's no + registry (legacy/test construction) or the instance isn't found. + """ + if self.registry: + client = self.registry.sonarr(instance) + if client is not None: + mapper = self.registry.sonarr_mapper(instance) or self.path_mapper + return client, isinstance(client, SonarrDbClient), mapper + _log("WARNING", f"[{instance}] No Sonarr client in registry — falling back to default instance") + return self.sonarr, self.using_db, self.path_mapper + + def get_episode_import_history(self, episode_id: int, instance: str = 'sonarr') -> Optional[str]: """ Get episode import history from either database or API Wraps both SonarrDbClient.get_episode_import_date and SonarrClient.get_episode_import_history """ - if self.using_db and self.sonarr_db: + client, using_db, _ = self._resolve_sonarr(instance) + if using_db and client: # Database client returns (date_iso, source) - date_iso, source = self.sonarr_db.get_episode_import_date(episode_id) + date_iso, source = client.get_episode_import_date(episode_id) return date_iso - elif self.sonarr_api: + elif client: # API client returns Optional[str] - return self.sonarr_api.get_episode_import_history(episode_id) + return client.get_episode_import_history(episode_id) else: return None - def find_series_path(self, series_title: str, imdb_id: str, sonarr_path: str = None) -> Optional[Path]: + def find_series_path(self, series_title: str, imdb_id: str, sonarr_path: str = None, instance: str = 'sonarr') -> Optional[Path]: """Find series directory path using unified file utilities""" + _, _, path_mapper = self._resolve_sonarr(instance) return find_media_path_by_imdb_and_title( title=series_title, imdb_id=imdb_id, search_paths=config.tv_paths, webhook_path=sonarr_path, - path_mapper=self.path_mapper + path_mapper=path_mapper ) def _episode_completion_counts(self, imdb_id: str, instance: str) -> Tuple[int, int]: @@ -225,7 +248,7 @@ def _gather_episode_dates(self, series_path: Path, imdb_id: str, disk_episodes: episodes_needing_lookup = episodes_needing_nfo_check # Renamed for clarity if episodes_needing_lookup: _log("DEBUG", f"TIER 2 - Querying Sonarr for {len(episodes_needing_lookup)} episodes missing from database") - sonarr_episodes = self._get_sonarr_episodes(imdb_id, episodes_needing_lookup) + sonarr_episodes = self._get_sonarr_episodes(imdb_id, episodes_needing_lookup, instance=instance) # Process episodes that needed lookup for (season, episode) in episodes_needing_lookup: @@ -320,7 +343,7 @@ def _gather_episode_dates_nfo_first(self, series_path: Path, imdb_id: str, disk_ # STEP 2: For episodes missing from database, query Sonarr if episodes_needing_api_lookup: _log("DEBUG", f"STEP 2 - Querying Sonarr for {len(episodes_needing_api_lookup)} episodes missing from database") - sonarr_episodes = self._get_sonarr_episodes(imdb_id, episodes_needing_api_lookup) + sonarr_episodes = self._get_sonarr_episodes(imdb_id, episodes_needing_api_lookup, instance=instance) for (season, episode) in episodes_needing_api_lookup: aired = None @@ -372,24 +395,23 @@ def _gather_episode_dates_nfo_first(self, series_path: Path, imdb_id: str, disk_ return episode_dates - def _get_sonarr_episodes(self, imdb_id: str, episodes_filter: List[Tuple[int, int]] = None) -> Dict[Tuple[int, int], Dict[str, Any]]: + def _get_sonarr_episodes(self, imdb_id: str, episodes_filter: List[Tuple[int, int]] = None, instance: str = 'sonarr') -> Dict[Tuple[int, int], Dict[str, Any]]: """Get episode information from Sonarr including import history - optimized to only fetch needed episodes""" try: + client, using_db, _ = self._resolve_sonarr(instance) + # Handle different method names for DB vs API client - if self.using_db: - series_data = self.sonarr.get_series_by_imdb(imdb_id) + if using_db: + series_data = client.get_series_by_imdb(imdb_id) else: - series_data = self.sonarr.series_by_imdb(imdb_id) + series_data = client.series_by_imdb(imdb_id) if not series_data: # Try fuzzy matching if exact IMDb lookup fails _log("DEBUG", f"Exact IMDb lookup failed for {imdb_id}, trying fuzzy matching") # Get all series and try fuzzy matching - if self.using_db: - all_series = self.sonarr.get_all_series() - else: - all_series = self.sonarr.get_all_series() + all_series = client.get_all_series() if all_series: _log("DEBUG", f"Found {len(all_series)} total series in Sonarr") @@ -423,11 +445,11 @@ def _get_sonarr_episodes(self, imdb_id: str, episodes_filter: List[Tuple[int, in return {} # Handle different method names for DB vs API client - if self.using_db: - episodes = self.sonarr.get_all_episodes_for_series(series_id) + if using_db: + episodes = client.get_all_episodes_for_series(series_id) else: - episodes = self.sonarr.episodes_for_series(series_id) - + episodes = client.episodes_for_series(series_id) + # Convert episodes_filter to set for faster lookup filter_set = set(episodes_filter) if episodes_filter else None @@ -460,7 +482,7 @@ def _get_sonarr_episodes(self, imdb_id: str, episodes_filter: List[Tuple[int, in # First try to get import date from history (more accurate) episode_id = episode.get('id') if episode_id and episode.get('hasFile'): - import_date = self.get_episode_import_history(episode_id) + import_date = self.get_episode_import_history(episode_id, instance=instance) api_calls_made += 1 if import_date: episode_data['dateAdded'] = import_date @@ -680,20 +702,20 @@ def process_webhook_episodes(self, series_path: Path, webhook_episodes: List[Dic if not imdb_id: imdb_id = parse_imdb_from_path(series_path) if not imdb_id: - _log("ERROR", f"No IMDb ID found in series path: {series_path}") + _log("ERROR", f"[{instance}] No IMDb ID found in series path: {series_path}") return if not webhook_episodes: - _log("WARNING", f"No episodes in webhook, falling back to series processing: {series_path}") + _log("WARNING", f"[{instance}] No episodes in webhook, falling back to series processing: {series_path}") self.process_series(series_path, instance=instance) return - _log("INFO", f"Processing {len(webhook_episodes)} webhook episodes for: {series_path.name} (IMDb: {imdb_id})") + _log("INFO", f"[{instance}] Processing {len(webhook_episodes)} webhook episodes for: {series_path.name} (IMDb: {imdb_id})") self.db.upsert_series(imdb_id, str(series_path), instance=instance) # Get enhanced metadata from Sonarr - series_metadata = self._get_sonarr_series_metadata(imdb_id) + series_metadata = self._get_sonarr_series_metadata(imdb_id, instance=instance) episodes_processed = 0 for webhook_episode in webhook_episodes: @@ -701,15 +723,15 @@ def process_webhook_episodes(self, series_path: Path, webhook_episodes: List[Dic episode_num = webhook_episode.get("episodeNumber") if not season_num or not episode_num: - _log("WARNING", f"Invalid episode data in webhook: {webhook_episode}") + _log("WARNING", f"[{instance}] Invalid episode data in webhook: {webhook_episode}") continue - + # Check if episode file exists on disk season_dir = series_path / config.get_season_dir_name(season_num) if not season_dir.exists(): - _log("WARNING", f"Season directory not found: {season_dir}") + _log("WARNING", f"[{instance}] Season directory not found: {season_dir}") continue - + # Find matching episode files episode_files = [] for file_path in season_dir.iterdir(): @@ -717,27 +739,27 @@ def process_webhook_episodes(self, series_path: Path, webhook_episodes: List[Dic parsed = self._parse_episode_from_filename(file_path.name) if parsed and parsed == (season_num, episode_num): episode_files.append(file_path) - + if not episode_files: - _log("WARNING", f"No video files found for S{season_num:02d}E{episode_num:02d}") + _log("WARNING", f"[{instance}] No video files found for S{season_num:02d}E{episode_num:02d}") continue - + # Get episode date information - webhook processing prioritizes existing DB entries - _log("DEBUG", f"Processing webhook episode: IMDb={imdb_id}, S{season_num:02d}E{episode_num:02d}") + _log("DEBUG", f"[{instance}] Processing webhook episode: IMDb={imdb_id}, S{season_num:02d}E{episode_num:02d}") aired, dateadded, source = self._get_webhook_episode_date(imdb_id, season_num, episode_num, series_metadata, webhook_episode, instance=instance) - enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num, season_dir) if series_metadata else self._get_episode_metadata(None, season_num, episode_num, season_dir) - + enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num, season_dir, instance=instance) if series_metadata else self._get_episode_metadata(None, season_num, episode_num, season_dir, instance=instance) + # NFO file operations removed - database is now the single source of truth # (Phase 1: Remove NFO file write operations) - + self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True, instance=instance) verification = self.db.get_episode_date(imdb_id, season_num, episode_num, instance) if verification: - _log("DEBUG", f"Verified database entry saved: S{season_num:02d}E{episode_num:02d} -> {verification['dateadded']}") + _log("DEBUG", f"[{instance}] Verified database entry saved: S{season_num:02d}E{episode_num:02d} -> {verification['dateadded']}") else: - _log("ERROR", f"Failed to save episode to database: S{season_num:02d}E{episode_num:02d}") - + _log("ERROR", f"[{instance}] Failed to save episode to database: S{season_num:02d}E{episode_num:02d}") + episodes_processed += 1 # DISABLED: Skip creating season.nfo and tvshow.nfo files (user only wants episode NFOs) @@ -755,7 +777,7 @@ def process_webhook_episodes(self, series_path: Path, webhook_episodes: List[Dic # tvdb_id = self.external_clients.get_tvdb_series_id(imdb_id) # self.nfo_manager.create_tvshow_nfo(series_path, imdb_id, tvdb_id) - _log("INFO", f"Completed targeted processing: {episodes_processed}/{len(webhook_episodes)} episodes processed") + _log("INFO", f"[{instance}] Completed targeted processing: {episodes_processed}/{len(webhook_episodes)} episodes processed") def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]: """Parse season and episode numbers from filename""" @@ -771,17 +793,18 @@ def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int return None - def _get_sonarr_series_metadata(self, imdb_id: str) -> Optional[Dict[str, Any]]: + def _get_sonarr_series_metadata(self, imdb_id: str, instance: str = 'sonarr') -> Optional[Dict[str, Any]]: """Get enhanced series metadata from Sonarr (database or API)""" try: - if not self.sonarr: + client, using_db, _ = self._resolve_sonarr(instance) + if not client: return None # Handle different method names for DB vs API client - if self.using_db: - series_data = self.sonarr.get_series_by_imdb(imdb_id) + if using_db: + series_data = client.get_series_by_imdb(imdb_id) else: - series_data = self.sonarr.series_by_imdb(imdb_id) + series_data = client.series_by_imdb(imdb_id) if not series_data: return None @@ -791,10 +814,10 @@ def _get_sonarr_series_metadata(self, imdb_id: str) -> Optional[Dict[str, Any]]: return None # Get all episodes for this series - if self.using_db: - episodes = self.sonarr.get_all_episodes_for_series(series_id) + if using_db: + episodes = client.get_all_episodes_for_series(series_id) else: - episodes = self.sonarr.episodes_for_series(series_id) + episodes = client.episodes_for_series(series_id) # Organize episodes by season/episode episode_map = {} @@ -811,15 +834,15 @@ def _get_sonarr_series_metadata(self, imdb_id: str) -> Optional[Dict[str, Any]]: } except Exception as e: - _log("ERROR", f"Failed to get Sonarr series metadata for {imdb_id}: {e}") + _log("ERROR", f"[{instance}] Failed to get Sonarr series metadata for {imdb_id}: {e}") return None - - def _get_episode_metadata(self, series_metadata: Optional[Dict[str, Any]], season_num: int, episode_num: int, season_dir: Optional[Path] = None) -> Optional[Dict[str, Any]]: + + def _get_episode_metadata(self, series_metadata: Optional[Dict[str, Any]], season_num: int, episode_num: int, season_dir: Optional[Path] = None, instance: str = 'sonarr') -> Optional[Dict[str, Any]]: """Get enhanced episode metadata including title extraction from filename""" - _log("DEBUG", f"Getting episode metadata for S{season_num:02d}E{episode_num:02d}, season_dir: {season_dir}") - + _log("DEBUG", f"[{instance}] Getting episode metadata for S{season_num:02d}E{episode_num:02d}, season_dir: {season_dir}") + metadata = {} - + # Try to get title from Sonarr first if series_metadata and 'episodes' in series_metadata: episode_data = series_metadata['episodes'].get((season_num, episode_num)) @@ -827,27 +850,27 @@ def _get_episode_metadata(self, series_metadata: Optional[Dict[str, Any]], seaso title = episode_data.get('title') if title and title != 'TBA': metadata['title'] = title - _log("DEBUG", f"Got title from Sonarr for S{season_num:02d}E{episode_num:02d}: {title}") - + _log("DEBUG", f"[{instance}] Got title from Sonarr for S{season_num:02d}E{episode_num:02d}: {title}") + # If no title from Sonarr, try to extract from filename if 'title' not in metadata and season_dir: - title = self._extract_title_from_filename(season_num, episode_num, season_dir) + title = self._extract_title_from_filename(season_num, episode_num, season_dir, instance=instance) if title: metadata['title'] = title - _log("DEBUG", f"Extracted title from filename for S{season_num:02d}E{episode_num:02d}: {title}") - + _log("DEBUG", f"[{instance}] Extracted title from filename for S{season_num:02d}E{episode_num:02d}: {title}") + return metadata if metadata else None - - def _extract_title_from_filename(self, season_num: int, episode_num: int, season_dir: Path) -> Optional[str]: + + def _extract_title_from_filename(self, season_num: int, episode_num: int, season_dir: Path, instance: str = 'sonarr') -> Optional[str]: """Extract episode title from video filename using regex pattern""" season_pattern = f"S{season_num:02d}E{episode_num:02d}" - + try: # Look for video files in the season directory for file_path in season_dir.iterdir(): if file_path.is_file() and file_path.suffix.lower() in ('.mkv', '.mp4', '.avi', '.mov', '.m4v'): filename = file_path.name - + # Check if this file matches our season/episode if season_pattern in filename.upper(): # Extract title using regex pattern: S01E01-Title[WEBDL-1080p] @@ -857,12 +880,12 @@ def _extract_title_from_filename(self, season_num: int, episode_num: int, season # Clean up the title title = title.replace('-', ' ').strip() if title: - _log("DEBUG", f"Extracted title '{title}' from filename: {filename}") + _log("DEBUG", f"[{instance}] Extracted title '{title}' from filename: {filename}") return title - + except Exception as e: - _log("ERROR", f"Error extracting title from filename for S{season_num:02d}E{episode_num:02d}: {e}") - + _log("ERROR", f"[{instance}] Error extracting title from filename for S{season_num:02d}E{episode_num:02d}: {e}") + return None def _get_webhook_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict[str, Any]] = None, webhook_episode: Optional[Dict[str, Any]] = None, instance: str = 'sonarr') -> Tuple[Optional[str], Optional[str], str]: @@ -887,7 +910,7 @@ def _get_webhook_episode_date(self, imdb_id: str, season_num: int, episode_num: existing_entry = self.db.get_episode_date(imdb_id, season_num, episode_num, instance) if existing_entry and existing_entry.get('dateadded'): existing_date = existing_entry['dateadded'] - _log("INFO", f"Found existing date in database for S{season_num:02d}E{episode_num:02d}: {existing_date}") + _log("INFO", f"[{instance}] Found existing date in database for S{season_num:02d}E{episode_num:02d}: {existing_date}") # For webhook processing, use existing data (fast path) # Manual scans will validate below @@ -901,10 +924,10 @@ def _get_webhook_episode_date(self, imdb_id: str, season_num: int, episode_num: return aired, str(existing_date), "database:existing" except Exception as e: - _log("DEBUG", f"Database check failed for S{season_num:02d}E{episode_num:02d}: {e}") - - _log("INFO", f"No existing date in database for S{season_num:02d}E{episode_num:02d}, checking Sonarr import history") - + _log("DEBUG", f"[{instance}] Database check failed for S{season_num:02d}E{episode_num:02d}: {e}") + + _log("INFO", f"[{instance}] No existing date in database for S{season_num:02d}E{episode_num:02d}, checking Sonarr import history") + # Get aired date and episode ID from Sonarr aired = None episode_id = None @@ -913,53 +936,54 @@ def _get_webhook_episode_date(self, imdb_id: str, season_num: int, episode_num: if episode_data: aired = episode_data.get('airDate') episode_id = episode_data.get('id') - _log("DEBUG", f"Got aired date from Sonarr for S{season_num:02d}E{episode_num:02d}: {aired}") - + _log("DEBUG", f"[{instance}] Got aired date from Sonarr for S{season_num:02d}E{episode_num:02d}: {aired}") + # STEP 2: Check Sonarr import history (authoritative source) - _log("INFO", f"Checking Sonarr import history for S{season_num:02d}E{episode_num:02d}") - - if episode_id and hasattr(self, 'sonarr'): + _log("INFO", f"[{instance}] Checking Sonarr import history for S{season_num:02d}E{episode_num:02d}") + + client, _, _ = self._resolve_sonarr(instance) + if episode_id and client: try: - _log("DEBUG", f"Calling get_episode_import_history for episode_id: {episode_id}") - import_history = self.get_episode_import_history(episode_id) - _log("DEBUG", f"Import history result: {import_history}") - + _log("DEBUG", f"[{instance}] Calling get_episode_import_history for episode_id: {episode_id}") + import_history = self.get_episode_import_history(episode_id, instance=instance) + _log("DEBUG", f"[{instance}] Import history result: {import_history}") + if import_history: # Found actual import event from Sonarr - _log("INFO", f"Found Sonarr import event for S{season_num:02d}E{episode_num:02d}: {import_history}") + _log("INFO", f"[{instance}] Found Sonarr import event for S{season_num:02d}E{episode_num:02d}: {import_history}") return aired, import_history, "sonarr:import_history" else: # No import events found - this means only renames/moves exist in history # The episode was already in Sonarr, just being managed/renamed - _log("INFO", f"No import events found for S{season_num:02d}E{episode_num:02d} - only renames/moves in history") - + _log("INFO", f"[{instance}] No import events found for S{season_num:02d}E{episode_num:02d} - only renames/moves in history") + if aired: - _log("INFO", f"Using air date for existing episode (rename-only history): {aired}") + _log("INFO", f"[{instance}] Using air date for existing episode (rename-only history): {aired}") return aired, aired + "T20:00:00", "airdate" else: - _log("DEBUG", f"No air date available for rename-only episode S{season_num:02d}E{episode_num:02d}") - + _log("DEBUG", f"[{instance}] No air date available for rename-only episode S{season_num:02d}E{episode_num:02d}") + except Exception as e: - _log("ERROR", f"Error checking Sonarr import history for S{season_num:02d}E{episode_num:02d}: {e}") + _log("ERROR", f"[{instance}] Error checking Sonarr import history for S{season_num:02d}E{episode_num:02d}: {e}") import traceback _log("ERROR", traceback.format_exc()) else: if not episode_id: - _log("DEBUG", f"No episode_id found for S{season_num:02d}E{episode_num:02d}") - if not hasattr(self, 'sonarr'): - _log("DEBUG", f"No sonarr client available") - + _log("DEBUG", f"[{instance}] No episode_id found for S{season_num:02d}E{episode_num:02d}") + if not client: + _log("DEBUG", f"[{instance}] No sonarr client available") + # Check our database to avoid duplicates existing = self.db.get_episode_date(imdb_id, season_num, episode_num, instance) if existing and existing.get('dateadded'): - _log("INFO", f"Episode S{season_num:02d}E{episode_num:02d} already exists in our database: {existing['dateadded']}") + _log("INFO", f"[{instance}] Episode S{season_num:02d}E{episode_num:02d} already exists in our database: {existing['dateadded']}") return existing.get('aired'), existing.get('dateadded'), existing.get('source', 'chronarr:database') - + # STEP 3: Truly new episode - use webhook date dateadded = datetime.now().isoformat() source = "sonarr:webhook" - - _log("INFO", f"No import history and not in database - using webhook date for genuinely new episode S{season_num:02d}E{episode_num:02d}: {dateadded}") + + _log("INFO", f"[{instance}] No import history and not in database - using webhook date for genuinely new episode S{season_num:02d}E{episode_num:02d}: {dateadded}") return aired, dateadded, source diff --git a/start_web.py b/start_web.py index 4719c03..66d3dd6 100644 --- a/start_web.py +++ b/start_web.py @@ -11,6 +11,12 @@ from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse +# utils.logging has to be imported before config.settings — importing it is +# what actually loads .env/.env.secrets into the process (a side effect at +# the bottom of that module). See the same note in main.py for the full +# explanation of why this ordering matters. +from utils.logging import _log + # Import existing configuration (keep using core config for simplicity) from config.settings import config diff --git a/static/index.html b/static/index.html index 55f2fea..22cf329 100644 --- a/static/index.html +++ b/static/index.html @@ -444,6 +444,15 @@

Populate Database

+
+ + +

+ Picking a specific instance populates only that one and sets Media Type for you (a Radarr instance is always movies, a Sonarr instance always TV). +

+
diff --git a/static/js/app.js b/static/js/app.js index 9b9db2c..65c9c34 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -54,7 +54,7 @@ function switchTab(tabName) { case 'movies': loadMovies(); break; case 'tv': loadSeries(); break; case 'reports': loadReport(); break; - case 'tools': loadDetailedStats(); break; + case 'tools': loadDetailedStats(); loadPopulateInstanceOptions(); break; } } @@ -139,6 +139,10 @@ function initializeEventListeners() { document.getElementById('bulk-update-form').addEventListener('submit', handleBulkUpdate); document.getElementById('manual-scan-form').addEventListener('submit', handleManualScan); document.getElementById('populate-form').addEventListener('submit', handlePopulateDatabase); + const populateInstanceSelect = document.getElementById('populate-instance'); + if (populateInstanceSelect) { + populateInstanceSelect.addEventListener('change', syncPopulateMediaTypeToInstance); + } } // API calls @@ -1867,10 +1871,86 @@ async function bulkDeleteSelected() { // Database Population Functions // --------------------------- +// Fills the Instance dropdown on the Populate Database card from the same +// /api/instances data the sidebar already uses — "All Instances" plus one +// entry per configured Radarr/Sonarr instance, grouped by type so it's +// obvious which is which once there's more than a couple. +async function loadPopulateInstanceOptions() { + const select = document.getElementById('populate-instance'); + if (!select) return; + + try { + const data = await apiCall('/api/instances'); + const radarrNames = (data.radarr || []).map(i => i.name); + const sonarrNames = (data.sonarr || []).map(i => i.name); + + // Keep "All Instances", drop everything else and rebuild — this + // gets called every time the Tools tab is opened, so it has to be + // safe to run repeatedly without piling up duplicate options. + select.innerHTML = ''; + + if (radarrNames.length) { + const group = document.createElement('optgroup'); + group.label = 'Radarr'; + radarrNames.forEach(name => { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + opt.dataset.mediaType = 'movies'; + group.appendChild(opt); + }); + select.appendChild(group); + } + + if (sonarrNames.length) { + const group = document.createElement('optgroup'); + group.label = 'Sonarr'; + sonarrNames.forEach(name => { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + opt.dataset.mediaType = 'tv'; + group.appendChild(opt); + }); + select.appendChild(group); + } + + // Reset in case the last selection here no longer exists (e.g. an + // instance was deleted since this was last loaded). + syncPopulateMediaTypeToInstance(); + } catch (error) { + console.error('Failed to load instances for populate dropdown:', error); + // Leave just "All Instances" — populate still works, just without + // the ability to target a single instance until this loads. + } +} + +function syncPopulateMediaTypeToInstance() { + const instanceSelect = document.getElementById('populate-instance'); + const mediaTypeSelect = document.getElementById('populate-media-type'); + if (!instanceSelect || !mediaTypeSelect) return; + + const selectedOption = instanceSelect.options[instanceSelect.selectedIndex]; + const mediaType = selectedOption ? selectedOption.dataset.mediaType : null; + + if (instanceSelect.value !== 'all' && mediaType) { + // A specific instance can only ever be one media type — a Radarr + // instance is movies, a Sonarr instance is TV. Lock the Media Type + // dropdown to match instead of making the user keep the two in sync + // by hand (picking the wrong one used to silently populate nothing). + mediaTypeSelect.value = mediaType; + mediaTypeSelect.disabled = true; + } else { + mediaTypeSelect.disabled = false; + } +} + async function handlePopulateDatabase(event) { event.preventDefault(); const mediaType = document.getElementById('populate-media-type').value; + const instanceSelect = document.getElementById('populate-instance'); + const instance = instanceSelect ? instanceSelect.value : 'all'; // Validate input if (!mediaType) { @@ -1879,7 +1959,8 @@ async function handlePopulateDatabase(event) { } // Confirm with user - const confirmMsg = `Are you sure you want to populate the database with ${mediaType}? This will query Radarr/Sonarr and may take several minutes.`; + const target = instance !== 'all' ? `instance '${instance}'` : `${mediaType}`; + const confirmMsg = `Are you sure you want to populate the database with ${target}? This will query Radarr/Sonarr and may take several minutes.`; if (!confirm(confirmMsg)) { return; } @@ -1890,7 +1971,7 @@ async function handlePopulateDatabase(event) { // Start the population showToast('🚀 Starting database population...', 'info'); - const response = await fetch(`/admin/populate-database?media_type=${mediaType}`, { + const response = await fetch(`/admin/populate-database?media_type=${mediaType}&instance=${encodeURIComponent(instance)}`, { method: 'POST', credentials: 'include' }); diff --git a/static/setup.html b/static/setup.html index 5aa7c79..222c0bc 100644 --- a/static/setup.html +++ b/static/setup.html @@ -157,6 +157,20 @@ background: var(--success-color); } + .instance-row-actions { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + } + + .btn-danger { + background: var(--danger-color); + } + + .btn-danger:hover { + background: #c0392b; + } + .empty-note { color: var(--text-muted); font-size: 0.9rem; @@ -208,6 +222,170 @@ grid-template-columns: 1fr; } } + + /* --- Add Instance wizard --- */ + + .btn-add-instance { + margin-top: 0.75rem; + padding: 0.45rem 0.9rem; + background: none; + border: 1px dashed var(--border-color); + border-radius: 6px; + color: #667eea; + font-size: 0.85rem; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.4rem; + } + + .btn-add-instance:hover { + background: var(--light-color); + } + + .add-instance-form { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color); + } + + .form-row { + margin-bottom: 0.85rem; + } + + .form-row label { + display: block; + font-size: 0.85rem; + font-weight: 500; + color: var(--dark-color); + margin-bottom: 0.3rem; + } + + .form-row input, + .form-row select { + width: 100%; + padding: 0.45rem 0.7rem; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 0.9rem; + font-family: inherit; + box-sizing: border-box; + } + + .form-row input:focus, + .form-row select:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.15); + } + + .field-hint { + font-size: 0.78rem; + color: var(--text-muted); + margin-top: 0.25rem; + } + + .db-toggle-row { + margin: 1rem 0 0.5rem; + font-size: 0.88rem; + } + + .db-toggle-row label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-weight: 500; + } + + .db-fields { + margin-top: 0.75rem; + padding: 0.85rem; + background: var(--light-color); + border-radius: 6px; + } + + .wizard-actions { + display: flex; + gap: 0.6rem; + margin-top: 1rem; + flex-wrap: wrap; + } + + .wizard-actions button { + padding: 0.5rem 1rem; + border-radius: 5px; + font-size: 0.88rem; + cursor: pointer; + border: none; + } + + .btn-primary { + background: #667eea; + color: white; + } + + .btn-primary:hover { + background: #5a6fd6; + } + + .btn-primary:disabled { + background: #a9b4e8; + cursor: not-allowed; + } + + .btn-secondary { + background: var(--light-color); + color: var(--dark-color); + border: 1px solid var(--border-color) !important; + } + + .btn-secondary:hover { + background: #e9ecef; + } + + .btn-cancel { + background: none; + color: var(--text-muted); + } + + .btn-cancel:hover { + text-decoration: underline; + } + + .wizard-result { + margin-top: 0.85rem; + font-size: 0.85rem; + line-height: 1.5; + } + + .wizard-result:empty { + margin-top: 0; + } + + .wizard-result .result-success { + color: #155724; + background: #d4edda; + border-radius: 6px; + padding: 0.65rem 0.85rem; + } + + .wizard-result .result-error { + color: #721c24; + background: #f8d7da; + border-radius: 6px; + padding: 0.65rem 0.85rem; + } + + .wizard-result .result-testing { + color: var(--text-muted); + } + + .test-badge { + display: inline-block; + font-size: 0.8rem; + margin-right: 0.75rem; + } @@ -253,6 +431,36 @@

Sonarr Instances

Loading… + + +
+

Add Instance

+ +
+
+ + +
+

Backup & Restore Config

+

+ A snapshot of .env and .env.secrets exactly as they are right now — + every comment and setting, byte for byte. The downloaded file contains your real API keys and + database passwords in plain text, so store it the same way you'd store either of those files. + Every wizard save above also takes an automatic snapshot first, so a bad save is never a dead end. +

+
+ + Download Backup + + + +
+
+
@@ -273,7 +481,15 @@

Sonarr Instances

return base + path; } - function renderInstances(containerId, instances, serviceLabel) { + // The full instance name (e.g. "sonarr_strm") minus its media-type + // prefix is the bare segment the wizard's name field wants (e.g. + // "strm"). The default instance (name == media type exactly) has + // no segment at all. + function bareNameSegment(mediaType, fullName) { + return fullName === mediaType ? '' : fullName.slice(mediaType.length + 1); + } + + function renderInstances(containerId, instances, serviceLabel, mediaType) { const container = document.getElementById(containerId); if (!instances || instances.length === 0) { container.innerHTML = `No ${serviceLabel} instances configured.`; @@ -295,6 +511,7 @@

Sonarr Instances

: ''; const webhookUrl = buildWebhookUrl(inst.webhook_path); + const segment = bareNameSegment(mediaType, inst.name); row.innerHTML = `
@@ -304,9 +521,17 @@

Sonarr Instances

- +
+ + + +
`; container.appendChild(row); } @@ -322,23 +547,46 @@

Sonarr Instances

}); } + function showCopied(btn) { + btn.classList.add('copied'); + const originalHtml = btn.innerHTML; + btn.innerHTML = ' Copied'; + setTimeout(() => { + btn.innerHTML = originalHtml; + btn.classList.remove('copied'); + }, 1800); + } + function copyUrl(btn) { const input = btn.closest('.instance-row').querySelector('.url-field'); if (!input) return; - navigator.clipboard.writeText(input.value).then(() => { - btn.classList.add('copied'); - const icon = btn.querySelector('i'); - const originalHtml = btn.innerHTML; - btn.innerHTML = ' Copied'; - setTimeout(() => { - btn.innerHTML = originalHtml; - btn.classList.remove('copied'); - }, 1800); - }).catch(() => { - // Fallback for older browsers / HTTP contexts + + // navigator.clipboard doesn't just reject on an insecure origin + // (plain HTTP on anything but localhost) — it's often undefined + // entirely, so accessing .writeText() throws synchronously, + // before a .then()/.catch() chain ever gets attached. Most + // people reaching this page over their LAN are on plain HTTP, + // so check for the API rather than assuming it exists. + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(input.value).then(() => { + showCopied(btn); + }).catch(() => { + fallbackCopy(input, btn); + }); + } else { + fallbackCopy(input, btn); + } + } + + function fallbackCopy(input, btn) { + try { input.select(); + input.setSelectionRange(0, input.value.length); document.execCommand('copy'); - }); + showCopied(btn); + } catch (err) { + alert(`Could not copy automatically — select and copy manually:\n\n${input.value}`); + } } function escHtml(str) { @@ -354,8 +602,8 @@

Sonarr Instances

const resp = await fetch('/api/instances'); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); - renderInstances('radarr-list', data.radarr || [], 'Radarr'); - renderInstances('sonarr-list', data.sonarr || [], 'Sonarr'); + renderInstances('radarr-list', data.radarr || [], 'Radarr', 'radarr'); + renderInstances('sonarr-list', data.sonarr || [], 'Sonarr', 'sonarr'); } catch (err) { const msg = ` Could not load instance data: ${escHtml(err.message)}`; document.getElementById('radarr-list').innerHTML = msg; @@ -364,6 +612,454 @@

Sonarr Instances

} loadInstances(); + + // --- Add Instance wizard --- + // + // One form template shared by both Radarr and Sonarr — the only real + // difference between the two is the paths field label (movies vs TV) + // and a couple of placeholder strings, so it's simpler to generate + // the HTML once per media type than to keep two near-identical forms + // in sync by hand. + + // FastAPI's own detail is a plain string, but a Pydantic validation + // error (a required field missing, wrong type, etc.) comes back as a + // list of {msg, loc, ...} objects instead — stringify that properly + // rather than letting it print as "[object Object]". + function formatErrorDetail(detail) { + if (!detail) return null; + if (typeof detail === 'string') return detail; + if (Array.isArray(detail)) { + return detail.map(d => d.msg || JSON.stringify(d)).join('; '); + } + return JSON.stringify(detail); + } + + // One form for both Radarr and Sonarr — a Media Type dropdown at the + // top drives which labels/placeholders show and which payload field + // (movie_paths vs tv_paths) the paths box maps to. Fixed "instance-add-*" + // ids throughout since there's only ever one copy of this form now; + // switching the dropdown re-labels in place rather than rebuilding + // the form, so nothing already typed gets lost by picking the wrong + // type first. + + function addFormTemplate() { + return ` +
+
+ + +
+
+ + +

Letters, numbers, and underscores only — it becomes part of an env var name and the webhook URL.

+
+
+ + +
+
+ + +
+
+ + +

Leave blank and hit Test Connection — filled in automatically from Radarr/Sonarr's own root folders.

+
+
+ + +
+ +
+ +
+ + +
+ + + +
+
+
+ `; + } + + function toggleAddForm() { + const container = document.getElementById('instance-add-form'); + const toggleBtn = document.getElementById('instance-add-toggle'); + if (container.innerHTML.trim()) { + // Already open — collapse it back to the "+ Add" button. + container.innerHTML = ''; + toggleBtn.hidden = false; + return; + } + container.innerHTML = addFormTemplate(); + // container itself survives innerHTML swaps, so any leftover + // editMode flag from a previous Edit needs clearing here — + // opening via the plain "+ Add Instance" button always means a + // real add, never a continuation of an earlier edit. + container.dataset.editMode = 'false'; + toggleBtn.hidden = true; + } + + // Opens the Add Instance form pre-filled with one instance's current + // settings and switches it into edit mode — Name and Media Type get + // locked (editing doesn't rename or retype an instance), everything + // else stays editable. mediaType/segment identify which instance; + // segment is "" for the default (unprefixed) instance. + async function startEditInstance(mediaType, segment) { + const container = document.getElementById('instance-add-form'); + container.innerHTML = addFormTemplate(); + document.getElementById('instance-add-toggle').hidden = true; + + const resultEl = document.getElementById('instance-add-result'); + resultEl.innerHTML = `Loading current settings…`; + + try { + const resp = await fetch( + `/api/wizard/instance-details?media_type=${encodeURIComponent(mediaType)}&name=${encodeURIComponent(segment)}` + ); + const data = await resp.json(); + if (!resp.ok) throw new Error(formatErrorDetail(data.detail) || `HTTP ${resp.status}`); + + document.getElementById('instance-add-type').value = mediaType; + document.getElementById('instance-add-type').disabled = true; + onInstanceTypeChange(); + + document.getElementById('instance-add-name').value = segment; + document.getElementById('instance-add-name').disabled = true; + document.getElementById('instance-add-url').value = data.url || ''; + document.getElementById('instance-add-api-key').value = data.api_key || ''; + document.getElementById('instance-add-root-folders').value = (data.root_folders || []).join(','); + + const paths = mediaType === 'radarr' ? data.movie_paths : data.tv_paths; + document.getElementById('instance-add-paths').value = (paths || []).join(','); + + if (data.db_type) { + document.getElementById('instance-add-db-enable').checked = true; + toggleDbFields(); + document.getElementById('instance-add-db-type').value = data.db_type; + toggleDbTypeFields(); + if (data.db_type === 'sqlite') { + document.getElementById('instance-add-db-path').value = data.db_path || ''; + } else { + document.getElementById('instance-add-db-host').value = data.db_host || ''; + document.getElementById('instance-add-db-port').value = data.db_port || ''; + document.getElementById('instance-add-db-name').value = data.db_name || ''; + document.getElementById('instance-add-db-user').value = data.db_user || ''; + document.getElementById('instance-add-db-password').value = data.db_password || ''; + } + } + + container.dataset.editMode = 'true'; + document.getElementById('instance-add-save-btn').textContent = 'Save Changes'; + + const fullName = segment ? `${mediaType}_${segment}` : mediaType; + resultEl.innerHTML = `
Editing ${escHtml(fullName)} — change what you need, then Save Changes.
`; + container.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } catch (err) { + resultEl.innerHTML = `
Could not load current settings: ${escHtml(err.message)}
`; + } + } + + async function deleteInstance(mediaType, segment, fullName) { + const confirmed = confirm( + `Delete ${fullName}? This removes it from .env and .env.secrets.\n\n` + + `A snapshot of the current files is taken first, so this can be undone by restoring ` + + `that backup from the Backup & Restore section below.\n\n` + + `Note: if this instance's env vars were already present when the container was created, ` + + `a plain restart may not fully remove it — you may need to recreate the container ` + + `(docker compose up -d --force-recreate).` + ); + if (!confirmed) return; + + try { + const resp = await fetch('/api/wizard/delete-instance', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ media_type: mediaType, name: segment }), + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(formatErrorDetail(data.detail) || `HTTP ${resp.status}`); + + alert(`Removed ${data.instance_name} from the config files.\n\n${data.restart_reason}`); + loadInstances(); + } catch (err) { + alert(`Could not delete ${fullName}: ${err.message}`); + } + } + + // Re-labels the form in place for the currently selected media type. + // Doesn't touch any entered values — only the labels/placeholders/ + // hints that describe which service they're for. + function onInstanceTypeChange() { + const mediaType = document.getElementById('instance-add-type').value; + const isRadarr = mediaType === 'radarr'; + const label = isRadarr ? 'Radarr' : 'Sonarr'; + const defaultPort = isRadarr ? '7878' : '8989'; + + document.getElementById('instance-add-url-label').textContent = `${label} URL`; + document.getElementById('instance-add-url').placeholder = `http://${mediaType}:${defaultPort}`; + document.getElementById('instance-add-api-key').placeholder = `Found in ${label} → Settings → General`; + document.getElementById('instance-add-root-folders').placeholder = `What ${label} sees on its side, comma-separated for more than one`; + document.getElementById('instance-add-db-toggle-label').textContent = + `Connect directly to ${label}'s database (faster than the API, optional)`; + document.getElementById('instance-add-db-path').placeholder = + `/${mediaType}-${isRadarr ? '4k' : 'strm'}-data/${mediaType}.db`; + + const pathsLabel = document.getElementById('instance-add-paths-label'); + const pathsInput = document.getElementById('instance-add-paths'); + if (isRadarr) { + pathsLabel.textContent = 'Movie Paths'; + pathsInput.placeholder = 'Container paths where movies live, comma-separated (e.g. /data/movies)'; + } else { + pathsLabel.textContent = 'TV Paths'; + pathsInput.placeholder = 'Container paths where TV shows live, comma-separated (e.g. /data/tv)'; + } + } + + function toggleDbFields() { + const enabled = document.getElementById('instance-add-db-enable').checked; + document.getElementById('instance-add-db-fields').hidden = !enabled; + if (enabled) toggleDbTypeFields(); + } + + function toggleDbTypeFields() { + const isSqlite = document.getElementById('instance-add-db-type').value === 'sqlite'; + document.getElementById('instance-add-db-path-row').hidden = !isSqlite; + document.getElementById('instance-add-db-pg-fields').hidden = isSqlite; + } + + // Comma-separated text field -> array of trimmed, non-empty strings. + function splitCsv(value) { + return (value || '').split(',').map(s => s.trim()).filter(Boolean); + } + + // Read every field currently in the form into the shape the wizard + // API expects. Shared by the test and save calls so they can never + // drift apart on what a field means. + function collectWizardPayload() { + const field = id => document.getElementById(id).value.trim(); + const mediaType = document.getElementById('instance-add-type').value; + const editMode = document.getElementById('instance-add-form').dataset.editMode === 'true'; + + const payload = { + media_type: mediaType, + name: field('instance-add-name'), + url: field('instance-add-url'), + api_key: field('instance-add-api-key'), + root_folders: splitCsv(field('instance-add-root-folders')), + edit_existing: editMode, + }; + + const paths = splitCsv(field('instance-add-paths')); + if (mediaType === 'radarr') { + payload.movie_paths = paths; + } else { + payload.tv_paths = paths; + } + + if (document.getElementById('instance-add-db-enable').checked) { + payload.db_type = document.getElementById('instance-add-db-type').value; + if (payload.db_type === 'sqlite') { + payload.db_path = field('instance-add-db-path'); + } else { + payload.db_host = field('instance-add-db-host'); + const port = field('instance-add-db-port'); + if (port) payload.db_port = parseInt(port, 10); + payload.db_name = field('instance-add-db-name'); + payload.db_user = field('instance-add-db-user'); + payload.db_password = field('instance-add-db-password'); + } + } + + return payload; + } + + async function testWizardConnection() { + const resultEl = document.getElementById('instance-add-result'); + const btn = document.getElementById('instance-add-test-btn'); + const payload = collectWizardPayload(); + + if (!payload.url || !payload.api_key) { + resultEl.innerHTML = `
URL and API key are required before testing.
`; + return; + } + + btn.disabled = true; + resultEl.innerHTML = `Testing…`; + + try { + const resp = await fetch('/api/wizard/test-connection', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(formatErrorDetail(data.detail) || `HTTP ${resp.status}`); + + const parts = []; + if (data.api) { + parts.push(data.api.success + ? `✅ API connection OK` + : `❌ API: ${escHtml(data.api.error)}`); + + // Radarr/Sonarr already know their own root folders — no + // reason to make someone retype what the API already + // has. Only fills the field if it's still empty, so this + // never overwrites something the user already typed. + if (data.api.success && data.api.root_folders && data.api.root_folders.length) { + const rootFoldersInput = document.getElementById('instance-add-root-folders'); + if (rootFoldersInput && !rootFoldersInput.value.trim()) { + rootFoldersInput.value = data.api.root_folders.join(','); + parts.push(`📁 Found ${data.api.root_folders.length} root folder(s), filled in below`); + } + } + } + if (data.db) { + parts.push(data.db.success + ? `✅ Database connection OK` + : `❌ Database: ${escHtml(data.db.error)}`); + } + resultEl.innerHTML = parts.length ? parts.join('') : 'Nothing to test yet — fill in a URL/API key or enable the database section.'; + } catch (err) { + resultEl.innerHTML = `
${escHtml(err.message)}
`; + } finally { + btn.disabled = false; + } + } + + async function saveWizardInstance() { + const resultEl = document.getElementById('instance-add-result'); + const saveBtn = document.getElementById('instance-add-save-btn'); + const payload = collectWizardPayload(); + + if (!payload.url || !payload.api_key) { + resultEl.innerHTML = `
URL and API key are required.
`; + return; + } + + saveBtn.disabled = true; + resultEl.innerHTML = `Saving…`; + + try { + const resp = await fetch('/api/wizard/save-instance', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(formatErrorDetail(data.detail) || `HTTP ${resp.status}`); + + resultEl.innerHTML = ` +
+ Added ${escHtml(data.instance_name)}. + Webhook path: ${escHtml(data.webhook_path)}
+ ${escHtml(data.restart_reason)} +
+ `; + // The new instance isn't live until chronarr-core restarts, so + // there's nothing more to do here — leave the success message + // up rather than collapsing the form, and refresh the instance + // list in case a restart already happened in the background. + loadInstances(); + } catch (err) { + resultEl.innerHTML = `
${escHtml(err.message)}
`; + } finally { + saveBtn.disabled = false; + } + } + + // --- Backup & Restore --- + + function handleRestoreFileChosen(event) { + const file = event.target.files[0]; + // Clear the input now so choosing the same filename again later + // still fires this handler — browsers don't fire `change` for + // an unchanged value otherwise. + event.target.value = ''; + if (!file) return; + + const resultEl = document.getElementById('restore-result'); + const reader = new FileReader(); + reader.onload = () => restoreFromBackupText(reader.result, resultEl); + reader.onerror = () => { + resultEl.innerHTML = `
Could not read that file.
`; + }; + reader.readAsText(file); + } + + async function restoreFromBackupText(text, resultEl) { + let backup; + try { + backup = JSON.parse(text); + } catch (err) { + resultEl.innerHTML = `
That doesn't look like a valid backup file — it isn't valid JSON.
`; + return; + } + + if (typeof backup.env !== 'string' || typeof backup.env_secrets !== 'string') { + resultEl.innerHTML = `
That file doesn't look like a Chronarr env backup — missing "env"/"env_secrets" content.
`; + return; + } + + const when = backup.exported_at ? ` from ${backup.exported_at}` : ''; + const confirmed = confirm( + `This will overwrite your current .env and .env.secrets with the backup${when}. ` + + `Chronarr auto-snapshots the current files first, so this can be undone by restoring again — ` + + `but it does take effect immediately on disk. Continue?` + ); + if (!confirmed) return; + + resultEl.innerHTML = `Restoring…`; + + try { + const resp = await fetch('/api/wizard/env-restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(backup), + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(formatErrorDetail(data.detail) || `HTTP ${resp.status}`); + + resultEl.innerHTML = ` +
+ Restored. ${escHtml(data.restart_reason)}
+ Your previous files were saved as ${escHtml(data.pre_restore_backup || 'n/a')} first. +
+ `; + } catch (err) { + resultEl.innerHTML = `
${escHtml(err.message)}
`; + } + } diff --git a/webhooks/webhook_batcher.py b/webhooks/webhook_batcher.py index ec37917..a58a337 100644 --- a/webhooks/webhook_batcher.py +++ b/webhooks/webhook_batcher.py @@ -53,8 +53,9 @@ def add_webhook(self, key: str, webhook_data: Dict, media_type: str): webhook_data['episodes'] = existing_eps self.pending[key] = webhook_data - _log("INFO", f"Batched {media_type} webhook for {key} ({len(webhook_data.get('episodes', []))} episode(s) pending)") - _log("DEBUG", f"Batch added - key: {key}, media_type: {media_type}, timer scheduled for {config.batch_delay}s") + instance = webhook_data.get('instance', media_type) + _log("INFO", f"[{instance}] Batched {media_type} webhook for {key} ({len(webhook_data.get('episodes', []))} episode(s) pending)") + _log("DEBUG", f"[{instance}] Batch added - key: {key}, media_type: {media_type}, timer scheduled for {config.batch_delay}s") timer = threading.Timer(config.batch_delay, self._process_item, args=[key]) self.timers[key] = timer @@ -68,7 +69,8 @@ def _process_item(self, key: str): if key in self.processing: # Previous batch for this key is still running — re-schedule so accumulated # episode data isn't stranded with no timer and no processor to pick it up. - _log("DEBUG", f"Batch item {key} still processing, re-scheduling in {config.batch_delay}s") + instance = self.pending[key].get('instance', '?') + _log("DEBUG", f"[{instance}] Batch item {key} still processing, re-scheduling in {config.batch_delay}s") timer = threading.Timer(config.batch_delay, self._process_item, args=[key]) self.timers[key] = timer timer.start() @@ -89,17 +91,18 @@ def _process_sync(self, key: str, webhook_data: Dict): try: media_type = webhook_data.get('media_type') path_str = webhook_data.get('path') - - _log("DEBUG", f"Processing batch item: key={key}, media_type={media_type}, path={path_str}") - + instance = webhook_data.get('instance', media_type or '?') + + _log("DEBUG", f"[{instance}] Processing batch item: key={key}, media_type={media_type}, path={path_str}") + if not path_str: - _log("ERROR", f"No path found for {media_type} {key}") + _log("ERROR", f"[{instance}] No path found for {media_type} {key}") return - + path_obj = Path(path_str) if not path_obj.exists(): - _log("ERROR", f"BATCH PROCESSING FAILED: Path does not exist: {path_obj}") - _log("ERROR", f"This indicates a path mapping issue - webhook rejected to prevent wrong processing") + _log("ERROR", f"[{instance}] BATCH PROCESSING FAILED: Path does not exist: {path_obj}") + _log("ERROR", f"[{instance}] This indicates a path mapping issue - webhook rejected to prevent wrong processing") return # Validate IMDb ID for movies — only reject if a conflicting ID is found. @@ -112,14 +115,14 @@ def _process_sync(self, key: str, webhook_data: Dict): if detected_imdb: # An ID was found — check it matches if detected_imdb == expected_imdb or detected_imdb.replace('tt', '') == expected_imdb.replace('tt', ''): - _log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} confirmed in folder name") + _log("DEBUG", f"[{instance}] Batch validation passed: IMDb {expected_imdb} confirmed in folder name") else: - _log("ERROR", f"BATCH VALIDATION FAILED: folder contains {detected_imdb} but webhook expected {expected_imdb} in {path_str}") - _log("ERROR", f"This prevents processing the wrong movie due to a mismatched batch entry") + _log("ERROR", f"[{instance}] BATCH VALIDATION FAILED: folder contains {detected_imdb} but webhook expected {expected_imdb} in {path_str}") + _log("ERROR", f"[{instance}] This prevents processing the wrong movie due to a mismatched batch entry") return else: # No IMDb ID in folder/file names — standard naming, trust the webhook - _log("DEBUG", f"Batch validation: no IMDb in folder name for {path_str}, trusting webhook ID {expected_imdb}") + _log("DEBUG", f"[{instance}] Batch validation: no IMDb in folder name for {path_str}, trusting webhook ID {expected_imdb}") # Validate IMDb ID for TV shows — same logic: conflict = reject, absent = proceed. if media_type == 'tv': @@ -128,65 +131,71 @@ def _process_sync(self, key: str, webhook_data: Dict): detected_imdb = parse_imdb_from_path(path_obj) if detected_imdb: if detected_imdb == expected_imdb or detected_imdb.replace('tt', '') == expected_imdb.replace('tt', ''): - _log("DEBUG", f"TV batch validation passed: IMDb {expected_imdb} confirmed in folder name") + _log("DEBUG", f"[{instance}] TV batch validation passed: IMDb {expected_imdb} confirmed in folder name") else: - _log("ERROR", f"BATCH VALIDATION FAILED: folder contains {detected_imdb} but webhook expected {expected_imdb} in TV {path_str}") - _log("ERROR", f"This prevents processing the wrong series due to a mismatched batch entry") + _log("ERROR", f"[{instance}] BATCH VALIDATION FAILED: folder contains {detected_imdb} but webhook expected {expected_imdb} in TV {path_str}") + _log("ERROR", f"[{instance}] This prevents processing the wrong series due to a mismatched batch entry") return else: - _log("DEBUG", f"TV batch validation: no IMDb in folder name for {path_str}, trusting webhook ID {expected_imdb}") - + _log("DEBUG", f"[{instance}] TV batch validation: no IMDb in folder name for {path_str}, trusting webhook ID {expected_imdb}") + if not self.tv_processor: - _log("ERROR", "TV processor not available") + _log("ERROR", f"[{instance}] TV processor not available") return - instance = webhook_data.get('instance', 'sonarr') processing_mode = webhook_data.get('processing_mode', config.tv_webhook_processing_mode) episodes_data = webhook_data.get('episodes', []) if processing_mode == 'targeted' and episodes_data: - _log("INFO", f"Using targeted episode processing for {len(episodes_data)} episodes") + _log("INFO", f"[{instance}] Using targeted episode processing for {len(episodes_data)} episodes") if len(episodes_data) > 1 and config.sequential_delay > 0: - _log("INFO", f"Processing {len(episodes_data)} episodes sequentially with {config.sequential_delay}s delay") - self._process_episodes_sequentially(path_obj, episodes_data, instance=instance) + _log("INFO", f"[{instance}] Processing {len(episodes_data)} episodes sequentially with {config.sequential_delay}s delay") + self._process_episodes_sequentially(path_obj, episodes_data, imdb_id=expected_imdb, instance=instance) else: self.tv_processor.process_webhook_episodes(path_obj, episodes_data, imdb_id=expected_imdb, instance=instance) else: - _log("INFO", f"Using series processing mode (fallback or configured)") + _log("INFO", f"[{instance}] Using series processing mode (fallback or configured)") self.tv_processor.process_series(path_obj, imdb_id=expected_imdb, instance=instance) elif media_type == 'movie': if not self.movie_processor: - _log("ERROR", "Movie processor not available") + _log("ERROR", f"[{instance}] Movie processor not available") return - instance = webhook_data.get('instance', 'radarr') self.movie_processor.process_movie(path_obj, webhook_mode=True, imdb_id=expected_imdb, instance=instance) else: - _log("ERROR", f"Unknown media type: {media_type}") - + _log("ERROR", f"[{instance}] Unknown media type: {media_type}") + except Exception as e: - _log("ERROR", f"Error processing {media_type} {key}: {e}") + _log("ERROR", f"[{locals().get('instance', '?')}] Error processing {media_type} {key}: {e}") finally: with self.lock: self.processing.discard(key) - def _process_episodes_sequentially(self, path_obj: Path, episodes_data: list, instance: str = 'sonarr'): - """Process episodes one by one with delays to avoid Sonarr API spam.""" + def _process_episodes_sequentially(self, path_obj: Path, episodes_data: list, imdb_id: str = None, instance: str = 'sonarr'): + """Process episodes one by one with delays to avoid Sonarr API spam. + + imdb_id is the ID the batch already validated (webhook-provided, + or confirmed against the folder name) — pass it through rather than + letting process_webhook_episodes() re-derive it from the path. + That fallback only works for ID-tagged folder names; a normal + "Gunsmoke" folder with no embedded IMDb tag has nothing to parse, + so every episode in the batch would fail outright without this. + """ total_episodes = len(episodes_data) for i, episode in enumerate(episodes_data, 1): try: season = episode.get('seasonNumber', '?') episode_num = episode.get('episodeNumber', '?') - _log("INFO", f"Processing episode {i}/{total_episodes}: S{season:02d}E{episode_num:02d}") - self.tv_processor.process_webhook_episodes(path_obj, [episode], instance=instance) + _log("INFO", f"[{instance}] Processing episode {i}/{total_episodes}: S{season:02d}E{episode_num:02d}") + self.tv_processor.process_webhook_episodes(path_obj, [episode], imdb_id=imdb_id, instance=instance) if i < total_episodes and config.sequential_delay > 0: - _log("INFO", f"Waiting {config.sequential_delay}s before next episode...") + _log("INFO", f"[{instance}] Waiting {config.sequential_delay}s before next episode...") time.sleep(config.sequential_delay) except Exception as e: - _log("ERROR", f"Error processing episode {i}/{total_episodes}: {e}") + _log("ERROR", f"[{instance}] Error processing episode {i}/{total_episodes}: {e}") - _log("INFO", f"Completed sequential processing of {total_episodes} episodes") + _log("INFO", f"[{instance}] Completed sequential processing of {total_episodes} episodes") def get_status(self) -> Dict: """Get batch queue status""" From 8073faf0bd124dfafbb564c9c7d5bca1e19cc31c Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Mon, 31 Aug 2026 08:33:21 -0400 Subject: [PATCH 30/45] fix: instance-scoped episodes/delete, orphaned-cleanup, and setup page - Setup page Base URL now persists to localStorage instead of resetting to the browser's own address on every reload, with a Reset button and hint text about using the Docker container/service name when Radarr/Sonarr run on the same network as Chronarr. - Viewing a series' episodes (GET /api/series/{imdb_id}/episodes) queried by imdb_id alone with no instance filter. The same IMDb ID can have independent series/episode rows under multiple Sonarr instances, so a series that's genuinely a handful of episodes under one instance could show every episode from every instance sharing that IMDb ID merged into one list. Same root cause broke single and bulk episode delete. Both endpoints now take an instance param; Edit/Delete/bulk-delete in the UI thread each row's own instance through instead of assuming the default. - Orphaned-record cleanup could delete another instance's data. core/database.py's delete_orphaned_episodes()/delete_orphaned_movies()/ delete_orphaned_series() deleted by imdb_id alone, so removing one instance's genuinely-orphaned row would delete every instance's row sharing that imdb_id. Fixed to scope both the check and the delete to (imdb_id, instance). - The class actually wired to the cleanup scheduler and the manual cleanup endpoint (utils/orphaned_cleanup.py's OrphanedRecordCleaner) had the same delete bug, plus was constructed with a single fixed default-instance Radarr/Sonarr client, so its "does this still exist" check looked at the wrong instance for any named-instance row. Now takes the InstanceRegistry and resolves the right client per row's own instance for both the check and the delete. --- api/routes.py | 23 +++--- api/web_routes.py | 37 ++++++---- chronarr-web/static/js/app.js | 27 +++---- core/database.py | 60 ++++++++++----- scheduler/cleanup_scheduler.py | 12 +-- static/js/app.js | 27 +++---- static/setup.html | 50 +++++++++++-- utils/orphaned_cleanup.py | 131 ++++++++++++++++++++++++--------- 8 files changed, 253 insertions(+), 114 deletions(-) diff --git a/api/routes.py b/api/routes.py index 8e945a4..6a18888 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1651,24 +1651,29 @@ async def cleanup_orphaned_records_hybrid(request: OrphanedCleanupRequest, depen from utils.orphaned_cleanup import OrphanedRecordCleaner db = dependencies["db"] - radarr_db_client = dependencies.get("radarr_db_client") - sonarr_db_client = dependencies.get("sonarr_db_client") + registry = dependencies.get("registry") try: - # Initialize the cleaner with available database clients + # Initialize the cleaner — registry lets it resolve the right + # Radarr/Sonarr client per row's own instance instead of a single + # fixed client (the old radarr_db_client/sonarr_db_client keys were + # never actually populated in dependencies, so check_database was + # silently inert before this). cleaner = OrphanedRecordCleaner( chronarr_db=db, - radarr_db_client=radarr_db_client, - sonarr_db_client=sonarr_db_client + registry=registry ) # Validate that we can perform the requested checks warnings = [] if request.check_database: - if request.check_movies and not radarr_db_client: - warnings.append("Radarr database client not configured - movie database validation disabled") - if request.check_series and not sonarr_db_client: - warnings.append("Sonarr database client not configured - series database validation disabled") + if not registry: + warnings.append("Instance registry not available - database validation disabled") + else: + if request.check_movies and not registry.radarr_names: + warnings.append("No Radarr instances configured - movie database validation disabled") + if request.check_series and not registry.sonarr_names: + warnings.append("No Sonarr instances configured - series database validation disabled") _log("INFO", f"Starting hybrid orphaned record cleanup (dry_run={request.dry_run})") diff --git a/api/web_routes.py b/api/web_routes.py index fa7e93f..33c52ac 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -389,32 +389,41 @@ async def debug_series_date_distribution(dependencies: dict): } -async def get_series_episodes(dependencies: dict, imdb_id: str): - """Get all episodes for a specific TV series""" +async def get_series_episodes(dependencies: dict, imdb_id: str, instance: str = 'sonarr'): + """Get all episodes for a specific TV series, scoped to one instance. + + A given IMDb ID can exist independently under multiple Sonarr instances + (e.g. a full back-catalog under the default `sonarr` and a separate, + smaller pickup under a named instance like `sonarr_strm`) — each is its + own series/episodes rows, keyed by (imdb_id, instance). Not filtering by + instance here pulled every instance's episodes into one merged list, so + a series row that's genuinely only 15 episodes under one instance would + show all 175 from every instance sharing the IMDb ID. + """ db = dependencies["db"] - + with db.get_connection() as conn: cursor = conn.cursor() - + # Get series info - PostgreSQL - cursor.execute("SELECT * FROM series WHERE imdb_id = %s", (imdb_id,)) + cursor.execute("SELECT * FROM series WHERE imdb_id = %s AND instance = %s", (imdb_id, instance)) series_row = cursor.fetchone() if not series_row: raise HTTPException(status_code=404, detail="Series not found") - + series_info = dict(series_row) try: series_info['title'] = _clean_folder_title(Path(series_info['path']).name) if series_info['path'] else imdb_id except: series_info['title'] = imdb_id - + # Get episodes - PostgreSQL cursor.execute(""" SELECT season, episode, instance, aired, dateadded, source, has_video_file, last_updated FROM episodes - WHERE imdb_id = %s + WHERE imdb_id = %s AND instance = %s ORDER BY season, episode - """, (imdb_id,)) + """, (imdb_id, instance)) episodes = [] for row in cursor.fetchall(): @@ -1715,8 +1724,8 @@ async def api_series_list(skip: int = 0, limit: int = 50, search: str = None, return await get_tv_series_list(dependencies, skip, limit, search, imdb_search, date_filter, source_filter, instance) @app.get("/api/series/{imdb_id}/episodes") - async def api_series_episodes(imdb_id: str): - return await get_series_episodes(dependencies, imdb_id) + async def api_series_episodes(imdb_id: str, instance: str = 'sonarr'): + return await get_series_episodes(dependencies, imdb_id, instance) @app.get("/api/series/sources") async def api_series_sources(): @@ -1811,13 +1820,13 @@ async def api_episode_date_options(imdb_id: str, season: int, episode: int, inst return await get_episode_date_options(dependencies, imdb_id, season, episode, instance) @app.delete("/api/episodes/{imdb_id}/{season}/{episode}") - async def api_delete_episode(imdb_id: str, season: int, episode: int): + async def api_delete_episode(imdb_id: str, season: int, episode: int, instance: str = 'sonarr'): """Delete an episode from the database""" db = dependencies["db"] - + try: # Use the existing database method - deleted = db.delete_episode(imdb_id, season, episode) + deleted = db.delete_episode(imdb_id, season, episode, instance) if deleted: return { diff --git a/chronarr-web/static/js/app.js b/chronarr-web/static/js/app.js index 65c9c34..b99aaa9 100644 --- a/chronarr-web/static/js/app.js +++ b/chronarr-web/static/js/app.js @@ -499,7 +499,7 @@ function updateSeriesTable(data) {
`; return ` - + @@ -635,7 +635,7 @@ function showEpisodesModal(data) { - @@ -1311,7 +1311,7 @@ async function updateEpisodeDate(imdbId, season, episode, dateadded, source, ins const episodesModal = document.getElementById('episodes-modal'); if (episodesModal) { closeEpisodesModal(); - setTimeout(() => viewSeriesEpisodes(imdbId), 100); + setTimeout(() => viewSeriesEpisodes(imdbId, instance), 100); } } catch (error) { @@ -1407,23 +1407,23 @@ Analysis: } // Episode deletion functionality -async function deleteEpisode(imdbId, season, episode) { +async function deleteEpisode(imdbId, season, episode, instance = 'sonarr') { // Validate parameters if (!imdbId || season === undefined || season === null || episode === undefined || episode === null) { console.error('deleteEpisode: Invalid parameters:', {imdbId, season, episode}); showToast('Invalid episode parameters', 'error'); return; } - + const episodeStr = `S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`; - + // Confirmation dialog if (!confirm(`⚠️ Delete Episode ${episodeStr}?\n\nThis will permanently remove the episode from the database.\n\nAre you sure you want to continue?`)) { return; } - + try { - const response = await fetch(`/api/episodes/${imdbId}/${season}/${episode}`, { + const response = await fetch(`/api/episodes/${imdbId}/${season}/${episode}?instance=${encodeURIComponent(instance)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' @@ -1829,9 +1829,10 @@ async function bulkDeleteSelected() { const imdbId = row.getAttribute('data-imdb'); const season = parseInt(row.getAttribute('data-season')); const episode = parseInt(row.getAttribute('data-episode')); - + const instance = row.getAttribute('data-instance') || 'sonarr'; + try { - const response = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}`, { + const response = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}?instance=${encodeURIComponent(instance)}`, { method: 'DELETE' }); diff --git a/core/database.py b/core/database.py index 74a5d48..4914984 100644 --- a/core/database.py +++ b/core/database.py @@ -581,19 +581,29 @@ def delete_series_episodes(self, imdb_id: str, instance: str = 'sonarr') -> int: return deleted_count def delete_orphaned_episodes(self) -> List[Dict]: - """Delete DB episode rows that have no matching file on disk. Checks filesystem.""" + """Delete DB episode rows that have no matching file on disk. Checks filesystem. + + Scoped per (imdb_id, instance): the same IMDb ID can have independent + series/episode rows under multiple Sonarr instances (e.g. a full + back-catalog under the default instance and a separate pickup under + a named one). Without filtering the episode SELECT/DELETE by + instance too, checking one instance's directory listing would delete + another instance's still-present episodes just because they share an + IMDb ID. + """ from utils.file_utils import find_episodes_on_disk from pathlib import Path - + deleted_episodes = [] - + with self.get_connection() as conn: cursor = conn.cursor() - cursor.execute("SELECT DISTINCT imdb_id, path FROM series") + cursor.execute("SELECT DISTINCT imdb_id, instance, path FROM series") series_list = cursor.fetchall() for series in series_list: imdb_id = series['imdb_id'] + instance = series['instance'] series_path = Path(series['path']) if not series_path.exists(): continue @@ -602,18 +612,19 @@ def delete_orphaned_episodes(self) -> List[Dict]: disk_episode_keys = set(disk_episodes.keys()) cursor.execute( - "SELECT season, episode, dateadded, source FROM episodes WHERE imdb_id = %s", - (imdb_id,) + "SELECT season, episode, dateadded, source FROM episodes WHERE imdb_id = %s AND instance = %s", + (imdb_id, instance) ) for ep in cursor.fetchall(): season, episode = ep['season'], ep['episode'] if (season, episode) not in disk_episode_keys: cursor.execute( - "DELETE FROM episodes WHERE imdb_id = %s AND season = %s AND episode = %s", - (imdb_id, season, episode) + "DELETE FROM episodes WHERE imdb_id = %s AND instance = %s AND season = %s AND episode = %s", + (imdb_id, instance, season, episode) ) deleted_episodes.append({ 'imdb_id': imdb_id, + 'instance': instance, 'season': season, 'episode': episode, 'dateadded': ep['dateadded'], @@ -787,7 +798,12 @@ def get_series_missing_before(self, cutoff) -> List[Dict]: return [dict(r) for r in cursor.fetchall()] def delete_orphaned_movies(self) -> List[Dict]: - """Delete DB movie rows whose directory or video files no longer exist on disk.""" + """Delete DB movie rows whose directory or video files no longer exist on disk. + + Scoped per (imdb_id, instance) — see delete_orphaned_episodes for why: the same + IMDb ID can have independent rows under multiple Radarr instances, and deleting + by imdb_id alone would remove another instance's still-valid row too. + """ from pathlib import Path deleted_movies = [] @@ -795,16 +811,17 @@ def delete_orphaned_movies(self) -> List[Dict]: with self.get_connection() as conn: cursor = conn.cursor() - cursor.execute("SELECT imdb_id, path, dateadded, source FROM movies") + cursor.execute("SELECT imdb_id, instance, path, dateadded, source FROM movies") for movie in cursor.fetchall(): imdb_id = movie['imdb_id'] + instance = movie['instance'] movie_path = Path(movie['path']) if not movie_path.exists(): - cursor.execute("DELETE FROM movies WHERE imdb_id = %s", (imdb_id,)) + cursor.execute("DELETE FROM movies WHERE imdb_id = %s AND instance = %s", (imdb_id, instance)) deleted_movies.append({ - 'imdb_id': imdb_id, 'reason': 'directory_not_found', + 'imdb_id': imdb_id, 'instance': instance, 'reason': 'directory_not_found', 'path': str(movie_path), 'dateadded': movie['dateadded'], 'source': movie['source'] }) @@ -815,9 +832,9 @@ def delete_orphaned_movies(self) -> List[Dict]: for f in movie_path.iterdir() if f.is_file() ) if not has_video: - cursor.execute("DELETE FROM movies WHERE imdb_id = %s", (imdb_id,)) + cursor.execute("DELETE FROM movies WHERE imdb_id = %s AND instance = %s", (imdb_id, instance)) deleted_movies.append({ - 'imdb_id': imdb_id, 'reason': 'no_video_files', + 'imdb_id': imdb_id, 'instance': instance, 'reason': 'no_video_files', 'path': str(movie_path), 'dateadded': movie['dateadded'], 'source': movie['source'] }) @@ -825,27 +842,32 @@ def delete_orphaned_movies(self) -> List[Dict]: conn.commit() return deleted_movies - + def delete_orphaned_series(self) -> List[Dict]: - """Delete DB series (and all their episodes) whose directory no longer exists on disk.""" + """Delete DB series (and all their episodes) whose directory no longer exists on disk. + + Scoped per (imdb_id, instance) — see delete_orphaned_episodes for why. + """ from pathlib import Path deleted_series = [] with self.get_connection() as conn: cursor = conn.cursor() - cursor.execute("SELECT imdb_id, path, last_updated, metadata FROM series") + cursor.execute("SELECT imdb_id, instance, path, last_updated, metadata FROM series") for series in cursor.fetchall(): imdb_id = series['imdb_id'] + instance = series['instance'] series_path = Path(series['path']) if not series_path.exists(): - cursor.execute("DELETE FROM episodes WHERE imdb_id = %s", (imdb_id,)) + cursor.execute("DELETE FROM episodes WHERE imdb_id = %s AND instance = %s", (imdb_id, instance)) episodes_deleted = cursor.rowcount - cursor.execute("DELETE FROM series WHERE imdb_id = %s", (imdb_id,)) + cursor.execute("DELETE FROM series WHERE imdb_id = %s AND instance = %s", (imdb_id, instance)) deleted_series.append({ 'imdb_id': imdb_id, + 'instance': instance, 'reason': 'directory_not_found', 'path': str(series_path), 'last_updated': series['last_updated'], diff --git a/scheduler/cleanup_scheduler.py b/scheduler/cleanup_scheduler.py index 6058b10..e3c2de9 100644 --- a/scheduler/cleanup_scheduler.py +++ b/scheduler/cleanup_scheduler.py @@ -215,14 +215,16 @@ async def _run_orphaned_cleanup(self, cleanup: Dict[str, Any], execution_id: int from utils.orphaned_cleanup import OrphanedRecordCleaner db = self.dependencies.get("db") - radarr_db_client = self.dependencies.get("radarr_db_client") - sonarr_db_client = self.dependencies.get("sonarr_db_client") + registry = self.dependencies.get("registry") - # Initialize the cleaner + # Initialize the cleaner — registry lets it resolve the right + # Radarr/Sonarr client per row's own instance instead of a single + # fixed client (this dict never actually had radarr_db_client/ + # sonarr_db_client keys, so check_database was silently inert + # before this). cleaner = OrphanedRecordCleaner( chronarr_db=db, - radarr_db_client=radarr_db_client, - sonarr_db_client=sonarr_db_client + registry=registry ) # Run cleanup with configured options diff --git a/static/js/app.js b/static/js/app.js index 65c9c34..b99aaa9 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -499,7 +499,7 @@ function updateSeriesTable(data) { `; return ` - + @@ -635,7 +635,7 @@ function showEpisodesModal(data) { - @@ -1311,7 +1311,7 @@ async function updateEpisodeDate(imdbId, season, episode, dateadded, source, ins const episodesModal = document.getElementById('episodes-modal'); if (episodesModal) { closeEpisodesModal(); - setTimeout(() => viewSeriesEpisodes(imdbId), 100); + setTimeout(() => viewSeriesEpisodes(imdbId, instance), 100); } } catch (error) { @@ -1407,23 +1407,23 @@ Analysis: } // Episode deletion functionality -async function deleteEpisode(imdbId, season, episode) { +async function deleteEpisode(imdbId, season, episode, instance = 'sonarr') { // Validate parameters if (!imdbId || season === undefined || season === null || episode === undefined || episode === null) { console.error('deleteEpisode: Invalid parameters:', {imdbId, season, episode}); showToast('Invalid episode parameters', 'error'); return; } - + const episodeStr = `S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`; - + // Confirmation dialog if (!confirm(`⚠️ Delete Episode ${episodeStr}?\n\nThis will permanently remove the episode from the database.\n\nAre you sure you want to continue?`)) { return; } - + try { - const response = await fetch(`/api/episodes/${imdbId}/${season}/${episode}`, { + const response = await fetch(`/api/episodes/${imdbId}/${season}/${episode}?instance=${encodeURIComponent(instance)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' @@ -1829,9 +1829,10 @@ async function bulkDeleteSelected() { const imdbId = row.getAttribute('data-imdb'); const season = parseInt(row.getAttribute('data-season')); const episode = parseInt(row.getAttribute('data-episode')); - + const instance = row.getAttribute('data-instance') || 'sonarr'; + try { - const response = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}`, { + const response = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}?instance=${encodeURIComponent(instance)}`, { method: 'DELETE' }); diff --git a/static/setup.html b/static/setup.html index 222c0bc..9a32010 100644 --- a/static/setup.html +++ b/static/setup.html @@ -408,11 +408,18 @@

Chronarr Core URL

+

This is the address Radarr and Sonarr will use to reach Chronarr's core container. - It should be the hostname (or IP) your media apps can reach, on port - 8080 by default. Edit it once here and all webhook URLs below update automatically. + Defaults to the address you're viewing this page from, but that's often wrong: if Radarr/Sonarr + run as Docker containers on the same network as Chronarr, use the Docker + container or service name instead (e.g. http://chronarr-core:8080, + not this page's IP) — container names resolve between containers on a shared network even when + the host IP doesn't. Edit it once here and all webhook URLs below update automatically; your + change is remembered in this browser so it won't reset on the next visit.

@@ -466,15 +473,48 @@

Backup & Restore Config

+ diff --git a/static/index.html b/static/index.html index 9d4d492..790dc62 100644 --- a/static/index.html +++ b/static/index.html @@ -630,6 +630,6 @@
- + From 60cfa3312b4eefbba689c80e7135662e5a54ee1f Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Wed, 30 Sep 2026 17:14:10 -0400 Subject: [PATCH 41/45] feat: populate-run added/skipped item lists, movie title fallback, bulk 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
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. --- api/routes.py | 39 ++++++++++++++++++++++++++---- api/web_routes.py | 5 ++++ chronarr-web/static/index.html | 7 ++++-- chronarr-web/static/js/app.js | 44 ++++++++++++++++++++++++++++++++++ core/database.py | 13 ++++++++++ core/database_populator.py | 42 ++++++++++++++++++++++++++++---- static/index.html | 7 ++++-- static/js/app.js | 44 ++++++++++++++++++++++++++++++++++ 8 files changed, 189 insertions(+), 12 deletions(-) diff --git a/api/routes.py b/api/routes.py index 24606eb..17122f1 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2901,6 +2901,27 @@ async def lookup_movie_by_title(title: str, year: str, dependencies: dict): raise HTTPException(status_code=500, detail=f"Movie title lookup failed: {str(e)}") +def _movie_title_from_result(result: Optional[dict]) -> Optional[str]: + """Best-effort title for a movies-table row for plugin-lookup logging. + + movies.title is only ever set once a title has come back from the + date-resolution pipeline (upsert_movie_dates) — a row created by the + bare upsert_movie() path (e.g. still waiting on a date) can have a real + path but a NULL title. Same situation as episodes/series — fall back to + parsing it out of the directory name rather than showing nothing. + """ + if not result: + return None + title = result.get('title') + if title: + return title + path = result.get('path') + if not path or path == 'unknown': + return None + from utils.file_utils import extract_title_from_directory_name + return extract_title_from_directory_name(Path(path).name) + + async def lookup_movie(imdb_id: str, dependencies: dict, instance: str = None): """ Lookup movie dateadded from Chronarr database for Emby plugin integration @@ -2957,7 +2978,7 @@ async def lookup_movie(imdb_id: str, dependencies: dict, instance: str = None): # by hand; clearing any stale miss record means a title that # later resolves drops off the Unresolved Lookups page on its # own instead of sitting there claiming it's still broken. - movie_title = result.get('title') + movie_title = _movie_title_from_result(result) db.clear_lookup_miss(imdb_id, 'movie') title_part = f'"{movie_title}" ' if movie_title else '' _log("INFO", f"Plugin lookup: movie {title_part}({imdb_id}) -> found (source={result.get('source', 'database')})") @@ -2979,7 +3000,7 @@ async def lookup_movie(imdb_id: str, dependencies: dict, instance: str = None): # get_movie_dates* call above, just without a usable dateadded. if result: reason, reason_desc = 'no_resolved_date', 'in DB, no resolved date yet' - movie_title = result.get('title') + movie_title = _movie_title_from_result(result) else: reason, reason_desc = 'no_db_record', 'no DB record' movie_title = None @@ -3059,22 +3080,24 @@ def update_status(status_data): def _merge_movie_stats(all_stats): merged = {'total': 0, 'added': 0, 'updated': 0, 'skipped': 0, 'errors': 0, - 'duration': 0.0, 'skipped_items': []} + 'duration': 0.0, 'skipped_items': [], 'added_items': []} for inst_name, s in all_stats: for k in ('total', 'added', 'updated', 'skipped', 'errors'): merged[k] += s.get(k, 0) merged['duration'] += s.get('duration', 0.0) merged['skipped_items'].extend(s.get('skipped_items', [])) + merged['added_items'].extend(s.get('added_items', [])) return merged def _merge_tv_stats(all_stats): merged = {'total_series': 0, 'total_episodes': 0, 'added': 0, 'updated': 0, - 'skipped': 0, 'errors': 0, 'duration': 0.0, 'skipped_items': []} + 'skipped': 0, 'errors': 0, 'duration': 0.0, 'skipped_items': [], 'added_items': []} for inst_name, s in all_stats: for k in ('total_series', 'total_episodes', 'added', 'updated', 'skipped', 'errors'): merged[k] += s.get(k, 0) merged['duration'] += s.get('duration', 0.0) merged['skipped_items'].extend(s.get('skipped_items', [])) + merged['added_items'].extend(s.get('added_items', [])) return merged print(f"INFO: [Worker Process] Starting database population: {media_type} (instance: {instance})") @@ -3798,6 +3821,14 @@ async def _dismiss_unresolved_lookup(lookup_id: int): raise HTTPException(status_code=404, detail="Unresolved lookup not found") return {"dismissed": True, "id": lookup_id} + @app.post("/api/unresolved-lookups/dismiss-all") + async def _dismiss_all_unresolved_lookups(): + db = dependencies.get("db") + if not db: + raise HTTPException(status_code=500, detail="Database not available") + count = db.dismiss_all_unresolved_lookups() + return {"dismissed_count": count} + return { "restored": True, "pre_restore_backup": str(pre_restore_backup) if pre_restore_backup else None, diff --git a/api/web_routes.py b/api/web_routes.py index cf925a7..0e8ae06 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -2600,6 +2600,11 @@ async def unresolved_lookups_dismiss(lookup_id: int): """Proxy dismissing an unresolved plugin lookup to the core container.""" return await _proxy_post_to_core(f"/api/unresolved-lookups/{lookup_id}/dismiss", {}) + @app.post("/api/unresolved-lookups/dismiss-all") + async def unresolved_lookups_dismiss_all(): + """Proxy dismissing every active unresolved plugin lookup to the core container.""" + return await _proxy_post_to_core("/api/unresolved-lookups/dismiss-all", {}) + @app.get("/setup") async def setup_page(): """Serve the instance setup / webhook URL reference page.""" diff --git a/chronarr-web/static/index.html b/chronarr-web/static/index.html index 790dc62..76ae1b3 100644 --- a/chronarr-web/static/index.html +++ b/chronarr-web/static/index.html @@ -553,11 +553,14 @@

Unresolved Lookups

Movies and episodes the Emby/Jellyfin plugin asked about that Chronarr answered "not found" for — persists across scans so these don't have to be caught live in the logs. Clears itself automatically once a lookup for the same item succeeds.

-
+
+

Loading…

@@ -630,6 +633,6 @@
- + diff --git a/chronarr-web/static/js/app.js b/chronarr-web/static/js/app.js index 0500064..1b61b82 100644 --- a/chronarr-web/static/js/app.js +++ b/chronarr-web/static/js/app.js @@ -2142,6 +2142,19 @@ async function dismissUnresolvedLookup(id) { } } +async function dismissAllUnresolvedLookups() { + if (!confirm('Dismiss every unresolved lookup currently shown? Any that miss again later will resurface.')) { + return; + } + try { + const result = await apiCall('/api/unresolved-lookups/dismiss-all', { method: 'POST' }); + showToast(`✅ Dismissed ${result.dismissed_count || 0} lookups`, 'success'); + loadUnresolvedLookups(); + } catch (error) { + // apiCall already surfaces a toast on failure + } +} + async function handlePopulateDatabase(event) { event.preventDefault(); @@ -2249,6 +2262,33 @@ function stopPopulatePolling() { } } +// Renders a collapsed-by-default
list of populate-run items — +// used for both added_items and skipped_items, which can run into the +// thousands on a full library sync, so this is never expanded by default. +// Item shape tells movie apart from episode: episodes carry `season`. +function renderPopulateItemsList(items, label) { + if (!items || items.length === 0) return ''; + + const rows = items.map(item => { + if ('season' in item) { + const ep = `S${String(item.season).padStart(2, '0')}E${String(item.episode).padStart(2, '0')}`; + const extra = item.reason ? escapeHtml(item.reason) : escapeHtml(item.source || ''); + return `${escapeHtml(item.title || 'Unknown')} ${ep} (${escapeHtml(item.episode_title || 'Unknown')}) — ${extra}`; + } + const extra = item.reason ? escapeHtml(item.reason) : escapeHtml(item.source || ''); + return `${escapeHtml(item.title || 'Unknown')} (${item.year || 'N/A'}) [${escapeHtml(item.imdb_id || '')}] — ${extra}`; + }); + + return ` +
+ ${label} (${items.length}) +
+ ${rows.map(r => `
${r}
`).join('')} +
+
+ `; +} + function updatePopulateProgress(status) { const progressBar = document.getElementById('populate-progress-bar'); const operationText = document.getElementById('populate-current-operation'); @@ -2279,6 +2319,8 @@ function updatePopulateProgress(status) { Movies:
Total: ${m.total || 0} | Added: ${m.added || 0} | Skipped: ${m.skipped || 0} | Errors: ${m.errors || 0}
Duration: ${m.duration ? m.duration.toFixed(2) : 0}s + ${renderPopulateItemsList(m.added_items, 'Added')} + ${renderPopulateItemsList(m.skipped_items, 'Skipped')}
`; } @@ -2291,6 +2333,8 @@ function updatePopulateProgress(status) { Series: ${t.total_series || 0} | Episodes: ${t.total_episodes || 0}
Added: ${t.added || 0} | Skipped: ${t.skipped || 0} | Errors: ${t.errors || 0}
Duration: ${t.duration ? t.duration.toFixed(2) : 0}s + ${renderPopulateItemsList(t.added_items, 'Added')} + ${renderPopulateItemsList(t.skipped_items, 'Skipped')}
`; } diff --git a/core/database.py b/core/database.py index 3deec32..63e096b 100644 --- a/core/database.py +++ b/core/database.py @@ -1112,6 +1112,19 @@ def dismiss_unresolved_lookup(self, lookup_id: int) -> bool: conn.commit() return cursor.rowcount > 0 + def dismiss_all_unresolved_lookups(self) -> int: + """Dismiss every currently-active unresolved lookup in one go. Returns the count dismissed.""" + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + UPDATE unresolved_lookups + SET dismissed = TRUE, dismissed_at = CURRENT_TIMESTAMP + WHERE dismissed = FALSE + """) + count = cursor.rowcount + conn.commit() + return count + # Scheduled Scans Methods def create_scheduled_scan(self, name: str, description: str, cron_expression: str, diff --git a/core/database_populator.py b/core/database_populator.py index c111d1e..f1515d0 100644 --- a/core/database_populator.py +++ b/core/database_populator.py @@ -198,7 +198,8 @@ def populate_movies(self, instance: str = 'radarr') -> Dict[str, any]: 'skipped': 0, 'errors': 0, 'duration': 0.0, - 'skipped_items': [] # Track what was skipped and why + 'skipped_items': [], # Track what was skipped and why + 'added_items': [] # Track what was newly added } try: @@ -267,7 +268,7 @@ def populate_movies(self, instance: str = 'radarr') -> Dict[str, any]: # Already in database - update file path and video status if needed existing_path = existing.get('path') if not existing_path or existing_path == 'unknown' or existing_path != path: - _log("INFO", f"[{instance}] Movie {imdb_id} exists but updating file info: {path}") + _log("INFO", f"[{instance}] Movie \"{movie.get('title', 'Unknown')}\" {imdb_id} exists but updating file info: {path}") self.db.update_movie_file_info(imdb_id, path, has_video_file=True) # Add to processing history @@ -387,6 +388,12 @@ def populate_movies(self, instance: str = 'radarr') -> Dict[str, any]: _log("WARNING", f"[{instance}] Failed to add processing history for {imdb_id}: {e}") stats['added'] += 1 + stats['added_items'].append({ + 'title': title or 'Unknown', + 'year': year, + 'imdb_id': imdb_id, + 'source': source, + }) _log("DEBUG", f"[{instance}] Added movie {imdb_id}: {title} ({year}) (source: {source})") except Exception as e: @@ -407,6 +414,16 @@ def populate_movies(self, instance: str = 'radarr') -> Dict[str, any]: for item in stats['skipped_items']: _log("INFO", f"[{instance}] - {item['title']} ({item.get('year', 'N/A')}) [{item.get('imdb_id', 'No IMDb')}]: {item['reason']}") + # Log details of added items — same convention as skipped items above, + # capped in the log (the full list is still in stats['added_items'] + # for the web interface) since a full library sync can add thousands. + if stats['added_items']: + _log("INFO", f"[{instance}] Added items details ({len(stats['added_items'])} total):") + for item in stats['added_items'][:20]: + _log("INFO", f"[{instance}] + {item['title']} ({item.get('year', 'N/A')}) [{item.get('imdb_id', 'No IMDb')}] (source: {item.get('source', 'unknown')})") + if len(stats['added_items']) > 20: + _log("INFO", f"[{instance}] ... and {len(stats['added_items']) - 20} more (see web interface for full list)") + return stats def populate_tv_episodes(self, instance: str = 'sonarr') -> Dict[str, any]: @@ -438,7 +455,8 @@ def populate_tv_episodes(self, instance: str = 'sonarr') -> Dict[str, any]: 'skipped': 0, 'errors': 0, 'duration': 0.0, - 'skipped_items': [] # Track what was skipped and why + 'skipped_items': [], # Track what was skipped and why + 'added_items': [] # Track what was newly added } try: @@ -516,7 +534,7 @@ def populate_tv_episodes(self, instance: str = 'sonarr') -> Dict[str, any]: existing_path = existing.get('path') episode_path = episode.get('path', 'unknown') if not existing_path or existing_path == 'unknown' or existing_path != episode_path: - _log("INFO", f"[{instance}] Episode {imdb_id} S{season_num:02d}E{episode_num:02d} exists but updating file info: {episode_path}") + _log("INFO", f"[{instance}] Episode \"{series_title}\" {imdb_id} S{season_num:02d}E{episode_num:02d} exists but updating file info: {episode_path}") self.db.update_episode_file_info(imdb_id, season_num, episode_num, episode_path, has_video_file=True) # Add to processing history @@ -614,6 +632,13 @@ def populate_tv_episodes(self, instance: str = 'sonarr') -> Dict[str, any]: _log("WARNING", f"[{instance}] Failed to add processing history for {imdb_id} S{season_num:02d}E{episode_num:02d}: {e}") stats['added'] += 1 + stats['added_items'].append({ + 'title': series_title, + 'episode_title': episode_title, + 'season': season_num, + 'episode': episode_num, + 'source': source, + }) except Exception as e: _log("ERROR", f"[{instance}] Error processing episode S{season_num:02d}E{episode_num:02d} of {series_title}: {e}") @@ -640,6 +665,15 @@ def populate_tv_episodes(self, instance: str = 'sonarr') -> Dict[str, any]: if len(stats['skipped_items']) > 20: _log("INFO", f"[{instance}] ... and {len(stats['skipped_items']) - 20} more (see web interface for full list)") + # Log details of added episodes — see populate_movies() for why this + # is capped in the log but not in stats['added_items'] itself. + if stats['added_items']: + _log("INFO", f"[{instance}] Added episodes details ({len(stats['added_items'])} total):") + for item in stats['added_items'][:20]: + _log("INFO", f"[{instance}] + {item['title']} S{str(item['season']).zfill(2)}E{str(item['episode']).zfill(2)} ({item.get('episode_title', 'Unknown')}) (source: {item.get('source', 'unknown')})") + if len(stats['added_items']) > 20: + _log("INFO", f"[{instance}] ... and {len(stats['added_items']) - 20} more (see web interface for full list)") + return stats def populate_all(self) -> Dict[str, any]: diff --git a/static/index.html b/static/index.html index 790dc62..76ae1b3 100644 --- a/static/index.html +++ b/static/index.html @@ -553,11 +553,14 @@

Unresolved Lookups

Movies and episodes the Emby/Jellyfin plugin asked about that Chronarr answered "not found" for — persists across scans so these don't have to be caught live in the logs. Clears itself automatically once a lookup for the same item succeeds.

-
+
+

Loading…

@@ -630,6 +633,6 @@
- + diff --git a/static/js/app.js b/static/js/app.js index 0500064..1b61b82 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2142,6 +2142,19 @@ async function dismissUnresolvedLookup(id) { } } +async function dismissAllUnresolvedLookups() { + if (!confirm('Dismiss every unresolved lookup currently shown? Any that miss again later will resurface.')) { + return; + } + try { + const result = await apiCall('/api/unresolved-lookups/dismiss-all', { method: 'POST' }); + showToast(`✅ Dismissed ${result.dismissed_count || 0} lookups`, 'success'); + loadUnresolvedLookups(); + } catch (error) { + // apiCall already surfaces a toast on failure + } +} + async function handlePopulateDatabase(event) { event.preventDefault(); @@ -2249,6 +2262,33 @@ function stopPopulatePolling() { } } +// Renders a collapsed-by-default
list of populate-run items — +// used for both added_items and skipped_items, which can run into the +// thousands on a full library sync, so this is never expanded by default. +// Item shape tells movie apart from episode: episodes carry `season`. +function renderPopulateItemsList(items, label) { + if (!items || items.length === 0) return ''; + + const rows = items.map(item => { + if ('season' in item) { + const ep = `S${String(item.season).padStart(2, '0')}E${String(item.episode).padStart(2, '0')}`; + const extra = item.reason ? escapeHtml(item.reason) : escapeHtml(item.source || ''); + return `${escapeHtml(item.title || 'Unknown')} ${ep} (${escapeHtml(item.episode_title || 'Unknown')}) — ${extra}`; + } + const extra = item.reason ? escapeHtml(item.reason) : escapeHtml(item.source || ''); + return `${escapeHtml(item.title || 'Unknown')} (${item.year || 'N/A'}) [${escapeHtml(item.imdb_id || '')}] — ${extra}`; + }); + + return ` +
+ ${label} (${items.length}) +
+ ${rows.map(r => `
${r}
`).join('')} +
+
+ `; +} + function updatePopulateProgress(status) { const progressBar = document.getElementById('populate-progress-bar'); const operationText = document.getElementById('populate-current-operation'); @@ -2279,6 +2319,8 @@ function updatePopulateProgress(status) { Movies:
Total: ${m.total || 0} | Added: ${m.added || 0} | Skipped: ${m.skipped || 0} | Errors: ${m.errors || 0}
Duration: ${m.duration ? m.duration.toFixed(2) : 0}s + ${renderPopulateItemsList(m.added_items, 'Added')} + ${renderPopulateItemsList(m.skipped_items, 'Skipped')}
`; } @@ -2291,6 +2333,8 @@ function updatePopulateProgress(status) { Series: ${t.total_series || 0} | Episodes: ${t.total_episodes || 0}
Added: ${t.added || 0} | Skipped: ${t.skipped || 0} | Errors: ${t.errors || 0}
Duration: ${t.duration ? t.duration.toFixed(2) : 0}s + ${renderPopulateItemsList(t.added_items, 'Added')} + ${renderPopulateItemsList(t.skipped_items, 'Skipped')}
`; } From 5be172c9aaaceb578fadd239c87e03e49c262554 Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Wed, 30 Sep 2026 17:14:16 -0400 Subject: [PATCH 42/45] fix: emby plugin 2.0.21 - library exclusions were never actually applied 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. --- Emby-DLL/CHANGELOG.md | 3 +++ Emby-DLL/Chronarr.Emby.Plugin.dll | Bin 259072 -> 267776 bytes 2 files changed, 3 insertions(+) diff --git a/Emby-DLL/CHANGELOG.md b/Emby-DLL/CHANGELOG.md index 88889e1..443b4a5 100644 --- a/Emby-DLL/CHANGELOG.md +++ b/Emby-DLL/CHANGELOG.md @@ -1,5 +1,8 @@ # Chronarr Emby Plugin — Changelog +## v2.0.21 — Library Exclusions Fix +- Fixed library exclusions not actually excluding anything — the config page saved a library's ID in a different GUID format than the real-time sync and scheduled task compared it against, so a checked-off library still got scanned + ## v2.0.20 — Server Name & Admin Pre-fill - Server name field is now read-only and sourced automatically from Emby's configured server name - Admin username is pre-filled from the first Emby administrator account at startup diff --git a/Emby-DLL/Chronarr.Emby.Plugin.dll b/Emby-DLL/Chronarr.Emby.Plugin.dll index ce94dda6b63329ec2916005dedff03648d8cd61c..29e5da0c87672fc11c117898c410c8bc9ba9b6ee 100644 GIT binary patch delta 57897 zcmce9d3+Q_`gcuFPtTFbF*BLTNe)6nlT0oy2_T1n0-^$fg0c#T2Z|0%P?2Ql;KAyO zt{4wc@xc4o^+v%HMb~rH6?OS7$?AHti>}umvdX^S=c($MPKfCK^X9`;)$`O-Pd)Y2 zQ%@aT-E@4G+i_ps6&Fvvcl1TK3HA5B=wFfLY7!zx7s8OpJK{)JAUzJT5P6)|gkT5= zMJ7bYx<54J%mnpOLi&@t;?{UXF=eh=Fy%bbjbM($jUHt)3k(QH{Qbcai0G9X{a-Z!Mq9mXh zqsjmpEir`V0HSUyL}+lBs7A8oKj3TDa8?m#QJ5G=S?^Pp$yvpmMPXtPWxZfgUM=U9 za2|z;I?B6;vf`Xo%2^a9>M85jl-0ml{Wy!lL?eRuxh6Ue@|7{3!bCISPc#QJzMSzC zCWaue;)j_-IirFzC`=4P5N|YxGrp4X6ebQLe1SQF@uVz+^_plwvNl#*fObTh+`G;?T4_et5FVoMq7!|4s=FQiGv;JjFJ*#9O#UK z5@Q|cjD8a1gq4vcqo2fh2YE(2i9;OdjB*ka9O#U05)%ns3ypZi4roM!J>@5nSX!QS zSqS!39fK8N+ynw)(K*diIARjgo2m<>IScMbzO@w8>oxgxS^Hv?PDr{!t&Qv` z7}h#BdJ6L-vPf$q1IiA&JCE=eh9t6BYa`Q%=wZF{@1DYu5?i9Rk<~;zVNdn4e5!~7 zyCO=fib|z1MU1e~Io>F=WbuAl8yQi=8}?Q&i&1e3?BbMG#WDCQh|m}IRWB=&(WPg!BI8eO|)>E>U0-Kc5lBszpsA&8j zregRGhNq}_D#cgA86ei&DI(MK6j5oO6cK4TdWwl)&}7`pV6g&T0loq1C*RPp$iV25 zUV+LRTp4=>3@+&sh@sI&A-e<&E|Yk-Q4I!%><=)wq={3FMjM6f3)J9Z0jz}(fc`2T ze`oQA9>a@j?>W4H&}6myk1ndakI}`I^5}vnWV{cdE;wEo?@E_gTAY_QUaD?C+od-C z9NVSZ_p@E9TiLEOvvmI*jvVU8X%rum?k_^4zZgrsgMo{QfjS4nfIDZX0cFe1A!4JA zLfJV5s&lBc!JN5sSlNG=TalYscY6=@a>*Xvi)xnzS-nbO)Ik{6P}sLnSYcXUR9@*z z4I+$ns{E()mDa)$ASNvwsbb8r3{O|_3>D8*@zE-tMKSfJCg7Fu2P01FkeEV;#1l48 zEXn4HrIzDK5lbz{ND)gd$D1OST8=NpEJ@{EVCQ9(!^!Ym`8qI0%L@pV;;9CsjY2tB z-b3{(F5 zqm4p2MK+pEs2>HWEe3~jjx11fB(=?GqmbuF=jeMHt)jn*-Fr@0)5AKrdHY)jwJ)*X z;Im}2_Mh9hRr}9v)U-b4Hg3~ciE$3}U`BI;cxtPymP7j(Oh-PpR9ME+ zvb3dATlO2-)RLdF)N0I1)M5LLY--njvl6vx|5=F|^|NLr>c0JFC2HkQ&PsAb6(`5L z#nRg1WI{%l$f!XFa%*~gz|HHLo*V;gVm6qk;97Ky6qvhCBL%alUJHZb;RT;4{LpAv zn8y(9EyxTDEwzbbcj{J-_0(+~M^iU*+%CyXbVprSFl5d_aauhXf_MhNv*5ByUN*GO z1zeq+P68cA$yn?0WYCFu1YjnkKi@d>7XZAQCI>aT6fT5)xLY|?g9!6x|2%d>MDR>1ze2Hvs$z^B` z7IMjVERR@E|NXg`X8c@`)V2_`E}P9ZXLDWHrR z4JZ&YwJsxU5i~bEN&xXpBwHA5BQa#ZMjoQ5h2?KiKY{mp@ z!eU<()?H%M1JGGr-J@q2T(bRJs zw@Wh9Eu9TP(%C%C>u{e60ec8A0nX5vMvcTLgYGxc#JF2Qjc@&PHp8_ zPi^5in%c~9yCgGpDFi`jk0%?JXY<4sE%g=`(o%om*qz$Jv7UOH<7nzlj@u=f>5)tc zN;CC{UFiD|{<-V5SgkwJhAvXQZnksUs7a3Zi=}~H)@jA7RnJU7t}K|`fTWp9+rTQM zjO0cj6Hr$mo^2$5V*~Z$jpS1{&@*SCk^G&VQ(tT(ze)pOP!y2dtU#TspgJu?Gtf$i z+mUn#u@E)$8OK3rY1mdDwD&S=M@zwV!7ieym!!5N+Qd?oCLMy(+S0^W@Q5%E%j^K- z)E^a-ogBMU|HpAO^&ZFVGTZb@W(1k%lRRvw#4KM5#Jry3GFs{xj@_xJIgX~D8UU>1RCW^Qp8n9-l=l-EfK8c5ZQQw8ekdUs8o$GiXn$SEj3sgJ4X!gMw!jbUWq zCjpbV$ce^ObeO`gVSG8*$FXjw7{#D!H7EU&vnzm3PRMeKgax}f24tOED<)Z3yo04) z+aH@vPM{%?CS6=cEyACc{67>gW}NvefQd_~S`*or7`gk%o}!NK`x1HNXTIV% zn)*A(?GkPJWw#NO?lnyij~Led@XX$QZ#s1Vf=ewKxc9=uPy$S~(boTpyrDcso*Ma&OOG zg=RbglQHws%qb|Eyflc8|20tcd5Zf8xeTkzk+3BS)w+zTTXdl6t_3gX>I!gMi^;czl+DTn5YrT)ch57fAKKw?g_ zxOKfu(SA zE2_uBR}PKpdd#lmuCN4FImPZx)g{TT7L%3A>gCEeOPaA)b_aE6tt$^EhDq&LHwbqK zMsu7r_~g?lo%{?z;x-6Xi!tF9D?wR|Mitwu^Kh8zEl9>WE;MfkY2pq9$q`|3)KUtU zCdVK!?*tMOW+*lVVp(-nhWEOl&&0$Ni0AmYPV;U6+1*h#H*dk5EU$6-;=%7FCx<(S z6_#7rB4S9A2F-gw$R>lmvw1H73{r9yiFTxeK8Mff?@O*FA=rVO;Y(g;10zvPjQAdb zGO+uQBiXNwLm7s&juliB4+J_R#LUOWcvu818S6k^+5=guNZa#!u4#e*Zl+098 zi`^jCy(rp);eK*DBL_7N$^wS@kHWtdzR~9yE}+j{?Ox4$M?T+nG2S6KbPu-tFkRt4bo#}Aw97fGMbMe zNIcHc7AH%Iz`1knOq4`VV>=jttc;6R-S|3?NpBIlz{K7lU~Z+rVW_lXNjw2$#M5m? z3{@%{1ww|T!$60XOS;ZIB~4Zty2hP&mWJJv$g9tGC!V%}I9B~aX0|?s%$kBgx2Gl+ zz-*zsOFxlUFFaTtxJq!Yxf`}qPPWrFhy;Q9u*G)p9%y8E*W(cj8fxjuOCesS5nXT@ z7_$S30R#!G`V6V+`huEOpCYl(m_mExXOL0dK{;j+E0!#8e3XNAJZO0N3?TtJTM325 zWVqZkr`E7js*5T;T?LYiAl+5=%saP4EcGR}p-zXwZvY4<3t^?772<^#L{&X&LKCBa zuV78kTGE}!hLi!L-ko1(80L9t{d$v;ZZHQjK}a4l-6fq9`&C$#C~X@RyncFa&=Z(K z4V(*_&qIR5b_9<3K~G?*VMvn-m@gpHNW6$JOHEb3LpqqOC`Ch00AWNA>0Y1t60qjW z29|;L&qH`AYg%^uqDhtfMx2>$fJG8`y5A8 z-5j?|w3!Pb<+gze!b_0+T<$|v?ju$1FS49On^yY28O&Laqz5N=UNsh@R$5N6DckZgJ?)3LR#iB=Yk~b$%*%TYW2_UB02u=WTxQ*b1 zL!4=L1}8vygpJ??5J%bwPLyf<^g-5-8B(XlPPa)o0mKX&!3n#%Ra){)SbFOYQvXbl zXl~tn2Y^QFN~5-T)zOk+2gk*HYK{Z;$54o>p(3@#R&rh?;6M>hE8K!?03H(SrL({Y zWBfiuM&ezB$#X!*ek$=EK-rtnkbrH4G`IOjAT?UF^>oek4*ywSA)R(|Em+CJnHLLr1&mb0LC-?^TMOGaHam||n)MpzCOgxVgf>w1#{yukgJd2YF z2xZiyp-BYUSrZ6gs+NH+h<-nI!El_!Awh%*=+B0UjjS-3;fvKZt(-{(HXUrBCmkU` zcLg;P(ku3dA(dIe)7jH&tc5=j^*C7r(_D-;n*WC&UhXK06>GAadVuyu2`-dnB)=sV z1jzE#JSp;c2Zq-N#Z9UeL{nFirnih(F)tgEdHwDF?IMyR6?X>4lgf`p4@d<8fUD7^ z768m8fSO8V#}Pm>5pX;K#G-qA(1^*>$x_rL%O*`Yc;_4_xn$bq5P}v5Vmy|R^jC%7 z?dZXm@EebGJv?afs#&NFrrp7D@ha@U8RWP>q#+xsovQH$h>jQcMLI0TkyCT?87?uW zHHlFTdTucmiwtm!H!FY}`M3hOky{nOy1o2$qa#q+9+$=St%+&2o6v<|DwAP#7B z4x6)-(SakWvDRkK0Qmy9I0;=+2Nkx?#8ee9XeVmO)8Y{O8~fxK^NEqdg(ux;qYy)k zW3t5noihu!me_?f9^o8Ki#?cSz;uN=R}QSf#@BXw?Cb)Cm%(Y`c%~H-7@qIVB8z>W zl14I_9w#3FffbaZxywQGN>3Ux1(!g-@n4MXlb17SHQ8Tm2ohv$$?s7RY3l4`tcugr zpSD)0=j?Rn59XB4hXzGFaz1-*5T9tC3<{npIRQ6+ijCj|5U1J*P5^P5jo<_ji){oa zfH>Voa6&FeI#p{l^G)cT*Z3CPEavY3J)8*C$_yNIu_DXk8w(PcqzV~ z@Z8&I{TLb59h75+(b@U?jJLs*(TrGS^el8N75bD5QH~iwA@eU7OM%wi2;}5c1PSBR zuUKpR71(-BtA$C=f?CbL5fxf?hTSqw0I|eIZ~}-kZ3HKPz|1H$%n2Y8Hp1dmadHl7 zs72+pQ0lPNB#(_3fL1M57G{#La9t}%QI{d2j?$7(48?ge^@vs;NKr?qV$=m1+ewN< z)Ywc?B%EfkX{1OlO(sbo&D=wcC1(T^<3IacT2Ax)yi-0-M_+-S23TqtCG}L2B2TK7 zB0OJS&ao$THpfQl9FD!Ia}ndgdkG$eJ_nBncZ|&%aZ7%Tc4Rg``Pd{9kHqJY3EQh9 zy@@Z7l3PTt1Te(mKLhc>-o)S2dDRl&)qK!b-jMhbS=s(rliOG4^Tqr$hgpF@cQtLC zx#E7q=p=5xT?LBKp(NK3GQR>Cz=4AKH7D#0TnD5UC%yr;x*q2ZSA$ud%P@Zf)hPbI z0m{6OY#*+lcgvRj+=J`?j`FdLaU!)g)+aO9+E|Hgr0CWoh|j$l<}-#n3rn|pB(iZF z%FOTVk&lOj)Dbx9p?;7gT&8sh%A{7Lk;&YHoP4{>X%EH>=OUv(T{;PQHYL7AoAwpM zipNirHdBD;MEp<@t^9e$MI>&;&xap`x9GYBJ=cQ;@KXwL8B&|@D+dgla9yWr+t5P{ zt&JqCuBZLUcnDm3jcuT*l_a{Jb|Q@%6aRoOBHkMPL*&RO%-z7F0;*;~Ud;|(xD>5h)ar6B z>`qm198F<=2MOCLAAS(MNUbF(%}f^YsmZh=BVN`Qo(GY_;=_6?1{Z=^ zF)P78lr2>(ozAMxDUFpDxpIddk5}Z|LEc-BDr@j4I?rMwz?DhSBpK6^lPyfse`fkX7fwaHMZ}3sTlT-QzjyfXs8&aG--o zlhu%*tQCusKu70`&4a8qn3bIHIe`hk!$xodh}AZN6F{7ABRB!X1vY{cKwM}eIAQPU zk#Uio!HF`hscnso;RHy2X(KoR#Kks(5}gkXA8yG55+NTFJs!sXjqq_l5|o2d5xqOB#tgYSvxZb#!=aH?mmvd> zl?*%&!ZbnW3~PZYm;ACsGcQKMPki)DpfK4c_H?ZutDF6+1&we9On!{SLd!Fbr!LyH!A2{xEAEpI)Iu2 zsf_^L1e&>6AOp||s1Owh*Tk@+?I0qGsVpMmNODqJYu~j~t!QVUgH*%U9=L7Dv(n^L znuv)T7M8l00!upx`fSaHN;DI*q!FktC`sf1Bv}G(6Emif)dQ!Sm_3c4lgz-OaRHF_ z+z!FT2(nyy^w;SbR%NxM?U)`S6xoiL9@dc~CJUt}B=o#)Q!GGFSYq^qMe~;=@~FG? zfFT%}+(Bg%;F(`r|hT(*pMLT}yx1Z)r*CUR0Ek<`q!}PD#~7p zI+4CQoyJ~5y~YKNq^F;wMhlCZy#ND>n!SwUqU6~e7c00V`AdR3h;@OjF(&Dt8n6$N z?gF0V8iMIU3@3oN)<$pwi1jvt6J=O)Zm=<&011YQWWouX`^fa7?t03{Ucn<=nD<9f zkG2sm`D=)oSu*nqHqcs2hGVpRf`)t5DP1K{NW;HoI-b8<5n82#hSc?^!%bOU0BtLM?^oSP@bT(}nxVJ7m z7&H1fkIY^Pm2y{A$5`NMdCEs(R!dh;dQ?f0s56io^ zi<6gtt}ph|D_VNe8Ug;9W_B$HGnI8$j~wn8?v;3{A{aK-S+pQH0&4(yCY|k%FYqU( zqoM>HBHykjFBqsFuf?hZDUwqQ>8U&DVWq|w&BNGy7h%%t%7Fk{8*+4PXWJ>>7j493 zu0>VBuugt55UeXuVmy}?1O6p|$)mne#YW-Wf(dVUAhws@h0|NHiNPBIAtUW@4nG7wD1mJZo@^B) zu;-yCT-fpi%wiaEor#{POWld0Kg!|wALUR=4Ehm+yTHI9>>o(`eH!BQAHr3w<* z{@}d?t&NBi*f3dmB@TON%Jc3snyG;p2m!OKr(CiCCp5~XfRQ2XVTNk<_E@toA%B$_nVPr=bj68(aXKY)Gr7)rC zi0+lqeTXcr6~iCroXH7dX^b2x(pNgt52e$o-^gO{Q2L0Q+)!!ztw@^i7Pz*@P1f2d zGlB6QNZpSVOr&8&egv-+5|pSwWBK7^p$R{L;}v&}RhON74ON8wrU?`dImomWJzQcS z3ZEqT}~Oe?bc55z9^O{j$sru zvC+D*@+)dv&b4PLzThNb71W#_;i`+xE4a&XCkuDEkva_naxO>cybYFxfp{+X(T);&5@*F5t2s& z>8yXhDp!!+h7dnA);X+Iw9s}b9}P5HK1%;j2GuRJd>(Of1lr1@(I_zv zw=wqc@BjN>cmW>A)+oLAKql9y2J%5@OX@+uaPkeyjiOOleU0J5>VTyt!#)Rb9!)AgV-QF3!g)M~M`E3e66uP~5okap916!*g~G{O z$T-k{fk-42k@v1n#Uhp*PAHPVDsz7?Q*>Z2vy_zgzX+IiGLN!ZJjmlOZ5PggB8?*H zFbYclPezgPGC%HQtYq$C%FD3UNS6J)Ox}-r87}ivUM5Y1ULtqpbTXL#Y%g;V1W|oQ zQaHSfqkct@sj^QmLp3@pQ%;7^y_}3C-SmOI4E6~SJe3>y6-(}I$yZ+H>YfrxFO#44GC%HQtp8=&VlP8V{BbX1ud4!M@Bhz#|C{WBTqWrj zTB(V=GT~W`*GLvE$P^B5t)`1B6uzWDU85k}?;}qwA+v?XHg+V0iZv(r5~FmctFYjc z4;pD>vCn|f8LcBhUfn@CW`E2_t8QW%3%39z5lqTx9mPbHMf>79b2QZv4SAB!O~&Ah z@_T_ir$szc^C7hfS_q4U3#K+9Vi%gv3#RUpNKAFoN5mtR>6CrN-4#}(Kr7!TonU^7 z*FL%5VEsW*8;AZp$%>HpiSw;3?*c?v>1BpXOXnb$SoPdPda!NfIscoQF zdN45FNe?*F#WmSl)O zVA(pJE$KOA!IEC+9CJjWwH?6O@W-x^7jaV+N-`*Y>o_ukY`%TV=)eD^AH9?26%8*;m>c#I-v6Hc^l|4abUEehv)3KR@rN;h@ESu9g z(|!)8&tVc~9ykSk6`}$cAtv{hY_O8db3|YKe7uZuys*QsD|KB6Twny#>-!#;4fa_yD*trUO`oHt69l=@R5DVn_LWZuf2WOUtK+?1Shg_QCe@C%={U1~OAaWhpXA9g1ryQE&DR%e{DhO8OJ4Rr!t8{tx0F2CtC*)~a z--o=FO5PVBFJ7=nPf8h6^oFo@W{F2ji7^$ki*|Us1Bkqa1^H=-9dqTd>3#KUdalHC zYU$!KlO@;Bs^ZDgo+~qG%$3^AgRSSGA;(&C1jz4VHx&Z+42swjp=fU zM)OO5WXO??M*V|J>+Bi0;Vg&Qx*o>TwFPxM zObp$?k0iMMy(pFGrmo9bOwLKpbms8WXPTd?P(S^B`3%2Snk^6Dy&;rz9q?g^ z@VYGWdqg8!n87^}g1* zYF4qeza&9(TwjvV>2qI_C_(8$|B7>hMZTNf^jl|huoXU!jQuQnDmgtQk90>vFIR&*|WQDQZmUc*xM+SycFVT zZ99|^Rsew=`hE;`q^BsZ;b-=$c7 z#RO~II81S4G`9*y)RjavY!sQ`02@WS8aO9|TnBn}wMX;}DQWOjutZKiC&$il&PCQ;w8t_}GF3>9t_%*Yh`bchADFaVv+bMP|I=PGd zJvXelLQny!q%>!ez-i8o{i<3Quf$N|{c4s;M$Ns!W`YltWHziBG;LV#oj2P0BRD%Y ztZos8)+e!1cOwf%{XQaG@wlB7EkF7aItFW_Yfxbv$E^pkXz8yHbv?%&f~B^6gFJ}4 zX2)Sb)O+~VaIgGKLM3q^7hv%NdC4W)o1Y?X{`v3&m(3;YIf?4F~w5tbJB6g!qBR0wQnSyIE_Rp~CWFvmb$%#PXMka-&N%xicpTX5_;*3RZ`Q2hCG;kCJqH zfWO8j2SA?VH3jn@5FOtsd6I0=)Abxo*Z%GcK63OcU|X>&4y5)#4D5R6VOfOJ2>1&V z2w(EdV_}6`L8Y}3MPeps6GywSIZMn!l6Ppckx=KxyFo)X_c_Jwe?>?)iJGWm=26XU zltE@k@xJI7`w9e=gV`MePTBqh)@6Z~&5**Mn1f_uE(OOSpig+3$0I@K)Z|fRecsd; zD6i`CYGyOlhZ;~HW#FW;8@`^x{{D zvW!BqpGn9Q8oFgZezhWPI0Kgp9ovt~3ephmwI=`bT_>&fNmUb#*ahAr|XHlB& zmz+shbykOYO>X?W=6q!LdG0}aAQSzDV)=P8nCK8PnZ=MqYoiP@QHmWi5S4?8(y@ov zJc_vgf~^ND?Gun-gR}zt*)v+Tf16>CRr^q4Pp^_|ZInT)c8d9f3-S^Wv-g-Mf)z%< zY2e=izbA0BatYGrLvNWzgWFnBA=9Xp5KqES^kYAf-L6 z^sw=W*F2WQG-o0#2zFt9u#L}myls5af@Tp+79_v&73_K#wEo2Dz{C$z?R*I|{={-@ zgEFtc)$xmCP#MsMG(lx|$-H@BnN}QD1d9}({>V@T$mPkEF!~7{el)lQyGNx*?I|F2 zF3}~0MW^9BVp(@=T^O;M#YF~<=?7h1i^hx}iU5BR{2DMM@%drVAL)9`orKT9Z#Hlz z;fFh!jK`e%C6w-1m1K+G8Q2C);YszM$irCwHzM^!rlkTs4&Qz$BCk0YX>7jJBbts{ z*cojl)nsggk?B#{^a~!9*z!D$@{SP+%(INKdJf05jY;OmpuC@aYmq-zhD59mix{|h z`N<&jaisWrL5@?^g_L_Ac+2?>6X`^9;{I^T}WJ3VdIPy z#T@WTZY4oNpl4?Aet~>*0gYiVe`v8T&yhoiaad@QlUL1D?eB|$^%ERg7orwbUU6$Z z38>aZ2h^a(J|E~0sQ73@e)o_B)%4m-B(r=pCU zqUkc7V~Qr*z~NSEFU9TuLKw!262oa!U#8QjrVMQR+9`HS(NqqcXo`l3hy1DUfY90~ zOwZDIO2yMO9g@*Bt!8OiWmf7xR6>RZ<3>kx}fJrMb#1IZNBq<<%$9 zrkZPAj2hN%z$i(7lPvw-E9dhvqMi(1>ea^|DT)M@BEJHsP>?@M)XP>RLE7$HPLy^Z zOr(_EU&@#wl*Ww1v(T-CF?)URm|t3nWGgZ@;gOp$f)#?kHv)^NFB<+DsW=YFxrd(z zuS^fGx&?WW>=C$`k}heUPQ8%b6#$N|_HnWK^YIRbVx8PPAC1dNY(rVcl>u_8prB@C zUZINfH#h>3+;DCcs=#0AIP(mypw6pm?}88{HkYJVT%^Qhs_Lgv8%eD6uL{+XG`;G= z@fr(z^a8t$Bon{d(x(9RioP54HaBx2%7U+Q(8NG#+G;%qO{)gsxrh^s70^b&nF=6* z=tXmoWha&?U2uqNUPI|Ne zmm0UF>%u(AkB8YcuuBN#n@eDH@~LqK?K95=7MF|KAsqg8U>2mA@>9g<-}Ed=xLx?* zy7)VOhXBBW2l|%8yMSvp_|0DWkD_|u7Xd#Ebk6DaQs6e=SETTmZpl%)&9hh*?8UOg zFyvzqftB_5$ii&95BjI+cEaT-#ZN=LZH>s{7xwT*>?9aLy>}HhCwiS{74}3}Wl9RR zUiqb+sdy>ZOrVzdIHlCh&?z>pOM#OQIj{)50(w+WJTfM;3iV^9Le&JjE=G&JEChcm z7^_Jfl+gm<8bsJ+&jpbBFJy460&0HtOxt@h)!K+v$YZJ9&^?fk{>ProS6gff$ISY( z=3JgJTX7TUXH1VK9fQiLaKg!O5oFmtW`*Las`eyX1pW%1gK1j7Ux>$C5{pUOX#FLS^zhr1s=D61C7H7j|DK zLP79jg=C~IeEjjebd0}Jc zhis~jmih1rHgjS#@-_?-h3GvG;^?0_ZaBrBBNn2BoQin!GXm0}*)~6#3>n{LoezB4 zgAG2F+ei(P^I^Ep`B1hz^L}wW%TX1+6u4i0g_7?^@Rtp?<-@TE3!=(Mg`F(7VDOSQ z8g9b~VGlHglnS*6;^QUD^pa!zX>!RCp;h+P3=~Ov+Vb8?2*>plsvf+rpvO?o{T{uh zW<+wm<^)tEw}7hBY|6;vmvbYbP;T58%8TovQ2n8*X|JM&P%giJ7s``Sh$Dw(+(4a@ zY+no|*!wel!zC}b?JNuPT)UgN5pdEX_MP$)Jq%{6Yv7l=&7(MAvm?Qxd z0KNqAQHbryibm0)aP@!1uOIw>I)gTi-_abeKj3RqjM9 zT2G(dN`Ip$u^gD%hF17bqeF*xN}pMlx%pc-wY6@u@@7&1{__}lvFjRL(6^?*)QbX` zBK@w;qmo4ymaV$^8Tv34L`rL;kjF|qh^Nh2fcDE6)B8Cs2H{FHCVAW$)MLys)2o;Y z17U4n6u!Kq>u=B*Y>7$ECRahHWcV;B4uRUv0i42MVBlN=a2W~`5h{+ixL_eeM})0P0dMAEZq$J9yvNvB+S6w43T z;FN0=&10P~U9F8W=#-0M$J9yX!j`AULGMlQ7e9K?t9FpGK5r_NsSig{qp7|DTpwl7 zQ540Fd6Vl?=e#)BO(xGI??c11J_K+rgMeDD!*~JWE+9NFqwB(edzG~!S*mZsXTf>+`B)CmpfXE2 z>W7fOwGDyQseKqMSdG z_KnK)dP5cf!M@mOsmGH9Qf0v3J=r)`~oK4+-qgQ2+1RT4=b_rsZsz&r+Lg~xm3 ztZ*cXH}Nn? zT?V1EX2-K_l8H{O@$3o%4)Z!cSUH#mzym0%wNVD0T2t(}@J!{v2}T?UcArgWWUSWn z@SiO7@ug$x>I3$K=RiGSP*1R+EFjK(Gd+<(%bw`GV#O$nn$e>VHmCcb5LxJhVnnhJ zzL0%T0))96TC$ZsySKcyXZ!{&#D9EB^tRHa&g<}ekCkApNIDmAwoP?a^fBQ2sWN{- z5r1!nB`Win)}EQ9&I5F+#oyPWUM+=`n*7Wbo<)qw^eSaguTt#jRnkr$y?Qh1-~aQ~ zKia8B!Vgp}ry@Vut5l&But%?E(CF39YgQg?6@k#vrMei38Sn@mR{fBLE-go7A4;QR zXiwT73daK`9MkB@vmp@v6CV)q5l;$-COkp}Lr!)v}|rmj|C`*k!DMG@_QgCeMLHIqYS#iLa}3-JQ+?)s6z_Vdw zJ~_<=dQWpcHO=iq6+bqX-vk3?6q#P+em+7~QF{(kSkQ}ObW%sBr>@gM(qrmk!`W}@ zA}r=Cdy0MurSKZ{HgM|2xVNx0Wnpcu##GjU##E3zC3iL(RVe82lo>RQsnS)W=$%C8 zm~zt>4#kl?rUoDjV~SpI!|jQw-nNGzfH(914JJpP3m1p;1^FzL{-2f!WNxGR76p~o ze5rvH$+zM(%^VMF>plwqKLM~fh#2S^Po@SI^34${11=N6rMrOVUgpBZ<+Ew|XgY(% zqv^j}g;q5RIv!2SRzP2va7|>P6}5hS7>c zR4WLJR@C)s1(oU93g*Jaovp}VWh=bIAFb%j>8Ks^z(joh8CF<2Nr=;iAbs@mukF`)PDhQk0U#YdLQHWIet$%|6hEv> z=)Wvx9Fn}}BNhq#w%}KT-=X+X9{qgy!O_zHaKnd|Xg>a?z}M0r_JAdu2N~i_4(~47 ziO=A_I|z77>?!(le?z=fOyS)e&MKjF1&1vhj$`~Ei>?KW>p5J@nB$Arl^UX>h*U~6C_`*7 zpu+BAV)Wax=jsfxrQ*vH{9l$D;TyDQjbs|D7OTBq>ls96yZm(@@; zuMYgOBu5OaBdJH#?W_xkr47`MoC;z$S|bs^7)UIRLp6pN+DKwOk5&Iww}Trs6Hn8MJ*!T9r>~(CYqb34~U7`RfLIDJ%=|_Cun2h z0rByms(4sz$R86o#N$C?`f7CZps+Z)?u*KR7zt$t#1{=;K#O0Mw?j|!`d6XU*Hu;V zV(|c)mm^{|Wwm9ZtoX71IbvSZaq%2+XH6bTE${!jRoP#h*}O8|UySKT;ptT?QO(EP z5uZV^0nuFCHYgybnbIIwpUX)pp3!`nkj5$h41M{ome!0 z?5Px(fT$fvy1clNbbKH5Z5iUlXgl>Bvj}ouetP`bLF ztl?p_%n(;aH$c_@8MFqSa`<4aHb-1rK=yQ1^U(U37>n-iFRar1P$P{{{GX%vbJ1AF z^|{rECZ(Zaa0HV?kX#7zUd1qIi3c>Ukf0HkIGHm$8wqR?r!jOoXO0u+GIT6M)5H}FWmW!ww#^h98ER{y za`VK)49)NVL&X%=0`W9MhjZRi@xII(K$Pc+4;iwUXG&O~Gk7(F7m0nM11)q_jT|yb ztQFZVf^ISix>7_Kx|;K@62%M^)luG6qSQq#Jh9>Jev`yCqMV_-3nf&+(E5P{T~B@% zy6q2bPZBqXdduam%o8s#xKY%*+^BFc4y`tdn?*B2wDp^W|LuMVLzrCwJtBrN^deJk z76&o(F}h--cv7@5^qoe~b7B-j*_`*1IGCYh(4iZ}n_{fXq6$}W=6hm1XAbjIX1AEY zP>7+wh)E7XKNW{@-Y%|wx0uS%9nAFaVj4rRQE>UEIFg}j8TwvKX9%+o8sT=$bh)i` z>vh*G&Mag$Ue_FkE@dd}I*y@_7%FtlW9VdN+Rt@@3;SXb0Mm_PfNMVI&1bP~=xl~e z*FsmNI4OIPs1(byPeV98+ggI?mEcl@B6~T)8?sj*T$A07a8z~&!aczY5pK`E7~!MY zYY{#bBo0Qejt|gY;ZlTzAuIBTaXTy~uci?t}r+H)y<02RB$ zgb<}q2@_3%DP^hkO!?0cl{$&(Z{W~k%B`I4Py8+M29q{&_^Z7CfYCi1zL`g?{TyD( ztS2+gryTy3F|w3oC;7-y^(a*-e8GL;kSNUEgD?*IkXRntiSX)}I}#F0gMNh8)CK~+ z&8Ki{ErstkyCW{~3RfUooXb_pmdVz?%`FIW>p$o8qCC>UJJ}R|%IV1*4rH1)Le#K( zIql@!ZgJsE1-1oHpY`m~1vR;mN+J6-V-qfM^uYW`v+8MSJvWq7Pv7k?L^E8uB+z%+ zq|13M#ME38C6q&$!MPSivWKo>>6`^5(QHN9lQJZ-%zmhNZ$lMy_EhD$`2P=or;$nv zbP8`q*esrj)F6FAGg1Dlz82vggbwXms6NwrXY)t!6gTXVo2t@YRYF5bmg72-X+6-vQ64wixLZ`Z9!Bo;HLlJ=E?uIGkxv z`bmVeUGrXqY?mNZ(sen~+ufZA_h_5Af_#L3F1fQ;x~C9C^Gv~)5NlmNafoW2z}PhR zAx(G^H(%vaEssR7{^{j6l?bMzXlyP2&Dir|>TQX)ryUTSo2PpWYVk z^5`Z|u2&i&5iPrVl95;&qBoI}G%dt-rre2&dsddgPjQpcQ7(1g%jh_d>-Q*S*CjiS zINWZzcd|md5pH*X<{**+5z(pcFPY{Mrui1(2Hn}pAJPKNaT_=@jcNztHqy;wL6?+P zhICFKE1%?z?$buH3o)xCp0Uek|y`Z+%_s%Lg*a7X;W;pra2_a#Jc z4x9a7lKuBImYzgEp4-LMFX!s}s!S@GuJHC{LkBCoHN67!rdZ<(S8n#W#2f`(R=EQw z*Yg!Ls$x9IPgBsQ;Guw)D(IiF!vU>O(8|i`fI1X(dgW|D7b~c#{5U{YDCo1;iGbEy z3LI!I0(i56J|4Ij&|MNL+-|;w)BHTl&_cW@zOFkfKPaa4myj!euAVLCRY~Yir7f;( zF}j+dH9k*pvnN|jRFF}y!<#LpDd@7w@%h7jArb=5Nr2`sBzfkG+XvF~V#PEn zcBxEa8WmGX@iFPC?W$QL#`#)G<+Ux`L=gZm)r7d#P<>s(*h6X*NXcZI8VF_C@$vlMrVz9JL|#x2C<7FYmJzf zwI#nv+|@*+tHs`a+wupCXBAXX@nZfEfoY5LQUl(|A0{R$==}U0fYvK$b+jx0AhAb5 zHp-y(JlmgVMG=-Nn8hyxm`p~kl-f23F>AxngP|IQyJ9$@ejS63k%j#xBQk`Bay z(rB@Sp*6n8q0-S}xyj>izj`hfId@sBRDS(*GjElFwhW(iWLgF-Kr$;-wK?Nzt=caT-72;ONwg1 zX8K48UEi<8HCdddpfebv4`vh7K-9pkx{F6ksG*W5zg5s)KS3{zk$HoI_0h?q5C`Z~ zZs-8)|EGwGaT08f=IT?#3I+YCU`TYTm_DBJ)`>ftM@FZKd51{owu{!8N|iAomRM zs)9CPXEa0XkPy^Od){3tZ+-nLATYzKZws2RFJjTI9mKeK{Z)XakSW{AhMuY z!k{BS412O5YnI4WU>+=CwkS|g2`pi@s8CQlOz0Rv?|@Lji(x{?h-L+ChLs#ES`_pg ztmIg6h=LA-PnaX7DrinLPs|ZVONg5^*Al-}nGc{%bH%R}^d#CeSFBgiVz4<*+^nFp z!R9z|w}QSwn~oO`D#$%JPaH3{D(E6KX`XmaL06zj^TewPdb760^$TIWt-xKidEysh zmx9hHovWW9K2p#sJp4LAe6FAtmI6OOuPqdGb&?eP4SH>%XjagD@fO!fqD4VZ#q;prz#XEXt!UvQ zF;zjYp@oaY(Gp?>pDd17d6&aOoGeaK(0X`?lf{xrYW^c-oFbO1Owz?EBBdZw{;A?3 z1(EVk6_+cB6nvVvMnRY`Ca~0-^)5SKK z$#S0|UQR=oc+L>J8M?-IZRCjPnc_=^*7 zTL1R!vqX-9K4VHa7pngg|Hln;qGyRh&iur6SN;irCQDK=x&LjRv&78|t?>=-zbKjz zJ0=s;YkUg=ivj&fL8-u*fId`ET}?8&RQy#zV`|O?^tFPlSf{>B{D&Y5$3=I=wq`FA zc~h9FSkaV$V)=w#M zwSufUWqXZPVxt0=mPN%XafgDQDcp+<+HV!~`@$%?>oEo02SGc;GYWbFf_8|P6f}I$ zTz$28Q$f@5#AdbFsi5z1qw0L|fr28|pgeKD*dswv+IUU$0_%k)T-Qdm?(JcveAefUXkxGr2(Oh)eF}QR zpR2DI@uP`yweUCP>KjDIYzdv2-yOY9bTK5S<{QKp49O8=-5|=2;VOJHB72P+M2&)u zk3?}k+o+&UEFw0FgA{b5KMzNZ;}o>DYOa2xn5>`|s$AkmF+)MKv0J!F%vI0=Y-(;2 z3l$U!w771z#OVqw59EoPMXQ3|ji{B}T+IzcrQ$Zy69paA) zBC+ofA1G)8^6nJ76?6yk?i61uh?;bl*sCCF(p|!B9V?YjZMs`zDTvy1x5!rzwdo#F zsvv69J)&Ad+@^a)6GPXC$rZb!_lo&*i03up@v_Cy--`HL3DpfDs7XQbqOSq9WBq}@ zb;2K?0`Nx8l!ozJu|tuDhy0V+949H?(Eb~JP|Q(KxxUl=ka$o*59@mDVX;d=UG-V9 zM@0GYvib)ji=&T;DGbRP9utd?r}>9wfTnKuV`7OSeE`toBB7vH$~*NfqD?_9m^!wI ziydsXhz}W};e^MQ;%^e-8DNX}MnU8+wutW)ME+u{(C6{|Cue}IA{(GR13V$33L>BI zgeX%G`GhA$je^K0JSiF#M2_Yuagc(@(L5!_Nr**zT1-wu;n>q+4nf%eiR{M0*fZk7 z6R3KrnrFoo49PY+096WG)P6U?_mk(Ua3Tglq zGbFh@F9tAlfoO2)+VkR6hAcaCsmdJ3nU63enLaOGN@SQmFaD??s&KoQn9SsD7qb*Z zc`u05&dub#AXX@d@?I1bZJcMZq%Vpl334lbC%#;jDfm0?Bx(~Cd`aY;pUHbkR49n@ zUKZ0Y$mG2&<|~NuUJ-LH%;dcyPGM+Wp+);ZdokN}({HrvvuecK zV!i@N(jCHgvz=K3s8B(a_m0Thl*xNXR49n@-W6Bfp2>Sx+@>JPdrvI6Ba`=@Xjc&O zEL^ylcxNV)u3xxB3};=M*oe?A##Z1Gint?4*D?0S=%Mo};JxB3gg!B}9Cy0J9^eD` zI=2v6V)sDe@OlY_TTx0ABMOKQanre!olAIpscr&T*r)?nOjUZt*Ld*j)e?8G#A`kNFVkQ8W=*NsC>CZ`zFQQUa zT+K6R4IX3VQB)R-2i#0LJ>6#U95=-cMCP2H3O97IK$S$KkdNLa!jB!&&mcygZCAbP%5Z ztm#L z(M)N#cppcw5EmGi%9WU18kLIcMDr}dpAxZ${gch30FT|v22fgttGTED*Ki7th#xEU zDI5Od)464`cYlo3#v3@0@?u)e7K3ogUyq9mzrgPWTto@rcaf`DT;l52dGu{n)*!5w zYw@eYFOFX$eogo_<2M+;q4*8M&o8#)eq9J5ZV4d_h(QSPU*2-q!r^!hr*Jrf!?_$T zKp4f52GK9$@H`Gzi|rQJZ2(Y=Dv0$52rF>Ag64wpJmEj65q>wqVd6W4qj2YrZVZ(2 z#e`B^Ojv?6-AgFtdkLj{FQJt0C6w~Lgi^kjP|EibO8H*GC0y44v9Qs46xVFNu6qV; zU)cC4LP`(i^iWQZ=JaSzPvrDOPEX_XG)~Xr^ej%#7AY^m0mD zsNR)d;EHoNl*7>+PULVJhqE}G$KfIlmvFe8!%O(-LZ$nghT~m3_{PN!zHzaGZ(Qu) z8yCCy#>Fl{H!eP6{e8sxvp!-4e#8p=h!yw|3akJTu3R9b8y9a0&%jSy%UnMceuePy zrhg%9D8o0sT+}sw*X1~%+XIyr0#i{|riEOW*ViJvy{=jNR^jij8?Cjt=MOnl+ae|n zn4x_S`~vMkyaX3GReMl8%;5uBr)oNwF3{GwhGn&DQ{0pex%9ePZ3FNZYqz+*FTY;f z;-UkrOT=RZw`eoml&*AlH{78Gx$JK7Mb#tP-4O9@Z2{-r?MgN7)E2w*8a~tVK~t!a zw!T2Fb!gc?AkmI_Xd6f@t_#R zqCd~!vuMCN*I^KSfooIAx$fPl%n%`0?Vwt1w@YhocMo7b1DMYMSAzvcmF|BJzTX{k zS@n-1{Y}Fb_jztt!%l=v&E3E>R**o)Hhze-KIDG)0@ufde?@q7^EU`PD!xPbW`DOH za^2VPD1`o?Y6hzRQ)#Y#o_ln2zP`Y9PJOXn>7FxWCn&c=tOc%r52jLw7FdIjadM-4M%uF+CLhP0MW_yGdz_#J(eiaekfe#DblFF7r^c0;Zomr^AgWa(SgnR-R|!4 z^`0Ofc?QMs{+m4uT>6mj+(TVwHON6G6+-IQb*NV z_PwnAP)C~hpyUQ)y#6$8ecc5JUuL7;A~u+0$WtP>7_-;_XR!g!a#>`_A=l}!&$qNO z{q`6$bb4a4Q~Nr;&zP%!U#@!>=<7>}^-KM-yl-iv`wejuWxe+j_h*G8yz?O3B;dE3 zM|g>52+ZoU+G2gR`>4hfy^FXRtKDVhiQZ+p$2`{?g2Apr`WKBCd(YF^|DUIan>)R$ z^{E3kfW_B!S>8+Z^O_&>E^)0d*@5t;=FOhvuDt_meC@6aaQMH>wIu5xr0;0l?3sZI z3^BuXYV9zDZHgW{d0BYZpE4-_1W@Pno^d>3-3tmW`37U%{JZ$pTOTDQ486_dPQ ziOwuTTnP8_pl^$7U*ls4_n6Q7?sGj}@)pulaE$i|3-Kfi@g!Hj-F0sX&W2Db&2!*Z7tPh`8K%Ui_s|m zreTtQ2g|jKLmC$;|1#I)s*4dm&}fKdJRg>F>vxOR=EA^kv3Fou;AIx*We9|;yG&mS z`a-Rg=|2LuLT!di&pJP_6Y2K?MH=a^NF&Zxkw&bGG&%)vX_quF$l9VmVD2>rp-0=@ zgS7iAF3#HR`jhXntUcWNZ@IE>x%HJ=9iBo~YSm3oWNp*0@NLWbo@sQAx=GilgMu1$ zTs~v+8S}Cj%RWON^6Ox!g=#hh2Oyl}jU#+3xJ~?_^l!mtq`wUAc6t5Z1qX18E4A$W z{@F0DLKER_4aNEZZtDPU>i}+R$n|R&SIG5E?W69Y;IKCPJojNmTeG*g=%L0?S8@E6 z>@=8G5$RT3pjler&n|O0#4t;>03Fyjnf}+=yH)* zl^l-eZ~=!Mu1ke=Pr+&iZU%5q!7ZGAmebof{XVBZ;IvC)MjVdkZ~=!M8kJqm>6dpI8<^tj>GXB&gXCehaDWQ z=I~~pMFnnQ;8_m0ari!mA8;uAMC0PHg2PG<$8k8G!~fUVwZKPJo%wU_oyVO?azkba z1W3rui##ANUh)W-@KO{MANW{nlSyU*1IbLB2N0Fc4A!dER^7o~ty04(6$M|*+SrOU zQM6KVD^=@abq%i6wY4a?ty{a@?*IGl3<+5K+rass?|$c;?|k2PzH{!)J@?*pF!~si zj48&Qj6<%p_{T}ZOpky*GU+JO!ktctn#t15S~L0>Q;a(q54!iD`;Qo1IYieou4GK* zumOz2j7J$oZd&wCmbomMv5m0<{K3f`O#6U{|0GyBQ1o8Cw}U z7?X?xj5`^J7>5~07>_dIrV?AjXfT#BwlcOcb};%FlZ+|G0mhw-LyW_WBaBBGMFGoa zG#JYmTN&FDoOLkz7?X?xj6;mV!&Wl3GIlT~83!1L7>5~07>_avFL%#qFqSd4GPZd+ z{+D_8iC9SovxXQ)7)2p>%-G7h;amn_!m>S5@Rc4 z2V;_Pz@m%O;)*E)z#UVDfFn~zfX&4NCDe9^afDG!WoeA9j2(sr` zVH8!|fU$KNoi>uw(jrnk1pIlis3uM;W0G-*QPeP>G08Z@*ilRT0mhL8XGR^djOm0O zj021#jK&P+GfIKGwz!12LzKvfOv$_CfZQ)%mmf=)@;zlpc~P;cGDt=?K;Z+|yUU_ohMJElfH@R>ngx3Rcg)|+dXNkN7 z9=_A@`L}$0&cTC6j=Xp%XOh?m8z+=4!F|IWW#<6THdX)|cFmEDB*il9jLS-aj-bCUrEhT>9?{^d8ZcY|aH`8mV zoBx>55B#K=@Rka~AI&6umpK)=TR{)krGT9c#McW6ca#zCuBXzv973!9HxgXwWp3c( zs$XLq;;Q{jJGq1HTyvz31Z^w633wUfSv*|5qnrZ0gnovR@X_!E;>c(ps`?Vl6coE7}q_wu2cZRHxlbZGYt= z`d|gMy=6RMg_}5EcnK}P-cxgCeX288h)2Gn>e%kbc1)_I-x-{TR|N}k134!><>`_{ z5nfJG5V;k=siF#4Dyo47Zj&k~n+`0;2cHzwXarW`#;JmOvw(HD!K$FfS-=^1u}?w0 zg}|kl8&WI-DtHI%Y|tx!3T8_i=yQSCcR(#E&MQKu;PXw(aj*)g;EAld>g2UTQLD8o*>=``a7Z*^ml;@ z7M(EYJAn#b^1B9f8mQo<74#mUBKD?l{7DM? z5(m*n!N%<_l)M5|u>1ND=vRRXc2pk*{R^Oi7y0&pJ`BWen|KuTn?MD7l8=La3#ee5 z@k!7}fQopVUzhwfP!aElr@(&~sEB_TgTUY8K>@s%bsU)@J`vBL_*0-FPGCb{ijzP^ zd?ua){a-*u{6P$Z{v%KkpNkhj|2I$(f5N?aDZT(I;?H>WRf_)sDtO8AAn3mWu~{Ks z0WE=wP~@wiRiJ|J7|LJZzz$S|Lmme01S&$4uY-0072%d|g3bXVCgoe8%YcYUc?5I? z5HTr#4Y~@bh-va&(A7Xi)X4Wh*8)pKr&0~{E47I15NHG65yU~6h~NOP_<=4Ln?RR~ zEubsJwV*4+^`I-oH$Yd4Byuz z)ta;$wA-}1wEMM(wS(H*+J9=A>ulF5m*3UzzBlK=oS)|$&hg|<&b=r1vD~L}x8yy6 z4XXQuElGP-0t-&|=Pw5B1=A09-RS3pzCV44$iNt!savhTfE96P6me%1 zaSKZkcSaF6+LIz~aiWqGaiinM#b4zMxYP8waI24t2}tEgi&a%#g>((lE$S4x19{?M zH6`D(-6&7m9v1=oLvl0H4fbc{=k{0SLdRj*>G(uGgY+|`*OA_HJS%&g^~w@$z7o(D zBF$H}BHyYVmI0)#+G^!ltzP*f=znm%DkHAP#a&1{UG>T#S4y$FQ%a?~URjFtU3We1 zm4o5RNh!rSrZOLCZBD)NDAK>>+$cW)-<6wE%8?p#4=Y;UVP!&IN*O?UEbm77T;6Aj zSN}{YL%JMk1Ja{N&*=5a>q!5p3z7cc=T@ilMkbo$u9dd*@}YA(&uVWHEsML_+m{8S zeLcSZr9HlAbRID;iTNV2HGz;HpZd5sur3&lMSQVfIJ73*(-Vj+?rd+LHUDol&T!|q zx6kJCbldxd4yrzTe){d9%f`1X?rv{y3xz}dz2SKD(*C|cQ%Aafc)7Ninl!aHr&kUc z`@+KoGQIQpr~gLvnH}lNU-;j2^7*Ohix0GT&Xm^Nk^c69^7PLSw50bQn3Ar2aicl8 zUsj|aeX%ip^2MUbEtmOvf_`5tuqxOU2t@;J(f&}^Vn57DYcG`+eo4Do9rJ|w-7RuT zy6vUO`>uIuyiyM>n-=>;%m2$q`)+#q5k*}Wh^3ofd1%%J;Ycs~zcz4AJO~Lv|KebK z`-)(o2M5vqXe`is8Y|@M4Wxhb%6axcuP@k>t~zvMVLS@&k7*Q$Yzjp7-G6A6=0+#& zt9@PTgQ4`XH@?1{4lj;}V!__PC0y$QNE7XmFR?BO#Ku%OClKR`$j=Q&)63snv2WX( zZ`m9xL;k>)3-?|5_7y6bv+t32?^e=Jj5xHV;ZS#QT|AO*9H~jq8Ocv?{m_+OIkL)O z3Eg+oNTpH^vo^H1U%JK@8Dr=v4%@l!{S_HIKY4#^VGA32ss^lvo$0RsIFQ$F(N?$V z(hnBYTODQf;4oe73;EUsB4XuQ)XpBXlg#v*4{n?K_oF-A`(aV~vkzA0P-8)Uj7QV+ zkF{t-g0(3fIhM+&gO$-Xe{V2Evt!@!V+&M$St!a4F876&`9u4reN->gOFr`Ek|e8S zZFJv-zn!8uR|F%`*oBdOogcp;WjsCS_|kEhTfwe=L3Nqj7diflR7GlY`s`0z(-Th= zrQbf`P9HciLC#A5`b3qSo&M>k)6@AUw6u0|YP#{ndBGVtBSdip8^Hthga$5w4O}hG z$F(Ar#WST@elyMhTfyIof1>|7)HFm8zer}wsg&A<@ES=kBxd=|=^ZCZ5o2bNMCT$nDkY)owaY{$rm&wd3$>2XGIGy z=N@9{B_!6NpKe@plOM+RJ>D>IWlegJCJ~gyfc@qLnk*>|WL(#eqB%&tEGmvF0lHX! zpyAJXO7d3f%S^72nt7onrujts7Os=+|IzR{unI;a?a-7=q{pD3!?=EqB9g?W3M zERk*I&U5AD!3A!4m2)Ec6?ghJ;o6}AQPhCE3BA;tzgrR7++2_25NeRSeHo*p;Kd=YYaSYt7qTW%+)+$) zA*WHNMx$`1FaC)y>ko&6IBGJwMB&Swl+peR*_G>nVm;muM_# z^do3NF_P)syj782NU2Z#5eLO(1EBln-? zFq#QnD2an_#Q{Zo96S<7QA?gW-Po){N7su^mfeNlY{|~-tO~+&WOKNLXAKD=;bg@| zm`GF5jemn?fo#r~n@`1BZFaJp+3~1DGs8mzQer;60&wwCImtY}Lh92-Ympop^`0^P zhk160%elAP%Mh@CHl*7nZ#D0{NYmZEQ3JNuHI~foJ zg*l84g81Fv@>OuPPt@)X9GTgeq;ec}A;;NnI{tnqZt4exW?1q#jy4_Hu zt~(1osg(5iCl`9O94|f&7YmhHdP#9^p(odtwNfWbMi1T2QzcrVXB=rQ-BiUQ@fCgIj4$+zN57~0X#!RI zGTU>R^Gj8sw&!Zy=9C37{9;>>{IRo1N;wXn7;uASGlVB@M`7|7t*{{Zs4Pg`L!;#t zIlwH(jCIPHiJj7uJWPXFT*QT*6my_E6K{@TzL{s(q$jnj7;^C+y?eIU(N*$Y>af=? zq+Y_2@Tl~}a|8fFh^Cs?=Q$Ot=j0Jj@+oSUdeUy#kUeGAcFDTZo9)m&c{A&sWID<8 z9ZcV0_I1g^#BF?V8y9SMKp-Buqc9=DE4XmG%Rty9kJ2&u;G4|-rp-~1e9{5?p*r(- zGk-VJdz~!7Q^UNy%zJ|AC+tE|bX_$>9w<}s%ki?woamR=$b;rBepx#HPnZL+;!VaQ z?2td1`~7%?@ThsjFQ?#-z^I-U52i=*DTg6SK)ImbT?zCj8;C3dv5lMe;D{R0NJ#ID2njG`d*7vgV|7%YV<7bH@D zp+l!r)3g#h{uiWNxwx0;N$t=mX{w{d&W=gB98Mc*X>9+4Z2yBy-wl}ssXXS~&7Av~ zzR!io@ucQ^Qj2Jidm+Y?T26uKX(5rG)JpeUG9SK&#)y;5pgf6*;7R_A;y{R8By|^z z@TArvl(Mc_YyLhUCl{|}V+!3Ax1Md2Xi+ef61_w%*j8dzbjuo=+VG`rcC3f1Rkzd& zpb|UO3x{$OQaIJD(Ljy^L2mS<0=fo`$c;350UH(q6~2UMwr_tPM+5-VC;22>yPr#j zm>y#K0MiGUe%bC$pz6zHS*lmh!xv4F=>^-W5OIhfg*w_+PcGV4tI>*vvN;~7OGi0o zjs~TAbtnV@M7o8V6<`xm!^|p3^(z{+gd4n)a+2Wxmtl zF+6^9`@CYvr1-u^(KQ+Yx!hT#d5RQ|VLrG{ZjdoEf4!V2SDV%A^f;4MP8YF< zg3@44@i!5rp120t1<7L?{!~x$7^Y{T)jD^BEOOpF!xw+U|8aG#K6DNxlkecS!`;Q#o#0bwC#+@6LDHQJy(I zz3e`=_iDrGMh!T>fEyv#J(XtTMmeRfKriUWqKIV$Tv}Ck5s{Y>53(TnAPe!Cv5m5P z(t|W(D@$3R;;DhDR+}30@r_u=c2n3wRt*gdMAhVGC86r>FdjJh&kSqc0a1t}51TA8Iau7YXC%x@k~!t? z^f$@KIaU4i6Fp{7?uU@wKmwgA2jO4^?5 zoIqoRnL(3-PKz0{v+9z)WZdXbWlV0;6|(Iqno0QY{Tpq)2L9YY*4C`@rw!#3V-$M= zvrqB@+(as-nz2gYBtF-P1!NgI<@JH};OS|NCn6`KPpX&IG9 zh*Kz;ol>tc^LmTv%5vWryfh>~RNI%SSYvDsOgDlP1b+Z)w&Kv~WP6L^P!xv-5-xL; zrsn933hJeNs8>uxka?QoD8^Zc&P3ew5Q_WEJ0fzLEHVcpazi4Iqz||en3)qNH7<0~ z6#!2}r$y;#*rH{)HB5Yj$wie5P=(JmBrGKv zstK=)4&CO1QMm+*+J{kDE|;0^m~2Yu2F|EgdwiY%T;Z9mWv)S#?8S(u#zE_~2Qh?& z2`5J^|DGm9JI>`gMjPklaT+!kqzz~9*;ptgkixAEh;u4B_Ozf}p=bmMkHM921tmQ5 zv>801-}PMS=8jVkyP9>Nv+H;R=i?yjmhwS8^PQNSBl}HHT+Wjn=J|11tVt}b2)VW9 z`naq=+g3xi0^Dki(2*2-4HoSuL1{LP6vQEhm9P!=Sx?5`ay1K%+3*BH`8IBuWzL3$i-WOUIFkU&DSr4N(9*r5D=z;oz z=w4qa?(6C4Z)j;WZ+J{KPN}%a{N*F+l9`c!&mRu;^c&Gwe@|dhdAQHl6^!*SFq#_X zE;PF0k!U!wfE1{Qlh&t7L;-H#CPV;>IVo0zYX89@}3-{t?b{F?*TpG^0 zDHxk!#QSIugIz#3q+tr(LpR`DYFiyO&IyD95nqoH4a8#fwRZOFuLgg!M-Ao6ndTo4 zsueb4MR1FG;j`-b2@o)`C)gSBMf%~PuAX=ln?07pd|ffN+~U)Qkl_pUW3WKHpWMz$ zbnzyD(TAJvM%1?n!hBhAP-f*a(#&W0gWcV5W+-ORH{y+M+6{@Jy@nW|(Tte)KC6~3 z>I(Pv`N-$NnBiOJ!?3_&jEfH*2y|@>`0K;n-C6PQH$u%=hmnmUmO^IVPt}KJ&TMLF z#7+5dXx(C?-e?K*F22Na6YXsn(|o;s_^cfV0M#l~&TMmhn6=Md-Tyqe?J@T@uU1ar6QD}`^)ac7-f+jp|$Rpjf zOE6~cSo?6{NOlex@L!a+>f(K)vJ+!B>@Ze-W0SS%g(1Zd_4kB*e#@$02qW#ski{Z# zjPe+5sTEI=&Yp1B#wd-I5$x@YglTNCO+kBSSq?%dwhFWyaDIvtFu3ea^9qcx0PAjT4uFDizqBaLQ*N4L!m(bd? z$fz-|KB`Vn)HXx{*jVif)HHsrv9fW7Q9UZrIKB4<@(Wsyl{4L_ZVX^TH=gRbI;+o_ zt=V}E8Q(!nHPv*mp<4$(II6y_o)4E<@y(L}ft?Lw4mWGji5ZyP2%y65#Gh7mX?{D9H>(y`HWn20C@Rip> zMD}Mp(Z-!+6O?bwFhBjB+QdbJ#>eW{rKulR7s^)iyyNOJ7d`nw`V|hE$JJ}3I#Vm)k0?&AI|g=dq1Jq2%TYk1UVFH zh&Gf+E4&5e_^)Pq%hW4shRm6B>v68i$44T^TushaJU+@r&Q>};+C|P*IKID&%++0u_*fS?Tif_J7dczm_;?pNTi5smCZjNl4D(YL`WZG9E=nM>yt*y8E{wjf zi>e+D6;j=qd9Z=C44S<4MU6STqa2SPEVMQ!gEfDb*dQ-pkrKuHW2lsTuSLjRi3#O@+W(W6k z#45B>wN{$iU-fmwU+7P*%qw-2+$yu9hIs>pfx0t`SycvURXMIywGRMS1Gp#YutkTH zb$Fl-r|5914ySSWSBxWsrYFhSO>eS=P0I8oIk%bbO>%BC-Oj zJoHP17bud%|?_fRfSVgOYp&EG2df>6jRtG~h@JjT+<0@Dk3}xWSID(Nma45G!fBvf} z9sN{guc5@vw3ngO!sI=Nk|@9DP!iqy4kc5mKE{El^f*;RNmSo+D2e7Wlp17cG#TP; zF*K$cw7h?2V6e*n&A{L~U|`t)ab$3<_81vlr#(gncgCJ0gX^>B$l&UvM}`^$%CyT9 zV@+D#P@XtN1uIoh?*Z|NjQPR~cuj9&G^p`|ASqeM1Ya`30qnR4A-tqX2cuhkFxEn{ z5e+4;LP8;#$*U#wB-eB3kT-da5Q>v22|E>^jW%v9B*E+%$j?o0iBL?L;gWT=;*Hu5 zK~h~}I%{(%N2BeSxI*#6I6e>>;4jc(9}W~fV`FX&wI9h0vjA>tIMDH!9b6;(e=v@s zyN!ke`w}1^GR#LkA_^`sJ&fTP&<;rqM6P`a2dF)Kp4aSPEU^$e*(PccpN+E24#}|F zf>Kud(X7H80JwEcAC#dV<7ThRkdGCzl(*Z$1Y)(%WhwK7EG2I5U81?$!dz;#9|w*i z-4;bH_I#ij%n`Kxc+isY!(MoQZsuE1TR)n)y}v5;^%URnfmB$HA^Q~2+Lr*VWs1ENv1|{Sy``MWiEyu0Y_KkA4N6M0 zH(+G!wuU3w)uTOSIhu8-pO*U-@3Pcog_Vxj`MIE`1t`a$yiLgEZ5~5cvbWhRp(pvc zgx=&935%03U8Hv^Hru0|4P(mLJT1!{Z&Qt_G||0;DZ`3Gl-2$!KoV$`KnS5SCPx3_x zy~!6OEKWWvVW(oVeM%@}8KFLUADy2Ep`Io)+Te+whCb50Zl5l3t3EL{K+8jYtka3r z>7E&nRFyGt1)}yD85@WrW+ko$F}@tpSe})*HH|b)wi1t~k=`kTt;93wl%{ek@mU6e zfN)Srb3%&e97Z$3JOp!;wIM-Ihy?(p+_M;hJ`+d$X3tUWKKF)l1o5Nj1Re0!t&W} z0vj)*Z6r4gz=BvJAYgN+2oeFIAx)472#pwjilvbwpv2MyiJ&ba!@TsfbgLxZK~1L1+OWVEv0HIi#HP=GB3;33@kA-+haSzv%OQvp)ZLohfMd?9JZei(dos; z0WEe1(0C^ohdl_-sM7Cjh+$X1cO#7*6c!d@JMT-_smOLv^*3Xg?lx(EtX~N0Ew<() z7x0l5^05}OQwdRIJEVj#W~1e_0kR?ytw?I?paD*fV*3-brR~&539w-}giU)4B8k6p zKXyS#Qvs}o|Hi0gRb#%!OZ76#gelBoQ;>CsWvRsldL6Ekn=!E)qELaA5VrPIoIV)# zPZ7=9En18xsN-SCMWVn|NDG^+|P9`JGXW0h96?uDkP+B8ly%Wd3`LnoAL z6`F}@d5~}^a@d!#gwlE~c?Q$7l2sK0?Ce#YxNq`;x?`uejE2jQuDcicf=k!_Smh?< zL-cr<8!Ebul!49N`y?Yf_qFd_HetlR}tAz`+T(i*3C~2cBLpH3N z+cGt;Vv19Yp7h4@ym54_WnY2hn9+b0{Ai#nu4L{MKvRE3!A8qQ=ktau+is0MF^y1v zy}GrqvQ5;Yc&ua9$f#?=T*Wgn65B!61oN2SYC+d(paRfm8YADr!A>k)D8iKP8muqbj=SdMIiIjw;_Ij|!uK`H> zynyy!&Tx6+Rsj225TRj~fvn)g1IB2*Xguf2#3nssMkFR^|6%a6&M6LA;PfbE&s{jWg& zF+E)x?N!awih7CFQvU|3YD1UoUaRS2(eo9U(b(^oN>`kS28nNAa3h1C1B4Pwi)pzP z--x(0pcUVgjoy@w+BYMX(-W*utSnBwUsdCjL11+-ZA$APgN^%P2}=_@B`m8RXy1Z- z311YJ)E~gU6)1LR)s=fssIYHieqn`A18Zg0=-gzdzl$!0>JneoH!aH`mK?HjqPdL; zf8hGOpmK>P1+y+O?P4-}(h*P`l?(Fj6I*s3t6|3*tqObNw?jw!4uJTbqHTF%6*KhN zc80wXUr$G%WNvQ2f9cZwF48@C2{%Z_k80w`5;WBr0G53h3y0CWW;Z#n^v3U@!F)E; zV@E8V?JVR7TT0q8WJxooHr5OqQjWgz#39MD??qZuo`=;{NDOOuQi<*NrM|1Fa1Ov& zVza?qL9kzJ@v1TL%AP%BxuguTvQnC`kP}<~EA+^Yv!1l;Pn5dJn)mHqMA^4N#v*TG zU0HVH27Te@NI5#9rAw)-Nfb0`JQS=H1p&RDcrKPAA*;z#)M#1u;!FkoiJg!K8t+Ce zvEiL%sn!8CPJd)h_cFY0#y5N8SRYw9CbO|(4#gh?aQSC%94%w1J`UMfd81Vx2F%e@ z#UqI4ChE#jGLk2cGRb1DESp@^(?UlIp#F*abslCxNdZDt~v01-p$SRo|s>4F+FsNAXMsh6w z+&xpl;q0A1x6ZnvDZml}CG&Cv>pIapd39DW8nB;0Nmih@G;R*ziTJFrFBc&`--Tk)r{iNZw2%V8fKgs?ibXwYn@25o{`91N!9JFo&? z6u&EBaq?{mI~Cc^Qwvqb>=|HhxY+D~CH3Copou{+>l^H6nB@!G!s$yF3h~;0j@2Tj z*9`mfiWOVxuKFzPt4VTL*fkg==*qkLMyQHhtmIZ!@(m=hlE0I%IQhDSor-MdLrLD5 z3}pely^#p^JrE(+2hHgOiB#2?9mCQTi6HOrG=U?jWzlJif1@`gOwiH&veA**C{~zy z*TPC*;#NvX1cd$51c`t!CQaZ->JN42rY@`>a8 zo?I-pM0S#28bpIckryF0WM6}{qTQalSPr5N);DMwt6rhN(AHMHqya%6SoM-)>|ltI zMZ6`r@Wo+*aAYh|q{teyDK|^N@{*U1@oibYafoL5xO}F7@oKIJ+FZ*+kGdAIe3tS$ zGoZb!#uinAu$+fV*)IXas$Ds8{gBwnt$Zm`RHhs&@m*Si%8%MPT9?T>FIQNm%<^Hn z8cb=d$YijhVcJ% zut|My2f~K#q5Ysl9SoA&)UlLdi3zdk?T&PD!>RUW%gow}HZpBfVC3QzV{fDCZBt-m z1r%c+tIBu8s=&h9Hy-F>%Gy;;mWn%;tXZXV7{ zV?$cJAUvi$bTYaGPCeYdzg!2%DW*E$4D%k(N*{J&X0noBL5S7C5Ti)K_{*?hJ{qNB zj~Raj2(BH*@G1VU5V!lKtfqP=PkhFSAc)fnm9CowtHA}-t02`S=CM)!hND*EoXl8w z4FoJxG|#;*p4WQwH0+LcvDrmYOYZQ5QA^7a=RQqTAnpL+eoNP$J18BOmVn!ZLdyM1 z1cal~1c`uvjf>)t2na`~2@(NePMRQ*>^yd2$1&*?iGcOkG(jRD%uN#{0>ZpBK_Va= zmnKLAg!ySgs;6m&!-LF6C-4ZvpJ`)_q1U`Q_HWUY?XLqgD0=1$D7K*_{s!nP{t8)j zO(pd!zD8uXHJ&>VlXj@Ci&N|Z%+yNCTPy{Ix26l1NR^p7aOibOBiH2b~U0lG?>QE9DO7p^g5arHIwn>!xHhGdn*&Qbx z*fKaIzL2B#+pMkI`J_)N)l?d9MY_70T_pUMu#qq3s5f~E2fpMI4scz$R6<|!RD^uQ zV9~p%++eos4$ho7dYA(@E;}4p2=*jGU5m=}C7K-~yF)~GaB=MuxVNqa(Ahrfl}(N8 ziAct&l!zTSyq8$BgVn9lw3?rTUZQX3t()TSpaQ$g z^?^$<-TDOeZ^DSZUY8!*uUF|ETmTl54px%GP+-IQbTcPU=c2tCdu!ehN2`UBXMhYd z5j!~;p^aw5n(;kO+l4eGDkvBGFQUlWKhCRm24L&S&U_5dFd=D7K1Fu6YJ-(Z$~54@V&`! zv#7z7Sd6|Z83+#i&1)h`#M5m-I!!wGxjfT3wbpS!Ra9z9}9NZbag9@kb6!a_y4-EU(W z_sE$3YxvS=c%BuNh^(;Em-xE=IhE^LW&TOb9HCBf)=OBL z_*BBOM8im?mnR;Sup;s2k*VZn@a8i1cZ=h+oimIv-Xz?hp_wEsY7C=4!fBiKS7 z>=HhL$Q-|Fm;L;Go5lVZ$%5;@Xow-Rs6^Q1-^i33V}R zVlyb%ef*NE0LiLT8#F5zMzWV+QCQC#g%I zF#5b1onorhV$hV)P}R(9Gc)l9+A01inkm*#B~q ze`fw-__SCN(?0J-gJ!u_;6$w5eu}yySfzpsBv=7rl{Z$1eFW2EK}0wOxgy@4oO=6v zN=+G8=U_exuYmquoZ(w|knArg1FTTpTQV0yIU=_~D^_w0BQWhO^znxM1xT?P;pt)? zox6JBg6fk;1bES@6l0C{P^d{X(pjRz3v{gJ$Y+Umv$DB?6Z3jP{uuVYUs9@Qe51o+ znyeZ=18e|03kPaylIio`~?s%BQ;b5xH^iRRTndHs1%d-V?DX94R5Do?X{>_ z7dMSu_)6W7=49A8H#4&|ETX(LgD|O}J=?!RPSeJmX@%-aD&k)QwXseNdF&rR%jre) z+L&{#TsNDA6GBYDa;(5BMJP`DvE}kTOKReTffGk$?3hn%%hK+euS)pjl5be~e435( zrf7)Ix1)T%Z3Zji-%x79gp&QQWSvp~F|(vWU0!Q6ibe4MaO%YXQm_3{{z#Nqw(gwN zpC^oVeufI8D%Qc98+E_E3V6Df*r+YW~2{C(;-sFT^K!FNALa|mL&obmTSBsQO(JF{7Q1wM=1$C=oc5b!mb`(w=HuX4Q5nr(=!lBf}&b#huZ?w8Q`u zs-}Y3kSJUGC>XyjP0SReTm53F?~h|eX!+w<`dMo+!5IE{KB96{mF*EfeBT^CUYFh~8N>|K=(?QEB5f^0`x*A% zAd?5e`H3xH1>x@?XnOX3QeDXI$R$EY!jdp5z1VkLjR0oz4$Q_XnO8dHI0O3t_+Pyq zc^I*#a(_o5+nw_($j?@@dj*8)r14|jDbg{y0qK`mV?g>OJG;fobf%YjWh%*QJYOaF zJ0d8~-%$i8y2!>XJ`tYNihqmB@q@$mKM-k}Y)0!s@*u7m_9kHuUQ<1fi*08VQ(E`m za`^6+6L81Y$>Hwe!I-MeVx13?{s~EW!KP-d)}nFgx?o_2G;t@0j;rn$K(0=@Z+x16 zHuz0Nb+zH+P+L8IcP)xXGmkI9c(zLCl}vff2krdwKyUl^$jr;yj3YUC0(?vSUm$}R z|2J0ld?^8cAF@kOHMgrfgqv(jb69SXl+*}7xUcY4Xm0n+ntc^ViVCWUX61GHCk@w7Df;0=b)p> z4M;Mh$s2j*M)-3M+Oglnq1hP|X?Zk`dm6+qb;a+?#FHCUu1yHW{M>^k?Oczj9Y7wf zz1hKuacnX}$(s;^i3~lrC=xU~7>oCZD)LaAQ)q%(dZGc(jycMoVrI^-PbJT+^;XDc!FIxXU>K&j)pKxVspu zSv^{~{t#8bGDC$S8~!QO6xXf%2Gr3CoWKouckfp0;AT+m6)$7~dD3>_ypp-nd}aqX zYb4KPifZBxZuc~W<4*UH=oZ(=dK6WRX$8`ks)>;Y@KlY?Bpx`mPsdU|i3#`@P0+zuse5cw`Lsi@(-u3_E%dm;U%5F5pRJpMw}0q{nbUVS$X{aaW;Jv~paq zT?r5=3>U^$gbNcJ*f`+nLlGwuj;Lo>PEigg9Etz7mxfxwvL9D)IXB+_MnUal9B<-5 zu?uM;9#j7>yv)9x4Bq|7c$u8Z+RJ2g*~7~e?9v`I;eHHhD3_#yEg^s30brN>jB ztV|r4)-nq+p~33PjbIoo=Y7tu`OHnvv)}^N+64jlwon=IsAM-D}{@vw0HIWjtdbe8l7bRuwBg_79 ziCv4bd&>8kY7anK1AN`&g#h@w#zXWC1Yc|sEeGC&IiKls{X~}`?T?{ebcfDGau_TB zHQ4!Jt((b^kNCBq-G*O{rjJ%T5sy8POHFUs)NRZD;K48R7!g)da@&HH5yIjmmRJZo z6`9Wv_`QrU-)z`3&;nU$d~n0U#<+xGF(SgS9uZ*}^(;)0`Mqxz#+Z_Y69zv$g;p3V z`@%cX`WWU|zF}Wjj|a{lhj_J+a4=8kcj$)s)jNZOrm*+92h541C3-KS^4^?l4a4h+ zB0O&{X_?jRo{L#iO@w}X2+GmyJRb%0T9*BvZ2j~!!&^UAm$!cRaj0HQ!qyK9jmGZ3 zptH;lPT;K{hpxpe=fl>I*RuT3oh)U?_gyd5=~|wlss{Vlbwlg8)=J%HN=Z^ZSrvM+ z3pKSWd;x8!WvNwRrkPx;LX}HarMmKc{I%0}S;$hLyqXtXGA)&~N(mO;&&2 zvi2~iNmIHe`d5(9#Ie0WvG9sXOBk0qEfP|MP(*yVlVE#XTON0xzz%Xxzlg& zW#)7DGo?M$Qx@^_Xhw(C=pM$Mjg0b$rfaf2Aa(An*#qx_Og-rMNZwB|*!DQF?fa1g z+kPnZ#jFzNK_KxTu|`jCYZP|2M)%GNFY%{$R&0zc-^BBS4|AyAgz}mEEs4hNYsEx3 z0nWD*q2snj&InPmvtqLpbM!}ZyAPLaQnpx@H{03DvfjyB)^*Z%!rNyhpmJ%;(!wyA z{m6!cnV?5s%VM&&tm^&jIySj_0Dd{!#`9$99j0mrEBOf6bvsz5UB~T!Ndkis362*J z5_dAk?qYZzLk?u59V{E~Qin=%dya|b_8b-Q_p%t)z-=*!FHvjRyTB^Y(Q1l7#xk5x zz-&Yn>56c`9s;>NMT(r0RxMyRq;iiQFK?=O>_)I)ir^;;-tZDX?~64A_L*!qn-Sz# zCH3qTS7Lg=Nlqhp%nrCn@7eG<#yFfjr*U+<(R6NKmvWM-O?DkQO|K&z7UHfMuP613 z$EpOC_#a-Ejbn^<2ccEpg4Gwhs*QN{Jf^t!ollYTRuy)u}lX9&IZjiHyy)>!Z z>&wr6{-%ak9xZG-u&+ij-Ld~bxn{>6_3yK`@5T%K#D{*?z9l6NVo6yI)%(zW1^oqP z!e@R~ZlH#0h=xvQ&7ABFtQFf&t7aU$H-Q0JO7%I3>b{1>LXegNL;Vp=@7qJLlktDq zrBU}($W#^b%WL=Y)<3=vlPw0OX~l+Xb;nNMo4ScbAotnccmD~FrI#2SkZ0uFyLUMy zGkVFH4O3IfiS6eF$6^`VTPS4KXx$D&=Ac>`^qzhEIOl>5I$r)*ho$@p`yTC2*st>V z<4OJr4%I8rnB?o(pWt~pv=AroB+sGCop3%#Vs|nIYZUj$jQGO7u~;}}^yLRvr7>%@ z!rP!Q9*f^gxA?p={S*e~lDQ=_z|=xiMp#<>-;vG5 zdntIeW@pTFr^pR5&sT8Q{P)!}@)};AI)3+u{XscjYmdQZ;!OZyf9bkbxN>}UL5|aL z@piQ=^{AnV*D7{HU(D{&^@HV{>jOD8+$7%noj_OP5Z%VK+|cijs`!wqK97H#@l9#H^ISy}58~ zI`ed%ro+)+dCH)cp+#h5<(u_bQAn+%UBmgVb!e81b?B$Zk9WA`U1P~li!ml9%2;|9 zNf=AdBS^0&(AK=3I2JwB%Pqr$_TFn@E8$IC2=&a47ZKgtUd!Yi6#QB8B{1;DOBnke zJL=t**}+_zNA0#=;|`UZgFfq9)|=P2FLS6q*nst|&8D@^WQ*YhUf&|@wZ7$ydVQPT zZH-Hb35aT3tU7eW{Z}lB*PAzF4jnNh)tTdBr7523%APs8^kai_>*w@Q@C&hhmxAu~ z_g?gC+GC@W_D~!8G=6qElf_p81r_2$L3&>{6m`?B%PV-id?fb-u#U8Y8X>1pnamkG zUWENA+uYke5aW}L4ij8Zm#0qRSYh`PSWR$2SFyYu4DkY9x>`&SFUDtggIThPVR&Qf zoPp0lX0KuR8Y*#s48vEDgueN8D%w`%41v)ne*;QUp4ss#!adIE^@h;wU@pz0HiX#$ z!y6&j=>(TxhOHzRFkpTKScr`fifZiMPX-Jp@IL4@2NBot1ZRY(UK>JvOfR}+M-0XX zj_Lib5fea1twMLka7xdr;t9g+h;b>%BPOFDm)}RjD`muFH2lvBHt~*iI-9>Qy?0dB zFDi@m`;_&AkhcCO#nN8o^2L|A%)i&RH6!CkcCE~VB^#T+>Yu&sRfD)R)oV+>NKq{i zbdqSJbPEJcUW=wTtDJz#*iH^zi%aREVDG&uo)(bRkTPdh$8FB8y{cZ<@YEuE)f}6R zT67UJ!PgtIo7F6t_p0xlJi%d0a_v<;^d1^?k{I>tNJ8y@haks_O~qV*6`qxR6S&_7 zi&`|W7nU`{Bzb^>Gnv8UTO6qO0zD=#ZX3H{Nbu{NkY{#sz$=Ehz7ghZ5X4J{Ts%!S z3ex3`GP8q=i}hD!Hx>KUi@JG7rdML?*xQt0#g0l#*-|Kc<1lnxkDTjd$@cKn9*HiE|GT=i1@`7^A z*ZoVj_kpM{{_xqc4kWgZKygVlt{3~m*zuU4;Z9(=RiFeC_i%mg2AkQzEb)&!UXM!vl6T#C<<~ZEqu8F`$T^@ON4(B`XBMF_@gCNH$<@23sNTaXuI|5x(21C;t zEuWP(VV*Gke1dM_@tbPolt;#sA0UP&Svat#Be7|y&gs06$;#I6PqZBcQRBhlSD#sd z@9fDGlsO-Y&erEcEHQp3*!f)OB%$J{MyO~RP|Fibm}<9W+DWN+I8w2z=F6)-+iI>h zTjX{Q)rWjwkglyqNmhm!7aCae~R zvG^Pj&ndiGO^c%MBPqG0cHAb%|84#H!6^ANX303g1A! zf^jw&sU^(iVekgy$AS_&SXcB}@C4&+%#6qU(PWp!`4c&FATrZ%n28o@IsK8a zcRBWC2+!>qpBbcWV0AAI{hFf;b0&tTG#!jBw-+bj3B+SdJX*pzdg=GyV?gM zjxAm_(q*{v=Dh+);!$o0u&4)a6p_cEBOCw5V`LCwW!+~0g7Tw-PYIue>_PUMWx;3_ zBGE=GXy4AZcLxK=49PbC{7ByS)vsA9x zl6w*d9!~O)9O?WBunAi&#*`v+)Qp*)y6!+Fb*VF;%GwlcR~{$J zwuw%b3B8HUQ_Zyj`KU-FSK1Meym;rLA9CeGB7M3wYo=nQGr@=_1i*VLI+~}68--E^ z%4tF=PdvtyK2wCeco50cTr#JR;{^s2*#6{WB

UFfwK4oIbk--fJgZRD+#NZuGc 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 yNM3=9i!^ Date: Thu, 1 Oct 2026 10:12:15 -0400 Subject: [PATCH 43/45] feat: auto-detect and merge a series' IMDb ID changing between syncs 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. --- core/database.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/core/database.py b/core/database.py index 63e096b..704266a 100644 --- a/core/database.py +++ b/core/database.py @@ -328,6 +328,7 @@ def _init_postgresql_tables(self, cursor): def upsert_series(self, imdb_id: str, path: str, instance: str = 'sonarr', metadata: Optional[Dict] = None): with self.get_connection() as conn: cursor = conn.cursor() + self._auto_migrate_series_on_path_match(cursor, imdb_id, path, instance) timestamp = datetime.utcnow() cursor.execute(""" INSERT INTO series (imdb_id, instance, path, last_updated, metadata) @@ -337,6 +338,50 @@ def upsert_series(self, imdb_id: str, path: str, instance: str = 'sonarr', metad last_updated = EXCLUDED.last_updated, metadata = EXCLUDED.metadata """, (imdb_id, instance, path, timestamp, json.dumps(metadata) if metadata else None)) + + def _auto_migrate_series_on_path_match(self, cursor, new_imdb_id: str, path: str, instance: str): + """Detect Sonarr silently re-mapping a series' IMDb ID and merge instead of duplicating. + + Common for very new/upcoming titles whose metadata hasn't settled on a + canonical IMDb ID yet - the next sync reports a different imdb_id for + the exact same show. series' PK is (imdb_id, instance), so without + this the upsert below would just create a second row for the "new" ID + and silently fork the episode history between the two. The series + path Sonarr hands us is the stable signal here (it doesn't change + just because a metadata ID got corrected), so a path match under the + same instance but a different imdb_id is treated as proof it's the + same show and merged via the same logic migrate_series_imdb_id() uses + manually - episodes move over, any season/episode that already exists + under the new ID (a genuine collision between the two, not just + happenstance) is dropped rather than left orphaned under a dead ID. + """ + if not path: + return + cursor.execute( + "SELECT imdb_id FROM series WHERE instance = %s AND path = %s AND imdb_id != %s", + (instance, path, new_imdb_id) + ) + row = cursor.fetchone() + if not row: + return + old_imdb_id = row['imdb_id'] + _log("INFO", f"[{instance}] Series IMDb ID changed: {old_imdb_id} -> {new_imdb_id} (same path: {path}) — auto-migrating episode history") + + cursor.execute(""" + UPDATE episodes SET imdb_id = %s, skipped = FALSE, skip_reason = NULL + WHERE imdb_id = %s AND instance = %s + AND NOT EXISTS ( + SELECT 1 FROM episodes e2 + WHERE e2.imdb_id = %s AND e2.instance = %s + AND e2.season = episodes.season AND e2.episode = episodes.episode + ) + """, (new_imdb_id, old_imdb_id, instance, new_imdb_id, instance)) + + # Whatever's left under the old ID is a real season/episode collision + # with the new ID — can't move it without violating the PK, and the + # actively-syncing new ID's data is the one to trust. + cursor.execute("DELETE FROM episodes WHERE imdb_id = %s AND instance = %s", (old_imdb_id, instance)) + cursor.execute("DELETE FROM series WHERE imdb_id = %s AND instance = %s", (old_imdb_id, instance)) def upsert_episode_date(self, imdb_id: str, season: int, episode: int, aired: Optional[str], dateadded: Optional[str], From fb554bd90c5e0829a42753d4897cd40299a56417 Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Thu, 1 Oct 2026 10:12:21 -0400 Subject: [PATCH 44/45] fix: update bundled Emby plugin DLL to the verified 2.0.22 fix 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/CHANGELOG.md | 4 ++-- Emby-DLL/Chronarr.Emby.Plugin.dll | Bin 267776 -> 269312 bytes 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Emby-DLL/CHANGELOG.md b/Emby-DLL/CHANGELOG.md index 443b4a5..4784ff3 100644 --- a/Emby-DLL/CHANGELOG.md +++ b/Emby-DLL/CHANGELOG.md @@ -1,7 +1,7 @@ # Chronarr Emby Plugin — Changelog -## v2.0.21 — Library Exclusions Fix -- Fixed library exclusions not actually excluding anything — the config page saved a library's ID in a different GUID format than the real-time sync and scheduled task compared it against, so a checked-off library still got scanned +## v2.0.22 — Library Exclusions Fix +- Fixed library exclusions not actually excluding anything — Emby's virtual-folder API exposes three different ID fields for a library (`Guid`, `ItemId`, `Id`), and the config page was capturing the wrong one (`Id`, a short legacy numeric string on some Emby versions, not a GUID at all). That never matched what the real-time sync and scheduled task compare against, so a checked-off library still got scanned regardless of which library it was. Now reads the library's actual `Guid` field — **re-check and re-save Library Exclusions after updating** so the corrected IDs get stored. ## v2.0.20 — Server Name & Admin Pre-fill - Server name field is now read-only and sourced automatically from Emby's configured server name diff --git a/Emby-DLL/Chronarr.Emby.Plugin.dll b/Emby-DLL/Chronarr.Emby.Plugin.dll index 29e5da0c87672fc11c117898c410c8bc9ba9b6ee..d3d44e672d660e0ad48baebc444114db7b6571f6 100644 GIT binary patch delta 29783 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 delta 28063 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 Date: Thu, 1 Oct 2026 11:18:36 -0400 Subject: [PATCH 45/45] fix: update bundled Emby plugin DLL - license checksum fix included 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...). --- Emby-DLL/CHANGELOG.md | 3 +++ Emby-DLL/Chronarr.Emby.Plugin.dll | Bin 269312 -> 269312 bytes 2 files changed, 3 insertions(+) diff --git a/Emby-DLL/CHANGELOG.md b/Emby-DLL/CHANGELOG.md index 4784ff3..a3a86fe 100644 --- a/Emby-DLL/CHANGELOG.md +++ b/Emby-DLL/CHANGELOG.md @@ -1,5 +1,8 @@ # Chronarr Emby Plugin — Changelog +## v2.0.22 — License Checksum Fix (same DLL version, no code bump needed) +- Fixed license validation silently failing for every license issued since the Chronarr rebrand — the plugin's local license format check only recognized the legacy `NFOG-` key prefix; the license server has been issuing `CHRO-` prefixed keys for a while now, so a perfectly valid, server-verified license would still fail this local check and disable all sync. Both prefixes are now accepted. + ## v2.0.22 — Library Exclusions Fix - Fixed library exclusions not actually excluding anything — Emby's virtual-folder API exposes three different ID fields for a library (`Guid`, `ItemId`, `Id`), and the config page was capturing the wrong one (`Id`, a short legacy numeric string on some Emby versions, not a GUID at all). That never matched what the real-time sync and scheduled task compare against, so a checked-off library still got scanned regardless of which library it was. Now reads the library's actual `Guid` field — **re-check and re-save Library Exclusions after updating** so the corrected IDs get stored. diff --git a/Emby-DLL/Chronarr.Emby.Plugin.dll b/Emby-DLL/Chronarr.Emby.Plugin.dll index d3d44e672d660e0ad48baebc444114db7b6571f6..7661ae2f53805e7067f899804cfc5029d7f4c279 100644 GIT binary patch delta 3870 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 BaDxB<

- Episode - - Aired - - Date Added - - Source - - Video - EpisodeAiredDate AddedSourceVideo Actions
${episode.source_description || episode.source || 'Unknown'} ${hasVideoBadge} - ${movie.released || '-'} ${movie.source_description || movie.source || 'Unknown'} - ${episode.aired || '-'} ${episode.source_description || episode.source || 'Unknown'} -
${series.episodes_with_video} ${instanceBadge(series.instance)} - ${dateadded}
${series.episodes_with_video} ${instanceBadge(series.instance)} - ${dateadded}