fixes: debugs for 422 and delete option
Local Docker Build (Dev) / build-dev (push) Successful in 4s
Local Docker Build (Dev) / build-dev (push) Successful in 4s
This commit is contained in:
@@ -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)}
|
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
|
# Route Registration
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
@@ -1122,6 +1204,18 @@ def register_routes(app, dependencies: dict):
|
|||||||
async def _debug_movie_history(imdb_id: str):
|
async def _debug_movie_history(imdb_id: str):
|
||||||
return await debug_movie_history(imdb_id, dependencies)
|
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")
|
@app.post("/manual/scan")
|
||||||
async def _manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = None, scan_type: str = "both", scan_mode: str = "smart"):
|
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)
|
return await manual_scan(background_tasks, path, scan_type, scan_mode, dependencies)
|
||||||
|
|||||||
@@ -149,6 +149,19 @@ async def get_tv_series_list(dependencies: dict,
|
|||||||
"""Get paginated list of TV series with episode counts"""
|
"""Get paginated list of TV series with episode counts"""
|
||||||
db = dependencies["db"]
|
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:
|
with db.get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
|||||||
@@ -294,6 +294,123 @@ class NFOGuardDatabase:
|
|||||||
"database_type": "postgresql"
|
"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):
|
def close(self):
|
||||||
"""Close all database connections"""
|
"""Close all database connections"""
|
||||||
if hasattr(self._local, 'connection'):
|
if hasattr(self._local, 'connection'):
|
||||||
|
|||||||
@@ -519,6 +519,9 @@ function showEpisodesModal(data) {
|
|||||||
<button class="btn btn-sm btn-primary" onclick="editEpisode('${data.series.imdb_id}', ${episode.season}, ${episode.episode}, '${dateadded}', '${episode.source || ''}')">
|
<button class="btn btn-sm btn-primary" onclick="editEpisode('${data.series.imdb_id}', ${episode.season}, ${episode.episode}, '${dateadded}', '${episode.source || ''}')">
|
||||||
<i class="fas fa-edit"></i> Edit
|
<i class="fas fa-edit"></i> Edit
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="deleteEpisode('${data.series.imdb_id}', ${episode.season}, ${episode.episode})" style="margin-left: 5px;">
|
||||||
|
<i class="fas fa-trash"></i> Delete
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
@@ -1250,3 +1253,73 @@ Analysis:
|
|||||||
showToast('Debug failed: ' + error.message, '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 : '<div><strong>With Video:</strong> -</div>';
|
||||||
|
|
||||||
|
statsDiv.innerHTML = `
|
||||||
|
<div><strong>Total Episodes:</strong> ${totalEpisodes}</div>
|
||||||
|
<div><strong>With Dates:</strong> ${episodesWithDates}</div>
|
||||||
|
<div style="color: #dc3545;"><strong>Missing Dates:</strong> ${episodesWithoutDates}</div>
|
||||||
|
${videoCountText}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user