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:
2026-01-16 19:18:50 +01:00
parent d642234814
commit ecfa8d3c10
9 changed files with 699 additions and 5 deletions

View File

@@ -85,6 +85,11 @@ class AnimeSummary(BaseModel):
missing_episodes: Episode dictionary mapping seasons to episode numbers
has_missing: Boolean flag indicating if series has missing episodes
link: Optional link to the series page (used when adding new series)
has_nfo: Whether the series has NFO metadata
nfo_created_at: ISO timestamp when NFO was created
nfo_updated_at: ISO timestamp when NFO was last updated
tmdb_id: The Movie Database (TMDB) ID
tvdb_id: TheTVDB ID
"""
key: str = Field(
...,
@@ -114,6 +119,26 @@ class AnimeSummary(BaseModel):
default="",
description="Link to the series page (for adding new series)"
)
has_nfo: bool = Field(
default=False,
description="Whether the series has NFO metadata"
)
nfo_created_at: Optional[str] = Field(
default=None,
description="ISO timestamp when NFO was created"
)
nfo_updated_at: Optional[str] = Field(
default=None,
description="ISO timestamp when NFO was last updated"
)
tmdb_id: Optional[int] = Field(
default=None,
description="The Movie Database (TMDB) ID"
)
tvdb_id: Optional[int] = Field(
default=None,
description="TheTVDB ID"
)
class Config:
"""Pydantic model configuration."""
@@ -125,7 +150,12 @@ class AnimeSummary(BaseModel):
"folder": "beheneko the elf girls cat (2025)",
"missing_episodes": {"1": [1, 2, 3, 4]},
"has_missing": True,
"link": "https://aniworld.to/anime/stream/beheneko"
"link": "https://aniworld.to/anime/stream/beheneko",
"has_nfo": True,
"nfo_created_at": "2025-01-15T10:30:00Z",
"nfo_updated_at": "2025-01-15T10:30:00Z",
"tmdb_id": 12345,
"tvdb_id": 67890
}
}
@@ -188,6 +218,7 @@ async def list_anime(
filter: Optional[str] = None,
_auth: dict = Depends(require_auth),
series_app: Any = Depends(get_series_app),
anime_service: AnimeService = Depends(get_anime_service),
) -> List[AnimeSummary]:
"""List all library series with their missing episodes status.
@@ -282,6 +313,36 @@ async def list_anime(
series = series_app.list.GetList()
summaries: List[AnimeSummary] = []
# Build a map of folder -> NFO data for efficient lookup
nfo_map = {}
try:
# Get all series from database to fetch NFO metadata
from src.server.database.connection import get_db_session
session = get_db_session()
from src.server.database.models import AnimeSeries as DBAnimeSeries
db_series_list = session.query(DBAnimeSeries).all()
for db_series in db_series_list:
nfo_created = (
db_series.nfo_created_at.isoformat()
if db_series.nfo_created_at else None
)
nfo_updated = (
db_series.nfo_updated_at.isoformat()
if db_series.nfo_updated_at else None
)
nfo_map[db_series.folder_name] = {
"has_nfo": db_series.has_nfo or False,
"nfo_created_at": nfo_created,
"nfo_updated_at": nfo_updated,
"tmdb_id": db_series.tmdb_id,
"tvdb_id": db_series.tvdb_id,
}
except Exception as e:
logger.warning(f"Could not fetch NFO data from database: {e}")
# Continue without NFO data if database query fails
for serie in series:
# Get all properties from the serie object
key = getattr(serie, "key", "")
@@ -296,6 +357,9 @@ async def list_anime(
# Determine if series has missing episodes
has_missing = bool(episode_dict)
# Get NFO data from map
nfo_data = nfo_map.get(folder, {})
summaries.append(
AnimeSummary(
key=key,
@@ -304,6 +368,11 @@ async def list_anime(
folder=folder,
missing_episodes=missing_episodes,
has_missing=has_missing,
has_nfo=nfo_data.get("has_nfo", False),
nfo_created_at=nfo_data.get("nfo_created_at"),
nfo_updated_at=nfo_data.get("nfo_updated_at"),
tmdb_id=nfo_data.get("tmdb_id"),
tvdb_id=nfo_data.get("tvdb_id"),
)
)

View File

@@ -884,7 +884,7 @@ class AnimeService:
AnimeServiceError: If update fails
"""
from datetime import datetime, timezone
from src.server.database.connection import get_db_session
from src.server.database.models import AnimeSeries

View File

@@ -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);

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

View File

@@ -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>';
}

View File

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

View File

@@ -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>