Add comprehensive TV show enhancements and URL-safe endpoints

 Features Added:
• Single season/episode processing capability via new endpoints
• Enhanced NFO generation with full Sonarr API metadata (titles, plots, ratings)
• NFOGuard timestamp tracking in all NFO source comments
• URL-safe /tv/scan-season and /tv/scan-episode endpoints

🔧 Technical Improvements:
• Fixed URL encoding issues for paths with spaces and special characters
• Enhanced TVProcessor with process_season() and process_episode_file() methods
• Rich metadata extraction from Sonarr API (episode titles, plots, runtime, ratings)
• XML escaping for special characters in metadata
• Comprehensive episode parsing (SxxExx and numeric formats)

📚 Documentation:
• Updated README with new TV processing endpoints
• Added enhanced NFO generation examples showing before/after
• Documented URL-safe alternatives to manual scan paths

🎯 User Benefits:
• Can now process single seasons: Chicago Fire Season 13
• Can now process single episodes: S13E21 files
• Enhanced NFO files with full episode metadata for better Emby/Plex experience
• Timestamps show when NFOGuard processed each file
This commit is contained in:
2025-09-11 09:39:42 -04:00
parent c7c783570c
commit c705fb4d6d
3 changed files with 426 additions and 12 deletions
+75 -1
View File
@@ -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 `<dateadded>` fields.
**NEW**: Full metadata integration from Sonarr API (episode titles, plots, ratings, runtime).
Optionally adds `<lockdata>` 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
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<episodedetails>
<season>13</season>
<episode>2</episode>
<aired>2024-10-03</aired>
<dateadded>2025-02-19T23:10:35+00:00</dateadded>
<premiered>2025-02-19</premiered>
<lockdata>true</lockdata>
<!-- source: TV Episode; sonarr:history.import -->
<!-- managed by NFOGuard -->
</episodedetails>
```
### After (Enhanced Format):
```xml
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<episodedetails>
<season>13</season>
<episode>2</episode>
<title>Ride the Blade</title>
<plot>Firehouse 51 tackles a fire at a local restaurant. Boden has an emotional conversation with his stepson.</plot>
<runtime>42</runtime>
<rating>8.2</rating>
<votes>1543</votes>
<aired>2024-10-03</aired>
<dateadded>2025-02-19T23:10:35+00:00</dateadded>
<premiered>2025-02-19</premiered>
<lockdata>true</lockdata>
<!-- source: TV Episode; sonarr:history.import -->
<!-- managed by NFOGuard at 2025-09-11T13:45:22+00:00 -->
</episodedetails>
```
**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
+40 -5
View File
@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&apos;"))
@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>{episode_num}</episode>",
]
# Add enhanced metadata if available
if enhanced_metadata:
if enhanced_metadata.get("title"):
xml_lines.append(f" <title>{self._escape_xml(enhanced_metadata['title'])}</title>")
if enhanced_metadata.get("overview"):
xml_lines.append(f" <plot>{self._escape_xml(enhanced_metadata['overview'])}</plot>")
if enhanced_metadata.get("runtime"):
xml_lines.append(f" <runtime>{enhanced_metadata['runtime']}</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" <rating>{imdb_rating}</rating>")
xml_lines.append(f" <votes>{imdb_votes}</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(" <lockdata>true</lockdata>")
# Add source comments
# Add source comments with timestamp
if source_detail:
current_time = datetime.now(timezone.utc).isoformat(timespec="seconds")
xml_lines.append(f" <!-- source: TV Episode; {source_detail} -->")
xml_lines.append(f" <!-- managed by {self.manager_brand} -->")
xml_lines.append(f" <!-- managed by {self.manager_brand} at {current_time} -->")
xml_lines.append("</episodedetails>")
xml_lines.append("") # Final newline
+306 -1
View File
@@ -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,7 +1510,29 @@ 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:
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_season(series_path, scan_path)
except Exception as 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:
@@ -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"""