Compare commits

...

11 Commits

Author SHA1 Message Date
sbcrumb b63c188813 chore: Bump version to 1.9.3 with NFO preservation fix
Local Docker Build (Dev) / build-dev (push) Successful in 24s
2025-09-26 08:28:48 -04:00
sbcrumb 16bcf89ddd fix: Replace NFO migration logic with preservation logic in create_episode_nfo
The core issue was that create_episode_nfo() method was still using the old
migration logic that renamed long NFO files to S01E01.nfo format.

Changes:
- Modified create_episode_nfo() to check for video-matching NFO files first
- Update existing NFO files in-place while preserving original filenames
- Removed old migration and cleanup logic that deleted original files
- Added clear logging to distinguish preservation vs standard creation

This ensures TV episode NFO files keep their original long names that
match the video files exactly, while still getting NFOGuard metadata.
2025-09-26 08:28:39 -04:00
sbcrumb 0b17ee54fd chore: Bump version to 1.9.2 to verify new NFO preservation code deployment
Local Docker Build (Dev) / build-dev (push) Successful in 24s
2025-09-26 08:23:44 -04:00
sbcrumb 4980d1ae04 feat: Preserve TV episode NFO filenames matching video files
Local Docker Build (Dev) / build-dev (push) Successful in 29s
Instead of migrating long NFO names to standardized S01E01.nfo format,
now preserves original filenames that match video files while still
populating NFOGuard metadata (dateadded, aired, season, episode).

Changes:
- Added update_episode_nfo_preserving_name() method to NFOManager
- Added find_episode_nfo_matching_video() to locate NFO files by video name pattern
- Modified TVProcessor to update existing NFOs in-place rather than migrate names
- Episodes with existing NFOs now get "nfo_update_required" source for special handling

This maintains the user's preferred naming convention where NFO files
match their corresponding video files exactly.
2025-09-26 08:16:42 -04:00
sbcrumb 24d94306c1 fix: remove file lock
Local Docker Build (Dev) / build-dev (push) Successful in 18s
2025-09-25 19:12:37 -04:00
sbcrumb 94590ac070 feat:add file cache system
Local Docker Build (Dev) / build-dev (push) Successful in 15s
2025-09-25 18:58:31 -04:00
sbcrumb 714fceec98 another update
Local Docker Build (Dev) / build-dev (push) Successful in 28s
2025-09-25 18:51:21 -04:00
sbcrumb 70302837a7 huge: update
Local Docker Build (Dev) / build-dev (push) Successful in 28s
2025-09-25 18:41:37 -04:00
sbcrumb 740f912b57 state2
Local Docker Build (Dev) / build-dev (push) Successful in 23s
2025-09-25 18:04:15 -04:00
sbcrumb bcf428fbc0 update: add api routes
Local Docker Build (Dev) / build-dev (push) Successful in 27s
2025-09-25 15:47:50 -04:00
sbcrumb 5adc596077 refactor: Extract models, config, and main app setup
Local Docker Build (Dev) / build-dev (push) Successful in 33s
- Create modular directory structure
  - Extract Pydantic models to api/models.py
  - Move configuration to config/settings.py
  - Create clean main.py with FastAPI app setup

  WIP: Routes and processor classes still need extraction
2025-09-25 15:32:51 -04:00
17 changed files with 3223 additions and 66 deletions
+1 -1
View File
@@ -1 +1 @@
1.9.1 1.9.3
View File
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""
Pydantic models for NFOGuard API requests and responses
"""
from pydantic import BaseModel
from typing import Optional, Dict, Any, List
class SonarrWebhook(BaseModel):
eventType: str
series: Optional[Dict[str, Any]] = None
episodes: Optional[list] = []
episodeFile: Optional[Dict[str, Any]] = None
isUpgrade: Optional[bool] = False
class Config:
extra = "allow"
class RadarrWebhook(BaseModel):
eventType: str
movie: Optional[Dict[str, Any]] = None
movieFile: Optional[Dict[str, Any]] = None
isUpgrade: Optional[bool] = False
deletedFiles: Optional[list] = []
remoteMovie: Optional[Dict[str, Any]] = None
renamedMovieFiles: Optional[List[Dict[str, Any]]] = None
class Config:
extra = "allow"
class HealthResponse(BaseModel):
status: str
version: str
uptime: str
database_status: str
radarr_database: Optional[Dict[str, Any]] = None
class TVSeasonRequest(BaseModel):
series_path: str
season_name: str
class TVEpisodeRequest(BaseModel):
series_path: str
season_name: str
episode_name: str
+839
View File
@@ -0,0 +1,839 @@
#!/usr/bin/env python3
"""
FastAPI routes for NFOGuard
"""
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Any, Optional
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
from fastapi.responses import JSONResponse
# Import models
from .models import SonarrWebhook, RadarrWebhook, HealthResponse, TVSeasonRequest, TVEpisodeRequest
# Import core utilities
from core.logging import _log
from core.database import NFOGuardDatabase
from core.fs_cache import fs_cache, clear_all_caches
from core.xml_cache import xml_cache
from core.batch_operations import optimize_library_scan
async def _read_payload(request: Request) -> Optional[Dict[str, Any]]:
"""Read and validate JSON payload from webhook"""
try:
content_type = request.headers.get("content-type", "")
if "application/json" not in content_type.lower():
_log("WARNING", f"Invalid content type: {content_type}")
return None
body = await request.body()
if not body:
_log("WARNING", "Empty request body")
return None
payload = json.loads(body.decode('utf-8'))
_log("DEBUG", f"Raw payload: {json.dumps(payload, indent=2)}")
return payload
except json.JSONDecodeError as e:
_log("ERROR", f"Invalid JSON payload: {e}")
return None
except Exception as e:
_log("ERROR", f"Error reading payload: {e}")
return None
def register_routes(
app: FastAPI,
tv_processor,
movie_processor,
batcher,
db: NFOGuardDatabase,
start_time: datetime,
version: str
):
"""Register all routes with the FastAPI app"""
@app.post("/webhook/sonarr")
async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks):
"""Handle Sonarr webhooks"""
try:
payload = await _read_payload(request)
if not payload:
raise HTTPException(status_code=422, detail="Empty Sonarr payload")
webhook = SonarrWebhook(**payload)
_log("INFO", f"Received Sonarr webhook: {webhook.eventType}")
if webhook.eventType not in ["Download", "Upgrade", "Rename"]:
return {"status": "ignored", "reason": f"Event type {webhook.eventType} not processed"}
if not webhook.series:
_log("WARNING", "No series data in Sonarr webhook")
return {"status": "error", "message": "No series data"}
# Process the webhook using the batcher
await batcher.queue_sonarr_webhook(payload)
return {"status": "queued", "message": f"Sonarr {webhook.eventType} webhook queued for processing"}
except Exception as e:
_log("ERROR", f"Sonarr webhook error: {e}")
raise HTTPException(status_code=500, detail=f"Webhook processing failed: {str(e)}")
@app.post("/webhook/radarr")
async def radarr_webhook(request: Request, background_tasks: BackgroundTasks):
"""Handle Radarr webhooks"""
try:
payload = await _read_payload(request)
if not payload:
raise HTTPException(status_code=422, detail="Empty Radarr payload")
webhook = RadarrWebhook(**payload)
_log("INFO", f"Received Radarr webhook: {webhook.eventType}")
if webhook.eventType not in ["Download", "Upgrade", "Rename"]:
return {"status": "ignored", "reason": f"Event type {webhook.eventType} not processed"}
if not webhook.movie:
_log("WARNING", "No movie data in Radarr webhook")
return {"status": "error", "message": "No movie data"}
# Process the webhook using the batcher
await batcher.queue_radarr_webhook(payload)
return {"status": "queued", "message": f"Radarr {webhook.eventType} webhook queued for processing"}
except Exception as e:
_log("ERROR", f"Radarr webhook error: {e}")
raise HTTPException(status_code=500, detail=f"Webhook processing failed: {str(e)}")
@app.get("/health")
async def health() -> HealthResponse:
"""Health check endpoint with database status"""
uptime = datetime.now(timezone.utc) - start_time
# Check NFOGuard database
try:
with db.get_connection() as conn:
conn.execute("SELECT 1").fetchone()
db_status = "healthy"
except Exception as e:
db_status = f"error: {e}"
# Check Radarr database if available
radarr_db_health = None
overall_status = "healthy" if db_status == "healthy" else "degraded"
# Get Radarr client with database access from movie processor
try:
if hasattr(movie_processor, 'radarr') and movie_processor.radarr:
if hasattr(movie_processor.radarr, 'db_client') and movie_processor.radarr.db_client:
radarr_test = movie_processor.radarr.db_client.test_connection()
radarr_db_health = {
"radarr_db_available": radarr_test["success"],
"radarr_db_path": radarr_test.get("db_path"),
"radarr_db_error": radarr_test.get("error")
}
if not radarr_test["success"]:
overall_status = "degraded"
except Exception as e:
_log("DEBUG", f"Radarr database health check failed: {e}")
return HealthResponse(
status=overall_status,
version=version,
uptime=str(uptime).split('.')[0], # Remove microseconds
database_status=db_status,
radarr_database=radarr_db_health
)
@app.get("/stats")
async def get_stats():
"""Get processing statistics"""
return {
"message": "Stats endpoint - implementation needed",
"uptime": str(datetime.now(timezone.utc) - start_time).split('.')[0]
}
@app.get("/batch/status")
async def batch_status():
"""Get current batch processing status"""
return {
"pending_items": batcher.get_pending_count(),
"processing_items": batcher.get_processing_count(),
"total_queued": batcher.get_pending_count() + batcher.get_processing_count()
}
@app.post("/manual/scan")
async def manual_scan(background_tasks: BackgroundTasks):
"""Trigger manual library scan"""
try:
_log("INFO", "Manual scan triggered via API")
# Add background task to scan all libraries
def scan_libraries():
total_processed = 0
# Scan TV libraries
for tv_path in config.tv_paths:
if tv_path.exists():
_log("INFO", f"Scanning TV library: {tv_path}")
for series_dir in tv_path.iterdir():
if series_dir.is_dir() and "[imdb-" in series_dir.name:
try:
tv_processor.process_series(series_dir)
total_processed += 1
except Exception as e:
_log("ERROR", f"Error processing series {series_dir.name}: {e}")
# Scan Movie libraries
for movie_path in config.movie_paths:
if movie_path.exists():
_log("INFO", f"Scanning movie library: {movie_path}")
for movie_dir in movie_path.iterdir():
if movie_dir.is_dir():
try:
movie_processor.process_movie(movie_dir)
total_processed += 1
except Exception as e:
_log("ERROR", f"Error processing movie {movie_dir.name}: {e}")
_log("INFO", f"Manual scan completed: {total_processed} items processed")
background_tasks.add_task(scan_libraries)
return {"status": "started", "message": "Manual scan initiated", "note": "Scan running in background - check logs for progress"}
except Exception as e:
_log("ERROR", f"Manual scan failed: {e}")
raise HTTPException(status_code=500, detail=f"Manual scan failed: {str(e)}")
@app.post("/tv/scan-season")
async def scan_tv_season(request: TVSeasonRequest, background_tasks: BackgroundTasks):
"""Scan specific TV season"""
try:
_log("INFO", f"TV season scan requested: {request.series_path}/{request.season_name}")
return {"status": "started", "message": f"Scanning season {request.season_name}"}
except Exception as e:
_log("ERROR", f"TV season scan failed: {e}")
raise HTTPException(status_code=500, detail=f"Season scan failed: {str(e)}")
@app.post("/tv/scan-episode")
async def scan_tv_episode(request: TVEpisodeRequest, background_tasks: BackgroundTasks):
"""Scan specific TV episode"""
try:
_log("INFO", f"TV episode scan requested: {request.series_path}/{request.season_name}/{request.episode_name}")
return {"status": "started", "message": f"Scanning episode {request.episode_name}"}
except Exception as e:
_log("ERROR", f"TV episode scan failed: {e}")
raise HTTPException(status_code=500, detail=f"Episode scan failed: {str(e)}")
# Debug endpoints
@app.get("/debug/movie/{imdb_id}")
async def debug_movie(imdb_id: str):
"""Debug movie processing for specific IMDb ID"""
return {"message": f"Debug info for movie {imdb_id} - implementation needed"}
@app.get("/debug/movie/{imdb_id}/history")
async def debug_movie_history(imdb_id: str):
"""Debug movie processing history"""
return {"message": f"Debug history for movie {imdb_id} - implementation needed"}
@app.get("/debug/movie/{imdb_id}/priority")
async def debug_movie_priority(imdb_id: str):
"""Debug movie priority processing"""
return {"message": f"Debug priority for movie {imdb_id} - implementation needed"}
@app.get("/debug/tmdb/{imdb_id}")
async def debug_tmdb(imdb_id: str):
"""Debug TMDB API calls for movie"""
return {"message": f"Debug TMDB for movie {imdb_id} - implementation needed"}
# Test endpoints
@app.post("/test/bulk-update")
async def test_bulk_update():
"""Test bulk update functionality"""
return {"message": "Test bulk update - implementation needed"}
@app.post("/test/movie-scan")
async def test_movie_scan():
"""Test movie scan functionality"""
return {"message": "Test movie scan - implementation needed"}
@app.post("/bulk/update")
async def bulk_update():
"""Bulk update functionality"""
return {"message": "Bulk update - implementation needed"}
# Performance monitoring endpoints
@app.get("/cache/stats")
async def cache_stats():
"""Get comprehensive cache performance statistics"""
fs_stats = fs_cache.get_cache_stats()
xml_stats = xml_cache.get_cache_stats()
# Combine stats
combined_stats = {
"filesystem_cache": fs_stats,
"xml_processing_cache": xml_stats,
"overall_hit_rate": round(
((fs_stats["cache_hits"] + xml_stats["xml_cache_hits"]) /
(fs_stats["cache_hits"] + fs_stats["cache_misses"] +
xml_stats["xml_cache_hits"] + xml_stats["xml_cache_misses"]) * 100
) if (fs_stats["cache_hits"] + fs_stats["cache_misses"] +
xml_stats["xml_cache_hits"] + xml_stats["xml_cache_misses"]) > 0 else 0, 2
)
}
return combined_stats
@app.post("/cache/clear")
async def clear_cache():
"""Clear all caches"""
clear_all_caches()
return {"status": "success", "message": "All caches cleared"}
@app.post("/scan/optimized")
async def optimized_scan(background_tasks: BackgroundTasks):
"""Trigger optimized library scan using batch operations"""
try:
_log("INFO", "Optimized scan triggered via API")
def run_optimized_scan():
# Combine all library paths
all_paths = list(config.tv_paths) + list(config.movie_paths)
existing_paths = [p for p in all_paths if p.exists()]
if not existing_paths:
_log("WARNING", "No valid library paths found")
return
# Run optimized scan
results = optimize_library_scan(existing_paths)
_log("INFO", f"Optimized scan complete: {results}")
background_tasks.add_task(run_optimized_scan)
return {
"status": "started",
"message": "Optimized library scan initiated",
"note": "Uses batch operations and caching for improved performance"
}
except Exception as e:
_log("ERROR", f"Optimized scan failed: {e}")
raise HTTPException(status_code=500, detail=f"Optimized scan failed: {str(e)}")
@app.post("/nfo/strip-lockdata")
async def strip_lockdata_from_library(background_tasks: BackgroundTasks):
"""Strip lockdata elements from all NFO files for Emby compatibility"""
try:
_log("INFO", "Lockdata stripping initiated via API")
def strip_lockdata_task():
total_processed = 0
# Process TV libraries
for tv_path in config.tv_paths:
if tv_path.exists():
_log("INFO", f"Stripping lockdata from TV library: {tv_path}")
processed = tv_processor.nfo_manager.strip_lockdata_from_directory(tv_path)
total_processed += processed
# Process Movie libraries
for movie_path in config.movie_paths:
if movie_path.exists():
_log("INFO", f"Stripping lockdata from movie library: {movie_path}")
processed = movie_processor.nfo_manager.strip_lockdata_from_directory(movie_path)
total_processed += processed
_log("INFO", f"Lockdata stripping complete: {total_processed} NFO files processed")
background_tasks.add_task(strip_lockdata_task)
return {
"status": "started",
"message": "Lockdata stripping initiated for Emby compatibility",
"note": "This will remove <lockdata>true</lockdata> from all NFO files"
}
except Exception as e:
_log("ERROR", f"Lockdata stripping failed: {e}")
raise HTTPException(status_code=500, detail=f"Lockdata stripping failed: {str(e)}")
@app.get("/debug/nfo/{path:path}")
async def debug_nfo_content(path: str):
"""Debug NFO file content to troubleshoot Emby title issues"""
try:
from pathlib import Path
import xml.etree.ElementTree as ET
# Construct full path
nfo_path = Path("/" + path.lstrip("/"))
if not nfo_path.exists():
raise HTTPException(status_code=404, detail=f"NFO file not found: {nfo_path}")
if not str(nfo_path).endswith('.nfo'):
raise HTTPException(status_code=400, detail="Path must point to a .nfo file")
# Parse NFO content
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
# Extract key elements that Emby uses
result = {
"file_path": str(nfo_path),
"root_element": root.tag,
"elements_found": {},
"nfoguard_data": {},
"raw_xml": None
}
# Check for key elements
key_elements = ["title", "plot", "premiered", "dateadded", "lockdata", "uniqueid", "year", "runtime"]
for element_name in key_elements:
elem = root.find(f".//{element_name}")
if elem is not None:
result["elements_found"][element_name] = {
"text": elem.text,
"attributes": elem.attrib if elem.attrib else None
}
# Check NFOGuard specific data
nfoguard_source = root.find(".//nfoguard_source")
if nfoguard_source is not None:
result["nfoguard_data"]["source"] = nfoguard_source.text
# Get first few lines of raw XML for inspection
with open(nfo_path, 'r', encoding='utf-8') as f:
raw_content = f.read()
lines = raw_content.split('\n')
result["raw_xml"] = lines[:20] # First 20 lines
return result
except ET.ParseError as e:
# If XML parsing fails, return raw content
with open(nfo_path, 'r', encoding='utf-8') as f:
raw_content = f.read()
return {
"file_path": str(nfo_path),
"parse_error": str(e),
"raw_content": raw_content[:1000] # First 1000 chars
}
except Exception as e:
_log("ERROR", f"NFO debug failed: {e}")
raise HTTPException(status_code=500, detail=f"NFO debug failed: {str(e)}")
@app.get("/debug/emby-title-test/{imdb_id}")
async def test_emby_title_format(imdb_id: str):
"""Test different NFO title formats for Emby compatibility"""
try:
# Create a test NFO with different title positioning to see what Emby prefers
test_formats = {
"title_first": """<?xml version="1.0" encoding="utf-8"?>
<movie>
<title>Test Movie Title</title>
<plot>Test plot description</plot>
<uniqueid type="imdb" default="true">{imdb_id}</uniqueid>
<dateadded>2023-01-01T12:00:00</dateadded>
</movie>""",
"title_after_plot": """<?xml version="1.0" encoding="utf-8"?>
<movie>
<plot>Test plot description</plot>
<title>Test Movie Title</title>
<uniqueid type="imdb" default="true">{imdb_id}</uniqueid>
<dateadded>2023-01-01T12:00:00</dateadded>
</movie>""",
"title_before_uniqueid": """<?xml version="1.0" encoding="utf-8"?>
<movie>
<plot>Test plot description</plot>
<uniqueid type="imdb" default="true">{imdb_id}</uniqueid>
<title>Test Movie Title</title>
<dateadded>2023-01-01T12:00:00</dateadded>
</movie>"""
}
for format_name, xml_content in test_formats.items():
test_formats[format_name] = xml_content.format(imdb_id=imdb_id)
return {
"imdb_id": imdb_id,
"test_formats": test_formats,
"recommendation": "Try creating a test NFO file with title as the first element after <movie>"
}
except Exception as e:
_log("ERROR", f"Emby title test failed: {e}")
raise HTTPException(status_code=500, detail=f"Emby title test failed: {str(e)}")
@app.get("/debug/tv-episode/{path:path}")
async def debug_tv_episode_nfo(path: str):
"""Debug TV episode NFO specifically for Emby title issues"""
try:
from pathlib import Path
import xml.etree.ElementTree as ET
# Handle paths like "media/TV/ShowName/Season 01/S01E01.nfo"
nfo_path = Path("/" + path.lstrip("/"))
if not nfo_path.exists():
raise HTTPException(status_code=404, detail=f"Episode NFO not found: {nfo_path}")
if not str(nfo_path).endswith('.nfo'):
raise HTTPException(status_code=400, detail="Path must point to a .nfo file")
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
# Check if it's an episode NFO
if root.tag != "episodedetails":
return {"error": f"Not a TV episode NFO (root is {root.tag}, expected episodedetails)"}
# Extract TV-specific elements
result = {
"file_path": str(nfo_path),
"nfo_type": "TV Episode",
"emby_compatibility_check": {},
"elements": {},
"video_file_info": None,
"recommendations": []
}
# Check critical elements for TV episodes
tv_elements = ["title", "season", "episode", "aired", "plot", "uniqueid"]
for elem_name in tv_elements:
elem = root.find(elem_name)
if elem is not None:
result["elements"][elem_name] = {
"text": elem.text,
"attributes": elem.attrib if elem.attrib else {}
}
# Emby compatibility checks
title_elem = root.find("title")
if title_elem is not None and title_elem.text:
result["emby_compatibility_check"]["has_title"] = True
result["emby_compatibility_check"]["title_value"] = title_elem.text.strip()
result["emby_compatibility_check"]["title_position"] = list(root).index(title_elem)
if list(root).index(title_elem) == 0:
result["emby_compatibility_check"]["title_is_first"] = True
else:
result["emby_compatibility_check"]["title_is_first"] = False
result["recommendations"].append("Consider moving <title> to be the first element after <episodedetails>")
else:
result["emby_compatibility_check"]["has_title"] = False
result["recommendations"].append("CRITICAL: No <title> element found!")
# Check for video file in same directory
season_dir = nfo_path.parent
episode_pattern = nfo_path.stem # S01E01 from S01E01.nfo
video_exts = [".mkv", ".mp4", ".avi", ".mov", ".m4v"]
for video_ext in video_exts:
potential_video = season_dir / f"{episode_pattern}{video_ext}"
if potential_video.exists():
result["video_file_info"] = {
"filename": potential_video.name,
"matches_nfo": True
}
break
# Look for any video file with the pattern
if not result["video_file_info"]:
for video_file in season_dir.glob(f"*{episode_pattern}*"):
if video_file.suffix.lower() in video_exts:
result["video_file_info"] = {
"filename": video_file.name,
"matches_nfo": episode_pattern in video_file.stem
}
if not (episode_pattern in video_file.stem):
result["recommendations"].append(f"Video filename '{video_file.name}' doesn't cleanly match NFO pattern")
break
# Element order analysis
element_order = [child.tag for child in root]
result["element_order"] = element_order
# Emby-specific title positioning checks
title_position = element_order.index('title') + 1 if 'title' in element_order else -1
result["title_position"] = title_position
if title_position > 1:
result["recommendations"].append(f"Title element is at position {title_position}. Emby prefers title as first element.")
# Check for common Emby compatibility issues
emby_checks = {
"title_first": element_order[0] == "title" if element_order else False,
"has_plot": "plot" in element_order,
"has_aired": "aired" in element_order,
"has_season": "season" in element_order,
"has_episode": "episode" in element_order,
"no_lockdata": "lockdata" not in element_order
}
result["emby_compatibility"] = emby_checks
# Additional Emby recommendations
if not emby_checks["title_first"]:
result["recommendations"].append("Move <title> to be the first element for best Emby compatibility")
if not emby_checks["no_lockdata"]:
result["recommendations"].append("Remove <lockdata> elements - they can cause Emby metadata issues")
# Check title content quality
if title_elem is not None and title_elem.text:
title_text = title_elem.text.strip()
# Check if title looks like filename
video_filename_base = None
for video_file in video_files:
if episode_pattern in video_file.stem:
video_filename_base = video_file.stem
break
if video_filename_base and title_text.lower() in video_filename_base.lower():
result["recommendations"].append(f"Title '{title_text}' appears to be derived from filename - ensure it's a proper episode title")
return result
except ET.ParseError as e:
with open(nfo_path, 'r', encoding='utf-8') as f:
raw_content = f.read()
return {
"file_path": str(nfo_path),
"error": "XML Parse Error",
"parse_error": str(e),
"raw_content_sample": raw_content[:500]
}
except Exception as e:
_log("ERROR", f"TV episode debug failed: {e}")
raise HTTPException(status_code=500, detail=f"TV episode debug failed: {str(e)}")
@app.post("/debug/validate-series-nfos")
async def validate_series_nfos(request: Dict[str, Any]):
"""Validate NFO files for an entire TV series for Emby compatibility"""
try:
series_path_str = request.get("series_path")
if not series_path_str:
raise HTTPException(status_code=400, detail="series_path required")
series_path = Path(series_path_str)
if not series_path.exists():
raise HTTPException(status_code=404, detail="Series path not found")
_log("INFO", f"Validating series NFOs: {series_path}")
validation_results = {
"series_path": str(series_path),
"series_nfo": None,
"seasons": {},
"episodes": [],
"summary": {
"total_nfos": 0,
"emby_compatible": 0,
"title_position_issues": 0,
"lockdata_issues": 0,
"common_issues": []
}
}
# Check series NFO
tvshow_nfo = series_path / "tvshow.nfo"
if tvshow_nfo.exists():
validation_results["series_nfo"] = await debug_nfo_structure(str(tvshow_nfo), "series")
validation_results["summary"]["total_nfos"] += 1
# Check season NFOs and episodes
from core.fs_cache import fs_cache
season_dirs = [d for d in fs_cache.get_directory_contents(series_path)
if d.is_dir() and d.name.lower().startswith("season")]
for season_dir in season_dirs:
season_name = season_dir.name
season_data = {"path": str(season_dir), "nfo": None, "episodes": []}
# Check season NFO
season_nfo = season_dir / "season.nfo"
if season_nfo.exists():
season_data["nfo"] = await debug_nfo_structure(str(season_nfo), "season")
validation_results["summary"]["total_nfos"] += 1
# Check episode NFOs
video_files = fs_cache.find_video_files(season_dir)
for video_file in video_files:
nfo_file = video_file.with_suffix('.nfo')
if nfo_file.exists():
episode_debug = await debug_tv_episode_nfo(str(nfo_file).replace(str(series_path) + "/", ""))
season_data["episodes"].append(episode_debug)
validation_results["episodes"].append(episode_debug)
validation_results["summary"]["total_nfos"] += 1
# Analyze for summary
if "emby_compatibility" in episode_debug:
emby_compat = episode_debug["emby_compatibility"]
if all(emby_compat.values()):
validation_results["summary"]["emby_compatible"] += 1
if not emby_compat.get("title_first", False):
validation_results["summary"]["title_position_issues"] += 1
if not emby_compat.get("no_lockdata", True):
validation_results["summary"]["lockdata_issues"] += 1
validation_results["seasons"][season_name] = season_data
# Generate common issues summary
if validation_results["summary"]["title_position_issues"] > 0:
validation_results["summary"]["common_issues"].append(
f"{validation_results['summary']['title_position_issues']} NFOs have title positioning issues"
)
if validation_results["summary"]["lockdata_issues"] > 0:
validation_results["summary"]["common_issues"].append(
f"{validation_results['summary']['lockdata_issues']} NFOs contain lockdata elements"
)
compat_rate = (validation_results["summary"]["emby_compatible"] /
validation_results["summary"]["total_nfos"] * 100) if validation_results["summary"]["total_nfos"] > 0 else 0
validation_results["summary"]["compatibility_rate"] = round(compat_rate, 1)
return validation_results
except Exception as e:
_log("ERROR", f"Series NFO validation failed: {e}")
raise HTTPException(status_code=500, detail=f"Series NFO validation failed: {str(e)}")
async def debug_nfo_structure(nfo_path_str: str, nfo_type: str) -> Dict[str, Any]:
"""Helper function to debug NFO structure"""
try:
nfo_path = Path(nfo_path_str)
if not nfo_path.exists():
return {"error": "NFO file not found", "path": nfo_path_str}
import xml.etree.ElementTree as ET
tree = ET.parse(nfo_path)
root = tree.getroot()
# Basic structure analysis
result = {
"file_path": str(nfo_path),
"root_element": root.tag,
"element_count": len(root),
"elements": {},
"recommendations": []
}
# Element analysis
for child in root:
if child.text:
result["elements"][child.tag] = child.text.strip()[:100] # Truncate long values
else:
result["elements"][child.tag] = None
# Element order analysis
element_order = [child.tag for child in root]
result["element_order"] = element_order
# Common checks
if "title" in element_order:
title_position = element_order.index('title') + 1
result["title_position"] = title_position
if title_position > 1:
result["recommendations"].append(f"Title element is at position {title_position}. Consider moving to position 1.")
if "lockdata" in element_order:
result["recommendations"].append("Contains lockdata element - consider removing for Emby compatibility")
return result
except Exception as e:
return {"error": str(e), "path": nfo_path_str}
@app.post("/debug/fix-emby-compatibility")
async def fix_emby_compatibility(request: Dict[str, Any]):
"""Apply Emby compatibility fixes to NFO files"""
try:
file_path_str = request.get("file_path")
series_path_str = request.get("series_path")
if not file_path_str and not series_path_str:
raise HTTPException(status_code=400, detail="Either file_path or series_path required")
results = {
"fixed_files": [],
"total_processed": 0,
"total_updated": 0,
"errors": []
}
nfo_files = []
# Single file fix
if file_path_str:
file_path = Path(file_path_str)
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if file_path.suffix.lower() == '.nfo':
nfo_files.append(file_path)
# Series directory fix
if series_path_str:
series_path = Path(series_path_str)
if not series_path.exists():
raise HTTPException(status_code=404, detail="Series path not found")
# Find all NFO files in series directory
from core.fs_cache import fs_cache
# Series NFO
tvshow_nfo = series_path / "tvshow.nfo"
if tvshow_nfo.exists():
nfo_files.append(tvshow_nfo)
# Season and episode NFOs
season_dirs = [d for d in fs_cache.get_directory_contents(series_path)
if d.is_dir() and d.name.lower().startswith("season")]
for season_dir in season_dirs:
# Season NFO
season_nfo = season_dir / "season.nfo"
if season_nfo.exists():
nfo_files.append(season_nfo)
# Episode NFOs
video_files = fs_cache.find_video_files(season_dir)
for video_file in video_files:
nfo_file = video_file.with_suffix('.nfo')
if nfo_file.exists():
nfo_files.append(nfo_file)
# Apply fixes to all found NFO files
for nfo_file in nfo_files:
try:
fix_results = nfo_manager.fix_nfo_for_emby_compatibility(nfo_file)
file_result = {
"file_path": str(nfo_file),
"lockdata_removed": fix_results["lockdata_removed"],
"title_reordered": fix_results["title_reordered"],
"updated": fix_results["file_updated"]
}
results["fixed_files"].append(file_result)
results["total_processed"] += 1
if fix_results["file_updated"]:
results["total_updated"] += 1
except Exception as e:
error_msg = f"Error fixing {nfo_file}: {str(e)}"
results["errors"].append(error_msg)
_log("ERROR", error_msg)
_log("INFO", f"Emby compatibility fixes complete: {results['total_updated']} of {results['total_processed']} files updated")
return results
except Exception as e:
_log("ERROR", f"Emby compatibility fix failed: {e}")
raise HTTPException(status_code=500, detail=f"Emby compatibility fix failed: {str(e)}")
View File
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""
NFOGuard configuration management
"""
import os
from pathlib import Path
def _bool_env(name: str, default: bool) -> bool:
"""Convert environment variable to boolean"""
v = os.environ.get(name)
if v is None:
return default
return v.lower() in ("1", "true", "yes", "y", "on")
class NFOGuardConfig:
def __init__(self):
# Paths - No hardcoded defaults, must be configured via environment
tv_paths_env = os.environ.get("TV_PATHS", "")
movie_paths_env = os.environ.get("MOVIE_PATHS", "")
if not tv_paths_env:
raise ValueError("TV_PATHS environment variable is required but not set")
if not movie_paths_env:
raise ValueError("MOVIE_PATHS environment variable is required but not set")
self.tv_paths = [Path(p.strip()) for p in tv_paths_env.split(",") if p.strip()]
self.movie_paths = [Path(p.strip()) for p in movie_paths_env.split(",") if p.strip()]
# Core settings
self.manage_nfo = _bool_env("MANAGE_NFO", True)
self.fix_dir_mtimes = _bool_env("FIX_DIR_MTIMES", True)
self.lock_metadata = _bool_env("LOCK_METADATA", True)
self.debug = _bool_env("DEBUG", False)
self.manager_brand = os.environ.get("MANAGER_BRAND", "NFOGuard")
# Batching
self.batch_delay = float(os.environ.get("BATCH_DELAY", "5.0"))
self.max_concurrent = int(os.environ.get("MAX_CONCURRENT_SERIES", "3"))
# Database
self.db_path = Path(os.environ.get("DB_PATH", "/app/data/media_dates.db"))
# Movie processing
self.movie_priority = os.environ.get("MOVIE_PRIORITY", "import_then_digital").lower()
self.prefer_release_dates_over_file_dates = _bool_env("PREFER_RELEASE_DATES_OVER_FILE_DATES", True)
self.allow_file_date_fallback = _bool_env("ALLOW_FILE_DATE_FALLBACK", False)
self.release_date_priority = [p.strip() for p in os.environ.get("RELEASE_DATE_PRIORITY", "digital,physical,theatrical").split(",")]
self.enable_smart_date_validation = _bool_env("ENABLE_SMART_DATE_VALIDATION", True)
self.max_release_date_gap_years = int(os.environ.get("MAX_RELEASE_DATE_GAP_YEARS", "10"))
self.movie_poll_mode = os.environ.get("MOVIE_POLL_MODE", "always").lower()
self.movie_update_mode = os.environ.get("MOVIE_DATE_UPDATE_MODE", "backfill_only").lower()
# TV processing
self.tv_season_dir_format = os.environ.get("TV_SEASON_DIR_FORMAT", "Season {season:02d}")
self.tv_season_dir_pattern = os.environ.get("TV_SEASON_DIR_PATTERN", "season ").lower()
self.tv_webhook_processing_mode = os.environ.get("TV_WEBHOOK_PROCESSING_MODE", "targeted").lower()
# Global configuration instance
config = NFOGuardConfig()
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""
Batch Operations for NFOGuard
Optimizes bulk file processing and NFO operations
"""
import os
import time
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Any
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from core.logging import _log
from core.fs_cache import fs_cache
from core.xml_cache import xml_cache
class BatchNFOProcessor:
"""Handles batch NFO operations for improved performance"""
def __init__(self, max_workers: int = 4):
self.max_workers = max_workers
def batch_find_video_files(self, directories: List[Path]) -> Dict[Path, List[Path]]:
"""Find video files in multiple directories concurrently"""
results = {}
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
# Submit all directory scans
future_to_dir = {
executor.submit(fs_cache.find_video_files, directory): directory
for directory in directories if directory.exists()
}
# Collect results
for future in as_completed(future_to_dir):
directory = future_to_dir[future]
try:
video_files = future.result()
results[directory] = video_files
except Exception as e:
_log("ERROR", f"Error scanning directory {directory}: {e}")
results[directory] = []
return results
def batch_check_nfo_files(self, nfo_paths: List[Path]) -> Dict[Path, bool]:
"""Check multiple NFO files for NFOGuard data concurrently"""
results = {}
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
# Submit all NFO checks
future_to_path = {
executor.submit(xml_cache.check_nfo_has_nfoguard_data, nfo_path): nfo_path
for nfo_path in nfo_paths
}
# Collect results
for future in as_completed(future_to_path):
nfo_path = future_to_path[future]
try:
has_data = future.result()
results[nfo_path] = has_data
except Exception as e:
_log("ERROR", f"Error checking NFO {nfo_path}: {e}")
results[nfo_path] = False
return results
def batch_extract_nfo_dates(self, nfo_paths: List[Path]) -> Dict[Path, Optional[Dict[str, Any]]]:
"""Extract date information from multiple NFO files concurrently"""
results = {}
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
# Submit all NFO extractions
future_to_path = {
executor.submit(xml_cache.extract_nfo_dates_cached, nfo_path): nfo_path
for nfo_path in nfo_paths if nfo_path.exists()
}
# Collect results
for future in as_completed(future_to_path):
nfo_path = future_to_path[future]
try:
dates_data = future.result()
results[nfo_path] = dates_data
except Exception as e:
_log("ERROR", f"Error extracting dates from NFO {nfo_path}: {e}")
results[nfo_path] = None
return results
def batch_update_file_mtimes(self, file_mtime_pairs: List[Tuple[Path, datetime]]):
"""Update file modification times in batch"""
updated_count = 0
error_count = 0
for file_path, target_datetime in file_mtime_pairs:
try:
if file_path.exists():
# Convert datetime to timestamp
timestamp = target_datetime.timestamp()
os.utime(file_path, (timestamp, timestamp))
updated_count += 1
else:
error_count += 1
_log("WARNING", f"File not found for mtime update: {file_path}")
except Exception as e:
error_count += 1
_log("ERROR", f"Error updating mtime for {file_path}: {e}")
_log("INFO", f"Batch mtime update complete: {updated_count} updated, {error_count} errors")
return {"updated": updated_count, "errors": error_count}
def scan_series_episodes_optimized(self, series_path: Path) -> Dict[Tuple[int, int], List[Path]]:
"""Optimized episode scanning using batch operations"""
disk_episodes = {}
# Get season directories
season_dirs = fs_cache.get_directory_contents(series_path)
valid_season_dirs = []
for season_dir in season_dirs:
if (season_dir.is_dir() and
season_dir.name.lower().startswith("season")):
valid_season_dirs.append(season_dir)
if not valid_season_dirs:
return disk_episodes
# Batch scan all season directories
season_video_files = self.batch_find_video_files(valid_season_dirs)
# Process results
for season_dir, video_files in season_video_files.items():
# Extract season number
try:
season_name = season_dir.name.lower()
if "season" in season_name:
season_part = season_name.replace("season", "").strip()
season_num = int(season_part)
else:
continue
except (ValueError, IndexError):
continue
# Process video files
for video_file in video_files:
from core.fs_cache import parse_episode_from_filename
episode_info = parse_episode_from_filename(video_file.name)
if episode_info:
file_season, file_episode = episode_info
key = (season_num, file_episode)
if key not in disk_episodes:
disk_episodes[key] = []
disk_episodes[key].append(video_file)
return disk_episodes
def batch_series_nfo_check(self, series_paths: List[Path]) -> Dict[Path, Dict[str, Any]]:
"""Check multiple series for NFO data in batch"""
results = {}
# Collect all NFO paths to check
nfo_paths_to_series = {}
for series_path in series_paths:
if not series_path.exists():
continue
# Check main series NFO
tvshow_nfo = series_path / "tvshow.nfo"
if tvshow_nfo.exists():
nfo_paths_to_series[tvshow_nfo] = (series_path, "tvshow")
# Check season NFOs
season_dirs = fs_cache.get_directory_contents(series_path)
for season_dir in season_dirs:
if season_dir.is_dir() and season_dir.name.lower().startswith("season"):
season_nfo = season_dir / "season.nfo"
if season_nfo.exists():
nfo_paths_to_series[season_nfo] = (series_path, f"season_{season_dir.name}")
if not nfo_paths_to_series:
return results
# Batch check all NFOs
nfo_results = self.batch_check_nfo_files(list(nfo_paths_to_series.keys()))
# Organize results by series
for nfo_path, has_nfoguard_data in nfo_results.items():
if nfo_path in nfo_paths_to_series:
series_path, nfo_type = nfo_paths_to_series[nfo_path]
if series_path not in results:
results[series_path] = {}
results[series_path][nfo_type] = has_nfoguard_data
return results
# Global batch processor instance
batch_processor = BatchNFOProcessor()
def optimize_library_scan(library_paths: List[Path]) -> Dict[str, Any]:
"""Perform optimized library scan using batch operations"""
start_time = time.time()
stats = {
"total_paths": len(library_paths),
"processed": 0,
"errors": 0,
"series_found": 0,
"movies_found": 0,
"processing_time": 0
}
series_paths = []
movie_paths = []
# Categorize paths
for lib_path in library_paths:
if not lib_path.exists():
stats["errors"] += 1
continue
# Use cached directory scanning
items = fs_cache.get_directory_contents(lib_path)
for item in items:
if item.is_dir() and "[imdb-" in item.name.lower():
# Determine if it's a series or movie based on structure
season_dirs = [d for d in fs_cache.get_directory_contents(item)
if d.is_dir() and d.name.lower().startswith("season")]
if season_dirs:
series_paths.append(item)
stats["series_found"] += 1
else:
movie_paths.append(item)
stats["movies_found"] += 1
# Batch process series
if series_paths:
_log("INFO", f"Batch processing {len(series_paths)} TV series")
series_results = batch_processor.batch_series_nfo_check(series_paths)
stats["processed"] += len(series_results)
# Movies can be processed similarly
stats["processing_time"] = round(time.time() - start_time, 2)
_log("INFO", f"Optimized library scan complete: {stats}")
return stats
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""
File System Caching for NFOGuard
Provides LRU caching for expensive file system operations
"""
import os
import time
from functools import lru_cache
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Any
from datetime import datetime, timezone
from core.logging import _log
class FileSystemCache:
"""Smart caching for file system operations with mtime invalidation"""
def __init__(self, max_cache_size: int = 1000):
self.max_cache_size = max_cache_size
self._dir_cache: Dict[str, Tuple[float, List[Path]]] = {}
self._file_cache: Dict[str, Tuple[float, Any]] = {}
self._mtime_cache: Dict[str, float] = {}
self._cache_hits = 0
self._cache_misses = 0
def get_directory_contents(self, directory_path: Path, pattern: str = "*") -> List[Path]:
"""Get directory contents with caching and mtime invalidation"""
cache_key = f"{directory_path}:{pattern}"
if not directory_path.exists():
return []
# Check if directory mtime has changed
current_mtime = directory_path.stat().st_mtime
if cache_key in self._dir_cache:
cached_mtime, cached_contents = self._dir_cache[cache_key]
if cached_mtime == current_mtime:
self._cache_hits += 1
return cached_contents
# Cache miss - scan directory
self._cache_misses += 1
contents = []
try:
if pattern == "*":
contents = list(directory_path.iterdir())
else:
contents = list(directory_path.glob(pattern))
except (OSError, PermissionError) as e:
_log("DEBUG", f"Error scanning directory {directory_path}: {e}")
return []
# Cache the results
self._dir_cache[cache_key] = (current_mtime, contents)
self._cleanup_cache()
return contents
def find_video_files(self, directory_path: Path) -> List[Path]:
"""Find video files with caching"""
video_extensions = {".mkv", ".mp4", ".avi", ".mov", ".m4v"}
cache_key = f"videos:{directory_path}"
if not directory_path.exists():
return []
current_mtime = directory_path.stat().st_mtime
# Check cache
if cache_key in self._file_cache:
cached_mtime, cached_files = self._file_cache[cache_key]
if cached_mtime == current_mtime:
self._cache_hits += 1
return cached_files
# Find video files
self._cache_misses += 1
video_files = []
try:
for file_path in directory_path.iterdir():
if file_path.is_file() and file_path.suffix.lower() in video_extensions:
video_files.append(file_path)
except (OSError, PermissionError) as e:
_log("DEBUG", f"Error scanning for videos in {directory_path}: {e}")
return []
# Cache results
self._file_cache[cache_key] = (current_mtime, video_files)
self._cleanup_cache()
return video_files
def get_file_mtime(self, file_path: Path) -> Optional[float]:
"""Get file modification time with caching"""
cache_key = str(file_path)
# Always check actual mtime for files (they change frequently)
try:
current_mtime = file_path.stat().st_mtime
self._mtime_cache[cache_key] = current_mtime
return current_mtime
except (OSError, FileNotFoundError):
self._mtime_cache.pop(cache_key, None)
return None
def file_exists(self, file_path: Path) -> bool:
"""Check if file exists with smart caching"""
cache_key = f"exists:{file_path}"
# For existence checks, we can cache briefly but need to be careful
if cache_key in self._file_cache:
cache_time, exists = self._file_cache[cache_key]
if time.time() - cache_time < 30: # Cache for 30 seconds
self._cache_hits += 1
return exists
# Check actual existence
self._cache_misses += 1
exists = file_path.exists()
self._file_cache[cache_key] = (time.time(), exists)
return exists
def find_episode_files(self, season_dir: Path, season_num: int, episode_num: int) -> List[Path]:
"""Find episode files with pattern caching"""
cache_key = f"episode:{season_dir}:S{season_num:02d}E{episode_num:02d}"
if not season_dir.exists():
return []
current_mtime = season_dir.stat().st_mtime
# Check cache
if cache_key in self._file_cache:
cached_mtime, cached_files = self._file_cache[cache_key]
if cached_mtime == current_mtime:
self._cache_hits += 1
return cached_files
# Find episode files
self._cache_misses += 1
episode_pattern = f"S{season_num:02d}E{episode_num:02d}"
episode_files = []
video_extensions = {".mkv", ".mp4", ".avi", ".mov", ".m4v"}
try:
for file_path in season_dir.iterdir():
if (file_path.is_file() and
file_path.suffix.lower() in video_extensions and
episode_pattern.upper() in file_path.name.upper()):
episode_files.append(file_path)
except (OSError, PermissionError) as e:
_log("DEBUG", f"Error finding episode files in {season_dir}: {e}")
return []
# Cache results
self._file_cache[cache_key] = (current_mtime, episode_files)
self._cleanup_cache()
return episode_files
def _cleanup_cache(self):
"""Clean up cache when it gets too large"""
if len(self._dir_cache) > self.max_cache_size:
# Remove oldest 25% of entries
remove_count = self.max_cache_size // 4
old_keys = list(self._dir_cache.keys())[:remove_count]
for key in old_keys:
del self._dir_cache[key]
if len(self._file_cache) > self.max_cache_size:
remove_count = self.max_cache_size // 4
old_keys = list(self._file_cache.keys())[:remove_count]
for key in old_keys:
del self._file_cache[key]
def clear_cache(self):
"""Clear all caches"""
self._dir_cache.clear()
self._file_cache.clear()
self._mtime_cache.clear()
_log("INFO", "File system cache cleared")
def get_cache_stats(self) -> Dict[str, Any]:
"""Get cache performance statistics"""
total_requests = self._cache_hits + self._cache_misses
hit_rate = (self._cache_hits / total_requests * 100) if total_requests > 0 else 0
return {
"cache_hits": self._cache_hits,
"cache_misses": self._cache_misses,
"hit_rate_percent": round(hit_rate, 2),
"dir_cache_size": len(self._dir_cache),
"file_cache_size": len(self._file_cache),
"mtime_cache_size": len(self._mtime_cache)
}
# Global cache instance
fs_cache = FileSystemCache()
# Cached utility functions
@lru_cache(maxsize=500)
def parse_episode_from_filename(filename: str) -> Optional[Tuple[int, int]]:
"""Parse season/episode from filename with LRU caching"""
import re
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))
return None
@lru_cache(maxsize=200)
def extract_imdb_from_path(path_str: str) -> Optional[str]:
"""Extract IMDb ID from path with LRU caching"""
import re
match = re.search(r'\[imdb-([^]]+)\]', path_str, re.IGNORECASE)
return match.group(1) if match else None
def clear_all_caches():
"""Clear all caches including LRU caches"""
fs_cache.clear_cache()
parse_episode_from_filename.cache_clear()
extract_imdb_from_path.cache_clear()
# Also clear XML caches
try:
from core.xml_cache import clear_xml_caches
clear_xml_caches()
except ImportError:
pass
_log("INFO", "All caches cleared")
+296 -65
View File
@@ -9,6 +9,7 @@ from pathlib import Path
from datetime import datetime from datetime import datetime
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
import re import re
from .logging import _log
class NFOManager: class NFOManager:
@@ -138,11 +139,26 @@ class NFOManager:
# Look for NFOGuard fields # Look for NFOGuard fields
dateadded_elem = root.find('.//dateadded') dateadded_elem = root.find('.//dateadded')
premiered_elem = root.find('.//premiered') premiered_elem = root.find('.//premiered')
lockdata_elem = root.find('.//lockdata') # Strip any existing lockdata elements for Emby compatibility
lockdata_elements = root.findall('.//lockdata')
if lockdata_elements:
modified = False
for lockdata_elem in lockdata_elements:
# Find parent and remove the element
for parent in root.iter():
if lockdata_elem in parent:
parent.remove(lockdata_elem)
modified = True
_log("DEBUG", f"Stripped lockdata element from {nfo_path.name}")
break
if modified:
# Write back the cleaned NFO
tree = ET.ElementTree(root)
ET.indent(tree, space=" ", level=0)
tree.write(nfo_path, encoding="utf-8", xml_declaration=True)
# Only consider it NFOGuard-managed if it has dateadded and lockdata # Only consider it NFOGuard-managed if it has dateadded (lockdata no longer required)
if (dateadded_elem is not None and dateadded_elem.text and if (dateadded_elem is not None and dateadded_elem.text):
lockdata_elem is not None and lockdata_elem.text == "true"):
result = { result = {
"dateadded": dateadded_elem.text.strip(), "dateadded": dateadded_elem.text.strip(),
@@ -176,11 +192,26 @@ class NFOManager:
# Look for NFOGuard fields in episode NFO # Look for NFOGuard fields in episode NFO
dateadded_elem = root.find('.//dateadded') dateadded_elem = root.find('.//dateadded')
aired_elem = root.find('.//aired') aired_elem = root.find('.//aired')
lockdata_elem = root.find('.//lockdata') # Strip any existing lockdata elements for Emby compatibility
lockdata_elements = root.findall('.//lockdata')
if lockdata_elements:
modified = False
for lockdata_elem in lockdata_elements:
# Find parent and remove the element
for parent in root.iter():
if lockdata_elem in parent:
parent.remove(lockdata_elem)
modified = True
_log("DEBUG", f"Stripped lockdata element from {nfo_path.name}")
break
if modified:
# Write back the cleaned NFO
tree = ET.ElementTree(root)
ET.indent(tree, space=" ", level=0)
tree.write(nfo_path, encoding="utf-8", xml_declaration=True)
# Only consider it NFOGuard-managed if it has dateadded and lockdata # Only consider it NFOGuard-managed if it has dateadded (lockdata no longer required)
if (dateadded_elem is not None and dateadded_elem.text and if (dateadded_elem is not None and dateadded_elem.text):
lockdata_elem is not None and lockdata_elem.text == "true"):
result = { result = {
"dateadded": dateadded_elem.text.strip(), "dateadded": dateadded_elem.text.strip(),
@@ -350,7 +381,7 @@ class NFOManager:
# Remove existing NFOGuard-managed elements to avoid duplicates # Remove existing NFOGuard-managed elements to avoid duplicates
# These will be re-added at the very bottom # These will be re-added at the very bottom
nfoguard_fields = ["dateadded", "lockdata", "premiered", "year"] nfoguard_fields = ["dateadded", "premiered", "year"]
for tag in nfoguard_fields: for tag in nfoguard_fields:
existing = movie.find(tag) existing = movie.find(tag)
if existing is not None: if existing is not None:
@@ -410,11 +441,7 @@ class NFOManager:
movie.append(dateadded_elem) movie.append(dateadded_elem)
print(f"✅ Added dateadded to NFO: {dateadded}") print(f"✅ Added dateadded to NFO: {dateadded}")
# Add lockdata at the very end # Note: lockdata element removed for Emby compatibility
if lock_metadata:
lockdata = ET.Element("lockdata")
lockdata.text = "true"
movie.append(lockdata)
# Write file with proper formatting # Write file with proper formatting
tree = ET.ElementTree(movie) tree = ET.ElementTree(movie)
@@ -476,9 +503,7 @@ class NFOManager:
tvdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="tvdb") tvdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="tvdb")
tvdb_uniqueid.text = tvdb_id tvdb_uniqueid.text = tvdb_id
# Add lockdata at the very end # Note: lockdata element removed for Emby compatibility
lockdata = ET.SubElement(tvshow, "lockdata")
lockdata.text = "true"
# Add NFOGuard comment at the beginning # Add NFOGuard comment at the beginning
comment_text = f" Created by {self.manager_brand} " comment_text = f" Created by {self.manager_brand} "
@@ -521,7 +546,7 @@ class NFOManager:
raise ValueError("Root element is not <season>") raise ValueError("Root element is not <season>")
# Remove existing NFOGuard-managed elements # Remove existing NFOGuard-managed elements
for tag in ["seasonnumber", "lockdata"]: for tag in ["seasonnumber"]:
existing = season.find(tag) existing = season.find(tag)
if existing is not None: if existing is not None:
season.remove(existing) season.remove(existing)
@@ -538,9 +563,7 @@ class NFOManager:
seasonnumber = ET.SubElement(season, "seasonnumber") seasonnumber = ET.SubElement(season, "seasonnumber")
seasonnumber.text = str(season_number) seasonnumber.text = str(season_number)
# Add lockdata at the end # Note: lockdata element removed for Emby compatibility
lockdata = ET.SubElement(season, "lockdata")
lockdata.text = "true"
# Add NFOGuard comment at the beginning # Add NFOGuard comment at the beginning
comment_text = f" Created by {self.manager_brand} " comment_text = f" Created by {self.manager_brand} "
@@ -610,22 +633,32 @@ class NFOManager:
def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int, def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int,
aired: Optional[str], dateadded: Optional[str], source: str, aired: Optional[str], dateadded: Optional[str], source: str,
lock_metadata: bool = True, enhanced_metadata: Optional[Dict[str, Any]] = None) -> None: lock_metadata: bool = True, enhanced_metadata: Optional[Dict[str, Any]] = None) -> None:
"""Create or update episode NFO file preserving existing content""" """Create or update episode NFO file - preserves existing long names matching video files"""
# Generate episode filename pattern
episode_filename = f"S{season_num:02d}E{episode_num:02d}.nfo"
nfo_path = season_dir / episode_filename
# Track if we need to delete an old long-named NFO file
old_nfo_to_delete = None
try: try:
# First, check for existing long-named NFO files that need migration # First, check for NFO files that match video file names (preserve long names)
existing_long_nfo = self.find_existing_episode_nfo(season_dir, season_num, episode_num) existing_matching_nfo = self.find_episode_nfo_matching_video(season_dir, season_num, episode_num)
# Prioritize long-named file for migration, otherwise use standard file if existing_matching_nfo:
source_nfo_path = existing_long_nfo if existing_long_nfo else nfo_path if nfo_path.exists() else None # Update the existing NFO in-place while preserving its filename
print(f"🎯 Updating existing NFO (preserving name): {existing_matching_nfo.name}")
success = self.update_episode_nfo_preserving_name(
existing_matching_nfo, season_num, episode_num, aired, dateadded, source
)
if success:
return # Successfully updated, we're done
else:
print(f"⚠️ Failed to update existing NFO, falling back to standard creation")
# Fallback: Create standard S01E01.nfo format (for new episodes without existing NFOs)
episode_filename = f"S{season_num:02d}E{episode_num:02d}.nfo"
nfo_path = season_dir / episode_filename
# Check if standard format NFO exists
source_nfo_path = nfo_path if nfo_path.exists() else None
if source_nfo_path: if source_nfo_path:
# Standard NFO file processing (S01E01.nfo format)
try: try:
tree = ET.parse(source_nfo_path) tree = ET.parse(source_nfo_path)
episode = tree.getroot() episode = tree.getroot()
@@ -634,33 +667,16 @@ class NFOManager:
if episode.tag != "episodedetails": if episode.tag != "episodedetails":
raise ValueError("Root element is not <episodedetails>") raise ValueError("Root element is not <episodedetails>")
# If we're migrating from a long-named file, mark it for deletion print(f"📝 Updating standard NFO: {episode_filename}")
if existing_long_nfo and source_nfo_path == existing_long_nfo:
old_nfo_to_delete = existing_long_nfo
print(f"📦 Migrating episode NFO: {existing_long_nfo.name} -> {episode_filename}")
# Show what content fields are being preserved
content_fields = ["title", "plot", "runtime", "premiered"]
preserved_content = []
for field in content_fields:
elem = episode.find(field)
if elem is not None and elem.text:
preserved_content.append(field)
if preserved_content:
print(f" 📄 Preserving content: {', '.join(preserved_content)}")
# Preserve existing content fields and store NFOGuard-managed fields for re-adding # Remove existing NFOGuard fields to re-add them at the bottom
preserved_values = {} nfoguard_fields = ["aired", "dateadded", "season", "episode"]
# Extract and preserve NFOGuard-managed fields (we'll re-add these at the bottom)
nfoguard_fields = ["aired", "dateadded", "lockdata", "season", "episode"]
for tag in nfoguard_fields: for tag in nfoguard_fields:
existing = episode.find(tag) existing = episode.find(tag)
if existing is not None: if existing is not None:
# Store the value before removing # Preserve existing aired date if not provided
if tag == "aired" and not aired: if tag == "aired" and not aired:
aired = existing.text # Preserve existing aired date aired = existing.text
preserved_values[tag] = existing.text
episode.remove(existing) episode.remove(existing)
# Important: DO NOT remove content fields like title, plot, runtime, premiered, etc. # Important: DO NOT remove content fields like title, plot, runtime, premiered, etc.
@@ -737,10 +753,7 @@ class NFOManager:
dateadded_elem = ET.SubElement(episode, "dateadded") dateadded_elem = ET.SubElement(episode, "dateadded")
dateadded_elem.text = dateadded dateadded_elem.text = dateadded
# Add lockdata at the very end # Note: lockdata element removed for Emby compatibility
if lock_metadata:
lockdata = ET.SubElement(episode, "lockdata")
lockdata.text = "true"
# Add NFOGuard comment at the bottom with other NFOGuard fields # Add NFOGuard comment at the bottom with other NFOGuard fields
nfoguard_comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ") nfoguard_comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ")
@@ -756,13 +769,7 @@ class NFOManager:
print(f"✅ Successfully created/updated episode NFO: {nfo_path}") print(f"✅ Successfully created/updated episode NFO: {nfo_path}")
print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}") print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}")
# Clean up old long-named NFO file if we migrated from it # No cleanup needed - we preserve existing NFO filenames
if old_nfo_to_delete and old_nfo_to_delete.exists():
try:
old_nfo_to_delete.unlink()
print(f"🗑️ Cleaned up old NFO file: {old_nfo_to_delete.name}")
except Exception as cleanup_error:
print(f"⚠️ Warning: Could not delete old NFO file {old_nfo_to_delete.name}: {cleanup_error}")
except Exception as e: except Exception as e:
print(f"❌ Error creating/updating episode NFO {nfo_path}: {e}") print(f"❌ Error creating/updating episode NFO {nfo_path}: {e}")
@@ -803,4 +810,228 @@ class NFOManager:
print(f"✅ Updated {len(updated_files)} video file timestamps in {movie_dir.name}") print(f"✅ Updated {len(updated_files)} video file timestamps in {movie_dir.name}")
else: else:
print(f"⚠️ No video files found to update in {movie_dir.name}") print(f"⚠️ No video files found to update in {movie_dir.name}")
def strip_lockdata_from_nfo(self, nfo_path: Path) -> bool:
"""Strip existing lockdata elements from NFO file for Emby compatibility"""
if not nfo_path.exists():
return False
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
# Find and remove all lockdata elements
lockdata_elements = root.findall('.//lockdata')
if not lockdata_elements:
return False # No lockdata found, nothing to remove
removed_count = 0
for lockdata_elem in lockdata_elements:
# Find parent and remove the element
for parent in root.iter():
if lockdata_elem in parent:
parent.remove(lockdata_elem)
removed_count += 1
break
if removed_count > 0:
# Write back the modified NFO
ET.indent(tree, space=" ", level=0)
tree.write(nfo_path, encoding="utf-8", xml_declaration=True)
_log("INFO", f"Removed {removed_count} lockdata elements from {nfo_path.name}")
return True
except Exception as e:
_log("ERROR", f"Error stripping lockdata from {nfo_path}: {e}")
return False
def reorder_nfo_for_emby(self, nfo_path: Path) -> bool:
"""Reorder NFO elements to put title first for better Emby compatibility"""
if not nfo_path.exists():
return False
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
# Find title element
title_elem = root.find('title')
if title_elem is None:
return False # No title element to reorder
# Check if title is already first
if len(root) > 0 and root[0].tag == 'title':
return False # Title is already first
# Remove title from current position
root.remove(title_elem)
# Insert title as first element
root.insert(0, title_elem)
# Write back the modified NFO
ET.indent(tree, space=" ", level=0)
tree.write(nfo_path, encoding="utf-8", xml_declaration=True)
_log("INFO", f"Reordered NFO elements - moved title to first position: {nfo_path.name}")
return True
except Exception as e:
_log("ERROR", f"Error reordering NFO {nfo_path}: {e}")
return False
def fix_nfo_for_emby_compatibility(self, nfo_path: Path) -> Dict[str, bool]:
"""Apply all Emby compatibility fixes to an NFO file"""
results = {
"lockdata_removed": False,
"title_reordered": False,
"file_updated": False
}
if not nfo_path.exists():
return results
try:
# Strip lockdata elements
results["lockdata_removed"] = self.strip_lockdata_from_nfo(nfo_path)
# Reorder title to first position
results["title_reordered"] = self.reorder_nfo_for_emby(nfo_path)
results["file_updated"] = results["lockdata_removed"] or results["title_reordered"]
if results["file_updated"]:
_log("INFO", f"Applied Emby compatibility fixes to {nfo_path.name}: "
f"lockdata_removed={results['lockdata_removed']}, "
f"title_reordered={results['title_reordered']}")
except Exception as e:
_log("ERROR", f"Error applying Emby fixes to {nfo_path}: {e}")
return results
def strip_lockdata_from_directory(self, directory_path: Path) -> int:
"""Strip lockdata from all NFO files in a directory"""
if not directory_path.exists():
return 0
processed_count = 0
nfo_files = list(directory_path.glob("**/*.nfo"))
_log("INFO", f"Scanning {len(nfo_files)} NFO files for lockdata removal in {directory_path}")
for nfo_file in nfo_files:
if self.strip_lockdata_from_nfo(nfo_file):
processed_count += 1
_log("INFO", f"Stripped lockdata from {processed_count} NFO files in {directory_path}")
return processed_count
def update_episode_nfo_preserving_name(self, nfo_path: Path, season_num: int, episode_num: int,
aired: Optional[str], dateadded: Optional[str], source: str) -> bool:
"""Update existing TV episode NFO file while preserving its original filename"""
if not nfo_path.exists():
return False
try:
# Parse the existing NFO file
tree = ET.parse(nfo_path)
episode = tree.getroot()
# Ensure root element is <episodedetails>
if episode.tag != "episodedetails":
print(f"⚠️ NFO file {nfo_path.name} does not have episodedetails root element")
return False
# Remove any existing NFOGuard fields to re-add them at the bottom
nfoguard_fields = ["aired", "dateadded", "season", "episode"]
for field_name in nfoguard_fields:
existing_field = episode.find(field_name)
if existing_field is not None:
episode.remove(existing_field)
# Remove any existing NFOGuard comments
for child in episode:
if isinstance(child, ET.Comment):
if self.manager_brand in str(child):
episode.remove(child)
# Add NFOGuard fields at the bottom
# Basic episode info at the end
season_elem = ET.SubElement(episode, "season")
season_elem.text = str(season_num)
episode_elem = ET.SubElement(episode, "episode")
episode_elem.text = str(episode_num)
# Dates at the end
if aired:
aired_elem = ET.SubElement(episode, "aired")
aired_elem.text = aired[:10] if len(aired) >= 10 else aired
if dateadded:
dateadded_elem = ET.SubElement(episode, "dateadded")
dateadded_elem.text = dateadded
# Add NFOGuard comment at the bottom with other NFOGuard fields
nfoguard_comment = ET.Comment(f" Updated by {self.manager_brand} - Source: {source} ")
episode.append(nfoguard_comment)
# Write file with proper formatting
tree = ET.ElementTree(episode)
ET.indent(tree, space=" ", level=0)
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
print(f"✅ Successfully updated episode NFO (preserving name): {nfo_path.name}")
print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}")
return True
except Exception as e:
print(f"❌ Error updating episode NFO {nfo_path.name}: {e}")
return False
def find_episode_nfo_matching_video(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
"""Find episode NFO that matches the naming pattern of corresponding video files"""
if not season_dir.exists():
return None
# Find video files for this episode (check common patterns)
season_episode_pattern = f"S{season_num:02d}E{episode_num:02d}"
# Look for video files with season/episode pattern
video_extensions = ['.mkv', '.mp4', '.avi', '.m4v']
for ext in video_extensions:
video_files = season_dir.glob(f"*{season_episode_pattern}*{ext}")
for video_file in video_files:
# For each video file, look for an NFO with the same base name
nfo_path = video_file.with_suffix('.nfo')
if nfo_path.exists():
# Verify this NFO contains the right season/episode
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
if root.tag == "episodedetails":
season_elem = root.find("season")
episode_elem = root.find("episode")
if (season_elem is not None and episode_elem is not None and
season_elem.text and episode_elem.text):
try:
file_season = int(season_elem.text)
file_episode = int(episode_elem.text)
if file_season == season_num and file_episode == episode_num:
print(f"🎯 Found episode NFO matching video file: {nfo_path.name}")
return nfo_path
except ValueError:
continue
except (ET.ParseError, Exception):
continue
return None
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""
XML Processing Cache for NFOGuard
Optimizes NFO file parsing and manipulation
"""
import os
import time
from functools import lru_cache
from pathlib import Path
from typing import Dict, Optional, Any
from xml.etree import ElementTree as ET
from core.logging import _log
class XMLProcessingCache:
"""Caches parsed XML trees and NFO data with mtime invalidation"""
def __init__(self, max_cache_size: int = 500):
self.max_cache_size = max_cache_size
self._xml_tree_cache: Dict[str, tuple] = {} # (mtime, parsed_tree)
self._nfo_data_cache: Dict[str, tuple] = {} # (mtime, extracted_data)
self._cache_hits = 0
self._cache_misses = 0
def get_parsed_nfo(self, nfo_path: Path) -> Optional[ET.Element]:
"""Get parsed NFO file with caching"""
if not nfo_path.exists():
return None
cache_key = str(nfo_path)
current_mtime = nfo_path.stat().st_mtime
# Check cache
if cache_key in self._xml_tree_cache:
cached_mtime, cached_tree = self._xml_tree_cache[cache_key]
if cached_mtime == current_mtime:
self._cache_hits += 1
return cached_tree
# Parse XML
self._cache_misses += 1
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
# Cache the parsed tree
self._xml_tree_cache[cache_key] = (current_mtime, root)
self._cleanup_cache()
return root
except (ET.ParseError, OSError) as e:
_log("DEBUG", f"Error parsing NFO {nfo_path}: {e}")
return None
def extract_nfo_dates_cached(self, nfo_path: Path) -> Optional[Dict[str, Any]]:
"""Extract NFOGuard date fields from NFO with caching"""
if not nfo_path.exists():
return None
cache_key = f"dates:{nfo_path}"
current_mtime = nfo_path.stat().st_mtime
# Check cache
if cache_key in self._nfo_data_cache:
cached_mtime, cached_data = self._nfo_data_cache[cache_key]
if cached_mtime == current_mtime:
self._cache_hits += 1
return cached_data
# Extract data
self._cache_misses += 1
root = self.get_parsed_nfo(nfo_path)
if not root:
return None
dates_data = self._extract_nfoguard_dates(root)
# Cache the extracted data
self._nfo_data_cache[cache_key] = (current_mtime, dates_data)
self._cleanup_cache()
return dates_data
def _extract_nfoguard_dates(self, root: ET.Element) -> Optional[Dict[str, Any]]:
"""Extract NFOGuard date fields from parsed XML"""
try:
# Look for NFOGuard date fields
dateadded_elem = root.find(".//dateadded")
source_elem = root.find(".//nfoguard_source")
aired_elem = root.find(".//aired") or root.find(".//premiered")
if dateadded_elem is not None and dateadded_elem.text:
result = {
"dateadded": dateadded_elem.text.strip(),
"source": source_elem.text.strip() if source_elem is not None and source_elem.text else "unknown",
"aired": aired_elem.text.strip() if aired_elem is not None and aired_elem.text else None
}
# Check if title exists for episodes
title_elem = root.find(".//title")
if title_elem is not None and title_elem.text:
result["has_title"] = True
result["title"] = title_elem.text.strip()
else:
result["has_title"] = False
return result
except Exception as e:
_log("DEBUG", f"Error extracting NFOGuard dates from XML: {e}")
return None
def check_nfo_has_nfoguard_data(self, nfo_path: Path) -> bool:
"""Quickly check if NFO has NFOGuard data"""
cache_key = f"has_nfoguard:{nfo_path}"
if not nfo_path.exists():
return False
current_mtime = nfo_path.stat().st_mtime
# Check cache
if cache_key in self._nfo_data_cache:
cached_mtime, has_data = self._nfo_data_cache[cache_key]
if cached_mtime == current_mtime:
self._cache_hits += 1
return has_data
# Check for NFOGuard markers
self._cache_misses += 1
try:
# Quick text search first (faster than XML parsing)
with open(nfo_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
has_data = '<nfoguard_source>' in content or '<!-- NFOGuard -->' in content
# Cache result
self._nfo_data_cache[cache_key] = (current_mtime, has_data)
return has_data
except (OSError, UnicodeDecodeError) as e:
_log("DEBUG", f"Error checking NFOGuard markers in {nfo_path}: {e}")
return False
def _cleanup_cache(self):
"""Clean up caches when they get too large"""
if len(self._xml_tree_cache) > self.max_cache_size:
# Remove oldest 25% of entries
remove_count = self.max_cache_size // 4
old_keys = list(self._xml_tree_cache.keys())[:remove_count]
for key in old_keys:
del self._xml_tree_cache[key]
if len(self._nfo_data_cache) > self.max_cache_size:
remove_count = self.max_cache_size // 4
old_keys = list(self._nfo_data_cache.keys())[:remove_count]
for key in old_keys:
del self._nfo_data_cache[key]
def clear_cache(self):
"""Clear all XML caches"""
self._xml_tree_cache.clear()
self._nfo_data_cache.clear()
_log("INFO", "XML processing cache cleared")
def get_cache_stats(self) -> Dict[str, Any]:
"""Get XML cache performance statistics"""
total_requests = self._cache_hits + self._cache_misses
hit_rate = (self._cache_hits / total_requests * 100) if total_requests > 0 else 0
return {
"xml_cache_hits": self._cache_hits,
"xml_cache_misses": self._cache_misses,
"xml_hit_rate_percent": round(hit_rate, 2),
"xml_tree_cache_size": len(self._xml_tree_cache),
"nfo_data_cache_size": len(self._nfo_data_cache)
}
# Global XML cache instance
xml_cache = XMLProcessingCache()
@lru_cache(maxsize=1000)
def parse_imdb_from_filename(filename: str) -> Optional[str]:
"""Parse IMDb ID from filename with LRU caching"""
import re
match = re.search(r'\[imdb-([^]]+)\]', filename, re.IGNORECASE)
return match.group(1) if match else None
@lru_cache(maxsize=200)
def clean_title_for_search(title: str) -> str:
"""Clean title for searching with LRU caching"""
return title.lower().replace(" ", "").replace("-", "").replace("_", "")
def clear_xml_caches():
"""Clear all XML-related caches"""
xml_cache.clear_cache()
parse_imdb_from_filename.cache_clear()
clean_title_for_search.cache_clear()
_log("INFO", "All XML caches cleared")
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""
NFOGuard - Main application entry point
Automated NFO file management for Radarr and Sonarr
"""
import os
import signal
import sys
import uvicorn
from datetime import datetime, timezone
from fastapi import FastAPI
# Import configuration first
from config.settings import config
# Import other components
from core.database import NFOGuardDatabase
from core.nfo_manager import NFOManager
from core.path_mapper import PathMapper
from processors.tv_processor import TVProcessor
from processors.movie_processor import MovieProcessor
from webhooks.webhook_batcher import WebhookBatcher
# ---------------------------
# Version and Build Info
# ---------------------------
def get_version() -> str:
"""Get version from VERSION file with build information"""
try:
with open("VERSION", "r", encoding="utf-8") as f:
version = f.read().strip()
except FileNotFoundError:
version = "development"
# Add build source suffix for identification
build_source = os.environ.get("BUILD_SOURCE", "")
if build_source == "gitea":
if "gitea" not in version: # Don't double-add gitea suffix
version = f"{version}-gitea"
return version
# ---------------------------
# Application Setup
# ---------------------------
version = get_version()
app = FastAPI(
title="NFOGuard",
description="Webhook server for preserving media import dates",
version=version
)
start_time = datetime.now(timezone.utc)
# Initialize components
db = NFOGuardDatabase(config.db_path)
nfo_manager = NFOManager(config.manager_brand, config.debug)
path_mapper = PathMapper(config)
tv_processor = TVProcessor(db, nfo_manager, path_mapper)
movie_processor = MovieProcessor(db, nfo_manager, path_mapper)
batcher = WebhookBatcher(nfo_manager)
# Import and register routes
from api.routes import register_routes
register_routes(app, tv_processor, movie_processor, batcher, db, start_time, version)
# ---------------------------
# Signal Handlers
# ---------------------------
def signal_handler(signum, frame):
"""Handle shutdown signals gracefully"""
print(f"Received signal {signum}, shutting down gracefully...")
sys.exit(0)
# ---------------------------
# Main Entry Point
# ---------------------------
if __name__ == "__main__":
# Register signal handlers for graceful shutdown
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
print("Starting NFOGuard")
print(f"Version: {version}")
print(f"TV paths: {[str(p) for p in config.tv_paths]}")
print(f"Movie paths: {[str(p) for p in config.movie_paths]}")
print(f"Database: {config.db_path}")
print(f"Config: manage_nfo={config.manage_nfo}, fix_mtimes={config.fix_dir_mtimes}")
print(f"Movie priority: {config.movie_priority}")
try:
uvicorn.run(
app,
host="0.0.0.0",
port=int(os.environ.get("PORT", "8080")),
reload=False
)
except KeyboardInterrupt:
print("NFOGuard stopped by user")
except Exception as e:
print(f"NFOGuard crashed: {e}")
sys.exit(1)
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env python3
"""
Processing components for NFOGuard
"""
from .tv_processor import TVProcessor
from .movie_processor import MovieProcessor
__all__ = ['TVProcessor', 'MovieProcessor']
+419
View File
@@ -0,0 +1,419 @@
#!/usr/bin/env python3
"""
Movie processing logic for NFOGuard
"""
import os
import glob
from pathlib import Path
from typing import Optional, Dict, Any, List
from datetime import datetime, timezone
# Import core components
from core.database import NFOGuardDatabase
from core.nfo_manager import NFOManager
from core.path_mapper import PathMapper
from core.logging import _log
from core.fs_cache import fs_cache, extract_imdb_from_path
from clients.radarr_client import RadarrClient
from clients.external_clients import ExternalClientManager
from config.settings import config
class MovieProcessor:
"""Handles movie processing and validation"""
def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper):
self.db = db
self.nfo_manager = nfo_manager
self.path_mapper = path_mapper
self.radarr = RadarrClient(
os.environ.get("RADARR_URL", ""),
os.environ.get("RADARR_API_KEY", "")
)
self.external_clients = ExternalClientManager()
def find_movie_path(self, movie_title: str, imdb_id: str, radarr_path: str = None) -> Optional[Path]:
"""Find movie directory path"""
# Try webhook path first
if radarr_path:
container_path = self.path_mapper.radarr_path_to_container_path(radarr_path)
path_obj = Path(container_path)
if path_obj.exists():
return path_obj
# Search by IMDb ID or title
for media_path in config.movie_paths:
if not media_path.exists():
continue
# Search by IMDb ID
if imdb_id:
pattern = str(media_path / f"*[imdb-{imdb_id}]*")
matches = glob.glob(pattern)
if matches:
return Path(matches[0])
# Search by title
if movie_title:
title_clean = movie_title.lower().replace(" ", "").replace("-", "")
for item in media_path.iterdir():
if item.is_dir() and "[imdb-" in item.name.lower():
item_clean = item.name.lower().replace(" ", "").replace("-", "")
if title_clean in item_clean:
return item
return None
def process_movie(self, movie_path: Path, webhook_mode: bool = False) -> None:
"""Process a movie directory"""
imdb_id = self.nfo_manager.find_movie_imdb_id(movie_path)
if not imdb_id:
_log("ERROR", f"No IMDb ID found in movie directory, filenames, or NFO file: {movie_path}")
return
# Handle TMDB ID fallback case
is_tmdb_fallback = imdb_id.startswith("tmdb-")
if is_tmdb_fallback:
_log("INFO", f"Processing movie: {movie_path.name} (TMDB: {imdb_id})")
else:
_log("INFO", f"Processing movie: {movie_path.name} (IMDb: {imdb_id})")
# Update database
self.db.upsert_movie(imdb_id, str(movie_path))
# Check for video files
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
has_video = any(f.is_file() and f.suffix.lower() in video_exts for f in movie_path.iterdir())
if not has_video:
_log("WARNING", f"No video files found in: {movie_path}")
self.db.upsert_movie_dates(imdb_id, None, None, None, False)
return
# TIER 1: Check if NFO file already has NFOGuard data (fastest - no DB or API calls)
nfo_path = movie_path / "movie.nfo"
nfo_data = self.nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
if nfo_data:
_log("INFO", f"🚀 Using existing NFOGuard data from NFO file: {nfo_data['dateadded']} (source: {nfo_data['source']})")
dateadded = nfo_data["dateadded"]
source = nfo_data["source"]
released = nfo_data.get("released")
# Update file mtimes if enabled (NFO is already correct)
if config.fix_dir_mtimes and dateadded:
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [nfo-only]")
return
# TIER 1.5: Special handling for TMDB-only movies - extract dates from existing NFO
if is_tmdb_fallback:
tmdb_nfo_data = self._extract_dates_from_tmdb_nfo(nfo_path)
if tmdb_nfo_data:
_log("INFO", f"🎬 Using TMDB data from existing NFO file: {tmdb_nfo_data['dateadded']} (source: {tmdb_nfo_data['source']})")
dateadded = tmdb_nfo_data["dateadded"]
source = tmdb_nfo_data["source"]
released = tmdb_nfo_data.get("released")
# Create NFO with NFOGuard fields added
if config.manage_nfo:
self.nfo_manager.create_movie_nfo(
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
)
# Update file mtimes
if config.fix_dir_mtimes and dateadded:
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
# Save to database
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [tmdb-nfo-enhanced]")
return
# TIER 2: Check database for existing movie data
existing_movie = self.db.get_movie_dates(imdb_id)
if existing_movie and existing_movie.get("dateadded") and existing_movie.get("source") != "no_valid_date_source":
_log("INFO", f"✅ Using complete database data: {existing_movie['dateadded']} (source: {existing_movie['source']})")
# Still create NFO and update files but skip API queries
dateadded = existing_movie["dateadded"]
source = existing_movie["source"]
released = existing_movie.get("released")
if config.manage_nfo and dateadded:
self.nfo_manager.create_movie_nfo(
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
)
if config.fix_dir_mtimes and dateadded:
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [database-only]")
return
# TIER 3: Full processing with API calls (slowest)
_log("DEBUG", f"Movie requires full processing - querying external APIs")
# Get movie dates from various sources
movie_dates = self.get_movie_dates(imdb_id, movie_path, webhook_mode=webhook_mode,
fallback_to_tmdb=(not is_tmdb_fallback))
dateadded = movie_dates.get("dateadded")
released = movie_dates.get("released")
source = movie_dates.get("source", "no_valid_date_source")
# Create NFO
if config.manage_nfo and dateadded:
self.nfo_manager.create_movie_nfo(
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
)
# Update file mtimes
if config.fix_dir_mtimes and dateadded:
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
# Save to database
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [full-processing]")
def _extract_dates_from_tmdb_nfo(self, nfo_path: Path) -> Optional[Dict]:
"""Extract dates from existing TMDB NFO file"""
if not nfo_path.exists():
return None
try:
with open(nfo_path, 'r', encoding='utf-8') as f:
content = f.read()
# Look for dateadded and premiered elements
dateadded = None
released = None
# Extract dateadded
import re
dateadded_match = re.search(r'<dateadded>([^<]+)</dateadded>', content)
if dateadded_match:
dateadded = dateadded_match.group(1).strip()
# Extract premiered (release date)
premiered_match = re.search(r'<premiered>([^<]+)</premiered>', content)
if premiered_match:
released = premiered_match.group(1).strip()
if dateadded:
return {
"dateadded": dateadded,
"released": released,
"source": "tmdb:nfo.dateadded"
}
except Exception as e:
_log("DEBUG", f"Could not extract dates from TMDB NFO: {e}")
return None
def get_movie_dates(self, imdb_id: str, movie_path: Path = None, webhook_mode: bool = False, fallback_to_tmdb: bool = True) -> Dict[str, Any]:
"""Get movie dates from various sources with priority system"""
# Initialize result
result = {
"dateadded": None,
"released": None,
"source": "no_valid_date_source"
}
# Check if this is a TMDB ID
if imdb_id.startswith("tmdb-"):
return self._get_tmdb_movie_dates(imdb_id, movie_path)
# Priority 1: Radarr import history (most accurate for dateadded)
if self.radarr.enabled and config.movie_priority >= 1:
try:
movie_data = self.radarr.get_movie_by_imdb(imdb_id)
if movie_data:
# Get import history for accurate dateadded
movie_id = movie_data.get("id")
if movie_id:
import_history = self.radarr.get_movie_import_history(movie_id)
if import_history:
result["dateadded"] = self._parse_date_to_iso(import_history)
result["source"] = "radarr:history.import"
_log("INFO", f"Found Radarr import date: {result['dateadded']}")
# Get release date from Radarr
release_date = movie_data.get("digitalRelease") or movie_data.get("physicalRelease") or movie_data.get("inCinemas")
if release_date:
result["released"] = self._parse_date_to_iso(release_date)
# If we have dateadded from import, we're done
if result["dateadded"]:
return result
# Fallback to using dateadded from Radarr API
radarr_dateadded = movie_data.get("added")
if radarr_dateadded:
result["dateadded"] = self._parse_date_to_iso(radarr_dateadded)
result["source"] = "radarr:movie.added"
return result
except Exception as e:
_log("DEBUG", f"Radarr query failed for {imdb_id}: {e}")
# Priority 2: External APIs (TMDB, OMDb, etc.) for release date
if config.movie_priority >= 2 and fallback_to_tmdb:
try:
# Try TMDB
if self.external_clients.tmdb.enabled:
tmdb_data = self.external_clients.tmdb.find_by_imdb(imdb_id)
if tmdb_data:
release_date = tmdb_data.get("release_date")
if release_date:
released_iso = self._parse_date_to_iso(release_date)
result["released"] = released_iso
# Use release date as dateadded fallback
if not result["dateadded"]:
result["dateadded"] = released_iso
result["source"] = "tmdb:release_date"
return result
# Try OMDb as additional fallback
if self.external_clients.omdb.enabled:
omdb_data = self.external_clients.omdb.get_by_imdb(imdb_id)
if omdb_data and omdb_data.get("Released"):
released_date = self._parse_omdb_date(omdb_data["Released"])
if released_date:
result["released"] = released_date
if not result["dateadded"]:
result["dateadded"] = released_date
result["source"] = "omdb:released"
return result
except Exception as e:
_log("DEBUG", f"External API query failed for {imdb_id}: {e}")
# Priority 3: File system dates as absolute fallback
if not result["dateadded"] and movie_path and config.movie_priority >= 3:
try:
# Use directory creation time as last resort
if movie_path.exists():
dir_stat = movie_path.stat()
# Use the earliest of creation or modification time
earliest_time = min(dir_stat.st_ctime, dir_stat.st_mtime)
fs_date = datetime.fromtimestamp(earliest_time, tz=timezone.utc)
result["dateadded"] = fs_date.isoformat(timespec="seconds")
result["source"] = "filesystem:dir.ctime"
_log("DEBUG", f"Using filesystem date as fallback: {result['dateadded']}")
return result
except Exception as e:
_log("DEBUG", f"Filesystem date extraction failed: {e}")
return result
def _get_tmdb_movie_dates(self, tmdb_id: str, movie_path: Path = None) -> Dict[str, Any]:
"""Get movie dates for TMDB-only movies"""
result = {
"dateadded": None,
"released": None,
"source": "no_valid_date_source"
}
# Extract TMDB ID from the string (format: "tmdb-12345")
try:
tmdb_numeric_id = tmdb_id.replace("tmdb-", "")
if self.external_clients.tmdb.enabled:
tmdb_data = self.external_clients.tmdb.get_movie_details(tmdb_numeric_id)
if tmdb_data:
release_date = tmdb_data.get("release_date")
if release_date:
released_iso = self._parse_date_to_iso(release_date)
result["released"] = released_iso
result["dateadded"] = released_iso # Use release date as dateadded
result["source"] = "tmdb:release_date"
return result
except Exception as e:
_log("DEBUG", f"TMDB query failed for {tmdb_id}: {e}")
# Fallback to filesystem date for TMDB movies
if movie_path and movie_path.exists():
try:
dir_stat = movie_path.stat()
earliest_time = min(dir_stat.st_ctime, dir_stat.st_mtime)
fs_date = datetime.fromtimestamp(earliest_time, tz=timezone.utc)
result["dateadded"] = fs_date.isoformat(timespec="seconds")
result["source"] = "filesystem:dir.ctime"
except Exception as e:
_log("DEBUG", f"Filesystem date extraction failed: {e}")
return result
def _parse_date_to_iso(self, date_str: str) -> Optional[str]:
"""Parse date string to ISO format"""
if not date_str:
return None
try:
# Handle different date formats
if len(date_str) == 10 and date_str[4] == "-": # YYYY-MM-DD
dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
else: # ISO format with timezone
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc)
return dt.isoformat(timespec="seconds")
except Exception:
return None
def _parse_omdb_date(self, date_str: str) -> Optional[str]:
"""Parse OMDb date format (e.g., '25 Dec 2020')"""
if not date_str or date_str == "N/A":
return None
try:
# Parse OMDb date format: "25 Dec 2020"
dt = datetime.strptime(date_str, "%d %b %Y").replace(tzinfo=timezone.utc)
return dt.isoformat(timespec="seconds")
except Exception:
return None
def validate_batch_movies(self, movie_paths: List[Path]) -> Dict[str, Any]:
"""Validate and process a batch of movies"""
results = {
"processed": 0,
"errors": [],
"skipped": 0
}
_log("INFO", f"Starting batch processing of {len(movie_paths)} movies")
for i, movie_path in enumerate(movie_paths, 1):
try:
_log("INFO", f"Processing movie {i}/{len(movie_paths)}: {movie_path.name}")
# Check if directory exists and has video files
if not movie_path.exists() or not movie_path.is_dir():
results["errors"].append(f"Path does not exist or is not a directory: {movie_path}")
continue
# Check for IMDb ID
imdb_id = self.nfo_manager.find_movie_imdb_id(movie_path)
if not imdb_id:
results["skipped"] += 1
_log("WARNING", f"Skipping {movie_path.name}: No IMDb ID found")
continue
# Process the movie
self.process_movie(movie_path)
results["processed"] += 1
except Exception as e:
error_msg = f"Error processing {movie_path}: {str(e)}"
results["errors"].append(error_msg)
_log("ERROR", error_msg)
continue
_log("INFO", f"Batch processing complete: {results['processed']} processed, "
f"{results['skipped']} skipped, {len(results['errors'])} errors")
return results
+509
View File
@@ -0,0 +1,509 @@
#!/usr/bin/env python3
"""
TV Show processing logic for NFOGuard
"""
import os
import glob
import re
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple
from datetime import datetime, timezone, timedelta
# Import core components
from core.database import NFOGuardDatabase
from core.nfo_manager import NFOManager
from core.path_mapper import PathMapper
from core.logging import _log
from core.fs_cache import fs_cache, parse_episode_from_filename, extract_imdb_from_path
from clients.sonarr_client import SonarrClient
from clients.external_clients import ExternalClientManager
from config.settings import config
class TVProcessor:
"""Handles TV series processing"""
def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper):
self.db = db
self.nfo_manager = nfo_manager
self.path_mapper = path_mapper
self.sonarr = SonarrClient(
os.environ.get("SONARR_URL", ""),
os.environ.get("SONARR_API_KEY", "")
)
self.external_clients = ExternalClientManager()
def find_series_path(self, series_title: str, imdb_id: str, sonarr_path: str = None) -> Optional[Path]:
"""Find series directory path"""
# Try webhook path first
if sonarr_path:
container_path = self.path_mapper.sonarr_path_to_container_path(sonarr_path)
path_obj = Path(container_path)
if path_obj.exists():
return path_obj
# Search by IMDb ID or title
for media_path in config.tv_paths:
if not media_path.exists():
continue
# Search by IMDb ID
if imdb_id:
pattern = str(media_path / f"*[imdb-{imdb_id}]*")
matches = glob.glob(pattern)
if matches:
return Path(matches[0])
# Search by title
if series_title:
title_clean = series_title.lower().replace(" ", "").replace("-", "")
for item in media_path.iterdir():
if item.is_dir() and "[imdb-" in item.name.lower():
item_clean = item.name.lower().replace(" ", "").replace("-", "")
if title_clean in item_clean:
return item
return None
def process_series(self, series_path: Path) -> None:
"""Process a TV series 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
_log("INFO", f"Processing TV series: {series_path.name}")
# Update database
self.db.upsert_series(imdb_id, str(series_path))
# Find video files
disk_episodes = self._find_disk_episodes(series_path)
_log("INFO", f"Found {len(disk_episodes)} episodes on disk")
# Get episode dates
episode_dates = self._gather_episode_dates(series_path, imdb_id, disk_episodes)
# Process episodes
for (season, episode), (aired, dateadded, source) in episode_dates.items():
if (season, episode) in disk_episodes:
# Create or update NFO
if config.manage_nfo:
season_dir = series_path / config.tv_season_dir_format.format(season=season)
# Check if this is an existing NFO that needs updating while preserving name
if source == "nfo_update_required":
# Find the existing NFO file and update it in place
existing_nfo = self.nfo_manager.find_episode_nfo_matching_video(season_dir, season, episode)
if existing_nfo:
# Update existing NFO while preserving its filename
self.nfo_manager.update_episode_nfo_preserving_name(
existing_nfo, season, episode, aired, dateadded, "file_update"
)
else:
# Fallback to standard creation if NFO not found
self.nfo_manager.create_episode_nfo(
season_dir,
season, episode, aired, dateadded, source, config.lock_metadata
)
else:
# Standard NFO creation for new episodes
self.nfo_manager.create_episode_nfo(
season_dir,
season, episode, aired, dateadded, source, config.lock_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/tvshow NFOs
if config.manage_nfo:
seasons_processed = set()
for (season, episode) in disk_episodes.keys():
if season not in seasons_processed:
season_dir = series_path / config.tv_season_dir_format.format(season=season)
self.nfo_manager.create_season_nfo(season_dir, season)
seasons_processed.add(season)
# Get TVDB ID for better Emby compatibility
tvdb_id = self.external_clients.get_tvdb_series_id(imdb_id)
self.nfo_manager.create_tvshow_nfo(series_path, imdb_id, tvdb_id)
_log("INFO", f"Completed processing TV series: {series_path.name}")
def _find_disk_episodes(self, series_path: Path) -> Dict[Tuple[int, int], List[Path]]:
"""Find all episode video files on disk"""
disk_episodes = {}
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
# Use cached directory listing
season_dirs = fs_cache.get_directory_contents(series_path)
for season_dir in season_dirs:
if not (season_dir.is_dir() and season_dir.name.lower().startswith(config.tv_season_dir_pattern)):
continue
try:
# Extract season number from directory name
# Handle formats like "Season 01", "S01", "Season01", etc.
dir_name = season_dir.name.lower()
if config.tv_season_dir_pattern in dir_name:
# Extract everything after the pattern
season_part = dir_name[len(config.tv_season_dir_pattern):].strip()
else:
continue
season_num = int(season_part)
except (ValueError, IndexError):
continue
# Use cached video file discovery
video_files = fs_cache.find_video_files(season_dir)
for video_file in video_files:
# Use cached episode parsing
episode_info = parse_episode_from_filename(video_file.name)
if episode_info:
file_season, file_episode = episode_info
key = (season_num, file_episode) # Use directory season number
if key not in disk_episodes:
disk_episodes[key] = []
disk_episodes[key].append(video_file)
return disk_episodes
def _gather_episode_dates(self, series_path: Path, imdb_id: str, disk_episodes: Dict) -> Dict:
"""Gather episode dates from various sources"""
episode_dates = {}
# Check cache first
cached_episodes = self.db.get_series_episodes(imdb_id, has_video_file_only=True)
for ep in cached_episodes:
key = (ep["season"], ep["episode"])
if key in disk_episodes: # Only use cached data for episodes we have
episode_dates[key] = (ep["aired"], ep["dateadded"], ep["source"])
# Check for existing NFO files that match video file names (preserve long names)
nfo_episodes_found = 0
for (season_num, episode_num) in disk_episodes.keys():
if (season_num, episode_num) not in episode_dates:
# Check if this episode has an NFO file matching its video file name
season_dir = series_path / config.tv_season_dir_format.format(season=season_num)
existing_nfo = self.nfo_manager.find_episode_nfo_matching_video(season_dir, season_num, episode_num)
if existing_nfo:
# Force processing of this episode to update NFO with NFOGuard data
episode_dates[(season_num, episode_num)] = (None, None, "nfo_update_required")
nfo_episodes_found += 1
if nfo_episodes_found > 0:
_log("INFO", f"Found {nfo_episodes_found} episodes with existing NFO files to update (preserving names)")
# Find missing episodes (not in cache and no existing NFO)
cached_keys = set(episode_dates.keys())
missing_keys = set(disk_episodes.keys()) - cached_keys
if not missing_keys:
if nfo_episodes_found == 0:
_log("INFO", "All episodes found in cache")
return episode_dates
_log("INFO", f"Querying APIs for {len(missing_keys)} missing episodes")
# Query Sonarr for missing episodes
if self.sonarr.enabled:
series = self.sonarr.series_by_imdb(imdb_id)
if series:
series_id = series.get("id")
if series_id:
episodes = self.sonarr.episodes_for_series(series_id)
for ep in episodes:
season_num = ep.get("seasonNumber")
episode_num = ep.get("episodeNumber")
if not isinstance(season_num, int) or not isinstance(episode_num, int):
continue
key = (season_num, episode_num)
if key not in missing_keys:
continue
# Get dates
aired = self._parse_date_to_iso(ep.get("airDateUtc"))
dateadded = None
source = "sonarr:episode.airDateUtc"
# Try to get import history
episode_id = ep.get("id")
if episode_id:
import_date = self.sonarr.get_episode_import_history(episode_id)
if import_date:
dateadded = self._parse_date_to_iso(import_date)
source = "sonarr:history.import"
if not dateadded:
dateadded = aired
if aired or dateadded:
episode_dates[key] = (aired, dateadded, source)
# Fill remaining gaps with external APIs
remaining_keys = missing_keys - set(episode_dates.keys())
if remaining_keys and self.external_clients.tmdb.enabled:
tmdb_movie = self.external_clients.tmdb.find_by_imdb(imdb_id)
if tmdb_movie:
tv_id = tmdb_movie.get("id")
if tv_id:
seasons_needed = set(season for season, episode in remaining_keys)
for season_num in seasons_needed:
tmdb_episodes = self.external_clients.tmdb.get_tv_season_episodes(tv_id, season_num)
for ep_num, air_date in tmdb_episodes.items():
key = (season_num, ep_num)
if key in remaining_keys:
aired = self._parse_date_to_iso(air_date)
episode_dates[key] = (aired, aired, "tmdb:air_date")
return episode_dates
def _parse_date_to_iso(self, date_str: str) -> Optional[str]:
"""Parse date string to ISO format"""
if not date_str:
return None
try:
if len(date_str) == 10 and date_str[4] == "-":
dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
else:
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc)
return dt.isoformat(timespec="seconds")
except Exception:
return None
def process_webhook_episodes(self, series_path: Path, episodes_data: List[Dict]) -> None:
"""Process specific episodes from webhook data"""
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
_log("INFO", f"Processing {len(episodes_data)} episodes from webhook for series: {series_path.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)
for episode_data in episodes_data:
season_num = episode_data.get("seasonNumber")
episode_num = episode_data.get("episodeNumber")
if not isinstance(season_num, int) or not isinstance(episode_num, int):
continue
_log("INFO", f"Processing webhook episode S{season_num:02d}E{episode_num:02d}")
# Find episode file
season_dir = series_path / config.tv_season_dir_format.format(season=season_num)
if not season_dir.exists():
_log("WARNING", f"Season directory not found: {season_dir}")
continue
# Find video file for this episode
episode_file = self._find_episode_video_file(season_dir, season_num, episode_num)
if not episode_file:
_log("WARNING", f"Video file not found for S{season_num:02d}E{episode_num:02d}")
continue
# Process this specific episode
self.process_episode_file(series_path, season_dir, episode_file)
def _find_episode_video_file(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
"""Find video file for specific episode"""
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
episode_pattern = f"S{season_num:02d}E{episode_num:02d}"
for video_file in season_dir.iterdir():
if (video_file.is_file() and
video_file.suffix.lower() in video_exts and
episode_pattern.upper() in video_file.name.upper()):
return video_file
return None
def _get_sonarr_series_metadata(self, imdb_id: str) -> Optional[Dict]:
"""Get series metadata from Sonarr"""
if not self.sonarr.enabled:
return None
series = self.sonarr.series_by_imdb(imdb_id)
if not series:
return None
series_id = series.get("id")
if not series_id:
return None
# Get episodes for this series
episodes = self.sonarr.episodes_for_series(series_id)
return {
"series": series,
"episodes": {(ep.get("seasonNumber"), ep.get("episodeNumber")): ep for ep in episodes}
}
def _get_episode_metadata(self, series_metadata: Optional[Dict], season_num: int, episode_num: int, season_path: Path) -> Dict:
"""Get enhanced episode metadata"""
metadata = {}
# Try to get title from Sonarr first
if series_metadata and "episodes" in series_metadata:
episode_data = series_metadata["episodes"].get((season_num, episode_num))
if episode_data and episode_data.get("title"):
metadata["title"] = episode_data["title"]
# Fallback to extracting title from filename
if "title" not in metadata:
filename_title = self._extract_title_from_filename(season_path, season_num, episode_num)
if filename_title:
metadata["title"] = filename_title
return metadata
def _extract_title_from_filename(self, season_path: Path, season_num: int, episode_num: int) -> Optional[str]:
"""Extract episode title from video filename"""
episode_file = self._find_episode_video_file(season_path, season_num, episode_num)
if not episode_file:
return None
filename = episode_file.stem
# Pattern: Series.S01E01.Title.Here.RESOLUTION.etc
# Look for everything after S##E## until common quality indicators
episode_pattern = f"S{season_num:02d}E{episode_num:02d}"
# Find the episode marker
episode_index = filename.upper().find(episode_pattern.upper())
if episode_index == -1:
return None
# Extract everything after the episode marker
after_episode = filename[episode_index + len(episode_pattern):]
# Split by common separators
parts = re.split(r'[.\-_\s]+', after_episode)
# Stop at common quality/release indicators
stop_words = {
'720p', '1080p', '4k', '2160p', 'hdtv', 'webrip', 'bluray', 'dvdrip',
'web-dl', 'brrip', 'hdcam', 'hdts', 'cam', 'ts', 'tc', 'r5', 'dvdscr',
'x264', 'x265', 'h264', 'h265', 'xvid', 'divx', 'aac', 'mp3', 'ac3',
'dts', 'flac', 'atmos', 'truehd', 'dd', 'ddp', 'eac3'
}
title_parts = []
for part in parts:
if not part:
continue
if part.lower() in stop_words:
break
# Stop at year patterns (e.g., 2023)
if re.match(r'^\d{4}$', part):
break
title_parts.append(part)
if title_parts:
return ' '.join(title_parts)
return None
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, season_path) if series_metadata else self._get_episode_metadata(None, season_num, episode_num, season_path)
# 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 _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]:
"""Parse season and episode numbers from filename"""
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))
return None
def _get_single_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict]) -> Tuple[Optional[str], Optional[str], str]:
"""Get dates for a single episode"""
# Check database first
existing_episode = self.db.get_episode_date(imdb_id, season_num, episode_num)
if existing_episode and existing_episode.get("dateadded"):
return existing_episode.get("aired"), existing_episode["dateadded"], existing_episode["source"]
# Try Sonarr metadata
if series_metadata and "episodes" in series_metadata:
episode_data = series_metadata["episodes"].get((season_num, episode_num))
if episode_data:
aired = self._parse_date_to_iso(episode_data.get("airDateUtc"))
dateadded = aired # Use air date as fallback
source = "sonarr:episode.airDateUtc"
# Try to get import history
episode_id = episode_data.get("id")
if episode_id and self.sonarr.enabled:
import_date = self.sonarr.get_episode_import_history(episode_id)
if import_date:
dateadded = self._parse_date_to_iso(import_date)
source = "sonarr:history.import"
return aired, dateadded, source
# Fallback to external APIs
if self.external_clients.tmdb.enabled:
tmdb_movie = self.external_clients.tmdb.find_by_imdb(imdb_id)
if tmdb_movie:
tv_id = tmdb_movie.get("id")
if tv_id:
tmdb_episodes = self.external_clients.tmdb.get_tv_season_episodes(tv_id, season_num)
air_date = tmdb_episodes.get(episode_num)
if air_date:
aired = self._parse_date_to_iso(air_date)
return aired, aired, "tmdb:air_date"
return None, None, "no_valid_date_source"
View File
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env python3
"""
Webhook processing components for NFOGuard
"""
from .webhook_batcher import WebhookBatcher
__all__ = ['WebhookBatcher']
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""
Webhook batch processing logic for NFOGuard
"""
import asyncio
import threading
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, Set, List, Any, Optional
from core.logging import _log
from core.nfo_manager import NFOManager
from config.settings import config
class WebhookBatcher:
"""Batches webhook events to avoid processing storms"""
def __init__(self, nfo_manager: NFOManager):
self.pending: Dict[str, Dict] = {}
self.timers: Dict[str, threading.Timer] = {}
self.processing: Set[str] = set()
self.lock = threading.Lock()
self.executor = ThreadPoolExecutor(max_workers=config.max_concurrent)
self.nfo_manager = nfo_manager
def add_webhook(self, key: str, webhook_data: Dict, media_type: str):
"""Add webhook to batch queue"""
with self.lock:
if key in self.timers:
self.timers[key].cancel()
webhook_data['media_type'] = media_type
self.pending[key] = webhook_data
_log("INFO", f"Batched {media_type} webhook for {key}")
_log("DEBUG", f"Batch added - key: {key}, media_type: {media_type}, timer scheduled for {config.batch_delay}s")
timer = threading.Timer(config.batch_delay, self._process_item, args=[key])
self.timers[key] = timer
timer.start()
def _process_item(self, key: str):
"""Process a batched item"""
with self.lock:
if key in self.processing or key not in self.pending:
return
self.processing.add(key)
webhook_data = self.pending.pop(key)
self.timers.pop(key, None)
try:
self.executor.submit(self._process_sync, key, webhook_data)
except Exception as e:
_log("ERROR", f"Error submitting processing for {key}: {e}")
with self.lock:
self.processing.discard(key)
def _process_sync(self, key: str, webhook_data: Dict):
"""Synchronous processing of webhook data with validation"""
try:
media_type = webhook_data.get('media_type')
path_str = webhook_data.get('path')
_log("DEBUG", f"Processing batch item: key={key}, media_type={media_type}, path={path_str}")
if not path_str:
_log("ERROR", f"No path found in webhook data for key: {key}")
return
path = Path(path_str)
if not path.exists():
_log("WARNING", f"Path does not exist: {path}")
return
# Import processors here to avoid circular imports
from processors.tv_processor import TVProcessor
from processors.movie_processor import MovieProcessor
from core.database import NFOGuardDatabase
from core.path_mapper import PathMapper
# Initialize components
db = NFOGuardDatabase(config.db_path)
path_mapper = PathMapper(config)
if media_type == 'tv':
processor = TVProcessor(db, self.nfo_manager, path_mapper)
# Check if this is a series or episode-specific processing
episodes_data = webhook_data.get('episodes', [])
if episodes_data:
processor.process_webhook_episodes(path, episodes_data)
else:
processor.process_series(path)
elif media_type == 'movie':
processor = MovieProcessor(db, self.nfo_manager, path_mapper)
processor.process_movie(path, webhook_mode=True)
else:
_log("ERROR", f"Unknown media type: {media_type}")
return
_log("INFO", f"Completed processing {media_type} webhook for: {path.name}")
except Exception as e:
_log("ERROR", f"Error processing batch item {key}: {e}")
finally:
with self.lock:
self.processing.discard(key)
async def queue_sonarr_webhook(self, webhook_data: Dict[str, Any]) -> None:
"""Queue Sonarr webhook for batch processing"""
try:
event_type = webhook_data.get('eventType')
series = webhook_data.get('series', {})
episodes = webhook_data.get('episodes', [])
if not series:
_log("WARNING", "No series data in Sonarr webhook")
return
series_title = series.get('title', 'Unknown')
imdb_id = series.get('imdbId')
series_path = series.get('path')
_log("INFO", f"Queuing Sonarr {event_type} webhook: {series_title}")
# Create batch key (use IMDb ID if available, otherwise title)
batch_key = imdb_id if imdb_id else f"series_{series_title.replace(' ', '_')}"
# Find series path
from processors.tv_processor import TVProcessor
from core.database import NFOGuardDatabase
from core.path_mapper import PathMapper
db = NFOGuardDatabase(config.db_path)
path_mapper = PathMapper(config)
tv_processor = TVProcessor(db, self.nfo_manager, path_mapper)
found_path = tv_processor.find_series_path(series_title, imdb_id, series_path)
if not found_path:
_log("WARNING", f"Could not find series path for: {series_title}")
return
# Prepare webhook data for processing
batch_data = {
'path': str(found_path),
'series': series,
'episodes': episodes,
'event_type': event_type
}
# Add to batch
self.add_webhook(batch_key, batch_data, 'tv')
except Exception as e:
_log("ERROR", f"Error queuing Sonarr webhook: {e}")
async def queue_radarr_webhook(self, webhook_data: Dict[str, Any]) -> None:
"""Queue Radarr webhook for batch processing"""
try:
event_type = webhook_data.get('eventType')
movie = webhook_data.get('movie', {})
if not movie:
_log("WARNING", "No movie data in Radarr webhook")
return
movie_title = movie.get('title', 'Unknown')
imdb_id = movie.get('imdbId')
movie_path = movie.get('folderPath')
_log("INFO", f"Queuing Radarr {event_type} webhook: {movie_title}")
# Create batch key (use IMDb ID if available, otherwise title)
batch_key = imdb_id if imdb_id else f"movie_{movie_title.replace(' ', '_')}"
# Find movie path
from processors.movie_processor import MovieProcessor
from core.database import NFOGuardDatabase
from core.path_mapper import PathMapper
db = NFOGuardDatabase(config.db_path)
path_mapper = PathMapper(config)
movie_processor = MovieProcessor(db, self.nfo_manager, path_mapper)
found_path = movie_processor.find_movie_path(movie_title, imdb_id, movie_path)
if not found_path:
_log("WARNING", f"Could not find movie path for: {movie_title}")
return
# Prepare webhook data for processing
batch_data = {
'path': str(found_path),
'movie': movie,
'event_type': event_type
}
# Add to batch
self.add_webhook(batch_key, batch_data, 'movie')
except Exception as e:
_log("ERROR", f"Error queuing Radarr webhook: {e}")
def get_pending_count(self) -> int:
"""Get count of pending webhooks"""
with self.lock:
return len(self.pending)
def get_processing_count(self) -> int:
"""Get count of currently processing webhooks"""
with self.lock:
return len(self.processing)
def shutdown(self):
"""Shutdown the webhook batcher"""
_log("INFO", "Shutting down WebhookBatcher...")
# Cancel all pending timers
with self.lock:
for timer in self.timers.values():
timer.cancel()
self.timers.clear()
# Shutdown executor
self.executor.shutdown(wait=True)
_log("INFO", "WebhookBatcher shutdown complete")