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:
646
src/server/web/static/js/pages/anime-settings.js
Normal file
646
src/server/web/static/js/pages/anime-settings.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user