feat: Complete frontend integration with native WebSocket and FastAPI backend

- Created websocket_client.js: Native WebSocket wrapper with Socket.IO-compatible interface
  - Automatic reconnection with exponential backoff
  - Room-based subscriptions for targeted updates
  - Message queueing during disconnection

- Updated HTML templates (index.html, queue.html):
  - Replaced Socket.IO CDN with native websocket_client.js
  - No external dependencies needed

- Updated JavaScript files (app.js, queue.js):
  - Added room subscriptions on WebSocket connect (scan_progress, download_progress, downloads)
  - Added dual event handlers for backward compatibility
  - Support both old (scan_completed) and new (scan_complete) message types
  - Support both old (download_error) and new (download_failed) message types
  - Support both old (queue_updated) and new (queue_status) message types

- Registered anime router in fastapi_app.py:
  - Added anime_router import and registration
  - All API routers now properly included

- Documentation:
  - Created FRONTEND_INTEGRATION.md with comprehensive integration guide
  - Updated infrastructure.md with frontend integration section
  - Updated instructions.md to mark task as completed

- Testing:
  - Verified anime endpoint tests pass (pytest)
  - API endpoint mapping documented
  - WebSocket message format changes documented

Benefits:
  - Native WebSocket API (faster, smaller footprint)
  - No external CDN dependencies
  - Full backward compatibility with existing code
  - Proper integration with backend services
  - Real-time updates via room-based messaging
This commit is contained in:
2025-10-17 12:12:47 +02:00
parent 99e24a2fc3
commit 8f7c489bd2
9 changed files with 809 additions and 21 deletions

View File

@@ -133,9 +133,20 @@ class AniWorldApp {
initSocket() {
this.socket = io();
// Handle initial connection message from server
this.socket.on('connected', (data) => {
console.log('WebSocket connection confirmed', data);
});
this.socket.on('connect', () => {
this.isConnected = true;
console.log('Connected to server');
// Subscribe to rooms for targeted updates
this.socket.join('scan_progress');
this.socket.join('download_progress');
this.socket.join('downloads');
this.showToast(this.localization.getText('connected-server'), 'success');
this.updateConnectionStatus();
this.checkProcessLocks();
@@ -158,18 +169,24 @@ class AniWorldApp {
this.updateStatus(`Scanning: ${data.folder} (${data.counter})`);
});
this.socket.on('scan_completed', () => {
// Handle both 'scan_completed' (legacy) and 'scan_complete' (new backend)
const handleScanComplete = () => {
this.hideStatus();
this.showToast('Scan completed successfully', 'success');
this.updateProcessStatus('rescan', false);
this.loadSeries();
});
};
this.socket.on('scan_completed', handleScanComplete);
this.socket.on('scan_complete', handleScanComplete);
this.socket.on('scan_error', (data) => {
// Handle both 'scan_error' (legacy) and 'scan_failed' (new backend)
const handleScanError = (data) => {
this.hideStatus();
this.showToast(`Scan error: ${data.message}`, 'error');
this.showToast(`Scan error: ${data.message || data.error}`, 'error');
this.updateProcessStatus('rescan', false, true);
});
};
this.socket.on('scan_error', handleScanError);
this.socket.on('scan_failed', handleScanError);
// Scheduled scan events
this.socket.on('scheduled_rescan_started', () => {

View File

@@ -22,8 +22,18 @@ class QueueManager {
initSocket() {
this.socket = io();
// Handle initial connection message from server
this.socket.on('connected', (data) => {
console.log('WebSocket connection confirmed', data);
});
this.socket.on('connect', () => {
console.log('Connected to server');
// Subscribe to rooms for targeted updates
this.socket.join('downloads');
this.socket.join('download_progress');
this.showToast('Connected to server', 'success');
});
@@ -32,10 +42,18 @@ class QueueManager {
this.showToast('Disconnected from server', 'warning');
});
// Queue update events
// Queue update events - handle both old and new message types
this.socket.on('queue_updated', (data) => {
this.updateQueueDisplay(data);
});
this.socket.on('queue_status', (data) => {
// New backend sends queue_status messages
if (data.queue_status) {
this.updateQueueDisplay(data.queue_status);
} else {
this.updateQueueDisplay(data);
}
});
this.socket.on('download_progress_update', (data) => {
this.updateDownloadProgress(data);
@@ -46,21 +64,33 @@ class QueueManager {
this.showToast('Download queue started', 'success');
this.loadQueueData(); // Refresh data
});
this.socket.on('queue_started', () => {
this.showToast('Download queue started', 'success');
this.loadQueueData(); // Refresh data
});
this.socket.on('download_progress', (data) => {
this.updateDownloadProgress(data);
});
this.socket.on('download_completed', (data) => {
this.showToast(`Completed: ${data.serie} - Episode ${data.episode}`, 'success');
// Handle both old and new download completion events
const handleDownloadComplete = (data) => {
const serieName = data.serie_name || data.serie || 'Unknown';
const episode = data.episode || '';
this.showToast(`Completed: ${serieName}${episode ? ' - Episode ' + episode : ''}`, 'success');
this.loadQueueData(); // Refresh data
});
};
this.socket.on('download_completed', handleDownloadComplete);
this.socket.on('download_complete', handleDownloadComplete);
this.socket.on('download_error', (data) => {
// Handle both old and new download error events
const handleDownloadError = (data) => {
const message = data.error || data.message || 'Unknown error';
this.showToast(`Download failed: ${message}`, 'error');
this.loadQueueData(); // Refresh data
});
};
this.socket.on('download_error', handleDownloadError);
this.socket.on('download_failed', handleDownloadError);
this.socket.on('download_queue_completed', () => {
this.showToast('All downloads completed!', 'success');
@@ -71,9 +101,23 @@ class QueueManager {
this.showToast('Stopping downloads...', 'info');
});
this.socket.on('download_stopped', () => {
// Handle both old and new queue stopped events
const handleQueueStopped = () => {
this.showToast('Download queue stopped', 'success');
this.loadQueueData(); // Refresh data
};
this.socket.on('download_stopped', handleQueueStopped);
this.socket.on('queue_stopped', handleQueueStopped);
// Handle queue paused/resumed
this.socket.on('queue_paused', () => {
this.showToast('Queue paused', 'info');
this.loadQueueData();
});
this.socket.on('queue_resumed', () => {
this.showToast('Queue resumed', 'success');
this.loadQueueData();
});
}

View File

@@ -0,0 +1,233 @@
/**
* Native WebSocket Client Wrapper
* Provides Socket.IO-like interface using native WebSocket API
*
* This wrapper maintains compatibility with existing Socket.IO-style
* event handlers while using the modern WebSocket API underneath.
*/
class WebSocketClient {
constructor(url = null) {
this.ws = null;
this.url = url || this.getWebSocketUrl();
this.eventHandlers = new Map();
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000;
this.isConnected = false;
this.rooms = new Set();
this.messageQueue = [];
this.autoReconnect = true;
}
/**
* Get WebSocket URL based on current page URL
*/
getWebSocketUrl() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
return `${protocol}//${host}/ws/connect`;
}
/**
* Connect to WebSocket server
*/
connect() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
console.log('WebSocket already connected');
return;
}
try {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.isConnected = true;
this.reconnectAttempts = 0;
// Emit connect event
this.emit('connect');
// Rejoin rooms
this.rejoinRooms();
// Process queued messages
this.processMessageQueue();
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data);
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
this.emit('error', { error: 'WebSocket connection error' });
};
this.ws.onclose = (event) => {
console.log('WebSocket disconnected', event.code, event.reason);
this.isConnected = false;
this.emit('disconnect', { code: event.code, reason: event.reason });
// Attempt reconnection
if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = this.reconnectDelay * this.reconnectAttempts;
console.log(`Attempting reconnection in ${delay}ms (attempt ${this.reconnectAttempts})`);
setTimeout(() => this.connect(), delay);
}
};
} catch (error) {
console.error('Failed to create WebSocket connection:', error);
this.emit('error', { error: 'Failed to connect' });
}
}
/**
* Disconnect from WebSocket server
*/
disconnect() {
this.autoReconnect = false;
if (this.ws) {
this.ws.close(1000, 'Client disconnected');
}
}
/**
* Handle incoming WebSocket message
*/
handleMessage(data) {
try {
const message = JSON.parse(data);
const { type, data: payload, timestamp } = message;
// Emit event with payload
if (type) {
this.emit(type, payload || {});
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error, data);
}
}
/**
* Register event handler (Socket.IO-style)
*/
on(event, handler) {
if (!this.eventHandlers.has(event)) {
this.eventHandlers.set(event, []);
}
this.eventHandlers.get(event).push(handler);
}
/**
* Remove event handler
*/
off(event, handler) {
if (!this.eventHandlers.has(event)) {
return;
}
const handlers = this.eventHandlers.get(event);
const index = handlers.indexOf(handler);
if (index !== -1) {
handlers.splice(index, 1);
}
}
/**
* Emit event to registered handlers
*/
emit(event, data = null) {
if (!this.eventHandlers.has(event)) {
return;
}
const handlers = this.eventHandlers.get(event);
handlers.forEach(handler => {
try {
if (data !== null) {
handler(data);
} else {
handler();
}
} catch (error) {
console.error(`Error in event handler for ${event}:`, error);
}
});
}
/**
* Send message to server
*/
send(type, data = {}) {
const message = JSON.stringify({
type,
data,
timestamp: new Date().toISOString()
});
if (this.isConnected && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(message);
} else {
console.warn('WebSocket not connected, queueing message');
this.messageQueue.push(message);
}
}
/**
* Join a room (subscribe to topic)
*/
join(room) {
this.rooms.add(room);
if (this.isConnected) {
this.send('join', { room });
}
}
/**
* Leave a room (unsubscribe from topic)
*/
leave(room) {
this.rooms.delete(room);
if (this.isConnected) {
this.send('leave', { room });
}
}
/**
* Rejoin all rooms after reconnection
*/
rejoinRooms() {
this.rooms.forEach(room => {
this.send('join', { room });
});
}
/**
* Process queued messages after connection
*/
processMessageQueue() {
while (this.messageQueue.length > 0 && this.isConnected) {
const message = this.messageQueue.shift();
this.ws.send(message);
}
}
/**
* Check if connected
*/
connected() {
return this.isConnected && this.ws && this.ws.readyState === WebSocket.OPEN;
}
}
/**
* Create global io() function for Socket.IO compatibility
*/
function io(url = null) {
const client = new WebSocketClient(url);
client.connect();
return client;
}

View File

@@ -455,7 +455,7 @@
</div>
<!-- Scripts -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
<script src="/static/js/websocket_client.js"></script>
<script src="/static/js/localization.js"></script>
<!-- UX Enhancement Scripts -->

View File

@@ -245,7 +245,7 @@
</div>
<!-- Scripts -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
<script src="/static/js/websocket_client.js"></script>
<script src="/static/js/queue.js"></script>
</body>