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
|
### Task: Implement Asynchronous Series Data Loading with Background Processing
|
||||||
|
|
||||||
**Priority:** High
|
**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
|
#### Overview
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,77 @@
|
|||||||
opacity: 0.5;
|
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 Card Actions */
|
||||||
.series-actions {
|
.series-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -82,7 +82,12 @@ AniWorld.SeriesManager = (function() {
|
|||||||
nfo_created_at: anime.nfo_created_at || null,
|
nfo_created_at: anime.nfo_created_at || null,
|
||||||
nfo_updated_at: anime.nfo_updated_at || null,
|
nfo_updated_at: anime.nfo_updated_at || null,
|
||||||
tmdb_id: anime.tmdb_id || 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') {
|
} 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
|
* Create HTML for a series card
|
||||||
* @param {Object} serie - Series data object
|
* @param {Object} serie - Series data object
|
||||||
@@ -284,10 +337,12 @@ AniWorld.SeriesManager = (function() {
|
|||||||
const hasMissingEpisodes = serie.missing_episodes > 0;
|
const hasMissingEpisodes = serie.missing_episodes > 0;
|
||||||
const canBeSelected = hasMissingEpisodes;
|
const canBeSelected = hasMissingEpisodes;
|
||||||
const hasNfo = serie.has_nfo || false;
|
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' : '') + ' ' +
|
return '<div class="series-card ' + (isSelected ? 'selected' : '') + ' ' +
|
||||||
(hasMissingEpisodes ? 'has-missing' : 'complete') + '" ' +
|
(hasMissingEpisodes ? 'has-missing' : 'complete') + ' ' +
|
||||||
'data-key="' + serie.key + '" data-folder="' + serie.folder + '">' +
|
(isLoading ? 'loading' : '') + '" ' +
|
||||||
|
'data-key="' + serie.key + '" data-series-id="' + serie.key + '" data-folder="' + serie.folder + '">' +
|
||||||
'<div class="series-card-header">' +
|
'<div class="series-card-header">' +
|
||||||
'<input type="checkbox" class="series-checkbox" data-key="' + serie.key + '"' +
|
'<input type="checkbox" class="series-checkbox" data-key="' + serie.key + '"' +
|
||||||
(isSelected ? ' checked' : '') + (canBeSelected ? '' : ' disabled') + '>' +
|
(isSelected ? ' checked' : '') + (canBeSelected ? '' : ' disabled') + '>' +
|
||||||
@@ -308,6 +363,7 @@ AniWorld.SeriesManager = (function() {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'<span class="series-site">' + serie.site + '</span>' +
|
'<span class="series-site">' + serie.site + '</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
(isLoading ? getLoadingIndicatorHTML(serie) : '') +
|
||||||
'<div class="series-actions">' +
|
'<div class="series-actions">' +
|
||||||
(hasNfo ?
|
(hasNfo ?
|
||||||
'<button class="btn btn-sm btn-secondary nfo-view-btn" data-key="' + serie.key + '" title="View NFO">' +
|
'<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
|
// Public API
|
||||||
return {
|
return {
|
||||||
init: init,
|
init: init,
|
||||||
@@ -355,6 +463,7 @@ AniWorld.SeriesManager = (function() {
|
|||||||
applyFiltersAndSort: applyFiltersAndSort,
|
applyFiltersAndSort: applyFiltersAndSort,
|
||||||
getSeriesData: getSeriesData,
|
getSeriesData: getSeriesData,
|
||||||
getFilteredSeriesData: getFilteredSeriesData,
|
getFilteredSeriesData: getFilteredSeriesData,
|
||||||
findByKey: findByKey
|
findByKey: findByKey,
|
||||||
|
updateSeriesLoadingStatus: updateSeriesLoadingStatus
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -133,6 +133,14 @@ AniWorld.IndexSocketHandler = (function() {
|
|||||||
AniWorld.ScanManager.updateProcessStatus('download', false, true);
|
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
|
// Download events
|
||||||
socket.on(WS_EVENTS.DOWNLOAD_STARTED, function(data) {
|
socket.on(WS_EVENTS.DOWNLOAD_STARTED, function(data) {
|
||||||
isDownloading = true;
|
isDownloading = true;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ AniWorld.Constants = (function() {
|
|||||||
ANIME_RESCAN: '/api/anime/rescan',
|
ANIME_RESCAN: '/api/anime/rescan',
|
||||||
ANIME_STATUS: '/api/anime/status',
|
ANIME_STATUS: '/api/anime/status',
|
||||||
ANIME_SCAN_STATUS: '/api/anime/scan/status',
|
ANIME_SCAN_STATUS: '/api/anime/scan/status',
|
||||||
|
ANIME_LOADING_STATUS: '/api/anime', // + /{key}/loading-status
|
||||||
|
|
||||||
// Queue endpoints
|
// Queue endpoints
|
||||||
QUEUE_STATUS: '/api/queue/status',
|
QUEUE_STATUS: '/api/queue/status',
|
||||||
@@ -99,6 +100,9 @@ AniWorld.Constants = (function() {
|
|||||||
SCAN_ERROR: 'scan_error',
|
SCAN_ERROR: 'scan_error',
|
||||||
SCAN_FAILED: 'scan_failed',
|
SCAN_FAILED: 'scan_failed',
|
||||||
|
|
||||||
|
// Series loading events
|
||||||
|
SERIES_LOADING_UPDATE: 'series_loading_update',
|
||||||
|
|
||||||
// Scheduled scan events
|
// Scheduled scan events
|
||||||
SCHEDULED_RESCAN_STARTED: 'scheduled_rescan_started',
|
SCHEDULED_RESCAN_STARTED: 'scheduled_rescan_started',
|
||||||
SCHEDULED_RESCAN_COMPLETED: 'scheduled_rescan_completed',
|
SCHEDULED_RESCAN_COMPLETED: 'scheduled_rescan_completed',
|
||||||
|
|||||||
Reference in New Issue
Block a user