Add frontend UI for async series loading
- Add SERIES_LOADING_UPDATE WebSocket event - Update series cards to display loading indicators - Add real-time status updates via WebSocket - Include progress tracking (episodes, NFO, logo, images) - Add CSS styling for loading states - Implement updateSeriesLoadingStatus function
This commit is contained in:
@@ -122,7 +122,59 @@ For each task completed:
|
||||
### Task: Implement Asynchronous Series Data Loading with Background Processing
|
||||
|
||||
**Priority:** High
|
||||
**Status:** Not Started
|
||||
**Status:** ✅ Completed
|
||||
|
||||
#### Implementation Summary
|
||||
|
||||
Successfully implemented asynchronous series data loading with background processing. The system allows users to add series immediately while metadata (episodes, NFO files, logos, images) loads asynchronously in the background.
|
||||
|
||||
**Completed Items:**
|
||||
|
||||
- ✅ Architecture document created with detailed component diagrams
|
||||
- ✅ Database schema updated with loading status fields
|
||||
- ✅ BackgroundLoaderService created with task queue and worker
|
||||
- ✅ API endpoints updated (POST returns 202 Accepted, GET loading-status added)
|
||||
- ✅ Startup check for incomplete series implemented
|
||||
- ✅ Graceful shutdown handling for background tasks
|
||||
- ✅ Database migration script created and tested
|
||||
- ✅ Unit tests written and passing (10 tests, 100% pass rate)
|
||||
- ✅ Frontend UI updates for loading indicators and WebSocket integration
|
||||
- ✅ Git commit with clear message
|
||||
|
||||
**Key Features Implemented:**
|
||||
|
||||
1. **Immediate Series Addition**: POST /api/anime/add returns 202 Accepted immediately
|
||||
2. **Background Processing**: Tasks queued and processed asynchronously
|
||||
3. **Status Tracking**: GET /api/anime/{key}/loading-status endpoint for real-time status
|
||||
4. **Startup Validation**: Checks for incomplete series on app startup
|
||||
5. **WebSocket Integration**: Real-time status updates via existing WebSocket service
|
||||
6. **Clean Architecture**: Reuses existing services, no code duplication
|
||||
7. **Frontend UI**: Loading indicators with progress tracking on series cards
|
||||
8. **Real-time Updates**: WebSocket handlers update UI as loading progresses
|
||||
|
||||
**Remaining Work:**
|
||||
|
||||
- [ ] Integration tests for complete flow (Task 9 in instructions)
|
||||
- [ ] Manual end-to-end testing
|
||||
|
||||
**Files Created:**
|
||||
|
||||
- `docs/architecture/async_loading_architecture.md` - Architecture documentation
|
||||
- `src/server/services/background_loader_service.py` - Main service (481 lines)
|
||||
- `scripts/migrate_loading_status.py` - Database migration script
|
||||
- `tests/unit/test_background_loader_service.py` - Unit tests (10 tests)
|
||||
|
||||
**Files Modified:**
|
||||
|
||||
- `src/server/database/models.py` - Added loading status fields to AnimeSeries
|
||||
- `src/server/database/service.py` - Updated AnimeSeriesService.create()
|
||||
- `src/server/api/anime.py` - Updated POST /add, added GET loading-status
|
||||
- `src/server/fastapi_app.py` - Added startup/shutdown integration
|
||||
- `src/server/utils/dependencies.py` - Added BackgroundLoaderService dependency
|
||||
- `src/server/web/static/js/shared/constants.js` - Added SERIES_LOADING_UPDATE event
|
||||
- `src/server/web/static/js/index/series-manager.js` - Added loading status handling and UI updates
|
||||
- `src/server/web/static/js/index/socket-handler.js` - Added WebSocket handler for loading updates
|
||||
- `src/server/web/static/css/components/cards.css` - Added loading indicator styles
|
||||
|
||||
#### Overview
|
||||
|
||||
|
||||
@@ -103,6 +103,77 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Series Card Loading State */
|
||||
.series-card.loading {
|
||||
border-color: var(--color-info);
|
||||
background: linear-gradient(90deg, rgba(59, 130, 246, 0.05) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
/* Loading Indicator */
|
||||
.loading-indicator {
|
||||
background: var(--color-surface-secondary, var(--color-surface));
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: var(--spacing-md);
|
||||
margin-top: var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.loading-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.status-text i.fa-spinner {
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.progress-items {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.progress-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
font-size: var(--font-size-caption);
|
||||
}
|
||||
|
||||
.progress-item.completed {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.progress-item.completed .icon {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.progress-item.pending {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.progress-item.pending .icon {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.progress-item .icon {
|
||||
font-size: 1.1em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.progress-item .label {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Series Card Actions */
|
||||
.series-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -82,7 +82,12 @@ AniWorld.SeriesManager = (function() {
|
||||
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
|
||||
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') {
|
||||
@@ -274,6 +279,54 @@ AniWorld.SeriesManager = (function() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -284,10 +337,12 @@ AniWorld.SeriesManager = (function() {
|
||||
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';
|
||||
|
||||
return '<div class="series-card ' + (isSelected ? 'selected' : '') + ' ' +
|
||||
(hasMissingEpisodes ? 'has-missing' : 'complete') + '" ' +
|
||||
'data-key="' + serie.key + '" data-folder="' + serie.folder + '">' +
|
||||
(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') + '>' +
|
||||
@@ -308,6 +363,7 @@ AniWorld.SeriesManager = (function() {
|
||||
'</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">' +
|
||||
@@ -347,6 +403,58 @@ AniWorld.SeriesManager = (function() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
@@ -355,6 +463,7 @@ AniWorld.SeriesManager = (function() {
|
||||
applyFiltersAndSort: applyFiltersAndSort,
|
||||
getSeriesData: getSeriesData,
|
||||
getFilteredSeriesData: getFilteredSeriesData,
|
||||
findByKey: findByKey
|
||||
findByKey: findByKey,
|
||||
updateSeriesLoadingStatus: updateSeriesLoadingStatus
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -133,6 +133,14 @@ AniWorld.IndexSocketHandler = (function() {
|
||||
AniWorld.ScanManager.updateProcessStatus('download', false, true);
|
||||
});
|
||||
|
||||
// Series loading events
|
||||
socket.on(WS_EVENTS.SERIES_LOADING_UPDATE, function(data) {
|
||||
console.log('Series loading update:', data);
|
||||
if (AniWorld.SeriesManager && AniWorld.SeriesManager.updateSeriesLoadingStatus) {
|
||||
AniWorld.SeriesManager.updateSeriesLoadingStatus(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Download events
|
||||
socket.on(WS_EVENTS.DOWNLOAD_STARTED, function(data) {
|
||||
isDownloading = true;
|
||||
|
||||
@@ -26,6 +26,7 @@ AniWorld.Constants = (function() {
|
||||
ANIME_RESCAN: '/api/anime/rescan',
|
||||
ANIME_STATUS: '/api/anime/status',
|
||||
ANIME_SCAN_STATUS: '/api/anime/scan/status',
|
||||
ANIME_LOADING_STATUS: '/api/anime', // + /{key}/loading-status
|
||||
|
||||
// Queue endpoints
|
||||
QUEUE_STATUS: '/api/queue/status',
|
||||
@@ -99,6 +100,9 @@ AniWorld.Constants = (function() {
|
||||
SCAN_ERROR: 'scan_error',
|
||||
SCAN_FAILED: 'scan_failed',
|
||||
|
||||
// Series loading events
|
||||
SERIES_LOADING_UPDATE: 'series_loading_update',
|
||||
|
||||
// Scheduled scan events
|
||||
SCHEDULED_RESCAN_STARTED: 'scheduled_rescan_started',
|
||||
SCHEDULED_RESCAN_COMPLETED: 'scheduled_rescan_completed',
|
||||
|
||||
Reference in New Issue
Block a user