feat(nfo): implement NFO diagnostics and repair
- Add NFO API endpoints: diagnostics, repair, validate, needs-repair - Create /settings/nfo page with full NFO management UI - Add NFO status section to edit modal with repair functionality - Add anime details API for edit modal pre-fill data - Fix auth test fixtures in test_nfo_diagnostics_repair.py Implements NFO diagnostics when editing anime series via right-click menu. Users can now check NFO status, see missing tags, and repair NFOs directly from the edit modal or the dedicated NFO settings page.
This commit is contained in:
@@ -12,6 +12,8 @@ var AniWorld = window.AniWorld || {};
|
||||
AniWorld.EditModal = (function() {
|
||||
'use strict';
|
||||
|
||||
const API = AniWorld.Constants ? AniWorld.Constants.API : {};
|
||||
|
||||
let modalElement = null;
|
||||
let originalData = null;
|
||||
let currentKey = null;
|
||||
@@ -34,13 +36,26 @@ AniWorld.EditModal = (function() {
|
||||
hideKeyWarning();
|
||||
|
||||
try {
|
||||
// Find series data from the local series list
|
||||
const seriesData = findSeriesData(seriesKey);
|
||||
// Try to find series data from the local series list first
|
||||
let seriesData = findSeriesData(seriesKey);
|
||||
|
||||
// If not found locally, fetch from API
|
||||
if (!seriesData) {
|
||||
seriesData = await fetchSeriesDetails(seriesKey);
|
||||
}
|
||||
|
||||
originalData = {
|
||||
key: seriesKey,
|
||||
tmdb_id: seriesData ? seriesData.tmdb_id : null,
|
||||
tvdb_id: seriesData ? seriesData.tvdb_id : null,
|
||||
name: seriesData ? seriesData.name : seriesKey,
|
||||
year: seriesData ? seriesData.year : null,
|
||||
status: seriesData ? seriesData.status : null,
|
||||
genres: seriesData ? (seriesData.genres || []) : [],
|
||||
studio: seriesData ? (seriesData.studio || []) : [],
|
||||
premiered: seriesData ? seriesData.premiered : null,
|
||||
rating: seriesData ? seriesData.rating : null,
|
||||
rating_votes: seriesData ? seriesData.rating_votes : null,
|
||||
};
|
||||
|
||||
// Populate form fields
|
||||
@@ -48,6 +63,12 @@ AniWorld.EditModal = (function() {
|
||||
setFieldValue('edit-tmdb-id', originalData.tmdb_id || '');
|
||||
setFieldValue('edit-tvdb-id', originalData.tvdb_id || '');
|
||||
|
||||
// Populate display fields
|
||||
populateDisplayFields(originalData);
|
||||
|
||||
// Show/hide TMDB fetch button based on whether TMDB ID exists
|
||||
updateTmdbFetchButtonState();
|
||||
|
||||
// Load NFO diagnostics
|
||||
await loadDiagnostics(seriesKey);
|
||||
|
||||
@@ -62,6 +83,128 @@ AniWorld.EditModal = (function() {
|
||||
attachListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch series details from the API.
|
||||
* @param {string} key - Series key
|
||||
* @returns {Promise<Object|null>} Series details or null on error
|
||||
*/
|
||||
async function fetchSeriesDetails(key) {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get(
|
||||
'/api/anime/' + encodeURIComponent(key) + '/details'
|
||||
);
|
||||
if (response && response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch series details:', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate display-only fields in the modal.
|
||||
* @param {Object} data - Series data
|
||||
*/
|
||||
function populateDisplayFields(data) {
|
||||
const fields = [
|
||||
{ id: 'edit-name', value: data.name || '' },
|
||||
{ id: 'edit-year', value: data.year || '' },
|
||||
{ id: 'edit-status', value: data.status || '' },
|
||||
{ id: 'edit-genres', value: (data.genres || []).join(', ') },
|
||||
{ id: 'edit-studio', value: (data.studio || []).join(', ') },
|
||||
{ id: 'edit-premiered', value: data.premiered || '' },
|
||||
{ id: 'edit-rating', value: data.rating ? data.rating.toFixed(1) + '/10' : '' },
|
||||
];
|
||||
|
||||
fields.forEach(function(field) {
|
||||
const el = document.getElementById(field.id);
|
||||
if (el) el.textContent = field.value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the TMDB fetch button visibility.
|
||||
*/
|
||||
function updateTmdbFetchButtonState() {
|
||||
const btn = document.getElementById('btn-fetch-tmdb');
|
||||
const tmdbValue = getFieldValue('edit-tmdb-id').trim();
|
||||
|
||||
if (btn) {
|
||||
btn.style.display = tmdbValue ? 'none' : 'inline-flex';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch TMDB data for auto-fill.
|
||||
*/
|
||||
async function fetchTmdbData() {
|
||||
const btn = document.getElementById('btn-fetch-tmdb');
|
||||
const resultsContainer = document.getElementById('tmdb-search-results');
|
||||
if (!btn || !resultsContainer) return;
|
||||
|
||||
// Show loading state
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Searching...';
|
||||
resultsContainer.innerHTML = '';
|
||||
resultsContainer.style.display = 'block';
|
||||
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get(
|
||||
'/api/anime/' + encodeURIComponent(currentKey) + '/tmdb-search'
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
resultsContainer.innerHTML = '<p class="tmdb-error">Connection error</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 400) {
|
||||
resultsContainer.innerHTML = '<p class="tmdb-error">TMDB not configured</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
resultsContainer.innerHTML = '<p class="tmdb-error">Search failed</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await response.json();
|
||||
|
||||
if (results.length === 0) {
|
||||
resultsContainer.innerHTML = '<p class="tmdb-no-results">No matches found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Render results
|
||||
resultsContainer.innerHTML = results.slice(0, 5).map(function(r) {
|
||||
const year = r.year ? ' (' + r.year + ')' : '';
|
||||
const rating = r.vote_average ? ' ★' + r.vote_average.toFixed(1) : '';
|
||||
return '<div class="tmdb-result-item" data-tmdb-id="' + r.tmdb_id + '">' +
|
||||
'<span class="tmdb-result-title">' + escapeHtml(r.title) + year + rating + '</span>' +
|
||||
'<span class="tmdb-result-overview">' + escapeHtml(r.overview || '') + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
// Attach click handlers to results
|
||||
resultsContainer.querySelectorAll('.tmdb-result-item').forEach(function(item) {
|
||||
item.addEventListener('click', function() {
|
||||
const tmdbId = this.getAttribute('data-tmdb-id');
|
||||
setFieldValue('edit-tmdb-id', tmdbId);
|
||||
resultsContainer.innerHTML = '<p class="tmdb-selected">TMDB ID ' + tmdbId + ' selected</p>';
|
||||
updateTmdbFetchButtonState();
|
||||
});
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
resultsContainer.innerHTML = '<p class="tmdb-error">Search failed</p>';
|
||||
console.error('TMDB search error:', err);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-search"></i> Fetch from TMDB';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the edit modal and reset state.
|
||||
*/
|
||||
@@ -272,6 +415,7 @@ AniWorld.EditModal = (function() {
|
||||
function updateRepairButtonState() {
|
||||
const btn = document.getElementById('btn-repair-nfo');
|
||||
const hint = document.getElementById('repair-hint');
|
||||
const diagnosticsLink = document.getElementById('btn-open-nfo-diagnostics');
|
||||
const tmdbValue = getFieldValue('edit-tmdb-id').trim();
|
||||
|
||||
if (btn) {
|
||||
@@ -281,6 +425,11 @@ AniWorld.EditModal = (function() {
|
||||
if (hint) {
|
||||
hint.style.display = tmdbValue ? 'none' : 'block';
|
||||
}
|
||||
// Show link to full diagnostics page
|
||||
if (diagnosticsLink && currentKey) {
|
||||
diagnosticsLink.href = '/settings/nfo?key=' + encodeURIComponent(currentKey);
|
||||
diagnosticsLink.style.display = 'inline-flex';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
@@ -395,8 +544,10 @@ AniWorld.EditModal = (function() {
|
||||
const saveBtn = document.getElementById('btn-save-metadata');
|
||||
const cancelBtn = document.getElementById('btn-cancel-metadata');
|
||||
const repairBtn = document.getElementById('btn-repair-nfo');
|
||||
const fetchTmdbBtn = document.getElementById('btn-fetch-tmdb');
|
||||
const overlay = modalElement ? modalElement.querySelector('.modal-overlay') : null;
|
||||
const keyInput = document.getElementById('edit-key');
|
||||
const tmdbInput = document.getElementById('edit-tmdb-id');
|
||||
|
||||
if (saveBtn) {
|
||||
var saveFn = function() { save(); };
|
||||
@@ -416,6 +567,12 @@ AniWorld.EditModal = (function() {
|
||||
listeners.push({ el: repairBtn, event: 'click', fn: repairFn });
|
||||
}
|
||||
|
||||
if (fetchTmdbBtn) {
|
||||
var fetchTmdbFn = function() { fetchTmdbData(); };
|
||||
fetchTmdbBtn.addEventListener('click', fetchTmdbFn);
|
||||
listeners.push({ el: fetchTmdbBtn, event: 'click', fn: fetchTmdbFn });
|
||||
}
|
||||
|
||||
if (overlay) {
|
||||
var overlayFn = function() { close(); };
|
||||
overlay.addEventListener('click', overlayFn);
|
||||
@@ -432,6 +589,14 @@ AniWorld.EditModal = (function() {
|
||||
keyInput.addEventListener('input', keyFn);
|
||||
listeners.push({ el: keyInput, event: 'input', fn: keyFn });
|
||||
}
|
||||
|
||||
if (tmdbInput) {
|
||||
var tmdbFn = function() {
|
||||
updateTmdbFetchButtonState();
|
||||
};
|
||||
tmdbInput.addEventListener('input', tmdbFn);
|
||||
listeners.push({ el: tmdbInput, event: 'input', fn: tmdbFn });
|
||||
}
|
||||
}
|
||||
|
||||
function detachListeners() {
|
||||
|
||||
587
src/server/web/static/js/pages/nfo-settings.js
Normal file
587
src/server/web/static/js/pages/nfo-settings.js
Normal file
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* AniWorld - NFO Settings Page
|
||||
*
|
||||
* Diagnostics and repair interface for NFO metadata files.
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.NfoSettings = (function() {
|
||||
'use strict';
|
||||
|
||||
const API = AniWorld.Constants ? AniWorld.Constants.API : {};
|
||||
|
||||
// State
|
||||
let allSeries = [];
|
||||
let filteredSeries = [];
|
||||
let currentFilter = 'all';
|
||||
let currentSeries = null;
|
||||
let searchQuery = '';
|
||||
|
||||
// DOM Elements
|
||||
const elements = {
|
||||
seriesList: document.getElementById('series-list'),
|
||||
loadingState: document.getElementById('loading-state'),
|
||||
emptyState: document.getElementById('empty-state'),
|
||||
searchInput: document.getElementById('search-input'),
|
||||
detailPanel: document.getElementById('detail-panel'),
|
||||
panelTitle: document.getElementById('panel-title'),
|
||||
panelStatusBadge: document.getElementById('panel-status-badge'),
|
||||
panelNfoPath: document.getElementById('panel-nfo-path'),
|
||||
panelMissingTags: document.getElementById('panel-missing-tags'),
|
||||
missingTagsSection: document.getElementById('missing-tags-section'),
|
||||
nfoPreviewSection: document.getElementById('nfo-preview-section'),
|
||||
nfoPreviewContent: document.getElementById('nfo-preview-content'),
|
||||
btnScanAll: document.getElementById('btn-scan-all'),
|
||||
btnRepairAll: document.getElementById('btn-repair-all'),
|
||||
btnClosePanel: document.getElementById('btn-close-panel'),
|
||||
btnRepairSingle: document.getElementById('btn-repair-single'),
|
||||
btnValidate: document.getElementById('btn-validate'),
|
||||
btnViewNfo: document.getElementById('btn-view-nfo'),
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize the page
|
||||
*/
|
||||
async function init() {
|
||||
// Check auth first
|
||||
if (!AniWorld.Auth || !AniWorld.Auth.isAuthenticated()) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
attachEventListeners();
|
||||
await loadSeriesNeedingRepair();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach event listeners
|
||||
*/
|
||||
function attachEventListeners() {
|
||||
// Search
|
||||
if (elements.searchInput) {
|
||||
elements.searchInput.addEventListener('input', debounce(handleSearch, 300));
|
||||
}
|
||||
|
||||
// Filter buttons
|
||||
document.querySelectorAll('.filter-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
const filter = this.getAttribute('data-filter');
|
||||
setFilter(filter);
|
||||
});
|
||||
});
|
||||
|
||||
// Bulk actions
|
||||
if (elements.btnScanAll) {
|
||||
elements.btnScanAll.addEventListener('click', handleScanAll);
|
||||
}
|
||||
if (elements.btnRepairAll) {
|
||||
elements.btnRepairAll.addEventListener('click', handleRepairAll);
|
||||
}
|
||||
|
||||
// Panel close
|
||||
if (elements.btnClosePanel) {
|
||||
elements.btnClosePanel.addEventListener('click', closePanel);
|
||||
}
|
||||
|
||||
// Panel overlay click
|
||||
const overlay = elements.detailPanel?.querySelector('.panel-overlay');
|
||||
if (overlay) {
|
||||
overlay.addEventListener('click', closePanel);
|
||||
}
|
||||
|
||||
// Single series actions
|
||||
if (elements.btnRepairSingle) {
|
||||
elements.btnRepairSingle.addEventListener('click', handleRepairSingle);
|
||||
}
|
||||
if (elements.btnValidate) {
|
||||
elements.btnValidate.addEventListener('click', handleValidate);
|
||||
}
|
||||
if (elements.btnViewNfo) {
|
||||
elements.btnViewNfo.addEventListener('click', handleViewNfo);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all series needing repair
|
||||
*/
|
||||
async function loadSeriesNeedingRepair() {
|
||||
showLoading(true);
|
||||
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get(API.NFO_NEEDS_REPAIR || '/api/nfo/needs-repair');
|
||||
if (!response || !response.ok) {
|
||||
throw new Error('Failed to load NFO status');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
processNeedsRepairResponse(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load series:', err);
|
||||
AniWorld.UI.showToast('Failed to load series data', 'error');
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the needs-repair response
|
||||
*/
|
||||
function processNeedsRepairResponse(data) {
|
||||
allSeries = data.series || [];
|
||||
|
||||
// Calculate complete count
|
||||
const total = data.total || 0;
|
||||
const missing = data.missing_nfo_count || 0;
|
||||
const incomplete = data.incomplete_nfo_count || 0;
|
||||
const complete = total - missing - incomplete;
|
||||
|
||||
// Update stats
|
||||
updateStat('total-series', total);
|
||||
updateStat('missing-nfo', missing);
|
||||
updateStat('incomplete-nfo', incomplete);
|
||||
updateStat('complete-nfo', complete);
|
||||
|
||||
// Update filter counts
|
||||
updateFilterCount('all', total);
|
||||
updateFilterCount('missing', missing);
|
||||
updateFilterCount('incomplete', incomplete);
|
||||
updateFilterCount('complete', complete);
|
||||
|
||||
// Apply filter and render
|
||||
applyFilter();
|
||||
showLoading(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a stat value
|
||||
*/
|
||||
function updateStat(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update filter button count
|
||||
*/
|
||||
function updateFilterCount(filter, count) {
|
||||
const el = document.getElementById('filter-' + filter + '-count');
|
||||
if (el) el.textContent = count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide loading state
|
||||
*/
|
||||
function showLoading(show) {
|
||||
if (elements.loadingState) {
|
||||
elements.loadingState.classList.toggle('hidden', !show);
|
||||
}
|
||||
if (elements.seriesList) {
|
||||
elements.seriesList.style.display = show ? 'none' : 'grid';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide empty state
|
||||
*/
|
||||
function showEmpty(show) {
|
||||
if (elements.emptyState) {
|
||||
elements.emptyState.classList.toggle('hidden', !show);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle search input
|
||||
*/
|
||||
function handleSearch() {
|
||||
searchQuery = (elements.searchInput?.value || '').toLowerCase().trim();
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active filter
|
||||
*/
|
||||
function setFilter(filter) {
|
||||
currentFilter = filter;
|
||||
|
||||
// Update active button
|
||||
document.querySelectorAll('.filter-btn').forEach(function(btn) {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-filter') === filter);
|
||||
});
|
||||
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply current filter and search to series list
|
||||
*/
|
||||
function applyFilter() {
|
||||
filteredSeries = allSeries.filter(function(series) {
|
||||
// Apply filter
|
||||
let matchesFilter = false;
|
||||
if (currentFilter === 'all') {
|
||||
matchesFilter = true;
|
||||
} else if (currentFilter === 'missing') {
|
||||
matchesFilter = !series.has_nfo;
|
||||
} else if (currentFilter === 'incomplete') {
|
||||
matchesFilter = series.has_nfo && series.missing_tags && series.missing_tags.length > 0;
|
||||
} else if (currentFilter === 'complete') {
|
||||
matchesFilter = series.has_nfo && (!series.missing_tags || series.missing_tags.length === 0);
|
||||
}
|
||||
|
||||
// Apply search
|
||||
if (matchesFilter && searchQuery) {
|
||||
const matchesSearch =
|
||||
series.name.toLowerCase().includes(searchQuery) ||
|
||||
series.folder.toLowerCase().includes(searchQuery) ||
|
||||
series.key.toLowerCase().includes(searchQuery);
|
||||
matchesFilter = matchesSearch;
|
||||
}
|
||||
|
||||
return matchesFilter;
|
||||
});
|
||||
|
||||
renderSeriesList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the series list
|
||||
*/
|
||||
function renderSeriesList() {
|
||||
if (!elements.seriesList) return;
|
||||
|
||||
if (filteredSeries.length === 0) {
|
||||
showEmpty(true);
|
||||
// Clear any existing cards but keep loading/empty states
|
||||
elements.seriesList.querySelectorAll('.series-card').forEach(function(el) { el.remove(); });
|
||||
return;
|
||||
}
|
||||
|
||||
showEmpty(false);
|
||||
|
||||
// Build HTML
|
||||
const html = filteredSeries.map(function(series) {
|
||||
const statusClass = !series.has_nfo ? 'missing' :
|
||||
(series.missing_tags && series.missing_tags.length > 0) ? 'incomplete' : 'complete';
|
||||
const statusText = !series.has_nfo ? 'No NFO' :
|
||||
(series.missing_tags && series.missing_tags.length > 0) ?
|
||||
series.missing_tags.length + ' Missing' : 'Complete';
|
||||
|
||||
return '<div class="series-card ' + statusClass + '" data-key="' + escapeHtml(series.key) + '">' +
|
||||
'<div class="series-card-header">' +
|
||||
'<div>' +
|
||||
'<h4 class="series-name">' + escapeHtml(series.name) + '</h4>' +
|
||||
'<div class="series-folder">' + escapeHtml(series.folder) + '</div>' +
|
||||
'</div>' +
|
||||
'<span class="nfo-status-badge ' + statusClass + '">' + statusText + '</span>' +
|
||||
'</div>' +
|
||||
(series.missing_tags && series.missing_tags.length > 0 ?
|
||||
'<div class="missing-tags-list">' +
|
||||
series.missing_tags.slice(0, 3).map(function(tag) {
|
||||
return '<span class="missing-tag-chip">' + escapeHtml(tag) + '</span>';
|
||||
}).join('') +
|
||||
(series.missing_tags.length > 3 ? '<span class="missing-tag-chip">+' + (series.missing_tags.length - 3) + '</span>' : '') +
|
||||
'</div>' : '') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
// Clear existing cards and add new ones
|
||||
elements.seriesList.querySelectorAll('.series-card').forEach(function(el) { el.remove(); });
|
||||
elements.seriesList.insertAdjacentHTML('beforeend', html);
|
||||
|
||||
// Attach click handlers
|
||||
elements.seriesList.querySelectorAll('.series-card').forEach(function(card) {
|
||||
card.addEventListener('click', function() {
|
||||
const key = this.getAttribute('data-key');
|
||||
openDetailPanel(key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open detail panel for a series
|
||||
*/
|
||||
async function openDetailPanel(key) {
|
||||
currentSeries = filteredSeries.find(function(s) { return s.key === key; });
|
||||
if (!currentSeries) return;
|
||||
|
||||
// Show panel
|
||||
elements.detailPanel.classList.remove('hidden');
|
||||
|
||||
// Populate header
|
||||
elements.panelTitle.textContent = currentSeries.name;
|
||||
|
||||
// Populate status
|
||||
updateDetailStatus();
|
||||
|
||||
// Load diagnostics for this series
|
||||
await loadDiagnosticsForSeries(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update detail panel status display
|
||||
*/
|
||||
function updateDetailStatus() {
|
||||
if (!currentSeries) return;
|
||||
|
||||
const badge = elements.panelStatusBadge;
|
||||
const pathEl = elements.panelNfoPath;
|
||||
|
||||
if (!currentSeries.has_nfo) {
|
||||
badge.className = 'nfo-status-badge missing';
|
||||
badge.textContent = 'No NFO File';
|
||||
pathEl.textContent = '';
|
||||
} else if (currentSeries.missing_tags && currentSeries.missing_tags.length > 0) {
|
||||
badge.className = 'nfo-status-badge incomplete';
|
||||
badge.textContent = currentSeries.missing_tags.length + ' Missing Tags';
|
||||
pathEl.textContent = currentSeries.nfo_path || '';
|
||||
} else {
|
||||
badge.className = 'nfo-status-badge complete';
|
||||
badge.textContent = 'Complete';
|
||||
pathEl.textContent = currentSeries.nfo_path || '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load diagnostics for a specific series
|
||||
*/
|
||||
async function loadDiagnosticsForSeries(key) {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get('/api/nfo/' + encodeURIComponent(key) + '/diagnostics');
|
||||
if (!response || !response.ok) {
|
||||
throw new Error('Failed to load diagnostics');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Update current series with fresh data
|
||||
if (currentSeries && currentSeries.key === key) {
|
||||
currentSeries.has_nfo = data.has_nfo;
|
||||
currentSeries.nfo_path = data.nfo_path;
|
||||
currentSeries.missing_tags = data.missing_tags;
|
||||
updateDetailStatus();
|
||||
renderMissingTags();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load diagnostics:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render missing tags in detail panel
|
||||
*/
|
||||
function renderMissingTags() {
|
||||
if (!currentSeries || !currentSeries.missing_tags || currentSeries.missing_tags.length === 0) {
|
||||
elements.missingTagsSection.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
elements.missingTagsSection.style.display = 'block';
|
||||
elements.panelMissingTags.innerHTML = currentSeries.missing_tags.map(function(tag) {
|
||||
return '<span class="missing-tag-chip">' + escapeHtml(tag) + '</span>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close detail panel
|
||||
*/
|
||||
function closePanel() {
|
||||
elements.detailPanel.classList.add('hidden');
|
||||
currentSeries = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle scan all button
|
||||
*/
|
||||
async function handleScanAll() {
|
||||
AniWorld.UI.showToast('Scanning all series for NFO issues...', 'info');
|
||||
// Reload the data
|
||||
await loadSeriesNeedingRepair();
|
||||
AniWorld.UI.showToast('Scan complete', 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle repair all button
|
||||
*/
|
||||
async function handleRepairAll() {
|
||||
const missingSeries = allSeries.filter(function(s) { return !s.has_nfo; });
|
||||
const incompleteSeries = allSeries.filter(function(s) {
|
||||
return s.has_nfo && s.missing_tags && s.missing_tags.length > 0;
|
||||
});
|
||||
|
||||
const total = missingSeries.length + incompleteSeries.length;
|
||||
if (total === 0) {
|
||||
AniWorld.UI.showToast('All NFO files are complete', 'success');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await AniWorld.UI.showConfirmModal(
|
||||
'Repair All NFO',
|
||||
'This will repair ' + total + ' series. Continue?'
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
let repaired = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const series of missingSeries.concat(incompleteSeries)) {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.post(
|
||||
'/api/nfo/' + encodeURIComponent(series.key) + '/repair',
|
||||
{}
|
||||
);
|
||||
|
||||
if (response && response.ok) {
|
||||
repaired++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
AniWorld.UI.showToast(
|
||||
'Repair complete: ' + repaired + ' repaired, ' + failed + ' failed',
|
||||
failed > 0 ? 'warning' : 'success'
|
||||
);
|
||||
|
||||
// Reload data
|
||||
await loadSeriesNeedingRepair();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle repair single series
|
||||
*/
|
||||
async function handleRepairSingle() {
|
||||
if (!currentSeries) return;
|
||||
|
||||
elements.btnRepairSingle.disabled = true;
|
||||
elements.btnRepairSingle.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Repairing...';
|
||||
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.post(
|
||||
'/api/nfo/' + encodeURIComponent(currentSeries.key) + '/repair',
|
||||
{}
|
||||
);
|
||||
|
||||
if (!response || !response.ok) {
|
||||
const err = await response?.json();
|
||||
AniWorld.UI.showToast(err?.detail || 'Failed to repair NFO', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
AniWorld.UI.showToast(result.message, 'success');
|
||||
|
||||
// Reload diagnostics and list
|
||||
await loadDiagnosticsForSeries(currentSeries.key);
|
||||
await loadSeriesNeedingRepair();
|
||||
|
||||
// Refresh detail panel
|
||||
if (currentSeries) {
|
||||
currentSeries = filteredSeries.find(function(s) { return s.key === currentSeries.key; });
|
||||
}
|
||||
} catch (err) {
|
||||
AniWorld.UI.showToast('Connection error during repair', 'error');
|
||||
} finally {
|
||||
elements.btnRepairSingle.disabled = false;
|
||||
elements.btnRepairSingle.innerHTML = '<i class="fas fa-wrench"></i> Repair NFO';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle validate button
|
||||
*/
|
||||
async function handleValidate() {
|
||||
if (!currentSeries) return;
|
||||
|
||||
elements.btnValidate.disabled = true;
|
||||
elements.btnValidate.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Validating...';
|
||||
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get(
|
||||
'/api/nfo/' + encodeURIComponent(currentSeries.key) + '/validate'
|
||||
);
|
||||
|
||||
if (!response || !response.ok) {
|
||||
AniWorld.UI.showToast('Failed to validate NFO', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.valid) {
|
||||
AniWorld.UI.showToast('NFO XML is valid', 'success');
|
||||
} else {
|
||||
AniWorld.UI.showToast('NFO XML is invalid: ' + (result.error || 'Unknown error'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
AniWorld.UI.showToast('Connection error during validation', 'error');
|
||||
} finally {
|
||||
elements.btnValidate.disabled = false;
|
||||
elements.btnValidate.innerHTML = '<i class="fas fa-check"></i> Validate XML';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle view NFO button
|
||||
*/
|
||||
async function handleViewNfo() {
|
||||
if (!currentSeries || !currentSeries.nfo_path) {
|
||||
AniWorld.UI.showToast('No NFO file to view', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
elements.btnViewNfo.disabled = true;
|
||||
elements.btnViewNfo.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...';
|
||||
|
||||
try {
|
||||
// Fetch NFO content via API
|
||||
const response = await fetch(currentSeries.nfo_path);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch NFO file');
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
elements.nfoPreviewContent.querySelector('code').textContent = content;
|
||||
elements.nfoPreviewSection.style.display = 'block';
|
||||
} catch (err) {
|
||||
AniWorld.UI.showToast('Failed to load NFO content', 'error');
|
||||
} finally {
|
||||
elements.btnViewNfo.disabled = false;
|
||||
elements.btnViewNfo.innerHTML = '<i class="fas fa-file-code"></i> View NFO';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = function() {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
};
|
||||
})();
|
||||
|
||||
// Initialize on DOM ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
AniWorld.NfoSettings.init();
|
||||
});
|
||||
Reference in New Issue
Block a user