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

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