diff --git a/README.md b/README.md index 7ccb929..b3ca43e 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,11 @@ It integrates with **Sonarr** and **Radarr**, listens for import/rename/upgrade - **Import Date Locking** Captures the original date a movie or episode was imported and preserves it across upgrades. -- **NFO Management** +- **Enhanced NFO Management** Creates and updates `.nfo` files for movies and TV episodes with consistent `` fields. - Optionally adds `` to prevent metadata drift from scrapers. + **NEW**: Full metadata integration from Sonarr API (episode titles, plots, ratings, runtime). + Optionally adds `` to prevent metadata drift from scrapers. + **NEW**: Timestamps all NFO files with creation/update dates. - **Filesystem Time Fixing** Updates file and directory modification times (`mtime`) to match the preserved import date. @@ -165,6 +167,32 @@ curl -X POST "http://localhost:8080/manual/scan?path=/media/movies" curl -X POST "http://localhost:8080/bulk/update" ``` +### TV Show Processing (NEW in v0.6.0+) +```bash +# Process a specific TV season (URL-safe) +curl -X POST "http://localhost:8080/tv/scan-season" \ + -H "Content-Type: application/json" \ + -d '{ + "series_path": "/media/TV/tv/Chicago Fire (2012) [imdb-tt2261391]", + "season_name": "Season 13" + }' + +# Process a specific TV episode (URL-safe) +curl -X POST "http://localhost:8080/tv/scan-episode" \ + -H "Content-Type: application/json" \ + -d '{ + "series_path": "/media/TV/tv/Chicago Fire (2012) [imdb-tt2261391]", + "season_name": "Season 13", + "episode_name": "Chicago Fire (2012)-S13E21-The Bad Guy[WEBDL-1080p][AAC2.0][h264].mkv" + }' + +# Process single season via manual scan (URL encoding required for spaces) +curl -X POST "http://localhost:8080/manual/scan?path=/media/TV/tv/Chicago%20Fire%20(2012)%20%5Bimdb-tt2261391%5D/Season%2013" + +# Process single episode via manual scan (URL encoding required for spaces) +curl -X POST "http://localhost:8080/manual/scan?path=/media/TV/tv/Chicago%20Fire%20(2012)%20%5Bimdb-tt2261391%5D/Season%2013/episode.mkv" +``` + ### Testing & Validation ```bash # Test database connections @@ -427,6 +455,52 @@ Emby/Jellyfin/Plex see the file as unchanged in chronology. Result: no more "old shows" showing up in "Recently Added." +## 📺 Enhanced NFO Generation (NEW) + +NFOGuard now creates **full-featured NFO files** with rich metadata from Sonarr: + +### Before (Basic Format): +```xml + + + 13 + 2 + 2024-10-03 + 2025-02-19T23:10:35+00:00 + 2025-02-19 + true + + + +``` + +### After (Enhanced Format): +```xml + + + 13 + 2 + Ride the Blade + Firehouse 51 tackles a fire at a local restaurant. Boden has an emotional conversation with his stepson. + 42 + 8.2 + 1543 + 2024-10-03 + 2025-02-19T23:10:35+00:00 + 2025-02-19 + true + + + +``` + +**Enhanced features:** +- ✅ **Episode titles** from Sonarr API +- ✅ **Plot summaries** for better library browsing +- ✅ **Runtime** and **ratings** data +- ✅ **Timestamps** showing when NFOGuard processed the file +- ✅ **Smart URL-safe endpoints** for processing individual seasons/episodes + 🔍 Logging & Debugging # Enable verbose logging DEBUG=true diff --git a/core/nfo_manager.py b/core/nfo_manager.py index 218187d..016c9af 100644 --- a/core/nfo_manager.py +++ b/core/nfo_manager.py @@ -73,6 +73,18 @@ class NFOManager: return None + @staticmethod + def _escape_xml(text: str) -> str: + """Escape XML special characters""" + if not text: + return "" + return (str(text) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'")) + @staticmethod def _ensure_child_text(parent: ET.Element, tag: str, text: str, overwrite: bool = True) -> ET.Element: """Ensure child element exists with specified text""" @@ -183,10 +195,11 @@ class NFOManager: if lock_metadata: self._ensure_child_text(root, "lockdata", "true", overwrite=True) - # Add footer comments + # Add footer comments with timestamp self._strip_existing_footer_comments(root) + current_time = datetime.now(timezone.utc).isoformat(timespec="seconds") root.append(ET.Comment(f" source: Movie; {source_detail} ")) - root.append(ET.Comment(f" managed by {self.manager_brand} ")) + root.append(ET.Comment(f" managed by {self.manager_brand} at {current_time} ")) # Pretty-print and save self._indent_xml(root) @@ -208,7 +221,8 @@ class NFOManager: aired: Optional[str] = None, dateadded: Optional[str] = None, source_detail: str = "unknown", - lock_metadata: bool = True + lock_metadata: bool = True, + enhanced_metadata: Optional[Dict[str, Any]] = None ): """Create episode NFO file (S01E01.nfo format)""" try: @@ -227,6 +241,26 @@ class NFOManager: f" {episode_num}", ] + # Add enhanced metadata if available + if enhanced_metadata: + if enhanced_metadata.get("title"): + xml_lines.append(f" {self._escape_xml(enhanced_metadata['title'])}") + + if enhanced_metadata.get("overview"): + xml_lines.append(f" {self._escape_xml(enhanced_metadata['overview'])}") + + if enhanced_metadata.get("runtime"): + xml_lines.append(f" {enhanced_metadata['runtime']}") + + # Add ratings if available + if enhanced_metadata.get("ratings"): + ratings = enhanced_metadata["ratings"] + if ratings.get("imdb", {}).get("value"): + imdb_rating = ratings["imdb"]["value"] + imdb_votes = ratings["imdb"].get("votes", 0) + xml_lines.append(f" {imdb_rating}") + xml_lines.append(f" {imdb_votes}") + if aired: # Extract date part from ISO string air_date = aired.split('T')[0] @@ -241,10 +275,11 @@ class NFOManager: if lock_metadata: xml_lines.append(" true") - # Add source comments + # Add source comments with timestamp if source_detail: + current_time = datetime.now(timezone.utc).isoformat(timespec="seconds") xml_lines.append(f" ") - xml_lines.append(f" ") + xml_lines.append(f" ") xml_lines.append("") xml_lines.append("") # Final newline diff --git a/nfoguard.py b/nfoguard.py index 2246726..46805bc 100644 --- a/nfoguard.py +++ b/nfoguard.py @@ -416,6 +416,230 @@ class TVProcessor: except Exception: return None + def process_season(self, series_path: Path, season_path: Path) -> None: + """Process a specific TV season directory""" + imdb_id = self.nfo_manager.parse_imdb_from_path(series_path) + if not imdb_id: + _log("ERROR", f"No IMDb ID found in series path: {series_path}") + return + + # Extract season number from path + season_match = re.search(r'season\s*(\d+)', season_path.name, re.IGNORECASE) + if not season_match: + _log("ERROR", f"Could not extract season number from: {season_path.name}") + return + + season_num = int(season_match.group(1)) + _log("INFO", f"Processing TV season {season_num}: {season_path}") + + # Update database + self.db.upsert_series(imdb_id, str(series_path)) + + # Find video files in this season only + disk_episodes = self._find_season_episodes(season_path, season_num) + _log("INFO", f"Found {len(disk_episodes)} episodes in season {season_num}") + + # Get enhanced metadata from Sonarr + series_metadata = self._get_sonarr_series_metadata(imdb_id) + + # Get episode dates for this season + episode_dates = self._gather_season_episode_dates(series_path, imdb_id, season_num, disk_episodes, series_metadata) + + # Process episodes + for (season, episode), (aired, dateadded, source) in episode_dates.items(): + if (season, episode) in disk_episodes: + # Get enhanced episode metadata + enhanced_metadata = self._get_episode_metadata(series_metadata, season, episode) if series_metadata else None + + # Create NFO + if config.manage_nfo: + self.nfo_manager.create_episode_nfo( + season_path, + season, episode, aired, dateadded, source, config.lock_metadata, + enhanced_metadata + ) + + # Update file mtimes + if config.fix_dir_mtimes and dateadded: + video_files = disk_episodes[(season, episode)] + for video_file in video_files: + self.nfo_manager.set_file_mtime(video_file, dateadded) + + # Save to database + self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True) + + # Create season NFO + if config.manage_nfo: + self.nfo_manager.create_season_nfo(season_path, season_num) + self.nfo_manager.create_tvshow_nfo(series_path, imdb_id) + + _log("INFO", f"Completed processing season {season_num}") + + def process_episode_file(self, series_path: Path, season_path: Path, episode_file: Path) -> None: + """Process a single TV episode file""" + imdb_id = self.nfo_manager.parse_imdb_from_path(series_path) + if not imdb_id: + _log("ERROR", f"No IMDb ID found in series path: {series_path}") + return + + # Parse episode info from filename + episode_info = self._parse_episode_from_filename(episode_file.name) + if not episode_info: + _log("ERROR", f"Could not parse episode info from: {episode_file.name}") + return + + season_num, episode_num = episode_info + _log("INFO", f"Processing single episode S{season_num:02d}E{episode_num:02d}: {episode_file.name}") + + # Update database + self.db.upsert_series(imdb_id, str(series_path)) + + # Get enhanced metadata from Sonarr + series_metadata = self._get_sonarr_series_metadata(imdb_id) + enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num) if series_metadata else None + + # Get episode date + aired, dateadded, source = self._get_single_episode_date(imdb_id, season_num, episode_num, series_metadata) + + # Create NFO + if config.manage_nfo and dateadded: + self.nfo_manager.create_episode_nfo( + season_path, + season_num, episode_num, aired, dateadded, source, config.lock_metadata, + enhanced_metadata + ) + + # Update file mtime + if config.fix_dir_mtimes and dateadded: + self.nfo_manager.set_file_mtime(episode_file, dateadded) + + # Save to database + self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True) + + _log("INFO", f"Completed processing episode S{season_num:02d}E{episode_num:02d}") + + def _find_season_episodes(self, season_path: Path, season_num: int) -> Dict[Tuple[int, int], List[Path]]: + """Find all episode video files in a single season""" + disk_episodes = {} + video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v", ".ts", ".m2ts") + + for file_path in season_path.iterdir(): + if not file_path.is_file() or file_path.suffix.lower() not in video_exts: + continue + + episode_info = self._parse_episode_from_filename(file_path.name) + if episode_info and episode_info[0] == season_num: + season, episode = episode_info + key = (season, episode) + if key not in disk_episodes: + disk_episodes[key] = [] + disk_episodes[key].append(file_path) + + return disk_episodes + + def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]: + """Parse season and episode numbers from filename""" + # Try SxxExx format + match = re.search(r'S(\d{1,2})E(\d{1,2})', filename, re.IGNORECASE) + if match: + return int(match.group(1)), int(match.group(2)) + + # Try season.episode format + match = re.search(r'(\d{1,2})\.(\d{1,2})', filename) + if match: + return int(match.group(1)), int(match.group(2)) + + return None + + def _get_sonarr_series_metadata(self, imdb_id: str) -> Optional[Dict[str, Any]]: + """Get enhanced series metadata from Sonarr API""" + try: + if not self.sonarr.enabled: + return None + + series = self.sonarr.series_by_imdb(imdb_id) + if not series: + _log("DEBUG", f"No Sonarr series found for IMDb {imdb_id}") + return None + + # Get episodes for the series + series_id = series.get("id") + if series_id: + episodes = self.sonarr.episodes_for_series(series_id) + series["episodes"] = episodes + _log("DEBUG", f"Got {len(episodes)} episodes from Sonarr for series {series.get('title')}") + + return series + + except Exception as e: + _log("ERROR", f"Error getting Sonarr metadata for {imdb_id}: {e}") + return None + + def _get_episode_metadata(self, series_metadata: Dict[str, Any], season_num: int, episode_num: int) -> Optional[Dict[str, Any]]: + """Extract specific episode metadata from series data""" + if not series_metadata or "episodes" not in series_metadata: + return None + + for episode in series_metadata["episodes"]: + if episode.get("seasonNumber") == season_num and episode.get("episodeNumber") == episode_num: + return { + "title": episode.get("title"), + "overview": episode.get("overview"), + "runtime": episode.get("runtime"), + "ratings": episode.get("ratings", {}) + } + + return None + + def _gather_season_episode_dates(self, series_path: Path, imdb_id: str, season_num: int, disk_episodes: Dict[Tuple[int, int], List[Path]], series_metadata: Optional[Dict[str, Any]] = None) -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]: + """Get episode dates for a specific season""" + episode_dates = {} + + for (season, episode) in disk_episodes.keys(): + if season != season_num: + continue + + aired, dateadded, source = self._get_single_episode_date(imdb_id, season, episode, series_metadata) + episode_dates[(season, episode)] = (aired, dateadded, source) + + return episode_dates + + def _get_single_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict[str, Any]] = None) -> Tuple[Optional[str], Optional[str], str]: + """Get date info for a single episode""" + # Try database first + existing = self.db.get_episode_date(imdb_id, season_num, episode_num) + if existing and existing.get("dateadded"): + return existing.get("aired"), existing["dateadded"], existing.get("source", "database") + + # Try Sonarr API if metadata available + if series_metadata and "episodes" in series_metadata: + for episode in series_metadata["episodes"]: + if episode.get("seasonNumber") == season_num and episode.get("episodeNumber") == episode_num: + aired = episode.get("airDateUtc") + if aired: + return aired, aired, "sonarr:episode.airDateUtc" + + # Try Sonarr API history + if self.sonarr.enabled: + try: + # Get episode ID from Sonarr + series = self.sonarr.series_by_imdb(imdb_id) + if series: + episodes = self.sonarr.episodes_for_series(series["id"]) + for ep in episodes: + if ep.get("seasonNumber") == season_num and ep.get("episodeNumber") == episode_num: + import_date = self.sonarr.get_episode_import_history(ep["id"]) + if import_date: + return ep.get("airDateUtc"), import_date, "sonarr:history.import" + elif ep.get("airDateUtc"): + return ep.get("airDateUtc"), ep.get("airDateUtc"), "sonarr:episode.airDateUtc" + except Exception as e: + _log("DEBUG", f"Sonarr API error for episode S{season_num:02d}E{episode_num:02d}: {e}") + + # Fallback to current time + current_time = datetime.now(timezone.utc).isoformat(timespec="seconds") + return None, current_time, "fallback:current_time" + class MovieProcessor: """Handles movie processing""" @@ -1286,13 +1510,35 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N if not scan_path.exists(): continue - if scan_type in ["both", "tv"] and scan_path in config.tv_paths: - for item in scan_path.iterdir(): - if item.is_dir() and nfo_manager.parse_imdb_from_path(item): + if scan_type in ["both", "tv"] and (scan_path in config.tv_paths or path): + # Handle specific season/episode path + if path and scan_path.name.lower().startswith('season'): + # Single season processing + series_path = scan_path.parent + if nfo_manager.parse_imdb_from_path(series_path): + _log("INFO", f"Processing single season: {scan_path}") try: - tv_processor.process_series(item) + tv_processor.process_season(series_path, scan_path) except Exception as e: - _log("ERROR", f"Failed processing TV series {item}: {e}") + _log("ERROR", f"Failed processing season {scan_path}: {e}") + elif path and scan_path.is_file() and scan_path.suffix.lower() in ('.mkv', '.mp4', '.avi'): + # Single episode processing + season_path = scan_path.parent + series_path = season_path.parent + if nfo_manager.parse_imdb_from_path(series_path): + _log("INFO", f"Processing single episode: {scan_path}") + try: + tv_processor.process_episode_file(series_path, season_path, scan_path) + except Exception as e: + _log("ERROR", f"Failed processing episode {scan_path}: {e}") + else: + # Full series processing + for item in scan_path.iterdir(): + if item.is_dir() and nfo_manager.parse_imdb_from_path(item): + try: + tv_processor.process_series(item) + except Exception as e: + _log("ERROR", f"Failed processing TV series {item}: {e}") if scan_type in ["both", "movies"] and scan_path in config.movie_paths: _log("INFO", f"Scanning movies in: {scan_path}") @@ -1310,6 +1556,65 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N background_tasks.add_task(run_scan) return {"status": "started", "message": f"Manual {scan_type} scan started"} +@app.post("/tv/scan-season") +async def scan_tv_season(background_tasks: BackgroundTasks, series_path: str, season_name: str): + """Scan a specific TV season - URL-safe endpoint""" + try: + series_dir = Path(series_path) + season_dir = series_dir / season_name + + if not series_dir.exists(): + raise HTTPException(status_code=404, detail=f"Series path not found: {series_path}") + if not season_dir.exists(): + raise HTTPException(status_code=404, detail=f"Season path not found: {season_dir}") + + imdb_id = nfo_manager.parse_imdb_from_path(series_dir) + if not imdb_id: + raise HTTPException(status_code=400, detail="No IMDb ID found in series path") + + async def process_season(): + _log("INFO", f"Processing TV season: {season_dir}") + try: + tv_processor.process_season(series_dir, season_dir) + except Exception as e: + _log("ERROR", f"Failed processing season {season_dir}: {e}") + + background_tasks.add_task(process_season) + return {"status": "started", "message": f"Season scan started for {season_name}"} + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/tv/scan-episode") +async def scan_tv_episode(background_tasks: BackgroundTasks, series_path: str, season_name: str, episode_name: str): + """Scan a specific TV episode - URL-safe endpoint""" + try: + series_dir = Path(series_path) + season_dir = series_dir / season_name + episode_file = season_dir / episode_name + + if not series_dir.exists(): + raise HTTPException(status_code=404, detail=f"Series path not found: {series_path}") + if not episode_file.exists(): + raise HTTPException(status_code=404, detail=f"Episode file not found: {episode_file}") + + imdb_id = nfo_manager.parse_imdb_from_path(series_dir) + if not imdb_id: + raise HTTPException(status_code=400, detail="No IMDb ID found in series path") + + async def process_episode(): + _log("INFO", f"Processing TV episode: {episode_file}") + try: + tv_processor.process_episode_file(series_dir, season_dir, episode_file) + except Exception as e: + _log("ERROR", f"Failed processing episode {episode_file}: {e}") + + background_tasks.add_task(process_episode) + return {"status": "started", "message": f"Episode scan started for {episode_name}"} + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + @app.post("/test/bulk-update") async def test_bulk_update(): """Test bulk update functionality without modifying data"""