diff --git a/api/models.py b/api/models.py
index 1836646..01f9352 100644
--- a/api/models.py
+++ b/api/models.py
@@ -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
\ No newline at end of file
diff --git a/api/routes.py b/api/routes.py
index 08f9711..fa135bb 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -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)
\ No newline at end of file
+ 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"}
\ No newline at end of file
diff --git a/api/web_routes.py b/api/web_routes.py
new file mode 100644
index 0000000..0b8db1f
--- /dev/null
+++ b/api/web_routes.py
@@ -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}'"
+ }
\ No newline at end of file
diff --git a/static/css/styles.css b/static/css/styles.css
new file mode 100644
index 0000000..3115034
--- /dev/null
+++ b/static/css/styles.css
@@ -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; }
\ No newline at end of file
diff --git a/static/index.html b/static/index.html
new file mode 100644
index 0000000..8a34532
--- /dev/null
+++ b/static/index.html
@@ -0,0 +1,357 @@
+
+
+
+
+
+ NFOGuard - Database Management
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
Total Movies
+
- with dates
+
+
+
+
+
+
+
+
+
-
+
TV Series
+
- episodes
+
+
+
+
+
+
+
+
+
-
+
Missing Dates
+
- no valid source
+
+
+
+
+
+
+
+
+
-
+
Recent Activity
+
Last 7 days
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Title |
+ IMDb ID |
+ Released |
+ Date Added |
+ Source |
+ Video File |
+ Actions |
+
+
+
+
+ | Loading movies... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Series Title |
+ IMDb ID |
+ Episodes |
+ With Dates |
+ With Video |
+ Actions |
+
+
+
+
+ | Loading series... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Movies
+
- with dates
+
- missing dates
+
+
+
Episodes
+
- with dates
+
- missing dates
+
+
+
+
+
+
Movies Missing Dates
+
+
+
+
+ | Title |
+ IMDb ID |
+ Released |
+ Source |
+ Quick Fix |
+
+
+
+
+ | Loading report... |
+
+
+
+
+
+
+
+
Episodes Missing Dates
+
+
+
+
+ | Series |
+ Episode |
+ IMDb ID |
+ Aired |
+ Source |
+ Quick Fix |
+
+
+
+
+ | Loading report... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/static/js/app.js b/static/js/app.js
new file mode 100644
index 0000000..b7c70ae
--- /dev/null
+++ b/static/js/app.js
@@ -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 = 'No movie source data available
';
+ }
+
+ // 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 = 'No episode source data available
';
+ }
+}
+
+function createSimpleChart(data) {
+ const total = data.reduce((sum, item) => sum + item.count, 0);
+ let html = '';
+
+ data.forEach((item, index) => {
+ const percentage = ((item.count / total) * 100).toFixed(1);
+ const color = getChartColor(index);
+ html += `
+
+ ${item.source}
+ ${item.count} (${percentage}%)
+
+ `;
+ });
+
+ html += '
';
+ 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 = '| No movies found |
';
+ return;
+ }
+
+ tbody.innerHTML = data.movies.map(movie => {
+ const dateadded = movie.dateadded ? formatDateTime(movie.dateadded) : '';
+ const hasDateBadge = movie.dateadded ?
+ 'Yes' :
+ 'No';
+ const hasVideoBadge = movie.has_video_file ?
+ 'Yes' :
+ 'No';
+
+ return `
+
+ | ${escapeHtml(movie.title)} |
+ ${movie.imdb_id} |
+ ${movie.released || '-'} |
+ ${dateadded || '-'} |
+ ${movie.source || 'Unknown'} |
+ ${hasVideoBadge} |
+
+
+ |
+
+ `;
+ }).join('');
+}
+
+function updateMoviesPagination(data) {
+ const pagination = document.getElementById('movies-pagination');
+
+ if (data.pages <= 1) {
+ pagination.innerHTML = '';
+ return;
+ }
+
+ let html = '';
+
+ if (data.has_prev) {
+ html += ``;
+ }
+
+ html += `Page ${data.page} of ${data.pages}`;
+
+ if (data.has_next) {
+ html += ``;
+ }
+
+ 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 = '';
+ dashboardData.movie_sources.forEach(source => {
+ select.innerHTML += ``;
+ });
+
+ 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 = '| No series found |
';
+ 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 `
+
+ | ${escapeHtml(series.title)} |
+ ${series.imdb_id} |
+ ${series.total_episodes} |
+
+ ${series.episodes_with_dates}
+ (${progressPercent}%)
+ |
+ ${series.episodes_with_video} |
+
+
+ |
+
+ `;
+ }).join('');
+}
+
+function updateSeriesPagination(data) {
+ const pagination = document.getElementById('series-pagination');
+
+ if (data.pages <= 1) {
+ pagination.innerHTML = '';
+ return;
+ }
+
+ let html = '';
+
+ if (data.has_prev) {
+ html += ``;
+ }
+
+ html += `Page ${data.page} of ${data.pages}`;
+
+ if (data.has_next) {
+ html += ``;
+ }
+
+ 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 = `
+
+
+
+
+
+
+
+
+ | Episode |
+ Aired |
+ Date Added |
+ Source |
+ Video |
+ Actions |
+
+
+
+ ${data.episodes.map(episode => {
+ const dateadded = episode.dateadded ? formatDateTime(episode.dateadded) : '';
+ const hasVideoBadge = episode.has_video_file ?
+ 'Yes' :
+ 'No';
+
+ return `
+
+ | S${episode.season.toString().padStart(2, '0')}E${episode.episode.toString().padStart(2, '0')} |
+ ${episode.aired || '-'} |
+ ${dateadded || '-'} |
+ ${episode.source || 'Unknown'} |
+ ${hasVideoBadge} |
+
+
+ |
+
+ `;
+ }).join('')}
+
+
+
+
+
+
+ `;
+
+ 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 = '| No movies missing dates |
';
+ } else {
+ moviesTbody.innerHTML = data.movies_missing.map(movie => `
+
+ | ${escapeHtml(movie.title)} |
+ ${movie.imdb_id} |
+ ${movie.released || '-'} |
+ ${movie.source || 'Unknown'} |
+
+
+ |
+
+ `).join('');
+ }
+
+ // Episodes missing dates
+ const episodesTbody = document.getElementById('report-episodes-tbody');
+ if (data.episodes_missing.length === 0) {
+ episodesTbody.innerHTML = '| No episodes missing dates |
';
+ } else {
+ episodesTbody.innerHTML = data.episodes_missing.map(episode => `
+
+ | ${escapeHtml(episode.series_title)} |
+ S${episode.season.toString().padStart(2, '0')}E${episode.episode.toString().padStart(2, '0')} |
+ ${episode.imdb_id} |
+ ${episode.aired || '-'} |
+ ${episode.source || 'Unknown'} |
+
+
+ |
+
+ `).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 = `
+
+
+ Database Size: ${data.database_size_mb} MB
+
+
+ Total Movies: ${data.movies_total} (${data.movies_with_video} with video files)
+
+
+ Movies with Dates: ${data.movies_with_dates} (${((data.movies_with_dates / data.movies_total) * 100).toFixed(1)}%)
+
+
+ Total Series: ${data.series_count}
+
+
+ Total Episodes: ${data.episodes_total} (${data.episodes_with_video} with video files)
+
+
+ Episodes with Dates: ${data.episodes_with_dates} (${((data.episodes_with_dates / data.episodes_total) * 100).toFixed(1)}%)
+
+
+ Processing History: ${data.processing_history_count} events
+
+
+ `;
+ 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 = `
+
+ ${escapeHtml(message)}
+
+ `;
+
+ 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);
+ }
+ });
+}
\ No newline at end of file