- 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
553 lines
21 KiB
JavaScript
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
|
|
};
|
|
})();
|