refactor: migrate main.py to modular routers and add project roadmap
CI / Test (Python 3.11) (push) Has been cancelled
CI / Test (Python 3.12) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Summary (push) Has been cancelled

- Migrated monolithic main.py to feature-scoped routers in app/routers/
- Added GEMINI.md for project context and AI instructional guidelines
- Updated README.md with a comprehensive modernization plan (SQL migration, robust scraping DSL, frontend modernization)
- Improved authentication with cookie support and modular JS
- Updated test suite and documentation
This commit is contained in:
root
2026-03-24 10:12:04 +00:00
parent 1b5d7f9238
commit d4d8d8a3b6
42 changed files with 4518 additions and 2426 deletions
+85
View File
@@ -0,0 +1,85 @@
import { describe, it, expect, beforeAll } from 'vitest';
// Set up global window object for jsdom
global.window = global.window || {};
// Define skeleton functions for testing (same as in auth-api.js)
const API_BASE = '/api';
async function login(username, password) {
throw new Error('Not implemented yet');
}
async function register(username, password, email = null, full_name = null) {
throw new Error('Not implemented yet');
}
async function logout() {
throw new Error('Not implemented yet');
}
async function getMe(token) {
throw new Error('Not implemented yet');
}
// Set up window object
window.authApi = {
login,
register,
logout,
getMe,
};
describe('authApi', () => {
describe('login function', () => {
it('should be a function', () => {
expect(typeof window.authApi.login).toBe('function');
});
it('should return a Promise', () => {
const result = window.authApi.login('test', 'test');
expect(result).toBeInstanceOf(Promise);
});
});
describe('register function', () => {
it('should be a function', () => {
expect(typeof window.authApi.register).toBe('function');
});
it('should return a Promise', () => {
const result = window.authApi.register('testuser', 'password123', null, null);
expect(result).toBeInstanceOf(Promise);
});
it('should handle optional parameters', async () => {
try {
await window.authApi.register('test', 'password');
} catch (e) {
expect(e.message).toBe('Not implemented yet');
}
});
});
describe('logout function', () => {
it('should be a function', () => {
expect(typeof window.authApi.logout).toBe('function');
});
it('should return a Promise', () => {
const result = window.authApi.logout();
expect(result).toBeInstanceOf(Promise);
});
});
describe('getMe function', () => {
it('should be a function', () => {
expect(typeof window.authApi.getMe).toBe('function');
});
it('should return a Promise', () => {
const result = window.authApi.getMe('fake-token');
expect(result).toBeInstanceOf(Promise);
});
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect, beforeEach } from 'vitest';
// Mock DOM elements for displayError tests
const mockDocument = () => {
const elements = {};
global.document = {
getElementById: (id) => elements[id] || null,
};
beforeEach(() => {
elements.authError = {
textContent: '',
classList: {
add: () => {},
remove: () => {}
}
};
elements.authSuccess = {
textContent: '',
classList: {
add: () => {},
remove: () => {}
}
};
});
};
describe('safeJsonParse', () => {
// Import the function - we'll need to make it work with Vitest
// For now, we'll define it inline for testing
const safeJsonParse = (text, fallback = null) => {
try {
if (text === undefined || text === null || text === '') {
return fallback;
}
return JSON.parse(text);
} catch (error) {
return fallback;
}
};
it('should parse valid JSON string', () => {
const result = safeJsonParse('{"key":"value"}');
expect(result).toEqual({ key: 'value' });
});
it('should return fallback for invalid JSON', () => {
const result = safeJsonParse('invalid json');
expect(result).toBeNull();
});
it('should return custom fallback when provided', () => {
const result = safeJsonParse('invalid', 'custom fallback');
expect(result).toBe('custom fallback');
});
it('should return fallback for undefined input', () => {
const result = safeJsonParse(undefined);
expect(result).toBeNull();
});
it('should return fallback for null input', () => {
const result = safeJsonParse(null);
expect(result).toBeNull();
});
it('should return fallback for empty string', () => {
const result = safeJsonParse('');
expect(result).toBeNull();
});
it('should parse valid JSON array', () => {
const result = safeJsonParse('[1, 2, 3]');
expect(result).toEqual([1, 2, 3]);
});
it('should parse nested JSON', () => {
const result = safeJsonParse('{"user":{"name":"John","age":30}}');
expect(result).toEqual({ user: { name: 'John', age: 30 } });
});
});
+8
View File
@@ -0,0 +1,8 @@
// Smoke test to verify Vitest setup
import { describe, it, expect } from 'vitest';
describe('smoke', () => {
it('works', () => {
expect(true).toBe(true);
});
});
+27 -15
View File
@@ -62,16 +62,13 @@ async function searchAnimeDetails(query, malId = null) {
const providersData = await getProvidersInfo();
// Build results HTML
const streamingParts = [
`<div class="streaming-results-header">
<h3>🎬 Résultats de streaming</h3>
</div>
<div class="search-results" style="margin-top: 20px;">`
];
const streamingParts = [];
let hasResults = false;
// Display results from each provider - render all cards in parallel
for (const [providerId, results] of Object.entries(streamingData.value.results)) {
if (results && results.length > 0) {
hasResults = true;
const provider = providersData.anime_providers[providerId];
// Render all cards for this provider
@@ -81,8 +78,17 @@ async function searchAnimeDetails(query, malId = null) {
}
}
streamingParts.push('</div>');
streamingHtml = streamingParts.join('');
// Only add header and wrapper if we have results
if (hasResults) {
streamingParts.unshift(
`<div class="streaming-results-header">
<h3>🎬 Résultats de streaming</h3>
</div>
<div class="search-results" style="margin-top: 20px;">`
);
streamingParts.push('</div>');
streamingHtml = streamingParts.join('');
}
}
// Display results
@@ -150,16 +156,13 @@ async function getProviderSearchResults(query) {
}
// Build results HTML
const htmlParts = [
`<div class="streaming-results-header">
<h3>🎬 Résultats de streaming</h3>
</div>
<div class="search-results" style="margin-top: 20px;">`
];
const htmlParts = [];
let hasResults = false;
// Display results from each provider
for (const [providerId, results] of Object.entries(data.results)) {
if (results && results.length > 0) {
hasResults = true;
const providersData = await getProvidersInfo();
const provider = providersData.anime_providers[providerId];
@@ -170,7 +173,16 @@ async function getProviderSearchResults(query) {
}
}
htmlParts.push('</div>');
// Only add header and wrapper if we have results
if (hasResults) {
htmlParts.unshift(
`<div class="streaming-results-header">
<h3>🎬 Résultats de streaming</h3>
</div>
<div class="search-results" style="margin-top: 20px;">`
);
htmlParts.push('</div>');
}
return htmlParts.join('');
+98
View File
@@ -0,0 +1,98 @@
/**
* Auth API client module
* Following the pattern from static/js/watchlist.js (global exports)
*/
// Use the global API_BASE from auth-utils.js, fallback to /api
const AUTH_API_BASE = typeof window.API_BASE !== 'undefined' ? window.API_BASE : '/api';
async function login(username, password) {
try {
const response = await fetch(`${AUTH_API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const text = await response.text();
const data = window.safeJsonParse(text, {});
if (!response.ok) {
const errorMessage = data.detail || 'Erreur de connexion';
throw new Error(errorMessage);
}
return data;
} catch (error) {
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new Error('Erreur de connexion au serveur');
}
throw error;
}
}
async function register(username, password, email = null, full_name = null) {
try {
const response = await fetch(`${AUTH_API_BASE}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, email, full_name }),
});
const text = await response.text();
const data = window.safeJsonParse(text, {});
if (!response.ok) {
const errorMessage = data.detail || 'Erreur lors de l\'inscription';
throw new Error(errorMessage);
}
return data;
} catch (error) {
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new Error('Erreur de connexion au serveur');
}
throw error;
}
}
async function logout() {
try {
const response = await fetch(`${AUTH_API_BASE}/auth/logout`, { method: 'POST' });
const text = await response.text();
const data = window.safeJsonParse(text, { status: 'success' });
return data;
} catch (error) {
return { status: 'success', message: 'Logged out locally' };
}
}
async function getMe(token) {
try {
const response = await fetch(`${AUTH_API_BASE}/auth/me`, {
headers: { 'Authorization': `Bearer ${token}` },
});
const text = await response.text();
const data = window.safeJsonParse(text, {});
if (!response.ok) {
const errorMessage = data.detail || 'Erreur de connexion';
throw new Error(errorMessage);
}
return data;
} catch (error) {
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new Error('Erreur de connexion au serveur');
}
throw error;
}
}
window.authApi = {
login,
register,
logout,
getMe,
};
+128
View File
@@ -0,0 +1,128 @@
/**
* Auth UI handlers module
* Following the pattern from static/js/watchlist.js (global exports)
*/
async function handleLogin(event) {
event.preventDefault();
const username = document.getElementById('loginUsername').value;
const password = document.getElementById('loginPassword').value;
const button = document.getElementById('loginSubmit');
if (!button) {
console.error('Login button not found');
return;
}
const originalText = button.textContent;
setLoading('loginSubmit', true, { loadingText: 'Connexion...', originalText });
try {
const data = await window.authApi.login(username, password);
if (data.access_token) {
window.setToken(data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
window.displaySuccess('authSuccess', 'Connexion réussie! Redirection...');
setTimeout(() => {
window.location.href = '/web';
}, 1000);
}
} catch (error) {
window.displayError('authError', error.message || 'Erreur lors de la connexion');
} finally {
setLoading('loginSubmit', false, { originalText });
}
}
async function handleRegister(event) {
event.preventDefault();
const username = document.getElementById('registerUsername').value;
const password = document.getElementById('registerPassword').value;
const passwordConfirm = document.getElementById('registerPasswordConfirm').value;
const email = document.getElementById('registerEmail').value || null;
const full_name = document.getElementById('registerFullName').value || null;
if (password !== passwordConfirm) {
window.displayError('authError', 'Les mots de passe ne correspondent pas');
return;
}
const button = document.getElementById('registerSubmit');
if (!button) {
console.error('Register button not found');
return;
}
const originalText = button.textContent;
setLoading('registerSubmit', true, { loadingText: 'Inscription...', originalText });
try {
const data = await window.authApi.register(username, password, email, full_name);
window.displaySuccess('authSuccess', 'Inscription réussie! Vous pouvez maintenant vous connecter.');
setTimeout(() => {
window.authUi.switchTab('login');
document.getElementById('loginUsername').value = username;
}, 1500);
} catch (error) {
window.displayError('authError', error.message || 'Erreur lors de l\'inscription');
} finally {
setLoading('registerSubmit', false, { originalText });
}
}
function setLoading(buttonId, isLoading, options = {}) {
const button = document.getElementById(buttonId);
if (!button) {
return;
}
const defaultLoadingText = '...';
const loadingText = options.loadingText || defaultLoadingText;
if (isLoading) {
const origText = options.originalText || button.textContent;
button.dataset.originalText = origText;
button.textContent = loadingText;
button.disabled = true;
} else {
const origText = button.dataset.originalText || options.originalText || 'Se connecter';
button.textContent = origText;
button.disabled = false;
}
}
function resetLoading(buttonId, originalText) {
setLoading(buttonId, false, { originalText });
}
function switchTab(tab) {
const tabs = document.querySelectorAll('.auth-tab');
const forms = document.querySelectorAll('.auth-form');
tabs.forEach(t => t.classList.remove('active'));
forms.forEach(f => f.classList.remove('active'));
if (tab === 'login') {
tabs[0].classList.add('active');
document.getElementById('loginForm').classList.add('active');
} else {
tabs[1].classList.add('active');
document.getElementById('registerForm').classList.add('active');
}
document.getElementById('authError').classList.remove('show');
document.getElementById('authSuccess').classList.remove('show');
}
window.authUi = {
handleLogin,
handleRegister,
setLoading,
resetLoading,
switchTab,
};
+105
View File
@@ -0,0 +1,105 @@
/**
* Auth utilities - safe JSON parsing and error display
* Following the pattern from static/js/watchlist.js (global exports)
*/
// API base URL - use relative path for same-origin
const API_BASE = '/api';
/**
* Safely parse JSON string with fallback
* @param {string} text - The JSON string to parse
* @param {*} fallback - The fallback value if parsing fails (default: null)
* @returns {*} Parsed object or fallback value
*/
function safeJsonParse(text, fallback = null) {
try {
if (text === undefined || text === null || text === '') {
return fallback;
}
return JSON.parse(text);
} catch (error) {
console.error('JSON parse error:', error.message);
return fallback;
}
}
/**
* Display error message in the specified element
* Handles string, object, and array errors properly
* @param {string} elementId - The ID of the element to display error in
* @param {*} error - The error (string, object, or array)
* @param {string} defaultMessage - Default message if error is invalid
*/
function displayError(elementId, error, defaultMessage = 'Une erreur est survenue') {
const errorDiv = document.getElementById(elementId);
if (!errorDiv) {
console.error('Error element not found:', elementId);
return;
}
let message = defaultMessage;
if (error === null || error === undefined) {
message = defaultMessage;
} else if (typeof error === 'string') {
message = error;
} else if (typeof error === 'object') {
// Handle array errors
if (Array.isArray(error)) {
message = error.join('\n');
}
// Handle FastAPI HTTPException detail (can be string or object)
else if (error.detail) {
if (typeof error.detail === 'string') {
message = error.detail;
} else if (typeof error.detail === 'object' && error.detail.msg) {
message = error.detail.msg;
} else {
// Stringify the object to avoid "[object Object]"
message = JSON.stringify(error.detail);
}
}
// Handle generic object
else {
message = JSON.stringify(error);
}
}
errorDiv.textContent = message;
errorDiv.classList.add('show');
// Hide success message if visible
const successDiv = document.getElementById(elementId.replace('Error', 'Success'));
if (successDiv) {
successDiv.classList.remove('show');
}
}
/**
* Display success message in the specified element
* @param {string} elementId - The ID of the element to display success in
* @param {string} message - The success message
*/
function displaySuccess(elementId, message) {
const successDiv = document.getElementById(elementId);
if (!successDiv) {
console.error('Success element not found:', elementId);
return;
}
successDiv.textContent = message;
successDiv.classList.add('show');
// Hide error message if visible
const errorDiv = document.getElementById(elementId.replace('Success', 'Error'));
if (errorDiv) {
errorDiv.classList.remove('show');
}
}
// Export globally (following watchlist.js pattern)
window.safeJsonParse = safeJsonParse;
window.displayError = displayError;
window.displaySuccess = displaySuccess;
window.API_BASE = API_BASE;
+73 -7
View File
@@ -5,9 +5,74 @@
// Use relative path for API
const AUTH_API_BASE = '/api';
const COOKIE_NAME = 'auth_token';
const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
/**
* Set token in HTTP-only cookie (via server)
* Since we can't set HttpOnly cookies from JavaScript, we store in localStorage
* but also try to set a non-HttpOnly cookie for compatibility
*/
function setToken(token) {
// Store in localStorage as primary (for backward compatibility)
localStorage.setItem('auth_token', token);
// Also try to set cookie (non-HttpOnly, but better than nothing)
// Note: HttpOnly must be set by server, this is a fallback
const expires = new Date();
expires.setTime(expires.getTime() + COOKIE_MAX_AGE * 1000);
document.cookie = `${COOKIE_NAME}=${token};expires=${expires.toUTCString()};path=/;SameSite=Strict`;
}
/**
* Get token from cookie first, then fallback to localStorage
*/
function getToken() {
// Try cookie first
const cookieToken = getTokenFromCookie();
if (cookieToken) {
return cookieToken;
}
// Fallback to localStorage
return localStorage.getItem('auth_token');
}
/**
* Get token from cookie
*/
function getTokenFromCookie() {
const name = COOKIE_NAME + '=';
const decodedCookie = decodeURIComponent(document.cookie);
const cookieArray = decodedCookie.split(';');
for (let i = 0; i < cookieArray.length; i++) {
let cookie = cookieArray[i];
while (cookie.charAt(0) === ' ') {
cookie = cookie.substring(1);
}
if (cookie.indexOf(name) === 0) {
return cookie.substring(name.length, cookie.length);
}
}
return null;
}
/**
* Remove token from cookie and localStorage
*/
function removeToken() {
// Remove from localStorage
localStorage.removeItem('auth_token');
localStorage.removeItem('user');
// Remove cookie
document.cookie = `${COOKIE_NAME}=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;`;
}
// Check if user is authenticated
async function checkAuth() {
const token = localStorage.getItem('auth_token');
const token = getToken();
const userStr = localStorage.getItem('user');
if (!token) {
@@ -31,8 +96,7 @@ async function checkAuth() {
return true;
} else {
// Token invalid, remove it and redirect
localStorage.removeItem('auth_token');
localStorage.removeItem('user');
removeToken();
redirectToLogin();
return false;
}
@@ -97,9 +161,8 @@ async function handleLogout() {
return;
}
// Remove token from localStorage
localStorage.removeItem('auth_token');
localStorage.removeItem('user');
// Remove token from localStorage and cookie
removeToken();
// Call logout endpoint
try {
@@ -114,7 +177,7 @@ async function handleLogout() {
// Add authorization header to all fetch requests
function addAuthHeader(options = {}) {
const token = localStorage.getItem('auth_token');
const token = getToken();
if (token) {
options.headers = options.headers || {};
options.headers['Authorization'] = `Bearer ${token}`;
@@ -135,6 +198,9 @@ window.showLoginPrompt = showLoginPrompt;
window.handleLogout = handleLogout;
window.authFetch = authFetch;
window.addAuthHeader = addAuthHeader;
window.getToken = getToken;
window.setToken = setToken;
window.removeToken = removeToken;
// Check authentication on page load
document.addEventListener('DOMContentLoaded', () => {
+1 -1
View File
@@ -237,7 +237,7 @@ async function handleAddToWatchlist(animeUrl, providerId) {
// Trigger download of all episodes immediately
try {
const token = localStorage.getItem('auth_token');
const token = getToken();
const downloadResponse = await fetch(`${API_BASE}/watchlist/${result.id}/download-all`, {
method: 'POST',
headers: {
+12 -12
View File
@@ -7,7 +7,7 @@
* Get user's watchlist
*/
async function getWatchlist(status = null) {
const token = localStorage.getItem('auth_token');
const token = getToken();
if (!token) {
throw new Error('Not authenticated');
}
@@ -34,7 +34,7 @@ async function getWatchlist(status = null) {
* Add anime to watchlist
*/
async function addToWatchlist(animeData) {
const token = localStorage.getItem('auth_token');
const token = getToken();
if (!token) {
throw new Error('Not authenticated');
}
@@ -60,7 +60,7 @@ async function addToWatchlist(animeData) {
* Update watchlist item
*/
async function updateWatchlistItem(itemId, updateData) {
const token = localStorage.getItem('auth_token');
const token = getToken();
if (!token) {
throw new Error('Not authenticated');
}
@@ -85,7 +85,7 @@ async function updateWatchlistItem(itemId, updateData) {
* Delete from watchlist
*/
async function deleteFromWatchlist(itemId) {
const token = localStorage.getItem('auth_token');
const token = getToken();
if (!token) {
throw new Error('Not authenticated');
}
@@ -122,7 +122,7 @@ async function resumeWatchlistItem(itemId) {
* Check specific anime for new episodes
*/
async function checkWatchlistItem(itemId) {
const token = localStorage.getItem('auth_token');
const token = getToken();
if (!token) {
throw new Error('Not authenticated');
}
@@ -145,7 +145,7 @@ async function checkWatchlistItem(itemId) {
* Check all watchlist items
*/
async function checkAllWatchlistItems() {
const token = localStorage.getItem('auth_token');
const token = getToken();
if (!token) {
throw new Error('Not authenticated');
}
@@ -168,7 +168,7 @@ async function checkAllWatchlistItems() {
* Get watchlist settings
*/
async function getWatchlistSettings() {
const token = localStorage.getItem('auth_token');
const token = getToken();
if (!token) {
throw new Error('Not authenticated');
}
@@ -190,7 +190,7 @@ async function getWatchlistSettings() {
* Update watchlist settings
*/
async function updateWatchlistSettings(settings) {
const token = localStorage.getItem('auth_token');
const token = getToken();
if (!token) {
throw new Error('Not authenticated');
}
@@ -215,7 +215,7 @@ async function updateWatchlistSettings(settings) {
* Get watchlist statistics
*/
async function getWatchlistStats() {
const token = localStorage.getItem('auth_token');
const token = getToken();
if (!token) {
throw new Error('Not authenticated');
}
@@ -237,7 +237,7 @@ async function getWatchlistStats() {
* Get scheduler status
*/
async function getSchedulerStatus() {
const token = localStorage.getItem('auth_token');
const token = getToken();
if (!token) {
throw new Error('Not authenticated');
}
@@ -259,7 +259,7 @@ async function getSchedulerStatus() {
* Start scheduler
*/
async function startScheduler() {
const token = localStorage.getItem('auth_token');
const token = getToken();
if (!token) {
throw new Error('Not authenticated');
}
@@ -282,7 +282,7 @@ async function startScheduler() {
* Stop scheduler
*/
async function stopScheduler() {
const token = localStorage.getItem('auth_token');
const token = getToken();
if (!token) {
throw new Error('Not authenticated');
}