feat: Add NFO UI features (Task 6)
- Extended AnimeSummary model with NFO fields (has_nfo, nfo_created_at, nfo_updated_at, tmdb_id, tvdb_id) - Updated list_anime endpoint to fetch and return NFO data from database - Added NFO status badges to series cards (green=exists, gray=missing) - Created nfo-manager.js module with createNFO, refreshNFO, viewNFO operations - Added NFO action buttons to series cards (Create/View/Refresh) - Integrated WebSocket handlers for real-time NFO events (creating, completed, failed) - Added CSS styles for NFO badges and action buttons - All 34 NFO API tests passing, all 32 anime endpoint tests passing - Documented in docs/task6_status.md (90% complete, NFO status page deferred)
This commit is contained in:
@@ -75,6 +75,7 @@
|
||||
right: var(--spacing-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.status-missing {
|
||||
@@ -87,6 +88,40 @@
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
/* NFO Status Badge */
|
||||
.nfo-badge {
|
||||
font-size: 1em;
|
||||
margin-left: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.nfo-badge.nfo-exists {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.nfo-badge.nfo-missing {
|
||||
color: var(--color-text-tertiary);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Series Card Actions */
|
||||
.series-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
margin-top: var(--spacing-sm);
|
||||
padding-top: var(--spacing-sm);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.series-actions .btn {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-caption);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
}
|
||||
|
||||
.series-actions .btn i {
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* Series Card States */
|
||||
.series-card.has-missing {
|
||||
border-left: 4px solid var(--color-warning);
|
||||
|
||||
239
src/server/web/static/js/index/nfo-manager.js
Normal file
239
src/server/web/static/js/index/nfo-manager.js
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* NFO Manager Module
|
||||
*
|
||||
* Handles NFO metadata operations including creating, viewing, and refreshing
|
||||
* NFO files for anime series.
|
||||
*/
|
||||
|
||||
window.AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.NFOManager = (function() {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Create NFO metadata for a series
|
||||
* @param {string} seriesKey - The unique identifier for the series
|
||||
* @returns {Promise<object>} API response
|
||||
*/
|
||||
async function createNFO(seriesKey) {
|
||||
try {
|
||||
AniWorld.UI.showLoading('Creating NFO metadata...');
|
||||
|
||||
const response = await AniWorld.ApiClient.request(
|
||||
`/api/nfo/series/${encodeURIComponent(seriesKey)}`,
|
||||
{
|
||||
method: 'POST'
|
||||
}
|
||||
);
|
||||
|
||||
if (response && response.status === 'success') {
|
||||
AniWorld.UI.showToast('NFO creation started', 'success');
|
||||
return response;
|
||||
} else {
|
||||
throw new Error(response?.message || 'Failed to create NFO');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating NFO:', error);
|
||||
AniWorld.UI.showToast(
|
||||
'Failed to create NFO: ' + error.message,
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
AniWorld.UI.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh NFO metadata for a series (update existing NFO)
|
||||
* @param {string} seriesKey - The unique identifier for the series
|
||||
* @returns {Promise<object>} API response
|
||||
*/
|
||||
async function refreshNFO(seriesKey) {
|
||||
try {
|
||||
AniWorld.UI.showLoading('Refreshing NFO metadata...');
|
||||
|
||||
const response = await AniWorld.ApiClient.request(
|
||||
`/api/nfo/series/${encodeURIComponent(seriesKey)}`,
|
||||
{
|
||||
method: 'PUT'
|
||||
}
|
||||
);
|
||||
|
||||
if (response && response.status === 'success') {
|
||||
AniWorld.UI.showToast('NFO refresh started', 'success');
|
||||
return response;
|
||||
} else {
|
||||
throw new Error(response?.message || 'Failed to refresh NFO');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing NFO:', error);
|
||||
AniWorld.UI.showToast(
|
||||
'Failed to refresh NFO: ' + error.message,
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
AniWorld.UI.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* View NFO metadata for a series
|
||||
* @param {string} seriesKey - The unique identifier for the series
|
||||
* @returns {Promise<object>} NFO data
|
||||
*/
|
||||
async function viewNFO(seriesKey) {
|
||||
try {
|
||||
AniWorld.UI.showLoading('Loading NFO data...');
|
||||
|
||||
const response = await AniWorld.ApiClient.request(
|
||||
`/api/nfo/series/${encodeURIComponent(seriesKey)}`
|
||||
);
|
||||
|
||||
if (response && response.data) {
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error('No NFO data available');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error viewing NFO:', error);
|
||||
AniWorld.UI.showToast(
|
||||
'Failed to load NFO: ' + error.message,
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
AniWorld.UI.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show NFO data in a modal
|
||||
* @param {string} seriesKey - The unique identifier for the series
|
||||
*/
|
||||
async function showNFOModal(seriesKey) {
|
||||
try {
|
||||
const nfoData = await viewNFO(seriesKey);
|
||||
|
||||
// Format NFO data for display
|
||||
const nfoHtml = formatNFOData(nfoData);
|
||||
|
||||
// Show modal (assuming a modal utility exists)
|
||||
if (AniWorld.UI.showModal) {
|
||||
AniWorld.UI.showModal({
|
||||
title: 'NFO Metadata',
|
||||
content: nfoHtml,
|
||||
size: 'large'
|
||||
});
|
||||
} else {
|
||||
// Fallback: log to console
|
||||
console.log('NFO Data:', nfoData);
|
||||
alert('NFO Data:\n' + JSON.stringify(nfoData, null, 2));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error showing NFO modal:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format NFO data for display in HTML
|
||||
* @param {object} nfoData - NFO metadata object
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
function formatNFOData(nfoData) {
|
||||
let html = '<div class="nfo-data">';
|
||||
|
||||
if (nfoData.title) {
|
||||
html += '<div class="nfo-field"><strong>Title:</strong> ' +
|
||||
AniWorld.UI.escapeHtml(nfoData.title) + '</div>';
|
||||
}
|
||||
|
||||
if (nfoData.plot) {
|
||||
html += '<div class="nfo-field"><strong>Plot:</strong> ' +
|
||||
AniWorld.UI.escapeHtml(nfoData.plot) + '</div>';
|
||||
}
|
||||
|
||||
if (nfoData.year) {
|
||||
html += '<div class="nfo-field"><strong>Year:</strong> ' +
|
||||
nfoData.year + '</div>';
|
||||
}
|
||||
|
||||
if (nfoData.genre) {
|
||||
const genres = Array.isArray(nfoData.genre)
|
||||
? nfoData.genre.join(', ')
|
||||
: nfoData.genre;
|
||||
html += '<div class="nfo-field"><strong>Genre:</strong> ' +
|
||||
AniWorld.UI.escapeHtml(genres) + '</div>';
|
||||
}
|
||||
|
||||
if (nfoData.rating) {
|
||||
html += '<div class="nfo-field"><strong>Rating:</strong> ' +
|
||||
nfoData.rating + '</div>';
|
||||
}
|
||||
|
||||
if (nfoData.tmdb_id) {
|
||||
html += '<div class="nfo-field"><strong>TMDB ID:</strong> ' +
|
||||
nfoData.tmdb_id + '</div>';
|
||||
}
|
||||
|
||||
if (nfoData.tvdb_id) {
|
||||
html += '<div class="nfo-field"><strong>TVDB ID:</strong> ' +
|
||||
nfoData.tvdb_id + '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get NFO statistics
|
||||
* @returns {Promise<object>} Statistics data
|
||||
*/
|
||||
async function getStatistics() {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.request('/api/nfo/statistics');
|
||||
|
||||
if (response && response.data) {
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error('Failed to get NFO statistics');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting NFO statistics:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get series without NFO
|
||||
* @param {number} limit - Maximum number of results
|
||||
* @returns {Promise<Array>} List of series without NFO
|
||||
*/
|
||||
async function getSeriesWithoutNFO(limit = 10) {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.request(
|
||||
`/api/nfo/missing?limit=${limit}`
|
||||
);
|
||||
|
||||
if (response && response.data) {
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error('Failed to get series without NFO');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting series without NFO:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
createNFO: createNFO,
|
||||
refreshNFO: refreshNFO,
|
||||
viewNFO: viewNFO,
|
||||
showNFOModal: showNFOModal,
|
||||
getStatistics: getStatistics,
|
||||
getSeriesWithoutNFO: getSeriesWithoutNFO
|
||||
};
|
||||
})();
|
||||
@@ -77,7 +77,12 @@ AniWorld.SeriesManager = (function() {
|
||||
folder: anime.folder,
|
||||
episodeDict: episodeDict,
|
||||
missing_episodes: totalMissing,
|
||||
has_missing: anime.has_missing || totalMissing > 0
|
||||
has_missing: anime.has_missing || totalMissing > 0,
|
||||
has_nfo: anime.has_nfo || false,
|
||||
nfo_created_at: anime.nfo_created_at || null,
|
||||
nfo_updated_at: anime.nfo_updated_at || null,
|
||||
tmdb_id: anime.tmdb_id || null,
|
||||
tvdb_id: anime.tvdb_id || null
|
||||
};
|
||||
});
|
||||
} else if (data.status === 'success') {
|
||||
@@ -226,6 +231,47 @@ AniWorld.SeriesManager = (function() {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Bind NFO button events
|
||||
grid.querySelectorAll('.nfo-create-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
const seriesKey = e.currentTarget.dataset.key;
|
||||
if (AniWorld.NFOManager) {
|
||||
AniWorld.NFOManager.createNFO(seriesKey).then(function() {
|
||||
// Reload series to reflect new NFO status
|
||||
loadSeries();
|
||||
}).catch(function(error) {
|
||||
console.error('Error creating NFO:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
grid.querySelectorAll('.nfo-view-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
const seriesKey = e.currentTarget.dataset.key;
|
||||
if (AniWorld.NFOManager) {
|
||||
AniWorld.NFOManager.showNFOModal(seriesKey);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
grid.querySelectorAll('.nfo-refresh-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
const seriesKey = e.currentTarget.dataset.key;
|
||||
if (AniWorld.NFOManager) {
|
||||
AniWorld.NFOManager.refreshNFO(seriesKey).then(function() {
|
||||
// Reload series to reflect updated NFO
|
||||
loadSeries();
|
||||
}).catch(function(error) {
|
||||
console.error('Error refreshing NFO:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -237,6 +283,7 @@ AniWorld.SeriesManager = (function() {
|
||||
const isSelected = AniWorld.SelectionManager ? AniWorld.SelectionManager.isSelected(serie.key) : false;
|
||||
const hasMissingEpisodes = serie.missing_episodes > 0;
|
||||
const canBeSelected = hasMissingEpisodes;
|
||||
const hasNfo = serie.has_nfo || false;
|
||||
|
||||
return '<div class="series-card ' + (isSelected ? 'selected' : '') + ' ' +
|
||||
(hasMissingEpisodes ? 'has-missing' : 'complete') + '" ' +
|
||||
@@ -250,6 +297,8 @@ AniWorld.SeriesManager = (function() {
|
||||
'</div>' +
|
||||
'<div class="series-status">' +
|
||||
(hasMissingEpisodes ? '' : '<i class="fas fa-check-circle status-complete" title="Complete"></i>') +
|
||||
(hasNfo ? '<i class="fas fa-file-alt nfo-badge nfo-exists" title="NFO metadata available"></i>' :
|
||||
'<i class="fas fa-file-alt nfo-badge nfo-missing" title="No NFO metadata"></i>') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="series-stats">' +
|
||||
@@ -259,6 +308,15 @@ AniWorld.SeriesManager = (function() {
|
||||
'</div>' +
|
||||
'<span class="series-site">' + serie.site + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="series-actions">' +
|
||||
(hasNfo ?
|
||||
'<button class="btn btn-sm btn-secondary nfo-view-btn" data-key="' + serie.key + '" title="View NFO">' +
|
||||
'<i class="fas fa-eye"></i> View NFO</button>' +
|
||||
'<button class="btn btn-sm btn-secondary nfo-refresh-btn" data-key="' + serie.key + '" title="Refresh NFO">' +
|
||||
'<i class="fas fa-sync-alt"></i> Refresh</button>' :
|
||||
'<button class="btn btn-sm btn-primary nfo-create-btn" data-key="' + serie.key + '" title="Create NFO">' +
|
||||
'<i class="fas fa-plus"></i> Create NFO</button>') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
|
||||
@@ -237,6 +237,35 @@ AniWorld.IndexSocketHandler = (function() {
|
||||
AniWorld.ConfigManager.hideStatus();
|
||||
AniWorld.UI.showToast('Download cancelled', 'warning');
|
||||
});
|
||||
|
||||
// NFO events
|
||||
socket.on('nfo_creating', function(data) {
|
||||
console.log('NFO creation started:', data);
|
||||
AniWorld.UI.showToast(
|
||||
'Creating NFO for ' + (data.series_name || data.series_key),
|
||||
'info'
|
||||
);
|
||||
});
|
||||
|
||||
socket.on('nfo_completed', function(data) {
|
||||
console.log('NFO creation completed:', data);
|
||||
AniWorld.UI.showToast(
|
||||
'NFO created for ' + (data.series_name || data.series_key),
|
||||
'success'
|
||||
);
|
||||
// Reload series to reflect new NFO status
|
||||
if (AniWorld.SeriesManager && AniWorld.SeriesManager.loadSeries) {
|
||||
AniWorld.SeriesManager.loadSeries();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('nfo_failed', function(data) {
|
||||
console.error('NFO creation failed:', data);
|
||||
AniWorld.UI.showToast(
|
||||
'NFO creation failed: ' + (data.message || data.error || 'Unknown error'),
|
||||
'error'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -457,6 +457,7 @@
|
||||
<script src="/static/js/index/selection-manager.js"></script>
|
||||
<script src="/static/js/index/search.js"></script>
|
||||
<script src="/static/js/index/scan-manager.js"></script>
|
||||
<script src="/static/js/index/nfo-manager.js"></script>
|
||||
<!-- Config Sub-Modules (must load before config-manager.js) -->
|
||||
<script src="/static/js/index/scheduler-config.js"></script>
|
||||
<script src="/static/js/index/logging-config.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user