diff --git a/api/routes.py b/api/routes.py index 82c918d..35df7b8 100644 --- a/api/routes.py +++ b/api/routes.py @@ -688,6 +688,9 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N movie_skipped += 1 elif result == "processed": movie_processed += 1 + elif result == "no_video_files": + print(f"INFO: Skipped empty directory: {item.name}") + movie_skipped += 1 except Exception as e: print(f"ERROR: Failed processing movie {item}: {e}") movie_total += 1 @@ -1132,6 +1135,34 @@ async def delete_series_episodes(imdb_id: str, dependencies: dict): } +async def delete_movie(imdb_id: str, dependencies: dict): + """Delete a specific movie from the database""" + db = dependencies["db"] + + try: + deleted = db.delete_movie(imdb_id) + + if deleted: + return { + "success": True, + "message": f"Deleted movie {imdb_id} from database", + "imdb_id": imdb_id + } + else: + return { + "success": False, + "message": f"Movie {imdb_id} not found in database", + "imdb_id": imdb_id + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "imdb_id": imdb_id + } + + async def cleanup_orphaned_episodes(dependencies: dict): """Find and delete episodes that don't have corresponding video files""" db = dependencies["db"] @@ -1154,6 +1185,28 @@ async def cleanup_orphaned_episodes(dependencies: dict): } +async def cleanup_orphaned_movies(dependencies: dict): + """Find and delete movies that don't have corresponding video files""" + db = dependencies["db"] + + try: + deleted_movies = db.delete_orphaned_movies() + + return { + "success": True, + "message": f"Cleaned up {len(deleted_movies)} orphaned movies", + "deleted_count": len(deleted_movies), + "deleted_movies": deleted_movies + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "message": "Failed to cleanup orphaned movies" + } + + # --------------------------- # Route Registration # --------------------------- @@ -1212,10 +1265,18 @@ def register_routes(app, dependencies: dict): async def _delete_series_episodes(imdb_id: str): return await delete_series_episodes(imdb_id, dependencies) + @app.delete("/database/movie/{imdb_id}") + async def _delete_movie(imdb_id: str): + return await delete_movie(imdb_id, dependencies) + @app.post("/database/cleanup/orphaned-episodes") async def _cleanup_orphaned_episodes(): return await cleanup_orphaned_episodes(dependencies) + @app.post("/database/cleanup/orphaned-movies") + async def _cleanup_orphaned_movies(): + return await cleanup_orphaned_movies(dependencies) + @app.post("/manual/scan") async def _manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = None, scan_type: str = "both", scan_mode: str = "smart"): return await manual_scan(background_tasks, path, scan_type, scan_mode, dependencies) diff --git a/core/database.py b/core/database.py index a13235c..a80b15c 100644 --- a/core/database.py +++ b/core/database.py @@ -205,7 +205,7 @@ class NFOGuardDatabase: # Debug: Check what was actually saved cursor.execute("SELECT dateadded, source FROM movies WHERE imdb_id = %s", (imdb_id,)) result = cursor.fetchone() - print(f"🔍 DATABASE VERIFY: After upsert, found dateadded={result[0] if result else 'NOT_FOUND'}, source={result[1] if result else 'NOT_FOUND'}") + print(f"🔍 DATABASE VERIFY: After upsert, found dateadded={result['dateadded'] if result else 'NOT_FOUND'}, source={result['source'] if result else 'NOT_FOUND'}") def get_series_episodes(self, imdb_id: str, has_video_file_only: bool = False) -> List[Dict]: """Get all episodes for a series""" @@ -411,6 +411,96 @@ class NFOGuardDatabase: return deleted_episodes + def delete_movie(self, imdb_id: str) -> bool: + """ + Delete a specific movie from the database + + Args: + imdb_id: Movie IMDb ID + + Returns: + True if movie was deleted, False if not found + """ + with self.get_connection() as conn: + cursor = conn.cursor() + + cursor.execute(""" + DELETE FROM movies + WHERE imdb_id = %s + """, (imdb_id,)) + + deleted_count = cursor.rowcount + conn.commit() + + return deleted_count > 0 + + def delete_orphaned_movies(self) -> List[Dict]: + """ + Find and delete movies that don't have corresponding video files on disk + This requires checking filesystem for each movie, so use carefully + + Returns: + List of deleted movies with their details + """ + from pathlib import Path + + deleted_movies = [] + + with self.get_connection() as conn: + cursor = conn.cursor() + + # Get all movies with their paths + cursor.execute(""" + SELECT imdb_id, path, dateadded, source + FROM movies + """) + + movies_list = cursor.fetchall() + + for movie in movies_list: + imdb_id = movie['imdb_id'] + movie_path = Path(movie['path']) + + if not movie_path.exists(): + # Movie directory doesn't exist - delete it + cursor.execute(""" + DELETE FROM movies + WHERE imdb_id = %s + """, (imdb_id,)) + + deleted_movies.append({ + 'imdb_id': imdb_id, + 'reason': 'directory_not_found', + 'path': str(movie_path), + 'dateadded': movie['dateadded'], + 'source': movie['source'] + }) + continue + + # Check for video files + video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") + has_video = any(f.is_file() and f.suffix.lower() in video_exts + for f in movie_path.iterdir() if f.is_file()) + + if not has_video: + # No video files found - delete this movie + cursor.execute(""" + DELETE FROM movies + WHERE imdb_id = %s + """, (imdb_id,)) + + deleted_movies.append({ + 'imdb_id': imdb_id, + 'reason': 'no_video_files', + 'path': str(movie_path), + 'dateadded': movie['dateadded'], + 'source': movie['source'] + }) + + conn.commit() + + return deleted_movies + def close(self): """Close all database connections""" if hasattr(self._local, 'connection'): diff --git a/processors/movie_processor.py b/processors/movie_processor.py index 85db39e..b5daf61 100644 --- a/processors/movie_processor.py +++ b/processors/movie_processor.py @@ -188,9 +188,8 @@ class MovieProcessor: has_video = any(f.is_file() and f.suffix.lower() in video_exts for f in movie_path.iterdir()) if not has_video: - _log("WARNING", f"No video files found in: {movie_path}") - self.db.upsert_movie_dates(imdb_id, None, None, None, False) - return "processed" + _log("WARNING", f"No video files found in: {movie_path} - skipping database entry") + return "no_video_files" # TIER 1: Check database first (fastest - local lookup) existing = self.db.get_movie_dates(imdb_id) diff --git a/static/js/app.js b/static/js/app.js index f550935..86b6118 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -289,6 +289,9 @@ function updateMoviesTable(data) { + `; @@ -1315,6 +1318,40 @@ async function deleteEpisode(imdbId, season, episode) { } } +// Movie deletion functionality +async function deleteMovie(imdbId) { + // Confirmation dialog + if (!confirm(`⚠️ Delete Movie?\n\nThis will permanently remove the movie (${imdbId}) from the database.\n\nAre you sure you want to continue?`)) { + return; + } + + try { + const response = await fetch(`/database/movie/${imdbId}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + } + }); + + const result = await response.json(); + + if (response.ok && result.success) { + showToast(`✅ Movie deleted successfully`, 'success'); + + // Refresh the movies table + loadMovies(currentMoviesPage); + + } else { + const errorMsg = result.message || result.error || 'Unknown error'; + showToast(`❌ Failed to delete movie: ${errorMsg}`, 'error'); + } + + } catch (error) { + console.error('Delete movie failed:', error); + showToast(`❌ Delete failed: ${error.message}`, 'error'); + } +} + // Update episode counts in modal after deletion function updateEpisodeModalCounts() { const remainingRows = document.querySelectorAll('#episodes-table-body tr');