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