Files
Aniworld/src/server/web/static/js/index/series-manager.js

549 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 filterMode = 'all'; // 'all' | 'missing_episodes' | 'no_episodes'
let sortAlphabetical = false;
/**
* Initialize the series manager
*/
function init() {
bindEvents();
updateFilterButtonUI();
}
/**
* Bind UI events for filtering and sorting
*/
function bindEvents() {
const filterBtn = document.getElementById('show-missing-only');
if (filterBtn) {
filterBtn.addEventListener('click', toggleFilterMode);
}
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 url = filterMode && filterMode !== 'all'
? `${API.ANIME_LIST}?filter=${encodeURIComponent(filterMode)}`
: API.ANIME_LIST;
const response = await AniWorld.ApiClient.get(url);
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();
}
}
/**
* Cycle through filter modes:
* - all: Show all series
* - missing_episodes: Show only series with missing episodes
* - no_episodes: Show only series with zero downloaded episodes
*/
async function toggleFilterMode() {
const button = document.getElementById('show-missing-only');
const icon = button.querySelector('i');
const text = button.querySelector('span');
// Cycle through modes
if (filterMode === 'all') {
filterMode = 'missing_episodes';
} else if (filterMode === 'missing_episodes') {
filterMode = 'no_episodes';
} else {
filterMode = 'all';
}
// Update button UI and reload list based on new filter.
updateFilterButtonUI();
await loadSeries();
// Clear selection when filter changes
if (AniWorld.SelectionManager) {
AniWorld.SelectionManager.clearSelection();
}
}
/**
* Update the filter button UI to reflect current filter mode
*/
function updateFilterButtonUI() {
const button = document.getElementById('show-missing-only');
if (!button) {
return;
}
const icon = button.querySelector('i');
const text = button.querySelector('span');
const isActive = filterMode !== 'all';
button.setAttribute('data-active', isActive);
button.classList.toggle('active', isActive);
if (filterMode === 'missing_episodes') {
icon.className = 'fas fa-filter';
text.textContent = 'Missing Episodes Only';
} else if (filterMode === 'no_episodes') {
icon.className = 'fas fa-ban';
text.textContent = 'No Episodes';
} else {
icon.className = 'fas fa-filter-circle-xmark';
text.textContent = 'Show All Series';
}
}
/**
* 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();
// Apply client-side filter so that real-time WebSocket updates
// (e.g. an episode being marked downloaded) are immediately
// reflected without a full server reload.
if (filterMode === 'missing_episodes') {
filtered = filtered.filter(function(s) {
return s.missing_episodes > 0;
});
}
// 'no_episodes' filter state is maintained server-side;
// don't try to replicate it client-side here.
// 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;
}
});
filteredSeriesData = filtered;
}
/**
* Render series cards in the grid
*/
function renderSeries() {
const grid = document.getElementById('series-grid');
// Always use filteredSeriesData — applyFiltersAndSort() is always
// called before renderSeries(), so filteredSeriesData is current.
// The old fallback to seriesData was incorrect: when a filter is
// active and filteredSeriesData is empty it must show the empty-state
// message, not fall through to unfiltered seriesData.
const dataToRender = filteredSeriesData;
if (dataToRender.length === 0) {
let message;
if (filterMode === 'missing_episodes') {
message = 'No series with missing episodes found.';
} else if (filterMode === 'no_episodes') {
message = 'No series with zero downloaded episodes found.';
} else {
message = '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);
}
});
});
}
/**
* 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>';
}
/**
* 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
};
})();