From a8350627ddad9ce96781c9e4003354f8324e6bcd Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Fri, 10 Apr 2026 09:37:20 -0400 Subject: [PATCH 01/18] new --- CHANGELOG.md | 4 ++++ README.md | 2 +- internal/media/processor.go | 12 ++++++++++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b92a607..e152b34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,6 +152,10 @@ - Updated keyword display to show normalization in verbose mode - Enhanced keyword synchronization with smart duplicate cleaning - Force update mode bypasses all previous processing checks +- Added debug logging to show exact keywords being sent to Plex API +- Enhanced duplicate cleaning with detailed count reporting +- Optimized removal process speed: reduced delays from 500ms to 100ms per item +- Added progress indicators for removal operations on large libraries ### Documentation diff --git a/README.md b/README.md index c32dad7..6c930bd 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ services: - `PLEX_REQUIRES_HTTPS=true` - Use HTTPS (default: `true`) - `UPDATE_FIELD=label` - Field to update: `label` or `genre` (default: `label`) - `PROCESS_TIMER=1h` - How often to run 24h, 5m, 2h30m etc. (default: `1h`) -- `REMOVE=lock` - Clean mode: `lock` or `unlock` (runs once and exits) +- `REMOVE=lock/unlock` - Clean mode: `lock` or `unlock` (runs once and exits) - `VERBOSE_LOGGING=true` - Enable detailed lookup information (default: `false`) - `DATA_DIR=/data` - Directory for persistent storage (default: `/data`) - `FORCE_UPDATE=true` - Force reprocess all items regardless of previous processing (default: `false`) diff --git a/internal/media/processor.go b/internal/media/processor.go index 5ebf16c..a25f398 100644 --- a/internal/media/processor.go +++ b/internal/media/processor.go @@ -316,7 +316,14 @@ func (p *Processor) RemoveKeywordsFromItems(libraryID string, mediaType MediaTyp skippedCount := 0 totalKeywordsRemoved := 0 - for _, item := range items { + fmt.Printf("πŸš€ Optimized removal mode: reduced delays for faster processing\n") + + for i, item := range items { + // Show progress for large libraries + if len(items) > 100 && (i+1)%50 == 0 { + fmt.Printf("πŸ“Š Removal Progress: %d/%d (%.1f%%)\n", i+1, len(items), float64(i+1)/float64(len(items))*100) + } + tmdbID := p.extractTMDbID(item, mediaType) if tmdbID == "" { skippedCount++ @@ -377,7 +384,8 @@ func (p *Processor) RemoveKeywordsFromItems(libraryID string, mediaType MediaTyp removedCount++ fmt.Printf("βœ… Successfully removed keywords from %s\n", item.GetTitle()) - time.Sleep(500 * time.Millisecond) + // Reduced delay for faster removal (was 500ms) + time.Sleep(100 * time.Millisecond) } fmt.Printf("\nπŸ“Š Removal Summary:\n") From e433a7b8d64ec51e672fc5a096a939e1634168bc Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Fri, 10 Apr 2026 09:37:20 -0400 Subject: [PATCH 02/18] new --- CHANGELOG.md | 4 ++++ README.md | 2 +- internal/media/processor.go | 12 ++++++++++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b92a607..e152b34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,6 +152,10 @@ - Updated keyword display to show normalization in verbose mode - Enhanced keyword synchronization with smart duplicate cleaning - Force update mode bypasses all previous processing checks +- Added debug logging to show exact keywords being sent to Plex API +- Enhanced duplicate cleaning with detailed count reporting +- Optimized removal process speed: reduced delays from 500ms to 100ms per item +- Added progress indicators for removal operations on large libraries ### Documentation diff --git a/README.md b/README.md index 98aa29d..4fab9f9 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ services: - `PLEX_REQUIRES_HTTPS=true` - Use HTTPS (default: `true`) - `UPDATE_FIELD=label` - Field to update: `label` or `genre` (default: `label`) - `PROCESS_TIMER=1h` - How often to run 24h, 5m, 2h30m etc. (default: `1h`) -- `REMOVE=lock` - Clean mode: `lock` or `unlock` (runs once and exits) +- `REMOVE=lock/unlock` - Clean mode: `lock` or `unlock` (runs once and exits) - `VERBOSE_LOGGING=true` - Enable detailed lookup information (default: `false`) - `DATA_DIR=/data` - Directory for persistent storage (default: `/data`) - `FORCE_UPDATE=true` - Force reprocess all items regardless of previous processing (default: `false`) diff --git a/internal/media/processor.go b/internal/media/processor.go index 5d35aa6..4d9027a 100644 --- a/internal/media/processor.go +++ b/internal/media/processor.go @@ -474,7 +474,14 @@ func (p *Processor) RemoveKeywordsFromItems(libraryID string, mediaType MediaTyp skippedCount := 0 totalKeywordsRemoved := 0 - for _, item := range items { + fmt.Printf("πŸš€ Optimized removal mode: reduced delays for faster processing\n") + + for i, item := range items { + // Show progress for large libraries + if len(items) > 100 && (i+1)%50 == 0 { + fmt.Printf("πŸ“Š Removal Progress: %d/%d (%.1f%%)\n", i+1, len(items), float64(i+1)/float64(len(items))*100) + } + tmdbID := p.extractTMDbID(item, mediaType) if tmdbID == "" { skippedCount++ @@ -535,7 +542,8 @@ func (p *Processor) RemoveKeywordsFromItems(libraryID string, mediaType MediaTyp removedCount++ fmt.Printf("βœ… Successfully removed keywords from %s\n", item.GetTitle()) - time.Sleep(500 * time.Millisecond) + // Reduced delay for faster removal (was 500ms) + time.Sleep(100 * time.Millisecond) } fmt.Printf("\nπŸ“Š Removal Summary:\n") From b949ca30b259431efe3dd521115375b566de7c5d Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Fri, 10 Apr 2026 11:18:35 -0400 Subject: [PATCH 03/18] feat: add batch processing, keyword prefix, and Plex webhook support - Batch processing (BATCH_SIZE, BATCH_DELAY, ITEM_DELAY) to prevent API flooding on large libraries - Keyword prefix (KEYWORD_PREFIX) to separate TMDb keywords from real genres in Plex dropdown - Plex webhook server (WEBHOOK_ENABLED, WEBHOOK_PORT, WEBHOOK_DEBOUNCE) for real-time processing on media add events - Remove all emoji from Go source, replace with bracketed tags - Rewrite README with table of contents and accurate feature docs - Clean up CHANGELOG to remove AI writing patterns - Refactor: Clients struct, keyword cache, batch helpers, forEachLibrary, graceful shutdown, concurrency safety --- CHANGELOG.md | 222 ++---- Dockerfile | 4 +- README.md | 1276 ++++++----------------------------- cmd/labelarr/main.go | 323 ++++----- docker-compose.yml | 18 +- internal/config/config.go | 62 +- internal/export/export.go | 8 +- internal/media/processor.go | 708 ++++++++++--------- internal/tmdb/client.go | 4 +- internal/webhook/server.go | 195 ++++++ 10 files changed, 1100 insertions(+), 1720 deletions(-) create mode 100644 internal/webhook/server.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e152b34..e9eae76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,184 +1,78 @@ # Changelog -## [Unreleased] - 2025-07-05 +## [Unreleased] ### Added -#### Radarr/Sonarr Integration -- βœ… Created Radarr API client module (`internal/radarr/`) with full API support - - Movie search by title, year, TMDb ID, IMDb ID, and file path - - Automatic TMDb ID extraction from Radarr database - - Connection testing and system status endpoints - -- βœ… Created Sonarr API client module (`internal/sonarr/`) with full API support - - TV series search by title, year, TMDb ID, TVDb ID, IMDb ID, and file path - - Episode fetching and file path matching - - Connection testing and system status endpoints - -- βœ… Updated configuration system to support Radarr/Sonarr - - Added `USE_RADARR` and `USE_SONARR` environment variables - - Added `RADARR_URL`, `RADARR_API_KEY`, `SONARR_URL`, `SONARR_API_KEY` configuration - - Added validation for Radarr/Sonarr settings when enabled - -- βœ… Enhanced TMDb ID extraction to use multiple sources - - Primary: Plex metadata (existing functionality preserved) - - Secondary: Radarr/Sonarr API matching (new) - - Fallback: File path regex matching (existing functionality preserved) - - Added source tracking to show where TMDb ID was found - -- βœ… Updated media processor to integrate with Radarr/Sonarr - - Modified `extractMovieTMDbID` to query Radarr when enabled - - Modified `extractTVShowTMDbID` to query Sonarr when enabled - - Added multiple matching strategies: title/year, IMDb ID, TVDb ID, file path - -- βœ… Updated main application to initialize Radarr/Sonarr clients - - Added connection testing on startup - - Graceful handling when Radarr/Sonarr are not configured - -- βœ… Created comprehensive docker-compose.yml example - - Includes all existing configuration options - - Added Radarr/Sonarr configuration examples with defaults - -#### Verbose Logging & Debugging - -- βœ… Added verbose logging feature - - New `VERBOSE_LOGGING` environment variable (default: false) - - Shows detailed TMDb ID lookup process for each item - - Displays all available Plex GUIDs (IMDb, TMDb, TVDb) - - Shows Radarr lookup attempts with title, file path, and IMDb ID matching - - Shows Sonarr lookup attempts with title, TVDb ID, IMDb ID, and file path matching - - Indicates source of successful TMDb ID matches - - Helps troubleshoot matching issues - -- βœ… Added progress tracking for large libraries - - Shows percentage progress for libraries with >100 items - - Displays current processing status - - Shows summary of skipped items in verbose mode - -- βœ… Enhanced label/genre application logging - - Shows when keywords are being applied to Plex - - Displays Plex API call timing in verbose mode - - Shows current and new keywords being merged - - Confirms successful application to Plex - -#### Persistent Storage +#### Plex Webhook Support +- Webhook listener for real-time processing (WEBHOOK_ENABLED, WEBHOOK_PORT) +- Handles library.new and library.on.deck events +- Configurable debounce window (WEBHOOK_DEBOUNCE, default 30s) +- Prevents concurrent processing of the same library +- Health check endpoint at /health +- Runs alongside the existing timer -- βœ… Added persistent storage for processed items - - Prevents reprocessing items after container restarts - - JSON file-based storage with atomic writes - - Tracks which field (label/genre) was updated for each item - - Configurable data directory via DATA_DIR environment variable - - Docker volume support for data persistence - - Storage directory defaults to `/data` in container +#### Keyword Prefix +- KEYWORD_PREFIX env var to prepend text to keywords (e.g. "- ") +- Useful when UPDATE_FIELD=genre to separate TMDb keywords from real genres -#### Error Handling & Connection Testing - -- βœ… Added TMDb API connection testing on startup - - Validates API token before processing begins - - Provides clear error messages for authentication failures - - Shows detailed error responses for debugging - -- βœ… Improved error handling throughout - - Better error messages for TMDb API failures - - Clear indication of authentication vs other errors - - Verbose mode shows why items are skipped +#### Batch Processing +- BATCH_SIZE (default 100) and BATCH_DELAY (default 10s) env vars +- Prevents API flooding on large libraries (4000+ items) +- ITEM_DELAY (default 500ms) controls per-item pacing ### Changed +- Removed all emoji from log output; replaced with bracketed tags +- Extracted Clients struct for processor initialization +- Added keyword cache by TMDb ID to avoid redundant API calls +- Eliminated redundant Plex API call after keyword sync for export -- Modified `NewProcessor` to accept optional Radarr/Sonarr clients and return error -- Enhanced TMDb ID detection to show source (Plex metadata, Radarr, Sonarr, or file path) -- Processor initialization now includes persistent storage setup -- Main application now tests all API connections on startup +## 2025-07-05 -### Documentation +### Added -- βœ… Updated README.md with comprehensive documentation - - Added Radarr/Sonarr Integration section with benefits and configuration - - Added Verbose Logging section with examples - - Updated environment variables documentation - - Added persistent storage information - - Updated docker-compose examples +#### Radarr/Sonarr Integration +- Radarr API client (internal/radarr/) -- movie lookup by title, year, TMDb ID, IMDb ID, file path +- Sonarr API client (internal/sonarr/) -- series lookup by title, year, TMDb ID, TVDb ID, IMDb ID, file path +- USE_RADARR, USE_SONARR, RADARR_URL, RADARR_API_KEY, SONARR_URL, SONARR_API_KEY env vars +- TMDb ID extraction chain: Plex metadata -> Radarr/Sonarr -> file path regex +- Connection testing on startup for all enabled services -- βœ… Created detailed CHANGELOG.md - - Comprehensive list of all changes - - Organized by feature area - - Technical implementation details +#### Verbose Logging +- VERBOSE_LOGGING env var (default false) +- Shows TMDb ID lookup source, Plex GUIDs, matching attempts +- Progress percentage for libraries over 100 items -#### Keyword Normalization +#### Persistent Storage +- JSON file storage for processed items (DATA_DIR env var) +- Tracks rating key, TMDb ID, update field, last processed time +- Skips already-processed items unless FORCE_UPDATE=true +- Runs in ephemeral mode when DATA_DIR is not set -- βœ… Added intelligent keyword normalization feature - - Automatically normalizes TMDb keywords for consistent formatting - - Pattern-based recognition for dynamic handling without hardcoding - - Smart title casing with proper article and preposition handling - - Automatic duplicate removal after normalization - -- βœ… Pattern Recognition Features - - **Critical Replacements**: Known abbreviations (sci-fi β†’ Sci-Fi, romcom β†’ Romantic Comedy) - - **Acronym Detection**: Automatically uppercases known acronyms (FBI, CIA, DEA, etc.) - - **Agency Patterns**: Detects agency roles (dea agent β†’ DEA Agent) - - **Parenthetical Acronyms**: Handles acronyms in parentheses (central intelligence agency (cia) β†’ Central Intelligence Agency (CIA)) - - **Century Patterns**: Properly formats centuries (5th century bc β†’ 5th Century BC) - - **City/State Patterns**: Handles location formatting (san francisco, california β†’ San Francisco, California) - - **Relationship Patterns**: Adds "Relationship" where appropriate (father daughter β†’ Father Daughter Relationship) - - **Credit Stinger Terms**: Expands compound terms (duringcreditsstinger β†’ During Credits Stinger) - -- βœ… Added comprehensive test suite - - 90+ test cases covering various normalization scenarios - - Tests for edge cases, mixed case preservation, and pattern matching - - Ensures consistent behavior across different keyword types - -- βœ… Smart duplicate cleaning functionality - - Automatically removes old unnormalized keywords when adding normalized versions - - Preserves manually set keywords in Plex - - Prevents accumulation of duplicate keywords (e.g., both "sci-fi" and "Sci-Fi") - - Shows cleaning activity in verbose logging mode +#### Keyword Normalization +- Pattern-based normalization: sci-fi -> Sci-Fi, romcom -> Romantic Comedy +- Acronym detection (FBI, CIA, DEA, etc.) +- Century formatting (5th century bc -> 5th Century BC) +- City/state, relationship, and credit stinger patterns +- 90+ test cases +- Duplicate cleaning: removes old unnormalized keywords when normalized versions are added #### Force Update Mode +- FORCE_UPDATE env var (default false) +- Reprocesses all items regardless of storage state -- βœ… Added force update functionality - - New `FORCE_UPDATE` environment variable (default: false) - - Reprocesses all items regardless of previous processing status - - Useful for applying keyword normalization to existing libraries - - Shows clear indication when force update mode is active - - Bypasses both storage checks and "already has keywords" logic +#### Export Functionality +- EXPORT_LABELS, EXPORT_LOCATION, EXPORT_MODE (txt/json) env vars +- Generates file lists per label per library +- JSON mode outputs a single structured export.json +- TXT mode creates per-library subdirectories with summary.txt ### Changed - -- Modified `NewProcessor` to accept optional Radarr/Sonarr clients and return error -- Enhanced TMDb ID detection to show source (Plex metadata, Radarr, Sonarr, or file path) -- Processor initialization now includes persistent storage setup -- Main application now tests all API connections on startup -- TMDb client now normalizes all keywords before returning them -- Updated keyword display to show normalization in verbose mode -- Enhanced keyword synchronization with smart duplicate cleaning -- Force update mode bypasses all previous processing checks -- Added debug logging to show exact keywords being sent to Plex API -- Enhanced duplicate cleaning with detailed count reporting -- Optimized removal process speed: reduced delays from 500ms to 100ms per item -- Added progress indicators for removal operations on large libraries - -### Documentation - -- βœ… Updated README.md with comprehensive documentation - - Added Radarr/Sonarr Integration section with benefits and configuration - - Added Verbose Logging section with examples - - Added Keyword Normalization section with pattern examples - - Added Force Update Mode section with use cases and examples - - Added Smart Duplicate Cleaning documentation - - Updated environment variables documentation - - Added persistent storage information - - Updated docker-compose examples - -- βœ… Created detailed CHANGELOG.md - - Comprehensive list of all changes - - Organized by feature area - - Technical implementation details - -### Technical Details -- Radarr/Sonarr clients use API v3 endpoints -- Implemented robust error handling and fallback mechanisms -- No breaking changes - Radarr/Sonarr integration is fully optional -- Maintains backward compatibility with existing file path matching -- Verbose logging provides detailed insights without affecting normal operation -- Keyword normalization uses regex patterns for scalability -- All features are designed to be non-breaking and backward compatible \ No newline at end of file +- NewProcessor accepts optional Radarr/Sonarr clients and returns error +- TMDb client normalizes keywords before returning them +- Removal delay reduced from 500ms to configurable ITEM_DELAY + +### Technical Notes +- Radarr/Sonarr use API v3 +- All new features are optional and backward compatible +- No breaking changes to existing configuration diff --git a/Dockerfile b/Dockerfile index a764f9b..9ebbe4c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,5 +27,7 @@ COPY --from=builder /app/labelarr . RUN adduser -D -s /bin/bash labelarr USER labelarr -# Run the application +# Webhook server port (only used when WEBHOOK_ENABLED=true) +EXPOSE 9090 + CMD ["./labelarr"] diff --git a/README.md b/README.md index 4fab9f9..bb6765e 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,35 @@ -# Labelarr πŸŽ¬πŸ“ΊπŸ·οΈ +# Labelarr [![GitHub Release](https://img.shields.io/github/v/release/nullable-eth/labelarr?style=flat-square)](https://github.com/nullable-eth/labelarr/releases/latest) [![Docker Image](https://img.shields.io/badge/docker-ghcr.io-blue?style=flat-square&logo=docker)](https://github.com/nullable-eth/labelarr/pkgs/container/labelarr) [![Go Version](https://img.shields.io/github/go-mod/go-version/nullable-eth/labelarr?style=flat-square)](https://golang.org/) -[![GitHub Actions](https://img.shields.io/github/actions/workflow/status/nullable-eth/labelarr/release.yml?branch=main&style=flat-square)](https://github.com/nullable-eth/labelarr/actions) -**Automatically sync TMDb keywords as Plex labels or genres for movies and TV shows** -Lightweight Docker container that bridges Plex with The Movie Database, adding searchable keywords to your media. - -## πŸš€ Quick Start - -### Docker Compose (Recommended) +Syncs TMDb keywords to Plex as labels or genres. Runs as a Docker container on a timer, or reacts to Plex webhooks in real time. + +## Table of Contents + +- [Quick Start](#quick-start) +- [How It Works](#how-it-works) +- [Environment Variables](#environment-variables) +- [Radarr/Sonarr Integration](#radarrsonarr-integration) +- [Webhook Support](#webhook-support) +- [Batch Processing](#batch-processing) +- [Keyword Prefix](#keyword-prefix) +- [Keyword Normalization](#keyword-normalization) +- [Export Functionality](#export-functionality) +- [TMDb ID Detection](#tmdb-id-detection) +- [Removing Keywords](#removing-keywords) +- [Field Locking](#field-locking) +- [Force Update Mode](#force-update-mode) +- [Verbose Logging](#verbose-logging) +- [Persistent Storage](#persistent-storage) +- [Getting API Keys](#getting-api-keys) +- [Troubleshooting](#troubleshooting) +- [Local Development](#local-development) + +## Quick Start ```yaml -version: '3.8' - services: labelarr: image: ghcr.io/nullable-eth/labelarr:latest @@ -22,1197 +37,336 @@ services: restart: unless-stopped volumes: - ./labelarr-data:/data - - ./exports:/data/exports # Mount host directory for export files environment: - # Required - Get from Plex Web (F12 β†’ Network β†’ X-Plex-Token) - PLEX_TOKEN=your_plex_token_here - # Required - Get from https://www.themoviedb.org/settings/api - TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token - # Required - Your Plex server details - PLEX_SERVER=plex - PLEX_PORT=32400 - PLEX_REQUIRES_HTTPS=true - # Process all libraries (recommended for first-time users) - MOVIE_PROCESS_ALL=true - TV_PROCESS_ALL=true - # Optional settings - - PROCESS_TIMER=1h - - UPDATE_FIELD=label # or 'genre' - # Optional Radarr/Sonarr integration - # - USE_RADARR=true - # - RADARR_URL=http://radarr:7878 - # - RADARR_API_KEY=your_radarr_api_key - # - USE_SONARR=true - # - SONARR_URL=http://sonarr:8989 - # - SONARR_API_KEY=your_sonarr_api_key - # Optional export functionality - # - EXPORT_LABELS=action,comedy,thriller,documentary,kids - # - EXPORT_LOCATION=/data/exports ``` -**Run:** `docker-compose up -d` - -### What it does - -βœ… **Detects TMDb IDs** from Plex metadata, Radarr/Sonarr APIs, or file paths (e.g., `{tmdb-12345}`) -βœ… **Fetches keywords** from TMDb API for movies and TV shows -βœ… **Normalizes keywords** with proper capitalization and spelling -βœ… **Adds as Plex labels/genres** - never removes existing values -βœ… **Runs automatically** on configurable timer (default: 1 hour) -βœ… **Multi-architecture** support (AMD64 + ARM64) - -### πŸŽ‰ New Features in This Fork - -- **πŸš€ Radarr/Sonarr Integration** - Automatically detect TMDb IDs from your media managers -- **πŸ’Ύ Persistent Storage** - Tracks processed items across container restarts -- **πŸ” Verbose Logging** - Detailed debugging information for troubleshooting -- **πŸ“ Keyword Normalization** - Intelligent formatting with pattern recognition -- **πŸ”„ Force Update Mode** - Reprocess all items regardless of previous processing status -- **🧹 Smart Duplicate Cleaning** - Automatically removes old unnormalized keywords when adding normalized versions -- **πŸ”’ Enhanced Error Handling** - Better authentication and connection testing -- **πŸ“€ Export Functionality** - Generate file lists for specific labels to sync content or create backups - ---- - -
-

πŸ“Έ Examples in Plex

+Run `docker-compose up -d`. Labelarr processes your libraries immediately on startup, then repeats every hour. ![Labels](example/labels.png) ![Dynamic Filters](example/dynamic_filter.png) ![Filter](example/filter.png) -
- -
-

🐳 Alternative: Docker Run Command

- -```bash -docker run -d --name labelarr \ - -e PLEX_TOKEN=your_plex_token_here \ - -e TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token \ - -e PLEX_SERVER=localhost -e PLEX_PORT=32400 -e PLEX_REQUIRES_HTTPS=true \ - -e MOVIE_PROCESS_ALL=true -e TV_PROCESS_ALL=true \ - ghcr.io/nullable-eth/labelarr:latest -``` - -
- -
-

🐳 Advanced: Running with Plex Container Ensuring Labelarr Waits for Plex

-To avoid Labelarr startup errors when Plex is not yet ready, use Docker Compose's depends_on with condition: service_healthy and add a healthcheck to your Plex service. This ensures Labelarr only starts after Plex is healthy. - -```yaml -version: '3.8' -services: - plex: - image: plexinc/pms-docker:latest - container_name: plex - # ... your plex configuration ... - healthcheck: - test: curl --connect-timeout 15 --silent --show-error --fail http://localhost:32400/identity - interval: 1m00s - timeout: 15s - retries: 3 - start_period: 1m00s - - labelarr: - image: ghcr.io/nullable-eth/labelarr:latest - container_name: labelarr - restart: unless-stopped - depends_on: - plex: - condition: service_healthy - environment: - - PLEX_SERVER=localhost - - PLEX_PORT=32400 - - PLEX_REQUIRES_HTTPS=false - - PLEX_TOKEN=your_plex_token_here - - TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token - - MOVIE_PROCESS_ALL=true - - TV_PROCESS_ALL=true -``` +## How It Works -
+1. Fetches all movies/shows from your Plex libraries +2. Finds the TMDb ID for each item (from Plex metadata, Radarr/Sonarr, or file paths) +3. Pulls keywords from the TMDb API +4. Normalizes keyword formatting (capitalization, acronyms, known patterns) +5. Adds keywords as Plex labels or genres -- never removes existing values +6. Tracks what has been processed to skip it next time -
-

πŸ“‹ Environment Variables

+Runs on a configurable timer (default 1h). With webhooks enabled, also processes immediately when Plex adds new media. -**Required Settings:** +## Environment Variables -- `PLEX_TOKEN` - Get from Plex Web (F12 β†’ Network β†’ X-Plex-Token) -- `TMDB_READ_ACCESS_TOKEN` - Get from [TMDb API Settings](https://www.themoviedb.org/settings/api) -- `PLEX_SERVER` - Your Plex server address (e.g., `localhost`) -- `PLEX_PORT` - Usually `32400` +### Required -**Library Selection** (choose one approach): +| Variable | Description | +|----------|-------------| +| `PLEX_TOKEN` | Plex authentication token | +| `TMDB_READ_ACCESS_TOKEN` | TMDb API read access token | +| `PLEX_SERVER` | Plex server hostname or IP | +| `PLEX_PORT` | Plex server port (usually 32400) | -- `MOVIE_PROCESS_ALL=true` + `TV_PROCESS_ALL=true` - Process all libraries (recommended) -- `MOVIE_LIBRARY_ID=1` + `TV_LIBRARY_ID=2` - Process specific libraries only +### Library Selection -**Optional Settings:** +Pick one approach per media type: -- `PLEX_REQUIRES_HTTPS=true` - Use HTTPS (default: `true`) -- `UPDATE_FIELD=label` - Field to update: `label` or `genre` (default: `label`) -- `PROCESS_TIMER=1h` - How often to run 24h, 5m, 2h30m etc. (default: `1h`) -- `REMOVE=lock/unlock` - Clean mode: `lock` or `unlock` (runs once and exits) -- `VERBOSE_LOGGING=true` - Enable detailed lookup information (default: `false`) -- `DATA_DIR=/data` - Directory for persistent storage (default: `/data`) -- `FORCE_UPDATE=true` - Force reprocess all items regardless of previous processing (default: `false`) +| Variable | Description | +|----------|-------------| +| `MOVIE_PROCESS_ALL=true` | Process all movie libraries | +| `MOVIE_LIBRARY_ID=1` | Process a specific movie library by ID | +| `TV_PROCESS_ALL=true` | Process all TV show libraries | +| `TV_LIBRARY_ID=2` | Process a specific TV library by ID | -**Radarr Integration (Optional):** +### Optional -- `USE_RADARR=true` - Enable Radarr integration (default: `false`) -- `RADARR_URL=http://localhost:7878` - Your Radarr instance URL -- `RADARR_API_KEY=your_api_key` - Your Radarr API key +| Variable | Default | Description | +|----------|---------|-------------| +| `PLEX_REQUIRES_HTTPS` | `false` | Use HTTPS for Plex connection | +| `UPDATE_FIELD` | `label` | Field to update: `label` or `genre` | +| `PROCESS_TIMER` | `1h` | How often to run (e.g. `30m`, `2h`, `24h`) | +| `VERBOSE_LOGGING` | `false` | Show detailed lookup and matching info | +| `DATA_DIR` | _(none)_ | Directory for persistent storage; ephemeral if unset | +| `FORCE_UPDATE` | `false` | Reprocess all items regardless of storage state | +| `REMOVE` | _(none)_ | Removal mode: `lock` or `unlock` (runs once and exits) | -**Sonarr Integration (Optional):** +### Batch Processing -- `USE_SONARR=true` - Enable Sonarr integration (default: `false`) -- `SONARR_URL=http://localhost:8989` - Your Sonarr instance URL -- `SONARR_API_KEY=your_sonarr_api_key` - Your Sonarr API key +| Variable | Default | Description | +|----------|---------|-------------| +| `BATCH_SIZE` | `100` | Items per batch | +| `BATCH_DELAY` | `10s` | Pause between batches | +| `ITEM_DELAY` | `500ms` | Pause between individual items | -**Export Integration (Optional):** +### Keyword Prefix -- `EXPORT_LABELS=action,comedy,thriller` - Comma-separated list of labels to export file paths for -- `EXPORT_LOCATION=/path/to/export` - Directory where export files will be created -- `EXPORT_MODE=txt` - Export format: `txt` (default) or `json` +| Variable | Default | Description | +|----------|---------|-------------| +| `KEYWORD_PREFIX` | _(none)_ | String prepended to each keyword (e.g. `"- "`) | -
+### Webhook -
-

πŸ“– How It Works

+| Variable | Default | Description | +|----------|---------|-------------| +| `WEBHOOK_ENABLED` | `false` | Start the webhook HTTP server | +| `WEBHOOK_PORT` | `9090` | Port for the webhook listener | +| `WEBHOOK_DEBOUNCE` | `30s` | Debounce window for rapid events | -1. **Movie Processing**: Iterates through all movies in the library -2. **TMDb ID Extraction**: Gets TMDb IDs from: - - Plex metadata Guid field - - File/folder names with `{tmdb-12345}` format -3. **Keyword Fetching**: Retrieves keywords from TMDb API -4. **Label Synchronization**: Adds new keywords as labels (preserves existing labels) -5. **Progress Tracking**: Remembers processed movies to avoid re-processing +### Radarr/Sonarr -
+| Variable | Default | Description | +|----------|---------|-------------| +| `USE_RADARR` | `false` | Enable Radarr integration | +| `RADARR_URL` | _(none)_ | Radarr base URL (e.g. `http://radarr:7878`) | +| `RADARR_API_KEY` | _(none)_ | Radarr API key | +| `USE_SONARR` | `false` | Enable Sonarr integration | +| `SONARR_URL` | _(none)_ | Sonarr base URL (e.g. `http://sonarr:8989`) | +| `SONARR_API_KEY` | _(none)_ | Sonarr API key | -
-

πŸš€ Radarr/Sonarr Integration

+### Export -Labelarr now supports automatic TMDb ID detection through Radarr and Sonarr APIs, eliminating the need for TMDb IDs in file paths! +| Variable | Default | Description | +|----------|---------|-------------| +| `EXPORT_LABELS` | _(none)_ | Comma-separated labels to export file paths for | +| `EXPORT_LOCATION` | _(none)_ | Directory for export output | +| `EXPORT_MODE` | `txt` | Export format: `txt` or `json` | -### Benefits +## Radarr/Sonarr Integration -- βœ… **No file renaming required** - Works with your existing file structure -- βœ… **Multiple matching methods** - Title, year, IMDb ID, TVDb ID, file path -- βœ… **Automatic fallback** - If Radarr/Sonarr doesn't have the item, falls back to file path detection -- βœ… **Optional integration** - Enable only if you use Radarr/Sonarr +If your file paths don't contain TMDb IDs, Labelarr can look them up through Radarr and Sonarr's APIs. The lookup chain is: -### ⚑ **Performance Considerations** +1. Plex metadata (fastest) +2. Radarr/Sonarr API (title/year match, then IMDb/TVDb ID, then file path) +3. File path regex (fallback) -- **File path detection is faster** - If your file paths consistently contain TMDb IDs, file path detection is significantly faster than API calls -- **Radarr/Sonarr integration adds latency** - Each API lookup introduces network overhead and processing time -- **Recommendation**: Use file path detection (with TMDb IDs in filenames/folders) as your primary method for best performance -- **When to use APIs**: Only enable Radarr/Sonarr integration if your file paths don't contain TMDb IDs or are inconsistently formatted +This means you don't need to rename any files. Enable it by setting `USE_RADARR=true` and/or `USE_SONARR=true` with the corresponding URL and API key. -### How It Works +File path detection is faster than API calls. If your filenames already include TMDb IDs (e.g. `{tmdb-603}`), you don't need this. -1. **For Movies (Radarr)**: - - Matches by title and year - - Falls back to IMDb ID from Plex - - Checks file paths against Radarr's database - - Extracts TMDb ID from matched movie +API keys: Radarr/Sonarr Settings > General > Security > API Key. -2. **For TV Shows (Sonarr)**: - - Matches by title and year - - Uses TVDb ID from Plex if available - - Falls back to IMDb ID - - Checks episode file paths against Sonarr's database - - Extracts TMDb ID from matched series +## Webhook Support -### Configuration Example +Instead of waiting for the next timer tick, Labelarr can react to Plex webhook events immediately. ```yaml -services: - labelarr: - image: ghcr.io/nullable-eth/labelarr:latest - environment: - # ... other config ... - - # Enable Radarr integration - - USE_RADARR=true - - RADARR_URL=http://radarr:7878 - - RADARR_API_KEY=your_radarr_api_key - - # Enable Sonarr integration - - USE_SONARR=true - - SONARR_URL=http://sonarr:8989 - - SONARR_API_KEY=your_sonarr_api_key +environment: + - WEBHOOK_ENABLED=true + - WEBHOOK_PORT=9090 + - WEBHOOK_DEBOUNCE=30s +ports: + - "9090:9090" ``` -### Finding Your API Keys +Configure Plex to send webhooks to `http://labelarr:9090/webhook` (Settings > Webhooks in Plex). Labelarr listens for `library.new` and `library.on.deck` events. -**Radarr**: Settings β†’ General β†’ Security β†’ API Key -**Sonarr**: Settings β†’ General β†’ Security β†’ API Key +When multiple events arrive for the same library in quick succession (common during bulk imports), the debounce window coalesces them into a single processing run. -
+The webhook server runs alongside the existing timer. Both can be active at the same time. -
-

πŸ” TMDb ID Detection

+A health check is available at `/health`. -The application can find TMDb IDs from multiple sources and supports flexible formats: +## Batch Processing -- **Plex Metadata**: Standard TMDb agent IDs -- **Radarr/Sonarr APIs**: Automatic matching (when enabled) -- **File Paths**: Flexible TMDb ID detection in filenames or directory names +Large libraries (4000+ items) can overwhelm Radarr/Sonarr APIs with thousands of requests. Batch processing breaks the work into chunks with pauses between them. -### βœ… **Supported Patterns** (Case-Insensitive) - -The TMDb ID detection is very flexible and supports various formats: - -**Direct Concatenation:** - -- `/movies/The Matrix (1999) tmdb603/file.mkv` -- `/movies/Inception (2010) TMDB27205/file.mkv` -- `/movies/Avatar (2009) Tmdb19995/file.mkv` - -**With Separators:** - -- `/movies/Interstellar (2014) tmdb:157336/file.mkv` -- `/movies/The Dark Knight (2008) tmdb-155/file.mkv` -- `/movies/Pulp Fiction (1994) tmdb_680/file.mkv` -- `/movies/Fight Club (1999) tmdb=550/file.mkv` -- `/movies/The Shawshank Redemption (1994) tmdb 278/file.mkv` - -**With Brackets/Braces:** - -- `/movies/Goodfellas (1990) {tmdb634}/file.mkv` -- `/movies/Forrest Gump (1994) [tmdb-13]/file.mkv` -- `/movies/The Godfather (1972) (tmdb:238)/file.mkv` -- `/movies/Taxi Driver (1976) {tmdb=103}/file.mkv` -- `/movies/Casablanca (1942) (tmdb 289)/file.mkv` +```yaml +environment: + - BATCH_SIZE=100 + - BATCH_DELAY=10s + - ITEM_DELAY=500ms +``` -**Mixed Examples:** +With 4000 items and a batch size of 100, Labelarr processes 100 items, pauses 10 seconds, processes the next 100, and so on. The per-item delay (default 500ms) paces individual API calls within each batch. -- `/movies/Citizen Kane (1941) something tmdb: 15678 extra/file.mkv` -- `/movies/Vertigo (1958) {tmdb=194884}/file.mkv` -- `/movies/Psycho (1960) [ tmdb-539 ]/file.mkv` +## Keyword Prefix -### ❌ **Will NOT Match** +When using `UPDATE_FIELD=genre`, TMDb keywords get mixed in with real Plex genres in the filter dropdown. A prefix separates them visually: -- `mytmdb12345` (preceded by alphanumeric characters) -- `tmdb12345abc` (followed by alphanumeric characters) -- `tmdb` (no digits following) +```yaml +environment: + - UPDATE_FIELD=genre + - KEYWORD_PREFIX="- " +``` -### πŸ“ **Example File Paths** +This turns `Sci-Fi` into `- Sci-Fi` in the genre list, so real genres sort to the top and keyword-derived genres cluster at the bottom. -``` -/movies/The Matrix (1999) [tmdb-603]/The Matrix.mkv -/movies/Inception (2010) (tmdb:27205)/Inception.mkv -/movies/Avatar (2009) tmdb19995/Avatar.mkv -/movies/Interstellar (2014) TMDB_157336/Interstellar.mkv -/movies/Edge Case - {tmdb=12345}/file.mkv -/movies/Colon: [tmdb:54321]/file.mkv -/movies/Semicolon; (tmdb;67890)/file.mkv -/movies/Underscore_tmdb_11111/file.mkv -/movies/ExtraSuffix tmdb-22222_extra/file.mkv -/movies/Direct tmdb194884 format/file.mkv -``` +The prefix is applied consistently during both add and remove operations. -
+## Keyword Normalization -
-

πŸ“€ Export Functionality

+TMDb keywords come in inconsistent formats. Labelarr normalizes them before applying: -Labelarr can automatically export file paths for media items that have specific labels, creating organized lists perfect for syncing content to alternate locations or creating backup sets. +- Title casing with proper article/preposition handling +- Acronym detection: `fbi` -> `FBI`, `cia` -> `CIA` +- Known replacements: `sci-fi` / `scifi` / `sci fi` -> `Sci-Fi`, `romcom` -> `Romantic Comedy` +- Relationship patterns: `father daughter` -> `Father Daughter Relationship` +- Century formatting: `5th century bc` -> `5th Century BC` +- Location formatting: `san francisco, california` -> `San Francisco, California` +- Credit stingers: `duringcreditsstinger` -> `During Credits Stinger` -### 🎯 **What It Does** +When a normalized keyword replaces an old unnormalized version, the old one is automatically removed from Plex. -- **Scans all processed media** for items containing specified labels -- **Creates separate text files** for each export label (e.g., `action.txt`, `comedy.txt`) -- **Lists full file paths** of matching movies and TV show episodes -- **Updates files** after each processing run with current results -- **Preserves existing files** until new export data is ready +90+ test cases cover the normalization rules. -### πŸ”§ **Configuration** +## Export Functionality -Add these environment variables to enable export functionality: +Generate file path lists for media matching specific labels. Useful for syncing specific genres to other devices or creating targeted backups. ```yaml environment: - # Specify which labels to export (comma-separated, case-insensitive) - - EXPORT_LABELS=action,comedy,thriller,documentary - # Directory where export files will be created + - EXPORT_LABELS=action,comedy,thriller - EXPORT_LOCATION=/data/exports - # Export format: txt (default) creates separate files, json creates single comprehensive file - EXPORT_MODE=txt - # ... other labelarr config ... -``` - -**Complete Docker Compose Example with Export:** - -```yaml -services: - labelarr: - image: ghcr.io/nullable-eth/labelarr:latest - container_name: labelarr - restart: unless-stopped - volumes: - - ./labelarr-data:/data - - ./exports:/data/exports # Mount host directory for export files - environment: - - PLEX_TOKEN=your_plex_token_here - - TMDB_READ_ACCESS_TOKEN=your_tmdb_token - - PLEX_SERVER=plex - - PLEX_PORT=32400 - - MOVIE_PROCESS_ALL=true - - TV_PROCESS_ALL=true - # Export configuration - - EXPORT_LABELS=action,comedy,thriller,documentary,kids - - EXPORT_LOCATION=/data/exports - - EXPORT_MODE=txt -``` - -### πŸ“ **Output Example** - -With `EXPORT_LABELS=action,comedy,kids` and `EXPORT_LOCATION=/data/exports`, Labelarr will create library-specific subdirectories: - -#### **Text Mode (Default)** - -``` -/data/exports/ -β”œβ”€β”€ summary.txt # Detailed statistics and file sizes -β”œβ”€β”€ Movies/ # Movie library exports -β”‚ β”œβ”€β”€ action.txt # Action movies only -β”‚ β”œβ”€β”€ comedy.txt # Comedy movies only -β”‚ └── kids.txt # Kids movies only -└── TV Shows/ # TV show library exports - β”œβ”€β”€ action.txt # Action TV shows only - β”œβ”€β”€ comedy.txt # Comedy TV shows only - └── kids.txt # Kids TV shows only +volumes: + - ./exports:/data/exports ``` -#### **JSON Mode** +### Text mode (default) -With `EXPORT_MODE=json`, Labelarr creates a single comprehensive JSON file: +Creates per-library subdirectories with one file per label: ``` /data/exports/ -└── export.json # Complete export data with statistics + summary.txt + Movies/ + action.txt + comedy.txt + TV Shows/ + action.txt + comedy.txt ``` -Each file contains full paths to matching media from that specific library: +Each file lists the full file paths of matching media. -**Movies/action.txt:** +### JSON mode -``` -/data/movies/John Wick (2014)/John Wick (2014) (Bluray-1080p).mkv -/data/movies/Mad Max Fury Road (2015)/Mad Max Fury Road (2015) (Bluray-2160p).mkv -/data/movies/The Dark Knight (2008)/The Dark Knight (2008) (Bluray-2160p).mkv -``` +Creates a single `export.json` with structured data including file sizes and statistics. -**TV Shows/action.txt:** +Label matching is case-insensitive. Items with multiple matching labels appear in each corresponding file. Exported paths reflect Plex's internal filesystem, so you may need to translate container paths to host paths. -``` -/data/tv/Breaking Bad (2008)/Season 01/Breaking Bad S01E01 (1080p).mkv -/data/tv/Breaking Bad (2008)/Season 01/Breaking Bad S01E02 (1080p).mkv -/data/tv/24 (2001)/Season 01/24 S01E01 (1080p).mkv -``` - -**export.json structure:** - -```json -{ - "generated_at": "2024-01-15 14:30:25", - "export_mode": "json", - "libraries": { - "Movies": { - "action": [ - { - "path": "/data/movies/John Wick (2014)/John Wick (2014) (Bluray-1080p).mkv", - "size": 4832716800 - }, - { - "path": "/data/movies/Mad Max Fury Road (2015)/Mad Max Fury Road (2015) (Bluray-2160p).mkv", - "size": 8945283072 - } - ], - "comedy": [ - { - "path": "/data/movies/The Hangover (2009)/The Hangover (2009) (Bluray-1080p).mkv", - "size": 3221225472 - } - ] - }, - "TV Shows": { - "action": [ - { - "path": "/data/tv/Breaking Bad (2008)/Season 01/Breaking Bad S01E01 (1080p).mkv", - "size": 2147483648 - } - ] - } - }, - "summary": { - "total_files": 1247, - "total_size": 2748779069440, - "total_size_formatted": "2.5 TB", - "library_stats": { - "Movies": { - "total_files": 156, - "total_size": 790495232000, - "total_size_formatted": "736.0 GB", - "labels": { - "action": { - "count": 89, - "size": 478150656000, - "size_formatted": "445.2 GB" - }, - "comedy": { - "count": 67, - "size": 312344576000, - "size_formatted": "290.8 GB" - } - } - } - }, - "label_totals": { - "action": { - "count": 632, - "size": 1797564416000, - "size_formatted": "1.6 TB" - }, - "comedy": { - "count": 615, - "size": 1052901376000, - "size_formatted": "980.3 GB" - } - } - } -} -``` +## TMDb ID Detection -**summary.txt:** +Labelarr looks for TMDb IDs in file and folder names using a flexible regex. All of these work: ``` -Labelarr Export Summary -Generated: 2024-01-15 14:30:25 - -πŸ“ Export Files Generated: - Movies/action.txt - Movies/comedy.txt - TV Shows/action.txt - TV Shows/comedy.txt - -πŸ“Š Overall Statistics: - Total files: 1,247 - Total size: 2.5 TB (2,748,779,069,440 bytes) - -πŸ“š Library Breakdown: - - Movies: - action.txt: 89 files, 445.2 GB (478,150,656,000 bytes) - comedy.txt: 67 files, 290.8 GB (312,344,576,000 bytes) - Library total: 156 files, 736.0 GB (790,495,232,000 bytes) - - TV Shows: - action.txt: 543 files, 1.2 TB (1,319,413,760,000 bytes) - comedy.txt: 548 files, 689.5 GB (740,556,800,000 bytes) - Library total: 1,091 files, 1.8 TB (2,059,970,560,000 bytes) - -🏷️ Label Totals (All Libraries): - action: 632 files, 1.6 TB (1,797,564,416,000 bytes) - comedy: 615 files, 980.3 GB (1,052,901,376,000 bytes) +/movies/The Matrix (1999) {tmdb-603}/file.mkv +/movies/Inception (2010) [tmdb:27205]/file.mkv +/movies/Avatar (2009) tmdb19995/file.mkv +/movies/Interstellar (2014) (tmdb=157336)/file.mkv +/movies/The Dark Knight (2008) TMDB_155/file.mkv ``` -### πŸ”„ **Use Cases** - -**Content Syncing:** - -- Export specific genres to sync to mobile devices or remote locations -- Create curated collections for different family members -- Sync action movies to gaming setup, kids content to tablets -- **Sync specific content to alternate Plex servers** for distributed media setups -- **Separate movie and TV exports** for different sync destinations -- **JSON format for programmatic processing** of export data with file sizes and metadata - -**Backup Management:** - -- Generate lists of premium content for priority backup -- Create separate backup sets by genre or rating -- Export documentary collections for educational archives -- **Create targeted backup lists** for specific movies/TV shows -- **Library-specific backup strategies** (movies vs TV shows) -- **JSON export for automated backup tools** that need file size information - -**Media Organization:** - -- Generate playlists for external media players -- Create file lists for batch operations (transcoding, moving, etc.) -- Export specific content types for different storage tiers -- **Organize exports by library type** for easier management -- **API integration with JSON format** for custom media management tools - -### πŸš€ **Performance** - -- **Memory efficient**: Accumulates paths during processing, writes once at completion -- **Atomic updates**: Existing export files preserved until new data is ready -- **Minimal overhead**: Only ~2-5 MB RAM usage for large libraries (10K+ items) - -### πŸ’‘ **Tips** +Separators (`-`, `:`, `_`, `=`, space) and bracket styles (`{}`, `[]`, `()`) all work. Case-insensitive. -- **Label names are case-insensitive**: `Action`, `action`, and `ACTION` all match -- **Multiple labels per item**: Movies with both "action" and "comedy" labels appear in both export files -- **Empty files created**: Labels with no matches still get empty `.txt` files for consistency (text mode) -- **File paths included**: Both movie files and all TV show episode files are included -- **Library separation**: Files are organized by Plex library (e.g., `Movies/action.txt` vs `TV Shows/action.txt`) in text mode -- **Library names sanitized**: Special characters in library names are replaced with underscores for valid folder names -- **Summary statistics**: `summary.txt` provides detailed file counts, sizes, and breakdowns by library and label (text mode) -- **File sizes from Plex**: Uses Plex metadata for accurate file sizes without filesystem access -- **JSON export includes everything**: Single file with all data, file sizes, and comprehensive statistics -- **Choose format based on use case**: Use `txt` for simple file lists, `json` for programmatic processing +Will not match: `mytmdb12345` (preceded by letters), `tmdb` (no digits), `tmdb12345abc` (followed by letters). -### ⚠️ **Important Notes** +### Radarr naming format -**Container File Paths:** +To include TMDb IDs in Radarr-managed files, set the folder format to: -- Exported file paths reflect your **Plex container's internal file system** -- If using volume mounts (e.g., `-v /host/media:/data/media`), paths may need processing -- Example: Plex sees `/data/media/movies/...` but host filesystem has `/mnt/nas/movies/...` -- Consider path mapping/replacement when using exported files outside the container environment - -**Path Processing Example:** - -```bash -# If Plex container mounts: -v /mnt/nas/media:/data/media -# Export shows: /data/media/movies/Action Movie.mkv -# You may need: /mnt/nas/media/movies/Action Movie.mkv +``` +{Movie CleanTitle} ({Release Year}) {tmdb-{TmdbId}} ``` -
- -
-

πŸ”§ Advanced Configuration

- -
-πŸ” Finding Library IDs - -To find your library's ID, open your Plex web app, click on the desired library, and look for `source=` in the URL: - -- `https://app.plex.tv/desktop/#!/media/xxxx/com.plexapp.plugins.library?source=1` -- Here, the library ID is `1` - -**⚠️ Note**: Starting with this version, explicit library configuration is required. The application will **NOT** auto-select libraries by default. - -- `MOVIE_LIBRARY_ID=1` - Process only specific movie library -- `MOVIE_PROCESS_ALL=true` - Process all movie libraries (recommended) -- Neither set: Movies are **NOT** processed - -
- -
-🏷️ Labels vs Genres (UPDATE_FIELD) - -Control whether TMDb keywords are synced as Plex **labels** (default) or **genres**: - -- `UPDATE_FIELD=label` (default): Syncs keywords as Plex labels -- `UPDATE_FIELD=genre`: Syncs keywords as Plex genres - -The chosen field will be **locked** after update to prevent Plex from overwriting it. - -![Example of genres updated and locked by Labelarr](example/genre.png) - -
+For existing libraries, use Radarr's mass rename feature to apply the new format. -
-πŸ—‘οΈ Removing Keywords (REMOVE) +## Removing Keywords -Remove **only** TMDb keywords while preserving custom labels/genres: +`REMOVE=lock` or `REMOVE=unlock` runs a single pass that removes TMDb keywords from the configured field, then exits. -- `REMOVE=lock`: Removes TMDb keywords and **locks** the field -- `REMOVE=unlock`: Removes TMDb keywords and **unlocks** the field for Plex to update +- `lock`: removes keywords, keeps the field locked (Plex can't overwrite) +- `unlock`: removes keywords, unlocks the field (Plex can refresh it) -**Use lock when**: You manually manage labels/genres -**Use unlock when**: You want Plex to refresh metadata naturally +Only TMDb-sourced keywords are removed. Custom labels you added manually are preserved. ```bash -# Example: Remove TMDb keywords from labels and lock field docker run --rm \ -e PLEX_TOKEN=... -e TMDB_READ_ACCESS_TOKEN=... \ -e REMOVE=lock -e UPDATE_FIELD=label \ - -e MOVIE_PROCESS_ALL=true -e TV_PROCESS_ALL=true \ + -e MOVIE_PROCESS_ALL=true \ ghcr.io/nullable-eth/labelarr:latest ``` -
- -
-πŸ”’ Field Locking & Plex Metadata - -**Locked fields** in Plex are protected from automatic updates: - -- βœ… Labelarr can still modify them -- βœ… Manual edits in Plex UI still work -- ❌ Plex cannot overwrite during metadata refresh -- πŸ”’ Lock icon appears in Plex UI - -**Unlocked fields** can be updated by Plex during metadata refreshes. - -**Labelarr's behavior:** - -- **Adding keywords**: Always locks the field -- **Remove with lock**: Keeps field locked after removing keywords -- **Remove with unlock**: Unlocks field for Plex to manage - -
- -
-πŸ”„ Migration from Previous Version - -**⚠️ Breaking Changes**: This version requires explicit library configuration. - -**Old behavior**: Auto-selected first movie library -**New behavior**: Must specify which libraries to process - -**Migration steps:** - -```bash -# Before (auto-selected movies) --e LIBRARY_ID=1 - -# After (explicit selection) --e MOVIE_LIBRARY_ID=1 # Specific library -# OR --e MOVIE_PROCESS_ALL=true # All movie libraries --e TV_PROCESS_ALL=true # All TV libraries -``` - -**New Features:** - -- πŸ“Ί TV show support -- πŸ”‡ Reduced verbose output -- πŸ“Š Better progress tracking -- πŸ›‘οΈ Enhanced error handling - -
- -
- -
-

πŸ”’ Understanding Field Locking & Plex Metadata

- -Field locking is a crucial concept in Plex that determines whether Plex can automatically update metadata fields during library scans and metadata refreshes. Understanding how this works with Labelarr is essential for managing your media library effectively. - -
-πŸ” What is Field Locking? - -When a field is **locked** in Plex: - -- βœ… The field value is **protected** from automatic changes -- βœ… Plex **cannot** overwrite the field during metadata refresh -- βœ… Manual edits in Plex UI are still possible -- βœ… External tools (like Labelarr) can still modify the field -- πŸ”’ A **lock icon** appears next to the field in Plex UI - -When a field is **unlocked** in Plex: - -- πŸ”„ Plex **can** update the field during metadata refresh -- πŸ”„ New metadata agents can overwrite existing values -- πŸ”„ "Refresh Metadata" will update the field with fresh data -- πŸ”“ **No lock icon** appears in Plex UI - -
- -
-🎯 Labelarr's Field Locking Behavior - -#### **During Normal Operation (Adding Keywords)** - -Labelarr **always locks** the field after adding TMDb keywords to prevent Plex from accidentally removing them during future metadata refreshes. - -#### **During Remove Operation** - -- `REMOVE=lock`: Removes TMDb keywords but **keeps the field locked** -- `REMOVE=unlock`: Removes TMDb keywords and **unlocks the field** - -
- -
-πŸ“‹ Practical Examples - -#### **Scenario 1: Mixed Content Management** - -You have movies with: - -- 🏷️ TMDb keywords: `action`, `thriller`, `heist` -- 🏷️ Custom labels: `watched`, `favorites`, `4k-remaster` - -**Using `REMOVE=lock`:** - -- βœ… Removes only: `action`, `thriller`, `heist` -- βœ… Keeps: `watched`, `favorites`, `4k-remaster` -- πŸ”’ Field remains **locked** - Plex won't add new genres -- πŸ’‘ **Best for**: Users who manually manage labels alongside TMDb keywords - -**Using `REMOVE=unlock`:** - -- βœ… Removes only: `action`, `thriller`, `heist` -- βœ… Keeps: `watched`, `favorites`, `4k-remaster` -- πŸ”“ Field becomes **unlocked** - Plex can add new metadata -- πŸ’‘ **Best for**: Users who want Plex to manage metadata going forward - -#### **Scenario 2: Complete Reset** - -You want to completely reset your library's metadata: - -1. **Step 1**: `REMOVE=unlock` - Removes TMDb keywords and unlocks fields -2. **Step 2**: Use Plex's "Refresh All Metadata" to restore original metadata -3. **Result**: Clean slate with Plex's default metadata - -
- -
-πŸ›‘οΈ Best Practices - -#### **Use Locking When:** - -- βœ… You manually curate labels/genres -- βœ… You use labels for organization (playlists, collections, etc.) -- βœ… You want to prevent accidental metadata overwrites -- βœ… You share your library and need consistent metadata - -#### **Use Unlocking When:** - -- βœ… You want to return to Plex's default metadata behavior -- βœ… You're switching to a different metadata agent -- βœ… You want Plex to automatically update metadata in the future -- βœ… You're troubleshooting metadata issues - -
- -
-πŸ” Visual Indicators - -In Plex Web UI, you'll see: - -- πŸ”’ **Lock icon** = Field is locked (protected from automatic updates) -- πŸ”“ **No lock icon** = Field is unlocked (can be updated by Plex) - -![Example of locked genre field in Plex](example/genre.png) - -*The lock icon indicates this genre field is protected from automatic changes* - -
- -
- -
-

πŸ” Verbose Logging

- -Enable verbose logging to see detailed information about TMDb ID lookups and matching attempts. - -### What it shows - -When `VERBOSE_LOGGING=true`, you'll see: - -- πŸ“‹ All available Plex GUIDs for each item -- 🎬 Radarr lookup attempts (title, file path, IMDb ID) -- πŸ“Ί Sonarr lookup attempts (title, TVDb ID, IMDb ID, file paths) -- πŸ“ File path pattern matching attempts -- βœ… Successful matches with source information -- ❌ Failed lookup attempts with reasons - -### Example Output - -``` -πŸ” Starting TMDb ID lookup for movie: The Matrix (1999) - πŸ“‹ Available Plex GUIDs: - - imdb://tt0133093 - - tmdb://603 - βœ… Found TMDb ID in Plex metadata: 603 - -πŸ” Starting TMDb ID lookup for movie: Inception (2010) - πŸ“‹ Available Plex GUIDs: - - imdb://tt1375666 - 🎬 Checking Radarr for movie match... - β†’ Searching by title: "Inception" year: 2010 - βœ… Found match in Radarr: Inception (TMDb: 27205) - -πŸ” Starting TMDb ID lookup for TV show: Breaking Bad (2008) - πŸ“‹ Available Plex GUIDs: - - tvdb://81189 - - imdb://tt0903747 - πŸ“Ί Checking Sonarr for series match... - β†’ Searching by title: "Breaking Bad" year: 2008 - ❌ No match found by title/year - β†’ Searching by TVDb ID: 81189 - βœ… Found match by TVDb ID: Breaking Bad (TMDb: 1396) -``` - -### Configuration - -```yaml -environment: - - VERBOSE_LOGGING=true -``` - -This is especially useful for: - -- Troubleshooting why certain items aren't being matched -- Understanding which data source provided the TMDb ID -- Debugging Radarr/Sonarr integration issues - -
- -
-

πŸ“ Keyword Normalization

- -Labelarr automatically normalizes keywords from TMDb using intelligent pattern recognition and proper capitalization rules. - -### How it works - -- **Smart Title Casing**: Proper capitalization with article/preposition handling -- **Acronym Recognition**: Automatically detects "fbi" β†’ "FBI", "usa" β†’ "USA" -- **Pattern-Based Rules**: Dynamic handling of common patterns without hardcoding every keyword -- **Critical Replacements**: Known abbreviations like "sci-fi" β†’ "Sci-Fi", "romcom" β†’ "Romantic Comedy" -- **Intelligent Patterns**: Recognizes relationships, locations, decades, and compound terms -- **Duplicate Removal**: Removes duplicates after normalization - -### Examples - -**Before normalization:** - -``` -sci-fi, action, fbi, based on novel, time travel, woman in peril -``` - -**After normalization:** +## Field Locking -``` -Sci-Fi, Action, FBI, Based on Novel, Time Travel, Woman in Peril -``` +Labelarr locks the label/genre field after writing to prevent Plex from overwriting keywords during metadata refreshes. Locked fields show a lock icon in the Plex UI. -### Pattern Recognition Examples +You can still edit locked fields manually in Plex. External tools (including Labelarr) can also modify them. -- **Critical Replacements**: `sci-fi`, `scifi`, `sci fi` β†’ `Sci-Fi` -- **Relationships**: `father daughter` β†’ `Father Daughter Relationship` -- **Locations**: `san francisco, california` β†’ `San Francisco, California` -- **Versus Patterns**: `man vs nature` β†’ `Man vs Nature` -- **Based On**: `based on novel` β†’ `Based on Novel` -- **Decades**: `1940s` β†’ `1940s` (preserved) -- **Ethnicity**: `african american lead` β†’ `African American Lead` -- **General Terms**: Any multi-word keyword gets proper title casing +![Example of locked genre field](example/genre.png) -### Smart Duplicate Cleaning +## Force Update Mode -Labelarr automatically cleans up duplicate keywords when applying normalization: +Set `FORCE_UPDATE=true` to reprocess every item regardless of whether it was already processed. Useful after: -- **Removes old versions**: If you have "sci-fi" and we add "Sci-Fi", the old version is removed -- **Preserves manual keywords**: Custom tags you've added manually are always kept -- **Handles complex patterns**: Works with all normalization patterns (agencies, centuries, etc.) +- Enabling keyword normalization on an existing library +- Switching between label and genre modes +- Wanting to refresh all keywords from TMDb -### Verbose Logging +This bypasses both the storage check and the "already has all keywords" check. -With `VERBOSE_LOGGING=true`, you'll see normalization and cleaning in action: +## Verbose Logging -``` -πŸ“ Normalized: "sci-fi" β†’ "Sci-Fi" -πŸ“ Normalized: "fbi" β†’ "FBI" -πŸ“ Normalized: "based on novel" β†’ "Based on Novel" -🧹 Cleaned 2 duplicate/unnormalized keywords -``` - -
+`VERBOSE_LOGGING=true` shows the full TMDb ID lookup chain for each item: which Plex GUIDs are available, Radarr/Sonarr lookup attempts, file path matching, and the source of the final match. -
-

πŸ”„ Force Update Mode

+Useful for debugging why specific items aren't being matched. -Use force update mode to reprocess all items in your library, regardless of whether they've been processed before. This is especially useful after implementing keyword normalization or when you want to refresh all metadata. +## Persistent Storage -### When to use Force Update +When `DATA_DIR` is set (e.g. `/data`), Labelarr saves processed items to a JSON file so it can skip them on restart. Without `DATA_DIR`, it runs in ephemeral mode and reprocesses everything each cycle. -- **After enabling keyword normalization** - Update existing keywords with proper formatting -- **Configuration changes** - When switching between label/genre fields -- **Keyword cleanup** - Refresh all TMDb keywords with latest data -- **Initial migration** - When moving from another labeling system - -### Configuration +Mount a volume to persist across container restarts: ```yaml +volumes: + - ./labelarr-data:/data environment: - - FORCE_UPDATE=true -``` - -### What it does - -When `FORCE_UPDATE=true`: - -- βœ… Processes all items regardless of previous processing status -- βœ… Reapplies keywords even if they already exist -- βœ… Updates storage with latest processing information -- βœ… Shows "FORCE UPDATE MODE" message in logs - -### Example Output - -``` -βœ… Found 1250 movies in library -πŸ”„ FORCE UPDATE MODE: All items will be reprocessed regardless of previous processing -⏳ Processing movies... -``` - -**⚠️ Note**: Force update will reprocess your entire library, which may take time for large collections. Consider running with `VERBOSE_LOGGING=true` to monitor progress. - -
- -
-

πŸ”‘ Getting API Keys

- -### Plex Token - -1. Open Plex Web App in browser -2. Press F12 β†’ Network tab -3. Refresh the page -4. Find any request with `X-Plex-Token` in headers -5. Copy the token value - -### TMDb API Key - -1. Visit [TMDb API Settings](https://www.themoviedb.org/settings/api) -2. Create account and generate API key -3. Use the Read Access Token (not the API key) - -
- -
-

πŸ”§ Troubleshooting

- -### Common Issues - -**401 Unauthorized from Plex** - -- Verify your Plex token is correct -- Check if your Plex server requires HTTPS - -**401 Unauthorized from TMDb** - -- Ensure you're using a valid API token. - -**No TMDb ID found** - -- Check if your movies have TMDb metadata -- Verify file naming includes `{tmdb-12345}` format -- Ensure TMDb agent is used in Plex - -**Connection refused** - -- Check PLEX_SERVER and PLEX_PORT values -- Try setting PLEX_REQUIRES_HTTPS=false for local servers - -### 🎬 Radarr Users: Ensuring TMDb ID in File Paths - -If you're using Radarr to manage your movie collection, follow these steps to ensure Labelarr can detect TMDb IDs from your file paths: - -#### **Configure Radarr Naming to Include TMDb ID** - -Radarr can automatically include TMDb IDs in your movie file and folder names. Update your naming scheme in Radarr settings: - -**Recommended Settings:** - -1. **Movie Folder Format**: - - ``` - {Movie CleanTitle} ({Release Year}) {tmdb-{TmdbId}} - ``` - - *Example*: `The Matrix (1999) {tmdb-603}` - -2. **Movie File Format**: - - ``` - {Movie CleanTitle} ({Release Year}) {tmdb-{TmdbId}} - {[Quality Full]}{[MediaInfo VideoDynamicRangeType]}{[Mediainfo AudioCodec}{ Mediainfo AudioChannels]}{[MediaInfo VideoCodec]}{-Release Group} - ``` - - *Example*: `The Matrix (1999) {tmdb-603} - [Bluray-1080p][x264][DTS 5.1]-GROUP` - -#### **Alternative Radarr Naming Options** - -If you prefer different bracket styles, these formats also work with Labelarr: - -- **Square brackets**: `{Movie CleanTitle} ({Release Year}) [tmdb-{TmdbId}]` -- **Parentheses**: `{Movie CleanTitle} ({Release Year}) (tmdb-{TmdbId})` -- **Different delimiters**: `{Movie CleanTitle} ({Release Year}) {tmdb:{TmdbId}}` or `{Movie CleanTitle} ({Release Year}) {tmdb;{TmdbId}}` - -#### **Common Radarr Configuration Pitfalls** - -❌ **Avoid these common mistakes:** - -1. **Missing TMDb ID in paths**: Default Radarr naming like `{Movie CleanTitle} ({Release Year})` doesn't include TMDb IDs -2. **Using only IMDb IDs**: `{imdb-{ImdbId}}` won't work - Labelarr specifically needs TMDb IDs -3. **Folder vs. file naming**: Ensure TMDb ID is in at least one location (folder name OR file name) - -#### **Verifying Your Configuration** - -After updating Radarr naming: - -1. **For new movies**: TMDb IDs will be included automatically -2. **For existing movies**: Use Radarr's "Rename Files" feature: - - Go to Movies β†’ Select movies β†’ Mass Editor - - Choose your root folder and click "Yes, move files" - - This will rename existing files to match your new naming scheme - -#### **Plex Agent Compatibility** - -- **New Plex Movie Agent**: Works with any naming scheme above -- **Legacy Plex Movie Agent**: May require specific TMDb ID placement for optimal matching -- **Best practice**: Include TMDb ID in folder names for maximum compatibility - -#### **Example Directory Structure** - -``` -/movies/ -β”œβ”€β”€ The Matrix (1999) {tmdb-603}/ -β”‚ └── The Matrix (1999) {tmdb-603} - [Bluray-1080p].mkv -β”œβ”€β”€ Inception (2010) [tmdb-27205]/ -β”‚ └── Inception (2010) [tmdb-27205] - [WEBDL-1080p].mkv -└── Avatar (2009) (tmdb:19995)/ - └── Avatar (2009) (tmdb:19995) - [Bluray-2160p].mkv + - DATA_DIR=/data ``` -#### **Migration from Existing Libraries** - -If you have an existing movie library without TMDb IDs in file paths: - -1. **Update Radarr naming scheme** as shown above -2. **Use Radarr's mass rename feature** to update existing files -3. **Wait for Plex to detect the changes** (or manually scan library) -4. **Run Labelarr** - it will now detect TMDb IDs from the updated file paths - -**⚠️ Note**: Large libraries may take time to rename. Consider doing this in batches during low-usage periods. - -### πŸ“Ί Sonarr Users: Renaming Existing Folders to Include TMDb ID +## Getting API Keys -If you're using Sonarr to manage your TV show collection and want to apply new folder naming that includes TMDb IDs, here's how to rename existing folders: +**Plex Token:** Open Plex Web, press F12, go to Network tab, refresh the page, and look for `X-Plex-Token` in any request header. -#### **πŸ”„ Apply the New Folder Names** +**TMDb:** Create an account at [themoviedb.org](https://www.themoviedb.org/settings/api) and generate a Read Access Token. -To actually rename existing folders: +**Radarr/Sonarr:** Settings > General > Security > API Key. -1. **Go to the Series tab** +## Troubleshooting -2. **Click the Mass Editor** (three sliders icon) +**401 from Plex** -- Check your token. Try `PLEX_REQUIRES_HTTPS=false` for local servers. -3. **Select the shows** you want to rename +**401 from TMDb** -- Make sure you're using the Read Access Token, not the API key. -4. **At the bottom, click "Edit"** +**No TMDb ID found** -- Enable `VERBOSE_LOGGING=true` to see where the lookup fails. Either add TMDb IDs to your file paths, enable Radarr/Sonarr integration, or make sure Plex is using the TMDb agent. -5. **In the popup:** - - Set the **Root Folder** to the same one it's already using (e.g., `/mnt/user/TV`) - - Click **"Save"** +**Container permission errors** -- If you see "mkdir /data: permission denied", either set `DATA_DIR` to a writable path with a mounted volume, or leave `DATA_DIR` unset to run in ephemeral mode. -6. **Sonarr will interpret this as a move** and apply the new folder naming format without physically moving the filesβ€”just renaming the folders. +**Large library crashes** -- Set `BATCH_SIZE` and `BATCH_DELAY` to reduce API pressure. The defaults (100 items, 10s pause) work for most setups. -#### **Example Result** - -After applying the new naming format, your TV show folders will include TMDb IDs: - -``` -/tv/Batman [tmdb-2287]/Season 3/Batman - S03E17 - The Joke's on Catwoman Bluray-1080p [tmdb-2287].mkv -``` - -**πŸ’‘ Pro Tip**: This method works for renaming folders without actually moving files, making it safe and efficient for large TV libraries. - -
- -
-

πŸ› οΈ Local Development

- -### Prerequisites - -- Go 1.23+ -- Git - -### Build and Run +## Local Development ```bash -# Clone the repository git clone https://github.com/nullable-eth/labelarr.git cd labelarr - -# Initialize Go modules go mod tidy +go build -o labelarr ./cmd/labelarr -# Set environment variables -export PLEX_SERVER=localhost -export PLEX_PORT=32400 -export PLEX_TOKEN=your_plex_token -export TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token -export MOVIE_PROCESS_ALL=true -export TV_PROCESS_ALL=true - -# Run the application -go run main.go -``` - -### Build Binary - -```bash -# Build for current platform -go build -o labelarr main.go - -# Build for Linux (Docker) -CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o labelarr main.go -``` - -
- -
-

πŸ“Š Monitoring

- -### View Logs - -```bash -# Docker logs -docker logs labelarr - -# Follow logs -docker logs -f labelarr +# Set required env vars and run +./labelarr ``` -### Log Output Includes - -- Processing progress with movie counts -- TMDb ID detection results -- Label synchronization status -- API error handling and retries -- Detailed processing summaries - -
- -
-

🀝 Contributing

- -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request - -
- -
-

πŸ“ž Support

- -- **GitHub**: [https://github.com/nullable-eth/labelarr](https://github.com/nullable-eth/labelarr) -- **Issues**: Report bugs and feature requests -- **Logs**: Check container logs for troubleshooting with `docker logs labelarr` - -
- -
-

πŸ“„ License

- -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. - -
- ---- +Build for Docker: `CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o labelarr ./cmd/labelarr` -**Tags**: plex, tmdb, automation, movies, tv shows, labels, genres, docker, go, selfhosted, media management +Run tests: `go test ./...` ---- +## License -⭐ **If you find this project helpful, please consider giving it a star!** +MIT. See [LICENSE](LICENSE). diff --git a/cmd/labelarr/main.go b/cmd/labelarr/main.go index 3e07b21..92246f9 100644 --- a/cmd/labelarr/main.go +++ b/cmd/labelarr/main.go @@ -1,8 +1,11 @@ package main import ( + "context" "fmt" "os" + "os/signal" + "syscall" "time" "github.com/nullable-eth/labelarr/internal/config" @@ -11,95 +14,88 @@ import ( "github.com/nullable-eth/labelarr/internal/radarr" "github.com/nullable-eth/labelarr/internal/sonarr" "github.com/nullable-eth/labelarr/internal/tmdb" + "github.com/nullable-eth/labelarr/internal/webhook" ) func main() { - // Load configuration cfg := config.Load() - // Validate configuration if err := cfg.Validate(); err != nil { - fmt.Printf("❌ Configuration error: %v\n", err) + fmt.Printf("[ERROR] Configuration error: %v\n", err) os.Exit(1) } - // Initialize clients plexClient := plex.NewClient(cfg) tmdbClient := tmdb.NewClient(cfg) - // Test TMDb connection if err := tmdbClient.TestConnection(); err != nil { - fmt.Printf("❌ Failed to connect to TMDb: %v\n", err) + fmt.Printf("[ERROR] Failed to connect to TMDb: %v\n", err) os.Exit(1) } - fmt.Println("βœ… Successfully connected to TMDb") + fmt.Println("[OK] Successfully connected to TMDb") - // Initialize Radarr client if enabled var radarrClient *radarr.Client if cfg.UseRadarr { radarrClient = radarr.NewClient(cfg.RadarrURL, cfg.RadarrAPIKey) if err := radarrClient.TestConnection(); err != nil { - fmt.Printf("❌ Failed to connect to Radarr: %v\n", err) + fmt.Printf("[ERROR] Failed to connect to Radarr: %v\n", err) os.Exit(1) } - fmt.Println("βœ… Successfully connected to Radarr") + fmt.Println("[OK] Successfully connected to Radarr") } - // Initialize Sonarr client if enabled var sonarrClient *sonarr.Client if cfg.UseSonarr { sonarrClient = sonarr.NewClient(cfg.SonarrURL, cfg.SonarrAPIKey) if err := sonarrClient.TestConnection(); err != nil { - fmt.Printf("❌ Failed to connect to Sonarr: %v\n", err) + fmt.Printf("[ERROR] Failed to connect to Sonarr: %v\n", err) os.Exit(1) } - fmt.Println("βœ… Successfully connected to Sonarr") + fmt.Println("[OK] Successfully connected to Sonarr") } - // Initialize single processor - processor, err := media.NewProcessor(cfg, plexClient, tmdbClient, radarrClient, sonarrClient) + processor, err := media.NewProcessor(cfg, media.Clients{ + Plex: plexClient, + TMDb: tmdbClient, + Radarr: radarrClient, + Sonarr: sonarrClient, + }) if err != nil { - fmt.Printf("❌ Failed to initialize processor: %v\n", err) + fmt.Printf("[ERROR] Failed to initialize processor: %v\n", err) os.Exit(1) } - fmt.Println("🏷️ Starting Labelarr with TMDb Integration...") - fmt.Printf("πŸ“‘ Server: %s://%s:%s\n", cfg.Protocol, cfg.PlexServer, cfg.PlexPort) + fmt.Println("[INFO] Starting Labelarr with TMDb Integration...") + fmt.Printf("[NET] Server: %s://%s:%s\n", cfg.Protocol, cfg.PlexServer, cfg.PlexPort) - // Get and validate libraries movieLibraries, tvLibraries := getLibraries(cfg, plexClient) - // Handle REMOVE mode - run once and exit if cfg.IsRemoveMode() { handleRemoveMode(cfg, processor, movieLibraries, tvLibraries) os.Exit(0) } - // Handle normal processing mode handleNormalMode(cfg, processor, movieLibraries, tvLibraries) } -// getLibraries fetches, separates, and validates libraries from Plex func getLibraries(cfg *config.Config, plexClient *plex.Client) ([]plex.Library, []plex.Library) { - // Get all libraries - fmt.Println("πŸ“š Fetching all libraries...") + fmt.Println("[INFO] Fetching all libraries...") libraries, err := plexClient.GetAllLibraries() if err != nil { - fmt.Printf("❌ Error fetching libraries: %v\n", err) + fmt.Printf("[ERROR] Error fetching libraries: %v\n", err) os.Exit(1) } if len(libraries) == 0 { - fmt.Println("❌ No libraries found!") + fmt.Println("[ERROR] No libraries found!") os.Exit(1) } - fmt.Printf("βœ… Found %d libraries:\n", len(libraries)) + fmt.Printf("[OK] Found %d libraries:\n", len(libraries)) for _, lib := range libraries { - fmt.Printf(" πŸ“ ID: %s - %s (%s)\n", lib.Key, lib.Title, lib.Type) + fmt.Printf(" ID: %s - %s (%s)\n", lib.Key, lib.Title, lib.Type) } - // Separate libraries by type var movieLibraries []plex.Library var tvLibraries []plex.Library for _, lib := range libraries { @@ -111,213 +107,192 @@ func getLibraries(cfg *config.Config, plexClient *plex.Client) ([]plex.Library, } } - // Validate libraries exist if len(movieLibraries) == 0 && !cfg.ProcessTVShows() { - fmt.Println("❌ No movie library found!") + fmt.Println("[ERROR] No movie library found!") os.Exit(1) } if cfg.ProcessTVShows() && len(tvLibraries) == 0 { - fmt.Println("❌ No TV show library found!") + fmt.Println("[ERROR] No TV show library found!") os.Exit(1) } return movieLibraries, tvLibraries } -// displayLibrarySelection shows which libraries will be processed +// findLibraryName returns the library title for the given ID, or the fallback if not found. +func findLibraryName(libraries []plex.Library, id, fallback string) string { + for _, lib := range libraries { + if lib.Key == id { + return lib.Title + } + } + return fallback +} + +// forEachLibrary calls fn for the relevant libraries based on config (processAll vs specific ID). +func forEachLibrary(processAll bool, specificID string, libraries []plex.Library, fallbackName string, fn func(id, name string)) { + if processAll { + for _, lib := range libraries { + fn(lib.Key, lib.Title) + } + } else if specificID != "" { + fn(specificID, findLibraryName(libraries, specificID, fallbackName)) + } +} + func displayLibrarySelection(cfg *config.Config, movieLibraries, tvLibraries []plex.Library) { - // Movie library selection if cfg.ProcessMovies() { if cfg.MovieProcessAll { - fmt.Printf("🎯 Processing all %d movie libraries\n", len(movieLibraries)) + fmt.Printf("[INFO] Processing all %d movie libraries\n", len(movieLibraries)) } else if cfg.MovieLibraryID != "" { - found := false - for _, lib := range movieLibraries { - if lib.Key == cfg.MovieLibraryID { - fmt.Printf("\n🎯 Using specified movie library: %s (ID: %s)\n", lib.Title, lib.Key) - found = true - break - } - } - if !found { - fmt.Printf("❌ Movie library with ID %s not found!\n", cfg.MovieLibraryID) + name := findLibraryName(movieLibraries, cfg.MovieLibraryID, "") + if name == "" { + fmt.Printf("[ERROR] Movie library with ID %s not found!\n", cfg.MovieLibraryID) os.Exit(1) } + fmt.Printf("[INFO] Using specified movie library: %s (ID: %s)\n", name, cfg.MovieLibraryID) } } - // TV library selection if cfg.ProcessTVShows() { if cfg.TVProcessAll { - fmt.Printf("πŸ“Ί Processing all %d TV show libraries\n", len(tvLibraries)) + fmt.Printf("[INFO] Processing all %d TV show libraries\n", len(tvLibraries)) } else if cfg.TVLibraryID != "" { - found := false - for _, lib := range tvLibraries { - if lib.Key == cfg.TVLibraryID { - fmt.Printf("\nπŸ“Ί Using specified TV library: %s (ID: %s)\n", lib.Title, lib.Key) - found = true - break - } - } - if !found { - fmt.Printf("❌ TV library with ID %s not found!\n", cfg.TVLibraryID) + name := findLibraryName(tvLibraries, cfg.TVLibraryID, "") + if name == "" { + fmt.Printf("[ERROR] TV library with ID %s not found!\n", cfg.TVLibraryID) os.Exit(1) } + fmt.Printf("[INFO] Using specified TV library: %s (ID: %s)\n", name, cfg.TVLibraryID) } else { - fmt.Printf("\nπŸ“Ί Using TV library: %s (ID: %s)\n", tvLibraries[0].Title, tvLibraries[0].Key) + fmt.Printf("[INFO] Using TV library: %s (ID: %s)\n", tvLibraries[0].Title, tvLibraries[0].Key) } } } -// handleRemoveMode processes keyword removal and exits func handleRemoveMode(cfg *config.Config, processor *media.Processor, movieLibraries, tvLibraries []plex.Library) { - // Display selected libraries displayLibrarySelection(cfg, movieLibraries, tvLibraries) - fmt.Printf("\nπŸ—‘οΈ Starting keyword removal from (field: %s, lock: %s)...\n", cfg.UpdateField, cfg.RemoveMode) + fmt.Printf("\n[REMOVE] Starting keyword removal (field: %s, lock: %s)...\n", cfg.UpdateField, cfg.RemoveMode) if cfg.ProcessMovies() { - // Process movie libraries - if cfg.MovieProcessAll { - for _, lib := range movieLibraries { - fmt.Printf("🎬 Processing library: %s (ID: %s)\n", lib.Title, lib.Key) - if err := processor.RemoveKeywordsFromItems(lib.Key, media.MediaTypeMovie); err != nil { - fmt.Printf("❌ Error removing keywords from movies: %v\n", err) - } - } - } else if cfg.MovieLibraryID != "" { - if err := processor.RemoveKeywordsFromItems(cfg.MovieLibraryID, media.MediaTypeMovie); err != nil { - fmt.Printf("❌ Error removing keywords from movies: %v\n", err) + forEachLibrary(cfg.MovieProcessAll, cfg.MovieLibraryID, movieLibraries, "Movies", func(id, name string) { + fmt.Printf("[MOVIE] Removing keywords from library: %s (ID: %s)\n", name, id) + if err := processor.RemoveKeywordsFromItems(id, media.MediaTypeMovie); err != nil { + fmt.Printf("[ERROR] Error removing keywords from movies: %v\n", err) } - } + }) } - // Process TV libraries if cfg.ProcessTVShows() { - if cfg.TVProcessAll { - for _, lib := range tvLibraries { - fmt.Printf("πŸ“Ί Processing TV library: %s (ID: %s)\n", lib.Title, lib.Key) - if err := processor.RemoveKeywordsFromItems(lib.Key, media.MediaTypeTV); err != nil { - fmt.Printf("❌ Error removing keywords from TV shows: %v\n", err) - } - } - } else if cfg.TVLibraryID != "" { - if err := processor.RemoveKeywordsFromItems(cfg.TVLibraryID, media.MediaTypeTV); err != nil { - fmt.Printf("❌ Error removing keywords from TV shows: %v\n", err) + forEachLibrary(cfg.TVProcessAll, cfg.TVLibraryID, tvLibraries, "TV Shows", func(id, name string) { + fmt.Printf("[TV] Removing keywords from library: %s (ID: %s)\n", name, id) + if err := processor.RemoveKeywordsFromItems(id, media.MediaTypeTV); err != nil { + fmt.Printf("[ERROR] Error removing keywords from TV shows: %v\n", err) } - } + }) } - fmt.Println("\nβœ… Keyword removal completed. Exiting.") + fmt.Println("\n[OK] Keyword removal completed. Exiting.") } -// handleNormalMode runs the periodic processing func handleNormalMode(cfg *config.Config, processor *media.Processor, movieLibraries, tvLibraries []plex.Library) { displayLibrarySelection(cfg, movieLibraries, tvLibraries) - fmt.Printf("πŸ”„ Starting periodic processing interval: %v\n", cfg.ProcessTimer) + + var webhookServer *webhook.Server + if cfg.WebhookEnabled { + webhookServer = webhook.NewServer(cfg, processor, movieLibraries, tvLibraries) + if err := webhookServer.Start(); err != nil { + fmt.Printf("[ERROR] %v\n", err) + os.Exit(1) + } + fmt.Printf("[OK] Webhook server listening on port %d\n", cfg.WebhookPort) + } + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-sigCh + fmt.Printf("\n[INFO] Received %s, shutting down...\n", sig) + if webhookServer != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = webhookServer.Stop(ctx) + } + os.Exit(0) + }() + + fmt.Printf("[INFO] Starting periodic processing interval: %v\n", cfg.ProcessTimer) processFunc := func() { - // Process movie libraries + processor.ClearKeywordCache() + if len(movieLibraries) > 0 { - if cfg.MovieProcessAll { - for _, lib := range movieLibraries { - fmt.Printf("🎬 Processing library: %s (ID: %s)\n", lib.Title, lib.Key) - if err := processor.ProcessAllItems(lib.Key, lib.Title, media.MediaTypeMovie); err != nil { - fmt.Printf("❌ Error processing movies: %v\n", err) - } + forEachLibrary(cfg.MovieProcessAll, cfg.MovieLibraryID, movieLibraries, "Movies", func(id, name string) { + fmt.Printf("[MOVIE] Processing library: %s (ID: %s)\n", name, id) + if err := processor.ProcessAllItems(id, name, media.MediaTypeMovie); err != nil { + fmt.Printf("[ERROR] Error processing movies: %v\n", err) } - } else if cfg.MovieLibraryID != "" { - // Find the library name for the specified ID - libraryName := "Movies" // Default fallback - for _, lib := range movieLibraries { - if lib.Key == cfg.MovieLibraryID { - libraryName = lib.Title - break - } - } - if err := processor.ProcessAllItems(cfg.MovieLibraryID, libraryName, media.MediaTypeMovie); err != nil { - fmt.Printf("❌ Error processing movies: %v\n", err) - } - } + }) } - // Process TV libraries if cfg.ProcessTVShows() { - if cfg.TVProcessAll { - for _, lib := range tvLibraries { - fmt.Printf("πŸ“Ί Processing TV library: %s (ID: %s)\n", lib.Title, lib.Key) - if err := processor.ProcessAllItems(lib.Key, lib.Title, media.MediaTypeTV); err != nil { - fmt.Printf("❌ Error processing TV shows: %v\n", err) - } + forEachLibrary(cfg.TVProcessAll, cfg.TVLibraryID, tvLibraries, "TV Shows", func(id, name string) { + fmt.Printf("[TV] Processing TV library: %s (ID: %s)\n", name, id) + if err := processor.ProcessAllItems(id, name, media.MediaTypeTV); err != nil { + fmt.Printf("[ERROR] Error processing TV shows: %v\n", err) } - } else if cfg.TVLibraryID != "" { - // Find the library name for the specified ID - libraryName := "TV Shows" // Default fallback - for _, lib := range tvLibraries { - if lib.Key == cfg.TVLibraryID { - libraryName = lib.Title - break - } - } - if err := processor.ProcessAllItems(cfg.TVLibraryID, libraryName, media.MediaTypeTV); err != nil { - fmt.Printf("❌ Error processing TV shows: %v\n", err) - } - } + }) } - // Write all accumulated export files after processing all libraries if cfg.HasExportEnabled() { - fmt.Printf("\nπŸ“€ Writing export files to %s...\n", cfg.ExportLocation) - if exporter := processor.GetExporter(); exporter != nil { - totalSummary, err := exporter.GetExportSummary() - if err != nil { - fmt.Printf("❌ Error getting export summary: %v\n", err) - } else { - totalAccumulated := 0 - for label, count := range totalSummary { - if count > 0 { - fmt.Printf(" πŸ“ %s: %d total file paths\n", label, count) - } - totalAccumulated += count - } - - if totalAccumulated > 0 { - fmt.Printf("πŸ“ Writing %d total file paths across all libraries...\n", totalAccumulated) - if err := exporter.FlushAll(); err != nil { - fmt.Printf("❌ Failed to write export files: %v\n", err) - } else { - if cfg.ExportMode == "json" { - fmt.Printf("βœ… Successfully wrote export data to export.json\n") - } else { - fmt.Printf("βœ… Successfully wrote export files to library subdirectories\n") - fmt.Printf("πŸ“Š Generated summary.txt with detailed statistics and file sizes\n") - } - } - } else { - fmt.Printf("πŸ“­ No matching items found for export labels\n") - // Still create empty files for each label in each library - if err := exporter.FlushAll(); err != nil { - fmt.Printf("❌ Failed to create export files: %v\n", err) - } else { - if cfg.ExportMode == "json" { - fmt.Printf("βœ… Created empty export.json file\n") - } else { - fmt.Printf("βœ… Created empty export files in library subdirectories\n") - fmt.Printf("πŸ“Š Generated summary.txt with export statistics\n") - } - } - } - } - } + writeExportFiles(cfg, processor) } } - // Process immediately on start processFunc() - // Set up timer for periodic processing ticker := time.NewTicker(cfg.ProcessTimer) defer ticker.Stop() for range ticker.C { - fmt.Printf("\n⏰ Timer triggered - processing at %s\n", time.Now().Format("15:04:05")) + fmt.Printf("\n[TIMER] Timer triggered - processing at %s\n", time.Now().Format("15:04:05")) processFunc() } } + +func writeExportFiles(cfg *config.Config, processor *media.Processor) { + fmt.Printf("\n[EXPORT] Writing export files to %s...\n", cfg.ExportLocation) + exporter := processor.GetExporter() + if exporter == nil { + return + } + + totalSummary, err := exporter.GetExportSummary() + if err != nil { + fmt.Printf("[ERROR] Error getting export summary: %v\n", err) + return + } + + totalAccumulated := 0 + for label, count := range totalSummary { + if count > 0 { + fmt.Printf(" %s: %d total file paths\n", label, count) + } + totalAccumulated += count + } + + if totalAccumulated > 0 { + fmt.Printf("[INFO] Writing %d total file paths across all libraries...\n", totalAccumulated) + } else { + fmt.Printf("[INFO] No matching items found for export labels\n") + } + + if err := exporter.FlushAll(); err != nil { + fmt.Printf("[ERROR] Failed to write export files: %v\n", err) + return + } + + if cfg.ExportMode == "json" { + fmt.Printf("[OK] Successfully wrote export data to export.json\n") + } else { + fmt.Printf("[OK] Successfully wrote export files to library subdirectories\n") + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 3c8f3c7..8471d34 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,4 +36,20 @@ services: # Sonarr integration (optional) - USE_SONARR=false # Set to true to enable - SONARR_URL=http://localhost:8989 # Your Sonarr URL - - SONARR_API_KEY=your_sonarr_api_key # Your Sonarr API key \ No newline at end of file + - SONARR_API_KEY=your_sonarr_api_key # Your Sonarr API key + + # Batch processing (optional, for large libraries) + # - BATCH_SIZE=100 + # - BATCH_DELAY=10s + # - ITEM_DELAY=500ms + + # Keyword prefix (optional, useful with UPDATE_FIELD=genre) + # - KEYWORD_PREFIX=- + + # Webhook support (optional, for real-time processing) + # - WEBHOOK_ENABLED=true + # - WEBHOOK_PORT=9090 + # - WEBHOOK_DEBOUNCE=30s + # Uncomment if using webhooks: + # ports: + # - "9090:9090" \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index d57368c..5a4199e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,6 +42,19 @@ type Config struct { // Force update configuration ForceUpdate bool + // Webhook configuration + WebhookEnabled bool + WebhookPort int + WebhookDebounce time.Duration + + // Keyword prefix configuration + KeywordPrefix string + + // Batch processing configuration + BatchSize int + BatchDelay time.Duration + ItemDelay time.Duration + // Export configuration ExportLabels []string ExportLocation string @@ -61,7 +74,7 @@ func Load() *Config { UpdateField: getEnvWithDefault("UPDATE_FIELD", "label"), RemoveMode: os.Getenv("REMOVE"), TMDbReadAccessToken: os.Getenv("TMDB_READ_ACCESS_TOKEN"), - ProcessTimer: getProcessTimerFromEnv(), + ProcessTimer: getDurationEnvWithDefault("PROCESS_TIMER", "1h"), // Radarr configuration RadarrURL: os.Getenv("RADARR_URL"), @@ -82,6 +95,19 @@ func Load() *Config { // Force update configuration ForceUpdate: getBoolEnvWithDefault("FORCE_UPDATE", false), + // Webhook configuration + WebhookEnabled: getBoolEnvWithDefault("WEBHOOK_ENABLED", false), + WebhookPort: getIntEnvWithDefault("WEBHOOK_PORT", 9090), + WebhookDebounce: getDurationEnvWithDefault("WEBHOOK_DEBOUNCE", "30s"), + + // Keyword prefix configuration + KeywordPrefix: os.Getenv("KEYWORD_PREFIX"), + + // Batch processing configuration + BatchSize: getIntEnvWithDefault("BATCH_SIZE", 100), + BatchDelay: getDurationEnvWithDefault("BATCH_DELAY", "10s"), + ItemDelay: getDurationEnvWithDefault("ITEM_DELAY", "500ms"), + // Export configuration ExportLabels: parseExportLabels(os.Getenv("EXPORT_LABELS")), ExportLocation: os.Getenv("EXPORT_LOCATION"), @@ -137,6 +163,13 @@ func (c *Config) Validate() error { return fmt.Errorf("EXPORT_MODE must be 'txt' or 'json'") } + if c.WebhookEnabled && (c.WebhookPort < 1 || c.WebhookPort > 65535) { + return fmt.Errorf("WEBHOOK_PORT must be between 1 and 65535") + } + if c.BatchSize < 1 { + return fmt.Errorf("BATCH_SIZE must be at least 1") + } + // Validate Radarr configuration if enabled if c.UseRadarr { if c.RadarrURL == "" { @@ -167,27 +200,40 @@ func getEnvWithDefault(envVar, defaultValue string) string { return defaultValue } -func getProcessTimerFromEnv() time.Duration { - timerStr := getEnvWithDefault("PROCESS_TIMER", "1h") - timer, err := time.ParseDuration(timerStr) +func getBoolEnvWithDefault(envVar string, defaultValue bool) bool { + value := os.Getenv(envVar) + if value == "" { + return defaultValue + } + result, err := strconv.ParseBool(value) if err != nil { - return 5 * time.Minute + return defaultValue } - return timer + return result } -func getBoolEnvWithDefault(envVar string, defaultValue bool) bool { +func getIntEnvWithDefault(envVar string, defaultValue int) int { value := os.Getenv(envVar) if value == "" { return defaultValue } - result, err := strconv.ParseBool(value) + result, err := strconv.Atoi(value) if err != nil { return defaultValue } return result } +func getDurationEnvWithDefault(envVar string, defaultValue string) time.Duration { + value := getEnvWithDefault(envVar, defaultValue) + duration, err := time.ParseDuration(value) + if err != nil { + fallback, _ := time.ParseDuration(defaultValue) + return fallback + } + return duration +} + func parseExportLabels(labels string) []string { if labels == "" { return nil diff --git a/internal/export/export.go b/internal/export/export.go index abc75c6..c7beb40 100644 --- a/internal/export/export.go +++ b/internal/export/export.go @@ -319,7 +319,7 @@ func (e *Exporter) writeSummary() error { } // Write export file list - fmt.Fprintf(file, "πŸ“ Export Files Generated:\n") + fmt.Fprintf(file, "[STORAGE] Export Files Generated:\n") for libraryName := range libraryStats { for _, label := range e.exportLabels { if stats, exists := libraryStats[libraryName][label]; exists && stats.Count > 0 { @@ -330,13 +330,13 @@ func (e *Exporter) writeSummary() error { fmt.Fprintf(file, "\n") // Write totals - fmt.Fprintf(file, "πŸ“Š Overall Statistics:\n") + fmt.Fprintf(file, "[STATS] Overall Statistics:\n") fmt.Fprintf(file, " Total files: %d\n", totalFiles) fmt.Fprintf(file, " Total size: %s (%d bytes)\n", formatFileSize(totalSize), totalSize) fmt.Fprintf(file, "\n") // Write per-library breakdown - fmt.Fprintf(file, "πŸ“š Library Breakdown:\n") + fmt.Fprintf(file, "[INFO] Library Breakdown:\n") for libraryName, labelStats := range libraryStats { fmt.Fprintf(file, "\n %s:\n", libraryName) @@ -363,7 +363,7 @@ func (e *Exporter) writeSummary() error { } // Write per-label totals across all libraries - fmt.Fprintf(file, "\n🏷️ Label Totals (All Libraries):\n") + fmt.Fprintf(file, "\n[LABEL] Label Totals (All Libraries):\n") labelTotals := make(map[string]struct { Count int Size int64 diff --git a/internal/media/processor.go b/internal/media/processor.go index 4d9027a..ee6935e 100644 --- a/internal/media/processor.go +++ b/internal/media/processor.go @@ -4,6 +4,7 @@ import ( "fmt" "regexp" "strings" + "sync" "time" "github.com/nullable-eth/labelarr/internal/config" @@ -20,11 +21,18 @@ import ( type MediaType string const ( - MediaTypeMovie MediaType = "movie" - MediaTypeTV MediaType = "tv" + MediaTypeMovie MediaType = "movie" + MediaTypeTV MediaType = "tv" + MediaTypeUnknown MediaType = "" ) -// ProcessedItem is now imported from storage package +// Clients groups external API clients for the processor. +type Clients struct { + Plex *plex.Client + TMDb *tmdb.Client + Radarr *radarr.Client + Sonarr *sonarr.Client +} // MediaItem interface for common media operations type MediaItem interface { @@ -46,10 +54,18 @@ type Processor struct { sonarrClient *sonarr.Client storage *storage.Storage exporter *export.Exporter + keywordCache map[string][]string + cacheMu sync.RWMutex + processingMu sync.Mutex + processing map[string]bool } // NewProcessor creates a new generic media processor -func NewProcessor(cfg *config.Config, plexClient *plex.Client, tmdbClient *tmdb.Client, radarrClient *radarr.Client, sonarrClient *sonarr.Client) (*Processor, error) { +func NewProcessor(cfg *config.Config, clients Clients) (*Processor, error) { + plexClient := clients.Plex + tmdbClient := clients.TMDb + radarrClient := clients.Radarr + sonarrClient := clients.Sonarr // Initialize persistent storage only if DATA_DIR is set var stor *storage.Storage if cfg.DataDir != "" { @@ -67,6 +83,8 @@ func NewProcessor(cfg *config.Config, plexClient *plex.Client, tmdbClient *tmdb. radarrClient: radarrClient, sonarrClient: sonarrClient, storage: stor, + keywordCache: make(map[string][]string), + processing: make(map[string]bool), } // Initialize exporter if export is enabled @@ -77,17 +95,17 @@ func NewProcessor(cfg *config.Config, plexClient *plex.Client, tmdbClient *tmdb. } processor.exporter = exporter - fmt.Printf("πŸ“€ Export enabled: Writing file paths for labels %v to %s\n", cfg.ExportLabels, cfg.ExportLocation) + fmt.Printf("[EXPORT] Export enabled: Writing file paths for labels %v to %s\n", cfg.ExportLabels, cfg.ExportLocation) } // Log storage initialization if stor != nil { count := stor.Count() if count > 0 { - fmt.Printf("πŸ“ Loaded %d previously processed items from storage\n", count) + fmt.Printf("[STORAGE] Loaded %d previously processed items from storage\n", count) } } else { - fmt.Printf("πŸ”„ Running in ephemeral mode - no persistent storage (set DATA_DIR to enable)\n") + fmt.Printf("[SYNC] Running in ephemeral mode - no persistent storage (set DATA_DIR to enable)\n") } return processor, nil @@ -98,26 +116,105 @@ func (p *Processor) GetExporter() *export.Exporter { return p.exporter } +type batch struct { + items []MediaItem + num int // 0-indexed batch number + total int // total number of batches + startIdx int // 1-indexed start position in overall list + endIdx int // end position in overall list +} + +// makeBatches splits items into batches based on config.BatchSize. +func (p *Processor) makeBatches(items []MediaItem) []batch { + batchSize := p.config.BatchSize + total := (len(items) + batchSize - 1) / batchSize + batches := make([]batch, 0, total) + for i := 0; i < total; i++ { + start := i * batchSize + end := start + batchSize + if end > len(items) { + end = len(items) + } + batches = append(batches, batch{ + items: items[start:end], + num: i, + total: total, + startIdx: start + 1, + endIdx: end, + }) + } + return batches +} + +// logBatchStart prints a batch start message if there are multiple batches. +func (b batch) logStart(label string, totalItems int) { + if b.total > 1 { + fmt.Printf("%s batch %d/%d (items %d-%d of %d)\n", + label, b.num+1, b.total, b.startIdx, b.endIdx, totalItems) + } +} + +// pauseAfter sleeps between batches (not after the last one). +func (p *Processor) pauseAfterBatch(b batch, label string) { + if b.total > 1 && b.num < b.total-1 { + fmt.Printf("%s batch %d complete. Pausing %v before next batch...\n", + label, b.num+1, p.config.BatchDelay) + time.Sleep(p.config.BatchDelay) + } +} + +// ClearKeywordCache resets the TMDb keyword cache. Call at the start of each +// processing cycle so keywords are refreshed from TMDb periodically. +func (p *Processor) ClearKeywordCache() { + p.cacheMu.Lock() + p.keywordCache = make(map[string][]string) + p.cacheMu.Unlock() +} + +func (p *Processor) applyKeywordPrefix(keywords []string) []string { + if p.config.KeywordPrefix == "" { + return keywords + } + prefixed := make([]string, len(keywords)) + for i, kw := range keywords { + prefixed[i] = p.config.KeywordPrefix + kw + } + return prefixed +} + // ProcessAllItems processes all items in the specified library func (p *Processor) ProcessAllItems(libraryID string, libraryName string, mediaType MediaType) error { + p.processingMu.Lock() + if p.processing[libraryID] { + p.processingMu.Unlock() + fmt.Printf("[INFO] Library %s is already being processed, skipping\n", libraryName) + return nil + } + p.processing[libraryID] = true + p.processingMu.Unlock() + defer func() { + p.processingMu.Lock() + delete(p.processing, libraryID) + p.processingMu.Unlock() + }() + var displayName, emoji string switch mediaType { case MediaTypeMovie: displayName = "movies" - emoji = "🎬" + emoji = "[MOVIE]" case MediaTypeTV: displayName = "tv shows" - emoji = "πŸ“Ί" + emoji = "[TV]" default: return fmt.Errorf("unsupported media type: %s", mediaType) } - fmt.Printf("πŸ“‹ Fetching all %s from library...\n", displayName) + fmt.Printf("[INFO] Fetching all %s from library...\n", displayName) - // Set current library in exporter if export is enabled if p.exporter != nil { if err := p.exporter.SetCurrentLibrary(libraryName); err != nil { - fmt.Printf("⚠️ Warning: Failed to set current library for export: %v\n", err) + fmt.Printf("[WARN] Warning: Failed to set current library for export: %v\n", err) } } @@ -127,21 +224,21 @@ func (p *Processor) ProcessAllItems(libraryID string, libraryName string, mediaT } if len(items) == 0 { - fmt.Printf("❌ No %s found in library!\n", displayName) + fmt.Printf("[ERROR] No %s found in library!\n", displayName) return nil } totalCount := len(items) - fmt.Printf("βœ… Found %d %s in library\n", totalCount, displayName) + fmt.Printf("[OK] Found %d %s in library\n", totalCount, displayName) if p.config.ForceUpdate { - fmt.Printf("πŸ”„ FORCE UPDATE MODE: All items will be reprocessed regardless of previous processing\n") + fmt.Printf("[SYNC] FORCE UPDATE MODE: All items will be reprocessed regardless of previous processing\n") } if p.config.VerboseLogging { - fmt.Printf("πŸ”Ž Starting detailed processing with verbose logging enabled...\n") + fmt.Printf("[DEBUG] Starting detailed processing with verbose logging enabled...\n") } else { - fmt.Printf("⏳ Processing %s... (enable VERBOSE_LOGGING=true for detailed lookup information)\n", displayName) + fmt.Printf("[WAIT] Processing %s... (enable VERBOSE_LOGGING=true for detailed lookup information)\n", displayName) } newItems := 0 @@ -153,36 +250,58 @@ func (p *Processor) ProcessAllItems(libraryID string, libraryName string, mediaT processedCount := 0 lastProgressReport := 0 - for _, item := range items { - processedCount++ + for _, b := range p.makeBatches(items) { + b.logStart(emoji+" Processing", len(items)) + + for _, item := range b.items { + processedCount++ - // Show progress for large libraries - if totalCount > 100 { - progress := (processedCount * 100) / totalCount - if progress >= lastProgressReport+10 { - fmt.Printf("πŸ“Š Progress: %d%% (%d/%d %s processed)\n", progress, processedCount, totalCount, displayName) - lastProgressReport = progress + if totalCount > 100 { + progress := (processedCount * 100) / totalCount + if progress >= lastProgressReport+10 { + fmt.Printf("[STATS] Progress: %d%% (%d/%d %s processed)\n", progress, processedCount, totalCount, displayName) + lastProgressReport = progress + } } - } - // Check if already processed (only if storage is enabled) - var exists bool - if p.storage != nil { - processed, storageExists := p.storage.Get(item.GetRatingKey()) - if storageExists && processed.KeywordsSynced && processed.UpdateField == p.config.UpdateField && !p.config.ForceUpdate { - // Still try to export if export is enabled, even if already processed + var exists bool + if p.storage != nil { + processed, storageExists := p.storage.Get(item.GetRatingKey()) + if storageExists && processed.KeywordsSynced && processed.UpdateField == p.config.UpdateField && !p.config.ForceUpdate { + if p.exporter != nil { + details, err := p.getItemDetails(item.GetRatingKey(), mediaType) + if err == nil { + currentLabels := p.extractCurrentValues(details) + + fileInfos, err := p.extractFileInfos(details, mediaType) + if err == nil && len(fileInfos) > 0 { + if err := p.exporter.ExportItemWithSizes(item.GetTitle(), currentLabels, fileInfos); err == nil { + if p.config.VerboseLogging { + fmt.Printf(" [EXPORT] Accumulated %d file paths for %s (already processed)\n", len(fileInfos), item.GetTitle()) + } + } + } + } + } + + skippedItems++ + skippedAlreadyExist++ + continue + } + exists = storageExists + } + + tmdbID := p.extractTMDbID(item, mediaType) + if tmdbID == "" { if p.exporter != nil { details, err := p.getItemDetails(item.GetRatingKey(), mediaType) if err == nil { - // Extract current labels for export currentLabels := p.extractCurrentValues(details) - // Extract file paths and sizes fileInfos, err := p.extractFileInfos(details, mediaType) if err == nil && len(fileInfos) > 0 { - // Accumulate the item for export if err := p.exporter.ExportItemWithSizes(item.GetTitle(), currentLabels, fileInfos); err == nil { if p.config.VerboseLogging { - fmt.Printf(" πŸ“€ Accumulated %d file paths for %s (already processed)\n", len(fileInfos), item.GetTitle()) + fmt.Printf(" [EXPORT] Accumulated %d file paths for %s (no TMDb ID)\n", len(fileInfos), item.GetTitle()) } } } @@ -190,252 +309,203 @@ func (p *Processor) ProcessAllItems(libraryID string, libraryName string, mediaT } skippedItems++ - skippedAlreadyExist++ + if p.config.VerboseLogging && skippedItems <= 10 { + fmt.Printf(" [SKIP] Skipped %s: %s (%d) - No TMDb ID found\n", strings.TrimSuffix(displayName, "s"), item.GetTitle(), item.GetYear()) + } continue } - exists = storageExists - } - - // Silently check if we need to process this item - tmdbID := p.extractTMDbID(item, mediaType) - if tmdbID == "" { - // Still try to export if export is enabled, even without TMDb ID - if p.exporter != nil { - details, err := p.getItemDetails(item.GetRatingKey(), mediaType) - if err == nil { - // Extract current labels for export - currentLabels := p.extractCurrentValues(details) - // Extract file paths and sizes - fileInfos, err := p.extractFileInfos(details, mediaType) - if err == nil && len(fileInfos) > 0 { - // Accumulate the item for export - if err := p.exporter.ExportItemWithSizes(item.GetTitle(), currentLabels, fileInfos); err == nil { - if p.config.VerboseLogging { - fmt.Printf(" πŸ“€ Accumulated %d file paths for %s (no TMDb ID)\n", len(fileInfos), item.GetTitle()) - } - } - } + keywords, err := p.getKeywords(tmdbID, mediaType) + if err != nil { + if p.config.VerboseLogging { + fmt.Printf(" [ERROR] Error fetching keywords for TMDb ID %s: %v\n", tmdbID, err) } + skippedItems++ + continue } - skippedItems++ - if p.config.VerboseLogging && skippedItems <= 10 { - fmt.Printf(" ⏭️ Skipped %s: %s (%d) - No TMDb ID found\n", strings.TrimSuffix(displayName, "s"), item.GetTitle(), item.GetYear()) - } - continue - } - - // Silently fetch keywords and details to check if processing is needed - keywords, err := p.getKeywords(tmdbID, mediaType) - if err != nil { if p.config.VerboseLogging { - fmt.Printf(" ❌ Error fetching keywords for TMDb ID %s: %v\n", tmdbID, err) + fmt.Printf(" [FETCH] Fetched %d keywords from TMDb: %v\n", len(keywords), keywords) } - skippedItems++ - continue - } - if p.config.VerboseLogging { - fmt.Printf(" πŸ“₯ Fetched %d keywords from TMDb: %v\n", len(keywords), keywords) - } + keywords = p.applyKeywordPrefix(keywords) - details, err := p.getItemDetails(item.GetRatingKey(), mediaType) - if err != nil { - if p.config.VerboseLogging { - fmt.Printf(" ❌ Error fetching item details: %v\n", err) + details, err := p.getItemDetails(item.GetRatingKey(), mediaType) + if err != nil { + if p.config.VerboseLogging { + fmt.Printf(" [ERROR] Error fetching item details: %v\n", err) + } + skippedItems++ + continue } - skippedItems++ - continue - } - - currentValues := p.extractCurrentValues(details) - if p.config.VerboseLogging { - fmt.Printf(" πŸ“‹ Current %ss in Plex: %v\n", p.config.UpdateField, currentValues) - } - currentValuesMap := make(map[string]bool) - for _, val := range currentValues { - currentValuesMap[strings.ToLower(val)] = true - } + currentValues := p.extractCurrentValues(details) + if p.config.VerboseLogging { + fmt.Printf(" [INFO] Current %ss in Plex: %v\n", p.config.UpdateField, currentValues) + } - allKeywordsExist := true - var missingKeywords []string - for _, keyword := range keywords { - if !currentValuesMap[strings.ToLower(keyword)] { - allKeywordsExist = false - missingKeywords = append(missingKeywords, keyword) + currentValuesMap := make(map[string]bool) + for _, val := range currentValues { + currentValuesMap[strings.ToLower(val)] = true } - } - if allKeywordsExist && !p.config.ForceUpdate { - // Silently skip - no verbose output - if p.config.VerboseLogging { - fmt.Printf(" ✨ Already has all keywords, skipping\n") + allKeywordsExist := true + var missingKeywords []string + for _, keyword := range keywords { + if !currentValuesMap[strings.ToLower(keyword)] { + allKeywordsExist = false + missingKeywords = append(missingKeywords, keyword) + } } - // Still export if export is enabled, even if no keyword updates are needed - if p.exporter != nil { - // Extract current labels for export - currentLabels := p.extractCurrentValues(details) + if allKeywordsExist && !p.config.ForceUpdate { + // Silently skip - no verbose output + if p.config.VerboseLogging { + fmt.Printf(" [OK] Already has all keywords, skipping\n") + } - // Extract file paths and sizes - fileInfos, err := p.extractFileInfos(details, mediaType) - if err != nil { - if p.config.VerboseLogging { - fmt.Printf(" ⚠️ Warning: Could not extract file paths for export: %v\n", err) - } - } else if len(fileInfos) > 0 { - // Accumulate the item for export - if err := p.exporter.ExportItemWithSizes(item.GetTitle(), currentLabels, fileInfos); err != nil { + // Still export if export is enabled, even if no keyword updates are needed + if p.exporter != nil { + currentLabels := p.extractCurrentValues(details) + + fileInfos, err := p.extractFileInfos(details, mediaType) + if err != nil { if p.config.VerboseLogging { - fmt.Printf(" ⚠️ Warning: Export accumulation failed for %s: %v\n", item.GetTitle(), err) + fmt.Printf(" [WARN] Warning: Could not extract file paths for export: %v\n", err) + } + } else if len(fileInfos) > 0 { + if err := p.exporter.ExportItemWithSizes(item.GetTitle(), currentLabels, fileInfos); err != nil { + if p.config.VerboseLogging { + fmt.Printf(" [WARN] Warning: Export accumulation failed for %s: %v\n", item.GetTitle(), err) + } + } else if p.config.VerboseLogging { + fmt.Printf(" [EXPORT] Accumulated %d file paths for %s (already had keywords)\n", len(fileInfos), item.GetTitle()) } - } else if p.config.VerboseLogging { - fmt.Printf(" πŸ“€ Accumulated %d file paths for %s (already had keywords)\n", len(fileInfos), item.GetTitle()) } } + + skippedItems++ + skippedAlreadyExist++ + continue } - skippedItems++ - skippedAlreadyExist++ - continue - } + if p.config.ForceUpdate && allKeywordsExist { + if p.config.VerboseLogging { + fmt.Printf(" [SYNC] Force update enabled - reprocessing item with existing keywords\n") + } + } - if p.config.ForceUpdate && allKeywordsExist { if p.config.VerboseLogging { - fmt.Printf(" πŸ”„ Force update enabled - reprocessing item with existing keywords\n") + fmt.Printf(" [NEW] Missing keywords to add: %v\n", missingKeywords) } - } - if p.config.VerboseLogging { - fmt.Printf(" πŸ†• Missing keywords to add: %v\n", missingKeywords) - } - - // Only show verbose output for completely new items (never processed before) - if !exists { - fmt.Printf("\n%s Processing new %s: %s (%d)\n", emoji, strings.TrimSuffix(displayName, "s"), item.GetTitle(), item.GetYear()) - - // Show source of TMDb ID - source := p.getTMDbIDSource(item, mediaType, tmdbID) - fmt.Printf("πŸ”‘ TMDb ID: %s (source: %s)\n", tmdbID, source) - fmt.Printf("🏷️ Found %d TMDb keywords\n", len(keywords)) - } + if !exists { + fmt.Printf("\n%s Processing new %s: %s (%d)\n", emoji, strings.TrimSuffix(displayName, "s"), item.GetTitle(), item.GetYear()) - // Show when we're about to apply labels/genres - if p.config.VerboseLogging || !exists { - fmt.Printf("πŸ”„ Applying %d keywords to %s field...\n", len(keywords), p.config.UpdateField) - if p.config.VerboseLogging { - fmt.Printf(" Current %ss: %v\n", p.config.UpdateField, currentValues) - fmt.Printf(" New keywords to add: %v\n", keywords) + // Show source of TMDb ID + source := p.getTMDbIDSource(item, mediaType, tmdbID) + fmt.Printf("[KEY] TMDb ID: %s (source: %s)\n", tmdbID, source) + fmt.Printf("[LABEL] Found %d TMDb keywords\n", len(keywords)) } - } - err = p.syncFieldWithKeywords(item.GetRatingKey(), libraryID, currentValues, keywords, mediaType) - if err != nil { - // Show error even for existing items since it's important - if exists { - fmt.Printf("❌ Error syncing %s for %s: %v\n", p.config.UpdateField, item.GetTitle(), err) + if p.config.VerboseLogging || !exists { + fmt.Printf("[SYNC] Applying %d keywords to %s field...\n", len(keywords), p.config.UpdateField) + if p.config.VerboseLogging { + fmt.Printf(" Current %ss: %v\n", p.config.UpdateField, currentValues) + fmt.Printf(" New keywords to add: %v\n", keywords) + } } - skippedItems++ - continue - } - - // Show success message when labels/genres are applied - if p.config.VerboseLogging || !exists { - fmt.Printf("βœ… Successfully applied %d keywords to Plex %s field\n", len(keywords), p.config.UpdateField) - } - // Export file paths if export is enabled - if p.exporter != nil { - // Get updated item details to get current labels - updatedDetails, err := p.getItemDetails(item.GetRatingKey(), mediaType) + err = p.syncFieldWithKeywords(item.GetRatingKey(), libraryID, currentValues, keywords, mediaType) if err != nil { - if p.config.VerboseLogging { - fmt.Printf(" ⚠️ Warning: Could not get updated details for export: %v\n", err) + // Show error even for existing items since it's important + if exists { + fmt.Printf("[ERROR] Error syncing %s for %s: %v\n", p.config.UpdateField, item.GetTitle(), err) } - } else { - // Extract current labels for export - currentLabels := p.extractCurrentValues(updatedDetails) + skippedItems++ + continue + } + + if p.config.VerboseLogging || !exists { + fmt.Printf("[OK] Successfully applied %d keywords to Plex %s field\n", len(keywords), p.config.UpdateField) + } - // Extract file paths and sizes - fileInfos, err := p.extractFileInfos(updatedDetails, mediaType) + if p.exporter != nil { + mergedLabels := append(currentValues, keywords...) + fileInfos, err := p.extractFileInfos(details, mediaType) if err != nil { if p.config.VerboseLogging { - fmt.Printf(" ⚠️ Warning: Could not extract file paths for export: %v\n", err) + fmt.Printf(" [WARN] Could not extract file paths for export: %v\n", err) } } else if len(fileInfos) > 0 { - // Accumulate the item for export - if err := p.exporter.ExportItemWithSizes(item.GetTitle(), currentLabels, fileInfos); err != nil { + if err := p.exporter.ExportItemWithSizes(item.GetTitle(), mergedLabels, fileInfos); err != nil { if p.config.VerboseLogging { - fmt.Printf(" ⚠️ Warning: Export accumulation failed for %s: %v\n", item.GetTitle(), err) + fmt.Printf(" [WARN] Export accumulation failed for %s: %v\n", item.GetTitle(), err) } } else if p.config.VerboseLogging { - fmt.Printf(" πŸ“€ Accumulated %d file paths for %s\n", len(fileInfos), item.GetTitle()) + fmt.Printf(" [EXPORT] Accumulated %d file paths for %s\n", len(fileInfos), item.GetTitle()) } } } - } - // Save processed item (only if storage is enabled) - if p.storage != nil { - processedItem := &storage.ProcessedItem{ - RatingKey: item.GetRatingKey(), - Title: item.GetTitle(), - TMDbID: tmdbID, - LastProcessed: time.Now(), - KeywordsSynced: true, - UpdateField: p.config.UpdateField, + if p.storage != nil { + processedItem := &storage.ProcessedItem{ + RatingKey: item.GetRatingKey(), + Title: item.GetTitle(), + TMDbID: tmdbID, + LastProcessed: time.Now(), + KeywordsSynced: true, + UpdateField: p.config.UpdateField, + } + + if err := p.storage.Set(processedItem); err != nil { + fmt.Printf("[WARN] Warning: Failed to save processed item to storage: %v\n", err) + } } - if err := p.storage.Set(processedItem); err != nil { - fmt.Printf("⚠️ Warning: Failed to save processed item to storage: %v\n", err) + if exists { + updatedItems++ + } else { + newItems++ + fmt.Printf("[OK] Successfully processed new %s: %s\n", strings.TrimSuffix(displayName, "s"), item.GetTitle()) } - } - if exists { - updatedItems++ - } else { - newItems++ - fmt.Printf("βœ… Successfully processed new %s: %s\n", strings.TrimSuffix(displayName, "s"), item.GetTitle()) + time.Sleep(p.config.ItemDelay) } - time.Sleep(500 * time.Millisecond) + p.pauseAfterBatch(b, emoji+" Processing") } - // Show verbose summary if items were skipped if p.config.VerboseLogging && skippedItems > 10 { fmt.Printf(" ... and %d more items skipped\n", skippedItems-10) } - fmt.Printf("\nπŸ“Š Processing Summary:\n") - fmt.Printf(" πŸ“ˆ Total %s in library: %d\n", displayName, totalCount) - fmt.Printf(" πŸ†• New %s processed: %d\n", displayName, newItems) - fmt.Printf(" πŸ”„ Updated %s: %d\n", displayName, updatedItems) - fmt.Printf(" ⏭️ Skipped %s: %d\n", displayName, skippedItems) + fmt.Printf("\n[STATS] Processing Summary:\n") + fmt.Printf(" [TOTAL] Total %s in library: %d\n", displayName, totalCount) + fmt.Printf(" [NEW] New %s processed: %d\n", displayName, newItems) + fmt.Printf(" [SYNC] Updated %s: %d\n", displayName, updatedItems) + fmt.Printf(" [SKIP] Skipped %s: %d\n", displayName, skippedItems) if skippedAlreadyExist > 0 { - fmt.Printf(" ✨ Already have all keywords: %d\n", skippedAlreadyExist) + fmt.Printf(" [OK] Already have all keywords: %d\n", skippedAlreadyExist) } - // Show export summary if export is enabled if p.exporter != nil { librarySummary, err := p.exporter.GetLibraryExportSummary() if err != nil { - fmt.Printf(" ⚠️ Export summary error: %v\n", err) + fmt.Printf(" [WARN] Export summary error: %v\n", err) } else { - fmt.Printf("\nπŸ“€ Export Summary for %s:\n", libraryName) + fmt.Printf("\n[EXPORT] Export Summary for %s:\n", libraryName) totalAccumulated := 0 - // Show current library summary currentLibrary := p.exporter.GetCurrentLibrary() if librarySummary[currentLibrary] != nil { for label, count := range librarySummary[currentLibrary] { - fmt.Printf(" πŸ“ %s: %d file paths accumulated\n", label, count) + fmt.Printf(" [STORAGE] %s: %d file paths accumulated\n", label, count) totalAccumulated += count } } - fmt.Printf("πŸ“Š Total accumulated in this library: %d file paths\n", totalAccumulated) + fmt.Printf("[STATS] Total accumulated in this library: %d file paths\n", totalAccumulated) } } @@ -448,15 +518,15 @@ func (p *Processor) RemoveKeywordsFromItems(libraryID string, mediaType MediaTyp switch mediaType { case MediaTypeMovie: displayName = "movies" - emoji = "🎬" + emoji = "[MOVIE]" case MediaTypeTV: displayName = "tv shows" - emoji = "πŸ“Ί" + emoji = "[TV]" default: return fmt.Errorf("unsupported media type: %s", mediaType) } - fmt.Printf("\nπŸ“‹ Fetching all %s for keyword removal...\n", displayName) + fmt.Printf("\n[INFO] Fetching all %s for keyword removal...\n", displayName) items, err := p.fetchItems(libraryID, mediaType) if err != nil { @@ -464,93 +534,102 @@ func (p *Processor) RemoveKeywordsFromItems(libraryID string, mediaType MediaTyp } if len(items) == 0 { - fmt.Printf("❌ No %s found in library!\n", displayName) + fmt.Printf("[ERROR] No %s found in library!\n", displayName) return nil } - fmt.Printf("βœ… Found %d %s in library\n", len(items), displayName) + fmt.Printf("[OK] Found %d %s in library\n", len(items), displayName) removedCount := 0 skippedCount := 0 totalKeywordsRemoved := 0 - fmt.Printf("πŸš€ Optimized removal mode: reduced delays for faster processing\n") + processedCount := 0 - for i, item := range items { - // Show progress for large libraries - if len(items) > 100 && (i+1)%50 == 0 { - fmt.Printf("πŸ“Š Removal Progress: %d/%d (%.1f%%)\n", i+1, len(items), float64(i+1)/float64(len(items))*100) - } + for _, b := range p.makeBatches(items) { + b.logStart(emoji+" Removal", len(items)) - tmdbID := p.extractTMDbID(item, mediaType) - if tmdbID == "" { - skippedCount++ - continue - } + for _, item := range b.items { + processedCount++ - details, err := p.getItemDetails(item.GetRatingKey(), mediaType) - if err != nil { - fmt.Printf("❌ Error fetching %s details for %s: %v\n", strings.TrimSuffix(displayName, "s"), item.GetTitle(), err) - skippedCount++ - continue - } + if len(items) > 100 && processedCount%50 == 0 { + fmt.Printf("Removal Progress: %d/%d (%.1f%%)\n", processedCount, len(items), float64(processedCount)/float64(len(items))*100) + } - currentValues := p.extractCurrentValues(details) + tmdbID := p.extractTMDbID(item, mediaType) + if tmdbID == "" { + skippedCount++ + continue + } - if len(currentValues) == 0 { - skippedCount++ - continue - } + details, err := p.getItemDetails(item.GetRatingKey(), mediaType) + if err != nil { + fmt.Printf("[ERROR] Error fetching %s details for %s: %v\n", strings.TrimSuffix(displayName, "s"), item.GetTitle(), err) + skippedCount++ + continue + } - keywords, err := p.getKeywords(tmdbID, mediaType) - if err != nil { - keywords = []string{} - } + currentValues := p.extractCurrentValues(details) - keywordMap := make(map[string]bool) - for _, keyword := range keywords { - keywordMap[strings.ToLower(keyword)] = true - } + if len(currentValues) == 0 { + skippedCount++ + continue + } - var valuesToRemove []string - foundTMDbKeywords := false - for _, value := range currentValues { - if keywordMap[strings.ToLower(value)] { - foundTMDbKeywords = true - valuesToRemove = append(valuesToRemove, value) + keywords, err := p.getKeywords(tmdbID, mediaType) + if err != nil { + keywords = []string{} } - } - if !foundTMDbKeywords { - skippedCount++ - continue - } + keywords = p.applyKeywordPrefix(keywords) + + keywordMap := make(map[string]bool) + for _, keyword := range keywords { + keywordMap[strings.ToLower(keyword)] = true + } - fmt.Printf("\n%s Processing %s: %s (%d)\n", emoji, strings.TrimSuffix(displayName, "s"), item.GetTitle(), item.GetYear()) - fmt.Printf("πŸ”‘ TMDb ID: %s\n", tmdbID) - fmt.Printf("πŸ—‘οΈ Removing %d TMDb keywords from %s field\n", len(valuesToRemove), p.config.UpdateField) + var valuesToRemove []string + foundTMDbKeywords := false + for _, value := range currentValues { + if keywordMap[strings.ToLower(value)] { + foundTMDbKeywords = true + valuesToRemove = append(valuesToRemove, value) + } + } - lockField := p.config.RemoveMode == "lock" - err = p.removeItemFieldKeywords(item.GetRatingKey(), libraryID, valuesToRemove, lockField, mediaType) - if err != nil { - fmt.Printf("❌ Error removing keywords from %s: %v\n", item.GetTitle(), err) - skippedCount++ - continue - } + if !foundTMDbKeywords { + skippedCount++ + continue + } + + fmt.Printf("\n%s Processing %s: %s (%d)\n", emoji, strings.TrimSuffix(displayName, "s"), item.GetTitle(), item.GetYear()) + fmt.Printf("[KEY] TMDb ID: %s\n", tmdbID) + fmt.Printf("[REMOVE] Removing %d TMDb keywords from %s field\n", len(valuesToRemove), p.config.UpdateField) - totalKeywordsRemoved += len(valuesToRemove) - removedCount++ - fmt.Printf("βœ… Successfully removed keywords from %s\n", item.GetTitle()) + lockField := p.config.RemoveMode == "lock" + err = p.removeItemFieldKeywords(item.GetRatingKey(), libraryID, valuesToRemove, lockField, mediaType) + if err != nil { + fmt.Printf("[ERROR] Error removing keywords from %s: %v\n", item.GetTitle(), err) + skippedCount++ + continue + } - // Reduced delay for faster removal (was 500ms) - time.Sleep(100 * time.Millisecond) + totalKeywordsRemoved += len(valuesToRemove) + removedCount++ + fmt.Printf("[OK] Successfully removed keywords from %s\n", item.GetTitle()) + + time.Sleep(p.config.ItemDelay) + } + + p.pauseAfterBatch(b, emoji+" Removal") } - fmt.Printf("\nπŸ“Š Removal Summary:\n") - fmt.Printf(" πŸ“ˆ Total %s checked: %d\n", displayName, len(items)) - fmt.Printf(" πŸ—‘οΈ %s with keywords removed: %d\n", strings.Title(displayName), removedCount) - fmt.Printf(" ⏭️ Skipped %s: %d\n", displayName, skippedCount) - fmt.Printf(" 🏷️ Total keywords removed: %d\n", totalKeywordsRemoved) + fmt.Printf("\n[STATS] Removal Summary:\n") + fmt.Printf(" [TOTAL] Total %s checked: %d\n", displayName, len(items)) + displayTitle := strings.ToUpper(displayName[:1]) + displayName[1:] + fmt.Printf(" [REMOVE] %s with keywords removed: %d\n", displayTitle, removedCount) + fmt.Printf(" [SKIP] Skipped %s: %d\n", displayName, skippedCount) + fmt.Printf(" [LABEL] Total keywords removed: %d\n", totalKeywordsRemoved) return nil } @@ -609,14 +688,33 @@ func (p *Processor) getItemDetails(ratingKey string, mediaType MediaType) (Media // getKeywords gets keywords from TMDb based on media type func (p *Processor) getKeywords(tmdbID string, mediaType MediaType) ([]string, error) { + cacheKey := string(mediaType) + ":" + tmdbID + + p.cacheMu.RLock() + if cached, ok := p.keywordCache[cacheKey]; ok { + p.cacheMu.RUnlock() + return cached, nil + } + p.cacheMu.RUnlock() + + var keywords []string + var err error switch mediaType { case MediaTypeMovie: - return p.tmdbClient.GetMovieKeywords(tmdbID) + keywords, err = p.tmdbClient.GetMovieKeywords(tmdbID) case MediaTypeTV: - return p.tmdbClient.GetTVShowKeywords(tmdbID) + keywords, err = p.tmdbClient.GetTVShowKeywords(tmdbID) default: return nil, fmt.Errorf("unsupported media type: %s", mediaType) } + if err != nil { + return nil, err + } + + p.cacheMu.Lock() + p.keywordCache[cacheKey] = keywords + p.cacheMu.Unlock() + return keywords, nil } // syncFieldWithKeywords synchronizes the configured field with TMDb keywords @@ -627,7 +725,7 @@ func (p *Processor) syncFieldWithKeywords(itemID, libraryID string, currentValue if p.config.VerboseLogging && len(cleanedValues) != len(currentValues) { removedCount := len(currentValues) - len(cleanedValues) + len(keywords) - fmt.Printf(" 🧹 Cleaned %d duplicate/unnormalized keywords\n", removedCount) + fmt.Printf(" [CLEAN] Cleaned %d duplicate/unnormalized keywords\n", removedCount) } return p.updateItemField(itemID, libraryID, cleanedValues, mediaType) @@ -703,7 +801,7 @@ func (p *Processor) extractTMDbID(item MediaItem, mediaType MediaType) string { func (p *Processor) extractMovieTMDbID(item MediaItem) string { if p.config.VerboseLogging { fmt.Printf("\nπŸ” Starting TMDb ID lookup for movie: %s (%d)\n", item.GetTitle(), item.GetYear()) - fmt.Printf(" πŸ“‹ Available Plex GUIDs:\n") + fmt.Printf(" [INFO] Available Plex GUIDs:\n") for _, guid := range item.GetGuid() { fmt.Printf(" - %s\n", guid.ID) } @@ -716,7 +814,7 @@ func (p *Processor) extractMovieTMDbID(item MediaItem) string { if len(parts) > 1 { tmdbID := strings.Split(parts[1], "?")[0] if p.config.VerboseLogging { - fmt.Printf(" βœ… Found TMDb ID in Plex metadata: %s\n", tmdbID) + fmt.Printf(" [OK] Found TMDb ID in Plex metadata: %s\n", tmdbID) } return tmdbID } @@ -726,7 +824,7 @@ func (p *Processor) extractMovieTMDbID(item MediaItem) string { // If Radarr is enabled, try to match via Radarr if p.config.UseRadarr && p.radarrClient != nil { if p.config.VerboseLogging { - fmt.Printf(" 🎬 Checking Radarr for movie match...\n") + fmt.Printf(" [MOVIE] Checking Radarr for movie match...\n") } // Try to match by title and year first @@ -737,11 +835,11 @@ func (p *Processor) extractMovieTMDbID(item MediaItem) string { if err == nil && movie != nil { tmdbID := p.radarrClient.GetTMDbIDFromMovie(movie) if p.config.VerboseLogging { - fmt.Printf(" βœ… Found match in Radarr: %s (TMDb: %s)\n", movie.Title, tmdbID) + fmt.Printf(" [OK] Found match in Radarr: %s (TMDb: %s)\n", movie.Title, tmdbID) } return tmdbID } else if p.config.VerboseLogging { - fmt.Printf(" ❌ No match found by title/year\n") + fmt.Printf(" [ERROR] No match found by title/year\n") } // Try to match by file path @@ -757,14 +855,14 @@ func (p *Processor) extractMovieTMDbID(item MediaItem) string { if err == nil && movie != nil { tmdbID := p.radarrClient.GetTMDbIDFromMovie(movie) if p.config.VerboseLogging { - fmt.Printf(" βœ… Found match by file path: %s (TMDb: %s)\n", movie.Title, tmdbID) + fmt.Printf(" [OK] Found match by file path: %s (TMDb: %s)\n", movie.Title, tmdbID) } return tmdbID } } } if p.config.VerboseLogging { - fmt.Printf(" ❌ No match found by file path\n") + fmt.Printf(" [ERROR] No match found by file path\n") } // Try to match by IMDb ID if available @@ -778,11 +876,11 @@ func (p *Processor) extractMovieTMDbID(item MediaItem) string { if err == nil && movie != nil { tmdbID := p.radarrClient.GetTMDbIDFromMovie(movie) if p.config.VerboseLogging { - fmt.Printf(" βœ… Found match by IMDb ID: %s (TMDb: %s)\n", movie.Title, tmdbID) + fmt.Printf(" [OK] Found match by IMDb ID: %s (TMDb: %s)\n", movie.Title, tmdbID) } return tmdbID } else if p.config.VerboseLogging { - fmt.Printf(" ❌ No match found by IMDb ID\n") + fmt.Printf(" [ERROR] No match found by IMDb ID\n") } } } @@ -790,7 +888,7 @@ func (p *Processor) extractMovieTMDbID(item MediaItem) string { // If not found in Radarr or Radarr not enabled, try to extract from file paths if p.config.VerboseLogging { - fmt.Printf(" πŸ“ Checking file paths for TMDb ID pattern...\n") + fmt.Printf(" [STORAGE] Checking file paths for TMDb ID pattern...\n") } for _, mediaItem := range item.GetMedia() { for _, part := range mediaItem.Part { @@ -799,7 +897,7 @@ func (p *Processor) extractMovieTMDbID(item MediaItem) string { } if tmdbID := ExtractTMDbIDFromPath(part.File); tmdbID != "" { if p.config.VerboseLogging { - fmt.Printf(" βœ… Found TMDb ID in file path: %s\n", tmdbID) + fmt.Printf(" [OK] Found TMDb ID in file path: %s\n", tmdbID) } return tmdbID } @@ -807,7 +905,7 @@ func (p *Processor) extractMovieTMDbID(item MediaItem) string { } if p.config.VerboseLogging { - fmt.Printf(" ❌ No TMDb ID found for movie: %s\n", item.GetTitle()) + fmt.Printf(" [ERROR] No TMDb ID found for movie: %s\n", item.GetTitle()) } return "" @@ -817,7 +915,7 @@ func (p *Processor) extractMovieTMDbID(item MediaItem) string { func (p *Processor) extractTVShowTMDbID(item MediaItem) string { if p.config.VerboseLogging { fmt.Printf("\nπŸ” Starting TMDb ID lookup for TV show: %s (%d)\n", item.GetTitle(), item.GetYear()) - fmt.Printf(" πŸ“‹ Available Plex GUIDs:\n") + fmt.Printf(" [INFO] Available Plex GUIDs:\n") for _, guid := range item.GetGuid() { fmt.Printf(" - %s\n", guid.ID) } @@ -828,7 +926,7 @@ func (p *Processor) extractTVShowTMDbID(item MediaItem) string { if strings.HasPrefix(guid.ID, "tmdb://") { tmdbID := strings.TrimPrefix(guid.ID, "tmdb://") if p.config.VerboseLogging { - fmt.Printf(" βœ… Found TMDb ID in Plex metadata: %s\n", tmdbID) + fmt.Printf(" [OK] Found TMDb ID in Plex metadata: %s\n", tmdbID) } return tmdbID } @@ -837,7 +935,7 @@ func (p *Processor) extractTVShowTMDbID(item MediaItem) string { // If Sonarr is enabled, try to match via Sonarr if p.config.UseSonarr && p.sonarrClient != nil { if p.config.VerboseLogging { - fmt.Printf(" πŸ“Ί Checking Sonarr for series match...\n") + fmt.Printf(" [TV] Checking Sonarr for series match...\n") } // Try to match by title and year first @@ -848,11 +946,11 @@ func (p *Processor) extractTVShowTMDbID(item MediaItem) string { if err == nil && series != nil { tmdbID := p.sonarrClient.GetTMDbIDFromSeries(series) if p.config.VerboseLogging { - fmt.Printf(" βœ… Found match in Sonarr: %s (TMDb: %s)\n", series.Title, tmdbID) + fmt.Printf(" [OK] Found match in Sonarr: %s (TMDb: %s)\n", series.Title, tmdbID) } return tmdbID } else if p.config.VerboseLogging { - fmt.Printf(" ❌ No match found by title/year\n") + fmt.Printf(" [ERROR] No match found by title/year\n") } // Try to match by TVDb ID if available @@ -869,11 +967,11 @@ func (p *Processor) extractTVShowTMDbID(item MediaItem) string { if err == nil && series != nil { tmdbID := p.sonarrClient.GetTMDbIDFromSeries(series) if p.config.VerboseLogging { - fmt.Printf(" βœ… Found match by TVDb ID: %s (TMDb: %s)\n", series.Title, tmdbID) + fmt.Printf(" [OK] Found match by TVDb ID: %s (TMDb: %s)\n", series.Title, tmdbID) } return tmdbID } else if p.config.VerboseLogging { - fmt.Printf(" ❌ No match found by TVDb ID\n") + fmt.Printf(" [ERROR] No match found by TVDb ID\n") } } } @@ -890,11 +988,11 @@ func (p *Processor) extractTVShowTMDbID(item MediaItem) string { if err == nil && series != nil { tmdbID := p.sonarrClient.GetTMDbIDFromSeries(series) if p.config.VerboseLogging { - fmt.Printf(" βœ… Found match by IMDb ID: %s (TMDb: %s)\n", series.Title, tmdbID) + fmt.Printf(" [OK] Found match by IMDb ID: %s (TMDb: %s)\n", series.Title, tmdbID) } return tmdbID } else if p.config.VerboseLogging { - fmt.Printf(" ❌ No match found by IMDb ID\n") + fmt.Printf(" [ERROR] No match found by IMDb ID\n") } } } @@ -917,7 +1015,7 @@ func (p *Processor) extractTVShowTMDbID(item MediaItem) string { if err == nil && series != nil { tmdbID := p.sonarrClient.GetTMDbIDFromSeries(series) if p.config.VerboseLogging { - fmt.Printf(" βœ… Found match by file path: %s (TMDb: %s)\n", series.Title, tmdbID) + fmt.Printf(" [OK] Found match by file path: %s (TMDb: %s)\n", series.Title, tmdbID) } return tmdbID } @@ -928,23 +1026,23 @@ func (p *Processor) extractTVShowTMDbID(item MediaItem) string { fmt.Printf(" ... and %d more episodes\n", episodeCount-5) } if p.config.VerboseLogging { - fmt.Printf(" ❌ No match found by file path\n") + fmt.Printf(" [ERROR] No match found by file path\n") } } else if p.config.VerboseLogging { - fmt.Printf(" ⚠️ Could not fetch episodes: %v\n", err) + fmt.Printf(" [WARN] Could not fetch episodes: %v\n", err) } } // If no TMDb GUID found and Sonarr not enabled, get episodes and check their file paths if p.config.VerboseLogging { - fmt.Printf(" πŸ“ Checking episode file paths for TMDb ID pattern...\n") + fmt.Printf(" [STORAGE] Checking episode file paths for TMDb ID pattern...\n") } episodes, err := p.plexClient.GetTVShowEpisodes(item.GetRatingKey()) if err != nil { if p.config.VerboseLogging { - fmt.Printf(" ⚠️ Error fetching episodes: %v\n", err) + fmt.Printf(" [WARN] Error fetching episodes: %v\n", err) } else { - fmt.Printf("⚠️ Error fetching episodes for %s: %v\n", item.GetTitle(), err) + fmt.Printf("[WARN] Error fetching episodes for %s: %v\n", item.GetTitle(), err) } return "" } @@ -960,7 +1058,7 @@ func (p *Processor) extractTVShowTMDbID(item MediaItem) string { } if tmdbID := ExtractTMDbIDFromPath(part.File); tmdbID != "" { if p.config.VerboseLogging { - fmt.Printf(" βœ… Found TMDb ID in file path: %s\n", tmdbID) + fmt.Printf(" [OK] Found TMDb ID in file path: %s\n", tmdbID) } return tmdbID } @@ -973,7 +1071,7 @@ func (p *Processor) extractTVShowTMDbID(item MediaItem) string { } if p.config.VerboseLogging { - fmt.Printf(" ❌ No TMDb ID found for TV show: %s\n", item.GetTitle()) + fmt.Printf(" [ERROR] No TMDb ID found for TV show: %s\n", item.GetTitle()) } return "" diff --git a/internal/tmdb/client.go b/internal/tmdb/client.go index ba4ec49..6655af8 100644 --- a/internal/tmdb/client.go +++ b/internal/tmdb/client.go @@ -78,7 +78,7 @@ func (c *Client) GetMovieKeywords(tmdbID string) ([]string, error) { if c.config.VerboseLogging { for i, original := range keywords { if i < len(normalizedKeywords) && original != normalizedKeywords[i] { - fmt.Printf(" πŸ“ Normalized: \"%s\" β†’ \"%s\"\n", original, normalizedKeywords[i]) + fmt.Printf(" [NOTE] Normalized: \"%s\" β†’ \"%s\"\n", original, normalizedKeywords[i]) } } } @@ -139,7 +139,7 @@ func (c *Client) GetTVShowKeywords(tmdbID string) ([]string, error) { if c.config.VerboseLogging { for i, original := range keywords { if i < len(normalizedKeywords) && original != normalizedKeywords[i] { - fmt.Printf(" πŸ“ Normalized: \"%s\" β†’ \"%s\"\n", original, normalizedKeywords[i]) + fmt.Printf(" [NOTE] Normalized: \"%s\" β†’ \"%s\"\n", original, normalizedKeywords[i]) } } } diff --git a/internal/webhook/server.go b/internal/webhook/server.go new file mode 100644 index 0000000..7df0063 --- /dev/null +++ b/internal/webhook/server.go @@ -0,0 +1,195 @@ +package webhook + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "strconv" + "sync" + "time" + + "github.com/nullable-eth/labelarr/internal/config" + "github.com/nullable-eth/labelarr/internal/media" + "github.com/nullable-eth/labelarr/internal/plex" +) + +const ( + eventLibraryNew = "library.new" + eventLibraryOnDeck = "library.on.deck" +) + +type PlexWebhookPayload struct { + Event string `json:"event"` + Account struct { + Title string `json:"title"` + } `json:"Account"` + Metadata struct { + LibrarySectionID int `json:"librarySectionID"` + LibrarySectionTitle string `json:"librarySectionTitle"` + Type string `json:"type"` + } `json:"Metadata"` +} + +type libraryInfo struct { + name string + mediaType media.MediaType +} + +type Server struct { + config *config.Config + processor *media.Processor + httpServer *http.Server + libraryMap map[string]libraryInfo + debounceTimers map[string]*time.Timer + debounceGen map[string]uint64 + debounceMu sync.Mutex +} + +func NewServer(cfg *config.Config, proc *media.Processor, movieLibs, tvLibs []plex.Library) *Server { + libMap := make(map[string]libraryInfo, len(movieLibs)+len(tvLibs)) + for _, lib := range movieLibs { + libMap[lib.Key] = libraryInfo{name: lib.Title, mediaType: media.MediaTypeMovie} + } + for _, lib := range tvLibs { + libMap[lib.Key] = libraryInfo{name: lib.Title, mediaType: media.MediaTypeTV} + } + + return &Server{ + config: cfg, + processor: proc, + libraryMap: libMap, + debounceTimers: make(map[string]*time.Timer), + debounceGen: make(map[string]uint64), + } +} + +// Start binds the webhook port and begins serving. Returns an error if the port +// cannot be bound, so the caller knows immediately if startup failed. +func (s *Server) Start() error { + mux := http.NewServeMux() + mux.HandleFunc("/webhook", s.handleWebhook) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") + }) + + addr := fmt.Sprintf(":%d", s.config.WebhookPort) + + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("failed to bind webhook port %d: %w", s.config.WebhookPort, err) + } + + s.httpServer = &http.Server{ + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + + go func() { + if err := s.httpServer.Serve(ln); err != nil && err != http.ErrServerClosed { + fmt.Printf("Webhook server error: %v\n", err) + } + }() + + return nil +} + +// Stop gracefully shuts down the webhook server. +func (s *Server) Stop(ctx context.Context) error { + if s.httpServer != nil { + return s.httpServer.Shutdown(ctx) + } + return nil +} + +func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if err := r.ParseMultipartForm(1 << 20); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + payloadStr := r.FormValue("payload") + if payloadStr == "" { + http.Error(w, "Missing payload", http.StatusBadRequest) + return + } + + var payload PlexWebhookPayload + if err := json.Unmarshal([]byte(payloadStr), &payload); err != nil { + http.Error(w, "Invalid payload", http.StatusBadRequest) + return + } + + if s.config.VerboseLogging { + fmt.Printf("Webhook received: event=%s library=%s type=%s\n", + payload.Event, payload.Metadata.LibrarySectionTitle, payload.Metadata.Type) + } + + switch payload.Event { + case eventLibraryNew, eventLibraryOnDeck: + // proceed + default: + w.WriteHeader(http.StatusOK) + return + } + + libraryID := strconv.Itoa(payload.Metadata.LibrarySectionID) + + if info, ok := s.libraryMap[libraryID]; ok { + s.scheduleProcessing(libraryID, info.name, info.mediaType) + } + + w.WriteHeader(http.StatusOK) +} + +func (s *Server) scheduleProcessing(libraryID, libraryName string, mediaType media.MediaType) { + debounce := s.config.WebhookDebounce + + s.debounceMu.Lock() + defer s.debounceMu.Unlock() + + // Bump generation so any in-flight old callback becomes a no-op + s.debounceGen[libraryID]++ + gen := s.debounceGen[libraryID] + + // Stop old timer if it exists (ignore return value -- generation counter handles races) + if timer, exists := s.debounceTimers[libraryID]; exists { + timer.Stop() + if s.config.VerboseLogging { + fmt.Printf("Webhook: reset debounce timer for library %s\n", libraryName) + } + } else { + fmt.Printf("Webhook: scheduled processing for library %s in %v\n", libraryName, debounce) + } + + s.debounceTimers[libraryID] = time.AfterFunc(debounce, func() { + s.debounceMu.Lock() + // Only proceed if this callback's generation is still current + if s.debounceGen[libraryID] != gen { + s.debounceMu.Unlock() + return + } + delete(s.debounceTimers, libraryID) + delete(s.debounceGen, libraryID) + s.debounceMu.Unlock() + + s.processLibrary(libraryID, libraryName, mediaType) + }) +} + +func (s *Server) processLibrary(libraryID, libraryName string, mediaType media.MediaType) { + fmt.Printf("Webhook: processing library %s (ID: %s)\n", libraryName, libraryID) + if err := s.processor.ProcessAllItems(libraryID, libraryName, mediaType); err != nil { + fmt.Printf("Webhook: error processing library %s: %v\n", libraryName, err) + } else { + fmt.Printf("Webhook: finished processing library %s\n", libraryName) + } +} From 9485bb8a8667cdcf70e5f23d1110be32cbf3c430 Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Fri, 10 Apr 2026 12:57:06 -0400 Subject: [PATCH 04/18] fix: remove remaining emoji/unicode, improve webhook payload handling - Remove remaining emoji and unicode arrows from processor.go and tmdb/client.go - Expand webhook payload struct to match Plex spec - Use LibrarySectionType for correct media type resolution - Note Plex Pass requirement for webhooks in README --- README.md | 2 +- internal/media/processor.go | 18 +++++----- internal/tmdb/client.go | 4 +-- internal/webhook/server.go | 70 +++++++++++++++++++++++++++++++++---- 4 files changed, 75 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index bb6765e..712c477 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ API keys: Radarr/Sonarr Settings > General > Security > API Key. ## Webhook Support -Instead of waiting for the next timer tick, Labelarr can react to Plex webhook events immediately. +**Requires Plex Pass.** Instead of waiting for the next timer tick, Labelarr can react to Plex webhook events immediately. ```yaml environment: diff --git a/internal/media/processor.go b/internal/media/processor.go index ee6935e..237e6f0 100644 --- a/internal/media/processor.go +++ b/internal/media/processor.go @@ -800,7 +800,7 @@ func (p *Processor) extractTMDbID(item MediaItem, mediaType MediaType) string { // extractMovieTMDbID extracts TMDb ID from movie metadata or file paths func (p *Processor) extractMovieTMDbID(item MediaItem) string { if p.config.VerboseLogging { - fmt.Printf("\nπŸ” Starting TMDb ID lookup for movie: %s (%d)\n", item.GetTitle(), item.GetYear()) + fmt.Printf("\n[LOOKUP] Starting TMDb ID lookup for movie: %s (%d)\n", item.GetTitle(), item.GetYear()) fmt.Printf(" [INFO] Available Plex GUIDs:\n") for _, guid := range item.GetGuid() { fmt.Printf(" - %s\n", guid.ID) @@ -829,7 +829,7 @@ func (p *Processor) extractMovieTMDbID(item MediaItem) string { // Try to match by title and year first if p.config.VerboseLogging { - fmt.Printf(" β†’ Searching by title: \"%s\" year: %d\n", item.GetTitle(), item.GetYear()) + fmt.Printf(" -> Searching by title: \"%s\" year: %d\n", item.GetTitle(), item.GetYear()) } movie, err := p.radarrClient.FindMovieMatch(item.GetTitle(), item.GetYear()) if err == nil && movie != nil { @@ -844,7 +844,7 @@ func (p *Processor) extractMovieTMDbID(item MediaItem) string { // Try to match by file path if p.config.VerboseLogging { - fmt.Printf(" β†’ Searching by file path...\n") + fmt.Printf(" -> Searching by file path...\n") } for _, mediaItem := range item.GetMedia() { for _, part := range mediaItem.Part { @@ -870,7 +870,7 @@ func (p *Processor) extractMovieTMDbID(item MediaItem) string { if strings.Contains(guid.ID, "imdb://") { imdbID := strings.TrimPrefix(guid.ID, "imdb://") if p.config.VerboseLogging { - fmt.Printf(" β†’ Searching by IMDb ID: %s\n", imdbID) + fmt.Printf(" -> Searching by IMDb ID: %s\n", imdbID) } movie, err := p.radarrClient.GetMovieByIMDbID(imdbID) if err == nil && movie != nil { @@ -914,7 +914,7 @@ func (p *Processor) extractMovieTMDbID(item MediaItem) string { // extractTVShowTMDbID extracts TMDb ID from TV show metadata or episode file paths func (p *Processor) extractTVShowTMDbID(item MediaItem) string { if p.config.VerboseLogging { - fmt.Printf("\nπŸ” Starting TMDb ID lookup for TV show: %s (%d)\n", item.GetTitle(), item.GetYear()) + fmt.Printf("\n[LOOKUP] Starting TMDb ID lookup for TV show: %s (%d)\n", item.GetTitle(), item.GetYear()) fmt.Printf(" [INFO] Available Plex GUIDs:\n") for _, guid := range item.GetGuid() { fmt.Printf(" - %s\n", guid.ID) @@ -940,7 +940,7 @@ func (p *Processor) extractTVShowTMDbID(item MediaItem) string { // Try to match by title and year first if p.config.VerboseLogging { - fmt.Printf(" β†’ Searching by title: \"%s\" year: %d\n", item.GetTitle(), item.GetYear()) + fmt.Printf(" -> Searching by title: \"%s\" year: %d\n", item.GetTitle(), item.GetYear()) } series, err := p.sonarrClient.FindSeriesMatch(item.GetTitle(), item.GetYear()) if err == nil && series != nil { @@ -961,7 +961,7 @@ func (p *Processor) extractTVShowTMDbID(item MediaItem) string { var tvdbID int if _, err := fmt.Sscanf(tvdbIDStr, "%d", &tvdbID); err == nil { if p.config.VerboseLogging { - fmt.Printf(" β†’ Searching by TVDb ID: %d\n", tvdbID) + fmt.Printf(" -> Searching by TVDb ID: %d\n", tvdbID) } series, err := p.sonarrClient.GetSeriesByTVDbID(tvdbID) if err == nil && series != nil { @@ -982,7 +982,7 @@ func (p *Processor) extractTVShowTMDbID(item MediaItem) string { if strings.Contains(guid.ID, "imdb://") { imdbID := strings.TrimPrefix(guid.ID, "imdb://") if p.config.VerboseLogging { - fmt.Printf(" β†’ Searching by IMDb ID: %s\n", imdbID) + fmt.Printf(" -> Searching by IMDb ID: %s\n", imdbID) } series, err := p.sonarrClient.GetSeriesByIMDbID(imdbID) if err == nil && series != nil { @@ -999,7 +999,7 @@ func (p *Processor) extractTVShowTMDbID(item MediaItem) string { // Try to match by file path from episodes if p.config.VerboseLogging { - fmt.Printf(" β†’ Searching by episode file paths...\n") + fmt.Printf(" -> Searching by episode file paths...\n") } episodes, err := p.plexClient.GetTVShowEpisodes(item.GetRatingKey()) if err == nil { diff --git a/internal/tmdb/client.go b/internal/tmdb/client.go index 6655af8..a5e5801 100644 --- a/internal/tmdb/client.go +++ b/internal/tmdb/client.go @@ -78,7 +78,7 @@ func (c *Client) GetMovieKeywords(tmdbID string) ([]string, error) { if c.config.VerboseLogging { for i, original := range keywords { if i < len(normalizedKeywords) && original != normalizedKeywords[i] { - fmt.Printf(" [NOTE] Normalized: \"%s\" β†’ \"%s\"\n", original, normalizedKeywords[i]) + fmt.Printf(" [NOTE] Normalized: \"%s\" -> \"%s\"\n", original, normalizedKeywords[i]) } } } @@ -139,7 +139,7 @@ func (c *Client) GetTVShowKeywords(tmdbID string) ([]string, error) { if c.config.VerboseLogging { for i, original := range keywords { if i < len(normalizedKeywords) && original != normalizedKeywords[i] { - fmt.Printf(" [NOTE] Normalized: \"%s\" β†’ \"%s\"\n", original, normalizedKeywords[i]) + fmt.Printf(" [NOTE] Normalized: \"%s\" -> \"%s\"\n", original, normalizedKeywords[i]) } } } diff --git a/internal/webhook/server.go b/internal/webhook/server.go index 7df0063..dc229bb 100644 --- a/internal/webhook/server.go +++ b/internal/webhook/server.go @@ -20,15 +20,37 @@ const ( eventLibraryOnDeck = "library.on.deck" ) +// PlexWebhookPayload matches the Plex webhook JSON structure. +// Plex sends this as the "payload" field in a multipart/form-data POST. +// Requires Plex Pass on the server. type PlexWebhookPayload struct { Event string `json:"event"` + User bool `json:"user"` + Owner bool `json:"owner"` Account struct { + ID int `json:"id"` Title string `json:"title"` } `json:"Account"` + Server struct { + Title string `json:"title"` + UUID string `json:"uuid"` + } `json:"Server"` + Player struct { + Local bool `json:"local"` + Title string `json:"title"` + UUID string `json:"uuid"` + } `json:"Player"` Metadata struct { + LibrarySectionType string `json:"librarySectionType"` LibrarySectionID int `json:"librarySectionID"` LibrarySectionTitle string `json:"librarySectionTitle"` + RatingKey string `json:"ratingKey"` + Key string `json:"key"` + GUID string `json:"guid"` Type string `json:"type"` + Title string `json:"title"` + Year int `json:"year"` + AddedAt int64 `json:"addedAt"` } `json:"Metadata"` } @@ -111,6 +133,8 @@ func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) { return } + // Plex sends multipart/form-data with a "payload" JSON field + // and optionally a thumbnail image if err := r.ParseMultipartForm(1 << 20); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return @@ -129,13 +153,17 @@ func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) { } if s.config.VerboseLogging { - fmt.Printf("Webhook received: event=%s library=%s type=%s\n", - payload.Event, payload.Metadata.LibrarySectionTitle, payload.Metadata.Type) + fmt.Printf("Webhook received: event=%s library=%s section_type=%s media_type=%s title=%s\n", + payload.Event, + payload.Metadata.LibrarySectionTitle, + payload.Metadata.LibrarySectionType, + payload.Metadata.Type, + payload.Metadata.Title) } switch payload.Event { case eventLibraryNew, eventLibraryOnDeck: - // proceed + // These are the events fired when new media is added default: w.WriteHeader(http.StatusOK) return @@ -143,13 +171,42 @@ func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) { libraryID := strconv.Itoa(payload.Metadata.LibrarySectionID) - if info, ok := s.libraryMap[libraryID]; ok { - s.scheduleProcessing(libraryID, info.name, info.mediaType) + // Use LibrarySectionType to determine media type, falling back to library map. + // Plex Metadata.Type is "movie", "episode", "track" etc. but + // LibrarySectionType is "movie" or "show" which maps to our processing paths. + mediaType := s.resolveMediaType(libraryID, payload.Metadata.LibrarySectionType) + libraryName := payload.Metadata.LibrarySectionTitle + if libraryName == "" { + if info, ok := s.libraryMap[libraryID]; ok { + libraryName = info.name + } + } + + if mediaType != media.MediaTypeUnknown { + s.scheduleProcessing(libraryID, libraryName, mediaType) + } else if s.config.VerboseLogging { + fmt.Printf("Webhook: ignoring event for unknown library %s (ID: %s)\n", libraryName, libraryID) } w.WriteHeader(http.StatusOK) } +// resolveMediaType determines the MediaType from the Plex LibrarySectionType field, +// falling back to the pre-built library map if the section type is not recognized. +func (s *Server) resolveMediaType(libraryID, sectionType string) media.MediaType { + switch sectionType { + case "movie": + return media.MediaTypeMovie + case "show": + return media.MediaTypeTV + } + // Fall back to library map (built from Plex library list at startup) + if info, ok := s.libraryMap[libraryID]; ok { + return info.mediaType + } + return media.MediaTypeUnknown +} + func (s *Server) scheduleProcessing(libraryID, libraryName string, mediaType media.MediaType) { debounce := s.config.WebhookDebounce @@ -160,7 +217,7 @@ func (s *Server) scheduleProcessing(libraryID, libraryName string, mediaType med s.debounceGen[libraryID]++ gen := s.debounceGen[libraryID] - // Stop old timer if it exists (ignore return value -- generation counter handles races) + // Stop old timer if it exists (generation counter handles races) if timer, exists := s.debounceTimers[libraryID]; exists { timer.Stop() if s.config.VerboseLogging { @@ -172,7 +229,6 @@ func (s *Server) scheduleProcessing(libraryID, libraryName string, mediaType med s.debounceTimers[libraryID] = time.AfterFunc(debounce, func() { s.debounceMu.Lock() - // Only proceed if this callback's generation is still current if s.debounceGen[libraryID] != gen { s.debounceMu.Unlock() return From 5ffee30a082f542d2ffbdf8cfb5adc2fa75a4d68 Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Fri, 10 Apr 2026 13:06:54 -0400 Subject: [PATCH 05/18] improve: Radarr/Sonarr matching and API call efficiency - Cache GetAllMovies/GetAllSeries results per client instance to eliminate thousands of redundant API calls per processing cycle - Add bidirectional title matching (either string contains the other) - Add CleanTitle matching (strips all punctuation for fuzzy matching) Fixes movies like "(500) Days of Summer" where Plex strips parens - Clear Radarr/Sonarr caches alongside keyword cache each cycle - Apply same improvements to both Radarr and Sonarr clients --- cmd/labelarr/main.go | 2 +- internal/media/processor.go | 13 +- internal/radarr/client.go | 267 +++++++++++++++++++--------------- internal/sonarr/client.go | 276 ++++++++++++++++++++---------------- 4 files changed, 318 insertions(+), 240 deletions(-) diff --git a/cmd/labelarr/main.go b/cmd/labelarr/main.go index 92246f9..d0706f1 100644 --- a/cmd/labelarr/main.go +++ b/cmd/labelarr/main.go @@ -222,7 +222,7 @@ func handleNormalMode(cfg *config.Config, processor *media.Processor, movieLibra fmt.Printf("[INFO] Starting periodic processing interval: %v\n", cfg.ProcessTimer) processFunc := func() { - processor.ClearKeywordCache() + processor.ClearCaches() if len(movieLibraries) > 0 { forEachLibrary(cfg.MovieProcessAll, cfg.MovieLibraryID, movieLibraries, "Movies", func(id, name string) { diff --git a/internal/media/processor.go b/internal/media/processor.go index 237e6f0..8d38090 100644 --- a/internal/media/processor.go +++ b/internal/media/processor.go @@ -163,12 +163,19 @@ func (p *Processor) pauseAfterBatch(b batch, label string) { } } -// ClearKeywordCache resets the TMDb keyword cache. Call at the start of each -// processing cycle so keywords are refreshed from TMDb periodically. -func (p *Processor) ClearKeywordCache() { +// ClearCaches resets the TMDb keyword cache and Radarr/Sonarr library caches. +// Call at the start of each processing cycle so data is refreshed periodically. +func (p *Processor) ClearCaches() { p.cacheMu.Lock() p.keywordCache = make(map[string][]string) p.cacheMu.Unlock() + + if p.radarrClient != nil { + p.radarrClient.ClearCache() + } + if p.sonarrClient != nil { + p.sonarrClient.ClearCache() + } } func (p *Processor) applyKeywordPrefix(keywords []string) []string { diff --git a/internal/radarr/client.go b/internal/radarr/client.go index e4a07ab..692c09d 100644 --- a/internal/radarr/client.go +++ b/internal/radarr/client.go @@ -4,24 +4,29 @@ import ( "encoding/json" "fmt" "net/http" - "net/url" + "regexp" "strconv" "strings" + "sync" "time" ) -// Client represents a Radarr API client +var nonAlphanumeric = regexp.MustCompile(`[^a-z0-9]`) + +func cleanTitle(s string) string { + return nonAlphanumeric.ReplaceAllString(strings.ToLower(s), "") +} + type Client struct { baseURL string apiKey string httpClient *http.Client + movies []Movie + moviesMu sync.Mutex } -// NewClient creates a new Radarr API client func NewClient(baseURL, apiKey string) *Client { - // Ensure baseURL doesn't have trailing slash baseURL = strings.TrimRight(baseURL, "/") - return &Client{ baseURL: baseURL, apiKey: apiKey, @@ -31,211 +36,239 @@ func NewClient(baseURL, apiKey string) *Client { } } -// makeRequest performs an API request to Radarr -func (c *Client) makeRequest(method, endpoint string, params url.Values) (*http.Response, error) { +func (c *Client) makeRequest(method, endpoint string, params map[string]string) (*http.Response, error) { fullURL := fmt.Sprintf("%s%s", c.baseURL, endpoint) - - if params != nil && len(params) > 0 { - fullURL = fmt.Sprintf("%s?%s", fullURL, params.Encode()) + + if len(params) > 0 { + parts := make([]string, 0, len(params)) + for k, v := range params { + parts = append(parts, k+"="+v) + } + fullURL += "?" + strings.Join(parts, "&") } - + req, err := http.NewRequest(method, fullURL, nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } - + req.Header.Set("X-Api-Key", c.apiKey) req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - + resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("error making request: %w", err) } - + if resp.StatusCode != http.StatusOK { resp.Body.Close() return nil, fmt.Errorf("radarr API returned status %d", resp.StatusCode) } - + return resp, nil } -// GetAllMovies retrieves all movies from Radarr +// GetAllMovies fetches the full movie list from Radarr, caching the result +// for the lifetime of the client. Call ClearCache to refresh. func (c *Client) GetAllMovies() ([]Movie, error) { + c.moviesMu.Lock() + defer c.moviesMu.Unlock() + + if c.movies != nil { + return c.movies, nil + } + resp, err := c.makeRequest("GET", "/api/v3/movie", nil) if err != nil { return nil, err } defer resp.Body.Close() - + var movies []Movie if err := json.NewDecoder(resp.Body).Decode(&movies); err != nil { return nil, fmt.Errorf("error decoding movies: %w", err) } - + + c.movies = movies return movies, nil } -// GetMovieByTMDbID retrieves a movie by its TMDb ID -func (c *Client) GetMovieByTMDbID(tmdbID int) (*Movie, error) { - movies, err := c.GetAllMovies() - if err != nil { - return nil, err - } - - for _, movie := range movies { - if movie.TMDbID == tmdbID { - return &movie, nil - } - } - - return nil, fmt.Errorf("movie with TMDb ID %d not found", tmdbID) +// ClearCache forces the next GetAllMovies call to re-fetch from Radarr. +func (c *Client) ClearCache() { + c.moviesMu.Lock() + c.movies = nil + c.moviesMu.Unlock() } -// SearchMovieByTitle searches for movies by title +// SearchMovieByTitle returns all movies whose title, original title, clean title, +// or alternate titles match the query. Matching is bidirectional (either contains +// the other) and also checks a cleaned/normalized form for punctuation-insensitive matching. func (c *Client) SearchMovieByTitle(title string) ([]Movie, error) { - // First try to get all movies and filter locally - // This is more reliable than using Radarr's search endpoint allMovies, err := c.GetAllMovies() if err != nil { return nil, err } - - var matches []Movie + titleLower := strings.ToLower(title) - + titleClean := cleanTitle(title) + + var matches []Movie for _, movie := range allMovies { - if strings.Contains(strings.ToLower(movie.Title), titleLower) || - strings.Contains(strings.ToLower(movie.OriginalTitle), titleLower) { + if titleMatches(titleLower, titleClean, movie) { matches = append(matches, movie) - continue - } - - // Check alternate titles - for _, altTitle := range movie.AlternateTitles { - if strings.Contains(strings.ToLower(altTitle.Title), titleLower) { - matches = append(matches, movie) - break - } } } - + return matches, nil } -// FindMovieMatch attempts to find the best match for a movie by title and year +func titleMatches(titleLower, titleClean string, movie Movie) bool { + movieTitleLower := strings.ToLower(movie.Title) + movieOrigLower := strings.ToLower(movie.OriginalTitle) + + // Bidirectional substring match on title and original title + if containsEither(movieTitleLower, titleLower) || containsEither(movieOrigLower, titleLower) { + return true + } + + // CleanTitle match (Radarr provides this stripped of punctuation) + if movie.CleanTitle != "" && (movie.CleanTitle == titleClean || strings.Contains(movie.CleanTitle, titleClean) || strings.Contains(titleClean, movie.CleanTitle)) { + return true + } + + // Cleaned form match against title (for cases where CleanTitle field is empty) + if cleanTitle(movie.Title) == titleClean { + return true + } + + // Alternate titles + for _, alt := range movie.AlternateTitles { + altLower := strings.ToLower(alt.Title) + if containsEither(altLower, titleLower) { + return true + } + if alt.CleanTitle != "" && (alt.CleanTitle == titleClean || strings.Contains(alt.CleanTitle, titleClean) || strings.Contains(titleClean, alt.CleanTitle)) { + return true + } + } + + return false +} + +func containsEither(a, b string) bool { + return strings.Contains(a, b) || strings.Contains(b, a) +} + +// FindMovieMatch finds the best match for a movie by title and year. func (c *Client) FindMovieMatch(title string, year int) (*Movie, error) { movies, err := c.SearchMovieByTitle(title) if err != nil { return nil, err } - - // First try exact title and year match + titleLower := strings.ToLower(title) - for _, movie := range movies { - if strings.ToLower(movie.Title) == titleLower && movie.Year == year { - return &movie, nil + titleClean := cleanTitle(title) + + // Exact title + year + for i := range movies { + if strings.ToLower(movies[i].Title) == titleLower && movies[i].Year == year { + return &movies[i], nil } } - - // Then try year match with similar title - for _, movie := range movies { - if movie.Year == year { - return &movie, nil + + // CleanTitle + year + for i := range movies { + if movies[i].Year == year && (movies[i].CleanTitle == titleClean || cleanTitle(movies[i].Title) == titleClean) { + return &movies[i], nil } } - - // If still no match, try within 1 year range - for _, movie := range movies { - if movie.Year >= year-1 && movie.Year <= year+1 { - return &movie, nil + + // Year match with any title hit + for i := range movies { + if movies[i].Year == year { + return &movies[i], nil } } - - // Return first match if any found - if len(movies) > 0 { - return &movies[0], nil - } - - return nil, fmt.Errorf("no movie match found for: %s (%d)", title, year) -} -// GetSystemStatus retrieves Radarr system status (useful for testing connection) -func (c *Client) GetSystemStatus() (*SystemStatus, error) { - resp, err := c.makeRequest("GET", "/api/v3/system/status", nil) - if err != nil { - return nil, err + // Within 1 year + for i := range movies { + if movies[i].Year >= year-1 && movies[i].Year <= year+1 { + return &movies[i], nil + } } - defer resp.Body.Close() - - var status SystemStatus - if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { - return nil, fmt.Errorf("error decoding system status: %w", err) + + if len(movies) > 0 { + return &movies[0], nil } - - return &status, nil -} -// TestConnection tests the connection to Radarr -func (c *Client) TestConnection() error { - _, err := c.GetSystemStatus() - return err + return nil, fmt.Errorf("no movie match found for: %s (%d)", title, year) } -// GetMovieByIMDbID retrieves a movie by its IMDb ID func (c *Client) GetMovieByIMDbID(imdbID string) (*Movie, error) { - // Normalize IMDb ID format if !strings.HasPrefix(imdbID, "tt") { imdbID = "tt" + imdbID } - + movies, err := c.GetAllMovies() if err != nil { return nil, err } - - for _, movie := range movies { - if movie.IMDbID == imdbID { - return &movie, nil + + for i := range movies { + if movies[i].IMDbID == imdbID { + return &movies[i], nil } } - + return nil, fmt.Errorf("movie with IMDb ID %s not found", imdbID) } -// GetMovieByPath attempts to find a movie by its file path func (c *Client) GetMovieByPath(filePath string) (*Movie, error) { movies, err := c.GetAllMovies() if err != nil { return nil, err } - - // Normalize the file path for comparison + filePathLower := strings.ToLower(filePath) - - for _, movie := range movies { - // Check if the file path is within the movie's folder - if movie.Path != "" && strings.Contains(filePathLower, strings.ToLower(movie.Path)) { - return &movie, nil + + for i := range movies { + if movies[i].Path != "" && strings.Contains(filePathLower, strings.ToLower(movies[i].Path)) { + return &movies[i], nil } - - // Also check against the movie file path if available - if movie.HasFile && movie.MovieFile.Path != "" { - if strings.EqualFold(movie.MovieFile.Path, filePath) || - strings.Contains(filePathLower, strings.ToLower(movie.MovieFile.Path)) { - return &movie, nil + if movies[i].HasFile && movies[i].MovieFile.Path != "" { + if strings.EqualFold(movies[i].MovieFile.Path, filePath) || + strings.Contains(filePathLower, strings.ToLower(movies[i].MovieFile.Path)) { + return &movies[i], nil } } } - + return nil, fmt.Errorf("movie not found for path: %s", filePath) } -// GetTMDbIDFromMovie extracts the TMDb ID from a Radarr movie func (c *Client) GetTMDbIDFromMovie(movie *Movie) string { if movie.TMDbID > 0 { return strconv.Itoa(movie.TMDbID) } return "" -} \ No newline at end of file +} + +func (c *Client) GetSystemStatus() (*SystemStatus, error) { + resp, err := c.makeRequest("GET", "/api/v3/system/status", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var status SystemStatus + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + return nil, fmt.Errorf("error decoding system status: %w", err) + } + + return &status, nil +} + +func (c *Client) TestConnection() error { + _, err := c.GetSystemStatus() + return err +} diff --git a/internal/sonarr/client.go b/internal/sonarr/client.go index 89c5cc2..8cee7cd 100644 --- a/internal/sonarr/client.go +++ b/internal/sonarr/client.go @@ -5,23 +5,33 @@ import ( "fmt" "net/http" "net/url" + "regexp" "strconv" "strings" + "sync" "time" ) -// Client represents a Sonarr API client +var nonAlphanumeric = regexp.MustCompile(`[^a-z0-9]`) + +func cleanTitle(s string) string { + return nonAlphanumeric.ReplaceAllString(strings.ToLower(s), "") +} + +func containsEither(a, b string) bool { + return strings.Contains(a, b) || strings.Contains(b, a) +} + type Client struct { baseURL string apiKey string httpClient *http.Client + series []Series + seriesMu sync.Mutex } -// NewClient creates a new Sonarr API client func NewClient(baseURL, apiKey string) *Client { - // Ensure baseURL doesn't have trailing slash baseURL = strings.TrimRight(baseURL, "/") - return &Client{ baseURL: baseURL, apiKey: apiKey, @@ -31,216 +41,225 @@ func NewClient(baseURL, apiKey string) *Client { } } -// makeRequest performs an API request to Sonarr func (c *Client) makeRequest(method, endpoint string, params url.Values) (*http.Response, error) { fullURL := fmt.Sprintf("%s%s", c.baseURL, endpoint) - + if params != nil && len(params) > 0 { fullURL = fmt.Sprintf("%s?%s", fullURL, params.Encode()) } - + req, err := http.NewRequest(method, fullURL, nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } - + req.Header.Set("X-Api-Key", c.apiKey) req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - + resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("error making request: %w", err) } - + if resp.StatusCode != http.StatusOK { resp.Body.Close() return nil, fmt.Errorf("sonarr API returned status %d", resp.StatusCode) } - + return resp, nil } -// GetAllSeries retrieves all TV series from Sonarr +// GetAllSeries fetches the full series list from Sonarr, caching the result +// for the lifetime of the client. Call ClearCache to refresh. func (c *Client) GetAllSeries() ([]Series, error) { + c.seriesMu.Lock() + defer c.seriesMu.Unlock() + + if c.series != nil { + return c.series, nil + } + resp, err := c.makeRequest("GET", "/api/v3/series", nil) if err != nil { return nil, err } defer resp.Body.Close() - + var series []Series if err := json.NewDecoder(resp.Body).Decode(&series); err != nil { return nil, fmt.Errorf("error decoding series: %w", err) } - + + c.series = series return series, nil } -// GetSeriesByTMDbID retrieves a series by its TMDb ID -func (c *Client) GetSeriesByTMDbID(tmdbID int) (*Series, error) { - series, err := c.GetAllSeries() +// ClearCache forces the next GetAllSeries call to re-fetch from Sonarr. +func (c *Client) ClearCache() { + c.seriesMu.Lock() + c.series = nil + c.seriesMu.Unlock() +} + +// SearchSeriesByTitle returns all series whose title, sort title, clean title, +// or alternate titles match the query. Matching is bidirectional and also checks +// a cleaned/normalized form for punctuation-insensitive matching. +func (c *Client) SearchSeriesByTitle(title string) ([]Series, error) { + allSeries, err := c.GetAllSeries() if err != nil { return nil, err } - - for _, s := range series { - if s.TMDBID == tmdbID { - return &s, nil + + titleLower := strings.ToLower(title) + titleClean := cleanTitle(title) + + var matches []Series + for _, s := range allSeries { + if seriesTitleMatches(titleLower, titleClean, s) { + matches = append(matches, s) } } - - return nil, fmt.Errorf("series with TMDb ID %d not found", tmdbID) + + return matches, nil } -// GetSeriesByTVDbID retrieves a series by its TVDb ID -func (c *Client) GetSeriesByTVDbID(tvdbID int) (*Series, error) { - series, err := c.GetAllSeries() - if err != nil { - return nil, err +func seriesTitleMatches(titleLower, titleClean string, s Series) bool { + sTitleLower := strings.ToLower(s.Title) + sSortLower := strings.ToLower(s.SortTitle) + + if containsEither(sTitleLower, titleLower) || containsEither(sSortLower, titleLower) { + return true } - - for _, s := range series { - if s.TVDbID == tvdbID { - return &s, nil - } + + if s.CleanTitle != "" && (s.CleanTitle == titleClean || strings.Contains(s.CleanTitle, titleClean) || strings.Contains(titleClean, s.CleanTitle)) { + return true } - - return nil, fmt.Errorf("series with TVDb ID %d not found", tvdbID) -} -// SearchSeriesByTitle searches for series by title -func (c *Client) SearchSeriesByTitle(title string) ([]Series, error) { - // First try to get all series and filter locally - // This is more reliable than using Sonarr's search endpoint - allSeries, err := c.GetAllSeries() - if err != nil { - return nil, err + if cleanTitle(s.Title) == titleClean { + return true } - - var matches []Series - titleLower := strings.ToLower(title) - - for _, series := range allSeries { - if strings.Contains(strings.ToLower(series.Title), titleLower) || - strings.Contains(strings.ToLower(series.SortTitle), titleLower) { - matches = append(matches, series) - continue - } - - // Check alternate titles - for _, altTitle := range series.AlternateTitles { - if strings.Contains(strings.ToLower(altTitle.Title), titleLower) { - matches = append(matches, series) - break - } + + for _, alt := range s.AlternateTitles { + altLower := strings.ToLower(alt.Title) + if containsEither(altLower, titleLower) { + return true } } - - return matches, nil + + return false } -// FindSeriesMatch attempts to find the best match for a series by title and year +// FindSeriesMatch finds the best match for a series by title and year. func (c *Client) FindSeriesMatch(title string, year int) (*Series, error) { series, err := c.SearchSeriesByTitle(title) if err != nil { return nil, err } - - // First try exact title and year match + titleLower := strings.ToLower(title) - for _, s := range series { - if strings.ToLower(s.Title) == titleLower && s.Year == year { - return &s, nil + titleClean := cleanTitle(title) + + // Exact title + year + for i := range series { + if strings.ToLower(series[i].Title) == titleLower && series[i].Year == year { + return &series[i], nil } } - - // Then try year match with similar title - for _, s := range series { - if s.Year == year { - return &s, nil + + // CleanTitle + year + for i := range series { + if series[i].Year == year && (series[i].CleanTitle == titleClean || cleanTitle(series[i].Title) == titleClean) { + return &series[i], nil } } - - // If still no match, try within 1 year range - for _, s := range series { - if s.Year >= year-1 && s.Year <= year+1 { - return &s, nil + + // Year match with any title hit + for i := range series { + if series[i].Year == year { + return &series[i], nil } } - - // Return first match if any found + + // Within 1 year + for i := range series { + if series[i].Year >= year-1 && series[i].Year <= year+1 { + return &series[i], nil + } + } + if len(series) > 0 { return &series[0], nil } - + return nil, fmt.Errorf("no series match found for: %s (%d)", title, year) } -// GetSystemStatus retrieves Sonarr system status (useful for testing connection) -func (c *Client) GetSystemStatus() (*SystemStatus, error) { - resp, err := c.makeRequest("GET", "/api/v3/system/status", nil) +func (c *Client) GetSeriesByTMDbID(tmdbID int) (*Series, error) { + series, err := c.GetAllSeries() if err != nil { return nil, err } - defer resp.Body.Close() - - var status SystemStatus - if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { - return nil, fmt.Errorf("error decoding system status: %w", err) + + for i := range series { + if series[i].TMDBID == tmdbID { + return &series[i], nil + } } - - return &status, nil + + return nil, fmt.Errorf("series with TMDb ID %d not found", tmdbID) } -// TestConnection tests the connection to Sonarr -func (c *Client) TestConnection() error { - _, err := c.GetSystemStatus() - return err +func (c *Client) GetSeriesByTVDbID(tvdbID int) (*Series, error) { + series, err := c.GetAllSeries() + if err != nil { + return nil, err + } + + for i := range series { + if series[i].TVDbID == tvdbID { + return &series[i], nil + } + } + + return nil, fmt.Errorf("series with TVDb ID %d not found", tvdbID) } -// GetSeriesByIMDbID retrieves a series by its IMDb ID func (c *Client) GetSeriesByIMDbID(imdbID string) (*Series, error) { - // Normalize IMDb ID format if !strings.HasPrefix(imdbID, "tt") { imdbID = "tt" + imdbID } - + series, err := c.GetAllSeries() if err != nil { return nil, err } - - for _, s := range series { - if s.IMDBID == imdbID { - return &s, nil + + for i := range series { + if series[i].IMDBID == imdbID { + return &series[i], nil } } - + return nil, fmt.Errorf("series with IMDb ID %s not found", imdbID) } -// GetSeriesByPath attempts to find a series by its file path func (c *Client) GetSeriesByPath(filePath string) (*Series, error) { series, err := c.GetAllSeries() if err != nil { return nil, err } - - // Normalize the file path for comparison + filePathLower := strings.ToLower(filePath) - - for _, s := range series { - // Check if the file path is within the series' folder - if s.Path != "" && strings.Contains(filePathLower, strings.ToLower(s.Path)) { - return &s, nil + + for i := range series { + if series[i].Path != "" && strings.Contains(filePathLower, strings.ToLower(series[i].Path)) { + return &series[i], nil } } - + return nil, fmt.Errorf("series not found for path: %s", filePath) } -// GetTMDbIDFromSeries extracts the TMDb ID from a Sonarr series func (c *Client) GetTMDbIDFromSeries(series *Series) string { if series.TMDBID > 0 { return strconv.Itoa(series.TMDBID) @@ -248,21 +267,40 @@ func (c *Client) GetTMDbIDFromSeries(series *Series) string { return "" } -// GetEpisodesBySeries gets all episodes for a series func (c *Client) GetEpisodesBySeries(seriesID int) ([]Episode, error) { params := url.Values{} params.Set("seriesId", strconv.Itoa(seriesID)) - + resp, err := c.makeRequest("GET", "/api/v3/episode", params) if err != nil { return nil, err } defer resp.Body.Close() - + var episodes []Episode if err := json.NewDecoder(resp.Body).Decode(&episodes); err != nil { return nil, fmt.Errorf("error decoding episodes: %w", err) } - + return episodes, nil -} \ No newline at end of file +} + +func (c *Client) GetSystemStatus() (*SystemStatus, error) { + resp, err := c.makeRequest("GET", "/api/v3/system/status", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var status SystemStatus + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + return nil, fmt.Errorf("error decoding system status: %w", err) + } + + return &status, nil +} + +func (c *Client) TestConnection() error { + _, err := c.GetSystemStatus() + return err +} From 4882ae518ca906d61e85259e7c3968f8f35e9ec0 Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Fri, 10 Apr 2026 13:16:47 -0400 Subject: [PATCH 06/18] fix: remove last emojis from plex/client.go Replace globe and timer emojis in verbose Plex API logging with bracketed tags [API] and [TIMING]. --- internal/plex/client.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/plex/client.go b/internal/plex/client.go index 0e3d5e5..c30c34e 100644 --- a/internal/plex/client.go +++ b/internal/plex/client.go @@ -141,7 +141,7 @@ func (c *Client) GetMovieDetails(ratingKey string) (*Movie, error) { // UpdateMediaField updates a media item's field (labels or genres) with new keywords func (c *Client) UpdateMediaField(mediaID, libraryID string, keywords []string, updateField string, mediaType string) error { if c.config.VerboseLogging { - fmt.Printf(" 🌐 Making Plex API call to update %s field with %d keywords\n", updateField, len(keywords)) + fmt.Printf(" [API] Making Plex API call to update %s field with %d keywords\n", updateField, len(keywords)) } return c.updateMediaField(mediaID, libraryID, keywords, updateField, c.getMediaTypeForLibraryType(mediaType)) } @@ -342,7 +342,7 @@ func (c *Client) updateMediaField(mediaID, libraryID string, keywords []string, if c.config.VerboseLogging { duration := time.Since(startTime) - fmt.Printf(" ⏱️ Plex API call completed in %v\n", duration) + fmt.Printf(" [TIMING] Plex API call completed in %v\n", duration) } return nil From a59632dfd0cc4ab0709482ffe5e14f33a8d20108 Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Fri, 10 Apr 2026 14:23:36 -0400 Subject: [PATCH 07/18] improve: clean up verbose logging for TMDb ID lookup failures - Replace [ERROR] with [SKIP] for expected lookup misses (not found in Radarr/Sonarr, no TMDb ID in path). Keep [ERROR] only for real API/fetch failures. - Collapse duplicate file path iteration: check both API path match and TMDb ID regex in a single pass instead of listing paths twice. - Cap file path logging at 3 paths with "and N more" summary. - Consolidate episode fetching in TV show extraction to a single call instead of fetching twice (once for Sonarr, again for regex). - Shorter, more scannable log format for lookup results. --- internal/media/processor.go | 245 +++++++++++++----------------------- 1 file changed, 84 insertions(+), 161 deletions(-) diff --git a/internal/media/processor.go b/internal/media/processor.go index 8d38090..eb906ce 100644 --- a/internal/media/processor.go +++ b/internal/media/processor.go @@ -806,266 +806,182 @@ func (p *Processor) extractTMDbID(item MediaItem, mediaType MediaType) string { // extractMovieTMDbID extracts TMDb ID from movie metadata or file paths func (p *Processor) extractMovieTMDbID(item MediaItem) string { - if p.config.VerboseLogging { - fmt.Printf("\n[LOOKUP] Starting TMDb ID lookup for movie: %s (%d)\n", item.GetTitle(), item.GetYear()) - fmt.Printf(" [INFO] Available Plex GUIDs:\n") - for _, guid := range item.GetGuid() { - fmt.Printf(" - %s\n", guid.ID) - } + verbose := p.config.VerboseLogging + if verbose { + fmt.Printf("\n[LOOKUP] Movie: %s (%d)\n", item.GetTitle(), item.GetYear()) } - // First, try to get TMDb ID from Plex metadata + // 1. Plex metadata for _, guid := range item.GetGuid() { if strings.Contains(guid.ID, "tmdb://") { parts := strings.Split(guid.ID, "//") if len(parts) > 1 { tmdbID := strings.Split(parts[1], "?")[0] - if p.config.VerboseLogging { - fmt.Printf(" [OK] Found TMDb ID in Plex metadata: %s\n", tmdbID) + if verbose { + fmt.Printf(" [OK] Plex metadata: %s\n", tmdbID) } return tmdbID } } } - // If Radarr is enabled, try to match via Radarr + // 2. Radarr lookup (title/year, then IMDb ID) if p.config.UseRadarr && p.radarrClient != nil { - if p.config.VerboseLogging { - fmt.Printf(" [MOVIE] Checking Radarr for movie match...\n") - } - - // Try to match by title and year first - if p.config.VerboseLogging { - fmt.Printf(" -> Searching by title: \"%s\" year: %d\n", item.GetTitle(), item.GetYear()) - } movie, err := p.radarrClient.FindMovieMatch(item.GetTitle(), item.GetYear()) if err == nil && movie != nil { tmdbID := p.radarrClient.GetTMDbIDFromMovie(movie) - if p.config.VerboseLogging { - fmt.Printf(" [OK] Found match in Radarr: %s (TMDb: %s)\n", movie.Title, tmdbID) + if verbose { + fmt.Printf(" [OK] Radarr match: %s (TMDb: %s)\n", movie.Title, tmdbID) } return tmdbID - } else if p.config.VerboseLogging { - fmt.Printf(" [ERROR] No match found by title/year\n") + } else if verbose { + fmt.Printf(" [SKIP] No Radarr match by title/year\n") } - // Try to match by file path - if p.config.VerboseLogging { - fmt.Printf(" -> Searching by file path...\n") - } - for _, mediaItem := range item.GetMedia() { - for _, part := range mediaItem.Part { - if p.config.VerboseLogging { - fmt.Printf(" - Checking: %s\n", part.File) - } - movie, err := p.radarrClient.GetMovieByPath(part.File) - if err == nil && movie != nil { - tmdbID := p.radarrClient.GetTMDbIDFromMovie(movie) - if p.config.VerboseLogging { - fmt.Printf(" [OK] Found match by file path: %s (TMDb: %s)\n", movie.Title, tmdbID) - } - return tmdbID - } - } - } - if p.config.VerboseLogging { - fmt.Printf(" [ERROR] No match found by file path\n") - } - - // Try to match by IMDb ID if available for _, guid := range item.GetGuid() { if strings.Contains(guid.ID, "imdb://") { imdbID := strings.TrimPrefix(guid.ID, "imdb://") - if p.config.VerboseLogging { - fmt.Printf(" -> Searching by IMDb ID: %s\n", imdbID) - } movie, err := p.radarrClient.GetMovieByIMDbID(imdbID) if err == nil && movie != nil { tmdbID := p.radarrClient.GetTMDbIDFromMovie(movie) - if p.config.VerboseLogging { - fmt.Printf(" [OK] Found match by IMDb ID: %s (TMDb: %s)\n", movie.Title, tmdbID) + if verbose { + fmt.Printf(" [OK] Radarr match by IMDb %s: %s (TMDb: %s)\n", imdbID, movie.Title, tmdbID) } return tmdbID - } else if p.config.VerboseLogging { - fmt.Printf(" [ERROR] No match found by IMDb ID\n") + } else if verbose { + fmt.Printf(" [SKIP] No Radarr match by IMDb ID %s\n", imdbID) } } } } - // If not found in Radarr or Radarr not enabled, try to extract from file paths - if p.config.VerboseLogging { - fmt.Printf(" [STORAGE] Checking file paths for TMDb ID pattern...\n") - } + // 3. File paths: check Radarr path match AND TMDb ID regex in one pass + logged := 0 for _, mediaItem := range item.GetMedia() { for _, part := range mediaItem.Part { - if p.config.VerboseLogging { - fmt.Printf(" - Checking: %s\n", part.File) + if verbose && logged < 3 { + fmt.Printf(" [INFO] Checking path: %s\n", part.File) + logged++ + } + if p.config.UseRadarr && p.radarrClient != nil { + movie, err := p.radarrClient.GetMovieByPath(part.File) + if err == nil && movie != nil { + tmdbID := p.radarrClient.GetTMDbIDFromMovie(movie) + if verbose { + fmt.Printf(" [OK] Radarr path match: %s (TMDb: %s)\n", movie.Title, tmdbID) + } + return tmdbID + } } if tmdbID := ExtractTMDbIDFromPath(part.File); tmdbID != "" { - if p.config.VerboseLogging { - fmt.Printf(" [OK] Found TMDb ID in file path: %s\n", tmdbID) + if verbose { + fmt.Printf(" [OK] TMDb ID in file path: %s\n", tmdbID) } return tmdbID } } } - if p.config.VerboseLogging { - fmt.Printf(" [ERROR] No TMDb ID found for movie: %s\n", item.GetTitle()) + if verbose { + fmt.Printf(" [SKIP] No TMDb ID found for: %s\n", item.GetTitle()) } - return "" } // extractTVShowTMDbID extracts TMDb ID from TV show metadata or episode file paths func (p *Processor) extractTVShowTMDbID(item MediaItem) string { - if p.config.VerboseLogging { - fmt.Printf("\n[LOOKUP] Starting TMDb ID lookup for TV show: %s (%d)\n", item.GetTitle(), item.GetYear()) - fmt.Printf(" [INFO] Available Plex GUIDs:\n") - for _, guid := range item.GetGuid() { - fmt.Printf(" - %s\n", guid.ID) - } + verbose := p.config.VerboseLogging + if verbose { + fmt.Printf("\n[LOOKUP] TV show: %s (%d)\n", item.GetTitle(), item.GetYear()) } - // First check if we have TMDb GUID in the TV show metadata + // 1. Plex metadata for _, guid := range item.GetGuid() { if strings.HasPrefix(guid.ID, "tmdb://") { tmdbID := strings.TrimPrefix(guid.ID, "tmdb://") - if p.config.VerboseLogging { - fmt.Printf(" [OK] Found TMDb ID in Plex metadata: %s\n", tmdbID) + if verbose { + fmt.Printf(" [OK] Plex metadata: %s\n", tmdbID) } return tmdbID } } - // If Sonarr is enabled, try to match via Sonarr + // 2. Sonarr lookup (title/year, TVDb ID, IMDb ID) if p.config.UseSonarr && p.sonarrClient != nil { - if p.config.VerboseLogging { - fmt.Printf(" [TV] Checking Sonarr for series match...\n") - } - - // Try to match by title and year first - if p.config.VerboseLogging { - fmt.Printf(" -> Searching by title: \"%s\" year: %d\n", item.GetTitle(), item.GetYear()) - } series, err := p.sonarrClient.FindSeriesMatch(item.GetTitle(), item.GetYear()) if err == nil && series != nil { tmdbID := p.sonarrClient.GetTMDbIDFromSeries(series) - if p.config.VerboseLogging { - fmt.Printf(" [OK] Found match in Sonarr: %s (TMDb: %s)\n", series.Title, tmdbID) + if verbose { + fmt.Printf(" [OK] Sonarr match: %s (TMDb: %s)\n", series.Title, tmdbID) } return tmdbID - } else if p.config.VerboseLogging { - fmt.Printf(" [ERROR] No match found by title/year\n") + } else if verbose { + fmt.Printf(" [SKIP] No Sonarr match by title/year\n") } - // Try to match by TVDb ID if available for _, guid := range item.GetGuid() { if strings.Contains(guid.ID, "tvdb://") { tvdbIDStr := strings.TrimPrefix(guid.ID, "tvdb://") - // Parse TVDb ID to int var tvdbID int if _, err := fmt.Sscanf(tvdbIDStr, "%d", &tvdbID); err == nil { - if p.config.VerboseLogging { - fmt.Printf(" -> Searching by TVDb ID: %d\n", tvdbID) - } series, err := p.sonarrClient.GetSeriesByTVDbID(tvdbID) if err == nil && series != nil { tmdbID := p.sonarrClient.GetTMDbIDFromSeries(series) - if p.config.VerboseLogging { - fmt.Printf(" [OK] Found match by TVDb ID: %s (TMDb: %s)\n", series.Title, tmdbID) + if verbose { + fmt.Printf(" [OK] Sonarr match by TVDb %d: %s (TMDb: %s)\n", tvdbID, series.Title, tmdbID) } return tmdbID - } else if p.config.VerboseLogging { - fmt.Printf(" [ERROR] No match found by TVDb ID\n") + } else if verbose { + fmt.Printf(" [SKIP] No Sonarr match by TVDb ID %d\n", tvdbID) } } } - } - - // Try to match by IMDb ID if available - for _, guid := range item.GetGuid() { if strings.Contains(guid.ID, "imdb://") { imdbID := strings.TrimPrefix(guid.ID, "imdb://") - if p.config.VerboseLogging { - fmt.Printf(" -> Searching by IMDb ID: %s\n", imdbID) - } series, err := p.sonarrClient.GetSeriesByIMDbID(imdbID) if err == nil && series != nil { tmdbID := p.sonarrClient.GetTMDbIDFromSeries(series) - if p.config.VerboseLogging { - fmt.Printf(" [OK] Found match by IMDb ID: %s (TMDb: %s)\n", series.Title, tmdbID) + if verbose { + fmt.Printf(" [OK] Sonarr match by IMDb %s: %s (TMDb: %s)\n", imdbID, series.Title, tmdbID) } return tmdbID - } else if p.config.VerboseLogging { - fmt.Printf(" [ERROR] No match found by IMDb ID\n") - } - } - } - - // Try to match by file path from episodes - if p.config.VerboseLogging { - fmt.Printf(" -> Searching by episode file paths...\n") - } - episodes, err := p.plexClient.GetTVShowEpisodes(item.GetRatingKey()) - if err == nil { - episodeCount := 0 - for _, episode := range episodes { - for _, mediaItem := range episode.Media { - for _, part := range mediaItem.Part { - episodeCount++ - if episodeCount <= 5 && p.config.VerboseLogging { - fmt.Printf(" - Checking: %s\n", part.File) - } - series, err := p.sonarrClient.GetSeriesByPath(part.File) - if err == nil && series != nil { - tmdbID := p.sonarrClient.GetTMDbIDFromSeries(series) - if p.config.VerboseLogging { - fmt.Printf(" [OK] Found match by file path: %s (TMDb: %s)\n", series.Title, tmdbID) - } - return tmdbID - } - } + } else if verbose { + fmt.Printf(" [SKIP] No Sonarr match by IMDb ID %s\n", imdbID) } } - if episodeCount > 5 && p.config.VerboseLogging { - fmt.Printf(" ... and %d more episodes\n", episodeCount-5) - } - if p.config.VerboseLogging { - fmt.Printf(" [ERROR] No match found by file path\n") - } - } else if p.config.VerboseLogging { - fmt.Printf(" [WARN] Could not fetch episodes: %v\n", err) } } - // If no TMDb GUID found and Sonarr not enabled, get episodes and check their file paths - if p.config.VerboseLogging { - fmt.Printf(" [STORAGE] Checking episode file paths for TMDb ID pattern...\n") - } + // 3. Episode file paths: check Sonarr path match AND TMDb ID regex in one pass episodes, err := p.plexClient.GetTVShowEpisodes(item.GetRatingKey()) if err != nil { - if p.config.VerboseLogging { - fmt.Printf(" [WARN] Error fetching episodes: %v\n", err) - } else { - fmt.Printf("[WARN] Error fetching episodes for %s: %v\n", item.GetTitle(), err) + if verbose { + fmt.Printf(" [WARN] Could not fetch episodes: %v\n", err) } return "" } - // Check file paths in episodes for TMDb ID - stop at first match - episodeCount := 0 + logged := 0 for _, episode := range episodes { for _, mediaItem := range episode.Media { for _, part := range mediaItem.Part { - episodeCount++ - if episodeCount <= 5 && p.config.VerboseLogging { - fmt.Printf(" - Checking: %s\n", part.File) + if verbose && logged < 3 { + fmt.Printf(" [INFO] Checking path: %s\n", part.File) + logged++ + } + if p.config.UseSonarr && p.sonarrClient != nil { + series, err := p.sonarrClient.GetSeriesByPath(part.File) + if err == nil && series != nil { + tmdbID := p.sonarrClient.GetTMDbIDFromSeries(series) + if verbose { + fmt.Printf(" [OK] Sonarr path match: %s (TMDb: %s)\n", series.Title, tmdbID) + } + return tmdbID + } } if tmdbID := ExtractTMDbIDFromPath(part.File); tmdbID != "" { - if p.config.VerboseLogging { - fmt.Printf(" [OK] Found TMDb ID in file path: %s\n", tmdbID) + if verbose { + fmt.Printf(" [OK] TMDb ID in file path: %s\n", tmdbID) } return tmdbID } @@ -1073,14 +989,21 @@ func (p *Processor) extractTVShowTMDbID(item MediaItem) string { } } - if episodeCount > 5 && p.config.VerboseLogging { - fmt.Printf(" ... and %d more episodes\n", episodeCount-5) + if verbose && logged > 0 { + totalPaths := 0 + for _, ep := range episodes { + for _, m := range ep.Media { + totalPaths += len(m.Part) + } + } + if totalPaths > 3 { + fmt.Printf(" [INFO] ... and %d more paths checked\n", totalPaths-3) + } } - if p.config.VerboseLogging { - fmt.Printf(" [ERROR] No TMDb ID found for TV show: %s\n", item.GetTitle()) + if verbose { + fmt.Printf(" [SKIP] No TMDb ID found for: %s\n", item.GetTitle()) } - return "" } From c59950a6b2f9cec384f55277941b5ab9ca18cd8f Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Fri, 10 Apr 2026 14:30:33 -0400 Subject: [PATCH 08/18] improve: rewrite CLAUDE.md with build commands, architecture, conventions Add build/test/deploy commands (Docker + Portainer webhook), architecture overview, coding conventions, key patterns, and gotchas. --- CLAUDE.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9236c57 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,76 @@ +## Critical Rules + +- NEVER create mock data or simplified components unless explicitly told to +- NEVER replace existing complex components with simplified versions -- fix the actual problem +- ALWAYS work with the existing codebase -- do not create new simplified alternatives +- ALWAYS find and fix the root cause instead of creating workarounds +- ALWAYS track changes in CHANGELOG.md +- ALWAYS refer to CHANGELOG.md when working on tasks +- ALWAYS verify the app builds successfully before declaring done +- Fix all review findings -- never skip items as "not worth the churn" + +## Build and Test + +Go is not installed locally. Use Docker for all build verification: + +```bash +docker build -t labelarr:test . # Verify compilation +docker build --platform linux/amd64 -t ttlequals0/labelarr:latest . # Production build +docker push ttlequals0/labelarr:latest # Push to Docker Hub +``` + +Format Go code via Docker (macOS `sed` is aliased to `gsed`): + +```bash +docker run --rm -v $(pwd):/app -w /app golang:1.21-alpine gofmt -w +``` + +Deploy to Portainer after pushing the Docker image: + +```bash +curl -X POST https://portainer.ttlequals0.com/api/stacks/webhooks/01e4c5be-188c-4ce7-9213-d42b31a78851 +``` + +## Architecture + +Go 1.21 service. No external Go dependencies (stdlib only). Runs as a Docker container. + +``` +cmd/labelarr/main.go Entry point, client init, processing loop +internal/config/config.go Env var loading, Config struct, validation +internal/media/processor.go Core processing logic (batch iteration, keyword sync, TMDb ID extraction) +internal/plex/client.go Plex API client (libraries, items, label/genre updates) +internal/tmdb/client.go TMDb API client (keyword fetching, connection testing) +internal/radarr/client.go Radarr API client (movie lookup, cached library fetch) +internal/sonarr/client.go Sonarr API client (series lookup, cached library fetch) +internal/webhook/server.go Plex webhook HTTP server (debounce, graceful shutdown) +internal/export/export.go Label-based file path export (txt/json) +internal/storage/storage.go JSON persistence for processed items +internal/utils/normalize.go Keyword normalization (90+ test cases) +``` + +## Coding Conventions + +- No emoji in Go source. Log tags use bracketed format: `[OK]`, `[ERROR]`, `[SKIP]`, `[WARN]`, `[INFO]`, `[LOOKUP]`, etc. +- `[ERROR]` is reserved for actual failures (API errors, decode errors). Expected lookup misses use `[SKIP]`. +- No WHAT comments (comments that describe what the next line does). Only add comments explaining WHY. +- ASCII only in all output -- no em dashes, smart quotes, or unicode arrows. +- Use `fmt.Printf` for all logging (no log package). + +## Key Patterns + +- `NewProcessor` accepts a `Clients` struct, not individual client args +- Radarr/Sonarr clients cache `GetAllMovies()`/`GetAllSeries()` results. Call `ClearCache()` to refresh. +- `Processor.ClearCaches()` resets keyword cache + Radarr/Sonarr caches. Called at the start of each processing cycle. +- `Processor.ProcessAllItems()` has a per-library mutex guard -- safe to call from both timer and webhook goroutines. +- Keyword cache (`keywordCache`) is protected by `sync.RWMutex`. +- Batch iteration uses `makeBatches()` / `logStart()` / `pauseAfterBatch()` helpers. + +## Gotchas + +- macOS `sed` is aliased to `gsed` (GNU sed). Use `gsed -i` not `sed -i ''`. +- `strings.Title` is deprecated in Go 1.18+. Use manual capitalization instead. +- Plex webhooks require Plex Pass. The webhook payload uses `LibrarySectionType` ("movie"/"show") not `Metadata.Type` ("movie"/"episode") for media type resolution. +- `DATA_DIR` defaults to empty (ephemeral mode). Storage is only created when `DATA_DIR` is explicitly set. +- Docker image: `ttlequals0/labelarr:latest` on Docker Hub. +- Upstream repo: `nullable-eth/labelarr`. Our fork adds batch processing, keyword prefix, webhooks. From 7d1c6cccb7b547d50d37bf29bba3255cad6d387a Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Fri, 10 Apr 2026 14:35:14 -0400 Subject: [PATCH 09/18] feat: add version tracking, log v1.2.0 on startup - Create internal/version/version.go as single source of truth - Log version at startup before config loading - Tag Docker images with version (1.2.0 + latest) - Update CHANGELOG with 1.2.0 release date --- CHANGELOG.md | 6 +++++- cmd/labelarr/main.go | 3 +++ internal/version/version.go | 3 +++ 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 internal/version/version.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e9eae76..d987198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [1.2.0] - 2026-04-10 ### Added @@ -21,6 +21,10 @@ - Prevents API flooding on large libraries (4000+ items) - ITEM_DELAY (default 500ms) controls per-item pacing +#### Version Tracking +- Version constant in internal/version/version.go +- Logs version on startup + ### Changed - Removed all emoji from log output; replaced with bracketed tags - Extracted Clients struct for processor initialization diff --git a/cmd/labelarr/main.go b/cmd/labelarr/main.go index d0706f1..7d5559e 100644 --- a/cmd/labelarr/main.go +++ b/cmd/labelarr/main.go @@ -14,10 +14,13 @@ import ( "github.com/nullable-eth/labelarr/internal/radarr" "github.com/nullable-eth/labelarr/internal/sonarr" "github.com/nullable-eth/labelarr/internal/tmdb" + "github.com/nullable-eth/labelarr/internal/version" "github.com/nullable-eth/labelarr/internal/webhook" ) func main() { + fmt.Printf("[INFO] Labelarr v%s\n", version.Version) + cfg := config.Load() if err := cfg.Validate(); err != nil { diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..15f2623 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,3 @@ +package version + +const Version = "1.2.0" From 145b2e1434f2070fe7272773031684cda0d578aa Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Sat, 11 Apr 2026 19:56:04 -0400 Subject: [PATCH 10/18] feat: webhook processes single items instead of full library scan - Add ProcessSingleItem method that tags only the newly added item - Webhook accumulates rating keys during debounce window instead of keeping only the last one (fixes dropped items on rapid events) - ProcessSingleItem acquires the per-library mutex to prevent races with timer-triggered ProcessAllItems - ProcessSingleItem includes export logic for consistency - Remove library.on.deck event handling (fires on playback, not new media -- was triggering unnecessary reprocessing) - Falls back to full library scan when no rating key is in the payload --- internal/media/processor.go | 116 ++++++++++++++++++++++++++++++ internal/webhook/server.go | 140 +++++++++++++++++++++--------------- 2 files changed, 198 insertions(+), 58 deletions(-) diff --git a/internal/media/processor.go b/internal/media/processor.go index eb906ce..dac0714 100644 --- a/internal/media/processor.go +++ b/internal/media/processor.go @@ -190,6 +190,122 @@ func (p *Processor) applyKeywordPrefix(keywords []string) []string { } // ProcessAllItems processes all items in the specified library +// ProcessSingleItem processes a single item by rating key. Used by webhooks to +// tag only the newly added item instead of scanning the entire library. +// ProcessSingleItem processes a single item by rating key. Used by webhooks to +// tag only the newly added item instead of scanning the entire library. +func (p *Processor) ProcessSingleItem(ratingKey, libraryID string, mediaType MediaType) error { + p.processingMu.Lock() + if p.processing[libraryID] { + p.processingMu.Unlock() + fmt.Printf("[INFO] Library %s is already being processed, queuing item %s for next cycle\n", libraryID, ratingKey) + return nil + } + p.processing[libraryID] = true + p.processingMu.Unlock() + defer func() { + p.processingMu.Lock() + delete(p.processing, libraryID) + p.processingMu.Unlock() + }() + + var item MediaItem + + switch mediaType { + case MediaTypeMovie: + movie, err := p.plexClient.GetMovieDetails(ratingKey) + if err != nil { + return fmt.Errorf("failed to fetch movie %s: %w", ratingKey, err) + } + item = movie + case MediaTypeTV: + show, err := p.plexClient.GetTVShowDetails(ratingKey) + if err != nil { + return fmt.Errorf("failed to fetch TV show %s: %w", ratingKey, err) + } + item = show + default: + return fmt.Errorf("unsupported media type: %s", mediaType) + } + + fmt.Printf("[WEBHOOK] Processing single item: %s (%d)\n", item.GetTitle(), item.GetYear()) + + tmdbID := p.extractTMDbID(item, mediaType) + if tmdbID == "" { + fmt.Printf("[SKIP] No TMDb ID found for: %s\n", item.GetTitle()) + return nil + } + + keywords, err := p.getKeywords(tmdbID, mediaType) + if err != nil { + return fmt.Errorf("failed to fetch keywords for TMDb ID %s: %w", tmdbID, err) + } + + keywords = p.applyKeywordPrefix(keywords) + + details, err := p.getItemDetails(item.GetRatingKey(), mediaType) + if err != nil { + return fmt.Errorf("failed to fetch item details: %w", err) + } + + currentValues := p.extractCurrentValues(details) + + currentValuesMap := make(map[string]bool) + for _, val := range currentValues { + currentValuesMap[strings.ToLower(val)] = true + } + + allExist := true + for _, kw := range keywords { + if !currentValuesMap[strings.ToLower(kw)] { + allExist = false + break + } + } + + if allExist && !p.config.ForceUpdate { + fmt.Printf("[OK] %s already has all %d keywords\n", item.GetTitle(), len(keywords)) + return nil + } + + source := p.getTMDbIDSource(item, mediaType, tmdbID) + fmt.Printf("[KEY] TMDb ID: %s (source: %s)\n", tmdbID, source) + fmt.Printf("[SYNC] Applying %d keywords to %s field for %s\n", len(keywords), p.config.UpdateField, item.GetTitle()) + + if err := p.syncFieldWithKeywords(item.GetRatingKey(), libraryID, currentValues, keywords, mediaType); err != nil { + return fmt.Errorf("failed to sync keywords for %s: %w", item.GetTitle(), err) + } + + fmt.Printf("[OK] Successfully applied %d keywords to %s\n", len(keywords), item.GetTitle()) + + // Export if enabled + if p.exporter != nil { + mergedLabels := append(currentValues, keywords...) + fileInfos, err := p.extractFileInfos(details, mediaType) + if err == nil && len(fileInfos) > 0 { + if err := p.exporter.ExportItemWithSizes(item.GetTitle(), mergedLabels, fileInfos); err != nil { + fmt.Printf("[WARN] Export failed for %s: %v\n", item.GetTitle(), err) + } + } + } + + if p.storage != nil { + processedItem := &storage.ProcessedItem{ + RatingKey: item.GetRatingKey(), + Title: item.GetTitle(), + TMDbID: tmdbID, + LastProcessed: time.Now(), + KeywordsSynced: true, + UpdateField: p.config.UpdateField, + } + if err := p.storage.Set(processedItem); err != nil { + fmt.Printf("[WARN] Failed to save processed item to storage: %v\n", err) + } + } + + return nil +} + func (p *Processor) ProcessAllItems(libraryID string, libraryName string, mediaType MediaType) error { p.processingMu.Lock() if p.processing[libraryID] { diff --git a/internal/webhook/server.go b/internal/webhook/server.go index dc229bb..0f774b9 100644 --- a/internal/webhook/server.go +++ b/internal/webhook/server.go @@ -15,10 +15,7 @@ import ( "github.com/nullable-eth/labelarr/internal/plex" ) -const ( - eventLibraryNew = "library.new" - eventLibraryOnDeck = "library.on.deck" -) +const eventLibraryNew = "library.new" // PlexWebhookPayload matches the Plex webhook JSON structure. // Plex sends this as the "payload" field in a multipart/form-data POST. @@ -59,14 +56,22 @@ type libraryInfo struct { mediaType media.MediaType } +// pendingWork tracks accumulated rating keys during a debounce window. +type pendingWork struct { + libraryName string + mediaType media.MediaType + ratingKeys []string + timer *time.Timer + gen uint64 +} + type Server struct { - config *config.Config - processor *media.Processor - httpServer *http.Server - libraryMap map[string]libraryInfo - debounceTimers map[string]*time.Timer - debounceGen map[string]uint64 - debounceMu sync.Mutex + config *config.Config + processor *media.Processor + httpServer *http.Server + libraryMap map[string]libraryInfo + pending map[string]*pendingWork + pendingMu sync.Mutex } func NewServer(cfg *config.Config, proc *media.Processor, movieLibs, tvLibs []plex.Library) *Server { @@ -79,16 +84,13 @@ func NewServer(cfg *config.Config, proc *media.Processor, movieLibs, tvLibs []pl } return &Server{ - config: cfg, - processor: proc, - libraryMap: libMap, - debounceTimers: make(map[string]*time.Timer), - debounceGen: make(map[string]uint64), + config: cfg, + processor: proc, + libraryMap: libMap, + pending: make(map[string]*pendingWork), } } -// Start binds the webhook port and begins serving. Returns an error if the port -// cannot be bound, so the caller knows immediately if startup failed. func (s *Server) Start() error { mux := http.NewServeMux() mux.HandleFunc("/webhook", s.handleWebhook) @@ -119,7 +121,6 @@ func (s *Server) Start() error { return nil } -// Stop gracefully shuts down the webhook server. func (s *Server) Stop(ctx context.Context) error { if s.httpServer != nil { return s.httpServer.Shutdown(ctx) @@ -133,8 +134,6 @@ func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) { return } - // Plex sends multipart/form-data with a "payload" JSON field - // and optionally a thumbnail image if err := r.ParseMultipartForm(1 << 20); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return @@ -161,19 +160,12 @@ func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) { payload.Metadata.Title) } - switch payload.Event { - case eventLibraryNew, eventLibraryOnDeck: - // These are the events fired when new media is added - default: + if payload.Event != eventLibraryNew { w.WriteHeader(http.StatusOK) return } libraryID := strconv.Itoa(payload.Metadata.LibrarySectionID) - - // Use LibrarySectionType to determine media type, falling back to library map. - // Plex Metadata.Type is "movie", "episode", "track" etc. but - // LibrarySectionType is "movie" or "show" which maps to our processing paths. mediaType := s.resolveMediaType(libraryID, payload.Metadata.LibrarySectionType) libraryName := payload.Metadata.LibrarySectionTitle if libraryName == "" { @@ -183,7 +175,7 @@ func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) { } if mediaType != media.MediaTypeUnknown { - s.scheduleProcessing(libraryID, libraryName, mediaType) + s.addPendingItem(libraryID, libraryName, mediaType, payload.Metadata.RatingKey) } else if s.config.VerboseLogging { fmt.Printf("Webhook: ignoring event for unknown library %s (ID: %s)\n", libraryName, libraryID) } @@ -191,8 +183,6 @@ func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } -// resolveMediaType determines the MediaType from the Plex LibrarySectionType field, -// falling back to the pre-built library map if the section type is not recognized. func (s *Server) resolveMediaType(libraryID, sectionType string) media.MediaType { switch sectionType { case "movie": @@ -200,52 +190,86 @@ func (s *Server) resolveMediaType(libraryID, sectionType string) media.MediaType case "show": return media.MediaTypeTV } - // Fall back to library map (built from Plex library list at startup) if info, ok := s.libraryMap[libraryID]; ok { return info.mediaType } return media.MediaTypeUnknown } -func (s *Server) scheduleProcessing(libraryID, libraryName string, mediaType media.MediaType) { +// addPendingItem accumulates rating keys during the debounce window. Each new +// event for the same library resets the timer and adds the rating key to the list. +// When the timer fires, all accumulated keys are processed. +func (s *Server) addPendingItem(libraryID, libraryName string, mediaType media.MediaType, ratingKey string) { debounce := s.config.WebhookDebounce - s.debounceMu.Lock() - defer s.debounceMu.Unlock() - - // Bump generation so any in-flight old callback becomes a no-op - s.debounceGen[libraryID]++ - gen := s.debounceGen[libraryID] + s.pendingMu.Lock() + defer s.pendingMu.Unlock() - // Stop old timer if it exists (generation counter handles races) - if timer, exists := s.debounceTimers[libraryID]; exists { - timer.Stop() + pw, exists := s.pending[libraryID] + if exists { + pw.timer.Stop() + pw.gen++ + if ratingKey != "" { + pw.ratingKeys = appendUnique(pw.ratingKeys, ratingKey) + } if s.config.VerboseLogging { - fmt.Printf("Webhook: reset debounce timer for library %s\n", libraryName) + fmt.Printf("Webhook: reset debounce for library %s (%d items queued)\n", libraryName, len(pw.ratingKeys)) } } else { + var keys []string + if ratingKey != "" { + keys = []string{ratingKey} + } + pw = &pendingWork{ + libraryName: libraryName, + mediaType: mediaType, + ratingKeys: keys, + } + s.pending[libraryID] = pw fmt.Printf("Webhook: scheduled processing for library %s in %v\n", libraryName, debounce) } - s.debounceTimers[libraryID] = time.AfterFunc(debounce, func() { - s.debounceMu.Lock() - if s.debounceGen[libraryID] != gen { - s.debounceMu.Unlock() + gen := pw.gen + pw.timer = time.AfterFunc(debounce, func() { + s.pendingMu.Lock() + current, ok := s.pending[libraryID] + if !ok || current.gen != gen { + s.pendingMu.Unlock() return } - delete(s.debounceTimers, libraryID) - delete(s.debounceGen, libraryID) - s.debounceMu.Unlock() + keys := current.ratingKeys + delete(s.pending, libraryID) + s.pendingMu.Unlock() - s.processLibrary(libraryID, libraryName, mediaType) + s.processItems(libraryID, libraryName, mediaType, keys) }) } -func (s *Server) processLibrary(libraryID, libraryName string, mediaType media.MediaType) { - fmt.Printf("Webhook: processing library %s (ID: %s)\n", libraryName, libraryID) - if err := s.processor.ProcessAllItems(libraryID, libraryName, mediaType); err != nil { - fmt.Printf("Webhook: error processing library %s: %v\n", libraryName, err) - } else { - fmt.Printf("Webhook: finished processing library %s\n", libraryName) +func appendUnique(slice []string, val string) []string { + for _, v := range slice { + if v == val { + return slice + } + } + return append(slice, val) +} + +func (s *Server) processItems(libraryID, libraryName string, mediaType media.MediaType, ratingKeys []string) { + if len(ratingKeys) == 0 { + fmt.Printf("Webhook: processing full library %s (no rating keys in events)\n", libraryName) + if err := s.processor.ProcessAllItems(libraryID, libraryName, mediaType); err != nil { + fmt.Printf("Webhook: error processing library %s: %v\n", libraryName, err) + } else { + fmt.Printf("Webhook: finished processing library %s\n", libraryName) + } + return + } + + fmt.Printf("Webhook: processing %d items in library %s\n", len(ratingKeys), libraryName) + for _, key := range ratingKeys { + if err := s.processor.ProcessSingleItem(key, libraryID, mediaType); err != nil { + fmt.Printf("Webhook: error processing item %s: %v\n", key, err) + } } + fmt.Printf("Webhook: finished processing %d items in library %s\n", len(ratingKeys), libraryName) } From e4685a1b684938f434b230f61a89e70126cd8f2f Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Sun, 12 Apr 2026 15:41:56 -0400 Subject: [PATCH 11/18] fix: webhook items wait for in-flight scan; add library exclude and webhook-only mode ProcessSingleItem previously returned nil when the per-library mutex was held by a full scan, logging "queuing for next cycle" without any queue. Items were silently dropped. Replace the check-and-bail with a poll loop (5s interval, 2h deadline). ProcessAllItems keeps its skip-on-busy behavior so timer-driven scans do not stack; the asymmetry is documented inline. Add MOVIE_LIBRARY_EXCLUDE and TV_LIBRARY_EXCLUDE (comma-separated IDs) to skip specific libraries under *_PROCESS_ALL=true. Filter runs once at startup so both timer and webhook routing see the same set. Add WEBHOOK_ONLY=true to skip the startup full scan and periodic timer, leaving the webhook server as the sole trigger. Validated in config (requires WEBHOOK_ENABLED=true). Bumps version to 1.2.1. --- CHANGELOG.md | 16 ++++++++++++++++ cmd/labelarr/main.go | 26 ++++++++++++++++++++++++-- internal/config/config.go | 28 ++++++++++++++++++---------- internal/media/processor.go | 30 ++++++++++++++++++++++++------ internal/utils/set.go | 9 +++++++++ internal/version/version.go | 2 +- 6 files changed, 92 insertions(+), 19 deletions(-) create mode 100644 internal/utils/set.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d987198..7ef39b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [1.2.1] - 2026-04-12 + +### Added +- `MOVIE_LIBRARY_EXCLUDE` / `TV_LIBRARY_EXCLUDE`: comma-separated library + IDs to skip when `*_PROCESS_ALL=true`. Excluded libraries are filtered + from both timer-driven processing and webhook routing. +- `WEBHOOK_ONLY=true`: skips the startup full scan and the periodic timer + entirely, leaving the webhook server as the only trigger. Requires + `WEBHOOK_ENABLED=true`. + +### Fixed +- Webhook items are no longer silently dropped when a full library scan is + in progress. `ProcessSingleItem` now waits for the per-library slot to + free up (polling every 5s, bounded to a 2-hour deadline) instead of + logging a fake "queuing for next cycle" and returning early. + ## [1.2.0] - 2026-04-10 ### Added diff --git a/cmd/labelarr/main.go b/cmd/labelarr/main.go index 7d5559e..bbf0fa3 100644 --- a/cmd/labelarr/main.go +++ b/cmd/labelarr/main.go @@ -14,6 +14,7 @@ import ( "github.com/nullable-eth/labelarr/internal/radarr" "github.com/nullable-eth/labelarr/internal/sonarr" "github.com/nullable-eth/labelarr/internal/tmdb" + "github.com/nullable-eth/labelarr/internal/utils" "github.com/nullable-eth/labelarr/internal/version" "github.com/nullable-eth/labelarr/internal/webhook" ) @@ -99,8 +100,7 @@ func getLibraries(cfg *config.Config, plexClient *plex.Client) ([]plex.Library, fmt.Printf(" ID: %s - %s (%s)\n", lib.Key, lib.Title, lib.Type) } - var movieLibraries []plex.Library - var tvLibraries []plex.Library + var movieLibraries, tvLibraries []plex.Library for _, lib := range libraries { switch lib.Type { case "movie": @@ -109,6 +109,8 @@ func getLibraries(cfg *config.Config, plexClient *plex.Client) ([]plex.Library, tvLibraries = append(tvLibraries, lib) } } + movieLibraries = filterExcluded(movieLibraries, utils.StringSet(cfg.MovieLibraryExclude), "movie") + tvLibraries = filterExcluded(tvLibraries, utils.StringSet(cfg.TVLibraryExclude), "TV") if len(movieLibraries) == 0 && !cfg.ProcessTVShows() { fmt.Println("[ERROR] No movie library found!") @@ -123,6 +125,21 @@ func getLibraries(cfg *config.Config, plexClient *plex.Client) ([]plex.Library, return movieLibraries, tvLibraries } +func filterExcluded(libs []plex.Library, exclude map[string]bool, kind string) []plex.Library { + if len(exclude) == 0 { + return libs + } + kept := libs[:0] + for _, lib := range libs { + if exclude[lib.Key] { + fmt.Printf("[INFO] Excluding %s library: %s (ID: %s)\n", kind, lib.Title, lib.Key) + continue + } + kept = append(kept, lib) + } + return kept +} + // findLibraryName returns the library title for the given ID, or the fallback if not found. func findLibraryName(libraries []plex.Library, id, fallback string) string { for _, lib := range libraries { @@ -222,6 +239,11 @@ func handleNormalMode(cfg *config.Config, processor *media.Processor, movieLibra os.Exit(0) }() + if cfg.WebhookOnly { + fmt.Println("[INFO] WEBHOOK_ONLY=true: skipping startup full scan and periodic timer; webhook server is the only trigger") + select {} + } + fmt.Printf("[INFO] Starting periodic processing interval: %v\n", cfg.ProcessTimer) processFunc := func() { diff --git a/internal/config/config.go b/internal/config/config.go index 5a4199e..b60ba59 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,8 +16,11 @@ type Config struct { PlexToken string MovieLibraryID string MovieProcessAll bool + MovieLibraryExclude []string TVLibraryID string TVProcessAll bool + TVLibraryExclude []string + WebhookOnly bool UpdateField string RemoveMode string TMDbReadAccessToken string @@ -69,8 +72,11 @@ func Load() *Config { PlexToken: os.Getenv("PLEX_TOKEN"), MovieLibraryID: os.Getenv("MOVIE_LIBRARY_ID"), MovieProcessAll: getBoolEnvWithDefault("MOVIE_PROCESS_ALL", false), + MovieLibraryExclude: parseCSV(os.Getenv("MOVIE_LIBRARY_EXCLUDE")), TVLibraryID: os.Getenv("TV_LIBRARY_ID"), TVProcessAll: getBoolEnvWithDefault("TV_PROCESS_ALL", false), + TVLibraryExclude: parseCSV(os.Getenv("TV_LIBRARY_EXCLUDE")), + WebhookOnly: getBoolEnvWithDefault("WEBHOOK_ONLY", false), UpdateField: getEnvWithDefault("UPDATE_FIELD", "label"), RemoveMode: os.Getenv("REMOVE"), TMDbReadAccessToken: os.Getenv("TMDB_READ_ACCESS_TOKEN"), @@ -109,7 +115,7 @@ func Load() *Config { ItemDelay: getDurationEnvWithDefault("ITEM_DELAY", "500ms"), // Export configuration - ExportLabels: parseExportLabels(os.Getenv("EXPORT_LABELS")), + ExportLabels: parseCSV(os.Getenv("EXPORT_LABELS")), ExportLocation: os.Getenv("EXPORT_LOCATION"), ExportMode: getEnvWithDefault("EXPORT_MODE", "txt"), } @@ -162,6 +168,9 @@ func (c *Config) Validate() error { if c.ExportMode != "txt" && c.ExportMode != "json" { return fmt.Errorf("EXPORT_MODE must be 'txt' or 'json'") } + if c.WebhookOnly && !c.WebhookEnabled { + return fmt.Errorf("WEBHOOK_ONLY=true requires WEBHOOK_ENABLED=true") + } if c.WebhookEnabled && (c.WebhookPort < 1 || c.WebhookPort > 65535) { return fmt.Errorf("WEBHOOK_PORT must be between 1 and 65535") @@ -234,20 +243,19 @@ func getDurationEnvWithDefault(envVar string, defaultValue string) time.Duration return duration } -func parseExportLabels(labels string) []string { - if labels == "" { +func parseCSV(s string) []string { + if s == "" { return nil } - // Split and trim whitespace from each label - rawLabels := strings.Split(labels, ",") - var cleanLabels []string - for _, label := range rawLabels { - trimmed := strings.TrimSpace(label) + parts := strings.Split(s, ",") + var out []string + for _, p := range parts { + trimmed := strings.TrimSpace(p) if trimmed != "" { - cleanLabels = append(cleanLabels, trimmed) + out = append(out, trimmed) } } - return cleanLabels + return out } // HasExportEnabled returns true if export functionality is enabled diff --git a/internal/media/processor.go b/internal/media/processor.go index dac0714..3e69ec7 100644 --- a/internal/media/processor.go +++ b/internal/media/processor.go @@ -195,14 +195,29 @@ func (p *Processor) applyKeywordPrefix(keywords []string) []string { // ProcessSingleItem processes a single item by rating key. Used by webhooks to // tag only the newly added item instead of scanning the entire library. func (p *Processor) ProcessSingleItem(ratingKey, libraryID string, mediaType MediaType) error { - p.processingMu.Lock() - if p.processing[libraryID] { + const ( + pollInterval = 5 * time.Second + maxWait = 2 * time.Hour + ) + deadline := time.Now().Add(maxWait) + logged := false + for { + p.processingMu.Lock() + if !p.processing[libraryID] { + p.processing[libraryID] = true + p.processingMu.Unlock() + break + } p.processingMu.Unlock() - fmt.Printf("[INFO] Library %s is already being processed, queuing item %s for next cycle\n", libraryID, ratingKey) - return nil + if time.Now().After(deadline) { + return fmt.Errorf("timed out after %s waiting for library %s to free up for item %s", maxWait, libraryID, ratingKey) + } + if !logged { + fmt.Printf("[INFO] Library %s busy (scan in progress); webhook item %s will wait until it frees\n", libraryID, ratingKey) + logged = true + } + time.Sleep(pollInterval) } - p.processing[libraryID] = true - p.processingMu.Unlock() defer func() { p.processingMu.Lock() delete(p.processing, libraryID) @@ -307,6 +322,9 @@ func (p *Processor) ProcessSingleItem(ratingKey, libraryID string, mediaType Med } func (p *Processor) ProcessAllItems(libraryID string, libraryName string, mediaType MediaType) error { + // Unlike ProcessSingleItem, this path skips when the library is busy: + // the timer will re-fire on the next cycle, so waiting here would only + // stack redundant full scans behind each other. p.processingMu.Lock() if p.processing[libraryID] { p.processingMu.Unlock() diff --git a/internal/utils/set.go b/internal/utils/set.go new file mode 100644 index 0000000..894f866 --- /dev/null +++ b/internal/utils/set.go @@ -0,0 +1,9 @@ +package utils + +func StringSet(items []string) map[string]bool { + m := make(map[string]bool, len(items)) + for _, s := range items { + m[s] = true + } + return m +} diff --git a/internal/version/version.go b/internal/version/version.go index 15f2623..6f205bf 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,3 +1,3 @@ package version -const Version = "1.2.0" +const Version = "1.2.1" From d73efe9d7c745827a0c2e18f30ee5ae7d2645c18 Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Sun, 12 Apr 2026 17:23:03 -0400 Subject: [PATCH 12/18] fix(webhook): log reason for 400 responses Plex deliveries were being rejected with bare HTTP 400s and no log output, making it impossible to tell which failure path fired (multipart parse, missing payload field, or JSON unmarshal). Each 400 branch now logs the error plus diagnostics (Content-Type, form/file part keys, payload snippet) so the root cause of real Plex failures can be diagnosed from logs alone. --- CHANGELOG.md | 9 +++++++++ internal/version/version.go | 2 +- internal/webhook/server.go | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ef39b2..be9dcd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [1.2.2] - 2026-04-12 + +### Changed +- Webhook 400 responses now log the specific failure reason (parse error, + missing payload field, or JSON unmarshal error) along with + `Content-Type`, form/file part keys, and a payload snippet. Previously + all three paths returned 400 with no log, making Plex delivery failures + invisible. + ## [1.2.1] - 2026-04-12 ### Added diff --git a/internal/version/version.go b/internal/version/version.go index 6f205bf..a1b4325 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,3 +1,3 @@ package version -const Version = "1.2.1" +const Version = "1.2.2" diff --git a/internal/webhook/server.go b/internal/webhook/server.go index 0f774b9..f079ecf 100644 --- a/internal/webhook/server.go +++ b/internal/webhook/server.go @@ -135,18 +135,37 @@ func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) { } if err := r.ParseMultipartForm(1 << 20); err != nil { + fmt.Printf("[WEBHOOK] 400 ParseMultipartForm: content-type=%q content-length=%d err=%v\n", + r.Header.Get("Content-Type"), r.ContentLength, err) http.Error(w, "Bad request", http.StatusBadRequest) return } payloadStr := r.FormValue("payload") if payloadStr == "" { + formKeys := make([]string, 0, len(r.Form)) + for k := range r.Form { + formKeys = append(formKeys, k) + } + fileKeys := []string{} + if r.MultipartForm != nil { + for k := range r.MultipartForm.File { + fileKeys = append(fileKeys, k) + } + } + fmt.Printf("[WEBHOOK] 400 MissingPayload: content-type=%q form_keys=%v file_keys=%v\n", + r.Header.Get("Content-Type"), formKeys, fileKeys) http.Error(w, "Missing payload", http.StatusBadRequest) return } var payload PlexWebhookPayload if err := json.Unmarshal([]byte(payloadStr), &payload); err != nil { + snippet := payloadStr + if len(snippet) > 300 { + snippet = snippet[:300] + } + fmt.Printf("[WEBHOOK] 400 InvalidPayload: err=%v payload_snippet=%q\n", err, snippet) http.Error(w, "Invalid payload", http.StatusBadRequest) return } From d6ee9b4ffa82ee2a1c7bfb0c7f72995ef0bd4c4a Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Sun, 12 Apr 2026 17:30:28 -0400 Subject: [PATCH 13/18] fix(webhook): reject caused by GUID type mismatch; remove unused field Plex sends Metadata.Guid as an array of objects for items with multiple provider IDs (imdb, tmdb, tvdb). PlexWebhookPayload declared it as a string, so json.Unmarshal failed on every such payload and the handler returned 400, making labelarr ignore all real webhooks. The field was never read anywhere in the codebase; removing it lets the JSON decoder skip it regardless of shape. --- CHANGELOG.md | 10 ++++++++++ internal/version/version.go | 2 +- internal/webhook/server.go | 1 - 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be9dcd2..9e9a686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.2.3] - 2026-04-12 + +### Fixed +- Every Plex webhook delivery was being rejected with `HTTP 400` because + `PlexWebhookPayload.Metadata.GUID` was typed as `string`, while Plex + sends it as an array of objects (`[{id:"imdb://..."}, ...]`) for + multi-provider items. `json.Unmarshal` failed before reaching the + event handler. The unused field has been removed; unknown JSON fields + are ignored by the decoder, so the shape no longer matters. + ## [1.2.2] - 2026-04-12 ### Changed diff --git a/internal/version/version.go b/internal/version/version.go index a1b4325..76e2cb5 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,3 +1,3 @@ package version -const Version = "1.2.2" +const Version = "1.2.3" diff --git a/internal/webhook/server.go b/internal/webhook/server.go index f079ecf..1cced2a 100644 --- a/internal/webhook/server.go +++ b/internal/webhook/server.go @@ -43,7 +43,6 @@ type PlexWebhookPayload struct { LibrarySectionTitle string `json:"librarySectionTitle"` RatingKey string `json:"ratingKey"` Key string `json:"key"` - GUID string `json:"guid"` Type string `json:"type"` Title string `json:"title"` Year int `json:"year"` From e44ad7daa0eca5d3dbed754eb6b58fa237da8b15 Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Sun, 12 Apr 2026 18:03:17 -0400 Subject: [PATCH 14/18] feat(webhook): add POST /scan endpoint for manual scan triggers Adds a manual scan trigger so operators can force a catchup cycle without toggling WEBHOOK_ONLY. POST /scan runs a full scan; POST /scan?library= targets a single library. Returns 202 on accept, 409 on concurrent scan, 404 on unknown library. A Scanner interface is introduced and implemented by a scanRunner in main; the periodic timer now uses the same code path. --- CHANGELOG.md | 15 +++++++ README.md | 21 +++++++++ cmd/labelarr/main.go | 83 ++++++++++++++++++++++------------ internal/version/version.go | 2 +- internal/webhook/server.go | 88 ++++++++++++++++++++++++++++++++++++- 5 files changed, 179 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e9a686..b9ff175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [1.3.0] - 2026-04-12 + +### Added +- `POST /scan` endpoint on the webhook server for manual scan triggers. + - `POST /scan` runs a full scan across all non-excluded libraries. + - `POST /scan?library=` scans a single library; `library` + accepts the numeric Plex section ID or a case-insensitive library + title. + - Returns `202 Accepted` immediately; the scan runs in the + background. Returns `409 Conflict` if a scan is already in + progress, `404 Not Found` if the library param does not match, and + `405 Method Not Allowed` for non-POST requests. + - Works in `WEBHOOK_ONLY=true` mode β€” enables ad-hoc catchup scans + without toggling environment variables. + ## [1.2.3] - 2026-04-12 ### Fixed diff --git a/README.md b/README.md index 712c477..f2d43bd 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,27 @@ The webhook server runs alongside the existing timer. Both can be active at the A health check is available at `/health`. +### Manual Scan Trigger + +`POST /scan` on the webhook server kicks off a scan cycle without waiting for the timer. Useful when operating in `WEBHOOK_ONLY=true` mode or after extended downtime. + +```bash +# Full scan of all non-excluded libraries +curl -X POST http://labelarr:9090/scan + +# Scan a single library by Plex section ID +curl -X POST "http://labelarr:9090/scan?library=22" + +# Scan a single library by name (case-insensitive) +curl -X POST "http://labelarr:9090/scan?library=Movies" +``` + +Responses: +- `202 Accepted` β€” scan started in the background +- `409 Conflict` β€” a scan is already in progress +- `404 Not Found` β€” `library` param did not match any configured library +- `405 Method Not Allowed` β€” non-POST request + ## Batch Processing Large libraries (4000+ items) can overwhelm Radarr/Sonarr APIs with thousands of requests. Batch processing breaks the work into chunks with pauses between them. diff --git a/cmd/labelarr/main.go b/cmd/labelarr/main.go index bbf0fa3..ac37c8b 100644 --- a/cmd/labelarr/main.go +++ b/cmd/labelarr/main.go @@ -216,9 +216,11 @@ func handleRemoveMode(cfg *config.Config, processor *media.Processor, movieLibra func handleNormalMode(cfg *config.Config, processor *media.Processor, movieLibraries, tvLibraries []plex.Library) { displayLibrarySelection(cfg, movieLibraries, tvLibraries) + scanner := &scanRunner{cfg: cfg, processor: processor, movieLibs: movieLibraries, tvLibs: tvLibraries} + var webhookServer *webhook.Server if cfg.WebhookEnabled { - webhookServer = webhook.NewServer(cfg, processor, movieLibraries, tvLibraries) + webhookServer = webhook.NewServer(cfg, processor, movieLibraries, tvLibraries, scanner) if err := webhookServer.Start(); err != nil { fmt.Printf("[ERROR] %v\n", err) os.Exit(1) @@ -246,41 +248,66 @@ func handleNormalMode(cfg *config.Config, processor *media.Processor, movieLibra fmt.Printf("[INFO] Starting periodic processing interval: %v\n", cfg.ProcessTimer) - processFunc := func() { - processor.ClearCaches() + scanner.RunAll() - if len(movieLibraries) > 0 { - forEachLibrary(cfg.MovieProcessAll, cfg.MovieLibraryID, movieLibraries, "Movies", func(id, name string) { - fmt.Printf("[MOVIE] Processing library: %s (ID: %s)\n", name, id) - if err := processor.ProcessAllItems(id, name, media.MediaTypeMovie); err != nil { - fmt.Printf("[ERROR] Error processing movies: %v\n", err) - } - }) - } + ticker := time.NewTicker(cfg.ProcessTimer) + defer ticker.Stop() - if cfg.ProcessTVShows() { - forEachLibrary(cfg.TVProcessAll, cfg.TVLibraryID, tvLibraries, "TV Shows", func(id, name string) { - fmt.Printf("[TV] Processing TV library: %s (ID: %s)\n", name, id) - if err := processor.ProcessAllItems(id, name, media.MediaTypeTV); err != nil { - fmt.Printf("[ERROR] Error processing TV shows: %v\n", err) - } - }) - } + for range ticker.C { + fmt.Printf("\n[TIMER] Timer triggered - processing at %s\n", time.Now().Format("15:04:05")) + scanner.RunAll() + } +} - if cfg.HasExportEnabled() { - writeExportFiles(cfg, processor) - } +// scanRunner implements webhook.Scanner. Used by both the periodic timer and +// the /scan HTTP endpoint. +type scanRunner struct { + cfg *config.Config + processor *media.Processor + movieLibs []plex.Library + tvLibs []plex.Library +} + +func (r *scanRunner) RunAll() { + r.processor.ClearCaches() + + if len(r.movieLibs) > 0 { + forEachLibrary(r.cfg.MovieProcessAll, r.cfg.MovieLibraryID, r.movieLibs, "Movies", func(id, name string) { + fmt.Printf("[MOVIE] Processing library: %s (ID: %s)\n", name, id) + if err := r.processor.ProcessAllItems(id, name, media.MediaTypeMovie); err != nil { + fmt.Printf("[ERROR] Error processing movies: %v\n", err) + } + }) } - processFunc() + if r.cfg.ProcessTVShows() { + forEachLibrary(r.cfg.TVProcessAll, r.cfg.TVLibraryID, r.tvLibs, "TV Shows", func(id, name string) { + fmt.Printf("[TV] Processing TV library: %s (ID: %s)\n", name, id) + if err := r.processor.ProcessAllItems(id, name, media.MediaTypeTV); err != nil { + fmt.Printf("[ERROR] Error processing TV shows: %v\n", err) + } + }) + } - ticker := time.NewTicker(cfg.ProcessTimer) - defer ticker.Stop() + if r.cfg.HasExportEnabled() { + writeExportFiles(r.cfg, r.processor) + } +} - for range ticker.C { - fmt.Printf("\n[TIMER] Timer triggered - processing at %s\n", time.Now().Format("15:04:05")) - processFunc() +func (r *scanRunner) RunLibrary(libraryID, libraryName string, mediaType media.MediaType) error { + r.processor.ClearCaches() + tag := "[MOVIE]" + if mediaType == media.MediaTypeTV { + tag = "[TV]" + } + fmt.Printf("%s Processing library: %s (ID: %s)\n", tag, libraryName, libraryID) + if err := r.processor.ProcessAllItems(libraryID, libraryName, mediaType); err != nil { + return err + } + if r.cfg.HasExportEnabled() { + writeExportFiles(r.cfg, r.processor) } + return nil } func writeExportFiles(cfg *config.Config, processor *media.Processor) { diff --git a/internal/version/version.go b/internal/version/version.go index 76e2cb5..d88b596 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,3 +1,3 @@ package version -const Version = "1.2.3" +const Version = "1.3.0" diff --git a/internal/webhook/server.go b/internal/webhook/server.go index 1cced2a..494cb0f 100644 --- a/internal/webhook/server.go +++ b/internal/webhook/server.go @@ -7,6 +7,7 @@ import ( "net" "net/http" "strconv" + "strings" "sync" "time" @@ -64,16 +65,25 @@ type pendingWork struct { gen uint64 } +// Scanner kicks off full or per-library scan cycles. Implemented by main. +type Scanner interface { + RunAll() + RunLibrary(libraryID, libraryName string, mediaType media.MediaType) error +} + type Server struct { config *config.Config processor *media.Processor + scanner Scanner httpServer *http.Server libraryMap map[string]libraryInfo pending map[string]*pendingWork pendingMu sync.Mutex + scanMu sync.Mutex + scanning bool } -func NewServer(cfg *config.Config, proc *media.Processor, movieLibs, tvLibs []plex.Library) *Server { +func NewServer(cfg *config.Config, proc *media.Processor, movieLibs, tvLibs []plex.Library, scanner Scanner) *Server { libMap := make(map[string]libraryInfo, len(movieLibs)+len(tvLibs)) for _, lib := range movieLibs { libMap[lib.Key] = libraryInfo{name: lib.Title, mediaType: media.MediaTypeMovie} @@ -85,6 +95,7 @@ func NewServer(cfg *config.Config, proc *media.Processor, movieLibs, tvLibs []pl return &Server{ config: cfg, processor: proc, + scanner: scanner, libraryMap: libMap, pending: make(map[string]*pendingWork), } @@ -93,6 +104,7 @@ func NewServer(cfg *config.Config, proc *media.Processor, movieLibs, tvLibs []pl func (s *Server) Start() error { mux := http.NewServeMux() mux.HandleFunc("/webhook", s.handleWebhook) + mux.HandleFunc("/scan", s.handleScan) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprint(w, "ok") @@ -201,6 +213,80 @@ func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } +func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if s.scanner == nil { + http.Error(w, "Scanner unavailable", http.StatusServiceUnavailable) + return + } + + libParam := r.URL.Query().Get("library") + + var ( + targetID string + targetName string + targetMT media.MediaType + ) + if libParam != "" { + id, info, ok := s.findLibrary(libParam) + if !ok { + http.Error(w, "Library not found", http.StatusNotFound) + return + } + targetID, targetName, targetMT = id, info.name, info.mediaType + } + + s.scanMu.Lock() + if s.scanning { + s.scanMu.Unlock() + http.Error(w, "Scan already in progress", http.StatusConflict) + return + } + s.scanning = true + s.scanMu.Unlock() + + scope := "all libraries" + if libParam != "" { + scope = fmt.Sprintf("library %s (ID: %s)", targetName, targetID) + } + fmt.Printf("[INFO] Manual scan triggered via /scan from %s: %s\n", r.RemoteAddr, scope) + + go func() { + defer func() { + s.scanMu.Lock() + s.scanning = false + s.scanMu.Unlock() + fmt.Println("[INFO] Manual scan complete") + }() + if libParam == "" { + s.scanner.RunAll() + return + } + if err := s.scanner.RunLibrary(targetID, targetName, targetMT); err != nil { + fmt.Printf("[ERROR] Manual scan of %s failed: %v\n", targetName, err) + } + }() + + w.WriteHeader(http.StatusAccepted) + fmt.Fprintf(w, "scan started: %s\n", scope) +} + +func (s *Server) findLibrary(param string) (string, libraryInfo, bool) { + if info, ok := s.libraryMap[param]; ok { + return param, info, true + } + lower := strings.ToLower(param) + for id, info := range s.libraryMap { + if strings.ToLower(info.name) == lower { + return id, info, true + } + } + return "", libraryInfo{}, false +} + func (s *Server) resolveMediaType(libraryID, sectionType string) media.MediaType { switch sectionType { case "movie": From 3e474a20b6492e2934084bedd8e9c3f64a50ce31 Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Tue, 21 Apr 2026 18:04:50 -0400 Subject: [PATCH 15/18] fix(security): resolve CodeQL alerts (TLS verify, path injection, workflow permissions) - plex/client.go: gate InsecureSkipVerify on new PLEX_INSECURE_SKIP_VERIFY env var (default false). Startup logs a [WARN] when the opt-in is active. Resolves go/disabled-certificate-check. - export/export.go: add safeJoin containment check and harden sanitizeFilename to reject '', '.', '..'. All filepath.Join calls onto the export root now verify the result stays inside EXPORT_LOCATION. Resolves go/path-injection. - .github/workflows/release.yml: add least-privilege top-level 'permissions: contents: read' so the check-changes job inherits a read-only token. Existing per-job elevated permissions unchanged. Resolves actions/missing-workflow-permissions. Bump to v1.3.1. --- .github/workflows/release.yml | 3 ++ CHANGELOG.md | 10 ++++++ README.md | 1 + internal/config/config.go | 60 ++++++++++++++++++----------------- internal/export/export.go | 59 ++++++++++++++++++++++++++++------ internal/plex/client.go | 5 ++- internal/version/version.go | 2 +- 7 files changed, 100 insertions(+), 40 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff26f15..1c1abc2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,9 @@ on: - '.gitignore' - 'example/**' +permissions: + contents: read + jobs: check-changes: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index b9ff175..bd93484 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.3.1] - 2026-04-21 + +### Security +- Plex TLS certificate verification is now enabled by default. Previously, `InsecureSkipVerify` was hardcoded to `true`, which silently trusted any server certificate. Set `PLEX_INSECURE_SKIP_VERIFY=true` to opt back into skip-verify (e.g., for self-signed Plex certs). A `[WARN]` line is logged at startup when the flag is on. Resolves CodeQL `go/disabled-certificate-check`. +- Export paths are now validated to stay within `EXPORT_LOCATION`. Library names that resolve to `.`, `..`, or empty after sanitization are rejected, and a `safeJoin` helper verifies every export path stays inside the export root before writing. Resolves CodeQL `go/path-injection`. +- `.github/workflows/release.yml` now declares a least-privilege top-level `permissions: contents: read`. Per-job elevated permissions on `create-release`, `build-binaries`, and `publish-docker` are unchanged. Resolves CodeQL `actions/missing-workflow-permissions`. + +### Added +- `PLEX_INSECURE_SKIP_VERIFY` environment variable (default `false`). + ## [1.3.0] - 2026-04-12 ### Added diff --git a/README.md b/README.md index f2d43bd..08b6fc0 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ Pick one approach per media type: | Variable | Default | Description | |----------|---------|-------------| | `PLEX_REQUIRES_HTTPS` | `false` | Use HTTPS for Plex connection | +| `PLEX_INSECURE_SKIP_VERIFY` | `false` | Skip TLS certificate verification for Plex. Only takes effect when `PLEX_REQUIRES_HTTPS=true`. Enable only for self-signed certs; a `[WARN]` line is logged at startup. | | `UPDATE_FIELD` | `label` | Field to update: `label` or `genre` | | `PROCESS_TIMER` | `1h` | How often to run (e.g. `30m`, `2h`, `24h`) | | `VERBOSE_LOGGING` | `false` | Show detailed lookup and matching info | diff --git a/internal/config/config.go b/internal/config/config.go index b60ba59..7724e7e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,21 +10,22 @@ import ( // Config holds all application configuration type Config struct { - Protocol string - PlexServer string - PlexPort string - PlexToken string - MovieLibraryID string - MovieProcessAll bool - MovieLibraryExclude []string - TVLibraryID string - TVProcessAll bool - TVLibraryExclude []string - WebhookOnly bool - UpdateField string - RemoveMode string - TMDbReadAccessToken string - ProcessTimer time.Duration + Protocol string + PlexInsecureSkipVerify bool + PlexServer string + PlexPort string + PlexToken string + MovieLibraryID string + MovieProcessAll bool + MovieLibraryExclude []string + TVLibraryID string + TVProcessAll bool + TVLibraryExclude []string + WebhookOnly bool + UpdateField string + RemoveMode string + TMDbReadAccessToken string + ProcessTimer time.Duration // Radarr configuration RadarrURL string @@ -67,20 +68,21 @@ type Config struct { // Load loads configuration from environment variables func Load() *Config { config := &Config{ - PlexServer: os.Getenv("PLEX_SERVER"), - PlexPort: os.Getenv("PLEX_PORT"), - PlexToken: os.Getenv("PLEX_TOKEN"), - MovieLibraryID: os.Getenv("MOVIE_LIBRARY_ID"), - MovieProcessAll: getBoolEnvWithDefault("MOVIE_PROCESS_ALL", false), - MovieLibraryExclude: parseCSV(os.Getenv("MOVIE_LIBRARY_EXCLUDE")), - TVLibraryID: os.Getenv("TV_LIBRARY_ID"), - TVProcessAll: getBoolEnvWithDefault("TV_PROCESS_ALL", false), - TVLibraryExclude: parseCSV(os.Getenv("TV_LIBRARY_EXCLUDE")), - WebhookOnly: getBoolEnvWithDefault("WEBHOOK_ONLY", false), - UpdateField: getEnvWithDefault("UPDATE_FIELD", "label"), - RemoveMode: os.Getenv("REMOVE"), - TMDbReadAccessToken: os.Getenv("TMDB_READ_ACCESS_TOKEN"), - ProcessTimer: getDurationEnvWithDefault("PROCESS_TIMER", "1h"), + PlexInsecureSkipVerify: getBoolEnvWithDefault("PLEX_INSECURE_SKIP_VERIFY", false), + PlexServer: os.Getenv("PLEX_SERVER"), + PlexPort: os.Getenv("PLEX_PORT"), + PlexToken: os.Getenv("PLEX_TOKEN"), + MovieLibraryID: os.Getenv("MOVIE_LIBRARY_ID"), + MovieProcessAll: getBoolEnvWithDefault("MOVIE_PROCESS_ALL", false), + MovieLibraryExclude: parseCSV(os.Getenv("MOVIE_LIBRARY_EXCLUDE")), + TVLibraryID: os.Getenv("TV_LIBRARY_ID"), + TVProcessAll: getBoolEnvWithDefault("TV_PROCESS_ALL", false), + TVLibraryExclude: parseCSV(os.Getenv("TV_LIBRARY_EXCLUDE")), + WebhookOnly: getBoolEnvWithDefault("WEBHOOK_ONLY", false), + UpdateField: getEnvWithDefault("UPDATE_FIELD", "label"), + RemoveMode: os.Getenv("REMOVE"), + TMDbReadAccessToken: os.Getenv("TMDB_READ_ACCESS_TOKEN"), + ProcessTimer: getDurationEnvWithDefault("PROCESS_TIMER", "1h"), // Radarr configuration RadarrURL: os.Getenv("RADARR_URL"), diff --git a/internal/export/export.go b/internal/export/export.go index c7beb40..3a326fc 100644 --- a/internal/export/export.go +++ b/internal/export/export.go @@ -100,7 +100,10 @@ func (e *Exporter) SetCurrentLibrary(libraryName string) error { // Only create library-specific subdirectory in txt mode if e.exportMode == "txt" { - libraryPath := filepath.Join(e.exportLocation, sanitizedName) + libraryPath, err := e.safeJoin(sanitizedName) + if err != nil { + return fmt.Errorf("invalid library path: %w", err) + } if err := os.MkdirAll(libraryPath, 0755); err != nil { return fmt.Errorf("failed to create library directory %s: %w", libraryPath, err) } @@ -196,7 +199,10 @@ func (e *Exporter) FlushAll() error { func (e *Exporter) flushTxt() error { // Process each library for libraryName, libraryData := range e.accumulated { - libraryPath := filepath.Join(e.exportLocation, libraryName) + libraryPath, err := e.safeJoin(libraryName) + if err != nil { + return fmt.Errorf("invalid library path: %w", err) + } // Ensure library directory exists if err := os.MkdirAll(libraryPath, 0755); err != nil { @@ -253,7 +259,10 @@ func (e *Exporter) flushJSON() error { jsonData := e.buildJSONExportData() // Write JSON file - jsonPath := filepath.Join(e.exportLocation, "export.json") + jsonPath, err := e.safeJoin("export.json") + if err != nil { + return fmt.Errorf("invalid JSON export path: %w", err) + } file, err := os.Create(jsonPath) if err != nil { return fmt.Errorf("failed to create JSON export file: %w", err) @@ -274,7 +283,10 @@ func (e *Exporter) flushJSON() error { // writeSummary writes a summary.txt file with detailed statistics func (e *Exporter) writeSummary() error { - summaryPath := filepath.Join(e.exportLocation, "summary.txt") + summaryPath, err := e.safeJoin("summary.txt") + if err != nil { + return fmt.Errorf("invalid summary path: %w", err) + } file, err := os.Create(summaryPath) if err != nil { @@ -414,14 +426,20 @@ func (e *Exporter) ClearExportFiles() error { // Remove all library subdirectories and their contents for libraryName := range e.accumulated { - libraryPath := filepath.Join(e.exportLocation, libraryName) + libraryPath, err := e.safeJoin(libraryName) + if err != nil { + return fmt.Errorf("invalid library path: %w", err) + } if err := os.RemoveAll(libraryPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to remove library directory %s: %w", libraryPath, err) } } // Remove summary file - summaryPath := filepath.Join(e.exportLocation, "summary.txt") + summaryPath, err := e.safeJoin("summary.txt") + if err != nil { + return fmt.Errorf("invalid summary path: %w", err) + } if err := os.Remove(summaryPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to remove summary file: %w", err) } @@ -490,15 +508,38 @@ func (e *Exporter) GetCurrentLibrary() string { return e.currentLibrary } -// sanitizeFilename removes invalid characters from filenames +// sanitizeFilename removes invalid characters from filenames and rejects +// dots-only names that could traverse outside the export directory. func sanitizeFilename(filename string) string { - // Replace common invalid characters with underscores invalid := []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|"} result := filename for _, char := range invalid { result = strings.ReplaceAll(result, char, "_") } - return strings.TrimSpace(result) + result = strings.TrimSpace(result) + if result == "" || result == "." || result == ".." { + return "_" + } + return result +} + +// safeJoin joins path elements onto the export location and guarantees the +// resolved absolute path stays within the export root. Returns an error if +// the result would escape (e.g. via "..", absolute paths, or similar). +func (e *Exporter) safeJoin(elems ...string) (string, error) { + joined := filepath.Join(append([]string{e.exportLocation}, elems...)...) + absBase, err := filepath.Abs(e.exportLocation) + if err != nil { + return "", fmt.Errorf("resolve export location: %w", err) + } + absJoined, err := filepath.Abs(joined) + if err != nil { + return "", fmt.Errorf("resolve joined path: %w", err) + } + if absJoined != absBase && !strings.HasPrefix(absJoined, absBase+string(filepath.Separator)) { + return "", fmt.Errorf("path %q escapes export location %q", joined, e.exportLocation) + } + return joined, nil } // buildJSONExportData builds a JSONExportData struct from the accumulated data diff --git a/internal/plex/client.go b/internal/plex/client.go index c30c34e..75cfbf7 100644 --- a/internal/plex/client.go +++ b/internal/plex/client.go @@ -21,8 +21,11 @@ type Client struct { // NewClient creates a new Plex client func NewClient(cfg *config.Config) *Client { + if cfg.PlexInsecureSkipVerify { + fmt.Printf("[WARN] TLS certificate verification is disabled for Plex (PLEX_INSECURE_SKIP_VERIFY=true)\n") + } tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.PlexInsecureSkipVerify}, } return &Client{ diff --git a/internal/version/version.go b/internal/version/version.go index d88b596..055962e 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,3 +1,3 @@ package version -const Version = "1.3.0" +const Version = "1.3.1" From 1dba386ebd451b9a2136f05dcca996c453c0c37c Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Tue, 21 Apr 2026 18:15:57 -0400 Subject: [PATCH 16/18] chore(security): bump Go to 1.26, Alpine to 3.22, harden .dockerignore Clears 1 CRITICAL + 8 HIGH CVEs surfaced by Trivy on the prior golang:1.23-alpine / alpine:3.21 base (stdlib CVEs across crypto/tls, crypto/x509, archive/tar, archive/zip, net/url). - Dockerfile: golang:1.23-alpine -> 1.26-alpine, alpine:3.21 -> 3.22 - go.mod: go 1.21 -> 1.26 - release.yml: actions/setup-go pinned to 1.26 - .dockerignore: add .env*, *.pem, *.key, .claude, .github, CLAUDE.md, PR_DESCRIPTION.md, tmp/ to prevent accidental inclusion Post-bump Trivy scan: 0 HIGH / 0 CRITICAL on ttlequals0/labelarr:1.3.1. --- .dockerignore | 14 ++++++++++++++ .github/workflows/release.yml | 2 +- CHANGELOG.md | 5 +++++ Dockerfile | 4 ++-- go.mod | 2 +- 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.dockerignore b/.dockerignore index f657e9a..f7cca59 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,10 +1,24 @@ # Git .git .gitignore +.github # IDE .vscode .idea +.claude + +# Secrets / env files +.env +.env.* +*.pem +*.key +*.pfx + +# Local-only docs / scratch +CLAUDE.md +PR_DESCRIPTION.md +tmp/ # Go build artifacts *.exe diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c1abc2..b3c7d34 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -215,7 +215,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.26' - name: Build binary env: diff --git a/CHANGELOG.md b/CHANGELOG.md index bd93484..0885993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ ### Added - `PLEX_INSECURE_SKIP_VERIFY` environment variable (default `false`). +### Changed +- Bumped Docker build base images to `golang:1.26-alpine` (builder) and `alpine:3.22` (runtime) to pick up patched Go stdlib and Alpine packages. Clears 1 CRITICAL + 8 HIGH CVEs surfaced by Trivy on the previous `golang:1.23-alpine` / `alpine:3.21` base (CVE-2025-68121, CVE-2025-58183, CVE-2025-61726/28/29, CVE-2026-25679/32280/32281/32283). +- `go.mod` now requires Go 1.26; `release.yml` `actions/setup-go` pinned to `1.26`. +- `.dockerignore` hardened to exclude `.env*`, `*.pem`, `*.key`, `.claude`, `.github`, `CLAUDE.md`, `PR_DESCRIPTION.md`, `tmp/`. + ## [1.3.0] - 2026-04-12 ### Added diff --git a/Dockerfile b/Dockerfile index 9ebbe4c..319ae07 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM golang:1.23-alpine AS builder +FROM golang:1.26-alpine AS builder WORKDIR /app @@ -11,7 +11,7 @@ RUN go mod download RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o labelarr ./cmd/labelarr # Runtime stage -FROM alpine:3.21 +FROM alpine:3.22 # Install ca-certificates for HTTPS requests and debugging tools # Using retry logic for QEMU emulation stability during multi-arch builds diff --git a/go.mod b/go.mod index a149953..1ecf929 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/nullable-eth/labelarr -go 1.21 +go 1.26 From 9d4d518354846712d19843386e6478df338adb06 Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Tue, 21 Apr 2026 18:29:01 -0400 Subject: [PATCH 17/18] fix(plex): redact X-Plex-Token (and api_key) from error messages - v1.3.2 Transport failures on the Plex HTTP client bubble up Go's *url.Error, which embeds the full request URL including the token. With TLS verify now defaulting on, a misconfigured cert produces repeating error lines containing the Plex token, leaking it to stdout/Loki. - Add urlSecretRedactor regexp and redactURLSecrets helper - Add (*Client).safeDo wrapper that scrubs Do() errors before returning - Route all 9 httpClient.Do(req) call sites through safeDo - Redact url.Parse errors in UpdateMediaField/RemoveMediaFieldKeywords --- CHANGELOG.md | 5 +++++ internal/plex/client.go | 41 +++++++++++++++++++++++++++---------- internal/version/version.go | 2 +- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0885993..73d487b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [1.3.2] - 2026-04-21 + +### Security +- Plex client error messages now redact `X-Plex-Token`, `apikey`, and `api_key` query-string values. Previously a transport failure (e.g. TLS cert rejection) would bubble up Go's `*url.Error`, which embeds the full request URL including the Plex token, leaking it into logs/Loki. All `c.httpClient.Do` calls are now routed through a `safeDo` wrapper that scrubs the error string before returning. + ## [1.3.1] - 2026-04-21 ### Security diff --git a/internal/plex/client.go b/internal/plex/client.go index 75cfbf7..06af679 100644 --- a/internal/plex/client.go +++ b/internal/plex/client.go @@ -7,12 +7,31 @@ import ( "io" "net/http" "net/url" + "regexp" "strings" "time" "github.com/nullable-eth/labelarr/internal/config" ) +// urlSecretRedactor matches credential query params so tokens don't leak into +// error messages produced by net/http (which embed the full request URL). +var urlSecretRedactor = regexp.MustCompile(`([?&](?:X-Plex-Token|apikey|api_key)=)[^&\s"]+`) + +func redactURLSecrets(s string) string { + return urlSecretRedactor.ReplaceAllString(s, "${1}REDACTED") +} + +// safeDo wraps httpClient.Do so transport errors have their request URL +// stripped of secret query params before bubbling up. +func (c *Client) safeDo(req *http.Request) (*http.Response, error) { + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%s", redactURLSecrets(err.Error())) + } + return resp, nil +} + // Client represents a Plex API client type Client struct { config *config.Config @@ -45,7 +64,7 @@ func (c *Client) GetAllLibraries() ([]Library, error) { req.Header.Set("X-Plex-Token", c.config.PlexToken) req.Header.Set("Accept", "application/json") - resp, err := c.httpClient.Do(req) + resp, err := c.safeDo(req) if err != nil { return nil, fmt.Errorf("failed to fetch libraries: %w", err) } @@ -80,7 +99,7 @@ func (c *Client) GetMoviesFromLibrary(libraryID string) ([]Movie, error) { req.Header.Set("X-Plex-Token", c.config.PlexToken) req.Header.Set("Accept", "application/json") - resp, err := c.httpClient.Do(req) + resp, err := c.safeDo(req) if err != nil { return nil, fmt.Errorf("failed to fetch movies: %w", err) } @@ -114,7 +133,7 @@ func (c *Client) GetMovieDetails(ratingKey string) (*Movie, error) { req.Header.Set("X-Plex-Token", c.config.PlexToken) req.Header.Set("Accept", "application/json") - resp, err := c.httpClient.Do(req) + resp, err := c.safeDo(req) if err != nil { return nil, fmt.Errorf("failed to fetch movie details: %w", err) } @@ -165,7 +184,7 @@ func (c *Client) GetTVShowsFromLibrary(libraryID string) ([]TVShow, error) { req.Header.Set("X-Plex-Token", c.config.PlexToken) req.Header.Set("Accept", "application/json") - resp, err := c.httpClient.Do(req) + resp, err := c.safeDo(req) if err != nil { return nil, fmt.Errorf("failed to fetch TV shows: %w", err) } @@ -199,7 +218,7 @@ func (c *Client) GetTVShowDetails(ratingKey string) (*TVShow, error) { req.Header.Set("X-Plex-Token", c.config.PlexToken) req.Header.Set("Accept", "application/json") - resp, err := c.httpClient.Do(req) + resp, err := c.safeDo(req) if err != nil { return nil, fmt.Errorf("failed to fetch TV show details: %w", err) } @@ -237,7 +256,7 @@ func (c *Client) GetTVShowEpisodes(ratingKey string) ([]Episode, error) { req.Header.Set("X-Plex-Token", c.config.PlexToken) req.Header.Set("Accept", "application/json") - resp, err := c.httpClient.Do(req) + resp, err := c.safeDo(req) if err != nil { return nil, fmt.Errorf("failed to fetch TV show episodes: %w", err) } @@ -271,7 +290,7 @@ func (c *Client) GetAllTVShowEpisodes(ratingKey string) ([]Episode, error) { req.Header.Set("X-Plex-Token", c.config.PlexToken) req.Header.Set("Accept", "application/json") - resp, err := c.httpClient.Do(req) + resp, err := c.safeDo(req) if err != nil { return nil, fmt.Errorf("failed to fetch all TV show episodes: %w", err) } @@ -304,7 +323,7 @@ func (c *Client) updateMediaField(mediaID, libraryID string, keywords []string, // Parse the URL to add query parameters properly parsedURL, err := url.Parse(baseURL) if err != nil { - return fmt.Errorf("failed to parse URL: %w", err) + return fmt.Errorf("failed to parse URL: %s", redactURLSecrets(err.Error())) } // Create query parameters @@ -332,7 +351,7 @@ func (c *Client) updateMediaField(mediaID, libraryID string, keywords []string, return fmt.Errorf("failed to create request: %w", err) } - resp, err := c.httpClient.Do(req) + resp, err := c.safeDo(req) if err != nil { return fmt.Errorf("failed to update media field: %w", err) } @@ -359,7 +378,7 @@ func (c *Client) removeMediaFieldKeywords(mediaID, libraryID string, valuesToRem // Parse the URL to add query parameters properly parsedURL, err := url.Parse(baseURL) if err != nil { - return fmt.Errorf("failed to parse URL: %w", err) + return fmt.Errorf("failed to parse URL: %s", redactURLSecrets(err.Error())) } // Create query parameters @@ -391,7 +410,7 @@ func (c *Client) removeMediaFieldKeywords(mediaID, libraryID string, valuesToRem return fmt.Errorf("failed to create request: %w", err) } - resp, err := c.httpClient.Do(req) + resp, err := c.safeDo(req) if err != nil { return fmt.Errorf("failed to remove media field keywords: %w", err) } diff --git a/internal/version/version.go b/internal/version/version.go index 055962e..a57ddc6 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,3 +1,3 @@ package version -const Version = "1.3.1" +const Version = "1.3.2" From 1329be907e6a41ae46640e59dbc345d5bcff3332 Mon Sep 17 00:00:00 2001 From: Dominick Krachtus Date: Fri, 8 May 2026 09:24:33 -0700 Subject: [PATCH 18/18] Update docker-compose.yml Fix image reference --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 8471d34..0670818 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.8' services: labelarr: - image: ghcr.io/nullable-eth/labelarr:latest + image: ttlequals0/labelarr:latest container_name: labelarr restart: unless-stopped volumes: