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

@@ -469,4 +469,101 @@
flex-shrink: 0;
}
/* Series Info Grid (Read-only display) */
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-sm) var(--spacing-lg);
}
.info-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.info-item label {
font-size: var(--font-size-caption);
color: var(--color-text-secondary);
font-weight: 500;
}
.info-item .info-value {
font-size: var(--font-size-body);
color: var(--color-text-primary);
}
/* Input with action button */
.input-with-action {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.input-with-action .input-field {
flex: 1;
}
.btn-fetch-tmdb {
flex-shrink: 0;
white-space: nowrap;
}
/* TMDB Search Results */
.tmdb-search-results {
margin-top: var(--spacing-sm);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
background: var(--color-surface);
max-height: 200px;
overflow-y: auto;
}
.tmdb-result-item {
padding: var(--spacing-sm) var(--spacing-md);
cursor: pointer;
border-bottom: 1px solid var(--color-divider);
display: flex;
flex-direction: column;
gap: 2px;
}
.tmdb-result-item:last-child {
border-bottom: none;
}
.tmdb-result-item:hover {
background: var(--color-background-subtle);
}
.tmdb-result-title {
font-weight: 500;
color: var(--color-text-primary);
}
.tmdb-result-overview {
font-size: var(--font-size-caption);
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tmdb-error,
.tmdb-no-results,
.tmdb-selected {
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-caption);
color: var(--color-text-secondary);
margin: 0;
}
.tmdb-error {
color: var(--color-error, #e74c3c);
}
.tmdb-selected {
color: var(--color-success, #2ecc71);
}

View File

@@ -0,0 +1,427 @@
/* NFO Settings Page Styles */
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Header */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
}
.header-left .back-link {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.875rem;
transition: color 0.2s;
}
.header-left .back-link:hover {
color: var(--color-primary);
}
.header-center h1 {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text);
}
.header-right {
display: flex;
gap: 0.75rem;
}
/* Stats Bar */
.stats-bar {
display: flex;
gap: 1rem;
padding: 1rem 2rem;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem 1.5rem;
background: var(--color-background);
border-radius: 8px;
min-width: 120px;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text);
}
.stat-label {
font-size: 0.75rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-missing .stat-value {
color: var(--color-error);
}
.stat-incomplete .stat-value {
color: var(--color-warning);
}
.stat-complete .stat-value {
color: var(--color-success);
}
/* Toolbar */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
}
.search-box {
position: relative;
width: 300px;
}
.search-box i {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-secondary);
}
.search-box .input-field {
width: 100%;
padding-left: 2.5rem;
}
.filter-buttons {
display: flex;
gap: 0.5rem;
}
.filter-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.filter-btn:hover {
background: var(--color-surface-hover);
color: var(--color-text);
}
.filter-btn.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
.filter-btn .count {
background: rgba(255, 255, 255, 0.2);
padding: 0.125rem 0.5rem;
border-radius: 10px;
font-size: 0.75rem;
}
.filter-btn:not(.active) .count {
background: var(--color-border);
}
/* Series List */
.series-list-container {
flex: 1;
padding: 1rem 2rem;
overflow-y: auto;
}
.series-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1rem;
}
.loading-state,
.empty-state {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem;
color: var(--color-text-secondary);
}
.loading-state i {
font-size: 2rem;
margin-bottom: 1rem;
}
.empty-state i {
font-size: 3rem;
color: var(--color-success);
margin-bottom: 1rem;
}
/* Series Card */
.series-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.series-card:hover {
border-color: var(--color-primary);
transform: translateY(-2px);
}
.series-card.missing {
border-left: 4px solid var(--color-error);
}
.series-card.incomplete {
border-left: 4px solid var(--color-warning);
}
.series-card.complete {
border-left: 4px solid var(--color-success);
}
.series-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.series-name {
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
margin: 0;
}
.series-folder {
font-size: 0.75rem;
color: var(--color-text-secondary);
margin-top: 0.25rem;
}
.nfo-status-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.nfo-status-badge.missing,
.nfo-status-badge.no-nfo {
background: var(--color-error-bg);
color: var(--color-error);
}
.nfo-status-badge.incomplete {
background: var(--color-warning-bg);
color: var(--color-warning);
}
.nfo-status-badge.complete {
background: var(--color-success-bg);
color: var(--color-success);
}
.missing-tags-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.missing-tag-chip {
display: inline-block;
padding: 0.25rem 0.5rem;
background: var(--color-background);
border-radius: 4px;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
/* Detail Panel */
.detail-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 500px;
z-index: 1000;
display: flex;
}
.detail-panel.hidden {
display: none;
}
.panel-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
}
.panel-content {
position: relative;
width: 100%;
background: var(--color-surface);
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--color-border);
}
.panel-header h3 {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text);
margin: 0;
}
.panel-body {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
}
.detail-section {
margin-bottom: 1.5rem;
}
.detail-section h4 {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
}
.status-display {
display: flex;
align-items: center;
gap: 1rem;
}
.nfo-path {
font-size: 0.75rem;
color: var(--color-text-secondary);
word-break: break-all;
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.action-buttons .btn {
justify-content: center;
}
.nfo-preview {
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 1rem;
max-height: 300px;
overflow: auto;
font-size: 0.75rem;
line-height: 1.5;
}
.nfo-preview code {
color: var(--color-text-secondary);
white-space: pre-wrap;
word-break: break-all;
}
/* Responsive */
@media (max-width: 768px) {
.page-header {
flex-wrap: wrap;
gap: 1rem;
}
.stats-bar {
flex-wrap: wrap;
padding: 0.75rem 1rem;
}
.stat-item {
min-width: 100px;
padding: 0.5rem 1rem;
}
.toolbar {
flex-direction: column;
gap: 1rem;
padding: 0.75rem 1rem;
}
.search-box {
width: 100%;
}
.filter-buttons {
flex-wrap: wrap;
}
.series-list-container {
padding: 1rem;
}
.series-list {
grid-template-columns: 1fr;
}
.detail-panel {
width: 100%;
}
}

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

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