This commit is contained in:
@@ -49,5 +49,66 @@ class TVSeasonRequest(BaseModel):
|
||||
class TVEpisodeRequest(BaseModel):
|
||||
"""TV episode processing request model"""
|
||||
series_path: str
|
||||
season: int
|
||||
episode: int
|
||||
|
||||
|
||||
# Web interface models
|
||||
class MovieUpdateRequest(BaseModel):
|
||||
"""Request to update movie dateadded"""
|
||||
imdb_id: str
|
||||
dateadded: Optional[str]
|
||||
source: str
|
||||
|
||||
|
||||
class EpisodeUpdateRequest(BaseModel):
|
||||
"""Request to update episode dateadded"""
|
||||
imdb_id: str
|
||||
season: int
|
||||
episode: int
|
||||
dateadded: Optional[str]
|
||||
source: str
|
||||
|
||||
|
||||
class BulkUpdateRequest(BaseModel):
|
||||
"""Request for bulk source updates"""
|
||||
media_type: str # "movies" or "episodes"
|
||||
old_source: str
|
||||
new_source: str
|
||||
|
||||
|
||||
class MovieResponse(BaseModel):
|
||||
"""Movie data response"""
|
||||
imdb_id: str
|
||||
title: str
|
||||
path: str
|
||||
released: Optional[str]
|
||||
dateadded: Optional[str]
|
||||
source: Optional[str]
|
||||
has_video_file: bool
|
||||
last_updated: str
|
||||
|
||||
|
||||
class SeriesResponse(BaseModel):
|
||||
"""TV series data response"""
|
||||
imdb_id: str
|
||||
title: str
|
||||
path: str
|
||||
last_updated: str
|
||||
total_episodes: int
|
||||
episodes_with_dates: int
|
||||
episodes_with_video: int
|
||||
|
||||
|
||||
class EpisodeResponse(BaseModel):
|
||||
"""TV episode data response"""
|
||||
season: int
|
||||
episode: int
|
||||
aired: Optional[str]
|
||||
dateadded: Optional[str]
|
||||
source: Optional[str]
|
||||
has_video_file: bool
|
||||
last_updated: str
|
||||
series_path: str
|
||||
season_name: str
|
||||
episode_name: str
|
||||
+76
-2
@@ -10,7 +10,14 @@ from fastapi import HTTPException, BackgroundTasks, Request
|
||||
from typing import Optional
|
||||
|
||||
# Import models
|
||||
from api.models import SonarrWebhook, RadarrWebhook, HealthResponse, TVSeasonRequest, TVEpisodeRequest
|
||||
from api.models import (
|
||||
SonarrWebhook, RadarrWebhook, HealthResponse, TVSeasonRequest, TVEpisodeRequest,
|
||||
MovieUpdateRequest, EpisodeUpdateRequest, BulkUpdateRequest
|
||||
)
|
||||
from api.web_routes import (
|
||||
get_movies_list, get_tv_series_list, get_series_episodes, get_missing_dates_report,
|
||||
get_dashboard_stats, update_movie_date, update_episode_date, bulk_update_source
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------
|
||||
@@ -1002,4 +1009,71 @@ def register_routes(app, dependencies: dict):
|
||||
|
||||
# Include monitoring routes
|
||||
from api.monitoring_routes import router as monitoring_router
|
||||
app.include_router(monitoring_router)
|
||||
app.include_router(monitoring_router)
|
||||
|
||||
# ---------------------------
|
||||
# Web Interface API Routes
|
||||
# ---------------------------
|
||||
|
||||
@app.get("/api/dashboard")
|
||||
async def _dashboard_stats():
|
||||
"""Get dashboard statistics"""
|
||||
return await get_dashboard_stats(dependencies)
|
||||
|
||||
@app.get("/api/movies")
|
||||
async def _movies_list(skip: int = 0, limit: int = 100, has_date: Optional[bool] = None,
|
||||
source_filter: Optional[str] = None, search: Optional[str] = None):
|
||||
"""Get paginated movies list with filtering"""
|
||||
return await get_movies_list(dependencies, skip, limit, has_date, source_filter, search)
|
||||
|
||||
@app.get("/api/series")
|
||||
async def _series_list(skip: int = 0, limit: int = 50, search: Optional[str] = None):
|
||||
"""Get paginated TV series list"""
|
||||
return await get_tv_series_list(dependencies, skip, limit, search)
|
||||
|
||||
@app.get("/api/series/{imdb_id}/episodes")
|
||||
async def _series_episodes(imdb_id: str):
|
||||
"""Get episodes for a specific series"""
|
||||
return await get_series_episodes(dependencies, imdb_id)
|
||||
|
||||
@app.get("/api/reports/missing-dates")
|
||||
async def _missing_dates_report():
|
||||
"""Get report of content missing dateadded"""
|
||||
return await get_missing_dates_report(dependencies)
|
||||
|
||||
@app.put("/api/movies/{imdb_id}")
|
||||
async def _update_movie(imdb_id: str, request: MovieUpdateRequest):
|
||||
"""Update movie dateadded"""
|
||||
return await update_movie_date(dependencies, imdb_id, request.dateadded, request.source)
|
||||
|
||||
@app.put("/api/episodes/{imdb_id}/{season}/{episode}")
|
||||
async def _update_episode(imdb_id: str, season: int, episode: int, request: EpisodeUpdateRequest):
|
||||
"""Update episode dateadded"""
|
||||
return await update_episode_date(dependencies, imdb_id, season, episode, request.dateadded, request.source)
|
||||
|
||||
@app.post("/api/bulk/update-source")
|
||||
async def _bulk_update_source(request: BulkUpdateRequest):
|
||||
"""Bulk update source for movies or episodes"""
|
||||
return await bulk_update_source(dependencies, request.media_type, request.old_source, request.new_source)
|
||||
|
||||
# ---------------------------
|
||||
# Static Web Interface
|
||||
# ---------------------------
|
||||
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
import os
|
||||
|
||||
# Serve static files for web interface
|
||||
static_dir = os.path.join(os.path.dirname(__file__), "..", "static")
|
||||
if os.path.exists(static_dir):
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
|
||||
@app.get("/")
|
||||
async def _serve_index():
|
||||
"""Serve web interface index page"""
|
||||
index_path = os.path.join(static_dir, "index.html")
|
||||
if os.path.exists(index_path):
|
||||
return FileResponse(index_path)
|
||||
else:
|
||||
return {"message": "NFOGuard Web Interface - API endpoints available at /api/", "api_docs": "/docs"}
|
||||
@@ -0,0 +1,434 @@
|
||||
"""
|
||||
Web interface API routes for NFOGuard database management
|
||||
Provides endpoints for the web-based database manipulation interface
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Dict, Any
|
||||
from fastapi import HTTPException, Query
|
||||
from pathlib import Path
|
||||
|
||||
from api.models import *
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Database Query Endpoints
|
||||
# ---------------------------
|
||||
|
||||
async def get_movies_list(dependencies: dict,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, le=1000),
|
||||
has_date: Optional[bool] = Query(None),
|
||||
source_filter: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None)):
|
||||
"""Get paginated list of movies with filtering options"""
|
||||
db = dependencies["db"]
|
||||
|
||||
with db.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Build dynamic query
|
||||
where_conditions = []
|
||||
params = []
|
||||
|
||||
if has_date is not None:
|
||||
if has_date:
|
||||
where_conditions.append("dateadded IS NOT NULL AND dateadded != ''")
|
||||
else:
|
||||
where_conditions.append("(dateadded IS NULL OR dateadded = '')")
|
||||
|
||||
if source_filter:
|
||||
where_conditions.append("source = ?")
|
||||
params.append(source_filter)
|
||||
|
||||
if search:
|
||||
where_conditions.append("(imdb_id LIKE ? OR path LIKE ?)")
|
||||
params.extend([f"%{search}%", f"%{search}%"])
|
||||
|
||||
where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"
|
||||
|
||||
# Get total count
|
||||
count_query = f"SELECT COUNT(*) FROM movies WHERE {where_clause}"
|
||||
cursor.execute(count_query, params)
|
||||
total_count = cursor.fetchone()[0]
|
||||
|
||||
# Get paginated results
|
||||
query = f"""
|
||||
SELECT imdb_id, path, released, dateadded, source, has_video_file, last_updated
|
||||
FROM movies
|
||||
WHERE {where_clause}
|
||||
ORDER BY last_updated DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
cursor.execute(query, params + [limit, skip])
|
||||
|
||||
movies = []
|
||||
for row in cursor.fetchall():
|
||||
movie = dict(row)
|
||||
# Extract title from path for display
|
||||
try:
|
||||
movie['title'] = Path(movie['path']).name if movie['path'] else movie['imdb_id']
|
||||
except:
|
||||
movie['title'] = movie['imdb_id']
|
||||
movies.append(movie)
|
||||
|
||||
return {
|
||||
"movies": movies,
|
||||
"total_count": total_count,
|
||||
"page": skip // limit + 1,
|
||||
"pages": (total_count + limit - 1) // limit,
|
||||
"has_next": skip + limit < total_count,
|
||||
"has_prev": skip > 0
|
||||
}
|
||||
|
||||
|
||||
async def get_tv_series_list(dependencies: dict,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, le=500),
|
||||
search: Optional[str] = Query(None)):
|
||||
"""Get paginated list of TV series with episode counts"""
|
||||
db = dependencies["db"]
|
||||
|
||||
with db.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Build dynamic query
|
||||
where_conditions = []
|
||||
params = []
|
||||
|
||||
if search:
|
||||
where_conditions.append("(s.imdb_id LIKE ? OR s.path LIKE ?)")
|
||||
params.extend([f"%{search}%", f"%{search}%"])
|
||||
|
||||
where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"
|
||||
|
||||
# Get total count
|
||||
count_query = f"SELECT COUNT(*) FROM series s WHERE {where_clause}"
|
||||
cursor.execute(count_query, params)
|
||||
total_count = cursor.fetchone()[0]
|
||||
|
||||
# Get series with episode statistics
|
||||
query = f"""
|
||||
SELECT
|
||||
s.imdb_id,
|
||||
s.path,
|
||||
s.last_updated,
|
||||
COUNT(e.episode) as total_episodes,
|
||||
COUNT(CASE WHEN e.dateadded IS NOT NULL AND e.dateadded != '' THEN 1 END) as episodes_with_dates,
|
||||
COUNT(CASE WHEN e.has_video_file = 1 THEN 1 END) as episodes_with_video
|
||||
FROM series s
|
||||
LEFT JOIN episodes e ON s.imdb_id = e.imdb_id
|
||||
WHERE {where_clause}
|
||||
GROUP BY s.imdb_id, s.path, s.last_updated
|
||||
ORDER BY s.last_updated DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
cursor.execute(query, params + [limit, skip])
|
||||
|
||||
series = []
|
||||
for row in cursor.fetchall():
|
||||
series_data = dict(row)
|
||||
# Extract title from path
|
||||
try:
|
||||
series_data['title'] = Path(series_data['path']).name if series_data['path'] else series_data['imdb_id']
|
||||
except:
|
||||
series_data['title'] = series_data['imdb_id']
|
||||
series.append(series_data)
|
||||
|
||||
return {
|
||||
"series": series,
|
||||
"total_count": total_count,
|
||||
"page": skip // limit + 1,
|
||||
"pages": (total_count + limit - 1) // limit,
|
||||
"has_next": skip + limit < total_count,
|
||||
"has_prev": skip > 0
|
||||
}
|
||||
|
||||
|
||||
async def get_series_episodes(dependencies: dict, imdb_id: str):
|
||||
"""Get all episodes for a specific TV series"""
|
||||
db = dependencies["db"]
|
||||
|
||||
with db.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get series info
|
||||
cursor.execute("SELECT * FROM series WHERE imdb_id = ?", (imdb_id,))
|
||||
series_row = cursor.fetchone()
|
||||
if not series_row:
|
||||
raise HTTPException(status_code=404, detail="Series not found")
|
||||
|
||||
series_info = dict(series_row)
|
||||
try:
|
||||
series_info['title'] = Path(series_info['path']).name if series_info['path'] else imdb_id
|
||||
except:
|
||||
series_info['title'] = imdb_id
|
||||
|
||||
# Get episodes
|
||||
cursor.execute("""
|
||||
SELECT season, episode, aired, dateadded, source, has_video_file, last_updated
|
||||
FROM episodes
|
||||
WHERE imdb_id = ?
|
||||
ORDER BY season, episode
|
||||
""", (imdb_id,))
|
||||
|
||||
episodes = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
return {
|
||||
"series": series_info,
|
||||
"episodes": episodes
|
||||
}
|
||||
|
||||
|
||||
async def get_missing_dates_report(dependencies: dict):
|
||||
"""Generate report of movies and episodes missing dateadded"""
|
||||
db = dependencies["db"]
|
||||
|
||||
with db.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Movies without dates
|
||||
cursor.execute("""
|
||||
SELECT imdb_id, path, released, source, last_updated
|
||||
FROM movies
|
||||
WHERE dateadded IS NULL OR dateadded = '' OR source = 'no_valid_date_source'
|
||||
ORDER BY last_updated DESC
|
||||
""")
|
||||
movies_missing = []
|
||||
for row in cursor.fetchall():
|
||||
movie = dict(row)
|
||||
try:
|
||||
movie['title'] = Path(movie['path']).name if movie['path'] else movie['imdb_id']
|
||||
except:
|
||||
movie['title'] = movie['imdb_id']
|
||||
movies_missing.append(movie)
|
||||
|
||||
# Episodes without dates
|
||||
cursor.execute("""
|
||||
SELECT e.imdb_id, e.season, e.episode, e.aired, e.source, e.last_updated, s.path
|
||||
FROM episodes e
|
||||
JOIN series s ON e.imdb_id = s.imdb_id
|
||||
WHERE e.dateadded IS NULL OR e.dateadded = '' OR e.source = 'no_valid_date_source'
|
||||
ORDER BY e.last_updated DESC
|
||||
""")
|
||||
episodes_missing = []
|
||||
for row in cursor.fetchall():
|
||||
episode = dict(row)
|
||||
try:
|
||||
episode['series_title'] = Path(episode['path']).name if episode['path'] else episode['imdb_id']
|
||||
except:
|
||||
episode['series_title'] = episode['imdb_id']
|
||||
episodes_missing.append(episode)
|
||||
|
||||
# Summary statistics
|
||||
cursor.execute("SELECT COUNT(*) FROM movies WHERE dateadded IS NOT NULL AND dateadded != ''")
|
||||
movies_with_dates = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM movies")
|
||||
total_movies = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM episodes WHERE dateadded IS NOT NULL AND dateadded != ''")
|
||||
episodes_with_dates = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM episodes")
|
||||
total_episodes = cursor.fetchone()[0]
|
||||
|
||||
return {
|
||||
"summary": {
|
||||
"movies_with_dates": movies_with_dates,
|
||||
"movies_missing_dates": len(movies_missing),
|
||||
"total_movies": total_movies,
|
||||
"episodes_with_dates": episodes_with_dates,
|
||||
"episodes_missing_dates": len(episodes_missing),
|
||||
"total_episodes": total_episodes
|
||||
},
|
||||
"movies_missing": movies_missing,
|
||||
"episodes_missing": episodes_missing
|
||||
}
|
||||
|
||||
|
||||
async def get_dashboard_stats(dependencies: dict):
|
||||
"""Get comprehensive dashboard statistics"""
|
||||
db = dependencies["db"]
|
||||
|
||||
# Get basic stats from existing method
|
||||
stats = db.get_stats()
|
||||
|
||||
with db.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Enhanced statistics
|
||||
cursor.execute("SELECT COUNT(*) FROM movies WHERE dateadded IS NOT NULL AND dateadded != ''")
|
||||
movies_with_dates = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM episodes WHERE dateadded IS NOT NULL AND dateadded != ''")
|
||||
episodes_with_dates = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM movies WHERE source = 'no_valid_date_source'")
|
||||
movies_no_valid_source = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM episodes WHERE source = 'no_valid_date_source'")
|
||||
episodes_no_valid_source = cursor.fetchone()[0]
|
||||
|
||||
# Recent activity (last 7 days)
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM processing_history
|
||||
WHERE processed_at > datetime('now', '-7 days')
|
||||
""")
|
||||
recent_activity = cursor.fetchone()[0]
|
||||
|
||||
# Source distribution for movies
|
||||
cursor.execute("""
|
||||
SELECT source, COUNT(*) as count
|
||||
FROM movies
|
||||
WHERE source IS NOT NULL
|
||||
GROUP BY source
|
||||
ORDER BY count DESC
|
||||
""")
|
||||
movie_sources = [{"source": row[0], "count": row[1]} for row in cursor.fetchall()]
|
||||
|
||||
# Source distribution for episodes
|
||||
cursor.execute("""
|
||||
SELECT source, COUNT(*) as count
|
||||
FROM episodes
|
||||
WHERE source IS NOT NULL
|
||||
GROUP BY source
|
||||
ORDER BY count DESC
|
||||
""")
|
||||
episode_sources = [{"source": row[0], "count": row[1]} for row in cursor.fetchall()]
|
||||
|
||||
# Combine with enhanced stats
|
||||
stats.update({
|
||||
"movies_with_dates": movies_with_dates,
|
||||
"movies_missing_dates": stats["movies_total"] - movies_with_dates,
|
||||
"episodes_with_dates": episodes_with_dates,
|
||||
"episodes_missing_dates": stats["episodes_total"] - episodes_with_dates,
|
||||
"movies_no_valid_source": movies_no_valid_source,
|
||||
"episodes_no_valid_source": episodes_no_valid_source,
|
||||
"recent_activity_count": recent_activity,
|
||||
"movie_sources": movie_sources,
|
||||
"episode_sources": episode_sources
|
||||
})
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Database Modification Endpoints
|
||||
# ---------------------------
|
||||
|
||||
async def update_movie_date(dependencies: dict, imdb_id: str, dateadded: Optional[str], source: str):
|
||||
"""Update dateadded for a specific movie"""
|
||||
db = dependencies["db"]
|
||||
|
||||
# Validate movie exists
|
||||
movie = db.get_movie_dates(imdb_id)
|
||||
if not movie:
|
||||
raise HTTPException(status_code=404, detail="Movie not found")
|
||||
|
||||
# Update the date
|
||||
db.upsert_movie_dates(
|
||||
imdb_id=imdb_id,
|
||||
released=movie.get('released'),
|
||||
dateadded=dateadded,
|
||||
source=source,
|
||||
has_video_file=movie.get('has_video_file', False)
|
||||
)
|
||||
|
||||
# Add to processing history
|
||||
db.add_processing_history(
|
||||
imdb_id=imdb_id,
|
||||
media_type="movie",
|
||||
event_type="manual_date_update",
|
||||
details={"old_source": movie.get('source'), "new_source": source, "dateadded": dateadded}
|
||||
)
|
||||
|
||||
return {"status": "success", "message": f"Updated movie {imdb_id}"}
|
||||
|
||||
|
||||
async def update_episode_date(dependencies: dict, imdb_id: str, season: int, episode: int,
|
||||
dateadded: Optional[str], source: str):
|
||||
"""Update dateadded for a specific episode"""
|
||||
db = dependencies["db"]
|
||||
|
||||
# Get existing episode
|
||||
episode_data = db.get_episode_date(imdb_id, season, episode)
|
||||
if not episode_data:
|
||||
raise HTTPException(status_code=404, detail="Episode not found")
|
||||
|
||||
# Update the date
|
||||
db.upsert_episode_date(
|
||||
imdb_id=imdb_id,
|
||||
season=season,
|
||||
episode=episode,
|
||||
aired=episode_data.get('aired'),
|
||||
dateadded=dateadded,
|
||||
source=source,
|
||||
has_video_file=episode_data.get('has_video_file', False)
|
||||
)
|
||||
|
||||
# Add to processing history
|
||||
db.add_processing_history(
|
||||
imdb_id=imdb_id,
|
||||
media_type="episode",
|
||||
event_type="manual_date_update",
|
||||
details={
|
||||
"season": season,
|
||||
"episode": episode,
|
||||
"old_source": episode_data.get('source'),
|
||||
"new_source": source,
|
||||
"dateadded": dateadded
|
||||
}
|
||||
)
|
||||
|
||||
return {"status": "success", "message": f"Updated episode {imdb_id} S{season:02d}E{episode:02d}"}
|
||||
|
||||
|
||||
async def bulk_update_source(dependencies: dict, media_type: str, old_source: str, new_source: str):
|
||||
"""Bulk update source for movies or episodes"""
|
||||
db = dependencies["db"]
|
||||
|
||||
if media_type not in ["movies", "episodes"]:
|
||||
raise HTTPException(status_code=400, detail="media_type must be 'movies' or 'episodes'")
|
||||
|
||||
with db.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
if media_type == "movies":
|
||||
# Update movies
|
||||
cursor.execute("UPDATE movies SET source = ? WHERE source = ?", (new_source, old_source))
|
||||
updated_count = cursor.rowcount
|
||||
|
||||
# Add history entries
|
||||
cursor.execute("SELECT imdb_id FROM movies WHERE source = ?", (new_source,))
|
||||
for row in cursor.fetchall():
|
||||
db.add_processing_history(
|
||||
imdb_id=row[0],
|
||||
media_type="movie",
|
||||
event_type="bulk_source_update",
|
||||
details={"old_source": old_source, "new_source": new_source}
|
||||
)
|
||||
else:
|
||||
# Update episodes
|
||||
cursor.execute("UPDATE episodes SET source = ? WHERE source = ?", (new_source, old_source))
|
||||
updated_count = cursor.rowcount
|
||||
|
||||
# Add history entries
|
||||
cursor.execute("SELECT imdb_id, season, episode FROM episodes WHERE source = ?", (new_source,))
|
||||
for row in cursor.fetchall():
|
||||
db.add_processing_history(
|
||||
imdb_id=row[0],
|
||||
media_type="episode",
|
||||
event_type="bulk_source_update",
|
||||
details={
|
||||
"season": row[1],
|
||||
"episode": row[2],
|
||||
"old_source": old_source,
|
||||
"new_source": new_source
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Updated {updated_count} {media_type} from source '{old_source}' to '{new_source}'"
|
||||
}
|
||||
Reference in New Issue
Block a user