diff --git a/.env.example b/.env.example index 7244df9..e847ffa 100644 --- a/.env.example +++ b/.env.example @@ -1,172 +1,262 @@ # =========================================== -# 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 +# 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 -# 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) +# 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 +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). +# 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. -# 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 +RADARR_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_HOST=your-radarr-db-host 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, 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 +# 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 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 ← 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=/radarr-4k-data/radarr.db + +# =========================================== +# SONARR — MAIN INSTANCE # =========================================== -# Direct database access for better performance -# Phase 7: High-performance TV episode import date detection +# 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 +SONARR_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 +# 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 +# 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 ← 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 +# SONARR_4K_DB_NAME=sonarr-4k +# SONARR_4K_DB_USER=postgres +# SONARR_4K_DB_PASSWORD= → set in .env.secrets + +# =========================================== +# EXTERNAL APIs (OPTIONAL) +# =========================================== +# 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 # =========================================== -# RELEASE DATE PROCESSING +# MOVIE PROCESSING # =========================================== -# Priority order for release date fallbacks (digital, physical, theatrical) +# 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/.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/.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" 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/Emby-DLL/CHANGELOG.md b/Emby-DLL/CHANGELOG.md new file mode 100644 index 0000000..a3a86fe --- /dev/null +++ b/Emby-DLL/CHANGELOG.md @@ -0,0 +1,41 @@ +# 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. + +## 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 +- Registration and validation payloads now include structured server and user blocks for improved license server tracking + +## v2.0.19 — Movie Date Sync Fix +- Movies now resolve their IMDb ID from Emby's own metadata instead of requiring it in the folder name — standard naming (Movie (Year)) is fully supported for real-time sync and scheduled task + +## v2.0.18 — TV Episode Date Sync +- Real-time sync and scheduled task now resolve the series IMDb ID from Emby's own metadata instead of requiring it to be embedded in the folder name — standard Radarr/Sonarr naming (Series (Year)) is fully supported + +## v2.0.17 — Registration Error Handling +- Invalid or disposable email addresses now show a clear message in the plugin configuration page instead of a misleading network error after the offline grace period expires + +## v2.0.16 — Database-Direct Sync + License Self-Heal +- License validation retries every hour when invalid — self-heals after transient server outages without waiting 23 hours +- Removed NFO file dependency — date lookups go directly to the Chronarr database; real-time sync no longer silently skips items without a sidecar NFO file +- Movies without an IMDb ID in the filename fall back to reading the ID from movie.nfo content if present + +## v2.0.14 — Removed TLS Certificate Pinning +- Removed cert pinning from the license client — the pinned hash expired every 90 days on Let's Encrypt renewal, breaking check-ins for all users +- License responses are already protected by RSA signature verification; standard TLS CA validation is now used instead + +## v2.0.13 — Platform Reporting +- License check-ins now identify the media server platform (Emby) so the admin panel can distinguish Emby from Jellyfin installs + +## v2.0.12 — Security: Grace Period Hardening +- Offline grace period no longer bypasses an expired license — blocking the server after expiry is denied immediately +- Last server-validated expiry is stored in a signed state file; signature verified on every offline check + +## v2.0.11 — Library Exclusions +- New: exclude specific Emby libraries from processing — config page shows checkboxes for each library +- Exclusions apply to both scheduled task and real-time sync diff --git a/Emby-DLL/Chronarr.Emby.Plugin.dll b/Emby-DLL/Chronarr.Emby.Plugin.dll index ca6e95a..7661ae2 100644 Binary files a/Emby-DLL/Chronarr.Emby.Plugin.dll and b/Emby-DLL/Chronarr.Emby.Plugin.dll differ diff --git a/QUICKSTART.md b/QUICKSTART.md index eb833bb..40d8d44 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** @@ -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 @@ -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/README.md b/README.md index 5160908..461bd16 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** @@ -200,13 +201,12 @@ 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. 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 +335,92 @@ SONARR_DB_PASSWORD=sonarr_pass # Sonarr database password (.env.secrets) # See docker-compose.yml.example for volume mount configuration ``` -**SQLite Docker 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. + +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 +# 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_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_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 (multi-instance):** -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. +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 +``` + +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 +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 +433,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 +442,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. @@ -472,7 +536,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 @@ -486,15 +550,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 ``` @@ -507,8 +572,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 @@ -529,11 +595,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. @@ -554,8 +620,33 @@ 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 +- 🏠 **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 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 d1d3352..17122f1 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2,11 +2,16 @@ FastAPI routes for Chronarr - extracted from main chronarr.py for modular architecture """ import os +import re import hmac import hashlib import json +import uuid import requests import asyncio +import tempfile +import multiprocessing +from collections import deque from pathlib import Path from datetime import datetime, timezone from fastapi import HTTPException, BackgroundTasks, Request, Response @@ -16,7 +21,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 @@ -80,8 +86,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"] @@ -94,7 +100,13 @@ 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}") + # Short id so related log lines across the async receive -> batch -> + # background-thread processing hop can be told apart from other + # webhooks landing around the same time, without needing to thread it + # through every internal call — see it again at the batch/process + # boundaries in webhooks/webhook_batcher.py. + req_id = uuid.uuid4().hex[:6] + _log("INFO", f"[{instance}:{req_id}] 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 +122,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}:{req_id}] 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}:{req_id}] 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}:{req_id}] 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}:{req_id}] webhook.episodeFile present: {webhook.episodeFile is not None}") if webhook.episodeFile: - _log("DEBUG", f"episodeFile content: {webhook.episodeFile}") + _log("DEBUG", f"[{instance}:{req_id}] episodeFile content: {webhook.episodeFile}") if not episodes_data and webhook.episodeFile: episode_file = webhook.episodeFile @@ -141,17 +154,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}:{req_id}] 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}:{req_id}] 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}:{req_id}] Could not extract season/episode from filename: {file_path}") - _log("DEBUG", f"episodeFile seasonNumber: {season_num}, episodeNumber: {episode_num}") + _log("DEBUG", f"[{instance}:{req_id}] 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 +174,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}:{req_id}] 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}:{req_id}] 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}:{req_id}] 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}:{req_id}] 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}:{req_id}] 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}:{req_id}] 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}:{req_id}] Got {len(all_records)} total history records") # Filter for recent rename events since_timestamp = datetime.utcnow() - timedelta(hours=1) @@ -211,16 +224,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}:{req_id}] 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}:{req_id}] 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}:{req_id}] Found episodeId {episode_id} in rename event") if episode_id: # Fetch episode details using the episodeId @@ -233,7 +246,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}:{req_id}] 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,52 +255,54 @@ 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}:{req_id}] 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}:{req_id}] Episode details missing season/episode numbers") else: - _log("DEBUG", f"Failed to fetch episode details: {episode_response.status_code}") + _log("DEBUG", f"[{instance}:{req_id}] Failed to fetch episode details: {episode_response.status_code}") else: - _log("DEBUG", f"No episodeId found in rename event") + _log("DEBUG", f"[{instance}:{req_id}] No episodeId found in rename event") else: - _log("DEBUG", f"No recent rename events found in last hour") + _log("DEBUG", f"[{instance}:{req_id}] 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}:{req_id}] Failed to get rename history: {history_response.status_code}") else: - _log("DEBUG", f"No series found for IMDb {imdb_id}") + _log("DEBUG", f"[{instance}:{req_id}] No series found for IMDb {imdb_id}") else: - _log("DEBUG", f"Series lookup failed: {response.status_code}") + _log("DEBUG", f"[{instance}:{req_id}] Series lookup failed: {response.status_code}") except Exception as e: - _log("DEBUG", f"Error finding renamed episode: {e}") + _log("DEBUG", f"[{instance}:{req_id}] 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}:{req_id}] 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, + 'req_id': req_id, } 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}:{req_id}] Sonarr webhook error: {e}") 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"] @@ -295,9 +310,25 @@ 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}") - + # Short id so related log lines across the async receive -> batch -> + # background-thread processing hop can be told apart from other + # webhooks landing around the same time — see it again at the + # batch/process boundaries in webhooks/webhook_batcher.py. + req_id = uuid.uuid4().hex[:6] + _log("INFO", f"[{instance}:{req_id}] Received Radarr webhook: {payload.get('eventType', 'Unknown')}") + # A compact summary by default — the full payload is a multi-KB blob that + # drowns out everything around it. Set LOG_RAW_WEBHOOK_PAYLOADS=true for + # the old full-dump behavior when deep-debugging a specific webhook. + _movie = payload.get('movie', {}) or {} + _movie_file = payload.get('movieFile', {}) or {} + _log("DEBUG", ( + f"[{instance}:{req_id}] Radarr webhook summary: title={_movie.get('title')!r} " + f"imdb={_movie.get('imdbId')} year={_movie.get('year')} " + f"path={_movie_file.get('path') or _movie.get('folderPath')}" + )) + if os.environ.get("LOG_RAW_WEBHOOK_PAYLOADS", "false").lower() == "true": + _log("DEBUG", f"[{instance}:{req_id}] Full Radarr webhook payload: {payload}") + # Filter supported event types (same as Sonarr: Download, Upgrade, Rename) event_type = payload.get('eventType', '') if event_type not in ["Download", "Upgrade", "Rename"]: @@ -306,50 +337,50 @@ 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}:{req_id}] 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}:{req_id}] 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}:{req_id}] 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}:{req_id}] 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}:{req_id}] RADARR WEBHOOK REJECTED: Mapped path does not exist: {container_path}") + _log("ERROR", f"[{instance}:{req_id}] 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 - # 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, + 'req_id': req_id, } - - # 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"[{instance}:{req_id}] 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}:{req_id}] Radarr webhook error: {e}") return {"status": "error", "message": str(e)} @@ -568,13 +599,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 +863,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)').""" @@ -1635,24 +1680,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})") @@ -2569,44 +2619,67 @@ async def update_episode_nfo(imdb_id: str, season: int, episode: int, request: R # Emby Plugin Lookup Functions # --------------------------- -async def lookup_episode(imdb_id: str, season: int, episode: int, dependencies: dict): +async def lookup_episode(imdb_id: str, season: int, episode: int, dependencies: dict, instance: str = None): """ Lookup episode dateadded from Chronarr database for Emby plugin integration - + Returns dateadded information if found, or null if not available. Used by Emby plugin to populate missing dateadded elements in NFO files. + + The plugin's request has no concept of instance (it predates multi-instance + support) — when instance isn't given, search across every instance sharing + this imdb_id/season/episode instead of silently defaulting to 'sonarr' only, + which would miss any row that exists solely under a named instance. """ try: - _log("DEBUG", f"Episode lookup called for {imdb_id} S{season:02d}E{episode:02d}") - + _log("DEBUG", f"Episode lookup called for {imdb_id} S{season:02d}E{episode:02d} (instance: {instance or 'any'})") + db = dependencies.get("db") if not db: print(f"ERROR: Database not available in dependencies") raise HTTPException(status_code=500, detail="Database not available") - + # Normalize IMDb ID (ensure tt prefix) if not imdb_id.startswith('tt'): imdb_id = f"tt{imdb_id}" - + _log("DEBUG", f"Querying database for episode {imdb_id} S{season:02d}E{episode:02d}") - + # Query database for episode - result = db.get_episode_date(imdb_id, season, episode) - + if instance: + result = db.get_episode_date(imdb_id, season, episode, instance) + else: + result = db.get_episode_date_any_instance(imdb_id, season, episode) + _log("DEBUG", f"Database query result: {result}") if result and result.get('dateadded'): # Format response for Emby plugin dateadded = result['dateadded'] - + # Convert datetime to ISO string if needed if hasattr(dateadded, 'isoformat'): dateadded_str = dateadded.isoformat() else: dateadded_str = str(dateadded) - + # NOTE: Auto-fix functionality removed - just return database data - + + # One INFO line per lookup — same convention as the webhook + # handlers ("Received Sonarr webhook..."). The DEBUG lines above + # are silent by default now (LOG_LEVEL=INFO), so without this, + # a plugin scan (scheduled full-library or single-item) never + # showed up on Chronarr's side at all, only in Emby/Jellyfin's + # own log — there was no INFO-level line on a normal lookup, + # only on an outright error. Title comes from the series row + # (episodes has no title column of its own); clearing the miss + # record here means a show that resolves later drops off the + # Unresolved Lookups page on its own. + series_title = db.get_series_title_any_instance(imdb_id) + db.clear_lookup_miss(imdb_id, 'episode', season, episode) + title_part = f'"{series_title}" ' if series_title else '' + _log("INFO", f"Plugin lookup: episode {title_part}({imdb_id}) S{season:02d}E{episode:02d} -> found (source={result.get('source', 'database')})") + return { "found": True, "imdb_id": imdb_id, @@ -2615,10 +2688,26 @@ async def lookup_episode(imdb_id: str, season: int, episode: int, dependencies: "dateadded": dateadded_str, "source": result.get('source', 'database'), "air_date": result.get('air_date') if result.get('air_date') else None, + "instance": result.get('instance'), "auto_fixed": False # Auto-fix functionality disabled } else: - # Not found in database + # Not found — see lookup_movie() for why this distinguishes "no + # episode row at all" from "row exists, no resolved date yet". + # A series row can exist (with a title) even when this + # particular episode row doesn't, so the title lookup is + # independent of whether `result` itself is set. + series_title = db.get_series_title_any_instance(imdb_id) + reason, reason_desc = ('no_resolved_date', 'in DB, no resolved date yet') if result \ + else ('no_db_record', 'no episode record' if series_title else 'no DB record') + + db.record_lookup_miss( + imdb_id, 'episode', reason, instance=instance, title=series_title, + season=season, episode=episode + ) + + title_part = f'"{series_title}" ' if series_title else '' + _log("INFO", f"Plugin lookup: episode {title_part}({imdb_id}) S{season:02d}E{episode:02d} -> not found ({reason_desc})") return { "found": False, "imdb_id": imdb_id, @@ -2628,7 +2717,7 @@ async def lookup_episode(imdb_id: str, season: int, episode: int, dependencies: "source": None, "air_date": None } - + except Exception as e: _log("ERROR", f"Episode lookup failed for {imdb_id} S{season:02d}E{episode:02d}: {e}") raise HTTPException(status_code=500, detail=f"Episode lookup failed: {str(e)}") @@ -2812,28 +2901,52 @@ 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)}") -async def lookup_movie(imdb_id: str, dependencies: dict): +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 - - Returns dateadded information if found, or null if not available. + + Returns dateadded information if found, or null if not available. Used by Emby plugin to populate missing dateadded elements in NFO files. + + See lookup_episode() for why instance is optional and how it's resolved + when the caller (the plugin, which predates multi-instance) doesn't send one. """ try: db = dependencies.get("db") if not db: raise HTTPException(status_code=500, detail="Database not available") - - _log("DEBUG", f"Movie lookup called for IMDb ID: {imdb_id}") - + + _log("DEBUG", f"Movie lookup called for IMDb ID: {imdb_id} (instance: {instance or 'any'})") + # Normalize IMDb ID (ensure tt prefix) if not imdb_id.startswith('tt'): imdb_id = f"tt{imdb_id}" - + _log("DEBUG", f"Normalized IMDb ID: {imdb_id}") - + # Query database for movie - result = db.get_movie_dates(imdb_id) + result = db.get_movie_dates(imdb_id, instance) if instance else db.get_movie_dates_any_instance(imdb_id) _log("DEBUG", f"Movie lookup for {imdb_id}: database result = {result}") # If not found, let's see what movies we DO have in the database @@ -2848,25 +2961,54 @@ async def lookup_movie(imdb_id: str, dependencies: dict): if result and result.get('dateadded'): # Format response for Emby plugin dateadded = result['dateadded'] - + # Convert datetime to ISO string if needed if hasattr(dateadded, 'isoformat'): dateadded_str = dateadded.isoformat() else: dateadded_str = str(dateadded) - + # NOTE: Auto-fix functionality removed - just return database data - + + # One INFO line per lookup — see lookup_episode() for why this is + # needed: the DEBUG lines above are silent by default now + # (LOG_LEVEL=INFO), so a plugin scan never showed up on + # Chronarr's side at all, only in Emby/Jellyfin's own log. + # Title makes the line useful without cross-referencing the ID + # 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 = _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')})") + return { "found": True, "imdb_id": imdb_id, "dateadded": dateadded_str, "source": result.get('source', 'database'), "released": result.get('released') if result.get('released') else None, + "instance": result.get('instance'), "auto_fixed": False # Auto-fix functionality disabled } else: - # Not found in database + # Not found — but "not found" means two very different things, and + # collapsing them into one log line made it impossible to tell + # which one you're looking at without going and checking the DB + # by hand. `result` is the movie row (if any) from the + # 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 = _movie_title_from_result(result) + else: + reason, reason_desc = 'no_db_record', 'no DB record' + movie_title = None + + db.record_lookup_miss(imdb_id, 'movie', reason, instance=instance, title=movie_title) + + title_part = f'"{movie_title}" ' if movie_title else '' + _log("INFO", f"Plugin lookup: movie {title_part}({imdb_id}) -> not found ({reason_desc})") return { "found": False, "imdb_id": imdb_id, @@ -2874,43 +3016,223 @@ async def lookup_movie(imdb_id: str, dependencies: dict): "source": None, "released": None } - + except Exception as e: _log("ERROR", f"Movie lookup failed for {imdb_id}: {e}") raise HTTPException(status_code=500, detail=f"Movie lookup failed: {str(e)}") -async def populate_database(background_tasks: BackgroundTasks, media_type: str = "both", dependencies: dict = None): +# Populate Database — runs in a separate OS process (not an asyncio background +# task) so a large populate doesn't block this container's event loop, and +# writes its status to a file both this process and the worker process can +# see. Moved here from chronarr-web (2026-09-15): it was previously running +# entirely inside the web container, which has no LOG_DIR volume mount, so +# every _log() call DatabasePopulator makes went to a path invisible on the +# host and lost on restart — docker logs showed it live (print() doesn't care +# about mounts) but chronarr.log itself never got it. This container is the +# one with the log volume, so this is where the actual work belongs; web now +# proxies to here the same way it already does for /manual/scan. +POPULATE_STATUS_FILE = os.path.join(tempfile.gettempdir(), "chronarr_populate_status.json") +_populate_process = None + + +def _populate_worker_process(media_type: str, status_file: str, instance: str = "all"): + """ + Worker process that runs database population in complete isolation. + Runs in a separate process - keeps this container's API responsive. + """ + import os + import sys + import json + from datetime import datetime + + # Add parent directory to path for imports + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + def update_status(status_data): + """Write status to file for main process to read""" + try: + with open(status_file, 'w') as f: + json.dump(status_data, f) + except Exception as e: + print(f"ERROR: Failed to update status file: {e}") + + # Initialize status + status = { + "running": True, + "media_type": media_type, + "instance": instance, + "start_time": datetime.now().isoformat(), + "movies": {"status": "pending", "stats": None}, + "tv": {"status": "pending", "stats": None}, + "completed": False, + "error": None + } + update_status(status) + + try: + # Import here (inside process) to avoid pickling issues + from core.database import ChronarrDatabase + from core.database_populator import DatabasePopulator + from config.settings import config + + db = ChronarrDatabase(config) + + def _merge_movie_stats(all_stats): + merged = {'total': 0, 'added': 0, 'updated': 0, 'skipped': 0, 'errors': 0, + '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': [], '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})") + 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]}") + + # Run population based on media type + if media_type in ["movies", "both"]: + 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}'") + + 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}") + + 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] All movie population complete: {movie_stats}") + + if media_type in ["tv", "both"]: + 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}'") + + 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}") + + 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] All TV population complete: {tv_stats}") + + # Mark as completed + status["completed"] = True + status["running"] = False + update_status(status) + print("INFO: [Worker Process] Database population completed successfully") + + except Exception as e: + print(f"ERROR: [Worker Process] Database population failed: {e}") + import traceback + traceback.print_exc() + + status["error"] = str(e) + status["running"] = False + status["completed"] = True + update_status(status) + + +async def populate_database(background_tasks: BackgroundTasks, media_type: str = "both", dependencies: dict = None, instance: str = "all"): """ - Populate Chronarr database from Radarr/Sonarr sources + Populate Chronarr database from Radarr/Sonarr sources in a separate process. + This keeps this container's API responsive during population. Args: - background_tasks: FastAPI background tasks + background_tasks: FastAPI background tasks (not used - kept for compatibility) media_type: Type of media to populate ("movies", "tv", or "both") - dependencies: Dictionary with db, radarr_client, sonarr_client + 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 """ - from core.database_populator import DatabasePopulator - - db = dependencies["db"] - config = dependencies["config"] - - # Get Radarr and Sonarr clients - from clients.radarr_client import RadarrClient - from clients.sonarr_client import SonarrClient - - radarr_client = RadarrClient(config) - sonarr_client = SonarrClient(config) + global _populate_process if media_type not in ["both", "movies", "tv"]: raise HTTPException(status_code=400, detail="media_type must be 'both', 'movies', or 'tv'") - # Create global status tracking - populate_status = { + # Check if process is already running + if _populate_process and _populate_process.is_alive(): + return { + "status": "already_running", + "message": "Database population is already in progress" + } + + # Initialize status file + 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}, @@ -2918,111 +3240,608 @@ async def populate_database(background_tasks: BackgroundTasks, media_type: str = "error": None } - # Store status globally so it can be queried - global _populate_status - _populate_status = populate_status - - async def run_population(): - """Background task to populate the database""" - try: - populator = DatabasePopulator(db, radarr_client, sonarr_client) - - _log("INFO", f"Starting database population: {media_type}") - - if media_type == "movies": - populate_status["movies"]["status"] = "running" - movie_stats = populator.populate_movies() - populate_status["movies"]["status"] = "completed" - populate_status["movies"]["stats"] = movie_stats - _log("INFO", f"Movie population completed: {movie_stats}") - - elif media_type == "tv": - populate_status["tv"]["status"] = "running" - tv_stats = populator.populate_tv_episodes() - populate_status["tv"]["status"] = "completed" - populate_status["tv"]["stats"] = tv_stats - _log("INFO", f"TV population completed: {tv_stats}") - - elif media_type == "both": - populate_status["movies"]["status"] = "running" - movie_stats = populator.populate_movies() - populate_status["movies"]["status"] = "completed" - populate_status["movies"]["stats"] = movie_stats - _log("INFO", f"Movie population completed: {movie_stats}") - - populate_status["tv"]["status"] = "running" - tv_stats = populator.populate_tv_episodes() - populate_status["tv"]["status"] = "completed" - populate_status["tv"]["stats"] = tv_stats - _log("INFO", f"TV population completed: {tv_stats}") - - populate_status["completed"] = True - populate_status["running"] = False - _log("INFO", "Database population completed successfully") - - except Exception as e: - _log("ERROR", f"Database population failed: {e}") - populate_status["error"] = str(e) - populate_status["running"] = False - populate_status["completed"] = True - - # Add task to background - background_tasks.add_task(run_population) + try: + with open(POPULATE_STATUS_FILE, 'w') as f: + json.dump(initial_status, f) + except Exception as e: + print(f"ERROR: Failed to initialize status file: {e}") + raise HTTPException(status_code=500, detail=f"Failed to initialize status tracking: {e}") + + # Start population in separate process + _populate_process = multiprocessing.Process( + target=_populate_worker_process, + args=(media_type, POPULATE_STATUS_FILE, instance), + daemon=False # Keep process alive even if parent exits + ) + _populate_process.start() - _log("INFO", f"Database population 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 "") } async def get_populate_status(): - """Get the current status of database population""" - global _populate_status - if '_populate_status' not in globals(): + """Get the current status of database population from status file""" + global _populate_process + + # Check if status file exists + if not os.path.exists(POPULATE_STATUS_FILE): return {"running": False, "completed": False} - return _populate_status + # Read status from file + try: + with open(POPULATE_STATUS_FILE, 'r') as f: + status = json.load(f) -# Initialize global populate status -_populate_status = {"running": False, "completed": False} + # Check if process is still alive + if _populate_process: + status["process_alive"] = _populate_process.is_alive() + if not _populate_process.is_alive() and status.get("running"): + # Process died unexpectedly + status["running"] = False + status["completed"] = True + if not status.get("error"): + status["error"] = "Process terminated unexpectedly" + return status -def register_routes(app, dependencies: dict): + except Exception as e: + print(f"ERROR: Failed to read status file: {e}") + return { + "running": False, + "completed": True, + "error": f"Failed to read status: {e}" + } + +# 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. """ - 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 + 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.""" + + # 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): 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("/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})") + + # --------------------------- + # Log file endpoints + # --------------------------- + # Only chronarr.log (current) and its rotated backups (chronarr.log.1/.2/.3) + # ever exist in LOG_DIR — validate every filename against this before + # touching the filesystem so a request can't read/download anything else. + _LOG_FILENAME_RE = re.compile(r"^chronarr\.log(\.\d+)?$") + + def _log_dir() -> Path: + return Path(os.environ.get("LOG_DIR", "/app/data/logs")) + + def _validated_log_path(filename: str) -> Path: + if not _LOG_FILENAME_RE.match(filename): + raise HTTPException(status_code=404, detail="Log file not found") + path = _log_dir() / filename + if not path.is_file(): + raise HTTPException(status_code=404, detail="Log file not found") + return path + + @app.get("/api/logs") + async def _list_logs(): + """List the current log file and any rotated backups, newest first.""" + log_dir = _log_dir() + files = [] + if log_dir.is_dir(): + for path in log_dir.iterdir(): + if _LOG_FILENAME_RE.match(path.name): + stat = path.stat() + files.append({ + "filename": path.name, + "size_bytes": stat.st_size, + "modified_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + "is_current": path.name == "chronarr.log", + }) + # Current log first, then rotated backups oldest-suffix-last (.1 before .2, etc.) + files.sort(key=lambda f: (not f["is_current"], f["filename"])) + return {"files": files} + + @app.get("/api/logs/{filename}/tail") + async def _tail_log(filename: str, lines: int = 200): + """Return the last N lines of a log file as JSON, for the in-browser viewer. + + Reads the whole file to get an accurate tail (these files cap at 50MB + per the rotation policy — fast enough for occasional viewing) rather + than an approximate byte-offset seek. + """ + lines = max(1, min(lines, 5000)) # keep the response itself reasonable + path = _validated_log_path(filename) + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + tail = list(deque(f, maxlen=lines)) + except OSError as e: + raise HTTPException(status_code=500, detail=f"Failed to read log file: {e}") + return { + "filename": filename, + "lines": [line.rstrip("\n") for line in tail], + "returned_count": len(tail), + } + + @app.get("/api/logs/{filename}/download") + async def _download_log(filename: str): + """Download a log file as-is.""" + from fastapi.responses import FileResponse + path = _validated_log_path(filename) + return FileResponse(path, media_type="text/plain", filename=filename) + + # --------------------------- + # Unresolved plugin lookups + # --------------------------- + # /api/lookup/movie and /api/lookup/episode record a row here every time + # they answer "not found" — see lookup_movie()/lookup_episode() above. + # These two endpoints let the web UI show and clear that list instead of + # it only existing as a line in chronarr.log. + + @app.get("/api/unresolved-lookups") + async def _list_unresolved_lookups(include_dismissed: bool = False): + db = dependencies.get("db") + if not db: + raise HTTPException(status_code=500, detail="Database not available") + rows = db.get_unresolved_lookups(include_dismissed=include_dismissed) + return {"lookups": [dict(row) for row in rows]} + + @app.post("/api/unresolved-lookups/{lookup_id}/dismiss") + async def _dismiss_unresolved_lookup(lookup_id: int): + db = dependencies.get("db") + if not db: + raise HTTPException(status_code=500, detail="Database not available") + dismissed = db.dismiss_unresolved_lookup(lookup_id) + if not dismissed: + 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, + "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) - + @app.get("/health/simple") async def _health_simple(): """Simple health check for Docker without external dependencies""" @@ -3126,8 +3945,8 @@ async def _scan_status(): return await get_scan_status() @app.post("/admin/populate-database") - async def _populate_database(background_tasks: BackgroundTasks, media_type: str = "both"): - return await populate_database(background_tasks, media_type, dependencies) + async def _populate_database(background_tasks: BackgroundTasks, media_type: str = "both", instance: str = "all"): + return await populate_database(background_tasks, media_type, dependencies, instance) @app.get("/api/populate/status") async def _populate_status(): @@ -3166,12 +3985,12 @@ async def _debug_tmdb_lookup(imdb_id: str): # --------------------------- @app.get("/api/lookup/episode/{imdb_id}/{season}/{episode}") - async def _lookup_episode(imdb_id: str, season: int, episode: int): - return await lookup_episode(imdb_id, season, episode, dependencies) - + async def _lookup_episode(imdb_id: str, season: int, episode: int, instance: str = None): + return await lookup_episode(imdb_id, season, episode, dependencies, instance) + @app.get("/api/lookup/movie/{imdb_id}") - async def _lookup_movie(imdb_id: str): - return await lookup_movie(imdb_id, dependencies) + async def _lookup_movie(imdb_id: str, instance: str = None): + return await lookup_movie(imdb_id, dependencies, instance) @app.get("/api/lookup/movie/title/{title}") async def _lookup_movie_by_title(title: str, year: str = None): @@ -4008,10 +4827,27 @@ async def get_missing_imdb(): "message": f"Failed to retrieve missing IMDb items: {str(e)}" } + # --------------------------- + # Emby Plugin Support + # --------------------------- + + @app.get("/emby/changelog") + async def _emby_changelog(): + """Serve the Emby plugin changelog from the bundled CHANGELOG.md.""" + import os + from fastapi.responses import PlainTextResponse + changelog_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "Emby-DLL", "CHANGELOG.md" + ) + if os.path.exists(changelog_path): + with open(changelog_path, "r", encoding="utf-8") as f: + return PlainTextResponse(f.read(), media_type="text/markdown; charset=utf-8") + return PlainTextResponse("Changelog not found.", status_code=404) + # --------------------------- # Core API - No Web Interface # --------------------------- - + @app.get("/") async def _core_info(): """Core container API information - Web interface on separate container""" diff --git a/api/web_routes.py b/api/web_routes.py index 5858750..0e8ae06 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -4,8 +4,7 @@ """ import json import os -import tempfile -import multiprocessing +import re from datetime import datetime, timezone from typing import List, Optional, Dict, Any from fastapi import HTTPException, Query, BackgroundTasks @@ -14,14 +13,12 @@ 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) -# Status file for cross-process communication -# Use /tmp which is writable in both core and web containers (unlike /app/data, -# which can be mounted read-only) -POPULATE_STATUS_FILE = os.path.join(tempfile.gettempdir(), "chronarr_populate_status.json") - -# Process tracking -_populate_process = None +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() def map_source_to_description(source: str) -> str: @@ -58,9 +55,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: @@ -91,13 +101,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 +121,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 +136,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 +182,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 +213,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 +242,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 +261,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 +272,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 """ @@ -269,7 +285,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) @@ -362,32 +378,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'] = 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 - + # Get episodes - PostgreSQL cursor.execute(""" - SELECT season, episode, aired, dateadded, source, has_video_file, last_updated - FROM episodes - WHERE imdb_id = %s + SELECT season, episode, instance, aired, dateadded, source, has_video_file, last_updated + FROM episodes + WHERE imdb_id = %s AND instance = %s ORDER BY season, episode - """, (imdb_id,)) + """, (imdb_id, instance)) episodes = [] for row in cursor.fetchall(): @@ -411,8 +436,8 @@ async def get_missing_dates_report(dependencies: dict): # Movies without dates - PostgreSQL cursor.execute(""" - SELECT imdb_id, path, released, source, last_updated - FROM movies + SELECT imdb_id, instance, path, released, source, last_updated + FROM movies WHERE dateadded IS NULL OR source = 'no_valid_date_source' ORDER BY last_updated DESC """) @@ -429,9 +454,9 @@ async def get_missing_dates_report(dependencies: dict): # Episodes without dates - PostgreSQL cursor.execute(""" - SELECT e.imdb_id, e.season, e.episode, e.aired, e.source, e.last_updated, s.path + SELECT e.imdb_id, e.instance, e.season, e.episode, e.aired, e.source, e.last_updated, s.path FROM episodes e - JOIN series s ON e.imdb_id = s.imdb_id + JOIN series s ON e.imdb_id = s.imdb_id AND s.instance = e.instance WHERE e.dateadded IS NULL OR e.source = 'no_valid_date_source' ORDER BY e.last_updated DESC """) @@ -439,7 +464,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 @@ -566,7 +591,7 @@ async def get_dashboard_stats(dependencies: dict): # Database Modification Endpoints # --------------------------- -async def update_movie_date(dependencies: dict, imdb_id: str, dateadded: Optional[str], source: str): +async def update_movie_date(dependencies: dict, imdb_id: str, dateadded: Optional[str], source: str, instance: str = 'radarr'): """Update dateadded for a specific movie""" db = dependencies["db"] @@ -594,26 +619,27 @@ async def update_movie_date(dependencies: dict, imdb_id: str, dateadded: Optiona raise HTTPException(status_code=422, detail=f"Invalid date format: {dateadded}") # Validate movie exists - movie = db.get_movie_dates(imdb_id) + movie = db.get_movie_dates(imdb_id, instance) if not movie: raise HTTPException(status_code=404, detail="Movie not found") - + # Update the date db.upsert_movie_dates( imdb_id=imdb_id, released=movie.get('released'), dateadded=dateadded, source=source, - has_video_file=movie.get('has_video_file', False) + has_video_file=movie.get('has_video_file', False), + instance=instance ) - + # Add to processing history try: db.add_processing_history( imdb_id=imdb_id, media_type="movie", event_type="manual_date_update", - details={"old_source": movie.get('source'), "new_source": source, "dateadded": dateadded} + details={"old_source": movie.get('source'), "new_source": source, "dateadded": dateadded, "instance": instance} ) except Exception as e: print(f"⚠️ Failed to add processing history: {e}") @@ -623,15 +649,15 @@ async def update_movie_date(dependencies: dict, imdb_id: str, dateadded: Optiona return {"status": "success", "message": f"Updated movie {imdb_id}"} -async def update_episode_date(dependencies: dict, imdb_id: str, season: int, episode: int, - dateadded: Optional[str], source: str): +async def update_episode_date(dependencies: dict, imdb_id: str, season: int, episode: int, + dateadded: Optional[str], source: str, instance: str = 'sonarr'): """Update dateadded for a specific episode""" db = dependencies["db"] _log("DEBUG", f"update_episode_date called with dateadded={repr(dateadded)}, source={repr(source)}") # Get existing episode - episode_data = db.get_episode_date(imdb_id, season, episode) + episode_data = db.get_episode_date(imdb_id, season, episode, instance) if not episode_data: raise HTTPException(status_code=404, detail="Episode not found") @@ -655,7 +681,8 @@ async def update_episode_date(dependencies: dict, imdb_id: str, season: int, epi aired=episode_data.get('aired'), dateadded=dateadded, source=source, - has_video_file=episode_data.get('has_video_file', False) + has_video_file=episode_data.get('has_video_file', False), + instance=instance ) # Trigger NFO file update via core container @@ -665,10 +692,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" @@ -766,12 +801,12 @@ async def bulk_update_source(dependencies: dict, media_type: str, old_source: st } -async def get_movie_date_options(dependencies: dict, imdb_id: str): +async def get_movie_date_options(dependencies: dict, imdb_id: str, instance: str = 'radarr'): """Get available date options for a movie (Radarr import, digital release, etc.)""" db = dependencies["db"] # Get current movie data - movie = db.get_movie_dates(imdb_id) + movie = db.get_movie_dates(imdb_id, instance) if not movie: raise HTTPException(status_code=404, detail="Movie not found") @@ -942,9 +977,9 @@ async def get_movie_date_options(dependencies: dict, imdb_id: str): } -async def get_episode_date_options(dependencies: dict, imdb_id: str, season: int, episode: int): +async def get_episode_date_options(dependencies: dict, imdb_id: str, season: int, episode: int, instance: str = 'sonarr'): """Get available date options for an episode""" - _log("DEBUG", f"get_episode_date_options called with imdb_id={imdb_id}, season={season}, episode={episode}") + _log("DEBUG", f"get_episode_date_options called with imdb_id={imdb_id}, season={season}, episode={episode}, instance={instance}") db = dependencies["db"] # Validate parameters with enhanced checking @@ -969,7 +1004,7 @@ async def get_episode_date_options(dependencies: dict, imdb_id: str, season: int raise HTTPException(status_code=422, detail=f"Invalid parameter types: {e}") # Get current episode data - episode_data = db.get_episode_date(imdb_id, season, episode) + episode_data = db.get_episode_date(imdb_id, season, episode, instance) _log("DEBUG", f"Episode data from DB: {episode_data}") if not episode_data: print(f"❌ Episode not found in database: {imdb_id} S{season:02d}E{episode:02d}") @@ -1280,193 +1315,16 @@ async def get_episode_date_options(dependencies: dict, imdb_id: str, season: int } -def _populate_worker_process(media_type: str, status_file: str): - """ - Worker process that runs database population in complete isolation. - Runs in separate process - keeps web interface responsive. - """ - import os - import sys - import json - from datetime import datetime - - # Add parent directory to path for imports - sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - - def update_status(status_data): - """Write status to file for main process to read""" - try: - with open(status_file, 'w') as f: - json.dump(status_data, f) - except Exception as e: - print(f"ERROR: Failed to update status file: {e}") - - # Initialize status - status = { - "running": True, - "media_type": media_type, - "start_time": datetime.now().isoformat(), - "movies": {"status": "pending", "stats": None}, - "tv": {"status": "pending", "stats": None}, - "completed": False, - "error": None - } - update_status(status) - - try: - # 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) - - print(f"INFO: [Worker Process] Starting database population: {media_type}") - - # Run population based on media type - if media_type in ["movies", "both"]: - status["movies"]["status"] = "running" - update_status(status) - - movie_stats = populator.populate_movies() - - status["movies"]["status"] = "completed" - status["movies"]["stats"] = movie_stats - update_status(status) - print(f"INFO: [Worker Process] Movie population completed: {movie_stats}") - - if media_type in ["tv", "both"]: - status["tv"]["status"] = "running" - update_status(status) - - tv_stats = populator.populate_tv_episodes() - - status["tv"]["status"] = "completed" - status["tv"]["stats"] = tv_stats - update_status(status) - print(f"INFO: [Worker Process] TV population completed: {tv_stats}") - - # Mark as completed - status["completed"] = True - status["running"] = False - update_status(status) - print("INFO: [Worker Process] Database population completed successfully") - - except Exception as e: - print(f"ERROR: [Worker Process] Database population failed: {e}") - import traceback - traceback.print_exc() - - status["error"] = str(e) - status["running"] = False - status["completed"] = True - update_status(status) - - -async def populate_database(background_tasks: BackgroundTasks, media_type: str = "both", dependencies: dict = None): - """ - Populate Chronarr database from Radarr/Sonarr sources in separate process. - This keeps the web interface responsive during population. - - Args: - 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) - - Returns: - Status message indicating population has started - """ - global _populate_process - - if media_type not in ["both", "movies", "tv"]: - raise HTTPException(status_code=400, detail="media_type must be 'both', 'movies', or 'tv'") - - # Check if process is already running - if _populate_process and _populate_process.is_alive(): - return { - "status": "already_running", - "message": "Database population is already in progress" - } - - # Initialize status file - initial_status = { - "running": True, - "media_type": media_type, - "start_time": datetime.now().isoformat(), - "movies": {"status": "pending", "stats": None}, - "tv": {"status": "pending", "stats": None}, - "completed": False, - "error": None - } - - try: - with open(POPULATE_STATUS_FILE, 'w') as f: - json.dump(initial_status, f) - except Exception as e: - print(f"ERROR: Failed to initialize status file: {e}") - raise HTTPException(status_code=500, detail=f"Failed to initialize status tracking: {e}") - - # Start population in separate process - _populate_process = multiprocessing.Process( - target=_populate_worker_process, - args=(media_type, POPULATE_STATUS_FILE), - daemon=False # Keep process alive even if parent exits - ) - _populate_process.start() - - _log("INFO", f"Database population process started for: {media_type}") - return { - "status": "started", - "media_type": media_type, - "message": f"Database population started for {media_type}" - } - - -async def get_populate_status(): - """Get the current status of database population from status file""" - global _populate_process - - # Check if status file exists - if not os.path.exists(POPULATE_STATUS_FILE): - return {"running": False, "completed": False} - - # Read status from file - try: - with open(POPULATE_STATUS_FILE, 'r') as f: - status = json.load(f) - - # Check if process is still alive - if _populate_process: - status["process_alive"] = _populate_process.is_alive() - if not _populate_process.is_alive() and status.get("running"): - # Process died unexpectedly - status["running"] = False - status["completed"] = True - if not status.get("error"): - status["error"] = "Process terminated unexpectedly" - - return status - - except Exception as e: - print(f"ERROR: Failed to read status file: {e}") - return { - "running": False, - "completed": True, - "error": f"Failed to read status: {e}" - } +# Database population used to run its worker process directly in this +# container (multiprocessing.Process + a status file), the same way +# /manual/scan and /manual/cleanup-orphaned already proxy to chronarr-core +# below. Moved to chronarr-core (2026-09-15): this container has no LOG_DIR +# volume mount, so every _log() call DatabasePopulator made was writing to a +# path invisible on the host and lost on restart — docker logs showed it +# live (print() doesn't care about mounts) but the persisted chronarr.log +# never got it. See api/routes.py for the real implementation; the +# /admin/populate-database and /api/populate/status handlers below now just +# proxy to it. def register_web_routes(app, dependencies): @@ -1486,8 +1344,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"): @@ -1500,28 +1358,30 @@ async def api_update_movie(imdb_id: str, request: Request): data = await request.json() dateadded = data.get('dateadded') source = data.get('source', 'manual') + instance = data.get('instance', 'radarr') _log("DEBUG", f"API PUT /api/movies/{imdb_id} - Received JSON:") print(f" - Raw data: {data}") print(f" - dateadded: {dateadded}") print(f" - source: {source}") + print(f" - instance: {instance}") - return await update_movie_date(dependencies, imdb_id, dateadded, source) + return await update_movie_date(dependencies, imdb_id, dateadded, source, instance) except Exception as e: print(f"❌ Error parsing request body: {e}") raise HTTPException(status_code=400, detail=f"Invalid request body: {str(e)}") @app.get("/api/movies/{imdb_id}/date-options") - async def api_movie_date_options(imdb_id: str): - return await get_movie_date_options(dependencies, imdb_id) + async def api_movie_date_options(imdb_id: str, instance: str = 'radarr'): + return await get_movie_date_options(dependencies, imdb_id, instance) @app.get("/api/debug/movie/{imdb_id}/raw") - async def api_debug_movie_raw(imdb_id: str): + async def api_debug_movie_raw(imdb_id: str, instance: str = 'radarr'): """Get raw database data for a movie for debugging""" db = dependencies["db"] try: - movie = db.get_movie_dates(imdb_id) + movie = db.get_movie_dates(imdb_id, instance) if not movie: raise HTTPException(status_code=404, detail="Movie not found") @@ -1538,13 +1398,13 @@ async def api_debug_movie_raw(imdb_id: str): raise HTTPException(status_code=500, detail=f"Failed to get debug data: {str(e)}") @app.delete("/api/movies/{imdb_id}") - async def api_delete_movie(imdb_id: str): + async def api_delete_movie(imdb_id: str, instance: str = 'radarr'): """Delete a movie from the database""" db = dependencies["db"] - + try: # Use the existing database method - deleted = db.delete_movie(imdb_id) + deleted = db.delete_movie(imdb_id, instance) if deleted: return { @@ -1599,13 +1459,14 @@ 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): - 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(): @@ -1650,6 +1511,32 @@ async def api_migrate_series_imdb(old_imdb_id: str, request: Request): print(f"❌ Error migrating series IMDb ID: {e}") raise HTTPException(status_code=500, detail=f"Failed to migrate IMDb ID: {str(e)}") + @app.delete("/api/series/{imdb_id}") + async def api_delete_series(imdb_id: str, instance: str = 'sonarr'): + """Delete a series and all its episodes from the database""" + db = dependencies["db"] + + try: + episodes_deleted = db.delete_series_episodes(imdb_id, instance) + series_deleted = db.delete_series(imdb_id, instance) + + if series_deleted or episodes_deleted > 0: + return { + "success": True, + "status": "success", + "message": f"Deleted series {imdb_id} and {episodes_deleted} episode(s)", + "imdb_id": imdb_id, + "episodes_deleted": episodes_deleted + } + else: + raise HTTPException(status_code=404, detail="Series not found") + + except HTTPException: + raise + except Exception as e: + print(f"❌ Error deleting series: {e}") + raise HTTPException(status_code=500, detail=f"Failed to delete series: {str(e)}") + # Episode endpoints @app.post("/api/episodes/{imdb_id}/{season}/{episode}/update-date") async def api_update_episode_date(imdb_id: str, season: int, episode: int, @@ -1663,23 +1550,24 @@ async def api_update_episode(imdb_id: str, season: int, episode: int, request: R data = await request.json() dateadded = data.get('dateadded') source = data.get('source', 'manual') - return await update_episode_date(dependencies, imdb_id, season, episode, dateadded, source) + instance = data.get('instance', 'sonarr') + return await update_episode_date(dependencies, imdb_id, season, episode, dateadded, source, instance) except Exception as e: print(f"❌ Error parsing episode update request: {e}") raise HTTPException(status_code=400, detail=f"Invalid request format: {str(e)}") @app.get("/api/episodes/{imdb_id}/{season}/{episode}/date-options") - async def api_episode_date_options(imdb_id: str, season: int, episode: int): - return await get_episode_date_options(dependencies, imdb_id, season, episode) + async def api_episode_date_options(imdb_id: str, season: int, episode: int, instance: str = 'sonarr'): + 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 { @@ -1826,8 +1714,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 "" @@ -1863,8 +1751,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: @@ -1983,8 +1871,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 @@ -2412,27 +2300,320 @@ async def api_get_schedule_executions(schedule_id: int = None): """Get schedule execution history""" return await get_schedule_executions(dependencies, schedule_id) - # Database population endpoints - _log("DEBUG", "Registering /admin/populate-database endpoint...") - + # Database population endpoints (proxy to core container — see the note + # above register_web_routes() for why this doesn't run locally anymore) @app.post("/admin/populate-database") - async def api_populate_database(request: Request, background_tasks: BackgroundTasks): - """Populate database from Radarr/Sonarr""" - _log("DEBUG", "populate-database endpoint called!") + async def api_populate_database(request: Request): + """Proxy populate-database requests to core container""" + import urllib.request + import urllib.parse + import urllib.error + import socket + 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") + + core_host = os.environ.get("CORE_INTERNAL_HOST", "chronarr") + core_port = os.environ.get("CORE_INTERNAL_PORT", "8080") + query_string = urllib.parse.urlencode({"media_type": media_type, "instance": instance}) + core_url = f"http://{core_host}:{core_port}/admin/populate-database?{query_string}" - _log("DEBUG", "/admin/populate-database registered") + try: + req = urllib.request.Request(core_url, method='POST') + with urllib.request.urlopen(req, timeout=30) as response: + return json.loads(response.read().decode('utf-8')) + except urllib.error.HTTPError as e: + raise HTTPException(status_code=e.code, detail=f"Core container HTTP error: {e.reason}") + except urllib.error.URLError as e: + raise HTTPException(status_code=503, detail=f"Could not connect to core container: {str(e)}") + except socket.timeout: + raise HTTPException(status_code=504, detail="Core container request timed out") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Populate database request failed: {str(e)}") @app.get("/api/populate/status") async def api_populate_status(): - """Get database population status""" - return await get_populate_status() + """Proxy populate status requests to core container""" + import urllib.request + import urllib.error + 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/populate/status" + + try: + req = urllib.request.Request(core_url, method='GET') + with urllib.request.urlopen(req, timeout=30) as response: + return json.loads(response.read().decode('utf-8')) + except urllib.error.HTTPError as e: + raise HTTPException(status_code=e.code, detail=f"Core container HTTP error: {e.reason}") + except urllib.error.URLError as e: + raise HTTPException(status_code=503, detail=f"Could not connect to core container: {str(e)}") + except socket.timeout: + raise HTTPException(status_code=504, detail="Core container request timed out") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Populate status request failed: {str(e)}") + + # /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_INTERNAL_HOST", "chronarr") + core_port = os.environ.get("CORE_INTERNAL_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("/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("/api/logs") + async def logs_list(): + """Proxy the log file listing from the core container (only core has LOG_DIR mounted).""" + 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") + core_url = f"http://{core_host}:{core_port}/api/logs" + + try: + req = urllib.request.Request(core_url) + with urllib.request.urlopen(req, timeout=10) 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("/api/logs/{filename}/tail") + async def logs_tail(filename: str, request: Request): + """Proxy the log tail (last N lines, as JSON) 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/logs/{filename}/tail" + (f"?{query}" if query else "") + + try: + req = urllib.request.Request(core_url) + with urllib.request.urlopen(req, timeout=15) 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, AttributeError): + 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.get("/api/logs/{filename}/download") + async def logs_download(filename: str): + """Proxy a log file download from the core container. + + Passed through as raw bytes with the same Content-Disposition/media + type core set, rather than re-parsed — this is a file download. + """ + import urllib.request + import urllib.error + import 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/logs/{filename}/download" + + try: + req = urllib.request.Request(core_url) + with urllib.request.urlopen(req, timeout=30) as resp: + body = resp.read() + disposition = resp.headers.get("Content-Disposition", f'attachment; filename="{filename}"') + return Response(content=body, media_type="text/plain", 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.get("/api/unresolved-lookups") + async def unresolved_lookups_list(request: Request): + """Proxy the unresolved plugin-lookups list 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/unresolved-lookups" + (f"?{query}" if query else "") + + try: + req = urllib.request.Request(core_url) + with urllib.request.urlopen(req, timeout=10) 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.post("/api/unresolved-lookups/{lookup_id}/dismiss") + 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.""" + 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/chronarr-web/static/css/styles.css b/chronarr-web/static/css/styles.css index ab59410..5dbd73d 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,381 @@ 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; } -.nav-section { - margin-bottom: 0.125rem; +.auth-logout:hover { + background: rgba(255, 255, 255, 0.14); + color: #c9d1d9; +} + +/* 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-icon.skipped { background: linear-gradient(135deg, #f7971e, #ffd200); } .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; + min-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 { +.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; - margin-bottom: 1.25rem; - flex-wrap: wrap; - gap: 0.75rem; + padding: 0.4rem 0.75rem; + border-radius: 0.25rem; + font-size: 0.875rem; } -.content-header h2 { - color: var(--text); - font-weight: 700; - font-size: 1.25rem; +.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; + justify-content: space-between; align-items: center; - gap: 0.5rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; + gap: 1rem; } -.content-header h2 i { color: var(--accent); } +.content-header h2 { + color: var(--dark-color); + font-weight: 600; +} .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 +440,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; +} + +.btn-sm { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; } -/* ── Tables ──────────────────────────────────────────────── */ +/* 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 +539,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; +} + +.pagination .page-info { + margin: 0 1rem; + color: var(--text-muted); +} -/* ── Forms ───────────────────────────────────────────────── */ -.form-group { margin-bottom: 1rem; } +/* 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 +646,441 @@ 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); +} -.report-section { margin-bottom: 2rem; } +.summary-card p { + margin-bottom: 0.5rem; + font-size: 1.1rem; +} -.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; +.summary-card span { + font-weight: 700; + color: var(--primary-color); } -/* ── Tools ───────────────────────────────────────────────── */ -.tools-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 1.25rem; +.report-section { + margin-bottom: 2rem; } -.tool-card { - background: var(--surface); - border-radius: 0.625rem; - padding: 1.25rem; - box-shadow: var(--shadow); - border: 1px solid var(--border); +.report-section h3 { + margin-bottom: 1rem; + color: var(--dark-color); } -.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; +/* Tools — each tool is its own full page now (sidebar dropdown), not a + shared grid of cards, so a five-line form and a full log viewer don't + have to fight each other for space. */ +.tool-page { + background: white; + border-radius: 0.5rem; + padding: 1.5rem; + box-shadow: var(--shadow); + max-width: 640px; } -.tool-card h3 i { color: var(--accent); } +.tool-page.tool-page-wide { + max-width: none; +} -.tool-card > p { - margin-bottom: 1.25rem; +.tool-page-desc { + 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; -} - -.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; } + min-width: 300px; + border-left: 4px solid var(--primary-color); + animation: slideIn 0.3s ease; } -/* ── Scan / progress ─────────────────────────────────────── */ -.scan-status { - margin-top: 1rem; - padding: 1rem; - background: var(--surface-2); - border: 1px solid var(--border); - border-radius: 0.5rem; +.toast.success { + border-left-color: var(--success-color); } -.progress-bar { - width: 100%; - height: 6px; - background: var(--progress-bg); - border-radius: 999px; - overflow: hidden; - margin-bottom: 0.5rem; +.toast.warning { + border-left-color: var(--warning-color); } -.progress-fill { - height: 100%; - background: linear-gradient(90deg, var(--accent), var(--success)); - border-radius: 999px; - transition: width 0.3s ease; - width: 0%; +.toast.error { + border-left-color: var(--danger-color); } -.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-sub-items.expanded { + max-height: 600px; + } + + .main-content { + min-height: unset; + padding: 1rem; + } + + .content-header { + flex-direction: column; + align-items: stretch; } - .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; + .content-controls { + justify-content: center; } - .mobile-menu-btn { - background: none; - border: none; - color: var(--text); - font-size: 1.125rem; - cursor: pointer; - padding: 0.25rem; + .search-box input { + width: 200px; } - .mobile-title { - font-weight: 700; - color: var(--text); - font-size: 1rem; + .data-table { + font-size: 0.8rem; } - .sidebar-overlay { - display: none; - position: fixed; - inset: 0; - background: var(--modal-overlay); - z-index: 199; + .data-table th, + .data-table td { + padding: 0.5rem 0.25rem; } - .sidebar-overlay.active { display: block; } + .dashboard-grid { + grid-template-columns: 1fr; + } + + .tool-page { + max-width: none; + } +} + +/* 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; } + +/* 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; +} + +.scan-progress { + margin-bottom: 1rem; +} + +.progress-bar { + width: 100%; + height: 1.5rem; + background-color: #e9ecef; + border-radius: 0.375rem; + overflow: hidden; + margin-bottom: 0.5rem; +} - .main-content { padding: 1rem; } +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--primary-color), var(--success-color)); + transition: width 0.3s ease; + width: 0%; +} - .content-header { flex-direction: column; align-items: stretch; } - .content-controls { justify-content: flex-start; } - .search-box input { width: 100%; } +.scan-info { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.875rem; +} - .dashboard-grid { grid-template-columns: 1fr 1fr; } - .tools-grid { grid-template-columns: 1fr; } - .form-row { grid-template-columns: 1fr; } +.scan-info span:first-child { + color: var(--text-muted); +} - .data-table { font-size: 0.8125rem; } - .data-table th, .data-table td { padding: 0.5rem 0.625rem; } +.scan-info span:last-child { + font-weight: 600; + color: var(--primary-color); } -@media (max-width: 480px) { - .dashboard-grid { grid-template-columns: 1fr; } +.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..76ae1b3 100644 --- a/chronarr-web/static/index.html +++ b/chronarr-web/static/index.html @@ -4,93 +4,107 @@