├── docs └── img │ ├── setup.webp │ ├── stats.png │ ├── title.png │ ├── mobile6.png │ ├── screenshot.webp │ ├── trakt-api.png │ ├── dashbord-fixe.png │ ├── progress-live.png │ ├── screenshot-ui.png │ ├── track-connect.png │ ├── trakt-setting.png │ ├── dashboard-full.png │ └── initial-loading.png ├── public ├── assets │ ├── favicon.ico │ ├── favicon-96x96.png │ ├── apple-touch-icon.png │ ├── web-app-manifest-192x192.png │ ├── web-app-manifest-512x512.png │ ├── site.webmanifest │ ├── placeholder-poster.svg │ ├── setup-url-detection.js │ ├── modules │ │ ├── state.js │ │ ├── scroll-to-top.js │ │ ├── dom.js │ │ ├── global-stats.js │ │ ├── logger.js │ │ ├── language-selector.js │ │ ├── utils.js │ │ ├── i18n-lite.js │ │ ├── theme-ui.js │ │ ├── tabs.js │ │ ├── themes.js │ │ ├── mobile-tabs.js │ │ ├── lazy-loading.js │ │ ├── i18n.js │ │ ├── auth-guard.js │ │ ├── animations.js │ │ ├── pro-stats.js │ │ └── charts.js │ ├── loading-i18n.js │ ├── login.js │ ├── setup.js │ └── app-modular.js ├── img │ └── placeholder-poster.svg ├── login.html └── loading.html ├── .env.example ├── .gitignore ├── .githooks └── pre-commit ├── .dockerignore ├── lib ├── apiRateLimiter.js ├── statsCache.js ├── graphCache.js ├── template.js ├── progressTracker.js ├── auth.js ├── graph.js ├── authMiddleware.js ├── smartCache.js ├── setup.js ├── i18n.js ├── middleware.js ├── config.js ├── crypto.js ├── util.js ├── rateLimiter.js ├── tmdb.js ├── logger.js ├── cardCache.js └── heatmapData.js ├── scripts └── update-version.js ├── .github └── workflows │ ├── push-to-dockerhub.yml │ └── release.yml ├── package.json ├── docker-entrypoint.sh ├── UNRAID.md ├── Dockerfile └── unraid-template.xml /docs/img/setup.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diabolino/trakt_enhanced/HEAD/docs/img/setup.webp -------------------------------------------------------------------------------- /docs/img/stats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diabolino/trakt_enhanced/HEAD/docs/img/stats.png -------------------------------------------------------------------------------- /docs/img/title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diabolino/trakt_enhanced/HEAD/docs/img/title.png -------------------------------------------------------------------------------- /docs/img/mobile6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diabolino/trakt_enhanced/HEAD/docs/img/mobile6.png -------------------------------------------------------------------------------- /docs/img/screenshot.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diabolino/trakt_enhanced/HEAD/docs/img/screenshot.webp -------------------------------------------------------------------------------- /docs/img/trakt-api.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diabolino/trakt_enhanced/HEAD/docs/img/trakt-api.png -------------------------------------------------------------------------------- /docs/img/dashbord-fixe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diabolino/trakt_enhanced/HEAD/docs/img/dashbord-fixe.png -------------------------------------------------------------------------------- /docs/img/progress-live.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diabolino/trakt_enhanced/HEAD/docs/img/progress-live.png -------------------------------------------------------------------------------- /docs/img/screenshot-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diabolino/trakt_enhanced/HEAD/docs/img/screenshot-ui.png -------------------------------------------------------------------------------- /docs/img/track-connect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diabolino/trakt_enhanced/HEAD/docs/img/track-connect.png -------------------------------------------------------------------------------- /docs/img/trakt-setting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diabolino/trakt_enhanced/HEAD/docs/img/trakt-setting.png -------------------------------------------------------------------------------- /public/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diabolino/trakt_enhanced/HEAD/public/assets/favicon.ico -------------------------------------------------------------------------------- /docs/img/dashboard-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diabolino/trakt_enhanced/HEAD/docs/img/dashboard-full.png -------------------------------------------------------------------------------- /docs/img/initial-loading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diabolino/trakt_enhanced/HEAD/docs/img/initial-loading.png -------------------------------------------------------------------------------- /public/assets/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diabolino/trakt_enhanced/HEAD/public/assets/favicon-96x96.png -------------------------------------------------------------------------------- /public/assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diabolino/trakt_enhanced/HEAD/public/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /public/assets/web-app-manifest-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diabolino/trakt_enhanced/HEAD/public/assets/web-app-manifest-192x192.png -------------------------------------------------------------------------------- /public/assets/web-app-manifest-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diabolino/trakt_enhanced/HEAD/public/assets/web-app-manifest-512x512.png -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Example .env 2 | PORT=30009 3 | TRAKT_CLIENT_ID= 4 | TRAKT_CLIENT_SECRET= 5 | TMDB_API_KEY= 6 | FULL_REBUILD_PASSWORD= 7 | SESSION_SECRET= 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data 2 | node_modules 3 | .env 4 | package-lock.json 5 | public/assets/fa 6 | public/assets/tailwind.css 7 | .specstory 8 | .claude 9 | reddit-post.md 10 | count-lines.sh 11 | API_ENDPOINTS.md 12 | _BACKUP 13 | *.bak 14 | cookies.txt 15 | csrf_token.txt 16 | *.tmp -------------------------------------------------------------------------------- /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Vérifie si le package.json a été modifié 4 | if git diff --cached --name-only | grep -q "package.json"; then 5 | echo "📦 package.json modifié, mise à jour du Dockerfile..." 6 | npm run update:dockerfile 7 | 8 | # Ajouter le Dockerfile modifié au commit 9 | git add Dockerfile 10 | echo "✅ Dockerfile mis à jour automatiquement" 11 | fi -------------------------------------------------------------------------------- /public/assets/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MyWebSite", 3 | "short_name": "MySite", 4 | "icons": [ 5 | { 6 | "src": "/web-app-manifest-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "maskable" 10 | }, 11 | { 12 | "src": "/web-app-manifest-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png", 15 | "purpose": "maskable" 16 | } 17 | ], 18 | "theme_color": "#000000", 19 | "background_color": "#000000", 20 | "display": "standalone" 21 | } -------------------------------------------------------------------------------- /public/img/placeholder-poster.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Pas d'image 9 | -------------------------------------------------------------------------------- /public/assets/placeholder-poster.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /public/assets/setup-url-detection.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Auto-detect current URL and set OAuth redirect URI 3 | */ 4 | 5 | document.addEventListener('DOMContentLoaded', () => { 6 | const oauthInput = document.getElementById('oauthRedirectUri'); 7 | if (oauthInput && !oauthInput.value.includes(window.location.hostname)) { 8 | const currentOrigin = window.location.origin; 9 | oauthInput.value = `${currentOrigin}/auth/callback`; 10 | 11 | // Also update the placeholder to show the detected URL 12 | oauthInput.placeholder = `${currentOrigin}/auth/callback`; 13 | 14 | console.log('[SetupUrlDetection] Auto-detected OAuth redirect URI:', `${currentOrigin}/auth/callback`); 15 | } 16 | }); -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Node 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # Docker 8 | .dockerignore 9 | Dockerfile* 10 | docker-compose*.yml 11 | 12 | # Git 13 | .git/ 14 | .gitignore 15 | 16 | # IDE 17 | .vscode/ 18 | .idea/ 19 | *.swp 20 | *.swo 21 | 22 | # OS 23 | .DS_Store 24 | Thumbs.db 25 | 26 | # Logs 27 | *.log 28 | logs/ 29 | 30 | # Runtime data 31 | pids/ 32 | *.pid 33 | *.seed 34 | *.pid.lock 35 | 36 | # Coverage 37 | coverage/ 38 | 39 | # Temporary 40 | tmp/ 41 | temp/ 42 | 43 | # Documentation 44 | *.md 45 | docs/ 46 | 47 | # Environment files (but keep .env.example if any) 48 | .env* 49 | !.env.example 50 | 51 | .specstory 52 | .claude 53 | 54 | # Sensitive data that shouldn't be in Docker images 55 | data/.secrets/ 56 | data/.cache_* 57 | data/*.json 58 | 59 | # Test files 60 | test-*.js 61 | 62 | _BACKUP -------------------------------------------------------------------------------- /lib/apiRateLimiter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Endpoint pour surveiller le rate limiting 3 | */ 4 | 5 | import { traktRateLimiter } from './rateLimiter.js'; 6 | 7 | export function getRateLimitStats(req, res) { 8 | const stats = traktRateLimiter.getStats(); 9 | 10 | res.json({ 11 | ok: true, 12 | rateLimits: { 13 | GET: { 14 | used: stats.GET.current, 15 | limit: stats.GET.limit, 16 | remaining: stats.GET.limit - stats.GET.current, 17 | percentage: stats.GET.percentage.toFixed(2) + '%', 18 | resetIn: '5 minutes' 19 | }, 20 | POST: { 21 | used: stats.POST.current, 22 | limit: stats.POST.limit, 23 | remaining: stats.POST.limit - stats.POST.current, 24 | percentage: stats.POST.percentage.toFixed(2) + '%', 25 | resetIn: '1 second' 26 | }, 27 | queueLength: stats.queueLength, 28 | timestamp: new Date().toISOString() 29 | } 30 | }); 31 | } -------------------------------------------------------------------------------- /lib/statsCache.js: -------------------------------------------------------------------------------- 1 | // lib/statsCache.js (ESM) 2 | import fs from 'node:fs/promises'; 3 | import path from 'node:path'; 4 | 5 | const DIR = path.resolve(process.cwd(), 'data', '.cache_trakt', 'stats'); 6 | 7 | async function ensureDir() { 8 | try { await fs.mkdir(DIR, { recursive: true }); } catch {} 9 | } 10 | 11 | export async function readStatsCache(key, ttlMs = 12 * 3600 * 1000) { 12 | await ensureDir(); 13 | const file = path.join(DIR, `${key}.json`); 14 | try { 15 | const txt = await fs.readFile(file, 'utf8'); 16 | const js = JSON.parse(txt); 17 | if (js && Number(js.savedAt) && (Date.now() - Number(js.savedAt)) < ttlMs) { 18 | return js.data; 19 | } 20 | } catch {} 21 | return null; 22 | } 23 | 24 | export async function writeStatsCache(key, data) { 25 | await ensureDir(); 26 | const file = path.join(DIR, `${key}.json`); 27 | try { 28 | await fs.writeFile(file, JSON.stringify({ savedAt: Date.now(), data }), 'utf8'); 29 | } catch {} 30 | } 31 | -------------------------------------------------------------------------------- /public/assets/modules/state.js: -------------------------------------------------------------------------------- 1 | /** 2 | * App State & Persistence Module 3 | * Gestion de l'état de l'application et persistance localStorage 4 | */ 5 | 6 | let state = JSON.parse(localStorage.getItem('trakt_state') || '{}'); 7 | state.tab = state.tab || 'shows'; 8 | state.sort = state.sort || { field:'watched_at', dir:'desc' }; 9 | state.q = (typeof state.q === 'string') ? state.q : ''; 10 | state.width = state.width || 'limited'; 11 | 12 | // Normaliser anciens "field:dir" 13 | if (state.sort && typeof state.sort.field === 'string' && state.sort.field.includes(':')) { 14 | const [f, d] = state.sort.field.split(':'); 15 | state.sort = { field:f, dir:d || state.sort.dir || 'desc' }; 16 | } 17 | 18 | export { state }; 19 | 20 | export function saveState() { 21 | localStorage.setItem('trakt_state', JSON.stringify(state)); 22 | } 23 | 24 | export let DATA = { 25 | showsRows: [], 26 | moviesRows: [], 27 | showsUnseenRows: [], 28 | moviesUnseenRows: [], 29 | devicePrompt: null, 30 | cacheHit: false, 31 | cacheAge: 0, 32 | title: 'Trakt Enhanced', 33 | flash: null 34 | }; -------------------------------------------------------------------------------- /lib/graphCache.js: -------------------------------------------------------------------------------- 1 | // lib/graphCache.js (ESM) 2 | import fs from 'node:fs/promises'; 3 | import path from 'node:path'; 4 | 5 | const GRAPH_CACHE_DIR = path.resolve(process.cwd(), 'data', '.cache_trakt', 'graph'); 6 | 7 | async function ensureDir() { 8 | try { await fs.mkdir(GRAPH_CACHE_DIR, { recursive: true }); } catch {} 9 | } 10 | 11 | export async function readGraphCache(type, year, ttlMs = 2 * 3600 * 1000) { 12 | await ensureDir(); 13 | const file = path.join(GRAPH_CACHE_DIR, `${year}-${type}.json`); 14 | try { 15 | const txt = await fs.readFile(file, 'utf8'); 16 | const js = JSON.parse(txt); 17 | if (js && Number(js.savedAt) && (Date.now() - Number(js.savedAt)) < ttlMs) { 18 | return js.data; // shape: { year, days, max, sum, daysWithCount } 19 | } 20 | } catch {} 21 | return null; 22 | } 23 | 24 | export async function writeGraphCache(type, year, data) { 25 | await ensureDir(); 26 | const file = path.join(GRAPH_CACHE_DIR, `${year}-${type}.json`); 27 | const payload = { savedAt: Date.now(), data }; 28 | try { await fs.writeFile(file, JSON.stringify(payload), 'utf8'); } catch {} 29 | } 30 | -------------------------------------------------------------------------------- /lib/template.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Template Module - Remplacement des placeholders dans les templates 3 | */ 4 | 5 | import fs from 'node:fs/promises'; 6 | import { logger } from './logger.js'; 7 | 8 | /** 9 | * Remplace les placeholders dans un template HTML 10 | * @param {string} templatePath - Chemin vers le fichier template 11 | * @param {object} variables - Variables à remplacer 12 | * @returns {string} Template avec variables remplacées 13 | */ 14 | export async function renderTemplate(templatePath, variables = {}) { 15 | try { 16 | let content = await fs.readFile(templatePath, 'utf-8'); 17 | 18 | // Remplacer les placeholders 19 | for (const [key, value] of Object.entries(variables)) { 20 | const placeholder = ``; 21 | content = content.replace(new RegExp(placeholder, 'g'), value || ''); 22 | } 23 | 24 | return content; 25 | 26 | } catch (error) { 27 | logger.error('Template rendering failed', { 28 | templatePath, 29 | error: error.message 30 | }); 31 | throw error; 32 | } 33 | } 34 | 35 | export default { 36 | renderTemplate 37 | }; -------------------------------------------------------------------------------- /public/assets/modules/scroll-to-top.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Scroll-to-top Module 3 | * Gestion du bouton de remontée en haut de page 4 | */ 5 | 6 | // Gestion du scroll-to-top 7 | export function initScrollToTop() { 8 | const scrollBtn = document.getElementById('scroll-to-top'); 9 | if (!scrollBtn) { 10 | console.warn('Scroll-to-top button not found'); 11 | return; 12 | } 13 | 14 | // Afficher/masquer le bouton selon le scroll 15 | function toggleScrollButton() { 16 | if (window.scrollY > 300) { 17 | scrollBtn.classList.add('visible'); 18 | } else { 19 | scrollBtn.classList.remove('visible'); 20 | } 21 | } 22 | 23 | // Smooth scroll vers le haut 24 | scrollBtn.addEventListener('click', () => { 25 | window.scrollTo({ top: 0, behavior: 'smooth' }); 26 | }); 27 | 28 | // Écouter le scroll avec throttling 29 | let scrollTimeout; 30 | window.addEventListener('scroll', () => { 31 | if (scrollTimeout) return; 32 | scrollTimeout = setTimeout(() => { 33 | toggleScrollButton(); 34 | scrollTimeout = null; 35 | }, 100); 36 | }); 37 | 38 | // Check initial state 39 | toggleScrollButton(); 40 | } -------------------------------------------------------------------------------- /scripts/update-version.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { readFileSync, writeFileSync } from 'fs'; 4 | import { join } from 'path'; 5 | 6 | const packageJsonPath = join(process.cwd(), 'package.json'); 7 | const dockerfilePath = join(process.cwd(), 'Dockerfile'); 8 | 9 | try { 10 | // Lire la version du package.json 11 | const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); 12 | const version = packageJson.version; 13 | 14 | console.log(`🔄 Mise à jour du Dockerfile vers la version ${version}`); 15 | 16 | // Lire le Dockerfile 17 | let dockerfileContent = readFileSync(dockerfilePath, 'utf8'); 18 | 19 | // Remplacer la version dans le Dockerfile 20 | dockerfileContent = dockerfileContent.replace( 21 | /LABEL org\.opencontainers\.image\.version="[^"]+"/, 22 | `LABEL org.opencontainers.image.version="${version}"` 23 | ); 24 | 25 | // Écrire le Dockerfile mis à jour 26 | writeFileSync(dockerfilePath, dockerfileContent); 27 | 28 | console.log(`✅ Version ${version} mise à jour dans le Dockerfile`); 29 | 30 | } catch (error) { 31 | console.error('❌ Erreur lors de la mise à jour:', error.message); 32 | process.exit(1); 33 | } -------------------------------------------------------------------------------- /lib/progressTracker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Module de suivi du progrès pour le chargement initial 3 | */ 4 | 5 | const activeConnections = new Set(); 6 | 7 | export function addProgressConnection(res) { 8 | activeConnections.add(res); 9 | 10 | res.on('close', () => { 11 | activeConnections.delete(res); 12 | }); 13 | } 14 | 15 | export function sendProgress(step, status, message = null, progress = null) { 16 | const data = { 17 | step, 18 | status, 19 | message, 20 | progress, 21 | timestamp: new Date().toISOString() 22 | }; 23 | 24 | const payload = `data: ${JSON.stringify(data)}\n\n`; 25 | 26 | // Envoyer à toutes les connexions actives 27 | for (const res of activeConnections) { 28 | try { 29 | res.write(payload); 30 | } catch (error) { 31 | // Connexion fermée, la supprimer 32 | activeConnections.delete(res); 33 | } 34 | } 35 | } 36 | 37 | export function sendCompletion() { 38 | const data = { 39 | completed: true, 40 | timestamp: new Date().toISOString() 41 | }; 42 | 43 | const payload = `data: ${JSON.stringify(data)}\n\n`; 44 | 45 | for (const res of activeConnections) { 46 | try { 47 | res.write(payload); 48 | res.end(); 49 | } catch (error) { 50 | // Ignoré 51 | } 52 | } 53 | 54 | activeConnections.clear(); 55 | } 56 | 57 | export function hasActiveConnections() { 58 | return activeConnections.size > 0; 59 | } -------------------------------------------------------------------------------- /lib/auth.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Authentication Module 3 | * Gestion du hashage et vérification des mots de passe 4 | */ 5 | 6 | import crypto from 'node:crypto'; 7 | 8 | /** 9 | * Hash un mot de passe avec un salt aléatoire 10 | * @param {string} password - Mot de passe en clair 11 | * @returns {string} Hash au format "salt:hash" 12 | */ 13 | export function hashPassword(password) { 14 | const salt = crypto.randomBytes(16).toString('hex'); 15 | const hash = crypto.scryptSync(password, salt, 64).toString('hex'); 16 | return `${salt}:${hash}`; 17 | } 18 | 19 | /** 20 | * Vérifie un mot de passe contre un hash 21 | * @param {string} password - Mot de passe en clair à vérifier 22 | * @param {string} hashedPassword - Hash stocké au format "salt:hash" 23 | * @returns {boolean} True si le mot de passe correspond 24 | */ 25 | export function verifyPassword(password, hashedPassword) { 26 | if (!hashedPassword || !hashedPassword.includes(':')) { 27 | return false; 28 | } 29 | 30 | const [salt, hash] = hashedPassword.split(':'); 31 | const verifyHash = crypto.scryptSync(password, salt, 64).toString('hex'); 32 | return hash === verifyHash; 33 | } 34 | 35 | /** 36 | * Vérifie si un string est déjà hashé (contient un salt) 37 | * @param {string} password - String à vérifier 38 | * @returns {boolean} True si déjà hashé 39 | */ 40 | export function isPasswordHashed(password) { 41 | return password && typeof password === 'string' && password.includes(':'); 42 | } -------------------------------------------------------------------------------- /.github/workflows/push-to-dockerhub.yml: -------------------------------------------------------------------------------- 1 | name: Build and push to Docker Hub 2 | 'on': 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - 'package.json' 8 | - 'Dockerfile' 9 | - 'lib/**' 10 | - 'public/**' 11 | - 'server.js' 12 | release: 13 | types: [published] 14 | workflow_dispatch: null 15 | permissions: 16 | contents: read 17 | jobs: 18 | build-push: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | - name: Extract version from package.json 24 | id: version 25 | run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT 26 | - name: Set up QEMU 27 | uses: docker/setup-qemu-action@v3 28 | - name: Set up Docker Buildx 29 | uses: docker/setup-buildx-action@v3 30 | - name: Log in to Docker Hub 31 | uses: docker/login-action@v3 32 | with: 33 | registry: docker.io 34 | username: ${{ secrets.DOCKERHUB_USERNAME }} 35 | password: ${{ secrets.DOCKERHUB_TOKEN }} 36 | - name: Build and push (multi-arch) 37 | uses: docker/build-push-action@v6 38 | with: 39 | context: . 40 | file: ./Dockerfile 41 | platforms: 'linux/amd64,linux/arm64' 42 | push: true 43 | tags: | 44 | docker.io/diabolino/trakt_enhanced:latest 45 | docker.io/diabolino/trakt_enhanced:v${{ steps.version.outputs.VERSION }} 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trakt_enhanced", 3 | "version": "9.0.4", 4 | "description": "Trakt Enhanced (Node.js, modular, HTML separated)", 5 | "type": "module", 6 | "main": "server.js", 7 | "scripts": { 8 | "dev": "node --watch server.js", 9 | "dev:css": "tailwindcss -i ./src/tailwind.css -o ./public/assets/tailwind.css --watch", 10 | "build:css": "tailwindcss -i ./src/tailwind.css -o ./public/assets/tailwind.css", 11 | "build:fa": "mkdir -p public/assets/fa && cp -R node_modules/@fortawesome/fontawesome-free/css public/assets/fa/css && cp -R node_modules/@fortawesome/fontawesome-free/webfonts public/assets/fa/webfonts", 12 | "build:chartjs": "cp node_modules/chart.js/dist/chart.umd.js* public/assets/ 2>/dev/null || cp node_modules/chart.js/dist/chart.umd.js public/assets/", 13 | "build": "npm run build:css && npm run build:fa && npm run build:chartjs", 14 | "start": "node server.js", 15 | "version:patch": "npm version patch --no-git-tag-version && npm run update:dockerfile", 16 | "version:minor": "npm version minor --no-git-tag-version && npm run update:dockerfile", 17 | "version:major": "npm version major --no-git-tag-version && npm run update:dockerfile", 18 | "update:dockerfile": "node scripts/update-version.js" 19 | }, 20 | "engines": { 21 | "node": ">=18.18" 22 | }, 23 | "dependencies": { 24 | "@fortawesome/fontawesome-free": "^7.0.0", 25 | "chart.js": "^4.5.0", 26 | "compression": "^1.8.1", 27 | "dotenv": "^16.4.5", 28 | "express": "^4.19.2", 29 | "express-session": "^1.18.0", 30 | "morgan": "^1.10.0", 31 | "session-file-store": "^1.5.0", 32 | "trakt.tv": "^8.2.0", 33 | "winston": "^3.17.0", 34 | "winston-daily-rotate-file": "^5.0.0", 35 | "ws": "^8.18.3" 36 | }, 37 | "devDependencies": { 38 | "@tailwindcss/cli": "^4.1.12", 39 | "tailwindcss": "^4.1.12" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Variables par défaut pour Unraid/Docker 5 | : "${PUID:=99}" 6 | : "${PGID:=100}" 7 | : "${PORT:=30009}" 8 | 9 | echo "[entrypoint] Starting with PUID=${PUID} PGID=${PGID}" 10 | 11 | # Créer TOUS les dossiers nécessaires au démarrage 12 | # Ces dossiers doivent exister AVANT que Node.js démarre 13 | REQUIRED_DIRS="/app/data /app/data/logs /app/data/.cache_trakt /app/data/.secrets /app/data/sessions /app/config" 14 | 15 | for dir in $REQUIRED_DIRS; do 16 | if [ ! -d "$dir" ]; then 17 | echo "[entrypoint] Creating directory: $dir" 18 | mkdir -p "$dir" 2>/dev/null || true 19 | fi 20 | done 21 | 22 | # Si on est root (UID 0), on peut changer les permissions 23 | if [ "$(id -u)" = "0" ]; then 24 | echo "[entrypoint] Running as root, setting permissions..." 25 | # Changer les permissions de TOUS les dossiers data 26 | chown -R ${PUID}:${PGID} /app/data /app/config 2>/dev/null || true 27 | chmod -R 755 /app/data /app/config 2>/dev/null || true 28 | 29 | # Exécuter en tant que l'utilisateur spécifié 30 | echo "[entrypoint] Switching to user ${PUID}:${PGID}" 31 | exec su-exec ${PUID}:${PGID} "$@" 32 | else 33 | # On n'est pas root, essayer quand même de créer les dossiers 34 | echo "[entrypoint] Not running as root (UID=$(id -u))" 35 | 36 | # Vérifier si on peut écrire dans /app/data 37 | if [ -w "/app/data" ]; then 38 | echo "[entrypoint] /app/data is writable, creating subdirectories..." 39 | mkdir -p /app/data/logs /app/data/.cache_trakt /app/data/.secrets /app/data/sessions 2>/dev/null || true 40 | else 41 | echo "[entrypoint] WARNING: Cannot write to /app/data - logs may fail" 42 | echo "[entrypoint] To fix: chown -R ${PUID}:${PGID} ./data on host" 43 | fi 44 | 45 | # Exécuter normalement 46 | exec "$@" 47 | fi 48 | 49 | # Provide a safe default for session secret if user didn't set it 50 | if [ -z "${SESSION_SECRET}" ]; then 51 | export SESSION_SECRET="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" 52 | echo "[entrypoint] Generated SESSION_SECRET" 53 | fi -------------------------------------------------------------------------------- /public/assets/modules/dom.js: -------------------------------------------------------------------------------- 1 | /** 2 | * DOM & UI Mount Points Module 3 | * Gestion des références DOM et éléments d'interface 4 | */ 5 | 6 | export const elements = { 7 | // Layout 8 | toggleWidth: document.getElementById('toggleWidth'), 9 | mainContainer: document.getElementById('mainContainer'), 10 | 11 | // Flash et Auth 12 | flashBox: document.getElementById('flashBox'), 13 | deviceBox: document.getElementById('deviceBox'), 14 | 15 | // Navigation tabs 16 | tabBtns: { 17 | shows: document.getElementById('tabBtnShows'), 18 | movies: document.getElementById('tabBtnMovies'), 19 | shows_unseen: document.getElementById('tabBtnShowsUnseen'), 20 | movies_unseen: document.getElementById('tabBtnMoviesUnseen'), 21 | playback: document.getElementById('tabBtnPlayback'), 22 | stats: document.getElementById('tabBtnStats'), 23 | calendar: document.getElementById('tabBtnCalendar'), 24 | }, 25 | 26 | // Panels 27 | panels: { 28 | shows: document.getElementById('panelShows'), 29 | movies: document.getElementById('panelMovies'), 30 | shows_unseen: document.getElementById('panelShowsUnseen'), 31 | movies_unseen: document.getElementById('panelMoviesUnseen'), 32 | playback: document.getElementById('panelPlayback'), 33 | stats: document.getElementById('panelStats'), 34 | calendar: document.getElementById('panelCalendar'), 35 | }, 36 | 37 | // Grids 38 | grids: { 39 | shows: document.getElementById('gridS'), 40 | movies: document.getElementById('gridM'), 41 | shows_unseen: document.getElementById('gridSU'), 42 | movies_unseen: document.getElementById('gridMU'), 43 | }, 44 | 45 | // Contrôles 46 | sortActive: document.getElementById('sortActive'), 47 | qActive: document.getElementById('qActive'), 48 | 49 | // Modals 50 | openFullModal: document.getElementById('openFullModal'), 51 | closeFullModal: document.getElementById('closeFullModal'), 52 | fullModal: document.getElementById('fullModal'), 53 | 54 | // Autres sections 55 | filtersSec: document.querySelector('section.filters:not(#mobileFilters)'), 56 | }; 57 | 58 | // Configuration des options de tri 59 | export const SORT_ALL = Array.from(elements.sortActive.querySelectorAll('option')).map(o => ({ 60 | value: o.value, 61 | label: o.textContent, 62 | for: (o.getAttribute('data-for') || '') 63 | })); -------------------------------------------------------------------------------- /public/assets/modules/global-stats.js: -------------------------------------------------------------------------------- 1 | export async function loadGlobalStats() { 2 | try { 3 | const response = await fetch('/api/stats'); 4 | const data = await response.json(); 5 | 6 | if (!data.ok || !data.stats) { 7 | console.error('Failed to load global stats'); 8 | return; 9 | } 10 | 11 | const stats = data.stats; 12 | displayGlobalStats(stats); 13 | } catch (error) { 14 | console.error('Error loading global stats:', error); 15 | } 16 | } 17 | 18 | function displayGlobalStats(stats) { 19 | // Movies stats 20 | const moviesWatched = document.getElementById('moviesWatched'); 21 | const moviesCollected = document.getElementById('moviesCollected'); 22 | if (moviesWatched) moviesWatched.textContent = formatNumber(stats.movies?.watched || 0); 23 | if (moviesCollected) moviesCollected.textContent = formatNumber(stats.movies?.collected || 0); 24 | 25 | // Shows stats 26 | const showsWatched = document.getElementById('showsWatched'); 27 | const showsCollected = document.getElementById('showsCollected'); 28 | if (showsWatched) showsWatched.textContent = formatNumber(stats.shows?.watched || 0); 29 | if (showsCollected) showsCollected.textContent = formatNumber(stats.shows?.collected || 0); 30 | 31 | // Episodes stats 32 | const episodesWatched = document.getElementById('episodesWatched'); 33 | const episodesCollected = document.getElementById('episodesCollected'); 34 | if (episodesWatched) episodesWatched.textContent = formatNumber(stats.episodes?.watched || 0); 35 | if (episodesCollected) episodesCollected.textContent = formatNumber(stats.episodes?.collected || 0); 36 | 37 | // Watch time 38 | const totalMinutes = document.getElementById('totalMinutes'); 39 | const totalTime = document.getElementById('totalTime'); 40 | const minutes = stats.movies?.minutes + stats.episodes?.minutes || 0; 41 | if (totalMinutes) { 42 | const days = Math.floor(minutes / 1440); 43 | const hours = Math.floor((minutes % 1440) / 60); 44 | totalMinutes.textContent = `${days}d ${hours}h`; 45 | } 46 | if (totalTime) { 47 | totalTime.textContent = `${formatNumber(minutes)} min`; 48 | } 49 | } 50 | 51 | function formatNumber(num) { 52 | if (num >= 1000000) { 53 | return (num / 1000000).toFixed(1) + 'M'; 54 | } else if (num >= 1000) { 55 | return (num / 1000).toFixed(1) + 'K'; 56 | } 57 | return num.toLocaleString(); 58 | } -------------------------------------------------------------------------------- /lib/graph.js: -------------------------------------------------------------------------------- 1 | // lib/graph.js 2 | import { get as traktGet, headers as traktHeaders, loadToken, hasValidCredentials } from './trakt.js'; 3 | 4 | // YYYY-MM-DD 5 | const dkey = (d) => new Date(d).toISOString().slice(0,10); 6 | 7 | function yearRange(year) { 8 | const start = new Date(Date.UTC(year, 0, 1, 0, 0, 0)); 9 | const end = new Date(Date.UTC(year, 11, 31, 23, 59, 59)); 10 | return { start, end }; 11 | } 12 | 13 | // Récupère l'historique (par pages) borné à l'année, et agrège par jour 14 | export async function dailyCounts({ type='all', year=new Date().getFullYear(), token }) { 15 | if (!hasValidCredentials()) { 16 | throw new Error('Missing Trakt credentials. Please configure TRAKT_CLIENT_ID and TRAKT_CLIENT_SECRET.'); 17 | } 18 | 19 | // L'authentification est maintenant gérée automatiquement par traktGet 20 | // if (!token) { 21 | // const tk = await loadToken(); 22 | // token = tk?.access_token; 23 | // } 24 | // if (!token) throw new Error('No Trakt token'); 25 | 26 | // const hdrs = traktHeaders(token); 27 | const { start, end } = yearRange(Number(year)); 28 | 29 | const typePath = (type === 'movies') ? '/movies' : (type === 'shows' ? '/episodes' : ''); 30 | const startISO = start.toISOString(); 31 | const endISO = end.toISOString(); 32 | const perPage = 100; 33 | 34 | const counts = new Map(); // 'YYYY-MM-DD' -> n 35 | let page = 1; 36 | // On boucle tant qu’on reçoit des items 37 | for (;;) { 38 | const qs = `?page=${page}&limit=${perPage}&start_at=${encodeURIComponent(startISO)}&end_at=${encodeURIComponent(endISO)}`; 39 | const ep = `/users/me/history${typePath}${qs}`; 40 | const items = await traktGet(ep); // JSON already 41 | if (!Array.isArray(items) || items.length === 0) break; 42 | 43 | for (const it of items) { 44 | const k = dkey(it.watched_at || it.watchedAt || it.completed_at || start); 45 | counts.set(k, (counts.get(k) || 0) + 1); 46 | } 47 | if (items.length < perPage) break; 48 | page += 1; 49 | } 50 | 51 | // On sort toutes les dates de l’année (même celles = 0) 52 | const days = []; 53 | for (let t = start.getTime(); t <= end.getTime(); t += 86400000) { 54 | const k = dkey(t); 55 | days.push({ date: k, count: counts.get(k) || 0 }); 56 | } 57 | 58 | const sum = days.reduce((s,x)=>s+x.count, 0); 59 | const max = days.reduce((m,x)=>Math.max(m,x.count), 0); 60 | const daysWithCount = days.filter(d=>d.count>0).length; 61 | 62 | return { year: Number(year), type, sum, max, daysWithCount, days }; 63 | } 64 | -------------------------------------------------------------------------------- /public/assets/modules/logger.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Client-side Logger with configurable log levels 3 | * Par défaut, ne montre que les warnings et erreurs en production 4 | */ 5 | 6 | // Niveaux de logs (plus bas = plus verbeux) 7 | const LOG_LEVELS = { 8 | DEBUG: 0, 9 | INFO: 1, 10 | WARN: 2, 11 | ERROR: 3, 12 | SILENT: 4 13 | }; 14 | 15 | class ClientLogger { 16 | constructor() { 17 | // Déterminer le niveau de log selon l'environnement 18 | this.level = this.getLogLevel(); 19 | this.prefix = '[Client]'; 20 | } 21 | 22 | getLogLevel() { 23 | // Vérifier si on est en développement (localhost ou paramètre URL) 24 | const isDev = window.location.hostname === 'localhost' || 25 | window.location.hostname === '127.0.0.1' || 26 | new URLSearchParams(window.location.search).has('debug'); 27 | 28 | if (isDev) { 29 | return LOG_LEVELS.DEBUG; // Tout afficher en dev 30 | } else { 31 | return LOG_LEVELS.WARN; // Seulement warn/error en production 32 | } 33 | } 34 | 35 | debug(...args) { 36 | if (this.level <= LOG_LEVELS.DEBUG) { 37 | console.log(`${this.prefix} [DEBUG]`, ...args); 38 | } 39 | } 40 | 41 | info(...args) { 42 | if (this.level <= LOG_LEVELS.INFO) { 43 | console.info(`${this.prefix} [INFO]`, ...args); 44 | } 45 | } 46 | 47 | warn(...args) { 48 | if (this.level <= LOG_LEVELS.WARN) { 49 | console.warn(`${this.prefix} [WARN]`, ...args); 50 | } 51 | } 52 | 53 | error(...args) { 54 | if (this.level <= LOG_LEVELS.ERROR) { 55 | console.error(`${this.prefix} [ERROR]`, ...args); 56 | } 57 | } 58 | 59 | // Méthodes spécialisées pour les modules 60 | liveUpdates(...args) { 61 | if (this.level <= LOG_LEVELS.DEBUG) { 62 | console.log('[LiveUpdates]', ...args); 63 | } 64 | } 65 | 66 | liveUpdatesWarn(...args) { 67 | if (this.level <= LOG_LEVELS.WARN) { 68 | console.warn('[LiveUpdates]', ...args); 69 | } 70 | } 71 | 72 | liveUpdatesError(...args) { 73 | if (this.level <= LOG_LEVELS.ERROR) { 74 | console.error('[LiveUpdates]', ...args); 75 | } 76 | } 77 | 78 | // Méthode pour afficher le niveau de log actuel 79 | showLevel() { 80 | const levelName = Object.keys(LOG_LEVELS).find(key => LOG_LEVELS[key] === this.level); 81 | console.info(`${this.prefix} Log level: ${levelName}`); 82 | } 83 | } 84 | 85 | // Instance globale 86 | const logger = new ClientLogger(); 87 | 88 | // Afficher le niveau au démarrage seulement en debug 89 | if (logger.level <= LOG_LEVELS.DEBUG) { 90 | logger.showLevel(); 91 | } 92 | 93 | export default logger; -------------------------------------------------------------------------------- /UNRAID.md: -------------------------------------------------------------------------------- 1 | # Configuration Unraid pour Trakt Enhanced 2 | 3 | ## Volumes à mapper obligatoirement 4 | 5 | Pour conserver vos données et configuration entre les mises à jour, vous devez mapper ces volumes dans Unraid : 6 | 7 | ### 1. Données persistantes 8 | - **Conteneur**: `/app/data` 9 | - **Hôte**: `/mnt/user/appdata/trakt-enhanced/data` 10 | - **Type**: Lecture/Écriture (RW) 11 | - **Description**: Cache Trakt/TMDB, sessions, tokens d'authentification, cache d'images 12 | 13 | ### 2. Configuration (.env) 14 | - **Conteneur**: `/app/config` 15 | - **Hôte**: `/mnt/user/appdata/trakt-enhanced/config` 16 | - **Type**: Lecture/Écriture (RW) 17 | - **Description**: Dossier de configuration (le fichier .env y sera créé automatiquement) 18 | 19 | ## Configuration Unraid complète 20 | 21 | ```yaml 22 | # Configuration Container dans Unraid 23 | Name: trakt-enhanced 24 | Repository: diabolino/trakt_enhanced:latest 25 | Network Type: bridge 26 | 27 | # Variables d'environnement optionnelles 28 | - PORT: 30009 (par défaut) 29 | 30 | # Ports 31 | - Container Port: 30009 32 | - Host Port: 30009 33 | - Protocol: TCP 34 | 35 | # Volumes (OBLIGATOIRES pour persistance) 36 | - Container Path: /app/data 37 | Host Path: /mnt/user/appdata/trakt-enhanced/data 38 | Access Mode: Read/Write 39 | 40 | - Container Path: /app/config 41 | Host Path: /mnt/user/appdata/trakt-enhanced/config 42 | Access Mode: Read/Write 43 | ``` 44 | 45 | ## Configuration initiale 46 | 47 | 1. **Premier démarrage** : Accédez à `http://votre-ip:30009` 48 | 2. **Configuration** : L'interface vous guidera pour configurer vos clés API 49 | 3. **Après configuration** : Le fichier `.env` sera créé automatiquement 50 | 51 | ## Mise à jour 52 | 53 | Avec cette configuration, vos données et paramètres persisteront lors des mises à jour : 54 | - Arrêtez le conteneur 55 | - Mettez à jour vers la nouvelle image 56 | - Redémarrez → Configuration et données conservées ✅ 57 | 58 | ## Troubleshooting 59 | 60 | ### Permissions 61 | Le conteneur utilise UID/GID 99:100 (standard Unraid). Si vous avez des erreurs de permissions : 62 | ```bash 63 | # Depuis Unraid terminal - créer les dossiers avec les bonnes permissions 64 | mkdir -p /mnt/user/appdata/trakt-enhanced/{data,data/logs,config} 65 | chown -R 99:100 /mnt/user/appdata/trakt-enhanced/ 66 | chmod -R 755 /mnt/user/appdata/trakt-enhanced/ 67 | ``` 68 | 69 | ### Backup recommandé 70 | ```bash 71 | # Sauvegarder la configuration 72 | cp /mnt/user/appdata/trakt-enhanced/config/.env /mnt/user/appdata/trakt-enhanced/config/.env.backup 73 | 74 | # Sauvegarder les données 75 | tar -czf trakt-enhanced-backup.tar.gz /mnt/user/appdata/trakt-enhanced/ 76 | ``` -------------------------------------------------------------------------------- /lib/authMiddleware.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Auth Middleware 3 | * Vérifie que le token Trakt est valide pour tous les endpoints API 4 | */ 5 | 6 | import { hasValidCredentials, loadToken, ensureValidToken } from './trakt.js'; 7 | import { logger } from './logger.js'; 8 | 9 | /** 10 | * Liste des endpoints qui ne nécessitent pas d'authentification 11 | */ 12 | const AUTH_EXEMPT_ENDPOINTS = [ 13 | '/health', 14 | '/setup', 15 | '/auth', 16 | '/oauth', 17 | '/api/data', // Autorisé pour vérifier l'état d'auth 18 | '/api/token-status', // Autorisé pour vérifier le statut du token 19 | '/loading', 20 | '/favicon.ico', 21 | '/assets', 22 | '/locales' 23 | ]; 24 | 25 | /** 26 | * Middleware qui vérifie l'authentification Trakt 27 | */ 28 | export async function requireAuth(req, res, next) { 29 | // Vérifier si l'endpoint est exempté 30 | const path = req.path; 31 | const isExempt = AUTH_EXEMPT_ENDPOINTS.some(exempt => 32 | path === exempt || path.startsWith(exempt + '/') 33 | ); 34 | 35 | if (isExempt) { 36 | return next(); 37 | } 38 | 39 | // Vérifier les credentials de base 40 | if (!hasValidCredentials()) { 41 | logger.warn(`[AuthMiddleware] Missing Trakt credentials for ${path}`); 42 | return res.status(412).json({ 43 | ok: false, 44 | error: 'Trakt configuration missing', 45 | needsSetup: true 46 | }); 47 | } 48 | 49 | // Vérifier le token 50 | try { 51 | const token = await loadToken(); 52 | 53 | if (!token?.access_token) { 54 | logger.warn(`[AuthMiddleware] No valid token for ${path}`); 55 | return res.status(401).json({ 56 | ok: false, 57 | error: 'Authentication required', 58 | needsAuth: true 59 | }); 60 | } 61 | 62 | // Vérifier si le token est toujours valide 63 | const validToken = await ensureValidToken(); 64 | if (!validToken?.access_token) { 65 | logger.warn(`[AuthMiddleware] Token expired or invalid for ${path}`); 66 | return res.status(401).json({ 67 | ok: false, 68 | error: 'Authentication expired', 69 | needsAuth: true 70 | }); 71 | } 72 | 73 | // Token valide, continuer 74 | req.traktToken = validToken; 75 | next(); 76 | 77 | } catch (error) { 78 | logger.error(`[AuthMiddleware] Error checking auth for ${path}:`, error); 79 | return res.status(500).json({ 80 | ok: false, 81 | error: 'Authentication check failed' 82 | }); 83 | } 84 | } 85 | 86 | /** 87 | * Middleware optionnel qui vérifie l'auth mais ne bloque pas 88 | */ 89 | export async function checkAuth(req, res, next) { 90 | try { 91 | if (!hasValidCredentials()) { 92 | req.hasAuth = false; 93 | return next(); 94 | } 95 | 96 | const token = await loadToken(); 97 | req.hasAuth = !!token?.access_token; 98 | req.traktToken = token; 99 | next(); 100 | 101 | } catch (error) { 102 | req.hasAuth = false; 103 | next(); 104 | } 105 | } 106 | 107 | export default requireAuth; -------------------------------------------------------------------------------- /public/assets/modules/language-selector.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Language Selector Module 3 | */ 4 | 5 | import i18n from './i18n.js'; 6 | 7 | class LanguageSelector { 8 | constructor() { 9 | this.langToggle = document.getElementById('langToggle'); 10 | this.langToggleText = document.getElementById('langToggleText'); 11 | this.langDropdown = document.getElementById('langDropdown'); 12 | this.isOpen = false; 13 | } 14 | 15 | init() { 16 | if (!this.langToggle) return; 17 | 18 | this.updateUI(); 19 | this.attachEventListeners(); 20 | 21 | // Listen for language changes 22 | window.addEventListener('languageChanged', () => { 23 | this.updateUI(); 24 | }); 25 | } 26 | 27 | attachEventListeners() { 28 | // Toggle dropdown 29 | this.langToggle.addEventListener('click', (e) => { 30 | e.stopPropagation(); 31 | this.toggleDropdown(); 32 | }); 33 | 34 | // Language selection 35 | this.langDropdown.addEventListener('click', async (e) => { 36 | const button = e.target.closest('[data-lang]'); 37 | if (!button) return; 38 | 39 | e.preventDefault(); 40 | e.stopPropagation(); 41 | 42 | const selectedLang = button.dataset.lang; 43 | if (selectedLang !== i18n.getCurrentLanguage()) { 44 | await this.changeLanguage(selectedLang); 45 | } 46 | 47 | this.closeDropdown(); 48 | }); 49 | 50 | // Close dropdown when clicking outside 51 | document.addEventListener('click', () => { 52 | this.closeDropdown(); 53 | }); 54 | 55 | // Close dropdown on escape 56 | document.addEventListener('keydown', (e) => { 57 | if (e.key === 'Escape') { 58 | this.closeDropdown(); 59 | } 60 | }); 61 | } 62 | 63 | toggleDropdown() { 64 | if (this.isOpen) { 65 | this.closeDropdown(); 66 | } else { 67 | this.openDropdown(); 68 | } 69 | } 70 | 71 | openDropdown() { 72 | this.langDropdown.classList.remove('hidden'); 73 | this.isOpen = true; 74 | } 75 | 76 | closeDropdown() { 77 | this.langDropdown.classList.add('hidden'); 78 | this.isOpen = false; 79 | } 80 | 81 | async changeLanguage(lang) { 82 | const success = await i18n.changeLanguage(lang); 83 | if (success) { 84 | // Ne pas recharger la page - laissons les événements faire leur travail 85 | } else { 86 | console.error(`[LanguageSelector] Failed to change to ${lang}`); 87 | } 88 | } 89 | 90 | updateUI() { 91 | const currentLang = i18n.getCurrentLanguage(); 92 | const langMap = { 93 | 'fr': { text: 'FR', flag: '🇫🇷' }, 94 | 'en': { text: 'EN', flag: '🇺🇸' } 95 | }; 96 | 97 | const langInfo = langMap[currentLang] || langMap['fr']; 98 | if (this.langToggleText) { 99 | this.langToggleText.textContent = langInfo.text; 100 | } 101 | 102 | // Update active state in dropdown 103 | const buttons = this.langDropdown.querySelectorAll('[data-lang]'); 104 | buttons.forEach(button => { 105 | button.classList.toggle('bg-white/20', button.dataset.lang === currentLang); 106 | }); 107 | 108 | // Update tooltip 109 | if (this.langToggle) { 110 | this.langToggle.title = i18n.t('tooltips.change_language') || 'Changer de langue'; 111 | } 112 | } 113 | } 114 | 115 | // Initialize and export 116 | const languageSelector = new LanguageSelector(); 117 | export default languageSelector; -------------------------------------------------------------------------------- /public/assets/modules/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Utilities Module 3 | * Fonctions utilitaires partagées 4 | */ 5 | 6 | import { elements } from './dom.js'; 7 | import { state, saveState } from './state.js'; 8 | import i18n from './i18n.js'; 9 | 10 | export function posterURL(u) { 11 | if (!u) return ''; 12 | // Si c'est déjà un chemin vers cache_imgs, le retourner directement 13 | if (u.includes('/cache_imgs/')) { 14 | return u; // Servir directement depuis cache_imgs 15 | } 16 | // Si c'est juste un nom de fichier, construire le chemin complet 17 | const clean = u.replace(/^\/+/, ''); 18 | return `/cache_imgs/${clean}`; 19 | } 20 | 21 | export function escapeAttr(s) { 22 | return String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''').replace(/[\r\n]+/g,' '); 24 | } 25 | 26 | export function esc(s) { 27 | return String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); 29 | } 30 | 31 | export function applyWidth() { 32 | const full = state.width === 'full'; 33 | const containers = [ 34 | document.getElementById('watching-progress'), 35 | document.getElementById('flashBox'), 36 | document.getElementById('deviceBox') 37 | ]; 38 | 39 | 40 | // Fonction pour obtenir le texte traduit avec fallback 41 | const getTranslatedText = (key, fallback) => { 42 | 43 | if (typeof i18n !== 'undefined' && i18n.t && i18n.translations && Object.keys(i18n.translations).length > 0) { 44 | const translated = i18n.t(key); 45 | return translated; 46 | } 47 | return fallback; 48 | }; 49 | 50 | if (full) { 51 | elements.mainContainer.classList.remove('max-w-7xl','mx-auto'); 52 | elements.mainContainer.classList.add('w-full','max-w-none'); 53 | // Changer l'attribut data-i18n au lieu d'écraser le texte 54 | const span = elements.toggleWidth?.querySelector('span'); 55 | if (span) { 56 | span.setAttribute('data-i18n', 'buttons.limited_width'); 57 | span.textContent = getTranslatedText('buttons.limited_width', 'Limited width'); 58 | } 59 | 60 | // Appliquer la même logique à tous les conteneurs concernés 61 | containers.forEach(container => { 62 | if (container) { 63 | container.classList.remove('max-w-7xl','mx-auto'); 64 | container.classList.add('w-full','max-w-none'); 65 | } 66 | }); 67 | } else { 68 | elements.mainContainer.classList.add('max-w-7xl','mx-auto'); 69 | elements.mainContainer.classList.remove('w-full','max-w-none'); 70 | // Changer l'attribut data-i18n au lieu d'écraser le texte 71 | const span = elements.toggleWidth?.querySelector('span'); 72 | if (span) { 73 | span.setAttribute('data-i18n', 'buttons.full_width'); 74 | span.textContent = getTranslatedText('buttons.full_width', 'Full width'); 75 | } 76 | 77 | // Appliquer la même logique à tous les conteneurs concernés 78 | containers.forEach(container => { 79 | if (container) { 80 | container.classList.add('max-w-7xl','mx-auto'); 81 | container.classList.remove('w-full','max-w-none'); 82 | } 83 | }); 84 | } 85 | } 86 | 87 | export function humanMinutes(min) { 88 | return i18n.formatTime(min); 89 | } 90 | 91 | // Écouter les changements de langue pour mettre à jour le bouton largeur 92 | window.addEventListener('languageChanged', () => { 93 | applyWidth(); // Re-applique la largeur avec les nouvelles traductions 94 | }); 95 | 96 | // Écouter l'événement personnalisé pour mettre à jour le bouton largeur 97 | window.addEventListener('updateWidthButton', () => { 98 | applyWidth(); 99 | }); -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: [ 'package.json' ] 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | check-version: 14 | runs-on: ubuntu-latest 15 | outputs: 16 | version-changed: ${{ steps.version-check.outputs.changed }} 17 | version: ${{ steps.version-check.outputs.version }} 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 2 23 | 24 | - name: Check if version changed 25 | id: version-check 26 | run: | 27 | CURRENT_VERSION=$(node -p "require('./package.json').version") 28 | echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT 29 | 30 | if git show HEAD~1:package.json > /dev/null 2>&1; then 31 | PREV_VERSION=$(git show HEAD~1:package.json | node -p "JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8')).version") 32 | if [ "$CURRENT_VERSION" != "$PREV_VERSION" ]; then 33 | echo "changed=true" >> $GITHUB_OUTPUT 34 | echo "Version changed: $PREV_VERSION -> $CURRENT_VERSION" 35 | else 36 | echo "changed=false" >> $GITHUB_OUTPUT 37 | echo "Version unchanged: $CURRENT_VERSION" 38 | fi 39 | else 40 | echo "changed=true" >> $GITHUB_OUTPUT 41 | echo "No previous version found, treating as new release" 42 | fi 43 | 44 | create-release: 45 | needs: check-version 46 | if: needs.check-version.outputs.version-changed == 'true' 47 | runs-on: ubuntu-latest 48 | steps: 49 | - name: Checkout 50 | uses: actions/checkout@v4 51 | with: 52 | fetch-depth: 0 53 | 54 | - name: Generate release notes 55 | id: release-notes 56 | run: | 57 | VERSION="v${{ needs.check-version.outputs.version }}" 58 | echo "Generating release notes for $VERSION" 59 | 60 | # Get commits since last tag (or all commits if no tags) 61 | LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") 62 | if [ -n "$LAST_TAG" ]; then 63 | COMMITS=$(git log --pretty=format:"- %s" $LAST_TAG..HEAD) 64 | else 65 | COMMITS=$(git log --pretty=format:"- %s" --max-count=10) 66 | fi 67 | 68 | # Create release notes 69 | cat > release_notes.md << EOF 70 | # Trakt Enhanced $VERSION 71 | 72 | ## 🚀 What's New 73 | $COMMITS 74 | 75 | ## 📦 Installation 76 | 77 | ### Docker (Recommended) 78 | \`\`\`bash 79 | docker run -d \\ 80 | --name=trakt_enhanced \\ 81 | -p 30009:30009 \\ 82 | -v trakt_data:/app/data \\ 83 | --restart unless-stopped \\ 84 | docker.io/diabolino/trakt_enhanced:$VERSION 85 | \`\`\` 86 | 87 | ## 🔧 Configuration 88 | Visit the setup page on first run to configure your Trakt and TMDB API keys. 89 | EOF 90 | 91 | - name: Create Release 92 | uses: softprops/action-gh-release@v2 93 | with: 94 | tag_name: v${{ needs.check-version.outputs.version }} 95 | name: "Trakt Enhanced v${{ needs.check-version.outputs.version }}" 96 | body_path: release_notes.md 97 | draft: false 98 | prerelease: false 99 | generate_release_notes: true 100 | append_body: true -------------------------------------------------------------------------------- /public/assets/modules/i18n-lite.js: -------------------------------------------------------------------------------- 1 | /** 2 | * I18n Lite Module - Lightweight i18n for setup/loading pages 3 | * Simplified version without full UI integration 4 | */ 5 | 6 | class I18nLite { 7 | constructor() { 8 | this.currentLang = 'fr'; 9 | this.translations = {}; 10 | this.fallbackLang = 'fr'; 11 | this.supportedLangs = ['fr', 'en']; 12 | } 13 | 14 | async init() { 15 | this.currentLang = this.detectLanguage(); 16 | await this.loadTranslations(this.currentLang); 17 | 18 | if (this.currentLang !== this.fallbackLang) { 19 | await this.loadTranslations(this.fallbackLang); 20 | } 21 | 22 | } 23 | 24 | detectLanguage() { 25 | // 1. Check localStorage 26 | const stored = localStorage.getItem('trakt_lang'); 27 | if (stored && this.supportedLangs.includes(stored)) { 28 | return stored; 29 | } 30 | 31 | // 2. Check navigator language 32 | const nav = navigator.language || navigator.userLanguage || 'fr'; 33 | const navLang = nav.split('-')[0]; 34 | 35 | if (this.supportedLangs.includes(navLang)) { 36 | return navLang; 37 | } 38 | 39 | return this.fallbackLang; 40 | } 41 | 42 | async loadTranslations(lang) { 43 | try { 44 | const response = await fetch(`/locales/${lang}.json`); 45 | if (!response.ok) { 46 | throw new Error(`Failed to load ${lang} translations`); 47 | } 48 | 49 | this.translations[lang] = await response.json(); 50 | } catch (error) { 51 | console.error(`[i18n-lite] Error loading ${lang} translations:`, error); 52 | 53 | if (lang !== this.fallbackLang && !this.translations[this.fallbackLang]) { 54 | this.currentLang = this.fallbackLang; 55 | } 56 | } 57 | } 58 | 59 | t(key, vars = {}) { 60 | const translation = this.getTranslation(key, this.currentLang) || 61 | this.getTranslation(key, this.fallbackLang) || 62 | key; 63 | 64 | return this.interpolate(translation, vars); 65 | } 66 | 67 | getTranslation(key, lang) { 68 | const keys = key.split('.'); 69 | let current = this.translations[lang]; 70 | 71 | for (const k of keys) { 72 | if (current && typeof current === 'object' && k in current) { 73 | current = current[k]; 74 | } else { 75 | return null; 76 | } 77 | } 78 | 79 | return current; 80 | } 81 | 82 | interpolate(text, vars) { 83 | if (typeof text !== 'string') return text; 84 | 85 | return text.replace(/\{(\w+)\}/g, (match, key) => { 86 | return vars.hasOwnProperty(key) ? vars[key] : match; 87 | }); 88 | } 89 | 90 | updatePageLanguage(section) { 91 | // Update HTML lang attribute 92 | document.documentElement.lang = this.currentLang; 93 | 94 | // Update page title 95 | document.title = this.t(`${section}.page_title`); 96 | 97 | } 98 | 99 | getCurrentLanguage() { 100 | return this.currentLang; 101 | } 102 | 103 | async changeLanguage(lang) { 104 | if (!this.supportedLangs.includes(lang)) { 105 | console.warn(`[i18n-lite] Unsupported language: ${lang}`); 106 | return false; 107 | } 108 | 109 | // Load translations if not already loaded 110 | if (!this.translations[lang]) { 111 | await this.loadTranslations(lang); 112 | } 113 | 114 | this.currentLang = lang; 115 | localStorage.setItem('trakt_lang', lang); 116 | 117 | // Update HTML lang attribute 118 | document.documentElement.lang = lang; 119 | 120 | return true; 121 | } 122 | } 123 | 124 | // Export for use in setup/loading pages 125 | window.I18nLite = I18nLite; -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.7 2 | FROM node:20-alpine AS build 3 | 4 | # Build metadata 5 | LABEL org.opencontainers.image.title="Trakt Enhanced" 6 | LABEL org.opencontainers.image.description="Trakt Enhanced Node.js application" 7 | LABEL org.opencontainers.image.version="9.0.4" 8 | LABEL org.opencontainers.image.url="https://hub.docker.com/r/diabolino/trakt_enhanced" 9 | LABEL org.opencontainers.image.documentation="https://github.com/diabolino/trakt-enhanced/blob/main/README.md" 10 | LABEL org.opencontainers.image.source="https://github.com/diabolino/trakt-enhanced" 11 | LABEL org.opencontainers.image.vendor="Trakt Enhanced" 12 | LABEL org.opencontainers.image.authors="matt" 13 | 14 | # Install build deps for Alpine 15 | RUN apk add --no-cache \ 16 | git ca-certificates curl 17 | 18 | WORKDIR /src 19 | 20 | # copy package files first for caching 21 | COPY package*.json ./ 22 | 23 | # Install dependencies 24 | RUN set -eux; \ 25 | echo "[build] Installing dependencies"; \ 26 | if [ -f package-lock.json ]; then \ 27 | echo "[build] running npm ci (with legacy-peer-deps)"; \ 28 | npm ci --legacy-peer-deps --verbose || \ 29 | (echo "[build] npm ci failed, falling back to npm install" && \ 30 | npm install --legacy-peer-deps --no-audit --progress=false); \ 31 | else \ 32 | echo "[build] no package-lock.json, running npm install"; \ 33 | npm install --legacy-peer-deps --no-audit --progress=false; \ 34 | fi 35 | 36 | # copy full repo 37 | COPY . . 38 | 39 | # Clean any existing tokens, caches, or sensitive data that shouldn't be in the image 40 | RUN rm -rf data/.secrets/ data/.cache_* data/*.json || true 41 | 42 | # build assets (tailwind + fontawesome) 43 | RUN npm run build 44 | 45 | # prune dev deps to reduce image size 46 | RUN npm prune --production || true 47 | 48 | # runtime image 49 | FROM node:20-alpine AS runtime 50 | ENV NODE_ENV=production 51 | ENV PORT=30009 52 | ENV TZ=UTC 53 | ENV SESSION_SECRET="" 54 | ENV PUBLIC_HOST="" 55 | ENV DOCKER_HOST_IP="" 56 | ENV FULL_REBUILD_PASSWORD="" 57 | ENV PUID=99 58 | ENV PGID=100 59 | 60 | # IMPORTANT: Install su-exec for privilege dropping 61 | RUN apk add --no-cache ca-certificates tzdata curl su-exec 62 | 63 | WORKDIR /app 64 | 65 | # copy app from build stage 66 | COPY --from=build /src /app 67 | 68 | # copy logo for metadata/branding 69 | COPY --from=build /src/public/assets/favicon.svg /app/logo.svg 70 | 71 | # IMPORTANT: Créer TOUS les dossiers nécessaires avec les bonnes permissions 72 | # Ces dossiers seront créés dans l'image elle-même 73 | RUN mkdir -p /app/data \ 74 | /app/data/logs \ 75 | /app/data/.cache_trakt \ 76 | /app/data/.secrets \ 77 | /app/data/sessions \ 78 | /app/config && \ 79 | # Donner les permissions à tout le monde (sera restreint par l'entrypoint) 80 | chmod -R 777 /app/data /app/config && \ 81 | # Créer un fichier témoin pour vérifier les permissions 82 | touch /app/data/.docker_initialized && \ 83 | chmod 666 /app/data/.docker_initialized 84 | 85 | # Enhanced entrypoint script with better permission handling 86 | COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh 87 | RUN chmod +x /usr/local/bin/docker-entrypoint.sh 88 | 89 | # Declare volumes for persistent data 90 | VOLUME ["/app/data", "/app/config"] 91 | 92 | EXPOSE 30009 93 | 94 | HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ 95 | CMD curl -f http://127.0.0.1:${PORT:-30009}/health || exit 1 96 | 97 | # IMPORTANT: Ne PAS définir USER ici, laisser l'entrypoint gérer 98 | # Cela permet à l'entrypoint de créer les dossiers si nécessaire 99 | ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] 100 | CMD ["node", "server.js"] -------------------------------------------------------------------------------- /lib/smartCache.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Smart Cache Management - Invalidation sélective par série 3 | * Résout les problèmes de désynchronisation entre caches 4 | */ 5 | 6 | import fsp from 'node:fs/promises'; 7 | import { jsonLoad, jsonSave } from './util.js'; 8 | import { PAGE_CACHE_FILE } from './config.js'; 9 | import { enrichShowsWithProgressOptimized } from './trakt.js'; 10 | 11 | /** 12 | * Invalidation intelligente : Met à jour une série spécifique dans le cache global 13 | * @param {number} traktId - ID Trakt de la série à mettre à jour 14 | * @param {Function} headersFunc - Fonction qui retourne les headers d'auth 15 | */ 16 | export async function smartInvalidateShow(traktId, headersFunc) { 17 | try { 18 | console.log(`[smartCache] Updating show ${traktId} in global cache`); 19 | 20 | // 1. Charger le cache global 21 | const globalCache = await jsonLoad(PAGE_CACHE_FILE); 22 | if (!globalCache) { 23 | console.log('[smartCache] No global cache found, skipping smart update'); 24 | return false; 25 | } 26 | 27 | // 2. Trouver la série dans toutes les sections du cache 28 | const sections = ['showsRows', 'showsUnseenRows']; 29 | let updated = false; 30 | 31 | for (const section of sections) { 32 | const rows = globalCache[section] || []; 33 | const showIndex = rows.findIndex(row => row.ids?.trakt === traktId); 34 | 35 | if (showIndex !== -1) { 36 | console.log(`[smartCache] Found show in ${section}, updating...`); 37 | 38 | // 3. Récupérer les nouvelles données depuis l'API (le cache progress a été invalidé) 39 | const showToUpdate = [rows[showIndex]]; 40 | await enrichShowsWithProgressOptimized(showToUpdate, { 41 | updateMissing: true, 42 | headers: headersFunc 43 | }); 44 | 45 | // 4. Remplacer la série dans le cache 46 | rows[showIndex] = showToUpdate[0]; 47 | updated = true; 48 | 49 | console.log(`[smartCache] Updated show ${showToUpdate[0].title} in ${section}`); 50 | } 51 | } 52 | 53 | if (updated) { 54 | // 5. Sauvegarder le cache modifié 55 | await jsonSave(PAGE_CACHE_FILE, globalCache); 56 | console.log(`[smartCache] Global cache updated for show ${traktId}`); 57 | return true; 58 | } else { 59 | console.log(`[smartCache] Show ${traktId} not found in cache`); 60 | return false; 61 | } 62 | 63 | } catch (error) { 64 | console.warn(`[smartCache] Failed to smart update show ${traktId}:`, error.message); 65 | return false; 66 | } 67 | } 68 | 69 | /** 70 | * Fallback : Invalidation complète du cache (ancienne méthode) 71 | */ 72 | export async function fallbackInvalidateCache() { 73 | try { 74 | await fsp.unlink(PAGE_CACHE_FILE); 75 | console.log('[smartCache] Fallback: complete cache invalidation'); 76 | return true; 77 | } catch (error) { 78 | if (error.code !== 'ENOENT') { 79 | console.warn('[smartCache] Failed fallback invalidation:', error.message); 80 | } 81 | return false; 82 | } 83 | } 84 | 85 | /** 86 | * Invalidation hybride : Smart d'abord, fallback si échec 87 | * @param {number} traktId - ID Trakt de la série 88 | * @param {Function} headersFunc - Fonction headers d'auth 89 | */ 90 | export async function hybridInvalidate(traktId, headersFunc) { 91 | // Tentative d'invalidation intelligente 92 | const smartSuccess = await smartInvalidateShow(traktId, headersFunc); 93 | 94 | if (!smartSuccess) { 95 | // Fallback sur invalidation complète 96 | console.log('[smartCache] Smart update failed, using fallback invalidation'); 97 | await fallbackInvalidateCache(); 98 | } 99 | 100 | return smartSuccess; 101 | } -------------------------------------------------------------------------------- /public/assets/modules/theme-ui.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Theme UI Module - Gestionnaire d'interface pour les thèmes (Switch simple) 3 | */ 4 | 5 | import { themeManager, themes } from './themes.js'; 6 | 7 | class ThemeUI { 8 | constructor() { 9 | this.themeToggle = null; 10 | this.themeIcon = null; 11 | this.init(); 12 | } 13 | 14 | init() { 15 | 16 | // Attendre que le DOM soit complètement chargé ET que les éléments soient présents 17 | let attempts = 0; 18 | const maxAttempts = 50; // 5 secondes max 19 | 20 | const trySetup = () => { 21 | attempts++; 22 | let toggle = document.getElementById('themeToggle'); 23 | let icon = document.getElementById('themeIcon'); 24 | 25 | 26 | // Si pas trouvé après 10 tentatives, on le crée nous-mêmes ! 27 | if (!toggle && attempts > 10) { 28 | const header = document.querySelector('.app-header .flex.items-center.gap-2'); 29 | if (header) { 30 | toggle = document.createElement('button'); 31 | toggle.id = 'themeToggle'; 32 | toggle.className = 'btn btn-outline'; 33 | toggle.title = 'Changer de thème'; 34 | 35 | icon = document.createElement('i'); 36 | icon.id = 'themeIcon'; 37 | icon.className = 'fa-solid fa-circle-half-stroke'; 38 | 39 | toggle.appendChild(icon); 40 | header.insertBefore(toggle, header.firstChild); 41 | 42 | } 43 | } 44 | 45 | if (toggle && icon) { 46 | this.setupUI(); 47 | } else if (attempts < maxAttempts) { 48 | setTimeout(trySetup, 100); 49 | } else { 50 | console.error('[ThemeUI] Gave up after', maxAttempts, 'attempts. Elements not found.'); 51 | } 52 | }; 53 | 54 | // Démarrer la tentative 55 | if (document.readyState === 'loading') { 56 | document.addEventListener('DOMContentLoaded', trySetup); 57 | } else { 58 | trySetup(); 59 | } 60 | } 61 | 62 | setupUI() { 63 | 64 | // Les éléments ont déjà été vérifiés dans trySetup(), on peut les récupérer 65 | this.themeToggle = document.getElementById('themeToggle'); 66 | this.themeIcon = document.getElementById('themeIcon'); 67 | 68 | 69 | // Configurer les événements 70 | this.setupEvents(); 71 | 72 | // Mettre à jour l'état initial 73 | this.updateIcon(); 74 | 75 | } 76 | 77 | setupEvents() { 78 | 79 | // Cycle entre les thèmes au clic 80 | this.themeToggle.addEventListener('click', (e) => { 81 | e.preventDefault(); 82 | e.stopPropagation(); 83 | this.cycleTheme(); 84 | }); 85 | 86 | // Écouter les changements de thème 87 | window.addEventListener('themechange', (e) => { 88 | this.updateIcon(); 89 | }); 90 | 91 | } 92 | 93 | cycleTheme() { 94 | 95 | const current = themes.getCurrentTheme(); 96 | let next; 97 | 98 | switch(current) { 99 | case 'auto': next = 'light'; break; 100 | case 'light': next = 'dark'; break; 101 | case 'dark': next = 'auto'; break; 102 | default: next = 'auto'; 103 | } 104 | 105 | themes.setTheme(next); 106 | } 107 | 108 | updateIcon() { 109 | if (!this.themeIcon) return; 110 | 111 | const currentTheme = themes.getCurrentTheme(); 112 | const icon = this.getThemeIcon(currentTheme); 113 | 114 | 115 | // Supprimer toutes les classes d'icônes précédentes 116 | this.themeIcon.className = 'fa-solid ' + icon; 117 | } 118 | 119 | getThemeIcon(theme) { 120 | const icons = { 121 | auto: 'fa-circle-half-stroke', 122 | light: 'fa-sun', 123 | dark: 'fa-moon' 124 | }; 125 | return icons[theme] || 'fa-circle-half-stroke'; 126 | } 127 | } 128 | 129 | // Créer et exporter l'instance 130 | export const themeUI = new ThemeUI(); 131 | export default themeUI; -------------------------------------------------------------------------------- /public/assets/modules/tabs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Tabs Navigation Module 3 | * Gestion de la navigation par onglets 4 | */ 5 | 6 | import { state, saveState } from './state.js'; 7 | import { elements, SORT_ALL } from './dom.js'; 8 | import { renderCurrent } from './rendering.js'; 9 | import i18n from './i18n.js'; 10 | 11 | export function rebuildSortOptions(tab) { 12 | const allowed = SORT_ALL.filter(opt => 13 | opt.for.split(',').map(s => s.trim()).includes(tab) 14 | ); 15 | const defaultByTab = { 16 | shows: 'watched_at:desc', 17 | movies: 'watched_at:desc', 18 | shows_unseen: 'collected_at:desc', 19 | movies_unseen: 'collected_at:desc' 20 | }; 21 | const currentKey = `${state.sort.field}:${state.sort.dir}`; 22 | const selectedKey = allowed.some(o => o.value === currentKey) ? currentKey : defaultByTab[tab]; 23 | 24 | // Fonction pour obtenir la clé de traduction basée sur la valeur 25 | const getTranslationKey = (value) => { 26 | const translationMap = { 27 | 'watched_at:desc': 'sort.watched_at_desc', 28 | 'collected_at:desc': 'sort.collected_at_desc', 29 | 'title:asc': 'sort.title_asc', 30 | 'title:desc': 'sort.title_desc', 31 | 'year:desc': 'sort.year_desc', 32 | 'year:asc': 'sort.year_asc', 33 | 'episodes:desc': 'sort.episodes_desc', 34 | 'plays:desc': 'sort.plays_desc', 35 | 'missing:desc': 'sort.missing_desc', 36 | 'missing:asc': 'sort.missing_asc' 37 | }; 38 | return translationMap[value] || value; 39 | }; 40 | 41 | elements.sortActive.innerHTML = allowed 42 | .map(o => { 43 | const translationKey = getTranslationKey(o.value); 44 | const translatedLabel = i18n.t(translationKey) || o.label; 45 | return ``; 46 | }) 47 | .join(''); 48 | elements.sortActive.value = selectedKey; 49 | const [f, d] = selectedKey.split(':'); 50 | state.sort = { field: f, dir: d }; 51 | saveState(); 52 | } 53 | 54 | export function setTab(tab) { 55 | state.tab = tab; 56 | saveState(); 57 | 58 | // Activer le bouton courant / masquer les autres panneaux 59 | Object.entries(elements.tabBtns).forEach(([k,b]) => b?.classList.toggle('tab-btn-active', k===tab)); 60 | Object.entries(elements.panels).forEach(([k,p]) => p?.classList.toggle('hidden', k!==tab)); 61 | 62 | // Cacher les filtres sur "stats", "calendar" et "playback" 63 | const isStats = (tab === 'stats'); 64 | const isPlayback = (tab === 'playback'); 65 | const isCalendar = (tab === 'calendar'); 66 | const hideFilters = isStats || isPlayback || isCalendar; 67 | 68 | // Masquer le bouton mobile et sa section 69 | document.getElementById('mobileFiltersToggle')?.classList.toggle('hidden', hideFilters); 70 | 71 | // Forcer le masquage de mobileFilters sur Stats et Playback (override de sm:block) 72 | const mobileFilters = document.getElementById('mobileFilters'); 73 | if (mobileFilters) { 74 | mobileFilters.classList.toggle('force-hidden', hideFilters); 75 | } 76 | 77 | if (isStats) { 78 | // Charger les statistiques globales depuis l'API Trakt 79 | import('./global-stats.js').then(({ loadGlobalStats }) => loadGlobalStats().catch(()=>{})); 80 | // Charger Pro Stats (qui génère aussi la heatmap depuis ses données) 81 | import('./pro-stats.js').then(({ loadStatsPro }) => loadStatsPro().catch(()=>{})); 82 | return; 83 | } 84 | 85 | if (isCalendar) { 86 | // Charger le calendrier 87 | import('./calendar.js').then(({ initCalendar }) => { 88 | try { 89 | initCalendar(); 90 | } catch(e) { 91 | console.error('Calendar initialization error:', e); 92 | } 93 | }); 94 | return; 95 | } 96 | 97 | if (isPlayback) { 98 | // Charger les données de playback 99 | import('./playback.js').then(({ loadPlayback }) => loadPlayback().catch(()=>{})); 100 | return; 101 | } 102 | 103 | // Listes classiques 104 | rebuildSortOptions(tab); 105 | renderCurrent(); 106 | } -------------------------------------------------------------------------------- /public/assets/modules/themes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Theme Management Module 3 | * Gestion des thèmes et préférences utilisateur 4 | */ 5 | 6 | const THEMES = { 7 | DARK: 'dark', 8 | LIGHT: 'light', 9 | AUTO: 'auto' 10 | }; 11 | 12 | const THEME_KEY = 'trakt_theme_preference'; 13 | 14 | class ThemeManager { 15 | constructor() { 16 | this.currentTheme = this.loadTheme(); 17 | this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 18 | this.init(); 19 | } 20 | 21 | init() { 22 | // Écouter les changements de préférence système 23 | this.mediaQuery.addEventListener('change', () => { 24 | if (this.currentTheme === THEMES.AUTO) { 25 | this.applyTheme(); 26 | } 27 | }); 28 | 29 | // Appliquer le thème initial 30 | this.applyTheme(); 31 | } 32 | 33 | loadTheme() { 34 | const saved = localStorage.getItem(THEME_KEY); 35 | if (saved && Object.values(THEMES).includes(saved)) { 36 | return saved; 37 | } 38 | return THEMES.AUTO; // Par défaut : suivre le système 39 | } 40 | 41 | saveTheme(theme) { 42 | localStorage.setItem(THEME_KEY, theme); 43 | } 44 | 45 | getEffectiveTheme() { 46 | if (this.currentTheme === THEMES.AUTO) { 47 | return this.mediaQuery.matches ? THEMES.DARK : THEMES.LIGHT; 48 | } 49 | return this.currentTheme; 50 | } 51 | 52 | applyTheme() { 53 | const effectiveTheme = this.getEffectiveTheme(); 54 | const root = document.documentElement; 55 | 56 | // Retirer les anciennes classes 57 | root.classList.remove('theme-dark', 'theme-light'); 58 | 59 | // Ajouter la nouvelle classe 60 | root.classList.add(`theme-${effectiveTheme}`); 61 | 62 | // Mettre à jour la couleur de la barre d'adresse mobile 63 | this.updateMetaThemeColor(effectiveTheme); 64 | 65 | // Déclencher un événement personnalisé 66 | window.dispatchEvent(new CustomEvent('themechange', { 67 | detail: { 68 | theme: effectiveTheme, 69 | preference: this.currentTheme 70 | } 71 | })); 72 | } 73 | 74 | updateMetaThemeColor(theme) { 75 | let metaTheme = document.querySelector('meta[name="theme-color"]'); 76 | if (!metaTheme) { 77 | metaTheme = document.createElement('meta'); 78 | metaTheme.name = 'theme-color'; 79 | document.head.appendChild(metaTheme); 80 | } 81 | 82 | // Couleurs pour la barre d'adresse mobile 83 | const colors = { 84 | [THEMES.DARK]: '#0f172a', 85 | [THEMES.LIGHT]: '#f8fafc' 86 | }; 87 | 88 | metaTheme.content = colors[theme]; 89 | } 90 | 91 | setTheme(theme) { 92 | if (!Object.values(THEMES).includes(theme)) { 93 | console.warn(`Theme invalide: ${theme}`); 94 | return; 95 | } 96 | 97 | this.currentTheme = theme; 98 | this.saveTheme(theme); 99 | this.applyTheme(); 100 | } 101 | 102 | getCurrentTheme() { 103 | return this.currentTheme; 104 | } 105 | 106 | getEffectiveThemeName() { 107 | return this.getEffectiveTheme(); 108 | } 109 | 110 | toggle() { 111 | const effective = this.getEffectiveTheme(); 112 | const newTheme = effective === THEMES.DARK ? THEMES.LIGHT : THEMES.DARK; 113 | this.setTheme(newTheme); 114 | } 115 | } 116 | 117 | // Instance globale du gestionnaire de thèmes 118 | export const themeManager = new ThemeManager(); 119 | 120 | // Fonctions utilitaires 121 | export const themes = { 122 | setTheme: (theme) => themeManager.setTheme(theme), 123 | getCurrentTheme: () => themeManager.getCurrentTheme(), 124 | getEffectiveTheme: () => themeManager.getEffectiveThemeName(), 125 | toggle: () => themeManager.toggle(), 126 | 127 | // Constantes 128 | THEMES, 129 | 130 | // Vérifications 131 | isDark: () => themeManager.getEffectiveTheme() === THEMES.DARK, 132 | isLight: () => themeManager.getEffectiveTheme() === THEMES.LIGHT, 133 | isAuto: () => themeManager.getCurrentTheme() === THEMES.AUTO 134 | }; 135 | 136 | // Export par défaut 137 | export default themeManager; -------------------------------------------------------------------------------- /unraid-template.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Trakt-Enhanced 4 | docker.io/diabolino/trakt_enhanced:latest 5 | https://hub.docker.com/repository/docker/diabolino/trakt_enhanced/general 6 | bridge 7 | 8 | sh 9 | false 10 | https://github.com/diabolino/trakt-enhanced/issues 11 | https://github.com/diabolino/trakt-enhanced 12 | Application web moderne pour explorer votre historique Trakt avec une interface élégante. 13 | 14 | ✨ Fonctionnalités principales : 15 | • Interface responsive 100% personnalisable 16 | • Tri et recherche avancés (Séries, Films, À voir) 17 | • Posters haute qualité TMDB avec cache intelligent 18 | • Statistiques détaillées avec graphiques interactifs 19 | • Heatmap d'activité style GitHub 20 | • Rafraîchissement automatique programmable 21 | • Mode sombre/clair adaptatif 22 | 23 | 🔧 Technologies : Node.js, Express, Chart.js, Tailwind CSS 24 | 📊 APIs supportées : Trakt.tv, TMDB 25 | 🐳 Optimisé pour Unraid avec persistance des données 26 | MediaApp:Other 27 | http://[IP]:[PORT:30009]/ 28 | https://raw.githubusercontent.com/diabolino/trakt-enhanced/main/unraid-template.xml 29 | https://raw.githubusercontent.com/diabolino/trakt_enhanced/refs/heads/main/public/assets/web-app-manifest-512x512.png 30 | --health-cmd="curl -f http://localhost:30009/health || exit 1" --health-interval=30s --health-timeout=5s --health-retries=3 31 | 32 | 33 | 1756808192 34 | ☕ Offrir un café 35 | https://github.com/sponsors/diabolino 36 | 37 | 30009 38 | /mnt/user/appdata/trakt-enhanced/data 39 | /mnt/user/appdata/trakt-enhanced/config 40 | Europe/Paris 41 | production 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /public/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Connexion - Trakt Enhanced 7 | 8 | 9 | 10 | 11 |
12 | 13 |
14 |
15 |
16 | Trakt Enhanced 17 |
18 |

19 | Connexion requise 20 |

21 |

Veuillez vous connecter pour accéder à l'interface

22 |
23 | 24 | 32 | 33 |
34 | 35 | 36 |
37 |
38 | 41 | 44 |
45 | 46 |
47 | 50 | 53 |
54 |
55 | 56 | 61 |
62 | 63 | 64 |
65 |
66 | 71 | 81 |
82 |
83 |
84 |
85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /public/assets/modules/mobile-tabs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Mobile Tab Dropdown Module 3 | */ 4 | 5 | class MobileTabs { 6 | constructor() { 7 | this.dropdownBtn = null; 8 | this.dropdownMenu = null; 9 | this.dropdownText = null; 10 | this.isOpen = false; 11 | this.currentTab = 'shows'; 12 | this.init(); 13 | } 14 | 15 | init() { 16 | if (document.readyState === 'loading') { 17 | document.addEventListener('DOMContentLoaded', () => this.setup()); 18 | } else { 19 | this.setup(); 20 | } 21 | } 22 | 23 | setup() { 24 | this.dropdownBtn = document.getElementById('mobileTabDropdown'); 25 | this.dropdownMenu = document.getElementById('mobileTabMenu'); 26 | this.dropdownText = document.getElementById('mobileTabText'); 27 | 28 | if (!this.dropdownBtn || !this.dropdownMenu || !this.dropdownText) return; 29 | 30 | this.attachEvents(); 31 | this.syncWithDesktopTabs(); 32 | } 33 | 34 | attachEvents() { 35 | // Toggle dropdown 36 | this.dropdownBtn.addEventListener('click', (e) => { 37 | e.stopPropagation(); 38 | this.toggleDropdown(); 39 | }); 40 | 41 | // Options du dropdown 42 | const options = this.dropdownMenu.querySelectorAll('.mobile-tab-option'); 43 | options.forEach(option => { 44 | option.addEventListener('click', (e) => { 45 | e.stopPropagation(); 46 | const tab = option.getAttribute('data-tab'); 47 | this.selectTab(tab); 48 | this.closeDropdown(); 49 | }); 50 | }); 51 | 52 | // Fermer en cliquant ailleurs 53 | document.addEventListener('click', () => { 54 | if (this.isOpen) { 55 | this.closeDropdown(); 56 | } 57 | }); 58 | 59 | // Fermer avec Escape 60 | document.addEventListener('keydown', (e) => { 61 | if (e.key === 'Escape' && this.isOpen) { 62 | this.closeDropdown(); 63 | } 64 | }); 65 | } 66 | 67 | toggleDropdown() { 68 | if (this.isOpen) { 69 | this.closeDropdown(); 70 | } else { 71 | this.openDropdown(); 72 | } 73 | } 74 | 75 | openDropdown() { 76 | this.dropdownMenu.classList.remove('hidden'); 77 | this.dropdownBtn.querySelector('i:last-child').classList.add('rotate-180'); 78 | this.isOpen = true; 79 | } 80 | 81 | closeDropdown() { 82 | this.dropdownMenu.classList.add('hidden'); 83 | this.dropdownBtn.querySelector('i:last-child').classList.remove('rotate-180'); 84 | this.isOpen = false; 85 | } 86 | 87 | selectTab(tabId) { 88 | // Cliquer sur l'onglet desktop correspondant pour déclencher le changement 89 | const desktopTab = document.getElementById('tabBtn' + this.capitalizeFirst(tabId)); 90 | if (desktopTab) { 91 | desktopTab.click(); 92 | } 93 | 94 | this.currentTab = tabId; 95 | this.updateDropdownText(tabId); 96 | } 97 | 98 | updateDropdownText(tabId) { 99 | const option = this.dropdownMenu.querySelector(`[data-tab="${tabId}"]`); 100 | if (option) { 101 | this.dropdownText.innerHTML = option.innerHTML; 102 | } 103 | } 104 | 105 | // Synchroniser avec les onglets desktop quand ils changent 106 | syncWithDesktopTabs() { 107 | // Observer les changements de classe active sur les onglets desktop 108 | const desktopTabs = document.querySelectorAll('.tabs-group:not(.md\\:hidden) button[data-tab]'); 109 | 110 | desktopTabs.forEach(tab => { 111 | // Observer les changements de classe 112 | const observer = new MutationObserver((mutations) => { 113 | mutations.forEach((mutation) => { 114 | if (mutation.type === 'attributes' && mutation.attributeName === 'class') { 115 | if (tab.classList.contains('tab-btn-active')) { 116 | const tabId = tab.getAttribute('data-tab'); 117 | this.currentTab = tabId; 118 | this.updateDropdownText(tabId); 119 | } 120 | } 121 | }); 122 | }); 123 | 124 | observer.observe(tab, { attributes: true }); 125 | }); 126 | } 127 | 128 | capitalizeFirst(str) { 129 | const parts = str.split('_'); 130 | return parts.map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(''); 131 | } 132 | } 133 | 134 | // Créer l'instance 135 | const mobileTabs = new MobileTabs(); 136 | 137 | export default mobileTabs; -------------------------------------------------------------------------------- /lib/setup.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import crypto from 'node:crypto'; 4 | import { hashPassword, isPasswordHashed } from './auth.js'; 5 | 6 | // Chercher .env dans le dossier config s'il existe, sinon dans le répertoire courant 7 | const ENV_FILE = fs.existsSync('config') ? path.resolve('config/.env') : path.resolve('.env'); 8 | const ENV_EXAMPLE = path.resolve('.env.example'); 9 | 10 | /** 11 | * Vérifie si le fichier .env existe et contient les variables requises 12 | */ 13 | export function checkEnvFile() { 14 | if (!fs.existsSync(ENV_FILE)) { 15 | return { exists: false, valid: false, missing: [] }; 16 | } 17 | 18 | const envContent = fs.readFileSync(ENV_FILE, 'utf8'); 19 | const requiredVars = [ 20 | 'TRAKT_CLIENT_ID', 21 | 'TRAKT_CLIENT_SECRET', 22 | 'TMDB_API_KEY', 23 | 'SESSION_SECRET', 24 | 'LANGUAGE', 25 | 'AUTH_ENABLED' 26 | ]; 27 | 28 | const missing = []; 29 | for (const varName of requiredVars) { 30 | const regex = new RegExp(`^${varName}=.+$`, 'm'); 31 | if (!regex.test(envContent)) { 32 | missing.push(varName); 33 | } 34 | } 35 | 36 | return { 37 | exists: true, 38 | valid: missing.length === 0, 39 | missing 40 | }; 41 | } 42 | 43 | /** 44 | * Génère un fichier .env avec les valeurs fournies 45 | */ 46 | export function generateEnvFile(config) { 47 | // Préparer les valeurs d'authentification 48 | const authEnabled = config.enableAuth === true || config.enableAuth === 'on' || config.enableAuth === 'true'; 49 | const authUsername = authEnabled && config.authUsername ? config.authUsername : ''; 50 | 51 | // Hasher le mot de passe auth s'il est fourni et pas déjà hashé 52 | let authPassword = ''; 53 | if (authEnabled && config.authPassword) { 54 | authPassword = isPasswordHashed(config.authPassword) ? 55 | config.authPassword : 56 | hashPassword(config.authPassword); 57 | } 58 | 59 | // Hasher le mot de passe full rebuild s'il n'est pas déjà hashé 60 | const fullRebuildPassword = config.fullRebuildPassword ? ( 61 | isPasswordHashed(config.fullRebuildPassword) ? 62 | config.fullRebuildPassword : 63 | hashPassword(config.fullRebuildPassword) 64 | ) : hashPassword(crypto.randomBytes(16).toString('hex')); 65 | 66 | const envTemplate = `# Configuration Trakt Enhanced 67 | PORT=${config.port || 30009} 68 | TITLE=Trakt Enhanced 69 | 70 | # Trakt API Configuration 71 | TRAKT_CLIENT_ID=${config.traktClientId || ''} 72 | TRAKT_CLIENT_SECRET=${config.traktClientSecret || ''} 73 | OAUTH_REDIRECT_URI=${config.oauthRedirectUri || 'http://localhost:30009/auth/callback'} 74 | 75 | # TMDB API Configuration 76 | TMDB_API_KEY=${config.tmdbApiKey || ''} 77 | 78 | # Language Configuration 79 | LANGUAGE=${config.language || 'fr-FR'} 80 | 81 | # Authentication 82 | AUTH_ENABLED=${authEnabled ? 'true' : 'false'} 83 | AUTH_USERNAME=${authUsername} 84 | AUTH_PASSWORD=${authPassword} 85 | 86 | # Security 87 | SESSION_SECRET=${crypto.randomBytes(32).toString('hex')} 88 | FULL_REBUILD_PASSWORD=${fullRebuildPassword} 89 | `; 90 | 91 | // S'assurer que le dossier config existe 92 | const envDir = path.dirname(ENV_FILE); 93 | if (!fs.existsSync(envDir)) { 94 | fs.mkdirSync(envDir, { recursive: true }); 95 | } 96 | 97 | fs.writeFileSync(ENV_FILE, envTemplate, 'utf8'); 98 | return true; 99 | } 100 | 101 | /** 102 | * Obtient les valeurs par défaut depuis .env.example si disponible 103 | */ 104 | export function getDefaultConfig() { 105 | const defaults = { 106 | port: 30009, 107 | traktClientId: '', 108 | traktClientSecret: '', 109 | tmdbApiKey: '', 110 | language: 'fr-FR' 111 | }; 112 | 113 | if (fs.existsSync(ENV_EXAMPLE)) { 114 | const exampleContent = fs.readFileSync(ENV_EXAMPLE, 'utf8'); 115 | const lines = exampleContent.split('\n'); 116 | 117 | for (const line of lines) { 118 | const [key, value] = line.split('='); 119 | if (key && value && defaults.hasOwnProperty(toCamelCase(key))) { 120 | defaults[toCamelCase(key)] = value; 121 | } 122 | } 123 | } 124 | 125 | return defaults; 126 | } 127 | 128 | /** 129 | * Convertit SNAKE_CASE en camelCase 130 | */ 131 | function toCamelCase(str) { 132 | return str.toLowerCase().replace(/_([a-z])/g, (match, letter) => letter.toUpperCase()); 133 | } -------------------------------------------------------------------------------- /lib/i18n.js: -------------------------------------------------------------------------------- 1 | /** 2 | * I18n Module - Server-side internationalization 3 | */ 4 | import fs from 'node:fs'; 5 | import path from 'node:path'; 6 | 7 | class ServerI18n { 8 | constructor() { 9 | this.translations = new Map(); 10 | this.supportedLangs = ['fr', 'en']; 11 | this.fallbackLang = 'fr'; 12 | this.localesPath = path.join(process.cwd(), 'public', 'locales'); 13 | } 14 | 15 | /** 16 | * Load all translation files 17 | */ 18 | loadTranslations() { 19 | for (const lang of this.supportedLangs) { 20 | try { 21 | const filePath = path.join(this.localesPath, `${lang}.json`); 22 | if (fs.existsSync(filePath)) { 23 | const content = fs.readFileSync(filePath, 'utf-8'); 24 | this.translations.set(lang, JSON.parse(content)); 25 | console.log(`[i18n] Loaded server translations for: ${lang}`); 26 | } else { 27 | console.warn(`[i18n] Translation file not found: ${filePath}`); 28 | } 29 | } catch (error) { 30 | console.error(`[i18n] Error loading ${lang} translations:`, error); 31 | } 32 | } 33 | } 34 | 35 | /** 36 | * Detect language from request 37 | */ 38 | detectLanguage(req) { 39 | // 1. Check query parameter 40 | if (req.query.lang && this.supportedLangs.includes(req.query.lang)) { 41 | return req.query.lang; 42 | } 43 | 44 | // 2. Check session 45 | if (req.session?.language && this.supportedLangs.includes(req.session.language)) { 46 | return req.session.language; 47 | } 48 | 49 | // 3. Check Accept-Language header 50 | const acceptLang = req.get('Accept-Language'); 51 | if (acceptLang) { 52 | const langs = acceptLang.split(',').map(lang => { 53 | const [code, quality = 1] = lang.trim().split(';q='); 54 | return { code: code.split('-')[0], quality: parseFloat(quality) }; 55 | }).sort((a, b) => b.quality - a.quality); 56 | 57 | for (const { code } of langs) { 58 | if (this.supportedLangs.includes(code)) { 59 | return code; 60 | } 61 | } 62 | } 63 | 64 | // 4. Default fallback 65 | return this.fallbackLang; 66 | } 67 | 68 | /** 69 | * Get translation for a key 70 | */ 71 | t(key, lang, vars = {}) { 72 | const translation = this.getTranslation(key, lang) || 73 | this.getTranslation(key, this.fallbackLang) || 74 | key; 75 | 76 | return this.interpolate(translation, vars); 77 | } 78 | 79 | getTranslation(key, lang) { 80 | const translations = this.translations.get(lang); 81 | if (!translations) return null; 82 | 83 | const keys = key.split('.'); 84 | let current = translations; 85 | 86 | for (const k of keys) { 87 | if (current && typeof current === 'object' && k in current) { 88 | current = current[k]; 89 | } else { 90 | return null; 91 | } 92 | } 93 | 94 | return current; 95 | } 96 | 97 | interpolate(text, vars) { 98 | if (typeof text !== 'string') return text; 99 | 100 | return text.replace(/\{(\w+)\}/g, (match, key) => { 101 | return vars.hasOwnProperty(key) ? vars[key] : match; 102 | }); 103 | } 104 | 105 | /** 106 | * Express middleware to add i18n to request 107 | */ 108 | middleware() { 109 | return (req, res, next) => { 110 | const lang = this.detectLanguage(req); 111 | 112 | // Add translation function to request 113 | req.t = (key, vars) => this.t(key, lang, vars); 114 | req.lang = lang; 115 | 116 | // Save language in session 117 | if (req.session && req.query.lang) { 118 | req.session.language = lang; 119 | } 120 | 121 | next(); 122 | }; 123 | } 124 | 125 | /** 126 | * Get all supported languages with metadata 127 | */ 128 | getLanguagesInfo() { 129 | return this.supportedLangs.map(lang => ({ 130 | code: lang, 131 | name: this.t('app.title', lang) || lang.toUpperCase(), 132 | flag: this.getLanguageFlag(lang) 133 | })); 134 | } 135 | 136 | getLanguageFlag(lang) { 137 | const flags = { 138 | 'fr': '🇫🇷', 139 | 'en': '🇺🇸' 140 | }; 141 | return flags[lang] || '🌐'; 142 | } 143 | } 144 | 145 | // Create and export singleton 146 | const serverI18n = new ServerI18n(); 147 | serverI18n.loadTranslations(); 148 | 149 | export default serverI18n; 150 | export { serverI18n }; -------------------------------------------------------------------------------- /public/assets/loading-i18n.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Loading Page Internationalization 3 | */ 4 | 5 | class LoadingI18n { 6 | constructor(i18n) { 7 | this.i18n = i18n; 8 | } 9 | 10 | async translatePage() { 11 | // Update page title 12 | this.i18n.updatePageLanguage('loading'); 13 | 14 | // Translate main content 15 | const subtitle = document.querySelector('.text-gray-300.text-lg'); 16 | if (subtitle) { 17 | subtitle.textContent = this.i18n.t('loading.title'); 18 | } 19 | 20 | const description = document.querySelector('.text-gray-400.text-sm'); 21 | if (description) { 22 | description.textContent = this.i18n.t('loading.subtitle'); 23 | } 24 | 25 | // Translate progress section 26 | const progressLabel = Array.from(document.querySelectorAll('.text-gray-300')).find(el => el.textContent.includes('Progrès global')); 27 | if (progressLabel) { 28 | progressLabel.textContent = this.i18n.t('loading.global_progress'); 29 | } 30 | 31 | // Translate steps 32 | this.translateStep('step-auth', 'loading.step_auth', 'loading.step_auth_detail'); 33 | this.translateStep('step-shows', 'loading.step_shows', 'loading.step_shows_detail'); 34 | this.translateStep('step-movies', 'loading.step_movies', 'loading.step_movies_detail'); 35 | this.translateStep('step-progress', 'loading.step_progress', 'loading.step_progress_detail'); 36 | this.translateStep('step-collection', 'loading.step_collection', 'loading.step_collection_detail'); 37 | this.translateStep('step-final', 'loading.step_final', 'loading.step_final_detail'); 38 | 39 | // Translate info box 40 | this.translateInfoBox(); 41 | 42 | console.log('[LoadingI18n] Page translated'); 43 | } 44 | 45 | translateStep(stepId, titleKey, detailKey) { 46 | const step = document.getElementById(stepId); 47 | if (step) { 48 | const title = step.querySelector('.font-medium'); 49 | const detail = step.querySelector('.step-detail'); 50 | 51 | if (title) { 52 | title.textContent = this.i18n.t(titleKey); 53 | } 54 | if (detail) { 55 | detail.textContent = this.i18n.t(detailKey); 56 | } 57 | } 58 | } 59 | 60 | translateInfoBox() { 61 | const infoBox = document.querySelector('.bg-blue-900\\/30'); 62 | if (infoBox) { 63 | const strong = infoBox.querySelector('strong'); 64 | const text = infoBox.querySelector('.text-blue-200'); 65 | 66 | if (strong && text) { 67 | text.innerHTML = ` 68 | ${this.i18n.t('loading.info_title')} ${this.i18n.t('loading.info_text')} 69 | `; 70 | } 71 | } 72 | } 73 | 74 | // Method to update step details during loading process 75 | updateStepDetail(stepId, message) { 76 | const step = document.getElementById(stepId); 77 | if (step) { 78 | const detail = step.querySelector('.step-detail'); 79 | if (detail) { 80 | detail.textContent = message; 81 | } 82 | } 83 | } 84 | 85 | // Method to update step status with translated messages 86 | updateStepStatus(stepId, status, customMessage = null) { 87 | const step = document.getElementById(stepId); 88 | if (!step) return; 89 | 90 | const statusIcon = step.querySelector('.status-icon i'); 91 | const detail = step.querySelector('.step-detail'); 92 | 93 | if (statusIcon) { 94 | statusIcon.className = ''; 95 | 96 | switch (status) { 97 | case 'loading': 98 | statusIcon.className = 'fa-solid fa-spinner fa-spin text-sky-400'; 99 | break; 100 | case 'success': 101 | statusIcon.className = 'fa-solid fa-check text-green-400'; 102 | break; 103 | case 'error': 104 | statusIcon.className = 'fa-solid fa-times text-red-400'; 105 | break; 106 | case 'waiting': 107 | default: 108 | statusIcon.className = 'fa-regular fa-clock text-gray-500'; 109 | } 110 | } 111 | 112 | if (detail && customMessage) { 113 | detail.textContent = customMessage; 114 | } 115 | } 116 | } 117 | 118 | // Initialize when DOM is loaded 119 | document.addEventListener('DOMContentLoaded', async () => { 120 | if (typeof I18nLite !== 'undefined') { 121 | const i18n = new I18nLite(); 122 | await i18n.init(); 123 | 124 | const loadingI18n = new LoadingI18n(i18n); 125 | await loadingI18n.translatePage(); 126 | 127 | // Make available globally for loading script 128 | window.loadingI18n = loadingI18n; 129 | } 130 | }); -------------------------------------------------------------------------------- /lib/middleware.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Middleware de monitoring et gestion d'erreurs 3 | */ 4 | 5 | import { logger, loggers } from './logger.js'; 6 | 7 | // Middleware de logging des requêtes HTTP 8 | export function requestLoggingMiddleware(req, res, next) { 9 | const startTime = Date.now(); 10 | const originalSend = res.send; 11 | 12 | // Override de res.send pour capturer le temps de réponse 13 | res.send = function(data) { 14 | const duration = Date.now() - startTime; 15 | loggers.logRequest(req, res, duration); 16 | return originalSend.call(this, data); 17 | }; 18 | 19 | // Log des requêtes entrantes 20 | logger.debug('Incoming request', { 21 | method: req.method, 22 | url: req.url, 23 | userAgent: req.get('User-Agent'), 24 | ip: req.ip || req.connection?.remoteAddress, 25 | query: req.query, 26 | body: req.method === 'POST' ? req.body : undefined 27 | }); 28 | 29 | next(); 30 | } 31 | 32 | // Middleware de gestion centralisée des erreurs 33 | export function errorHandlingMiddleware(error, req, res, next) { 34 | // Log de l'erreur avec contexte 35 | loggers.logError(error, { 36 | method: req.method, 37 | url: req.url, 38 | userAgent: req.get('User-Agent'), 39 | ip: req.ip || req.connection?.remoteAddress, 40 | query: req.query, 41 | body: req.method === 'POST' ? req.body : undefined 42 | }); 43 | 44 | // Déterminer le code de statut 45 | let statusCode = 500; 46 | let message = 'Erreur interne du serveur'; 47 | 48 | if (error.name === 'ValidationError') { 49 | statusCode = 400; 50 | message = 'Données invalides'; 51 | } else if (error.name === 'UnauthorizedError') { 52 | statusCode = 401; 53 | message = 'Non autorisé'; 54 | } else if (error.status) { 55 | statusCode = error.status; 56 | } 57 | 58 | // En mode développement, inclure la stack trace 59 | const errorResponse = { 60 | error: message, 61 | timestamp: new Date().toISOString(), 62 | path: req.url 63 | }; 64 | 65 | if (process.env.NODE_ENV !== 'production') { 66 | errorResponse.details = error.message; 67 | errorResponse.stack = error.stack; 68 | } 69 | 70 | res.status(statusCode).json(errorResponse); 71 | } 72 | 73 | // Middleware de monitoring des performances 74 | export function performanceMiddleware(operation) { 75 | return (req, res, next) => { 76 | const startTime = Date.now(); 77 | 78 | const originalSend = res.send; 79 | res.send = function(data) { 80 | const duration = Date.now() - startTime; 81 | 82 | // Log si la requête prend plus de 1 seconde 83 | if (duration > 1000) { 84 | loggers.logPerformance(operation || req.url, duration, { 85 | method: req.method, 86 | url: req.url, 87 | statusCode: res.statusCode, 88 | slow: true 89 | }); 90 | } 91 | 92 | return originalSend.call(this, data); 93 | }; 94 | 95 | next(); 96 | }; 97 | } 98 | 99 | // Middleware de rate limiting simple (en mémoire) 100 | const requestCounts = new Map(); 101 | const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes 102 | const RATE_LIMIT_MAX = 100; // 100 requêtes par fenêtre 103 | 104 | export function rateLimitMiddleware(req, res, next) { 105 | const clientId = req.ip || req.connection?.remoteAddress || 'unknown'; 106 | const now = Date.now(); 107 | 108 | // Nettoyer les anciens compteurs 109 | for (const [key, data] of requestCounts.entries()) { 110 | if (now - data.windowStart > RATE_LIMIT_WINDOW) { 111 | requestCounts.delete(key); 112 | } 113 | } 114 | 115 | // Obtenir ou créer le compteur pour ce client 116 | let clientData = requestCounts.get(clientId); 117 | if (!clientData || now - clientData.windowStart > RATE_LIMIT_WINDOW) { 118 | clientData = { count: 0, windowStart: now }; 119 | requestCounts.set(clientId, clientData); 120 | } 121 | 122 | clientData.count++; 123 | 124 | // Vérifier la limite 125 | if (clientData.count > RATE_LIMIT_MAX) { 126 | logger.warn('Rate limit exceeded', { 127 | clientId, 128 | count: clientData.count, 129 | url: req.url, 130 | userAgent: req.get('User-Agent') 131 | }); 132 | 133 | return res.status(429).json({ 134 | error: 'Trop de requêtes', 135 | retryAfter: Math.ceil((RATE_LIMIT_WINDOW - (now - clientData.windowStart)) / 1000) 136 | }); 137 | } 138 | 139 | // Ajouter les headers de rate limiting 140 | res.set({ 141 | 'X-RateLimit-Limit': RATE_LIMIT_MAX, 142 | 'X-RateLimit-Remaining': Math.max(0, RATE_LIMIT_MAX - clientData.count), 143 | 'X-RateLimit-Reset': new Date(clientData.windowStart + RATE_LIMIT_WINDOW).toISOString() 144 | }); 145 | 146 | next(); 147 | } 148 | 149 | // Wrapper pour les fonctions async avec gestion d'erreurs 150 | export function asyncHandler(fn) { 151 | return (req, res, next) => { 152 | Promise.resolve(fn(req, res, next)).catch(next); 153 | }; 154 | } -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import dotenv from 'dotenv'; 4 | 5 | // Determine the correct .env file path (same logic as setup.js) 6 | const ENV_FILE = fs.existsSync('config') ? path.resolve('config/.env') : path.resolve('.env'); 7 | 8 | // Load the correct .env file 9 | if (fs.existsSync(ENV_FILE)) { 10 | dotenv.config({ path: ENV_FILE }); 11 | } else { 12 | dotenv.config(); // Fallback to default 13 | } 14 | 15 | // App title 16 | export const TITLE = process.env.TITLE || 'Trakt Enhanced'; 17 | 18 | // Store initial values to prevent loss 19 | const initialConfig = { 20 | TRAKT_CLIENT_ID: process.env.TRAKT_CLIENT_ID || '', 21 | TRAKT_CLIENT_SECRET: process.env.TRAKT_CLIENT_SECRET || '', 22 | TMDB_API_KEY: process.env.TMDB_API_KEY || '', 23 | LANGUAGE: process.env.LANGUAGE || 'fr-FR', 24 | FULL_REBUILD_PASSWORD: process.env.FULL_REBUILD_PASSWORD || '', 25 | OAUTH_REDIRECT_URI: process.env.OAUTH_REDIRECT_URI || `http://localhost:${process.env.PORT || 30009}/auth/callback` 26 | }; 27 | 28 | // External API keys (live bindings) — no defaults to avoid exposing secrets 29 | export let TRAKT_CLIENT_ID = initialConfig.TRAKT_CLIENT_ID; 30 | export let TRAKT_CLIENT_SECRET = initialConfig.TRAKT_CLIENT_SECRET; 31 | export let TMDB_API_KEY = initialConfig.TMDB_API_KEY; 32 | export let LANGUAGE = initialConfig.LANGUAGE; 33 | export let FULL_REBUILD_PASSWORD = initialConfig.FULL_REBUILD_PASSWORD; 34 | export let OAUTH_REDIRECT_URI = initialConfig.OAUTH_REDIRECT_URI; 35 | 36 | // Provide a way to reload .env after setup without restarting the process 37 | export function reloadEnv() { 38 | try { 39 | // Reload the correct .env file with proper path 40 | if (fs.existsSync(ENV_FILE)) { 41 | dotenv.config({ path: ENV_FILE, override: true }); 42 | } else { 43 | dotenv.config({ override: true }); 44 | } 45 | } catch (err) { 46 | console.warn('[config] Error reloading .env:', err.message); 47 | } 48 | 49 | // Only update if new values are non-empty, otherwise keep the initial values 50 | TRAKT_CLIENT_ID = process.env.TRAKT_CLIENT_ID || TRAKT_CLIENT_ID || initialConfig.TRAKT_CLIENT_ID; 51 | TRAKT_CLIENT_SECRET = process.env.TRAKT_CLIENT_SECRET || TRAKT_CLIENT_SECRET || initialConfig.TRAKT_CLIENT_SECRET; 52 | TMDB_API_KEY = process.env.TMDB_API_KEY || TMDB_API_KEY || initialConfig.TMDB_API_KEY; 53 | LANGUAGE = process.env.LANGUAGE || LANGUAGE || initialConfig.LANGUAGE; 54 | FULL_REBUILD_PASSWORD = process.env.FULL_REBUILD_PASSWORD || FULL_REBUILD_PASSWORD || initialConfig.FULL_REBUILD_PASSWORD; 55 | OAUTH_REDIRECT_URI = process.env.OAUTH_REDIRECT_URI || OAUTH_REDIRECT_URI || initialConfig.OAUTH_REDIRECT_URI; 56 | 57 | // Log if credentials are missing after reload 58 | if (!TRAKT_CLIENT_ID || !TRAKT_CLIENT_SECRET) { 59 | console.error('[config] WARNING: Trakt credentials are missing after reload!'); 60 | console.error('[config] Current values - ID:', TRAKT_CLIENT_ID ? 'present' : 'missing', '- Secret:', TRAKT_CLIENT_SECRET ? 'present' : 'missing'); 61 | console.error('[config] Initial values - ID:', initialConfig.TRAKT_CLIENT_ID ? 'present' : 'missing', '- Secret:', initialConfig.TRAKT_CLIENT_SECRET ? 'present' : 'missing'); 62 | 63 | // Try to restore from initial config 64 | if (initialConfig.TRAKT_CLIENT_ID && initialConfig.TRAKT_CLIENT_SECRET) { 65 | console.log('[config] Restoring credentials from initial config'); 66 | TRAKT_CLIENT_ID = initialConfig.TRAKT_CLIENT_ID; 67 | TRAKT_CLIENT_SECRET = initialConfig.TRAKT_CLIENT_SECRET; 68 | } 69 | } 70 | } 71 | 72 | // Limits (fixed defaults; not from env) 73 | export const SHOWS_LIMIT_FULL = 10000; 74 | export const MOVIES_LIMIT_FULL = 5000; 75 | 76 | // TTLs seconds (fixed defaults) 77 | export const PAGE_TTL = 6 * 3600; // 6h 78 | export const PROG_TTL = 6 * 3600; // 6h 79 | 80 | // Progress/batching (fixed defaults) 81 | export const MAX_SHOWS_PROGRESS_CALLS = 40; 82 | export const PROGRESS_THROTTLE_MS = 1200; 83 | 84 | // Server 85 | export const PORT = Number(process.env.PORT || 30009); 86 | 87 | // Paths 88 | export const DATA_DIR = path.resolve('./data'); 89 | export const CACHE_DIR = path.join(DATA_DIR, '.cache_tmdb'); 90 | export const SECRETS_DIR = path.join(DATA_DIR, '.secrets'); 91 | export const IMG_DIR = path.join(DATA_DIR, 'cache_imgs'); 92 | export const HIST_DIR = path.join(DATA_DIR, '.cache_trakt'); 93 | export const SESSIONS_DIR = path.join(DATA_DIR, 'sessions'); 94 | export const PAGE_CACHE_DIR = HIST_DIR; 95 | export const PAGE_CACHE_FILE = path.join(PAGE_CACHE_DIR, 'trakt_history_cache.json'); 96 | export const TOKEN_FILE = process.env.TRAKT_TOKEN_FILE || path.join(SECRETS_DIR, 'trakt_token.json'); 97 | export const HIST_FILE = path.join(HIST_DIR, 'trakt_master.json'); 98 | 99 | // Ensure directories exist 100 | for (const d of [DATA_DIR, CACHE_DIR, SECRETS_DIR, IMG_DIR, HIST_DIR, SESSIONS_DIR, PAGE_CACHE_DIR]) { 101 | fs.mkdirSync(d, { recursive: true }); 102 | } 103 | -------------------------------------------------------------------------------- /lib/crypto.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Crypto Module - Chiffrement et sécurité 3 | */ 4 | 5 | import crypto from 'node:crypto'; 6 | import { logger } from './logger.js'; 7 | 8 | // Configuration du chiffrement 9 | const ALGORITHM = 'aes-256-gcm'; 10 | const KEY_LENGTH = 32; // 256 bits 11 | const IV_LENGTH = 16; // 128 bits 12 | const TAG_LENGTH = 16; // 128 bits 13 | 14 | // Clé de chiffrement dérivée du secret de session ou variable d'environnement 15 | function getEncryptionKey() { 16 | const secret = process.env.ENCRYPTION_KEY || process.env.SESSION_SECRET || 'default-key-change-in-production'; 17 | 18 | if (secret === 'default-key-change-in-production' && process.env.NODE_ENV === 'production') { 19 | logger.warn('SECURITY: Using default encryption key in production!'); 20 | } 21 | 22 | // Dériver une clé stable de 32 bytes 23 | return crypto.scryptSync(secret, 'trakt-history-salt', KEY_LENGTH); 24 | } 25 | 26 | /** 27 | * Chiffre des données sensibles 28 | * @param {string|object} data - Données à chiffrer 29 | * @returns {string} Données chiffrées en base64 30 | */ 31 | export function encrypt(data) { 32 | try { 33 | const plaintext = typeof data === 'string' ? data : JSON.stringify(data); 34 | const key = getEncryptionKey(); 35 | const iv = crypto.randomBytes(IV_LENGTH); 36 | 37 | const cipher = crypto.createCipheriv(ALGORITHM, key, iv); 38 | cipher.setAAD(Buffer.from('trakt-history-aad')); 39 | 40 | let encrypted = cipher.update(plaintext, 'utf8', 'hex'); 41 | encrypted += cipher.final('hex'); 42 | 43 | const tag = cipher.getAuthTag(); 44 | 45 | // Combiner IV + TAG + données chiffrées 46 | const result = Buffer.concat([ 47 | iv, 48 | tag, 49 | Buffer.from(encrypted, 'hex') 50 | ]).toString('base64'); 51 | 52 | return result; 53 | 54 | } catch (error) { 55 | logger.error('Encryption failed', { error: error.message }); 56 | throw new Error('Encryption failed'); 57 | } 58 | } 59 | 60 | /** 61 | * Déchiffre des données 62 | * @param {string} encryptedData - Données chiffrées en base64 63 | * @returns {string|object} Données déchiffrées 64 | */ 65 | export function decrypt(encryptedData) { 66 | try { 67 | const key = getEncryptionKey(); 68 | const buffer = Buffer.from(encryptedData, 'base64'); 69 | 70 | // Extraire IV, TAG et données 71 | const iv = buffer.subarray(0, IV_LENGTH); 72 | const tag = buffer.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); 73 | const encrypted = buffer.subarray(IV_LENGTH + TAG_LENGTH); 74 | 75 | const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); 76 | decipher.setAAD(Buffer.from('trakt-history-aad')); 77 | decipher.setAuthTag(tag); 78 | 79 | let decrypted = decipher.update(encrypted, null, 'utf8'); 80 | decrypted += decipher.final('utf8'); 81 | 82 | // Essayer de parser en JSON, sinon retourner string 83 | try { 84 | return JSON.parse(decrypted); 85 | } catch { 86 | return decrypted; 87 | } 88 | 89 | } catch (error) { 90 | logger.error('Decryption failed', { error: error.message }); 91 | throw new Error('Decryption failed'); 92 | } 93 | } 94 | 95 | /** 96 | * Génère un token CSRF sécurisé 97 | * @returns {string} Token CSRF 98 | */ 99 | export function generateCSRFToken() { 100 | return crypto.randomBytes(32).toString('base64url'); 101 | } 102 | 103 | /** 104 | * Vérifie un token CSRF 105 | * @param {string} token - Token à vérifier 106 | * @param {string} expected - Token attendu 107 | * @returns {boolean} Validité du token 108 | */ 109 | export function verifyCSRFToken(token, expected) { 110 | if (!token || !expected) return false; 111 | 112 | try { 113 | const tokenBuffer = Buffer.from(token, 'base64url'); 114 | const expectedBuffer = Buffer.from(expected, 'base64url'); 115 | 116 | // Vérifier que les buffers ont la même longueur 117 | if (tokenBuffer.length !== expectedBuffer.length) { 118 | return false; 119 | } 120 | 121 | return crypto.timingSafeEqual(tokenBuffer, expectedBuffer); 122 | } catch (error) { 123 | // En cas d'erreur de décodage base64url ou autre 124 | return false; 125 | } 126 | } 127 | 128 | /** 129 | * Génère un hash sécurisé (pour les mots de passe, etc.) 130 | * @param {string} data - Données à hasher 131 | * @param {string} salt - Salt optionnel 132 | * @returns {string} Hash en hexadécimal 133 | */ 134 | export function secureHash(data, salt = '') { 135 | const hash = crypto.createHash('sha256'); 136 | hash.update(data + salt + 'trakt-history-pepper'); 137 | return hash.digest('hex'); 138 | } 139 | 140 | /** 141 | * Génère un salt aléatoire 142 | * @returns {string} Salt en hexadécimal 143 | */ 144 | export function generateSalt() { 145 | return crypto.randomBytes(16).toString('hex'); 146 | } 147 | 148 | // encryptTraktToken supprimée - plus de chiffrement des tokens Trakt 149 | 150 | /** 151 | * Déchiffre les tokens Trakt 152 | * @param {string} encryptedToken - Token chiffré 153 | * @returns {object} Données du token 154 | */ 155 | export function decryptTraktToken(encryptedToken) { 156 | logger.debug('Decrypting Trakt token'); 157 | return decrypt(encryptedToken); 158 | } 159 | 160 | export default { 161 | encrypt, 162 | decrypt, 163 | generateCSRFToken, 164 | verifyCSRFToken, 165 | secureHash, 166 | generateSalt, 167 | decryptTraktToken // Gardée pour migration des anciens tokens 168 | }; -------------------------------------------------------------------------------- /public/assets/modules/lazy-loading.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Lazy Loading Module 3 | * Système de chargement différé pour images et préchargement 4 | */ 5 | 6 | export class LazyLoadManager { 7 | constructor() { 8 | this.imageObserver = null; 9 | this.prefetchObserver = null; 10 | this.init(); 11 | } 12 | 13 | init() { 14 | // Intersection Observer for lazy loading images 15 | if ('IntersectionObserver' in window) { 16 | this.imageObserver = new IntersectionObserver((entries) => { 17 | entries.forEach(entry => { 18 | if (entry.isIntersecting) { 19 | this.loadImage(entry.target); 20 | this.imageObserver.unobserve(entry.target); 21 | } 22 | }); 23 | }, { 24 | rootMargin: '50px 0px', // Load images 50px before they become visible 25 | threshold: 0.1 26 | }); 27 | 28 | // Observer for prefetching data 29 | this.prefetchObserver = new IntersectionObserver((entries) => { 30 | entries.forEach(entry => { 31 | if (entry.isIntersecting) { 32 | this.prefetchData(entry.target); 33 | this.prefetchObserver.unobserve(entry.target); 34 | } 35 | }); 36 | }, { 37 | rootMargin: '200px 0px', // Prefetch data 200px before visible 38 | threshold: 0 39 | }); 40 | } 41 | } 42 | 43 | loadImage(element) { 44 | const bgUrl = element.dataset.bgSrc; 45 | if (!bgUrl) return; 46 | 47 | // If image is already loaded, skip 48 | if (element.classList.contains('loaded')) { 49 | return; 50 | } 51 | 52 | // Set the background using CSS variable 53 | element.style.setProperty('--bg-image', `url('${bgUrl}')`); 54 | element.classList.add('loaded', 'bg-image-dynamic'); 55 | element.classList.remove('loading'); 56 | } 57 | 58 | prefetchData(element) { 59 | const prefetchUrl = element.dataset.prefetch; 60 | if (!prefetchUrl) return; 61 | 62 | // Use fetch with low priority 63 | fetch(prefetchUrl, { 64 | method: 'GET', 65 | priority: 'low' 66 | }).catch(() => { 67 | // Silent fail for prefetch 68 | }); 69 | } 70 | 71 | observe(element) { 72 | if (this.imageObserver && element.dataset.bgSrc) { 73 | this.imageObserver.observe(element); 74 | } 75 | if (this.prefetchObserver && element.dataset.prefetch) { 76 | this.prefetchObserver.observe(element); 77 | } 78 | } 79 | 80 | // Batch observe elements 81 | observeAll(selector = '.poster[data-bg-src]') { 82 | document.querySelectorAll(selector).forEach(el => this.observe(el)); 83 | } 84 | 85 | // Update existing grids to use lazy loading 86 | convertExistingImages() { 87 | // Handle old-style background-image posters 88 | document.querySelectorAll('.poster[style*="background-image"]').forEach(poster => { 89 | const style = poster.getAttribute('style'); 90 | const match = style.match(/background-image:\s*url\(['"](.+?)['"]\)/); 91 | if (match) { 92 | const url = match[1]; 93 | poster.dataset.bgSrc = url; 94 | poster.style.removeProperty('background-image'); 95 | poster.classList.add('lazy-bg'); 96 | this.observe(poster); 97 | } 98 | }); 99 | 100 | // Handle new-style data-bg-src posters 101 | const posters = document.querySelectorAll('.poster[data-bg-src]'); 102 | 103 | posters.forEach(poster => { 104 | if (!poster.classList.contains('lazy-bg')) { 105 | poster.classList.add('lazy-bg'); 106 | } 107 | this.observe(poster); 108 | }); 109 | 110 | // If no intersection observer, load all immediately 111 | if (!this.imageObserver) { 112 | posters.forEach(poster => this.loadImage(poster)); 113 | } 114 | } 115 | } 116 | 117 | // Initialize when DOM is ready (will be exported below) 118 | 119 | // Auto-initialize when DOM is loaded 120 | 121 | export function initializeLazyLoading() { 122 | lazyManager.convertExistingImages(); 123 | 124 | // Watch for dynamically added images 125 | const observer = new MutationObserver((mutations) => { 126 | mutations.forEach((mutation) => { 127 | mutation.addedNodes.forEach((node) => { 128 | if (node.nodeType === 1) { // Element node 129 | // Check if it's a poster itself 130 | if (node.matches && node.matches('.poster[data-bg-src]')) { 131 | node.classList.add('lazy-bg'); 132 | lazyManager.observe(node); 133 | } 134 | // Check children for posters 135 | const posters = node.querySelectorAll && node.querySelectorAll('.poster[data-bg-src]'); 136 | if (posters) { 137 | posters.forEach(poster => { 138 | poster.classList.add('lazy-bg'); 139 | lazyManager.observe(poster); 140 | }); 141 | } 142 | } 143 | }); 144 | }); 145 | }); 146 | 147 | observer.observe(document.body, { 148 | childList: true, 149 | subtree: true 150 | }); 151 | } 152 | 153 | // Fallback function for browsers without Intersection Observer 154 | export function fallbackImageLoading() { 155 | setTimeout(() => { 156 | document.querySelectorAll('.poster[data-bg-src]').forEach(element => { 157 | const bgUrl = element.dataset.bgSrc; 158 | if (bgUrl) { 159 | element.style.setProperty('--bg-image', `url('${bgUrl}')`); 160 | element.classList.add('bg-image-dynamic'); 161 | } 162 | }); 163 | }, 100); 164 | } 165 | 166 | // Export instance for use by other modules 167 | export const lazyManager = new LazyLoadManager(); 168 | window.lazyManager = lazyManager; -------------------------------------------------------------------------------- /public/assets/modules/i18n.js: -------------------------------------------------------------------------------- 1 | /** 2 | * I18n Module - Client-side internationalization 3 | */ 4 | 5 | class I18n { 6 | constructor() { 7 | this.currentLang = 'fr'; 8 | this.translations = {}; 9 | this.fallbackLang = 'fr'; 10 | this.supportedLangs = ['fr', 'en']; 11 | } 12 | 13 | async init() { 14 | // Detect language from localStorage, navigator, or default 15 | this.currentLang = this.detectLanguage(); 16 | 17 | // Load current language translations 18 | await this.loadTranslations(this.currentLang); 19 | 20 | // Load fallback if different from current 21 | if (this.currentLang !== this.fallbackLang) { 22 | await this.loadTranslations(this.fallbackLang); 23 | } 24 | 25 | 26 | // Déclencher l'événement d'initialisation 27 | window.dispatchEvent(new CustomEvent('i18nInitialized', { 28 | detail: { lang: this.currentLang } 29 | })); 30 | } 31 | 32 | detectLanguage() { 33 | // 1. Check localStorage 34 | const stored = localStorage.getItem('trakt_lang'); 35 | if (stored && this.supportedLangs.includes(stored)) { 36 | return stored; 37 | } 38 | 39 | // 2. Check navigator language 40 | const nav = navigator.language || navigator.userLanguage || 'fr'; 41 | const navLang = nav.split('-')[0]; // 'fr-FR' -> 'fr' 42 | 43 | if (this.supportedLangs.includes(navLang)) { 44 | return navLang; 45 | } 46 | 47 | // 3. Default fallback 48 | return this.fallbackLang; 49 | } 50 | 51 | async loadTranslations(lang) { 52 | try { 53 | const response = await fetch(`/locales/${lang}.json`); 54 | if (!response.ok) { 55 | throw new Error(`Failed to load ${lang} translations`); 56 | } 57 | 58 | this.translations[lang] = await response.json(); 59 | } catch (error) { 60 | console.error(`[i18n] Error loading ${lang} translations:`, error); 61 | 62 | // If it's not the fallback language, try to load fallback 63 | if (lang !== this.fallbackLang && !this.translations[this.fallbackLang]) { 64 | this.currentLang = this.fallbackLang; 65 | } 66 | } 67 | } 68 | 69 | async changeLanguage(lang) { 70 | if (!this.supportedLangs.includes(lang)) { 71 | console.warn(`[i18n] Unsupported language: ${lang}`); 72 | return false; 73 | } 74 | 75 | // Load translations if not already loaded 76 | if (!this.translations[lang]) { 77 | await this.loadTranslations(lang); 78 | } 79 | 80 | this.currentLang = lang; 81 | localStorage.setItem('trakt_lang', lang); 82 | 83 | // Update HTML lang attribute 84 | document.documentElement.lang = lang; 85 | 86 | 87 | // Dispatch custom event for components to re-render 88 | window.dispatchEvent(new CustomEvent('languageChanged', { 89 | detail: { lang } 90 | })); 91 | 92 | return true; 93 | } 94 | 95 | /** 96 | * Get translation for a key with optional variables 97 | * @param {string} key - Translation key (e.g., 'navigation.shows') 98 | * @param {Object} vars - Variables to interpolate 99 | * @returns {string} Translated text 100 | */ 101 | t(key, vars = {}) { 102 | const translation = this.getTranslation(key, this.currentLang) || 103 | this.getTranslation(key, this.fallbackLang) || 104 | key; 105 | 106 | return this.interpolate(translation, vars); 107 | } 108 | 109 | getTranslation(key, lang) { 110 | const keys = key.split('.'); 111 | let current = this.translations[lang]; 112 | 113 | for (const k of keys) { 114 | if (current && typeof current === 'object' && k in current) { 115 | current = current[k]; 116 | } else { 117 | return null; 118 | } 119 | } 120 | 121 | return current; 122 | } 123 | 124 | interpolate(text, vars) { 125 | if (typeof text !== 'string') return text; 126 | 127 | return text.replace(/\{(\w+)\}/g, (match, key) => { 128 | return vars.hasOwnProperty(key) ? vars[key] : match; 129 | }); 130 | } 131 | 132 | /** 133 | * Get current language 134 | */ 135 | getCurrentLanguage() { 136 | return this.currentLang; 137 | } 138 | 139 | /** 140 | * Get supported languages 141 | */ 142 | getSupportedLanguages() { 143 | return this.supportedLangs; 144 | } 145 | 146 | /** 147 | * Format time duration with proper pluralization 148 | */ 149 | formatTime(minutes) { 150 | const m = Number(minutes || 0); 151 | const d = Math.floor(m / (60 * 24)); 152 | const h = Math.floor((m % (60 * 24)) / 60); 153 | const r = m % 60; 154 | 155 | if (d > 0) { 156 | return this.t('time.days_hours', { days: d, hours: h }); 157 | } 158 | if (h > 0) { 159 | return this.t('time.hours_minutes', { hours: h, minutes: r }); 160 | } 161 | return this.t('time.minutes', { count: m }); 162 | } 163 | 164 | /** 165 | * Get user timezone automatically or from translation 166 | */ 167 | getTimezone() { 168 | const configuredTz = this.t('stats.timezone'); 169 | if (configuredTz === 'auto' || !configuredTz) { 170 | try { 171 | return Intl.DateTimeFormat().resolvedOptions().timeZone; 172 | } catch (err) { 173 | return 'UTC'; // fallback 174 | } 175 | } 176 | return configuredTz; 177 | } 178 | } 179 | 180 | // Create global instance 181 | const i18n = new I18n(); 182 | 183 | // Auto-initialize when module loads 184 | i18n.init().catch(error => { 185 | console.error('[i18n] Initialization failed:', error); 186 | }); 187 | 188 | export default i18n; 189 | export { i18n }; -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | 2 | import fsp from 'node:fs/promises'; 3 | import path from 'node:path'; 4 | 5 | export const sleep = (ms) => new Promise(r => setTimeout(r, ms)); 6 | export function nowIso() { return new Date().toISOString(); } 7 | 8 | export async function jsonLoad(file) { 9 | try { return JSON.parse(await fsp.readFile(file, 'utf8')); } catch { return null; } 10 | } 11 | export async function jsonSave(file, data) { 12 | const tmp = `${file}.tmp${Math.random().toString(36).slice(2)}`; 13 | await fsp.writeFile(tmp, JSON.stringify(data, null, 2)); 14 | await fsp.chmod(tmp, 0o644); 15 | await fsp.rename(tmp, file); 16 | } 17 | 18 | export function cachePath(dir, key, ext='json') { 19 | const safe = key.replace(/[^a-z0-9\-_.]/gi, '_'); 20 | return path.join(dir, `${safe}.${ext}`); 21 | } 22 | 23 | // Device code persistence helpers 24 | const DEVICE_CODE_FILE = path.join(process.cwd(), 'data', '.device_code.json'); 25 | 26 | export async function saveDeviceCode(deviceCodeData) { 27 | try { 28 | await fsp.mkdir(path.dirname(DEVICE_CODE_FILE), { recursive: true }); 29 | await jsonSave(DEVICE_CODE_FILE, { 30 | ...deviceCodeData, 31 | created_at: Date.now() 32 | }); 33 | } catch (error) { 34 | console.warn('[device_code] Failed to save:', error.message); 35 | } 36 | } 37 | 38 | export async function loadDeviceCode() { 39 | try { 40 | const data = await jsonLoad(DEVICE_CODE_FILE); 41 | if (!data) return null; 42 | 43 | // Expire après 10 minutes (device codes Trakt expirent généralement après 15min) 44 | const age = (Date.now() - (data.created_at || 0)) / 1000; 45 | if (age > 600) { 46 | await clearDeviceCode(); 47 | return null; 48 | } 49 | 50 | return data; 51 | } catch { 52 | return null; 53 | } 54 | } 55 | 56 | export async function clearDeviceCode() { 57 | try { 58 | await fsp.unlink(DEVICE_CODE_FILE); 59 | } catch { 60 | // Ignore si le fichier n'existe pas 61 | } 62 | } 63 | 64 | export function h(s='') { 65 | return String(s).replace(/[&<>"']/g, c => ({ 66 | '&': '&', '<':'<', '>':'>', '"':'"', "'":''' 67 | })[c]); 68 | } 69 | 70 | // Get public host with intelligent defaults for different environments 71 | export async function getPublicHost(port) { 72 | if (process.env.PUBLIC_HOST) { 73 | return process.env.PUBLIC_HOST; 74 | } 75 | 76 | // Try to detect if we're in Docker (common indicator files/env vars) 77 | let isDocker = false; 78 | try { 79 | isDocker = process.env.container || 80 | process.env.HOSTNAME?.startsWith('docker-') || 81 | (await fsp.access('/.dockerenv').then(() => true).catch(() => false)); 82 | } catch { 83 | isDocker = false; 84 | } 85 | 86 | if (isDocker) { 87 | // In Docker, try to get the real host IP from common sources 88 | const dockerHost = process.env.DOCKER_HOST_IP || 89 | process.env.HOST_IP || 90 | '0.0.0.0'; // Fallback to bind all interfaces 91 | return `${dockerHost}:${port}`; 92 | } 93 | 94 | // Default for local development 95 | return `localhost:${port}`; 96 | } 97 | 98 | export function baseUrl(req) { 99 | // 1) Priorité à une URL publique si définie (déploiement derrière proxy/CDN) 100 | const envUrl = process.env.PUBLIC_BASE_URL || process.env.BASE_URL; 101 | if (envUrl) { 102 | try { 103 | const u = new URL(envUrl); 104 | return `${u.protocol}//${u.host}`.replace(/\/+$/, ''); 105 | } catch { 106 | return String(envUrl).replace(/\/+$/, ''); 107 | } 108 | } 109 | 110 | // 2) Fallback robuste si on n'a pas de vraie requête (ex: tâches en arrière-plan) 111 | const headers = req?.headers || {}; 112 | const xfProto = headers['x-forwarded-proto']; 113 | const proto = String( 114 | (Array.isArray(xfProto) ? xfProto[0] : xfProto) || 115 | req?.protocol || 116 | 'http' 117 | ).split(',')[0]; 118 | 119 | const host = 120 | headers['x-forwarded-host'] || 121 | headers['host'] || 122 | req?.get?.('host') || 123 | 'localhost:3000'; 124 | 125 | return `${proto}://${host}`.replace(/\/+$/, ''); 126 | } 127 | 128 | 129 | export function svgNoPoster() { 130 | return `data:image/svg+xml;utf8,` + encodeURIComponent( 131 | `No Poster` 132 | ); 133 | } 134 | 135 | export function makeRefresher(task) { 136 | let running = false; 137 | let timer = null; 138 | 139 | async function refreshNow(reason = 'manual') { 140 | if (running) { 141 | console.log(`[refresh] skip (${reason}) : déjà en cours`); 142 | return false; 143 | } 144 | running = true; 145 | const t0 = Date.now(); 146 | console.log(`[refresh] start (${reason})…`); 147 | try { 148 | await task(reason); 149 | const dt = ((Date.now() - t0) / 1000).toFixed(1); 150 | console.log(`[refresh] OK (${reason}) en ${dt}s`); 151 | } catch (err) { 152 | console.error(`[refresh] ERROR (${reason})`, err); 153 | } finally { 154 | running = false; 155 | } 156 | return true; 157 | } 158 | 159 | function schedule({ intervalMs = 60 * 60 * 1000, initialDelayMs = 0 } = {}) { 160 | if (timer) clearInterval(timer); 161 | 162 | const first = Math.max(0, initialDelayMs); 163 | console.log(`[refresh] scheduler ON → every ${Math.round(intervalMs/1000)}s (first in ${first}ms)`); 164 | 165 | setTimeout(() => { refreshNow('startup'); }, first); 166 | timer = setInterval(() => { refreshNow('hourly'); }, intervalMs); 167 | 168 | return () => { clearInterval(timer); timer = null; console.log('[refresh] scheduler OFF'); }; 169 | } 170 | 171 | 172 | return { refreshNow, schedule, isRunning: () => running }; 173 | } 174 | 175 | -------------------------------------------------------------------------------- /public/assets/login.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Login Page Handler 3 | */ 4 | 5 | // CSRF token récupéré depuis l'attribut data du script 6 | const scriptTag = document.currentScript || document.querySelector('script[data-csrf-token]'); 7 | const CSRF_TOKEN = scriptTag ? scriptTag.getAttribute('data-csrf-token') : ''; 8 | 9 | 10 | // Variable pour accéder au système i18n 11 | let i18n = null; 12 | 13 | // Initialiser l'i18n dès que disponible 14 | if (typeof I18nLite !== 'undefined') { 15 | i18n = new I18nLite(); 16 | i18n.init().then(() => { 17 | console.log('[Login] I18n initialized'); 18 | translatePage(); 19 | }); 20 | } 21 | 22 | // Helper function pour les traductions avec fallback 23 | function t(key, vars = {}) { 24 | if (i18n && i18n.t) { 25 | return i18n.t(key, vars); 26 | } 27 | return key; 28 | } 29 | 30 | // Traduire la page 31 | function translatePage() { 32 | if (!i18n) return; 33 | 34 | // Update page title 35 | document.title = i18n.t('login.page_title'); 36 | 37 | // Update language selector 38 | const langToggleText = document.getElementById('langToggleText'); 39 | if (langToggleText) { 40 | langToggleText.textContent = i18n.currentLang.toUpperCase(); 41 | } 42 | 43 | // Translate all elements with data-i18n 44 | document.querySelectorAll('[data-i18n]').forEach(element => { 45 | const key = element.getAttribute('data-i18n'); 46 | element.textContent = i18n.t(key); 47 | }); 48 | } 49 | 50 | // Afficher une alerte 51 | function showAlert(message, type = 'error') { 52 | const alertContainer = document.getElementById('alert-container'); 53 | const alert = document.getElementById('alert'); 54 | const alertIcon = document.getElementById('alert-icon'); 55 | const alertMessage = document.getElementById('alert-message'); 56 | 57 | if (type === 'error') { 58 | alert.className = 'p-4 rounded-lg bg-red-900/50 border border-red-700 text-red-200'; 59 | alertIcon.className = 'fa-solid fa-circle-xmark mr-3 text-red-400'; 60 | } else if (type === 'success') { 61 | alert.className = 'p-4 rounded-lg bg-green-900/50 border border-green-700 text-green-200'; 62 | alertIcon.className = 'fa-solid fa-check-circle mr-3 text-green-400'; 63 | } 64 | 65 | alertMessage.textContent = message; 66 | alertContainer.classList.remove('hidden'); 67 | 68 | // Auto-hide après 5 secondes 69 | setTimeout(() => { 70 | alertContainer.classList.add('hidden'); 71 | }, 5000); 72 | } 73 | 74 | document.addEventListener('DOMContentLoaded', () => { 75 | const loginForm = document.getElementById('login-form'); 76 | const submitBtn = document.getElementById('submit-btn'); 77 | 78 | // Gestion du sélecteur de langue 79 | const langToggle = document.getElementById('langToggle'); 80 | const langDropdown = document.getElementById('langDropdown'); 81 | 82 | if (langToggle && langDropdown) { 83 | langToggle.addEventListener('click', (e) => { 84 | e.stopPropagation(); 85 | langDropdown.classList.toggle('hidden'); 86 | }); 87 | 88 | // Fermer le dropdown en cliquant ailleurs 89 | document.addEventListener('click', () => { 90 | langDropdown.classList.add('hidden'); 91 | }); 92 | 93 | // Changer la langue 94 | const langButtons = langDropdown.querySelectorAll('[data-lang]'); 95 | langButtons.forEach(btn => { 96 | btn.addEventListener('click', (e) => { 97 | e.stopPropagation(); 98 | const lang = btn.getAttribute('data-lang'); 99 | if (i18n) { 100 | i18n.changeLanguage(lang); 101 | translatePage(); 102 | } 103 | langDropdown.classList.add('hidden'); 104 | }); 105 | }); 106 | } 107 | 108 | // Gestion du formulaire de login 109 | if (loginForm) { 110 | loginForm.addEventListener('submit', async (e) => { 111 | e.preventDefault(); 112 | 113 | const formData = new FormData(loginForm); 114 | 115 | // Convertir FormData en URLSearchParams pour plus de compatibilité 116 | const params = new URLSearchParams(); 117 | for (const [key, value] of formData.entries()) { 118 | params.append(key, value); 119 | } 120 | params.set('csrf', CSRF_TOKEN); 121 | 122 | 123 | // Désactiver le bouton pendant la soumission 124 | submitBtn.disabled = true; 125 | submitBtn.innerHTML = ` 126 | 127 | ${t('login.authenticating')} 128 | `; 129 | 130 | try { 131 | const response = await fetch('/login', { 132 | method: 'POST', 133 | headers: { 134 | 'Content-Type': 'application/x-www-form-urlencoded' 135 | }, 136 | body: params 137 | }); 138 | 139 | const data = await response.json(); 140 | 141 | if (response.ok && data.success) { 142 | showAlert(t('login.success'), 'success'); 143 | 144 | // Redirection après connexion réussie 145 | const returnUrl = new URLSearchParams(window.location.search).get('returnUrl') || '/'; 146 | setTimeout(() => { 147 | window.location.href = returnUrl; 148 | }, 1000); 149 | } else { 150 | showAlert(data.error || t('login.invalid_credentials'), 'error'); 151 | 152 | // Réactiver le bouton 153 | submitBtn.disabled = false; 154 | submitBtn.innerHTML = ` 155 | 156 | ${t('login.submit')} 157 | `; 158 | } 159 | } catch (error) { 160 | console.error('Login error:', error); 161 | showAlert(t('login.connection_error'), 'error'); 162 | 163 | // Réactiver le bouton 164 | submitBtn.disabled = false; 165 | submitBtn.innerHTML = ` 166 | 167 | ${t('login.submit')} 168 | `; 169 | } 170 | }); 171 | } 172 | }); -------------------------------------------------------------------------------- /public/assets/modules/auth-guard.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Auth Guard Module 3 | * Vérifie l'état de l'authentification et bloque tous les appels API si nécessaire 4 | */ 5 | 6 | let isAuthenticated = false; 7 | let authCheckInProgress = false; 8 | let authCheckPromise = null; 9 | 10 | /** 11 | * Vérifie l'état de l'authentification 12 | * @returns {Promise} true si authentifié, false sinon 13 | */ 14 | export async function checkAuthStatus() { 15 | // Si une vérification est déjà en cours, attendre son résultat 16 | if (authCheckInProgress && authCheckPromise) { 17 | return authCheckPromise; 18 | } 19 | 20 | authCheckInProgress = true; 21 | authCheckPromise = performAuthCheck(); 22 | 23 | try { 24 | const result = await authCheckPromise; 25 | return result; 26 | } finally { 27 | authCheckInProgress = false; 28 | authCheckPromise = null; 29 | } 30 | } 31 | 32 | async function performAuthCheck() { 33 | try { 34 | 35 | // Faire un appel minimal pour vérifier l'auth 36 | const response = await fetch('/api/data', { 37 | method: 'GET', 38 | cache: 'no-store', 39 | headers: { 40 | 'Accept': 'application/json' 41 | } 42 | }); 43 | 44 | if (!response.ok && response.status === 412) { 45 | // Needs setup 46 | window.location.href = '/setup'; 47 | return false; 48 | } 49 | 50 | const data = await response.json(); 51 | 52 | // Vérifier si on a besoin d'authentification 53 | if (data.needsAuth === true) { 54 | isAuthenticated = false; 55 | showAuthPrompt(data); 56 | // IMPORTANT: Ne pas déclencher de reload, juste afficher le prompt 57 | return false; 58 | } 59 | 60 | // Token valide 61 | isAuthenticated = true; 62 | hideAuthPrompt(); 63 | return true; 64 | 65 | } catch (error) { 66 | console.error('[AuthGuard] Auth check failed:', error); 67 | // En cas d'erreur réseau, considérer comme non authentifié 68 | isAuthenticated = false; 69 | return false; 70 | } 71 | } 72 | 73 | /** 74 | * Affiche uniquement l'interface de connexion 75 | */ 76 | function showAuthPrompt(data) { 77 | 78 | // Cacher l'interface principale 79 | const mainContainer = document.getElementById('mainContainer'); 80 | if (mainContainer) { 81 | mainContainer.style.display = 'none'; 82 | } 83 | 84 | // Cacher les onglets 85 | const tabsGroup = document.querySelector('.tabs-group'); 86 | if (tabsGroup) { 87 | tabsGroup.style.display = 'none'; 88 | } 89 | 90 | // Cacher le bouton de filtres mobile 91 | const mobileFiltersToggle = document.getElementById('mobileFiltersToggle'); 92 | if (mobileFiltersToggle) { 93 | mobileFiltersToggle.style.display = 'none'; 94 | } 95 | 96 | // Cacher les filtres 97 | const mobileFilters = document.getElementById('mobileFilters'); 98 | if (mobileFilters) { 99 | mobileFilters.style.display = 'none'; 100 | } 101 | 102 | // Afficher le message d'authentification 103 | const deviceBox = document.getElementById('deviceBox'); 104 | if (deviceBox) { 105 | deviceBox.innerHTML = ` 106 |
107 | 108 |

Connexion à Trakt requise

109 |

Votre token d'authentification est invalide ou expirant.
Veuillez vous reconnecter à Trakt pour continuer.

110 | 111 | Se connecter avec Trakt 112 | 113 |
114 | `; 115 | deviceBox.classList.remove('hidden'); 116 | } 117 | 118 | // Afficher un message d'erreur si présent 119 | if (data.flash) { 120 | const flashBox = document.getElementById('flashBox'); 121 | if (flashBox) { 122 | flashBox.textContent = data.flash; 123 | flashBox.classList.remove('hidden'); 124 | flashBox.className = 'mx-auto max-w-7xl mt-4 px-4 py-3 rounded-lg border border-yellow-600 bg-yellow-900/60 text-yellow-200'; 125 | } 126 | } 127 | } 128 | 129 | /** 130 | * Cache l'interface d'authentification 131 | */ 132 | function hideAuthPrompt() { 133 | const deviceBox = document.getElementById('deviceBox'); 134 | if (deviceBox) { 135 | deviceBox.classList.add('hidden'); 136 | } 137 | 138 | const mainContainer = document.getElementById('mainContainer'); 139 | if (mainContainer) { 140 | mainContainer.style.display = ''; 141 | } 142 | 143 | const tabsGroup = document.querySelector('.tabs-group'); 144 | if (tabsGroup) { 145 | tabsGroup.style.display = ''; 146 | } 147 | } 148 | 149 | /** 150 | * Wrapper pour fetch qui vérifie l'authentification 151 | */ 152 | export async function guardedFetch(url, options = {}) { 153 | // Liste des URLs qui ne nécessitent pas d'authentification 154 | const authExemptUrls = [ 155 | '/health', 156 | '/setup', 157 | '/auth', 158 | '/oauth', 159 | '/api/data' // On laisse passer pour le check initial 160 | ]; 161 | 162 | // Vérifier si l'URL est exemptée 163 | const isExempt = authExemptUrls.some(exempt => url.startsWith(exempt)); 164 | 165 | if (!isExempt && !isAuthenticated) { 166 | // Vérifier l'auth une fois de plus 167 | const authValid = await checkAuthStatus(); 168 | if (!authValid) { 169 | throw new Error('Authentication required'); 170 | } 171 | } 172 | 173 | // Faire l'appel normal 174 | const response = await fetch(url, options); 175 | 176 | // Si on reçoit une erreur 401, invalider l'auth 177 | if (response.status === 401) { 178 | isAuthenticated = false; 179 | await checkAuthStatus(); // Re-vérifier et afficher le prompt 180 | throw new Error('Authentication expired'); 181 | } 182 | 183 | return response; 184 | } 185 | 186 | /** 187 | * Vérifie si l'utilisateur est authentifié 188 | */ 189 | export function isUserAuthenticated() { 190 | return isAuthenticated; 191 | } 192 | 193 | /** 194 | * Force une re-vérification de l'authentification 195 | */ 196 | export async function forceAuthCheck() { 197 | isAuthenticated = false; 198 | authCheckPromise = null; 199 | return checkAuthStatus(); 200 | } 201 | 202 | // Export par défaut 203 | export default { 204 | checkAuthStatus, 205 | guardedFetch, 206 | isUserAuthenticated, 207 | forceAuthCheck 208 | }; -------------------------------------------------------------------------------- /lib/rateLimiter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Rate Limiter pour l'API Trakt 3 | * Respecte les limites : 1000 requêtes GET par 5 minutes 4 | * Soit environ 3.3 requêtes par seconde maximum 5 | */ 6 | 7 | import { logger } from './logger.js'; 8 | 9 | class TraktRateLimiter { 10 | constructor() { 11 | // File d'attente des requêtes 12 | this.queue = []; 13 | this.processing = false; 14 | 15 | // Compteurs pour les limites 16 | this.requestCounts = { 17 | GET: [], // Timestamps des requêtes GET 18 | POST: [], // Timestamps des requêtes POST/PUT/DELETE 19 | }; 20 | 21 | // Limites de l'API Trakt 22 | this.limits = { 23 | GET: { 24 | max: 1000, 25 | window: 5 * 60 * 1000 // 5 minutes en ms 26 | }, 27 | POST: { 28 | max: 1, 29 | window: 1000 // 1 seconde en ms 30 | } 31 | }; 32 | 33 | // Délai minimum entre les requêtes (300ms pour être prudent) 34 | this.minDelay = 300; 35 | this.lastRequestTime = 0; 36 | } 37 | 38 | /** 39 | * Nettoie les anciennes requêtes hors de la fenêtre de temps 40 | */ 41 | cleanOldRequests(method) { 42 | const now = Date.now(); 43 | const window = this.limits[method]?.window || this.limits.GET.window; 44 | 45 | this.requestCounts[method] = this.requestCounts[method].filter( 46 | timestamp => now - timestamp < window 47 | ); 48 | } 49 | 50 | /** 51 | * Vérifie si on peut faire une requête maintenant 52 | */ 53 | canMakeRequest(method = 'GET') { 54 | this.cleanOldRequests(method); 55 | 56 | const methodType = ['POST', 'PUT', 'DELETE'].includes(method) ? 'POST' : 'GET'; 57 | const limit = this.limits[methodType]; 58 | const count = this.requestCounts[methodType].length; 59 | 60 | return count < limit.max; 61 | } 62 | 63 | /** 64 | * Calcule le délai d'attente nécessaire avant la prochaine requête 65 | */ 66 | getWaitTime(method = 'GET') { 67 | const now = Date.now(); 68 | const methodType = ['POST', 'PUT', 'DELETE'].includes(method) ? 'POST' : 'GET'; 69 | 70 | // Nettoyer les anciennes requêtes 71 | this.cleanOldRequests(methodType); 72 | 73 | const limit = this.limits[methodType]; 74 | const requests = this.requestCounts[methodType]; 75 | 76 | // Si on est sous la limite 77 | if (requests.length < limit.max) { 78 | // Respecter le délai minimum entre requêtes 79 | const timeSinceLastRequest = now - this.lastRequestTime; 80 | if (timeSinceLastRequest < this.minDelay) { 81 | return this.minDelay - timeSinceLastRequest; 82 | } 83 | return 0; 84 | } 85 | 86 | // Si on a atteint la limite, calculer quand la plus ancienne requête sortira de la fenêtre 87 | const oldestRequest = Math.min(...requests); 88 | const waitTime = (oldestRequest + limit.window) - now; 89 | 90 | return Math.max(waitTime, this.minDelay); 91 | } 92 | 93 | /** 94 | * Enregistre une requête 95 | */ 96 | recordRequest(method = 'GET') { 97 | const methodType = ['POST', 'PUT', 'DELETE'].includes(method) ? 'POST' : 'GET'; 98 | const now = Date.now(); 99 | 100 | this.requestCounts[methodType].push(now); 101 | this.lastRequestTime = now; 102 | 103 | // Log si on approche de la limite 104 | this.cleanOldRequests(methodType); 105 | const count = this.requestCounts[methodType].length; 106 | const limit = this.limits[methodType]; 107 | 108 | if (count > limit.max * 0.8) { 109 | logger.warn(`Trakt rate limit warning: ${count}/${limit.max} ${methodType} requests in window`); 110 | } 111 | } 112 | 113 | /** 114 | * Exécute une requête avec rate limiting 115 | */ 116 | async executeWithRateLimit(fn, method = 'GET') { 117 | return new Promise((resolve, reject) => { 118 | this.queue.push({ fn, method, resolve, reject }); 119 | this.processQueue(); 120 | }); 121 | } 122 | 123 | /** 124 | * Traite la file d'attente des requêtes 125 | */ 126 | async processQueue() { 127 | if (this.processing || this.queue.length === 0) { 128 | return; 129 | } 130 | 131 | this.processing = true; 132 | 133 | while (this.queue.length > 0) { 134 | const { fn, method, resolve, reject } = this.queue.shift(); 135 | 136 | try { 137 | // Attendre si nécessaire 138 | const waitTime = this.getWaitTime(method); 139 | if (waitTime > 0) { 140 | console.log(`[RateLimiter] Waiting ${waitTime}ms before ${method} request`); 141 | await new Promise(r => setTimeout(r, waitTime)); 142 | } 143 | 144 | // Enregistrer et exécuter la requête 145 | this.recordRequest(method); 146 | const result = await fn(); 147 | resolve(result); 148 | 149 | } catch (error) { 150 | const statusCode = error.status || error.statusCode || 0; 151 | 152 | // Gestion spéciale pour les erreurs serveur (5xx) 153 | if (statusCode >= 500 && statusCode < 600) { 154 | console.log(`[RateLimiter] Got ${statusCode} server error, waiting 10s before retry`); 155 | // Remettre dans la queue pour réessayer 156 | this.queue.unshift({ fn, method, resolve, reject }); 157 | await new Promise(r => setTimeout(r, 10000)); // 10 secondes 158 | } 159 | // Si erreur 429, attendre plus longtemps 160 | else if (statusCode === 429) { 161 | const retryAfter = error.headers?.['retry-after'] || 60; 162 | console.log(`[RateLimiter] Got 429, waiting ${retryAfter}s`); 163 | 164 | // Remettre dans la queue pour réessayer 165 | this.queue.unshift({ fn, method, resolve, reject }); 166 | await new Promise(r => setTimeout(r, retryAfter * 1000)); 167 | } else { 168 | reject(error); 169 | } 170 | } 171 | } 172 | 173 | this.processing = false; 174 | } 175 | 176 | /** 177 | * Obtient les statistiques actuelles 178 | */ 179 | getStats() { 180 | this.cleanOldRequests('GET'); 181 | this.cleanOldRequests('POST'); 182 | 183 | return { 184 | GET: { 185 | current: this.requestCounts.GET.length, 186 | limit: this.limits.GET.max, 187 | percentage: (this.requestCounts.GET.length / this.limits.GET.max) * 100 188 | }, 189 | POST: { 190 | current: this.requestCounts.POST.length, 191 | limit: this.limits.POST.max, 192 | percentage: (this.requestCounts.POST.length / this.limits.POST.max) * 100 193 | }, 194 | queueLength: this.queue.length 195 | }; 196 | } 197 | } 198 | 199 | // Instance unique du rate limiter 200 | export const traktRateLimiter = new TraktRateLimiter(); -------------------------------------------------------------------------------- /public/assets/modules/animations.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Animation Module 3 | * Système d'animations fluides pour interactions UI 4 | */ 5 | 6 | export class AnimationManager { 7 | constructor() { 8 | this.observeElements(); 9 | this.setupIntersectionObserver(); 10 | } 11 | 12 | // Observer pour animer les éléments à l'apparition 13 | setupIntersectionObserver() { 14 | if ('IntersectionObserver' in window) { 15 | this.observer = new IntersectionObserver((entries) => { 16 | entries.forEach(entry => { 17 | if (entry.isIntersecting) { 18 | this.animateElement(entry.target); 19 | this.observer.unobserve(entry.target); 20 | } 21 | }); 22 | }, { 23 | rootMargin: '20px 0px', 24 | threshold: 0.1 25 | }); 26 | } 27 | } 28 | 29 | // Animer un élément selon son type 30 | animateElement(element) { 31 | if (element.classList.contains('card')) { 32 | element.classList.add('animate-fade-in-up'); 33 | } else if (element.classList.contains('filters')) { 34 | element.classList.add('animate-slide-in-right'); 35 | } else { 36 | element.classList.add('animate-fade-in'); 37 | } 38 | } 39 | 40 | // Observer les nouveaux éléments ajoutés 41 | observeElements() { 42 | // Observer les cartes existantes 43 | this.observeCards(); 44 | 45 | // Observer les mutations DOM pour les nouvelles cartes 46 | if ('MutationObserver' in window) { 47 | this.mutationObserver = new MutationObserver((mutations) => { 48 | mutations.forEach((mutation) => { 49 | mutation.addedNodes.forEach((node) => { 50 | if (node.nodeType === 1) { 51 | this.observeNewElement(node); 52 | 53 | // Observer les cartes dans les enfants 54 | const cards = node.querySelectorAll && node.querySelectorAll('.card'); 55 | if (cards) { 56 | cards.forEach(card => this.observeNewElement(card)); 57 | } 58 | } 59 | }); 60 | }); 61 | }); 62 | 63 | this.mutationObserver.observe(document.body, { 64 | childList: true, 65 | subtree: true 66 | }); 67 | } 68 | } 69 | 70 | // Observer les cartes existantes au chargement 71 | observeCards() { 72 | if (!this.observer) return; 73 | 74 | document.querySelectorAll('.card').forEach((card, index) => { 75 | // Ajouter un délai d'animation en cascade avec variable CSS 76 | card.style.setProperty('--animation-delay', `${index * 0.1}s`); 77 | card.classList.add('animation-delay-dynamic'); 78 | this.observer.observe(card); 79 | }); 80 | 81 | // Observer autres éléments 82 | document.querySelectorAll('.filters').forEach(filter => { 83 | this.observer.observe(filter); 84 | }); 85 | } 86 | 87 | // Observer un nouvel élément 88 | observeNewElement(element) { 89 | if (!this.observer) return; 90 | 91 | if (element.classList && element.classList.contains('card')) { 92 | this.observer.observe(element); 93 | } 94 | } 95 | 96 | // Animation de transition entre onglets 97 | animateTabTransition(fromPanel, toPanel) { 98 | if (fromPanel && toPanel) { 99 | // Animation de sortie 100 | fromPanel.classList.add('opacity-0-transform-slide'); 101 | 102 | setTimeout(() => { 103 | fromPanel.classList.add('hidden'); 104 | toPanel.classList.remove('hidden'); 105 | 106 | // Animation d'entrée 107 | toPanel.classList.add('opacity-0-transform-slide-right'); 108 | 109 | requestAnimationFrame(() => { 110 | toPanel.classList.remove('opacity-0-transform-slide-right'); 111 | toPanel.classList.add('opacity-1-transform-none'); 112 | }); 113 | }, 150); 114 | } 115 | } 116 | 117 | // Animation de recherche (filtrage des résultats) 118 | animateSearch(container) { 119 | const cards = container.querySelectorAll('.card'); 120 | 121 | // Animer la disparition 122 | cards.forEach((card, index) => { 123 | card.style.setProperty('--transition-delay', `${index * 0.02}s`); 124 | card.classList.add('opacity-0-scale-90', 'transition-delay-dynamic'); 125 | }); 126 | 127 | // Puis réanimer l'apparition 128 | setTimeout(() => { 129 | cards.forEach((card, index) => { 130 | card.style.setProperty('--transition-delay', `${index * 0.05}s`); 131 | card.classList.remove('opacity-0-scale-90'); 132 | card.classList.add('opacity-1-scale-100'); 133 | }); 134 | }, 200); 135 | } 136 | 137 | // Animation pour le loading des images 138 | animateImageLoad(imgElement) { 139 | imgElement.classList.add('opacity-0-scale-110'); 140 | 141 | imgElement.addEventListener('load', () => { 142 | imgElement.classList.remove('opacity-0-scale-110'); 143 | imgElement.classList.add('opacity-1-scale-100'); 144 | }); 145 | } 146 | 147 | // Effet de particules pour les interactions 148 | createParticleEffect(x, y, color = '#0ea5e9') { 149 | const particle = document.createElement('div'); 150 | particle.className = 'particle-effect'; 151 | particle.style.setProperty('--particle-x', `${x}px`); 152 | particle.style.setProperty('--particle-y', `${y}px`); 153 | particle.style.setProperty('--particle-color', color); 154 | 155 | document.body.appendChild(particle); 156 | 157 | // Nettoyer après l'animation 158 | setTimeout(() => { 159 | particle.remove(); 160 | }, 600); 161 | } 162 | } 163 | 164 | // Les keyframes pour particle-explosion sont maintenant dans tailwind.css 165 | 166 | // Export instance for use by other modules 167 | export const animationManager = new AnimationManager(); 168 | 169 | // Export pour usage manuel 170 | window.animationManager = animationManager; 171 | 172 | // Initialize animations - called by app-modular.js 173 | export function initializeAnimations() { 174 | // Animation d'entrée pour l'header 175 | const header = document.querySelector('.app-header'); 176 | if (header) { 177 | header.classList.add('transform-slide-up'); 178 | requestAnimationFrame(() => { 179 | header.classList.remove('transform-slide-up'); 180 | header.classList.add('transform-slide-none'); 181 | }); 182 | } 183 | 184 | // Ajouter des event listeners pour les effets interactifs 185 | document.addEventListener('click', (e) => { 186 | // Effet de particules sur clic des boutons 187 | if (e.target.matches('button, .btn')) { 188 | const rect = e.target.getBoundingClientRect(); 189 | const x = rect.left + rect.width / 2; 190 | const y = rect.top + rect.height / 2; 191 | animationManager.createParticleEffect(x, y); 192 | } 193 | }); 194 | 195 | } -------------------------------------------------------------------------------- /public/loading.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Chargement initial - Trakt Enhanced 7 | 8 | 9 | 10 | 11 |
12 | 13 |
14 |
15 | 20 | 30 |
31 |
32 | 33 |
34 | 35 |
36 |
37 | Trakt Enhanced 38 |

39 | Trakt Enhanced 40 |

41 |
42 |

Chargement initial en cours

43 |

Récupération de vos données depuis Trakt.tv...

44 |
45 | 46 | 47 |
48 |
49 | Progrès global 50 | 0% 51 |
52 |
53 |
54 |
55 |
56 | 57 | 58 |
59 |
60 | 61 |
62 |
Authentification
63 |
Vérification du token Trakt
64 |
65 |
66 | 67 |
68 |
69 | 70 |
71 | 72 |
73 |
Séries visionnées
74 |
Récupération de l'historique...
75 |
76 |
77 | 78 |
79 |
80 | 81 |
82 | 83 |
84 |
Films visionnés
85 |
En attente...
86 |
87 |
88 | 89 |
90 |
91 | 92 |
93 | 94 |
95 |
Progression des séries
96 |
En attente...
97 |
98 |
99 | 100 |
101 |
102 | 103 |
104 | 105 |
106 |
Collection
107 |
En attente...
108 |
109 |
110 | 111 |
112 |
113 | 114 |
115 | 116 |
117 |
Finalisation
118 |
En attente...
119 |
120 |
121 | 122 |
123 |
124 |
125 | 126 | 127 |
128 |
129 | 130 |
131 | Première synchronisation : Cette opération peut prendre quelques minutes selon la taille de votre collection Trakt. 132 | Les prochains chargements seront beaucoup plus rapides grâce au cache. 133 |
134 |
135 |
136 |
137 |
138 | 139 | 140 | 141 | 142 | 143 | -------------------------------------------------------------------------------- /lib/tmdb.js: -------------------------------------------------------------------------------- 1 | 2 | import fsp from 'node:fs/promises'; 3 | import path from 'node:path'; 4 | import { TMDB_API_KEY, CACHE_DIR, IMG_DIR, LANGUAGE } from './config.js'; 5 | import { cachePath, jsonSave, baseUrl } from './util.js'; 6 | import { loggers } from './logger.js'; 7 | 8 | async function httpGetJson(url, headers={}) { 9 | const startTime = Date.now(); 10 | 11 | try { 12 | const res = await fetch(url, { 13 | headers: { 'User-Agent':'trakt_fetcher', 'Accept':'application/json', ...headers }, 14 | redirect:'follow' 15 | }); 16 | 17 | const duration = Date.now() - startTime; 18 | loggers.logApiCall('tmdb', 'GET', url, duration, res.status); 19 | 20 | if (!res.ok) { 21 | try { 22 | return await res.json(); 23 | } catch { 24 | return null; 25 | } 26 | } 27 | return res.json(); 28 | } catch (err) { 29 | const duration = Date.now() - startTime; 30 | loggers.logApiCall('tmdb', 'GET', url, duration, 0, err); 31 | return null; 32 | } 33 | } 34 | 35 | // Fonction pour nettoyer le code de langue pour l'API TMDB 36 | function cleanLanguageCode(lang) { 37 | if (!lang) return 'fr-FR'; 38 | 39 | // Supprimer .UTF-8 et autres suffixes 40 | const cleaned = lang.replace(/\.UTF-8|\.utf8/gi, ''); 41 | 42 | // Mapper certaines valeurs communes 43 | const mapping = { 44 | 'fr': 'fr-FR', 45 | 'en': 'en-US', 46 | 'fr_FR': 'fr-FR', 47 | 'en_US': 'en-US' 48 | }; 49 | 50 | return mapping[cleaned] || cleaned || 'fr-FR'; 51 | } 52 | 53 | export async function tmdbGet(kind, id) { 54 | if (!TMDB_API_KEY) return null; 55 | const cleanLang = cleanLanguageCode(LANGUAGE); 56 | return httpGetJson(`https://api.themoviedb.org/3/${kind}/${id}?api_key=${TMDB_API_KEY}&language=${cleanLang}`); 57 | } 58 | export async function tmdbSearch(kind, query, year, language) { 59 | if (!TMDB_API_KEY || !query) return null; 60 | const y = year ? `&year=${encodeURIComponent(String(year))}` : ''; 61 | // Utiliser la langue fournie en paramètre, ou celle de la config par défaut 62 | const cleanLang = cleanLanguageCode(language || LANGUAGE); 63 | return httpGetJson(`https://api.themoviedb.org/3/search/${kind}?api_key=${TMDB_API_KEY}&query=${encodeURIComponent(query)}${y}&language=${cleanLang}`); 64 | } 65 | 66 | // Remplace intégralement posterLocalUrl par ceci : 67 | export async function posterLocalUrl(req, posterPath, size = 'w342', traktId = null) { 68 | if (!posterPath) return null; 69 | 70 | // Si on a un traktId, l'utiliser comme nom de fichier, sinon utiliser l'ancien système 71 | const filename = traktId 72 | ? `trakt_${traktId}.jpg` // Nouveau format : trakt_12345.jpg 73 | : `${size}_${String(posterPath).replace(/^\//, '').replace(/\//g, '_')}`; // Ancien format 74 | 75 | const file = path.join(IMG_DIR, filename); 76 | const relUrl = `/cache_imgs/${filename}`; 77 | 78 | try { 79 | const st = await fsp.stat(file).catch(() => null); 80 | const ttl = 180 * 24 * 3600 * 1000; // 180 jours 81 | 82 | // (Re)télécharger si absent ou trop ancien 83 | if (!st || (Date.now() - st.mtimeMs) > ttl) { 84 | const tmdbUrl = `https://image.tmdb.org/t/p/${size}${posterPath}`; 85 | const imgRes = await fetch(tmdbUrl); 86 | if (imgRes.ok) { 87 | const buf = Buffer.from(await imgRes.arrayBuffer()); 88 | await fsp.writeFile(file, buf); 89 | } else if (!st) { 90 | // échec de téléchargement et pas de fichier local -> pas d'image 91 | return null; 92 | } 93 | } 94 | 95 | return relUrl; 96 | } catch { 97 | // en cas d'erreur, si le fichier n'existe pas on renvoie null 98 | const exists = await fsp.stat(file).then(() => true).catch(() => false); 99 | return exists ? relUrl : null; 100 | } 101 | } 102 | 103 | // Nouvelle fonction pour obtenir l'URL du poster depuis l'ID Trakt uniquement 104 | export async function posterFromTraktId(traktId) { 105 | if (!traktId) return null; 106 | 107 | const filename = `trakt_${traktId}.jpg`; 108 | const file = path.join(IMG_DIR, filename); 109 | const relUrl = `/cache_imgs/${filename}`; 110 | 111 | // Vérifier si le fichier existe 112 | const exists = await fsp.stat(file).then(() => true).catch(() => false); 113 | return exists ? relUrl : null; 114 | } 115 | 116 | 117 | export async function getCachedMeta(req, kind, title, year, tmdbId, size, traktId = null) { 118 | let js = null; 119 | const cacheKey = tmdbId ? `${kind}-${tmdbId}` : `${kind}-${title}--${year || ''}`; 120 | const cacheFile = cachePath(CACHE_DIR, cacheKey, 'json'); 121 | 122 | // 1) charge depuis le cache disque si dispo 123 | try { js = JSON.parse(await fsp.readFile(cacheFile, 'utf8')); } catch {} 124 | 125 | // 2) sinon, récup TMDB (details si id connu, sinon search + meilleur candidat) 126 | if (!js) { 127 | js = tmdbId ? await tmdbGet(kind, tmdbId) : null; 128 | 129 | if ((!js || !js.poster_path) && title) { 130 | const search = await tmdbSearch(kind, title, year); 131 | if (search?.results?.length) { 132 | let best = null; 133 | const yy = String(year || ''); 134 | for (const cand of search.results) { 135 | const yr = kind === 'tv' 136 | ? (cand.first_air_date || '').slice(0, 4) 137 | : (cand.release_date || '').slice(0, 4); 138 | if (yy && yr === yy) { best = cand; break; } 139 | } 140 | if (!best) best = search.results[0]; 141 | js = best; 142 | } 143 | } 144 | 145 | if (js) await jsonSave(cacheFile, js); 146 | } 147 | 148 | // 3) construit les URLs utiles 149 | let poster = null, tmdbUrl = null; 150 | if (js?.poster_path) { 151 | poster = (await posterLocalUrl(req, js.poster_path, size, traktId)) 152 | || `https://image.tmdb.org/t/p/${size}${js.poster_path}`; 153 | } 154 | const tid = tmdbId || js?.id || null; 155 | if (tid) tmdbUrl = `https://www.themoviedb.org/${kind === 'tv' ? 'tv' : 'movie'}/${Number(tid)}`; 156 | 157 | // 4) overview (synopsis) — peut venir d'un "details" ou d'un "search result" 158 | const overview = 159 | (js && typeof js.overview === 'string' && js.overview.trim().length > 0) 160 | ? js.overview.trim() 161 | : null; 162 | 163 | // 5) titre localisé depuis TMDB 164 | const tmdbTitle = kind === 'tv' 165 | ? (js?.name || js?.original_name) 166 | : (js?.title || js?.original_title); 167 | 168 | return { poster, tmdbUrl, overview, title: tmdbTitle }; 169 | } 170 | 171 | export async function getDetailsCached(kind, tmdbId) { 172 | if (!tmdbId) return null; 173 | const cacheKey = `${kind}-${tmdbId}`; 174 | const cacheFile = cachePath(CACHE_DIR, cacheKey, 'json'); 175 | // try cache 176 | try { 177 | const js = JSON.parse(await fsp.readFile(cacheFile, 'utf8')); 178 | if (js && js.id) return js; 179 | } catch {} 180 | // otherwise fetch + save 181 | const d = await tmdbGet(kind, tmdbId); 182 | if (d) await jsonSave(cacheFile, d); 183 | return d || null; 184 | } 185 | 186 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Logger Module avec gestion robuste des permissions 3 | * Système de logging centralisé avec fallback sur console 4 | */ 5 | 6 | import winston from 'winston'; 7 | import DailyRotateFile from 'winston-daily-rotate-file'; 8 | import path from 'path'; 9 | import fs from 'fs'; 10 | import { DATA_DIR } from './config.js'; 11 | 12 | // Configuration du répertoire de logs 13 | const LOG_DIR = path.join(DATA_DIR, 'logs'); 14 | 15 | // Fonction helper pour créer un dossier de manière sûre 16 | function ensureLogDirectory() { 17 | try { 18 | if (!fs.existsSync(LOG_DIR)) { 19 | try { 20 | // Essayer de créer le dossier 21 | fs.mkdirSync(LOG_DIR, { recursive: true, mode: 0o755 }); 22 | console.log(`✅ Dossier de logs créé: ${LOG_DIR}`); 23 | return true; 24 | } catch (mkdirError) { 25 | // Si on ne peut pas créer, vérifier si le parent existe et est writable 26 | const parentDir = path.dirname(LOG_DIR); 27 | if (fs.existsSync(parentDir)) { 28 | try { 29 | // Tester l'écriture dans le parent 30 | const testFile = path.join(parentDir, '.write_test'); 31 | fs.writeFileSync(testFile, 'test'); 32 | fs.unlinkSync(testFile); 33 | 34 | // Si on peut écrire dans le parent, essayer avec une autre méthode 35 | fs.mkdirSync(LOG_DIR, { recursive: true }); 36 | return true; 37 | } catch (e) { 38 | console.warn(`⚠️ Impossible de créer ${LOG_DIR}: ${e.message}`); 39 | return false; 40 | } 41 | } 42 | console.warn(`⚠️ Dossier parent n'existe pas: ${parentDir}`); 43 | return false; 44 | } 45 | } 46 | 47 | // Le dossier existe, vérifier qu'on peut écrire dedans 48 | try { 49 | const testFile = path.join(LOG_DIR, '.write_test'); 50 | fs.writeFileSync(testFile, 'test'); 51 | fs.unlinkSync(testFile); 52 | return true; 53 | } catch (writeError) { 54 | console.warn(`⚠️ Impossible d'écrire dans ${LOG_DIR}: ${writeError.message}`); 55 | return false; 56 | } 57 | } catch (error) { 58 | console.warn(`⚠️ Erreur lors de la vérification du dossier de logs: ${error.message}`); 59 | return false; 60 | } 61 | } 62 | 63 | // Vérifier si on peut utiliser les fichiers de logs 64 | const canUseFileLogging = ensureLogDirectory(); 65 | 66 | if (!canUseFileLogging) { 67 | console.warn('⚠️ Les logs seront dirigés vers la console uniquement.'); 68 | console.warn('💡 Pour corriger: chmod -R 755 /app/data ou chown -R 99:100 /app/data'); 69 | } 70 | 71 | // Format personnalisé pour les logs 72 | const logFormat = winston.format.combine( 73 | winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), 74 | winston.format.errors({ stack: true }), 75 | winston.format.splat(), 76 | winston.format.printf(({ timestamp, level, message, ...meta }) => { 77 | const metaString = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : ''; 78 | return `${timestamp} [${level}]: ${message}${metaString ? '\n' + metaString : ''}`; 79 | }) 80 | ); 81 | 82 | // Créer les transports en fonction des permissions 83 | function createTransports(filePrefix = 'app') { 84 | const transports = [ 85 | // Toujours inclure la console 86 | new winston.transports.Console({ 87 | format: winston.format.combine( 88 | winston.format.colorize(), 89 | winston.format.simple() 90 | ) 91 | }) 92 | ]; 93 | 94 | // Ajouter les fichiers seulement si on a les permissions 95 | if (canUseFileLogging) { 96 | try { 97 | transports.push( 98 | new DailyRotateFile({ 99 | filename: path.join(LOG_DIR, `${filePrefix}-%DATE%.log`), 100 | datePattern: 'YYYY-MM-DD', 101 | maxSize: '20m', 102 | maxFiles: '14d', 103 | format: winston.format.combine( 104 | winston.format.timestamp(), 105 | winston.format.json() 106 | ), 107 | handleExceptions: false, // Éviter les erreurs de permissions 108 | handleRejections: false 109 | }) 110 | ); 111 | } catch (error) { 112 | console.warn(`⚠️ Impossible de créer le transport de fichier pour ${filePrefix}: ${error.message}`); 113 | } 114 | } 115 | 116 | return transports; 117 | } 118 | 119 | // Logger principal avec fallback robuste 120 | export const logger = winston.createLogger({ 121 | level: process.env.LOG_LEVEL || 'info', 122 | format: logFormat, 123 | transports: createTransports('app'), 124 | exitOnError: false, // Ne pas crasher sur erreur de log 125 | silent: false 126 | }); 127 | 128 | // Logger HTTP avec fallback 129 | export const httpLogger = winston.createLogger({ 130 | level: 'info', 131 | format: logFormat, 132 | transports: createTransports('http'), 133 | exitOnError: false, 134 | silent: false 135 | }); 136 | 137 | // Logger API avec fallback 138 | export const apiLogger = winston.createLogger({ 139 | level: 'info', 140 | format: logFormat, 141 | transports: createTransports('api'), 142 | exitOnError: false, 143 | silent: false 144 | }); 145 | 146 | // Logger de sécurité avec fallback 147 | export const securityLogger = winston.createLogger({ 148 | level: 'warn', 149 | format: logFormat, 150 | transports: createTransports('security'), 151 | exitOnError: false, 152 | silent: false 153 | }); 154 | 155 | // Gestionnaire d'erreurs global pour les loggers 156 | process.on('uncaughtException', (error) => { 157 | if (error.message && error.message.includes('EACCES') && error.message.includes('logs')) { 158 | console.error('❌ Erreur de permissions sur les logs. Continuant avec console uniquement...'); 159 | // Ne pas crasher l'application pour une erreur de logs 160 | } else { 161 | // Re-throw les autres erreurs 162 | throw error; 163 | } 164 | }); 165 | 166 | // Object loggers pour compatibilité avec l'ancien code 167 | export const loggers = { 168 | logRequest: (req, res, duration) => { 169 | httpLogger.info('HTTP Request', { 170 | method: req.method, 171 | url: req.url, 172 | userAgent: req.get('User-Agent'), 173 | ip: req.ip, 174 | duration: `${duration}ms` 175 | }); 176 | }, 177 | 178 | logError: (error, context = {}) => { 179 | logger.error('Application Error', { 180 | error: error.message, 181 | stack: error.stack, 182 | ...context 183 | }); 184 | }, 185 | 186 | logApiCall: (service, method, endpoint, duration, statusCode, error = null) => { 187 | const logData = { 188 | service, 189 | method, 190 | endpoint, 191 | duration: `${duration}ms`, 192 | statusCode 193 | }; 194 | 195 | if (error) { 196 | logData.error = error.message; 197 | apiLogger.error('API Error', logData); 198 | } else { 199 | apiLogger.info('API Call', logData); 200 | } 201 | }, 202 | 203 | logPerformance: (operation, duration, context = {}) => { 204 | logger.info('Performance', { 205 | operation, 206 | duration: `${duration}ms`, 207 | ...context 208 | }); 209 | } 210 | }; 211 | 212 | // Export par défaut 213 | export default logger; -------------------------------------------------------------------------------- /public/assets/modules/pro-stats.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Pro Stats Module 3 | * Gestion des statistiques avancées 4 | */ 5 | 6 | import { renderTopSimple, renderTopTitles, applyProgressBars } from './rendering.js'; 7 | import { createHoursChart, createWeekChart, createMonthsChart } from './charts.js'; 8 | import { datesOfYear, renderHeatmapSVG } from './graphs.js'; 9 | import i18n from './i18n.js'; 10 | 11 | export function listTable(rows, {cols=[['name','Nom'],['minutes','Min'],['plays','Vus']], limit=10} = {}){ 12 | const toNum = (x)=> typeof x === 'number' ? x : Number(x||0); 13 | const locale = i18n.currentLang === 'en' ? 'en-US' : 'fr-FR'; 14 | const head = cols.map(([k,lab])=>`${lab}`).join(''); 15 | const body = rows.slice(0, limit).map(r=>{ 16 | return ` 17 | ${cols.map(([k])=>`${(k in r)?(typeof r[k]==='number'?r[k].toLocaleString(locale):String(r[k])):''}`).join('')} 18 | `; 19 | }).join(''); 20 | return `${head?`${head}`:''}${body||''}
`; 21 | } 22 | 23 | export async function loadStatsPro() { 24 | const type = document.getElementById('proType').value; 25 | const range = document.getElementById('proRange').value; 26 | const params = new URLSearchParams(); 27 | params.set('type', type); 28 | if (range === 'year') { 29 | params.set('range','year'); 30 | params.set('year', document.getElementById('proYear').value); 31 | } else { 32 | params.set('range','lastDays'); 33 | params.set('lastDays', document.getElementById('proDays').value || '365'); 34 | } 35 | const r = await fetch(`/api/stats/pro?${params.toString()}`, { cache:'no-store' }).then(x=>x.json()); 36 | if (!r.ok) throw new Error(r.error || 'stats error'); 37 | await renderStatsPro(r.data); 38 | } 39 | 40 | export async function renderStatsPro(data){ 41 | // Sauvegarder les données pour pouvoir re-rendre lors du changement de langue 42 | lastProStatsData = data; 43 | // Récupérer les vraies données heatmap depuis l'API 44 | const year = Number(document.getElementById('proYear')?.value || new Date().getFullYear()); 45 | const type = document.getElementById('proType')?.value || 'all'; 46 | 47 | let heatmapData = { daysWithCount: 0, max: 0 }; 48 | try { 49 | const response = await fetch(`/api/graph?year=${year}&type=${type}`); 50 | const result = await response.json(); 51 | if (result.ok && result.data) { 52 | heatmapData = result.data; 53 | } 54 | } catch (err) { 55 | console.error('Erreur lors du chargement des données heatmap:', err); 56 | } 57 | 58 | // Résumé avec animations (6 tuiles maintenant) 59 | const sumEl = document.getElementById('proSummary'); 60 | const T = data.totals || {}; 61 | const locale = i18n.currentLang === 'en' ? 'en-US' : 'fr-FR'; 62 | sumEl.innerHTML = ` 63 |
64 |
${i18n.t('stats.watched')}
65 |
${(T.plays||0).toLocaleString(locale)}
66 |
67 |
68 |
${i18n.t('stats.movies')}
69 |
${(T.movies||0).toLocaleString(locale)}
70 |
71 |
72 |
${i18n.t('stats.episodes')}
73 |
${(T.episodes||0).toLocaleString(locale)}
74 |
75 |
76 |
${i18n.t('stats.hours')}
77 |
${(T.hours||0).toLocaleString(locale)}
78 |
79 |
80 |
${i18n.t('stats.active_days')}
81 |
${heatmapData.daysWithCount || 0}
82 |
83 |
84 |
${i18n.t('stats.max_per_day')}
85 |
${heatmapData.max || 0}
86 |
87 | `; 88 | 89 | // Graphiques Chart.js 90 | createHoursChart(data.distributions.hours || []); 91 | createWeekChart(data.distributions.weekday || []); 92 | createMonthsChart(data.distributions.months || {}); 93 | 94 | // Afficher la heatmap avec les vraies données 95 | const heatmapContainer = document.getElementById('graphContainer'); 96 | if (heatmapContainer && heatmapData) { 97 | const svg = renderHeatmapSVG(heatmapData, {}); 98 | heatmapContainer.innerHTML = svg; 99 | } 100 | 101 | // Tops 102 | document.getElementById('proTopGenres').innerHTML = renderTopSimple(data.top.genres || []); 103 | document.getElementById('proTopNetworks').innerHTML = renderTopSimple(data.top.networks || []); 104 | document.getElementById('proTopStudios').innerHTML = renderTopSimple(data.top.studios || []); 105 | document.getElementById('proTopTitles').innerHTML = renderTopTitles(data.top.titles || []); 106 | 107 | // Appliquer les styles après rendu 108 | setTimeout(() => applyProgressBars(), 10); 109 | } 110 | 111 | 112 | // Initialisation du sélecteur d'année 113 | (function initProYear(){ 114 | const ySel = document.getElementById('proYear'); 115 | if (ySel) { 116 | const nowY = new Date().getFullYear(); 117 | const years = []; 118 | for (let y=nowY; y>=nowY-10; y--) years.push(y); 119 | ySel.innerHTML = years.map(y=>``).join(''); 120 | ySel.value = String(nowY); 121 | } 122 | })(); 123 | 124 | // Event listeners 125 | document.getElementById('proRange')?.addEventListener('change', (e)=>{ 126 | const isYear = e.target.value === 'year'; 127 | document.getElementById('proYearWrap')?.classList.toggle('hidden', !isYear); 128 | document.getElementById('proDaysWrap')?.classList.toggle('hidden', isYear); 129 | // Recharger les données quand on change la période 130 | loadStatsPro(); 131 | }); 132 | 133 | document.getElementById('proReload')?.addEventListener('click', loadStatsPro); 134 | document.getElementById('proType')?.addEventListener('change', loadStatsPro); 135 | document.getElementById('proYear')?.addEventListener('change', loadStatsPro); 136 | document.getElementById('proDays')?.addEventListener('change', loadStatsPro); // Jours n'affecte que Pro Stats 137 | 138 | // Variable pour stocker les dernières données Pro Stats 139 | let lastProStatsData = null; 140 | 141 | // Rendre les stats pro quand la langue change 142 | window.addEventListener('languageChanged', () => { 143 | 144 | // Re-render les stats pro avec les nouvelles traductions si on a les données 145 | if (lastProStatsData) { 146 | renderStatsPro(lastProStatsData); 147 | } 148 | }); -------------------------------------------------------------------------------- /lib/cardCache.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Système de cache granulaire par carte/série 3 | * Remplace le cache de page global qui force à tout invalider 4 | */ 5 | 6 | import fsp from 'node:fs/promises'; 7 | import path from 'node:path'; 8 | import { jsonLoad, jsonSave } from './util.js'; 9 | import { DATA_DIR } from './config.js'; 10 | 11 | const CARD_CACHE_DIR = path.join(DATA_DIR, '.cache_cards'); 12 | 13 | // Ensure cache directory exists 14 | try { 15 | await fsp.mkdir(CARD_CACHE_DIR, { recursive: true }); 16 | } catch (error) { 17 | // Directory might already exist 18 | } 19 | 20 | /** 21 | * Cache une carte de série individuellement 22 | */ 23 | export async function cacheShowCard(traktId, cardData) { 24 | try { 25 | const cacheFile = path.join(CARD_CACHE_DIR, `show_${traktId}.json`); 26 | const data = { 27 | ...cardData, 28 | cached_at: new Date().toISOString(), 29 | trakt_id: traktId 30 | }; 31 | 32 | await jsonSave(cacheFile, data); 33 | console.log(`[cardCache] Cached show card ${traktId}`); 34 | return true; 35 | } catch (error) { 36 | console.error(`[cardCache] Failed to cache show ${traktId}:`, error.message); 37 | return false; 38 | } 39 | } 40 | 41 | /** 42 | * Cache une carte de film individuellement 43 | */ 44 | export async function cacheMovieCard(traktId, cardData) { 45 | try { 46 | const cacheFile = path.join(CARD_CACHE_DIR, `movie_${traktId}.json`); 47 | const data = { 48 | ...cardData, 49 | cached_at: new Date().toISOString(), 50 | trakt_id: traktId 51 | }; 52 | 53 | await jsonSave(cacheFile, data); 54 | console.log(`[cardCache] Cached movie card ${traktId}`); 55 | return true; 56 | } catch (error) { 57 | console.error(`[cardCache] Failed to cache movie ${traktId}:`, error.message); 58 | return false; 59 | } 60 | } 61 | 62 | /** 63 | * Récupère une carte de série depuis le cache 64 | */ 65 | export async function getShowCard(traktId, maxAge = 6 * 3600 * 1000) { 66 | try { 67 | const cacheFile = path.join(CARD_CACHE_DIR, `show_${traktId}.json`); 68 | const stat = await fsp.stat(cacheFile); 69 | 70 | // Vérifier l'age du cache 71 | const age = Date.now() - stat.mtimeMs; 72 | if (age > maxAge) { 73 | console.log(`[cardCache] Show ${traktId} cache expired (age: ${Math.round(age/1000)}s)`); 74 | return null; 75 | } 76 | 77 | const data = await jsonLoad(cacheFile); 78 | console.log(`[cardCache] Show ${traktId} loaded from cache`); 79 | return data; 80 | } catch (error) { 81 | if (error.code !== 'ENOENT') { 82 | console.warn(`[cardCache] Error loading show ${traktId}:`, error.message); 83 | } 84 | return null; 85 | } 86 | } 87 | 88 | /** 89 | * Récupère une carte de film depuis le cache 90 | */ 91 | export async function getMovieCard(traktId, maxAge = 6 * 3600 * 1000) { 92 | try { 93 | const cacheFile = path.join(CARD_CACHE_DIR, `movie_${traktId}.json`); 94 | const stat = await fsp.stat(cacheFile); 95 | 96 | const age = Date.now() - stat.mtimeMs; 97 | if (age > maxAge) { 98 | console.log(`[cardCache] Movie ${traktId} cache expired (age: ${Math.round(age/1000)}s)`); 99 | return null; 100 | } 101 | 102 | const data = await jsonLoad(cacheFile); 103 | console.log(`[cardCache] Movie ${traktId} loaded from cache`); 104 | return data; 105 | } catch (error) { 106 | if (error.code !== 'ENOENT') { 107 | console.warn(`[cardCache] Error loading movie ${traktId}:`, error.message); 108 | } 109 | return null; 110 | } 111 | } 112 | 113 | /** 114 | * Invalide seulement le cache d'une série spécifique 115 | */ 116 | export async function invalidateShowCard(traktId) { 117 | try { 118 | const cacheFile = path.join(CARD_CACHE_DIR, `show_${traktId}.json`); 119 | await fsp.unlink(cacheFile); 120 | console.log(`[cardCache] Invalidated show ${traktId} cache`); 121 | return true; 122 | } catch (error) { 123 | if (error.code !== 'ENOENT') { 124 | console.warn(`[cardCache] Failed to invalidate show ${traktId}:`, error.message); 125 | } 126 | return false; 127 | } 128 | } 129 | 130 | /** 131 | * Invalide seulement le cache d'un film spécifique 132 | */ 133 | export async function invalidateMovieCard(traktId) { 134 | try { 135 | const cacheFile = path.join(CARD_CACHE_DIR, `movie_${traktId}.json`); 136 | await fsp.unlink(cacheFile); 137 | console.log(`[cardCache] Invalidated movie ${traktId} cache`); 138 | return true; 139 | } catch (error) { 140 | if (error.code !== 'ENOENT') { 141 | console.warn(`[cardCache] Failed to invalidate movie ${traktId}:`, error.message); 142 | } 143 | return false; 144 | } 145 | } 146 | 147 | /** 148 | * Récupère toutes les cartes de séries depuis le cache 149 | */ 150 | export async function getAllShowCards(maxAge = 6 * 3600 * 1000) { 151 | try { 152 | const files = await fsp.readdir(CARD_CACHE_DIR); 153 | const showFiles = files.filter(f => f.startsWith('show_') && f.endsWith('.json')); 154 | 155 | const shows = []; 156 | for (const file of showFiles) { 157 | const filePath = path.join(CARD_CACHE_DIR, file); 158 | const stat = await fsp.stat(filePath); 159 | 160 | // Skip if too old 161 | const age = Date.now() - stat.mtimeMs; 162 | if (age > maxAge) continue; 163 | 164 | try { 165 | const data = await jsonLoad(filePath); 166 | shows.push(data); 167 | } catch (error) { 168 | console.warn(`[cardCache] Error loading ${file}:`, error.message); 169 | } 170 | } 171 | 172 | console.log(`[cardCache] Loaded ${shows.length} show cards from cache`); 173 | return shows; 174 | } catch (error) { 175 | console.error('[cardCache] Error loading show cards:', error.message); 176 | return []; 177 | } 178 | } 179 | 180 | /** 181 | * Récupère toutes les cartes de films depuis le cache 182 | */ 183 | export async function getAllMovieCards(maxAge = 6 * 3600 * 1000) { 184 | try { 185 | const files = await fsp.readdir(CARD_CACHE_DIR); 186 | const movieFiles = files.filter(f => f.startsWith('movie_') && f.endsWith('.json')); 187 | 188 | const movies = []; 189 | for (const file of movieFiles) { 190 | const filePath = path.join(CARD_CACHE_DIR, file); 191 | const stat = await fsp.stat(filePath); 192 | 193 | const age = Date.now() - stat.mtimeMs; 194 | if (age > maxAge) continue; 195 | 196 | try { 197 | const data = await jsonLoad(filePath); 198 | movies.push(data); 199 | } catch (error) { 200 | console.warn(`[cardCache] Error loading ${file}:`, error.message); 201 | } 202 | } 203 | 204 | console.log(`[cardCache] Loaded ${movies.length} movie cards from cache`); 205 | return movies; 206 | } catch (error) { 207 | console.error('[cardCache] Error loading movie cards:', error.message); 208 | return []; 209 | } 210 | } 211 | 212 | /** 213 | * Nettoie les caches expirés 214 | */ 215 | export async function cleanExpiredCards(maxAge = 6 * 3600 * 1000) { 216 | try { 217 | const files = await fsp.readdir(CARD_CACHE_DIR); 218 | let cleaned = 0; 219 | 220 | for (const file of files) { 221 | if (!file.endsWith('.json')) continue; 222 | 223 | const filePath = path.join(CARD_CACHE_DIR, file); 224 | const stat = await fsp.stat(filePath); 225 | 226 | const age = Date.now() - stat.mtimeMs; 227 | if (age > maxAge) { 228 | await fsp.unlink(filePath); 229 | cleaned++; 230 | } 231 | } 232 | 233 | if (cleaned > 0) { 234 | console.log(`[cardCache] Cleaned ${cleaned} expired cards`); 235 | } 236 | 237 | return cleaned; 238 | } catch (error) { 239 | console.error('[cardCache] Error cleaning expired cards:', error.message); 240 | return 0; 241 | } 242 | } -------------------------------------------------------------------------------- /public/assets/setup.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Script de configuration - Setup Form Handler 3 | */ 4 | 5 | // CSRF token récupéré depuis l'attribut data du script 6 | const scriptTag = document.currentScript || document.querySelector('script[data-csrf-token]'); 7 | const CSRF_TOKEN = scriptTag ? scriptTag.getAttribute('data-csrf-token') : ''; 8 | 9 | // Variable pour accéder au système i18n 10 | let i18n = null; 11 | 12 | // Initialiser l'i18n dès que disponible 13 | if (typeof I18nLite !== 'undefined') { 14 | i18n = new I18nLite(); 15 | i18n.init().then(() => { 16 | console.log('[Setup] I18n initialized'); 17 | }); 18 | } 19 | 20 | // Helper function pour les traductions avec fallback 21 | function t(key, vars = {}) { 22 | if (i18n && i18n.t) { 23 | return i18n.t(key, vars); 24 | } 25 | // Fallback si i18n n'est pas disponible 26 | return key; 27 | } 28 | 29 | document.addEventListener('DOMContentLoaded', () => { 30 | const setupForm = document.getElementById('setup-form'); 31 | if (!setupForm) return; 32 | 33 | // Gestion de l'affichage conditionnel des champs d'authentification 34 | const enableAuthCheckbox = document.getElementById('enableAuth'); 35 | const authFieldsContainer = document.getElementById('authFields'); 36 | const authUsernameInput = document.getElementById('authUsername'); 37 | const authPasswordInput = document.getElementById('authPassword'); 38 | 39 | if (enableAuthCheckbox && authFieldsContainer) { 40 | // État initial basé sur les valeurs pré-remplies 41 | const initialAuthEnabled = authUsernameInput && authUsernameInput.value.trim() !== ''; 42 | if (initialAuthEnabled) { 43 | enableAuthCheckbox.checked = true; 44 | authFieldsContainer.classList.remove('hidden'); 45 | authUsernameInput.required = true; 46 | authPasswordInput.required = true; 47 | } 48 | 49 | enableAuthCheckbox.addEventListener('change', (e) => { 50 | if (e.target.checked) { 51 | authFieldsContainer.classList.remove('hidden'); 52 | authUsernameInput.required = true; 53 | authPasswordInput.required = true; 54 | } else { 55 | authFieldsContainer.classList.add('hidden'); 56 | authUsernameInput.required = false; 57 | authPasswordInput.required = false; 58 | // Clear values when disabled 59 | authUsernameInput.value = ''; 60 | authPasswordInput.value = ''; 61 | } 62 | }); 63 | } 64 | 65 | // Pré-sélectionner la langue 66 | const languageSelect = document.getElementById('language'); 67 | const currentLanguage = ''; 68 | if (languageSelect && currentLanguage && currentLanguage !== '') { 69 | languageSelect.value = currentLanguage; 70 | } 71 | 72 | // Event listener pour le bouton de réinitialisation 73 | const retryBtn = document.getElementById('retryBtn'); 74 | if (retryBtn) { 75 | retryBtn.addEventListener('click', () => { 76 | window.location.reload(); 77 | }); 78 | } 79 | 80 | // Debug info pour le token CSRF 81 | console.log('CSRF Token Info:', { 82 | présent: !!CSRF_TOKEN, 83 | longueur: CSRF_TOKEN?.length || 0, 84 | début: CSRF_TOKEN?.substring(0, 10) + '...' || 'N/A' 85 | }); 86 | 87 | // Mise à jour de l'indicateur CSRF (optionnel, pour debug) 88 | const csrfInfo = document.getElementById('csrf-info'); 89 | const csrfState = document.getElementById('csrf-state'); 90 | if (csrfInfo && csrfState) { 91 | if (CSRF_TOKEN && CSRF_TOKEN.length > 10) { 92 | csrfState.textContent = t('setup.csrf_active'); 93 | csrfState.className = 'text-green-400'; 94 | } else { 95 | csrfState.textContent = t('setup.csrf_missing'); 96 | csrfState.className = 'text-yellow-400'; 97 | csrfInfo.classList.remove('hidden'); // Afficher seulement si problème 98 | } 99 | } 100 | 101 | setupForm.addEventListener('submit', async (e) => { 102 | e.preventDefault(); 103 | 104 | const submitBtn = document.getElementById('submit-btn'); 105 | const submitText = document.getElementById('submit-text'); 106 | const alertContainer = document.getElementById('alert-container'); 107 | const alert = document.getElementById('alert'); 108 | const alertIcon = document.getElementById('alert-icon'); 109 | const alertMessage = document.getElementById('alert-message'); 110 | 111 | // État loading 112 | submitBtn.disabled = true; 113 | submitText.innerHTML = `${t('setup.configuring')}`; 114 | 115 | try { 116 | const formData = new FormData(e.target); 117 | const config = Object.fromEntries(formData.entries()); 118 | 119 | const response = await fetch('/setup', { 120 | method: 'POST', 121 | headers: { 122 | 'Content-Type': 'application/json', 123 | 'X-CSRF-Token': CSRF_TOKEN 124 | }, 125 | body: JSON.stringify(config) 126 | }); 127 | 128 | // Gestion spécifique des erreurs HTTP 129 | if (!response.ok) { 130 | let errorMessage = t('setup.server_error'); 131 | 132 | if (response.status === 403) { 133 | const errorData = await response.json().catch(() => ({})); 134 | if (errorData.code === 'CSRF_MISSING' || errorData.code === 'CSRF_INVALID') { 135 | errorMessage = t('setup.csrf_error'); 136 | console.error('CSRF Error:', { 137 | token: CSRF_TOKEN ? 'présent' : 'manquant', 138 | tokenLength: CSRF_TOKEN?.length || 0, 139 | error: errorData 140 | }); 141 | } else { 142 | errorMessage = t('setup.access_denied'); 143 | } 144 | } else if (response.status === 400) { 145 | const errorData = await response.json().catch(() => ({})); 146 | errorMessage = errorData.error || t('setup.invalid_data'); 147 | } else if (response.status === 500) { 148 | errorMessage = t('setup.internal_error'); 149 | } else { 150 | errorMessage = t('setup.http_error', { status: response.status }); 151 | } 152 | 153 | throw new Error(errorMessage); 154 | } 155 | 156 | const result = await response.json(); 157 | 158 | if (result.success) { 159 | // Succès 160 | alert.className = 'p-4 rounded-lg mb-4 bg-green-800 border border-green-600 text-green-200'; 161 | alertIcon.className = 'fa-solid fa-check-circle mr-3 text-green-400'; 162 | alertMessage.textContent = t('setup.success_message'); 163 | alertContainer.classList.remove('hidden'); 164 | 165 | // Redirection après 2s 166 | setTimeout(() => { 167 | window.location.href = '/'; 168 | }, 2000); 169 | 170 | } else { 171 | // Erreur 172 | alert.className = 'p-4 rounded-lg mb-4 bg-red-800 border border-red-600 text-red-200'; 173 | alertIcon.className = 'fa-solid fa-exclamation-triangle mr-3 text-red-400'; 174 | alertMessage.textContent = result.error || t('setup.config_error'); 175 | alertContainer.classList.remove('hidden'); 176 | 177 | submitBtn.disabled = false; 178 | submitText.innerHTML = `${t('setup.create_config')}`; 179 | } 180 | 181 | } catch (error) { 182 | console.error('Erreur:', error); 183 | alert.className = 'p-4 rounded-lg mb-4 bg-red-800 border border-red-600 text-red-200'; 184 | alertIcon.className = 'fa-solid fa-exclamation-triangle mr-3 text-red-400'; 185 | 186 | // Utiliser le message d'erreur spécifique ou un message générique 187 | alertMessage.textContent = error.message || t('setup.connection_error'); 188 | alertContainer.classList.remove('hidden'); 189 | 190 | submitBtn.disabled = false; 191 | submitText.innerHTML = `${t('setup.create_config')}`; 192 | } 193 | }); 194 | }); -------------------------------------------------------------------------------- /public/assets/modules/charts.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Charts Module - Chart.js integration 3 | * Graphiques responsives avec Chart.js 4 | */ 5 | 6 | import { UNIFIED_PALETTE } from './graphs.js'; 7 | import i18n from './i18n.js'; 8 | 9 | // Configuration par défaut pour tous les graphiques 10 | const defaultConfig = { 11 | responsive: true, 12 | maintainAspectRatio: true, 13 | aspectRatio: window.innerWidth <= 640 ? 1.8 : 2.5, // Ratio plus compact sur mobile 14 | animation: false, 15 | plugins: { 16 | legend: { 17 | display: false 18 | }, 19 | tooltip: { 20 | backgroundColor: UNIFIED_PALETTE.background + 'e6', // 90% opacity 21 | titleColor: '#e2e8f0', 22 | bodyColor: '#cbd5e1', 23 | borderColor: UNIFIED_PALETTE.border, 24 | borderWidth: 1, 25 | cornerRadius: 8, 26 | displayColors: false 27 | } 28 | }, 29 | scales: { 30 | x: { 31 | grid: { 32 | color: UNIFIED_PALETTE.border + '0d', // 5% opacity 33 | borderColor: UNIFIED_PALETTE.border + '1a' // 10% opacity 34 | }, 35 | ticks: { 36 | color: '#64748b', 37 | font: { 38 | size: 11 39 | } 40 | } 41 | }, 42 | y: { 43 | grid: { 44 | color: UNIFIED_PALETTE.border + '0d', // 5% opacity 45 | borderColor: UNIFIED_PALETTE.border + '1a' // 10% opacity 46 | }, 47 | ticks: { 48 | color: '#64748b', 49 | font: { 50 | size: 11 51 | } 52 | } 53 | } 54 | } 55 | }; 56 | 57 | // Configuration pour graphique en barres 58 | const barConfig = { 59 | ...defaultConfig, 60 | plugins: { 61 | ...defaultConfig.plugins, 62 | tooltip: { 63 | ...defaultConfig.plugins.tooltip, 64 | callbacks: { 65 | title: function(context) { 66 | return context[0].label; 67 | }, 68 | label: function(context) { 69 | return context.parsed.y + ' min'; 70 | } 71 | } 72 | } 73 | } 74 | }; 75 | 76 | // Stockage des instances Chart.js 77 | const chartInstances = {}; 78 | 79 | // Fonction pour détruire un graphique existant 80 | function destroyChart(chartId) { 81 | if (chartInstances[chartId]) { 82 | chartInstances[chartId].destroy(); 83 | delete chartInstances[chartId]; 84 | } 85 | } 86 | 87 | // Créer graphique des heures 88 | export function createHoursChart(data) { 89 | // Sauvegarder les données pour re-render lors du changement de langue 90 | lastChartsData.hours = data; 91 | 92 | destroyChart('proChartHours'); 93 | 94 | const ctx = document.getElementById('proChartHours'); 95 | if (!ctx) return; 96 | 97 | const labels = Array.from({length: 24}, (_, i) => i + 'h'); 98 | 99 | chartInstances['proChartHours'] = new Chart(ctx, { 100 | type: 'bar', 101 | data: { 102 | labels: labels, 103 | datasets: [{ 104 | data: data || Array(24).fill(0), 105 | backgroundColor: UNIFIED_PALETTE.colors[3], // Vert vif de la heatmap 106 | borderColor: UNIFIED_PALETTE.colors[2], // Bleu moyen 107 | borderWidth: 1, 108 | borderRadius: 2, 109 | borderSkipped: false 110 | }] 111 | }, 112 | options: { 113 | ...barConfig, 114 | plugins: { 115 | ...barConfig.plugins, 116 | tooltip: { 117 | ...barConfig.plugins.tooltip, 118 | callbacks: { 119 | title: function(context) { 120 | return `${context[0].label}`; 121 | }, 122 | label: function(context) { 123 | return `${context.parsed.y} minutes`; 124 | } 125 | } 126 | } 127 | } 128 | } 129 | }); 130 | } 131 | 132 | // Créer graphique des jours de la semaine 133 | export function createWeekChart(data) { 134 | // Sauvegarder les données pour re-render lors du changement de langue 135 | lastChartsData.weekday = data; 136 | 137 | destroyChart('proChartWeek'); 138 | 139 | const ctx = document.getElementById('proChartWeek'); 140 | if (!ctx) return; 141 | 142 | const labels = i18n.t('calendar.weekdays_chart'); 143 | 144 | chartInstances['proChartWeek'] = new Chart(ctx, { 145 | type: 'bar', 146 | data: { 147 | labels: labels, 148 | datasets: [{ 149 | data: data || Array(7).fill(0), 150 | backgroundColor: UNIFIED_PALETTE.colors[3], // Vert vif de la heatmap 151 | borderColor: UNIFIED_PALETTE.colors[2], // Bleu moyen 152 | borderWidth: 1, 153 | borderRadius: 2, 154 | borderSkipped: false 155 | }] 156 | }, 157 | options: { 158 | ...barConfig, 159 | plugins: { 160 | ...barConfig.plugins, 161 | tooltip: { 162 | ...barConfig.plugins.tooltip, 163 | callbacks: { 164 | title: function(context) { 165 | return labels[context[0].dataIndex]; 166 | }, 167 | label: function(context) { 168 | return `${context.parsed.y} minutes`; 169 | } 170 | } 171 | } 172 | } 173 | } 174 | }); 175 | } 176 | 177 | // Créer graphique d'évolution par mois 178 | export function createMonthsChart(monthsObj) { 179 | // Sauvegarder les données pour re-render lors du changement de langue 180 | lastChartsData.months = monthsObj; 181 | 182 | destroyChart('proChartMonths'); 183 | 184 | const ctx = document.getElementById('proChartMonths'); 185 | if (!ctx) return; 186 | 187 | const monthsKeys = Object.keys(monthsObj || {}).sort(); 188 | const labels = monthsKeys.map(k => k.slice(5)); 189 | const data = monthsKeys.map(k => monthsObj[k].minutes || 0); 190 | 191 | chartInstances['proChartMonths'] = new Chart(ctx, { 192 | type: 'line', 193 | data: { 194 | labels: labels, 195 | datasets: [{ 196 | data: data, 197 | backgroundColor: UNIFIED_PALETTE.colors[3] + '1a', // Vert vif + 10% opacity 198 | borderColor: UNIFIED_PALETTE.colors[3], // Vert vif 199 | borderWidth: 2, 200 | fill: true, 201 | tension: 0.4, 202 | pointBackgroundColor: UNIFIED_PALETTE.colors[4], // Orange 203 | pointBorderColor: UNIFIED_PALETTE.colors[1], // Bleu sombre 204 | pointBorderWidth: 2, 205 | pointRadius: 4, 206 | pointHoverRadius: 6 207 | }] 208 | }, 209 | options: { 210 | ...defaultConfig, 211 | plugins: { 212 | ...defaultConfig.plugins, 213 | tooltip: { 214 | ...defaultConfig.plugins.tooltip, 215 | callbacks: { 216 | title: function(context) { 217 | return `Mois ${context[0].label}`; 218 | }, 219 | label: function(context) { 220 | return `${context.parsed.y} minutes`; 221 | } 222 | } 223 | } 224 | }, 225 | scales: { 226 | ...defaultConfig.scales, 227 | y: { 228 | ...defaultConfig.scales.y, 229 | beginAtZero: true 230 | } 231 | } 232 | } 233 | }); 234 | } 235 | 236 | // Fonction pour redimensionner tous les graphiques 237 | export function resizeAllCharts() { 238 | Object.values(chartInstances).forEach(chart => { 239 | chart.resize(); 240 | }); 241 | } 242 | 243 | // Note: fonction createHeatmap supprimée - on utilise maintenant renderHeatmapSVG 244 | 245 | // Nettoyage lors du changement de page 246 | export function destroyAllCharts() { 247 | Object.keys(chartInstances).forEach(chartId => { 248 | destroyChart(chartId); 249 | }); 250 | } 251 | 252 | // Variable pour stocker les dernières données des charts 253 | let lastChartsData = { 254 | hours: null, 255 | weekday: null, 256 | months: null 257 | }; 258 | 259 | // Re-créer les graphiques quand la langue change 260 | window.addEventListener('languageChanged', () => { 261 | 262 | // Re-créer les graphiques avec les nouvelles traductions si on a les données 263 | if (lastChartsData.hours !== null) { 264 | createHoursChart(lastChartsData.hours); 265 | } 266 | if (lastChartsData.weekday !== null) { 267 | createWeekChart(lastChartsData.weekday); 268 | } 269 | if (lastChartsData.months !== null) { 270 | createMonthsChart(lastChartsData.months); 271 | } 272 | 273 | }); -------------------------------------------------------------------------------- /public/assets/app-modular.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Trakt Enhanced Front‑End — Modular Edition 3 | * Application principale utilisant les modules découpés 4 | */ 5 | 6 | // Imports des modules - I18N EN PREMIER 7 | import i18n from './modules/i18n.js'; 8 | 9 | // Auth Guard - PRIORITÉ ABSOLUE 10 | import { checkAuthStatus } from './modules/auth-guard.js'; 11 | 12 | // Modules de base (pas de dépendance i18n) 13 | import { elements } from './modules/dom.js'; 14 | import { state, saveState } from './modules/state.js'; 15 | 16 | // Modules avec dépendances i18n 17 | import { applyWidth } from './modules/utils.js'; 18 | import { renderCurrent } from './modules/rendering.js'; 19 | import { setTab } from './modules/tabs.js'; 20 | import { loadData } from './modules/data.js'; 21 | 22 | // Modules UI 23 | import './modules/mobile-tabs.js'; 24 | import { lazyManager, initializeLazyLoading, fallbackImageLoading } from './modules/lazy-loading.js'; 25 | import { animationManager, initializeAnimations } from './modules/animations.js'; 26 | 27 | // Modules UI avec traductions 28 | import languageSelector from './modules/language-selector.js'; 29 | import uiTranslations from './modules/ui-translations.js'; 30 | import './modules/header-buttons.js'; 31 | 32 | // Autres modules 33 | import './modules/modals.js'; 34 | import './modules/search.js'; 35 | import './modules/pro-stats.js'; 36 | import './modules/charts.js'; 37 | import { loadGlobalStats } from './modules/global-stats.js'; 38 | import './modules/markWatched.js'; 39 | import { initScrollToTop } from './modules/scroll-to-top.js'; 40 | import { initWatchingProgress, stopWatchingProgress, applyWidthToProgressBarExternal } from './modules/watching-progress.js'; 41 | import { initHeatmapInteractions } from './modules/heatmap-interactions.js'; 42 | import { initWatchingDetails } from './modules/watching-details.js'; 43 | import { startLiveUpdates } from './modules/live-updates.js'; 44 | import { initCalendar } from './modules/calendar.js'; 45 | 46 | // Initialisation principale de l'application 47 | async function initializeApp() { 48 | 49 | // Vérifier l'authentification en premier 50 | const isAuthenticated = await checkAuthStatus(); 51 | 52 | // Attendre que i18n soit complètement initialisé (toujours nécessaire) 53 | await new Promise((resolve) => { 54 | if (i18n.translations && Object.keys(i18n.translations).length > 0) { 55 | resolve(); 56 | } else { 57 | window.addEventListener('i18nInitialized', resolve, { once: true }); 58 | } 59 | }); 60 | 61 | // Appliquer immédiatement les traductions UI 62 | uiTranslations.translateUI(); 63 | 64 | if (!isAuthenticated) { 65 | // L'interface de connexion est déjà affichée par auth-guard 66 | // On n'a pas besoin de charger les données 67 | return; 68 | } 69 | 70 | // Appliquer la largeur avec traductions 71 | applyWidth(); 72 | 73 | // Maintenant charger les données avec i18n pleinement initialisé 74 | await loadData(); 75 | 76 | // S'assurer que les options de tri sont traduites après le chargement des données 77 | setTimeout(() => { 78 | uiTranslations.translateSortOptions(); 79 | }, 100); 80 | 81 | // Démarrer les mises à jour en temps réel (seulement si authentifié) 82 | setTimeout(() => { 83 | startLiveUpdates(); 84 | }, 2000); // Attendre 2s après le chargement initial 85 | 86 | // Initialiser le calendrier et watching progress maintenant qu'on est authentifié 87 | initCalendar(); 88 | initWatchingProgress(); 89 | } 90 | 91 | // Charger la version de l'application 92 | async function loadAppVersion() { 93 | try { 94 | const response = await fetch('/health'); 95 | const health = await response.json(); 96 | const versionEl = document.getElementById('app-version'); 97 | if (versionEl && health.version) { 98 | versionEl.textContent = `v${health.version}`; 99 | } 100 | } catch (error) { 101 | console.warn('Could not load app version:', error); 102 | } 103 | } 104 | 105 | // Démarrer l'initialisation 106 | initializeApp().then(() => { 107 | }).catch(console.error); 108 | 109 | 110 | // Event listeners principaux 111 | // Le bouton toggleWidth est maintenant géré par header-buttons.js 112 | 113 | // Ajouter le bouton playback s'il existe dans le DOM mais pas dans elements 114 | const playbackBtn = document.getElementById('tabBtnPlayback'); 115 | if (playbackBtn && !elements.tabBtns.playback) { 116 | elements.tabBtns.playback = playbackBtn; 117 | elements.panels.playback = document.getElementById('panelPlayback'); 118 | } 119 | 120 | // Vérifier et corriger le bouton calendar s'il est null 121 | const calendarBtn = document.getElementById('tabBtnCalendar'); 122 | if (calendarBtn && !elements.tabBtns.calendar) { 123 | elements.tabBtns.calendar = calendarBtn; 124 | elements.panels.calendar = document.getElementById('panelCalendar'); 125 | } 126 | 127 | 128 | // Forcer la traduction du calendrier 129 | setTimeout(() => { 130 | // Forcer manuellement la traduction du calendrier 131 | const calendarBtn = document.getElementById('tabBtnCalendar'); 132 | if (calendarBtn) { 133 | const icon = calendarBtn.querySelector('i'); 134 | const iconHtml = icon ? icon.outerHTML : ''; 135 | const translatedText = i18n.t('navigation.calendar'); 136 | 137 | // Remplacer tout le contenu sauf l'icône 138 | calendarBtn.innerHTML = `${iconHtml}${translatedText}`; 139 | } 140 | }, 1000); 141 | 142 | Object.values(elements.tabBtns).forEach(btn => 143 | btn?.addEventListener('click', () => setTab(btn.dataset.tab)) 144 | ); 145 | 146 | // Reload des données au clic sur le titre 147 | document.getElementById('app-title')?.addEventListener('click', () => { 148 | loadData(); 149 | }); 150 | 151 | // Event listener pour le bouton de basculement des filtres mobile 152 | const mobileFiltersToggle = document.getElementById('mobileFiltersToggle'); 153 | if (mobileFiltersToggle) { 154 | mobileFiltersToggle.addEventListener('click', () => { 155 | const mobileFilters = document.getElementById('mobileFilters'); 156 | if (mobileFilters) { 157 | mobileFilters.classList.toggle('hidden'); 158 | } 159 | }); 160 | } 161 | 162 | elements.sortActive.addEventListener('change', () => { 163 | const [f,d] = String(elements.sortActive.value).split(':'); 164 | state.sort = { field:f, dir:d||'asc' }; 165 | saveState(); 166 | renderCurrent(); 167 | }); 168 | 169 | elements.qActive.addEventListener('input', () => { 170 | state.q = elements.qActive.value || ''; 171 | saveState(); 172 | renderCurrent(); 173 | }); 174 | 175 | document.addEventListener('keydown', e => { 176 | if ((e.ctrlKey||e.metaKey) && e.key==='/'){ 177 | e.preventDefault(); 178 | elements.qActive?.focus(); 179 | } 180 | }); 181 | 182 | elements.openFullModal?.addEventListener('click', () => { 183 | elements.fullModal.classList.remove('hidden'); 184 | }); 185 | 186 | elements.closeFullModal?.addEventListener('click', () => { 187 | elements.fullModal.classList.add('hidden'); 188 | }); 189 | 190 | // Les données sont maintenant chargées depuis initializeApp() 191 | // loadData() a été déplacé dans initializeApp() pour attendre i18n 192 | // applyWidth(); // Déjà appelé dans initializeApp() 193 | 194 | // Initialize lazy loading and animations 195 | initializeLazyLoading(); 196 | initializeAnimations(); 197 | 198 | // Fallback for browsers without Intersection Observer 199 | if (!('IntersectionObserver' in window)) { 200 | fallbackImageLoading(); 201 | } 202 | 203 | // Make managers available globally for other scripts 204 | window.lazyManager = lazyManager; 205 | window.animationManager = animationManager; 206 | 207 | // Initialiser les fonctionnalités de base au démarrage 208 | loadAppVersion(); 209 | initScrollToTop(); 210 | initHeatmapInteractions(); 211 | initWatchingDetails(); 212 | // NE PAS initialiser le calendrier automatiquement - sera fait après auth 213 | // initCalendar(); 214 | languageSelector.init(); 215 | 216 | // Initialiser les traductions UI après que i18n soit initialisé 217 | i18n.init().then(() => { 218 | uiTranslations.translateUI(); 219 | }); 220 | 221 | // Écouter les mises à jour du bouton largeur 222 | window.addEventListener('updateWidthButton', () => { 223 | applyWidth(); 224 | }); 225 | 226 | // initWatchingProgress(); // Auto-initialisé par le module lui-même 227 | 228 | -------------------------------------------------------------------------------- /lib/heatmapData.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Module pour générer les données de heatmap réelles 3 | * Utilise les mêmes sources que l'endpoint watchings-by-date 4 | */ 5 | 6 | import fs from 'node:fs/promises'; 7 | import path from 'node:path'; 8 | import { logger } from './logger.js'; 9 | import { jsonLoad } from './util.js'; 10 | import { ensureWatchedShowsCache } from './trakt.js'; 11 | 12 | /** 13 | * Parse une date de visionnage Trakt en date locale 14 | * @param {string} watchedAt - Date ISO string 15 | * @returns {string|null} Date au format YYYY-MM-DD en heure locale ou null 16 | */ 17 | function parseWatchedDate(watchedAt) { 18 | if (!watchedAt) return null; 19 | try { 20 | const date = new Date(watchedAt); 21 | const year = date.getFullYear(); 22 | const month = String(date.getMonth() + 1).padStart(2, '0'); 23 | const day = String(date.getDate()).padStart(2, '0'); 24 | return `${year}-${month}-${day}`; 25 | } catch (err) { 26 | return null; 27 | } 28 | } 29 | 30 | /** 31 | * Génère toutes les dates d'une année 32 | * @param {number} year - Année 33 | * @returns {Array} Liste des dates 34 | */ 35 | function datesOfYear(year) { 36 | const start = new Date(Date.UTC(year, 0, 1)); 37 | const end = new Date(Date.UTC(year, 11, 31)); 38 | const days = []; 39 | for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) { 40 | days.push(new Date(d)); 41 | } 42 | return days; 43 | } 44 | 45 | /** 46 | * Extrait les visionnages des données watched pour une année donnée 47 | * @param {Array} watchedShows - Données watched depuis /sync/watched/shows 48 | * @param {number} year - Année cible 49 | * @returns {Map} Map date -> count 50 | */ 51 | function extractWatchingsFromWatchedByYear(watchedShows, year) { 52 | const dailyCounts = new Map(); 53 | 54 | for (const watchedItem of watchedShows) { 55 | // Parcourir toutes les saisons et épisodes de chaque série 56 | for (const season of watchedItem.seasons || []) { 57 | for (const episode of season.episodes || []) { 58 | if (episode.last_watched_at) { 59 | const watchedDate = parseWatchedDate(episode.last_watched_at); 60 | if (watchedDate && watchedDate.startsWith(year.toString())) { 61 | const currentCount = dailyCounts.get(watchedDate) || 0; 62 | dailyCounts.set(watchedDate, currentCount + 1); 63 | } 64 | } 65 | } 66 | } 67 | } 68 | 69 | return dailyCounts; 70 | } 71 | 72 | /** 73 | * Génère les données de heatmap réelles pour une année 74 | * @param {number} year - Année 75 | * @param {string} type - Type de données (ignoré pour l'instant, toujours 'all') 76 | * @returns {Promise} Données de heatmap 77 | */ 78 | export async function generateRealHeatmapData(year, type = 'all') { 79 | try { 80 | const yearDates = datesOfYear(year); 81 | const allDailyCounts = new Map(); 82 | 83 | // 1. S'assurer que le cache global existe et charger les données 84 | try { 85 | // Force la création du cache s'il n'existe pas 86 | await ensureWatchedShowsCache(); 87 | 88 | const watchedCachePath = path.join(process.cwd(), 'data', '.cache_trakt', 'watched_shows_complete.json'); 89 | const watchedShows = await jsonLoad(watchedCachePath); 90 | 91 | if (Array.isArray(watchedShows) && watchedShows.length > 0) { 92 | logger.debug(`Heatmap: Chargement ${watchedShows.length} séries depuis le cache watched`); 93 | const showsDailyCounts = extractWatchingsFromWatchedByYear(watchedShows, year); 94 | 95 | // Fusionner les compteurs des séries 96 | for (const [date, count] of showsDailyCounts) { 97 | const currentCount = allDailyCounts.get(date) || 0; 98 | allDailyCounts.set(date, currentCount + count); 99 | } 100 | } else { 101 | logger.warn('Heatmap: Cache watched existe mais est vide'); 102 | } 103 | } catch (err) { 104 | logger.warn('Heatmap: Impossible de créer/lire le cache centralisé, utilisation des fichiers individuels:', err.message); 105 | 106 | // Fallback: utiliser les fichiers de progression individuels 107 | try { 108 | const cacheDir = path.join(process.cwd(), 'data', '.cache_trakt'); 109 | const files = await fs.readdir(cacheDir); 110 | const progressFiles = files.filter(f => f.startsWith('progress_') && f.endsWith('.json')); 111 | 112 | logger.debug(`Heatmap: Chargement depuis ${progressFiles.length} fichiers de progression`); 113 | 114 | for (const progressFile of progressFiles) { 115 | try { 116 | const progressData = await jsonLoad(path.join(cacheDir, progressFile)); 117 | 118 | // Extraire les données de visionnage de chaque fichier 119 | if (progressData && progressData.seasons) { 120 | for (const season of progressData.seasons) { 121 | if (season.episodes) { 122 | for (const episode of season.episodes) { 123 | if (episode.last_watched_at) { 124 | const watchedDate = parseWatchedDate(episode.last_watched_at); 125 | if (watchedDate && watchedDate.startsWith(year.toString())) { 126 | const currentCount = allDailyCounts.get(watchedDate) || 0; 127 | allDailyCounts.set(watchedDate, currentCount + 1); 128 | } 129 | } 130 | } 131 | } 132 | } 133 | } 134 | } catch (fileErr) { 135 | // Ignorer les fichiers de progression corrompus 136 | continue; 137 | } 138 | } 139 | } catch (dirErr) { 140 | logger.warn('Heatmap: Impossible de lire le répertoire de progression:', dirErr.message); 141 | } 142 | } 143 | 144 | // 2. Ajouter les films depuis l'historique mis à jour (si disponible) 145 | try { 146 | const historyPath = path.join(process.cwd(), 'data', '.cache_trakt', 'trakt_history_cache.json'); 147 | const historyContent = await fs.readFile(historyPath, 'utf8'); 148 | const history = JSON.parse(historyContent); 149 | 150 | // Parcourir les films et compter ceux de l'année demandée 151 | for (const movie of history.moviesRows || history.movies || []) { 152 | if (movie.watched_at) { 153 | const watchedDate = parseWatchedDate(movie.watched_at); 154 | if (watchedDate && watchedDate.startsWith(year.toString())) { 155 | // Un film peut avoir plusieurs visionnages (plays) 156 | const currentCount = allDailyCounts.get(watchedDate) || 0; 157 | allDailyCounts.set(watchedDate, currentCount + (movie.plays || 1)); 158 | } 159 | } 160 | } 161 | logger.debug(`Heatmap: Ajouté des films depuis l'historique`); 162 | } catch (err) { 163 | logger.debug('Heatmap: Historique des films indisponible'); 164 | } 165 | 166 | // 3. Créer les données de heatmap au format attendu 167 | const days = []; 168 | let max = 0; 169 | let daysWithCount = 0; 170 | let sum = 0; 171 | 172 | yearDates.forEach(date => { 173 | const utcDateStr = date.toISOString().slice(0, 10); 174 | // Chercher dans les données locales avec la clé locale correspondant à cette date UTC 175 | const localDateStr = new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 10); 176 | const count = allDailyCounts.get(localDateStr) || 0; 177 | 178 | days.push({ 179 | date: utcDateStr, 180 | count: count 181 | }); 182 | 183 | if (count > 0) { 184 | daysWithCount++; 185 | sum += count; 186 | } 187 | if (count > max) { 188 | max = count; 189 | } 190 | }); 191 | 192 | const heatmapData = { 193 | year: year, 194 | max: max, 195 | sum: sum, 196 | daysWithCount: daysWithCount, 197 | days: days 198 | }; 199 | 200 | logger.debug(`Heatmap générée pour ${year}: ${daysWithCount} jours actifs, max ${max}`); 201 | return heatmapData; 202 | 203 | } catch (err) { 204 | logger.error('Erreur génération heatmap:', err); 205 | return { 206 | year: year, 207 | max: 0, 208 | sum: 0, 209 | daysWithCount: 0, 210 | days: [] 211 | }; 212 | } 213 | } --------------------------------------------------------------------------------