feat: add webinterface
Local Docker Build (Dev) / build-dev (pull_request) Successful in 4s

This commit is contained in:
2025-10-14 14:57:17 -04:00
parent 867bd008c5
commit cebed7a6bb
6 changed files with 2377 additions and 2 deletions
+61
View File
@@ -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
View File
@@ -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"}
+434
View File
@@ -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}'"
}