Files
Aniworld/src/server/web/static/js/index/series-manager.js
Lukas d72b8cb1ab Add sync_single_series_after_scan with NFO metadata and WebSocket updates
- Implement sync_single_series_after_scan to persist scanned series to database
- Enhanced _broadcast_series_updated to include full NFO metadata (nfo_created_at, nfo_updated_at, tmdb_id, tvdb_id)
- Add immediate episode scanning in add_series endpoint when background loader isn't running
- Implement updateSingleSeries in frontend to handle series_updated WebSocket events
- Add SERIES_UPDATED event constant to WebSocket event definitions
- Update background loader to use sync_single_series_after_scan method
- Simplified background loader initialization in FastAPI app
- Add comprehensive tests for series update WebSocket payload and episode counting logic
- Import reorganization: move get_background_loader_service to dependencies module
2026-02-06 18:47:47 +01:00

553 lines
21 KiB
JavaScript

/**
* AniWorld - Series Manager Module
*
* Manages series data, filtering, sorting, and rendering.
*
* Dependencies: constants.js, api-client.js, ui-utils.js
*/
var AniWorld = window.AniWorld || {};
AniWorld.SeriesManager = (function() {
'use strict';
const API = AniWorld.Constants.API;
// State
let seriesData = [];
let filteredSeriesData = [];
let showMissingOnly = false;
let sortAlphabetical = false;
/**
* Initialize the series manager
*/
function init() {
bindEvents();
}
/**
* Bind UI events for filtering and sorting
*/
function bindEvents() {
const missingOnlyBtn = document.getElementById('show-missing-only');
if (missingOnlyBtn) {
missingOnlyBtn.addEventListener('click', toggleMissingOnlyFilter);
}
const sortBtn = document.getElementById('sort-alphabetical');
if (sortBtn) {
sortBtn.addEventListener('click', toggleAlphabeticalSort);
}
}
/**
* Load series from API
* @returns {Promise<Array>} Array of series data
*/
async function loadSeries() {
try {
AniWorld.UI.showLoading();
const response = await AniWorld.ApiClient.get(API.ANIME_LIST);
if (!response) {
return [];
}
const data = await response.json();
// Check if response has the expected format
if (Array.isArray(data)) {
// API returns array of AnimeSummary objects
seriesData = data.map(function(anime) {
// Count total missing episodes from the episode dictionary
const episodeDict = anime.missing_episodes || {};
const totalMissing = Object.values(episodeDict).reduce(
function(sum, episodes) {
return sum + (Array.isArray(episodes) ? episodes.length : 0);
},
0
);
return {
key: anime.key,
name: anime.name,
site: anime.site,
folder: anime.folder,
episodeDict: episodeDict,
missing_episodes: totalMissing,
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,
loading_status: anime.loading_status || 'completed',
episodes_loaded: anime.episodes_loaded !== false,
nfo_loaded: anime.nfo_loaded !== false,
logo_loaded: anime.logo_loaded !== false,
images_loaded: anime.images_loaded !== false
};
});
} else if (data.status === 'success') {
// Legacy format support
seriesData = data.series;
} else {
AniWorld.UI.showToast('Error loading series: ' + (data.message || 'Unknown error'), 'error');
return [];
}
applyFiltersAndSort();
renderSeries();
return seriesData;
} catch (error) {
console.error('Error loading series:', error);
AniWorld.UI.showToast('Failed to load series', 'error');
return [];
} finally {
AniWorld.UI.hideLoading();
}
}
/**
* Toggle missing episodes only filter
*/
function toggleMissingOnlyFilter() {
showMissingOnly = !showMissingOnly;
const button = document.getElementById('show-missing-only');
button.setAttribute('data-active', showMissingOnly);
button.classList.toggle('active', showMissingOnly);
const icon = button.querySelector('i');
const text = button.querySelector('span');
if (showMissingOnly) {
icon.className = 'fas fa-filter-circle-xmark';
text.textContent = 'Show All Series';
} else {
icon.className = 'fas fa-filter';
text.textContent = 'Missing Episodes Only';
}
applyFiltersAndSort();
renderSeries();
// Clear selection when filter changes
if (AniWorld.SelectionManager) {
AniWorld.SelectionManager.clearSelection();
}
}
/**
* Toggle alphabetical sorting
*/
function toggleAlphabeticalSort() {
sortAlphabetical = !sortAlphabetical;
const button = document.getElementById('sort-alphabetical');
button.setAttribute('data-active', sortAlphabetical);
button.classList.toggle('active', sortAlphabetical);
const icon = button.querySelector('i');
const text = button.querySelector('span');
if (sortAlphabetical) {
icon.className = 'fas fa-sort-alpha-up';
text.textContent = 'Default Sort';
} else {
icon.className = 'fas fa-sort-alpha-down';
text.textContent = 'A-Z Sort';
}
applyFiltersAndSort();
renderSeries();
}
/**
* Apply current filters and sorting to series data
*/
function applyFiltersAndSort() {
let filtered = seriesData.slice();
// Sort based on the current sorting mode
filtered.sort(function(a, b) {
if (sortAlphabetical) {
// Pure alphabetical sorting
return AniWorld.UI.getDisplayName(a).localeCompare(AniWorld.UI.getDisplayName(b));
} else {
// Default sorting: missing episodes first (descending), then by name
if (a.missing_episodes > 0 && b.missing_episodes === 0) return -1;
if (a.missing_episodes === 0 && b.missing_episodes > 0) return 1;
// If both have missing episodes, sort by count (descending)
if (a.missing_episodes > 0 && b.missing_episodes > 0) {
if (a.missing_episodes !== b.missing_episodes) {
return b.missing_episodes - a.missing_episodes;
}
}
// For series with same missing episode status, maintain stable order
return 0;
}
});
// Apply missing episodes filter
if (showMissingOnly) {
filtered = filtered.filter(function(serie) {
return serie.missing_episodes > 0;
});
}
filteredSeriesData = filtered;
}
/**
* Render series cards in the grid
*/
function renderSeries() {
const grid = document.getElementById('series-grid');
const dataToRender = filteredSeriesData.length > 0 ? filteredSeriesData :
(seriesData.length > 0 ? seriesData : []);
if (dataToRender.length === 0) {
const message = showMissingOnly ?
'No series with missing episodes found.' :
'No series found. Try searching for anime or rescanning your directory.';
grid.innerHTML =
'<div class="text-center" style="grid-column: 1 / -1; padding: 2rem;">' +
'<i class="fas fa-tv" style="font-size: 48px; color: var(--color-text-tertiary); margin-bottom: 1rem;"></i>' +
'<p style="color: var(--color-text-secondary);">' + message + '</p>' +
'</div>';
return;
}
grid.innerHTML = dataToRender.map(function(serie) {
return createSerieCard(serie);
}).join('');
// Bind checkbox events
grid.querySelectorAll('.series-checkbox').forEach(function(checkbox) {
checkbox.addEventListener('change', function(e) {
if (AniWorld.SelectionManager) {
AniWorld.SelectionManager.toggleSerieSelection(e.target.dataset.key, e.target.checked);
}
});
});
// 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);
});
}
});
});
}
/**
* Get loading indicator HTML for a series
* @param {Object} serie - Series data object
* @returns {string} HTML string
*/
function getLoadingIndicatorHTML(serie) {
const statusMessages = {
'pending': 'Queued for loading...',
'loading_episodes': 'Loading episodes...',
'loading_nfo': 'Generating NFO metadata...',
'loading_logo': 'Downloading logo...',
'loading_images': 'Downloading images...'
};
const statusMessage = statusMessages[serie.loading_status] || 'Loading...';
return '<div class="loading-indicator">' +
'<div class="loading-status">' +
'<span class="status-text"><i class="fas fa-spinner fa-spin"></i> ' + statusMessage + '</span>' +
'<div class="progress-items">' +
getProgressItemsHTML(serie) +
'</div>' +
'</div>' +
'</div>';
}
/**
* Get progress items HTML
* @param {Object} serie - Series data object
* @returns {string} HTML string
*/
function getProgressItemsHTML(serie) {
const items = [
{ key: 'episodes_loaded', label: 'Episodes' },
{ key: 'nfo_loaded', label: 'NFO' },
{ key: 'logo_loaded', label: 'Logo' },
{ key: 'images_loaded', label: 'Images' }
];
return items.map(function(item) {
const isLoaded = serie[item.key];
return '<div class="progress-item ' + (isLoaded ? 'completed' : 'pending') + '">' +
'<span class="icon">' + (isLoaded ? '✓' : '⋯') + '</span>' +
'<span class="label">' + item.label + '</span>' +
'</div>';
}).join('');
}
/**
* Create HTML for a series card
* @param {Object} serie - Series data object
* @returns {string} HTML string
*/
function createSerieCard(serie) {
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;
const isLoading = serie.loading_status && serie.loading_status !== 'completed' && serie.loading_status !== 'failed';
// Debug logging for troubleshooting
if (serie.key === 'so-im-a-spider-so-what') {
console.log('[createSerieCard] Spider series:', {
key: serie.key,
missing_episodes: serie.missing_episodes,
missing_episodes_type: typeof serie.missing_episodes,
episodeDict: serie.episodeDict,
hasMissingEpisodes: hasMissingEpisodes,
has_missing: serie.has_missing
});
}
return '<div class="series-card ' + (isSelected ? 'selected' : '') + ' ' +
(hasMissingEpisodes ? 'has-missing' : 'complete') + ' ' +
(isLoading ? 'loading' : '') + '" ' +
'data-key="' + serie.key + '" data-series-id="' + serie.key + '" data-folder="' + serie.folder + '">' +
'<div class="series-card-header">' +
'<input type="checkbox" class="series-checkbox" data-key="' + serie.key + '"' +
(isSelected ? ' checked' : '') + (canBeSelected ? '' : ' disabled') + '>' +
'<div class="series-info">' +
'<h3>' + AniWorld.UI.escapeHtml(AniWorld.UI.getDisplayName(serie)) + '</h3>' +
'<div class="series-folder">' + AniWorld.UI.escapeHtml(serie.folder) + '</div>' +
'</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">' +
'<div class="missing-episodes ' + (hasMissingEpisodes ? 'has-missing' : 'complete') + '">' +
'<i class="fas ' + (hasMissingEpisodes ? 'fa-exclamation-triangle' : 'fa-check') + '"></i>' +
'<span>' + (hasMissingEpisodes ? serie.missing_episodes + ' missing episodes' : 'Complete') + '</span>' +
'</div>' +
'<span class="series-site">' + serie.site + '</span>' +
'</div>' +
(isLoading ? getLoadingIndicatorHTML(serie) : '') +
'<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>';
}
/**
* Get all series data
* @returns {Array} Series data array
*/
function getSeriesData() {
return seriesData;
}
/**
* Get filtered series data
* @returns {Array} Filtered series data array
*/
function getFilteredSeriesData() {
return filteredSeriesData;
}
/**
* Find a series by key
* @param {string} key - Series key
* @returns {Object|undefined} Series object or undefined
*/
function findByKey(key) {
return seriesData.find(function(s) {
return s.key === key;
});
}
/**
* Update series loading status from WebSocket event
* @param {Object} data - Loading status data
*/
function updateSeriesLoadingStatus(data) {
const seriesKey = data.series_id || data.series_key;
if (!seriesKey) return;
// Find series in data
const serie = findByKey(seriesKey);
if (!serie) return;
// Update series data
serie.loading_status = data.status || data.loading_status;
if (data.progress) {
serie.episodes_loaded = data.progress.episodes || false;
serie.nfo_loaded = data.progress.nfo || false;
serie.logo_loaded = data.progress.logo || false;
serie.images_loaded = data.progress.images || false;
}
// Update the specific card in the DOM
const seriesCard = document.querySelector('[data-series-id="' + seriesKey + '"]');
if (seriesCard) {
// If loading completed, reload to get fresh data
if (data.status === 'completed') {
loadSeries();
} else {
// Update loading indicator
const loadingIndicator = seriesCard.querySelector('.loading-indicator');
if (loadingIndicator) {
loadingIndicator.innerHTML = '<div class="loading-status">' +
'<span class="status-text"><i class="fas fa-spinner fa-spin"></i> ' +
(data.message || 'Loading...') + '</span>' +
'<div class="progress-items">' +
getProgressItemsHTML(serie) +
'</div>' +
'</div>';
} else if (serie.loading_status !== 'completed' && serie.loading_status !== 'failed') {
// Add loading indicator if it doesn't exist
const statsDiv = seriesCard.querySelector('.series-stats');
if (statsDiv) {
statsDiv.insertAdjacentHTML('afterend', getLoadingIndicatorHTML(serie));
}
}
// Add loading class to card
seriesCard.classList.add('loading');
}
}
}
/**
* Update a single series from WebSocket data
* @param {Object} updatedData - Updated series data from WebSocket
*/
function updateSingleSeries(updatedData) {
if (!updatedData || !updatedData.key) {
console.warn('Invalid series update data:', updatedData);
return;
}
console.log('[updateSingleSeries] Received data:', updatedData);
console.log('[updateSingleSeries] missing_episodes type:', typeof updatedData.missing_episodes);
console.log('[updateSingleSeries] missing_episodes value:', updatedData.missing_episodes);
// Count total missing episodes from the episode dictionary
const episodeDict = updatedData.missing_episodes || {};
console.log('[updateSingleSeries] episodeDict:', episodeDict);
const totalMissing = Object.values(episodeDict).reduce(
function(sum, episodes) {
return sum + (Array.isArray(episodes) ? episodes.length : 0);
},
0
);
console.log('[updateSingleSeries] totalMissing calculated:', totalMissing);
// Transform WebSocket data to match our internal format
const transformedSerie = {
key: updatedData.key,
name: updatedData.name,
site: updatedData.site || 'aniworld.to',
folder: updatedData.folder,
episodeDict: episodeDict,
missing_episodes: totalMissing,
has_missing: updatedData.has_missing || totalMissing > 0,
has_nfo: updatedData.has_nfo || false,
nfo_created_at: updatedData.nfo_created_at || null,
nfo_updated_at: updatedData.nfo_updated_at || null,
tmdb_id: updatedData.tmdb_id || null,
tvdb_id: updatedData.tvdb_id || null,
loading_status: updatedData.loading_status || 'completed',
episodes_loaded: updatedData.episodes_loaded !== false,
nfo_loaded: updatedData.nfo_loaded !== false,
logo_loaded: updatedData.logo_loaded !== false,
images_loaded: updatedData.images_loaded !== false
};
console.log('[updateSingleSeries] Transformed serie:', transformedSerie);
// Find existing series in our data
const existingIndex = seriesData.findIndex(function(s) {
return s.key === updatedData.key;
});
if (existingIndex >= 0) {
// Update existing series
seriesData[existingIndex] = transformedSerie;
console.log('Updated existing series:', updatedData.key, transformedSerie);
} else {
// Add new series
seriesData.push(transformedSerie);
console.log('Added new series:', updatedData.key, transformedSerie);
}
// Reapply filters and re-render
applyFiltersAndSort();
renderSeries();
}
// Public API
return {
init: init,
loadSeries: loadSeries,
renderSeries: renderSeries,
applyFiltersAndSort: applyFiltersAndSort,
getSeriesData: getSeriesData,
getFilteredSeriesData: getFilteredSeriesData,
findByKey: findByKey,
updateSeriesLoadingStatus: updateSeriesLoadingStatus,
updateSingleSeries: updateSingleSeries
};
})();