├── 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 `${translatedLabel} `;
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 |
17 |
18 |
19 | Connexion requise
20 |
21 |
Veuillez vous connecter pour accéder à l'interface
22 |
23 |
24 |
32 |
33 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | FR
69 |
70 |
71 |
72 |
73 | 🇫🇷
74 | Français
75 |
76 |
77 | 🇺🇸
78 | English
79 |
80 |
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 |
16 |
17 | FR
18 |
19 |
20 |
21 |
22 | 🇫🇷
23 | Français
24 |
25 |
26 | 🇺🇸
27 | English
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
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 |
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 |
67 |
71 |
75 |
79 |
83 |
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=>`${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 | }
--------------------------------------------------------------------------------