Compare commits

...

37 Commits

Author SHA1 Message Date
sbcrumb c85df54d5a updates for acfivity dashboard
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-04 10:26:32 -05:00
sbcrumb 3ccf062267 update to skip files
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-04 09:23:06 -05:00
sbcrumb 76fd839fa8 update to api for manual entry
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-04 09:15:43 -05:00
sbcrumb a5dd635cc4 updates: fixes for imdb manual add and manual add dates
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-04 09:08:09 -05:00
sbcrumb 79f292ff8e update: to imdb and skipped items
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-04 08:55:06 -05:00
sbcrumb 2f0537ef1d update with the ability to add IMDB to skipped
Local Docker Build (Dev) / build-dev (push) Successful in 35s
2025-11-04 08:48:22 -05:00
sbcrumb d501c4364c update to tv
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-11-03 20:40:42 -05:00
sbcrumb a326825347 update: movie missing imdb
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 19:49:26 -05:00
sbcrumb c0ed1d9630 we bupdated
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 19:25:41 -05:00
sbcrumb 1f20b4397d update to web interface
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 19:05:43 -05:00
sbcrumb b0b2e87447 web test
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 16:49:14 -05:00
sbcrumb 7932b9cfba update to skiped files
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 16:37:42 -05:00
sbcrumb c9b099a218 web interface updates
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 16:20:21 -05:00
sbcrumb d83ba054b3 update to skipped files
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-11-03 16:04:42 -05:00
sbcrumb 3e3957234d udpate add bulk update of TV eps
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 15:52:42 -05:00
sbcrumb 1e292434c0 fix: tracking
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-11-03 15:45:17 -05:00
sbcrumb b48f1220e2 update to api error when scanning
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 15:40:13 -05:00
sbcrumb dc28508bd2 update web route wtih correct if
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 15:29:53 -05:00
sbcrumb db203b5f26 update to movie db population
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-11-03 15:27:17 -05:00
sbcrumb c7d12dadaf update to skipped movie
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 15:21:19 -05:00
sbcrumb 2d5688d313 update db
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-11-03 15:05:43 -05:00
sbcrumb d078decbb4 db radarr fixes
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-11-03 14:50:35 -05:00
sbcrumb 788b7e79f6 updates: more web interface and scan fixes
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 14:38:21 -05:00
sbcrumb 04b2afdb19 update: webinterface updates
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 14:31:39 -05:00
sbcrumb 65896f1974 movies: imdb lookup
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-11-03 14:22:58 -05:00
sbcrumb 01887f082a update: to reading IMDB's
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 14:14:42 -05:00
sbcrumb 73967da3e6 fix: movie fixes
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 14:07:00 -05:00
sbcrumb e397f0639b database: updates
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 11:11:00 -05:00
sbcrumb 2feac00b4c update: updates to web populate
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 11:06:11 -05:00
sbcrumb 099ec12560 web: updates to populate
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-11-03 11:03:27 -05:00
sbcrumb c91c33e9b2 udpate: web populate fix
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 10:54:06 -05:00
sbcrumb de2203a53d udpates: to database population
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 10:49:30 -05:00
sbcrumb 5305e327b3 web: populate link updates
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-11-03 10:37:04 -05:00
sbcrumb 942b9f7f63 feat: add sonarr DB integration vs API
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 10:26:56 -05:00
sbcrumb c2c720cd8b update to web interface
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 09:30:50 -05:00
sbcrumb d414da5bf3 updates to new phase
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-02 20:23:31 -05:00
sbcrumb 97a9f3431a refactor: update and remove anything to do with NFO files
Local Docker Build (Dev) / build-dev (push) Successful in 35s
moving to an all DB approach since radarr overwrites .nfo files
2025-11-02 13:43:28 -05:00
25 changed files with 3564 additions and 2429 deletions
+27 -8
View File
@@ -70,6 +70,21 @@ RADARR_DB_PORT=5432
RADARR_DB_NAME=radarr-main
RADARR_DB_USER=postgres
# ===========================================
# SONARR DATABASE CONNECTION (RECOMMENDED)
# ===========================================
# Direct database access for better performance
# Phase 7: High-performance TV episode import date detection
SONARR_DB_TYPE=postgresql
SONARR_DB_HOST=192.168.255.50
SONARR_DB_PORT=5432
SONARR_DB_NAME=sonarr-main
SONARR_DB_USER=postgres
# Alternative: SQLite (if Sonarr uses SQLite)
# SONARR_DB_TYPE=sqlite
# SONARR_DB_PATH=/path/to/sonarr.db
# ===========================================
# API CONNECTIONS (OPTIONAL)
# ===========================================
@@ -97,18 +112,22 @@ ALLOW_FILE_DATE_FALLBACK=false
TMDB_COUNTRY=US
# ===========================================
# NFO FILE MANAGEMENT
# NFO FILE MANAGEMENT (DEPRECATED - Phase 1 Migration)
# ===========================================
# Create/update .nfo files
MANAGE_NFO=true
# NFO file operations have been removed in favor of database-only architecture
# These settings are preserved for backward compatibility but no longer have effect
# The PostgreSQL database is now the single source of truth for all metadata
# Update file modification times to match import dates
FIX_DIR_MTIMES=true
# Create/update .nfo files (DEPRECATED - no longer used)
MANAGE_NFO=false
# Add lockdata tags to prevent metadata overwrites
LOCK_METADATA=true
# Update file modification times to match import dates (DEPRECATED - no longer used)
FIX_DIR_MTIMES=false
# Brand name in NFO comments
# Add lockdata tags to prevent metadata overwrites (DEPRECATED - no longer used)
LOCK_METADATA=false
# Brand name in NFO comments (DEPRECATED - no longer used)
MANAGER_BRAND=NFOGuard
# ===========================================
+1 -1
View File
@@ -1 +1 @@
2.8.0
2.10.0-skipped-imdb-edit-v6
+117
View File
@@ -2538,6 +2538,115 @@ async def lookup_movie(imdb_id: str, dependencies: dict):
raise HTTPException(status_code=500, detail=f"Movie lookup failed: {str(e)}")
async def populate_database(background_tasks: BackgroundTasks, media_type: str = "both", dependencies: dict = None):
"""
Populate NFOGuard database from Radarr/Sonarr sources
Args:
background_tasks: FastAPI background tasks
media_type: Type of media to populate ("movies", "tv", or "both")
dependencies: Dictionary with db, radarr_client, sonarr_client
Returns:
Status message indicating population has started
"""
from core.database_populator import DatabasePopulator
db = dependencies["db"]
config = dependencies["config"]
# Get Radarr and Sonarr clients
from clients.radarr_client import RadarrClient
from clients.sonarr_client import SonarrClient
radarr_client = RadarrClient(config)
sonarr_client = SonarrClient(config)
if media_type not in ["both", "movies", "tv"]:
raise HTTPException(status_code=400, detail="media_type must be 'both', 'movies', or 'tv'")
# Create global status tracking
populate_status = {
"running": True,
"media_type": media_type,
"start_time": datetime.now().isoformat(),
"movies": {"status": "pending", "stats": None},
"tv": {"status": "pending", "stats": None},
"completed": False,
"error": None
}
# Store status globally so it can be queried
global _populate_status
_populate_status = populate_status
async def run_population():
"""Background task to populate the database"""
try:
populator = DatabasePopulator(db, radarr_client, sonarr_client)
_log("INFO", f"Starting database population: {media_type}")
if media_type == "movies":
populate_status["movies"]["status"] = "running"
movie_stats = populator.populate_movies()
populate_status["movies"]["status"] = "completed"
populate_status["movies"]["stats"] = movie_stats
_log("INFO", f"Movie population completed: {movie_stats}")
elif media_type == "tv":
populate_status["tv"]["status"] = "running"
tv_stats = populator.populate_tv_episodes()
populate_status["tv"]["status"] = "completed"
populate_status["tv"]["stats"] = tv_stats
_log("INFO", f"TV population completed: {tv_stats}")
elif media_type == "both":
populate_status["movies"]["status"] = "running"
movie_stats = populator.populate_movies()
populate_status["movies"]["status"] = "completed"
populate_status["movies"]["stats"] = movie_stats
_log("INFO", f"Movie population completed: {movie_stats}")
populate_status["tv"]["status"] = "running"
tv_stats = populator.populate_tv_episodes()
populate_status["tv"]["status"] = "completed"
populate_status["tv"]["stats"] = tv_stats
_log("INFO", f"TV population completed: {tv_stats}")
populate_status["completed"] = True
populate_status["running"] = False
_log("INFO", "Database population completed successfully")
except Exception as e:
_log("ERROR", f"Database population failed: {e}")
populate_status["error"] = str(e)
populate_status["running"] = False
populate_status["completed"] = True
# Add task to background
background_tasks.add_task(run_population)
_log("INFO", f"Database population started for: {media_type}")
return {
"status": "started",
"media_type": media_type,
"message": f"Database population started for {media_type}"
}
async def get_populate_status():
"""Get the current status of database population"""
global _populate_status
if '_populate_status' not in globals():
return {"running": False, "completed": False}
return _populate_status
# Initialize global populate status
_populate_status = {"running": False, "completed": False}
def register_routes(app, dependencies: dict):
"""
Register all routes with the FastAPI app
@@ -2641,6 +2750,14 @@ def register_routes(app, dependencies: dict):
async def _scan_status():
return await get_scan_status()
@app.post("/admin/populate-database")
async def _populate_database(background_tasks: BackgroundTasks, media_type: str = "both"):
return await populate_database(background_tasks, media_type, dependencies)
@app.get("/api/populate/status")
async def _populate_status():
return await get_populate_status()
@app.post("/tv/scan-season")
async def _scan_tv_season(background_tasks: BackgroundTasks, request: TVSeasonRequest):
return await scan_tv_season(background_tasks, request, dependencies)
+457 -22
View File
@@ -3,14 +3,25 @@ Web interface API routes for NFOGuard database management
Provides endpoints for the web-based database manipulation interface
"""
import json
import os
import tempfile
import multiprocessing
from datetime import datetime, timezone
from typing import List, Optional, Dict, Any
from fastapi import HTTPException, Query
from fastapi import HTTPException, Query, BackgroundTasks
from pathlib import Path
from api.models import *
# Status file for cross-process communication
# Use /tmp which is writable in both core and web containers
POPULATE_STATUS_FILE = "/tmp/nfoguard_populate_status.json"
# Process tracking
_populate_process = None
def map_source_to_description(source: str) -> str:
"""Map technical source codes to user-friendly descriptions"""
if not source or source == "no_valid_date_source":
@@ -45,15 +56,23 @@ def map_source_to_description(source: str) -> str:
elif "omdb:" in source_lower:
return "OMDb Release"
# Sonarr sources
elif "sonarr:" in source_lower:
return "Sonarr API"
# Manual and other sources
elif "manual" in source_lower:
return "Manual Entry"
elif "digital_release" in source_lower:
return "Digital Release"
elif "nfo_file_existing" in source_lower:
return "NFO File (Legacy)"
elif "nfo:" in source_lower:
return "NFO File"
elif "webhook:" in source_lower:
return "Webhook/API"
elif "database" in source_lower:
return "Database"
# Fallback for unknown patterns
return source.title()
@@ -69,7 +88,8 @@ async def get_movies_list(dependencies: dict,
has_date: Optional[bool] = Query(None),
source_filter: Optional[str] = Query(None),
search: Optional[str] = Query(None),
imdb_search: Optional[str] = Query(None)):
imdb_search: Optional[str] = Query(None),
skipped: Optional[bool] = Query(None)):
"""Get paginated list of movies with filtering options"""
db = dependencies["db"]
@@ -80,6 +100,12 @@ async def get_movies_list(dependencies: dict,
where_conditions = []
params = []
if skipped is not None:
if skipped:
where_conditions.append("skipped = TRUE")
else:
where_conditions.append("(skipped = FALSE OR skipped IS NULL)")
if has_date is not None:
if has_date:
# PostgreSQL - NULL handling
@@ -93,8 +119,8 @@ async def get_movies_list(dependencies: dict,
params.append(source_filter)
if search:
where_conditions.append("(imdb_id ILIKE %s OR path ILIKE %s)")
params.extend([f"%{search}%", f"%{search}%"])
where_conditions.append("(imdb_id ILIKE %s OR path ILIKE %s OR title ILIKE %s)")
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
if imdb_search:
where_conditions.append("imdb_id ILIKE %s")
@@ -109,7 +135,7 @@ async def get_movies_list(dependencies: dict,
# Get paginated results - PostgreSQL
query = f"""
SELECT imdb_id, path, released, dateadded, source, has_video_file, last_updated
SELECT imdb_id, title, year, path, released, dateadded, source, has_video_file, last_updated, skipped, skip_reason
FROM movies
WHERE {where_clause}
ORDER BY last_updated DESC
@@ -120,11 +146,12 @@ async def get_movies_list(dependencies: dict,
movies = []
for row in cursor.fetchall():
movie = dict(row)
# Extract title from path for display
try:
movie['title'] = Path(movie['path']).name if movie['path'] else movie['imdb_id']
except:
movie['title'] = movie['imdb_id']
# Use database title if available, otherwise extract from path
if not movie.get('title'):
try:
movie['title'] = Path(movie['path']).name if movie['path'] else movie['imdb_id']
except:
movie['title'] = movie['imdb_id']
# Map source to user-friendly description
movie['source_description'] = map_source_to_description(movie.get('source'))
movies.append(movie)
@@ -150,8 +177,8 @@ async def get_tv_series_list(dependencies: dict,
db = dependencies["db"]
# Validate date_filter values
if date_filter and date_filter not in ['complete', 'incomplete', 'none']:
raise HTTPException(status_code=422, detail=f"Invalid date_filter: must be 'complete', 'incomplete', or 'none', got '{date_filter}'")
if date_filter and date_filter not in ['complete', 'incomplete', 'none', 'skipped']:
raise HTTPException(status_code=422, detail=f"Invalid date_filter: must be 'complete', 'incomplete', 'none', or 'skipped', got '{date_filter}'")
with db.get_connection() as conn:
cursor = conn.cursor()
@@ -184,13 +211,19 @@ async def get_tv_series_list(dependencies: dict,
elif date_filter == "none":
# No episodes have dates
having_conditions.append("COUNT(e.episode) > 0 AND COUNT(CASE WHEN e.dateadded IS NOT NULL THEN 1 END) = 0")
elif date_filter == "skipped":
# Has skipped episodes
having_conditions.append("COUNT(CASE WHEN e.skipped = TRUE THEN 1 END) > 0")
where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"
having_clause = " AND ".join(having_conditions) if having_conditions else ""
# Check if where_clause references episodes table (e.source, e.dateadded, etc.)
needs_episode_join = any(cond.startswith("e.") for cond in where_conditions)
# Get total count with same filtering logic as main query
if having_clause:
# When using HAVING clause, need to count filtered results
if having_clause or needs_episode_join:
# When using HAVING clause or filtering by episode fields, need to count filtered results with JOIN
count_query = f"""
SELECT COUNT(*) FROM (
SELECT s.imdb_id
@@ -198,12 +231,12 @@ async def get_tv_series_list(dependencies: dict,
LEFT JOIN episodes e ON s.imdb_id = e.imdb_id
WHERE {where_clause}
GROUP BY s.imdb_id
HAVING {having_clause}
{('HAVING ' + having_clause) if having_clause else ''}
) filtered_series
"""
cursor.execute(count_query, params)
else:
# Simple count when no HAVING clause
# Simple count when no HAVING clause and no episode filtering
count_query = f"SELECT COUNT(*) FROM series s WHERE {where_clause}"
cursor.execute(count_query, params)
total_count = db._get_first_value(cursor.fetchone())
@@ -218,7 +251,8 @@ async def get_tv_series_list(dependencies: dict,
s.last_updated,
COUNT(e.episode) as total_episodes,
COUNT(CASE WHEN e.dateadded IS NOT NULL THEN 1 END) as episodes_with_dates,
COUNT(CASE WHEN e.has_video_file = TRUE THEN 1 END) as episodes_with_video
COUNT(CASE WHEN e.has_video_file = TRUE THEN 1 END) as episodes_with_video,
COUNT(CASE WHEN e.skipped = TRUE THEN 1 END) as episodes_skipped
FROM series s
LEFT JOIN episodes e ON s.imdb_id = e.imdb_id
WHERE {where_clause}
@@ -466,6 +500,13 @@ async def get_dashboard_stats(dependencies: dict):
cursor.execute("SELECT COUNT(*) FROM episodes WHERE source = 'no_valid_date_source'")
episodes_no_valid_source = db._get_first_value(cursor.fetchone())
# Skipped items
cursor.execute("SELECT COUNT(*) FROM movies WHERE skipped = TRUE")
movies_skipped = db._get_first_value(cursor.fetchone())
cursor.execute("SELECT COUNT(*) FROM episodes WHERE skipped = TRUE")
episodes_skipped = db._get_first_value(cursor.fetchone())
# Recent activity (last 7 days)
cursor.execute("""
SELECT COUNT(*) FROM processing_history
@@ -495,6 +536,7 @@ async def get_dashboard_stats(dependencies: dict):
# Calculate total missing dates (movies + episodes)
total_missing_dates = movies_without_dates + episodes_without_dates
total_skipped = movies_skipped + episodes_skipped
# Combine with enhanced stats
stats.update({
@@ -507,6 +549,9 @@ async def get_dashboard_stats(dependencies: dict):
"total_missing_dates": total_missing_dates,
"movies_no_valid_source": movies_no_valid_source,
"episodes_no_valid_source": episodes_no_valid_source,
"movies_skipped": movies_skipped,
"episodes_skipped": episodes_skipped,
"total_skipped": total_skipped,
"recent_activity_count": recent_activity,
"movie_sources": movie_sources,
"episode_sources": episode_sources
@@ -722,7 +767,6 @@ async def bulk_update_source(dependencies: dict, media_type: str, old_source: st
async def get_movie_date_options(dependencies: dict, imdb_id: str):
"""Get available date options for a movie (Radarr import, digital release, etc.)"""
db = dependencies["db"]
nfo_manager = dependencies["nfo_manager"]
# Get current movie data
movie = db.get_movie_dates(imdb_id)
@@ -1234,6 +1278,195 @@ async def get_episode_date_options(dependencies: dict, imdb_id: str, season: int
}
def _populate_worker_process(media_type: str, status_file: str):
"""
Worker process that runs database population in complete isolation.
Runs in separate process - keeps web interface responsive.
"""
import os
import sys
import json
from datetime import datetime
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def update_status(status_data):
"""Write status to file for main process to read"""
try:
with open(status_file, 'w') as f:
json.dump(status_data, f)
except Exception as e:
print(f"ERROR: Failed to update status file: {e}")
# Initialize status
status = {
"running": True,
"media_type": media_type,
"start_time": datetime.now().isoformat(),
"movies": {"status": "pending", "stats": None},
"tv": {"status": "pending", "stats": None},
"completed": False,
"error": None
}
update_status(status)
try:
# Import here (inside process) to avoid pickling issues
from core.database import NFOGuardDatabase
from core.database_populator import DatabasePopulator
from clients.radarr_client import RadarrClient
from clients.sonarr_client import SonarrClient
from config.settings import config
# Initialize components
db = NFOGuardDatabase(config)
radarr_client = RadarrClient(
os.environ.get("RADARR_URL", ""),
os.environ.get("RADARR_API_KEY", "")
)
sonarr_client = SonarrClient(
os.environ.get("SONARR_URL", ""),
os.environ.get("SONARR_API_KEY", "")
)
populator = DatabasePopulator(db, radarr_client, sonarr_client)
print(f"INFO: [Worker Process] Starting database population: {media_type}")
# Run population based on media type
if media_type in ["movies", "both"]:
status["movies"]["status"] = "running"
update_status(status)
movie_stats = populator.populate_movies()
status["movies"]["status"] = "completed"
status["movies"]["stats"] = movie_stats
update_status(status)
print(f"INFO: [Worker Process] Movie population completed: {movie_stats}")
if media_type in ["tv", "both"]:
status["tv"]["status"] = "running"
update_status(status)
tv_stats = populator.populate_tv_episodes()
status["tv"]["status"] = "completed"
status["tv"]["stats"] = tv_stats
update_status(status)
print(f"INFO: [Worker Process] TV population completed: {tv_stats}")
# Mark as completed
status["completed"] = True
status["running"] = False
update_status(status)
print("INFO: [Worker Process] Database population completed successfully")
except Exception as e:
print(f"ERROR: [Worker Process] Database population failed: {e}")
import traceback
traceback.print_exc()
status["error"] = str(e)
status["running"] = False
status["completed"] = True
update_status(status)
async def populate_database(background_tasks: BackgroundTasks, media_type: str = "both", dependencies: dict = None):
"""
Populate NFOGuard database from Radarr/Sonarr sources in separate process.
This keeps the web interface responsive during population.
Args:
background_tasks: FastAPI background tasks (not used - kept for compatibility)
media_type: Type of media to populate ("movies", "tv", or "both")
dependencies: Dictionary with dependencies (not used in multiprocessing mode)
Returns:
Status message indicating population has started
"""
global _populate_process
if media_type not in ["both", "movies", "tv"]:
raise HTTPException(status_code=400, detail="media_type must be 'both', 'movies', or 'tv'")
# Check if process is already running
if _populate_process and _populate_process.is_alive():
return {
"status": "already_running",
"message": "Database population is already in progress"
}
# Initialize status file
initial_status = {
"running": True,
"media_type": media_type,
"start_time": datetime.now().isoformat(),
"movies": {"status": "pending", "stats": None},
"tv": {"status": "pending", "stats": None},
"completed": False,
"error": None
}
try:
with open(POPULATE_STATUS_FILE, 'w') as f:
json.dump(initial_status, f)
except Exception as e:
print(f"ERROR: Failed to initialize status file: {e}")
raise HTTPException(status_code=500, detail=f"Failed to initialize status tracking: {e}")
# Start population in separate process
_populate_process = multiprocessing.Process(
target=_populate_worker_process,
args=(media_type, POPULATE_STATUS_FILE),
daemon=False # Keep process alive even if parent exits
)
_populate_process.start()
print(f"INFO: Database population process started (PID: {_populate_process.pid}) for: {media_type}")
return {
"status": "started",
"media_type": media_type,
"message": f"Database population started for {media_type} in separate process (PID: {_populate_process.pid})"
}
async def get_populate_status():
"""Get the current status of database population from status file"""
global _populate_process
# Check if status file exists
if not os.path.exists(POPULATE_STATUS_FILE):
return {"running": False, "completed": False}
# Read status from file
try:
with open(POPULATE_STATUS_FILE, 'r') as f:
status = json.load(f)
# Check if process is still alive
if _populate_process:
status["process_alive"] = _populate_process.is_alive()
if not _populate_process.is_alive() and status.get("running"):
# Process died unexpectedly
status["running"] = False
status["completed"] = True
if not status.get("error"):
status["error"] = "Process terminated unexpectedly"
return status
except Exception as e:
print(f"ERROR: Failed to read status file: {e}")
return {
"running": False,
"completed": True,
"error": f"Failed to read status: {e}"
}
def register_web_routes(app, dependencies):
"""Register all web API routes with FastAPI app"""
from fastapi import Request, Response
@@ -1250,21 +1483,58 @@ def register_web_routes(app, dependencies):
# Movies endpoints
@app.get("/api/movies")
async def api_movies_list(skip: int = 0, limit: int = 100, has_date: bool = None,
source_filter: str = None, search: str = None, imdb_search: str = None):
return await get_movies_list(dependencies, skip, limit, has_date, source_filter, search, imdb_search)
source_filter: str = None, search: str = None, imdb_search: str = None,
skipped: bool = None):
return await get_movies_list(dependencies, skip, limit, has_date, source_filter, search, imdb_search, skipped)
@app.post("/api/movies/{imdb_id}/update-date")
async def api_update_movie_date(imdb_id: str, dateadded: str = None, source: str = "manual"):
return await update_movie_date(dependencies, imdb_id, dateadded, source)
@app.put("/api/movies/{imdb_id}")
async def api_update_movie(imdb_id: str, dateadded: str = None, source: str = "manual"):
return await update_movie_date(dependencies, imdb_id, dateadded, source)
async def api_update_movie(imdb_id: str, request: Request):
"""Update movie date from JSON body"""
try:
data = await request.json()
dateadded = data.get('dateadded')
source = data.get('source', 'manual')
print(f"🔍 API PUT /api/movies/{imdb_id} - Received JSON:")
print(f" - Raw data: {data}")
print(f" - dateadded: {dateadded}")
print(f" - source: {source}")
return await update_movie_date(dependencies, imdb_id, dateadded, source)
except Exception as e:
print(f"❌ Error parsing request body: {e}")
raise HTTPException(status_code=400, detail=f"Invalid request body: {str(e)}")
@app.get("/api/movies/{imdb_id}/date-options")
async def api_movie_date_options(imdb_id: str):
return await get_movie_date_options(dependencies, imdb_id)
@app.get("/api/debug/movie/{imdb_id}/raw")
async def api_debug_movie_raw(imdb_id: str):
"""Get raw database data for a movie for debugging"""
db = dependencies["db"]
try:
movie = db.get_movie_dates(imdb_id)
if not movie:
raise HTTPException(status_code=404, detail="Movie not found")
return {
"success": True,
"raw_data": movie
}
except HTTPException:
raise
except Exception as e:
print(f"❌ Error getting movie debug data: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get debug data: {str(e)}")
@app.delete("/api/movies/{imdb_id}")
async def api_delete_movie(imdb_id: str):
"""Delete a movie from the database"""
@@ -1290,6 +1560,41 @@ def register_web_routes(app, dependencies):
print(f"❌ Error deleting movie: {e}")
raise HTTPException(status_code=500, detail=f"Failed to delete movie: {str(e)}")
@app.post("/api/movies/{old_imdb_id}/migrate-imdb")
async def api_migrate_movie_imdb(old_imdb_id: str, request: Request):
"""Migrate a movie from placeholder IMDb ID to real IMDb ID"""
db = dependencies["db"]
try:
data = await request.json()
new_imdb_id = data.get('new_imdb_id')
if not new_imdb_id:
raise HTTPException(status_code=422, detail="new_imdb_id is required")
# Normalize IMDb ID
if not new_imdb_id.startswith('tt'):
new_imdb_id = f"tt{new_imdb_id}"
success = db.migrate_movie_imdb_id(old_imdb_id, new_imdb_id)
if success:
return {
"success": True,
"status": "success",
"message": f"Migrated movie from {old_imdb_id} to {new_imdb_id}",
"old_imdb_id": old_imdb_id,
"new_imdb_id": new_imdb_id
}
else:
raise HTTPException(status_code=404, detail="Movie not found")
except HTTPException:
raise
except Exception as e:
print(f"❌ Error migrating movie IMDb ID: {e}")
raise HTTPException(status_code=500, detail=f"Failed to migrate IMDb ID: {str(e)}")
# TV series endpoints
@app.get("/api/series")
async def api_series_list(skip: int = 0, limit: int = 50, search: str = None,
@@ -1308,6 +1613,41 @@ def register_web_routes(app, dependencies):
async def api_debug_series_date_distribution():
return await debug_series_date_distribution(dependencies)
@app.post("/api/series/{old_imdb_id}/migrate-imdb")
async def api_migrate_series_imdb(old_imdb_id: str, request: Request):
"""Migrate a series and all its episodes from placeholder IMDb ID to real IMDb ID"""
db = dependencies["db"]
try:
data = await request.json()
new_imdb_id = data.get('new_imdb_id')
if not new_imdb_id:
raise HTTPException(status_code=422, detail="new_imdb_id is required")
# Normalize IMDb ID
if not new_imdb_id.startswith('tt'):
new_imdb_id = f"tt{new_imdb_id}"
success = db.migrate_series_imdb_id(old_imdb_id, new_imdb_id)
if success:
return {
"success": True,
"status": "success",
"message": f"Migrated series and episodes from {old_imdb_id} to {new_imdb_id}",
"old_imdb_id": old_imdb_id,
"new_imdb_id": new_imdb_id
}
else:
raise HTTPException(status_code=404, detail="Series not found")
except HTTPException:
raise
except Exception as e:
print(f"❌ Error migrating series IMDb ID: {e}")
raise HTTPException(status_code=500, detail=f"Failed to migrate IMDb ID: {str(e)}")
# Episode endpoints
@app.post("/api/episodes/{imdb_id}/{season}/{episode}/update-date")
async def api_update_episode_date(imdb_id: str, season: int, episode: int,
@@ -1362,6 +1702,78 @@ def register_web_routes(app, dependencies):
async def api_bulk_update_source(media_type: str, old_source: str, new_source: str):
return {"error": "Bulk operations not available in web container. Use core container on port 8085."}
@app.post("/api/episodes/bulk-update-dates")
async def api_bulk_update_episode_dates(request: Request):
"""Bulk update episode dates"""
try:
data = await request.json()
episodes = data.get('episodes', [])
date_source = data.get('date_source', 'airdate')
custom_date = data.get('custom_date')
if not episodes:
return {"success": False, "message": "No episodes provided"}
db = dependencies["db"]
sonarr = dependencies.get("sonarr_client")
updated_count = 0
failed_count = 0
for ep in episodes:
imdb_id = ep.get('imdb_id')
season = ep.get('season')
episode = ep.get('episode')
if not all([imdb_id, season is not None, episode is not None]):
failed_count += 1
continue
try:
dateadded = None
source = "manual"
if date_source == 'airdate':
# Get airdate from Sonarr
if sonarr:
episode_data = sonarr.get_episode(imdb_id, season, episode)
if episode_data and episode_data.get('airDateUtc'):
dateadded = episode_data['airDateUtc']
source = "sonarr:airdate"
elif date_source == 'import':
# Get import date from Sonarr history
if sonarr:
import_date = sonarr.get_episode_import_date(imdb_id, season, episode)
if import_date:
dateadded = import_date
source = "sonarr:import"
elif date_source == 'custom':
# Use custom date
if custom_date:
dateadded = custom_date
source = "manual:custom"
if dateadded:
db.upsert_episode_dates(imdb_id, season, episode, dateadded, source)
updated_count += 1
else:
failed_count += 1
except Exception as e:
print(f"❌ Error updating episode {imdb_id} S{season:02d}E{episode:02d}: {e}")
failed_count += 1
return {
"success": True,
"updated": updated_count,
"failed": failed_count,
"message": f"Updated {updated_count} episode(s), {failed_count} failed"
}
except Exception as e:
print(f"❌ Bulk update error: {e}")
return {"success": False, "message": str(e)}
# Reports
@app.get("/api/reports/missing-dates")
async def api_missing_dates_report():
@@ -1839,6 +2251,7 @@ async def bulk_update_nfo_files(dependencies: dict, imdb_ids: list = None, fix_a
# Add database admin routes to the web interface
def register_database_admin_routes(app, dependencies):
"""Register database admin routes"""
from fastapi import Request, Response
@app.post("/api/admin/database/query")
async def api_database_query(query: str, limit: int = 100):
@@ -1936,6 +2349,28 @@ def register_database_admin_routes(app, dependencies):
"""Get schedule execution history"""
return await get_schedule_executions(dependencies, schedule_id)
# Database population endpoints
print("🔧 DEBUG: Registering /admin/populate-database endpoint...")
@app.post("/admin/populate-database")
async def api_populate_database(request: Request, background_tasks: BackgroundTasks):
"""Populate database from Radarr/Sonarr"""
print(f"🔥 DEBUG: populate-database endpoint called!")
try:
data = await request.json()
media_type = data.get("media_type", "both")
except Exception:
# Fallback to query parameter if JSON parsing fails
media_type = request.query_params.get("media_type", "both")
return await populate_database(background_tasks, media_type, dependencies)
print("✅ DEBUG: /admin/populate-database registered")
@app.get("/api/populate/status")
async def api_populate_status():
"""Get database population status"""
return await get_populate_status()
# Scheduled Scans Functions
+47
View File
@@ -165,6 +165,53 @@ class RadarrDbClient:
return None
def get_all_movies(self) -> List[Dict[str, Any]]:
"""
Get all movies from Radarr database (only movies with files)
Returns:
List of dictionaries with movie info
"""
query = """
SELECT
m."Id" as id,
m."Path" as path,
m."Added" as added,
mm."ImdbId" as imdb_id,
mm."Title" as title,
mm."Year" as year,
mm."DigitalRelease" as digital_release,
mm."PhysicalRelease" as physical_release,
mm."InCinemas" as in_cinemas,
(SELECT COUNT(*) FROM "MovieFiles" mf WHERE mf."MovieId" = m."Id") as file_count
FROM "Movies" m
JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
WHERE (SELECT COUNT(*) FROM "MovieFiles" mf WHERE mf."MovieId" = m."Id") > 0
ORDER BY mm."Title"
"""
if self.db_type == "sqlite":
# SQLite uses ? placeholders but this query has none
pass
try:
with self._get_connection() as conn:
if self.db_type == "postgresql":
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
else:
cursor = conn.cursor()
cursor.execute(query)
rows = cursor.fetchall()
if rows:
return [dict(row) if self.db_type == "sqlite" else row for row in rows]
return []
except Exception as e:
_log("ERROR", f"Database query error getting all movies: {e}")
return []
def get_earliest_import_date(self, movie_id: int) -> Tuple[Optional[str], str]:
"""
Get earliest import date from History table, accounting for upgrade scenarios
+582
View File
@@ -0,0 +1,582 @@
#!/usr/bin/env python3
"""
Direct Sonarr Database Client for NFOGuard
Provides high-performance access to Sonarr's SQLite/PostgreSQL database
"""
import os
import sqlite3
import psycopg2
import psycopg2.extras
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple, Union
from urllib.parse import urlparse
from core.logging import _log
class SonarrDbClient:
"""Direct database client for Sonarr's SQLite or PostgreSQL database"""
def __init__(self,
db_type: str = "sqlite",
db_path: Optional[str] = None,
db_host: Optional[str] = None,
db_port: Optional[int] = None,
db_name: Optional[str] = None,
db_user: Optional[str] = None,
db_password: Optional[str] = None):
"""
Initialize Sonarr database client
Args:
db_type: "sqlite" or "postgresql"
db_path: Path to SQLite database file
db_host: PostgreSQL host
db_port: PostgreSQL port
db_name: PostgreSQL database name
db_user: PostgreSQL username
db_password: PostgreSQL password
"""
self.db_type = db_type.lower()
self.db_path = db_path
self.db_host = db_host
self.db_port = db_port or 5432
self.db_name = db_name
self.db_user = db_user
self.db_password = db_password
self._test_connection()
@classmethod
def from_env(cls) -> Optional['SonarrDbClient']:
"""Create client from environment variables"""
db_type = os.environ.get("SONARR_DB_TYPE", "").lower()
if not db_type:
return None
if db_type == "sqlite":
db_path = os.environ.get("SONARR_DB_PATH")
if not db_path or not Path(db_path).exists():
_log("WARNING", f"SONARR_DB_PATH not found or invalid: {db_path}")
return None
return cls(db_type="sqlite", db_path=db_path)
elif db_type == "postgresql":
# Support both individual vars and connection string
db_url = os.environ.get("SONARR_DB_URL")
if db_url:
parsed = urlparse(db_url)
return cls(
db_type="postgresql",
db_host=parsed.hostname,
db_port=parsed.port or 5432,
db_name=parsed.path.lstrip('/'),
db_user=parsed.username,
db_password=parsed.password
)
else:
return cls(
db_type="postgresql",
db_host=os.environ.get("SONARR_DB_HOST"),
db_port=int(os.environ.get("SONARR_DB_PORT", "5432")),
db_name=os.environ.get("SONARR_DB_NAME"),
db_user=os.environ.get("SONARR_DB_USER"),
db_password=os.environ.get("SONARR_DB_PASSWORD")
)
else:
_log("ERROR", f"Unsupported database type: {db_type}")
return None
def _test_connection(self) -> None:
"""Test database connection on initialization"""
try:
conn = self._get_connection()
if conn:
conn.close()
_log("INFO", f"Connected to Sonarr {self.db_type} database successfully")
else:
raise Exception("Failed to create connection")
except Exception as e:
_log("ERROR", f"Failed to connect to Sonarr database: {e}")
raise
def _get_connection(self) -> Union[sqlite3.Connection, psycopg2.extensions.connection]:
"""Get database connection"""
if self.db_type == "sqlite":
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
elif self.db_type == "postgresql":
conn = psycopg2.connect(
host=self.db_host,
port=self.db_port,
database=self.db_name,
user=self.db_user,
password=self.db_password
)
return conn
else:
raise ValueError(f"Unsupported database type: {self.db_type}")
def get_series_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
"""
Find series by IMDb ID using database query
Returns:
Dictionary with series info including id, imdb_id, title, path
"""
imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}"
query = """
SELECT
"Id" as id,
"ImdbId" as imdb_id,
"TvdbId" as tvdb_id,
"Title" as title,
"Path" as path,
"Added" as added
FROM "Series"
WHERE "ImdbId" = %s
"""
if self.db_type == "sqlite":
query = query.replace("%s", "?")
try:
with self._get_connection() as conn:
if self.db_type == "postgresql":
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
else:
cursor = conn.cursor()
cursor.execute(query, (imdb_id,))
row = cursor.fetchone()
if row:
return dict(row) if self.db_type == "sqlite" else row
except Exception as e:
_log("ERROR", f"Database query error for IMDb {imdb_id}: {e}")
return None
def get_all_episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]:
"""
Get all episodes for a series
Args:
series_id: Sonarr series ID
Returns:
List of episode dictionaries with season, episode, air_date
"""
query = """
SELECT
"Id" as id,
"SeasonNumber" as season,
"EpisodeNumber" as episode,
"Title" as title,
"AirDate" as air_date,
"EpisodeFileId" as episode_file_id
FROM "Episodes"
WHERE "SeriesId" = %s
ORDER BY "SeasonNumber", "EpisodeNumber"
"""
if self.db_type == "sqlite":
query = query.replace("%s", "?")
try:
with self._get_connection() as conn:
if self.db_type == "postgresql":
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
else:
cursor = conn.cursor()
cursor.execute(query, (series_id,))
rows = cursor.fetchall()
if self.db_type == "sqlite":
return [dict(row) for row in rows]
else:
return rows
except Exception as e:
_log("ERROR", f"Database query error for series {series_id}: {e}")
return []
def get_episode_import_date(self, episode_id: int) -> Tuple[Optional[str], str]:
"""
Get earliest import date for an episode from History table
Args:
episode_id: Sonarr episode ID
Returns:
(date_iso, source_description)
"""
# Query for earliest import event (EventType 3)
import_query = """
SELECT
"Date" as event_date,
"EventType" as event_type
FROM "History"
WHERE "EpisodeId" = %s
AND "EventType" = 3
ORDER BY "Date" ASC
LIMIT 1
"""
if self.db_type == "sqlite":
import_query = import_query.replace("%s", "?")
try:
with self._get_connection() as conn:
if self.db_type == "postgresql":
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
else:
cursor = conn.cursor()
# Try import events first
cursor.execute(import_query, (episode_id,))
row = cursor.fetchone()
if row:
event_date = row['event_date'] if self.db_type == "postgresql" else row[0]
if isinstance(event_date, str):
dt = datetime.fromisoformat(event_date.replace("Z", "+00:00"))
else:
dt = event_date.replace(tzinfo=timezone.utc)
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
return date_iso, "sonarr:db.history.import"
except Exception as e:
_log("ERROR", f"Database query error for episode {episode_id}: {e}")
return None, "sonarr:db.no_import_found"
def get_episode_file_date(self, series_id: int, season: int, episode: int) -> Optional[str]:
"""
Get episode file DateAdded as fallback
Args:
series_id: Sonarr series ID
season: Season number
episode: Episode number
Returns:
ISO date string or None
"""
# First get the episode ID to find the file
episode_query = """
SELECT "EpisodeFileId"
FROM "Episodes"
WHERE "SeriesId" = %s
AND "SeasonNumber" = %s
AND "EpisodeNumber" = %s
"""
file_query = """
SELECT "DateAdded"
FROM "EpisodeFiles"
WHERE "Id" = %s
"""
if self.db_type == "sqlite":
episode_query = episode_query.replace("%s", "?")
file_query = file_query.replace("%s", "?")
try:
with self._get_connection() as conn:
if self.db_type == "postgresql":
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
else:
cursor = conn.cursor()
# Get episode file ID
cursor.execute(episode_query, (series_id, season, episode))
row = cursor.fetchone()
if row:
file_id = row['EpisodeFileId'] if self.db_type == "postgresql" else row[0]
if file_id:
# Get file date
cursor.execute(file_query, (file_id,))
file_row = cursor.fetchone()
if file_row:
date_value = file_row['DateAdded'] if self.db_type == "postgresql" else file_row[0]
if isinstance(date_value, str):
dt = datetime.fromisoformat(date_value.replace("Z", "+00:00"))
else:
dt = date_value.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).isoformat(timespec="seconds")
except Exception as e:
_log("ERROR", f"Database query error for episode file: {e}")
return None
def bulk_import_dates_for_series(self, series_id: int) -> Dict[Tuple[int, int], Tuple[Optional[str], str]]:
"""
Get import dates for all episodes in a series in a single query
Args:
series_id: Sonarr series ID
Returns:
Dictionary mapping (season, episode) -> (date_iso, source)
"""
query = """
SELECT
e."SeasonNumber" as season,
e."EpisodeNumber" as episode,
e."Id" as episode_id,
MIN(h."Date") as earliest_import
FROM "Episodes" e
LEFT JOIN "History" h ON e."Id" = h."EpisodeId" AND h."EventType" = 3
WHERE e."SeriesId" = %s
GROUP BY e."SeasonNumber", e."EpisodeNumber", e."Id"
ORDER BY e."SeasonNumber", e."EpisodeNumber"
"""
if self.db_type == "sqlite":
query = query.replace("%s", "?")
results = {}
try:
with self._get_connection() as conn:
if self.db_type == "postgresql":
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
else:
cursor = conn.cursor()
cursor.execute(query, (series_id,))
rows = cursor.fetchall()
for row in rows:
if self.db_type == "postgresql":
season, episode, episode_id, earliest_import = row['season'], row['episode'], row['episode_id'], row['earliest_import']
else:
season, episode, episode_id, earliest_import = row[0], row[1], row[2], row[3]
if earliest_import:
if isinstance(earliest_import, str):
dt = datetime.fromisoformat(earliest_import.replace("Z", "+00:00"))
else:
dt = earliest_import.replace(tzinfo=timezone.utc)
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
results[(season, episode)] = (date_iso, "sonarr:db.bulk.import")
else:
results[(season, episode)] = (None, "sonarr:db.bulk.no_import")
except Exception as e:
_log("ERROR", f"Bulk query error for series {series_id}: {e}")
return results
def get_database_stats(self) -> Dict[str, Any]:
"""Get basic statistics about the Sonarr database"""
stats = {}
queries = {
"total_series": 'SELECT COUNT(*) FROM "Series"',
"total_episodes": 'SELECT COUNT(*) FROM "Episodes"',
"total_episode_files": 'SELECT COUNT(*) FROM "EpisodeFiles"',
"total_history_events": 'SELECT COUNT(*) FROM "History"',
"import_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 3',
"grab_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 1'
}
try:
with self._get_connection() as conn:
cursor = conn.cursor()
for stat_name, query in queries.items():
cursor.execute(query)
result = cursor.fetchone()
stats[stat_name] = result[0] if result else 0
except Exception as e:
_log("ERROR", f"Stats query error: {e}")
stats["error"] = str(e)
return stats
def health_check(self) -> Dict[str, Any]:
"""
Comprehensive health check for the Sonarr database connection
Returns:
Dictionary with health status, connection info, and basic functionality tests
"""
health = {
"status": "healthy",
"database_type": self.db_type,
"connection": "ok",
"readable": False,
"tables_exist": False,
"sample_data": False,
"issues": [],
"tested_at": datetime.now(timezone.utc).isoformat(timespec="seconds")
}
try:
with self._get_connection() as conn:
cursor = conn.cursor()
# Test 1: Basic read
try:
cursor.execute('SELECT 1')
result = cursor.fetchone()
if result and result[0] == 1:
health["readable"] = True
health["connection"] = "readable"
else:
health["issues"].append("Basic SELECT query failed")
except Exception as e:
health["issues"].append(f"Read test failed: {e}")
health["status"] = "degraded"
# Test 2: Check required tables
required_tables = ["Series", "Episodes", "EpisodeFiles", "History"]
existing_tables = []
try:
if self.db_type == "postgresql":
cursor.execute("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('Series', 'Episodes', 'EpisodeFiles', 'History')
""")
else: # SQLite
cursor.execute("""
SELECT name
FROM sqlite_master
WHERE type='table'
AND name IN ('Series', 'Episodes', 'EpisodeFiles', 'History')
""")
rows = cursor.fetchall()
existing_tables = [row[0] for row in rows]
if len(existing_tables) == len(required_tables):
health["tables_exist"] = True
else:
missing = set(required_tables) - set(existing_tables)
health["issues"].append(f"Missing tables: {list(missing)}")
health["status"] = "degraded"
health["existing_tables"] = existing_tables
except Exception as e:
health["issues"].append(f"Table check failed: {e}")
health["status"] = "degraded"
# Test 3: Check for sample data
if health["tables_exist"]:
try:
cursor.execute('SELECT COUNT(*) FROM "Series"')
series_count = cursor.fetchone()[0]
cursor.execute('SELECT COUNT(*) FROM "Episodes"')
episode_count = cursor.fetchone()[0]
cursor.execute('SELECT COUNT(*) FROM "History"')
history_count = cursor.fetchone()[0]
if series_count > 0 and episode_count > 0:
health["sample_data"] = True
health["series_count"] = series_count
health["episode_count"] = episode_count
health["history_count"] = history_count
else:
health["issues"].append(f"Low data counts - Series: {series_count}, Episodes: {episode_count}")
except Exception as e:
health["issues"].append(f"Sample data check failed: {e}")
# Test 4: Test a real query
if health["sample_data"]:
try:
cursor.execute("""
SELECT COUNT(*)
FROM "Series"
WHERE "ImdbId" IS NOT NULL
""")
imdb_series = cursor.fetchone()[0]
health["series_with_imdb"] = imdb_series
if imdb_series > 0:
health["functional"] = True
else:
health["issues"].append("No series with IMDb IDs found")
except Exception as e:
health["issues"].append(f"Functional test failed: {e}")
health["status"] = "degraded"
except Exception as e:
health["status"] = "error"
health["connection"] = "failed"
health["issues"].append(f"Connection failed: {e}")
_log("ERROR", f"Database health check failed: {e}")
# Overall status
if health["issues"]:
if health["status"] == "healthy":
health["status"] = "degraded"
# Add connection details (safe info only)
health["connection_info"] = {
"type": self.db_type,
"host": self.db_host if self.db_type == "postgresql" else None,
"port": self.db_port if self.db_type == "postgresql" else None,
"database": self.db_name if self.db_type == "postgresql" else None,
"path": self.db_path if self.db_type == "sqlite" else None
}
return health
if __name__ == "__main__":
# Test the database client
print("Testing SonarrDbClient...")
# Test with environment variables
client = SonarrDbClient.from_env()
if client:
print("✅ Connected to Sonarr database")
# Test stats
stats = client.get_database_stats()
print(f"Database stats: {stats}")
# Test series lookup
test_series = client.get_series_by_imdb("tt1628033") # Top Gear from your data
if test_series:
print(f"Found test series: {test_series}")
# Test episodes
series_id = test_series['id']
episodes = client.get_all_episodes_for_series(series_id)
print(f"Found {len(episodes)} episodes")
# Test bulk import dates
if episodes:
import_dates = client.bulk_import_dates_for_series(series_id)
print(f"Import dates for {len(import_dates)} episodes")
else:
print("Test series not found")
else:
print("❌ Could not connect to database - check environment variables")
-473
View File
@@ -1,473 +0,0 @@
"""
Async NFO Manager for NFOGuard
High-performance async NFO file operations with concurrent processing
"""
import asyncio
from pathlib import Path
from typing import Optional, List, Dict, Any, Tuple
from datetime import datetime
import xml.etree.ElementTree as ET
from utils.logging import _log
from utils.async_file_utils import (
async_read_nfo_file,
async_write_nfo_file,
async_file_exists,
async_set_file_mtime,
async_batch_nfo_operations,
async_concurrent_episode_processing,
aiofiles
)
from utils.nfo_patterns import (
create_basic_nfo_structure,
extract_imdb_from_nfo_content,
extract_dates_from_nfo,
extract_imdb_id_from_text
)
from utils.validation import validate_date_string
from utils.file_utils import VIDEO_EXTENSIONS
class AsyncNFOManager:
"""Async NFO file manager with concurrent processing capabilities"""
def __init__(self, manager_brand: str = "NFOGuard", debug: bool = False):
self.manager_brand = manager_brand
self.debug = debug
async def _async_get_target_nfo_path(self, season_dir: Path, season: int, episode: int) -> Path:
"""
Get the target NFO path using video filename matching to prevent concatenation
Args:
season_dir: Path to season directory
season: Season number
episode: Episode number
Returns:
Path to target NFO file (video-matching preferred, generic fallback)
"""
try:
# Find video files in the season directory
video_files = []
if await async_file_exists(season_dir):
import re
try:
entries = await aiofiles.os.listdir(season_dir)
for entry in entries:
entry_path = season_dir / entry
if await aiofiles.os.path.isfile(entry_path):
if entry_path.suffix.lower() in VIDEO_EXTENSIONS:
# Parse episode info from filename
match = re.search(r'[Ss](\d{1,2})[Ee](\d{1,2})', entry)
if match:
s, e = int(match.group(1)), int(match.group(2))
if s == season and e == episode:
# Found matching video file - use its name for NFO
target_nfo = season_dir / f"{entry_path.stem}.nfo"
if self.debug:
_log("DEBUG", f"Video-matching NFO path: {target_nfo.name}")
return target_nfo
except Exception as e:
if self.debug:
_log("WARNING", f"Failed to scan season directory {season_dir}: {e}")
# Fallback to generic filename if no matching video found
target_nfo = season_dir / f"S{season:02d}E{episode:02d}.nfo"
if self.debug:
_log("WARNING", f"No video file found for S{season:02d}E{episode:02d}, using generic: {target_nfo.name}")
return target_nfo
except Exception as e:
_log("ERROR", f"Failed to determine NFO path for S{season:02d}E{episode:02d}: {e}")
# Emergency fallback
return season_dir / f"S{season:02d}E{episode:02d}.nfo"
async def async_parse_imdb_from_path(self, path: Path) -> Optional[str]:
"""
Async extract IMDb ID from directory path or filename
Args:
path: Path to examine
Returns:
IMDb ID if found, None otherwise
"""
# Use the sync version since it's just string processing
return extract_imdb_id_from_text(str(path))
async def async_parse_imdb_from_nfo(self, nfo_path: Path) -> Optional[str]:
"""
Async extract IMDb ID from NFO file content
Args:
nfo_path: Path to NFO file
Returns:
IMDb ID if found, None otherwise
"""
root = await async_read_nfo_file(nfo_path)
if root is None:
return None
return extract_imdb_from_nfo_content(root)
async def async_find_movie_imdb_id(self, movie_dir: Path) -> Optional[str]:
"""
Async find IMDb ID from directory name, filenames, or NFO file
Args:
movie_dir: Path to movie directory
Returns:
IMDb ID if found, None otherwise
"""
if self.debug:
_log("DEBUG", f"Async searching for IMDb ID in: {movie_dir.name}")
# First try directory name
imdb_id = await self.async_parse_imdb_from_path(movie_dir)
if imdb_id:
if self.debug:
_log("DEBUG", f"Found IMDb ID in directory name: {imdb_id}")
return imdb_id
# Try all files in the directory concurrently
if await async_file_exists(movie_dir):
try:
from utils.async_file_utils import aiofiles
entries = await aiofiles.os.listdir(movie_dir)
# Create tasks to check all files
async def check_file(filename: str) -> Optional[str]:
file_path = movie_dir / filename
if await aiofiles.os.path.isfile(file_path):
return await self.async_parse_imdb_from_path(file_path)
return None
# Check all files concurrently
results = await asyncio.gather(
*[check_file(filename) for filename in entries],
return_exceptions=True
)
# Find first valid IMDb ID
for result in results:
if isinstance(result, str) and result:
if self.debug:
_log("DEBUG", f"Found IMDb ID in filename: {result}")
return result
except Exception as e:
_log("WARNING", f"Failed to scan directory {movie_dir}: {e}")
# Finally, try NFO file content
nfo_path = movie_dir / "movie.nfo"
imdb_id = await self.async_parse_imdb_from_nfo(nfo_path)
if imdb_id:
if self.debug:
_log("DEBUG", f"Found IMDb ID in NFO file: {imdb_id}")
return imdb_id
if self.debug:
_log("DEBUG", f"No IMDb ID found for: {movie_dir.name}")
return None
async def async_create_movie_nfo(
self,
movie_dir: Path,
imdb_id: str,
dateadded: str,
premiered: Optional[str] = None,
lock_metadata: bool = True
) -> bool:
"""
Async create movie NFO file
Args:
movie_dir: Path to movie directory
imdb_id: IMDb ID
dateadded: Date added
premiered: Optional premiere date
lock_metadata: Whether to lock metadata
Returns:
True if successful, False otherwise
"""
try:
nfo_path = movie_dir / "movie.nfo"
# Prepare dates
dates = {"dateadded": dateadded}
if premiered and validate_date_string(premiered):
dates["premiered"] = premiered
# Create NFO structure
root = create_basic_nfo_structure(
media_type="movie",
title=movie_dir.name,
imdb_id=imdb_id,
dates=dates,
additional_fields={"source": self.manager_brand}
)
# Write NFO file asynchronously
success = await async_write_nfo_file(nfo_path, root, lock_metadata)
if success and self.debug:
_log("DEBUG", f"Created movie NFO: {nfo_path}")
return success
except Exception as e:
_log("ERROR", f"Failed to create movie NFO for {movie_dir}: {e}")
return False
async def async_create_episode_nfo(
self,
season_dir: Path,
season: int,
episode: int,
aired: Optional[str] = None,
dateadded: Optional[str] = None,
source: str = "unknown",
lock_metadata: bool = True
) -> bool:
"""
Async create episode NFO file with proper video filename matching
Args:
season_dir: Path to season directory
season: Season number
episode: Episode number
aired: Optional air date
dateadded: Optional date added
source: Source of the data
lock_metadata: Whether to lock metadata
Returns:
True if successful, False otherwise
"""
try:
# Use proper video filename matching to prevent NFO concatenation
nfo_path = await self._async_get_target_nfo_path(season_dir, season, episode)
# Prepare dates
dates = {}
if aired and validate_date_string(aired):
dates["aired"] = aired
if dateadded and validate_date_string(dateadded):
dates["dateadded"] = dateadded
# Create NFO structure
root = create_basic_nfo_structure(
media_type="episodedetails",
title=f"S{season:02d}E{episode:02d}",
dates=dates,
additional_fields={
"season": str(season),
"episode": str(episode),
"source": source
}
)
# Write NFO file asynchronously
success = await async_write_nfo_file(nfo_path, root, lock_metadata)
if success and self.debug:
_log("DEBUG", f"Created episode NFO: {nfo_path}")
return success
except Exception as e:
_log("ERROR", f"Failed to create episode NFO S{season:02d}E{episode:02d}: {e}")
return False
async def async_batch_create_episode_nfos(
self,
episode_data_list: List[Dict[str, Any]],
max_concurrent: int = 5
) -> List[bool]:
"""
Batch create multiple episode NFOs concurrently
Args:
episode_data_list: List of episode data dictionaries
max_concurrent: Maximum concurrent NFO operations
Returns:
List of success/failure results
"""
async def _create_episode_nfo(episode_data: Dict[str, Any]) -> bool:
return await self.async_create_episode_nfo(
season_dir=episode_data.get('season_dir'),
season=episode_data.get('season'),
episode=episode_data.get('episode'),
aired=episode_data.get('aired'),
dateadded=episode_data.get('dateadded'),
source=episode_data.get('source', 'unknown'),
lock_metadata=episode_data.get('lock_metadata', True)
)
return await async_concurrent_episode_processing(
episode_data_list,
_create_episode_nfo,
max_concurrent
)
async def async_set_file_mtime(self, file_path: Path, date_str: str) -> bool:
"""
Async set file modification time from date string
Args:
file_path: Path to file
date_str: Date string in ISO format
Returns:
True if successful, False otherwise
"""
try:
if not validate_date_string(date_str):
_log("WARNING", f"Invalid date format for mtime: {date_str}")
return False
# Parse date string to timestamp
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
timestamp = dt.timestamp()
# Set file mtime asynchronously
success = await async_set_file_mtime(file_path, timestamp)
if success and self.debug:
_log("DEBUG", f"Set mtime for {file_path}: {date_str}")
return success
except Exception as e:
_log("ERROR", f"Failed to set mtime for {file_path}: {e}")
return False
async def async_batch_set_file_mtimes(
self,
file_mtime_pairs: List[Tuple[Path, str]],
max_concurrent: int = 10
) -> List[bool]:
"""
Batch set file modification times concurrently
Args:
file_mtime_pairs: List of (file_path, date_str) tuples
max_concurrent: Maximum concurrent operations
Returns:
List of success/failure results
"""
async def _set_single_mtime(file_path: Path, date_str: str) -> bool:
return await self.async_set_file_mtime(file_path, date_str)
# Create tasks for all mtime operations
semaphore = asyncio.Semaphore(max_concurrent)
async def _set_mtime_with_semaphore(file_path: Path, date_str: str) -> bool:
async with semaphore:
return await _set_single_mtime(file_path, date_str)
tasks = [
_set_mtime_with_semaphore(file_path, date_str)
for file_path, date_str in file_mtime_pairs
]
results = await asyncio.gather(*tasks, return_exceptions=True)
return [result if isinstance(result, bool) else False for result in results]
async def async_validate_nfo_integrity(
self,
nfo_paths: List[Path],
max_concurrent: int = 10
) -> Dict[str, Any]:
"""
Async validate integrity of multiple NFO files
Args:
nfo_paths: List of NFO file paths to validate
max_concurrent: Maximum concurrent validations
Returns:
Dictionary with validation results and statistics
"""
results = {
'total_files': len(nfo_paths),
'valid_files': 0,
'invalid_files': 0,
'missing_files': 0,
'validation_errors': [],
'file_results': {}
}
async def _validate_single_nfo(nfo_path: Path) -> Dict[str, Any]:
file_result = {
'path': str(nfo_path),
'exists': False,
'valid_xml': False,
'has_imdb_id': False,
'has_dates': False,
'error': None
}
try:
# Check if file exists
if not await async_file_exists(nfo_path):
file_result['error'] = 'File does not exist'
return file_result
file_result['exists'] = True
# Try to parse NFO
root = await async_read_nfo_file(nfo_path)
if root is None:
file_result['error'] = 'Failed to parse XML'
return file_result
file_result['valid_xml'] = True
# Check for IMDb ID
imdb_id = extract_imdb_from_nfo_content(root)
file_result['has_imdb_id'] = bool(imdb_id)
# Check for dates
dates = extract_dates_from_nfo(root)
file_result['has_dates'] = any(dates.values())
except Exception as e:
file_result['error'] = str(e)
return file_result
# Validate all files concurrently
semaphore = asyncio.Semaphore(max_concurrent)
async def _validate_with_semaphore(nfo_path: Path) -> Dict[str, Any]:
async with semaphore:
return await _validate_single_nfo(nfo_path)
tasks = [_validate_with_semaphore(nfo_path) for nfo_path in nfo_paths]
file_results = await asyncio.gather(*tasks, return_exceptions=True)
# Process results
for i, result in enumerate(file_results):
if isinstance(result, dict):
path_str = str(nfo_paths[i])
results['file_results'][path_str] = result
if not result['exists']:
results['missing_files'] += 1
elif result['valid_xml']:
results['valid_files'] += 1
else:
results['invalid_files'] += 1
if result.get('error'):
results['validation_errors'].append(f"{path_str}: {result['error']}")
return results
+252 -5
View File
@@ -108,6 +108,8 @@ class NFOGuardDatabase:
cursor.execute("""
CREATE TABLE IF NOT EXISTS movies (
imdb_id VARCHAR(20) PRIMARY KEY,
title TEXT,
year INTEGER,
path TEXT NOT NULL,
released DATE,
dateadded TIMESTAMP,
@@ -117,6 +119,44 @@ class NFOGuardDatabase:
)
""")
# Add title and year columns if they don't exist (migration for existing databases)
cursor.execute("""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name='movies' AND column_name='title') THEN
ALTER TABLE movies ADD COLUMN title TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name='movies' AND column_name='year') THEN
ALTER TABLE movies ADD COLUMN year INTEGER;
END IF;
END $$;
""")
# Add skipped and skip_reason columns if they don't exist (migration for skip tracking)
cursor.execute("""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name='movies' AND column_name='skipped') THEN
ALTER TABLE movies ADD COLUMN skipped BOOLEAN DEFAULT FALSE;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name='movies' AND column_name='skip_reason') THEN
ALTER TABLE movies ADD COLUMN skip_reason TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name='episodes' AND column_name='skipped') THEN
ALTER TABLE episodes ADD COLUMN skipped BOOLEAN DEFAULT FALSE;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name='episodes' AND column_name='skip_reason') THEN
ALTER TABLE episodes ADD COLUMN skip_reason TEXT;
END IF;
END $$;
""")
# Processing history table
cursor.execute("""
CREATE TABLE IF NOT EXISTS processing_history (
@@ -255,25 +295,28 @@ class NFOGuardDatabase:
""", (imdb_id, path, timestamp))
def upsert_movie_dates(self, imdb_id: str, released: Optional[str],
dateadded: Optional[str], source: str, has_video_file: bool = False):
dateadded: Optional[str], source: str, has_video_file: bool = False,
title: Optional[str] = None, year: Optional[int] = None):
"""Insert or update movie date record"""
import os
if os.environ.get("DEBUG", "false").lower() == "true":
print(f"🔍 DATABASE UPSERT: imdb_id={imdb_id}, dateadded={dateadded}, source={source}")
print(f"🔍 DATABASE UPSERT: imdb_id={imdb_id}, title={title}, dateadded={dateadded}, source={source}")
with self.get_connection() as conn:
cursor = conn.cursor()
timestamp = datetime.utcnow()
cursor.execute("""
INSERT INTO movies (imdb_id, path, released, dateadded, source, has_video_file, last_updated)
VALUES (%s, COALESCE((SELECT path FROM movies WHERE imdb_id = %s), 'unknown'), %s, %s, %s, %s, %s)
INSERT INTO movies (imdb_id, title, year, path, released, dateadded, source, has_video_file, last_updated)
VALUES (%s, %s, %s, COALESCE((SELECT path FROM movies WHERE imdb_id = %s), 'unknown'), %s, %s, %s, %s, %s)
ON CONFLICT (imdb_id) DO UPDATE SET
title = EXCLUDED.title,
year = EXCLUDED.year,
released = EXCLUDED.released,
dateadded = EXCLUDED.dateadded,
source = EXCLUDED.source,
has_video_file = EXCLUDED.has_video_file,
last_updated = EXCLUDED.last_updated
""", (imdb_id, imdb_id, released, dateadded, source, has_video_file, timestamp))
""", (imdb_id, title, year, imdb_id, released, dateadded, source, has_video_file, timestamp))
# Debug: Check what was actually saved
cursor.execute("SELECT dateadded, source FROM movies WHERE imdb_id = %s", (imdb_id,))
@@ -282,6 +325,74 @@ class NFOGuardDatabase:
if os.environ.get("DEBUG", "false").lower() == "true":
print(f"🔍 DATABASE VERIFY: After upsert, found dateadded={result['dateadded'] if result else 'NOT_FOUND'}, source={result['source'] if result else 'NOT_FOUND'}")
def mark_movie_skipped(self, imdb_id: str, title: str, year: int, path: str, reason: str):
"""Mark a movie as skipped with reason"""
with self.get_connection() as conn:
cursor = conn.cursor()
timestamp = datetime.utcnow()
cursor.execute("""
INSERT INTO movies (imdb_id, title, year, path, skipped, skip_reason, has_video_file, last_updated)
VALUES (%s, %s, %s, %s, TRUE, %s, FALSE, %s)
ON CONFLICT (imdb_id) DO UPDATE SET
title = EXCLUDED.title,
year = EXCLUDED.year,
path = EXCLUDED.path,
skipped = TRUE,
skip_reason = EXCLUDED.skip_reason,
last_updated = EXCLUDED.last_updated
""", (imdb_id, title, year, path, reason, timestamp))
def mark_episode_skipped(self, imdb_id: str, season: int, episode: int, reason: str):
"""Mark an episode as skipped with reason"""
with self.get_connection() as conn:
cursor = conn.cursor()
timestamp = datetime.utcnow()
cursor.execute("""
INSERT INTO episodes (imdb_id, season, episode, skipped, skip_reason, has_video_file, last_updated)
VALUES (%s, %s, %s, TRUE, %s, FALSE, %s)
ON CONFLICT (imdb_id, season, episode) DO UPDATE SET
skipped = TRUE,
skip_reason = EXCLUDED.skip_reason,
last_updated = EXCLUDED.last_updated
""", (imdb_id, season, episode, reason, timestamp))
def clear_movie_skipped(self, imdb_id: str):
"""Clear skipped flag for a movie"""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE movies SET skipped = FALSE, skip_reason = NULL
WHERE imdb_id = %s
""", (imdb_id,))
def clear_episode_skipped(self, imdb_id: str, season: int, episode: int):
"""Clear skipped flag for an episode"""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE episodes SET skipped = FALSE, skip_reason = NULL
WHERE imdb_id = %s AND season = %s AND episode = %s
""", (imdb_id, season, episode))
def get_skipped_counts(self) -> Dict:
"""Get counts of skipped movies and episodes"""
with self.get_connection() as conn:
cursor = conn.cursor()
# Count skipped movies
cursor.execute("SELECT COUNT(*) as count FROM movies WHERE skipped = TRUE")
skipped_movies = cursor.fetchone()['count']
# Count skipped episodes
cursor.execute("SELECT COUNT(*) as count FROM episodes WHERE skipped = TRUE")
skipped_episodes = cursor.fetchone()['count']
return {
'movies': skipped_movies,
'episodes': skipped_episodes,
'total': skipped_movies + skipped_episodes
}
def get_series_episodes(self, imdb_id: str, has_video_file_only: bool = False) -> List[Dict]:
"""Get all episodes for a series"""
with self.get_connection() as conn:
@@ -326,6 +437,81 @@ class NFOGuardDatabase:
""", (imdb_id, media_type, event_type, datetime.utcnow().isoformat(),
json.dumps(details) if details else None))
def migrate_movie_imdb_id(self, old_imdb_id: str, new_imdb_id: str) -> bool:
"""Migrate a movie from placeholder IMDb ID to real IMDb ID"""
with self.get_connection() as conn:
cursor = conn.cursor()
# Check if old record exists
cursor.execute("SELECT * FROM movies WHERE imdb_id = %s", (old_imdb_id,))
old_record = cursor.fetchone()
if not old_record:
return False
old_data = dict(old_record)
# Check if new IMDb ID already exists
cursor.execute("SELECT * FROM movies WHERE imdb_id = %s", (new_imdb_id,))
if cursor.fetchone():
# New IMDb ID already exists, just delete the old one
cursor.execute("DELETE FROM movies WHERE imdb_id = %s", (old_imdb_id,))
return True
# Create new record with correct IMDb ID
cursor.execute("""
INSERT INTO movies (imdb_id, title, year, path, released, dateadded, source,
has_video_file, last_updated, skipped, skip_reason)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (new_imdb_id, old_data.get('title'), old_data.get('year'),
old_data.get('path'), old_data.get('released'), old_data.get('dateadded'),
old_data.get('source'), old_data.get('has_video_file'),
datetime.utcnow(), False, None)) # Clear skipped status
# Delete old placeholder record
cursor.execute("DELETE FROM movies WHERE imdb_id = %s", (old_imdb_id,))
return True
def migrate_series_imdb_id(self, old_imdb_id: str, new_imdb_id: str) -> bool:
"""Migrate a series and all its episodes from placeholder IMDb ID to real IMDb ID"""
with self.get_connection() as conn:
cursor = conn.cursor()
# Check if old series exists
cursor.execute("SELECT * FROM series WHERE imdb_id = %s", (old_imdb_id,))
old_series = cursor.fetchone()
if not old_series:
return False
old_series_data = dict(old_series)
# Check if new series IMDb ID already exists
cursor.execute("SELECT * FROM series WHERE imdb_id = %s", (new_imdb_id,))
if cursor.fetchone():
# New series already exists, migrate episodes and delete old series
cursor.execute("""
UPDATE episodes SET imdb_id = %s WHERE imdb_id = %s
""", (new_imdb_id, old_imdb_id))
cursor.execute("DELETE FROM series WHERE imdb_id = %s", (old_imdb_id,))
return True
# Create new series record
cursor.execute("""
INSERT INTO series (imdb_id, path, last_updated, metadata)
VALUES (%s, %s, %s, %s)
""", (new_imdb_id, old_series_data.get('path'),
datetime.utcnow(), old_series_data.get('metadata')))
# Migrate all episodes to new series IMDb ID and clear skipped status
cursor.execute("""
UPDATE episodes
SET imdb_id = %s, skipped = FALSE, skip_reason = NULL
WHERE imdb_id = %s
""", (new_imdb_id, old_imdb_id))
# Delete old placeholder series
cursor.execute("DELETE FROM series WHERE imdb_id = %s", (old_imdb_id,))
return True
def get_stats(self) -> Dict[str, Any]:
"""Get database statistics"""
with self.get_connection() as conn:
@@ -532,6 +718,67 @@ class NFOGuardDatabase:
return deleted_count > 0
def update_movie_file_info(self, imdb_id: str, path: str, has_video_file: bool = True) -> bool:
"""
Update path and video file status for an existing movie
Used when population finds a video file for a manually-added movie
Args:
imdb_id: Movie IMDb ID
path: File path to the video file
has_video_file: Whether a video file exists (default True)
Returns:
True if movie was updated, False if not found
"""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE movies
SET path = %s,
has_video_file = %s,
last_updated = %s
WHERE imdb_id = %s
""", (path, has_video_file, datetime.utcnow(), imdb_id))
updated_count = cursor.rowcount
conn.commit()
return updated_count > 0
def update_episode_file_info(self, imdb_id: str, season: int, episode: int,
path: str, has_video_file: bool = True) -> bool:
"""
Update path and video file status for an existing episode
Used when population finds a video file for a manually-added episode
Args:
imdb_id: Series IMDb ID
season: Season number
episode: Episode number
path: File path to the video file
has_video_file: Whether a video file exists (default True)
Returns:
True if episode was updated, False if not found
"""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE episodes
SET path = %s,
has_video_file = %s,
last_updated = %s
WHERE imdb_id = %s AND season = %s AND episode = %s
""", (path, has_video_file, datetime.utcnow(), imdb_id, season, episode))
updated_count = cursor.rowcount
conn.commit()
return updated_count > 0
def delete_orphaned_movies(self) -> List[Dict]:
"""
Find and delete movies that don't have corresponding video files on disk
+476
View File
@@ -0,0 +1,476 @@
#!/usr/bin/env python3
"""
Database Populator for NFOGuard
Bulk populates the NFOGuard database from Radarr/Sonarr
Phase 4: Replace NFO-based initial population with direct DB/API queries
"""
import time
import hashlib
from typing import Dict, List, Optional, Tuple
from datetime import datetime
from pathlib import Path
from core.database import NFOGuardDatabase
from clients.radarr_client import RadarrClient
from clients.sonarr_client import SonarrClient
from utils.logging import _log
from utils.imdb_utils import parse_imdb_from_path
class DatabasePopulator:
"""Populates NFOGuard database from Radarr/Sonarr sources"""
def __init__(self, db: NFOGuardDatabase, radarr_client: RadarrClient, sonarr_client: SonarrClient):
self.db = db
self.radarr = radarr_client
self.sonarr = sonarr_client
def populate_movies(self) -> Dict[str, any]:
"""
Populate movies from Radarr database/API
Returns:
Dictionary with statistics: {
'total': int,
'added': int,
'updated': int,
'skipped': int,
'errors': int,
'duration': float
}
"""
_log("INFO", "Starting movie population from Radarr")
start_time = time.time()
stats = {
'total': 0,
'added': 0,
'updated': 0,
'skipped': 0,
'errors': 0,
'duration': 0.0,
'skipped_items': [] # Track what was skipped and why
}
try:
# Get all movies from Radarr database
if not hasattr(self.radarr, 'db_client') or not self.radarr.db_client:
_log("ERROR", "Radarr database client not available - cannot populate movies")
stats['errors'] += 1
return stats
movies = self.radarr.db_client.get_all_movies()
if not movies:
_log("WARNING", "No movies found in Radarr database")
return stats
stats['total'] = len(movies)
_log("INFO", f"Found {stats['total']} movies in Radarr")
# Process each movie
for movie in movies:
try:
# Get movie path first (we'll need it for IMDb extraction)
path = movie.get('path', '')
# Try to get IMDb ID from Radarr database
imdb_id = movie.get('imdb_id')
# If not in database, try extracting from directory/filename
if not imdb_id and path:
imdb_id = parse_imdb_from_path(Path(path))
if imdb_id:
_log("DEBUG", f"Extracted IMDb ID {imdb_id} from path for: {movie.get('title')}")
if not imdb_id:
# Generate placeholder IMDb ID using hash of path
path_hash = hashlib.md5(path.encode()).hexdigest()[:12]
imdb_id = f"missing-{path_hash}"
skip_reason = 'No IMDb ID found'
skip_info = {
'title': movie.get('title', 'Unknown'),
'year': movie.get('year'),
'imdb_id': imdb_id,
'path': path,
'reason': skip_reason
}
stats['skipped_items'].append(skip_info)
_log("DEBUG", f"Movie without IMDb ID: {movie.get('title')} (path: {path}), using placeholder {imdb_id}")
# Mark as skipped in database with placeholder IMDb ID
self.db.mark_movie_skipped(
imdb_id=imdb_id,
title=movie.get('title', 'Unknown'),
year=movie.get('year', 0),
path=path,
reason=skip_reason
)
stats['skipped'] += 1
continue
# Check if movie already exists in database
existing = self.db.get_movie_dates(imdb_id)
if existing and existing.get('dateadded'):
# Already in database - update file path and video status if needed
existing_path = existing.get('path')
if not existing_path or existing_path == 'unknown' or existing_path != path:
_log("INFO", f"Movie {imdb_id} exists but updating file info: {path}")
self.db.update_movie_file_info(imdb_id, path, has_video_file=True)
# Add to processing history
try:
self.db.add_processing_history(
imdb_id=imdb_id,
media_type='movie',
event_type='file_info_update',
details={'path': path}
)
except Exception as e:
_log("WARNING", f"Failed to add processing history for {imdb_id}: {e}")
stats['updated'] += 1
else:
_log("DEBUG", f"Movie {imdb_id} already in database with correct path, skipping")
continue
# Get release date
released = None
if movie.get('digital_release'):
released = movie.get('digital_release')
source_type = 'radarr:digital'
elif movie.get('physical_release'):
released = movie.get('physical_release')
source_type = 'radarr:physical'
elif movie.get('in_cinemas'):
released = movie.get('in_cinemas')
source_type = 'radarr:theatrical'
else:
source_type = 'radarr:unknown'
# Get import date from Radarr history using Radarr's internal movie ID
radarr_movie_id = movie.get('id')
if radarr_movie_id:
# get_movie_import_date returns tuple (date, source)
import_date, import_source = self.radarr.get_movie_import_date(radarr_movie_id)
if import_date:
dateadded = import_date
source = import_source
elif released:
# Use release date as fallback
dateadded = released
source = f'{source_type}_fallback'
else:
skip_reason = 'No import date in Radarr history and no release dates available'
skip_info = {
'title': movie.get('title', 'Unknown'),
'year': movie.get('year'),
'imdb_id': imdb_id,
'reason': skip_reason
}
stats['skipped_items'].append(skip_info)
_log("DEBUG", f"No date available for movie {imdb_id}, skipping")
# Mark as skipped in database for troubleshooting
self.db.mark_movie_skipped(
imdb_id=imdb_id,
title=movie.get('title', 'Unknown'),
year=movie.get('year', 0),
path=path or 'unknown',
reason=skip_reason
)
stats['skipped'] += 1
continue
elif released:
# No Radarr ID, use release date
dateadded = released
source = f'{source_type}_fallback'
else:
skip_reason = 'No Radarr movie ID and no release dates available'
skip_info = {
'title': movie.get('title', 'Unknown'),
'year': movie.get('year'),
'imdb_id': imdb_id,
'reason': skip_reason
}
stats['skipped_items'].append(skip_info)
_log("DEBUG", f"No date available for movie {imdb_id}, skipping")
# Mark as skipped in database for troubleshooting
self.db.mark_movie_skipped(
imdb_id=imdb_id,
title=movie.get('title', 'Unknown'),
year=movie.get('year', 0),
path=path or 'unknown',
reason=skip_reason
)
stats['skipped'] += 1
continue
# Insert into database with title and year
title = movie.get('title')
year = movie.get('year')
self.db.upsert_movie_dates(
imdb_id, released, dateadded, source,
has_video_file=True, title=title, year=year
)
# Add to processing history
try:
self.db.add_processing_history(
imdb_id=imdb_id,
media_type='movie',
event_type='database_population',
details={'source': source, 'title': title}
)
except Exception as e:
_log("WARNING", f"Failed to add processing history for {imdb_id}: {e}")
stats['added'] += 1
_log("DEBUG", f"Added movie {imdb_id}: {title} ({year}) (source: {source})")
except Exception as e:
_log("ERROR", f"Error processing movie {movie.get('title', 'unknown')}: {e}")
stats['errors'] += 1
continue
except Exception as e:
_log("ERROR", f"Error during movie population: {e}")
stats['errors'] += 1
stats['duration'] = time.time() - start_time
_log("INFO", f"Movie population complete: {stats['added']} added, {stats['skipped']} skipped, {stats['errors']} errors in {stats['duration']:.2f}s")
# Log details of skipped items
if stats['skipped_items']:
_log("INFO", f"Skipped items details ({len(stats['skipped_items'])} total):")
for item in stats['skipped_items']:
_log("INFO", f" - {item['title']} ({item.get('year', 'N/A')}) [{item.get('imdb_id', 'No IMDb')}]: {item['reason']}")
return stats
def populate_tv_episodes(self) -> Dict[str, any]:
"""
Populate TV episodes from Sonarr API
Returns:
Dictionary with statistics: {
'total_series': int,
'total_episodes': int,
'added': int,
'updated': int,
'skipped': int,
'errors': int,
'duration': float
}
"""
_log("INFO", "Starting TV episode population from Sonarr")
start_time = time.time()
stats = {
'total_series': 0,
'total_episodes': 0,
'added': 0,
'updated': 0,
'skipped': 0,
'errors': 0,
'duration': 0.0,
'skipped_items': [] # Track what was skipped and why
}
try:
# Get all series from Sonarr
all_series = self.sonarr.get_all_series()
if not all_series:
_log("WARNING", "No series found in Sonarr")
return stats
stats['total_series'] = len(all_series)
_log("INFO", f"Found {stats['total_series']} series in Sonarr")
# Process each series
for series in all_series:
try:
imdb_id = series.get('imdbId')
series_id = series.get('id')
series_path = series.get('path', '')
series_title = series.get('title', 'Unknown')
if not imdb_id:
# Generate placeholder IMDb ID using hash of path
path_hash = hashlib.md5(series_path.encode()).hexdigest()[:12]
imdb_id = f"missing-{path_hash}"
_log("DEBUG", f"Series without IMDb ID: {series_title} (path: {series_path}), using placeholder {imdb_id}")
# Update series record
self.db.upsert_series(imdb_id, series_path)
# Try high-performance database bulk query first
sonarr_db = getattr(self.sonarr, 'db_client', None)
bulk_import_dates = {}
if sonarr_db:
try:
_log("DEBUG", f"Using DB bulk query for {series_title}")
bulk_import_dates = sonarr_db.bulk_import_dates_for_series(series_id)
_log("DEBUG", f"✅ Got {len(bulk_import_dates)} import dates from DB for {series_title}")
except Exception as e:
_log("WARNING", f"DB bulk query failed for {series_title}, falling back to API: {e}")
# Get all episodes for this series
episodes = self.sonarr.episodes_for_series(series_id)
if not episodes:
continue
_log("DEBUG", f"Processing {len(episodes)} episodes for {series_title}")
# Process each episode
for episode in episodes:
try:
season_num = episode.get('seasonNumber', 0)
episode_num = episode.get('episodeNumber', 0)
episode_title = episode.get('title', 'Unknown')
if season_num < 0 or episode_num <= 0:
continue
stats['total_episodes'] += 1
# Check if episode already exists
existing = self.db.get_episode_date(imdb_id, season_num, episode_num)
if existing and existing.get('dateadded'):
# Already in database - update file path and video status if needed
existing_path = existing.get('path')
episode_path = episode.get('path', 'unknown')
if not existing_path or existing_path == 'unknown' or existing_path != episode_path:
_log("INFO", f"Episode {imdb_id} S{season_num:02d}E{episode_num:02d} exists but updating file info: {episode_path}")
self.db.update_episode_file_info(imdb_id, season_num, episode_num, episode_path, has_video_file=True)
# Add to processing history
try:
self.db.add_processing_history(
imdb_id=imdb_id,
media_type='episode',
event_type='file_info_update',
details={'season': season_num, 'episode': episode_num, 'path': episode_path}
)
except Exception as e:
_log("WARNING", f"Failed to add processing history for {imdb_id} S{season_num:02d}E{episode_num:02d}: {e}")
stats['updated'] += 1
continue
# Only process episodes that have video files
has_file = episode.get('hasFile', False)
if not has_file:
# No video file - skip silently (intentionally filtered)
continue
# Get air date
aired = episode.get('airDate')
# Get import date
dateadded = None
source = None
# Try bulk DB result first
if (season_num, episode_num) in bulk_import_dates:
dateadded, source = bulk_import_dates[(season_num, episode_num)]
# Fall back to API query
else:
episode_id = episode.get('id')
if episode_id:
import_date = self.sonarr.get_episode_import_history(episode_id)
if import_date:
dateadded = import_date
source = 'sonarr:api.import_history'
# Fallback to air date if no import date
if not dateadded and aired:
dateadded = aired
source = 'sonarr:aired_fallback'
elif not dateadded:
# No date available
skip_reason = 'No import date from Sonarr history and no air date available'
skip_info = {
'title': series_title,
'episode_title': episode_title,
'season': season_num,
'episode': episode_num,
'reason': skip_reason
}
stats['skipped_items'].append(skip_info)
# Mark as skipped in database for troubleshooting
self.db.mark_episode_skipped(
imdb_id=imdb_id,
season=season_num,
episode=episode_num,
reason=skip_reason
)
stats['skipped'] += 1
continue
# Insert into database
self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, has_file)
# Add to processing history
try:
self.db.add_processing_history(
imdb_id=imdb_id,
media_type='episode',
event_type='database_population',
details={'season': season_num, 'episode': episode_num, 'source': source, 'title': episode_title}
)
except Exception as e:
_log("WARNING", f"Failed to add processing history for {imdb_id} S{season_num:02d}E{episode_num:02d}: {e}")
stats['added'] += 1
except Exception as e:
_log("ERROR", f"Error processing episode S{season_num:02d}E{episode_num:02d} of {series_title}: {e}")
stats['errors'] += 1
continue
except Exception as e:
_log("ERROR", f"Error processing series {series.get('title', 'unknown')}: {e}")
stats['errors'] += 1
continue
except Exception as e:
_log("ERROR", f"Error during TV episode population: {e}")
stats['errors'] += 1
stats['duration'] = time.time() - start_time
_log("INFO", f"TV episode population complete: {stats['added']} added, {stats['skipped']} skipped, {stats['errors']} errors in {stats['duration']:.2f}s")
# Log details of skipped items
if stats['skipped_items']:
_log("INFO", f"Skipped episodes details ({len(stats['skipped_items'])} total):")
for item in stats['skipped_items'][:20]: # Only log first 20 to avoid spam
_log("INFO", f" - {item['title']} S{str(item['season']).zfill(2)}E{str(item['episode']).zfill(2)} ({item.get('episode_title', 'Unknown')}): {item['reason']}")
if len(stats['skipped_items']) > 20:
_log("INFO", f" ... and {len(stats['skipped_items']) - 20} more (see web interface for full list)")
return stats
def populate_all(self) -> Dict[str, any]:
"""
Populate both movies and TV episodes
Returns:
Combined statistics dictionary
"""
_log("INFO", "Starting full database population")
start_time = time.time()
movie_stats = self.populate_movies()
tv_stats = self.populate_tv_episodes()
combined_stats = {
'movies': movie_stats,
'tv': tv_stats,
'total_duration': time.time() - start_time
}
_log("INFO", f"Full database population complete in {combined_stats['total_duration']:.2f}s")
return combined_stats
-276
View File
@@ -1,276 +0,0 @@
#!/usr/bin/env python3
"""
Episode NFO Manager - Handles TV episode NFO creation with video filename matching
Core principle: NFO filenames should match video filenames
"""
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple
import re
from .logging import _log
class EpisodeNFOManager:
"""Manages episode NFO files with video filename matching"""
def __init__(self, manager_brand: str = "NFOGuard"):
self.manager_brand = manager_brand
def find_video_files_for_season(self, season_dir: Path) -> Dict[Tuple[int, int], List[Path]]:
"""Find all video files in season directory, grouped by (season, episode)"""
if not season_dir.exists():
_log("DEBUG", f"Season directory does not exist: {season_dir}")
return {}
video_extensions = [".mkv", ".mp4", ".avi", ".mov", ".m4v"]
episodes = {}
_log("DEBUG", f"Scanning video files in: {season_dir}")
for video_file in season_dir.iterdir():
_log("DEBUG", f"Checking file: {video_file.name} (is_file: {video_file.is_file()}, suffix: {video_file.suffix.lower()})")
if (video_file.is_file() and
video_file.suffix.lower() in video_extensions):
episode_info = self._parse_episode_from_filename(video_file.name)
_log("DEBUG", f"Episode parsing for '{video_file.name}': {episode_info}")
if episode_info:
season_num, episode_num = episode_info
key = (season_num, episode_num)
if key not in episodes:
episodes[key] = []
episodes[key].append(video_file)
_log("DEBUG", f"Added video file: S{season_num:02d}E{episode_num:02d}{video_file.name}")
_log("DEBUG", f"Total video files found: {len(episodes)} episodes")
return episodes
def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]:
"""Extract season and episode numbers from filename"""
# Try S##E## format first (most common)
match = re.search(r'[Ss](\d{1,2})[Ee](\d{1,2})', filename)
if match:
return int(match.group(1)), int(match.group(2))
# Try ##x## format
match = re.search(r'(\d{1,2})x(\d{1,2})', filename)
if match:
return int(match.group(1)), int(match.group(2))
return None
def find_nfo_for_episode(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
"""Find existing NFO file for episode (prefer video-matching filename)"""
if not season_dir.exists():
return None
# First, look for NFO files that match video filenames
video_files = self.find_video_files_for_season(season_dir)
key = (season_num, episode_num)
if key in video_files:
for video_file in video_files[key]:
potential_nfo = season_dir / f"{video_file.stem}.nfo"
if potential_nfo.exists():
_log("DEBUG", f"Found video-matching NFO: {potential_nfo.name}")
return potential_nfo
# Fallback: look for short name NFO
short_nfo = season_dir / f"S{season_num:02d}E{episode_num:02d}.nfo"
if short_nfo.exists():
_log("DEBUG", f"Found short-name NFO: {short_nfo.name}")
return short_nfo
# Last resort: search all NFO files for matching season/episode data
for nfo_file in season_dir.glob("*.nfo"):
if self._nfo_matches_episode(nfo_file, season_num, episode_num):
_log("DEBUG", f"Found matching NFO by content: {nfo_file.name}")
return nfo_file
return None
def _nfo_matches_episode(self, nfo_path: Path, season_num: int, episode_num: int) -> bool:
"""Check if NFO file contains the specified 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)
return file_season == season_num and file_episode == episode_num
except ValueError:
pass
except (ET.ParseError, Exception):
pass
return False
def get_target_nfo_path(self, season_dir: Path, season_num: int, episode_num: int) -> Path:
"""Get the target NFO path (prefer video filename, fallback to short name)"""
video_files = self.find_video_files_for_season(season_dir)
key = (season_num, episode_num)
if key in video_files and video_files[key]:
# Use the first video file found (handle multiple files gracefully)
video_file = video_files[key][0]
target_nfo = season_dir / f"{video_file.stem}.nfo"
_log("DEBUG", f"Target NFO will match video: {target_nfo.name}")
return target_nfo
else:
# Fallback to short name if no video file found
target_nfo = season_dir / f"S{season_num:02d}E{episode_num:02d}.nfo"
_log("WARNING", f"No video file found for S{season_num:02d}E{episode_num:02d}, using short name: {target_nfo.name}")
return target_nfo
def migrate_nfo_to_video_filename(self, season_dir: Path, season_num: int, episode_num: int) -> bool:
"""If short-name NFO exists, rename it to match video filename"""
existing_nfo = self.find_nfo_for_episode(season_dir, season_num, episode_num)
target_nfo = self.get_target_nfo_path(season_dir, season_num, episode_num)
# If we already have the right filename, nothing to do
if existing_nfo and existing_nfo == target_nfo:
return True
# If we have an NFO but it doesn't match target, rename it
if existing_nfo and existing_nfo != target_nfo:
try:
_log("INFO", f"Migrating NFO filename: {existing_nfo.name} -> {target_nfo.name}")
existing_nfo.rename(target_nfo)
return True
except Exception as e:
_log("ERROR", f"Failed to rename NFO: {e}")
return False
return False
def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int,
aired: Optional[str], dateadded: Optional[str], source: str,
title: Optional[str] = None, plot: Optional[str] = None) -> bool:
"""Create or update episode NFO with video filename matching"""
# Get the target NFO path (matching video filename)
nfo_path = self.get_target_nfo_path(season_dir, season_num, episode_num)
# Migrate existing NFO if needed
self.migrate_nfo_to_video_filename(season_dir, season_num, episode_num)
try:
# Load existing NFO if it exists
episode_elem = None
if nfo_path.exists():
try:
tree = ET.parse(nfo_path)
episode_elem = tree.getroot()
if episode_elem.tag != "episodedetails":
raise ValueError("Root element is not <episodedetails>")
# Remove NFOGuard-managed fields (we'll re-add them)
for tag in ["season", "episode", "aired", "premiered", "dateadded", "lockdata"]:
existing = episode_elem.find(tag)
if existing is not None:
episode_elem.remove(existing)
_log("DEBUG", f"Loaded existing NFO content for {nfo_path.name}")
except (ET.ParseError, ValueError) as e:
_log("WARNING", f"Corrupted NFO file {nfo_path.name}: {e}. Creating new one.")
episode_elem = None
# Create new structure if needed
if episode_elem is None:
episode_elem = ET.Element("episodedetails")
# Add title if provided and not already present
if title and not episode_elem.find("title"):
title_elem = ET.SubElement(episode_elem, "title")
title_elem.text = title
# Add plot if provided and not already present
if plot and not episode_elem.find("plot"):
plot_elem = ET.SubElement(episode_elem, "plot")
plot_elem.text = plot
# Add NFOGuard fields at the end
season_elem = ET.SubElement(episode_elem, "season")
season_elem.text = str(season_num)
episode_num_elem = ET.SubElement(episode_elem, "episode")
episode_num_elem.text = str(episode_num)
if aired:
aired_elem = ET.SubElement(episode_elem, "aired")
aired_elem.text = aired[:10] if len(aired) >= 10 else aired
# Also add premiered for compatibility
premiered_elem = ET.SubElement(episode_elem, "premiered")
premiered_elem.text = aired[:10] if len(aired) >= 10 else aired
if dateadded:
dateadded_elem = ET.SubElement(episode_elem, "dateadded")
dateadded_elem.text = dateadded
# Add lockdata
lockdata_elem = ET.SubElement(episode_elem, "lockdata")
lockdata_elem.text = "true"
# Add comment with source at the bottom
comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ")
episode_elem.append(comment)
# Write the NFO file
tree = ET.ElementTree(episode_elem)
ET.indent(tree, space=" ", level=0)
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
_log("INFO", f"✅ Created/updated episode NFO: {nfo_path.name}")
_log("INFO", f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, DateAdded: {dateadded}, Source: {source}")
return True
except Exception as e:
_log("ERROR", f"Failed to create episode NFO {nfo_path}: {e}")
return False
def extract_nfoguard_data(self, nfo_path: Path) -> Optional[Dict[str, str]]:
"""Extract NFOGuard-managed data from existing NFO"""
if not nfo_path.exists():
return None
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
if root.tag != "episodedetails":
return None
# Look for NFOGuard fields
dateadded_elem = root.find("dateadded")
aired_elem = root.find("aired")
lockdata_elem = root.find("lockdata")
# 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"):
result = {
"dateadded": dateadded_elem.text.strip(),
"source": "existing_nfo"
}
if aired_elem is not None and aired_elem.text:
result["aired"] = aired_elem.text.strip()
_log("DEBUG", f"Found NFOGuard data in {nfo_path.name}: {result}")
return result
except (ET.ParseError, Exception) as e:
_log("WARNING", f"Error parsing NFO {nfo_path.name}: {e}")
return None
-890
View File
@@ -1,890 +0,0 @@
#!/usr/bin/env python3
"""
NFO Manager for creating and managing metadata files
Handles NFO creation for movies, TV shows, seasons, and episodes
"""
import os
import xml.etree.ElementTree as ET
from pathlib import Path
from datetime import datetime
from typing import Optional, Dict, Any, Tuple
import re
class NFOManager:
"""Manages NFO file creation and updates"""
def __init__(self, manager_brand: str = "NFOGuard", debug: bool = False):
self.manager_brand = manager_brand
self.debug = debug
def parse_imdb_from_path(self, path: Path) -> Optional[str]:
"""Extract IMDb ID from directory path or filename"""
# Look for various IMDb patterns in both directory and file names
path_str = str(path).lower()
# Try [imdb-ttXXXXXXX] format first (most explicit)
match = re.search(r'\[imdb-?(tt\d+)\]', path_str)
if match:
return match.group(1)
# Try standalone [ttXXXXXXX] format in brackets
match = re.search(r'\[(tt\d+)\]', path_str)
if match:
return match.group(1)
# Try {imdb-ttXXXXXXX} format with curly braces
match = re.search(r'\{imdb-?(tt\d+)\}', path_str)
if match:
return match.group(1)
# Try (imdb-ttXXXXXXX) format with parentheses
match = re.search(r'\(imdb-?(tt\d+)\)', path_str)
if match:
return match.group(1)
# Try ttXXXXXXX at end of filename/dirname (common pattern)
match = re.search(r'[-_\s](tt\d+)$', path_str)
if match:
return match.group(1)
return None
def parse_imdb_from_path_with_nfo_fallback(self, path: Path, sonarr_client=None, shutdown_event=None) -> Optional[str]:
"""
Enhanced IMDb detection that fallback to Sonarr ID lookup from NFO files
1. First try to parse IMDb ID from directory path/name
2. If not found, scan NFO files in directory for Sonarr series ID
3. Use Sonarr API to lookup series and extract IMDb ID
"""
# Primary: Try directory name first
imdb_id = self.parse_imdb_from_path(path)
if imdb_id:
return imdb_id
# Check for shutdown signal before expensive operations
if shutdown_event and shutdown_event.is_set():
return None
# Fallback: Check NFO files for Sonarr series ID
if not sonarr_client or not sonarr_client.enabled:
return None
try:
# Look for episode NFO files in the directory (and subdirectories)
nfo_files = []
if path.is_dir():
# Check current directory and season subdirectories
nfo_files.extend(list(path.glob("*.nfo")))
for subdir in path.iterdir():
if subdir.is_dir() and subdir.name.lower().startswith('season'):
nfo_files.extend(list(subdir.glob("*.nfo")))
# Extract Sonarr series ID from any NFO file
for nfo_file in nfo_files:
# Check for shutdown signal during NFO processing
if shutdown_event and shutdown_event.is_set():
return None
try:
tree = ET.parse(nfo_file)
root = tree.getroot()
# Look for Sonarr series ID
for uniqueid in root.findall('.//uniqueid[@type="sonarr"]'):
sonarr_id = uniqueid.text
if sonarr_id and sonarr_id.isdigit():
# Check for shutdown signal before API call
if shutdown_event and shutdown_event.is_set():
return None
# Look up series in Sonarr to get IMDb ID
series_data = sonarr_client.get_series_by_id(int(sonarr_id))
if series_data:
imdb_id = series_data.get('imdbId')
if imdb_id:
return imdb_id.replace('tt', '') if imdb_id.startswith('tt') else imdb_id
except Exception as e:
continue # Skip this NFO file and try the next one
except Exception as e:
pass # Fallback failed, return None
return None
def parse_imdb_from_nfo(self, nfo_path: Path) -> Optional[str]:
"""Extract IMDb ID from NFO file content"""
if not nfo_path.exists():
return None
try:
root = self._parse_nfo_with_tolerance(nfo_path)
# Check for <uniqueid type="imdb">ttXXXXXX</uniqueid>
imdb_uniqueid = root.find('.//uniqueid[@type="imdb"]')
if imdb_uniqueid is not None and imdb_uniqueid.text:
imdb_id = imdb_uniqueid.text.strip()
if imdb_id.startswith('tt'):
return imdb_id
# Check for legacy <imdbid>ttXXXXXX</imdbid>
imdbid_elem = root.find('.//imdbid')
if imdbid_elem is not None and imdbid_elem.text:
imdb_id = imdbid_elem.text.strip()
if imdb_id.startswith('tt'):
return imdb_id
# Check for legacy <imdb>ttXXXXXX</imdb>
imdb_elem = root.find('.//imdb')
if imdb_elem is not None and imdb_elem.text:
imdb_id = imdb_elem.text.strip()
if imdb_id.startswith('tt'):
return imdb_id
# Last resort: Check for TMDB ID as fallback identifier
# This handles movies that only have TMDB IDs in NFO files
tmdb_uniqueid = root.find('.//uniqueid[@type="tmdb"]')
if tmdb_uniqueid is not None and tmdb_uniqueid.text:
tmdb_id = tmdb_uniqueid.text.strip()
if tmdb_id.isdigit():
print(f"⚠️ Found TMDB ID {tmdb_id} but no IMDb ID - using TMDB ID as fallback")
# Return TMDB ID with prefix to distinguish from IMDb IDs
return f"tmdb-{tmdb_id}"
except (ET.ParseError, Exception):
# Skip corrupted or non-XML files
pass
return None
def find_movie_imdb_id(self, movie_dir: Path) -> Optional[str]:
"""Find IMDb ID from directory name, filenames, or NFO file"""
# First try directory name
imdb_id = self.parse_imdb_from_path(movie_dir)
if imdb_id:
return imdb_id
# Try all files in the directory for IMDb ID patterns
for file_path in movie_dir.iterdir():
if file_path.is_file():
imdb_id = self.parse_imdb_from_path(file_path)
if imdb_id:
return imdb_id
# Finally, try NFO file content (including TMDB fallback)
nfo_path = movie_dir / "movie.nfo"
imdb_id = self.parse_imdb_from_nfo(nfo_path)
if imdb_id:
return imdb_id
return None
def find_series_imdb_id(self, series_dir: Path) -> Optional[str]:
"""Find IMDb ID from TV series directory name, filenames, or tvshow.nfo file"""
# First try directory name
imdb_id = self.parse_imdb_from_path(series_dir)
if imdb_id:
return imdb_id
# Try all files in the directory for IMDb ID patterns
for file_path in series_dir.iterdir():
if file_path.is_file():
imdb_id = self.parse_imdb_from_path(file_path)
if imdb_id:
return imdb_id
# Finally, try tvshow.nfo file content
nfo_path = series_dir / "tvshow.nfo"
imdb_id = self.parse_imdb_from_nfo(nfo_path)
if imdb_id:
return imdb_id
return None
def extract_nfoguard_dates_from_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
"""Extract NFOGuard-managed dates from existing NFO file"""
if not nfo_path.exists():
return None
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
# Look for NFOGuard fields
dateadded_elem = root.find('.//dateadded')
premiered_elem = root.find('.//premiered')
aired_elem = root.find('.//aired') # For TV episodes
lockdata_elem = root.find('.//lockdata')
# Consider it NFOGuard-managed ONLY if it has BOTH lockdata=true AND dateadded
# This prevents incomplete NFO files from being marked as "complete"
if (lockdata_elem is not None and lockdata_elem.text == "true" and
dateadded_elem is not None and dateadded_elem.text):
# Extract original source from NFOGuard comment, default to nfo_file_existing
source = "nfo_file_existing"
# Parse XML content to find NFOGuard comment with source
nfo_content = nfo_path.read_text(encoding='utf-8')
import re
source_match = re.search(r'<!--\s*NFOGuard\s*-\s*Source:\s*([^-]+?)\s*-->', nfo_content)
if source_match:
source = source_match.group(1).strip()
print(f"🔍 Extracted original source from NFO comment: {source}")
result = {
"source": source
}
if dateadded_elem is not None and dateadded_elem.text:
result["dateadded"] = dateadded_elem.text.strip()
if premiered_elem is not None and premiered_elem.text:
result["released"] = premiered_elem.text.strip()
if aired_elem is not None and aired_elem.text:
result["aired"] = aired_elem.text.strip()
print(f"✅ Found NFOGuard data in NFO: dateadded={result.get('dateadded', 'None')}, source={source}, released={result.get('released', 'None')}, aired={result.get('aired', 'None')}")
return result
except ET.ParseError as e:
# Handle malformed XML files gracefully (common with external NFO files)
print(f"🔍 NFO XML parse error (will try text fallback): {nfo_path.name}")
# Try text-based fallback for extracting NFOGuard data
return self._extract_nfoguard_data_text_fallback(nfo_path)
except Exception as e:
print(f"⚠️ Unexpected error reading NFO file {nfo_path.name}: {e}")
pass
return None
def _extract_nfoguard_data_text_fallback(self, nfo_path: Path) -> Optional[Dict[str, str]]:
"""Text-based fallback for extracting NFOGuard data from malformed XML files"""
try:
content = nfo_path.read_text(encoding='utf-8', errors='ignore')
# Look for NFOGuard elements using regex
import re
# Check for lockdata=true first (NFOGuard marker)
if not re.search(r'<lockdata>true</lockdata>', content, re.IGNORECASE):
return None
# Extract dateadded
dateadded_match = re.search(r'<dateadded>([^<]+)</dateadded>', content, re.IGNORECASE)
if not dateadded_match:
return None
dateadded = dateadded_match.group(1).strip()
if not dateadded:
return None
result = {
"dateadded": dateadded,
"source": "nfo_file_existing" # Default source
}
# Extract optional fields
premiered_match = re.search(r'<premiered>([^<]+)</premiered>', content, re.IGNORECASE)
if premiered_match:
result["released"] = premiered_match.group(1).strip()
aired_match = re.search(r'<aired>([^<]+)</aired>', content, re.IGNORECASE)
if aired_match:
result["aired"] = aired_match.group(1).strip()
# Extract source from NFOGuard comment
source_match = re.search(r'<!--\s*NFOGuard\s*-\s*Source:\s*([^-]+?)\s*-->', content)
if source_match:
result["source"] = source_match.group(1).strip()
print(f"✅ Extracted NFOGuard data via text fallback: dateadded={result.get('dateadded', 'None')}, source={result['source']}")
return result
except Exception as e:
print(f"⚠️ Text fallback extraction failed for {nfo_path.name}: {e}")
return None
def extract_nfoguard_dates_from_episode_nfo(self, season_path: Path, season_num: int, episode_num: int) -> Optional[Dict[str, str]]:
"""Extract NFOGuard-managed dates from existing episode NFO file"""
nfo_filename = f"s{season_num:02d}e{episode_num:02d}.nfo"
nfo_path = season_path / nfo_filename
if not nfo_path.exists():
return None
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
# Look for NFOGuard fields in episode NFO
dateadded_elem = root.find('.//dateadded')
aired_elem = root.find('.//aired')
lockdata_elem = root.find('.//lockdata')
# Consider it NFOGuard-managed ONLY if it has BOTH lockdata=true AND dateadded
# This prevents incomplete episode NFO files from being marked as "complete"
if (lockdata_elem is not None and lockdata_elem.text == "true" and
dateadded_elem is not None and dateadded_elem.text):
# Extract original source from NFOGuard comment, default to episode_nfo_existing
source = "episode_nfo_existing"
# Parse XML content to find NFOGuard comment with source
nfo_content = nfo_path.read_text(encoding='utf-8')
import re
source_match = re.search(r'<!--\s*NFOGuard\s*-\s*Source:\s*([^-]+?)\s*-->', nfo_content)
if source_match:
source = source_match.group(1).strip()
print(f"🔍 Extracted original source from episode NFO comment: {source}")
result = {
"source": source
}
if dateadded_elem is not None and dateadded_elem.text:
result["dateadded"] = dateadded_elem.text.strip()
if aired_elem is not None and aired_elem.text:
result["aired"] = aired_elem.text.strip()
print(f"✅ Found NFOGuard data in episode NFO S{season_num:02d}E{episode_num:02d}: dateadded={result.get('dateadded', 'None')}, source={source}, aired={result.get('aired', 'None')}")
return result
except (ET.ParseError, Exception) as e:
print(f"⚠️ Error parsing episode NFO for NFOGuard data: {e}")
pass
return None
def _parse_nfo_with_tolerance(self, nfo_path: Path):
"""Parse NFO file with tolerance for URLs appended after XML"""
try:
# First try normal parsing
tree = ET.parse(nfo_path)
return tree.getroot()
except ET.ParseError as e:
# If parsing fails, try to extract just the XML part
try:
with open(nfo_path, 'r', encoding='utf-8') as f:
content = f.read()
# Find the last </movie> tag and truncate after it
last_movie_end = content.rfind('</movie>')
if last_movie_end != -1:
xml_content = content[:last_movie_end + 8] # +8 for </movie>
# Try parsing the truncated content
root = ET.fromstring(xml_content)
print(f"✅ Successfully parsed NFO after removing trailing content: {nfo_path.name}")
return root
else:
# Re-raise original error if we can't find </movie>
raise e
except Exception:
# Re-raise original error if our fix attempt fails
raise e
def create_movie_nfo(self, movie_dir: Path, imdb_id: str, dateadded: str,
released: Optional[str] = None, source: str = "unknown",
lock_metadata: bool = True) -> None:
"""Create or update movie.nfo file preserving existing content"""
nfo_path = movie_dir / "movie.nfo"
# Debug output only if DEBUG=true in environment
import os
if os.environ.get("DEBUG", "false").lower() == "true":
print(f"🔍 create_movie_nfo called: imdb_id={imdb_id}, dateadded={dateadded}, released={released}, source={source}")
print(f"🔍 NFO path: {nfo_path}")
print(f"🔍 NFO exists: {nfo_path.exists()}")
try:
# Try to load existing NFO file
if nfo_path.exists():
try:
# Try to parse the XML, handling URLs appended after </movie>
movie = self._parse_nfo_with_tolerance(nfo_path)
# Ensure root element is <movie>
if movie.tag != "movie":
raise ValueError("Root element is not <movie>")
# Only remove elements that are clearly NFOGuard-managed
# Look for elements that have NFOGuard characteristics
elements_to_remove = []
# Remove lockdata=true (this is definitely NFOGuard)
for lockdata in movie.findall("lockdata"):
if lockdata.text == "true":
elements_to_remove.append(lockdata)
# Remove IMDb uniqueids that we manage
for uniqueid in movie.findall("uniqueid[@type='imdb']"):
elements_to_remove.append(uniqueid)
# For dateadded/premiered/year, only remove if they appear to be NFOGuard-managed
# (i.e., if lockdata=true exists, these are likely ours)
has_nfoguard_lockdata = any(ld.text == "true" for ld in movie.findall("lockdata"))
if has_nfoguard_lockdata:
# This NFO was managed by NFOGuard, safe to remove our fields
for tag in ["dateadded", "premiered", "year"]:
existing = movie.find(tag)
if existing is not None:
# Store the value before removing (for premiered/year)
if tag == "premiered" and not released:
print(f"🔍 Preserving existing premiered date: {existing.text}")
released = existing.text
elements_to_remove.append(existing)
else:
# No NFOGuard lockdata found, be more conservative
# Only remove dateadded if it looks like NFOGuard format (ISO timestamp)
dateadded_elem = movie.find("dateadded")
if dateadded_elem is not None and dateadded_elem.text:
# NFOGuard uses ISO format like "2025-10-12 16:26:02"
if re.match(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}', dateadded_elem.text.strip()):
elements_to_remove.append(dateadded_elem)
# Remove all identified elements
for elem in elements_to_remove:
movie.remove(elem)
except (ET.ParseError, ValueError) as e:
print(f"⚠️ Corrupted NFO detected: {nfo_path} - {str(e)[:100]}...")
print(f" Creating new clean NFO file to replace corrupted one")
movie = ET.Element("movie")
else:
# Create new NFO structure
movie = ET.Element("movie")
# Create all NFOGuard elements first, then append them in correct order
# This ensures they appear as a group at the bottom of the file
nfoguard_elements = []
# Add IMDb uniqueid
uniqueid = ET.Element("uniqueid", type="imdb", default="true")
uniqueid.text = imdb_id
nfoguard_elements.append(uniqueid)
# Add premiered date if we have it
if released:
premiered_elem = ET.Element("premiered")
premiered_elem.text = released[:10] if len(released) >= 10 else released
nfoguard_elements.append(premiered_elem)
# Extract year from premiered date for consistency
try:
year_value = released[:4] if len(released) >= 4 else None
if year_value and year_value.isdigit():
year_elem = ET.Element("year")
year_elem.text = year_value
nfoguard_elements.append(year_elem)
except:
pass # Skip year if we can't extract it
# Add dateadded - THIS IS CRITICAL FOR EMBY PLUGIN
if os.environ.get("DEBUG", "false").lower() == "true":
print(f"🔍 About to add dateadded: {dateadded} (type: {type(dateadded)})")
if dateadded:
dateadded_elem = ET.Element("dateadded")
dateadded_elem.text = dateadded
nfoguard_elements.append(dateadded_elem)
print(f"✅ Adding dateadded to NFO: {dateadded}")
else:
print(f"❌ dateadded is empty/None, not adding to NFO")
# Add lockdata at the very end
if lock_metadata:
lockdata = ET.Element("lockdata")
lockdata.text = "true"
nfoguard_elements.append(lockdata)
# Add NFOGuard comment as the very last element (appears at bottom)
nfoguard_comment = ET.Comment(f" NFOGuard - Source: {source} ")
nfoguard_elements.append(nfoguard_comment)
# Now append all NFOGuard elements to the movie in one batch
# This ensures they appear as a contiguous block at the bottom
for elem in nfoguard_elements:
movie.append(elem)
print(f"✅ Added {len(nfoguard_elements)} NFOGuard elements to bottom of NFO")
# Write file with proper formatting
tree = ET.ElementTree(movie)
ET.indent(tree, space=" ", level=0)
# Write directly to file (comment is already embedded in XML at bottom)
with open(nfo_path, 'w', encoding='utf-8') as f:
f.write('<?xml version="1.0" encoding="utf-8"?>\n')
tree.write(f, encoding='unicode', xml_declaration=False)
print(f"✅ Successfully created/updated movie NFO: {nfo_path}")
print(f" IMDb ID: {imdb_id}, Date Added: {dateadded}, Source: {source}")
except Exception as e:
print(f"❌ Error creating/updating movie NFO {nfo_path}: {e}")
def create_tvshow_nfo(self, series_dir: Path, imdb_id: str, tvdb_id: Optional[str] = None) -> None:
"""Create or update tvshow.nfo file preserving existing content"""
nfo_path = series_dir / "tvshow.nfo"
try:
# Try to load existing NFO file
if nfo_path.exists():
try:
tree = ET.parse(nfo_path)
tvshow = tree.getroot()
# Ensure root element is <tvshow>
if tvshow.tag != "tvshow":
raise ValueError("Root element is not <tvshow>")
# Remove existing NFOGuard-managed elements to avoid duplicates
# These will be re-added at the bottom
for tag in ["lockdata"]:
existing = tvshow.find(tag)
if existing is not None:
tvshow.remove(existing)
# Remove ALL existing uniqueid with type="imdb" regardless of attributes
for uniqueid in tvshow.findall("uniqueid[@type='imdb']"):
tvshow.remove(uniqueid)
except (ET.ParseError, ValueError) as e:
print(f"⚠️ Corrupted TV show NFO detected: {nfo_path} - {str(e)[:100]}...")
print(f" Creating new clean tvshow.nfo file to replace corrupted one")
tvshow = ET.Element("tvshow")
else:
# Create new NFO structure
tvshow = ET.Element("tvshow")
# Add NFOGuard fields at the bottom
# Add IMDb uniqueid at the end
imdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="imdb", default="true")
imdb_uniqueid.text = imdb_id
# Add TVDB ID if available (preserve existing or add new)
if tvdb_id and not tvshow.find("uniqueid[@type='tvdb']"):
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"
# Add NFOGuard comment at the bottom
comment = ET.Comment(f" Created by {self.manager_brand} ")
tvshow.append(comment)
# Write file with proper formatting
tree = ET.ElementTree(tvshow)
ET.indent(tree, space=" ", level=0)
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
print(f"✅ Successfully created/updated TV show NFO: {nfo_path}")
print(f" IMDb ID: {imdb_id}" + (f", TVDB ID: {tvdb_id}" if tvdb_id else ""))
except Exception as e:
print(f"❌ Error creating/updating tvshow NFO {nfo_path}: {e}")
def create_season_nfo(self, season_dir: Path, season_number: int) -> None:
"""Create or update season.nfo file preserving existing content"""
nfo_path = season_dir / "season.nfo"
try:
season_dir.mkdir(exist_ok=True)
# Try to load existing NFO file
if nfo_path.exists():
try:
tree = ET.parse(nfo_path)
season = tree.getroot()
# Ensure root element is <season>
if season.tag != "season":
raise ValueError("Root element is not <season>")
# Remove existing NFOGuard-managed elements
for tag in ["seasonnumber", "lockdata"]:
existing = season.find(tag)
if existing is not None:
season.remove(existing)
except (ET.ParseError, ValueError) as e:
print(f"⚠️ Corrupted season NFO detected: {nfo_path} - {str(e)[:100]}...")
print(f" Creating new clean season.nfo file to replace corrupted one")
season = ET.Element("season")
else:
# Create new NFO structure
season = ET.Element("season")
# Add NFOGuard fields at the bottom
seasonnumber = ET.SubElement(season, "seasonnumber")
seasonnumber.text = str(season_number)
# Add lockdata at the end
lockdata = ET.SubElement(season, "lockdata")
lockdata.text = "true"
# Add NFOGuard comment at the bottom
comment = ET.Comment(f" Created by {self.manager_brand} ")
season.append(comment)
# Write file with proper formatting
tree = ET.ElementTree(season)
ET.indent(tree, space=" ", level=0)
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
print(f"✅ Successfully created/updated season NFO: {nfo_path}")
print(f" Season: {season_number}")
except Exception as e:
print(f"❌ Error creating/updating season NFO {nfo_path}: {e}")
def find_existing_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
"""Find any existing episode NFO file that matches season/episode"""
if not season_dir.exists():
return None
# Look for NFO files in the season directory
for nfo_file in season_dir.glob("*.nfo"):
# Check if this NFO contains the right season/episode
try:
tree = ET.parse(nfo_file)
root = tree.getroot()
if root.tag == "episodedetails":
# Check for season/episode elements
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 existing episode NFO: {nfo_file.name}")
return nfo_file
except ValueError:
continue
except (ET.ParseError, Exception):
# Skip corrupted or non-XML files
continue
return None
def _get_target_episode_nfo_name(self, season_dir: Path, season_num: int, episode_num: int) -> str:
"""Get target NFO filename - prefer existing NFO, then matching video file, fallback to short name"""
if not season_dir.exists():
return f"S{season_num:02d}E{episode_num:02d}.nfo"
# First check if an NFO already exists for this episode
existing_nfo = self.find_existing_episode_nfo(season_dir, season_num, episode_num)
if existing_nfo:
print(f"📂 Existing NFO found, will preserve filename: {existing_nfo.name}")
return existing_nfo.name
# Look for video files with matching season/episode
video_extensions = [".mkv", ".mp4", ".avi", ".mov", ".m4v", ".wmv", ".flv", ".webm"]
for video_file in season_dir.iterdir():
if (video_file.is_file() and
video_file.suffix.lower() in video_extensions):
# Parse episode info from video filename
episode_info = self._parse_episode_from_filename(video_file.name)
if episode_info and episode_info == (season_num, episode_num):
# Found matching video file - use its name for NFO
target_nfo_name = f"{video_file.stem}.nfo"
print(f"🎯 Target NFO will match video: {target_nfo_name}")
return target_nfo_name
# Fallback to short name if no matching video found
short_name = f"S{season_num:02d}E{episode_num:02d}.nfo"
print(f"⚠️ No matching video file found, using short name: {short_name}")
return short_name
def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]:
"""Parse season and episode numbers from filename"""
# Try S##E## format first
match = re.search(r'[Ss](\d{1,2})[Ee](\d{1,2})', filename)
if match:
return int(match.group(1)), int(match.group(2))
# Try ##x## format
match = re.search(r'(\d{1,2})x(\d{1,2})', filename)
if match:
return int(match.group(1)), int(match.group(2))
return None
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"""
# Get target NFO filename (prefer long name matching video file)
target_nfo_name = self._get_target_episode_nfo_name(season_dir, season_num, episode_num)
nfo_path = season_dir / target_nfo_name
try:
# Check for existing NFO file at target location
source_nfo_path = nfo_path if nfo_path.exists() else None
if source_nfo_path:
try:
tree = ET.parse(source_nfo_path)
episode = tree.getroot()
# Ensure root element is <episodedetails>
if episode.tag != "episodedetails":
raise ValueError("Root element is not <episodedetails>")
print(f"📝 Updating existing NFO: {nfo_path.name}")
# 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"]
for tag in nfoguard_fields:
existing = episode.find(tag)
if existing is not None:
# Store the value before removing
if tag == "aired" and not aired:
aired = existing.text # Preserve existing aired date
preserved_values[tag] = existing.text
episode.remove(existing)
# Important: DO NOT remove content fields like title, plot, runtime, premiered, etc.
# These should be preserved from the long-named NFO files
# Debug: Show what fields are preserved after removing NFOGuard fields (if DEBUG enabled)
if self.debug:
preserved_fields = [elem.tag for elem in episode]
if preserved_fields:
print(f" 🔍 Content preserved after cleanup: {', '.join(preserved_fields)}")
else:
print(f" ️ NFO contains only NFOGuard metadata (no additional content fields)")
except (ET.ParseError, ValueError) as e:
print(f"⚠️ Corrupted episode NFO detected: {nfo_path} - {str(e)[:100]}...")
print(f" Creating new clean episode NFO file to replace corrupted one")
episode = ET.Element("episodedetails")
else:
# Create new NFO structure
episode = ET.Element("episodedetails")
# Add enhanced metadata only if not already present (preserve existing from long-named NFO)
if enhanced_metadata:
if enhanced_metadata.get("title") and not episode.find("title"):
title_elem = ET.SubElement(episode, "title")
title_elem.text = enhanced_metadata["title"]
if enhanced_metadata.get("overview") and not episode.find("plot"):
plot_elem = ET.SubElement(episode, "plot")
plot_elem.text = enhanced_metadata["overview"]
if enhanced_metadata.get("runtime") and not episode.find("runtime"):
runtime_elem = ET.SubElement(episode, "runtime")
runtime_elem.text = str(enhanced_metadata["runtime"])
# 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")
# Convert datetime objects to strings
aired_str = str(aired)
aired_elem.text = aired_str[:10] if len(aired_str) >= 10 else aired_str
# Debug logging for dateadded
print(f"🔍 DEBUG: dateadded value: {repr(dateadded)} (type: {type(dateadded)})")
if dateadded:
dateadded_elem = ET.SubElement(episode, "dateadded")
# Convert datetime objects to strings
dateadded_elem.text = str(dateadded)
print(f"✅ Added dateadded to episode NFO: {dateadded}")
else:
print(f"❌ dateadded is empty/None, not adding to episode NFO")
# Add lockdata at the very end
if lock_metadata:
lockdata = ET.SubElement(episode, "lockdata")
lockdata.text = "true"
# Add NFOGuard comment at the bottom
comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ")
episode.append(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 created/updated episode NFO: {nfo_path}")
print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}")
# NFO file created/updated successfully
pass
except Exception as e:
print(f"❌ Error creating/updating episode NFO {nfo_path}: {e}")
def set_file_mtime(self, file_path: Path, iso_timestamp) -> None:
"""Set file modification time to match import date"""
try:
# Convert datetime objects to strings first
if hasattr(iso_timestamp, 'isoformat'):
iso_timestamp = iso_timestamp.isoformat()
elif not isinstance(iso_timestamp, str):
iso_timestamp = str(iso_timestamp)
# Parse ISO timestamp
if iso_timestamp.endswith('Z'):
dt = datetime.fromisoformat(iso_timestamp.replace('Z', '+00:00'))
elif '+' in iso_timestamp or 'T' in iso_timestamp:
dt = datetime.fromisoformat(iso_timestamp)
elif ' ' in iso_timestamp:
# Handle space-separated datetime format (e.g., "2025-10-16 20:31:22")
dt = datetime.fromisoformat(iso_timestamp.replace(' ', 'T'))
else:
# Assume it's a simple date (e.g., "2025-10-16")
dt = datetime.fromisoformat(iso_timestamp + 'T00:00:00')
# Convert to timestamp
timestamp = dt.timestamp()
# Set both access and modification times
os.utime(file_path, (timestamp, timestamp))
print(f"✅ Updated file timestamp: {file_path.name} -> {iso_timestamp}")
except Exception as e:
print(f"❌ Error setting mtime for {file_path}: {e}")
def update_movie_files_mtime(self, movie_dir: Path, iso_timestamp: str) -> None:
"""Update modification times for all video files in movie directory"""
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
updated_files = []
for file_path in movie_dir.iterdir():
if file_path.is_file() and file_path.suffix.lower() in video_exts:
self.set_file_mtime(file_path, iso_timestamp)
updated_files.append(file_path.name)
if updated_files:
print(f"✅ Updated {len(updated_files)} video file timestamps in {movie_dir.name}")
else:
print(f"⚠️ No video files found to update in {movie_dir.name}")
+8 -8
View File
@@ -22,7 +22,7 @@ from utils.logging import _log
# Import core components
from core.database import NFOGuardDatabase
from core.nfo_manager import NFOManager
# from core.nfo_manager import NFOManager # Phase 3: Removed - no longer needed
from core.path_mapper import PathMapper
# Import clients
@@ -93,20 +93,20 @@ def initialize_components():
# Initialize core components
db = NFOGuardDatabase(config=config)
nfo_manager = NFOManager(config.manager_brand, config.debug)
# nfo_manager = NFOManager(config.manager_brand, config.debug) # Phase 3: Removed
path_mapper = PathMapper(config)
# Initialize processors
tv_processor = TVProcessor(db, nfo_manager, path_mapper)
movie_processor = MovieProcessor(db, nfo_manager, path_mapper)
# Initialize processors (nfo_manager=None for backward compatibility)
tv_processor = TVProcessor(db, None, path_mapper)
movie_processor = MovieProcessor(db, None, path_mapper)
# Initialize webhook batcher with nfo_manager for comprehensive IMDb detection
batcher = WebhookBatcher(nfo_manager)
# Initialize webhook batcher (no longer needs nfo_manager - Phase 3)
batcher = WebhookBatcher(nfo_manager=None)
batcher.set_processors(tv_processor, movie_processor)
return {
"db": db,
"nfo_manager": nfo_manager,
# "nfo_manager": nfo_manager, # Phase 3: Removed
"path_mapper": path_mapper,
"tv_processor": tv_processor,
"movie_processor": movie_processor,
+154 -4
View File
@@ -5,7 +5,7 @@ Provides endpoints for the web-based database manipulation interface
import json
from datetime import datetime, timezone
from typing import List, Optional, Dict, Any
from fastapi import HTTPException, Query
from fastapi import HTTPException, Query, BackgroundTasks
from pathlib import Path
import sys
@@ -14,6 +14,10 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from api.models import *
# Global status tracking for database population
_populate_status = {"running": False, "completed": False}
def map_source_to_description(source: str) -> str:
"""Map technical source codes to user-friendly descriptions"""
if not source or source == "no_valid_date_source":
@@ -48,15 +52,23 @@ def map_source_to_description(source: str) -> str:
elif "omdb:" in source_lower:
return "OMDb Release"
# Sonarr sources
elif "sonarr:" in source_lower:
return "Sonarr API"
# Manual and other sources
elif "manual" in source_lower:
return "Manual Entry"
elif "digital_release" in source_lower:
return "Digital Release"
elif "nfo_file_existing" in source_lower:
return "NFO File (Legacy)"
elif "nfo:" in source_lower:
return "NFO File"
elif "webhook:" in source_lower:
return "Webhook/API"
elif "database" in source_lower:
return "Database"
# Fallback for unknown patterns
return source.title()
@@ -469,10 +481,17 @@ async def get_dashboard_stats(dependencies: dict):
cursor.execute("SELECT COUNT(*) FROM episodes WHERE source = 'no_valid_date_source'")
episodes_no_valid_source = db._get_first_value(cursor.fetchone())
# Recent activity (last 7 days)
# Recent activity (last 7 days) - count items processed, not history events
cursor.execute("""
SELECT COUNT(*) FROM processing_history
WHERE processed_at > NOW() - INTERVAL '7 days'
SELECT COUNT(*) FROM (
SELECT imdb_id FROM movies
WHERE created_at > NOW() - INTERVAL '7 days'
OR updated_at > NOW() - INTERVAL '7 days'
UNION
SELECT DISTINCT imdb_id FROM episodes
WHERE created_at > NOW() - INTERVAL '7 days'
OR updated_at > NOW() - INTERVAL '7 days'
) AS recent_items
""")
recent_activity = db._get_first_value(cursor.fetchone())
@@ -1009,6 +1028,111 @@ async def delete_movie(dependencies: dict, imdb_id: str):
raise HTTPException(status_code=500, detail=f"Failed to delete movie: {str(e)}")
async def populate_database(background_tasks: BackgroundTasks, media_type: str = "both", dependencies: dict = None):
"""
Populate NFOGuard database from Radarr/Sonarr sources
Args:
background_tasks: FastAPI background tasks
media_type: Type of media to populate ("movies", "tv", or "both")
dependencies: Dictionary with db, radarr_client, sonarr_client
Returns:
Status message indicating population has started
"""
from core.database_populator import DatabasePopulator
db = dependencies["db"]
config = dependencies["config"]
# Get Radarr and Sonarr clients
from clients.radarr_client import RadarrClient
from clients.sonarr_client import SonarrClient
radarr_client = RadarrClient(config)
sonarr_client = SonarrClient(config)
if media_type not in ["both", "movies", "tv"]:
raise HTTPException(status_code=400, detail="media_type must be 'both', 'movies', or 'tv'")
# Create global status tracking
populate_status = {
"running": True,
"media_type": media_type,
"start_time": datetime.now().isoformat(),
"movies": {"status": "pending", "stats": None},
"tv": {"status": "pending", "stats": None},
"completed": False,
"error": None
}
# Store status globally so it can be queried
global _populate_status
_populate_status = populate_status
async def run_population():
"""Background task to populate the database"""
try:
populator = DatabasePopulator(db, radarr_client, sonarr_client)
print(f"INFO: Starting database population: {media_type}")
if media_type == "movies":
populate_status["movies"]["status"] = "running"
movie_stats = populator.populate_movies()
populate_status["movies"]["status"] = "completed"
populate_status["movies"]["stats"] = movie_stats
print(f"INFO: Movie population completed: {movie_stats}")
elif media_type == "tv":
populate_status["tv"]["status"] = "running"
tv_stats = populator.populate_tv_episodes()
populate_status["tv"]["status"] = "completed"
populate_status["tv"]["stats"] = tv_stats
print(f"INFO: TV population completed: {tv_stats}")
elif media_type == "both":
populate_status["movies"]["status"] = "running"
movie_stats = populator.populate_movies()
populate_status["movies"]["status"] = "completed"
populate_status["movies"]["stats"] = movie_stats
print(f"INFO: Movie population completed: {movie_stats}")
populate_status["tv"]["status"] = "running"
tv_stats = populator.populate_tv_episodes()
populate_status["tv"]["status"] = "completed"
populate_status["tv"]["stats"] = tv_stats
print(f"INFO: TV population completed: {tv_stats}")
populate_status["completed"] = True
populate_status["running"] = False
print("INFO: Database population completed successfully")
except Exception as e:
print(f"ERROR: Database population failed: {e}")
populate_status["error"] = str(e)
populate_status["running"] = False
populate_status["completed"] = True
# Add task to background
background_tasks.add_task(run_population)
print(f"INFO: Database population started for: {media_type}")
return {
"status": "started",
"media_type": media_type,
"message": f"Database population started for {media_type}"
}
async def get_populate_status():
"""Get the current status of database population"""
global _populate_status
if '_populate_status' not in globals():
return {"running": False, "completed": False}
return _populate_status
def register_web_routes(app, dependencies):
"""Register all web API routes with FastAPI app"""
from fastapi import Request, Response
@@ -1133,6 +1257,28 @@ def register_web_routes(app, dependencies):
response.delete_cookie("nfoguard_session")
return {"status": "logged_out", "message": "Session cleared"}
# Database population endpoints
print("🔧 DEBUG: Registering /admin/populate-database endpoint...")
@app.post("/admin/populate-database")
async def api_populate_database(request: Request, background_tasks: BackgroundTasks):
"""Populate database from Radarr/Sonarr"""
print(f"🔥 DEBUG: populate-database endpoint called!")
try:
data = await request.json()
media_type = data.get("media_type", "both")
except Exception:
# Fallback to query parameter if JSON parsing fails
media_type = request.query_params.get("media_type", "both")
return await populate_database(background_tasks, media_type, dependencies)
print("✅ DEBUG: /admin/populate-database registered")
@app.get("/api/populate/status")
async def api_populate_status():
"""Get database population status"""
return await get_populate_status()
# Health endpoint
@app.get("/health")
async def health_check():
@@ -1140,3 +1286,7 @@ def register_web_routes(app, dependencies):
return {"status": "healthy", "service": "nfoguard-web"}
print("✅ Web routes registered successfully")
print(f"📋 DEBUG: Registered routes count: {len(app.routes)}")
for route in app.routes:
if hasattr(route, 'path') and hasattr(route, 'methods'):
print(f" - {route.methods} {route.path}")
+1 -1
View File
@@ -33,7 +33,7 @@ def create_web_app() -> FastAPI:
app = FastAPI(
title="NFOGuard Web Interface",
description="Web interface for NFOGuard media database management",
version="2.6.12-web",
version="2.9.0-fixes-only-files",
docs_url="/docs" if web_config.web_debug else None,
redoc_url="/redoc" if web_config.web_debug else None
)
+90 -17
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NFOGuard - Database Management</title>
<link rel="stylesheet" href="/static/css/styles.css?v=manual-scan-ui">
<link rel="stylesheet" href="/static/css/styles.css?v=2.10.0-skipped-imdb-edit-v2">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head>
<body>
@@ -12,7 +12,7 @@
<!-- Header -->
<header class="app-header">
<div class="header-content">
<h1><i class="fas fa-shield-alt"></i> NFOGuard</h1>
<h1><i class="fas fa-shield-alt"></i> NFOGuard <span style="font-size: 0.5em; color: #888;">v2.10.0-skipped-imdb-edit-v2</span></h1>
<p>Database Management & Reporting</p>
</div>
<div class="auth-status" id="auth-status" style="display: none;">
@@ -93,6 +93,17 @@
<small>Last 7 days</small>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, #f39c12 0%, #e67e22 100%);">
<i class="fas fa-ban"></i>
</div>
<div class="stat-info">
<h3 id="skipped-total">-</h3>
<p>Skipped Items</p>
<small id="skipped-breakdown">- movies, - episodes</small>
</div>
</div>
</div>
<div class="dashboard-charts">
@@ -127,6 +138,7 @@
<option value="">All Movies</option>
<option value="true">With Dates</option>
<option value="false">Missing Dates</option>
<option value="skipped">Skipped</option>
</select>
<select id="movies-filter-source">
<option value="">All Sources</option>
@@ -139,16 +151,30 @@
</div>
<div class="table-container">
<table class="data-table" id="movies-table">
<table class="data-table sortable-table" id="movies-table">
<thead>
<tr>
<th>Title</th>
<th>IMDb ID</th>
<th>Movie Released</th>
<th>Date Added to Library</th>
<th>Source</th>
<th>Date Type</th>
<th>Video File</th>
<th class="sortable" onclick="sortTable('movies-tbody', 0, 'text')" style="cursor: pointer;">
Title <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('movies-tbody', 1, 'text')" style="cursor: pointer;">
IMDb ID <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('movies-tbody', 2, 'date')" style="cursor: pointer;">
Movie Released <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('movies-tbody', 3, 'date')" style="cursor: pointer;">
Date Added to Library <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('movies-tbody', 4, 'text')" style="cursor: pointer;">
Source <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('movies-tbody', 5, 'text')" style="cursor: pointer;">
Date Type <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('movies-tbody', 6, 'text')" style="cursor: pointer;">
Video File <i class="fas fa-sort"></i>
</th>
<th>Actions</th>
</tr>
</thead>
@@ -184,6 +210,7 @@
<option value="complete">Fully Dated</option>
<option value="incomplete">Missing Dates</option>
<option value="none">No Dates</option>
<option value="skipped">Skipped</option>
</select>
<select id="series-filter-source">
<option value="">All Sources</option>
@@ -196,14 +223,27 @@
</div>
<div class="table-container">
<table class="data-table" id="series-table">
<table class="data-table sortable-table" id="series-table">
<thead>
<tr>
<th>Series Title</th>
<th>IMDb ID</th>
<th>Episodes</th>
<th>With Dates</th>
<th>With Video</th>
<th class="sortable" onclick="sortTable('series-tbody', 0, 'text')" style="cursor: pointer;">
Series Title <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('series-tbody', 1, 'text')" style="cursor: pointer;">
IMDb ID <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('series-tbody', 2, 'number')" style="cursor: pointer;">
Episodes <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('series-tbody', 3, 'number')" style="cursor: pointer;">
With Dates <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('series-tbody', 4, 'number')" style="cursor: pointer;">
With Video <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('series-tbody', 5, 'number')" style="cursor: pointer;">
Skipped <i class="fas fa-sort"></i>
</th>
<th>Actions</th>
</tr>
</thead>
@@ -443,6 +483,39 @@
<i class="fas fa-sync"></i> Refresh Stats
</button>
</div>
<div class="tool-card">
<h3><i class="fas fa-upload"></i> Populate Database</h3>
<p>Bulk import data from Radarr/Sonarr into NFOGuard database</p>
<form id="populate-form">
<div class="form-group">
<label>Media Type:</label>
<select id="populate-media-type" required>
<option value="both">Movies & TV Shows</option>
<option value="movies">Movies Only</option>
<option value="tv">TV Shows Only</option>
</select>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-play"></i> Start Population
</button>
</form>
<div id="populate-status" class="scan-status" style="display: none;">
<div class="scan-progress">
<div class="progress-bar">
<div class="progress-fill" id="populate-progress-bar"></div>
</div>
<div class="scan-info">
<span id="populate-current-operation">Running...</span>
<span id="populate-progress-text">In Progress</span>
</div>
</div>
<div id="populate-results" class="stats-display" style="margin-top: 10px;"></div>
<button class="btn btn-secondary btn-sm" onclick="stopPopulatePolling()">
<i class="fas fa-times"></i> Hide Status
</button>
</div>
</div>
</div>
</div>
</main>
@@ -654,6 +727,6 @@
<!-- Toast Notifications -->
<div class="toast-container" id="toast-container"></div>
<script src="/static/js/app.js?v=scheduled-scans-system"></script>
<script src="/static/js/app.js?v=2.10.0-skipped-imdb-edit-v2"></script>
</body>
</html>
+745 -31
View File
@@ -8,11 +8,35 @@ let dashboardData = null;
// Initialize app
document.addEventListener('DOMContentLoaded', function() {
initializeTabs();
initializeEventListeners();
checkAuthStatus(); // Check authentication status on page load
loadDashboard();
loadSeriesSources();
try {
initializeTabs();
} catch (error) {
console.error('Error initializing tabs:', error);
}
try {
initializeEventListeners();
} catch (error) {
console.error('Error initializing event listeners:', error);
}
try {
checkAuthStatus(); // Check authentication status on page load
} catch (error) {
console.error('Error checking auth status:', error);
}
try {
loadDashboard();
} catch (error) {
console.error('Error loading dashboard:', error);
}
try {
loadSeriesSources();
} catch (error) {
console.error('Error loading series sources:', error);
}
});
// Tab management
@@ -62,21 +86,37 @@ function switchTab(tabName) {
// Event listeners
function initializeEventListeners() {
// Search inputs
document.getElementById('movies-search').addEventListener('input', debounce(loadMovies, 500));
document.getElementById('movies-imdb-search').addEventListener('input', debounce(loadMovies, 500));
document.getElementById('series-search').addEventListener('input', debounce(loadSeries, 500));
document.getElementById('series-imdb-search').addEventListener('input', debounce(loadSeries, 500));
const moviesSearch = document.getElementById('movies-search');
const moviesImdbSearch = document.getElementById('movies-imdb-search');
const seriesSearch = document.getElementById('series-search');
const seriesImdbSearch = document.getElementById('series-imdb-search');
if (moviesSearch) moviesSearch.addEventListener('input', debounce(loadMovies, 500));
if (moviesImdbSearch) moviesImdbSearch.addEventListener('input', debounce(loadMovies, 500));
if (seriesSearch) seriesSearch.addEventListener('input', debounce(loadSeries, 500));
if (seriesImdbSearch) seriesImdbSearch.addEventListener('input', debounce(loadSeries, 500));
// Filter dropdowns
document.getElementById('movies-filter-date').addEventListener('change', loadMovies);
document.getElementById('movies-filter-source').addEventListener('change', loadMovies);
document.getElementById('series-filter-date').addEventListener('change', loadSeries);
document.getElementById('series-filter-source').addEventListener('change', loadSeries);
const moviesFilterDate = document.getElementById('movies-filter-date');
const moviesFilterSource = document.getElementById('movies-filter-source');
const seriesFilterDate = document.getElementById('series-filter-date');
const seriesFilterSource = document.getElementById('series-filter-source');
if (moviesFilterDate) moviesFilterDate.addEventListener('change', loadMovies);
if (moviesFilterSource) moviesFilterSource.addEventListener('change', loadMovies);
if (seriesFilterDate) seriesFilterDate.addEventListener('change', loadSeries);
if (seriesFilterSource) seriesFilterSource.addEventListener('change', loadSeries);
// Forms
document.getElementById('edit-form').addEventListener('submit', handleEditSubmit);
document.getElementById('bulk-update-form').addEventListener('submit', handleBulkUpdate);
document.getElementById('manual-scan-form').addEventListener('submit', handleManualScan);
const editForm = document.getElementById('edit-form');
const bulkUpdateForm = document.getElementById('bulk-update-form');
const manualScanForm = document.getElementById('manual-scan-form');
const populateForm = document.getElementById('populate-form');
if (editForm) editForm.addEventListener('submit', handleEditSubmit);
if (bulkUpdateForm) bulkUpdateForm.addEventListener('submit', handleBulkUpdate);
if (manualScanForm) manualScanForm.addEventListener('submit', handleManualScan);
if (populateForm) populateForm.addEventListener('submit', handlePopulateDatabase);
}
// API calls
@@ -140,6 +180,24 @@ function updateDashboardStats() {
document.getElementById('no-valid-source-total').textContent = `${moviesWithoutDates} movies, ${episodesWithoutDates} episodes without dates`;
document.getElementById('recent-activity').textContent = dashboardData.recent_activity_count || 0;
// Skipped items
const skippedMovies = dashboardData.movies_skipped || 0;
const skippedEpisodes = dashboardData.episodes_skipped || 0;
const skippedTotal = dashboardData.total_skipped || (skippedMovies + skippedEpisodes);
document.getElementById('skipped-total').textContent = skippedTotal;
document.getElementById('skipped-breakdown').textContent = `${skippedMovies} movies, ${skippedEpisodes} episodes`;
}
function showSkippedItems() {
// Switch to Movies tab and filter to show only skipped items
switchTab('movies');
// Set the filter dropdown to "skipped"
const moviesFilter = document.getElementById('movies-filter-date');
if (moviesFilter) {
moviesFilter.value = 'skipped';
refreshMovies();
}
}
function updateDashboardCharts() {
@@ -195,7 +253,7 @@ async function loadMovies(page = 1) {
const search = document.getElementById('movies-search').value;
const imdbSearch = document.getElementById('movies-imdb-search').value;
const hasDate = document.getElementById('movies-filter-date').value;
const dateFilter = document.getElementById('movies-filter-date').value;
const sourceFilter = document.getElementById('movies-filter-source').value;
const skip = (page - 1) * 100;
@@ -208,7 +266,14 @@ async function loadMovies(page = 1) {
if (search) params.append('search', search);
if (imdbSearch) params.append('imdb_search', imdbSearch);
if (hasDate) params.append('has_date', hasDate);
// Handle different filter values
if (dateFilter === 'skipped') {
params.append('skipped', 'true');
} else if (dateFilter) {
params.append('has_date', dateFilter);
}
if (sourceFilter) params.append('source_filter', sourceFilter);
try {
@@ -275,10 +340,22 @@ function updateMoviesTable(data) {
dateTypeBadge = 'badge-warning';
}
// Check if skipped and if IMDb ID is placeholder
const isSkipped = movie.skipped === true;
const isPlaceholder = movie.imdb_id && movie.imdb_id.startsWith('missing-');
const skipReasonBadge = isSkipped ? `<br><span class="badge badge-warning" title="${escapeHtml(movie.skip_reason || 'Skipped')}"">Skipped</span>` : '';
// Add Update IMDb ID button for placeholder IDs
const updateImdbButton = isPlaceholder ? `
<button class="btn btn-sm btn-info" onclick="updateMovieImdbId('${movie.imdb_id}')" title="Update IMDb ID" style="margin-left: 5px;">
<i class="fas fa-id-card"></i> Update IMDb
</button>
` : '';
return `
<tr>
<td>${escapeHtml(movie.title)}</td>
<td><code>${movie.imdb_id}</code></td>
<td><code>${movie.imdb_id}</code>${skipReasonBadge}</td>
<td>${movie.released || '-'}</td>
<td>${dateadded || '-'}</td>
<td><span class="badge badge-secondary">${movie.source_description || movie.source || 'Unknown'}</span></td>
@@ -291,6 +368,7 @@ function updateMoviesTable(data) {
<button class="btn btn-sm btn-secondary" onclick="debugMovie('${movie.imdb_id}')" title="Debug Data">
<i class="fas fa-bug"></i>
</button>
${updateImdbButton}
<button class="btn btn-sm btn-danger" onclick="deleteMovie('${movie.imdb_id}')" style="margin-left: 5px;" title="Delete Movie">
<i class="fas fa-trash"></i> Delete
</button>
@@ -402,7 +480,7 @@ function updateSeriesTable(data) {
const tbody = document.getElementById('series-tbody');
if (!data.series || data.series.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center">No series found</td></tr>';
tbody.innerHTML = '<tr><td colspan="7" class="text-center">No series found</td></tr>';
return;
}
@@ -410,6 +488,16 @@ function updateSeriesTable(data) {
const progressPercent = series.total_episodes > 0 ?
((series.episodes_with_dates / series.total_episodes) * 100).toFixed(1) : 0;
// Check if IMDb ID is placeholder
const isPlaceholder = series.imdb_id && series.imdb_id.startsWith('missing-');
// Add Update IMDb ID button for placeholder IDs
const updateImdbButton = isPlaceholder ? `
<button class="btn btn-sm btn-info" onclick="updateSeriesImdbId('${series.imdb_id}')" title="Update IMDb ID" style="margin-left: 5px;">
<i class="fas fa-id-card"></i> Update IMDb
</button>
` : '';
return `
<tr>
<td>${escapeHtml(series.title)}</td>
@@ -420,10 +508,12 @@ function updateSeriesTable(data) {
<small class="text-muted">(${progressPercent}%)</small>
</td>
<td>${series.episodes_with_video}</td>
<td>${series.episodes_skipped || 0}</td>
<td>
<button class="btn btn-sm btn-primary" onclick="viewSeriesEpisodes('${series.imdb_id}')">
<i class="fas fa-list"></i> Episodes
</button>
${updateImdbButton}
</td>
</tr>
`;
@@ -508,22 +598,66 @@ function showEpisodesModal(data) {
<button id="bulk-select-all" class="btn btn-sm btn-secondary" onclick="toggleSelectAll()">
<i class="fas fa-check-square"></i> Select All
</button>
<button id="bulk-update-dates" class="btn btn-sm btn-primary" onclick="showBulkUpdateModal()" style="margin-left: 10px;" disabled>
<i class="fas fa-calendar"></i> Update Dates (<span id="update-count">0</span>)
</button>
<button id="bulk-delete-selected" class="btn btn-sm btn-danger" onclick="bulkDeleteSelected()" style="margin-left: 10px;" disabled>
<i class="fas fa-trash"></i> Delete Selected (<span id="selected-count">0</span>)
</button>
</div>
<!-- Bulk Update Dates Modal -->
<div id="bulk-update-modal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h3>Bulk Update Episode Dates</h3>
<button class="close-button" onclick="closeBulkUpdateModal()">&times;</button>
</div>
<div class="modal-body">
<p>Update <strong><span id="modal-selected-count">0</span></strong> selected episode(s) to:</p>
<div class="form-group">
<label>Date Source:</label>
<select id="bulk-date-source" class="form-control">
<option value="airdate">Air Date (from Sonarr)</option>
<option value="import">Import Date (from Sonarr history)</option>
<option value="custom">Custom Date</option>
</select>
</div>
<div id="custom-date-input" style="display: none; margin-top: 10px;">
<label>Custom Date:</label>
<input type="datetime-local" id="bulk-custom-date" class="form-control">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeBulkUpdateModal()">Cancel</button>
<button class="btn btn-primary" onclick="executeBulkUpdate()">
<i class="fas fa-save"></i> Update Dates
</button>
</div>
</div>
</div>
<div class="table-container">
<table class="data-table">
<table class="data-table sortable-table">
<thead>
<tr>
<th width="40px">
<input type="checkbox" id="select-all-checkbox" onchange="toggleSelectAll()">
</th>
<th>Episode</th>
<th>Aired</th>
<th>Date Added</th>
<th>Source</th>
<th>Video</th>
<th class="sortable" onclick="sortTable('episodes-table-body', 1, 'text')" style="cursor: pointer;">
Episode <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('episodes-table-body', 2, 'date')" style="cursor: pointer;">
Aired <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('episodes-table-body', 3, 'date')" style="cursor: pointer;">
Date Added <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('episodes-table-body', 4, 'text')" style="cursor: pointer;">
Source <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('episodes-table-body', 5, 'text')" style="cursor: pointer;">
Video <i class="fas fa-sort"></i>
</th>
<th>Actions</th>
</tr>
</thead>
@@ -943,6 +1077,7 @@ function showEnhancedEditModal(mediaType, options, currentDateadded, currentSour
// Build enhanced edit form with date options
let formHtml = `
<form id="edit-form-enhanced" onsubmit="handleEnhancedEditSubmit(event); return false;">
<input type="hidden" id="edit-imdb-id" value="${options.imdb_id}">
<input type="hidden" id="edit-media-type" value="${mediaType}">
${mediaType === 'episode' ? `
@@ -1002,8 +1137,9 @@ function showEnhancedEditModal(mediaType, options, currentDateadded, currentSour
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary" onclick="handleEnhancedEditSubmit(event)">Save Changes</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
`;
modalBody.innerHTML = formHtml;
@@ -1086,6 +1222,8 @@ function updateEditDateFromOption(optionIndex, option) {
async function handleEnhancedEditSubmit(event) {
event.preventDefault();
console.log('🔍 Enhanced Edit Submit called');
const modal = document.getElementById('edit-modal');
const options = JSON.parse(modal.dataset.options);
const imdbId = options.imdb_id;
@@ -1093,7 +1231,17 @@ async function handleEnhancedEditSubmit(event) {
const dateadded = document.getElementById('edit-dateadded').value;
const source = document.getElementById('edit-source').value;
console.log('🔍 Form values:', {
imdbId,
mediaType,
dateadded,
dateaddedType: typeof dateadded,
dateaddedLength: dateadded ? dateadded.length : 0,
source
});
if (!dateadded) {
console.log('❌ Date field is empty!');
showToast('Please enter a date', 'warning');
return;
}
@@ -1102,15 +1250,19 @@ async function handleEnhancedEditSubmit(event) {
let isoDateadded = null;
try {
isoDateadded = new Date(dateadded).toISOString();
console.log('✅ Converted to ISO:', isoDateadded);
} catch (e) {
console.error('❌ Date conversion error:', e);
showToast('Invalid date format', 'error');
return;
}
try {
if (mediaType === 'movie') {
console.log('📤 Calling updateMovieDate with:', { imdbId, isoDateadded, source });
await updateMovieDate(imdbId, isoDateadded, source);
} else {
console.log('📤 Calling updateEpisodeDate with:', { imdbId, season: options.season, episode: options.episode, isoDateadded, source });
await updateEpisodeDate(imdbId, options.season, options.episode, isoDateadded, source);
}
@@ -1147,6 +1299,8 @@ function closeModal() {
async function handleEditSubmit(event) {
event.preventDefault();
console.log('🔍 Basic Edit Submit called (OLD HANDLER)');
const imdbId = document.getElementById('edit-imdb-id').value;
const mediaType = document.getElementById('edit-media-type').value;
const season = document.getElementById('edit-season').value;
@@ -1154,13 +1308,26 @@ async function handleEditSubmit(event) {
const dateadded = document.getElementById('edit-dateadded').value;
const source = document.getElementById('edit-source').value;
console.log('🔍 OLD Form values:', {
imdbId,
mediaType,
dateadded,
dateaddedType: typeof dateadded,
dateaddedLength: dateadded ? dateadded.length : 0,
source
});
// Convert datetime-local to ISO string
const isoDateadded = dateadded ? new Date(dateadded).toISOString() : null;
console.log('🔍 OLD isoDateadded:', isoDateadded);
try {
if (mediaType === 'movie') {
console.log('📤 OLD calling updateMovieDate with:', { imdbId, isoDateadded, source });
await updateMovieDate(imdbId, isoDateadded, source);
} else {
console.log('📤 OLD calling updateEpisodeDate');
await updateEpisodeDate(imdbId, parseInt(season), parseInt(episode), isoDateadded, source);
}
@@ -1172,15 +1339,29 @@ async function handleEditSubmit(event) {
// Update functions
async function updateMovieDate(imdbId, dateadded, source) {
console.log('🔍 updateMovieDate called with:', {
imdbId,
dateadded,
dateaddedType: typeof dateadded,
dateaddedValue: dateadded,
source
});
try {
const payload = {
dateadded: dateadded,
source: source
};
console.log('📤 Sending API request with payload:', JSON.stringify(payload));
const result = await apiCall(`/api/movies/${imdbId}`, {
method: 'PUT',
body: JSON.stringify({
dateadded: dateadded,
source: source
})
body: JSON.stringify(payload)
});
console.log('✅ API response:', result);
showToast(result.message, 'success');
// Refresh current view
@@ -1395,6 +1576,92 @@ async function deleteMovie(imdbId) {
}
}
async function updateMovieImdbId(oldImdbId) {
// Prompt for new IMDb ID
const newImdbId = prompt(`Update IMDb ID\n\nCurrent: ${oldImdbId}\n\nEnter the correct IMDb ID (with or without 'tt' prefix):`);
if (!newImdbId || newImdbId.trim() === '') {
return;
}
const cleanImdbId = newImdbId.trim();
// Confirmation
if (!confirm(`Update IMDb ID?\n\nOld: ${oldImdbId}\nNew: ${cleanImdbId}\n\nThis will migrate the movie record to the new IMDb ID. Continue?`)) {
return;
}
try {
const response = await fetch(`/api/movies/${oldImdbId}/migrate-imdb`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ new_imdb_id: cleanImdbId })
});
const result = await response.json();
if (response.ok && result.success) {
showToast(`✅ IMDb ID updated successfully to ${result.new_imdb_id}`, 'success');
// Refresh the movies table
loadMovies(currentMoviesPage);
} else {
const errorMsg = result.message || result.detail || 'Unknown error';
showToast(`❌ Failed to update IMDb ID: ${errorMsg}`, 'error');
}
} catch (error) {
console.error('Update IMDb ID failed:', error);
showToast(`❌ Update failed: ${error.message}`, 'error');
}
}
async function updateSeriesImdbId(oldImdbId) {
// Prompt for new IMDb ID
const newImdbId = prompt(`Update Series IMDb ID\n\nCurrent: ${oldImdbId}\n\nEnter the correct IMDb ID (with or without 'tt' prefix):`);
if (!newImdbId || newImdbId.trim() === '') {
return;
}
const cleanImdbId = newImdbId.trim();
// Confirmation
if (!confirm(`Update Series IMDb ID?\n\nOld: ${oldImdbId}\nNew: ${cleanImdbId}\n\nThis will migrate the series and ALL its episodes to the new IMDb ID. Continue?`)) {
return;
}
try {
const response = await fetch(`/api/series/${oldImdbId}/migrate-imdb`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ new_imdb_id: cleanImdbId })
});
const result = await response.json();
if (response.ok && result.success) {
showToast(`✅ Series IMDb ID updated successfully to ${result.new_imdb_id}`, 'success');
// Refresh the series table
loadSeries(currentSeriesPage);
} else {
const errorMsg = result.message || result.detail || 'Unknown error';
showToast(`❌ Failed to update IMDb ID: ${errorMsg}`, 'error');
}
} catch (error) {
console.error('Update series IMDb ID failed:', error);
showToast(`❌ Update failed: ${error.message}`, 'error');
}
}
// Update episode counts in modal after deletion
function updateEpisodeModalCounts() {
const remainingRows = document.querySelectorAll('#episodes-table-body tr');
@@ -1659,16 +1926,26 @@ function updateBulkDeleteButton() {
const selectedCheckboxes = document.querySelectorAll('.episode-checkbox:checked');
const selectedCount = selectedCheckboxes.length;
const bulkDeleteButton = document.getElementById('bulk-delete-selected');
const bulkUpdateButton = document.getElementById('bulk-update-dates');
const selectedCountSpan = document.getElementById('selected-count');
const updateCountSpan = document.getElementById('update-count');
if (selectedCountSpan) {
selectedCountSpan.textContent = selectedCount;
}
if (updateCountSpan) {
updateCountSpan.textContent = selectedCount;
}
if (bulkDeleteButton) {
bulkDeleteButton.disabled = selectedCount === 0;
}
if (bulkUpdateButton) {
bulkUpdateButton.disabled = selectedCount === 0;
}
// Update select all checkbox state
const selectAllCheckbox = document.getElementById('select-all-checkbox');
const allCheckboxes = document.querySelectorAll('.episode-checkbox');
@@ -1742,3 +2019,440 @@ async function bulkDeleteSelected() {
showToast(`❌ Failed to delete ${failCount} episode(s)`, 'error');
}
}
// Bulk update dates functions
function showBulkUpdateModal() {
const selectedCheckboxes = document.querySelectorAll('.episode-checkbox:checked');
const selectedCount = selectedCheckboxes.length;
if (selectedCount === 0) {
showToast('❌ No episodes selected', 'error');
return;
}
const modal = document.getElementById('bulk-update-modal');
const modalSelectedCount = document.getElementById('modal-selected-count');
const dateSourceSelect = document.getElementById('bulk-date-source');
const customDateInput = document.getElementById('custom-date-input');
if (modalSelectedCount) {
modalSelectedCount.textContent = selectedCount;
}
// Show/hide custom date input based on selection
if (dateSourceSelect) {
dateSourceSelect.onchange = function() {
if (customDateInput) {
customDateInput.style.display = this.value === 'custom' ? 'block' : 'none';
}
};
}
if (modal) {
modal.style.display = 'flex';
}
}
function closeBulkUpdateModal() {
const modal = document.getElementById('bulk-update-modal');
if (modal) {
modal.style.display = 'none';
}
}
async function executeBulkUpdate() {
const selectedCheckboxes = document.querySelectorAll('.episode-checkbox:checked');
const selectedCount = selectedCheckboxes.length;
const dateSource = document.getElementById('bulk-date-source').value;
const customDate = document.getElementById('bulk-custom-date').value;
if (selectedCount === 0) {
showToast('❌ No episodes selected', 'error');
return;
}
if (dateSource === 'custom' && !customDate) {
showToast('❌ Please enter a custom date', 'error');
return;
}
// Close modal
closeBulkUpdateModal();
// Collect selected episodes
const episodes = [];
selectedCheckboxes.forEach(checkbox => {
const row = checkbox.closest('tr');
episodes.push({
imdb_id: row.getAttribute('data-imdb'),
season: parseInt(row.getAttribute('data-season')),
episode: parseInt(row.getAttribute('data-episode'))
});
});
// Show progress
showToast(`⏳ Updating ${selectedCount} episode(s)...`, 'info');
try {
const response = await apiCall('/api/episodes/bulk-update-dates', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
episodes: episodes,
date_source: dateSource,
custom_date: customDate || null
})
});
if (response.success) {
showToast(`✅ Successfully updated ${response.updated || selectedCount} episode(s)`, 'success');
// Reload the current series to show updated dates
const currentImdbId = currentTVSeries;
if (currentImdbId) {
loadEpisodes(currentImdbId);
}
// Clear selections
selectedCheckboxes.forEach(checkbox => checkbox.checked = false);
updateBulkDeleteButton();
} else {
showToast(`${response.message || 'Failed to update episodes'}`, 'error');
}
} catch (error) {
console.error('Bulk update error:', error);
showToast('❌ Error updating episodes', 'error');
}
}
// ==================== Database Population Functions ====================
let populatePollingInterval = null;
async function handlePopulateDatabase(event) {
event.preventDefault();
const mediaType = document.getElementById('populate-media-type').value;
const submitButton = event.target.querySelector('button[type="submit"]');
const originalText = submitButton.innerHTML;
// Confirm action
const confirmMsg = mediaType === 'both'
? 'This will populate the database with ALL movies and TV episodes from Radarr/Sonarr. Continue?'
: mediaType === 'movies'
? 'This will populate the database with ALL movies from Radarr. Continue?'
: 'This will populate the database with ALL TV episodes from Sonarr. Continue?';
if (!confirm(confirmMsg)) {
return;
}
// Disable button and show loading
submitButton.disabled = true;
submitButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Starting...';
try {
const response = await fetch('/admin/populate-database', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ media_type: mediaType })
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.status === 'started') {
showPopulateStatus();
startPopulatePolling();
} else {
throw new Error('Failed to start population');
}
} catch (error) {
console.error('Error starting population:', error);
showToast('❌ Failed to start database population', 'error');
submitButton.disabled = false;
submitButton.innerHTML = originalText;
}
}
function showPopulateStatus() {
const statusDiv = document.getElementById('populate-status');
const resultsDiv = document.getElementById('populate-results');
const progressBar = document.getElementById('populate-progress-bar');
const progressText = document.getElementById('populate-progress-text');
if (statusDiv) {
statusDiv.style.display = 'block';
}
if (progressBar) {
progressBar.style.width = '0%';
}
if (progressText) {
progressText.textContent = 'Starting...';
}
if (resultsDiv) {
resultsDiv.style.display = 'none';
}
}
function hidePopulateStatus() {
const statusDiv = document.getElementById('populate-status');
if (statusDiv) {
statusDiv.style.display = 'none';
}
}
function startPopulatePolling() {
// Clear any existing interval
if (populatePollingInterval) {
clearInterval(populatePollingInterval);
}
// Poll every 2 seconds
populatePollingInterval = setInterval(async () => {
try {
const response = await fetch('/api/populate/status');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const status = await response.json();
updatePopulateProgress(status);
// Stop polling if complete or error (API returns "completed": true/false and "error": string)
if (status.completed || status.error) {
stopPopulatePolling();
// Re-enable submit button
const submitButton = document.querySelector('#populate-form button[type="submit"]');
if (submitButton) {
submitButton.disabled = false;
submitButton.innerHTML = '<i class="fas fa-play"></i> Start Population';
}
}
} catch (error) {
console.error('Error polling populate status:', error);
stopPopulatePolling();
// Re-enable submit button
const submitButton = document.querySelector('#populate-form button[type="submit"]');
if (submitButton) {
submitButton.disabled = false;
submitButton.innerHTML = '<i class="fas fa-play"></i> Start Population';
}
}
}, 2000);
}
function stopPopulatePolling() {
if (populatePollingInterval) {
clearInterval(populatePollingInterval);
populatePollingInterval = null;
}
}
function updatePopulateProgress(status) {
const progressBar = document.getElementById('populate-progress-bar');
const progressText = document.getElementById('populate-progress-text');
const resultsDiv = document.getElementById('populate-results');
// Handle running status (API returns "running": true/false)
if (status.running && !status.completed) {
if (progressBar) {
// Show indeterminate progress
progressBar.style.width = '50%';
}
if (progressText) {
progressText.textContent = 'Populating database...';
}
} else if (status.completed) {
if (progressBar) {
progressBar.style.width = '100%';
}
if (progressText) {
progressText.textContent = 'Complete!';
}
// Show results (API returns movies/tv with nested stats)
if (resultsDiv && (status.movies || status.tv)) {
let html = '<h4>Population Results</h4>';
// Movies
if (status.movies && status.movies.stats) {
const m = status.movies.stats;
html += '<div class="populate-section">';
html += '<h5><i class="fas fa-film"></i> Movies</h5>';
html += '<ul>';
html += `<li>Total found: ${m.total || 0}</li>`;
html += `<li>Added: ${m.added || 0}</li>`;
html += `<li>Skipped: ${m.skipped || 0}</li>`;
html += `<li>Errors: ${m.errors || 0}</li>`;
html += `<li>Duration: ${(m.duration || 0).toFixed(2)}s</li>`;
html += '</ul>';
// Show skipped items if any
if (m.skipped_items && m.skipped_items.length > 0) {
html += '<details style="margin-top: 10px;"><summary>Skipped Movies (' + m.skipped_items.length + ')</summary><ul>';
m.skipped_items.forEach(item => {
html += `<li><strong>${item.title || 'Unknown'}</strong> (${item.year || 'N/A'}) [${item.imdb_id || 'No IMDb'}] - ${item.reason}</li>`;
});
html += '</ul></details>';
}
html += '</div>';
}
// TV Episodes
if (status.tv && status.tv.stats) {
const tv = status.tv.stats;
html += '<div class="populate-section">';
html += '<h5><i class="fas fa-tv"></i> TV Shows</h5>';
html += '<ul>';
html += `<li>Series: ${tv.total_series || 0}</li>`;
html += `<li>Episodes: ${tv.total_episodes || 0}</li>`;
html += `<li>Added: ${tv.added || 0}</li>`;
html += `<li>Skipped: ${tv.skipped || 0}</li>`;
html += `<li>Errors: ${tv.errors || 0}</li>`;
html += `<li>Duration: ${(tv.duration || 0).toFixed(2)}s</li>`;
html += '</ul>';
// Show skipped items if any
if (tv.skipped_items && tv.skipped_items.length > 0) {
html += '<details style="margin-top: 10px;"><summary>Skipped Episodes (' + tv.skipped_items.length + ')</summary><ul>';
tv.skipped_items.forEach(item => {
html += `<li><strong>${item.title || 'Unknown'}</strong> S${String(item.season).padStart(2,'0')}E${String(item.episode).padStart(2,'0')} - ${item.reason}</li>`;
});
html += '</ul></details>';
}
html += '</div>';
}
resultsDiv.innerHTML = html;
resultsDiv.style.display = 'block';
}
showToast('✅ Database population completed successfully', 'success');
} else if (status.status === 'error') {
if (progressFill && progressText) {
progressFill.style.width = '100%';
progressFill.style.backgroundColor = '#dc3545';
progressText.textContent = 'Error!';
}
const errorMsg = status.error || 'Unknown error occurred';
showToast(`❌ Database population failed: ${errorMsg}`, 'error');
if (resultsDiv) {
resultsDiv.innerHTML = `<p class="error">Error: ${errorMsg}</p>`;
resultsDiv.style.display = 'block';
}
}
}
// ==================== Table Sorting Functions ====================
let sortDirections = {}; // Track sort direction for each table column
function sortTable(tableBodyId, columnIndex, dataType) {
const tbody = document.getElementById(tableBodyId);
if (!tbody) return;
const sortKey = `${tableBodyId}-${columnIndex}`;
// Toggle sort direction
if (!sortDirections[sortKey]) {
sortDirections[sortKey] = 'asc';
} else {
sortDirections[sortKey] = sortDirections[sortKey] === 'asc' ? 'desc' : 'asc';
}
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
const aCell = a.cells[columnIndex];
const bCell = b.cells[columnIndex];
if (!aCell || !bCell) return 0;
let aValue = aCell.textContent.trim();
let bValue = bCell.textContent.trim();
// Handle MISSING values - always sort to bottom
if (aValue === 'MISSING') return 1;
if (bValue === 'MISSING') return -1;
// Handle different data types
if (dataType === 'date') {
// Parse dates for comparison
aValue = aValue === '-' ? '' : aValue;
bValue = bValue === '-' ? '' : bValue;
if (!aValue && !bValue) return 0;
if (!aValue) return 1;
if (!bValue) return -1;
const aDate = new Date(aValue);
const bDate = new Date(bValue);
if (sortDirections[sortKey] === 'asc') {
return aDate - bDate;
} else {
return bDate - aDate;
}
} else if (dataType === 'number') {
const aNum = parseFloat(aValue) || 0;
const bNum = parseFloat(bValue) || 0;
if (sortDirections[sortKey] === 'asc') {
return aNum - bNum;
} else {
return bNum - aNum;
}
} else {
// Text comparison
if (sortDirections[sortKey] === 'asc') {
return aValue.localeCompare(bValue);
} else {
return bValue.localeCompare(aValue);
}
}
});
// Re-append sorted rows
rows.forEach(row => tbody.appendChild(row));
// Update sort icons
updateSortIcons(tableBodyId, columnIndex, sortDirections[sortKey]);
}
function updateSortIcons(tableBodyId, activeColumn, direction) {
// Find the table and update icons
const tbody = document.getElementById(tableBodyId);
if (!tbody) return;
const table = tbody.closest('table');
if (!table) return;
const headers = table.querySelectorAll('th.sortable');
headers.forEach((header, index) => {
const icon = header.querySelector('i');
if (!icon) return;
// +1 to account for checkbox column
if (index + 1 === activeColumn) {
icon.className = direction === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down';
} else {
icon.className = 'fas fa-sort';
}
});
}
+34 -311
View File
@@ -11,12 +11,12 @@ from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from core.database import NFOGuardDatabase
from core.nfo_manager import NFOManager
from core.path_mapper import PathMapper
from clients.radarr_client import RadarrClient
from clients.external_clients import ExternalClientManager
from config.settings import config
from utils.logging import _log
from utils.imdb_utils import find_imdb_in_directory # Phase 3: Replaced NFOManager
from utils.file_utils import find_media_path_by_imdb_and_title
@@ -68,9 +68,9 @@ def convert_utc_to_local(utc_iso_string: str) -> str:
class MovieProcessor:
"""Handles movie processing"""
def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper):
def __init__(self, db: NFOGuardDatabase, nfo_manager, path_mapper: PathMapper):
# nfo_manager parameter kept for backward compatibility but no longer used (Phase 3)
self.db = db
self.nfo_manager = nfo_manager
self.path_mapper = path_mapper
self.radarr = RadarrClient(
os.environ.get("RADARR_URL", ""),
@@ -153,7 +153,7 @@ class MovieProcessor:
def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan: bool = False, scan_mode: str = "smart", shutdown_event=None) -> str:
"""Process a movie directory"""
imdb_id = self.nfo_manager.find_movie_imdb_id(movie_path)
imdb_id = find_imdb_in_directory(movie_path) # Phase 3: Using imdb_utils instead of NFOManager
if not imdb_id:
_log("ERROR", f"No IMDb ID found in movie directory, filenames, or NFO file: {movie_path}")
return "error"
@@ -225,106 +225,16 @@ class MovieProcessor:
if released and hasattr(released, 'isoformat'):
released = released.isoformat()
# Create NFO with existing data and update files
if config.manage_nfo:
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)
# NFO file operations removed - database is now the single source of truth
# (Phase 1: Remove NFO file write operations)
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [database-cached]")
return "processed"
else:
_log("INFO", f"🔍 TIER 1 SKIP - {imdb_id}: Database incomplete, proceeding to Tier 2")
# TIER 2: Check if NFO file has NFOGuard data and cache it in database
nfo_path = movie_path / "movie.nfo"
_log("INFO", f"🔍 TIER 2 - Checking NFO file: {nfo_path}")
_log("INFO", f"🔍 TIER 2 - NFO exists: {nfo_path.exists()}")
nfo_data = self.nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
_log("INFO", f"🔍 TIER 2 - NFOGuard data extracted: {nfo_data}")
if nfo_data:
_log("INFO", f"🚀 TIER 2 - Found NFOGuard data in NFO file: {nfo_data['dateadded']} (source: {nfo_data['source']})")
dateadded = nfo_data["dateadded"]
source = nfo_data["source"]
released = nfo_data.get("released")
# Cache NFO data in database for future lookups
# Fixed parameter order: imdb_id, released, dateadded, source
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
_log("INFO", f"✅ Cached NFO data in database for {imdb_id}")
# 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-cached]")
return "processed"
# TIER 2.5: Check for any existing valid date data in NFO (even without lockdata marker)
# Only use NFO dates if prioritize_nfo is enabled, otherwise check external APIs first
existing_nfo_data = self._extract_any_valid_dates_from_nfo(nfo_path)
if existing_nfo_data and config.manual_scan_prioritize_nfo:
_log("INFO", f"🔍 TIER 2.5 - Found existing date data in NFO (no lockdata): {existing_nfo_data['dateadded']} (source: {existing_nfo_data['source']})")
_log("INFO", f"⚡ MANUAL_SCAN_PRIORITIZE_NFO=True - Using NFO date for speed")
dateadded = existing_nfo_data["dateadded"]
source = existing_nfo_data["source"]
released = existing_nfo_data.get("released")
# Cache existing data in database and add proper NFOGuard formatting
# Fixed parameter order: imdb_id, released, dateadded, source
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
_log("INFO", f"✅ Cached existing NFO data in database for {imdb_id}")
# Update NFO file to add NFOGuard formatting (lockdata, comment)
if config.manage_nfo:
self.nfo_manager.create_movie_nfo(
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
)
_log("INFO", f"✅ Added NFOGuard formatting to existing NFO for {imdb_id}")
# Update file mtimes if enabled
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}) [existing-nfo-enhanced]")
return "processed"
elif existing_nfo_data:
_log("INFO", f"🔍 TIER 2.5 - Found existing date data in NFO (no lockdata): {existing_nfo_data['dateadded']} (source: {existing_nfo_data['source']})")
_log("INFO", f"🎯 MANUAL_SCAN_PRIORITIZE_NFO=False - Will verify against external APIs first")
# Store NFO data as fallback but continue to TIER 3 to check external APIs
nfo_fallback_data = existing_nfo_data
# 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 enabled
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]")
return "processed"
# TIER 3: No cached data found - proceed with API lookups and verification
# TIER 2: Query external APIs directly (NFO layer removed in Phase 2)
_log("INFO", f"🔍 TIER 2 - No database cache, querying external APIs")
# Check for shutdown signal before expensive API operations
if shutdown_event and shutdown_event.is_set():
@@ -362,18 +272,8 @@ class MovieProcessor:
final_source = f"{source}_as_dateadded" if source else "release_date_fallback"
_log("INFO", f"Using release date as dateadded: {final_dateadded} (source: {final_source})")
# Create NFO regardless of date availability (preserves existing metadata)
if config.debug:
print(f"🔍 TIER3 - config.manage_nfo: {config.manage_nfo}")
if config.manage_nfo:
if config.debug:
print(f"🔍 TIER3 - Calling create_movie_nfo with final_dateadded: {final_dateadded}")
self.nfo_manager.create_movie_nfo(
movie_path, imdb_id, final_dateadded, released, final_source, config.lock_metadata
)
else:
if config.debug:
print(f"❌ TIER3 - manage_nfo is disabled, skipping NFO creation")
# NFO file operations removed - database is now the single source of truth
# (Phase 1: Remove NFO file write operations)
# Skip remaining processing if no valid date found and file dates disabled
if final_dateadded is None:
@@ -387,9 +287,8 @@ class MovieProcessor:
_log("DEBUG", f"Movie {movie_path.name} proceeding to save: dateadded={dateadded}, source={source}")
# Update file mtimes (only if we have a valid date)
if config.fix_dir_mtimes and dateadded and dateadded != "MANUAL_REVIEW_NEEDED":
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
# File mtime operations removed - database is now the single source of truth
# (Phase 1: Remove NFO file write operations)
_log("DEBUG", f"Movie processing reached file mtime section: fix_dir_mtimes={config.fix_dir_mtimes}, dateadded={dateadded}")
@@ -407,76 +306,34 @@ class MovieProcessor:
return "processed"
def _process_movie_nfo_first(self, movie_path: Path, imdb_id: str, shutdown_event=None) -> str:
"""Process movie for incomplete mode: Check NFO files first for missing dateadded elements"""
_log("INFO", f"🔍 NFO-FIRST MODE: Checking movie for missing dateadded in NFO file: {movie_path.name}")
"""Process movie for incomplete mode: Database-first then API (NFO checks removed in Phase 2)"""
_log("INFO", f"🔍 INCOMPLETE MODE: Checking movie for missing data: {movie_path.name}")
# Check for shutdown signal
if shutdown_event and shutdown_event.is_set():
_log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie NFO-first processing: {movie_path.name}")
_log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie processing: {movie_path.name}")
return "shutdown"
nfo_path = movie_path / "movie.nfo"
# STEP 1: Check if NFO file exists and has dateadded
_log("DEBUG", f"STEP 1 - Checking NFO file for missing dateadded: {nfo_path}")
_log("INFO", f"🔍 NFO exists: {nfo_path.exists()}")
if nfo_path.exists():
nfo_data = self.nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
_log("INFO", f"🔍 NFOGuard data extracted: {nfo_data}")
if nfo_data and nfo_data.get('dateadded'):
# NFO has dateadded - this movie is complete
_log("INFO", f"✅ NFO has dateadded={nfo_data['dateadded']}, movie marked as complete")
dateadded = nfo_data["dateadded"]
source = nfo_data["source"]
released = nfo_data.get("released")
# Cache NFO data in database for future lookups
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
# Update file mtimes if needed
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-complete]")
return "processed"
else:
# NFO exists but missing dateadded
_log("INFO", f"🔍 NFO exists but missing dateadded - needs DB/API lookup")
else:
# No NFO file found
_log("INFO", f"🔍 No NFO file found - needs DB/API lookup")
# STEP 2: For movies missing dateadded in NFO, check database
_log("DEBUG", f"STEP 2 - Checking database for missing dateadded")
# STEP 1: Check database for existing data (Phase 2: NFO check removed)
_log("DEBUG", f"STEP 1 - Checking database for existing data")
existing = self.db.get_movie_dates(imdb_id)
if existing and existing.get("dateadded") and existing.get("source") != "no_valid_date_source":
# Found in database - use cached data and will add to NFO
_log("INFO", f"✅ Database has dateadded={existing['dateadded']} - will add to NFO")
# Found in database - data is complete
_log("INFO", f"✅ Database has dateadded={existing['dateadded']}")
dateadded, source, released = existing["dateadded"], existing["source"], existing.get("released")
# Convert datetime objects to strings for NFO manager
# Convert datetime objects to strings
if hasattr(dateadded, 'isoformat'):
dateadded = dateadded.isoformat()
if released and hasattr(released, 'isoformat'):
released = released.isoformat()
# Create NFO with database data
if config.manage_nfo:
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-to-nfo]")
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [database-cached]")
return "processed"
# STEP 3: For movies still missing dateadded, query APIs
_log("DEBUG", f"STEP 3 - Querying APIs for missing dateadded")
# STEP 2: Database incomplete or missing, query APIs
_log("DEBUG", f"STEP 2 - Querying APIs for missing data")
# Check for shutdown signal before API calls
if shutdown_event and shutdown_event.is_set():
@@ -487,19 +344,10 @@ class MovieProcessor:
is_tmdb_fallback = imdb_id.startswith("tmdb-")
if is_tmdb_fallback:
# TMDB fallback processing
# TMDB fallback processing - use file modification time
_log("INFO", f"🔍 TMDB fallback processing for {imdb_id}")
# Check for existing TMDB NFO date data
tmdb_data = self._extract_dates_from_tmdb_nfo(nfo_path)
if tmdb_data and tmdb_data.get('dateadded'):
dateadded = tmdb_data['dateadded']
released = tmdb_data.get('released')
source = "tmdb_nfo"
else:
# Use file modification time as fallback for TMDB
dateadded, source, released = self._get_file_mtime_date(movie_path)
_log("INFO", f"Using file mtime as fallback for TMDB movie: {dateadded}")
dateadded, source, released = self._get_file_mtime_date(movie_path)
_log("INFO", f"Using file mtime for TMDB movie: {dateadded}")
else:
# Standard IMDb processing
# Try to get digital release date from external APIs
@@ -511,115 +359,21 @@ class MovieProcessor:
released = digital_date # For movies, digital release is often the key date
_log("INFO", f"Got digital release date from APIs: {dateadded} (source: {source})")
else:
# Check Radarr NFO for premiered date
radarr_premiered = self._get_radarr_nfo_premiered_date(movie_path)
if radarr_premiered:
dateadded = radarr_premiered
source = "radarr_nfo"
released = radarr_premiered
_log("INFO", f"Got premiered date from Radarr NFO: {dateadded}")
else:
# Last resort: file modification time
dateadded, source, released = self._get_file_mtime_date(movie_path)
_log("INFO", f"Using file mtime as last resort: {dateadded}")
# Last resort: file modification time
dateadded, source, released = self._get_file_mtime_date(movie_path)
_log("INFO", f"Using file mtime as fallback: {dateadded}")
# Save to database and create NFO
# Save to database only (NFO operations removed in Phase 1)
if dateadded:
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
if config.manage_nfo:
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"🔍 NFO-FIRST COMPLETE: {movie_path.name} (source: {source})")
_log("INFO", f"🔍 INCOMPLETE MODE COMPLETE: {movie_path.name} (source: {source})")
return "processed"
else:
_log("WARNING", f"Could not determine dateadded for movie: {movie_path.name}")
return "error"
def _extract_dates_from_tmdb_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
"""Extract date information from TMDB-based NFO file"""
if not nfo_path.exists():
return None
try:
root = self.nfo_manager._parse_nfo_with_tolerance(nfo_path)
# Look for premiered date (from TMDB)
premiered_elem = root.find('.//premiered')
if premiered_elem is not None and premiered_elem.text:
premiered_date = premiered_elem.text.strip()
print(f"✅ Found TMDB premiered date: {premiered_date}")
return {
"dateadded": premiered_date,
"source": "tmdb:premiered_from_nfo",
"released": premiered_date
}
except (ET.ParseError, Exception) as e:
print(f"⚠️ Error parsing TMDB NFO for dates: {e}")
return None
def _extract_any_valid_dates_from_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
"""Extract any valid date information from NFO file, even without NFOGuard markers"""
if not nfo_path.exists():
return None
try:
import xml.etree.ElementTree as ET
tree = ET.parse(nfo_path)
root = tree.getroot()
# Look for dateadded element (indicates previously processed by NFOGuard or similar)
dateadded_elem = root.find('.//dateadded')
premiered_elem = root.find('.//premiered')
if dateadded_elem is not None and dateadded_elem.text:
dateadded = dateadded_elem.text.strip()
# Try to determine source from NFOGuard comment if present
source = "existing_nfo_data"
try:
nfo_content = nfo_path.read_text(encoding='utf-8')
import re
# Look for NFOGuard comment pattern
source_match = re.search(r'<!--.*?NFOGuard.*?Source:\s*([^-\s]+).*?-->', nfo_content, re.DOTALL | re.IGNORECASE)
if source_match:
source = source_match.group(1).strip()
_log("DEBUG", f"Found source in NFOGuard comment: {source}")
else:
# Try to infer source from dateadded format/content
if "tmdb" in nfo_content.lower() or (premiered_elem and premiered_elem.text):
source = "tmdb:digital"
elif "radarr" in nfo_content.lower():
source = "radarr:db.history.import"
else:
source = "existing_nfo_data"
_log("DEBUG", f"Inferred source from NFO content: {source}")
except Exception as e:
_log("DEBUG", f"Could not determine source from NFO content: {e}")
result = {
"dateadded": dateadded,
"source": source
}
if premiered_elem is not None and premiered_elem.text:
result["released"] = premiered_elem.text.strip()
_log("INFO", f"✅ Found existing date data in NFO: dateadded={dateadded}, source={source}")
return result
except (ET.ParseError, Exception) as e:
_log("DEBUG", f"Error parsing NFO for existing date data: {e}")
return None
# NFO helper methods removed in Phase 2 - database is the single source of truth
def _decide_movie_dates(self, imdb_id: str, movie_path: Path, should_query: bool, existing: Optional[Dict]) -> Tuple[str, str, Optional[str]]:
"""Decide movie dates based on configuration and available data"""
@@ -687,14 +441,7 @@ class MovieProcessor:
# When using digital release date, store it as both dateadded and released
return digital_date, digital_source, digital_date
else:
_log("WARNING", f"⚠️ Movie {imdb_id}: No import date OR digital release date found - trying additional fallbacks")
# Try Radarr's own NFO premiered date as fallback
radarr_premiered = self._get_radarr_nfo_premiered_date(movie_path)
if radarr_premiered:
_log("INFO", f"✅ Movie {imdb_id}: Using Radarr NFO premiered date {radarr_premiered}")
# When using Radarr NFO premiered date, store it as both dateadded and released
return radarr_premiered, "radarr:nfo.premiered", radarr_premiered
_log("WARNING", f"⚠️ Movie {imdb_id}: No import date OR digital release date found")
else: # digital_then_import
# Try digital release first
@@ -753,31 +500,7 @@ class MovieProcessor:
_log("ERROR", f"External clients error for {imdb_id}: {e}")
return None, f"release:error:{str(e)}"
def _get_radarr_nfo_premiered_date(self, movie_path: Path) -> Optional[str]:
"""Extract premiered date from Radarr's existing movie.nfo file"""
try:
nfo_path = movie_path / "movie.nfo"
if not nfo_path.exists():
_log("DEBUG", f"No existing NFO file found at {nfo_path}")
return None
nfo_content = nfo_path.read_text(encoding='utf-8')
# Look for <premiered>YYYY-MM-DD</premiered>
match = re.search(r'<premiered>(\d{4}-\d{2}-\d{2})</premiered>', nfo_content)
if match:
premiered_date = match.group(1)
# Convert to ISO format with timezone
iso_date = f"{premiered_date}T00:00:00+00:00"
_log("INFO", f"✅ Found Radarr NFO premiered date: {premiered_date}")
return iso_date
else:
_log("DEBUG", f"No <premiered> tag found in existing NFO")
return None
except Exception as e:
_log("ERROR", f"Error reading Radarr NFO file: {e}")
return None
# _get_radarr_nfo_premiered_date() removed in Phase 2 - no longer reading NFO files
def _log_failed_movie(self, movie_path: Path, imdb_id: str, reason: str, available_countries: List[str] = None):
"""Log movies that failed to get valid dates to a debug file"""
+47 -220
View File
@@ -11,13 +11,12 @@ from typing import Optional, Dict, List, Set, Tuple, Any
from datetime import datetime
from core.database import NFOGuardDatabase
from core.nfo_manager import NFOManager
from core.async_nfo_manager import AsyncNFOManager
from core.path_mapper import PathMapper
from clients.sonarr_client import SonarrClient
from clients.external_clients import ExternalClientManager
from config.settings import config
from utils.logging import _log
from utils.imdb_utils import parse_imdb_from_path # Phase 3: Replaced NFOManager
from utils.file_utils import (
find_media_path_by_imdb_and_title,
find_episodes_on_disk,
@@ -32,10 +31,9 @@ from utils.async_file_utils import (
class TVProcessor:
"""Handles TV series processing"""
def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper):
def __init__(self, db: NFOGuardDatabase, nfo_manager, path_mapper: PathMapper):
# nfo_manager parameter kept for backward compatibility but no longer used (Phase 3)
self.db = db
self.nfo_manager = nfo_manager
self.async_nfo_manager = AsyncNFOManager(config.manager_brand, config.debug)
self.path_mapper = path_mapper
self.sonarr = SonarrClient(
os.environ.get("SONARR_URL", ""),
@@ -147,7 +145,7 @@ class TVProcessor:
def process_series(self, series_path: Path, force_scan: bool = False, scan_mode: str = "smart") -> str:
"""Process a TV series directory"""
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
imdb_id = parse_imdb_from_path(series_path) # Phase 3: Using imdb_utils
if not imdb_id:
_log("ERROR", f"No IMDb ID found in series path: {series_path}")
return "error"
@@ -193,19 +191,8 @@ class TVProcessor:
if (season, episode) in disk_episodes:
episode_count += 1
# Create NFO
if config.manage_nfo:
season_dir = series_path / config.get_season_dir_name(season)
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)
# NFO file operations removed - database is now the single source of truth
# (Phase 1: Remove NFO file write operations)
# Save to database
try:
@@ -259,64 +246,12 @@ class TVProcessor:
# Not in database or incomplete - needs NFO check
episodes_needing_nfo_check.append((season, episode))
_log("INFO", f"Database cache hits: {db_cache_hits}/{len(disk_episodes)} episodes. Need NFO check: {len(episodes_needing_nfo_check)}")
_log("INFO", f"TIER 1 - Database cache hits: {db_cache_hits}/{len(disk_episodes)} episodes. Need API lookup: {len(episodes_needing_nfo_check)}")
# TIER 2: Check NFO files for NFOGuard dates and cache them in database
nfo_cache_hits = 0
episodes_needing_lookup = []
if episodes_needing_nfo_check:
_log("DEBUG", f"TIER 2 - Checking NFO files for NFOGuard dates for {len(episodes_needing_nfo_check)} episodes")
for (season, episode) in episodes_needing_nfo_check:
# Look for existing NFO files for this episode
season_dir = series_path / config.get_season_dir_name(season)
episode_files = disk_episodes[(season, episode)]
nfo_found = False
for episode_file in episode_files:
# Try to find matching NFO file
nfo_path = episode_file.with_suffix('.nfo')
if nfo_path.exists():
# Extract NFOGuard data from episode NFO
nfo_data = self.nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
if nfo_data:
aired = nfo_data.get('aired')
dateadded = nfo_data.get('dateadded')
source = nfo_data.get('source', 'nfo_cache')
_log("DEBUG", f"S{season:02d}E{episode:02d}: NFO data found - aired={aired}, dateadded={dateadded}, source={source}")
# Skip incomplete NFO files with "unknown" source or no useful dates
if source == "unknown" and not dateadded and not aired:
_log("INFO", f"S{season:02d}E{episode:02d}: Ignoring incomplete NFO file with source 'unknown' and no dates")
break # Break out of NFO search to mark episode as needing API lookup
# Apply fallback logic if NFO has aired but no dateadded
if not dateadded and aired:
dateadded = aired
source = f"{source}_aired_fallback" if source != 'nfo_cache' else 'nfo_aired_fallback'
_log("DEBUG", f"S{season:02d}E{episode:02d}: NFO has aired but no dateadded, using aired as fallback: {dateadded}")
if dateadded:
episode_dates[(season, episode)] = (aired, dateadded, source)
nfo_cache_hits += 1
nfo_found = True
# Cache NFO data in database for future lookups
self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True)
_log("DEBUG", f"NFO cache hit for S{season:02d}E{episode:02d}: {dateadded} - cached in DB")
break
if not nfo_found:
# No NFO data found - needs API lookup
episodes_needing_lookup.append((season, episode))
_log("INFO", f"NFO cache hits: {nfo_cache_hits}/{len(episodes_needing_nfo_check)} episodes. Need API lookup: {len(episodes_needing_lookup)}")
# TIER 3: Only call Sonarr API for episodes not in database or NFO files
# TIER 2: Query Sonarr API for episodes not in database (NFO layer removed in Phase 2)
episodes_needing_lookup = episodes_needing_nfo_check # Renamed for clarity
if episodes_needing_lookup:
_log("DEBUG", f"TIER 3 - Querying Sonarr for {len(episodes_needing_lookup)} episodes missing from database and NFO files")
_log("DEBUG", f"TIER 2 - Querying Sonarr for {len(episodes_needing_lookup)} episodes missing from database")
sonarr_episodes = self._get_sonarr_episodes(imdb_id, episodes_needing_lookup)
# Process episodes that needed lookup
@@ -385,77 +320,35 @@ class TVProcessor:
return episode_dates
def _gather_episode_dates_nfo_first(self, series_path: Path, imdb_id: str, disk_episodes: Dict[Tuple[int, int], List[Path]]) -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]:
"""Gather episode dates for incomplete mode: Check NFO files first for missing dateadded elements"""
_log("INFO", f"🔍 NFO-FIRST MODE: Checking {len(disk_episodes)} episodes for missing dateadded in NFO files")
"""Gather episode dates for incomplete mode: Database-first then API (NFO checks removed in Phase 2)"""
_log("INFO", f"🔍 INCOMPLETE MODE: Checking {len(disk_episodes)} episodes for missing data")
episode_dates = {}
episodes_needing_db_check = []
episodes_needing_api_lookup = []
# STEP 1: Check each NFO file to determine if dateadded is missing
_log("DEBUG", f"STEP 1 - Checking NFO files for missing dateadded elements")
nfo_complete_count = 0
# STEP 1: Check database for all episodes (NFO check removed in Phase 2)
_log("DEBUG", f"STEP 1 - Checking database for {len(disk_episodes)} episodes")
db_hits = 0
for (season, episode) in disk_episodes:
season_dir = series_path / config.get_season_dir_name(season)
episode_files = disk_episodes[(season, episode)]
db_result = self.db.get_episode_date(imdb_id, season, episode)
nfo_has_dateadded = False
nfo_data = None
if db_result and db_result.get('dateadded'):
# Found in database - use cached data
aired = db_result.get('aired')
dateadded = db_result.get('dateadded')
source = db_result.get('source', 'database_cache')
episode_dates[(season, episode)] = (aired, dateadded, source)
db_hits += 1
_log("DEBUG", f"S{season:02d}E{episode:02d}: Database has dateadded={dateadded}")
else:
# Not in database or incomplete - needs API lookup
episodes_needing_api_lookup.append((season, episode))
# Look for NFO file for this episode
for episode_file in episode_files:
nfo_path = episode_file.with_suffix('.nfo')
if nfo_path.exists():
# Extract NFOGuard data from episode NFO
nfo_data = self.nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
if nfo_data and nfo_data.get('dateadded'):
# NFO has dateadded - this episode is complete
aired = nfo_data.get('aired')
dateadded = nfo_data.get('dateadded')
source = nfo_data.get('source', 'nfo_cache')
episode_dates[(season, episode)] = (aired, dateadded, source)
nfo_complete_count += 1
nfo_has_dateadded = True
_log("DEBUG", f"S{season:02d}E{episode:02d}: NFO has dateadded={dateadded}, marked as complete")
break
_log("INFO", f"Database hits: {db_hits}/{len(disk_episodes)} episodes. Need API lookup: {len(episodes_needing_api_lookup)}")
if not nfo_has_dateadded:
# NFO missing or missing dateadded - needs further processing
if nfo_data:
# NFO exists but missing dateadded
_log("DEBUG", f"S{season:02d}E{episode:02d}: NFO exists but missing dateadded - needs DB/API lookup")
else:
# No NFO file found
_log("DEBUG", f"S{season:02d}E{episode:02d}: No NFO file found - needs DB/API lookup")
episodes_needing_db_check.append((season, episode))
_log("INFO", f"NFO files with dateadded: {nfo_complete_count}/{len(disk_episodes)} episodes. Need DB check: {len(episodes_needing_db_check)}")
# STEP 2: For episodes missing dateadded in NFO, check database
if episodes_needing_db_check:
_log("DEBUG", f"STEP 2 - Checking database for {len(episodes_needing_db_check)} episodes missing dateadded")
db_hits = 0
for (season, episode) in episodes_needing_db_check:
db_result = self.db.get_episode_date(imdb_id, season, episode)
if db_result and db_result.get('dateadded'):
# Found in database - use cached data and will add to NFO later
aired = db_result.get('aired')
dateadded = db_result.get('dateadded')
source = db_result.get('source', 'database_cache')
episode_dates[(season, episode)] = (aired, dateadded, source)
db_hits += 1
_log("DEBUG", f"S{season:02d}E{episode:02d}: Database has dateadded={dateadded} - will add to NFO")
else:
# Not in database or incomplete - needs API lookup
episodes_needing_api_lookup.append((season, episode))
_log("INFO", f"Database hits: {db_hits}/{len(episodes_needing_db_check)} episodes. Need API lookup: {len(episodes_needing_api_lookup)}")
# STEP 3: For episodes still missing dateadded, query Sonarr
# STEP 2: For episodes missing from database, query Sonarr
if episodes_needing_api_lookup:
_log("DEBUG", f"STEP 3 - Querying Sonarr for {len(episodes_needing_api_lookup)} episodes missing from NFO and database")
_log("DEBUG", f"STEP 2 - Querying Sonarr for {len(episodes_needing_api_lookup)} episodes missing from database")
sonarr_episodes = self._get_sonarr_episodes(imdb_id, episodes_needing_api_lookup)
for (season, episode) in episodes_needing_api_lookup:
@@ -503,7 +396,7 @@ class TVProcessor:
episode_dates[(season, episode)] = (aired, dateadded, source)
_log("INFO", f"🔍 NFO-FIRST COMPLETE: {len(episode_dates)} episodes processed")
_log("INFO", f"🔍 INCOMPLETE MODE COMPLETE: {len(episode_dates)} episodes processed")
for (s, e), (aired, dateadded, source) in episode_dates.items():
_log("INFO", f" S{s:02d}E{e:02d}: aired={aired}, dateadded={dateadded}, source={source}")
@@ -648,18 +541,8 @@ class TVProcessor:
processed_count = 0
for (season, episode), (aired, dateadded, source) in episode_dates.items():
if (season, episode) in season_episodes:
# Create NFO
if config.manage_nfo:
self.nfo_manager.create_episode_nfo(
season_path,
season, episode, aired, dateadded, source, config.lock_metadata
)
# Update file mtimes
if config.fix_dir_mtimes and dateadded:
video_files = season_episodes[(season, episode)]
for video_file in video_files:
self.nfo_manager.set_file_mtime(video_file, dateadded)
# NFO file operations removed - database is now the single source of truth
# (Phase 1: Remove NFO file write operations)
# Save to database
try:
@@ -728,16 +611,8 @@ class TVProcessor:
aired, dateadded, source = episode_dates[(season_num, episode_num)]
# Create NFO
if config.manage_nfo:
self.nfo_manager.create_episode_nfo(
season_path,
season_num, episode_num, aired, dateadded, source, config.lock_metadata
)
# Update file mtime
if config.fix_dir_mtimes and dateadded:
self.nfo_manager.set_file_mtime(episode_path, dateadded)
# NFO file operations removed - database is now the single source of truth
# (Phase 1: Remove NFO file write operations)
# Save to database
self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True)
@@ -765,7 +640,7 @@ class TVProcessor:
Returns:
Dictionary with processing results
"""
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
imdb_id = parse_imdb_from_path(series_path) # Phase 3: Using imdb_utils
if not imdb_id:
return {"status": "error", "reason": f"No IMDb ID found in series path: {series_path}"}
@@ -787,25 +662,8 @@ class TVProcessor:
for (season, episode), (aired, dateadded, source) in episode_dates.items():
if (season, episode) in disk_episodes:
season_dir = series_path / config.get_season_dir_name(season)
# Prepare NFO creation data
if config.manage_nfo:
episode_data_list.append({
'season_dir': season_dir,
'season': season,
'episode': episode,
'aired': aired,
'dateadded': dateadded,
'source': source,
'lock_metadata': config.lock_metadata
})
# Prepare mtime operations
if config.fix_dir_mtimes and dateadded:
video_files = disk_episodes[(season, episode)]
for video_file in video_files:
mtime_operations.append((video_file, dateadded))
# NFO file operations removed - database is now the single source of truth
# (Phase 1: Remove NFO file write operations)
# Save to database
try:
@@ -815,27 +673,10 @@ class TVProcessor:
_log("ERROR", f"S{season:02d}E{episode:02d}: Database write failed: {e}")
# Continue processing other episodes
# Process NFOs and mtimes concurrently
# NFO and mtime operations removed - database is now the single source of truth
# (Phase 1: Remove NFO file write operations)
results = {}
if episode_data_list:
_log("INFO", f"Creating {len(episode_data_list)} episode NFOs concurrently")
nfo_results = await self.async_nfo_manager.async_batch_create_episode_nfos(
episode_data_list,
max_concurrent=config.max_concurrent
)
results['nfo_created'] = sum(nfo_results)
results['nfo_failed'] = len(nfo_results) - sum(nfo_results)
if mtime_operations:
_log("INFO", f"Setting mtimes for {len(mtime_operations)} files concurrently")
mtime_results = await self.async_nfo_manager.async_batch_set_file_mtimes(
mtime_operations,
max_concurrent=10
)
results['mtime_updated'] = sum(mtime_results)
results['mtime_failed'] = len(mtime_results) - sum(mtime_results)
_log("INFO", f"Completed async processing TV series: {series_path.name}")
return {
@@ -892,7 +733,7 @@ class TVProcessor:
def process_webhook_episodes(self, series_path: Path, webhook_episodes: List[Dict[str, Any]]) -> None:
"""Process only the specific episodes mentioned in a webhook (targeted mode)"""
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
imdb_id = parse_imdb_from_path(series_path) # Phase 3: Using imdb_utils
if not imdb_id:
_log("ERROR", f"No IMDb ID found in series path: {series_path}")
return
@@ -942,18 +783,8 @@ class TVProcessor:
aired, dateadded, source = self._get_webhook_episode_date(imdb_id, season_num, episode_num, series_metadata, webhook_episode)
enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num, season_dir) if series_metadata else self._get_episode_metadata(None, season_num, episode_num, season_dir)
# Create NFO
if config.manage_nfo:
self.nfo_manager.create_episode_nfo(
season_dir,
season_num, episode_num, aired, dateadded, source, config.lock_metadata,
enhanced_metadata
)
# Update file mtimes
if config.fix_dir_mtimes and dateadded:
for episode_file in episode_files:
self.nfo_manager.set_file_mtime(episode_file, dateadded)
# NFO file operations removed - database is now the single source of truth
# (Phase 1: Remove NFO file write operations)
# Save to database
self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True)
@@ -1208,17 +1039,13 @@ class TVProcessor:
dateadded = episode_data.get('dateadded')
# Get IMDb ID
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
imdb_id = parse_imdb_from_path(series_path) # Phase 3: Using imdb_utils
if not imdb_id:
return {"status": "error", "reason": "No IMDb ID found"}
# Create NFO if needed
nfo_success = True
if config.manage_nfo:
season_dir = series_path / config.get_season_dir_name(season)
nfo_success = await self.async_nfo_manager.async_create_episode_nfo(
season_dir, season, episode, aired, dateadded, "webhook", config.lock_metadata
)
# NFO file operations removed - database is now the single source of truth
# (Phase 1: Remove NFO file write operations)
nfo_success = True # Always True since we're not creating NFOs anymore
# Update database
self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, "webhook", True)
+69 -5
View File
@@ -29,6 +29,19 @@ class TVSeriesProcessor:
self.sonarr = sonarr_client
self.external_manager = ExternalClientManager()
# Initialize Sonarr database client for high-performance queries
self.sonarr_db = None
try:
from clients.sonarr_db_client import SonarrDbClient
self.sonarr_db = SonarrDbClient.from_env()
if self.sonarr_db:
_log("INFO", "✅ SONARR DB MODE: Direct database access enabled")
else:
_log("INFO", "⚠️ SONARR API MODE: Using API (configure SONARR_DB_TYPE for better performance)")
except Exception as e:
_log("WARNING", f"Sonarr DB client initialization failed, using API: {e}")
self.sonarr_db = None
def process_series_manual_scan(self, series_path: Path) -> bool:
"""Process a TV series during manual scan"""
_log("INFO", f"Processing TV series: {series_path.name}")
@@ -219,11 +232,62 @@ class TVSeriesProcessor:
return False
def _get_episode_dates_from_sonarr(self, imdb_id: str, season_num: int, episode_num: int) -> Tuple[Optional[str], Optional[str], str]:
"""Get episode dates from Sonarr API with proper fallback"""
"""Get episode dates from Sonarr (DB first, then API fallback)"""
aired = None
dateadded = None
source = "no_data_found"
# TIER 1: Try Sonarr database (high performance)
if self.sonarr_db:
try:
# Find series by IMDb
series = self.sonarr_db.get_series_by_imdb(imdb_id)
if series:
series_id = series['id']
# Get all episodes for series
episodes = self.sonarr_db.get_all_episodes_for_series(series_id)
# Find target episode
target_episode = None
for ep in episodes:
if ep['season'] == season_num and ep['episode'] == episode_num:
target_episode = ep
break
if target_episode:
# Get air date
aired = target_episode.get('air_date')
# Try to get import date from history
episode_id = target_episode['id']
import_date, import_source = self.sonarr_db.get_episode_import_date(episode_id)
if import_date:
dateadded = import_date
source = import_source
_log("INFO", f"✅ DB: Found import date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
return aired, dateadded, source
# Fallback to episode file date
file_date = self.sonarr_db.get_episode_file_date(series_id, season_num, episode_num)
if file_date:
dateadded = file_date
source = "sonarr:db.file.dateAdded"
_log("INFO", f"✅ DB: Using file date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
return aired, dateadded, source
# Use air date as fallback
if aired:
dateadded = convert_utc_to_local(aired)
source = "sonarr:db.airDate"
_log("WARNING", f"⚠️ DB: No import date, using air date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
return aired, dateadded, source
except Exception as e:
_log("WARNING", f"Sonarr DB query failed for S{season_num:02d}E{episode_num:02d}, falling back to API: {e}")
# TIER 2: Fall back to Sonarr API
if not self.sonarr.enabled:
_log("WARNING", "Sonarr not enabled, cannot get episode dates")
return aired, dateadded, source
@@ -266,15 +330,15 @@ class TVSeriesProcessor:
# Sonarr import dates are already in local timezone despite 'Z' suffix
# Remove 'Z' and use as-is to avoid double timezone conversion
dateadded = import_date.replace('Z', '') if 'Z' in import_date else import_date
source = "sonarr:history.import"
_log("INFO", f"Found import date for S{season_num:02d}E{episode_num:02d}: {dateadded} (no timezone conversion)")
source = "sonarr:api.history.import"
_log("INFO", f"✅ API: Found import date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
return aired, dateadded, source
# Fallback to airdate if no import history
if aired:
dateadded = convert_utc_to_local(aired)
source = "sonarr:episode.airDateUtc"
_log("WARNING", f"No import history for S{season_num:02d}E{episode_num:02d}, using airdate: {dateadded}")
source = "sonarr:api.episode.airDateUtc"
_log("WARNING", f"⚠️ API: No import history for S{season_num:02d}E{episode_num:02d}, using airdate: {dateadded}")
return aired, dateadded, source
except Exception as e:
+2 -2
View File
@@ -29,7 +29,7 @@ def create_web_app() -> FastAPI:
app = FastAPI(
title="NFOGuard Web Interface",
description="Web interface for NFOGuard media database management",
version="2.7.0-web",
version="2.9.0-fixes-only-files",
docs_url=None, # Disable docs in production
redoc_url=None
)
@@ -94,7 +94,7 @@ def setup_static_files(app: FastAPI) -> None:
"status": "healthy",
"service": "nfoguard-web",
"timestamp": time.time(),
"version": "2.8.0-web"
"version": "2.9.0-fixes-only-files"
}
except Exception as e:
from fastapi import HTTPException
+33
View File
@@ -382,6 +382,39 @@
<i class="fas fa-sync"></i> Refresh Stats
</button>
</div>
<div class="tool-card">
<h3><i class="fas fa-upload"></i> Populate Database</h3>
<p>Bulk import data from Radarr/Sonarr into NFOGuard database</p>
<form id="populate-form">
<div class="form-group">
<label>Media Type:</label>
<select id="populate-media-type" required>
<option value="both">Movies & TV Shows</option>
<option value="movies">Movies Only</option>
<option value="tv">TV Shows Only</option>
</select>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-play"></i> Start Population
</button>
</form>
<div id="populate-status" class="scan-status" style="display: none;">
<div class="scan-progress">
<div class="progress-bar">
<div class="progress-fill" id="populate-progress-bar"></div>
</div>
<div class="scan-info">
<span id="populate-current-operation">Running...</span>
<span id="populate-progress-text">In Progress</span>
</div>
</div>
<div id="populate-results" class="stats-display" style="margin-top: 10px;"></div>
<button class="btn btn-secondary btn-sm" onclick="stopPopulatePolling()">
<i class="fas fa-times"></i> Hide Status
</button>
</div>
</div>
</div>
</div>
</main>
+184
View File
@@ -77,6 +77,7 @@ function initializeEventListeners() {
document.getElementById('edit-form').addEventListener('submit', handleEditSubmit);
document.getElementById('bulk-update-form').addEventListener('submit', handleBulkUpdate);
document.getElementById('manual-scan-form').addEventListener('submit', handleManualScan);
document.getElementById('populate-form').addEventListener('submit', handlePopulateDatabase);
}
// API calls
@@ -1742,3 +1743,186 @@ async function bulkDeleteSelected() {
showToast(`❌ Failed to delete ${failCount} episode(s)`, 'error');
}
}
// ---------------------------
// Database Population Functions
// ---------------------------
async function handlePopulateDatabase(event) {
event.preventDefault();
const mediaType = document.getElementById('populate-media-type').value;
// Validate input
if (!mediaType) {
showToast('❌ Please select media type', 'error');
return;
}
// Confirm with user
const confirmMsg = `Are you sure you want to populate the database with ${mediaType}? This will query Radarr/Sonarr and may take several minutes.`;
if (!confirm(confirmMsg)) {
return;
}
try {
// Show populate status
showPopulateStatus();
// Start the population
showToast('🚀 Starting database population...', 'info');
const response = await fetch(`/admin/populate-database?media_type=${mediaType}`, {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (result.status === 'started') {
showToast('✅ Population started successfully', 'success');
// Start polling for status
startPopulatePolling();
} else {
showToast(`${result.message || 'Population completed'}`, 'info');
hidePopulateStatus();
}
} catch (error) {
console.error('Database population failed:', error);
showToast(`❌ Population failed: ${error.message}`, 'error');
hidePopulateStatus();
}
}
function showPopulateStatus() {
const populateStatus = document.getElementById('populate-status');
const progressBar = document.getElementById('populate-progress-bar');
const operationText = document.getElementById('populate-current-operation');
const progressText = document.getElementById('populate-progress-text');
const resultsDiv = document.getElementById('populate-results');
populateStatus.style.display = 'block';
progressBar.style.width = '0%';
operationText.textContent = 'Initializing...';
progressText.textContent = '0%';
resultsDiv.innerHTML = '';
}
function hidePopulateStatus() {
const populateStatus = document.getElementById('populate-status');
if (populateStatus) {
populateStatus.style.display = 'none';
}
}
function startPopulatePolling() {
// Poll every 2 seconds for populate status
window.populatePollingInterval = setInterval(async () => {
try {
const response = await fetch('/api/populate/status');
if (!response.ok) {
throw new Error('Failed to get populate status');
}
const status = await response.json();
updatePopulateProgress(status);
// Stop polling if population is complete
if (!status.running && status.completed) {
stopPopulatePolling();
showToast('✅ Database population completed!', 'success');
}
} catch (error) {
console.error('Failed to poll populate status:', error);
// Don't show error toast repeatedly, just stop polling
stopPopulatePolling();
}
}, 2000);
}
function stopPopulatePolling() {
if (window.populatePollingInterval) {
clearInterval(window.populatePollingInterval);
window.populatePollingInterval = null;
}
}
function updatePopulateProgress(status) {
const progressBar = document.getElementById('populate-progress-bar');
const operationText = document.getElementById('populate-current-operation');
const progressText = document.getElementById('populate-progress-text');
const resultsDiv = document.getElementById('populate-results');
if (status.error) {
progressBar.style.width = '100%';
progressBar.style.backgroundColor = '#e74c3c';
operationText.textContent = 'Error occurred';
progressText.textContent = 'Failed';
resultsDiv.innerHTML = `<p style="color: #e74c3c;">Error: ${status.error}</p>`;
return;
}
if (!status.running && status.completed) {
progressBar.style.width = '100%';
operationText.textContent = 'Population completed';
progressText.textContent = '100%';
// Display results
let resultsHTML = '<h4>Population Results:</h4>';
if (status.movies && status.movies.stats) {
const m = status.movies.stats;
resultsHTML += `
<div style="margin: 10px 0;">
<strong>Movies:</strong><br>
Total: ${m.total || 0} | Added: ${m.added || 0} | Skipped: ${m.skipped || 0} | Errors: ${m.errors || 0}<br>
Duration: ${m.duration ? m.duration.toFixed(2) : 0}s
</div>
`;
}
if (status.tv && status.tv.stats) {
const t = status.tv.stats;
resultsHTML += `
<div style="margin: 10px 0;">
<strong>TV Shows:</strong><br>
Series: ${t.total_series || 0} | Episodes: ${t.total_episodes || 0}<br>
Added: ${t.added || 0} | Skipped: ${t.skipped || 0} | Errors: ${t.errors || 0}<br>
Duration: ${t.duration ? t.duration.toFixed(2) : 0}s
</div>
`;
}
resultsDiv.innerHTML = resultsHTML;
return;
}
// Update progress based on status
let totalProgress = 0;
let progressDetails = '';
if (status.movies && status.movies.status === 'running') {
totalProgress = 50;
progressDetails = 'Processing movies...';
} else if (status.movies && status.movies.status === 'completed') {
totalProgress = 50;
progressDetails = 'Movies completed, processing TV...';
}
if (status.tv && status.tv.status === 'running') {
totalProgress = 75;
progressDetails = 'Processing TV shows...';
} else if (status.tv && status.tv.status === 'completed') {
totalProgress = 100;
progressDetails = 'Completed';
}
progressBar.style.width = `${totalProgress}%`;
operationText.textContent = progressDetails;
progressText.textContent = `${totalProgress}%`;
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""
IMDb ID Extraction Utilities
Parses IMDb IDs from directory/file names (no file I/O)
Phase 3: Replaces NFOManager for IMDb ID extraction only
"""
import re
from pathlib import Path
from typing import Optional
def parse_imdb_from_path(path: Path) -> Optional[str]:
"""
Extract IMDb ID from directory path or filename using regex patterns.
Does NOT read any files - only parses the path string.
Supported patterns:
- [imdb-tt1234567]
- [tt1234567]
- {imdb-tt1234567}
- (imdb-tt1234567)
- -tt1234567 (at end)
- _tt1234567 (at end)
Args:
path: Path object to parse
Returns:
IMDb ID (e.g., "tt1234567") or None if not found
"""
path_str = str(path).lower()
# Try [imdb-ttXXXXXXX] format first (most explicit)
match = re.search(r'\[imdb-?(tt\d+)\]', path_str)
if match:
return match.group(1)
# Try standalone [ttXXXXXXX] format in brackets
match = re.search(r'\[(tt\d+)\]', path_str)
if match:
return match.group(1)
# Try {imdb-ttXXXXXXX} format with curly braces
match = re.search(r'\{imdb-?(tt\d+)\}', path_str)
if match:
return match.group(1)
# Try (imdb-ttXXXXXXX) format with parentheses
match = re.search(r'\(imdb-?(tt\d+)\)', path_str)
if match:
return match.group(1)
# Try ttXXXXXXX at end of filename/dirname (common pattern)
match = re.search(r'[-_\s](tt\d+)$', path_str)
if match:
return match.group(1)
return None
def find_imdb_in_directory(directory: Path) -> Optional[str]:
"""
Find IMDb ID from directory name or filenames within the directory.
Does NOT read file contents - only checks filenames.
Args:
directory: Directory path to search
Returns:
IMDb ID or None if not found
"""
# First try directory name itself
imdb_id = parse_imdb_from_path(directory)
if imdb_id:
return imdb_id
# Try all filenames in the directory
if directory.is_dir():
for file_path in directory.iterdir():
if file_path.is_file():
imdb_id = parse_imdb_from_path(file_path)
if imdb_id:
return imdb_id
return None
+17 -3
View File
@@ -96,6 +96,8 @@ def _setup_file_logging():
# Clear any existing handlers to avoid duplicates
logger.handlers.clear()
# Try to set up file logging
file_logging_enabled = False
try:
file_handler = SafeRotatingFileHandler(
log_dir / "nfoguard.log", maxBytes=50*1024*1024, backupCount=3
@@ -106,9 +108,9 @@ def _setup_file_logging():
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
file_logging_enabled = True
except Exception as e:
# If RotatingFileHandler fails, fall back to regular FileHandler
print(f"Warning: Could not setup rotating file handler ({e}), using regular file handler")
# If RotatingFileHandler fails, try regular FileHandler
try:
file_handler = logging.FileHandler(log_dir / "nfoguard.log")
formatter = TimezoneAwareFormatter(
@@ -116,8 +118,20 @@ def _setup_file_logging():
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
file_logging_enabled = True
except Exception as e2:
print(f"Error: Could not setup any file logging: {e2}")
# File logging not available (e.g., read-only filesystem)
# Fall back to console-only logging silently
pass
# If file logging failed, ensure console handler is added
if not file_logging_enabled:
console_handler = logging.StreamHandler()
formatter = TimezoneAwareFormatter(
'[%(asctime)s] %(levelname)s: %(message)s'
)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
return logger
+28 -44
View File
@@ -10,12 +10,14 @@ from concurrent.futures import ThreadPoolExecutor
from config.settings import config
from utils.logging import _log
from utils.imdb_utils import find_imdb_in_directory, parse_imdb_from_path # Phase 3: Replaced NFOManager
class WebhookBatcher:
"""Batches webhook events to avoid processing storms"""
def __init__(self, nfo_manager=None):
# nfo_manager parameter kept for backward compatibility but no longer used (Phase 3)
self.pending: Dict[str, Dict] = {}
self.timers: Dict[str, threading.Timer] = {}
self.processing: Set[str] = set()
@@ -24,8 +26,6 @@ class WebhookBatcher:
# Will be set by the application when processors are available
self.tv_processor = None
self.movie_processor = None
# NFO manager for comprehensive IMDb detection
self.nfo_manager = nfo_manager
def set_processors(self, tv_processor, movie_processor):
"""Set the processor instances"""
@@ -85,55 +85,39 @@ class WebhookBatcher:
if media_type == 'movie':
expected_imdb = key.replace('movie:', '') if key.startswith('movie:') else key
# Use comprehensive IMDb detection (directory, filenames, NFO files)
if self.nfo_manager:
detected_imdb = self.nfo_manager.find_movie_imdb_id(path_obj)
imdb_match = False
if detected_imdb:
# Compare with and without 'tt' prefix for flexibility
if detected_imdb == expected_imdb or detected_imdb.replace('tt', '') == expected_imdb.replace('tt', ''):
imdb_match = True
# Use imdb_utils for IMDb detection (Phase 3: no NFO reading)
detected_imdb = find_imdb_in_directory(path_obj)
imdb_match = False
if detected_imdb:
# Compare with and without 'tt' prefix for flexibility
if detected_imdb == expected_imdb or detected_imdb.replace('tt', '') == expected_imdb.replace('tt', ''):
imdb_match = True
if not imdb_match:
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found via comprehensive detection in path {path_str}")
_log("ERROR", f"Detected IMDb: {detected_imdb}, Expected: {expected_imdb}")
_log("ERROR", f"This prevents processing wrong movies due to batch corruption")
return
_log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} matches detected {detected_imdb}")
else:
# Fallback to simple string search if nfo_manager not available
if expected_imdb not in path_str.lower():
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found in path {path_str} (fallback mode)")
_log("ERROR", f"This prevents processing wrong movies due to batch corruption")
return
_log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} found in path (fallback mode)")
if not imdb_match:
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} but found {detected_imdb} in {path_str}")
_log("ERROR", f"Detected IMDb: {detected_imdb}, Expected: {expected_imdb}")
_log("ERROR", f"This prevents processing wrong movies due to batch corruption")
return
_log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} matches detected {detected_imdb}")
# CRITICAL: Validate that the path contains the expected IMDb ID for TV shows
if media_type == 'tv':
expected_imdb = key.replace('tv:', '') if key.startswith('tv:') else key
# Use comprehensive IMDb detection (directory, filenames, tvshow.nfo files)
if self.nfo_manager:
detected_imdb = self.nfo_manager.find_series_imdb_id(path_obj)
imdb_match = False
if detected_imdb:
# Compare with and without 'tt' prefix for flexibility
if detected_imdb == expected_imdb or detected_imdb.replace('tt', '') == expected_imdb.replace('tt', ''):
imdb_match = True
# Use imdb_utils for IMDb detection (Phase 3: no NFO reading)
detected_imdb = parse_imdb_from_path(path_obj)
imdb_match = False
if detected_imdb:
# Compare with and without 'tt' prefix for flexibility
if detected_imdb == expected_imdb or detected_imdb.replace('tt', '') == expected_imdb.replace('tt', ''):
imdb_match = True
if not imdb_match:
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found via comprehensive detection in TV path {path_str}")
_log("ERROR", f"Detected TV IMDb: {detected_imdb}, Expected: {expected_imdb}")
_log("ERROR", f"This prevents processing wrong TV series due to batch corruption")
return
_log("DEBUG", f"TV batch validation passed: IMDb {expected_imdb} matches detected {detected_imdb}")
else:
# Fallback to simple string search if nfo_manager not available
if expected_imdb not in path_str.lower():
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found in TV path {path_str} (fallback mode)")
_log("ERROR", f"This prevents processing wrong TV series due to batch corruption")
return
_log("DEBUG", f"TV batch validation passed: IMDb {expected_imdb} found in path (fallback mode)")
if not imdb_match:
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} but found {detected_imdb} in TV {path_str}")
_log("ERROR", f"Detected TV IMDb: {detected_imdb}, Expected: {expected_imdb}")
_log("ERROR", f"This prevents processing wrong TV series due to batch corruption")
return
_log("DEBUG", f"TV batch validation passed: IMDb {expected_imdb} matches detected {detected_imdb}")
if not self.tv_processor:
_log("ERROR", "TV processor not available")