feat: add webinterface #50

Merged
sbcrumb merged 1 commits from web-interface into dev 2025-10-14 14:58:16 -04:00
6 changed files with 2377 additions and 2 deletions
+61
View File
@@ -49,5 +49,66 @@ class TVSeasonRequest(BaseModel):
class TVEpisodeRequest(BaseModel): class TVEpisodeRequest(BaseModel):
"""TV episode processing request model""" """TV episode processing request model"""
series_path: str 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 season_name: str
episode_name: str episode_name: str
+76 -2
View File
@@ -10,7 +10,14 @@ from fastapi import HTTPException, BackgroundTasks, Request
from typing import Optional from typing import Optional
# Import models # 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 # Include monitoring routes
from api.monitoring_routes import router as monitoring_router 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}'"
}
+658
View File
@@ -0,0 +1,658 @@
/* NFOGuard Web Interface Styles */
:root {
--primary-color: #007bff;
--secondary-color: #6c757d;
--success-color: #28a745;
--warning-color: #ffc107;
--danger-color: #dc3545;
--dark-color: #343a40;
--light-color: #f8f9fa;
--border-color: #dee2e6;
--text-muted: #6c757d;
--shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--shadow-lg: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--dark-color);
background-color: #f5f5f5;
}
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Header */
.app-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem 0;
box-shadow: var(--shadow-lg);
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
text-align: center;
}
.header-content h1 {
font-size: 2rem;
font-weight: 300;
margin-bottom: 0.5rem;
}
.header-content h1 i {
margin-right: 0.5rem;
}
.header-content p {
opacity: 0.9;
font-size: 1rem;
}
.nav-tabs {
max-width: 1200px;
margin: 1rem auto 0;
padding: 0 1rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: center;
}
.nav-tab {
background: rgba(255, 255, 255, 0.1);
border: none;
color: white;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.3s ease;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-tab:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-1px);
}
.nav-tab.active {
background: rgba(255, 255, 255, 0.9);
color: var(--dark-color);
}
/* Main Content */
.main-content {
flex: 1;
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
width: 100%;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Dashboard */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: var(--shadow);
display: flex;
align-items: center;
gap: 1rem;
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: white;
}
.stat-icon.movies { background: linear-gradient(135deg, #667eea, #764ba2); }
.stat-icon.tv { background: linear-gradient(135deg, #f093fb, #f5576c); }
.stat-icon.missing { background: linear-gradient(135deg, #ffecd2, #fcb69f); }
.stat-icon.activity { background: linear-gradient(135deg, #a8edea, #fed6e3); }
.stat-info h3 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.stat-info p {
font-weight: 500;
margin-bottom: 0.25rem;
}
.stat-info small {
color: var(--text-muted);
font-size: 0.85rem;
}
.dashboard-charts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.chart-card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: var(--shadow);
}
.chart-card h3 {
margin-bottom: 1rem;
color: var(--dark-color);
}
.chart-container {
height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: var(--light-color);
border-radius: 0.25rem;
color: var(--text-muted);
}
/* Content Header */
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.content-header h2 {
color: var(--dark-color);
font-weight: 600;
}
.content-controls {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.search-box {
position: relative;
display: flex;
align-items: center;
}
.search-box i {
position: absolute;
left: 0.75rem;
color: var(--text-muted);
}
.search-box input {
padding: 0.5rem 0.75rem 0.5rem 2.5rem;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
font-size: 0.9rem;
width: 250px;
}
.search-box input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
/* Buttons */
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: #0056b3;
transform: translateY(-1px);
}
.btn-secondary {
background-color: var(--secondary-color);
color: white;
}
.btn-secondary:hover {
background-color: #545b62;
}
.btn-success {
background-color: var(--success-color);
color: white;
}
.btn-success:hover {
background-color: #1e7e34;
}
.btn-warning {
background-color: var(--warning-color);
color: var(--dark-color);
}
.btn-warning:hover {
background-color: #e0a800;
}
.btn-danger {
background-color: var(--danger-color);
color: white;
}
.btn-danger:hover {
background-color: #c82333;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
/* Tables */
.table-container {
background: white;
border-radius: 0.5rem;
box-shadow: var(--shadow);
overflow: hidden;
margin-bottom: 1rem;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.data-table th {
background-color: var(--light-color);
font-weight: 600;
color: var(--dark-color);
position: sticky;
top: 0;
}
.data-table tr:hover {
background-color: rgba(0, 123, 255, 0.05);
}
.data-table .loading {
text-align: center;
color: var(--text-muted);
font-style: italic;
padding: 2rem;
}
/* Status badges */
.badge {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.badge-success {
background-color: #d4edda;
color: #155724;
}
.badge-warning {
background-color: #fff3cd;
color: #856404;
}
.badge-danger {
background-color: #f8d7da;
color: #721c24;
}
.badge-secondary {
background-color: #e9ecef;
color: #495057;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
}
.pagination .btn {
padding: 0.5rem 0.75rem;
}
.pagination .page-info {
margin: 0 1rem;
color: var(--text-muted);
}
/* Forms */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
font-size: 0.9rem;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.form-group small {
display: block;
margin-top: 0.25rem;
color: var(--text-muted);
font-size: 0.8rem;
}
.form-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
/* Modal */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal.active {
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: 0.5rem;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.modal-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-muted);
}
.modal-close:hover {
color: var(--dark-color);
}
.modal-body {
padding: 1.5rem;
}
/* Reports */
.report-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.summary-card {
background: white;
padding: 1.5rem;
border-radius: 0.5rem;
box-shadow: var(--shadow);
text-align: center;
}
.summary-card h3 {
margin-bottom: 1rem;
color: var(--dark-color);
}
.summary-card p {
margin-bottom: 0.5rem;
font-size: 1.1rem;
}
.summary-card span {
font-weight: 700;
color: var(--primary-color);
}
.report-section {
margin-bottom: 2rem;
}
.report-section h3 {
margin-bottom: 1rem;
color: var(--dark-color);
}
/* Tools */
.tools-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.tool-card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: var(--shadow);
}
.tool-card h3 {
margin-bottom: 0.5rem;
color: var(--dark-color);
}
.tool-card p {
margin-bottom: 1.5rem;
color: var(--text-muted);
}
.stats-display {
background: var(--light-color);
padding: 1rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
min-height: 100px;
}
/* Toast notifications */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 1050;
}
.toast {
background: white;
border-radius: 0.25rem;
box-shadow: var(--shadow-lg);
margin-bottom: 0.5rem;
padding: 0.75rem 1rem;
min-width: 300px;
border-left: 4px solid var(--primary-color);
animation: slideIn 0.3s ease;
}
.toast.success {
border-left-color: var(--success-color);
}
.toast.warning {
border-left-color: var(--warning-color);
}
.toast.error {
border-left-color: var(--danger-color);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Responsive */
@media (max-width: 768px) {
.content-header {
flex-direction: column;
align-items: stretch;
}
.content-controls {
justify-content: center;
}
.search-box input {
width: 200px;
}
.nav-tabs {
flex-direction: column;
gap: 0.25rem;
}
.data-table {
font-size: 0.8rem;
}
.data-table th,
.data-table td {
padding: 0.5rem 0.25rem;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.tools-grid {
grid-template-columns: 1fr;
}
}
/* Utility classes */
.text-center { text-align: center; }
.text-muted { color: var(--text-muted); }
.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.d-none { display: none; }
.d-block { display: block; }
.d-flex { display: flex; }
.justify-content-between { justify-content: space-between; }
.align-items-center { align-items: center; }
+357
View File
@@ -0,0 +1,357 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NFOGuard - Database Management</title>
<link rel="stylesheet" href="/static/css/styles.css">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head>
<body>
<div class="app-container">
<!-- Header -->
<header class="app-header">
<div class="header-content">
<h1><i class="fas fa-shield-alt"></i> NFOGuard</h1>
<p>Database Management & Reporting</p>
</div>
<nav class="nav-tabs">
<button class="nav-tab active" data-tab="dashboard">
<i class="fas fa-tachometer-alt"></i> Dashboard
</button>
<button class="nav-tab" data-tab="movies">
<i class="fas fa-film"></i> Movies
</button>
<button class="nav-tab" data-tab="tv">
<i class="fas fa-tv"></i> TV Series
</button>
<button class="nav-tab" data-tab="reports">
<i class="fas fa-chart-bar"></i> Reports
</button>
<button class="nav-tab" data-tab="tools">
<i class="fas fa-tools"></i> Tools
</button>
</nav>
</header>
<!-- Main Content -->
<main class="main-content">
<!-- Dashboard Tab -->
<div class="tab-content active" id="dashboard">
<div class="dashboard-grid">
<div class="stat-card">
<div class="stat-icon movies">
<i class="fas fa-film"></i>
</div>
<div class="stat-info">
<h3 id="movies-total">-</h3>
<p>Total Movies</p>
<small id="movies-with-dates">- with dates</small>
</div>
</div>
<div class="stat-card">
<div class="stat-icon tv">
<i class="fas fa-tv"></i>
</div>
<div class="stat-info">
<h3 id="series-total">-</h3>
<p>TV Series</p>
<small id="episodes-total">- episodes</small>
</div>
</div>
<div class="stat-card">
<div class="stat-icon missing">
<i class="fas fa-exclamation-triangle"></i>
</div>
<div class="stat-info">
<h3 id="missing-dates-total">-</h3>
<p>Missing Dates</p>
<small id="no-valid-source-total">- no valid source</small>
</div>
</div>
<div class="stat-card">
<div class="stat-icon activity">
<i class="fas fa-history"></i>
</div>
<div class="stat-info">
<h3 id="recent-activity">-</h3>
<p>Recent Activity</p>
<small>Last 7 days</small>
</div>
</div>
</div>
<div class="dashboard-charts">
<div class="chart-card">
<h3><i class="fas fa-chart-pie"></i> Movie Sources</h3>
<div id="movie-sources-chart" class="chart-container"></div>
</div>
<div class="chart-card">
<h3><i class="fas fa-chart-pie"></i> Episode Sources</h3>
<div id="episode-sources-chart" class="chart-container"></div>
</div>
</div>
</div>
<!-- Movies Tab -->
<div class="tab-content" id="movies">
<div class="content-header">
<h2><i class="fas fa-film"></i> Movies Database</h2>
<div class="content-controls">
<div class="search-box">
<i class="fas fa-search"></i>
<input type="text" id="movies-search" placeholder="Search movies...">
</div>
<select id="movies-filter-date">
<option value="">All Movies</option>
<option value="true">With Dates</option>
<option value="false">Missing Dates</option>
</select>
<select id="movies-filter-source">
<option value="">All Sources</option>
</select>
<button class="btn btn-primary" onclick="refreshMovies()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
</div>
<div class="table-container">
<table class="data-table" id="movies-table">
<thead>
<tr>
<th>Title</th>
<th>IMDb ID</th>
<th>Released</th>
<th>Date Added</th>
<th>Source</th>
<th>Video File</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="movies-tbody">
<tr>
<td colspan="7" class="loading">Loading movies...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="movies-pagination"></div>
</div>
<!-- TV Series Tab -->
<div class="tab-content" id="tv">
<div class="content-header">
<h2><i class="fas fa-tv"></i> TV Series Database</h2>
<div class="content-controls">
<div class="search-box">
<i class="fas fa-search"></i>
<input type="text" id="series-search" placeholder="Search series...">
</div>
<button class="btn btn-primary" onclick="refreshSeries()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
</div>
<div class="table-container">
<table class="data-table" id="series-table">
<thead>
<tr>
<th>Series Title</th>
<th>IMDb ID</th>
<th>Episodes</th>
<th>With Dates</th>
<th>With Video</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="series-tbody">
<tr>
<td colspan="6" class="loading">Loading series...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="series-pagination"></div>
</div>
<!-- Reports Tab -->
<div class="tab-content" id="reports">
<div class="content-header">
<h2><i class="fas fa-chart-bar"></i> Missing Dates Report</h2>
<div class="content-controls">
<button class="btn btn-primary" onclick="refreshReport()">
<i class="fas fa-sync"></i> Refresh Report
</button>
</div>
</div>
<div class="report-summary" id="report-summary">
<div class="summary-card">
<h3>Movies</h3>
<p><span id="report-movies-with">-</span> with dates</p>
<p><span id="report-movies-missing">-</span> missing dates</p>
</div>
<div class="summary-card">
<h3>Episodes</h3>
<p><span id="report-episodes-with">-</span> with dates</p>
<p><span id="report-episodes-missing">-</span> missing dates</p>
</div>
</div>
<div class="report-content">
<div class="report-section">
<h3><i class="fas fa-film"></i> Movies Missing Dates</h3>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Title</th>
<th>IMDb ID</th>
<th>Released</th>
<th>Source</th>
<th>Quick Fix</th>
</tr>
</thead>
<tbody id="report-movies-tbody">
<tr>
<td colspan="5" class="loading">Loading report...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="report-section">
<h3><i class="fas fa-tv"></i> Episodes Missing Dates</h3>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Series</th>
<th>Episode</th>
<th>IMDb ID</th>
<th>Aired</th>
<th>Source</th>
<th>Quick Fix</th>
</tr>
</thead>
<tbody id="report-episodes-tbody">
<tr>
<td colspan="6" class="loading">Loading report...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Tools Tab -->
<div class="tab-content" id="tools">
<div class="content-header">
<h2><i class="fas fa-tools"></i> Database Tools</h2>
</div>
<div class="tools-grid">
<div class="tool-card">
<h3><i class="fas fa-exchange-alt"></i> Bulk Source Update</h3>
<p>Change source for multiple items at once</p>
<form id="bulk-update-form">
<div class="form-group">
<label>Media Type:</label>
<select id="bulk-media-type" required>
<option value="">Select type...</option>
<option value="movies">Movies</option>
<option value="episodes">Episodes</option>
</select>
</div>
<div class="form-group">
<label>From Source:</label>
<input type="text" id="bulk-old-source" placeholder="e.g., no_valid_date_source" required>
</div>
<div class="form-group">
<label>To Source:</label>
<select id="bulk-new-source" required>
<option value="">Select new source...</option>
<option value="airdate">Air Date</option>
<option value="digital_release">Digital Release</option>
<option value="manual">Manual</option>
<option value="radarr:db.history.import">Radarr Import</option>
<option value="sonarr:history.import">Sonarr Import</option>
</select>
</div>
<button type="submit" class="btn btn-warning">
<i class="fas fa-exchange-alt"></i> Update Sources
</button>
</form>
</div>
<div class="tool-card">
<h3><i class="fas fa-database"></i> Database Statistics</h3>
<p>View detailed database information</p>
<div class="stats-display" id="detailed-stats">
<p>Click refresh to load detailed statistics</p>
</div>
<button class="btn btn-secondary" onclick="loadDetailedStats()">
<i class="fas fa-sync"></i> Refresh Stats
</button>
</div>
</div>
</div>
</main>
</div>
<!-- Edit Modal -->
<div class="modal" id="edit-modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modal-title">Edit Entry</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<form id="edit-form">
<input type="hidden" id="edit-imdb-id">
<input type="hidden" id="edit-season">
<input type="hidden" id="edit-episode">
<input type="hidden" id="edit-media-type">
<div class="form-group">
<label for="edit-dateadded">Date Added:</label>
<input type="datetime-local" id="edit-dateadded">
<small>Leave empty to clear date</small>
</div>
<div class="form-group">
<label for="edit-source">Source:</label>
<select id="edit-source" required>
<option value="manual">Manual</option>
<option value="airdate">Air Date</option>
<option value="digital_release">Digital Release</option>
<option value="radarr:db.history.import">Radarr Import</option>
<option value="sonarr:history.import">Sonarr Import</option>
<option value="no_valid_date_source">No Valid Source</option>
</select>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
</div>
<!-- Toast Notifications -->
<div class="toast-container" id="toast-container"></div>
<script src="/static/js/app.js"></script>
</body>
</html>
+791
View File
@@ -0,0 +1,791 @@
// NFOGuard Web Interface JavaScript
// Global state
let currentTab = 'dashboard';
let currentMoviesPage = 1;
let currentSeriesPage = 1;
let dashboardData = null;
// Initialize app
document.addEventListener('DOMContentLoaded', function() {
initializeTabs();
initializeEventListeners();
loadDashboard();
});
// Tab management
function initializeTabs() {
const tabButtons = document.querySelectorAll('.nav-tab');
const tabContents = document.querySelectorAll('.tab-content');
tabButtons.forEach(button => {
button.addEventListener('click', function() {
const tabName = this.dataset.tab;
switchTab(tabName);
});
});
}
function switchTab(tabName) {
// Update button states
document.querySelectorAll('.nav-tab').forEach(btn => btn.classList.remove('active'));
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
// Update content
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
document.getElementById(tabName).classList.add('active');
currentTab = tabName;
// Load tab-specific data
switch(tabName) {
case 'dashboard':
loadDashboard();
break;
case 'movies':
loadMovies();
break;
case 'tv':
loadSeries();
break;
case 'reports':
loadReport();
break;
case 'tools':
loadDetailedStats();
break;
}
}
// Event listeners
function initializeEventListeners() {
// Search inputs
document.getElementById('movies-search').addEventListener('input', debounce(loadMovies, 500));
document.getElementById('series-search').addEventListener('input', debounce(loadSeries, 500));
// Filter dropdowns
document.getElementById('movies-filter-date').addEventListener('change', loadMovies);
document.getElementById('movies-filter-source').addEventListener('change', loadMovies);
// Forms
document.getElementById('edit-form').addEventListener('submit', handleEditSubmit);
document.getElementById('bulk-update-form').addEventListener('submit', handleBulkUpdate);
}
// API calls
async function apiCall(endpoint, options = {}) {
try {
const response = await fetch(endpoint, {
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('API call failed:', error);
showToast(`API Error: ${error.message}`, 'error');
throw error;
}
}
// Dashboard
async function loadDashboard() {
try {
dashboardData = await apiCall('/api/dashboard');
updateDashboardStats();
updateDashboardCharts();
} catch (error) {
console.error('Failed to load dashboard:', error);
}
}
function updateDashboardStats() {
if (!dashboardData) return;
document.getElementById('movies-total').textContent = dashboardData.movies_total || 0;
document.getElementById('movies-with-dates').textContent = `${dashboardData.movies_with_dates || 0} with dates`;
document.getElementById('series-total').textContent = dashboardData.series_count || 0;
document.getElementById('episodes-total').textContent = `${dashboardData.episodes_total || 0} episodes`;
const missingTotal = (dashboardData.movies_missing_dates || 0) + (dashboardData.episodes_missing_dates || 0);
document.getElementById('missing-dates-total').textContent = missingTotal;
const noValidTotal = (dashboardData.movies_no_valid_source || 0) + (dashboardData.episodes_no_valid_source || 0);
document.getElementById('no-valid-source-total').textContent = `${noValidTotal} no valid source`;
document.getElementById('recent-activity').textContent = dashboardData.recent_activity_count || 0;
}
function updateDashboardCharts() {
if (!dashboardData) return;
// Movie sources chart
const movieChart = document.getElementById('movie-sources-chart');
if (dashboardData.movie_sources && dashboardData.movie_sources.length > 0) {
movieChart.innerHTML = createSimpleChart(dashboardData.movie_sources);
} else {
movieChart.innerHTML = '<p>No movie source data available</p>';
}
// Episode sources chart
const episodeChart = document.getElementById('episode-sources-chart');
if (dashboardData.episode_sources && dashboardData.episode_sources.length > 0) {
episodeChart.innerHTML = createSimpleChart(dashboardData.episode_sources);
} else {
episodeChart.innerHTML = '<p>No episode source data available</p>';
}
}
function createSimpleChart(data) {
const total = data.reduce((sum, item) => sum + item.count, 0);
let html = '<div class="simple-chart">';
data.forEach((item, index) => {
const percentage = ((item.count / total) * 100).toFixed(1);
const color = getChartColor(index);
html += `
<div class="chart-item" style="background-color: ${color}20; border-left: 4px solid ${color};">
<span class="chart-label">${item.source}</span>
<span class="chart-value">${item.count} (${percentage}%)</span>
</div>
`;
});
html += '</div>';
return html;
}
function getChartColor(index) {
const colors = ['#007bff', '#28a745', '#ffc107', '#dc3545', '#6c757d', '#17a2b8', '#6f42c1'];
return colors[index % colors.length];
}
// Movies
async function loadMovies(page = 1) {
const search = document.getElementById('movies-search').value;
const hasDate = document.getElementById('movies-filter-date').value;
const sourceFilter = document.getElementById('movies-filter-source').value;
const params = new URLSearchParams({
skip: (page - 1) * 100,
limit: 100
});
if (search) params.append('search', search);
if (hasDate) params.append('has_date', hasDate);
if (sourceFilter) params.append('source_filter', sourceFilter);
try {
const data = await apiCall(`/api/movies?${params}`);
updateMoviesTable(data);
updateMoviesPagination(data);
updateMoviesSourceFilter(data);
currentMoviesPage = page;
} catch (error) {
console.error('Failed to load movies:', error);
}
}
function updateMoviesTable(data) {
const tbody = document.getElementById('movies-tbody');
if (!data.movies || data.movies.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center">No movies found</td></tr>';
return;
}
tbody.innerHTML = data.movies.map(movie => {
const dateadded = movie.dateadded ? formatDateTime(movie.dateadded) : '';
const hasDateBadge = movie.dateadded ?
'<span class="badge badge-success">Yes</span>' :
'<span class="badge badge-warning">No</span>';
const hasVideoBadge = movie.has_video_file ?
'<span class="badge badge-success">Yes</span>' :
'<span class="badge badge-secondary">No</span>';
return `
<tr>
<td>${escapeHtml(movie.title)}</td>
<td><code>${movie.imdb_id}</code></td>
<td>${movie.released || '-'}</td>
<td>${dateadded || '-'}</td>
<td><span class="badge badge-secondary">${movie.source || 'Unknown'}</span></td>
<td>${hasVideoBadge}</td>
<td>
<button class="btn btn-sm btn-primary" onclick="editMovie('${movie.imdb_id}', '${dateadded}', '${movie.source || ''}')">
<i class="fas fa-edit"></i> Edit
</button>
</td>
</tr>
`;
}).join('');
}
function updateMoviesPagination(data) {
const pagination = document.getElementById('movies-pagination');
if (data.pages <= 1) {
pagination.innerHTML = '';
return;
}
let html = '';
if (data.has_prev) {
html += `<button class="btn btn-secondary btn-sm" onclick="loadMovies(${data.page - 1})">
<i class="fas fa-chevron-left"></i> Previous
</button>`;
}
html += `<span class="page-info">Page ${data.page} of ${data.pages}</span>`;
if (data.has_next) {
html += `<button class="btn btn-secondary btn-sm" onclick="loadMovies(${data.page + 1})">
Next <i class="fas fa-chevron-right"></i>
</button>`;
}
pagination.innerHTML = html;
}
function updateMoviesSourceFilter(data) {
// This would be populated from dashboard data
if (dashboardData && dashboardData.movie_sources) {
const select = document.getElementById('movies-filter-source');
const currentValue = select.value;
select.innerHTML = '<option value="">All Sources</option>';
dashboardData.movie_sources.forEach(source => {
select.innerHTML += `<option value="${source.source}">${source.source} (${source.count})</option>`;
});
select.value = currentValue;
}
}
function refreshMovies() {
loadMovies(currentMoviesPage);
}
// TV Series
async function loadSeries(page = 1) {
const search = document.getElementById('series-search').value;
const params = new URLSearchParams({
skip: (page - 1) * 50,
limit: 50
});
if (search) params.append('search', search);
try {
const data = await apiCall(`/api/series?${params}`);
updateSeriesTable(data);
updateSeriesPagination(data);
currentSeriesPage = page;
} catch (error) {
console.error('Failed to load series:', error);
}
}
function updateSeriesTable(data) {
const tbody = document.getElementById('series-tbody');
if (!data.series || data.series.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center">No series found</td></tr>';
return;
}
tbody.innerHTML = data.series.map(series => {
const progressPercent = series.total_episodes > 0 ?
((series.episodes_with_dates / series.total_episodes) * 100).toFixed(1) : 0;
return `
<tr>
<td>${escapeHtml(series.title)}</td>
<td><code>${series.imdb_id}</code></td>
<td>${series.total_episodes}</td>
<td>
${series.episodes_with_dates}
<small class="text-muted">(${progressPercent}%)</small>
</td>
<td>${series.episodes_with_video}</td>
<td>
<button class="btn btn-sm btn-primary" onclick="viewSeriesEpisodes('${series.imdb_id}')">
<i class="fas fa-list"></i> Episodes
</button>
</td>
</tr>
`;
}).join('');
}
function updateSeriesPagination(data) {
const pagination = document.getElementById('series-pagination');
if (data.pages <= 1) {
pagination.innerHTML = '';
return;
}
let html = '';
if (data.has_prev) {
html += `<button class="btn btn-secondary btn-sm" onclick="loadSeries(${data.page - 1})">
<i class="fas fa-chevron-left"></i> Previous
</button>`;
}
html += `<span class="page-info">Page ${data.page} of ${data.pages}</span>`;
if (data.has_next) {
html += `<button class="btn btn-secondary btn-sm" onclick="loadSeries(${data.page + 1})">
Next <i class="fas fa-chevron-right"></i>
</button>`;
}
pagination.innerHTML = html;
}
function refreshSeries() {
loadSeries(currentSeriesPage);
}
async function viewSeriesEpisodes(imdbId) {
try {
const data = await apiCall(`/api/series/${imdbId}/episodes`);
showEpisodesModal(data);
} catch (error) {
console.error('Failed to load episodes:', error);
}
}
function showEpisodesModal(data) {
const modalHtml = `
<div class="modal active" id="episodes-modal">
<div class="modal-content" style="max-width: 800px;">
<div class="modal-header">
<h3>${escapeHtml(data.series.title)} - Episodes</h3>
<button class="modal-close" onclick="closeEpisodesModal()">&times;</button>
</div>
<div class="modal-body">
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Episode</th>
<th>Aired</th>
<th>Date Added</th>
<th>Source</th>
<th>Video</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${data.episodes.map(episode => {
const dateadded = episode.dateadded ? formatDateTime(episode.dateadded) : '';
const hasVideoBadge = episode.has_video_file ?
'<span class="badge badge-success">Yes</span>' :
'<span class="badge badge-secondary">No</span>';
return `
<tr>
<td>S${episode.season.toString().padStart(2, '0')}E${episode.episode.toString().padStart(2, '0')}</td>
<td>${episode.aired || '-'}</td>
<td>${dateadded || '-'}</td>
<td><span class="badge badge-secondary">${episode.source || 'Unknown'}</span></td>
<td>${hasVideoBadge}</td>
<td>
<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
</button>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
}
function closeEpisodesModal() {
const modal = document.getElementById('episodes-modal');
if (modal) {
modal.remove();
}
}
// Reports
async function loadReport() {
try {
const data = await apiCall('/api/reports/missing-dates');
updateReportSummary(data.summary);
updateReportTables(data);
} catch (error) {
console.error('Failed to load report:', error);
}
}
function updateReportSummary(summary) {
document.getElementById('report-movies-with').textContent = summary.movies_with_dates;
document.getElementById('report-movies-missing').textContent = summary.movies_missing_dates;
document.getElementById('report-episodes-with').textContent = summary.episodes_with_dates;
document.getElementById('report-episodes-missing').textContent = summary.episodes_missing_dates;
}
function updateReportTables(data) {
// Movies missing dates
const moviesTbody = document.getElementById('report-movies-tbody');
if (data.movies_missing.length === 0) {
moviesTbody.innerHTML = '<tr><td colspan="5" class="text-center">No movies missing dates</td></tr>';
} else {
moviesTbody.innerHTML = data.movies_missing.map(movie => `
<tr>
<td>${escapeHtml(movie.title)}</td>
<td><code>${movie.imdb_id}</code></td>
<td>${movie.released || '-'}</td>
<td><span class="badge badge-warning">${movie.source || 'Unknown'}</span></td>
<td>
<button class="btn btn-sm btn-success" onclick="quickFixMovie('${movie.imdb_id}', '${movie.released || ''}')">
<i class="fas fa-magic"></i> Fix
</button>
</td>
</tr>
`).join('');
}
// Episodes missing dates
const episodesTbody = document.getElementById('report-episodes-tbody');
if (data.episodes_missing.length === 0) {
episodesTbody.innerHTML = '<tr><td colspan="6" class="text-center">No episodes missing dates</td></tr>';
} else {
episodesTbody.innerHTML = data.episodes_missing.map(episode => `
<tr>
<td>${escapeHtml(episode.series_title)}</td>
<td>S${episode.season.toString().padStart(2, '0')}E${episode.episode.toString().padStart(2, '0')}</td>
<td><code>${episode.imdb_id}</code></td>
<td>${episode.aired || '-'}</td>
<td><span class="badge badge-warning">${episode.source || 'Unknown'}</span></td>
<td>
<button class="btn btn-sm btn-success" onclick="quickFixEpisode('${episode.imdb_id}', ${episode.season}, ${episode.episode}, '${episode.aired || ''}')">
<i class="fas fa-magic"></i> Fix
</button>
</td>
</tr>
`).join('');
}
}
function refreshReport() {
loadReport();
}
// Quick fix functions
function quickFixMovie(imdbId, released) {
let newSource = 'manual';
let newDate = null;
if (released) {
newSource = 'digital_release';
newDate = released + 'T00:00:00';
} else {
newDate = new Date().toISOString().slice(0, 16);
}
updateMovieDate(imdbId, newDate, newSource);
}
function quickFixEpisode(imdbId, season, episode, aired) {
let newSource = 'manual';
let newDate = null;
if (aired) {
newSource = 'airdate';
newDate = aired + 'T00:00:00';
} else {
newDate = new Date().toISOString().slice(0, 16);
}
updateEpisodeDate(imdbId, season, episode, newDate, newSource);
}
// Tools
async function loadDetailedStats() {
try {
const data = await apiCall('/api/dashboard');
const statsHtml = `
<div class="stats-grid">
<div class="stat-row">
<strong>Database Size:</strong> ${data.database_size_mb} MB
</div>
<div class="stat-row">
<strong>Total Movies:</strong> ${data.movies_total} (${data.movies_with_video} with video files)
</div>
<div class="stat-row">
<strong>Movies with Dates:</strong> ${data.movies_with_dates} (${((data.movies_with_dates / data.movies_total) * 100).toFixed(1)}%)
</div>
<div class="stat-row">
<strong>Total Series:</strong> ${data.series_count}
</div>
<div class="stat-row">
<strong>Total Episodes:</strong> ${data.episodes_total} (${data.episodes_with_video} with video files)
</div>
<div class="stat-row">
<strong>Episodes with Dates:</strong> ${data.episodes_with_dates} (${((data.episodes_with_dates / data.episodes_total) * 100).toFixed(1)}%)
</div>
<div class="stat-row">
<strong>Processing History:</strong> ${data.processing_history_count} events
</div>
</div>
`;
document.getElementById('detailed-stats').innerHTML = statsHtml;
} catch (error) {
console.error('Failed to load detailed stats:', error);
}
}
async function handleBulkUpdate(event) {
event.preventDefault();
const mediaType = document.getElementById('bulk-media-type').value;
const oldSource = document.getElementById('bulk-old-source').value;
const newSource = document.getElementById('bulk-new-source').value;
if (!mediaType || !oldSource || !newSource) {
showToast('Please fill in all fields', 'warning');
return;
}
if (!confirm(`This will update all ${mediaType} with source "${oldSource}" to "${newSource}". Continue?`)) {
return;
}
try {
const result = await apiCall('/api/bulk/update-source', {
method: 'POST',
body: JSON.stringify({
media_type: mediaType,
old_source: oldSource,
new_source: newSource
})
});
showToast(result.message, 'success');
// Reset form
document.getElementById('bulk-update-form').reset();
// Refresh current tab
if (currentTab === 'movies') loadMovies(currentMoviesPage);
if (currentTab === 'tv') loadSeries(currentSeriesPage);
if (currentTab === 'reports') loadReport();
if (currentTab === 'dashboard') loadDashboard();
} catch (error) {
console.error('Bulk update failed:', error);
}
}
// Edit modal functions
function editMovie(imdbId, dateadded, source) {
document.getElementById('modal-title').textContent = `Edit Movie: ${imdbId}`;
document.getElementById('edit-imdb-id').value = imdbId;
document.getElementById('edit-media-type').value = 'movie';
document.getElementById('edit-season').value = '';
document.getElementById('edit-episode').value = '';
// Convert dateadded to datetime-local format if present
if (dateadded && dateadded !== '-') {
try {
const date = new Date(dateadded);
document.getElementById('edit-dateadded').value = date.toISOString().slice(0, 16);
} catch (e) {
document.getElementById('edit-dateadded').value = '';
}
} else {
document.getElementById('edit-dateadded').value = '';
}
document.getElementById('edit-source').value = source || 'manual';
document.getElementById('edit-modal').classList.add('active');
}
function editEpisode(imdbId, season, episode, dateadded, source) {
document.getElementById('modal-title').textContent = `Edit Episode: ${imdbId} S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`;
document.getElementById('edit-imdb-id').value = imdbId;
document.getElementById('edit-media-type').value = 'episode';
document.getElementById('edit-season').value = season;
document.getElementById('edit-episode').value = episode;
// Convert dateadded to datetime-local format if present
if (dateadded && dateadded !== '-') {
try {
const date = new Date(dateadded);
document.getElementById('edit-dateadded').value = date.toISOString().slice(0, 16);
} catch (e) {
document.getElementById('edit-dateadded').value = '';
}
} else {
document.getElementById('edit-dateadded').value = '';
}
document.getElementById('edit-source').value = source || 'manual';
document.getElementById('edit-modal').classList.add('active');
}
function closeModal() {
document.getElementById('edit-modal').classList.remove('active');
}
async function handleEditSubmit(event) {
event.preventDefault();
const imdbId = document.getElementById('edit-imdb-id').value;
const mediaType = document.getElementById('edit-media-type').value;
const season = document.getElementById('edit-season').value;
const episode = document.getElementById('edit-episode').value;
const dateadded = document.getElementById('edit-dateadded').value;
const source = document.getElementById('edit-source').value;
// Convert datetime-local to ISO string
const isoDateadded = dateadded ? new Date(dateadded).toISOString() : null;
try {
if (mediaType === 'movie') {
await updateMovieDate(imdbId, isoDateadded, source);
} else {
await updateEpisodeDate(imdbId, parseInt(season), parseInt(episode), isoDateadded, source);
}
closeModal();
} catch (error) {
console.error('Update failed:', error);
}
}
// Update functions
async function updateMovieDate(imdbId, dateadded, source) {
try {
const result = await apiCall(`/api/movies/${imdbId}`, {
method: 'PUT',
body: JSON.stringify({
imdb_id: imdbId,
dateadded: dateadded,
source: source
})
});
showToast(result.message, 'success');
// Refresh current view
if (currentTab === 'movies') loadMovies(currentMoviesPage);
if (currentTab === 'reports') loadReport();
if (currentTab === 'dashboard') loadDashboard();
} catch (error) {
console.error('Movie update failed:', error);
}
}
async function updateEpisodeDate(imdbId, season, episode, dateadded, source) {
try {
const result = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}`, {
method: 'PUT',
body: JSON.stringify({
imdb_id: imdbId,
season: season,
episode: episode,
dateadded: dateadded,
source: source
})
});
showToast(result.message, 'success');
// Refresh current view
if (currentTab === 'tv') loadSeries(currentSeriesPage);
if (currentTab === 'reports') loadReport();
if (currentTab === 'dashboard') loadDashboard();
// Refresh episodes modal if open
const episodesModal = document.getElementById('episodes-modal');
if (episodesModal) {
closeEpisodesModal();
setTimeout(() => viewSeriesEpisodes(imdbId), 100);
}
} catch (error) {
console.error('Episode update failed:', error);
}
}
// Utility functions
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
function formatDateTime(dateString) {
try {
const date = new Date(dateString);
return date.toLocaleString();
} catch (e) {
return dateString;
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<div class="toast-content">
<span>${escapeHtml(message)}</span>
</div>
`;
container.appendChild(toast);
// Auto remove after 5 seconds
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 5000);
// Remove on click
toast.addEventListener('click', () => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
});
}