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');