650 lines
23 KiB
JavaScript
650 lines
23 KiB
JavaScript
/**
|
|
* 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.');
|
|
// Redirect to login, preserving the intended destination
|
|
setTimeout(function() {
|
|
window.location.href = '/login?next=' + encodeURIComponent(window.location.href);
|
|
}, 1500);
|
|
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.UI && AniWorld.UI.showToast) {
|
|
AniWorld.UI.showToast(msg, 'success');
|
|
} else {
|
|
console.info('[AnimeSettings] ' + msg);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show an error toast via shared UI utilities.
|
|
*/
|
|
function showError(msg) {
|
|
if (AniWorld.UI && AniWorld.UI.showToast) {
|
|
AniWorld.UI.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();
|
|
}
|
|
});
|
|
} |