/** * 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
.
     */
    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 = '';
        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 = '';
                return;
            }
            const list = await resp.json();
            select.innerHTML = '' +
                list.map(function (s) {
                    return '';
                }).join('');
        } catch (err) {
            console.error('Failed to populate series select:', err);
            select.innerHTML = '';
        }
    }

    /**
     * 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, ''');
    }

    // 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();
        }
    });
}