diff --git a/VERSION b/VERSION index 097a15a..2714f53 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.6.2 +2.6.4 diff --git a/api/routes.py b/api/routes.py index 6e758f0..82c918d 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1072,6 +1072,88 @@ async def debug_tmdb_lookup(imdb_id: str, dependencies: dict): return {"error": str(e), "imdb_id": imdb_id, "traceback": str(e)} +# --------------------------- +# Database Cleanup Endpoints +# --------------------------- + +async def delete_episode(imdb_id: str, season: int, episode: int, dependencies: dict): + """Delete a specific episode from the database""" + db = dependencies["db"] + + try: + deleted = db.delete_episode(imdb_id, season, episode) + + if deleted: + return { + "success": True, + "message": f"Deleted episode S{season:02d}E{episode:02d} from series {imdb_id}", + "imdb_id": imdb_id, + "season": season, + "episode": episode + } + else: + return { + "success": False, + "message": f"Episode S{season:02d}E{episode:02d} not found in series {imdb_id}", + "imdb_id": imdb_id, + "season": season, + "episode": episode + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "imdb_id": imdb_id, + "season": season, + "episode": episode + } + + +async def delete_series_episodes(imdb_id: str, dependencies: dict): + """Delete all episodes for a series from the database""" + db = dependencies["db"] + + try: + deleted_count = db.delete_series_episodes(imdb_id) + + return { + "success": True, + "message": f"Deleted {deleted_count} episodes from series {imdb_id}", + "imdb_id": imdb_id, + "deleted_count": deleted_count + } + + 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"] + + try: + deleted_episodes = db.delete_orphaned_episodes() + + return { + "success": True, + "message": f"Cleaned up {len(deleted_episodes)} orphaned episodes", + "deleted_count": len(deleted_episodes), + "deleted_episodes": deleted_episodes + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "message": "Failed to cleanup orphaned episodes" + } + + # --------------------------- # Route Registration # --------------------------- @@ -1122,6 +1204,18 @@ def register_routes(app, dependencies: dict): async def _debug_movie_history(imdb_id: str): return await debug_movie_history(imdb_id, dependencies) + @app.delete("/database/episode/{imdb_id}/{season}/{episode}") + async def _delete_episode(imdb_id: str, season: int, episode: int): + return await delete_episode(imdb_id, season, episode, dependencies) + + @app.delete("/database/series/{imdb_id}/episodes") + async def _delete_series_episodes(imdb_id: str): + return await delete_series_episodes(imdb_id, dependencies) + + @app.post("/database/cleanup/orphaned-episodes") + async def _cleanup_orphaned_episodes(): + return await cleanup_orphaned_episodes(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/api/web_routes.py b/api/web_routes.py index 78cf429..df98688 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -149,6 +149,19 @@ async def get_tv_series_list(dependencies: dict, """Get paginated list of TV series with episode counts""" db = dependencies["db"] + # Debug logging for 422 troubleshooting + try: + print(f"DEBUG: Series list params - skip:{skip}, limit:{limit}, search:'{search}', imdb_search:'{imdb_search}', date_filter:'{date_filter}', source_filter:'{source_filter}'") + + # Validate date_filter values + if date_filter and date_filter not in ['complete', 'incomplete', 'none']: + print(f"ERROR: Invalid date_filter value: '{date_filter}'") + raise HTTPException(status_code=422, detail=f"Invalid date_filter: must be 'complete', 'incomplete', or 'none', got '{date_filter}'") + + except Exception as e: + print(f"ERROR: Parameter validation failed: {e}") + raise + with db.get_connection() as conn: cursor = conn.cursor() diff --git a/core/database.py b/core/database.py index ad4311e..a13235c 100644 --- a/core/database.py +++ b/core/database.py @@ -294,6 +294,123 @@ class NFOGuardDatabase: "database_type": "postgresql" } + def delete_episode(self, imdb_id: str, season: int, episode: int) -> bool: + """ + Delete a specific episode from the database + + Args: + imdb_id: Series IMDb ID + season: Season number + episode: Episode number + + Returns: + True if episode was deleted, False if not found + """ + with self.get_connection() as conn: + cursor = conn.cursor() + + cursor.execute(""" + DELETE FROM episodes + WHERE imdb_id = %s AND season = %s AND episode = %s + """, (imdb_id, season, episode)) + + deleted_count = cursor.rowcount + conn.commit() + + return deleted_count > 0 + + def delete_series_episodes(self, imdb_id: str) -> int: + """ + Delete all episodes for a series from the database + + Args: + imdb_id: Series IMDb ID + + Returns: + Number of episodes deleted + """ + with self.get_connection() as conn: + cursor = conn.cursor() + + cursor.execute(""" + DELETE FROM episodes + WHERE imdb_id = %s + """, (imdb_id,)) + + deleted_count = cursor.rowcount + conn.commit() + + return deleted_count + + def delete_orphaned_episodes(self) -> List[Dict]: + """ + Find and delete episodes that don't have corresponding video files on disk + This requires checking filesystem for each episode, so use carefully + + Returns: + List of deleted episodes with their details + """ + from utils.file_utils import find_episodes_on_disk + from pathlib import Path + + deleted_episodes = [] + + with self.get_connection() as conn: + cursor = conn.cursor() + + # Get all series with their paths + cursor.execute(""" + SELECT DISTINCT imdb_id, path FROM series + """) + + series_list = cursor.fetchall() + + for series in series_list: + imdb_id = series['imdb_id'] + series_path = Path(series['path']) + + if not series_path.exists(): + continue + + # Get episodes on disk + disk_episodes = find_episodes_on_disk(series_path) + disk_episode_keys = set(disk_episodes.keys()) + + # Get episodes in database + cursor.execute(""" + SELECT season, episode, dateadded, source + FROM episodes + WHERE imdb_id = %s + """, (imdb_id,)) + + db_episodes = cursor.fetchall() + + # Find orphaned episodes (in DB but not on disk) + for db_episode in db_episodes: + season = db_episode['season'] + episode = db_episode['episode'] + episode_key = (season, episode) + + if episode_key not in disk_episode_keys: + # Episode is orphaned - delete it + cursor.execute(""" + DELETE FROM episodes + WHERE imdb_id = %s AND season = %s AND episode = %s + """, (imdb_id, season, episode)) + + deleted_episodes.append({ + 'imdb_id': imdb_id, + 'season': season, + 'episode': episode, + 'dateadded': db_episode['dateadded'], + 'source': db_episode['source'], + 'series_path': str(series_path) + }) + + conn.commit() + + return deleted_episodes + def close(self): """Close all database connections""" if hasattr(self._local, 'connection'): diff --git a/static/js/app.js b/static/js/app.js index 7ce6a8f..9921799 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -519,6 +519,9 @@ function showEpisodesModal(data) { + `; @@ -1249,4 +1252,74 @@ Analysis: console.error('Debug failed:', error); showToast('Debug failed: ' + error.message, 'error'); } +} + +// Episode deletion functionality +async function deleteEpisode(imdbId, season, episode) { + const episodeStr = `S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`; + + // Confirmation dialog + if (!confirm(`⚠️ Delete Episode ${episodeStr}?\n\nThis will permanently remove the episode from the database.\n\nAre you sure you want to continue?`)) { + return; + } + + try { + const response = await fetch(`/database/episode/${imdbId}/${season}/${episode}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + } + }); + + const result = await response.json(); + + if (response.ok && result.success) { + showToast(`✅ Episode ${episodeStr} deleted successfully`, 'success'); + + // Remove the row from the table + const rows = document.querySelectorAll('#episodes-table-body tr'); + rows.forEach(row => { + const episodeCell = row.querySelector('td:first-child'); + if (episodeCell && episodeCell.textContent === episodeStr) { + row.remove(); + } + }); + + // Update episode counts in modal header + updateEpisodeModalCounts(); + + } else { + const errorMsg = result.message || result.error || 'Unknown error'; + showToast(`❌ Failed to delete episode: ${errorMsg}`, 'error'); + } + + } catch (error) { + console.error('Delete episode 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'); + const totalEpisodes = remainingRows.length; + const episodesWithDates = Array.from(remainingRows).filter(row => + row.getAttribute('data-has-date') === 'true' + ).length; + const episodesWithoutDates = totalEpisodes - episodesWithDates; + + // Update the stats in the modal + const statsDiv = document.querySelector('.episode-stats'); + if (statsDiv) { + // Keep the existing "With Video" count by finding it + const videoCountDiv = statsDiv.querySelector('div:nth-child(4)'); + const videoCountText = videoCountDiv ? videoCountDiv.innerHTML : '