From f46f1df7cbce2d9691818315f10473fbbcae4a8e Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 11:04:55 -0400 Subject: [PATCH] web: fix missing .nfo dates and manual date changes --- api/web_routes.py | 307 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 300 insertions(+), 7 deletions(-) diff --git a/api/web_routes.py b/api/web_routes.py index 01061e6..1e7efb5 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -1237,15 +1237,15 @@ def register_web_routes(app, dependencies): @app.post("/api/movies/{imdb_id}/update-date") async def api_update_movie_date(imdb_id: str, dateadded: str = None, source: str = "manual"): - return {"error": "Updates not available in web container. Use core container on port 8085."} + 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 {"error": "Updates not available in web container. Use core container on port 8085."} + return await update_movie_date(dependencies, imdb_id, dateadded, source) @app.get("/api/movies/{imdb_id}/date-options") async def api_movie_date_options(imdb_id: str): - return {"options": [], "message": "Date options not available in web container. Use core container on port 8085."} + return await get_movie_date_options(dependencies, imdb_id) @app.delete("/api/movies/{imdb_id}") async def api_delete_movie(imdb_id: str): @@ -1294,16 +1294,16 @@ def register_web_routes(app, dependencies): @app.post("/api/episodes/{imdb_id}/{season}/{episode}/update-date") async def api_update_episode_date(imdb_id: str, season: int, episode: int, dateadded: str = None, source: str = "manual"): - return {"error": "Updates not available in web container. Use core container on port 8085."} + return await update_episode_date(dependencies, imdb_id, season, episode, dateadded, source) @app.put("/api/episodes/{imdb_id}/{season}/{episode}") async def api_update_episode(imdb_id: str, season: int, episode: int, dateadded: str = None, source: str = "manual"): - return {"error": "Updates not available in web container. Use core container on port 8085."} + return await update_episode_date(dependencies, imdb_id, season, episode, dateadded, source) @app.get("/api/episodes/{imdb_id}/{season}/{episode}/date-options") async def api_episode_date_options(imdb_id: str, season: int, episode: int): - return {"options": [], "message": "Date options not available in web container. Use core container on port 8085."} + return await get_episode_date_options(dependencies, imdb_id, season, episode) @app.delete("/api/episodes/{imdb_id}/{season}/{episode}") async def api_delete_episode(imdb_id: str, season: int, episode: int): @@ -1458,4 +1458,297 @@ def register_web_routes(app, dependencies): except json.JSONDecodeError: return {"scanning": False, "message": "Invalid response from core container"} except Exception as e: - return {"scanning": False, "message": f"Unable to check scan status: {str(e)}"} \ No newline at end of file + return {"scanning": False, "message": f"Unable to check scan status: {str(e)}"} + + # Register database admin routes + await register_database_admin_routes(app, dependencies) + + +# ============================================================================= +# DATABASE ADMIN INTERFACE +# ============================================================================= + +async def execute_database_query(dependencies: dict, query: str, limit: int = 100): + """Execute a database query and return results""" + db = dependencies["db"] + + try: + with db.get_connection() as conn: + cursor = conn.cursor() + + # Add LIMIT if not already present in query + if "LIMIT" not in query.upper() and not query.strip().endswith(";"): + query += f" LIMIT {limit}" + elif query.strip().endswith(";"): + query = query.strip()[:-1] + f" LIMIT {limit};" + + cursor.execute(query) + + if query.strip().upper().startswith("SELECT"): + results = cursor.fetchall() + columns = [desc[0] for desc in cursor.description] if cursor.description else [] + return { + "success": True, + "query": query, + "columns": columns, + "rows": [dict(zip(columns, row)) for row in results], + "row_count": len(results) + } + else: + conn.commit() + return { + "success": True, + "query": query, + "message": f"Query executed successfully. Rows affected: {cursor.rowcount}", + "rows_affected": cursor.rowcount + } + + except Exception as e: + return { + "success": False, + "query": query, + "error": str(e), + "message": f"Database query failed: {str(e)}" + } + + +async def get_episodes_missing_nfo_dateadded(dependencies: dict): + """Find episodes that have dateadded in database but missing from NFO files""" + db = dependencies["db"] + config = dependencies["config"] + + try: + # Get all episodes with dateadded in database + with db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT e.imdb_id, e.season, e.episode, e.dateadded, e.source, e.has_video_file, + s.path as series_path + FROM episodes e + JOIN series s ON e.imdb_id = s.imdb_id + WHERE e.dateadded IS NOT NULL + AND e.has_video_file = TRUE + ORDER BY e.imdb_id, e.season, e.episode + LIMIT 500 + """) + episodes = [dict(row) for row in cursor.fetchall()] + + # Check each episode's NFO file + missing_dateadded = [] + nfo_manager = dependencies["nfo_manager"] + + for ep in episodes: + try: + # Build expected NFO path + series_path = Path(ep['series_path']) + season_dir = series_path / config.tv_season_dir_format.format(season=ep['season']) + + if not season_dir.exists(): + continue + + # Find NFO file for this episode + nfo_files = list(season_dir.glob(f"*S{ep['season']:02d}E{ep['episode']:02d}*.nfo")) + if not nfo_files: + continue + + nfo_path = nfo_files[0] # Use first match + + # Parse NFO and check for dateadded + try: + import xml.etree.ElementTree as ET + tree = ET.parse(nfo_path) + root = tree.getroot() + dateadded_elem = root.find('dateadded') + + if dateadded_elem is None or not dateadded_elem.text: + missing_dateadded.append({ + **ep, + 'nfo_path': str(nfo_path), + 'nfo_exists': True, + 'dateadded_in_nfo': False + }) + except ET.ParseError: + # NFO file is corrupted + missing_dateadded.append({ + **ep, + 'nfo_path': str(nfo_path), + 'nfo_exists': True, + 'dateadded_in_nfo': False, + 'nfo_corrupt': True + }) + + except Exception as e: + print(f"Error checking NFO for {ep['imdb_id']} S{ep['season']:02d}E{ep['episode']:02d}: {e}") + continue + + return { + "success": True, + "total_episodes_checked": len(episodes), + "missing_dateadded_count": len(missing_dateadded), + "episodes": missing_dateadded[:50] # Limit display to first 50 + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "message": f"Failed to check NFO files: {str(e)}" + } + + +async def bulk_update_nfo_files(dependencies: dict, imdb_ids: list = None, fix_all: bool = False): + """Update NFO files with missing dateadded elements from database""" + db = dependencies["db"] + config = dependencies["config"] + nfo_manager = dependencies["nfo_manager"] + + if not config.manage_nfo: + return { + "success": False, + "message": "NFO management is disabled in configuration" + } + + try: + # Build query based on parameters + if fix_all: + query = """ + SELECT e.imdb_id, e.season, e.episode, e.aired, e.dateadded, e.source, + s.path as series_path + FROM episodes e + JOIN series s ON e.imdb_id = s.imdb_id + WHERE e.dateadded IS NOT NULL + AND e.has_video_file = TRUE + ORDER BY e.imdb_id, e.season, e.episode + """ + params = [] + elif imdb_ids: + placeholders = ','.join(['%s'] * len(imdb_ids)) + query = f""" + SELECT e.imdb_id, e.season, e.episode, e.aired, e.dateadded, e.source, + s.path as series_path + FROM episodes e + JOIN series s ON e.imdb_id = s.imdb_id + WHERE e.imdb_id IN ({placeholders}) + AND e.dateadded IS NOT NULL + AND e.has_video_file = TRUE + ORDER BY e.imdb_id, e.season, e.episode + """ + params = imdb_ids + else: + return { + "success": False, + "message": "No IMDb IDs specified and fix_all not enabled" + } + + # Get episodes to update + with db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(query, params) + episodes = [dict(row) for row in cursor.fetchall()] + + if not episodes: + return { + "success": True, + "message": "No episodes found to update", + "updated_count": 0 + } + + # Update NFO files + updated_count = 0 + errors = [] + + for ep in episodes: + try: + series_path = Path(ep['series_path']) + season_dir = series_path / config.tv_season_dir_format.format(season=ep['season']) + + if not season_dir.exists(): + continue + + # Update NFO file + nfo_manager.create_episode_nfo( + season_dir=season_dir, + season_num=ep['season'], + episode_num=ep['episode'], + aired=ep['aired'], + dateadded=ep['dateadded'], + source=ep['source'], + lock_metadata=config.lock_metadata + ) + + updated_count += 1 + print(f"✅ Updated NFO: {ep['imdb_id']} S{ep['season']:02d}E{ep['episode']:02d}") + + except Exception as e: + error_msg = f"Failed to update {ep['imdb_id']} S{ep['season']:02d}E{ep['episode']:02d}: {str(e)}" + errors.append(error_msg) + print(f"❌ {error_msg}") + + return { + "success": True, + "message": f"Updated {updated_count} NFO files", + "updated_count": updated_count, + "total_episodes": len(episodes), + "errors": errors[:10] # Limit errors to first 10 + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "message": f"Bulk NFO update failed: {str(e)}" + } + + +# Add database admin routes to the web interface +async def register_database_admin_routes(app, dependencies): + """Register database admin routes""" + + @app.post("/api/admin/database/query") + async def api_database_query(query: str, limit: int = 100): + """Execute a database query""" + return await execute_database_query(dependencies, query, limit) + + @app.get("/api/admin/nfo/missing-dateadded") + async def api_episodes_missing_nfo_dateadded(): + """Get episodes missing dateadded in NFO files""" + return await get_episodes_missing_nfo_dateadded(dependencies) + + @app.post("/api/admin/nfo/bulk-update") + async def api_bulk_update_nfo_files(imdb_ids: list = None, fix_all: bool = False): + """Bulk update NFO files with missing dateadded""" + return await bulk_update_nfo_files(dependencies, imdb_ids, fix_all) + + @app.get("/api/admin/database/quick-queries") + async def api_database_quick_queries(): + """Get predefined quick queries for common tasks""" + return { + "success": True, + "queries": [ + { + "name": "Episodes missing dateadded in NFO", + "description": "Find episodes with dateadded in DB but missing from NFO files", + "query": "SELECT e.imdb_id, e.season, e.episode, e.dateadded, e.source, s.path FROM episodes e JOIN series s ON e.imdb_id = s.imdb_id WHERE e.dateadded IS NOT NULL AND e.has_video_file = TRUE ORDER BY e.last_updated DESC" + }, + { + "name": "Recent webhook episodes", + "description": "Episodes processed in last 24 hours", + "query": "SELECT * FROM episodes WHERE last_updated > NOW() - INTERVAL '1 day' ORDER BY last_updated DESC" + }, + { + "name": "Episodes by source type", + "description": "Count episodes by their date source", + "query": "SELECT source, COUNT(*) as count FROM episodes GROUP BY source ORDER BY count DESC" + }, + { + "name": "Series with most episodes", + "description": "Series sorted by episode count", + "query": "SELECT s.imdb_id, s.path, COUNT(e.episode) as episode_count FROM series s LEFT JOIN episodes e ON s.imdb_id = e.imdb_id GROUP BY s.imdb_id, s.path ORDER BY episode_count DESC" + }, + { + "name": "Episodes without video files", + "description": "Episodes in database without video files", + "query": "SELECT imdb_id, season, episode, dateadded, source FROM episodes WHERE has_video_file = FALSE ORDER BY imdb_id, season, episode" + } + ] + } \ No newline at end of file