feat(anime): rename NFO Diagnostics to Anime Settings + add edit endpoints

Replaces the read-only 'NFO Diagnostics' page with a full per-anime
Settings page reached from the right-click context menu on series cards.
Users can now view and edit key, name, folder, tmdb_id, tvdb_id and site
for each anime; changes are persisted to the DB and optionally written
back to the NFO file or used to regenerate it.

Backend
- Rename NfoDiagnosticsResponse -> NfoSettingsResponse,
  NfoSeriesDiagnostics -> NfoSeriesSettings
- Rename get_nfo_diagnostics -> get_nfo_settings,
  repair_nfo -> repair_nfo_settings
- Fix nfo.py bug: repair was calling non-existent
  update_series_nfo_status(); now uses update_nfo_status() and an
  explicit AnimeSeriesService.update(nfo_path=...)
- New endpoints on /api/anime/{key}:
    GET  /settings            -> AnimeSettingsResponse
    PUT  /settings            -> AnimeSettingsResponse
                                 (body: name/folder/tmdb_id/tvdb_id/site,
                                  options: apply_to_nfo, rename_disk)
    POST /regenerate-nfo      -> AnimeSettingsRegenerateNfoResponse
- New Pydantic models: AnimeSettingsResponse,
  AnimeSettingsUpdateRequest, AnimeSettingsRegenerateNfoResponse
- /anime/settings page route; /settings/nfo now 301-redirects to it

Frontend
- New AniWorld.AnimeSettingsManager JS module (single-page form,
  no tabs) with public API init/loadSeries/saveSettings/regenerateNfo/
  validateField/populateForm/showSaveSuccess/showError
- New anime-settings.html template + anime-settings.css
- Right-click menu: data-action 'nfo-diagnostics' replaced by
  'anime-settings' (label 'Anime Settings'), navigates to
  /anime/settings?key=...
- Library 'Open NFO Diagnostics' link renamed to 'Open Anime Settings'

Bug fix
- context-menu click handler was calling hide() BEFORE building the
  navigation URL, which cleared currentSeriesKey to null and produced
  /anime/settings?key=null. Captures the key into a local const first.
  Regression-locked by tests/frontend/unit/context_menu.test.js.

Tests
- 21 new pytest tests in tests/api/test_anime_settings_endpoints.py
  (GET/PUT/regenerate-nfo, auth, validation, nfo-repair bug regression)
- tests/api/test_nfo_endpoints.py trimmed to 6 focused tests
- 31 new Vitest unit tests for AnimeSettingsManager
- 5 new Vitest unit tests for ContextMenu (incl. source-invariant
  regression guard for the hide()-before-key bug)
- 5 new Playwright E2E tests covering right-click, direct nav,
  legacy /settings/nfo redirect, and context-menu labels
- New vitest.config.js (environment: happy-dom)

Docs
- Docs/API.md: new section 'Anime Settings Endpoints'
- Docs/CHANGELOG.md: documents the rename and the context-menu bug fix

Verified
- pytest: 27/27 (21 new + 6 trimmed nfo)
- vitest: 36/36 (31 anime-settings + 5 context-menu)
- playwright e2e: 5/5
This commit is contained in:
2026-06-21 07:52:22 +02:00
parent e050f6fa2d
commit eabce18e41
22 changed files with 3216 additions and 2302 deletions

View File

@@ -0,0 +1,226 @@
/* ============================================================
Anime Settings Page
------------------------------------------------------------
Layout and styling for /anime/settings (renamed from
/settings/nfo — formerly "NFO Diagnostics").
============================================================ */
.anime-settings-main {
padding: 1.5rem;
max-width: 1100px;
margin: 0 auto;
}
.settings-section {
margin-bottom: 1.5rem;
}
.settings-header-card {
background: var(--color-card-bg, #1f2937);
color: var(--color-text, #f3f4f6);
padding: 1.25rem 1.5rem;
border-radius: 8px;
margin-bottom: 1.25rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.settings-header-card h2 {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
}
.status-badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
background: var(--color-badge-bg, #374151);
color: var(--color-badge-text, #f9fafb);
}
.status-badge.status-complete {
background: #10b981;
color: #ffffff;
}
.status-badge.status-incomplete {
background: #f59e0b;
color: #ffffff;
}
.status-badge.status-failed {
background: #ef4444;
color: #ffffff;
}
.status-badge.status-pending {
background: #6366f1;
color: #ffffff;
}
.settings-section-card {
background: var(--color-card-bg, #1f2937);
border: 1px solid var(--color-border, #374151);
padding: 1.25rem 1.5rem;
border-radius: 8px;
margin-bottom: 1.25rem;
}
.settings-section-card h3 {
margin-top: 0;
margin-bottom: 0.75rem;
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem 1.5rem;
margin-bottom: 1rem;
}
.settings-field {
display: flex;
flex-direction: column;
}
.settings-field.full-width {
grid-column: 1 / -1;
}
.settings-field label {
font-weight: 600;
font-size: 0.85rem;
color: var(--color-text-muted, #9ca3af);
margin-bottom: 0.25rem;
}
.settings-field input.input-field {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border, #4b5563);
border-radius: 6px;
background: var(--color-input-bg, #111827);
color: var(--color-text, #f9fafb);
font-size: 0.95rem;
}
.settings-field input.input-field:focus {
outline: none;
border-color: var(--color-accent, #3b82f6);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);
}
.settings-field .config-hint {
font-size: 0.8rem;
color: var(--color-text-muted, #9ca3af);
margin-top: 0.25rem;
}
.settings-field .config-hint.hint-error {
color: #ef4444;
}
.value-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: var(--color-code-bg, #111827);
padding: 0.25rem 0.5rem;
border-radius: 4px;
word-break: break-all;
display: inline-block;
font-size: 0.9rem;
}
.value {
font-size: 0.95rem;
color: var(--color-text, #f3f4f6);
}
.settings-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.settings-toggles {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border, #374151);
}
.nfo-content {
margin-top: 1rem;
padding: 0.75rem;
background: var(--color-code-bg, #111827);
border: 1px solid var(--color-border, #4b5563);
border-radius: 6px;
max-height: 400px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8rem;
white-space: pre-wrap;
word-break: break-all;
color: var(--color-text, #e5e7eb);
}
.error-box {
background: var(--color-card-bg, #1f2937);
border: 1px solid #ef4444;
padding: 1.5rem;
border-radius: 8px;
text-align: center;
color: var(--color-text, #f3f4f6);
}
.error-box i {
font-size: 2rem;
color: #ef4444;
margin-bottom: 0.5rem;
display: block;
}
.error-box h2 {
margin: 0 0 0.5rem 0;
}
.error-box p {
color: var(--color-text-muted, #9ca3af);
margin-bottom: 1rem;
}
.loading-spinner {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-muted, #9ca3af);
}
.loading-spinner i {
font-size: 2rem;
margin-bottom: 0.5rem;
display: block;
color: var(--color-accent, #3b82f6);
}
.hidden {
display: none !important;
}
@media (max-width: 720px) {
.settings-grid {
grid-template-columns: 1fr;
}
.anime-settings-main {
padding: 1rem;
}
}

View File

@@ -1,637 +0,0 @@
/**
* AniWorld - NFO Settings Page Styles
*
* Standalone page for NFO diagnostics, repair, and settings.
*/
.nfo-main-content {
padding: var(--spacing-lg);
max-width: 1400px;
margin: 0 auto;
}
/* ========== Stats Section ========== */
.nfo-stats-section {
margin-bottom: var(--spacing-xl);
}
.nfo-stats-section .stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--spacing-lg);
}
.nfo-stats-section .stat-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
padding: var(--spacing-lg);
display: flex;
align-items: center;
gap: var(--spacing-lg);
}
.nfo-stats-section .stat-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-tertiary);
font-size: 20px;
}
.nfo-stats-section .stat-success .stat-icon {
background: rgba(16, 124, 16, 0.1);
color: var(--color-success);
}
.nfo-stats-section .stat-warning .stat-icon {
background: rgba(255, 140, 0, 0.1);
color: var(--color-warning);
}
.nfo-stats-section .stat-error .stat-icon {
background: rgba(209, 52, 56, 0.1);
color: var(--color-error);
}
.nfo-stats-section .stat-info {
display: flex;
flex-direction: column;
}
.nfo-stats-section .stat-value {
font-size: 28px;
font-weight: 600;
color: var(--color-text-primary);
line-height: 1.2;
}
.nfo-stats-section .stat-label {
font-size: var(--font-size-body);
color: var(--color-text-secondary);
}
/* ========== Tab Navigation ========== */
.nfo-tabs {
display: flex;
gap: var(--spacing-xs);
background: var(--color-bg-secondary);
padding: var(--spacing-xs);
border-radius: var(--border-radius-lg);
margin-bottom: var(--spacing-lg);
border: 1px solid var(--color-border);
}
.nfo-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
padding: var(--spacing-md) var(--spacing-lg);
border: none;
background: transparent;
color: var(--color-text-secondary);
font-size: var(--font-size-body);
font-weight: 500;
cursor: pointer;
border-radius: var(--border-radius-md);
transition: all var(--transition-duration) ease;
}
.nfo-tab:hover {
background: var(--color-surface-hover);
color: var(--color-text-primary);
}
.nfo-tab.active {
background: var(--color-surface);
color: var(--color-primary);
box-shadow: var(--shadow-card);
}
.nfo-tab i {
font-size: 16px;
}
/* ========== Tab Content ========== */
.nfo-tab-content {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
overflow: hidden;
}
.tab-panel {
display: none;
padding: var(--spacing-xl);
animation: fadeIn 0.2s ease;
}
.tab-panel.active {
display: block;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.panel-header h2 {
margin: 0;
font-size: var(--font-size-title);
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.panel-header h2 i {
color: var(--color-primary);
}
.panel-actions {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.panel-actions .search-input {
width: 200px;
}
.panel-actions .filter-select {
min-width: 140px;
}
/* ========== Overview Tab ========== */
.overview-content {
max-width: 900px;
}
.health-summary {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-xl);
}
.health-chart {
background: var(--color-bg-secondary);
border-radius: var(--border-radius-lg);
padding: var(--spacing-xl);
display: flex;
align-items: center;
justify-content: center;
min-height: 250px;
}
.health-details h3 {
font-size: var(--font-size-subtitle);
margin: 0 0 var(--spacing-sm) 0;
color: var(--color-text-primary);
}
.health-details h3:not(:first-child) {
margin-top: var(--spacing-xl);
}
.info-text {
color: var(--color-text-secondary);
margin: 0 0 var(--spacing-sm) 0;
font-size: var(--font-size-body);
}
.tag-list {
list-style: none;
padding: 0;
margin: 0;
}
.tag-list li {
padding: var(--spacing-xs) 0;
color: var(--color-text-secondary);
font-size: var(--font-size-body);
}
.tag-list li code {
background: var(--color-bg-tertiary);
padding: 2px 6px;
border-radius: var(--border-radius-sm);
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
color: var(--color-text-primary);
}
.optional-tags {
columns: 2;
}
/* ========== Diagnostics Tab ========== */
.series-list-container {
overflow-x: auto;
}
.series-diagnostics-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-body);
}
.series-diagnostics-table th,
.series-diagnostics-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--color-border);
}
.series-diagnostics-table th {
background: var(--color-bg-secondary);
font-weight: 600;
color: var(--color-text-primary);
white-space: nowrap;
}
.series-diagnostics-table th.sortable {
cursor: pointer;
user-select: none;
}
.series-diagnostics-table th.sortable:hover {
background: var(--color-bg-tertiary);
}
.series-diagnostics-table th i {
margin-left: var(--spacing-xs);
color: var(--color-text-tertiary);
}
.series-diagnostics-table tbody tr:hover {
background: var(--color-surface-hover);
}
.series-diagnostics-table .status-badge {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.series-diagnostics-table .status-complete {
background: rgba(16, 124, 16, 0.1);
color: var(--color-success);
}
.series-diagnostics-table .status-incomplete {
background: rgba(255, 140, 0, 0.1);
color: var(--color-warning);
}
.series-diagnostics-table .status-missing {
background: rgba(209, 52, 56, 0.1);
color: var(--color-error);
}
.missing-tags-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.missing-tag-chip {
display: inline-block;
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-family: 'Consolas', 'Monaco', monospace;
}
.nfo-path {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
color: var(--color-text-tertiary);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.loading-row td {
text-align: center;
padding: var(--spacing-xxl);
}
.loading-spinner {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
color: var(--color-text-secondary);
}
.loading-spinner i {
font-size: 24px;
}
/* ========== Repair Tab ========== */
.repair-content {
max-width: 900px;
}
.repair-info {
margin-bottom: var(--spacing-xl);
}
.info-card {
display: flex;
gap: var(--spacing-md);
padding: var(--spacing-lg);
background: var(--color-bg-secondary);
border-radius: var(--border-radius-lg);
border-left: 4px solid var(--color-primary);
}
.info-card i {
font-size: 20px;
color: var(--color-primary);
flex-shrink: 0;
}
.info-card h4 {
margin: 0 0 var(--spacing-xs) 0;
font-size: var(--font-size-subtitle);
color: var(--color-text-primary);
}
.info-card p {
margin: 0;
font-size: var(--font-size-body);
color: var(--color-text-secondary);
}
.repair-actions {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-lg);
}
.repair-list-container {
margin-bottom: var(--spacing-lg);
}
.repair-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-body);
}
.repair-table th,
.repair-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--color-border);
}
.repair-table th {
background: var(--color-bg-secondary);
font-weight: 600;
color: var(--color-text-primary);
}
.checkbox-col {
width: 40px;
}
.repair-table tbody tr:hover {
background: var(--color-surface-hover);
}
.repair-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: var(--spacing-lg);
border-top: 1px solid var(--color-border);
}
.selection-count {
color: var(--color-text-secondary);
font-size: var(--font-size-body);
}
.selection-count span {
font-weight: 600;
color: var(--color-primary);
}
/* ========== Settings Tab ========== */
.settings-content {
max-width: 700px;
}
.settings-section {
margin-bottom: var(--spacing-xxl);
padding-bottom: var(--spacing-xxl);
border-bottom: 1px solid var(--color-border);
}
.settings-section:last-child {
border-bottom: none;
margin-bottom: 0;
}
.settings-section h3 {
margin: 0 0 var(--spacing-lg) 0;
font-size: var(--font-size-subtitle);
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.settings-section h3 i {
color: var(--color-primary);
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--spacing-xl);
padding: var(--spacing-md) 0;
}
.setting-info {
flex: 1;
}
.setting-info label {
font-weight: 500;
color: var(--color-text-primary);
display: block;
margin-bottom: var(--spacing-xs);
}
.setting-description {
margin: 0;
font-size: var(--font-size-body);
color: var(--color-text-secondary);
}
.setting-description a {
color: var(--color-primary);
}
.setting-control {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.setting-control .input-field {
width: 300px;
}
/* Toggle Switch */
.toggle-switch {
position: relative;
display: inline-block;
width: 48px;
height: 26px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--color-bg-tertiary);
transition: 0.2s;
border-radius: 13px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.2s;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.toggle-switch input:checked + .toggle-slider {
background-color: var(--color-primary);
}
.toggle-switch input:checked + .toggle-slider:before {
transform: translateX(22px);
}
/* Connection Status */
.connection-status {
margin-top: var(--spacing-md);
padding: var(--spacing-md);
border-radius: var(--border-radius-md);
font-size: var(--font-size-body);
}
.connection-status.success {
background: rgba(16, 124, 16, 0.1);
color: var(--color-success);
border: 1px solid var(--color-success);
}
.connection-status.error {
background: rgba(209, 52, 56, 0.1);
color: var(--color-error);
border: 1px solid var(--color-error);
}
/* ========== Responsive ========== */
@media (max-width: 1024px) {
.nfo-stats-section .stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.health-summary {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.nfo-tabs {
flex-wrap: wrap;
}
.nfo-tab {
flex: 1 1 45%;
}
.nfo-tab span {
display: none;
}
.panel-header {
flex-direction: column;
align-items: flex-start;
}
.panel-actions {
width: 100%;
}
.panel-actions .search-input,
.panel-actions .filter-select {
flex: 1;
}
.setting-item {
flex-direction: column;
gap: var(--spacing-md);
}
.setting-control {
width: 100%;
}
.setting-control .input-field {
width: 100%;
}
.repair-footer {
flex-direction: column;
gap: var(--spacing-md);
}
.repair-footer .btn {
width: 100%;
}
}

View File

@@ -2,7 +2,7 @@
* AniWorld - Context Menu Component
*
* Right-click context menu for anime series cards.
* Provides quick access to NFO diagnostics.
* Provides quick access to per-anime settings.
*
* Dependencies: ui-utils.js
*/
@@ -67,9 +67,9 @@ AniWorld.ContextMenu = (function() {
menuElement = document.createElement('div');
menuElement.className = 'context-menu';
menuElement.innerHTML = `
<div class="context-menu-item" data-action="nfo-diagnostics">
<i class="fa-solid fa-file-circle-check"></i>
<span>NFO Diagnostics</span>
<div class="context-menu-item" data-action="anime-settings">
<i class="fa-solid fa-gear"></i>
<span>Anime Settings</span>
</div>
`;
@@ -96,11 +96,13 @@ AniWorld.ContextMenu = (function() {
menuElement.style.top = posY + 'px';
// Attach action handlers
// NFO Diagnostics - opens the full NFO settings page
menuElement.querySelector('[data-action="nfo-diagnostics"]').addEventListener('click', function() {
// Anime Settings - opens the per-anime settings page
menuElement.querySelector('[data-action="anime-settings"]').addEventListener('click', function() {
// Capture the key BEFORE hide() clears it
const key = currentSeriesKey;
hide();
// Navigate to NFO settings page with this series selected
window.location.href = '/settings/nfo?key=' + encodeURIComponent(currentSeriesKey);
// Navigate to anime settings page with this series selected
window.location.href = '/anime/settings?key=' + encodeURIComponent(key);
});
}

View File

@@ -0,0 +1,646 @@
/**
* AniWorld - Anime Settings Page Manager
*
* Handles the per-anime settings page reached via the right-click
* context menu. Loads the current settings via
* GET /api/anime/{key}/settings and saves changes via
* PUT /api/anime/{key}/settings.
*
* Public API:
* - init() : bind DOM events and start initial load
* - loadSeries(key) : fetch settings for a series key
* - saveSettings(opts) : PUT settings, opts.applyToNfo / opts.renameDisk
* - regenerateNfo() : POST regenerate-nfo endpoint
* - validateField(name, value) : client-side validation, returns error string or null
* - populateForm(data) : fill the form from a payload
* - showSaveSuccess(msg) : success toast
* - showError(msg) : error toast
*
* Dependencies: shared/constants.js, shared/auth.js, shared/api-client.js,
* shared/ui-utils.js
*/
var AniWorld = window.AniWorld || {};
AniWorld.AnimeSettingsManager = (function () {
'use strict';
// API paths (kept in sync with constants.js)
const API_BASE = '/api/anime';
const API_NFO_BASE = '/api/nfo';
// Page state
let currentKey = null;
let currentData = null;
let originalData = null;
let elements = null;
/**
* Initialize the page — bind events and start the initial load.
*/
function init() {
ensureElements();
bindEvents();
// Read ?key=... from the URL
const url = new URL(window.location.href);
currentKey = url.searchParams.get('key');
if (currentKey) {
loadSeries(currentKey);
} else {
showNoKey();
populateSeriesSelect();
}
}
/**
* Cache the DOM elements we'll touch repeatedly.
* Idempotent — safe to call from public functions that need elements.
*/
function ensureElements() {
if (elements) return;
const ids = [
'no-key-section', 'loading-section', 'error-section',
'settings-section', 'series-select', 'load-series-btn',
'retry-btn', 'error-message', 'series-display-name',
'badge-loading-status', 'badge-has-nfo', 'badge-episode-counts',
'overview-key', 'overview-year', 'overview-loading-status',
'overview-episode-count', 'overview-missing-count',
'overview-nfo-created', 'overview-nfo-updated', 'overview-nfo-path',
'field-name', 'field-folder', 'field-tmdb-id', 'field-tvdb-id',
'field-site', 'hint-name', 'hint-folder', 'hint-tmdb-id',
'hint-tvdb-id', 'hint-site',
'save-db-btn', 'save-db-nfo-btn', 'reset-btn',
'rename-disk-toggle',
'regenerate-nfo-btn', 'view-nfo-btn', 'nfo-content',
];
const map = {};
ids.forEach(function (id) {
map[id] = document.getElementById(id);
});
elements = map;
}
/**
* Cache the DOM elements we'll touch repeatedly.
* @deprecated Use ensureElements() instead.
*/
function cacheElements() {
ensureElements();
}
/**
* Wire up click handlers and escape-key dismissal.
*/
function bindEvents() {
if (elements['load-series-btn']) {
elements['load-series-btn'].addEventListener('click', function () {
const v = elements['series-select'].value;
if (v) {
window.location.href = '/anime/settings?key=' +
encodeURIComponent(v);
}
});
}
if (elements['retry-btn']) {
elements['retry-btn'].addEventListener('click', function () {
if (currentKey) {
loadSeries(currentKey);
} else {
showNoKey();
}
});
}
if (elements['save-db-btn']) {
elements['save-db-btn'].addEventListener('click', function () {
saveSettings({ applyToNfo: false });
});
}
if (elements['save-db-nfo-btn']) {
elements['save-db-nfo-btn'].addEventListener('click', function () {
saveSettings({ applyToNfo: true });
});
}
if (elements['reset-btn']) {
elements['reset-btn'].addEventListener('click', function () {
if (originalData) {
populateForm(originalData);
clearValidationHints();
}
});
}
if (elements['regenerate-nfo-btn']) {
elements['regenerate-nfo-btn'].addEventListener('click',
regenerateNfo);
}
if (elements['view-nfo-btn']) {
elements['view-nfo-btn'].addEventListener('click', viewNfoContent);
}
}
/**
* Fetch the AnimeSettingsResponse for a series and populate the page.
*
* @param {string} key - Series unique key
*/
async function loadSeries(key) {
ensureElements();
if (!key) {
showNoKey();
return;
}
currentKey = key;
showLoading();
try {
const token = AniWorld.Auth && AniWorld.Auth.getToken
? AniWorld.Auth.getToken() : null;
const headers = token
? { 'Authorization': 'Bearer ' + token }
: {};
const resp = await fetch(
API_BASE + '/' + encodeURIComponent(key) + '/settings',
{ headers: headers, method: 'GET' }
);
if (resp.status === 401) {
showError('Not authenticated — please log in again.');
showErrorState('Authentication required.');
return;
}
if (resp.status === 404) {
showErrorState('Series not found: ' + key);
return;
}
if (!resp.ok) {
const text = await resp.text();
throw new Error('HTTP ' + resp.status + ': ' + text);
}
const data = await resp.json();
currentData = data;
// Deep clone for original-data reset
originalData = JSON.parse(JSON.stringify(data));
populateForm(data);
showSettings();
} catch (err) {
console.error('Failed to load series settings:', err);
showErrorState(err && err.message ? err.message : String(err));
}
}
/**
* Save the current form contents via PUT /api/anime/{key}/settings.
*
* @param {Object} opts
* @param {boolean} opts.applyToNfo - Regenerate tvshow.nfo after save
* @param {boolean} [opts.renameDisk] - Also rename folder on disk
*/
async function saveSettings(opts) {
ensureElements();
if (!currentKey) {
showError('No series selected.');
return;
}
opts = opts || {};
const renameDisk = !!(elements['rename-disk-toggle'] &&
elements['rename-disk-toggle'].checked);
const payload = collectFormPayload();
const validationError = validatePayload(payload);
if (validationError) {
showError(validationError);
return;
}
payload.apply_to_nfo = !!opts.applyToNfo;
payload.rename_disk = renameDisk && payload.folder !== undefined &&
payload.folder !== (currentData && currentData.folder);
try {
const token = AniWorld.Auth && AniWorld.Auth.getToken
? AniWorld.Auth.getToken() : null;
const headers = { 'Content-Type': 'application/json' };
if (token) {
headers['Authorization'] = 'Bearer ' + token;
}
const resp = await fetch(
API_BASE + '/' + encodeURIComponent(currentKey) + '/settings',
{
headers: headers,
method: 'PUT',
body: JSON.stringify(payload),
}
);
if (resp.status === 401) {
showError('Not authenticated — please log in again.');
return;
}
if (resp.status === 422) {
const body = await resp.json().catch(function () { return {}; });
showError('Validation failed: ' + (body.detail || resp.status));
return;
}
if (!resp.ok) {
const text = await resp.text();
throw new Error('HTTP ' + resp.status + ': ' + text);
}
const data = await resp.json();
currentData = data;
originalData = JSON.parse(JSON.stringify(data));
populateForm(data);
if (opts.applyToNfo) {
showSaveSuccess('Settings saved and tvshow.nfo regenerated.');
} else {
showSaveSuccess('Settings saved to database.');
}
} catch (err) {
console.error('Failed to save settings:', err);
showError('Save failed: ' + (err && err.message ? err.message : err));
}
}
/**
* Call POST /api/anime/{key}/regenerate-nfo to regenerate tvshow.nfo.
*/
async function regenerateNfo() {
ensureElements();
if (!currentKey) {
showError('No series selected.');
return;
}
try {
const token = AniWorld.Auth && AniWorld.Auth.getToken
? AniWorld.Auth.getToken() : null;
const headers = {};
if (token) {
headers['Authorization'] = 'Bearer ' + token;
}
const resp = await fetch(
API_BASE + '/' + encodeURIComponent(currentKey) +
'/regenerate-nfo',
{ headers: headers, method: 'POST' }
);
if (resp.status === 400) {
const body = await resp.json().catch(function () { return {}; });
showError('Cannot regenerate: ' + (body.detail || resp.status));
return;
}
if (resp.status === 404) {
showError('Series not found.');
return;
}
if (!resp.ok) {
const text = await resp.text();
throw new Error('HTTP ' + resp.status + ': ' + text);
}
const data = await resp.json();
showSaveSuccess(data.message || 'NFO regenerated.');
// Refresh data so the NFO badges update
loadSeries(currentKey);
} catch (err) {
console.error('NFO regeneration failed:', err);
showError('Regenerate failed: ' +
(err && err.message ? err.message : err));
}
}
/**
* Fetch and display the raw tvshow.nfo XML in a <pre>.
*/
async function viewNfoContent() {
if (!currentKey) {
showError('No series selected.');
return;
}
try {
const token = AniWorld.Auth && AniWorld.Auth.getToken
? AniWorld.Auth.getToken() : null;
const headers = { 'Accept': 'application/json' };
if (token) {
headers['Authorization'] = 'Bearer ' + token;
}
const resp = await fetch(
API_NFO_BASE + '/' + encodeURIComponent(currentKey) + '/content',
{ headers: headers, method: 'GET' }
);
if (!resp.ok) {
const text = await resp.text();
throw new Error('HTTP ' + resp.status + ': ' + text);
}
const data = await resp.json();
const pre = elements['nfo-content'];
if (pre) {
pre.textContent = data.content || JSON.stringify(data, null, 2);
pre.classList.remove('hidden');
}
} catch (err) {
console.error('Failed to fetch NFO content:', err);
showError('Could not fetch NFO content: ' +
(err && err.message ? err.message : err));
}
}
/**
* Validate a single field. Returns null if valid, or an error message.
*
* @param {string} name Field name (name, folder, tmdb_id, tvdb_id, site)
* @param {*} value Value from the form
* @returns {string|null}
*/
function validateField(name, value) {
switch (name) {
case 'name':
if (value === '' || value == null) {
return 'Name cannot be empty.';
}
if (typeof value === 'string' && value.length > 500) {
return 'Name exceeds 500 characters.';
}
return null;
case 'folder':
if (value === '' || value == null) {
return 'Folder cannot be empty.';
}
if (typeof value === 'string' && /\.\./.test(value)) {
return 'Folder name cannot contain ".." (path traversal).';
}
if (typeof value === 'string' && /[<>:"|?*\x00]/.test(value)) {
return 'Folder name contains invalid characters.';
}
return null;
case 'tmdb_id':
if (value === '' || value == null || value === undefined) {
return null; // optional
}
const tmdb = Number(value);
if (!Number.isFinite(tmdb) || !Number.isInteger(tmdb)) {
return 'TMDB ID must be an integer.';
}
if (tmdb <= 0) {
return 'TMDB ID must be a positive integer.';
}
if (tmdb > 9999999999) {
return 'TMDB ID exceeds 10 digits.';
}
return null;
case 'tvdb_id':
if (value === '' || value == null || value === undefined) {
return null;
}
const tvdb = Number(value);
if (!Number.isFinite(tvdb) || !Number.isInteger(tvdb)) {
return 'TVDB ID must be an integer.';
}
if (tvdb <= 0) {
return 'TVDB ID must be a positive integer.';
}
if (tvdb > 9999999999) {
return 'TVDB ID exceeds 10 digits.';
}
return null;
case 'site':
if (value && typeof value === 'string' && value.length > 500) {
return 'Site URL exceeds 500 characters.';
}
return null;
default:
return null;
}
}
/**
* Validate the whole payload. Returns null if all fields valid, or the
* first error message encountered.
*
* @param {Object} payload
* @returns {string|null}
*/
function validatePayload(payload) {
const fields = ['name', 'folder', 'tmdb_id', 'tvdb_id', 'site'];
for (let i = 0; i < fields.length; i++) {
const name = fields[i];
if (payload[name] === undefined) continue;
const err = validateField(name, payload[name]);
if (err) return name + ': ' + err;
}
return null;
}
/**
* Populate the form from a settings payload.
*
* @param {Object} data AnimeSettingsResponse dict
*/
function populateForm(data) {
ensureElements();
if (!data) return;
// Overview
setText(elements['series-display-name'], data.name || '(unnamed)');
setText(elements['overview-key'], data.key || '—');
setText(elements['overview-year'], data.year || '—');
setText(elements['overview-loading-status'],
data.loading_status || '—');
setText(elements['overview-episode-count'],
data.episode_count != null ? String(data.episode_count) : '—');
setText(elements['overview-missing-count'],
data.missing_episode_count != null
? String(data.missing_episode_count) : '—');
setText(elements['overview-nfo-created'],
data.nfo_created_at || '—');
setText(elements['overview-nfo-updated'],
data.nfo_updated_at || '—');
setText(elements['overview-nfo-path'], data.nfo_path || '—');
// Badges
const lstatus = elements['badge-loading-status'];
if (lstatus) {
lstatus.textContent = 'Loading: ' + (data.loading_status || '—');
lstatus.className = 'status-badge ' +
(data.loading_status === 'completed'
? 'status-complete'
: data.loading_status === 'failed'
? 'status-failed'
: 'status-pending');
}
const nfoBadge = elements['badge-has-nfo'];
if (nfoBadge) {
nfoBadge.textContent = data.has_nfo ? 'NFO ✓' : 'NFO ✗';
nfoBadge.className = 'status-badge ' +
(data.has_nfo ? 'status-complete' : 'status-incomplete');
}
const epBadge = elements['badge-episode-counts'];
if (epBadge) {
epBadge.textContent =
(data.missing_episode_count || 0) + ' / ' +
(data.episode_count || 0) + ' missing';
epBadge.className = 'status-badge';
}
// Editable inputs
setValue(elements['field-name'], data.name || '');
setValue(elements['field-folder'], data.folder || '');
setValue(elements['field-tmdb-id'],
data.tmdb_id != null ? data.tmdb_id : '');
setValue(elements['field-tvdb-id'],
data.tvdb_id != null ? data.tvdb_id : '');
setValue(elements['field-site'], data.site || '');
clearValidationHints();
}
/**
* Collect current form values into a partial payload (omits empty
* string / null fields so the server treats them as no-change).
*/
function collectFormPayload() {
const payload = {};
const setIfPresent = function (key, raw) {
if (raw === undefined || raw === null) return;
const trimmed = typeof raw === 'string' ? raw.trim() : raw;
if (trimmed === '' || trimmed === null) return;
payload[key] = typeof raw === 'string' ? trimmed : raw;
};
setIfPresent('name', elements['field-name'].value);
setIfPresent('folder', elements['field-folder'].value);
setIfPresent('tmdb_id', elements['field-tmdb-id'].value);
setIfPresent('tvdb_id', elements['field-tvdb-id'].value);
setIfPresent('site', elements['field-site'].value);
return payload;
}
/**
* Populate the series-select dropdown with options for keys without
* ?key=... in the URL.
*/
async function populateSeriesSelect() {
const select = elements['series-select'];
if (!select) return;
select.innerHTML = '<option value="">Loading…</option>';
try {
const token = AniWorld.Auth && AniWorld.Auth.getToken
? AniWorld.Auth.getToken() : null;
const headers = token
? { 'Authorization': 'Bearer ' + token }
: {};
const resp = await fetch(API_BASE + '?per_page=500', {
headers: headers, method: 'GET',
});
if (!resp.ok) {
select.innerHTML = '<option value="">Failed to load series</option>';
return;
}
const list = await resp.json();
select.innerHTML = '<option value="">Select a series…</option>' +
list.map(function (s) {
return '<option value="' + escapeHtml(s.key) + '">' +
escapeHtml(s.name || s.key) + '</option>';
}).join('');
} catch (err) {
console.error('Failed to populate series select:', err);
select.innerHTML = '<option value="">Failed to load series</option>';
}
}
/**
* Show a success toast via shared UI utilities.
*/
function showSaveSuccess(msg) {
if (AniWorld.UiUtils && AniWorld.UiUtils.showToast) {
AniWorld.UiUtils.showToast(msg, 'success');
} else {
console.info('[AnimeSettings] ' + msg);
}
}
/**
* Show an error toast via shared UI utilities.
*/
function showError(msg) {
if (AniWorld.UiUtils && AniWorld.UiUtils.showToast) {
AniWorld.UiUtils.showToast(msg, 'error');
} else {
console.error('[AnimeSettings] ' + msg);
}
}
// View-state helpers --------------------------------------------------
function showLoading() {
showOnly('loading-section');
}
function showSettings() {
showOnly('settings-section');
}
function showNoKey() {
showOnly('no-key-section');
}
function showErrorState(msg) {
showOnly('error-section');
if (elements['error-message']) {
elements['error-message'].textContent = msg || 'Unknown error.';
}
}
function showOnly(id) {
const sections = ['no-key-section', 'loading-section',
'error-section', 'settings-section'];
sections.forEach(function (s) {
const el = document.getElementById(s);
if (!el) return;
if (s === id) {
el.classList.remove('hidden');
} else {
el.classList.add('hidden');
}
});
}
function clearValidationHints() {
['hint-name', 'hint-folder', 'hint-tmdb-id',
'hint-tvdb-id', 'hint-site'].forEach(function (id) {
const el = elements[id];
if (el) {
el.textContent = '';
el.classList.remove('hint-error');
}
});
}
function setText(el, text) {
if (el) el.textContent = text;
}
function setValue(el, text) {
if (el) el.value = text;
}
function escapeHtml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Public API ----------------------------------------------------------
return {
init: init,
loadSeries: loadSeries,
saveSettings: saveSettings,
regenerateNfo: regenerateNfo,
validateField: validateField,
populateForm: populateForm,
showSaveSuccess: showSaveSuccess,
showError: showError,
};
})();
// Bootstrap on DOMContentLoaded — only register the listener.
// Tests call AnimeSettingsManager.init() explicitly after seeding the DOM.
if (typeof document !== 'undefined') {
document.addEventListener('DOMContentLoaded', function () {
if (AniWorld.AnimeSettingsManager && AniWorld.AnimeSettingsManager.init) {
AniWorld.AnimeSettingsManager.init();
}
});
}

View File

@@ -1,804 +0,0 @@
/**
* AniWorld - NFO Settings Page
*
* Handles NFO diagnostics, repair operations, and settings
* for the dedicated NFO settings page.
*/
(function() {
'use strict';
const API = {
NFO_DIAGNOSTICS: '/api/nfo',
NFO_REPAIR: '/api/nfo',
NFO_NEEDS_REPAIR: '/api/nfo/needs-repair',
NFO_BATCH_REPAIR: '/api/nfo/batch/repair',
CONFIG: '/api/config',
ANIME_LIST: '/api/anime'
};
// State
let allDiagnostics = [];
let seriesNeedingRepair = [];
let selectedForRepair = new Set();
// DOM Elements
const elements = {
// Stats
totalSeries: document.getElementById('total-series'),
completeSeries: document.getElementById('complete-series'),
incompleteSeries: document.getElementById('incomplete-series'),
missingSeries: document.getElementById('missing-series'),
// Tabs
tabButtons: document.querySelectorAll('.nfo-tab'),
tabPanels: document.querySelectorAll('.tab-panel'),
// Overview
refreshOverview: document.getElementById('btn-refresh-overview'),
// Diagnostics
diagnosticsSearch: document.getElementById('diagnostics-search'),
filterStatus: document.getElementById('filter-status'),
refreshDiagnostics: document.getElementById('btn-refresh-diagnostics'),
diagnosticsTableBody: document.getElementById('diagnostics-table-body'),
// Repair
selectAllRepair: document.getElementById('btn-select-all-repair'),
clearSelection: document.getElementById('btn-clear-selection'),
repairTableBody: document.getElementById('repair-table-body'),
selectAllRepairCheckbox: document.getElementById('select-all-repair'),
selectedCount: document.getElementById('selected-count'),
batchRepair: document.getElementById('btn-batch-repair'),
// Settings
tmdbApiKey: document.getElementById('tmdb-api-key'),
btnTestTmdb: document.getElementById('btn-test-tmdb'),
tmdbStatus: document.getElementById('tmdb-status'),
nfoAutoCreate: document.getElementById('nfo-auto-create'),
nfoUpdateOnScan: document.getElementById('nfo-update-on-scan'),
nfoDownloadPoster: document.getElementById('nfo-download-poster'),
nfoDownloadFanart: document.getElementById('nfo-download-fanart'),
nfoDownloadLogo: document.getElementById('nfo-download-logo'),
saveNfoSettings: document.getElementById('btn-save-nfo-settings'),
// General
loadingOverlay: document.getElementById('loading-overlay'),
toastContainer: document.getElementById('toast-container')
};
/**
* Initialize the page
*/
function init() {
setupTabNavigation();
setupEventListeners();
loadInitialData();
initTheme();
initAuth();
}
/**
* Setup tab navigation
*/
function setupTabNavigation() {
elements.tabButtons.forEach(button => {
button.addEventListener('click', () => {
const tabId = button.dataset.tab;
switchTab(tabId);
});
});
}
/**
* Switch to a specific tab
*/
function switchTab(tabId) {
elements.tabButtons.forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabId);
});
elements.tabPanels.forEach(panel => {
panel.classList.toggle('active', panel.id === `tab-${tabId}`);
});
// Load data for the active tab
if (tabId === 'overview') {
loadOverviewData();
} else if (tabId === 'diagnostics') {
loadDiagnostics();
} else if (tabId === 'repair') {
loadRepairList();
} else if (tabId === 'settings') {
loadSettings();
}
}
/**
* Setup event listeners
*/
function setupEventListeners() {
// Overview
elements.refreshOverview?.addEventListener('click', loadOverviewData);
// Diagnostics
elements.diagnosticsSearch?.addEventListener('input', filterDiagnostics);
elements.filterStatus?.addEventListener('change', filterDiagnostics);
elements.refreshDiagnostics?.addEventListener('click', loadDiagnostics);
// Repair
elements.selectAllRepair?.addEventListener('click', selectAllForRepair);
elements.clearSelection?.addEventListener('click', clearRepairSelection);
elements.selectAllRepairCheckbox?.addEventListener('change', toggleSelectAllRepair);
elements.batchRepair?.addEventListener('click', performBatchRepair);
// Settings
elements.btnTestTmdb?.addEventListener('click', testTmdbConnection);
elements.saveNfoSettings?.addEventListener('click', saveSettings);
}
/**
* Load initial data
*/
function loadInitialData() {
loadOverviewData();
}
/**
* Load overview data (stats)
*/
async function loadOverviewData() {
try {
// Get needs-repair data which includes all series diagnostics
const response = await fetch(API.NFO_NEEDS_REPAIR, {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Failed to load overview data');
}
const data = await response.json();
allDiagnostics = data.series || [];
// Calculate stats
const total = allDiagnostics.length;
const missing = allDiagnostics.filter(s => !s.has_nfo).length;
const incomplete = allDiagnostics.filter(s => s.has_nfo && s.missing_tags && s.missing_tags.length > 0).length;
const complete = total - incomplete - missing;
updateStats({
total,
complete,
incomplete,
missing
});
// Also update repair list for batch repair
seriesNeedingRepair = allDiagnostics.filter(s => !s.has_nfo || (s.missing_tags && s.missing_tags.length > 0));
} catch (error) {
console.error('Error loading overview:', error);
showToast('Failed to load overview data', 'error');
}
}
/**
* Update statistics display
*/
function updateStats(stats) {
if (elements.totalSeries) elements.totalSeries.textContent = stats.total;
if (elements.completeSeries) elements.completeSeries.textContent = stats.complete;
if (elements.incompleteSeries) elements.incompleteSeries.textContent = stats.incomplete;
if (elements.missingSeries) elements.missingSeries.textContent = stats.missing;
}
/**
* Load diagnostics list
*/
async function loadDiagnostics() {
if (!elements.diagnosticsTableBody) return;
showLoading(elements.diagnosticsTableBody, 'Loading diagnostics...');
try {
const response = await fetch(API.NFO_NEEDS_REPAIR, {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Failed to load diagnostics');
}
const data = await response.json();
allDiagnostics = data.series || [];
renderDiagnosticsTable(allDiagnostics);
} catch (error) {
console.error('Error loading diagnostics:', error);
showToast('Failed to load diagnostics', 'error');
elements.diagnosticsTableBody.innerHTML = `
<tr>
<td colspan="5" class="error-message">
Failed to load diagnostics. Please try again.
</td>
</tr>
`;
}
}
/**
* Render diagnostics table
*/
function renderDiagnosticsTable(series) {
if (!elements.diagnosticsTableBody) return;
if (series.length === 0) {
elements.diagnosticsTableBody.innerHTML = `
<tr>
<td colspan="5" class="empty-message">
No series found.
</td>
</tr>
`;
return;
}
elements.diagnosticsTableBody.innerHTML = series.map(s => {
const status = getStatus(s);
const statusClass = status.toLowerCase();
const statusIcon = getStatusIcon(status);
const missingTags = s.missing_tags || [];
const missingTagsHtml = missingTags.length > 0
? missingTags.map(tag => `<span class="missing-tag-chip">${escapeHtml(tag)}</span>`).join('')
: '<span class="all-good">All present</span>';
const nfoPath = s.folder ? `${escapeHtml(s.folder)}/tvshow.nfo` : '-';
return `
<tr data-key="${escapeHtml(s.key)}">
<td class="name-cell">
<div class="series-name">${escapeHtml(s.name || s.key)}</div>
<div class="series-key">${escapeHtml(s.key)}</div>
</td>
<td>
<span class="status-badge ${statusClass}">
${statusIcon}
${status}
</span>
</td>
<td class="tags-cell">${missingTagsHtml}</td>
<td class="path-cell"><span class="nfo-path" title="${nfoPath}">${nfoPath}</span></td>
<td class="actions-cell">
<button class="btn btn-xs btn-secondary" onclick="NfoSettings.repairSingle('${escapeHtml(s.key)}')">
<i class="fas fa-wrench"></i> Repair
</button>
</td>
</tr>
`;
}).join('');
}
/**
* Get status for a series
*/
function getStatus(series) {
if (!series.has_nfo) return 'Missing';
if (series.missing_tags && series.missing_tags.length > 0) return 'Incomplete';
return 'Complete';
}
/**
* Get status icon
*/
function getStatusIcon(status) {
switch (status.toLowerCase()) {
case 'complete':
return '<i class="fas fa-check-circle"></i>';
case 'incomplete':
return '<i class="fas fa-exclamation-circle"></i>';
case 'missing':
return '<i class="fas fa-times-circle"></i>';
default:
return '';
}
}
/**
* Filter diagnostics based on search and status
*/
function filterDiagnostics() {
const searchTerm = (elements.diagnosticsSearch?.value || '').toLowerCase();
const statusFilter = elements.filterStatus?.value || 'all';
let filtered = allDiagnostics;
// Filter by search term
if (searchTerm) {
filtered = filtered.filter(s =>
(s.name || '').toLowerCase().includes(searchTerm) ||
s.key.toLowerCase().includes(searchTerm)
);
}
// Filter by status
if (statusFilter !== 'all') {
filtered = filtered.filter(s => {
const status = getStatus(s).toLowerCase();
return status === statusFilter;
});
}
renderDiagnosticsTable(filtered);
}
/**
* Load repair list
*/
async function loadRepairList() {
if (!elements.repairTableBody) return;
showLoading(elements.repairTableBody, 'Loading series needing repair...');
try {
const response = await fetch(API.NFO_NEEDS_REPAIR, {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Failed to load repair list');
}
const data = await response.json();
seriesNeedingRepair = (data.series || []).filter(s =>
!s.has_nfo || (s.missing_tags && s.missing_tags.length > 0)
);
renderRepairTable(seriesNeedingRepair);
} catch (error) {
console.error('Error loading repair list:', error);
showToast('Failed to load repair list', 'error');
elements.repairTableBody.innerHTML = `
<tr>
<td colspan="5" class="error-message">
Failed to load repair list. Please try again.
</td>
</tr>
`;
}
}
/**
* Render repair table
*/
function renderRepairTable(series) {
if (!elements.repairTableBody) return;
if (series.length === 0) {
elements.repairTableBody.innerHTML = `
<tr>
<td colspan="5" class="empty-message">
All series have complete NFO files!
</td>
</tr>
`;
return;
}
elements.repairTableBody.innerHTML = series.map(s => {
const status = getStatus(s);
const statusClass = status.toLowerCase();
const isSelected = selectedForRepair.has(s.key);
const hasTmdbId = s.tmdb_id && s.tmdb_id > 0;
return `
<tr data-key="${escapeHtml(s.key)}">
<td class="checkbox-col">
<input type="checkbox" class="repair-checkbox"
data-key="${escapeHtml(s.key)}"
${isSelected ? 'checked' : ''}>
</td>
<td class="name-cell">
<div class="series-name">${escapeHtml(s.name || s.key)}</div>
<div class="series-key">${escapeHtml(s.key)}</div>
</td>
<td>
<span class="status-badge ${statusClass}">
${status}
</span>
</td>
<td class="tmdb-cell">
${hasTmdbId ? `<span class="tmdb-id">${s.tmdb_id}</span>` : '<span class="no-tmdb">No TMDB ID</span>'}
</td>
<td class="priority-cell">
${!s.has_nfo ? '<span class="priority high">High</span>' : '<span class="priority normal">Normal</span>'}
</td>
</tr>
`;
}).join('');
// Attach checkbox listeners
elements.repairTableBody.querySelectorAll('.repair-checkbox').forEach(cb => {
cb.addEventListener('change', (e) => {
const key = e.target.dataset.key;
if (e.target.checked) {
selectedForRepair.add(key);
} else {
selectedForRepair.delete(key);
}
updateSelectedCount();
});
});
updateSelectedCount();
}
/**
* Select all series for repair
*/
function selectAllForRepair() {
seriesNeedingRepair.forEach(s => selectedForRepair.add(s.key));
updateRepairCheckboxes();
updateSelectedCount();
}
/**
* Clear repair selection
*/
function clearRepairSelection() {
selectedForRepair.clear();
updateRepairCheckboxes();
updateSelectedCount();
}
/**
* Toggle select all repair checkboxes
*/
function toggleSelectAllRepair(e) {
if (e.target.checked) {
selectAllForRepair();
} else {
clearRepairSelection();
}
}
/**
* Update repair checkboxes based on selection
*/
function updateRepairCheckboxes() {
if (!elements.repairTableBody) return;
elements.repairTableBody.querySelectorAll('.repair-checkbox').forEach(cb => {
cb.checked = selectedForRepair.has(cb.dataset.key);
});
}
/**
* Update selected count display
*/
function updateSelectedCount() {
if (elements.selectedCount) {
elements.selectedCount.textContent = selectedForRepair.size;
}
if (elements.batchRepair) {
elements.batchRepair.disabled = selectedForRepair.size === 0;
}
}
/**
* Perform batch repair
*/
async function performBatchRepair() {
if (selectedForRepair.size === 0) {
showToast('No series selected for repair', 'warning');
return;
}
const keys = Array.from(selectedForRepair);
showLoading(true);
try {
const response = await fetch(API.NFO_BATCH_REPAIR, {
method: 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify(keys)
});
if (!response.ok) {
throw new Error('Batch repair failed');
}
const result = await response.json();
showToast(
`Repair complete: ${result.success} succeeded, ${result.failed} failed`,
result.failed > 0 ? 'warning' : 'success'
);
// Clear selection and reload
selectedForRepair.clear();
updateSelectedCount();
loadRepairList();
loadOverviewData();
} catch (error) {
console.error('Error performing batch repair:', error);
showToast('Batch repair failed', 'error');
} finally {
showLoading(false);
}
}
/**
* Repair a single series (global function for onclick)
*/
async function repairSingle(key) {
showLoading(true);
try {
const response = await fetch(`${API.NFO_REPAIR}/${encodeURIComponent(key)}/repair`, {
method: 'POST',
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Repair failed');
}
const result = await response.json();
if (result.success) {
showToast(`Successfully repaired "${key}"`, 'success');
} else {
showToast(`Failed to repair "${key}": ${result.error || 'Unknown error'}`, 'error');
}
// Reload data
loadDiagnostics();
loadOverviewData();
} catch (error) {
console.error('Error repairing series:', error);
showToast(`Failed to repair "${key}"`, 'error');
} finally {
showLoading(false);
}
}
/**
* Make repairSingle available globally
*/
window.NfoSettings = { repairSingle };
/**
* Load settings
*/
async function loadSettings() {
try {
const response = await fetch(API.CONFIG, {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Failed to load settings');
}
const config = await response.json();
// Populate form fields
if (elements.tmdbApiKey) elements.tmdbApiKey.value = config.tmdb_api_key || '';
if (elements.nfoAutoCreate) elements.nfoAutoCreate.checked = config.nfo_auto_create || false;
if (elements.nfoUpdateOnScan) elements.nfoUpdateOnScan.checked = config.nfo_update_on_scan || false;
if (elements.nfoDownloadPoster) elements.nfoDownloadPoster.checked = config.nfo_download_poster !== false;
if (elements.nfoDownloadFanart) elements.nfoDownloadFanart.checked = config.nfo_download_fanart !== false;
if (elements.nfoDownloadLogo) elements.nfoDownloadLogo.checked = config.nfo_download_logo !== false;
} catch (error) {
console.error('Error loading settings:', error);
showToast('Failed to load settings', 'error');
}
}
/**
* Save settings
*/
async function saveSettings() {
const payload = {
tmdb_api_key: elements.tmdbApiKey?.value || '',
nfo_auto_create: elements.nfoAutoCreate?.checked || false,
nfo_update_on_scan: elements.nfoUpdateOnScan?.checked || false,
nfo_download_poster: elements.nfoDownloadPoster?.checked || false,
nfo_download_fanart: elements.nfoDownloadFanart?.checked || false,
nfo_download_logo: elements.nfoDownloadLogo?.checked || false
};
showLoading(true);
try {
const response = await fetch(API.CONFIG, {
method: 'PUT',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error('Failed to save settings');
}
showToast('Settings saved successfully', 'success');
} catch (error) {
console.error('Error saving settings:', error);
showToast('Failed to save settings', 'error');
} finally {
showLoading(false);
}
}
/**
* Test TMDB connection
*/
async function testTmdbConnection() {
const apiKey = elements.tmdbApiKey?.value;
if (!apiKey) {
showTmdbStatus('Please enter an API key first', 'error');
return;
}
elements.btnTestTmdb.disabled = true;
elements.btnTestTmdb.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Testing...';
try {
const response = await fetch(`${API.NFO_DIAGNOSTICS}/validate?api_key=${encodeURIComponent(apiKey)}`, {
method: 'GET',
headers: getAuthHeaders()
});
if (response.ok) {
showTmdbStatus('Connection successful! TMDB API is working.', 'success');
} else {
const error = await response.json().catch(() => ({}));
showTmdbStatus(`Connection failed: ${error.detail || 'Invalid API key'}`, 'error');
}
} catch (error) {
console.error('Error testing TMDB connection:', error);
showTmdbStatus('Connection failed: Network error', 'error');
} finally {
elements.btnTestTmdb.disabled = false;
elements.btnTestTmdb.innerHTML = '<i class="fas fa-plug"></i> Test Connection';
}
}
/**
* Show TMDB status message
*/
function showTmdbStatus(message, type) {
if (!elements.tmdbStatus) return;
elements.tmdbStatus.textContent = message;
elements.tmdbStatus.className = `connection-status ${type}`;
elements.tmdbStatus.classList.remove('hidden');
}
// ========== Utility Functions ==========
/**
* Get authentication headers
*/
function getAuthHeaders() {
const headers = {
'Content-Type': 'application/json'
};
const token = localStorage.getItem('auth_token');
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
/**
* Show loading overlay
*/
function showLoading(show) {
if (elements.loadingOverlay) {
elements.loadingOverlay.classList.toggle('hidden', !show);
}
}
/**
* Show loading state in a container
*/
function showLoading(container, message) {
if (!container) return;
container.innerHTML = `
<tr class="loading-row">
<td colspan="5">
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i>
<span>${message}</span>
</div>
</td>
</tr>
`;
}
/**
* Show toast notification
*/
function showToast(message, type = 'info') {
if (!elements.toastContainer) return;
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.innerHTML = `
<i class="toast-icon ${getToastIcon(type)}"></i>
<span class="toast-message">${escapeHtml(message)}</span>
<button class="toast-close" onclick="this.parentElement.remove()">
<i class="fas fa-times"></i>
</button>
`;
elements.toastContainer.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
if (toast.parentElement) {
toast.remove();
}
}, 5000);
}
/**
* Get toast icon class
*/
function getToastIcon(type) {
switch (type) {
case 'success': return 'fas fa-check-circle';
case 'error': return 'fas fa-exclamation-circle';
case 'warning': return 'fas fa-exclamation-triangle';
default: return 'fas fa-info-circle';
}
}
/**
* Escape HTML
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ========== Initialize ==========
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Also expose functions for theme and auth that might be needed
function initTheme() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle && AniWorld && AniWorld.Theme) {
themeToggle.addEventListener('click', () => AniWorld.Theme.toggle());
}
}
function initAuth() {
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn && AniWorld && AniWorld.Auth) {
logoutBtn.addEventListener('click', () => AniWorld.Auth.logout());
}
}
})();