diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e8da16c..408c6fa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,20 +10,9 @@ repos: - id: mixed-line-ending args: ['--fix=lf'] - - repo: https://github.com/psf/black - rev: 25.1.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.13.0 hooks: - - id: black - language_version: python3 - - - repo: https://github.com/pycqa/isort - rev: 6.0.1 - hooks: - - id: isort - args: ["--profile", "black"] - - - repo: https://github.com/pycqa/flake8 - rev: 7.3.0 - hooks: - - id: flake8 - args: [--max-line-length=88, --extend-ignore=E203] + - id: ruff-check + args: [ --fix ] + - id: ruff-format diff --git a/README.md b/README.md index b35d47f..539e54b 100644 --- a/README.md +++ b/README.md @@ -7,18 +7,21 @@ > โš ๏ธ **Active Development**: This project is under active development. Features may change and stability is not guaranteed. Use at your own risk. -A Python automation tool that connects to your Bazarr instance, identifies movies missing subtitles, and automatically downloads them from SubSource. +A Python automation tool that connects to your Bazarr instance, identifies movies and TV episodes missing subtitles, and automatically downloads them from SubSource. ## Features - ๐ŸŽฌ **Automatic Movie Detection**: Lists all movies missing subtitles from your Bazarr instance +- ๐Ÿ“บ **TV Series Episode Support**: Automatically downloads subtitles for wanted TV series episodes - ๐ŸŒ **SubSource Integration**: Downloads subtitles from SubSource's anonymous API (no account needed) - ๐Ÿ“ค **Seamless Upload**: Automatically uploads downloaded subtitles back to Bazarr +- ๐Ÿ”„ **Automatic Synchronization**: Built-in subtitle sync with configurable parameters (No Framerate Fix, GSS, etc.) - ๐ŸŒ **Multi-language Support**: Supports multiple languages, forced, and hearing impaired subtitles - โฑ๏ธ **Smart Retry Logic**: Uses Bazarr's own search intervals to avoid redundant API calls - ๐Ÿ“Š **Progress Tracking**: Tracks search history to prevent unnecessary duplicate searches - ๐Ÿงน **Clean Operation**: Automatically cleans up temporary files after successful uploads - โš™๏ธ **Configurable**: External configuration file for easy setup +- ๐Ÿ”ง **Episode Matching**: Intelligent episode matching using season/episode patterns and scene names ## Requirements @@ -63,6 +66,17 @@ A Python automation tool that connects to your Bazarr instance, identifies movie [download] directory = /tmp/downloaded_subtitles + [movies] + # Enable movie subtitle downloads + enabled = true + + [episodes] + # Enable TV series episode subtitle downloads + enabled = true + # Search patterns: season_episode,episode_title,scene_name + search_patterns = season_episode,episode_title,scene_name + + [logging] level = INFO file = /var/log/bazarr_subsource.log @@ -143,12 +157,24 @@ The tool's built-in tracking system prevents redundant searches, making frequent ### Download Settings - `directory`: Local directory for temporary subtitle files (default: `/tmp/downloaded_subtitles`) +### Movies Settings +- `enabled`: Enable movie subtitle downloads (default: `true`) + +### Episodes Settings +- `enabled`: Enable TV series episode subtitle downloads (default: `true`) +- `search_patterns`: Episode search patterns, comma-separated (default: `season_episode,episode_title,scene_name`) + - `season_episode`: Search using "Series S01E01" format + - `episode_title`: Search using "Series Episode Title" format + - `scene_name`: Search using scene release names + ### Logging - `level`: Log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`) - `file`: Log file path (default: `/var/log/bazarr_subsource.log`) +- Log rotation: 10MB max file size, keeps 5 backup files ## How It Works +### Movies 1. **Connect to Bazarr**: Fetches all movies missing subtitles using the `/api/movies/wanted` endpoint 2. **Search SubSource**: For each movie, searches SubSource API for available subtitles 3. **Smart Filtering**: Uses Bazarr's own search intervals to avoid redundant searches @@ -156,8 +182,73 @@ The tool's built-in tracking system prevents redundant searches, making frequent 5. **Upload to Bazarr**: Uploads extracted subtitles back to Bazarr using the `/api/movies/subtitles` endpoint 6. **Cleanup**: Removes temporary files and updates tracking data +### TV Series Episodes +1. **Connect to Bazarr**: Fetches all episodes missing subtitles using the `/api/episodes/wanted` endpoint +2. **Episode Enrichment**: Retrieves series information for each episode from `/api/series` +3. **Multi-Pattern Search**: Searches SubSource using various patterns: + - Series name + S01E01 format + - Series name + episode title + - Scene release names +4. **Episode Matching**: Filters SubSource results to match specific season/episode using regex patterns +5. **Upload to Bazarr**: Uploads matched subtitles using the `/api/episodes/subtitles` endpoint +6. **Cleanup**: Removes temporary files and updates episode tracking data + ## Advanced Features +### Automatic Subtitle Synchronization +The tool uses Bazarr's built-in SubSync functionality for subtitle synchronization: + +#### Features +- **Conditional Sync**: Automatically synchronizes subtitles only if SubSync is enabled in Bazarr +- **Uses Bazarr Settings**: Reads sync parameters directly from your Bazarr configuration +- **Audio/Video Reference**: Uses first audio track as reference for sync +- **Framerate Handling**: Respects your Bazarr SubSync framerate settings +- **Advanced Algorithms**: Uses Golden-Section Search (GSS) if enabled in Bazarr + +#### How It Works +1. **Check SubSync Status**: Tool reads your Bazarr's SubSync settings via `/api/system/settings` +2. **Upload**: Subtitle is uploaded to Bazarr successfully +3. **Conditional Sync**: If SubSync is enabled in Bazarr, synchronization is performed +4. **Retrieve Path**: Gets the server-side subtitle file path from Bazarr +5. **Synchronize**: Calls Bazarr's `/api/subtitles` PATCH endpoint with your configured parameters +6. **Verification**: Confirms successful synchronization before cleanup + +#### SubSync Configuration +No configuration needed in this tool! SubSync settings are automatically retrieved from your Bazarr instance: +- Enable/disable SubSync in Bazarr Settings โ†’ Subtitles โ†’ SubSync +- Configure `max_offset_seconds`, `no_fix_framerate`, and `gss` settings in Bazarr +- Tool automatically uses your Bazarr SubSync preferences + +**Note**: If SubSync is disabled in Bazarr, subtitles will be uploaded without synchronization. + +### Sub-Zero Subtitle Content Modifications +The tool displays your Bazarr Sub-Zero configuration for subtitle content modifications: + +#### Features +- **Automatic Detection**: Reads Sub-Zero modification settings from Bazarr +- **Modification Display**: Shows which Sub-Zero mods are active +- **No Configuration Required**: Uses your existing Bazarr Sub-Zero settings + +#### How It Works +1. **Read Settings**: Tool fetches Sub-Zero configuration from `/api/system/settings` +2. **Display Status**: Shows whether Sub-Zero mods are enabled and which ones are active +3. **Automatic Triggering**: After successful subtitle upload, triggers Sub-Zero modifications if enabled +4. **Post-Processing**: Sub-Zero mods are applied before subtitle synchronization + +#### Common Sub-Zero Modifications +Based on your Bazarr configuration, common modifications include: +- **common**: Basic fixes (OCR errors, formatting, color tags) +- **hearing_impaired**: Hearing impaired content processing +- **ocr**: Advanced OCR error correction +- **fps**: Frame rate conversion fixes + +#### Processing Order +After successful subtitle upload to Bazarr: +1. **Sub-Zero Modifications** (if enabled): Applied first to improve subtitle quality +2. **SubSync** (if enabled): Applied second to synchronize timing + +This ensures subtitles are properly cleaned up before timing synchronization. + ### Intelligent Retry Logic The tool integrates with Bazarr's system tasks to determine optimal search intervals: - Reads Bazarr's "Search for Missing Movies Subtitles" task interval @@ -253,17 +344,19 @@ bazarr-subsource/ SubSource's anonymous API has rate limits. This tool implements: - 2-second delays between API calls -- Intelligent retry logic based on Bazarr's intervals +- Intelligent retry logic based on Bazarr's intervals for both movies and episodes - Local tracking to minimize unnecessary requests +- Episode-specific search patterns to reduce API calls - No authentication headers or account credentials needed ## Troubleshooting ### Common Issues -**"No movies are currently missing subtitles!"** -- Check if your Bazarr has movies with missing subtitles +**"No movies are currently missing subtitles!" / "No episodes want subtitles."** +- Check if your Bazarr has movies/episodes with missing subtitles - Verify your Bazarr API key and URL are correct +- For episodes: Ensure `episodes_enabled = true` in your config **"Error connecting to Bazarr API"** - Ensure Bazarr is running and accessible @@ -279,6 +372,12 @@ SubSource's anonymous API has rate limits. This tool implements: - The tool creates a default config on first run - Edit `~/.config/bazarr-subsource/config.cfg` with your settings +**Episode subtitles not found** +- Episodes are searched using multiple patterns (S01E01, episode title, scene name) +- SubSource has limited TV series coverage compared to movies +- Check if the series name matches exactly in both Bazarr and SubSource +- Some episodes may not have subtitles available on SubSource + **Cron job not running or failing** - Check cron service is running: `sudo systemctl status cron` - Verify cron job syntax: `crontab -l` diff --git a/api/bazarr.py b/api/bazarr.py index 6263000..9344a4a 100644 --- a/api/bazarr.py +++ b/api/bazarr.py @@ -6,7 +6,7 @@ import logging import os import re -from typing import Dict, Optional +from typing import Dict, List, Optional import requests @@ -54,7 +54,7 @@ def get_wanted_movies(self, start: int = 0, length: int = -1) -> Optional[Dict]: logger.error(f"Error parsing JSON response: {e}") return None - def upload_subtitle_to_bazarr( + def upload_movie_subtitle( self, radarr_id: int, subtitle_file: str, @@ -63,7 +63,7 @@ def upload_subtitle_to_bazarr( hi: bool = False, ) -> bool: """ - Upload a subtitle file to Bazarr. + Upload a subtitle file for a movie to Bazarr. Args: radarr_id: Radarr movie ID @@ -105,6 +105,207 @@ def upload_subtitle_to_bazarr( print(f" โœ— Error reading subtitle file: {e}") return False + def sync_subtitle( + self, + subtitle_path: str, + media_type: str, + media_id: int, + language: str, + forced: bool = False, + hi: bool = False, + reference: str = "a:0", + max_offset_seconds: int = 300, + no_fix_framerate: bool = False, + use_gss: bool = False, + ) -> bool: + """ + Synchronize a subtitle file using Bazarr's sync functionality. + + Args: + subtitle_path: Path to the subtitle file on the Bazarr server + media_type: Either "movie" or "episode" + media_id: Media ID (radarrId for movies, episodeId for episodes) + language: Language code (e.g., 'en') + forced: Whether subtitle is forced + hi: Whether subtitle is hearing impaired + reference: Reference for sync (e.g., 'a:0' for first audio track) + max_offset_seconds: Maximum offset seconds to allow + no_fix_framerate: Don't try to fix framerate issues + use_gss: Use Golden-Section Search algorithm + + Returns: + True if successful, False otherwise + """ + try: + url = f"{self.bazarr_url}/api/subtitles" + + # Prepare sync parameters + params = { + "action": "sync", + "language": language, + "path": subtitle_path, + "type": media_type, + "id": media_id, + "forced": "true" if forced else "false", + "hi": "true" if hi else "false", + "reference": reference, + "max_offset_seconds": str(max_offset_seconds), + "no_fix_framerate": "true" if no_fix_framerate else "false", + "gss": "true" if use_gss else "false", + } + + response = self.session.patch( + url, params=params, auth=self.auth, timeout=300 + ) + response.raise_for_status() + + print(" โœ“ Synchronized subtitle with Bazarr") + return True + + except requests.exceptions.RequestException as e: + print(f" โœ— Error synchronizing subtitle: {e}") + return False + + def trigger_subzero_mods( + self, + subtitle_path: str, + media_type: str, + media_id: int, + language: str, + forced: bool = False, + hi: bool = False, + ) -> bool: + """ + Trigger Sub-Zero subtitle modifications using Bazarr's API. + + Args: + subtitle_path: Path to the subtitle file on the Bazarr server + media_type: Either "movie" or "episode" + media_id: Media ID (radarrId for movies, episodeId for episodes) + language: Language code (e.g., 'en') + forced: Whether subtitle is forced + hi: Whether subtitle is hearing impaired + + Returns: + True if successful, False otherwise + """ + try: + url = f"{self.bazarr_url}/api/subtitles" + + # Prepare Sub-Zero modification parameters + params = { + "action": "subzero", + "language": language, + "path": subtitle_path, + "type": media_type, + "id": media_id, + "forced": "true" if forced else "false", + "hi": "true" if hi else "false", + } + + response = self.session.patch( + url, params=params, auth=self.auth, timeout=60 + ) + response.raise_for_status() + + print(" โœ“ Applied Sub-Zero modifications") + return True + + except requests.exceptions.RequestException as e: + print(f" โœ— Error applying Sub-Zero modifications: {e}") + return False + + def get_movie_subtitles(self, radarr_id: int) -> Optional[Dict]: + """ + Get movie details including subtitle information. + + Args: + radarr_id: Radarr movie ID + + Returns: + Movie data with subtitle paths or None if error + """ + try: + url = f"{self.bazarr_url}/api/movies" + params = {"radarrid[]": radarr_id} + + response = self.session.get(url, params=params, auth=self.auth, timeout=30) + response.raise_for_status() + + data = response.json() + if data and "data" in data and data["data"]: + return data["data"][0] # Return first (and only) movie + return None + + except requests.exceptions.RequestException as e: + logger.error(f"Error getting movie subtitles: {e}") + return None + except (json.JSONDecodeError, KeyError, IndexError) as e: + logger.error(f"Error parsing movie subtitles response: {e}") + return None + + def get_system_settings(self) -> Optional[Dict]: + """ + Get system settings from Bazarr. + + Returns: + Dictionary containing system settings or None if error + """ + try: + url = f"{self.bazarr_url}/api/system/settings" + response = self.session.get(url, auth=self.auth, timeout=30) + response.raise_for_status() + return response.json() + except (requests.exceptions.RequestException, json.JSONDecodeError) as e: + logger.error(f"Error fetching system settings: {e}") + return None + + def get_sync_settings(self) -> Dict: + """ + Get subtitle synchronization settings from Bazarr's system settings. + + Returns: + Dictionary containing sync settings with defaults if not available + """ + settings = self.get_system_settings() + if not settings or "subsync" not in settings: + logger.warning("Could not fetch sync settings from Bazarr, using defaults") + return { + "enabled": False, + "max_offset_seconds": 300, + "no_fix_framerate": False, + "use_gss": False, + "reference": "a:0", + } + + subsync = settings["subsync"] + return { + "enabled": subsync.get("use_subsync", False), + "max_offset_seconds": subsync.get("max_offset_seconds", 300), + "no_fix_framerate": subsync.get("no_fix_framerate", False), + "use_gss": subsync.get("gss", False), + "reference": "a:0", # Always use first audio track as reference + } + + def get_subzero_settings(self) -> Dict: + """ + Get Sub-Zero subtitle modification settings from Bazarr's system settings. + + Returns: + Dictionary containing Sub-Zero settings with defaults if not available + """ + settings = self.get_system_settings() + if not settings or "general" not in settings: + logger.warning( + "Could not fetch Sub-Zero settings from Bazarr, using defaults" + ) + return {"mods": [], "enabled": False} + + general = settings["general"] + subzero_mods = general.get("subzero_mods", []) + + return {"mods": subzero_mods, "enabled": len(subzero_mods) > 0} + def get_system_tasks(self) -> Optional[Dict]: """ Fetch system tasks from Bazarr API to get search intervals. @@ -182,7 +383,6 @@ def get_missing_subtitles_search_interval(self) -> int: search_name.lower() in task_job_id.lower() for search_name in search_task_names ): - # Get interval - could be in different formats interval_str = task.get("interval", "") @@ -295,3 +495,331 @@ def _parse_interval_to_minutes(self, interval_str: str) -> int: return int(interval_str) * 60 raise ValueError(f"Unrecognized interval format: {interval_str}") + + def get_wanted_episodes(self, start: int = 0, length: int = -1) -> List[Dict]: + """ + Get list of wanted episodes from Bazarr. + + Args: + start: Paging start integer + length: Paging length integer (-1 for all) + + Returns: + List of wanted episode dictionaries + """ + try: + url = f"{self.bazarr_url}/api/episodes/wanted" + params = {"start": start, "length": length} + + response = self.session.get(url, params=params, auth=self.auth, timeout=30) + response.raise_for_status() + + data = response.json() + episodes = data.get("data", []) if isinstance(data, dict) else data + + # Enrich episode data with series information + enriched_episodes = [] + for episode in episodes: + enriched_episode = self._enrich_episode_data(episode) + if enriched_episode: + enriched_episodes.append(enriched_episode) + + logger.info(f"Found {len(enriched_episodes)} wanted episodes") + return enriched_episodes + + except requests.exceptions.RequestException as e: + logger.error(f"Error fetching wanted episodes: {e}") + return [] + + def _enrich_episode_data(self, episode: Dict) -> Optional[Dict]: + """ + Enrich episode data with series information. + + Args: + episode: Raw episode data from Bazarr + + Returns: + Enriched episode data or None + """ + season, episode_number = episode.get("episode_number", "").split("x") + enriched_episode = { + "series_title": episode.get("seriesTitle", "Unknown Series").strip(), + "season": season, + "episode_number": episode_number, + "episode_title": episode.get("episodeTitle", "Unknown Episode").strip(), + "missing_subtitles": episode.get("missing_subtitles", []), + "sonarr_series_id": episode.get("sonarrSeriesId", ""), + "sonarr_episode_id": episode.get("sonarrEpisodeId", ""), + "scene_name": episode.get("sceneName", ""), + "tags": episode.get("tags", []), + "series_type": episode.get("seriesType", "standard"), + } + + try: + # Get more series information + series_info = self.get_series_info(enriched_episode["sonarr_series_id"]) + if series_info: + enriched_episode["year"] = series_info.get("year") + enriched_episode["imdb"] = series_info.get("imdbId") + enriched_episode["tvdb"] = series_info.get("tvdbId") + + return enriched_episode + + except Exception as e: + logger.warning(f"Could not enrich episode data: {e}") + return enriched_episode + + def get_series_info(self, series_id: int) -> Optional[Dict]: + """ + Get series information from Bazarr. + + Args: + series_id: Series ID + + Returns: + Series information dictionary or None + """ + try: + url = f"{self.bazarr_url}/api/series" + params = {"seriesid[]": series_id} + + response = self.session.get(url, params=params, auth=self.auth, timeout=30) + response.raise_for_status() + + data = response.json() + series_list = data.get("data", []) if isinstance(data, dict) else data + + # Find the series with matching ID + for series in series_list: + if ( + series.get("sonarrSeriesId") == series_id + or series.get("seriesId") == series_id + ): + return series + + return None + + except requests.exceptions.RequestException as e: + logger.warning(f"Error fetching series info for ID {series_id}: {e}") + return None + + def upload_episode_subtitle( + self, + series_id: int, + episode_id: int, + language: str, + subtitle_file: str, + forced: bool = False, + hi: bool = False, + ) -> bool: + """ + Upload a subtitle file for an episode to Bazarr. + + Args: + series_id: Series ID + episode_id: Episode ID + language: Language code (e.g., 'en') + subtitle_file: Path to subtitle file + forced: Whether subtitle is forced + hi: Whether subtitle is hearing impaired + + Returns: + True if upload successful, False otherwise + """ + try: + url = f"{self.bazarr_url}/api/episodes/subtitles" + + params = { + "seriesid": series_id, + "episodeid": episode_id, + "language": language, + "forced": "true" if forced else "false", + "hi": "true" if hi else "false", + } + + with open(subtitle_file, "rb") as f: + files = {"file": (subtitle_file, f, "text/plain")} + + response = self.session.post( + url, params=params, files=files, auth=self.auth, timeout=60 + ) + response.raise_for_status() + + logger.info( + f"Successfully uploaded subtitle for episode {episode_id}: " + f"{subtitle_file}" + ) + return True + + except requests.exceptions.RequestException as e: + logger.error(f"Error uploading episode subtitle: {e}") + return False + except FileNotFoundError: + logger.error(f"Subtitle file not found: {subtitle_file}") + return False + + def get_episode_search_interval(self) -> int: + """ + Get episode search interval from Bazarr settings. + + Returns: + Search interval in hours (default 24) + """ + try: + url = f"{self.bazarr_url}/api/system/settings" + response = self.session.get(url, auth=self.auth, timeout=30) + response.raise_for_status() + + settings = response.json() + + # Look for episode search interval setting + # This might be under different keys depending on Bazarr version + interval = settings.get("general", {}).get("episode_search_interval", 24) + if isinstance(interval, str): + interval = int(interval) + + return max(1, interval) # Minimum 1 hour + + except Exception as e: + logger.warning(f"Could not get episode search interval from Bazarr: {e}") + return 24 # Default fallback + + def sync_episode_subtitle( + self, + subtitle_path: str, + series_id: int, + episode_id: int, + language: str, + forced: bool = False, + hi: bool = False, + reference: str = "a:0", + max_offset_seconds: int = 300, + no_fix_framerate: bool = False, + use_gss: bool = False, + ) -> bool: + """ + Synchronize an episode subtitle file using Bazarr's sync functionality. + + Args: + subtitle_path: Path to the subtitle file on the Bazarr server + series_id: Sonarr series ID + episode_id: Sonarr episode ID + language: Language code (e.g., 'en') + forced: Whether subtitle is forced + hi: Whether subtitle is hearing impaired + reference: Reference for sync (e.g., 'a:0' for first audio track) + max_offset_seconds: Maximum offset seconds to allow + no_fix_framerate: Don't try to fix framerate issues + use_gss: Use Golden-Section Search algorithm + + Returns: + True if successful, False otherwise + """ + try: + url = f"{self.bazarr_url}/api/subtitles" + + # Prepare sync parameters + params = { + "action": "sync", + "language": language, + "path": subtitle_path, + "type": "episode", + "id": episode_id, + "forced": "true" if forced else "false", + "hi": "true" if hi else "false", + "reference": reference, + "max_offset_seconds": str(max_offset_seconds), + "no_fix_framerate": "true" if no_fix_framerate else "false", + "gss": "true" if use_gss else "false", + } + + response = self.session.patch( + url, params=params, auth=self.auth, timeout=300 + ) + response.raise_for_status() + + print(" โœ“ Synchronized episode subtitle with Bazarr") + return True + + except requests.exceptions.RequestException as e: + print(f" โœ— Error synchronizing episode subtitle: {e}") + return False + + def trigger_episode_subzero_mods( + self, + subtitle_path: str, + series_id: int, + episode_id: int, + language: str, + forced: bool = False, + hi: bool = False, + ) -> bool: + """ + Trigger Sub-Zero subtitle modifications for an episode using Bazarr's API. + + Args: + subtitle_path: Path to the subtitle file on the Bazarr server + series_id: Sonarr series ID + episode_id: Sonarr episode ID + language: Language code (e.g., 'en') + forced: Whether subtitle is forced + hi: Whether subtitle is hearing impaired + + Returns: + True if successful, False otherwise + """ + try: + url = f"{self.bazarr_url}/api/subtitles" + + # Prepare Sub-Zero modification parameters + params = { + "action": "subzero", + "language": language, + "path": subtitle_path, + "type": "episode", + "id": episode_id, + "forced": "true" if forced else "false", + "hi": "true" if hi else "false", + } + + response = self.session.patch( + url, params=params, auth=self.auth, timeout=60 + ) + response.raise_for_status() + + print(" โœ“ Applied Sub-Zero modifications to episode") + return True + + except requests.exceptions.RequestException as e: + print(f" โœ— Error applying Sub-Zero modifications to episode: {e}") + return False + + def get_episode_subtitles(self, series_id: int, episode_id: int) -> Optional[Dict]: + """ + Get episode details including subtitle information. + + Args: + series_id: Sonarr series ID + episode_id: Sonarr episode ID + + Returns: + Episode data with subtitle paths or None if error + """ + try: + url = f"{self.bazarr_url}/api/episodes" + params = {"seriesid[]": series_id, "episodeid[]": episode_id} + + response = self.session.get(url, params=params, auth=self.auth, timeout=30) + response.raise_for_status() + + data = response.json() + if data and "data" in data and data["data"]: + return data["data"][0] # Return first (and only) episode + return None + + except requests.exceptions.RequestException as e: + logger.error(f"Error getting episode subtitles: {e}") + return None + except (KeyError, IndexError) as e: + logger.error(f"Error parsing episode subtitles response: {e}") + return None diff --git a/api/subsource.py b/api/subsource.py index 963e457..fb34adb 100644 --- a/api/subsource.py +++ b/api/subsource.py @@ -5,9 +5,10 @@ import json import logging import os +import re import time import zipfile -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Tuple import requests @@ -19,7 +20,9 @@ class SubSourceDownloader: """SubSource subtitle downloader.""" - def __init__(self, api_url: str, download_dir: str, bazarr=None): + def __init__( + self, api_url: str, download_dir: str, bazarr=None, cf_clearance: str = None + ): self.api_url = api_url self.download_dir = download_dir self.session = requests.Session() @@ -28,19 +31,36 @@ def __init__(self, api_url: str, download_dir: str, bazarr=None): self._search_interval_hours = None self._movie_years_cache = {} # Cache movie years to avoid repeated API calls - # Setup optimized session headers + # Setup optimized session headers with Cloudflare bypass headers self.session.headers.update( { - "User-Agent": ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " - "AppleWebKit/537.36" - ), - "Accept": "application/json", + "Accept": "application/json, text/plain, */*", + "Accept-Language": "en,bn;q=0.9,en-US;q=0.8", "Content-Type": "application/json", - "Connection": "keep-alive", + "Cache-Control": "no-cache", + "DNT": "1", + "Origin": "https://subsource.net", + "Pragma": "no-cache", + "Referer": "https://subsource.net/", + "Sec-CH-UA": '"Chromium";v="142", "Microsoft Edge";v="142", "Not_A Brand";v="99"', + "Sec-CH-UA-Mobile": "?0", + "Sec-CH-UA-Platform": '"macOS"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-site", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0", } ) + # Set Cloudflare clearance cookie (priority: env var > config parameter) + cf_clearance_cookie = os.environ.get("SUBSOURCE_CF_CLEARANCE") or cf_clearance + if cf_clearance_cookie: + self.session.cookies.set("cf_clearance", cf_clearance_cookie) + source = ( + "environment" if os.environ.get("SUBSOURCE_CF_CLEARANCE") else "config" + ) + logger.info(f"Using Cloudflare clearance cookie from {source}") + # Create download directory if it doesn't exist os.makedirs(download_dir, exist_ok=True) @@ -207,8 +227,7 @@ def download_subtitle(self, subtitle_info: Dict, filename: str) -> Optional[str] if not download_token: logger.error( - f"No download token found in response for subtitle ID " - f"{subtitle_id}" + f"No download token found in response for subtitle ID {subtitle_id}" ) logger.debug(f"Response data: {details_data}") return None @@ -234,8 +253,7 @@ def download_subtitle(self, subtitle_info: Dict, filename: str) -> Optional[str] if "text/html" in content_type: logger.error( - f"Received HTML instead of ZIP file for subtitle ID " - f"{subtitle_id}" + f"Received HTML instead of ZIP file for subtitle ID {subtitle_id}" ) return None @@ -317,8 +335,7 @@ def _extract_subtitle_from_zip( if not subtitle_files: logger.error( - f"No subtitle files found in ZIP for subtitle ID " - f"{subtitle_id}" + f"No subtitle files found in ZIP for subtitle ID {subtitle_id}" ) return None @@ -562,3 +579,386 @@ def get_tracking_summary(self) -> dict: summary = self.tracker.get_tracking_summary() summary["search_interval_hours"] = self._get_search_interval_hours() return summary + + # TV Series / Episode methods + + def _extract_episode_info_from_subtitle( + self, subtitle: Dict + ) -> Tuple[Optional[int], Optional[int]]: + """ + Extract season/episode info from subtitle release info. + + Args: + subtitle: Subtitle data from SubSource + + Returns: + Tuple of (season, episode) or (None, None) if not found + """ + release_info = subtitle.get("release_info", "") + + # First, look for S01E01 pattern (most specific) + season_episode_match = re.search(r"[Ss](\d+)[Ee](\d+)", release_info) + if season_episode_match: + season = int(season_episode_match.group(1)) + episode = int(season_episode_match.group(2)) + return season, episode + + # Look for 1x01 pattern + alt_pattern = re.search(r"(\d+)x(\d+)", release_info) + if alt_pattern: + season = int(alt_pattern.group(1)) + episode = int(alt_pattern.group(2)) + return season, episode + + # Fallback: Look for standalone E01 pattern (least specific) + episode_match = re.search(r"[Ee](\d+)", release_info) + if episode_match: + episode = int(episode_match.group(1)) + # For E01 format, we don't extract season from the subtitle + # We rely on the season context from the search + return None, episode + + return None, None + + def _find_best_series_match( + self, + search_results: List[Dict], + series_title: str, + series_year: Optional[int], + season: int, + ) -> Optional[Dict]: + """ + Find the best matching TV series from search results. + + Args: + search_results: List of search results from SubSource + series_title: Target series title + series_year: Target series year (optional) + season: Target season number + + Returns: + Best matching series or None + """ + series_candidates = [] + + # Filter for TV series results + for result in search_results: + result_type = result.get("type", "").lower() + result_title = result.get("title", "").lower() + series_title_lower = series_title.lower() + + # Must be a TV series and title must match + if result_type == "tvseries" and series_title_lower in result_title: + series_candidates.append(result) + + if not series_candidates: + print(f" No TV series found matching '{series_title}'") + return None + + print(f" Found {len(series_candidates)} TV series candidate(s)") + + # If only one candidate, return it + if len(series_candidates) == 1: + return series_candidates[0] + + # Multiple candidates - filter by release year if available + if series_year: + year_matches = [] + for candidate in series_candidates: + candidate_year = candidate.get("releaseYear") + if candidate_year == series_year: + year_matches.append(candidate) + + if year_matches: + print(f" Matched {len(year_matches)} series by year {series_year}") + # If multiple year matches, check for season availability + if len(year_matches) == 1: + return year_matches[0] + else: + # Check which has the target season + for candidate in year_matches: + if self._has_season(candidate, season): + return candidate + # If no season match, return first year match + return year_matches[0] + + # No year match or no year provided - check for season availability + for candidate in series_candidates: + if self._has_season(candidate, season): + print(f" Selected series with season {season}") + return candidate + + # If no season match, return first candidate + print(" No exact match found, using first candidate") + return series_candidates[0] + + def _has_season(self, series: Dict, season: int) -> bool: + """ + Check if a series has the specified season. + + Args: + series: Series data from SubSource + season: Target season number + + Returns: + True if series has the season + """ + seasons = series.get("seasons", []) + if not seasons: + return False + + for season_data in seasons: + season_num = season_data.get("season") + if season_num == season: + return True + return False + + def _get_season_link(self, series: Dict, season: int) -> Optional[str]: + """ + Get the link for a specific season from series data. + + Args: + series: Series data from SubSource + season: Target season number + + Returns: + Season link or None if not found + """ + seasons = series.get("seasons", []) + if not seasons: + print(" No seasons data available") + return None + + # Look for exact season match first + for season_data in seasons: + season_num = int(season_data.get("season")) + if int(season_num) == int(season): + link = season_data.get("link") + if link: + print(f" Found exact season {season} match") + return link.replace("=", "-") + + # If only one season available, use it even if number doesn't match + if len(seasons) == 1: + link = seasons[0].get("link") + if link: + available_season = seasons[0].get("season", "unknown") + print( + f" Using only available season {available_season} for target season {season}" + ) + return link.replace("=", "-") + + # Multiple seasons but no exact match - check release years + series_year = series.get("releaseYear") + if series_year: + # Try to find season with matching release year or close to it + for season_data in seasons: + season_year = season_data.get("releaseYear") + if season_year and abs(season_year - series_year) <= 1: # Within 1 year + link = season_data.get("link") + if link: + available_season = season_data.get("season", "unknown") + print( + f" Using season {available_season} based on release year match" + ) + return link.replace("=", "-") + + print(f" No suitable season found for season {season}") + return None + + def _is_subtitle_match(self, subtitle: Dict, target_episode: Dict) -> bool: + """ + Check if a subtitle matches the target episode. + + Args: + subtitle: Subtitle data from SubSource + target_episode: Episode data from Bazarr + + Returns: + True if subtitle matches episode + """ + target_season = target_episode.get("season") + target_episode_num = target_episode.get("episode_number") or target_episode.get( + "episode" + ) + + if not target_season or not target_episode_num: + return False + + sub_season, sub_episode = self._extract_episode_info_from_subtitle(subtitle) + + # If we got both season and episode from subtitle (S01E01 format) + if sub_season is not None and sub_episode is not None: + return sub_season == target_season and sub_episode == target_episode_num + + # If we only got episode number (E01 format), match just the episode + # We assume the season context is correct from the search + if sub_episode is not None: + return sub_episode == target_episode_num + + return False + + def search_episode_subtitles( + self, episode: Dict, language: str = "english" + ) -> List[Dict]: + """ + Search for subtitles for a specific episode. + + Args: + episode: Episode data from Bazarr + language: Subtitle language + + Returns: + List of matching subtitle results + """ + series_title = episode.get("series_title", "Unknown") + season = episode.get("season", 0) + episode_number = episode.get("episode_number", 0) + series_year = episode.get("seriesYear") + + print(f" Searching SubSource for: {series_title} S{season}E{episode_number}") + + try: + print(f" Searching with series name: {series_title}") + + # Search with original series name only + search_url = f"{self.api_url}/movie/search" + search_payload = { + "query": series_title, + "signal": {}, + "includeSeasons": True, # Include TV shows + "limit": 15, + } + + response = self.session.post(search_url, json=search_payload, timeout=15) + response.raise_for_status() + + time.sleep(2) # Rate limiting + + search_data = response.json() + search_results = search_data.get("results", []) + + print(f" Found {len(search_results)} result(s)") + + # Find the best matching TV series + best_series = self._find_best_series_match( + search_results, series_title, series_year, season + ) + + if not best_series: + print(" No matching TV series found") + episode_key = f"{series_title}:S{season}E{episode_number}" + self.tracker.record_no_subtitles_found(episode_key, 0, language) + return [] + + # Get season link from the best series + season_link = self._get_season_link(best_series, season) + if not season_link: + print(f" Season {season} not found in series") + episode_key = f"{series_title}:S{season}E{episode_number}" + self.tracker.record_no_subtitles_found(episode_key, 0, language) + return [] + + print(f" Found season {season}, getting subtitles...") + + # Get subtitles for the season + subtitles_url = f"{self.api_url}{season_link}" + params = {"language": language.lower(), "sort_by_date": "false"} + + time.sleep(2) # Rate limiting + + sub_response = self.session.get(subtitles_url, params=params, timeout=15) + sub_response.raise_for_status() + + subtitles_data = sub_response.json() + if isinstance(subtitles_data, list): + subtitles = subtitles_data + else: + subtitles = subtitles_data.get("subtitles", []) + + # Filter subtitles that match our episode + matching_subtitles = [] + for subtitle in subtitles: + if self._is_subtitle_match(subtitle, episode): + subtitle["source_query"] = series_title + subtitle["source_link"] = season_link + matching_subtitles.append(subtitle) + + print(f" Found {len(matching_subtitles)} matching episode subtitles") + + if not matching_subtitles: + episode_key = f"{series_title}:S{season}E{episode_number}" + self.tracker.record_no_subtitles_found(episode_key, 0, language) + + return matching_subtitles + + except requests.exceptions.RequestException as e: + print(f" Error searching for episode: {e}") + episode_key = f"{series_title}:S{season}E{episode_number}" + self.tracker.record_no_subtitles_found(episode_key, 0, language) + return [] + + def get_subtitle_for_episode(self, episode: Dict) -> Tuple[List[str], int]: + """ + Download subtitles for an episode. + + Args: + episode: Episode dictionary from Bazarr API + + Returns: + Tuple of (downloaded subtitle file paths, number of skipped subtitles) + """ + series_title = episode.get("series_title") + season = episode.get("season") + episode_number = episode.get("episode_number") + missing_subs = episode.get("missing_subtitles", []) + + episode_key = f"{series_title}:S{season}E{episode_number}" + + downloaded_files = [] + skipped_count = 0 + + print(f" Processing: {episode_key}") + + for sub in missing_subs: + lang_name = sub.get("name", "Unknown") + lang_code = sub.get("code2", "en") + + print(f" Looking for {lang_name} subtitle...") + + # Check if we should skip this search based on recent failures + search_interval = self._get_search_interval_hours() + if self.tracker.should_skip_search( + episode_key, 0, lang_name.lower(), search_interval + ): + print( + f" Skipping {lang_name} subtitle " + f"(last tried within {search_interval}h interval)" + ) + skipped_count += 1 + continue + + # Search for subtitles + results = self.search_episode_subtitles(episode, lang_name.lower()) + + if not results: + print(f" No subtitles found for {lang_name}") + continue + + # Take the best result (first one) + best_result = results[0] + + # Download subtitle + downloaded_file = self.download_subtitle( + best_result, f"temp_episode_{lang_code}.srt" + ) + if downloaded_file: + downloaded_files.append(downloaded_file) + print(f" โœ“ Downloaded {lang_name} subtitle") + else: + self.tracker.record_download_failure( + episode_key, 0, lang_name.lower(), "Download failed" + ) + print(f" โœ— Failed to download {lang_name} subtitle") + + return downloaded_files, skipped_count diff --git a/core/config.py b/core/config.py index 7021196..cdd9410 100644 --- a/core/config.py +++ b/core/config.py @@ -43,7 +43,17 @@ def load_config(): "username": config.get("auth", "username"), "password": config.get("auth", "password"), "subsource_api_url": config.get("subsource", "api_url"), + "subsource_cf_clearance": config.get( + "subsource", "cf_clearance", fallback="" + ), "download_directory": config.get("download", "directory"), + "movies_enabled": config.getboolean("movies", "enabled", fallback=True), + "episodes_enabled": config.getboolean("episodes", "enabled", fallback=True), + "episodes_search_patterns": config.get( + "episodes", + "search_patterns", + fallback="season_episode,episode_title,scene_name", + ), "log_level": config.get("logging", "level", fallback="INFO"), "log_file": config.get("logging", "file", fallback="bazarr_subsource.log"), } @@ -71,6 +81,13 @@ def create_default_config(config_file: Path): config["download"] = {"directory": "/tmp/downloaded_subtitles"} + config["movies"] = {"enabled": "true"} + + config["episodes"] = { + "enabled": "true", + "search_patterns": "season_episode,episode_title,scene_name", + } + config["logging"] = {"level": "INFO", "file": "/var/log/bazarr_subsource.log"} with open(config_file, "w") as f: @@ -90,18 +107,35 @@ def create_default_config(config_file: Path): "in front of Bazarr\n" ) f.write( - "# Leave empty or remove this section if connecting " "directly to Bazarr\n" + "# Leave empty or remove this section if connecting directly to Bazarr\n" ) f.write("username = your_username\n") f.write("password = your_password\n\n") # Write remaining sections f.write("[subsource]\n") - f.write("api_url = https://api.subsource.net/v1\n\n") + f.write("api_url = https://api.subsource.net/v1\n") + f.write( + "# Cloudflare clearance cookie (get from browser DevTools > Application > Cookies)\n" + ) + f.write( + "# Leave empty if not needed or set via SUBSOURCE_CF_CLEARANCE environment variable\n" + ) + f.write("cf_clearance = \n\n") f.write("[download]\n") f.write("directory = /tmp/downloaded_subtitles\n\n") + f.write("[movies]\n") + f.write("# Enable movie subtitle downloads\n") + f.write("enabled = true\n\n") + + f.write("[episodes]\n") + f.write("# Enable TV series episode subtitle downloads\n") + f.write("enabled = true\n") + f.write("# Search patterns: season_episode,episode_title,scene_name\n") + f.write("search_patterns = season_episode,episode_title,scene_name\n\n") + f.write("[logging]\n") f.write("level = INFO\n") f.write("file = /var/log/bazarr_subsource.log\n") @@ -128,7 +162,10 @@ def setup_logging(log_level: str, log_file: str): # Rotating file handler - 10MB max, keep 5 old files file_handler = logging.handlers.RotatingFileHandler( - log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8" # 10MB + log_file, + maxBytes=10 * 1024 * 1024, + backupCount=5, + encoding="utf-8", # 10MB ) file_handler.setLevel(level) file_handler.setFormatter(detailed_formatter) diff --git a/run.py b/run.py index 8329d10..5de296f 100644 --- a/run.py +++ b/run.py @@ -36,7 +36,7 @@ from api.bazarr import Bazarr from api.subsource import SubSourceDownloader from core.config import load_config, setup_logging -from utils import format_movie_info +from utils import format_movie_info, format_episode_info # Logging will be configured after loading config logger = None @@ -49,6 +49,8 @@ def main(): try: # Load configuration first config = load_config() + movies_enabled = config.get("movies_enabled", True) + episodes_enabled = config.get("episodes_enabled", True) # Setup logging setup_logging(config["log_level"], config["log_file"]) @@ -59,11 +61,12 @@ def main(): logger.info("Starting Bazarr SubSource execution") logger.info("=" * 60) - print("Bazarr Wanted Movies - SubSource Downloader") + print("Bazarr SubSource Integration Tool") print("=" * 50) - print(f"Connecting to Bazarr at: {config['bazarr_url']}") - print("Fetching wanted movies...", end=" ", flush=True) + print( + f"Connecting to Bazarr at: {config['bazarr_url']}...", end=" ", flush=True + ) # Initialize Bazarr client bazarr = Bazarr( @@ -73,126 +76,409 @@ def main(): config["password"], ) - # Fetch wanted movies - data = bazarr.get_wanted_movies() - if data is None: - sys.exit(1) - - # Extract movies from response - movies = data.get("data", []) - - print(f"Done!\nFound {len(movies)} wanted movies") - - if not movies: - print("No movies are currently missing subtitles!") - return - - print("\nWanted Movies:") - print("-" * 40) + # Test connection and get settings + try: + sync_settings = bazarr.get_sync_settings() + subzero_settings = bazarr.get_subzero_settings() + print("โœ“ Connected") + except Exception as e: + print("โœ— Connection failed") + logger.error(f"Failed to connect to Bazarr: {e}") + raise + + print("\nBazarr Configuration:") + print("-" * 20) + + # SubSync settings + if sync_settings["enabled"]: + print("โœ“ SubSync: Enabled") + print(f" โ€ข Max Offset Seconds: {sync_settings['max_offset_seconds']}s") + print( + f" โ€ข Golden-Section Search: {'Yes' if sync_settings['use_gss'] else 'No'}" + ) + print( + f" โ€ข No Fix Framerate: {'Yes' if sync_settings['no_fix_framerate'] else 'No'}" + ) + else: + print("โœ— SubSync: Disabled") - # Display each movie - for movie in movies: - print(format_movie_info(movie)) + # Sub-Zero settings + if subzero_settings["enabled"]: + print("โœ“ Sub-Zero: Enabled") + print( + f" โ€ข Common Fixes: {'Yes' if 'common' in subzero_settings['mods'] else 'No'}" + ) + print( + f" โ€ข Remove Tags: {'Yes' if 'remove_tags' in subzero_settings['mods'] else 'No'}" + ) + print( + f" โ€ข OCR Fixes: {'Yes' if 'OCR_fixes' in subzero_settings['mods'] else 'No'}" + ) + print( + f" โ€ข Fix Uppercase: {'Yes' if 'fix_uppercase' in subzero_settings['mods'] else 'No'}" + ) + print( + f" โ€ข Remove HI: {'Yes' if 'remove_HI' in subzero_settings['mods'] else 'No'}" + ) + else: + print("โœ— Sub-Zero: Disabled") + + # Initialize SubSource downloader (needed for both movies and episodes) + downloader = None + if movies_enabled or episodes_enabled: + print("\nInitializing SubSource downloader...") + downloader = SubSourceDownloader( + config["subsource_api_url"], + config["download_directory"], + bazarr, # Pass Bazarr client for API calls + cf_clearance=config.get("subsource_cf_clearance"), + ) + print(f"Download directory: {config['download_directory']}") + print("โœ“ SubSource downloader initialized") - print(f"\nTotal: {len(movies)} movies need subtitles") + # Process movies if enabled + movies = [] + total_downloads = 0 + successful_uploads = 0 + subtitles_skipped = 0 - # Automatically proceed with downloading subtitles print("\n" + "=" * 50) - print("Automatically downloading missing subtitles from SubSource...") - - # Initialize SubSource downloader - print("\nInitializing SubSource downloader...") - downloader = SubSourceDownloader( - config["subsource_api_url"], - config["download_directory"], - bazarr, # Pass Bazarr client for API calls - ) + print("PROCESSING MOVIES") + print("=" * 50) - print(f"Download directory: {config['download_directory']}") + if movies_enabled: + print("Fetching wanted movies from Bazarr...", end=" ", flush=True) - # Clean up obsolete tracking entries - print("Cleaning up obsolete tracking entries...") - removed_count = downloader.tracker.cleanup_obsolete_movies(movies) - if removed_count > 0: - print(f"Removed {removed_count} obsolete movie(s) from tracking database") + # Fetch wanted movies + data = bazarr.get_wanted_movies() + if data is None: + sys.exit(1) - print("\nStarting subtitle downloads...") - print("=" * 50) + # Extract movies from response + movies = data.get("data", []) - total_downloads = 0 - successful_uploads = 0 - subtitles_skipped = 0 + print(f"Done!\nFound {len(movies)} wanted movies") - # Process each movie - for i, movie in enumerate(movies, 1): - print(f"\n[{i}/{len(movies)}] Processing movie:") - - # Download subtitles for this movie - downloaded_files, movie_skipped = downloader.get_subtitle_for_movie(movie) - subtitles_skipped += movie_skipped - - if not downloaded_files: - print(" No subtitles downloaded for this movie.") - continue - - total_downloads += len(downloaded_files) - - # Get movie info for upload - radarr_id = movie.get("radarrId", movie.get("radarrid")) - if not radarr_id: - print(" โœ— No Radarr ID found, cannot upload to Bazarr") - continue - - # Upload each downloaded subtitle to Bazarr - print(" Uploading subtitles to Bazarr...") - missing_subs = movie.get("missing_subtitles", []) - - for j, subtitle_file in enumerate(downloaded_files): - if j < len(missing_subs): - sub_info = missing_subs[j] - lang_code = sub_info.get("code2", "en") - forced = sub_info.get("forced", False) - hi = sub_info.get("hi", False) - - if bazarr.upload_subtitle_to_bazarr( - radarr_id, subtitle_file, lang_code, forced, hi - ): - successful_uploads += 1 - - # Clean up tracking database for successful download - title = movie.get("title", "Unknown") - year = movie.get("year", 0) - lang_name = sub_info.get("name", "Unknown") - downloader.tracker.remove_successful_download( - title, year, lang_name.lower() - ) - - # Remove local file after successful upload - try: - os.remove(subtitle_file) - print(f" Cleaned up local file: {subtitle_file}") - except OSError: - pass - - # Small delay between movies - if i < len(movies): - time.sleep(1) + if not movies: + print("No movies are currently missing subtitles!") + else: + print("Movie processing disabled in configuration.") + + # Continue with movie processing if we have movies + if movies: + print("\nWanted Movies:") + + # Display each movie + for movie in movies: + print(format_movie_info(movie)) + + print(f"\nTotal: {len(movies)} movies need subtitles\n") + + # Movie subtitle downloads + print("Downloading missing movie subtitles:") + print("-" * 40) + + # Clean up obsolete tracking entries + print("Cleaning up obsolete movie tracking entries...") + removed_count = downloader.tracker.cleanup_obsolete_movies(movies) + if removed_count > 0: + print( + f"Removed {removed_count} obsolete movie(s) from tracking database" + ) + + print("\nStarting movie subtitle downloads...") + + # Process each movie + for i, movie in enumerate(movies, 1): + print(f"\n[{i}/{len(movies)}] Processing movie:") + + # Download subtitles for this movie + downloaded_files, movie_skipped = downloader.get_subtitle_for_movie( + movie + ) + subtitles_skipped += movie_skipped + + if not downloaded_files: + print(" No subtitles downloaded for this movie.") + continue + + total_downloads += len(downloaded_files) + + # Get movie info for upload + radarr_id = movie.get("radarrId", movie.get("radarrid")) + if not radarr_id: + print(" โœ— No Radarr ID found, cannot upload to Bazarr") + continue + + # Upload each downloaded subtitle to Bazarr + print(" Uploading subtitles to Bazarr...") + missing_subs = movie.get("missing_subtitles", []) + + for j, subtitle_file in enumerate(downloaded_files): + if j < len(missing_subs): + sub_info = missing_subs[j] + lang_code = sub_info.get("code2", "en") + forced = sub_info.get("forced", False) + hi = sub_info.get("hi", False) + + if bazarr.upload_movie_subtitle( + radarr_id, subtitle_file, lang_code, forced, hi + ): + successful_uploads += 1 + + # Get movie details to find subtitle path for post-processing + movie_data = bazarr.get_movie_subtitles(radarr_id) + if movie_data and "subtitles" in movie_data: + # Find the subtitle we just uploaded + for subtitle in movie_data["subtitles"]: + if ( + subtitle.get("code2") == lang_code + and subtitle.get("forced") == forced + and subtitle.get("hi") == hi + ): + subtitle_path = subtitle.get("path") + if subtitle_path: + # Apply Sub-Zero modifications if enabled + if subzero_settings["enabled"]: + print( + " Applying Sub-Zero modifications..." + ) + bazarr.trigger_subzero_mods( + subtitle_path=subtitle_path, + media_type="movie", + media_id=radarr_id, + language=lang_code, + forced=forced, + hi=hi, + ) + + # Perform subtitle synchronization if enabled + if sync_settings["enabled"]: + print( + " Performing subtitle synchronization..." + ) + bazarr.sync_subtitle( + subtitle_path=subtitle_path, + media_type="movie", + media_id=radarr_id, + language=lang_code, + forced=forced, + hi=hi, + reference=sync_settings[ + "reference" + ], + max_offset_seconds=sync_settings[ + "max_offset_seconds" + ], + no_fix_framerate=sync_settings[ + "no_fix_framerate" + ], + use_gss=sync_settings["use_gss"], + ) + break + + # Clean up tracking database for successful download + title = movie.get("title", "Unknown") + year = movie.get("year", 0) + lang_name = sub_info.get("name", "Unknown") + downloader.tracker.remove_successful_download( + title, year, lang_name.lower() + ) + + # Remove local file after successful upload + try: + os.remove(subtitle_file) + print(f" Cleaned up local file: {subtitle_file}") + except OSError: + pass + + # Small delay between movies + if i < len(movies): + time.sleep(1) + + # Process TV series episodes if enabled + episodes = [] + episodes_processed = 0 + episodes_downloads = 0 + episodes_uploads = 0 + episodes_skipped = 0 + + if episodes_enabled: + print("\n" + "=" * 50) + print("PROCESSING TV SERIES") + print("=" * 50) + print("Fetching wanted episodes from Bazarr...", end=" ", flush=True) + + # Fetch wanted episodes + episodes = bazarr.get_wanted_episodes() + print(f"Done!\nFound {len(episodes)} wanted episodes") + else: + print("TV Series processing disabled in configuration.") + + # Continue with tv series processing if we have tv series + if episodes: + print("\nWanted Episodes:") + + # Display each episode + for episode in episodes: + print(format_episode_info(episode)) + + print(f"\nTotal: {len(episodes)} episodes need subtitles\n") + + # TV series subtitle downloads + print("Downloading missing TV series subtitles:") + print("-" * 40) + + # Clean up obsolete episode tracking entries + print("Cleaning up obsolete episode tracking entries...") + removed_count = downloader.tracker.cleanup_obsolete_movies(episodes) + if removed_count > 0: + print( + f"Removed {removed_count} obsolete episode(s) " + f"from tracking database" + ) + + print("\nStarting episode subtitle downloads...") + + for i, episode in enumerate(episodes, 1): + print(f"\n[{i}/{len(episodes)}]", end=" ") + + # Download subtitles for this episode + downloaded_files, skipped_count = downloader.get_subtitle_for_episode( + episode + ) + episodes_downloads += len(downloaded_files) + episodes_skipped += skipped_count + + # Upload each downloaded subtitle to Bazarr + for subtitle_file in downloaded_files: + # Extract subtitle info from the episode and file + series_id = episode.get("sonarrSeriesId") or episode.get("seriesId") + episode_id = episode.get("sonarrEpisodeId") or episode.get( + "episodeId" + ) + + # Determine language from filename or default to first + # missing subtitle + missing_subs = episode.get("missing_subtitles", []) + if missing_subs: + lang_code = missing_subs[0].get("code2", "en") + lang_name = missing_subs[0].get("name", "Unknown") + else: + lang_code = "en" + lang_name = "English" + + if series_id and episode_id: + sub_info = {"name": lang_name, "code2": lang_code} + + # Upload to Bazarr + if bazarr.upload_episode_subtitle( + series_id, episode_id, lang_code, subtitle_file + ): + episodes_uploads += 1 + print(f" โœ“ Uploaded {lang_name} subtitle to Bazarr") + + # Get episode details to find subtitle path for post-processing + episode_data = bazarr.get_episode_subtitles( + series_id, episode_id + ) + if episode_data and "subtitles" in episode_data: + # Find the subtitle we just uploaded + for subtitle in episode_data["subtitles"]: + if subtitle.get("code2") == lang_code: + subtitle_path = subtitle.get("path") + if subtitle_path: + # Apply Sub-Zero modifications if enabled + if subzero_settings["enabled"]: + print( + " Applying Sub-Zero modifications..." + ) + bazarr.trigger_episode_subzero_mods( + subtitle_path=subtitle_path, + series_id=series_id, + episode_id=episode_id, + language=lang_code, + forced=False, + hi=False, + ) + + # Perform episode subtitle synchronization if enabled + if sync_settings["enabled"]: + print( + " Performing episode subtitle synchronization..." + ) + bazarr.sync_episode_subtitle( + subtitle_path=subtitle_path, + series_id=series_id, + episode_id=episode_id, + language=lang_code, + reference=sync_settings[ + "reference" + ], + max_offset_seconds=sync_settings[ + "max_offset_seconds" + ], + no_fix_framerate=sync_settings[ + "no_fix_framerate" + ], + use_gss=sync_settings["use_gss"], + ) + break + + # Clean up tracking database for successful download + series_title = episode.get("seriesTitle", "Unknown") + season = episode.get("season", 0) + episode_num = episode.get("episode", 0) + episode_key = ( + f"{series_title}:S{season:02d}E{episode_num:02d}" + ) + downloader.tracker.remove_successful_download( + episode_key, 0, lang_name.lower() + ) + + # Remove local file after successful upload + try: + os.remove(subtitle_file) + print(f" Cleaned up local file: {subtitle_file}") + except OSError: + pass + else: + print( + f" โœ— Failed to upload {lang_name} subtitle to Bazarr" + ) + else: + print(" โœ— Missing series_id or episode_id for upload") + + # Small delay between episodes + if i < len(episodes): + time.sleep(1) # Summary print("\n" + "=" * 50) print("DOWNLOAD SUMMARY") print("=" * 50) print(f"Movies processed: {len(movies)}") - print(f"Subtitles downloaded: {total_downloads}") - print(f"Subtitles uploaded to Bazarr: {successful_uploads}") - print(f"Subtitles skipped: {subtitles_skipped}") - - if successful_uploads > 0: - print(f"\nโœ“ Successfully processed {successful_uploads} subtitle(s)!") + print(f"Episodes processed: {episodes_processed}") + print(f"Movie subtitles downloaded: {total_downloads}") + print(f"Episode subtitles downloaded: {episodes_downloads}") + print(f"Movie subtitles uploaded to Bazarr: {successful_uploads}") + print(f"Episode subtitles uploaded to Bazarr: {episodes_uploads}") + print(f"Movie subtitles skipped: {subtitles_skipped}") + print(f"Episode subtitles skipped: {episodes_skipped}") + + total_all_uploads = successful_uploads + episodes_uploads + + if total_all_uploads > 0: + print(f"\nโœ“ Successfully processed {total_all_uploads} subtitle(s)!") + if successful_uploads > 0: + print(f" - Movies: {successful_uploads}") + if episodes_uploads > 0: + print(f" - Episodes: {episodes_uploads}") print("Check your Bazarr interface to verify the subtitles were added.") logger.info( f"Execution completed successfully. " - f"{successful_uploads} subtitles uploaded." + f"{total_all_uploads} subtitles uploaded " + f"({successful_uploads} movies, {episodes_uploads} episodes)." ) else: print("\nโš  No subtitles were successfully uploaded.") diff --git a/tests/api/test_bazarr.py b/tests/api/test_bazarr.py index 9466cbc..302cb89 100644 --- a/tests/api/test_bazarr.py +++ b/tests/api/test_bazarr.py @@ -107,7 +107,7 @@ def test_upload_subtitle_success(self, mock_post, mock_basename, mock_open): mock_basename.return_value = "test.srt" mock_open.return_value.__enter__.return_value = Mock() - result = self.client.upload_subtitle_to_bazarr( + result = self.client.upload_movie_subtitle( radarr_id=123, subtitle_file="/path/to/test.srt", language="en", @@ -139,7 +139,7 @@ def test_upload_subtitle_request_exception( mock_basename.return_value = "test.srt" mock_open.return_value.__enter__.return_value = Mock() - result = self.client.upload_subtitle_to_bazarr( + result = self.client.upload_movie_subtitle( radarr_id=123, subtitle_file="/path/to/test.srt", language="en" ) @@ -148,7 +148,7 @@ def test_upload_subtitle_request_exception( @patch("builtins.open", side_effect=IOError("File not found")) def test_upload_subtitle_file_error(self, mock_open): """Test subtitle upload handles file errors.""" - result = self.client.upload_subtitle_to_bazarr( + result = self.client.upload_movie_subtitle( radarr_id=123, subtitle_file="/invalid/path/test.srt", language="en" ) @@ -254,6 +254,518 @@ def test_parse_interval_to_minutes_invalid_format(self): with self.assertRaises(ValueError): self.client._parse_interval_to_minutes("invalid format") + @patch("api.bazarr.requests.Session.patch") + def test_sync_subtitle_success(self, mock_patch): + """Test successful subtitle synchronization.""" + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_patch.return_value = mock_response + + result = self.client.sync_subtitle( + subtitle_path="/path/to/subtitle.srt", + media_type="movie", + media_id=123, + language="en", + forced=False, + hi=False, + ) + + self.assertTrue(result) + mock_patch.assert_called_once() + + # Check the call parameters + call_args = mock_patch.call_args + self.assertIn("params", call_args.kwargs) + params = call_args.kwargs["params"] + self.assertEqual(params["action"], "sync") + self.assertEqual(params["language"], "en") + self.assertEqual(params["path"], "/path/to/subtitle.srt") + self.assertEqual(params["type"], "movie") + self.assertEqual(params["id"], 123) + + @patch("api.bazarr.requests.Session.patch") + def test_sync_subtitle_with_options(self, mock_patch): + """Test subtitle synchronization with custom options.""" + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_patch.return_value = mock_response + + result = self.client.sync_subtitle( + subtitle_path="/path/to/subtitle.srt", + media_type="episode", + media_id=456, + language="fr", + forced=True, + hi=True, + reference="a:1", + max_offset_seconds=600, + no_fix_framerate=True, + use_gss=True, + ) + + self.assertTrue(result) + + # Check custom parameters + call_args = mock_patch.call_args + params = call_args.kwargs["params"] + self.assertEqual(params["forced"], "true") + self.assertEqual(params["hi"], "true") + self.assertEqual(params["reference"], "a:1") + self.assertEqual(params["max_offset_seconds"], "600") + self.assertEqual(params["no_fix_framerate"], "true") + self.assertEqual(params["gss"], "true") + + @patch("api.bazarr.requests.Session.patch") + def test_sync_subtitle_exception(self, mock_patch): + """Test sync_subtitle handles exceptions.""" + mock_patch.side_effect = requests.exceptions.RequestException("Network error") + + result = self.client.sync_subtitle( + subtitle_path="/path/to/subtitle.srt", + media_type="movie", + media_id=123, + language="en", + ) + + self.assertFalse(result) + + @patch("api.bazarr.requests.Session.get") + def test_get_movie_subtitles_success(self, mock_get): + """Test successful get_movie_subtitles request.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "radarrid": 123, + "title": "Test Movie", + "subtitles": [ + { + "code2": "en", + "path": "/path/to/subtitle.srt", + "forced": False, + "hi": False, + } + ], + } + ] + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + result = self.client.get_movie_subtitles(123) + + self.assertIsNotNone(result) + self.assertEqual(result["radarrid"], 123) + self.assertIn("subtitles", result) + mock_get.assert_called_once() + + @patch("api.bazarr.requests.Session.get") + def test_get_movie_subtitles_exception(self, mock_get): + """Test get_movie_subtitles handles exceptions.""" + mock_get.side_effect = requests.exceptions.RequestException("Network error") + + result = self.client.get_movie_subtitles(123) + + self.assertIsNone(result) + + # Episode-related tests + + @patch("api.bazarr.requests.Session.get") + def test_get_wanted_episodes_success(self, mock_get): + """Test successful retrieval of wanted episodes.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "sonarrEpisodeId": 123, + "sonarrSeriesId": 456, + "title": "Pilot", + "season": 1, + "episode": 1, + "missing_subtitles": [{"name": "English", "code2": "en"}], + }, + { + "sonarrEpisodeId": 124, + "sonarrSeriesId": 456, + "title": "Cat's in the Bag", + "season": 1, + "episode": 2, + "missing_subtitles": [{"name": "Spanish", "code2": "es"}], + }, + ] + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + # Mock series enrichment + with patch.object(self.client, "_enrich_episode_data", side_effect=lambda x: x): + episodes = self.client.get_wanted_episodes() + + self.assertEqual(len(episodes), 2) + self.assertEqual(episodes[0]["title"], "Pilot") + self.assertEqual(episodes[0]["season"], 1) + self.assertEqual(episodes[0]["episode"], 1) + mock_get.assert_called_once() + + @patch("api.bazarr.requests.Session.get") + def test_get_series_info_success(self, mock_get): + """Test successful series info retrieval.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "sonarrSeriesId": 456, + "title": "Breaking Bad", + "year": 2008, + "imdbId": "tt0903747", + } + ] + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + series_info = self.client.get_series_info(456) + + self.assertIsNotNone(series_info) + self.assertEqual(series_info["title"], "Breaking Bad") + self.assertEqual(series_info["year"], 2008) + + @patch("api.bazarr.requests.Session.post") + def test_upload_episode_subtitle_success(self, mock_post): + """Test successful episode subtitle upload.""" + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + # Create temporary subtitle file + import tempfile + + with tempfile.NamedTemporaryFile(mode="w", suffix=".srt", delete=False) as f: + f.write("1\n00:00:01,000 --> 00:00:02,000\nTest subtitle\n") + temp_file = f.name + + try: + result = self.client.upload_episode_subtitle( + series_id=456, + episode_id=123, + language="en", + subtitle_file=temp_file, + forced=False, + hi=False, + ) + + self.assertTrue(result) + mock_post.assert_called_once() + + # Check call parameters + call_args = mock_post.call_args + self.assertIn("params", call_args.kwargs) + self.assertEqual(call_args.kwargs["params"]["seriesid"], 456) + self.assertEqual(call_args.kwargs["params"]["episodeid"], 123) + self.assertEqual(call_args.kwargs["params"]["language"], "en") + + finally: + import os + + os.unlink(temp_file) + + @patch("api.bazarr.requests.Session.patch") + def test_sync_episode_subtitle_success(self, mock_patch): + """Test successful episode subtitle synchronization.""" + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_patch.return_value = mock_response + + result = self.client.sync_episode_subtitle( + subtitle_path="/path/to/episode.srt", + series_id=456, + episode_id=123, + language="en", + forced=False, + hi=False, + ) + + self.assertTrue(result) + mock_patch.assert_called_once() + + # Check the call parameters + call_args = mock_patch.call_args + self.assertIn("params", call_args.kwargs) + params = call_args.kwargs["params"] + self.assertEqual(params["action"], "sync") + self.assertEqual(params["language"], "en") + self.assertEqual(params["path"], "/path/to/episode.srt") + self.assertEqual(params["type"], "episode") + self.assertEqual(params["id"], 123) + + @patch("api.bazarr.requests.Session.get") + def test_get_episode_subtitles_success(self, mock_get): + """Test successful get_episode_subtitles request.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "sonarrEpisodeId": 123, + "title": "Test Episode", + "subtitles": [ + { + "code2": "en", + "path": "/path/to/episode.srt", + "forced": False, + "hi": False, + } + ], + } + ] + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + result = self.client.get_episode_subtitles(456, 123) + + self.assertIsNotNone(result) + self.assertEqual(result["sonarrEpisodeId"], 123) + self.assertIn("subtitles", result) + mock_get.assert_called_once() + + @patch("api.bazarr.requests.Session.get") + def test_get_system_settings_success(self, mock_get): + """Test successful get_system_settings request.""" + mock_response = Mock() + mock_response.json.return_value = { + "subsync": { + "max_offset_seconds": 300, + "no_fix_framerate": True, + "gss": False, + }, + "general": {"use_subsync": True}, + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + result = self.client.get_system_settings() + + self.assertIsNotNone(result) + self.assertIn("subsync", result) + mock_get.assert_called_once() + + @patch("api.bazarr.requests.Session.get") + def test_get_system_settings_exception(self, mock_get): + """Test get_system_settings handles exceptions.""" + mock_get.side_effect = requests.exceptions.RequestException("Network error") + + result = self.client.get_system_settings() + + self.assertIsNone(result) + + @patch.object(Bazarr, "get_system_settings") + def test_get_sync_settings_success(self, mock_get_settings): + """Test successful sync settings retrieval.""" + mock_get_settings.return_value = { + "subsync": { + "use_subsync": False, + "max_offset_seconds": 600, + "no_fix_framerate": True, + "gss": True, + } + } + + result = self.client.get_sync_settings() + + expected = { + "enabled": False, + "max_offset_seconds": 600, + "no_fix_framerate": True, + "use_gss": True, + "reference": "a:0", + } + self.assertEqual(result, expected) + + @patch.object(Bazarr, "get_system_settings") + def test_get_sync_settings_no_subsync_section(self, mock_get_settings): + """Test sync settings with missing subsync section.""" + mock_get_settings.return_value = {"general": {"some_setting": True}} + + result = self.client.get_sync_settings() + + expected = { + "enabled": False, + "max_offset_seconds": 300, + "no_fix_framerate": False, + "use_gss": False, + "reference": "a:0", + } + self.assertEqual(result, expected) + + @patch.object(Bazarr, "get_system_settings") + def test_get_sync_settings_api_failure(self, mock_get_settings): + """Test sync settings when API call fails.""" + mock_get_settings.return_value = None + + result = self.client.get_sync_settings() + + expected = { + "enabled": False, + "max_offset_seconds": 300, + "no_fix_framerate": False, + "use_gss": False, + "reference": "a:0", + } + self.assertEqual(result, expected) + + @patch.object(Bazarr, "get_system_settings") + def test_get_sync_settings_enabled(self, mock_get_settings): + """Test sync settings when SubSync is enabled.""" + mock_get_settings.return_value = { + "subsync": { + "use_subsync": True, + "max_offset_seconds": 600, + "no_fix_framerate": False, + "gss": True, + } + } + + result = self.client.get_sync_settings() + + expected = { + "enabled": True, + "max_offset_seconds": 600, + "no_fix_framerate": False, + "use_gss": True, + "reference": "a:0", + } + self.assertEqual(result, expected) + + @patch.object(Bazarr, "get_system_settings") + def test_get_subzero_settings_enabled(self, mock_get_settings): + """Test Sub-Zero settings when modifications are enabled.""" + mock_get_settings.return_value = { + "general": {"subzero_mods": ["common", "hearing_impaired"]} + } + + result = self.client.get_subzero_settings() + + expected = {"mods": ["common", "hearing_impaired"], "enabled": True} + self.assertEqual(result, expected) + + @patch.object(Bazarr, "get_system_settings") + def test_get_subzero_settings_disabled(self, mock_get_settings): + """Test Sub-Zero settings when no modifications are configured.""" + mock_get_settings.return_value = {"general": {"subzero_mods": []}} + + result = self.client.get_subzero_settings() + + expected = {"mods": [], "enabled": False} + self.assertEqual(result, expected) + + @patch.object(Bazarr, "get_system_settings") + def test_get_subzero_settings_missing_section(self, mock_get_settings): + """Test Sub-Zero settings when general section is missing.""" + mock_get_settings.return_value = {"subsync": {"use_subsync": True}} + + result = self.client.get_subzero_settings() + + expected = {"mods": [], "enabled": False} + self.assertEqual(result, expected) + + @patch.object(Bazarr, "get_system_settings") + def test_get_subzero_settings_api_failure(self, mock_get_settings): + """Test Sub-Zero settings when API call fails.""" + mock_get_settings.return_value = None + + result = self.client.get_subzero_settings() + + expected = {"mods": [], "enabled": False} + self.assertEqual(result, expected) + + @patch("api.bazarr.requests.Session.patch") + def test_trigger_subzero_mods_success(self, mock_patch): + """Test successful Sub-Zero modification trigger.""" + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_patch.return_value = mock_response + + result = self.client.trigger_subzero_mods( + subtitle_path="/path/to/subtitle.srt", + media_type="movie", + media_id=123, + language="en", + forced=False, + hi=False, + ) + + self.assertTrue(result) + mock_patch.assert_called_once() + + # Check the call parameters + call_args = mock_patch.call_args + self.assertIn("params", call_args.kwargs) + params = call_args.kwargs["params"] + self.assertEqual(params["action"], "subzero") + self.assertEqual(params["language"], "en") + self.assertEqual(params["path"], "/path/to/subtitle.srt") + self.assertEqual(params["type"], "movie") + self.assertEqual(params["id"], 123) + + @patch("api.bazarr.requests.Session.patch") + def test_trigger_subzero_mods_exception(self, mock_patch): + """Test Sub-Zero trigger handles exceptions.""" + mock_patch.side_effect = requests.exceptions.RequestException("Network error") + + result = self.client.trigger_subzero_mods( + subtitle_path="/path/to/subtitle.srt", + media_type="movie", + media_id=123, + language="en", + ) + + self.assertFalse(result) + + @patch("api.bazarr.requests.Session.patch") + def test_trigger_episode_subzero_mods_success(self, mock_patch): + """Test successful episode Sub-Zero modification trigger.""" + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_patch.return_value = mock_response + + result = self.client.trigger_episode_subzero_mods( + subtitle_path="/path/to/episode.srt", + series_id=456, + episode_id=123, + language="fr", + forced=True, + hi=True, + ) + + self.assertTrue(result) + mock_patch.assert_called_once() + + # Check the call parameters + call_args = mock_patch.call_args + self.assertIn("params", call_args.kwargs) + params = call_args.kwargs["params"] + self.assertEqual(params["action"], "subzero") + self.assertEqual(params["language"], "fr") + self.assertEqual(params["path"], "/path/to/episode.srt") + self.assertEqual(params["type"], "episode") + self.assertEqual(params["id"], 123) + self.assertEqual(params["forced"], "true") + self.assertEqual(params["hi"], "true") + + @patch("api.bazarr.requests.Session.patch") + def test_trigger_episode_subzero_mods_exception(self, mock_patch): + """Test episode Sub-Zero trigger handles exceptions.""" + mock_patch.side_effect = requests.exceptions.RequestException("Network error") + + result = self.client.trigger_episode_subzero_mods( + subtitle_path="/path/to/episode.srt", + series_id=456, + episode_id=123, + language="en", + ) + + self.assertFalse(result) + if __name__ == "__main__": unittest.main() diff --git a/tests/api/test_subsource.py b/tests/api/test_subsource.py index 6756c2f..2ba17ab 100644 --- a/tests/api/test_subsource.py +++ b/tests/api/test_subsource.py @@ -44,14 +44,13 @@ def test_init(self): # Check that download directory exists self.assertTrue(os.path.exists(self.download_dir)) - # Check session headers + # Check session headers (verify Cloudflare bypass headers are present) expected_headers = { - "User-Agent": ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/537.36" - ), - "Accept": "application/json", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0", + "Accept": "application/json, text/plain, */*", "Content-Type": "application/json", - "Connection": "keep-alive", + "Origin": "https://subsource.net", + "Referer": "https://subsource.net/", } for key, value in expected_headers.items(): self.assertEqual(self.downloader.session.headers[key], value) @@ -382,6 +381,118 @@ def test_get_subtitle_for_movie_with_tracking(self, mock_interval): self.assertEqual(len(downloaded_files), 0) self.assertEqual(skipped_count, 1) + # TV Series / Episode tests + + def test_extract_episode_info_s01e01_format(self): + """Test episode info extraction from S01E01 format.""" + subtitle = {"release_info": "Breaking.Bad.S01E01.720p.BluRay.x264-REWARD"} + + season, episode = self.downloader._extract_episode_info_from_subtitle(subtitle) + + # S01E01 format should return both season and episode + self.assertEqual(season, 1) + self.assertEqual(episode, 1) + + def test_extract_episode_info_1x01_format(self): + """Test episode info extraction from 1x01 format.""" + subtitle = {"release_info": "Breaking.Bad.1x01.720p.BluRay.x264-REWARD"} + + season, episode = self.downloader._extract_episode_info_from_subtitle(subtitle) + + self.assertEqual(season, 1) + self.assertEqual(episode, 1) + + def test_extract_episode_info_no_match(self): + """Test episode info extraction when no pattern matches.""" + subtitle = {"release_info": "Breaking.Bad.720p.BluRay.x264-REWARD"} + + season, episode = self.downloader._extract_episode_info_from_subtitle(subtitle) + + self.assertIsNone(season) + self.assertIsNone(episode) + + def test_is_subtitle_match_success(self): + """Test subtitle matching with correct season/episode.""" + subtitle = {"release_info": "Breaking.Bad.S01E01.720p.BluRay.x264-REWARD"} + target_episode = {"season": 1, "episode": 1} + + result = self.downloader._is_subtitle_match(subtitle, target_episode) + + self.assertTrue(result) + + def test_is_subtitle_match_failure(self): + """Test subtitle matching with wrong season/episode.""" + subtitle = {"release_info": "Breaking.Bad.S01E02.720p.BluRay.x264-REWARD"} + target_episode = {"season": 1, "episode": 1} + + result = self.downloader._is_subtitle_match(subtitle, target_episode) + + self.assertFalse(result) + + @patch("api.subsource.requests.Session.post") + @patch("api.subsource.requests.Session.get") + def test_search_episode_subtitles_success(self, mock_get, mock_post): + """Test successful episode subtitle search.""" + # Mock search response + mock_search_response = Mock() + mock_search_response.json.return_value = { + "results": [ + { + "title": "Breaking Bad", + "type": "tvseries", + "link": "/subtitles/breaking-bad-2008", + "seasons": [ + {"season": 1, "link": "/subtitles/breaking-bad-2008/s1"} + ], + "releaseYear": 2008, + } + ] + } + mock_search_response.raise_for_status.return_value = None + mock_post.return_value = mock_search_response + + # Mock subtitles response + mock_sub_response = Mock() + mock_sub_response.json.return_value = [ + { + "id": "123", + "release_info": "Breaking.Bad.S01E01.720p.BluRay.x264-REWARD", + "language": "english", + } + ] + mock_sub_response.raise_for_status.return_value = None + mock_get.return_value = mock_sub_response + + episode = { + "series_title": "Breaking Bad", + "season": 1, + "episode_number": 1, + } + + results = self.downloader.search_episode_subtitles(episode, "english") + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["id"], "123") + self.assertIn("source_query", results[0]) + + @patch("api.subsource.requests.Session.post") + def test_search_episode_subtitles_no_results(self, mock_post): + """Test episode subtitle search with no results.""" + mock_response = Mock() + mock_response.json.return_value = {"results": []} + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + episode = { + "series_title": "Unknown Show", + "season": 1, + "episode_number": 1, + } + + results = self.downloader.search_episode_subtitles(episode, "english") + + self.assertEqual(len(results), 0) + if __name__ == "__main__": unittest.main() diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 3702a00..dd5b370 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -39,7 +39,15 @@ def test_create_default_config(self): config.read(self.config_file) # Check all required sections exist - expected_sections = ["bazarr", "auth", "subsource", "download", "logging"] + expected_sections = [ + "bazarr", + "auth", + "subsource", + "download", + "movies", + "episodes", + "logging", + ] for section in expected_sections: self.assertIn(section, config.sections()) @@ -108,6 +116,9 @@ def test_load_config_success(self, mock_home): "password", "subsource_api_url", "download_directory", + "movies_enabled", + "episodes_enabled", + "episodes_search_patterns", "log_level", "log_file", ] @@ -207,6 +218,27 @@ def test_setup_logging_rotation(self): self.assertEqual(handler.maxBytes, 10 * 1024 * 1024) # 10MB self.assertEqual(handler.backupCount, 5) + def test_episode_configuration_defaults(self): + """Test that episode configuration has proper defaults.""" + # Create basic config without episode section + config = configparser.ConfigParser() + config["bazarr"] = {"url": "http://test", "api_key": "test"} + config["auth"] = {"username": "test", "password": "test"} + config["subsource"] = {"api_url": "http://test"} + config["download"] = {"directory": "/tmp"} + config["logging"] = {"level": "INFO", "file": "test.log"} + + # Test fallback values for episodes + episodes_enabled = config.getboolean("episodes", "enabled", fallback=True) + episodes_patterns = config.get( + "episodes", + "search_patterns", + fallback="season_episode,episode_title,scene_name", + ) + + self.assertTrue(episodes_enabled) # Default should be True + self.assertEqual(episodes_patterns, "season_episode,episode_title,scene_name") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_run.py b/tests/test_run.py index a58b659..5e4bf66 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -35,12 +35,21 @@ def test_main_no_movies( "password": "test_pass", "subsource_api_url": "https://api.test.com", "download_directory": "/tmp", + "episodes_enabled": False, # Disable episodes for this test } mock_load_config.return_value = mock_config # Mock Bazarr client mock_bazarr = Mock() mock_bazarr.get_wanted_movies.return_value = {"data": []} + mock_bazarr.get_sync_settings.return_value = { + "enabled": False, + "max_offset_seconds": 300, + "no_fix_framerate": False, + "use_gss": False, + "reference": "a:0", + } + mock_bazarr.get_subzero_settings.return_value = {"mods": [], "enabled": False} mock_bazarr_class.return_value = mock_bazarr # Mock logging setup @@ -90,6 +99,7 @@ def test_main_with_movies_success( "password": "test_pass", "subsource_api_url": "https://api.test.com", "download_directory": "/tmp", + "episodes_enabled": False, # Disable episodes for this test } mock_load_config.return_value = mock_config @@ -109,7 +119,30 @@ def test_main_with_movies_success( # Mock Bazarr client mock_bazarr = Mock() mock_bazarr.get_wanted_movies.return_value = movies_data - mock_bazarr.upload_subtitle_to_bazarr.return_value = True + mock_bazarr.upload_movie_subtitle.return_value = True + mock_bazarr.get_movie_subtitles.return_value = { + "subtitles": [ + { + "code2": "en", + "forced": False, + "hi": False, + "path": "/path/to/subtitle.srt", + } + ] + } + mock_bazarr.sync_subtitle.return_value = True + mock_bazarr.trigger_subzero_mods.return_value = True + mock_bazarr.get_sync_settings.return_value = { + "enabled": True, + "max_offset_seconds": 300, + "no_fix_framerate": False, + "use_gss": False, + "reference": "a:0", + } + mock_bazarr.get_subzero_settings.return_value = { + "mods": ["common"], + "enabled": True, + } mock_bazarr_class.return_value = mock_bazarr # Mock SubSource downloader @@ -125,12 +158,12 @@ def test_main_with_movies_success( # Verify downloader was created mock_downloader_class.assert_called_once_with( - "https://api.test.com", "/tmp", mock_bazarr + "https://api.test.com", "/tmp", mock_bazarr, cf_clearance=None ) # Verify subtitle processing mock_downloader.get_subtitle_for_movie.assert_called_once() - mock_bazarr.upload_subtitle_to_bazarr.assert_called_once_with( + mock_bazarr.upload_movie_subtitle.assert_called_once_with( 123, "/tmp/test.srt", "en", False, False ) @@ -162,6 +195,14 @@ def test_main_bazarr_api_failure( # Mock Bazarr client to return None (API failure) mock_bazarr = Mock() mock_bazarr.get_wanted_movies.return_value = None + mock_bazarr.get_sync_settings.return_value = { + "enabled": False, + "max_offset_seconds": 300, + "no_fix_framerate": False, + "use_gss": False, + "reference": "a:0", + } + mock_bazarr.get_subzero_settings.return_value = {"mods": [], "enabled": False} mock_bazarr_class.return_value = mock_bazarr # Mock logging @@ -233,6 +274,7 @@ def test_main_no_radarr_id( "password": "test_pass", "subsource_api_url": "https://api.test.com", "download_directory": "/tmp", + "episodes_enabled": False, # Disable episodes for this test } mock_load_config.return_value = mock_config @@ -251,6 +293,14 @@ def test_main_no_radarr_id( # Mock Bazarr client mock_bazarr = Mock() mock_bazarr.get_wanted_movies.return_value = movies_data + mock_bazarr.get_sync_settings.return_value = { + "enabled": False, + "max_offset_seconds": 300, + "no_fix_framerate": False, + "use_gss": False, + "reference": "a:0", + } + mock_bazarr.get_subzero_settings.return_value = {"mods": [], "enabled": False} mock_bazarr_class.return_value = mock_bazarr # Mock SubSource downloader @@ -292,6 +342,7 @@ def test_main_no_subtitles_downloaded( "password": "test_pass", "subsource_api_url": "https://api.test.com", "download_directory": "/tmp", + "episodes_enabled": False, # Disable episodes for this test } mock_load_config.return_value = mock_config @@ -311,6 +362,14 @@ def test_main_no_subtitles_downloaded( # Mock Bazarr client mock_bazarr = Mock() mock_bazarr.get_wanted_movies.return_value = movies_data + mock_bazarr.get_sync_settings.return_value = { + "enabled": False, + "max_offset_seconds": 300, + "no_fix_framerate": False, + "use_gss": False, + "reference": "a:0", + } + mock_bazarr.get_subzero_settings.return_value = {"mods": [], "enabled": False} mock_bazarr_class.return_value = mock_bazarr # Mock SubSource downloader to return no files diff --git a/utils.py b/utils.py index 6a19237..494daa9 100644 --- a/utils.py +++ b/utils.py @@ -19,14 +19,21 @@ def format_movie_info(movie: Dict) -> str: Formatted string with movie information """ title = movie.get("title", "Unknown Title").strip() - # Try different possible year field names + missing_subs = movie.get("missing_subtitles", []) + + # Get year from multiple possible fields year = ( movie.get("year") or movie.get("movie_year") or movie.get("releaseYear") or movie.get("release_year") ) - missing_subs = movie.get("missing_subtitles", []) + + # Format title with year if available + if year: + title_with_year = f"{title} ({year})" + else: + title_with_year = title # Format missing subtitles languages languages = [] @@ -45,8 +52,46 @@ def format_movie_info(movie: Dict) -> str: missing_langs = ", ".join(languages) if languages else "Unknown" - # Include year if available - if year: - return f"โ€ข {title} ({year}) - Missing: {missing_langs}" + return f"โ€ข {title_with_year} - Missing: {missing_langs}" + + +def format_episode_info(episode: Dict) -> str: + """ + Format episode information for display. + + Args: + episode: Episode dictionary from API response + + Returns: + Formatted string with episode information + """ + series_title = episode.get("series_title") + season = episode.get("season") + episode_number = episode.get("episode_number") + episode_title = episode.get("episode_title") + missing_subs = episode.get("missing_subtitles", []) + + # Format missing subtitles languages + languages = [] + for sub in missing_subs: + lang_name = sub.get("name", "Unknown") + forced = sub.get("forced", False) + hi = sub.get("hi", False) + + lang_desc = lang_name + if forced: + lang_desc += " (Forced)" + if hi: + lang_desc += " (HI)" + + languages.append(lang_desc) + + missing_langs = ", ".join(languages) if languages else "Unknown" + + # Format season and episode number + if season is not None and episode_number is not None: + season_episode = f"S{season}E{episode_number}" else: - return f"โ€ข {title} - Missing: {missing_langs}" + season_episode = "S??E??" + + return f"โ€ข {series_title} {season_episode} - {episode_title} - Missing: {missing_langs}"