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:
2026-06-14 19:34:58 +02:00
parent 75084b3941
commit 6dc3cda810
11 changed files with 2278 additions and 319 deletions

View File

@@ -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() {