Files
nfoguard/api/web_routes.py
T
sbcrumb 0bf72c1e5c
Local Docker Build (Dev) / build-dev (push) Successful in 4s
feat:fix
2025-10-14 15:59:52 -04:00

620 lines
22 KiB
Python

"""
Web interface API routes for NFOGuard database management
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 pathlib import Path
from api.models import *
# ---------------------------
# Database Query Endpoints
# ---------------------------
async def get_movies_list(dependencies: dict,
skip: int = Query(0, ge=0),
limit: int = Query(100, le=1000),
has_date: Optional[bool] = Query(None),
source_filter: Optional[str] = Query(None),
search: Optional[str] = Query(None),
imdb_search: Optional[str] = Query(None)):
"""Get paginated list of movies with filtering options"""
db = dependencies["db"]
with db.get_connection() as conn:
cursor = conn.cursor()
# Build dynamic query
where_conditions = []
params = []
if has_date is not None:
if has_date:
where_conditions.append("dateadded IS NOT NULL AND dateadded != ''")
else:
where_conditions.append("(dateadded IS NULL OR dateadded = '')")
if source_filter:
where_conditions.append("source = ?")
params.append(source_filter)
if search:
where_conditions.append("(imdb_id LIKE ? OR path LIKE ?)")
params.extend([f"%{search}%", f"%{search}%"])
if imdb_search:
where_conditions.append("imdb_id LIKE ?")
params.append(f"%{imdb_search}%")
where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"
# Get total count
count_query = f"SELECT COUNT(*) FROM movies WHERE {where_clause}"
cursor.execute(count_query, params)
total_count = cursor.fetchone()[0]
# Get paginated results
query = f"""
SELECT imdb_id, path, released, dateadded, source, has_video_file, last_updated
FROM movies
WHERE {where_clause}
ORDER BY last_updated DESC
LIMIT ? OFFSET ?
"""
cursor.execute(query, params + [limit, skip])
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']
movies.append(movie)
return {
"movies": movies,
"total_count": total_count,
"page": skip // limit + 1,
"pages": (total_count + limit - 1) // limit,
"has_next": skip + limit < total_count,
"has_prev": skip > 0
}
async def get_tv_series_list(dependencies: dict,
skip: int = Query(0, ge=0),
limit: int = Query(50, le=500),
search: Optional[str] = Query(None)):
"""Get paginated list of TV series with episode counts"""
db = dependencies["db"]
with db.get_connection() as conn:
cursor = conn.cursor()
# Build dynamic query
where_conditions = []
params = []
if search:
where_conditions.append("(s.imdb_id LIKE ? OR s.path LIKE ?)")
params.extend([f"%{search}%", f"%{search}%"])
where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"
# Get total count
count_query = f"SELECT COUNT(*) FROM series s WHERE {where_clause}"
cursor.execute(count_query, params)
total_count = cursor.fetchone()[0]
# Get series with episode statistics
query = f"""
SELECT
s.imdb_id,
s.path,
s.last_updated,
COUNT(e.episode) as total_episodes,
COUNT(CASE WHEN e.dateadded IS NOT NULL AND e.dateadded != '' THEN 1 END) as episodes_with_dates,
COUNT(CASE WHEN e.has_video_file = 1 THEN 1 END) as episodes_with_video
FROM series s
LEFT JOIN episodes e ON s.imdb_id = e.imdb_id
WHERE {where_clause}
GROUP BY s.imdb_id, s.path, s.last_updated
ORDER BY s.last_updated DESC
LIMIT ? OFFSET ?
"""
cursor.execute(query, params + [limit, skip])
series = []
for row in cursor.fetchall():
series_data = dict(row)
# Extract title from path
try:
series_data['title'] = Path(series_data['path']).name if series_data['path'] else series_data['imdb_id']
except:
series_data['title'] = series_data['imdb_id']
series.append(series_data)
return {
"series": series,
"total_count": total_count,
"page": skip // limit + 1,
"pages": (total_count + limit - 1) // limit,
"has_next": skip + limit < total_count,
"has_prev": skip > 0
}
async def get_series_episodes(dependencies: dict, imdb_id: str):
"""Get all episodes for a specific TV series"""
db = dependencies["db"]
with db.get_connection() as conn:
cursor = conn.cursor()
# Get series info
cursor.execute("SELECT * FROM series WHERE imdb_id = ?", (imdb_id,))
series_row = cursor.fetchone()
if not series_row:
raise HTTPException(status_code=404, detail="Series not found")
series_info = dict(series_row)
try:
series_info['title'] = Path(series_info['path']).name if series_info['path'] else imdb_id
except:
series_info['title'] = imdb_id
# Get episodes
cursor.execute("""
SELECT season, episode, aired, dateadded, source, has_video_file, last_updated
FROM episodes
WHERE imdb_id = ?
ORDER BY season, episode
""", (imdb_id,))
episodes = [dict(row) for row in cursor.fetchall()]
return {
"series": series_info,
"episodes": episodes
}
async def get_missing_dates_report(dependencies: dict):
"""Generate report of movies and episodes missing dateadded"""
db = dependencies["db"]
with db.get_connection() as conn:
cursor = conn.cursor()
# Movies without dates
cursor.execute("""
SELECT imdb_id, path, released, source, last_updated
FROM movies
WHERE dateadded IS NULL OR dateadded = '' OR source = 'no_valid_date_source'
ORDER BY last_updated DESC
""")
movies_missing = []
for row in cursor.fetchall():
movie = dict(row)
try:
movie['title'] = Path(movie['path']).name if movie['path'] else movie['imdb_id']
except:
movie['title'] = movie['imdb_id']
movies_missing.append(movie)
# Episodes without dates
cursor.execute("""
SELECT e.imdb_id, e.season, e.episode, e.aired, e.source, e.last_updated, s.path
FROM episodes e
JOIN series s ON e.imdb_id = s.imdb_id
WHERE e.dateadded IS NULL OR e.dateadded = '' OR e.source = 'no_valid_date_source'
ORDER BY e.last_updated DESC
""")
episodes_missing = []
for row in cursor.fetchall():
episode = dict(row)
try:
episode['series_title'] = Path(episode['path']).name if episode['path'] else episode['imdb_id']
except:
episode['series_title'] = episode['imdb_id']
episodes_missing.append(episode)
# Summary statistics
cursor.execute("SELECT COUNT(*) FROM movies WHERE dateadded IS NOT NULL AND dateadded != ''")
movies_with_dates = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM movies")
total_movies = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM episodes WHERE dateadded IS NOT NULL AND dateadded != ''")
episodes_with_dates = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM episodes")
total_episodes = cursor.fetchone()[0]
return {
"summary": {
"movies_with_dates": movies_with_dates,
"movies_missing_dates": len(movies_missing),
"total_movies": total_movies,
"episodes_with_dates": episodes_with_dates,
"episodes_missing_dates": len(episodes_missing),
"total_episodes": total_episodes
},
"movies_missing": movies_missing,
"episodes_missing": episodes_missing
}
async def get_dashboard_stats(dependencies: dict):
"""Get comprehensive dashboard statistics"""
db = dependencies["db"]
# Get basic stats from existing method
stats = db.get_stats()
with db.get_connection() as conn:
cursor = conn.cursor()
# Enhanced statistics
cursor.execute("SELECT COUNT(*) FROM movies WHERE dateadded IS NOT NULL AND dateadded != ''")
movies_with_dates = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM episodes WHERE dateadded IS NOT NULL AND dateadded != ''")
episodes_with_dates = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM movies WHERE source = 'no_valid_date_source'")
movies_no_valid_source = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM episodes WHERE source = 'no_valid_date_source'")
episodes_no_valid_source = cursor.fetchone()[0]
# Recent activity (last 7 days)
cursor.execute("""
SELECT COUNT(*) FROM processing_history
WHERE processed_at > datetime('now', '-7 days')
""")
recent_activity = cursor.fetchone()[0]
# Source distribution for movies
cursor.execute("""
SELECT source, COUNT(*) as count
FROM movies
WHERE source IS NOT NULL
GROUP BY source
ORDER BY count DESC
""")
movie_sources = [{"source": row[0], "count": row[1]} for row in cursor.fetchall()]
# Source distribution for episodes
cursor.execute("""
SELECT source, COUNT(*) as count
FROM episodes
WHERE source IS NOT NULL
GROUP BY source
ORDER BY count DESC
""")
episode_sources = [{"source": row[0], "count": row[1]} for row in cursor.fetchall()]
# Combine with enhanced stats
stats.update({
"movies_with_dates": movies_with_dates,
"movies_missing_dates": stats["movies_total"] - movies_with_dates,
"episodes_with_dates": episodes_with_dates,
"episodes_missing_dates": stats["episodes_total"] - episodes_with_dates,
"movies_no_valid_source": movies_no_valid_source,
"episodes_no_valid_source": episodes_no_valid_source,
"recent_activity_count": recent_activity,
"movie_sources": movie_sources,
"episode_sources": episode_sources
})
return stats
# ---------------------------
# Database Modification Endpoints
# ---------------------------
async def update_movie_date(dependencies: dict, imdb_id: str, dateadded: Optional[str], source: str):
"""Update dateadded for a specific movie"""
db = dependencies["db"]
# Debug logging to track the issue
print(f"🔍 UPDATE_MOVIE_DATE: imdb_id={imdb_id}, dateadded={dateadded}, source={source}")
print(f" - dateadded type: {type(dateadded)}")
print(f" - dateadded repr: {repr(dateadded)}")
# Validate inputs
if not imdb_id or not imdb_id.strip():
print(f"❌ Invalid imdb_id: {repr(imdb_id)}")
raise HTTPException(status_code=422, detail="Invalid IMDb ID")
if not source or not source.strip():
print(f"❌ Invalid source: {repr(source)}")
raise HTTPException(status_code=422, detail="Invalid source")
# Validate date format if provided
if dateadded:
try:
from datetime import datetime
datetime.fromisoformat(dateadded.replace('Z', '+00:00'))
except Exception as e:
print(f"❌ Invalid dateadded format: {repr(dateadded)} - {e}")
raise HTTPException(status_code=422, detail=f"Invalid date format: {dateadded}")
# Validate movie exists
movie = db.get_movie_dates(imdb_id)
if not movie:
raise HTTPException(status_code=404, detail="Movie not found")
# Update the date
db.upsert_movie_dates(
imdb_id=imdb_id,
released=movie.get('released'),
dateadded=dateadded,
source=source,
has_video_file=movie.get('has_video_file', False)
)
# Add to processing history
try:
db.add_processing_history(
imdb_id=imdb_id,
media_type="movie",
event_type="manual_date_update",
details={"old_source": movie.get('source'), "new_source": source, "dateadded": dateadded}
)
except Exception as e:
print(f"⚠️ Failed to add processing history: {e}")
# Don't fail the entire update for history logging issues
print(f"✅ Successfully updated movie {imdb_id}")
return {"status": "success", "message": f"Updated movie {imdb_id}"}
async def update_episode_date(dependencies: dict, imdb_id: str, season: int, episode: int,
dateadded: Optional[str], source: str):
"""Update dateadded for a specific episode"""
db = dependencies["db"]
# Get existing episode
episode_data = db.get_episode_date(imdb_id, season, episode)
if not episode_data:
raise HTTPException(status_code=404, detail="Episode not found")
# Update the date
db.upsert_episode_date(
imdb_id=imdb_id,
season=season,
episode=episode,
aired=episode_data.get('aired'),
dateadded=dateadded,
source=source,
has_video_file=episode_data.get('has_video_file', False)
)
# Add to processing history
db.add_processing_history(
imdb_id=imdb_id,
media_type="episode",
event_type="manual_date_update",
details={
"season": season,
"episode": episode,
"old_source": episode_data.get('source'),
"new_source": source,
"dateadded": dateadded
}
)
return {"status": "success", "message": f"Updated episode {imdb_id} S{season:02d}E{episode:02d}"}
async def bulk_update_source(dependencies: dict, media_type: str, old_source: str, new_source: str):
"""Bulk update source for movies or episodes"""
db = dependencies["db"]
if media_type not in ["movies", "episodes"]:
raise HTTPException(status_code=400, detail="media_type must be 'movies' or 'episodes'")
with db.get_connection() as conn:
cursor = conn.cursor()
if media_type == "movies":
# Update movies
cursor.execute("UPDATE movies SET source = ? WHERE source = ?", (new_source, old_source))
updated_count = cursor.rowcount
# Add history entries
cursor.execute("SELECT imdb_id FROM movies WHERE source = ?", (new_source,))
for row in cursor.fetchall():
db.add_processing_history(
imdb_id=row[0],
media_type="movie",
event_type="bulk_source_update",
details={"old_source": old_source, "new_source": new_source}
)
else:
# Update episodes
cursor.execute("UPDATE episodes SET source = ? WHERE source = ?", (new_source, old_source))
updated_count = cursor.rowcount
# Add history entries
cursor.execute("SELECT imdb_id, season, episode FROM episodes WHERE source = ?", (new_source,))
for row in cursor.fetchall():
db.add_processing_history(
imdb_id=row[0],
media_type="episode",
event_type="bulk_source_update",
details={
"season": row[1],
"episode": row[2],
"old_source": old_source,
"new_source": new_source
}
)
return {
"status": "success",
"message": f"Updated {updated_count} {media_type} from source '{old_source}' to '{new_source}'"
}
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)
if not movie:
raise HTTPException(status_code=404, detail="Movie not found")
# Debug logging (can be removed once Smart Fix is working)
print(f"🔍 DEBUG: Movie data for {imdb_id}:")
print(f" - released: {repr(movie.get('released'))}")
print(f" - dateadded: {repr(movie.get('dateadded'))}")
print(f" - source: {repr(movie.get('source'))}")
options = []
# Option 1: Current dateadded (if exists and is different from released)
if movie.get('dateadded'):
current_source = movie.get('source', 'Unknown')
current_date = movie['dateadded']
# Determine what type of current date this is
if 'radarr' in current_source.lower() and 'import' in current_source.lower():
label = "Keep Current (Radarr Import Date)"
description = f"Keep using Radarr download/import date: {current_date}"
elif current_source == 'digital_release':
label = "Keep Current (Digital Release)"
description = f"Keep using digital release date: {current_date}"
elif current_source == 'nfo_file_existing':
label = "Keep Current (From Existing NFO)"
description = f"Keep using date from existing NFO file: {current_date}"
else:
label = f"Keep Current ({current_source})"
description = f"Keep using current date from {current_source}: {current_date}"
options.append({
"type": "current",
"label": label,
"date": current_date,
"source": current_source,
"description": description
})
# Option 2: Released date as digital release (if different from current)
if movie.get('released') and movie['released'].strip():
try:
released_raw = movie['released']
# Handle different released date formats
if 'T' in released_raw:
# Already has time component: 2018-07-27T00:00:00+00:00
release_date = released_raw
else:
# Just date: 2018-07-27
release_date = f"{released_raw}T00:00:00"
# Validate the date format
from datetime import datetime
datetime.fromisoformat(release_date.replace('Z', '+00:00'))
# Only add if it's different from current dateadded
current_dateadded = movie.get('dateadded', '')
if not current_dateadded or not current_dateadded.startswith(released_raw[:10]): # Compare just the date part
options.append({
"type": "digital_release",
"label": "Use Actual Release Date",
"date": release_date,
"source": "digital_release",
"description": f"Use the movie's actual release date: {released_raw[:10]} (instead of download date)"
})
except Exception as e:
print(f"⚠️ Invalid released date format for {imdb_id}: {movie.get('released')} - {e}")
# Don't add this option if the date is invalid
# Option 3: Manual entry
options.append({
"type": "manual",
"label": "Manual Entry",
"date": None,
"source": "manual",
"description": "Enter custom date and time"
})
# Try to get additional metadata from external sources
try:
# This would integrate with your existing TMDB/OMDB logic
# For now, we'll use the existing released date as an example
pass
except Exception:
pass
print(f"🔍 DEBUG: Generated {len(options)} options for {imdb_id}:")
for i, option in enumerate(options):
print(f" Option {i}: {option}")
return {
"imdb_id": imdb_id,
"current_data": movie,
"options": options
}
async def get_episode_date_options(dependencies: dict, imdb_id: str, season: int, episode: int):
"""Get available date options for an episode"""
db = dependencies["db"]
# Get current episode data
episode_data = db.get_episode_date(imdb_id, season, episode)
if not episode_data:
raise HTTPException(status_code=404, detail="Episode not found")
options = []
# Option 1: Current dateadded (if exists)
if episode_data.get('dateadded'):
options.append({
"type": "current",
"label": f"Keep Current ({episode_data.get('source', 'Unknown')})",
"date": episode_data['dateadded'],
"source": episode_data.get('source', 'manual'),
"description": f"Currently using: {episode_data.get('source', 'Unknown')}"
})
# Option 2: Aired date
if episode_data.get('aired'):
options.append({
"type": "airdate",
"label": "Use Air Date",
"date": f"{episode_data['aired']}T20:00:00", # Default to 8 PM
"source": "airdate",
"description": f"Use original air date: {episode_data['aired']}"
})
# Option 3: Manual entry
options.append({
"type": "manual",
"label": "Manual Entry",
"date": None,
"source": "manual",
"description": "Enter custom date and time"
})
return {
"imdb_id": imdb_id,
"season": season,
"episode": episode,
"current_data": episode_data,
"options": options
}