Compare commits
11 Commits
dev
...
b63c188813
| Author | SHA1 | Date | |
|---|---|---|---|
| b63c188813 | |||
| 16bcf89ddd | |||
| 0b17ee54fd | |||
| 4980d1ae04 | |||
| 24d94306c1 | |||
| 94590ac070 | |||
| 714fceec98 | |||
| 70302837a7 | |||
| 740f912b57 | |||
| bcf428fbc0 | |||
| 5adc596077 |
@@ -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
@@ -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)}")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
@@ -9,6 +9,7 @@ from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
import re
|
||||
from .logging import _log
|
||||
|
||||
|
||||
class NFOManager:
|
||||
@@ -138,11 +139,26 @@ class NFOManager:
|
||||
# Look for NFOGuard fields
|
||||
dateadded_elem = root.find('.//dateadded')
|
||||
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
|
||||
if (dateadded_elem is not None and dateadded_elem.text and
|
||||
lockdata_elem is not None and lockdata_elem.text == "true"):
|
||||
# Only consider it NFOGuard-managed if it has dateadded (lockdata no longer required)
|
||||
if (dateadded_elem is not None and dateadded_elem.text):
|
||||
|
||||
result = {
|
||||
"dateadded": dateadded_elem.text.strip(),
|
||||
@@ -176,11 +192,26 @@ class NFOManager:
|
||||
# Look for NFOGuard fields in episode NFO
|
||||
dateadded_elem = root.find('.//dateadded')
|
||||
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
|
||||
if (dateadded_elem is not None and dateadded_elem.text and
|
||||
lockdata_elem is not None and lockdata_elem.text == "true"):
|
||||
# Only consider it NFOGuard-managed if it has dateadded (lockdata no longer required)
|
||||
if (dateadded_elem is not None and dateadded_elem.text):
|
||||
|
||||
result = {
|
||||
"dateadded": dateadded_elem.text.strip(),
|
||||
@@ -350,7 +381,7 @@ class NFOManager:
|
||||
|
||||
# Remove existing NFOGuard-managed elements to avoid duplicates
|
||||
# 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:
|
||||
existing = movie.find(tag)
|
||||
if existing is not None:
|
||||
@@ -410,11 +441,7 @@ class NFOManager:
|
||||
movie.append(dateadded_elem)
|
||||
print(f"✅ Added dateadded to NFO: {dateadded}")
|
||||
|
||||
# Add lockdata at the very end
|
||||
if lock_metadata:
|
||||
lockdata = ET.Element("lockdata")
|
||||
lockdata.text = "true"
|
||||
movie.append(lockdata)
|
||||
# Note: lockdata element removed for Emby compatibility
|
||||
|
||||
# Write file with proper formatting
|
||||
tree = ET.ElementTree(movie)
|
||||
@@ -476,9 +503,7 @@ class NFOManager:
|
||||
tvdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="tvdb")
|
||||
tvdb_uniqueid.text = tvdb_id
|
||||
|
||||
# Add lockdata at the very end
|
||||
lockdata = ET.SubElement(tvshow, "lockdata")
|
||||
lockdata.text = "true"
|
||||
# Note: lockdata element removed for Emby compatibility
|
||||
|
||||
# Add NFOGuard comment at the beginning
|
||||
comment_text = f" Created by {self.manager_brand} "
|
||||
@@ -521,7 +546,7 @@ class NFOManager:
|
||||
raise ValueError("Root element is not <season>")
|
||||
|
||||
# Remove existing NFOGuard-managed elements
|
||||
for tag in ["seasonnumber", "lockdata"]:
|
||||
for tag in ["seasonnumber"]:
|
||||
existing = season.find(tag)
|
||||
if existing is not None:
|
||||
season.remove(existing)
|
||||
@@ -538,9 +563,7 @@ class NFOManager:
|
||||
seasonnumber = ET.SubElement(season, "seasonnumber")
|
||||
seasonnumber.text = str(season_number)
|
||||
|
||||
# Add lockdata at the end
|
||||
lockdata = ET.SubElement(season, "lockdata")
|
||||
lockdata.text = "true"
|
||||
# Note: lockdata element removed for Emby compatibility
|
||||
|
||||
# Add NFOGuard comment at the beginning
|
||||
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,
|
||||
aired: Optional[str], dateadded: Optional[str], source: str,
|
||||
lock_metadata: bool = True, enhanced_metadata: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Create or update episode NFO file preserving existing content"""
|
||||
# Generate episode filename pattern
|
||||
"""Create or update episode NFO file - preserves existing long names matching video files"""
|
||||
|
||||
try:
|
||||
# First, check for NFO files that match video file names (preserve long names)
|
||||
existing_matching_nfo = self.find_episode_nfo_matching_video(season_dir, season_num, episode_num)
|
||||
|
||||
if existing_matching_nfo:
|
||||
# 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
|
||||
|
||||
# Track if we need to delete an old long-named NFO file
|
||||
old_nfo_to_delete = None
|
||||
|
||||
try:
|
||||
# First, check for existing long-named NFO files that need migration
|
||||
existing_long_nfo = self.find_existing_episode_nfo(season_dir, season_num, episode_num)
|
||||
|
||||
# Prioritize long-named file for migration, otherwise use standard file
|
||||
source_nfo_path = existing_long_nfo if existing_long_nfo else nfo_path if nfo_path.exists() else None
|
||||
# Check if standard format NFO exists
|
||||
source_nfo_path = nfo_path if nfo_path.exists() else None
|
||||
|
||||
if source_nfo_path:
|
||||
# Standard NFO file processing (S01E01.nfo format)
|
||||
try:
|
||||
tree = ET.parse(source_nfo_path)
|
||||
episode = tree.getroot()
|
||||
@@ -634,33 +667,16 @@ class NFOManager:
|
||||
if episode.tag != "episodedetails":
|
||||
raise ValueError("Root element is not <episodedetails>")
|
||||
|
||||
# If we're migrating from a long-named file, mark it for deletion
|
||||
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}")
|
||||
print(f"📝 Updating standard NFO: {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
|
||||
preserved_values = {}
|
||||
|
||||
# Extract and preserve NFOGuard-managed fields (we'll re-add these at the bottom)
|
||||
nfoguard_fields = ["aired", "dateadded", "lockdata", "season", "episode"]
|
||||
# Remove existing NFOGuard fields to re-add them at the bottom
|
||||
nfoguard_fields = ["aired", "dateadded", "season", "episode"]
|
||||
for tag in nfoguard_fields:
|
||||
existing = episode.find(tag)
|
||||
if existing is not None:
|
||||
# Store the value before removing
|
||||
# Preserve existing aired date if not provided
|
||||
if tag == "aired" and not aired:
|
||||
aired = existing.text # Preserve existing aired date
|
||||
preserved_values[tag] = existing.text
|
||||
aired = existing.text
|
||||
episode.remove(existing)
|
||||
|
||||
# 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.text = dateadded
|
||||
|
||||
# Add lockdata at the very end
|
||||
if lock_metadata:
|
||||
lockdata = ET.SubElement(episode, "lockdata")
|
||||
lockdata.text = "true"
|
||||
# Note: lockdata element removed for Emby compatibility
|
||||
|
||||
# Add NFOGuard comment at the bottom with other NFOGuard fields
|
||||
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" 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
|
||||
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}")
|
||||
# No cleanup needed - we preserve existing NFO filenames
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating/updating episode NFO {nfo_path}: {e}")
|
||||
@@ -804,3 +811,227 @@ class NFOManager:
|
||||
else:
|
||||
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
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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']
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Webhook processing components for NFOGuard
|
||||
"""
|
||||
|
||||
from .webhook_batcher import WebhookBatcher
|
||||
|
||||
__all__ = ['WebhookBatcher']
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user