Compare commits
24 Commits
87ac116b9b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
034aa69f8d | ||
|
|
060dfb3099 | ||
|
|
ad11bce308 | ||
|
|
1ffe99285d | ||
|
|
0d33462349 | ||
|
|
b8a0b85c54 | ||
|
|
2c8c0b5eb0 | ||
|
|
b497746cfa | ||
|
|
489e570348 | ||
|
|
117ad2d0ce | ||
|
|
0d7d27ef82 | ||
|
|
e903b55a46 | ||
|
|
512e9a480f | ||
|
|
acd26ea427 | ||
|
|
4fac95a1d8 | ||
|
|
8f0e343e8e | ||
|
|
853518e1fd | ||
|
|
b8e7c5a005 | ||
|
|
03c74d96c4 | ||
|
|
5da6f9f991 | ||
|
|
de505cc8f6 | ||
|
|
c49c3d7fc4 | ||
|
|
f9a4e596d4 | ||
|
|
0c1b8287d1 |
26
.gitea/workflows/deploy.yml
Normal file
26
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: Deploy with Docker Compose
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main # adapte la branche que tu veux déployer
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: mac-orbstack-runner # le nom que tu as donné au runner
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy stack
|
||||||
|
env:
|
||||||
|
DOCKER_BUILDKIT: 1
|
||||||
|
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||||
|
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
|
||||||
|
NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }}
|
||||||
|
ADMIN_DEFAULT_PASSWORD: ${{ secrets.ADMIN_DEFAULT_PASSWORD }}
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
PRISMA_DATA_PATH: ${{ vars.PRISMA_DATA_PATH }}
|
||||||
|
NODE_ENV: production
|
||||||
|
run: |
|
||||||
|
docker compose up -d --build
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -53,4 +53,6 @@ mongo-keyfile
|
|||||||
prisma/data/
|
prisma/data/
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
|
temp/
|
||||||
@@ -4,9 +4,6 @@ FROM node:20-alpine AS builder
|
|||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Configure pnpm store location
|
|
||||||
ENV PNPM_HOME="/app/.pnpm-store"
|
|
||||||
|
|
||||||
# Install dependencies for node-gyp
|
# Install dependencies for node-gyp
|
||||||
RUN apk add --no-cache python3 make g++
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
@@ -23,8 +20,8 @@ COPY prisma ./prisma
|
|||||||
COPY tsconfig.json .eslintrc.json ./
|
COPY tsconfig.json .eslintrc.json ./
|
||||||
COPY tailwind.config.ts postcss.config.js ./
|
COPY tailwind.config.ts postcss.config.js ./
|
||||||
|
|
||||||
# Install dependencies with pnpm
|
# Install dependencies with pnpm using cache mount for store
|
||||||
RUN pnpm config set store-dir /app/.pnpm-store && \
|
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Generate Prisma Client
|
# Generate Prisma Client
|
||||||
|
|||||||
4
ENV.md
4
ENV.md
@@ -6,6 +6,10 @@
|
|||||||
# Database Configuration (SQLite)
|
# Database Configuration (SQLite)
|
||||||
DATABASE_URL=file:./data/stripstream.db
|
DATABASE_URL=file:./data/stripstream.db
|
||||||
|
|
||||||
|
# Prisma Data Path (optional - default: ./prisma/data)
|
||||||
|
# Chemin sur l'hôte où seront stockées les données Prisma (base de données SQLite)
|
||||||
|
# PRISMA_DATA_PATH=./prisma/data
|
||||||
|
|
||||||
# NextAuth Configuration
|
# NextAuth Configuration
|
||||||
NEXTAUTH_SECRET=your-secret-key-here-generate-with-openssl-rand-base64-32
|
NEXTAUTH_SECRET=your-secret-key-here-generate-with-openssl-rand-base64-32
|
||||||
# Si derrière un reverse proxy HTTPS, utiliser l'URL HTTPS publique :
|
# Si derrière un reverse proxy HTTPS, utiliser l'URL HTTPS publique :
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
stripstream-app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -14,15 +14,16 @@ services:
|
|||||||
- "3020:3000"
|
- "3020:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- stripstream_cache:/app/.cache
|
- stripstream_cache:/app/.cache
|
||||||
- ./prisma/data:/app/data
|
- ${PRISMA_DATA_PATH:-./prisma/data}:/app/prisma/data
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=${NODE_ENV}
|
- NODE_ENV=${NODE_ENV}
|
||||||
- DATABASE_URL=${DATABASE_URL}
|
- DATABASE_URL=file:/app/prisma/data/stripstream.db
|
||||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||||
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||||
- AUTH_TRUST_HOST=true
|
- AUTH_TRUST_HOST=true
|
||||||
- KOMGA_MAX_CONCURRENT_REQUESTS=5
|
- KOMGA_MAX_CONCURRENT_REQUESTS=5
|
||||||
- ADMIN_DEFAULT_PASSWORD=${ADMIN_DEFAULT_PASSWORD}
|
- ADMIN_DEFAULT_PASSWORD=${ADMIN_DEFAULT_PASSWORD}
|
||||||
|
- PRISMA_DATA_PATH=${PRISMA_DATA_PATH:-./prisma/data}
|
||||||
networks:
|
networks:
|
||||||
- stripstream-network
|
- stripstream-network
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
22
docs/api.md
22
docs/api.md
@@ -27,22 +27,6 @@
|
|||||||
- **Body** : `{ url: string, username: string, password: string }`
|
- **Body** : `{ url: string, username: string, password: string }`
|
||||||
- **Réponse** : `{ message: string, config: Config }`
|
- **Réponse** : `{ message: string, config: Config }`
|
||||||
|
|
||||||
### GET /api/komga/ttl-config
|
|
||||||
|
|
||||||
- **Description** : Récupération de la configuration TTL du cache
|
|
||||||
- **Réponse** : `{ defaultTTL: number, homeTTL: number, ... }`
|
|
||||||
|
|
||||||
### POST /api/komga/ttl-config
|
|
||||||
|
|
||||||
- **Description** : Sauvegarde de la configuration TTL
|
|
||||||
- **Body** : `{ defaultTTL: number, homeTTL: number, ... }`
|
|
||||||
- **Réponse** : `{ message: string, config: TTLConfig }`
|
|
||||||
|
|
||||||
### GET /api/komga/cache/mode
|
|
||||||
|
|
||||||
- **Description** : Récupération du mode de cache actuel
|
|
||||||
- **Réponse** : `{ mode: string }`
|
|
||||||
|
|
||||||
## 📚 Bibliothèques
|
## 📚 Bibliothèques
|
||||||
|
|
||||||
### GET /api/komga/libraries
|
### GET /api/komga/libraries
|
||||||
@@ -123,13 +107,13 @@
|
|||||||
### GET /api/preferences
|
### GET /api/preferences
|
||||||
|
|
||||||
- **Description** : Récupération des préférences utilisateur
|
- **Description** : Récupération des préférences utilisateur
|
||||||
- **Réponse** : `{ showThumbnails: boolean, cacheMode: "memory" | "file", showOnlyUnread: boolean, debug: boolean }`
|
- **Réponse** : `{ showThumbnails: boolean, showOnlyUnread: boolean, displayMode: object, background: object, readerPrefetchCount: number }`
|
||||||
|
|
||||||
### PUT /api/preferences
|
### PUT /api/preferences
|
||||||
|
|
||||||
- **Description** : Mise à jour des préférences utilisateur
|
- **Description** : Mise à jour des préférences utilisateur
|
||||||
- **Body** : `{ showThumbnails?: boolean, cacheMode?: "memory" | "file", showOnlyUnread?: boolean, debug?: boolean }`
|
- **Body** : `{ showThumbnails?: boolean, showOnlyUnread?: boolean, displayMode?: object, background?: object, readerPrefetchCount?: number }`
|
||||||
- **Réponse** : `{ showThumbnails: boolean, cacheMode: "memory" | "file", showOnlyUnread: boolean, debug: boolean }`
|
- **Réponse** : `{ showThumbnails: boolean, showOnlyUnread: boolean, displayMode: object, background: object, readerPrefetchCount: number }`
|
||||||
|
|
||||||
## 🧪 Test
|
## 🧪 Test
|
||||||
|
|
||||||
|
|||||||
549
docs/caching.md
549
docs/caching.md
@@ -1,549 +0,0 @@
|
|||||||
# Système de Caching
|
|
||||||
|
|
||||||
Ce document décrit l'architecture et les stratégies de caching de StripStream.
|
|
||||||
|
|
||||||
## Vue d'ensemble
|
|
||||||
|
|
||||||
Le système de caching est organisé en **3 couches indépendantes** avec des responsabilités clairement définies :
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ NAVIGATEUR │
|
|
||||||
│ ┌────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ Service Worker (Cache API) │ │
|
|
||||||
│ │ → Offline support │ │
|
|
||||||
│ │ → Images (covers + pages) │ │
|
|
||||||
│ └────────────────────────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ SERVEUR NEXT.JS │
|
|
||||||
│ ┌────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ ServerCacheService │ │
|
|
||||||
│ │ → Données API Komga │ │
|
|
||||||
│ │ → Stale-while-revalidate │ │
|
|
||||||
│ │ → Mode fichier ou mémoire │ │
|
|
||||||
│ └────────────────────────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ SERVEUR KOMGA │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Couche 1 : Service Worker (Client)
|
|
||||||
|
|
||||||
### Fichier
|
|
||||||
|
|
||||||
`public/sw.js`
|
|
||||||
|
|
||||||
### Responsabilité
|
|
||||||
|
|
||||||
- Support offline de l'application
|
|
||||||
- Cache persistant des images (couvertures et pages de livres)
|
|
||||||
- Cache des ressources statiques Next.js
|
|
||||||
|
|
||||||
### Stratégies
|
|
||||||
|
|
||||||
#### Images : Cache-First
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Pour toutes les images (covers + pages)
|
|
||||||
const isImageResource = (url) => {
|
|
||||||
return (
|
|
||||||
(url.includes("/api/v1/books/") &&
|
|
||||||
(url.includes("/pages") || url.includes("/thumbnail") || url.includes("/cover"))) ||
|
|
||||||
(url.includes("/api/komga/images/") &&
|
|
||||||
(url.includes("/series/") || url.includes("/books/")) &&
|
|
||||||
url.includes("/thumbnail"))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**Comportement** :
|
|
||||||
|
|
||||||
1. Vérifier si l'image est dans le cache
|
|
||||||
2. Si oui → retourner depuis le cache
|
|
||||||
3. Si non → fetch depuis le réseau
|
|
||||||
4. Si succès → mettre en cache + retourner
|
|
||||||
5. Si échec → retourner 404
|
|
||||||
|
|
||||||
**Avantages** :
|
|
||||||
|
|
||||||
- Performance maximale (lecture instantanée depuis le cache)
|
|
||||||
- Fonctionne offline une fois les images chargées
|
|
||||||
- Économise la bande passante
|
|
||||||
|
|
||||||
#### Navigation et ressources statiques : Network-First
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Pour les pages et ressources _next/static
|
|
||||||
event.respondWith(
|
|
||||||
fetch(request)
|
|
||||||
.then((response) => {
|
|
||||||
// Mise en cache si succès
|
|
||||||
if (response.ok && (isNextStaticResource || isNavigation)) {
|
|
||||||
cache.put(request, response.clone());
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
})
|
|
||||||
.catch(async () => {
|
|
||||||
// Fallback sur le cache si offline
|
|
||||||
const cachedResponse = await cache.match(request);
|
|
||||||
if (cachedResponse) return cachedResponse;
|
|
||||||
|
|
||||||
// Page offline si navigation
|
|
||||||
if (request.mode === "navigate") {
|
|
||||||
return cache.match("/offline.html");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Avantages** :
|
|
||||||
|
|
||||||
- Toujours la dernière version quand online
|
|
||||||
- Fallback offline si nécessaire
|
|
||||||
- Navigation fluide même sans connexion
|
|
||||||
|
|
||||||
### Caches
|
|
||||||
|
|
||||||
| Cache | Usage | Stratégie | Taille |
|
|
||||||
| ----------------------- | ---------------------------- | ------------- | -------- |
|
|
||||||
| `stripstream-cache-v1` | Ressources statiques + pages | Network-First | ~5 MB |
|
|
||||||
| `stripstream-images-v1` | Images (covers + pages) | Cache-First | Illimité |
|
|
||||||
|
|
||||||
### Nettoyage
|
|
||||||
|
|
||||||
- Automatique lors de l'activation du Service Worker
|
|
||||||
- Suppression des anciennes versions de cache
|
|
||||||
- Pas d'expiration (contrôlé par l'utilisateur via les paramètres du navigateur)
|
|
||||||
|
|
||||||
## Couche 2 : ServerCacheService (Serveur)
|
|
||||||
|
|
||||||
### Fichier
|
|
||||||
|
|
||||||
`src/lib/services/server-cache.service.ts`
|
|
||||||
|
|
||||||
### Responsabilité
|
|
||||||
|
|
||||||
- Cache des réponses API Komga côté serveur
|
|
||||||
- Optimisation des temps de réponse
|
|
||||||
- Réduction de la charge sur Komga
|
|
||||||
|
|
||||||
### Stratégie : Stale-While-Revalidate
|
|
||||||
|
|
||||||
Cette stratégie est **la clé de la performance** de l'application.
|
|
||||||
|
|
||||||
#### Principe
|
|
||||||
|
|
||||||
```
|
|
||||||
Requête → Cache existe ?
|
|
||||||
├─ Non → Fetch normal + mise en cache
|
|
||||||
└─ Oui → Cache valide ?
|
|
||||||
├─ Oui → Retourne immédiatement
|
|
||||||
└─ Non → Retourne le cache expiré (stale)
|
|
||||||
ET revalide en background
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Implémentation
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
async getOrSet<T>(
|
|
||||||
key: string,
|
|
||||||
fetcher: () => Promise<T>,
|
|
||||||
type: keyof typeof ServerCacheService.DEFAULT_TTL = "DEFAULT"
|
|
||||||
): Promise<T> {
|
|
||||||
const cacheKey = `${user.id}-${key}`;
|
|
||||||
const cachedResult = this.getStale(cacheKey);
|
|
||||||
|
|
||||||
if (cachedResult !== null) {
|
|
||||||
const { data, isStale } = cachedResult;
|
|
||||||
|
|
||||||
// Si le cache est expiré, revalider en background
|
|
||||||
if (isStale) {
|
|
||||||
this.revalidateInBackground(cacheKey, fetcher, type, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data as T; // Retour immédiat
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pas de cache, fetch normal
|
|
||||||
const data = await fetcher();
|
|
||||||
this.set(cacheKey, data, type);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Avantages
|
|
||||||
|
|
||||||
✅ **Temps de réponse constant** : Le cache expiré est retourné instantanément
|
|
||||||
✅ **Données fraîches** : Revalidation en background pour la prochaine requête
|
|
||||||
✅ **Pas de délai** : L'utilisateur ne subit jamais l'attente de revalidation
|
|
||||||
✅ **Résilience** : Même si Komga est lent, l'app reste rapide
|
|
||||||
|
|
||||||
#### Inconvénients
|
|
||||||
|
|
||||||
⚠️ Les données peuvent être légèrement obsolètes (jusqu'au prochain refresh)
|
|
||||||
⚠️ Nécessite un cache initialisé (première requête toujours lente)
|
|
||||||
|
|
||||||
### Modes de stockage
|
|
||||||
|
|
||||||
L'utilisateur peut choisir entre deux modes :
|
|
||||||
|
|
||||||
#### Mode Mémoire (par défaut)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
cacheMode: "memory";
|
|
||||||
```
|
|
||||||
|
|
||||||
- Cache stocké en RAM
|
|
||||||
- **Performances** : Très rapide (lecture < 1ms)
|
|
||||||
- **Persistance** : Perdu au redémarrage du serveur
|
|
||||||
- **Capacité** : Limitée par la RAM disponible
|
|
||||||
- **Idéal pour** : Développement, faible charge
|
|
||||||
|
|
||||||
#### Mode Fichier
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
cacheMode: "file";
|
|
||||||
```
|
|
||||||
|
|
||||||
- Cache stocké sur disque (`.cache/`)
|
|
||||||
- **Performances** : Rapide (lecture 5-10ms)
|
|
||||||
- **Persistance** : Survit aux redémarrages
|
|
||||||
- **Capacité** : Limitée par l'espace disque
|
|
||||||
- **Idéal pour** : Production, haute charge
|
|
||||||
|
|
||||||
### Time-To-Live (TTL)
|
|
||||||
|
|
||||||
Chaque type de données a un TTL configuré :
|
|
||||||
|
|
||||||
| Type | TTL par défaut | Justification |
|
|
||||||
| ----------- | -------------- | ---------------------------------- |
|
|
||||||
| `DEFAULT` | 5 minutes | Données génériques |
|
|
||||||
| `HOME` | 10 minutes | Page d'accueil (données agrégées) |
|
|
||||||
| `LIBRARIES` | 24 heures | Bibliothèques (rarement modifiées) |
|
|
||||||
| `SERIES` | 5 minutes | Séries (métadonnées + progression) |
|
|
||||||
| `BOOKS` | 5 minutes | Livres (métadonnées + progression) |
|
|
||||||
| `IMAGES` | 7 jours | Images (immuables) |
|
|
||||||
|
|
||||||
#### Configuration personnalisée
|
|
||||||
|
|
||||||
Les TTL peuvent être personnalisés par l'utilisateur via la base de données :
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Modèle Prisma : TTLConfig
|
|
||||||
{
|
|
||||||
defaultTTL: 5 * 60 * 1000,
|
|
||||||
homeTTL: 10 * 60 * 1000,
|
|
||||||
librariesTTL: 24 * 60 * 60 * 1000,
|
|
||||||
seriesTTL: 5 * 60 * 1000,
|
|
||||||
booksTTL: 5 * 60 * 1000,
|
|
||||||
imagesTTL: 7 * 24 * 60 * 60 * 1000,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Isolation par utilisateur
|
|
||||||
|
|
||||||
Chaque utilisateur a son propre cache :
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const cacheKey = `${user.id}-${key}`;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Avantages** :
|
|
||||||
|
|
||||||
- Pas de collision entre utilisateurs
|
|
||||||
- Progression de lecture individuelle
|
|
||||||
- Préférences personnalisées
|
|
||||||
|
|
||||||
### Invalidation du cache
|
|
||||||
|
|
||||||
Le cache peut être invalidé :
|
|
||||||
|
|
||||||
#### Manuellement
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await cacheService.delete(key); // Une clé
|
|
||||||
await cacheService.deleteAll(prefix); // Toutes les clés avec préfixe
|
|
||||||
await cacheService.clear(); // Tout le cache
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Automatiquement
|
|
||||||
|
|
||||||
- Lors d'une mise à jour de progression
|
|
||||||
- Lors d'un changement de favoris
|
|
||||||
- Lors de la suppression d'une série
|
|
||||||
|
|
||||||
#### API
|
|
||||||
|
|
||||||
```
|
|
||||||
DELETE /api/komga/cache/clear // Vider tout le cache
|
|
||||||
DELETE /api/komga/home // Invalider le cache home
|
|
||||||
```
|
|
||||||
|
|
||||||
## Couche 3 : Cache HTTP (Navigateur)
|
|
||||||
|
|
||||||
### Responsabilité
|
|
||||||
|
|
||||||
- Cache basique géré par le navigateur
|
|
||||||
- Headers HTTP standard
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
#### Next.js ISR (Incremental Static Regeneration)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const revalidate = 60; // Revalidation toutes les 60 secondes
|
|
||||||
```
|
|
||||||
|
|
||||||
Utilisé uniquement pour les routes avec rendu statique.
|
|
||||||
|
|
||||||
#### Headers explicites (désactivé)
|
|
||||||
|
|
||||||
Les headers HTTP explicites ont été **supprimés** car :
|
|
||||||
|
|
||||||
- Le ServerCacheService gère déjà le caching efficacement
|
|
||||||
- Évite la confusion entre plusieurs couches de cache
|
|
||||||
- Simplifie le debugging
|
|
||||||
|
|
||||||
Avant (supprimé) :
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
NextResponse.json(data, {
|
|
||||||
headers: {
|
|
||||||
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Maintenant :
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
NextResponse.json(data); // Pas de headers
|
|
||||||
```
|
|
||||||
|
|
||||||
## Flow de données complet
|
|
||||||
|
|
||||||
Exemple : Chargement de la page d'accueil
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Utilisateur → GET /
|
|
||||||
↓
|
|
||||||
2. Next.js → HomeService.getHomeData()
|
|
||||||
↓
|
|
||||||
3. HomeService → ServerCacheService.getOrSet("home-ongoing", ...)
|
|
||||||
↓
|
|
||||||
4. ServerCacheService
|
|
||||||
├─ Cache valide ? → Retourne immédiatement
|
|
||||||
├─ Cache expiré ? → Retourne cache + revalide en background
|
|
||||||
└─ Pas de cache ? → Fetch Komga + mise en cache
|
|
||||||
↓
|
|
||||||
5. Response → Client
|
|
||||||
↓
|
|
||||||
6. Images → Service Worker (Cache-First)
|
|
||||||
├─ En cache ? → Lecture instantanée
|
|
||||||
└─ Pas en cache ? → Fetch + mise en cache
|
|
||||||
```
|
|
||||||
|
|
||||||
### Temps de réponse typiques
|
|
||||||
|
|
||||||
| Scénario | Temps | Détails |
|
|
||||||
| ----------------------------- | ----------- | -------------------------- |
|
|
||||||
| Cache ServerCache valide + SW | ~50ms | Optimal |
|
|
||||||
| Cache ServerCache expiré + SW | ~50ms | Revalidation en background |
|
|
||||||
| Pas de cache ServerCache + SW | ~200-500ms | Première requête |
|
|
||||||
| Cache SW uniquement | ~10ms | Images seulement |
|
|
||||||
| Tout à froid | ~500-1000ms | Pire cas |
|
|
||||||
|
|
||||||
## Cas d'usage
|
|
||||||
|
|
||||||
### 1. Première visite
|
|
||||||
|
|
||||||
```
|
|
||||||
User → App → Komga (tous les caches vides)
|
|
||||||
Temps : ~500-1000ms
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Visite suivante (online)
|
|
||||||
|
|
||||||
```
|
|
||||||
User → ServerCache (valide) → Images SW
|
|
||||||
Temps : ~50ms
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Cache expiré (online)
|
|
||||||
|
|
||||||
```
|
|
||||||
User → ServerCache (stale) → Retour immédiat
|
|
||||||
↓
|
|
||||||
Revalidation background → Mise à jour cache
|
|
||||||
Temps ressenti : ~50ms (aucun délai)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Mode offline
|
|
||||||
|
|
||||||
```
|
|
||||||
User → Service Worker cache uniquement
|
|
||||||
Fonctionnalités :
|
|
||||||
✅ Navigation entre pages déjà visitées
|
|
||||||
✅ Consultation des images déjà vues
|
|
||||||
❌ Nouvelles données (nécessite connexion)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Monitoring et debug
|
|
||||||
|
|
||||||
### Logs de cache (recommandé pour le dev)
|
|
||||||
|
|
||||||
Activez les logs détaillés du cache serveur :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Dans docker-compose.dev.yml ou .env
|
|
||||||
CACHE_DEBUG=true
|
|
||||||
```
|
|
||||||
|
|
||||||
**Format des logs** :
|
|
||||||
|
|
||||||
```
|
|
||||||
[CACHE HIT] home-ongoing | HOME | 0.45ms # Cache valide
|
|
||||||
[CACHE STALE] home-ongoing | HOME | 0.52ms # Cache expiré (retourné + revalidation)
|
|
||||||
[CACHE MISS] home-ongoing | HOME # Pas de cache
|
|
||||||
[CACHE SET] home-ongoing | HOME | 324.18ms # Mise en cache
|
|
||||||
[CACHE REVALIDATE] home-ongoing | HOME | 287ms # Revalidation background
|
|
||||||
```
|
|
||||||
|
|
||||||
📖 **Documentation complète** : [docs/cache-debug.md](./cache-debug.md)
|
|
||||||
|
|
||||||
### API de monitoring
|
|
||||||
|
|
||||||
#### Taille du cache serveur
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GET /api/komga/cache/size
|
|
||||||
Response: { sizeInBytes: 15728640, itemCount: 234 }
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Mode de cache actuel
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GET /api/komga/cache/mode
|
|
||||||
Response: { mode: "memory" }
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Changer le mode
|
|
||||||
|
|
||||||
```bash
|
|
||||||
POST /api/komga/cache/mode
|
|
||||||
Body: { mode: "file" }
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Vider le cache
|
|
||||||
|
|
||||||
```bash
|
|
||||||
POST /api/komga/cache/clear
|
|
||||||
```
|
|
||||||
|
|
||||||
### DevTools du navigateur
|
|
||||||
|
|
||||||
#### Network Tab
|
|
||||||
|
|
||||||
- Temps de réponse < 50ms = cache serveur
|
|
||||||
- Headers `X-Cache` si configurés
|
|
||||||
- Onglet "Timing" pour détails
|
|
||||||
|
|
||||||
#### Application → Cache Storage
|
|
||||||
|
|
||||||
Inspecter le Service Worker :
|
|
||||||
|
|
||||||
- `stripstream-cache-v1` : Ressources statiques
|
|
||||||
- `stripstream-images-v1` : Images
|
|
||||||
|
|
||||||
Actions disponibles :
|
|
||||||
|
|
||||||
- Voir le contenu
|
|
||||||
- Supprimer des entrées
|
|
||||||
- Vider complètement
|
|
||||||
|
|
||||||
#### Application → Service Workers
|
|
||||||
|
|
||||||
- État du Service Worker
|
|
||||||
- "Unregister" pour le désactiver
|
|
||||||
- "Update" pour forcer une mise à jour
|
|
||||||
|
|
||||||
## Optimisations futures possibles
|
|
||||||
|
|
||||||
### 1. Cache Redis (optionnel)
|
|
||||||
|
|
||||||
- Pour un déploiement multi-instances
|
|
||||||
- Cache partagé entre plusieurs serveurs
|
|
||||||
- TTL natif Redis
|
|
||||||
|
|
||||||
### 2. Compression
|
|
||||||
|
|
||||||
- Compresser les données en cache (Brotli/Gzip)
|
|
||||||
- Économie d'espace disque/mémoire
|
|
||||||
- Trade-off CPU vs espace
|
|
||||||
|
|
||||||
### 3. Prefetching intelligent
|
|
||||||
|
|
||||||
- Précharger les séries en cours de lecture
|
|
||||||
- Précharger les pages suivantes dans le reader
|
|
||||||
- Basé sur l'historique utilisateur
|
|
||||||
|
|
||||||
### 4. Cache Analytics
|
|
||||||
|
|
||||||
- Ratio hit/miss
|
|
||||||
- Temps de réponse moyens
|
|
||||||
- Identification des données les plus consultées
|
|
||||||
|
|
||||||
## Bonnes pratiques
|
|
||||||
|
|
||||||
### Pour les développeurs
|
|
||||||
|
|
||||||
✅ **Utiliser BaseApiService.fetchWithCache()**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await this.fetchWithCache<T>(
|
|
||||||
"cache-key",
|
|
||||||
async () => this.fetchFromApi(...),
|
|
||||||
"HOME" // Type de TTL
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
✅ **Invalider le cache après modification**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await HomeService.invalidateHomeCache();
|
|
||||||
```
|
|
||||||
|
|
||||||
✅ **Choisir le bon TTL**
|
|
||||||
|
|
||||||
- Court (1-5 min) : Données qui changent souvent
|
|
||||||
- Moyen (10-30 min) : Données agrégées
|
|
||||||
- Long (24h+) : Données quasi-statiques
|
|
||||||
|
|
||||||
❌ **Ne pas cacher les mutations**
|
|
||||||
Les POST/PUT/DELETE ne doivent jamais être cachés
|
|
||||||
|
|
||||||
❌ **Ne pas oublier l'isolation utilisateur**
|
|
||||||
Toujours préfixer avec `userId` pour les données personnelles
|
|
||||||
|
|
||||||
### Pour les utilisateurs
|
|
||||||
|
|
||||||
- **Mode mémoire** : Plus rapide, mais cache perdu au redémarrage
|
|
||||||
- **Mode fichier** : Persistant, idéal pour production
|
|
||||||
- **Vider le cache** : En cas de problème d'affichage
|
|
||||||
- **Offline** : Consulter les pages déjà visitées
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
Le système de caching de StripStream est conçu pour :
|
|
||||||
|
|
||||||
🎯 **Performance** : Temps de réponse constants grâce au stale-while-revalidate
|
|
||||||
🔒 **Fiabilité** : Fonctionne même si Komga est lent ou inaccessible
|
|
||||||
💾 **Flexibilité** : Mode mémoire ou fichier selon les besoins
|
|
||||||
🚀 **Offline-first** : Support complet du mode hors ligne
|
|
||||||
🧹 **Simplicité** : 3 couches bien définies, pas de redondance
|
|
||||||
|
|
||||||
Le système est maintenu simple avec des responsabilités claires pour chaque couche, facilitant la maintenance et l'évolution future.
|
|
||||||
@@ -7,10 +7,12 @@ Service de gestion de l'authentification
|
|||||||
### Méthodes
|
### Méthodes
|
||||||
|
|
||||||
- `loginUser(email: string, password: string): Promise<UserData>`
|
- `loginUser(email: string, password: string): Promise<UserData>`
|
||||||
|
|
||||||
- Authentifie un utilisateur
|
- Authentifie un utilisateur
|
||||||
- Retourne les données utilisateur
|
- Retourne les données utilisateur
|
||||||
|
|
||||||
- `createUser(email: string, password: string): Promise<UserData>`
|
- `createUser(email: string, password: string): Promise<UserData>`
|
||||||
|
|
||||||
- Crée un nouvel utilisateur
|
- Crée un nouvel utilisateur
|
||||||
- Retourne les données utilisateur
|
- Retourne les données utilisateur
|
||||||
|
|
||||||
@@ -24,10 +26,11 @@ Service de gestion des bibliothèques
|
|||||||
### Méthodes
|
### Méthodes
|
||||||
|
|
||||||
- `getLibraries(): Promise<Library[]>`
|
- `getLibraries(): Promise<Library[]>`
|
||||||
|
|
||||||
- Récupère la liste des bibliothèques
|
- Récupère la liste des bibliothèques
|
||||||
- Met en cache les résultats
|
|
||||||
|
|
||||||
- `getLibrary(libraryId: string): Promise<Library>`
|
- `getLibrary(libraryId: string): Promise<Library>`
|
||||||
|
|
||||||
- Récupère une bibliothèque spécifique
|
- Récupère une bibliothèque spécifique
|
||||||
- Lance une erreur si non trouvée
|
- Lance une erreur si non trouvée
|
||||||
|
|
||||||
@@ -47,9 +50,11 @@ Service de gestion des séries
|
|||||||
### Méthodes
|
### Méthodes
|
||||||
|
|
||||||
- `getSeries(seriesId: string): Promise<Series>`
|
- `getSeries(seriesId: string): Promise<Series>`
|
||||||
|
|
||||||
- Récupère les détails d'une série
|
- Récupère les détails d'une série
|
||||||
|
|
||||||
- `getSeriesBooks(seriesId: string, page: number = 0, size: number = 24, unreadOnly: boolean = false): Promise<LibraryResponse<KomgaBook>>`
|
- `getSeriesBooks(seriesId: string, page: number = 0, size: number = 24, unreadOnly: boolean = false): Promise<LibraryResponse<KomgaBook>>`
|
||||||
|
|
||||||
- Récupère les livres d'une série
|
- Récupère les livres d'une série
|
||||||
- Supporte la pagination et le filtrage
|
- Supporte la pagination et le filtrage
|
||||||
|
|
||||||
@@ -63,15 +68,19 @@ Service de gestion des livres
|
|||||||
### Méthodes
|
### Méthodes
|
||||||
|
|
||||||
- `getBook(bookId: string): Promise<{ book: KomgaBook; pages: number[] }>`
|
- `getBook(bookId: string): Promise<{ book: KomgaBook; pages: number[] }>`
|
||||||
|
|
||||||
- Récupère les détails d'un livre et ses pages
|
- Récupère les détails d'un livre et ses pages
|
||||||
|
|
||||||
- `updateReadProgress(bookId: string, page: number, completed: boolean = false): Promise<void>`
|
- `updateReadProgress(bookId: string, page: number, completed: boolean = false): Promise<void>`
|
||||||
|
|
||||||
- Met à jour la progression de lecture
|
- Met à jour la progression de lecture
|
||||||
|
|
||||||
- `getPage(bookId: string, pageNumber: number): Promise<Response>`
|
- `getPage(bookId: string, pageNumber: number): Promise<Response>`
|
||||||
|
|
||||||
- Récupère une page spécifique d'un livre
|
- Récupère une page spécifique d'un livre
|
||||||
|
|
||||||
- `getCover(bookId: string): Promise<Response>`
|
- `getCover(bookId: string): Promise<Response>`
|
||||||
|
|
||||||
- Récupère la couverture d'un livre
|
- Récupère la couverture d'un livre
|
||||||
|
|
||||||
- `getPageThumbnail(bookId: string, pageNumber: number): Promise<Response>`
|
- `getPageThumbnail(bookId: string, pageNumber: number): Promise<Response>`
|
||||||
@@ -84,13 +93,15 @@ Service de gestion des images
|
|||||||
### Méthodes
|
### Méthodes
|
||||||
|
|
||||||
- `getImage(path: string): Promise<ImageResponse>`
|
- `getImage(path: string): Promise<ImageResponse>`
|
||||||
|
|
||||||
- Récupère une image depuis le serveur
|
- Récupère une image depuis le serveur
|
||||||
- Gère le cache des images
|
|
||||||
|
|
||||||
- `getSeriesThumbnailUrl(seriesId: string): string`
|
- `getSeriesThumbnailUrl(seriesId: string): string`
|
||||||
|
|
||||||
- Génère l'URL de la miniature d'une série
|
- Génère l'URL de la miniature d'une série
|
||||||
|
|
||||||
- `getBookThumbnailUrl(bookId: string): string`
|
- `getBookThumbnailUrl(bookId: string): string`
|
||||||
|
|
||||||
- Génère l'URL de la miniature d'un livre
|
- Génère l'URL de la miniature d'un livre
|
||||||
|
|
||||||
- `getBookPageUrl(bookId: string, pageNumber: number): string`
|
- `getBookPageUrl(bookId: string, pageNumber: number): string`
|
||||||
@@ -103,29 +114,20 @@ Service de gestion de la configuration
|
|||||||
### Méthodes
|
### Méthodes
|
||||||
|
|
||||||
- `getConfig(): Promise<Config>`
|
- `getConfig(): Promise<Config>`
|
||||||
|
|
||||||
- Récupère la configuration Komga
|
- Récupère la configuration Komga
|
||||||
|
|
||||||
- `saveConfig(config: Config): Promise<Config>`
|
- `saveConfig(config: Config): Promise<Config>`
|
||||||
|
|
||||||
- Sauvegarde la configuration Komga
|
- Sauvegarde la configuration Komga
|
||||||
|
|
||||||
- `getTTLConfig(): Promise<TTLConfig>`
|
- `getTTLConfig(): Promise<TTLConfig>`
|
||||||
|
|
||||||
- Récupère la configuration TTL
|
- Récupère la configuration TTL
|
||||||
|
|
||||||
- `saveTTLConfig(config: TTLConfig): Promise<TTLConfig>`
|
- `saveTTLConfig(config: TTLConfig): Promise<TTLConfig>`
|
||||||
- Sauvegarde la configuration TTL
|
- Sauvegarde la configuration TTL
|
||||||
|
|
||||||
## 🔄 ServerCacheService
|
|
||||||
|
|
||||||
Service de gestion du cache serveur
|
|
||||||
|
|
||||||
### Méthodes
|
|
||||||
|
|
||||||
- `getCacheMode(): string`
|
|
||||||
- Récupère le mode de cache actuel
|
|
||||||
|
|
||||||
- `clearCache(): void`
|
|
||||||
- Vide le cache serveur
|
|
||||||
|
|
||||||
## ⭐ FavoriteService
|
## ⭐ FavoriteService
|
||||||
|
|
||||||
Service de gestion des favoris
|
Service de gestion des favoris
|
||||||
@@ -142,6 +144,7 @@ Service de gestion des préférences
|
|||||||
### Méthodes
|
### Méthodes
|
||||||
|
|
||||||
- `getPreferences(): Promise<Preferences>`
|
- `getPreferences(): Promise<Preferences>`
|
||||||
|
|
||||||
- Récupère les préférences utilisateur
|
- Récupère les préférences utilisateur
|
||||||
|
|
||||||
- `savePreferences(preferences: Preferences): Promise<void>`
|
- `savePreferences(preferences: Preferences): Promise<void>`
|
||||||
@@ -164,13 +167,12 @@ Service de base pour les appels API
|
|||||||
### Méthodes
|
### Méthodes
|
||||||
|
|
||||||
- `buildUrl(config: Config, path: string, params?: Record<string, string>): string`
|
- `buildUrl(config: Config, path: string, params?: Record<string, string>): string`
|
||||||
|
|
||||||
- Construit une URL d'API
|
- Construit une URL d'API
|
||||||
|
|
||||||
- `getAuthHeaders(config: Config): Headers`
|
- `getAuthHeaders(config: Config): Headers`
|
||||||
|
|
||||||
- Génère les en-têtes d'authentification
|
- Génère les en-têtes d'authentification
|
||||||
|
|
||||||
- `fetchFromApi<T>(url: string, headers: Headers, raw?: boolean): Promise<T>`
|
- `fetchFromApi<T>(url: string, headers: Headers, raw?: boolean): Promise<T>`
|
||||||
- Effectue un appel API avec gestion d'erreurs
|
- Effectue un appel API avec gestion d'erreurs
|
||||||
|
|
||||||
- `fetchWithCache<T>(key: string, fetcher: () => Promise<T>, type: CacheType): Promise<T>`
|
|
||||||
- Effectue un appel API avec mise en cache
|
|
||||||
|
|||||||
@@ -18,10 +18,12 @@
|
|||||||
"@prisma/client": "^6.17.1",
|
"@prisma/client": "^6.17.1",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "1.1.15",
|
"@radix-ui/react-dialog": "1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||||
"@radix-ui/react-progress": "^1.1.2",
|
"@radix-ui/react-progress": "^1.1.2",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "1.2.3",
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
@@ -35,7 +37,7 @@
|
|||||||
"i18next-browser-languagedetector": "^8.0.4",
|
"i18next-browser-languagedetector": "^8.0.4",
|
||||||
"lucide-react": "^0.487.0",
|
"lucide-react": "^0.487.0",
|
||||||
"mongodb": "^6.20.0",
|
"mongodb": "^6.20.0",
|
||||||
"next": "^15.4.7",
|
"next": "^15.5.9",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"next-themes": "0.2.1",
|
"next-themes": "0.2.1",
|
||||||
"photoswipe": "^5.4.4",
|
"photoswipe": "^5.4.4",
|
||||||
|
|||||||
6033
pnpm-lock.yaml
generated
6033
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,6 @@ model User {
|
|||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
config KomgaConfig?
|
config KomgaConfig?
|
||||||
ttlConfig TTLConfig?
|
|
||||||
preferences Preferences?
|
preferences Preferences?
|
||||||
favorites Favorite[]
|
favorites Favorite[]
|
||||||
|
|
||||||
@@ -42,37 +41,16 @@ model KomgaConfig {
|
|||||||
@@map("komgaconfigs")
|
@@map("komgaconfigs")
|
||||||
}
|
}
|
||||||
|
|
||||||
model TTLConfig {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
userId Int @unique
|
|
||||||
defaultTTL Int @default(5)
|
|
||||||
homeTTL Int @default(5)
|
|
||||||
librariesTTL Int @default(1440)
|
|
||||||
seriesTTL Int @default(5)
|
|
||||||
booksTTL Int @default(5)
|
|
||||||
imagesTTL Int @default(1440)
|
|
||||||
imageCacheMaxAge Int @default(2592000) // 30 jours en secondes
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@map("ttlconfigs")
|
|
||||||
}
|
|
||||||
|
|
||||||
model Preferences {
|
model Preferences {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int @unique
|
userId Int @unique
|
||||||
showThumbnails Boolean @default(true)
|
showThumbnails Boolean @default(true)
|
||||||
cacheMode String @default("memory") // "memory" | "file"
|
showOnlyUnread Boolean @default(false)
|
||||||
showOnlyUnread Boolean @default(false)
|
displayMode Json
|
||||||
displayMode Json
|
background Json
|
||||||
background Json
|
readerPrefetchCount Int @default(5)
|
||||||
komgaMaxConcurrentRequests Int @default(5)
|
createdAt DateTime @default(now())
|
||||||
circuitBreakerConfig Json
|
updatedAt DateTime @updatedAt
|
||||||
readerPrefetchCount Int @default(5)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@ -92,4 +70,3 @@ model Favorite {
|
|||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@map("favorites")
|
@@map("favorites")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
464
public/sw.js
464
public/sw.js
@@ -1,17 +1,20 @@
|
|||||||
// StripStream Service Worker - Version 1
|
// StripStream Service Worker - Version 2
|
||||||
// Architecture: Cache-as-you-go for images and static resources only
|
// Architecture: SWR (Stale-While-Revalidate) for all resources
|
||||||
// API data caching is handled by ServerCacheService on the server
|
|
||||||
|
|
||||||
const VERSION = "v1";
|
const VERSION = "v2.5";
|
||||||
const STATIC_CACHE = `stripstream-static-${VERSION}`;
|
const STATIC_CACHE = `stripstream-static-${VERSION}`;
|
||||||
|
const PAGES_CACHE = `stripstream-pages-${VERSION}`; // Navigation + RSC (client-side navigation)
|
||||||
|
const API_CACHE = `stripstream-api-${VERSION}`;
|
||||||
const IMAGES_CACHE = `stripstream-images-${VERSION}`;
|
const IMAGES_CACHE = `stripstream-images-${VERSION}`;
|
||||||
const DATA_CACHE = `stripstream-data-${VERSION}`;
|
|
||||||
const RSC_CACHE = `stripstream-rsc-${VERSION}`;
|
|
||||||
const BOOKS_CACHE = "stripstream-books"; // Never version this - managed by DownloadManager
|
const BOOKS_CACHE = "stripstream-books"; // Never version this - managed by DownloadManager
|
||||||
|
|
||||||
const OFFLINE_PAGE = "/offline.html";
|
const OFFLINE_PAGE = "/offline.html";
|
||||||
const PRECACHE_ASSETS = [OFFLINE_PAGE, "/manifest.json"];
|
const PRECACHE_ASSETS = [OFFLINE_PAGE, "/manifest.json"];
|
||||||
|
|
||||||
|
// Cache size limits
|
||||||
|
const IMAGES_CACHE_MAX_SIZE = 100 * 1024 * 1024; // 100MB
|
||||||
|
const IMAGES_CACHE_MAX_ENTRIES = 500;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Utility Functions - Request Detection
|
// Utility Functions - Request Detection
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -20,21 +23,76 @@ function isNextStaticResource(url) {
|
|||||||
return url.includes("/_next/static/");
|
return url.includes("/_next/static/");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isImageRequest(url) {
|
|
||||||
return url.includes("/api/komga/images/");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isApiDataRequest(url) {
|
|
||||||
return url.includes("/api/komga/") && !isImageRequest(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNextRSCRequest(request) {
|
function isNextRSCRequest(request) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
return url.searchParams.has("_rsc") || request.headers.get("RSC") === "1";
|
return url.searchParams.has("_rsc") || request.headers.get("RSC") === "1";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removed: shouldCacheApiData - API data is no longer cached by SW
|
function isApiRequest(url) {
|
||||||
// API data caching is handled by ServerCacheService on the server
|
return url.includes("/api/komga/") && !url.includes("/api/komga/images/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageRequest(url) {
|
||||||
|
return url.includes("/api/komga/images/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBookPageRequest(url) {
|
||||||
|
// Book pages: /api/komga/images/books/{id}/pages/{num} or /api/komga/books/{id}/pages/{num}
|
||||||
|
// These are handled by manual download (DownloadManager) - don't cache via SWR
|
||||||
|
return (
|
||||||
|
(url.includes("/api/komga/images/books/") || url.includes("/api/komga/books/")) &&
|
||||||
|
url.includes("/pages/")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBooksManualCache(url) {
|
||||||
|
// Check if this is a request that should be handled by the books manual cache
|
||||||
|
return url.includes("/api/komga/images/books/") && url.includes("/pages");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Client Communication
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function notifyClients(message) {
|
||||||
|
const clients = await self.clients.matchAll({ type: "window" });
|
||||||
|
clients.forEach((client) => {
|
||||||
|
client.postMessage(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cache Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function getCacheSize(cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const keys = await cache.keys();
|
||||||
|
let totalSize = 0;
|
||||||
|
|
||||||
|
for (const request of keys) {
|
||||||
|
const response = await cache.match(request);
|
||||||
|
if (response) {
|
||||||
|
const blob = await response.clone().blob();
|
||||||
|
totalSize += blob.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trimCache(cacheName, maxEntries) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const keys = await cache.keys();
|
||||||
|
|
||||||
|
if (keys.length > maxEntries) {
|
||||||
|
// Remove oldest entries (FIFO)
|
||||||
|
const toDelete = keys.slice(0, keys.length - maxEntries);
|
||||||
|
await Promise.all(toDelete.map((key) => cache.delete(key)));
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[SW] Trimmed ${toDelete.length} entries from ${cacheName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Cache Strategies
|
// Cache Strategies
|
||||||
@@ -42,7 +100,7 @@ function isNextRSCRequest(request) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache-First: Serve from cache, fallback to network
|
* Cache-First: Serve from cache, fallback to network
|
||||||
* Used for: Images, Next.js static resources
|
* Used for: Next.js static resources (immutable)
|
||||||
*/
|
*/
|
||||||
async function cacheFirstStrategy(request, cacheName, options = {}) {
|
async function cacheFirstStrategy(request, cacheName, options = {}) {
|
||||||
const cache = await caches.open(cacheName);
|
const cache = await caches.open(cacheName);
|
||||||
@@ -70,21 +128,70 @@ async function cacheFirstStrategy(request, cacheName, options = {}) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Stale-While-Revalidate: Serve from cache immediately, update in background
|
* Stale-While-Revalidate: Serve from cache immediately, update in background
|
||||||
* Used for: API data, RSC payloads
|
* Used for: API calls, images
|
||||||
|
* Respects Cache-Control: no-cache to force network-first (for refresh buttons)
|
||||||
*/
|
*/
|
||||||
async function staleWhileRevalidateStrategy(request, cacheName) {
|
async function staleWhileRevalidateStrategy(request, cacheName, options = {}) {
|
||||||
const cache = await caches.open(cacheName);
|
const cache = await caches.open(cacheName);
|
||||||
const cached = await cache.match(request);
|
|
||||||
|
// Check if client requested no-cache (refresh button, router.refresh(), etc.)
|
||||||
|
// 1. Check Cache-Control header
|
||||||
|
const cacheControl = request.headers.get("Cache-Control");
|
||||||
|
const noCacheHeader =
|
||||||
|
cacheControl && (cacheControl.includes("no-cache") || cacheControl.includes("no-store"));
|
||||||
|
// 2. Check request.cache mode (used by Next.js router.refresh())
|
||||||
|
const noCacheMode =
|
||||||
|
request.cache === "no-cache" || request.cache === "no-store" || request.cache === "reload";
|
||||||
|
const noCache = noCacheHeader || noCacheMode;
|
||||||
|
|
||||||
|
// If no-cache, skip cached response and go network-first
|
||||||
|
const cached = noCache ? null : await cache.match(request);
|
||||||
|
|
||||||
// Start network request (don't await)
|
// Start network request (don't await)
|
||||||
const fetchPromise = fetch(request)
|
const fetchPromise = fetch(request)
|
||||||
.then((response) => {
|
.then(async (response) => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
cache.put(request, response.clone());
|
// Clone response for cache
|
||||||
|
const responseToCache = response.clone();
|
||||||
|
|
||||||
|
// Check if content changed (for notification)
|
||||||
|
if (cached && options.notifyOnChange) {
|
||||||
|
try {
|
||||||
|
const cachedResponse = await cache.match(request);
|
||||||
|
if (cachedResponse) {
|
||||||
|
// For JSON APIs, compare content
|
||||||
|
if (options.isJson) {
|
||||||
|
const oldText = await cachedResponse.text();
|
||||||
|
const newText = await response.clone().text();
|
||||||
|
if (oldText !== newText) {
|
||||||
|
notifyClients({
|
||||||
|
type: "CACHE_UPDATED",
|
||||||
|
url: request.url,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore comparison errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
await cache.put(request, responseToCache);
|
||||||
|
|
||||||
|
// Trim cache if needed (for images)
|
||||||
|
if (options.maxEntries) {
|
||||||
|
trimCache(cacheName, options.maxEntries);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
})
|
})
|
||||||
.catch(() => null);
|
.catch((error) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("[SW] Network request failed:", request.url, error.message);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
// Return cached version immediately if available
|
// Return cached version immediately if available
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -101,40 +208,50 @@ async function staleWhileRevalidateStrategy(request, cacheName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigation Strategy: Network-First with SPA fallback
|
* Navigation SWR: Serve from cache immediately, update in background
|
||||||
|
* Falls back to offline page if nothing cached
|
||||||
* Used for: Page navigations
|
* Used for: Page navigations
|
||||||
*/
|
*/
|
||||||
async function navigationStrategy(request) {
|
async function navigationSWRStrategy(request, cacheName) {
|
||||||
const cache = await caches.open(STATIC_CACHE);
|
const cache = await caches.open(cacheName);
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
|
||||||
try {
|
// Start network request in background
|
||||||
// Try network first
|
const fetchPromise = fetch(request)
|
||||||
const response = await fetch(request);
|
.then(async (response) => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
cache.put(request, response.clone());
|
await cache.put(request, response.clone());
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
})
|
||||||
// Network failed - try cache
|
.catch(() => null);
|
||||||
const cached = await cache.match(request);
|
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to serve root page for SPA client-side routing
|
// Return cached version immediately if available
|
||||||
const rootPage = await cache.match("/");
|
if (cached) {
|
||||||
if (rootPage) {
|
return cached;
|
||||||
return rootPage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last resort: offline page
|
|
||||||
const offlinePage = await cache.match(OFFLINE_PAGE);
|
|
||||||
if (offlinePage) {
|
|
||||||
return offlinePage;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No cache - wait for network
|
||||||
|
const response = await fetchPromise;
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network failed and no cache - try fallbacks
|
||||||
|
// Try to serve root page for SPA client-side routing
|
||||||
|
const rootPage = await cache.match("/");
|
||||||
|
if (rootPage) {
|
||||||
|
return rootPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: offline page (in static cache)
|
||||||
|
const staticCache = await caches.open(STATIC_CACHE);
|
||||||
|
const offlinePage = await staticCache.match(OFFLINE_PAGE);
|
||||||
|
if (offlinePage) {
|
||||||
|
return offlinePage;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Offline and no cached page available");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -169,9 +286,10 @@ self.addEventListener("activate", (event) => {
|
|||||||
(async () => {
|
(async () => {
|
||||||
// Clean up old caches, but preserve BOOKS_CACHE
|
// Clean up old caches, but preserve BOOKS_CACHE
|
||||||
const cacheNames = await caches.keys();
|
const cacheNames = await caches.keys();
|
||||||
|
const currentCaches = [STATIC_CACHE, PAGES_CACHE, API_CACHE, IMAGES_CACHE, BOOKS_CACHE];
|
||||||
|
|
||||||
const cachesToDelete = cacheNames.filter(
|
const cachesToDelete = cacheNames.filter(
|
||||||
(name) =>
|
(name) => name.startsWith("stripstream-") && !currentCaches.includes(name)
|
||||||
name.startsWith("stripstream-") && name !== BOOKS_CACHE && !name.endsWith(`-${VERSION}`)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await Promise.all(cachesToDelete.map((name) => caches.delete(name)));
|
await Promise.all(cachesToDelete.map((name) => caches.delete(name)));
|
||||||
@@ -184,10 +302,179 @@ self.addEventListener("activate", (event) => {
|
|||||||
await self.clients.claim();
|
await self.clients.claim();
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log("[SW] Activated and claimed clients");
|
console.log("[SW] Activated and claimed clients");
|
||||||
|
|
||||||
|
// Notify clients that SW is ready
|
||||||
|
notifyClients({ type: "SW_ACTIVATED", version: VERSION });
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Message Handler - Client Communication
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
self.addEventListener("message", async (event) => {
|
||||||
|
const { type, payload } = event.data || {};
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "GET_CACHE_STATS": {
|
||||||
|
try {
|
||||||
|
const [staticSize, pagesSize, apiSize, imagesSize, booksSize] = await Promise.all([
|
||||||
|
getCacheSize(STATIC_CACHE),
|
||||||
|
getCacheSize(PAGES_CACHE),
|
||||||
|
getCacheSize(API_CACHE),
|
||||||
|
getCacheSize(IMAGES_CACHE),
|
||||||
|
getCacheSize(BOOKS_CACHE),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const staticCache = await caches.open(STATIC_CACHE);
|
||||||
|
const pagesCache = await caches.open(PAGES_CACHE);
|
||||||
|
const apiCache = await caches.open(API_CACHE);
|
||||||
|
const imagesCache = await caches.open(IMAGES_CACHE);
|
||||||
|
const booksCache = await caches.open(BOOKS_CACHE);
|
||||||
|
|
||||||
|
const [staticKeys, pagesKeys, apiKeys, imagesKeys, booksKeys] = await Promise.all([
|
||||||
|
staticCache.keys(),
|
||||||
|
pagesCache.keys(),
|
||||||
|
apiCache.keys(),
|
||||||
|
imagesCache.keys(),
|
||||||
|
booksCache.keys(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
event.source.postMessage({
|
||||||
|
type: "CACHE_STATS",
|
||||||
|
payload: {
|
||||||
|
static: { size: staticSize, entries: staticKeys.length },
|
||||||
|
pages: { size: pagesSize, entries: pagesKeys.length },
|
||||||
|
api: { size: apiSize, entries: apiKeys.length },
|
||||||
|
images: { size: imagesSize, entries: imagesKeys.length },
|
||||||
|
books: { size: booksSize, entries: booksKeys.length },
|
||||||
|
total: staticSize + pagesSize + apiSize + imagesSize + booksSize,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
event.source.postMessage({
|
||||||
|
type: "CACHE_STATS_ERROR",
|
||||||
|
payload: { error: error.message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "CLEAR_CACHE": {
|
||||||
|
try {
|
||||||
|
const cacheType = payload?.cacheType || "all";
|
||||||
|
const cachesToClear = [];
|
||||||
|
|
||||||
|
if (cacheType === "all" || cacheType === "static") {
|
||||||
|
cachesToClear.push(STATIC_CACHE);
|
||||||
|
}
|
||||||
|
if (cacheType === "all" || cacheType === "pages") {
|
||||||
|
cachesToClear.push(PAGES_CACHE);
|
||||||
|
}
|
||||||
|
if (cacheType === "all" || cacheType === "api") {
|
||||||
|
cachesToClear.push(API_CACHE);
|
||||||
|
}
|
||||||
|
if (cacheType === "all" || cacheType === "images") {
|
||||||
|
cachesToClear.push(IMAGES_CACHE);
|
||||||
|
}
|
||||||
|
// Note: BOOKS_CACHE is not cleared by default, only explicitly
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
cachesToClear.map(async (cacheName) => {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const keys = await cache.keys();
|
||||||
|
await Promise.all(keys.map((key) => cache.delete(key)));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
event.source.postMessage({
|
||||||
|
type: "CACHE_CLEARED",
|
||||||
|
payload: { caches: cachesToClear },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
event.source.postMessage({
|
||||||
|
type: "CACHE_CLEAR_ERROR",
|
||||||
|
payload: { error: error.message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SKIP_WAITING": {
|
||||||
|
self.skipWaiting();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "GET_VERSION": {
|
||||||
|
event.source.postMessage({
|
||||||
|
type: "SW_VERSION",
|
||||||
|
payload: { version: VERSION },
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "GET_CACHE_ENTRIES": {
|
||||||
|
try {
|
||||||
|
const cacheType = payload?.cacheType;
|
||||||
|
let cacheName;
|
||||||
|
|
||||||
|
switch (cacheType) {
|
||||||
|
case "static":
|
||||||
|
cacheName = STATIC_CACHE;
|
||||||
|
break;
|
||||||
|
case "pages":
|
||||||
|
cacheName = PAGES_CACHE;
|
||||||
|
break;
|
||||||
|
case "api":
|
||||||
|
cacheName = API_CACHE;
|
||||||
|
break;
|
||||||
|
case "images":
|
||||||
|
cacheName = IMAGES_CACHE;
|
||||||
|
break;
|
||||||
|
case "books":
|
||||||
|
cacheName = BOOKS_CACHE;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error("Invalid cache type");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const keys = await cache.keys();
|
||||||
|
|
||||||
|
const entries = await Promise.all(
|
||||||
|
keys.map(async (request) => {
|
||||||
|
const response = await cache.match(request);
|
||||||
|
let size = 0;
|
||||||
|
if (response) {
|
||||||
|
const blob = await response.clone().blob();
|
||||||
|
size = blob.size;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url: request.url,
|
||||||
|
size,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort by size descending
|
||||||
|
entries.sort((a, b) => b.size - a.size);
|
||||||
|
|
||||||
|
event.source.postMessage({
|
||||||
|
type: "CACHE_ENTRIES",
|
||||||
|
payload: { cacheType, entries },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
event.source.postMessage({
|
||||||
|
type: "CACHE_ENTRIES_ERROR",
|
||||||
|
payload: { error: error.message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Fetch Handler - Request Routing
|
// Fetch Handler - Request Routing
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -202,39 +489,68 @@ self.addEventListener("fetch", (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route 1: Images → Cache-First with ignoreSearch
|
// Route 1: Book pages (handled by DownloadManager) - Check manual cache only, no SWR
|
||||||
if (isImageRequest(url.href)) {
|
if (isBookPageRequest(url.href)) {
|
||||||
event.respondWith(cacheFirstStrategy(request, IMAGES_CACHE, { ignoreSearch: true }));
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
// Check the manual books cache
|
||||||
|
const booksCache = await caches.open(BOOKS_CACHE);
|
||||||
|
const cached = await booksCache.match(request);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not in cache - fetch from network without caching
|
||||||
|
// Book pages are large and should only be cached via manual download
|
||||||
|
return fetch(request);
|
||||||
|
})()
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route 2: Next.js RSC payloads → Stale-While-Revalidate
|
// Route 2: Next.js RSC payloads (client-side navigation) → SWR in PAGES_CACHE
|
||||||
if (isNextRSCRequest(request)) {
|
if (isNextRSCRequest(request)) {
|
||||||
event.respondWith(staleWhileRevalidateStrategy(request, RSC_CACHE));
|
event.respondWith(
|
||||||
|
staleWhileRevalidateStrategy(request, PAGES_CACHE, {
|
||||||
|
notifyOnChange: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route 3: API data → Network only (no SW caching)
|
// Route 3: Next.js static resources → Cache-First with ignoreSearch
|
||||||
// API data caching is handled by ServerCacheService on the server
|
|
||||||
// This avoids double caching and simplifies cache invalidation
|
|
||||||
if (isApiDataRequest(url.href)) {
|
|
||||||
// Let the request pass through to the network
|
|
||||||
// ServerCacheService will handle caching server-side
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route 4: Next.js static resources → Cache-First with ignoreSearch
|
|
||||||
if (isNextStaticResource(url.href)) {
|
if (isNextStaticResource(url.href)) {
|
||||||
event.respondWith(cacheFirstStrategy(request, STATIC_CACHE, { ignoreSearch: true }));
|
event.respondWith(cacheFirstStrategy(request, STATIC_CACHE, { ignoreSearch: true }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route 5: Navigation → Network-First with SPA fallback
|
// Route 4: API requests (JSON) → SWR with notification
|
||||||
if (request.mode === "navigate") {
|
if (isApiRequest(url.href)) {
|
||||||
event.respondWith(navigationStrategy(request));
|
event.respondWith(
|
||||||
|
staleWhileRevalidateStrategy(request, API_CACHE, {
|
||||||
|
notifyOnChange: true,
|
||||||
|
isJson: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route 6: Everything else → Network only (no caching)
|
// Route 5: Image requests (thumbnails, covers) → SWR with cache size management
|
||||||
// This includes: API auth, preferences, and other dynamic content
|
// Note: Book pages are excluded (Route 1) and only use manual download cache
|
||||||
|
if (isImageRequest(url.href)) {
|
||||||
|
event.respondWith(
|
||||||
|
staleWhileRevalidateStrategy(request, IMAGES_CACHE, {
|
||||||
|
maxEntries: IMAGES_CACHE_MAX_ENTRIES,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route 6: Navigation → SWR (cache first, revalidate in background)
|
||||||
|
if (request.mode === "navigate") {
|
||||||
|
event.respondWith(navigationSWRStrategy(request, PAGES_CACHE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route 7: Everything else → Network only (no caching)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { BookService } from "@/lib/services/book.service";
|
import { BookService } from "@/lib/services/book.service";
|
||||||
import { SeriesService } from "@/lib/services/series.service";
|
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
import { getErrorMessage } from "@/utils/errors";
|
import { getErrorMessage } from "@/utils/errors";
|
||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
@@ -12,9 +11,40 @@ export async function PATCH(
|
|||||||
{ params }: { params: Promise<{ bookId: string }> }
|
{ params }: { params: Promise<{ bookId: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { page, completed } = await request.json();
|
|
||||||
const bookId: string = (await params).bookId;
|
const bookId: string = (await params).bookId;
|
||||||
|
|
||||||
|
// Handle empty or invalid body (can happen when request is aborted during navigation)
|
||||||
|
let body: { page?: unknown; completed?: boolean };
|
||||||
|
try {
|
||||||
|
const text = await request.text();
|
||||||
|
if (!text) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
code: ERROR_CODES.BOOK.PROGRESS_UPDATE_ERROR,
|
||||||
|
name: "Progress update error",
|
||||||
|
message: "Empty request body",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
body = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
code: ERROR_CODES.BOOK.PROGRESS_UPDATE_ERROR,
|
||||||
|
name: "Progress update error",
|
||||||
|
message: "Invalid JSON body",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { page, completed } = body;
|
||||||
|
|
||||||
if (typeof page !== "number") {
|
if (typeof page !== "number") {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
@@ -30,16 +60,6 @@ export async function PATCH(
|
|||||||
|
|
||||||
await BookService.updateReadProgress(bookId, page, completed);
|
await BookService.updateReadProgress(bookId, page, completed);
|
||||||
|
|
||||||
// Invalider le cache de la série après avoir mis à jour la progression
|
|
||||||
try {
|
|
||||||
const seriesId = await BookService.getBookSeriesId(bookId);
|
|
||||||
await SeriesService.invalidateSeriesBooksCache(seriesId);
|
|
||||||
await SeriesService.invalidateSeriesCache(seriesId);
|
|
||||||
} catch (cacheError) {
|
|
||||||
// Ne pas faire échouer la requête si l'invalidation du cache échoue
|
|
||||||
logger.error({ err: cacheError }, "Erreur lors de l'invalidation du cache de la série:");
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ message: "📖 Progression mise à jour avec succès" });
|
return NextResponse.json({ message: "📖 Progression mise à jour avec succès" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Erreur lors de la mise à jour de la progression:");
|
logger.error({ err: error }, "Erreur lors de la mise à jour de la progression:");
|
||||||
@@ -77,16 +97,6 @@ export async function DELETE(
|
|||||||
|
|
||||||
await BookService.deleteReadProgress(bookId);
|
await BookService.deleteReadProgress(bookId);
|
||||||
|
|
||||||
// Invalider le cache de la série après avoir supprimé la progression
|
|
||||||
try {
|
|
||||||
const seriesId = await BookService.getBookSeriesId(bookId);
|
|
||||||
await SeriesService.invalidateSeriesBooksCache(seriesId);
|
|
||||||
await SeriesService.invalidateSeriesCache(seriesId);
|
|
||||||
} catch (cacheError) {
|
|
||||||
// Ne pas faire échouer la requête si l'invalidation du cache échoue
|
|
||||||
logger.error({ err: cacheError }, "Erreur lors de l'invalidation du cache de la série:");
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ message: "🗑️ Progression supprimée avec succès" });
|
return NextResponse.json({ message: "🗑️ Progression supprimée avec succès" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Erreur lors de la suppression de la progression:");
|
logger.error({ err: error }, "Erreur lors de la suppression de la progression:");
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
|
||||||
import { getErrorMessage } from "@/utils/errors";
|
|
||||||
import { LibraryService } from "@/lib/services/library.service";
|
|
||||||
import { HomeService } from "@/lib/services/home.service";
|
|
||||||
import { SeriesService } from "@/lib/services/series.service";
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import type { NextRequest } from "next/server";
|
|
||||||
import logger from "@/lib/logger";
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ libraryId: string; seriesId: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { libraryId, seriesId } = await params;
|
|
||||||
|
|
||||||
await HomeService.invalidateHomeCache();
|
|
||||||
revalidatePath("/");
|
|
||||||
|
|
||||||
if (libraryId) {
|
|
||||||
await LibraryService.invalidateLibrarySeriesCache(libraryId);
|
|
||||||
revalidatePath(`/library/${libraryId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seriesId) {
|
|
||||||
await SeriesService.invalidateSeriesBooksCache(seriesId);
|
|
||||||
await SeriesService.invalidateSeriesCache(seriesId);
|
|
||||||
revalidatePath(`/series/${seriesId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ message: "🧹 Cache vidé avec succès" });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Erreur lors de la suppression du cache:");
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
code: ERROR_CODES.CACHE.CLEAR_ERROR,
|
|
||||||
name: "Cache clear error",
|
|
||||||
message: getErrorMessage(ERROR_CODES.CACHE.CLEAR_ERROR),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
34
src/app/api/komga/cache/clear/route.ts
vendored
34
src/app/api/komga/cache/clear/route.ts
vendored
@@ -1,34 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import type { ServerCacheService } from "@/lib/services/server-cache.service";
|
|
||||||
import { getServerCacheService } from "@/lib/services/server-cache.service";
|
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
|
||||||
import { getErrorMessage } from "@/utils/errors";
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import logger from "@/lib/logger";
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
try {
|
|
||||||
const cacheService: ServerCacheService = await getServerCacheService();
|
|
||||||
await cacheService.clear();
|
|
||||||
|
|
||||||
// Revalider toutes les pages importantes après le vidage du cache
|
|
||||||
revalidatePath("/");
|
|
||||||
revalidatePath("/libraries");
|
|
||||||
revalidatePath("/series");
|
|
||||||
revalidatePath("/books");
|
|
||||||
|
|
||||||
return NextResponse.json({ message: "🧹 Cache vidé avec succès" });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Erreur lors de la suppression du cache:");
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
code: ERROR_CODES.CACHE.CLEAR_ERROR,
|
|
||||||
name: "Cache clear error",
|
|
||||||
message: getErrorMessage(ERROR_CODES.CACHE.CLEAR_ERROR),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
27
src/app/api/komga/cache/entries/route.ts
vendored
27
src/app/api/komga/cache/entries/route.ts
vendored
@@ -1,27 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import type { ServerCacheService } from "@/lib/services/server-cache.service";
|
|
||||||
import { getServerCacheService } from "@/lib/services/server-cache.service";
|
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
|
||||||
import { getErrorMessage } from "@/utils/errors";
|
|
||||||
import logger from "@/lib/logger";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const cacheService: ServerCacheService = await getServerCacheService();
|
|
||||||
const entries = await cacheService.getCacheEntries();
|
|
||||||
|
|
||||||
return NextResponse.json({ entries });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Erreur lors de la récupération des entrées du cache");
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
code: ERROR_CODES.CACHE.SIZE_FETCH_ERROR,
|
|
||||||
name: "Cache entries fetch error",
|
|
||||||
message: getErrorMessage(ERROR_CODES.CACHE.SIZE_FETCH_ERROR),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
60
src/app/api/komga/cache/mode/route.ts
vendored
60
src/app/api/komga/cache/mode/route.ts
vendored
@@ -1,60 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import type { CacheMode, ServerCacheService } from "@/lib/services/server-cache.service";
|
|
||||||
import { getServerCacheService } from "@/lib/services/server-cache.service";
|
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
|
||||||
import { getErrorMessage } from "@/utils/errors";
|
|
||||||
import type { NextRequest } from "next/server";
|
|
||||||
import logger from "@/lib/logger";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const cacheService: ServerCacheService = await getServerCacheService();
|
|
||||||
return NextResponse.json({ mode: cacheService.getCacheMode() });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Erreur lors de la récupération du mode de cache:");
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
code: ERROR_CODES.CACHE.MODE_FETCH_ERROR,
|
|
||||||
name: "Cache mode fetch error",
|
|
||||||
message: getErrorMessage(ERROR_CODES.CACHE.MODE_FETCH_ERROR),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { mode }: { mode: CacheMode } = await request.json();
|
|
||||||
if (mode !== "file" && mode !== "memory") {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
code: ERROR_CODES.CACHE.INVALID_MODE,
|
|
||||||
name: "Invalid cache mode",
|
|
||||||
message: getErrorMessage(ERROR_CODES.CACHE.INVALID_MODE),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheService: ServerCacheService = await getServerCacheService();
|
|
||||||
cacheService.setCacheMode(mode);
|
|
||||||
return NextResponse.json({ mode: cacheService.getCacheMode() });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Erreur lors de la mise à jour du mode de cache:");
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
code: ERROR_CODES.CACHE.MODE_UPDATE_ERROR,
|
|
||||||
name: "Cache mode update error",
|
|
||||||
message: getErrorMessage(ERROR_CODES.CACHE.MODE_UPDATE_ERROR),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
31
src/app/api/komga/cache/size/route.ts
vendored
31
src/app/api/komga/cache/size/route.ts
vendored
@@ -1,31 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import type { ServerCacheService } from "@/lib/services/server-cache.service";
|
|
||||||
import { getServerCacheService } from "@/lib/services/server-cache.service";
|
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
|
||||||
import { getErrorMessage } from "@/utils/errors";
|
|
||||||
import logger from "@/lib/logger";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const cacheService: ServerCacheService = await getServerCacheService();
|
|
||||||
const { sizeInBytes, itemCount } = await cacheService.getCacheSize();
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
sizeInBytes,
|
|
||||||
itemCount,
|
|
||||||
mode: cacheService.getCacheMode(),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Erreur lors de la récupération de la taille du cache:");
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
code: ERROR_CODES.CACHE.SIZE_FETCH_ERROR,
|
|
||||||
name: "Cache size fetch error",
|
|
||||||
message: getErrorMessage(ERROR_CODES.CACHE.SIZE_FETCH_ERROR),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,34 +35,3 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE() {
|
|
||||||
try {
|
|
||||||
await HomeService.invalidateHomeCache();
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "API Home - Erreur lors de l'invalidation du cache:");
|
|
||||||
if (error instanceof AppError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
code: error.code,
|
|
||||||
name: "Cache invalidation error",
|
|
||||||
message: getErrorMessage(error.code),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
code: ERROR_CODES.CACHE.DELETE_ERROR,
|
|
||||||
name: "Cache invalidation error",
|
|
||||||
message: getErrorMessage(ERROR_CODES.CACHE.DELETE_ERROR),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -53,40 +53,3 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ libraryId: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const libraryId: string = (await params).libraryId;
|
|
||||||
|
|
||||||
await LibraryService.invalidateLibrarySeriesCache(libraryId);
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "API Library Cache Invalidation - Erreur:");
|
|
||||||
if (error instanceof AppError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
code: error.code,
|
|
||||||
name: "Cache invalidation error",
|
|
||||||
message: getErrorMessage(error.code),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
code: ERROR_CODES.CACHE.DELETE_ERROR,
|
|
||||||
name: "Cache invalidation error",
|
|
||||||
message: getErrorMessage(ERROR_CODES.CACHE.DELETE_ERROR),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -52,43 +52,3 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ seriesId: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const seriesId: string = (await params).seriesId;
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
SeriesService.invalidateSeriesBooksCache(seriesId),
|
|
||||||
SeriesService.invalidateSeriesCache(seriesId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "API Series Cache Invalidation - Erreur:");
|
|
||||||
if (error instanceof AppError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
code: error.code,
|
|
||||||
name: "Cache invalidation error",
|
|
||||||
message: getErrorMessage(error.code),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
code: ERROR_CODES.CACHE.DELETE_ERROR,
|
|
||||||
name: "Cache invalidation error",
|
|
||||||
message: getErrorMessage(ERROR_CODES.CACHE.DELETE_ERROR),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { ConfigDBService } from "@/lib/services/config-db.service";
|
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
|
||||||
import type { TTLConfig } from "@/types/komga";
|
|
||||||
import { getErrorMessage } from "@/utils/errors";
|
|
||||||
import type { NextRequest } from "next/server";
|
|
||||||
import logger from "@/lib/logger";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const config: TTLConfig | null = await ConfigDBService.getTTLConfig();
|
|
||||||
return NextResponse.json(config);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Erreur lors de la récupération de la configuration TTL");
|
|
||||||
if (error instanceof Error) {
|
|
||||||
if (error.message === getErrorMessage(ERROR_CODES.MIDDLEWARE.UNAUTHORIZED)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
name: "Unauthorized",
|
|
||||||
code: ERROR_CODES.MIDDLEWARE.UNAUTHORIZED,
|
|
||||||
message: getErrorMessage(ERROR_CODES.MIDDLEWARE.UNAUTHORIZED),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
name: "TTL fetch error",
|
|
||||||
code: ERROR_CODES.CONFIG.TTL_FETCH_ERROR,
|
|
||||||
message: getErrorMessage(ERROR_CODES.CONFIG.TTL_FETCH_ERROR),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const data = await request.json();
|
|
||||||
const config: TTLConfig = await ConfigDBService.saveTTLConfig(data);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
message: "⏱️ Configuration TTL sauvegardée avec succès",
|
|
||||||
config: {
|
|
||||||
defaultTTL: config.defaultTTL,
|
|
||||||
homeTTL: config.homeTTL,
|
|
||||||
librariesTTL: config.librariesTTL,
|
|
||||||
seriesTTL: config.seriesTTL,
|
|
||||||
booksTTL: config.booksTTL,
|
|
||||||
imagesTTL: config.imagesTTL,
|
|
||||||
imageCacheMaxAge: config.imageCacheMaxAge,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Erreur lors de la sauvegarde de la configuration TTL");
|
|
||||||
if (
|
|
||||||
error instanceof Error &&
|
|
||||||
error.message === getErrorMessage(ERROR_CODES.MIDDLEWARE.UNAUTHORIZED)
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
name: "Unauthorized",
|
|
||||||
code: ERROR_CODES.MIDDLEWARE.UNAUTHORIZED,
|
|
||||||
message: getErrorMessage(ERROR_CODES.MIDDLEWARE.UNAUTHORIZED),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
name: "TTL save error",
|
|
||||||
code: ERROR_CODES.CONFIG.TTL_SAVE_ERROR,
|
|
||||||
message: getErrorMessage(ERROR_CODES.CONFIG.TTL_SAVE_ERROR),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -71,14 +71,17 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const locale = cookieStore.get("NEXT_LOCALE")?.value || "fr";
|
const locale = cookieStore.get("NEXT_LOCALE")?.value || "fr";
|
||||||
|
|
||||||
// Les libraries et favorites sont chargés côté client par la Sidebar
|
|
||||||
let preferences: UserPreferences = defaultPreferences;
|
let preferences: UserPreferences = defaultPreferences;
|
||||||
let userIsAdmin = false;
|
let userIsAdmin = false;
|
||||||
|
let libraries: any[] = [];
|
||||||
|
let favorites: any[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [preferencesData, isAdminCheck] = await Promise.allSettled([
|
const [preferencesData, isAdminCheck, librariesData, favoritesData] = await Promise.allSettled([
|
||||||
PreferencesService.getPreferences(),
|
PreferencesService.getPreferences(),
|
||||||
import("@/lib/auth-utils").then((m) => m.isAdmin()),
|
import("@/lib/auth-utils").then((m) => m.isAdmin()),
|
||||||
|
import("@/lib/services/library.service").then((m) => m.LibraryService.getLibraries()),
|
||||||
|
import("@/lib/services/favorites.service").then((m) => m.FavoritesService.getFavorites()),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (preferencesData.status === "fulfilled") {
|
if (preferencesData.status === "fulfilled") {
|
||||||
@@ -88,8 +91,16 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
if (isAdminCheck.status === "fulfilled") {
|
if (isAdminCheck.status === "fulfilled") {
|
||||||
userIsAdmin = isAdminCheck.value;
|
userIsAdmin = isAdminCheck.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (librariesData.status === "fulfilled") {
|
||||||
|
libraries = librariesData.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (favoritesData.status === "fulfilled") {
|
||||||
|
favorites = favoritesData.value;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Erreur lors du chargement des préférences:");
|
logger.error({ err: error }, "Erreur lors du chargement des données initiales:");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -155,7 +166,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<I18nProvider locale={locale}>
|
<I18nProvider locale={locale}>
|
||||||
<PreferencesProvider initialPreferences={preferences}>
|
<PreferencesProvider initialPreferences={preferences}>
|
||||||
<ClientLayout initialLibraries={[]} initialFavorites={[]} userIsAdmin={userIsAdmin}>
|
<ClientLayout initialLibraries={libraries} initialFavorites={favorites} userIsAdmin={userIsAdmin}>
|
||||||
{children}
|
{children}
|
||||||
</ClientLayout>
|
</ClientLayout>
|
||||||
</PreferencesProvider>
|
</PreferencesProvider>
|
||||||
|
|||||||
@@ -1,269 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid";
|
|
||||||
import { RefreshButton } from "@/components/library/RefreshButton";
|
|
||||||
import { LibraryHeader } from "@/components/library/LibraryHeader";
|
|
||||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
|
||||||
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
|
|
||||||
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
|
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
|
||||||
import { OptimizedSkeleton } from "@/components/skeletons/OptimizedSkeletons";
|
|
||||||
import type { LibraryResponse } from "@/types/library";
|
|
||||||
import type { KomgaSeries, KomgaLibrary } from "@/types/komga";
|
|
||||||
import type { UserPreferences } from "@/types/preferences";
|
|
||||||
import { Container } from "@/components/ui/container";
|
|
||||||
import { Section } from "@/components/ui/section";
|
|
||||||
import logger from "@/lib/logger";
|
|
||||||
|
|
||||||
interface ClientLibraryPageProps {
|
|
||||||
currentPage: number;
|
|
||||||
libraryId: string;
|
|
||||||
preferences: UserPreferences;
|
|
||||||
unreadOnly: boolean;
|
|
||||||
search?: string;
|
|
||||||
pageSize?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 20;
|
|
||||||
|
|
||||||
export function ClientLibraryPage({
|
|
||||||
currentPage,
|
|
||||||
libraryId,
|
|
||||||
preferences,
|
|
||||||
unreadOnly,
|
|
||||||
search,
|
|
||||||
pageSize,
|
|
||||||
}: ClientLibraryPageProps) {
|
|
||||||
const { t } = useTranslate();
|
|
||||||
const [library, setLibrary] = useState<KomgaLibrary | null>(null);
|
|
||||||
const [series, setSeries] = useState<LibraryResponse<KomgaSeries> | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const effectivePageSize = pageSize || preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
page: String(currentPage - 1),
|
|
||||||
size: String(effectivePageSize),
|
|
||||||
unread: String(unreadOnly),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (search) {
|
|
||||||
params.append("search", search);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
|
|
||||||
cache: "default", // Utilise le cache HTTP du navigateur
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error?.code || "SERIES_FETCH_ERROR");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
setLibrary(data.library);
|
|
||||||
setSeries(data.series);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({ err }, "Error fetching library series");
|
|
||||||
setError(err instanceof Error ? err.message : "SERIES_FETCH_ERROR");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, [libraryId, currentPage, unreadOnly, search, effectivePageSize]);
|
|
||||||
|
|
||||||
const handleRefresh = async (libraryId: string) => {
|
|
||||||
try {
|
|
||||||
// Invalidate cache via API
|
|
||||||
const cacheResponse = await fetch(`/api/komga/libraries/${libraryId}/series`, {
|
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!cacheResponse.ok) {
|
|
||||||
throw new Error("Error invalidating cache");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recharger les données
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
page: String(currentPage - 1),
|
|
||||||
size: String(effectivePageSize),
|
|
||||||
unread: String(unreadOnly),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (search) {
|
|
||||||
params.append("search", search);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
|
|
||||||
cache: "reload", // Force un nouveau fetch après invalidation
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Error refreshing library");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
setLibrary(data.library);
|
|
||||||
setSeries(data.series);
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Error during refresh:");
|
|
||||||
return { success: false, error: "Error refreshing library" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRetry = async () => {
|
|
||||||
setError(null);
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
page: String(currentPage - 1),
|
|
||||||
size: String(effectivePageSize),
|
|
||||||
unread: String(unreadOnly),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (search) {
|
|
||||||
params.append("search", search);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
|
|
||||||
cache: "reload", // Force un nouveau fetch lors du retry
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error?.code || "SERIES_FETCH_ERROR");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
setLibrary(data.library);
|
|
||||||
setSeries(data.series);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({ err }, "Error fetching library series");
|
|
||||||
setError(err instanceof Error ? err.message : "SERIES_FETCH_ERROR");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const pullToRefresh = usePullToRefresh({
|
|
||||||
onRefresh: async () => {
|
|
||||||
await handleRefresh(libraryId);
|
|
||||||
},
|
|
||||||
enabled: !loading && !error && !!library && !!series,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Header skeleton */}
|
|
||||||
<div className="relative min-h-[200px] md:h-[200px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden mb-8">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-primary/10 to-background" />
|
|
||||||
<div className="relative container mx-auto px-4 py-8 h-full">
|
|
||||||
<div className="flex flex-col md:flex-row gap-6 items-center md:items-start h-full">
|
|
||||||
<OptimizedSkeleton className="w-[120px] h-[120px] rounded-lg" />
|
|
||||||
<div className="flex-1 space-y-3">
|
|
||||||
<OptimizedSkeleton className="h-10 w-64" />
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<OptimizedSkeleton className="h-8 w-32" />
|
|
||||||
<OptimizedSkeleton className="h-8 w-32" />
|
|
||||||
<OptimizedSkeleton className="h-10 w-10 rounded-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Container>
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="flex flex-col gap-4 mb-8">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
|
||||||
<div className="w-full">
|
|
||||||
<OptimizedSkeleton className="h-10 w-full" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<OptimizedSkeleton className="h-10 w-24" />
|
|
||||||
<OptimizedSkeleton className="h-10 w-10 rounded" />
|
|
||||||
<OptimizedSkeleton className="h-10 w-10 rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Grid */}
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
|
|
||||||
{Array.from({ length: effectivePageSize }).map((_, i) => (
|
|
||||||
<OptimizedSkeleton key={i} className="aspect-[2/3] w-full rounded-lg" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
|
|
||||||
<OptimizedSkeleton className="h-5 w-32 order-2 sm:order-1" />
|
|
||||||
<OptimizedSkeleton className="h-10 w-64 order-1 sm:order-2" />
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<Section
|
|
||||||
title={library?.name || t("series.empty")}
|
|
||||||
actions={<RefreshButton libraryId={libraryId} refreshLibrary={handleRefresh} />}
|
|
||||||
/>
|
|
||||||
<ErrorMessage errorCode={error} onRetry={handleRetry} />
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!library || !series) {
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<ErrorMessage errorCode="SERIES_FETCH_ERROR" onRetry={handleRetry} />
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PullToRefreshIndicator
|
|
||||||
isPulling={pullToRefresh.isPulling}
|
|
||||||
isRefreshing={pullToRefresh.isRefreshing}
|
|
||||||
progress={pullToRefresh.progress}
|
|
||||||
canRefresh={pullToRefresh.canRefresh}
|
|
||||||
isHiding={pullToRefresh.isHiding}
|
|
||||||
/>
|
|
||||||
<LibraryHeader
|
|
||||||
library={library}
|
|
||||||
seriesCount={series.totalElements}
|
|
||||||
series={series.content || []}
|
|
||||||
refreshLibrary={handleRefresh}
|
|
||||||
/>
|
|
||||||
<Container>
|
|
||||||
<PaginatedSeriesGrid
|
|
||||||
series={series.content || []}
|
|
||||||
currentPage={currentPage}
|
|
||||||
totalPages={series.totalPages}
|
|
||||||
totalElements={series.totalElements}
|
|
||||||
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
|
||||||
showOnlyUnread={unreadOnly}
|
|
||||||
pageSize={effectivePageSize}
|
|
||||||
/>
|
|
||||||
</Container>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
81
src/app/libraries/[libraryId]/LibraryClientWrapper.tsx
Normal file
81
src/app/libraries/[libraryId]/LibraryClientWrapper.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
|
||||||
|
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
|
||||||
|
import { RefreshProvider } from "@/contexts/RefreshContext";
|
||||||
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
|
|
||||||
|
interface LibraryClientWrapperProps {
|
||||||
|
children: ReactNode;
|
||||||
|
libraryId: string;
|
||||||
|
currentPage: number;
|
||||||
|
unreadOnly: boolean;
|
||||||
|
search?: string;
|
||||||
|
pageSize: number;
|
||||||
|
preferences: UserPreferences;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibraryClientWrapper({
|
||||||
|
children,
|
||||||
|
libraryId,
|
||||||
|
currentPage,
|
||||||
|
unreadOnly,
|
||||||
|
search,
|
||||||
|
pageSize,
|
||||||
|
}: LibraryClientWrapperProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
try {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
|
||||||
|
// Fetch fresh data from network with cache bypass
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(currentPage),
|
||||||
|
size: String(pageSize),
|
||||||
|
...(unreadOnly && { unreadOnly: "true" }),
|
||||||
|
...(search && { search }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "Cache-Control": "no-cache" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to refresh library");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger Next.js revalidation to update the UI
|
||||||
|
router.refresh();
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Error refreshing library" };
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pullToRefresh = usePullToRefresh({
|
||||||
|
onRefresh: async () => {
|
||||||
|
await handleRefresh();
|
||||||
|
},
|
||||||
|
enabled: !isRefreshing,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PullToRefreshIndicator
|
||||||
|
isPulling={pullToRefresh.isPulling}
|
||||||
|
isRefreshing={pullToRefresh.isRefreshing || isRefreshing}
|
||||||
|
progress={pullToRefresh.progress}
|
||||||
|
canRefresh={pullToRefresh.canRefresh}
|
||||||
|
isHiding={pullToRefresh.isHiding}
|
||||||
|
/>
|
||||||
|
<RefreshProvider refreshLibrary={handleRefresh}>{children}</RefreshProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/app/libraries/[libraryId]/LibraryContent.tsx
Normal file
53
src/app/libraries/[libraryId]/LibraryContent.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LibraryHeader } from "@/components/library/LibraryHeader";
|
||||||
|
import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid";
|
||||||
|
import { Container } from "@/components/ui/container";
|
||||||
|
import { useRefresh } from "@/contexts/RefreshContext";
|
||||||
|
import type { KomgaLibrary } from "@/types/komga";
|
||||||
|
import type { LibraryResponse } from "@/types/library";
|
||||||
|
import type { Series } from "@/types/series";
|
||||||
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
|
|
||||||
|
interface LibraryContentProps {
|
||||||
|
library: KomgaLibrary;
|
||||||
|
series: LibraryResponse<Series>;
|
||||||
|
currentPage: number;
|
||||||
|
preferences: UserPreferences;
|
||||||
|
unreadOnly: boolean;
|
||||||
|
search?: string;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibraryContent({
|
||||||
|
library,
|
||||||
|
series,
|
||||||
|
currentPage,
|
||||||
|
preferences,
|
||||||
|
unreadOnly,
|
||||||
|
pageSize,
|
||||||
|
}: LibraryContentProps) {
|
||||||
|
const { refreshLibrary } = useRefresh();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LibraryHeader
|
||||||
|
library={library}
|
||||||
|
seriesCount={series.totalElements}
|
||||||
|
series={series.content || []}
|
||||||
|
refreshLibrary={refreshLibrary || (async () => ({ success: false }))}
|
||||||
|
/>
|
||||||
|
<Container>
|
||||||
|
<PaginatedSeriesGrid
|
||||||
|
series={series.content || []}
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={series.totalPages}
|
||||||
|
totalElements={series.totalElements}
|
||||||
|
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
||||||
|
showOnlyUnread={unreadOnly}
|
||||||
|
pageSize={pageSize}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { PreferencesService } from "@/lib/services/preferences.service";
|
import { PreferencesService } from "@/lib/services/preferences.service";
|
||||||
import { ClientLibraryPage } from "./ClientLibraryPage";
|
import { LibraryService } from "@/lib/services/library.service";
|
||||||
|
import { LibraryClientWrapper } from "./LibraryClientWrapper";
|
||||||
|
import { LibraryContent } from "./LibraryContent";
|
||||||
|
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||||
|
import { AppError } from "@/utils/errors";
|
||||||
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
import type { UserPreferences } from "@/types/preferences";
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -7,6 +12,8 @@ interface PageProps {
|
|||||||
searchParams: Promise<{ page?: string; unread?: string; search?: string; size?: string }>;
|
searchParams: Promise<{ page?: string; unread?: string; search?: string; size?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
export default async function LibraryPage({ params, searchParams }: PageProps) {
|
export default async function LibraryPage({ params, searchParams }: PageProps) {
|
||||||
const libraryId = (await params).libraryId;
|
const libraryId = (await params).libraryId;
|
||||||
const unread = (await searchParams).unread;
|
const unread = (await searchParams).unread;
|
||||||
@@ -19,15 +26,49 @@ export default async function LibraryPage({ params, searchParams }: PageProps) {
|
|||||||
|
|
||||||
// Utiliser le paramètre d'URL s'il existe, sinon utiliser la préférence utilisateur
|
// Utiliser le paramètre d'URL s'il existe, sinon utiliser la préférence utilisateur
|
||||||
const unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread;
|
const unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread;
|
||||||
|
const effectivePageSize = size
|
||||||
|
? parseInt(size)
|
||||||
|
: preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
return (
|
try {
|
||||||
<ClientLibraryPage
|
const [series, library] = await Promise.all([
|
||||||
currentPage={currentPage}
|
LibraryService.getLibrarySeries(
|
||||||
libraryId={libraryId}
|
libraryId,
|
||||||
preferences={preferences}
|
currentPage - 1,
|
||||||
unreadOnly={unreadOnly}
|
effectivePageSize,
|
||||||
search={search}
|
unreadOnly,
|
||||||
pageSize={size ? parseInt(size) : undefined}
|
search
|
||||||
/>
|
),
|
||||||
);
|
LibraryService.getLibrary(libraryId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LibraryClientWrapper
|
||||||
|
libraryId={libraryId}
|
||||||
|
currentPage={currentPage}
|
||||||
|
unreadOnly={unreadOnly}
|
||||||
|
search={search}
|
||||||
|
pageSize={effectivePageSize}
|
||||||
|
preferences={preferences}
|
||||||
|
>
|
||||||
|
<LibraryContent
|
||||||
|
library={library}
|
||||||
|
series={series}
|
||||||
|
currentPage={currentPage}
|
||||||
|
preferences={preferences}
|
||||||
|
unreadOnly={unreadOnly}
|
||||||
|
search={search}
|
||||||
|
pageSize={effectivePageSize}
|
||||||
|
/>
|
||||||
|
</LibraryClientWrapper>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.SERIES.FETCH_ERROR;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<ErrorMessage errorCode={errorCode} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,33 @@
|
|||||||
import { ClientHomePage } from "@/components/home/ClientHomePage";
|
import { HomeService } from "@/lib/services/home.service";
|
||||||
|
import { HomeContent } from "@/components/home/HomeContent";
|
||||||
|
import { HomeClientWrapper } from "@/components/home/HomeClientWrapper";
|
||||||
|
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||||
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
|
import { AppError } from "@/utils/errors";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default async function HomePage() {
|
||||||
return <ClientHomePage />;
|
try {
|
||||||
|
const data = await HomeService.getHomeData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HomeClientWrapper>
|
||||||
|
<HomeContent data={data} />
|
||||||
|
</HomeClientWrapper>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Si la config Komga est manquante, rediriger vers les settings
|
||||||
|
if (error instanceof AppError && error.code === ERROR_CODES.KOMGA.MISSING_CONFIG) {
|
||||||
|
redirect("/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Afficher une erreur pour les autres cas
|
||||||
|
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.KOMGA.SERVER_UNREACHABLE;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<ErrorMessage errorCode={errorCode} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,207 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { PaginatedBookGrid } from "@/components/series/PaginatedBookGrid";
|
|
||||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
|
||||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
|
||||||
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
|
|
||||||
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
|
|
||||||
import { OptimizedSkeleton } from "@/components/skeletons/OptimizedSkeletons";
|
|
||||||
import type { LibraryResponse } from "@/types/library";
|
|
||||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
|
||||||
import type { UserPreferences } from "@/types/preferences";
|
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
|
||||||
import logger from "@/lib/logger";
|
|
||||||
|
|
||||||
interface ClientSeriesPageProps {
|
|
||||||
seriesId: string;
|
|
||||||
currentPage: number;
|
|
||||||
preferences: UserPreferences;
|
|
||||||
unreadOnly: boolean;
|
|
||||||
pageSize?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 20;
|
|
||||||
|
|
||||||
export function ClientSeriesPage({
|
|
||||||
seriesId,
|
|
||||||
currentPage,
|
|
||||||
preferences,
|
|
||||||
unreadOnly,
|
|
||||||
pageSize,
|
|
||||||
}: ClientSeriesPageProps) {
|
|
||||||
const [series, setSeries] = useState<KomgaSeries | null>(null);
|
|
||||||
const [books, setBooks] = useState<LibraryResponse<KomgaBook> | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const effectivePageSize = pageSize || preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
page: String(currentPage - 1),
|
|
||||||
size: String(effectivePageSize),
|
|
||||||
unread: String(unreadOnly),
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
|
|
||||||
cache: "default", // Utilise le cache HTTP du navigateur
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error?.code || ERROR_CODES.BOOK.PAGES_FETCH_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
setSeries(data.series);
|
|
||||||
setBooks(data.books);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({ err }, "Error fetching series books");
|
|
||||||
setError(err instanceof Error ? err.message : ERROR_CODES.BOOK.PAGES_FETCH_ERROR);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, [seriesId, currentPage, unreadOnly, effectivePageSize]);
|
|
||||||
|
|
||||||
const handleRefresh = async (seriesId: string) => {
|
|
||||||
try {
|
|
||||||
// Invalidate cache via API
|
|
||||||
const cacheResponse = await fetch(`/api/komga/series/${seriesId}/books`, {
|
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!cacheResponse.ok) {
|
|
||||||
throw new Error("Erreur lors de l'invalidation du cache");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recharger les données
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
page: String(currentPage - 1),
|
|
||||||
size: String(effectivePageSize),
|
|
||||||
unread: String(unreadOnly),
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
|
|
||||||
cache: "reload", // Force un nouveau fetch après invalidation
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Erreur lors du rafraîchissement de la série");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
setSeries(data.series);
|
|
||||||
setBooks(data.books);
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Erreur lors du rafraîchissement:");
|
|
||||||
return { success: false, error: "Erreur lors du rafraîchissement de la série" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRetry = async () => {
|
|
||||||
setError(null);
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
page: String(currentPage - 1),
|
|
||||||
size: String(effectivePageSize),
|
|
||||||
unread: String(unreadOnly),
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
|
|
||||||
cache: "reload", // Force un nouveau fetch lors du retry
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error?.code || ERROR_CODES.BOOK.PAGES_FETCH_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
setSeries(data.series);
|
|
||||||
setBooks(data.books);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({ err }, "Error fetching series books");
|
|
||||||
setError(err instanceof Error ? err.message : ERROR_CODES.BOOK.PAGES_FETCH_ERROR);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const pullToRefresh = usePullToRefresh({
|
|
||||||
onRefresh: async () => {
|
|
||||||
await handleRefresh(seriesId);
|
|
||||||
},
|
|
||||||
enabled: !loading && !error && !!series && !!books,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="container py-8 space-y-8">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<OptimizedSkeleton className="h-64 w-full rounded" />
|
|
||||||
<OptimizedSkeleton className="h-10 w-64" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
|
||||||
{Array.from({ length: effectivePageSize }).map((_, i) => (
|
|
||||||
<OptimizedSkeleton key={i} className="aspect-[3/4] w-full rounded" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="container py-8 space-y-8">
|
|
||||||
<h1 className="text-3xl font-bold">Série</h1>
|
|
||||||
<ErrorMessage errorCode={error} onRetry={handleRetry} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!series || !books) {
|
|
||||||
return (
|
|
||||||
<div className="container py-8 space-y-8">
|
|
||||||
<h1 className="text-3xl font-bold">Série</h1>
|
|
||||||
<ErrorMessage errorCode={ERROR_CODES.SERIES.FETCH_ERROR} onRetry={handleRetry} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PullToRefreshIndicator
|
|
||||||
isPulling={pullToRefresh.isPulling}
|
|
||||||
isRefreshing={pullToRefresh.isRefreshing}
|
|
||||||
progress={pullToRefresh.progress}
|
|
||||||
canRefresh={pullToRefresh.canRefresh}
|
|
||||||
isHiding={pullToRefresh.isHiding}
|
|
||||||
/>
|
|
||||||
<div className="container">
|
|
||||||
<SeriesHeader series={series} refreshSeries={handleRefresh} />
|
|
||||||
<PaginatedBookGrid
|
|
||||||
books={books.content || []}
|
|
||||||
currentPage={currentPage}
|
|
||||||
totalPages={books.totalPages}
|
|
||||||
totalElements={books.totalElements}
|
|
||||||
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
|
||||||
showOnlyUnread={unreadOnly}
|
|
||||||
onRefresh={() => handleRefresh(seriesId)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
78
src/app/series/[seriesId]/SeriesClientWrapper.tsx
Normal file
78
src/app/series/[seriesId]/SeriesClientWrapper.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
|
||||||
|
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
|
||||||
|
import { RefreshProvider } from "@/contexts/RefreshContext";
|
||||||
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
|
|
||||||
|
interface SeriesClientWrapperProps {
|
||||||
|
children: ReactNode;
|
||||||
|
seriesId: string;
|
||||||
|
currentPage: number;
|
||||||
|
unreadOnly: boolean;
|
||||||
|
pageSize: number;
|
||||||
|
preferences: UserPreferences;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SeriesClientWrapper({
|
||||||
|
children,
|
||||||
|
seriesId,
|
||||||
|
currentPage,
|
||||||
|
unreadOnly,
|
||||||
|
pageSize,
|
||||||
|
}: SeriesClientWrapperProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
try {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
|
||||||
|
// Fetch fresh data from network with cache bypass
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(currentPage),
|
||||||
|
size: String(pageSize),
|
||||||
|
...(unreadOnly && { unreadOnly: "true" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "Cache-Control": "no-cache" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to refresh series");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger Next.js revalidation to update the UI
|
||||||
|
router.refresh();
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Error refreshing series" };
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pullToRefresh = usePullToRefresh({
|
||||||
|
onRefresh: async () => {
|
||||||
|
await handleRefresh();
|
||||||
|
},
|
||||||
|
enabled: !isRefreshing,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PullToRefreshIndicator
|
||||||
|
isPulling={pullToRefresh.isPulling}
|
||||||
|
isRefreshing={pullToRefresh.isRefreshing || isRefreshing}
|
||||||
|
progress={pullToRefresh.progress}
|
||||||
|
canRefresh={pullToRefresh.canRefresh}
|
||||||
|
isHiding={pullToRefresh.isHiding}
|
||||||
|
/>
|
||||||
|
<RefreshProvider refreshSeries={handleRefresh}>{children}</RefreshProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/app/series/[seriesId]/SeriesContent.tsx
Normal file
48
src/app/series/[seriesId]/SeriesContent.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PaginatedBookGrid } from "@/components/series/PaginatedBookGrid";
|
||||||
|
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||||
|
import { Container } from "@/components/ui/container";
|
||||||
|
import { useRefresh } from "@/contexts/RefreshContext";
|
||||||
|
import type { LibraryResponse } from "@/types/library";
|
||||||
|
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||||
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
|
|
||||||
|
interface SeriesContentProps {
|
||||||
|
series: KomgaSeries;
|
||||||
|
books: LibraryResponse<KomgaBook>;
|
||||||
|
currentPage: number;
|
||||||
|
preferences: UserPreferences;
|
||||||
|
unreadOnly: boolean;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SeriesContent({
|
||||||
|
series,
|
||||||
|
books,
|
||||||
|
currentPage,
|
||||||
|
preferences,
|
||||||
|
unreadOnly,
|
||||||
|
}: SeriesContentProps) {
|
||||||
|
const { refreshSeries } = useRefresh();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SeriesHeader
|
||||||
|
series={series}
|
||||||
|
refreshSeries={refreshSeries || (async () => ({ success: false }))}
|
||||||
|
/>
|
||||||
|
<Container>
|
||||||
|
<PaginatedBookGrid
|
||||||
|
books={books.content || []}
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={books.totalPages}
|
||||||
|
totalElements={books.totalElements}
|
||||||
|
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
||||||
|
showOnlyUnread={unreadOnly}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { PreferencesService } from "@/lib/services/preferences.service";
|
import { PreferencesService } from "@/lib/services/preferences.service";
|
||||||
import { ClientSeriesPage } from "./ClientSeriesPage";
|
import { SeriesService } from "@/lib/services/series.service";
|
||||||
|
import { SeriesClientWrapper } from "./SeriesClientWrapper";
|
||||||
|
import { SeriesContent } from "./SeriesContent";
|
||||||
|
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||||
|
import { AppError } from "@/utils/errors";
|
||||||
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
import type { UserPreferences } from "@/types/preferences";
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -7,6 +12,8 @@ interface PageProps {
|
|||||||
searchParams: Promise<{ page?: string; unread?: string; size?: string }>;
|
searchParams: Promise<{ page?: string; unread?: string; size?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
export default async function SeriesPage({ params, searchParams }: PageProps) {
|
export default async function SeriesPage({ params, searchParams }: PageProps) {
|
||||||
const seriesId = (await params).seriesId;
|
const seriesId = (await params).seriesId;
|
||||||
const page = (await searchParams).page;
|
const page = (await searchParams).page;
|
||||||
@@ -18,14 +25,41 @@ export default async function SeriesPage({ params, searchParams }: PageProps) {
|
|||||||
|
|
||||||
// Utiliser le paramètre d'URL s'il existe, sinon utiliser la préférence utilisateur
|
// Utiliser le paramètre d'URL s'il existe, sinon utiliser la préférence utilisateur
|
||||||
const unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread;
|
const unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread;
|
||||||
|
const effectivePageSize = size ? parseInt(size) : preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
return (
|
try {
|
||||||
<ClientSeriesPage
|
const [books, series] = await Promise.all([
|
||||||
seriesId={seriesId}
|
SeriesService.getSeriesBooks(seriesId, currentPage - 1, effectivePageSize, unreadOnly),
|
||||||
currentPage={currentPage}
|
SeriesService.getSeries(seriesId),
|
||||||
preferences={preferences}
|
]);
|
||||||
unreadOnly={unreadOnly}
|
|
||||||
pageSize={size ? parseInt(size) : undefined}
|
return (
|
||||||
/>
|
<SeriesClientWrapper
|
||||||
);
|
seriesId={seriesId}
|
||||||
|
currentPage={currentPage}
|
||||||
|
unreadOnly={unreadOnly}
|
||||||
|
pageSize={effectivePageSize}
|
||||||
|
preferences={preferences}
|
||||||
|
>
|
||||||
|
<SeriesContent
|
||||||
|
series={series}
|
||||||
|
books={books}
|
||||||
|
currentPage={currentPage}
|
||||||
|
preferences={preferences}
|
||||||
|
unreadOnly={unreadOnly}
|
||||||
|
pageSize={effectivePageSize}
|
||||||
|
/>
|
||||||
|
</SeriesClientWrapper>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const errorCode = error instanceof AppError
|
||||||
|
? error.code
|
||||||
|
: ERROR_CODES.BOOK.PAGES_FETCH_ERROR;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<ErrorMessage errorCode={errorCode} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ConfigDBService } from "@/lib/services/config-db.service";
|
import { ConfigDBService } from "@/lib/services/config-db.service";
|
||||||
import { ClientSettings } from "@/components/settings/ClientSettings";
|
import { ClientSettings } from "@/components/settings/ClientSettings";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import type { KomgaConfig, TTLConfig } from "@/types/komga";
|
import type { KomgaConfig } from "@/types/komga";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -13,7 +13,6 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
let config: KomgaConfig | null = null;
|
let config: KomgaConfig | null = null;
|
||||||
let ttlConfig: TTLConfig | null = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Récupérer la configuration Komga
|
// Récupérer la configuration Komga
|
||||||
@@ -27,13 +26,10 @@ export default async function SettingsPage() {
|
|||||||
password: null,
|
password: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer la configuration TTL
|
|
||||||
ttlConfig = await ConfigDBService.getTTLConfig();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Erreur lors de la récupération de la configuration:");
|
logger.error({ err: error }, "Erreur lors de la récupération de la configuration:");
|
||||||
// On ne fait rien si la config n'existe pas, on laissera le composant client gérer l'état initial
|
// On ne fait rien si la config n'existe pas, on laissera le composant client gérer l'état initial
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ClientSettings initialConfig={config} initialTTLConfig={ttlConfig} />;
|
return <ClientSettings initialConfig={config} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,14 +311,15 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
|
|||||||
return (
|
return (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="relative w-12 aspect-[2/3] bg-muted/80 backdrop-blur-md rounded overflow-hidden">
|
<div className="relative w-16 aspect-[2/3] bg-muted rounded overflow-hidden flex-shrink-0">
|
||||||
<Image
|
<Image
|
||||||
src={`/api/komga/images/books/${book.id}/thumbnail`}
|
src={`/api/komga/images/books/${book.id}/thumbnail`}
|
||||||
alt={t("books.coverAlt", { title: book.metadata?.title })}
|
alt={t("books.coverAlt", { title: book.metadata?.title })}
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
fill
|
fill
|
||||||
sizes="48px"
|
sizes="64px"
|
||||||
priority={false}
|
priority={false}
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { HomeContent } from "./HomeContent";
|
|
||||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
|
||||||
import { HomePageSkeleton } from "@/components/skeletons/OptimizedSkeletons";
|
|
||||||
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
|
|
||||||
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
|
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
|
||||||
import type { HomeData } from "@/types/home";
|
|
||||||
import logger from "@/lib/logger";
|
|
||||||
|
|
||||||
export function ClientHomePage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [data, setData] = useState<HomeData | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/komga/home", {
|
|
||||||
cache: "default", // Utilise le cache HTTP du navigateur
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
const errorCode = errorData.error?.code || ERROR_CODES.KOMGA.SERVER_UNREACHABLE;
|
|
||||||
|
|
||||||
// Si la config Komga est manquante, rediriger vers les settings
|
|
||||||
if (errorCode === ERROR_CODES.KOMGA.MISSING_CONFIG) {
|
|
||||||
router.push("/settings");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(errorCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
const homeData = await response.json();
|
|
||||||
setData(homeData);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({ err }, "Error fetching home data");
|
|
||||||
setError(err instanceof Error ? err.message : ERROR_CODES.KOMGA.SERVER_UNREACHABLE);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
|
||||||
try {
|
|
||||||
// Invalider le cache via l'API
|
|
||||||
const deleteResponse = await fetch("/api/komga/home", {
|
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!deleteResponse.ok) {
|
|
||||||
throw new Error("Erreur lors de l'invalidation du cache");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Récupérer les nouvelles données
|
|
||||||
const response = await fetch("/api/komga/home", {
|
|
||||||
cache: "reload", // Force un nouveau fetch après invalidation
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Erreur lors du rafraîchissement de la page d'accueil");
|
|
||||||
}
|
|
||||||
|
|
||||||
const homeData = await response.json();
|
|
||||||
setData(homeData);
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Erreur lors du rafraîchissement:");
|
|
||||||
return { success: false, error: "Erreur lors du rafraîchissement de la page d'accueil" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const pullToRefresh = usePullToRefresh({
|
|
||||||
onRefresh: async () => {
|
|
||||||
await handleRefresh();
|
|
||||||
},
|
|
||||||
enabled: !loading && !error && !!data,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <HomePageSkeleton />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRetry = () => {
|
|
||||||
fetchData();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<main className="container mx-auto px-4 py-8">
|
|
||||||
<ErrorMessage errorCode={error} onRetry={handleRetry} />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return (
|
|
||||||
<main className="container mx-auto px-4 py-8">
|
|
||||||
<ErrorMessage errorCode={ERROR_CODES.KOMGA.SERVER_UNREACHABLE} onRetry={handleRetry} />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PullToRefreshIndicator
|
|
||||||
isPulling={pullToRefresh.isPulling}
|
|
||||||
isRefreshing={pullToRefresh.isRefreshing}
|
|
||||||
progress={pullToRefresh.progress}
|
|
||||||
canRefresh={pullToRefresh.canRefresh}
|
|
||||||
isHiding={pullToRefresh.isHiding}
|
|
||||||
/>
|
|
||||||
<HomeContent data={data} refreshHome={handleRefresh} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
70
src/components/home/HomeClientWrapper.tsx
Normal file
70
src/components/home/HomeClientWrapper.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { RefreshButton } from "@/components/library/RefreshButton";
|
||||||
|
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
|
||||||
|
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
|
||||||
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
|
interface HomeClientWrapperProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomeClientWrapper({ children }: HomeClientWrapperProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslate();
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
try {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
|
||||||
|
// Fetch fresh data from network with cache bypass
|
||||||
|
const response = await fetch("/api/komga/home", {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "Cache-Control": "no-cache" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to refresh home");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger Next.js revalidation to update the UI
|
||||||
|
router.refresh();
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, "Erreur lors du rafraîchissement:");
|
||||||
|
return { success: false, error: "Erreur lors du rafraîchissement de la page d'accueil" };
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pullToRefresh = usePullToRefresh({
|
||||||
|
onRefresh: async () => {
|
||||||
|
await handleRefresh();
|
||||||
|
},
|
||||||
|
enabled: !isRefreshing,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PullToRefreshIndicator
|
||||||
|
isPulling={pullToRefresh.isPulling}
|
||||||
|
isRefreshing={pullToRefresh.isRefreshing || isRefreshing}
|
||||||
|
progress={pullToRefresh.progress}
|
||||||
|
canRefresh={pullToRefresh.canRefresh}
|
||||||
|
isHiding={pullToRefresh.isHiding}
|
||||||
|
/>
|
||||||
|
<main className="container mx-auto px-4 py-8 space-y-12">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-3xl font-bold">{t("home.title")}</h1>
|
||||||
|
<RefreshButton libraryId="home" refreshLibrary={handleRefresh} />
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,122 +1,74 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { HeroSection } from "./HeroSection";
|
|
||||||
import { MediaRow } from "./MediaRow";
|
import { MediaRow } from "./MediaRow";
|
||||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||||
import type { HomeData } from "@/types/home";
|
import type { HomeData } from "@/types/home";
|
||||||
import { RefreshButton } from "@/components/library/RefreshButton";
|
|
||||||
import { History, Sparkles, Clock, LibraryBig, BookOpen } from "lucide-react";
|
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
interface HomeContentProps {
|
interface HomeContentProps {
|
||||||
data: HomeData;
|
data: HomeData;
|
||||||
refreshHome: () => Promise<{ success: boolean; error?: string }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HomeContent({ data, refreshHome }: HomeContentProps) {
|
const optimizeSeriesData = (series: KomgaSeries[]) => {
|
||||||
const { t } = useTranslate();
|
return series.map(({ id, metadata, booksCount, booksReadCount }) => ({
|
||||||
const [showHero, setShowHero] = useState(false);
|
id,
|
||||||
|
metadata: { title: metadata.title },
|
||||||
|
booksCount,
|
||||||
|
booksReadCount,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
// Vérifier si la HeroSection a déjà été affichée
|
const optimizeBookData = (books: KomgaBook[]) => {
|
||||||
useEffect(() => {
|
return books.map(({ id, metadata, readProgress, media }) => ({
|
||||||
const heroShown = localStorage.getItem("heroSectionShown");
|
id,
|
||||||
if (!heroShown && data.ongoing && data.ongoing.length > 0) {
|
metadata: {
|
||||||
setShowHero(true);
|
title: metadata.title,
|
||||||
localStorage.setItem("heroSectionShown", "true");
|
number: metadata.number,
|
||||||
}
|
},
|
||||||
}, [data.ongoing]);
|
readProgress: readProgress || { page: 0 },
|
||||||
|
media,
|
||||||
// Vérification des données pour le debug
|
}));
|
||||||
// logger.info("HomeContent - Données reçues:", {
|
};
|
||||||
// ongoingCount: data.ongoing?.length || 0,
|
|
||||||
// recentlyReadCount: data.recentlyRead?.length || 0,
|
|
||||||
// onDeckCount: data.onDeck?.length || 0,
|
|
||||||
// });
|
|
||||||
|
|
||||||
const optimizeSeriesData = (series: KomgaSeries[]) => {
|
|
||||||
return series.map(({ id, metadata, booksCount, booksReadCount }) => ({
|
|
||||||
id,
|
|
||||||
metadata: { title: metadata.title },
|
|
||||||
booksCount,
|
|
||||||
booksReadCount,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const optimizeHeroSeriesData = (series: KomgaSeries[]) => {
|
|
||||||
return series.map(({ id, metadata, booksCount, booksReadCount }) => ({
|
|
||||||
id,
|
|
||||||
metadata: { title: metadata.title },
|
|
||||||
booksCount,
|
|
||||||
booksReadCount,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const optimizeBookData = (books: KomgaBook[]) => {
|
|
||||||
return books.map(({ id, metadata, readProgress, media }) => ({
|
|
||||||
id,
|
|
||||||
metadata: {
|
|
||||||
title: metadata.title,
|
|
||||||
number: metadata.number,
|
|
||||||
},
|
|
||||||
readProgress: readProgress || { page: 0 },
|
|
||||||
media,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
|
export function HomeContent({ data }: HomeContentProps) {
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8 space-y-12">
|
<div className="space-y-12">
|
||||||
<div className="flex justify-between items-center">
|
{data.ongoing && data.ongoing.length > 0 && (
|
||||||
<h1 className="text-3xl font-bold">{t("home.title")}</h1>
|
<MediaRow
|
||||||
<RefreshButton libraryId="home" refreshLibrary={refreshHome} />
|
titleKey="home.sections.continue_series"
|
||||||
</div>
|
items={optimizeSeriesData(data.ongoing)}
|
||||||
{/* Hero Section - Afficher uniquement si nous avons des séries en cours et si elle n'a jamais été affichée */}
|
iconName="LibraryBig"
|
||||||
{showHero && data.ongoing && data.ongoing.length > 0 && (
|
/>
|
||||||
<HeroSection series={optimizeHeroSeriesData(data.ongoing)} />
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sections de contenu */}
|
{data.ongoingBooks && data.ongoingBooks.length > 0 && (
|
||||||
<div className="space-y-12">
|
<MediaRow
|
||||||
{data.ongoing && data.ongoing.length > 0 && (
|
titleKey="home.sections.continue_reading"
|
||||||
<MediaRow
|
items={optimizeBookData(data.ongoingBooks)}
|
||||||
title={t("home.sections.continue_series")}
|
iconName="BookOpen"
|
||||||
items={optimizeSeriesData(data.ongoing)}
|
/>
|
||||||
icon={LibraryBig}
|
)}
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data.ongoingBooks && data.ongoingBooks.length > 0 && (
|
{data.onDeck && data.onDeck.length > 0 && (
|
||||||
<MediaRow
|
<MediaRow
|
||||||
title={t("home.sections.continue_reading")}
|
titleKey="home.sections.up_next"
|
||||||
items={optimizeBookData(data.ongoingBooks)}
|
items={optimizeBookData(data.onDeck)}
|
||||||
icon={BookOpen}
|
iconName="Clock"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data.onDeck && data.onDeck.length > 0 && (
|
{data.latestSeries && data.latestSeries.length > 0 && (
|
||||||
<MediaRow
|
<MediaRow
|
||||||
title={t("home.sections.up_next")}
|
titleKey="home.sections.latest_series"
|
||||||
items={optimizeBookData(data.onDeck)}
|
items={optimizeSeriesData(data.latestSeries)}
|
||||||
icon={Clock}
|
iconName="Sparkles"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data.latestSeries && data.latestSeries.length > 0 && (
|
{data.recentlyRead && data.recentlyRead.length > 0 && (
|
||||||
<MediaRow
|
<MediaRow
|
||||||
title={t("home.sections.latest_series")}
|
titleKey="home.sections.recently_added"
|
||||||
items={optimizeSeriesData(data.latestSeries)}
|
items={optimizeBookData(data.recentlyRead)}
|
||||||
icon={Sparkles}
|
iconName="History"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{data.recentlyRead && data.recentlyRead.length > 0 && (
|
|
||||||
<MediaRow
|
|
||||||
title={t("home.sections.recently_added")}
|
|
||||||
items={optimizeBookData(data.recentlyRead)}
|
|
||||||
icon={History}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { SeriesCover } from "../ui/series-cover";
|
|||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
import { ScrollContainer } from "@/components/ui/scroll-container";
|
import { ScrollContainer } from "@/components/ui/scroll-container";
|
||||||
import { Section } from "@/components/ui/section";
|
import { Section } from "@/components/ui/section";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import { History, Sparkles, Clock, LibraryBig, BookOpen } from "lucide-react";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -38,14 +38,23 @@ interface OptimizedBook extends BaseItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface MediaRowProps {
|
interface MediaRowProps {
|
||||||
title: string;
|
titleKey: string;
|
||||||
items: (OptimizedSeries | OptimizedBook)[];
|
items: (OptimizedSeries | OptimizedBook)[];
|
||||||
icon?: LucideIcon;
|
iconName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaRow({ title, items, icon }: MediaRowProps) {
|
const iconMap = {
|
||||||
|
LibraryBig,
|
||||||
|
BookOpen,
|
||||||
|
Clock,
|
||||||
|
Sparkles,
|
||||||
|
History,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MediaRow({ titleKey, items, iconName }: MediaRowProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
const icon = iconName ? iconMap[iconName as keyof typeof iconMap] : undefined;
|
||||||
|
|
||||||
const onItemClick = (item: OptimizedSeries | OptimizedBook) => {
|
const onItemClick = (item: OptimizedSeries | OptimizedBook) => {
|
||||||
const path = "booksCount" in item ? `/series/${item.id}` : `/books/${item.id}`;
|
const path = "booksCount" in item ? `/series/${item.id}` : `/books/${item.id}`;
|
||||||
@@ -55,7 +64,7 @@ export function MediaRow({ title, items, icon }: MediaRowProps) {
|
|||||||
if (!items.length) return null;
|
if (!items.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={title} icon={icon}>
|
<Section title={t(titleKey)} icon={icon}>
|
||||||
<ScrollContainer
|
<ScrollContainer
|
||||||
showArrows={true}
|
showArrows={true}
|
||||||
scrollAmount={400}
|
scrollAmount={400}
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ import { Sidebar } from "@/components/layout/Sidebar";
|
|||||||
import { InstallPWA } from "../ui/InstallPWA";
|
import { InstallPWA } from "../ui/InstallPWA";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { registerServiceWorker } from "@/lib/registerSW";
|
|
||||||
import { NetworkStatus } from "../ui/NetworkStatus";
|
import { NetworkStatus } from "../ui/NetworkStatus";
|
||||||
import { usePreferences } from "@/contexts/PreferencesContext";
|
import { usePreferences } from "@/contexts/PreferencesContext";
|
||||||
import { ImageCacheProvider } from "@/contexts/ImageCacheContext";
|
import { ServiceWorkerProvider } from "@/contexts/ServiceWorkerContext";
|
||||||
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
|
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
@@ -136,10 +135,6 @@ export default function ClientLayout({
|
|||||||
};
|
};
|
||||||
}, [isSidebarOpen]);
|
}, [isSidebarOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Enregistrer le service worker
|
|
||||||
registerServiceWorker();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Ne pas afficher le header et la sidebar sur les routes publiques et le reader
|
// Ne pas afficher le header et la sidebar sur les routes publiques et le reader
|
||||||
const isPublicRoute = publicRoutes.includes(pathname) || pathname.startsWith("/books/");
|
const isPublicRoute = publicRoutes.includes(pathname) || pathname.startsWith("/books/");
|
||||||
@@ -152,7 +147,7 @@ export default function ClientLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<ImageCacheProvider>
|
<ServiceWorkerProvider>
|
||||||
{/* Background fixe pour les images et gradients */}
|
{/* Background fixe pour les images et gradients */}
|
||||||
{hasCustomBackground && <div className="fixed inset-0 -z-10" style={backgroundStyle} />}
|
{hasCustomBackground && <div className="fixed inset-0 -z-10" style={backgroundStyle} />}
|
||||||
<div
|
<div
|
||||||
@@ -184,7 +179,7 @@ export default function ClientLayout({
|
|||||||
<Toaster />
|
<Toaster />
|
||||||
<NetworkStatus />
|
<NetworkStatus />
|
||||||
</div>
|
</div>
|
||||||
</ImageCacheProvider>
|
</ServiceWorkerProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { cn } from "@/lib/utils";
|
|||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
|
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
|
||||||
import { usePreferences } from "@/contexts/PreferencesContext";
|
|
||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
import { getErrorMessage } from "@/utils/errors";
|
import { getErrorMessage } from "@/utils/errors";
|
||||||
@@ -44,37 +43,12 @@ export function Sidebar({
|
|||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { preferences } = usePreferences();
|
|
||||||
const [libraries, setLibraries] = useState<KomgaLibrary[]>(initialLibraries || []);
|
const [libraries, setLibraries] = useState<KomgaLibrary[]>(initialLibraries || []);
|
||||||
const [favorites, setFavorites] = useState<KomgaSeries[]>(initialFavorites || []);
|
const [favorites, setFavorites] = useState<KomgaSeries[]>(initialFavorites || []);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const refreshLibraries = useCallback(async () => {
|
|
||||||
setIsRefreshing(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/komga/libraries");
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new AppError(ERROR_CODES.LIBRARY.FETCH_ERROR);
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
setLibraries(data);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Erreur de chargement des bibliothèques:");
|
|
||||||
toast({
|
|
||||||
title: "Erreur",
|
|
||||||
description:
|
|
||||||
error instanceof AppError
|
|
||||||
? error.message
|
|
||||||
: getErrorMessage(ERROR_CODES.LIBRARY.FETCH_ERROR),
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}
|
|
||||||
}, [toast]);
|
|
||||||
|
|
||||||
const refreshFavorites = useCallback(async () => {
|
const refreshFavorites = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const favoritesResponse = await fetch("/api/komga/favorites");
|
const favoritesResponse = await fetch("/api/komga/favorites");
|
||||||
@@ -111,13 +85,6 @@ export function Sidebar({
|
|||||||
}
|
}
|
||||||
}, [toast]);
|
}, [toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (Object.keys(preferences).length > 0) {
|
|
||||||
refreshLibraries();
|
|
||||||
refreshFavorites();
|
|
||||||
}
|
|
||||||
}, [preferences, refreshLibraries, refreshFavorites]);
|
|
||||||
|
|
||||||
// Mettre à jour les favoris quand ils changent
|
// Mettre à jour les favoris quand ils changent
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleFavoritesChange = () => {
|
const handleFavoritesChange = () => {
|
||||||
@@ -133,7 +100,10 @@ export function Sidebar({
|
|||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
await Promise.all([refreshLibraries(), refreshFavorites()]);
|
// Revalider côté serveur via router.refresh()
|
||||||
|
router.refresh();
|
||||||
|
// Petit délai pour laisser le temps au serveur
|
||||||
|
setTimeout(() => setIsRefreshing(false), 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
|
|||||||
@@ -75,8 +75,6 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Prefetch current and next pages
|
// Prefetch current and next pages
|
||||||
// Deduplication in useImageLoader prevents redundant requests
|
|
||||||
// Server queue (RequestQueueService) handles concurrency limits
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Prefetch pages starting from current page
|
// Prefetch pages starting from current page
|
||||||
prefetchPages(currentPage, prefetchCount);
|
prefetchPages(currentPage, prefetchCount);
|
||||||
|
|||||||
@@ -99,8 +99,6 @@ export function useImageLoader({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Prefetch multiple pages starting from a given page
|
// Prefetch multiple pages starting from a given page
|
||||||
// The server-side queue (RequestQueueService) handles concurrency limits
|
|
||||||
// We only deduplicate to avoid redundant HTTP requests
|
|
||||||
const prefetchPages = useCallback(
|
const prefetchPages = useCallback(
|
||||||
async (startPage: number, count: number = prefetchCount) => {
|
async (startPage: number, count: number = prefetchCount) => {
|
||||||
const pagesToPrefetch = [];
|
const pagesToPrefetch = [];
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useTranslate } from "@/hooks/useTranslate";
|
|||||||
import { usePreferences } from "@/contexts/PreferencesContext";
|
import { usePreferences } from "@/contexts/PreferencesContext";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Activity, Shield } from "lucide-react";
|
import { Activity } from "lucide-react";
|
||||||
import { SliderControl } from "@/components/ui/slider-control";
|
import { SliderControl } from "@/components/ui/slider-control";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
@@ -11,25 +11,6 @@ export function AdvancedSettings() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { preferences, updatePreferences } = usePreferences();
|
const { preferences, updatePreferences } = usePreferences();
|
||||||
|
|
||||||
const handleMaxConcurrentChange = async (value: number) => {
|
|
||||||
try {
|
|
||||||
await updatePreferences({
|
|
||||||
komgaMaxConcurrentRequests: value,
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
title: t("settings.title"),
|
|
||||||
description: t("settings.komga.messages.configSaved"),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Erreur:");
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("settings.error.title"),
|
|
||||||
description: t("settings.error.message"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrefetchChange = async (value: number) => {
|
const handlePrefetchChange = async (value: number) => {
|
||||||
try {
|
try {
|
||||||
await updatePreferences({
|
await updatePreferences({
|
||||||
@@ -49,28 +30,6 @@ export function AdvancedSettings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCircuitBreakerChange = async (field: string, value: number) => {
|
|
||||||
try {
|
|
||||||
await updatePreferences({
|
|
||||||
circuitBreakerConfig: {
|
|
||||||
...preferences.circuitBreakerConfig,
|
|
||||||
[field]: value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
title: t("settings.title"),
|
|
||||||
description: t("settings.komga.messages.configSaved"),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Erreur:");
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("settings.error.title"),
|
|
||||||
description: t("settings.error.message"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Performance Settings */}
|
{/* Performance Settings */}
|
||||||
@@ -85,18 +44,6 @@ export function AdvancedSettings() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<SliderControl
|
|
||||||
label={t("settings.advanced.maxConcurrentRequests.label")}
|
|
||||||
value={preferences.komgaMaxConcurrentRequests}
|
|
||||||
min={1}
|
|
||||||
max={10}
|
|
||||||
step={1}
|
|
||||||
description={t("settings.advanced.maxConcurrentRequests.description")}
|
|
||||||
onChange={handleMaxConcurrentChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="border-t" />
|
|
||||||
|
|
||||||
<SliderControl
|
<SliderControl
|
||||||
label={t("settings.advanced.prefetchCount.label")}
|
label={t("settings.advanced.prefetchCount.label")}
|
||||||
value={preferences.readerPrefetchCount}
|
value={preferences.readerPrefetchCount}
|
||||||
@@ -108,54 +55,6 @@ export function AdvancedSettings() {
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Circuit Breaker Configuration */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Shield className="h-5 w-5 text-primary" />
|
|
||||||
<CardTitle className="text-lg">{t("settings.advanced.circuitBreaker.title")}</CardTitle>
|
|
||||||
</div>
|
|
||||||
<CardDescription>{t("settings.advanced.circuitBreaker.description")}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<SliderControl
|
|
||||||
label={t("settings.advanced.circuitBreaker.threshold.label")}
|
|
||||||
value={preferences.circuitBreakerConfig.threshold ?? 5}
|
|
||||||
min={1}
|
|
||||||
max={20}
|
|
||||||
step={1}
|
|
||||||
description={t("settings.advanced.circuitBreaker.threshold.description")}
|
|
||||||
onChange={(value) => handleCircuitBreakerChange("threshold", value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="border-t" />
|
|
||||||
|
|
||||||
<SliderControl
|
|
||||||
label={t("settings.advanced.circuitBreaker.timeout.label")}
|
|
||||||
value={preferences.circuitBreakerConfig.timeout ?? 30000}
|
|
||||||
min={1000}
|
|
||||||
max={120000}
|
|
||||||
step={1000}
|
|
||||||
description={t("settings.advanced.circuitBreaker.timeout.description")}
|
|
||||||
onChange={(value) => handleCircuitBreakerChange("timeout", value)}
|
|
||||||
formatValue={(value) => `${value / 1000}s`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="border-t" />
|
|
||||||
|
|
||||||
<SliderControl
|
|
||||||
label={t("settings.advanced.circuitBreaker.resetTimeout.label")}
|
|
||||||
value={preferences.circuitBreakerConfig.resetTimeout ?? 60000}
|
|
||||||
min={10000}
|
|
||||||
max={600000}
|
|
||||||
step={1000}
|
|
||||||
description={t("settings.advanced.circuitBreaker.resetTimeout.description")}
|
|
||||||
onChange={(value) => handleCircuitBreakerChange("resetTimeout", value)}
|
|
||||||
formatValue={(value) => `${value / 1000}s`}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
|
||||||
import { usePreferences } from "@/contexts/PreferencesContext";
|
|
||||||
import logger from "@/lib/logger";
|
|
||||||
|
|
||||||
export function CacheModeSwitch() {
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { preferences, updatePreferences } = usePreferences();
|
|
||||||
|
|
||||||
const handleToggle = async (checked: boolean) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
// Mettre à jour les préférences
|
|
||||||
await updatePreferences({ cacheMode: checked ? "memory" : "file" });
|
|
||||||
|
|
||||||
// Mettre à jour le mode de cache côté serveur
|
|
||||||
const res = await fetch("/api/komga/cache/mode", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ mode: checked ? "memory" : "file" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Mode de cache modifié",
|
|
||||||
description: `Le cache est maintenant en mode ${checked ? "mémoire" : "fichier"}`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Erreur lors de la modification du mode de cache:");
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Erreur",
|
|
||||||
description: "Impossible de modifier le mode de cache",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Switch
|
|
||||||
id="cache-mode"
|
|
||||||
checked={preferences.cacheMode === "memory"}
|
|
||||||
onCheckedChange={handleToggle}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="cache-mode" className="text-sm text-muted-foreground">
|
|
||||||
Cache en mémoire {isLoading && "(chargement...)"}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { KomgaConfig, TTLConfigData } from "@/types/komga";
|
import type { KomgaConfig } from "@/types/komga";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
import { DisplaySettings } from "./DisplaySettings";
|
import { DisplaySettings } from "./DisplaySettings";
|
||||||
import { KomgaSettings } from "./KomgaSettings";
|
import { KomgaSettings } from "./KomgaSettings";
|
||||||
import { CacheSettings } from "./CacheSettings";
|
|
||||||
import { BackgroundSettings } from "./BackgroundSettings";
|
import { BackgroundSettings } from "./BackgroundSettings";
|
||||||
import { AdvancedSettings } from "./AdvancedSettings";
|
import { AdvancedSettings } from "./AdvancedSettings";
|
||||||
|
import { CacheSettings } from "./CacheSettings";
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
import { Monitor, Network, HardDrive } from "lucide-react";
|
import { Monitor, Network } from "lucide-react";
|
||||||
|
|
||||||
interface ClientSettingsProps {
|
interface ClientSettingsProps {
|
||||||
initialConfig: KomgaConfig | null;
|
initialConfig: KomgaConfig | null;
|
||||||
initialTTLConfig: TTLConfigData | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClientSettings({ initialConfig, initialTTLConfig }: ClientSettingsProps) {
|
export function ClientSettings({ initialConfig }: ClientSettingsProps) {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -23,7 +22,7 @@ export function ClientSettings({ initialConfig, initialTTLConfig }: ClientSettin
|
|||||||
<h1 className="text-3xl font-bold">{t("settings.title")}</h1>
|
<h1 className="text-3xl font-bold">{t("settings.title")}</h1>
|
||||||
|
|
||||||
<Tabs defaultValue="display" className="w-full">
|
<Tabs defaultValue="display" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="display" className="flex items-center gap-2">
|
<TabsTrigger value="display" className="flex items-center gap-2">
|
||||||
<Monitor className="h-4 w-4" />
|
<Monitor className="h-4 w-4" />
|
||||||
{t("settings.tabs.display")}
|
{t("settings.tabs.display")}
|
||||||
@@ -32,10 +31,6 @@ export function ClientSettings({ initialConfig, initialTTLConfig }: ClientSettin
|
|||||||
<Network className="h-4 w-4" />
|
<Network className="h-4 w-4" />
|
||||||
{t("settings.tabs.connection")}
|
{t("settings.tabs.connection")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="cache" className="flex items-center gap-2">
|
|
||||||
<HardDrive className="h-4 w-4" />
|
|
||||||
{t("settings.tabs.cache")}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="display" className="mt-6 space-y-6">
|
<TabsContent value="display" className="mt-6 space-y-6">
|
||||||
@@ -46,10 +41,7 @@ export function ClientSettings({ initialConfig, initialTTLConfig }: ClientSettin
|
|||||||
<TabsContent value="connection" className="mt-6 space-y-6">
|
<TabsContent value="connection" className="mt-6 space-y-6">
|
||||||
<KomgaSettings initialConfig={initialConfig} />
|
<KomgaSettings initialConfig={initialConfig} />
|
||||||
<AdvancedSettings />
|
<AdvancedSettings />
|
||||||
</TabsContent>
|
<CacheSettings />
|
||||||
|
|
||||||
<TabsContent value="cache" className="mt-6 space-y-6">
|
|
||||||
<CacheSettings initialTTLConfig={initialTTLConfig} />
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { CoverClient } from "./cover-client";
|
|||||||
import { ProgressBar } from "./progress-bar";
|
import { ProgressBar } from "./progress-bar";
|
||||||
import type { BookCoverProps } from "./cover-utils";
|
import type { BookCoverProps } from "./cover-utils";
|
||||||
import { getImageUrl } from "@/lib/utils/image-url";
|
import { getImageUrl } from "@/lib/utils/image-url";
|
||||||
import { useImageUrl } from "@/hooks/useImageUrl";
|
|
||||||
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
|
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
|
||||||
import { MarkAsReadButton } from "./mark-as-read-button";
|
import { MarkAsReadButton } from "./mark-as-read-button";
|
||||||
import { MarkAsUnreadButton } from "./mark-as-unread-button";
|
import { MarkAsUnreadButton } from "./mark-as-unread-button";
|
||||||
@@ -63,8 +62,7 @@ export function BookCover({
|
|||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const { isAccessible } = useBookOfflineStatus(book.id);
|
const { isAccessible } = useBookOfflineStatus(book.id);
|
||||||
|
|
||||||
const baseUrl = getImageUrl("book", book.id);
|
const imageUrl = getImageUrl("book", book.id);
|
||||||
const imageUrl = useImageUrl(baseUrl);
|
|
||||||
const isCompleted = book.readProgress?.completed || false;
|
const isCompleted = book.readProgress?.completed || false;
|
||||||
|
|
||||||
const currentPage = ClientOfflineBookService.getCurrentPage(book);
|
const currentPage = ClientOfflineBookService.getCurrentPage(book);
|
||||||
|
|||||||
12
src/components/ui/collapsible.tsx
Normal file
12
src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||||
|
|
||||||
|
const Collapsible = CollapsiblePrimitive.Root;
|
||||||
|
|
||||||
|
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||||
|
|
||||||
|
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||||
|
|
||||||
47
src/components/ui/scroll-area.tsx
Normal file
47
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative overflow-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
));
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none transition-colors",
|
||||||
|
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||||
|
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
));
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar };
|
||||||
|
|
||||||
@@ -4,7 +4,6 @@ import { CoverClient } from "./cover-client";
|
|||||||
import { ProgressBar } from "./progress-bar";
|
import { ProgressBar } from "./progress-bar";
|
||||||
import type { SeriesCoverProps } from "./cover-utils";
|
import type { SeriesCoverProps } from "./cover-utils";
|
||||||
import { getImageUrl } from "@/lib/utils/image-url";
|
import { getImageUrl } from "@/lib/utils/image-url";
|
||||||
import { useImageUrl } from "@/hooks/useImageUrl";
|
|
||||||
|
|
||||||
export function SeriesCover({
|
export function SeriesCover({
|
||||||
series,
|
series,
|
||||||
@@ -12,8 +11,7 @@ export function SeriesCover({
|
|||||||
className,
|
className,
|
||||||
showProgressUi = true,
|
showProgressUi = true,
|
||||||
}: SeriesCoverProps) {
|
}: SeriesCoverProps) {
|
||||||
const baseUrl = getImageUrl("series", series.id);
|
const imageUrl = getImageUrl("series", series.id);
|
||||||
const imageUrl = useImageUrl(baseUrl);
|
|
||||||
const isCompleted = series.booksCount === series.booksReadCount;
|
const isCompleted = series.booksCount === series.booksReadCount;
|
||||||
|
|
||||||
const readBooks = series.booksReadCount;
|
const readBooks = series.booksReadCount;
|
||||||
|
|||||||
@@ -63,16 +63,6 @@ export const ERROR_CODES = {
|
|||||||
UPDATE_ERROR: "PREFERENCES_UPDATE_ERROR",
|
UPDATE_ERROR: "PREFERENCES_UPDATE_ERROR",
|
||||||
CONTEXT_ERROR: "PREFERENCES_CONTEXT_ERROR",
|
CONTEXT_ERROR: "PREFERENCES_CONTEXT_ERROR",
|
||||||
},
|
},
|
||||||
CACHE: {
|
|
||||||
DELETE_ERROR: "CACHE_DELETE_ERROR",
|
|
||||||
SAVE_ERROR: "CACHE_SAVE_ERROR",
|
|
||||||
LOAD_ERROR: "CACHE_LOAD_ERROR",
|
|
||||||
CLEAR_ERROR: "CACHE_CLEAR_ERROR",
|
|
||||||
MODE_FETCH_ERROR: "CACHE_MODE_FETCH_ERROR",
|
|
||||||
MODE_UPDATE_ERROR: "CACHE_MODE_UPDATE_ERROR",
|
|
||||||
INVALID_MODE: "CACHE_INVALID_MODE",
|
|
||||||
SIZE_FETCH_ERROR: "CACHE_SIZE_FETCH_ERROR",
|
|
||||||
},
|
|
||||||
UI: {
|
UI: {
|
||||||
TABS_TRIGGER_ERROR: "UI_TABS_TRIGGER_ERROR",
|
TABS_TRIGGER_ERROR: "UI_TABS_TRIGGER_ERROR",
|
||||||
TABS_CONTENT_ERROR: "UI_TABS_CONTENT_ERROR",
|
TABS_CONTENT_ERROR: "UI_TABS_CONTENT_ERROR",
|
||||||
|
|||||||
@@ -60,16 +60,6 @@ export const ERROR_MESSAGES: Record<string, string> = {
|
|||||||
[ERROR_CODES.PREFERENCES.CONTEXT_ERROR]:
|
[ERROR_CODES.PREFERENCES.CONTEXT_ERROR]:
|
||||||
"🔄 usePreferences must be used within a PreferencesProvider",
|
"🔄 usePreferences must be used within a PreferencesProvider",
|
||||||
|
|
||||||
// Cache
|
|
||||||
[ERROR_CODES.CACHE.DELETE_ERROR]: "🗑️ Error deleting cache",
|
|
||||||
[ERROR_CODES.CACHE.SAVE_ERROR]: "💾 Error saving to cache",
|
|
||||||
[ERROR_CODES.CACHE.LOAD_ERROR]: "📂 Error loading from cache",
|
|
||||||
[ERROR_CODES.CACHE.CLEAR_ERROR]: "🧹 Error clearing cache completely",
|
|
||||||
[ERROR_CODES.CACHE.MODE_FETCH_ERROR]: "⚙️ Error fetching cache mode",
|
|
||||||
[ERROR_CODES.CACHE.MODE_UPDATE_ERROR]: "⚙️ Error updating cache mode",
|
|
||||||
[ERROR_CODES.CACHE.INVALID_MODE]: "⚠️ Invalid cache mode. Must be 'file' or 'memory'",
|
|
||||||
[ERROR_CODES.CACHE.SIZE_FETCH_ERROR]: "📊 Error fetching cache size",
|
|
||||||
|
|
||||||
// UI
|
// UI
|
||||||
[ERROR_CODES.UI.TABS_TRIGGER_ERROR]: "🔄 TabsTrigger must be used within a Tabs component",
|
[ERROR_CODES.UI.TABS_TRIGGER_ERROR]: "🔄 TabsTrigger must be used within a Tabs component",
|
||||||
[ERROR_CODES.UI.TABS_CONTENT_ERROR]: "🔄 TabsContent must be used within a Tabs component",
|
[ERROR_CODES.UI.TABS_CONTENT_ERROR]: "🔄 TabsContent must be used within a Tabs component",
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useCallback, useEffect } from "react";
|
|
||||||
import logger from "@/lib/logger";
|
|
||||||
|
|
||||||
interface ImageCacheContextType {
|
|
||||||
cacheVersion: string;
|
|
||||||
flushImageCache: () => void;
|
|
||||||
getImageUrl: (baseUrl: string) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ImageCacheContext = createContext<ImageCacheContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
export function ImageCacheProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const [cacheVersion, setCacheVersion] = useState<string>("");
|
|
||||||
|
|
||||||
// Initialiser la version depuis localStorage au montage
|
|
||||||
useEffect(() => {
|
|
||||||
const storedVersion = localStorage.getItem("imageCacheVersion");
|
|
||||||
if (storedVersion) {
|
|
||||||
setCacheVersion(storedVersion);
|
|
||||||
} else {
|
|
||||||
const newVersion = Date.now().toString();
|
|
||||||
setCacheVersion(newVersion);
|
|
||||||
localStorage.setItem("imageCacheVersion", newVersion);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const flushImageCache = useCallback(() => {
|
|
||||||
const newVersion = Date.now().toString();
|
|
||||||
setCacheVersion(newVersion);
|
|
||||||
localStorage.setItem("imageCacheVersion", newVersion);
|
|
||||||
logger.info(`🗑️ Image cache flushed - new version: ${newVersion}`);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getImageUrl = useCallback(
|
|
||||||
(baseUrl: string) => {
|
|
||||||
if (!cacheVersion) return baseUrl;
|
|
||||||
const separator = baseUrl.includes("?") ? "&" : "?";
|
|
||||||
return `${baseUrl}${separator}v=${cacheVersion}`;
|
|
||||||
},
|
|
||||||
[cacheVersion]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ImageCacheContext.Provider value={{ cacheVersion, flushImageCache, getImageUrl }}>
|
|
||||||
{children}
|
|
||||||
</ImageCacheContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useImageCache() {
|
|
||||||
const context = useContext(ImageCacheContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error("useImageCache must be used within an ImageCacheProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@@ -16,6 +16,10 @@ interface PreferencesContextType {
|
|||||||
|
|
||||||
const PreferencesContext = createContext<PreferencesContextType | undefined>(undefined);
|
const PreferencesContext = createContext<PreferencesContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
// Module-level flag to prevent duplicate fetches (survives StrictMode remounts)
|
||||||
|
let preferencesFetchInProgress = false;
|
||||||
|
let preferencesFetched = false;
|
||||||
|
|
||||||
export function PreferencesProvider({
|
export function PreferencesProvider({
|
||||||
children,
|
children,
|
||||||
initialPreferences,
|
initialPreferences,
|
||||||
@@ -29,7 +33,17 @@ export function PreferencesProvider({
|
|||||||
);
|
);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Check if we have valid initial preferences from server
|
||||||
|
const hasValidInitialPreferences =
|
||||||
|
initialPreferences && Object.keys(initialPreferences).length > 0;
|
||||||
|
|
||||||
const fetchPreferences = useCallback(async () => {
|
const fetchPreferences = useCallback(async () => {
|
||||||
|
// Prevent concurrent fetches
|
||||||
|
if (preferencesFetchInProgress || preferencesFetched) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
preferencesFetchInProgress = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/preferences");
|
const response = await fetch("/api/preferences");
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -45,25 +59,30 @@ export function PreferencesProvider({
|
|||||||
viewMode: data.displayMode?.viewMode || defaultPreferences.displayMode.viewMode,
|
viewMode: data.displayMode?.viewMode || defaultPreferences.displayMode.viewMode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
preferencesFetched = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Erreur lors de la récupération des préférences");
|
logger.error({ err: error }, "Erreur lors de la récupération des préférences");
|
||||||
setPreferences(defaultPreferences);
|
setPreferences(defaultPreferences);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
preferencesFetchInProgress = false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Recharger les préférences quand la session change (connexion/déconnexion)
|
|
||||||
if (status === "authenticated") {
|
if (status === "authenticated") {
|
||||||
// Toujours recharger depuis l'API pour avoir les dernières valeurs
|
// Skip refetch if we already have valid initial preferences from server
|
||||||
// même si on a des initialPreferences (qui peuvent être en cache)
|
if (hasValidInitialPreferences) {
|
||||||
|
preferencesFetched = true; // Mark as fetched since we have server data
|
||||||
|
return;
|
||||||
|
}
|
||||||
fetchPreferences();
|
fetchPreferences();
|
||||||
} else if (status === "unauthenticated") {
|
} else if (status === "unauthenticated") {
|
||||||
// Réinitialiser aux préférences par défaut quand l'utilisateur se déconnecte
|
// Reset to defaults when user logs out
|
||||||
setPreferences(defaultPreferences);
|
setPreferences(defaultPreferences);
|
||||||
|
preferencesFetched = false; // Allow refetch on next login
|
||||||
}
|
}
|
||||||
}, [status, fetchPreferences]);
|
}, [status, fetchPreferences, hasValidInitialPreferences]);
|
||||||
|
|
||||||
const updatePreferences = useCallback(async (newPreferences: Partial<UserPreferences>) => {
|
const updatePreferences = useCallback(async (newPreferences: Partial<UserPreferences>) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
31
src/contexts/RefreshContext.tsx
Normal file
31
src/contexts/RefreshContext.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, type ReactNode } from "react";
|
||||||
|
|
||||||
|
interface RefreshContextType {
|
||||||
|
refreshLibrary?: (libraryId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
refreshSeries?: (seriesId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RefreshContext = createContext<RefreshContextType>({});
|
||||||
|
|
||||||
|
export function RefreshProvider({
|
||||||
|
children,
|
||||||
|
refreshLibrary,
|
||||||
|
refreshSeries,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
refreshLibrary?: (libraryId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
refreshSeries?: (seriesId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<RefreshContext.Provider value={{ refreshLibrary, refreshSeries }}>
|
||||||
|
{children}
|
||||||
|
</RefreshContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRefresh() {
|
||||||
|
return useContext(RefreshContext);
|
||||||
|
}
|
||||||
|
|
||||||
368
src/contexts/ServiceWorkerContext.tsx
Normal file
368
src/contexts/ServiceWorkerContext.tsx
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState, useCallback, useRef } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { registerServiceWorker, unregisterServiceWorker } from "@/lib/registerSW";
|
||||||
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
|
interface CacheStats {
|
||||||
|
static: { size: number; entries: number };
|
||||||
|
pages: { size: number; entries: number };
|
||||||
|
api: { size: number; entries: number };
|
||||||
|
images: { size: number; entries: number };
|
||||||
|
books: { size: number; entries: number };
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CacheUpdate {
|
||||||
|
url: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CacheType = "all" | "static" | "pages" | "api" | "images" | "books";
|
||||||
|
|
||||||
|
interface ServiceWorkerContextValue {
|
||||||
|
isSupported: boolean;
|
||||||
|
isReady: boolean;
|
||||||
|
version: string | null;
|
||||||
|
hasNewVersion: boolean;
|
||||||
|
cacheUpdates: CacheUpdate[];
|
||||||
|
clearCacheUpdate: (url: string) => void;
|
||||||
|
clearAllCacheUpdates: () => void;
|
||||||
|
getCacheStats: () => Promise<CacheStats | null>;
|
||||||
|
getCacheEntries: (cacheType: CacheType) => Promise<CacheEntry[] | null>;
|
||||||
|
clearCache: (cacheType?: CacheType) => Promise<boolean>;
|
||||||
|
skipWaiting: () => void;
|
||||||
|
reloadForUpdate: () => void;
|
||||||
|
reinstallServiceWorker: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ServiceWorkerContext = createContext<ServiceWorkerContextValue | null>(null);
|
||||||
|
|
||||||
|
export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [isSupported, setIsSupported] = useState(false);
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [version, setVersion] = useState<string | null>(null);
|
||||||
|
const [hasNewVersion, setHasNewVersion] = useState(false);
|
||||||
|
const [cacheUpdates, setCacheUpdates] = useState<CacheUpdate[]>([]);
|
||||||
|
const pendingRequests = useRef<Map<string, (value: unknown) => void>>(new Map());
|
||||||
|
const waitingWorkerRef = useRef<ServiceWorker | null>(null);
|
||||||
|
|
||||||
|
// Handle messages from service worker
|
||||||
|
const handleMessage = useCallback((event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
// Ignore messages without proper data structure
|
||||||
|
if (!event.data || typeof event.data !== "object") return;
|
||||||
|
|
||||||
|
// Only handle messages from our service worker (check for known message types)
|
||||||
|
const knownTypes = [
|
||||||
|
"SW_ACTIVATED",
|
||||||
|
"SW_VERSION",
|
||||||
|
"CACHE_UPDATED",
|
||||||
|
"CACHE_STATS",
|
||||||
|
"CACHE_STATS_ERROR",
|
||||||
|
"CACHE_CLEARED",
|
||||||
|
"CACHE_CLEAR_ERROR",
|
||||||
|
"CACHE_ENTRIES",
|
||||||
|
"CACHE_ENTRIES_ERROR",
|
||||||
|
];
|
||||||
|
|
||||||
|
const type = event.data.type;
|
||||||
|
if (typeof type !== "string" || !knownTypes.includes(type)) return;
|
||||||
|
|
||||||
|
const payload = event.data.payload;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "SW_ACTIVATED":
|
||||||
|
setIsReady(true);
|
||||||
|
setVersion(payload?.version || null);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "SW_VERSION":
|
||||||
|
setVersion(payload?.version || null);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "CACHE_UPDATED": {
|
||||||
|
const url = typeof payload?.url === "string" ? payload.url : null;
|
||||||
|
const timestamp = typeof payload?.timestamp === "number" ? payload.timestamp : Date.now();
|
||||||
|
if (url) {
|
||||||
|
setCacheUpdates((prev) => {
|
||||||
|
// Avoid duplicates for the same URL within 1 second
|
||||||
|
const existing = prev.find((u) => u.url === url && Date.now() - u.timestamp < 1000);
|
||||||
|
if (existing) return prev;
|
||||||
|
return [...prev, { url, timestamp }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "CACHE_STATS":
|
||||||
|
const statsResolver = pendingRequests.current.get("CACHE_STATS");
|
||||||
|
if (statsResolver) {
|
||||||
|
statsResolver(payload);
|
||||||
|
pendingRequests.current.delete("CACHE_STATS");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "CACHE_STATS_ERROR":
|
||||||
|
const statsErrorResolver = pendingRequests.current.get("CACHE_STATS");
|
||||||
|
if (statsErrorResolver) {
|
||||||
|
statsErrorResolver(null);
|
||||||
|
pendingRequests.current.delete("CACHE_STATS");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "CACHE_CLEARED":
|
||||||
|
const clearResolver = pendingRequests.current.get("CACHE_CLEARED");
|
||||||
|
if (clearResolver) {
|
||||||
|
clearResolver(true);
|
||||||
|
pendingRequests.current.delete("CACHE_CLEARED");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "CACHE_CLEAR_ERROR":
|
||||||
|
const clearErrorResolver = pendingRequests.current.get("CACHE_CLEARED");
|
||||||
|
if (clearErrorResolver) {
|
||||||
|
clearErrorResolver(false);
|
||||||
|
pendingRequests.current.delete("CACHE_CLEARED");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "CACHE_ENTRIES": {
|
||||||
|
const entriesResolver = pendingRequests.current.get("CACHE_ENTRIES");
|
||||||
|
if (entriesResolver) {
|
||||||
|
entriesResolver(payload?.entries || null);
|
||||||
|
pendingRequests.current.delete("CACHE_ENTRIES");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "CACHE_ENTRIES_ERROR": {
|
||||||
|
const entriesErrorResolver = pendingRequests.current.get("CACHE_ENTRIES");
|
||||||
|
if (entriesErrorResolver) {
|
||||||
|
entriesErrorResolver(null);
|
||||||
|
pendingRequests.current.delete("CACHE_ENTRIES");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Ignore unknown message types
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Silently ignore message handling errors to prevent app crashes
|
||||||
|
// This can happen with malformed messages or during SW reinstall
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn("[SW Context] Error handling message:", error, event.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize service worker communication
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
||||||
|
setIsSupported(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSupported(true);
|
||||||
|
|
||||||
|
// Register service worker
|
||||||
|
registerServiceWorker({
|
||||||
|
onSuccess: (registration) => {
|
||||||
|
logger.info({ scope: registration.scope }, "Service worker registered");
|
||||||
|
setIsReady(true);
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUpdate: (registration) => {
|
||||||
|
logger.info("New service worker version available");
|
||||||
|
setHasNewVersion(true);
|
||||||
|
waitingWorkerRef.current = registration.waiting;
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
logger.error({ err: error }, "Service worker registration failed");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for messages
|
||||||
|
navigator.serviceWorker.addEventListener("message", handleMessage);
|
||||||
|
|
||||||
|
// Check if already controlled
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
setIsReady(true);
|
||||||
|
// Request version
|
||||||
|
navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for controller changes
|
||||||
|
const handleControllerChange = () => {
|
||||||
|
setIsReady(true);
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
navigator.serviceWorker.addEventListener("controllerchange", handleControllerChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
navigator.serviceWorker.removeEventListener("message", handleMessage);
|
||||||
|
navigator.serviceWorker.removeEventListener("controllerchange", handleControllerChange);
|
||||||
|
};
|
||||||
|
}, [handleMessage]);
|
||||||
|
|
||||||
|
const clearCacheUpdate = useCallback((url: string) => {
|
||||||
|
setCacheUpdates((prev) => prev.filter((u) => u.url !== url));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearAllCacheUpdates = useCallback(() => {
|
||||||
|
setCacheUpdates([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getCacheStats = useCallback(async (): Promise<CacheStats | null> => {
|
||||||
|
if (!navigator.serviceWorker.controller) return null;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
pendingRequests.current.set("CACHE_STATS", resolve as (value: unknown) => void);
|
||||||
|
navigator.serviceWorker.controller!.postMessage({ type: "GET_CACHE_STATS" });
|
||||||
|
|
||||||
|
// Timeout after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (pendingRequests.current.has("CACHE_STATS")) {
|
||||||
|
pendingRequests.current.delete("CACHE_STATS");
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getCacheEntries = useCallback(
|
||||||
|
async (cacheType: CacheType): Promise<CacheEntry[] | null> => {
|
||||||
|
if (!navigator.serviceWorker.controller) return null;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
pendingRequests.current.set("CACHE_ENTRIES", resolve as (value: unknown) => void);
|
||||||
|
navigator.serviceWorker.controller!.postMessage({
|
||||||
|
type: "GET_CACHE_ENTRIES",
|
||||||
|
payload: { cacheType },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timeout after 10 seconds (can be slow for large caches)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (pendingRequests.current.has("CACHE_ENTRIES")) {
|
||||||
|
pendingRequests.current.delete("CACHE_ENTRIES");
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearCache = useCallback(async (cacheType: CacheType = "all"): Promise<boolean> => {
|
||||||
|
if (!navigator.serviceWorker.controller) return false;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
pendingRequests.current.set("CACHE_CLEARED", resolve as (value: unknown) => void);
|
||||||
|
navigator.serviceWorker.controller!.postMessage({
|
||||||
|
type: "CLEAR_CACHE",
|
||||||
|
payload: { cacheType },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timeout after 10 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (pendingRequests.current.has("CACHE_CLEARED")) {
|
||||||
|
pendingRequests.current.delete("CACHE_CLEARED");
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const skipWaiting = useCallback(() => {
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
navigator.serviceWorker.controller.postMessage({ type: "SKIP_WAITING" });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reloadForUpdate = useCallback(() => {
|
||||||
|
if (waitingWorkerRef.current) {
|
||||||
|
waitingWorkerRef.current.postMessage({ type: "SKIP_WAITING" });
|
||||||
|
setHasNewVersion(false);
|
||||||
|
// Reload will happen automatically when new SW takes control
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reinstallServiceWorker = useCallback(async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
// Unregister all service workers
|
||||||
|
await unregisterServiceWorker();
|
||||||
|
setIsReady(false);
|
||||||
|
setVersion(null);
|
||||||
|
|
||||||
|
// Re-register
|
||||||
|
const registration = await registerServiceWorker({
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsReady(true);
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
logger.error({ err: error }, "Service worker re-registration failed");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (registration) {
|
||||||
|
// Force update check
|
||||||
|
await registration.update();
|
||||||
|
// Reload page to ensure new SW takes control
|
||||||
|
window.location.reload();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, "Failed to reinstall service worker");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ServiceWorkerContext.Provider
|
||||||
|
value={{
|
||||||
|
isSupported,
|
||||||
|
isReady,
|
||||||
|
version,
|
||||||
|
hasNewVersion,
|
||||||
|
cacheUpdates,
|
||||||
|
clearCacheUpdate,
|
||||||
|
clearAllCacheUpdates,
|
||||||
|
getCacheStats,
|
||||||
|
getCacheEntries,
|
||||||
|
clearCache,
|
||||||
|
skipWaiting,
|
||||||
|
reloadForUpdate,
|
||||||
|
reinstallServiceWorker,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ServiceWorkerContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServiceWorker() {
|
||||||
|
const context = useContext(ServiceWorkerContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useServiceWorker must be used within a ServiceWorkerProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
98
src/hooks/useCacheUpdate.ts
Normal file
98
src/hooks/useCacheUpdate.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useCallback } from "react";
|
||||||
|
import { useServiceWorker } from "@/contexts/ServiceWorkerContext";
|
||||||
|
|
||||||
|
interface UseCacheUpdateOptions {
|
||||||
|
/** Match exact URL or use pattern matching */
|
||||||
|
exact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseCacheUpdateResult {
|
||||||
|
/** Whether there's a pending update for this URL pattern */
|
||||||
|
hasUpdate: boolean;
|
||||||
|
/** Timestamp of the last update */
|
||||||
|
lastUpdateTime: number | null;
|
||||||
|
/** Clear the update notification for this URL */
|
||||||
|
clearUpdate: () => void;
|
||||||
|
/** All matching updates */
|
||||||
|
updates: Array<{ url: string; timestamp: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to listen for cache updates from the service worker
|
||||||
|
*
|
||||||
|
* @param urlPattern - URL or pattern to match against cache updates
|
||||||
|
* @param options - Options for matching behavior
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Match exact URL
|
||||||
|
* const { hasUpdate, clearUpdate } = useCacheUpdate('/api/komga/home', { exact: true });
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Match URL pattern (contains)
|
||||||
|
* const { hasUpdate, clearUpdate } = useCacheUpdate('/api/komga/series');
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Use in component
|
||||||
|
* useEffect(() => {
|
||||||
|
* if (hasUpdate) {
|
||||||
|
* refetch();
|
||||||
|
* clearUpdate();
|
||||||
|
* }
|
||||||
|
* }, [hasUpdate, refetch, clearUpdate]);
|
||||||
|
*/
|
||||||
|
export function useCacheUpdate(
|
||||||
|
urlPattern: string,
|
||||||
|
options: UseCacheUpdateOptions = {}
|
||||||
|
): UseCacheUpdateResult {
|
||||||
|
const { exact = false } = options;
|
||||||
|
const { cacheUpdates, clearCacheUpdate } = useServiceWorker();
|
||||||
|
|
||||||
|
const matchingUpdates = useMemo(() => {
|
||||||
|
return cacheUpdates.filter((update) => {
|
||||||
|
if (exact) {
|
||||||
|
return update.url === urlPattern || update.url.endsWith(urlPattern);
|
||||||
|
}
|
||||||
|
return update.url.includes(urlPattern);
|
||||||
|
});
|
||||||
|
}, [cacheUpdates, urlPattern, exact]);
|
||||||
|
|
||||||
|
const hasUpdate = matchingUpdates.length > 0;
|
||||||
|
|
||||||
|
const lastUpdateTime = useMemo(() => {
|
||||||
|
if (matchingUpdates.length === 0) return null;
|
||||||
|
return Math.max(...matchingUpdates.map((u) => u.timestamp));
|
||||||
|
}, [matchingUpdates]);
|
||||||
|
|
||||||
|
const clearUpdate = useCallback(() => {
|
||||||
|
matchingUpdates.forEach((update) => {
|
||||||
|
clearCacheUpdate(update.url);
|
||||||
|
});
|
||||||
|
}, [matchingUpdates, clearCacheUpdate]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasUpdate,
|
||||||
|
lastUpdateTime,
|
||||||
|
clearUpdate,
|
||||||
|
updates: matchingUpdates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to check if any cache update is available
|
||||||
|
* Useful for showing a global "refresh available" indicator
|
||||||
|
*/
|
||||||
|
export function useAnyCacheUpdate(): {
|
||||||
|
hasAnyUpdate: boolean;
|
||||||
|
updateCount: number;
|
||||||
|
clearAll: () => void;
|
||||||
|
} {
|
||||||
|
const { cacheUpdates, clearAllCacheUpdates } = useServiceWorker();
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasAnyUpdate: cacheUpdates.length > 0,
|
||||||
|
updateCount: cacheUpdates.length,
|
||||||
|
clearAll: clearAllCacheUpdates,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { useImageCache } from "@/contexts/ImageCacheContext";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook pour obtenir une URL d'image avec cache busting
|
|
||||||
* Ajoute automatiquement ?v={cacheVersion} à l'URL
|
|
||||||
*/
|
|
||||||
export function useImageUrl(baseUrl: string): string {
|
|
||||||
const { getImageUrl } = useImageCache();
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
return getImageUrl(baseUrl);
|
|
||||||
}, [baseUrl, getImageUrl]);
|
|
||||||
}
|
|
||||||
@@ -69,8 +69,7 @@
|
|||||||
"title": "Preferences",
|
"title": "Preferences",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"display": "Display",
|
"display": "Display",
|
||||||
"connection": "Connection",
|
"connection": "Connection"
|
||||||
"cache": "Cache"
|
|
||||||
},
|
},
|
||||||
"display": {
|
"display": {
|
||||||
"title": "Display Preferences",
|
"title": "Display Preferences",
|
||||||
@@ -106,31 +105,9 @@
|
|||||||
"title": "Advanced Settings",
|
"title": "Advanced Settings",
|
||||||
"description": "Configure advanced performance and reliability settings.",
|
"description": "Configure advanced performance and reliability settings.",
|
||||||
"save": "Save settings",
|
"save": "Save settings",
|
||||||
"maxConcurrentRequests": {
|
|
||||||
"label": "Max Concurrent Requests",
|
|
||||||
"description": "Maximum number of simultaneous requests to Komga server (1-10)"
|
|
||||||
},
|
|
||||||
"prefetchCount": {
|
"prefetchCount": {
|
||||||
"label": "Reader Prefetch Count",
|
"label": "Reader Prefetch Count",
|
||||||
"description": "Number of pages to preload in the reader (0-20)"
|
"description": "Number of pages to preload in the reader (0-20)"
|
||||||
},
|
|
||||||
"circuitBreaker": {
|
|
||||||
"title": "Circuit Breaker",
|
|
||||||
"description": "Automatic protection against server overload",
|
|
||||||
"threshold": {
|
|
||||||
"label": "Failure Threshold",
|
|
||||||
"description": "Number of consecutive failures before opening the circuit (1-20)"
|
|
||||||
},
|
|
||||||
"timeout": {
|
|
||||||
"label": "Request Timeout",
|
|
||||||
"description": "Maximum wait time for a request before considering it failed",
|
|
||||||
"unit": "milliseconds (1000ms = 1 second)"
|
|
||||||
},
|
|
||||||
"resetTimeout": {
|
|
||||||
"label": "Reset Timeout",
|
|
||||||
"description": "Time to wait before attempting to close the circuit",
|
|
||||||
"unit": "milliseconds (1000ms = 1 second)"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -161,73 +138,34 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cache": {
|
"cache": {
|
||||||
"title": "Cache Configuration",
|
"title": "Cache & Storage",
|
||||||
"description": "Manage data caching settings.",
|
"description": "Manage local cache for optimal offline experience.",
|
||||||
"mode": {
|
"notSupported": "Offline cache is not supported by your browser.",
|
||||||
"label": "Cache mode",
|
"initializing": "Initializing...",
|
||||||
"description": "Memory cache is faster but doesn't persist between restarts"
|
"totalStorage": "Total storage",
|
||||||
},
|
"imagesQuota": "{used}% of images quota used",
|
||||||
"size": {
|
"static": "Static resources",
|
||||||
"title": "Cache size",
|
"staticDesc": "Next.js scripts, styles and assets",
|
||||||
"server": "Server cache",
|
"pages": "Visited pages",
|
||||||
"serviceWorker": "SW cache (total)",
|
"pagesDesc": "Home, libraries, series and details",
|
||||||
"api": "API cache (data)",
|
"api": "API data",
|
||||||
"items": "{count} item(s)",
|
"apiDesc": "Series, books and library metadata",
|
||||||
"loading": "Loading...",
|
"images": "Images",
|
||||||
"error": "Error loading"
|
"imagesDesc": "Covers and thumbnails (100 MB limit)",
|
||||||
},
|
"books": "Offline books",
|
||||||
"ttl": {
|
"booksDesc": "Manually downloaded pages",
|
||||||
"default": "Default TTL (minutes)",
|
"clearAll": "Clear all cache",
|
||||||
"home": "Home page TTL",
|
"cleared": "Cache cleared",
|
||||||
"libraries": "Libraries TTL",
|
"clearedDesc": "Cache has been cleared successfully",
|
||||||
"series": "Series TTL",
|
"clearError": "Error clearing cache",
|
||||||
"books": "Books TTL",
|
"unavailable": "Cache statistics unavailable",
|
||||||
"images": "Images TTL",
|
"reinstall": "Reinstall Service Worker",
|
||||||
"imageCacheMaxAge": {
|
"reinstallError": "Error reinstalling Service Worker",
|
||||||
"label": "HTTP Image Cache Duration",
|
"entry": "entry",
|
||||||
"description": "Duration for images to be cached in the browser",
|
"entries": "entries",
|
||||||
"options": {
|
"loadingEntries": "Loading entries...",
|
||||||
"noCache": "No cache (0s)",
|
"noEntries": "No entries in this cache",
|
||||||
"oneHour": "1 hour (3600s)",
|
"loadError": "Error loading entries"
|
||||||
"oneDay": "1 day (86400s)",
|
|
||||||
"oneWeek": "1 week (604800s)",
|
|
||||||
"oneMonth": "1 month (2592000s) - Recommended",
|
|
||||||
"oneYear": "1 year (31536000s)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"buttons": {
|
|
||||||
"saveTTL": "Save TTL",
|
|
||||||
"clear": "Clear cache",
|
|
||||||
"clearing": "Clearing...",
|
|
||||||
"clearServiceWorker": "Clear service worker cache",
|
|
||||||
"clearingServiceWorker": "Clearing service worker cache...",
|
|
||||||
"flushImageCache": "Force reload images"
|
|
||||||
},
|
|
||||||
"messages": {
|
|
||||||
"ttlSaved": "TTL configuration saved successfully",
|
|
||||||
"cleared": "Server cache cleared successfully",
|
|
||||||
"serviceWorkerCleared": "Service worker cache cleared successfully",
|
|
||||||
"imageCacheFlushed": "Images will be reloaded - refresh the page"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"title": "Error clearing cache",
|
|
||||||
"message": "An error occurred while clearing the cache",
|
|
||||||
"ttl": "Error saving TTL configuration",
|
|
||||||
"serviceWorkerMessage": "An error occurred while clearing the service worker cache"
|
|
||||||
},
|
|
||||||
"entries": {
|
|
||||||
"title": "Cache content preview",
|
|
||||||
"serverTitle": "Server cache preview",
|
|
||||||
"serviceWorkerTitle": "Service worker cache preview",
|
|
||||||
"loading": "Loading entries...",
|
|
||||||
"empty": "No entries in cache",
|
|
||||||
"expired": "Expired",
|
|
||||||
"daysRemaining": "{count} day(s) remaining",
|
|
||||||
"hoursRemaining": "{count} hour(s) remaining",
|
|
||||||
"minutesRemaining": "{count} minute(s) remaining",
|
|
||||||
"lessThanMinute": "Less than a minute"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
@@ -463,14 +401,6 @@
|
|||||||
"PREFERENCES_UPDATE_ERROR": "Error updating preferences",
|
"PREFERENCES_UPDATE_ERROR": "Error updating preferences",
|
||||||
"PREFERENCES_CONTEXT_ERROR": "Preferences context error",
|
"PREFERENCES_CONTEXT_ERROR": "Preferences context error",
|
||||||
|
|
||||||
"CACHE_DELETE_ERROR": "Error deleting cache",
|
|
||||||
"CACHE_SAVE_ERROR": "Error saving cache",
|
|
||||||
"CACHE_LOAD_ERROR": "Error loading cache",
|
|
||||||
"CACHE_CLEAR_ERROR": "Error clearing cache",
|
|
||||||
"CACHE_MODE_FETCH_ERROR": "Error fetching cache mode",
|
|
||||||
"CACHE_MODE_UPDATE_ERROR": "Error updating cache mode",
|
|
||||||
"CACHE_INVALID_MODE": "Invalid cache mode",
|
|
||||||
|
|
||||||
"UI_TABS_TRIGGER_ERROR": "Error triggering tabs",
|
"UI_TABS_TRIGGER_ERROR": "Error triggering tabs",
|
||||||
"UI_TABS_CONTENT_ERROR": "Error loading tabs content",
|
"UI_TABS_CONTENT_ERROR": "Error loading tabs content",
|
||||||
|
|
||||||
|
|||||||
@@ -69,8 +69,7 @@
|
|||||||
"title": "Préférences",
|
"title": "Préférences",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"display": "Affichage",
|
"display": "Affichage",
|
||||||
"connection": "Connexion",
|
"connection": "Connexion"
|
||||||
"cache": "Cache"
|
|
||||||
},
|
},
|
||||||
"display": {
|
"display": {
|
||||||
"title": "Préférences d'affichage",
|
"title": "Préférences d'affichage",
|
||||||
@@ -106,31 +105,9 @@
|
|||||||
"title": "Paramètres avancés",
|
"title": "Paramètres avancés",
|
||||||
"description": "Configurez les paramètres avancés de performance et de fiabilité.",
|
"description": "Configurez les paramètres avancés de performance et de fiabilité.",
|
||||||
"save": "Enregistrer les paramètres",
|
"save": "Enregistrer les paramètres",
|
||||||
"maxConcurrentRequests": {
|
|
||||||
"label": "Requêtes simultanées max",
|
|
||||||
"description": "Nombre maximum de requêtes simultanées vers le serveur Komga (1-10)"
|
|
||||||
},
|
|
||||||
"prefetchCount": {
|
"prefetchCount": {
|
||||||
"label": "Préchargement du lecteur",
|
"label": "Préchargement du lecteur",
|
||||||
"description": "Nombre de pages à précharger dans le lecteur (0-20)"
|
"description": "Nombre de pages à précharger dans le lecteur (0-20)"
|
||||||
},
|
|
||||||
"circuitBreaker": {
|
|
||||||
"title": "Disjoncteur",
|
|
||||||
"description": "Protection automatique contre la surcharge du serveur",
|
|
||||||
"threshold": {
|
|
||||||
"label": "Seuil d'échec",
|
|
||||||
"description": "Nombre d'échecs consécutifs avant ouverture du circuit (1-20)"
|
|
||||||
},
|
|
||||||
"timeout": {
|
|
||||||
"label": "Délai d'expiration",
|
|
||||||
"description": "Temps d'attente maximum pour une requête avant de la considérer comme échouée",
|
|
||||||
"unit": "millisecondes (1000ms = 1 seconde)"
|
|
||||||
},
|
|
||||||
"resetTimeout": {
|
|
||||||
"label": "Délai de réinitialisation",
|
|
||||||
"description": "Temps d'attente avant de tenter de fermer le circuit",
|
|
||||||
"unit": "millisecondes (1000ms = 1 seconde)"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -161,73 +138,34 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cache": {
|
"cache": {
|
||||||
"title": "Configuration du Cache",
|
"title": "Cache et stockage",
|
||||||
"description": "Gérez les paramètres de mise en cache des données.",
|
"description": "Gérez le cache local pour une expérience hors-ligne optimale.",
|
||||||
"mode": {
|
"notSupported": "Le cache hors-ligne n'est pas supporté par votre navigateur.",
|
||||||
"label": "Mode de cache",
|
"initializing": "Initialisation...",
|
||||||
"description": "Le cache en mémoire est plus rapide mais ne persiste pas entre les redémarrages"
|
"totalStorage": "Stockage total",
|
||||||
},
|
"imagesQuota": "{used}% du quota images utilisé",
|
||||||
"size": {
|
"static": "Ressources statiques",
|
||||||
"title": "Taille du cache",
|
"staticDesc": "Scripts, styles et assets Next.js",
|
||||||
"server": "Cache serveur",
|
"pages": "Pages visitées",
|
||||||
"serviceWorker": "Cache SW (total)",
|
"pagesDesc": "Home, bibliothèques, séries et détails",
|
||||||
"api": "Cache API (données)",
|
"api": "Données API",
|
||||||
"items": "{count} élément(s)",
|
"apiDesc": "Métadonnées des séries, livres et bibliothèques",
|
||||||
"loading": "Chargement...",
|
"images": "Images",
|
||||||
"error": "Erreur lors du chargement"
|
"imagesDesc": "Couvertures et vignettes (limite 100 Mo)",
|
||||||
},
|
"books": "Livres hors-ligne",
|
||||||
"ttl": {
|
"booksDesc": "Pages téléchargées manuellement",
|
||||||
"default": "TTL par défaut (minutes)",
|
"clearAll": "Vider tout le cache",
|
||||||
"home": "TTL page d'accueil",
|
"cleared": "Cache vidé",
|
||||||
"libraries": "TTL bibliothèques",
|
"clearedDesc": "Le cache a été vidé avec succès",
|
||||||
"series": "TTL séries",
|
"clearError": "Erreur lors du vidage du cache",
|
||||||
"books": "TTL tomes",
|
"unavailable": "Statistiques du cache non disponibles",
|
||||||
"images": "TTL images",
|
"reinstall": "Réinstaller le Service Worker",
|
||||||
"imageCacheMaxAge": {
|
"reinstallError": "Erreur lors de la réinstallation du Service Worker",
|
||||||
"label": "Durée du cache HTTP des images",
|
"entry": "entrée",
|
||||||
"description": "Durée de conservation des images dans le cache du navigateur",
|
"entries": "entrées",
|
||||||
"options": {
|
"loadingEntries": "Chargement des entrées...",
|
||||||
"noCache": "Aucun cache (0s)",
|
"noEntries": "Aucune entrée dans ce cache",
|
||||||
"oneHour": "1 heure (3600s)",
|
"loadError": "Erreur lors du chargement des entrées"
|
||||||
"oneDay": "1 jour (86400s)",
|
|
||||||
"oneWeek": "1 semaine (604800s)",
|
|
||||||
"oneMonth": "1 mois (2592000s) - Recommandé",
|
|
||||||
"oneYear": "1 an (31536000s)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"buttons": {
|
|
||||||
"saveTTL": "Sauvegarder les TTL",
|
|
||||||
"clear": "Vider le cache",
|
|
||||||
"clearing": "Suppression...",
|
|
||||||
"clearServiceWorker": "Vider le cache du service worker",
|
|
||||||
"clearingServiceWorker": "Suppression du cache service worker...",
|
|
||||||
"flushImageCache": "Forcer le rechargement des images"
|
|
||||||
},
|
|
||||||
"messages": {
|
|
||||||
"ttlSaved": "La configuration des TTL a été sauvegardée avec succès",
|
|
||||||
"cleared": "Cache serveur supprimé avec succès",
|
|
||||||
"serviceWorkerCleared": "Cache du service worker supprimé avec succès",
|
|
||||||
"imageCacheFlushed": "Les images seront rechargées - rafraîchissez la page"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"title": "Erreur lors de la suppression du cache",
|
|
||||||
"message": "Une erreur est survenue lors de la suppression du cache",
|
|
||||||
"ttl": "Erreur lors de la sauvegarde de la configuration TTL",
|
|
||||||
"serviceWorkerMessage": "Une erreur est survenue lors de la suppression du cache du service worker"
|
|
||||||
},
|
|
||||||
"entries": {
|
|
||||||
"title": "Aperçu du contenu du cache",
|
|
||||||
"serverTitle": "Aperçu du cache serveur",
|
|
||||||
"serviceWorkerTitle": "Aperçu du cache service worker",
|
|
||||||
"loading": "Chargement des entrées...",
|
|
||||||
"empty": "Aucune entrée dans le cache",
|
|
||||||
"expired": "Expiré",
|
|
||||||
"daysRemaining": "{count} jour(s) restant(s)",
|
|
||||||
"hoursRemaining": "{count} heure(s) restante(s)",
|
|
||||||
"minutesRemaining": "{count} minute(s) restante(s)",
|
|
||||||
"lessThanMinute": "Moins d'une minute"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
@@ -461,14 +399,6 @@
|
|||||||
"PREFERENCES_UPDATE_ERROR": "Erreur lors de la mise à jour des préférences",
|
"PREFERENCES_UPDATE_ERROR": "Erreur lors de la mise à jour des préférences",
|
||||||
"PREFERENCES_CONTEXT_ERROR": "Erreur de contexte des préférences",
|
"PREFERENCES_CONTEXT_ERROR": "Erreur de contexte des préférences",
|
||||||
|
|
||||||
"CACHE_DELETE_ERROR": "Erreur lors de la suppression du cache",
|
|
||||||
"CACHE_SAVE_ERROR": "Erreur lors de la sauvegarde du cache",
|
|
||||||
"CACHE_LOAD_ERROR": "Erreur lors du chargement du cache",
|
|
||||||
"CACHE_CLEAR_ERROR": "Erreur lors de la suppression du cache",
|
|
||||||
"CACHE_MODE_FETCH_ERROR": "Erreur lors de la récupération du mode de cache",
|
|
||||||
"CACHE_MODE_UPDATE_ERROR": "Erreur lors de la mise à jour du mode de cache",
|
|
||||||
"CACHE_INVALID_MODE": "Mode de cache invalide",
|
|
||||||
|
|
||||||
"UI_TABS_TRIGGER_ERROR": "Erreur lors du déclenchement des onglets",
|
"UI_TABS_TRIGGER_ERROR": "Erreur lors du déclenchement des onglets",
|
||||||
"UI_TABS_CONTENT_ERROR": "Erreur lors du chargement du contenu des onglets",
|
"UI_TABS_CONTENT_ERROR": "Erreur lors du chargement du contenu des onglets",
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,137 @@
|
|||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
export const registerServiceWorker = async () => {
|
interface ServiceWorkerRegistrationOptions {
|
||||||
|
onUpdate?: (registration: ServiceWorkerRegistration) => void;
|
||||||
|
onSuccess?: (registration: ServiceWorkerRegistration) => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the service worker with optional callbacks for update and success events
|
||||||
|
*/
|
||||||
|
export const registerServiceWorker = async (
|
||||||
|
options: ServiceWorkerRegistrationOptions = {}
|
||||||
|
): Promise<ServiceWorkerRegistration | null> => {
|
||||||
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
||||||
return;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onUpdate, onSuccess, onError } = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.register("/sw.js", {
|
||||||
|
scope: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for updates immediately
|
||||||
|
registration.update().catch(() => {
|
||||||
|
// Ignore update check errors
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle updates
|
||||||
|
registration.addEventListener("updatefound", () => {
|
||||||
|
const newWorker = registration.installing;
|
||||||
|
|
||||||
|
if (!newWorker) return;
|
||||||
|
|
||||||
|
newWorker.addEventListener("statechange", () => {
|
||||||
|
if (newWorker.state === "installed") {
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
// New service worker available
|
||||||
|
logger.info("New service worker available");
|
||||||
|
onUpdate?.(registration);
|
||||||
|
} else {
|
||||||
|
// First install
|
||||||
|
logger.info("Service worker installed for the first time");
|
||||||
|
onSuccess?.(registration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// If already active, call success
|
||||||
|
if (registration.active) {
|
||||||
|
onSuccess?.(registration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return registration;
|
||||||
|
} catch (error) {
|
||||||
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
|
logger.error({ err }, "Service Worker registration failed");
|
||||||
|
onError?.(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister all service workers
|
||||||
|
*/
|
||||||
|
export const unregisterServiceWorker = async (): Promise<boolean> => {
|
||||||
|
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.serviceWorker.register("/sw.js");
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
// logger.info("Service Worker registered with scope:", registration.scope);
|
await Promise.all(registrations.map((reg) => reg.unregister()));
|
||||||
|
logger.info("All service workers unregistered");
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Service Worker registration failed:");
|
logger.error({ err: error }, "Failed to unregister service workers");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to the active service worker
|
||||||
|
*/
|
||||||
|
export const sendMessageToSW = <T = unknown>(message: unknown): Promise<T | null> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!navigator.serviceWorker.controller) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageChannel = new MessageChannel();
|
||||||
|
|
||||||
|
messageChannel.port1.onmessage = (event) => {
|
||||||
|
resolve(event.data as T);
|
||||||
|
};
|
||||||
|
|
||||||
|
navigator.serviceWorker.controller.postMessage(message, [messageChannel.port2]);
|
||||||
|
|
||||||
|
// Timeout after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(null);
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the app is running as a PWA (standalone mode)
|
||||||
|
*/
|
||||||
|
export const isPWA = (): boolean => {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
window.matchMedia("(display-mode: standalone)").matches ||
|
||||||
|
// iOS Safari
|
||||||
|
("standalone" in window.navigator &&
|
||||||
|
(window.navigator as { standalone?: boolean }).standalone === true)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current service worker registration
|
||||||
|
*/
|
||||||
|
export const getServiceWorkerRegistration = async (): Promise<ServiceWorkerRegistration | null> => {
|
||||||
|
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await navigator.serviceWorker.ready;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
import type { AuthConfig } from "@/types/auth";
|
import type { AuthConfig } from "@/types/auth";
|
||||||
import type { CacheType } from "@/types/cache";
|
|
||||||
import { getServerCacheService } from "./server-cache.service";
|
|
||||||
import { ConfigDBService } from "./config-db.service";
|
import { ConfigDBService } from "./config-db.service";
|
||||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||||
import { AppError } from "../../utils/errors";
|
import { AppError } from "../../utils/errors";
|
||||||
import type { KomgaConfig } from "@/types/komga";
|
import type { KomgaConfig } from "@/types/komga";
|
||||||
import type { ServerCacheService } from "./server-cache.service";
|
|
||||||
import { RequestMonitorService } from "./request-monitor.service";
|
|
||||||
import { RequestQueueService } from "./request-queue.service";
|
|
||||||
import { CircuitBreakerService } from "./circuit-breaker.service";
|
|
||||||
import { PreferencesService } from "./preferences.service";
|
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
export type { CacheType };
|
|
||||||
|
|
||||||
interface KomgaRequestInit extends RequestInit {
|
interface KomgaRequestInit extends RequestInit {
|
||||||
isImage?: boolean;
|
isImage?: boolean;
|
||||||
noJson?: boolean;
|
noJson?: boolean;
|
||||||
@@ -25,68 +16,7 @@ interface KomgaUrlBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export abstract class BaseApiService {
|
export abstract class BaseApiService {
|
||||||
private static requestQueueInitialized = false;
|
|
||||||
private static circuitBreakerInitialized = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialise le RequestQueueService avec les préférences de l'utilisateur
|
|
||||||
*/
|
|
||||||
private static async initializeRequestQueue(): Promise<void> {
|
|
||||||
if (this.requestQueueInitialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Configurer le getter qui récupère dynamiquement la valeur depuis les préférences
|
|
||||||
RequestQueueService.setMaxConcurrentGetter(async () => {
|
|
||||||
try {
|
|
||||||
const preferences = await PreferencesService.getPreferences();
|
|
||||||
return preferences.komgaMaxConcurrentRequests;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Failed to get preferences for request queue");
|
|
||||||
return 5; // Valeur par défaut
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.requestQueueInitialized = true;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Failed to initialize request queue");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialise le CircuitBreakerService avec les préférences de l'utilisateur
|
|
||||||
*/
|
|
||||||
private static async initializeCircuitBreaker(): Promise<void> {
|
|
||||||
if (this.circuitBreakerInitialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Configurer le getter qui récupère dynamiquement la config depuis les préférences
|
|
||||||
CircuitBreakerService.setConfigGetter(async () => {
|
|
||||||
try {
|
|
||||||
const preferences = await PreferencesService.getPreferences();
|
|
||||||
return preferences.circuitBreakerConfig;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Failed to get preferences for circuit breaker");
|
|
||||||
return {
|
|
||||||
threshold: 5,
|
|
||||||
timeout: 30000,
|
|
||||||
resetTimeout: 60000,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.circuitBreakerInitialized = true;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Failed to initialize circuit breaker");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static async getKomgaConfig(): Promise<AuthConfig> {
|
protected static async getKomgaConfig(): Promise<AuthConfig> {
|
||||||
// Initialiser les services si ce n'est pas déjà fait
|
|
||||||
await Promise.all([this.initializeRequestQueue(), this.initializeCircuitBreaker()]);
|
|
||||||
try {
|
try {
|
||||||
const config: KomgaConfig | null = await ConfigDBService.getConfig();
|
const config: KomgaConfig | null = await ConfigDBService.getConfig();
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@@ -117,22 +47,6 @@ export abstract class BaseApiService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static async fetchWithCache<T>(
|
|
||||||
key: string,
|
|
||||||
fetcher: () => Promise<T>,
|
|
||||||
type: CacheType = "DEFAULT"
|
|
||||||
): Promise<T> {
|
|
||||||
const cacheService: ServerCacheService = await getServerCacheService();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await cacheService.getOrSet(key, fetcher, type);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static buildUrl(
|
protected static buildUrl(
|
||||||
config: AuthConfig,
|
config: AuthConfig,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -159,12 +73,6 @@ export abstract class BaseApiService {
|
|||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static async resolveWithFallback(url: string): Promise<string> {
|
|
||||||
// DNS resolution is only needed server-side and causes build issues
|
|
||||||
// The fetch API will handle DNS resolution automatically
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static async fetchFromApi<T>(
|
protected static async fetchFromApi<T>(
|
||||||
urlBuilder: KomgaUrlBuilder,
|
urlBuilder: KomgaUrlBuilder,
|
||||||
headersOptions = {},
|
headersOptions = {},
|
||||||
@@ -172,7 +80,7 @@ export abstract class BaseApiService {
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const config: AuthConfig = await this.getKomgaConfig();
|
const config: AuthConfig = await this.getKomgaConfig();
|
||||||
const { path, params } = urlBuilder;
|
const { path, params } = urlBuilder;
|
||||||
const url = await this.resolveWithFallback(this.buildUrl(config, path, params));
|
const url = this.buildUrl(config, path, params);
|
||||||
|
|
||||||
const headers: Headers = this.getAuthHeaders(config);
|
const headers: Headers = this.getAuthHeaders(config);
|
||||||
if (headersOptions) {
|
if (headersOptions) {
|
||||||
@@ -185,10 +93,6 @@ export abstract class BaseApiService {
|
|||||||
const startTime = isDebug ? Date.now() : 0;
|
const startTime = isDebug ? Date.now() : 0;
|
||||||
|
|
||||||
if (isDebug) {
|
if (isDebug) {
|
||||||
const queueStats = {
|
|
||||||
active: RequestQueueService.getActiveCount(),
|
|
||||||
queued: RequestQueueService.getQueueLength(),
|
|
||||||
};
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
url,
|
url,
|
||||||
@@ -196,74 +100,64 @@ export abstract class BaseApiService {
|
|||||||
params,
|
params,
|
||||||
isImage: options.isImage,
|
isImage: options.isImage,
|
||||||
noJson: options.noJson,
|
noJson: options.noJson,
|
||||||
queue: queueStats,
|
|
||||||
},
|
},
|
||||||
"🔵 Komga Request"
|
"🔵 Komga Request"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timeout réduit à 15 secondes pour éviter les blocages longs
|
// Timeout de 15 secondes pour éviter les blocages longs
|
||||||
const timeoutMs = 15000;
|
const timeoutMs = 15000;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Utiliser le circuit breaker pour éviter de surcharger Komga
|
let response: Response;
|
||||||
const response = await CircuitBreakerService.execute(async () => {
|
|
||||||
// Enqueue la requête pour limiter la concurrence
|
|
||||||
return await RequestQueueService.enqueue(async () => {
|
|
||||||
try {
|
|
||||||
return await fetch(url, {
|
|
||||||
headers,
|
|
||||||
...options,
|
|
||||||
signal: controller.signal,
|
|
||||||
// Configure undici connection timeouts
|
|
||||||
// @ts-ignore - undici-specific options not in standard fetch types
|
|
||||||
connectTimeout: timeoutMs,
|
|
||||||
bodyTimeout: timeoutMs,
|
|
||||||
headersTimeout: timeoutMs,
|
|
||||||
});
|
|
||||||
} catch (fetchError: any) {
|
|
||||||
// Gestion spécifique des erreurs DNS
|
|
||||||
if (fetchError?.cause?.code === "EAI_AGAIN" || fetchError?.code === "EAI_AGAIN") {
|
|
||||||
logger.error(
|
|
||||||
`DNS resolution failed for ${url}. Retrying with different DNS settings...`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Retry avec des paramètres DNS différents
|
try {
|
||||||
return await fetch(url, {
|
response = await fetch(url, {
|
||||||
headers,
|
headers,
|
||||||
...options,
|
...options,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
// @ts-ignore - undici-specific options
|
// @ts-ignore - undici-specific options not in standard fetch types
|
||||||
connectTimeout: timeoutMs,
|
connectTimeout: timeoutMs,
|
||||||
bodyTimeout: timeoutMs,
|
bodyTimeout: timeoutMs,
|
||||||
headersTimeout: timeoutMs,
|
headersTimeout: timeoutMs,
|
||||||
// Force IPv4 si IPv6 pose problème
|
|
||||||
// @ts-ignore
|
|
||||||
family: 4,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry automatique sur timeout de connexion (cold start)
|
|
||||||
if (fetchError?.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
|
||||||
logger.info(`⏱️ Connection timeout for ${url}. Retrying once (cold start)...`);
|
|
||||||
|
|
||||||
return await fetch(url, {
|
|
||||||
headers,
|
|
||||||
...options,
|
|
||||||
signal: controller.signal,
|
|
||||||
// @ts-ignore - undici-specific options
|
|
||||||
connectTimeout: timeoutMs,
|
|
||||||
bodyTimeout: timeoutMs,
|
|
||||||
headersTimeout: timeoutMs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
throw fetchError;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
} catch (fetchError: any) {
|
||||||
|
// Gestion spécifique des erreurs DNS
|
||||||
|
if (fetchError?.cause?.code === "EAI_AGAIN" || fetchError?.code === "EAI_AGAIN") {
|
||||||
|
logger.error(`DNS resolution failed for ${url}. Retrying with different DNS settings...`);
|
||||||
|
|
||||||
|
response = await fetch(url, {
|
||||||
|
headers,
|
||||||
|
...options,
|
||||||
|
signal: controller.signal,
|
||||||
|
// @ts-ignore - undici-specific options
|
||||||
|
connectTimeout: timeoutMs,
|
||||||
|
bodyTimeout: timeoutMs,
|
||||||
|
headersTimeout: timeoutMs,
|
||||||
|
// Force IPv4 si IPv6 pose problème
|
||||||
|
// @ts-ignore
|
||||||
|
family: 4,
|
||||||
|
});
|
||||||
|
} else if (fetchError?.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||||
|
// Retry automatique sur timeout de connexion (cold start)
|
||||||
|
logger.info(`⏱️ Connection timeout for ${url}. Retrying once (cold start)...`);
|
||||||
|
|
||||||
|
response = await fetch(url, {
|
||||||
|
headers,
|
||||||
|
...options,
|
||||||
|
signal: controller.signal,
|
||||||
|
// @ts-ignore - undici-specific options
|
||||||
|
connectTimeout: timeoutMs,
|
||||||
|
bodyTimeout: timeoutMs,
|
||||||
|
headersTimeout: timeoutMs,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw fetchError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
if (isDebug) {
|
if (isDebug) {
|
||||||
@@ -320,7 +214,6 @@ export abstract class BaseApiService {
|
|||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
RequestMonitorService.decrementActive();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,28 @@
|
|||||||
import { BaseApiService } from "./base-api.service";
|
import { BaseApiService } from "./base-api.service";
|
||||||
import type { KomgaBook, KomgaBookWithPages, TTLConfig } from "@/types/komga";
|
import type { KomgaBook, KomgaBookWithPages } from "@/types/komga";
|
||||||
import type { ImageResponse } from "./image.service";
|
|
||||||
import { ImageService } from "./image.service";
|
import { ImageService } from "./image.service";
|
||||||
import { PreferencesService } from "./preferences.service";
|
import { PreferencesService } from "./preferences.service";
|
||||||
import { ConfigDBService } from "./config-db.service";
|
|
||||||
import { SeriesService } from "./series.service";
|
|
||||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||||
import { AppError } from "../../utils/errors";
|
import { AppError } from "../../utils/errors";
|
||||||
import logger from "@/lib/logger";
|
|
||||||
|
|
||||||
export class BookService extends BaseApiService {
|
export class BookService extends BaseApiService {
|
||||||
private static async getImageCacheMaxAge(): Promise<number> {
|
|
||||||
try {
|
|
||||||
const ttlConfig: TTLConfig | null = await ConfigDBService.getTTLConfig();
|
|
||||||
const maxAge = ttlConfig?.imageCacheMaxAge ?? 2592000;
|
|
||||||
return maxAge;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "[ImageCache] Error fetching TTL config");
|
|
||||||
return 2592000; // 30 jours par défaut en cas d'erreur
|
|
||||||
}
|
|
||||||
}
|
|
||||||
static async getBook(bookId: string): Promise<KomgaBookWithPages> {
|
static async getBook(bookId: string): Promise<KomgaBookWithPages> {
|
||||||
try {
|
try {
|
||||||
return this.fetchWithCache<KomgaBookWithPages>(
|
// Récupération parallèle des détails du tome et des pages
|
||||||
`book-${bookId}`,
|
const [book, pages] = await Promise.all([
|
||||||
async () => {
|
this.fetchFromApi<KomgaBook>({ path: `books/${bookId}` }),
|
||||||
// Récupération parallèle des détails du tome et des pages
|
this.fetchFromApi<{ number: number }[]>({ path: `books/${bookId}/pages` }),
|
||||||
const [book, pages] = await Promise.all([
|
]);
|
||||||
this.fetchFromApi<KomgaBook>({ path: `books/${bookId}` }),
|
|
||||||
this.fetchFromApi<{ number: number }[]>({ path: `books/${bookId}/pages` }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
book,
|
book,
|
||||||
pages: pages.map((page: any) => page.number),
|
pages: pages.map((page: any) => page.number),
|
||||||
};
|
};
|
||||||
},
|
|
||||||
"BOOKS"
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {}, error);
|
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {}, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async getNextBook(bookId: string, _seriesId: string): Promise<KomgaBook | null> {
|
public static async getNextBook(bookId: string, _seriesId: string): Promise<KomgaBook | null> {
|
||||||
try {
|
try {
|
||||||
// Utiliser l'endpoint natif Komga pour obtenir le livre suivant
|
// Utiliser l'endpoint natif Komga pour obtenir le livre suivant
|
||||||
@@ -63,7 +44,6 @@ export class BookService extends BaseApiService {
|
|||||||
|
|
||||||
static async getBookSeriesId(bookId: string): Promise<string> {
|
static async getBookSeriesId(bookId: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
// Récupérer le livre sans cache pour éviter les données obsolètes
|
|
||||||
const book = await this.fetchFromApi<KomgaBook>({ path: `books/${bookId}` });
|
const book = await this.fetchFromApi<KomgaBook>({ path: `books/${bookId}` });
|
||||||
return book.seriesId;
|
return book.seriesId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -126,24 +106,10 @@ export class BookService extends BaseApiService {
|
|||||||
try {
|
try {
|
||||||
// Ajuster le numéro de page pour l'API Komga (zero-based)
|
// Ajuster le numéro de page pour l'API Komga (zero-based)
|
||||||
const adjustedPageNumber = pageNumber - 1;
|
const adjustedPageNumber = pageNumber - 1;
|
||||||
const response: ImageResponse = await ImageService.getImage(
|
// Stream directement sans buffer en mémoire
|
||||||
|
return ImageService.streamImage(
|
||||||
`books/${bookId}/pages/${adjustedPageNumber}?zero_based=true`
|
`books/${bookId}/pages/${adjustedPageNumber}?zero_based=true`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convertir le Buffer Node.js en ArrayBuffer proprement
|
|
||||||
const arrayBuffer = response.buffer.buffer.slice(
|
|
||||||
response.buffer.byteOffset,
|
|
||||||
response.buffer.byteOffset + response.buffer.byteLength
|
|
||||||
) as ArrayBuffer;
|
|
||||||
|
|
||||||
const maxAge = await this.getImageCacheMaxAge();
|
|
||||||
|
|
||||||
return new Response(arrayBuffer, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": response.contentType || "image/jpeg",
|
|
||||||
"Cache-Control": `public, max-age=${maxAge}, immutable`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error);
|
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error);
|
||||||
}
|
}
|
||||||
@@ -153,20 +119,13 @@ export class BookService extends BaseApiService {
|
|||||||
try {
|
try {
|
||||||
// Récupérer les préférences de l'utilisateur
|
// Récupérer les préférences de l'utilisateur
|
||||||
const preferences = await PreferencesService.getPreferences();
|
const preferences = await PreferencesService.getPreferences();
|
||||||
const maxAge = await this.getImageCacheMaxAge();
|
|
||||||
|
|
||||||
// Si l'utilisateur préfère les vignettes, utiliser la miniature
|
// Si l'utilisateur préfère les vignettes, utiliser la miniature (streaming)
|
||||||
if (preferences.showThumbnails) {
|
if (preferences.showThumbnails) {
|
||||||
const response: ImageResponse = await ImageService.getImage(`books/${bookId}/thumbnail`);
|
return ImageService.streamImage(`books/${bookId}/thumbnail`);
|
||||||
return new Response(response.buffer.buffer as ArrayBuffer, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": response.contentType || "image/jpeg",
|
|
||||||
"Cache-Control": `public, max-age=${maxAge}, immutable`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sinon, récupérer la première page
|
// Sinon, récupérer la première page (streaming)
|
||||||
return this.getPage(bookId, 1);
|
return this.getPage(bookId, 1);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error);
|
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error);
|
||||||
@@ -183,17 +142,10 @@ export class BookService extends BaseApiService {
|
|||||||
|
|
||||||
static async getPageThumbnail(bookId: string, pageNumber: number): Promise<Response> {
|
static async getPageThumbnail(bookId: string, pageNumber: number): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const response: ImageResponse = await ImageService.getImage(
|
// Stream directement sans buffer en mémoire
|
||||||
|
return ImageService.streamImage(
|
||||||
`books/${bookId}/pages/${pageNumber}/thumbnail?zero_based=true`
|
`books/${bookId}/pages/${pageNumber}/thumbnail?zero_based=true`
|
||||||
);
|
);
|
||||||
const maxAge = await this.getImageCacheMaxAge();
|
|
||||||
|
|
||||||
return new Response(response.buffer.buffer as ArrayBuffer, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": response.contentType || "image/jpeg",
|
|
||||||
"Cache-Control": `public, max-age=${maxAge}, immutable`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error);
|
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error);
|
||||||
}
|
}
|
||||||
@@ -211,32 +163,53 @@ export class BookService extends BaseApiService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { LibraryService } = await import("./library.service");
|
// Use books/list directly with library filter to avoid extra series/list call
|
||||||
|
|
||||||
// Faire une requête légère : prendre une page de séries d'une bibliothèque au hasard
|
|
||||||
const randomLibraryIndex = Math.floor(Math.random() * libraryIds.length);
|
const randomLibraryIndex = Math.floor(Math.random() * libraryIds.length);
|
||||||
const randomLibraryId = libraryIds[randomLibraryIndex];
|
const randomLibraryId = libraryIds[randomLibraryIndex];
|
||||||
|
|
||||||
// Récupérer juste une page de séries (pas toutes)
|
// Random page offset for variety (assuming most libraries have at least 100 books)
|
||||||
const seriesResponse = await LibraryService.getLibrarySeries(randomLibraryId, 0, 20);
|
const randomPage = Math.floor(Math.random() * 5); // Pages 0-4
|
||||||
|
|
||||||
if (seriesResponse.content.length === 0) {
|
const searchBody = {
|
||||||
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {
|
condition: {
|
||||||
message: "Aucune série trouvée dans les bibliothèques sélectionnées",
|
libraryId: {
|
||||||
});
|
operator: "is",
|
||||||
}
|
value: randomLibraryId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Choisir une série au hasard parmi celles récupérées
|
const booksResponse = await this.fetchFromApi<{
|
||||||
const randomSeriesIndex = Math.floor(Math.random() * seriesResponse.content.length);
|
content: KomgaBook[];
|
||||||
const randomSeries = seriesResponse.content[randomSeriesIndex];
|
totalElements: number;
|
||||||
|
}>(
|
||||||
// Récupérer les books de cette série avec pagination
|
{
|
||||||
const booksResponse = await SeriesService.getSeriesBooks(randomSeries.id, 0, 100);
|
path: "books/list",
|
||||||
|
params: { page: String(randomPage), size: "20", sort: "number,asc" },
|
||||||
|
},
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
{ method: "POST", body: JSON.stringify(searchBody) }
|
||||||
|
);
|
||||||
|
|
||||||
if (booksResponse.content.length === 0) {
|
if (booksResponse.content.length === 0) {
|
||||||
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {
|
// Fallback to page 0 if random page was empty
|
||||||
message: "Aucun livre trouvé dans la série",
|
const fallbackResponse = await this.fetchFromApi<{
|
||||||
});
|
content: KomgaBook[];
|
||||||
|
totalElements: number;
|
||||||
|
}>(
|
||||||
|
{ path: "books/list", params: { page: "0", size: "20", sort: "number,asc" } },
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
{ method: "POST", body: JSON.stringify(searchBody) }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fallbackResponse.content.length === 0) {
|
||||||
|
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {
|
||||||
|
message: "Aucun livre trouvé dans les bibliothèques sélectionnées",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomBookIndex = Math.floor(Math.random() * fallbackResponse.content.length);
|
||||||
|
return fallbackResponse.content[randomBookIndex].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const randomBookIndex = Math.floor(Math.random() * booksResponse.content.length);
|
const randomBookIndex = Math.floor(Math.random() * booksResponse.content.length);
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
/**
|
|
||||||
* Circuit Breaker pour éviter de surcharger Komga quand il est défaillant
|
|
||||||
* Évite l'effet avalanche en coupant les requêtes vers un service défaillant
|
|
||||||
*/
|
|
||||||
import type { CircuitBreakerConfig } from "@/types/preferences";
|
|
||||||
import logger from "@/lib/logger";
|
|
||||||
|
|
||||||
interface CircuitBreakerState {
|
|
||||||
state: "CLOSED" | "OPEN" | "HALF_OPEN";
|
|
||||||
failureCount: number;
|
|
||||||
lastFailureTime: number;
|
|
||||||
nextAttemptTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
class CircuitBreaker {
|
|
||||||
private state: CircuitBreakerState = {
|
|
||||||
state: "CLOSED",
|
|
||||||
failureCount: 0,
|
|
||||||
lastFailureTime: 0,
|
|
||||||
nextAttemptTime: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
private config = {
|
|
||||||
failureThreshold: 5, // Nombre d'échecs avant ouverture
|
|
||||||
recoveryTimeout: 30000, // 30s avant tentative de récupération
|
|
||||||
resetTimeout: 60000, // Délai de reset après échec
|
|
||||||
};
|
|
||||||
|
|
||||||
private getConfigFromPreferences: (() => Promise<CircuitBreakerConfig>) | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure une fonction pour récupérer dynamiquement la config depuis les préférences
|
|
||||||
*/
|
|
||||||
setConfigGetter(getter: () => Promise<CircuitBreakerConfig>): void {
|
|
||||||
this.getConfigFromPreferences = getter;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Récupère la config actuelle, soit depuis les préférences, soit depuis les valeurs par défaut
|
|
||||||
*/
|
|
||||||
private async getCurrentConfig(): Promise<typeof this.config> {
|
|
||||||
if (this.getConfigFromPreferences) {
|
|
||||||
try {
|
|
||||||
const prefConfig = await this.getConfigFromPreferences();
|
|
||||||
return {
|
|
||||||
failureThreshold: prefConfig.threshold ?? 5,
|
|
||||||
recoveryTimeout: prefConfig.timeout ?? 30000,
|
|
||||||
resetTimeout: prefConfig.resetTimeout ?? 60000,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Error getting circuit breaker config from preferences");
|
|
||||||
return this.config;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.config;
|
|
||||||
}
|
|
||||||
|
|
||||||
async execute<T>(operation: () => Promise<T>): Promise<T> {
|
|
||||||
const config = await this.getCurrentConfig();
|
|
||||||
|
|
||||||
if (this.state.state === "OPEN") {
|
|
||||||
if (Date.now() < this.state.nextAttemptTime) {
|
|
||||||
throw new Error("Circuit breaker is OPEN - Komga service unavailable");
|
|
||||||
}
|
|
||||||
this.state.state = "HALF_OPEN";
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await operation();
|
|
||||||
this.onSuccess();
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
await this.onFailure(config);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private onSuccess(): void {
|
|
||||||
if (this.state.state === "HALF_OPEN") {
|
|
||||||
this.state.failureCount = 0;
|
|
||||||
this.state.state = "CLOSED";
|
|
||||||
logger.info("[CIRCUIT-BREAKER] ✅ Circuit closed - Komga recovered");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async onFailure(config: typeof this.config): Promise<void> {
|
|
||||||
this.state.failureCount++;
|
|
||||||
this.state.lastFailureTime = Date.now();
|
|
||||||
|
|
||||||
if (this.state.failureCount >= config.failureThreshold) {
|
|
||||||
this.state.state = "OPEN";
|
|
||||||
this.state.nextAttemptTime = Date.now() + config.resetTimeout;
|
|
||||||
logger.warn(
|
|
||||||
`[CIRCUIT-BREAKER] 🔴 Circuit OPEN - Komga failing (${this.state.failureCount} failures, reset in ${config.resetTimeout}ms)`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getState(): CircuitBreakerState {
|
|
||||||
return { ...this.state };
|
|
||||||
}
|
|
||||||
|
|
||||||
reset(): void {
|
|
||||||
this.state = {
|
|
||||||
state: "CLOSED",
|
|
||||||
failureCount: 0,
|
|
||||||
lastFailureTime: 0,
|
|
||||||
nextAttemptTime: 0,
|
|
||||||
};
|
|
||||||
logger.info("[CIRCUIT-BREAKER] 🔄 Circuit reset");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CircuitBreakerService = new CircuitBreaker();
|
|
||||||
@@ -2,30 +2,52 @@ import type { KomgaBook } from "@/types/komga";
|
|||||||
|
|
||||||
export class ClientOfflineBookService {
|
export class ClientOfflineBookService {
|
||||||
static setCurrentPage(book: KomgaBook, page: number) {
|
static setCurrentPage(book: KomgaBook, page: number) {
|
||||||
localStorage.setItem(`${book.id}-page`, page.toString());
|
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.setItem) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`${book.id}-page`, page.toString());
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage errors in SSR
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static getCurrentPage(book: KomgaBook) {
|
static getCurrentPage(book: KomgaBook) {
|
||||||
const readProgressPage = book.readProgress?.page || 0;
|
const readProgressPage = book.readProgress?.page || 0;
|
||||||
if (typeof localStorage !== "undefined") {
|
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.getItem) {
|
||||||
const cPageLS = localStorage.getItem(`${book.id}-page`) || "0";
|
try {
|
||||||
const currentPage = parseInt(cPageLS);
|
const cPageLS = localStorage.getItem(`${book.id}-page`) || "0";
|
||||||
|
const currentPage = parseInt(cPageLS);
|
||||||
|
|
||||||
if (currentPage < readProgressPage) {
|
if (currentPage < readProgressPage) {
|
||||||
|
return readProgressPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentPage;
|
||||||
|
} catch {
|
||||||
return readProgressPage;
|
return readProgressPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
return currentPage;
|
|
||||||
} else {
|
} else {
|
||||||
return readProgressPage;
|
return readProgressPage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static removeCurrentPage(book: KomgaBook) {
|
static removeCurrentPage(book: KomgaBook) {
|
||||||
localStorage.removeItem(`${book.id}-page`);
|
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.removeItem) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(`${book.id}-page`);
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage errors in SSR
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static removeCurrentPageById(bookId: string) {
|
static removeCurrentPageById(bookId: string) {
|
||||||
localStorage.removeItem(`${bookId}-page`);
|
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.removeItem) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(`${bookId}-page`);
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage errors in SSR
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import prisma from "@/lib/prisma";
|
|||||||
import { getCurrentUser } from "../auth-utils";
|
import { getCurrentUser } from "../auth-utils";
|
||||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||||
import { AppError } from "../../utils/errors";
|
import { AppError } from "../../utils/errors";
|
||||||
import type { User, KomgaConfigData, TTLConfigData, KomgaConfig, TTLConfig } from "@/types/komga";
|
import type { User, KomgaConfigData, KomgaConfig } from "@/types/komga";
|
||||||
|
|
||||||
export class ConfigDBService {
|
export class ConfigDBService {
|
||||||
private static async getCurrentUser(): Promise<User> {
|
private static async getCurrentUser(): Promise<User> {
|
||||||
@@ -62,58 +62,4 @@ export class ConfigDBService {
|
|||||||
throw new AppError(ERROR_CODES.CONFIG.FETCH_ERROR, {}, error);
|
throw new AppError(ERROR_CODES.CONFIG.FETCH_ERROR, {}, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getTTLConfig(): Promise<TTLConfig | null> {
|
|
||||||
try {
|
|
||||||
const user: User | null = await this.getCurrentUser();
|
|
||||||
const userId = parseInt(user.id, 10);
|
|
||||||
|
|
||||||
const config = await prisma.tTLConfig.findUnique({
|
|
||||||
where: { userId },
|
|
||||||
});
|
|
||||||
return config as TTLConfig | null;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof AppError) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new AppError(ERROR_CODES.CONFIG.TTL_FETCH_ERROR, {}, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async saveTTLConfig(data: TTLConfigData): Promise<TTLConfig> {
|
|
||||||
try {
|
|
||||||
const user: User | null = await this.getCurrentUser();
|
|
||||||
const userId = parseInt(user.id, 10);
|
|
||||||
|
|
||||||
const config = await prisma.tTLConfig.upsert({
|
|
||||||
where: { userId },
|
|
||||||
update: {
|
|
||||||
defaultTTL: data.defaultTTL,
|
|
||||||
homeTTL: data.homeTTL,
|
|
||||||
librariesTTL: data.librariesTTL,
|
|
||||||
seriesTTL: data.seriesTTL,
|
|
||||||
booksTTL: data.booksTTL,
|
|
||||||
imagesTTL: data.imagesTTL,
|
|
||||||
imageCacheMaxAge: data.imageCacheMaxAge,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
userId,
|
|
||||||
defaultTTL: data.defaultTTL,
|
|
||||||
homeTTL: data.homeTTL,
|
|
||||||
librariesTTL: data.librariesTTL,
|
|
||||||
seriesTTL: data.seriesTTL,
|
|
||||||
booksTTL: data.booksTTL,
|
|
||||||
imagesTTL: data.imagesTTL,
|
|
||||||
imageCacheMaxAge: data.imageCacheMaxAge,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return config as TTLConfig;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof AppError) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new AppError(ERROR_CODES.CONFIG.TTL_SAVE_ERROR, {}, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/lib/services/favorites.service.ts
Normal file
39
src/lib/services/favorites.service.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { FavoriteService } from "./favorite.service";
|
||||||
|
import { SeriesService } from "./series.service";
|
||||||
|
import type { KomgaSeries } from "@/types/komga";
|
||||||
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
|
export class FavoritesService {
|
||||||
|
static async getFavorites(): Promise<KomgaSeries[]> {
|
||||||
|
try {
|
||||||
|
const favoriteIds = await FavoriteService.getAllFavoriteIds();
|
||||||
|
|
||||||
|
if (favoriteIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch toutes les séries en parallèle
|
||||||
|
const promises = favoriteIds.map(async (id: string) => {
|
||||||
|
try {
|
||||||
|
return await SeriesService.getSeries(id);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error, seriesId: id }, "Error fetching favorite series");
|
||||||
|
// Si la série n'existe plus, la retirer des favoris
|
||||||
|
try {
|
||||||
|
await FavoriteService.removeFromFavorites(id);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
return results.filter((series): series is KomgaSeries => series !== null);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, "Error fetching favorites");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,7 +2,6 @@ import { BaseApiService } from "./base-api.service";
|
|||||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||||
import type { LibraryResponse } from "@/types/library";
|
import type { LibraryResponse } from "@/types/library";
|
||||||
import type { HomeData } from "@/types/home";
|
import type { HomeData } from "@/types/home";
|
||||||
import { getServerCacheService } from "./server-cache.service";
|
|
||||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||||
import { AppError } from "../../utils/errors";
|
import { AppError } from "../../utils/errors";
|
||||||
|
|
||||||
@@ -12,80 +11,55 @@ export class HomeService extends BaseApiService {
|
|||||||
static async getHomeData(): Promise<HomeData> {
|
static async getHomeData(): Promise<HomeData> {
|
||||||
try {
|
try {
|
||||||
const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([
|
const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([
|
||||||
this.fetchWithCache<LibraryResponse<KomgaSeries>>(
|
this.fetchFromApi<LibraryResponse<KomgaSeries>>({
|
||||||
"home-ongoing",
|
path: "series",
|
||||||
async () =>
|
params: {
|
||||||
this.fetchFromApi<LibraryResponse<KomgaSeries>>({
|
read_status: "IN_PROGRESS",
|
||||||
path: "series",
|
sort: "readDate,desc",
|
||||||
params: {
|
page: "0",
|
||||||
read_status: "IN_PROGRESS",
|
size: "10",
|
||||||
sort: "readDate,desc",
|
media_status: "READY",
|
||||||
page: "0",
|
},
|
||||||
size: "10",
|
}),
|
||||||
media_status: "READY",
|
this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
||||||
},
|
path: "books",
|
||||||
}),
|
params: {
|
||||||
"HOME"
|
read_status: "IN_PROGRESS",
|
||||||
),
|
sort: "readProgress.readDate,desc",
|
||||||
this.fetchWithCache<LibraryResponse<KomgaBook>>(
|
page: "0",
|
||||||
"home-ongoing-books",
|
size: "10",
|
||||||
async () =>
|
media_status: "READY",
|
||||||
this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
},
|
||||||
path: "books",
|
}),
|
||||||
params: {
|
this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
||||||
read_status: "IN_PROGRESS",
|
path: "books/latest",
|
||||||
sort: "readProgress.readDate,desc",
|
params: {
|
||||||
page: "0",
|
page: "0",
|
||||||
size: "10",
|
size: "10",
|
||||||
media_status: "READY",
|
media_status: "READY",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
"HOME"
|
this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
||||||
),
|
path: "books/ondeck",
|
||||||
this.fetchWithCache<LibraryResponse<KomgaBook>>(
|
params: {
|
||||||
"home-recently-read",
|
page: "0",
|
||||||
async () =>
|
size: "10",
|
||||||
this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
media_status: "READY",
|
||||||
path: "books/latest",
|
},
|
||||||
params: {
|
}),
|
||||||
page: "0",
|
this.fetchFromApi<LibraryResponse<KomgaSeries>>({
|
||||||
size: "10",
|
path: "series/latest",
|
||||||
media_status: "READY",
|
params: {
|
||||||
},
|
page: "0",
|
||||||
}),
|
size: "10",
|
||||||
"HOME"
|
media_status: "READY",
|
||||||
),
|
},
|
||||||
this.fetchWithCache<LibraryResponse<KomgaBook>>(
|
}),
|
||||||
"home-on-deck",
|
|
||||||
async () =>
|
|
||||||
this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
|
||||||
path: "books/ondeck",
|
|
||||||
params: {
|
|
||||||
page: "0",
|
|
||||||
size: "10",
|
|
||||||
media_status: "READY",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
"HOME"
|
|
||||||
),
|
|
||||||
this.fetchWithCache<LibraryResponse<KomgaSeries>>(
|
|
||||||
"home-latest-series",
|
|
||||||
async () =>
|
|
||||||
this.fetchFromApi<LibraryResponse<KomgaSeries>>({
|
|
||||||
path: "series/latest",
|
|
||||||
params: {
|
|
||||||
page: "0",
|
|
||||||
size: "10",
|
|
||||||
media_status: "READY",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
"HOME"
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ongoing: ongoing.content || [],
|
ongoing: ongoing.content || [],
|
||||||
ongoingBooks: ongoingBooks.content || [], // Nouveau champ
|
ongoingBooks: ongoingBooks.content || [],
|
||||||
recentlyRead: recentlyRead.content || [],
|
recentlyRead: recentlyRead.content || [],
|
||||||
onDeck: onDeck.content || [],
|
onDeck: onDeck.content || [],
|
||||||
latestSeries: latestSeries.content || [],
|
latestSeries: latestSeries.content || [],
|
||||||
@@ -97,17 +71,4 @@ export class HomeService extends BaseApiService {
|
|||||||
throw new AppError(ERROR_CODES.HOME.FETCH_ERROR, {}, error);
|
throw new AppError(ERROR_CODES.HOME.FETCH_ERROR, {}, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async invalidateHomeCache(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const cacheService = await getServerCacheService();
|
|
||||||
await cacheService.delete("home-ongoing");
|
|
||||||
await cacheService.delete("home-ongoing-books"); // Nouvelle clé de cache
|
|
||||||
await cacheService.delete("home-recently-read");
|
|
||||||
await cacheService.delete("home-on-deck");
|
|
||||||
await cacheService.delete("home-latest-series");
|
|
||||||
} catch (error) {
|
|
||||||
throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,28 +3,34 @@ import { ERROR_CODES } from "../../constants/errorCodes";
|
|||||||
import { AppError } from "../../utils/errors";
|
import { AppError } from "../../utils/errors";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
export interface ImageResponse {
|
// Cache HTTP navigateur : 30 jours (immutable car les thumbnails ne changent pas)
|
||||||
buffer: Buffer;
|
const IMAGE_CACHE_MAX_AGE = 2592000;
|
||||||
contentType: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ImageService extends BaseApiService {
|
export class ImageService extends BaseApiService {
|
||||||
static async getImage(path: string): Promise<ImageResponse> {
|
/**
|
||||||
|
* Stream an image directly from Komga without buffering in memory
|
||||||
|
* Returns a Response that can be directly returned to the client
|
||||||
|
*/
|
||||||
|
static async streamImage(
|
||||||
|
path: string,
|
||||||
|
cacheMaxAge: number = IMAGE_CACHE_MAX_AGE
|
||||||
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const headers = { Accept: "image/jpeg, image/png, image/gif, image/webp, */*" };
|
const headers = { Accept: "image/jpeg, image/png, image/gif, image/webp, */*" };
|
||||||
|
|
||||||
// NE PAS mettre en cache - les images sont trop grosses et les Buffers ne sérialisent pas bien
|
|
||||||
const response = await this.fetchFromApi<Response>({ path }, headers, { isImage: true });
|
const response = await this.fetchFromApi<Response>({ path }, headers, { isImage: true });
|
||||||
const contentType = response.headers.get("content-type");
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
|
||||||
|
|
||||||
return {
|
// Stream the response body directly without buffering
|
||||||
buffer,
|
return new Response(response.body, {
|
||||||
contentType,
|
status: response.status,
|
||||||
};
|
headers: {
|
||||||
|
"Content-Type": response.headers.get("content-type") || "image/jpeg",
|
||||||
|
"Content-Length": response.headers.get("content-length") || "",
|
||||||
|
"Cache-Control": `public, max-age=${cacheMaxAge}, immutable`,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Erreur lors de la récupération de l'image");
|
logger.error({ err: error }, "Erreur lors du streaming de l'image");
|
||||||
throw new AppError(ERROR_CODES.IMAGE.FETCH_ERROR, {}, error);
|
throw new AppError(ERROR_CODES.IMAGE.FETCH_ERROR, {}, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,51 @@
|
|||||||
import { BaseApiService } from "./base-api.service";
|
import { BaseApiService } from "./base-api.service";
|
||||||
import type { LibraryResponse } from "@/types/library";
|
import type { LibraryResponse } from "@/types/library";
|
||||||
import type { Series } from "@/types/series";
|
import type { Series } from "@/types/series";
|
||||||
import { getServerCacheService } from "./server-cache.service";
|
|
||||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||||
import { AppError } from "../../utils/errors";
|
import { AppError } from "../../utils/errors";
|
||||||
import type { KomgaLibrary } from "@/types/komga";
|
import type { KomgaLibrary } from "@/types/komga";
|
||||||
|
|
||||||
|
// Raw library type from Komga API (without booksCount)
|
||||||
|
interface KomgaLibraryRaw {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
root: string;
|
||||||
|
unavailable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class LibraryService extends BaseApiService {
|
export class LibraryService extends BaseApiService {
|
||||||
static async getLibraries(): Promise<KomgaLibrary[]> {
|
static async getLibraries(): Promise<KomgaLibrary[]> {
|
||||||
try {
|
try {
|
||||||
return this.fetchWithCache<KomgaLibrary[]>(
|
const libraries = await this.fetchFromApi<KomgaLibraryRaw[]>({ path: "libraries" });
|
||||||
"libraries",
|
|
||||||
async () => this.fetchFromApi<KomgaLibrary[]>({ path: "libraries" }),
|
// Enrich each library with book counts
|
||||||
"LIBRARIES"
|
const enrichedLibraries = await Promise.all(
|
||||||
|
libraries.map(async (library) => {
|
||||||
|
try {
|
||||||
|
const booksResponse = await this.fetchFromApi<{ totalElements: number }>({
|
||||||
|
path: "books",
|
||||||
|
params: { library_id: library.id, size: "0" },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...library,
|
||||||
|
importLastModified: "",
|
||||||
|
lastModified: "",
|
||||||
|
booksCount: booksResponse.totalElements,
|
||||||
|
booksReadCount: 0,
|
||||||
|
} as KomgaLibrary;
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
...library,
|
||||||
|
importLastModified: "",
|
||||||
|
lastModified: "",
|
||||||
|
booksCount: 0,
|
||||||
|
booksReadCount: 0,
|
||||||
|
} as KomgaLibrary;
|
||||||
|
}
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return enrichedLibraries;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new AppError(ERROR_CODES.LIBRARY.FETCH_ERROR, {}, error);
|
throw new AppError(ERROR_CODES.LIBRARY.FETCH_ERROR, {}, error);
|
||||||
}
|
}
|
||||||
@@ -21,12 +53,7 @@ export class LibraryService extends BaseApiService {
|
|||||||
|
|
||||||
static async getLibrary(libraryId: string): Promise<KomgaLibrary> {
|
static async getLibrary(libraryId: string): Promise<KomgaLibrary> {
|
||||||
try {
|
try {
|
||||||
const libraries = await this.getLibraries();
|
return this.fetchFromApi<KomgaLibrary>({ path: `libraries/${libraryId}` });
|
||||||
const library = libraries.find((library) => library.id === libraryId);
|
|
||||||
if (!library) {
|
|
||||||
throw new AppError(ERROR_CODES.LIBRARY.NOT_FOUND, { libraryId });
|
|
||||||
}
|
|
||||||
return library;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -87,35 +114,24 @@ export class LibraryService extends BaseApiService {
|
|||||||
|
|
||||||
const searchBody = { condition };
|
const searchBody = { condition };
|
||||||
|
|
||||||
// Clé de cache incluant tous les paramètres
|
const params: Record<string, string | string[]> = {
|
||||||
const cacheKey = `library-${libraryId}-series-p${page}-s${size}-u${unreadOnly}-q${
|
page: String(page),
|
||||||
search || ""
|
size: String(size),
|
||||||
}`;
|
sort: "metadata.titleSort,asc",
|
||||||
|
};
|
||||||
|
|
||||||
const response = await this.fetchWithCache<LibraryResponse<Series>>(
|
// Filtre de recherche Komga (recherche dans le titre)
|
||||||
cacheKey,
|
if (search) {
|
||||||
async () => {
|
params.search = search;
|
||||||
const params: Record<string, string | string[]> = {
|
}
|
||||||
page: String(page),
|
|
||||||
size: String(size),
|
|
||||||
sort: "metadata.titleSort,asc",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filtre de recherche Komga (recherche dans le titre)
|
const response = await this.fetchFromApi<LibraryResponse<Series>>(
|
||||||
if (search) {
|
{ path: "series/list", params },
|
||||||
params.search = search;
|
headers,
|
||||||
}
|
{
|
||||||
|
method: "POST",
|
||||||
return this.fetchFromApi<LibraryResponse<Series>>(
|
body: JSON.stringify(searchBody),
|
||||||
{ path: "series/list", params },
|
}
|
||||||
headers,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(searchBody),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
"SERIES"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filtrer uniquement les séries supprimées côté client (léger)
|
// Filtrer uniquement les séries supprimées côté client (léger)
|
||||||
@@ -131,17 +147,6 @@ export class LibraryService extends BaseApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async invalidateLibrarySeriesCache(libraryId: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const cacheService = await getServerCacheService();
|
|
||||||
// Invalider toutes les clés de cache pour cette bibliothèque
|
|
||||||
// Format: library-{id}-series-p{page}-s{size}-u{unread}-q{search}
|
|
||||||
await cacheService.deleteAll(`library-${libraryId}-series-`);
|
|
||||||
} catch (error) {
|
|
||||||
throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async scanLibrary(libraryId: string, deep: boolean = false): Promise<void> {
|
static async scanLibrary(libraryId: string, deep: boolean = false): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.fetchFromApi(
|
await this.fetchFromApi(
|
||||||
|
|||||||
@@ -2,11 +2,7 @@ import prisma from "@/lib/prisma";
|
|||||||
import { getCurrentUser } from "../auth-utils";
|
import { getCurrentUser } from "../auth-utils";
|
||||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||||
import { AppError } from "../../utils/errors";
|
import { AppError } from "../../utils/errors";
|
||||||
import type {
|
import type { UserPreferences, BackgroundPreferences } from "@/types/preferences";
|
||||||
UserPreferences,
|
|
||||||
BackgroundPreferences,
|
|
||||||
CircuitBreakerConfig,
|
|
||||||
} from "@/types/preferences";
|
|
||||||
import { defaultPreferences } from "@/types/preferences";
|
import { defaultPreferences } from "@/types/preferences";
|
||||||
import type { User } from "@/types/komga";
|
import type { User } from "@/types/komga";
|
||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
@@ -37,7 +33,6 @@ export class PreferencesService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
showThumbnails: preferences.showThumbnails,
|
showThumbnails: preferences.showThumbnails,
|
||||||
cacheMode: preferences.cacheMode as "memory" | "file",
|
|
||||||
showOnlyUnread: preferences.showOnlyUnread,
|
showOnlyUnread: preferences.showOnlyUnread,
|
||||||
displayMode: {
|
displayMode: {
|
||||||
...defaultPreferences.displayMode,
|
...defaultPreferences.displayMode,
|
||||||
@@ -45,9 +40,7 @@ export class PreferencesService {
|
|||||||
viewMode: displayMode?.viewMode || defaultPreferences.displayMode.viewMode,
|
viewMode: displayMode?.viewMode || defaultPreferences.displayMode.viewMode,
|
||||||
},
|
},
|
||||||
background: preferences.background as unknown as BackgroundPreferences,
|
background: preferences.background as unknown as BackgroundPreferences,
|
||||||
komgaMaxConcurrentRequests: preferences.komgaMaxConcurrentRequests,
|
|
||||||
readerPrefetchCount: preferences.readerPrefetchCount,
|
readerPrefetchCount: preferences.readerPrefetchCount,
|
||||||
circuitBreakerConfig: preferences.circuitBreakerConfig as unknown as CircuitBreakerConfig,
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
@@ -65,17 +58,12 @@ export class PreferencesService {
|
|||||||
const updateData: Record<string, any> = {};
|
const updateData: Record<string, any> = {};
|
||||||
if (preferences.showThumbnails !== undefined)
|
if (preferences.showThumbnails !== undefined)
|
||||||
updateData.showThumbnails = preferences.showThumbnails;
|
updateData.showThumbnails = preferences.showThumbnails;
|
||||||
if (preferences.cacheMode !== undefined) updateData.cacheMode = preferences.cacheMode;
|
|
||||||
if (preferences.showOnlyUnread !== undefined)
|
if (preferences.showOnlyUnread !== undefined)
|
||||||
updateData.showOnlyUnread = preferences.showOnlyUnread;
|
updateData.showOnlyUnread = preferences.showOnlyUnread;
|
||||||
if (preferences.displayMode !== undefined) updateData.displayMode = preferences.displayMode;
|
if (preferences.displayMode !== undefined) updateData.displayMode = preferences.displayMode;
|
||||||
if (preferences.background !== undefined) updateData.background = preferences.background;
|
if (preferences.background !== undefined) updateData.background = preferences.background;
|
||||||
if (preferences.komgaMaxConcurrentRequests !== undefined)
|
|
||||||
updateData.komgaMaxConcurrentRequests = preferences.komgaMaxConcurrentRequests;
|
|
||||||
if (preferences.readerPrefetchCount !== undefined)
|
if (preferences.readerPrefetchCount !== undefined)
|
||||||
updateData.readerPrefetchCount = preferences.readerPrefetchCount;
|
updateData.readerPrefetchCount = preferences.readerPrefetchCount;
|
||||||
if (preferences.circuitBreakerConfig !== undefined)
|
|
||||||
updateData.circuitBreakerConfig = preferences.circuitBreakerConfig;
|
|
||||||
|
|
||||||
const updatedPreferences = await prisma.preferences.upsert({
|
const updatedPreferences = await prisma.preferences.upsert({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -83,28 +71,20 @@ export class PreferencesService {
|
|||||||
create: {
|
create: {
|
||||||
userId,
|
userId,
|
||||||
showThumbnails: preferences.showThumbnails ?? defaultPreferences.showThumbnails,
|
showThumbnails: preferences.showThumbnails ?? defaultPreferences.showThumbnails,
|
||||||
cacheMode: preferences.cacheMode ?? defaultPreferences.cacheMode,
|
|
||||||
showOnlyUnread: preferences.showOnlyUnread ?? defaultPreferences.showOnlyUnread,
|
showOnlyUnread: preferences.showOnlyUnread ?? defaultPreferences.showOnlyUnread,
|
||||||
displayMode: preferences.displayMode ?? defaultPreferences.displayMode,
|
displayMode: preferences.displayMode ?? defaultPreferences.displayMode,
|
||||||
background: (preferences.background ??
|
background: (preferences.background ??
|
||||||
defaultPreferences.background) as unknown as Prisma.InputJsonValue,
|
defaultPreferences.background) as unknown as Prisma.InputJsonValue,
|
||||||
circuitBreakerConfig: (preferences.circuitBreakerConfig ??
|
|
||||||
defaultPreferences.circuitBreakerConfig) as unknown as Prisma.InputJsonValue,
|
|
||||||
komgaMaxConcurrentRequests: preferences.komgaMaxConcurrentRequests ?? 5,
|
|
||||||
readerPrefetchCount: preferences.readerPrefetchCount ?? 5,
|
readerPrefetchCount: preferences.readerPrefetchCount ?? 5,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
showThumbnails: updatedPreferences.showThumbnails,
|
showThumbnails: updatedPreferences.showThumbnails,
|
||||||
cacheMode: updatedPreferences.cacheMode as "memory" | "file",
|
|
||||||
showOnlyUnread: updatedPreferences.showOnlyUnread,
|
showOnlyUnread: updatedPreferences.showOnlyUnread,
|
||||||
displayMode: updatedPreferences.displayMode as UserPreferences["displayMode"],
|
displayMode: updatedPreferences.displayMode as UserPreferences["displayMode"],
|
||||||
background: updatedPreferences.background as unknown as BackgroundPreferences,
|
background: updatedPreferences.background as unknown as BackgroundPreferences,
|
||||||
komgaMaxConcurrentRequests: updatedPreferences.komgaMaxConcurrentRequests,
|
|
||||||
readerPrefetchCount: updatedPreferences.readerPrefetchCount,
|
readerPrefetchCount: updatedPreferences.readerPrefetchCount,
|
||||||
circuitBreakerConfig:
|
|
||||||
updatedPreferences.circuitBreakerConfig as unknown as CircuitBreakerConfig,
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* Service de monitoring des requêtes concurrentes vers Komga
|
|
||||||
* Permet de tracker le nombre de requêtes actives et d'alerter en cas de charge élevée
|
|
||||||
*/
|
|
||||||
import logger from "@/lib/logger";
|
|
||||||
|
|
||||||
class RequestMonitor {
|
|
||||||
private activeRequests = 0;
|
|
||||||
private readonly thresholds = {
|
|
||||||
warning: 10,
|
|
||||||
high: 20,
|
|
||||||
critical: 30,
|
|
||||||
};
|
|
||||||
|
|
||||||
incrementActive(): number {
|
|
||||||
this.activeRequests++;
|
|
||||||
this.checkThresholds();
|
|
||||||
return this.activeRequests;
|
|
||||||
}
|
|
||||||
|
|
||||||
decrementActive(): number {
|
|
||||||
this.activeRequests = Math.max(0, this.activeRequests - 1);
|
|
||||||
return this.activeRequests;
|
|
||||||
}
|
|
||||||
|
|
||||||
getActiveCount(): number {
|
|
||||||
return this.activeRequests;
|
|
||||||
}
|
|
||||||
|
|
||||||
private checkThresholds(): void {
|
|
||||||
const count = this.activeRequests;
|
|
||||||
|
|
||||||
if (count >= this.thresholds.critical) {
|
|
||||||
logger.warn(`[REQUEST-MONITOR] 🔴 CRITICAL concurrency: ${count} active requests`);
|
|
||||||
} else if (count >= this.thresholds.high) {
|
|
||||||
logger.warn(`[REQUEST-MONITOR] ⚠️ HIGH concurrency: ${count} active requests`);
|
|
||||||
} else if (count >= this.thresholds.warning) {
|
|
||||||
logger.info(`[REQUEST-MONITOR] ⚡ Warning concurrency: ${count} active requests`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Singleton instance
|
|
||||||
export const RequestMonitorService = new RequestMonitor();
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
/**
|
|
||||||
* Service de gestion de queue pour limiter les requêtes concurrentes vers Komga
|
|
||||||
* Évite de surcharger Komga avec trop de requêtes simultanées
|
|
||||||
*/
|
|
||||||
import logger from "@/lib/logger";
|
|
||||||
|
|
||||||
interface QueuedRequest<T> {
|
|
||||||
execute: () => Promise<T>;
|
|
||||||
resolve: (value: T) => void;
|
|
||||||
reject: (error: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
class RequestQueue {
|
|
||||||
private queue: QueuedRequest<any>[] = [];
|
|
||||||
private activeCount = 0;
|
|
||||||
private maxConcurrent: number;
|
|
||||||
private getMaxConcurrent: (() => Promise<number>) | null = null;
|
|
||||||
|
|
||||||
constructor(maxConcurrent?: number) {
|
|
||||||
// Valeur par défaut
|
|
||||||
this.maxConcurrent = maxConcurrent ?? 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure une fonction pour récupérer dynamiquement le max concurrent depuis les préférences
|
|
||||||
*/
|
|
||||||
setMaxConcurrentGetter(getter: () => Promise<number>): void {
|
|
||||||
this.getMaxConcurrent = getter;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Récupère la valeur de maxConcurrent, soit depuis les préférences, soit depuis la valeur fixe
|
|
||||||
*/
|
|
||||||
private async getCurrentMaxConcurrent(): Promise<number> {
|
|
||||||
if (this.getMaxConcurrent) {
|
|
||||||
try {
|
|
||||||
return await this.getMaxConcurrent();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Error getting maxConcurrent from preferences, using default");
|
|
||||||
return this.maxConcurrent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.maxConcurrent;
|
|
||||||
}
|
|
||||||
|
|
||||||
async enqueue<T>(execute: () => Promise<T>): Promise<T> {
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
// Limiter la taille de la queue pour éviter l'accumulation
|
|
||||||
if (this.queue.length >= 50) {
|
|
||||||
reject(new Error("Request queue is full - Komga may be overloaded"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.queue.push({ execute, resolve, reject });
|
|
||||||
this.processQueue();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async delay(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processQueue(): Promise<void> {
|
|
||||||
const maxConcurrent = await this.getCurrentMaxConcurrent();
|
|
||||||
if (this.activeCount >= maxConcurrent || this.queue.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.activeCount++;
|
|
||||||
const request = this.queue.shift();
|
|
||||||
|
|
||||||
if (!request) {
|
|
||||||
this.activeCount--;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Délai adaptatif : plus long si la queue est pleine
|
|
||||||
// Désactivé en mode debug pour ne pas ralentir les tests
|
|
||||||
const isDebug = process.env.KOMGA_DEBUG === "true";
|
|
||||||
if (!isDebug) {
|
|
||||||
const delayMs = this.queue.length > 10 ? 500 : 200;
|
|
||||||
await this.delay(delayMs);
|
|
||||||
}
|
|
||||||
const result = await request.execute();
|
|
||||||
request.resolve(result);
|
|
||||||
} catch (error) {
|
|
||||||
request.reject(error);
|
|
||||||
} finally {
|
|
||||||
this.activeCount--;
|
|
||||||
this.processQueue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getActiveCount(): number {
|
|
||||||
return this.activeCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
getQueueLength(): number {
|
|
||||||
return this.queue.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
setMaxConcurrent(max: number): void {
|
|
||||||
this.maxConcurrent = max;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Singleton instance - Par défaut limite à 5 requêtes simultanées
|
|
||||||
export const RequestQueueService = new RequestQueue(5);
|
|
||||||
@@ -1,50 +1,23 @@
|
|||||||
import { BaseApiService } from "./base-api.service";
|
import { BaseApiService } from "./base-api.service";
|
||||||
import type { LibraryResponse } from "@/types/library";
|
import type { LibraryResponse } from "@/types/library";
|
||||||
import type { KomgaBook, KomgaSeries, TTLConfig } from "@/types/komga";
|
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||||
import { BookService } from "./book.service";
|
import { BookService } from "./book.service";
|
||||||
import type { ImageResponse } from "./image.service";
|
|
||||||
import { ImageService } from "./image.service";
|
import { ImageService } from "./image.service";
|
||||||
import { PreferencesService } from "./preferences.service";
|
import { PreferencesService } from "./preferences.service";
|
||||||
import { ConfigDBService } from "./config-db.service";
|
|
||||||
import { getServerCacheService } from "./server-cache.service";
|
|
||||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||||
import { AppError } from "../../utils/errors";
|
import { AppError } from "../../utils/errors";
|
||||||
import type { UserPreferences } from "@/types/preferences";
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
import type { ServerCacheService } from "./server-cache.service";
|
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
export class SeriesService extends BaseApiService {
|
export class SeriesService extends BaseApiService {
|
||||||
private static async getImageCacheMaxAge(): Promise<number> {
|
|
||||||
try {
|
|
||||||
const ttlConfig: TTLConfig | null = await ConfigDBService.getTTLConfig();
|
|
||||||
const maxAge = ttlConfig?.imageCacheMaxAge ?? 2592000;
|
|
||||||
return maxAge;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "[ImageCache] Error fetching TTL config");
|
|
||||||
return 2592000; // 30 jours par défaut en cas d'erreur
|
|
||||||
}
|
|
||||||
}
|
|
||||||
static async getSeries(seriesId: string): Promise<KomgaSeries> {
|
static async getSeries(seriesId: string): Promise<KomgaSeries> {
|
||||||
try {
|
try {
|
||||||
return this.fetchWithCache<KomgaSeries>(
|
return this.fetchFromApi<KomgaSeries>({ path: `series/${seriesId}` });
|
||||||
`series-${seriesId}`,
|
|
||||||
async () => this.fetchFromApi<KomgaSeries>({ path: `series/${seriesId}` }),
|
|
||||||
"SERIES"
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async invalidateSeriesCache(seriesId: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const cacheService = await getServerCacheService();
|
|
||||||
await cacheService.delete(`series-${seriesId}`);
|
|
||||||
} catch (error) {
|
|
||||||
throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getSeriesBooks(
|
static async getSeriesBooks(
|
||||||
seriesId: string,
|
seriesId: string,
|
||||||
page: number = 0,
|
page: number = 0,
|
||||||
@@ -96,28 +69,19 @@ export class SeriesService extends BaseApiService {
|
|||||||
|
|
||||||
const searchBody = { condition };
|
const searchBody = { condition };
|
||||||
|
|
||||||
// Clé de cache incluant tous les paramètres
|
const params: Record<string, string | string[]> = {
|
||||||
const cacheKey = `series-${seriesId}-books-p${page}-s${size}-u${unreadOnly}`;
|
page: String(page),
|
||||||
|
size: String(size),
|
||||||
|
sort: "number,asc",
|
||||||
|
};
|
||||||
|
|
||||||
const response = await this.fetchWithCache<LibraryResponse<KomgaBook>>(
|
const response = await this.fetchFromApi<LibraryResponse<KomgaBook>>(
|
||||||
cacheKey,
|
{ path: "books/list", params },
|
||||||
async () => {
|
headers,
|
||||||
const params: Record<string, string | string[]> = {
|
{
|
||||||
page: String(page),
|
method: "POST",
|
||||||
size: String(size),
|
body: JSON.stringify(searchBody),
|
||||||
sort: "number,asc",
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return this.fetchFromApi<LibraryResponse<KomgaBook>>(
|
|
||||||
{ path: "books/list", params },
|
|
||||||
headers,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(searchBody),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
"BOOKS"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filtrer uniquement les livres supprimés côté client (léger)
|
// Filtrer uniquement les livres supprimés côté client (léger)
|
||||||
@@ -133,36 +97,17 @@ export class SeriesService extends BaseApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async invalidateSeriesBooksCache(seriesId: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const cacheService: ServerCacheService = await getServerCacheService();
|
|
||||||
// Invalider toutes les clés de cache pour cette série
|
|
||||||
// Format: series-{id}-books-p{page}-s{size}-u{unread}
|
|
||||||
await cacheService.deleteAll(`series-${seriesId}-books-`);
|
|
||||||
} catch (error) {
|
|
||||||
throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getFirstBook(seriesId: string): Promise<string> {
|
static async getFirstBook(seriesId: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
return this.fetchWithCache<string>(
|
const data: LibraryResponse<KomgaBook> = await this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
||||||
`series-first-book-${seriesId}`,
|
path: `series/${seriesId}/books`,
|
||||||
async () => {
|
params: { page: "0", size: "1" },
|
||||||
const data: LibraryResponse<KomgaBook> = await this.fetchFromApi<
|
});
|
||||||
LibraryResponse<KomgaBook>
|
if (!data.content || data.content.length === 0) {
|
||||||
>({
|
throw new AppError(ERROR_CODES.SERIES.NO_BOOKS_FOUND);
|
||||||
path: `series/${seriesId}/books`,
|
}
|
||||||
params: { page: "0", size: "1" },
|
|
||||||
});
|
|
||||||
if (!data.content || data.content.length === 0) {
|
|
||||||
throw new AppError(ERROR_CODES.SERIES.NO_BOOKS_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.content[0].id;
|
return data.content[0].id;
|
||||||
},
|
|
||||||
"SERIES"
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Erreur lors de la récupération du premier livre");
|
logger.error({ err: error }, "Erreur lors de la récupération du premier livre");
|
||||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||||
@@ -173,23 +118,15 @@ export class SeriesService extends BaseApiService {
|
|||||||
try {
|
try {
|
||||||
// Récupérer les préférences de l'utilisateur
|
// Récupérer les préférences de l'utilisateur
|
||||||
const preferences: UserPreferences = await PreferencesService.getPreferences();
|
const preferences: UserPreferences = await PreferencesService.getPreferences();
|
||||||
const maxAge = await this.getImageCacheMaxAge();
|
|
||||||
|
|
||||||
// Si l'utilisateur préfère les vignettes, utiliser la miniature
|
// Si l'utilisateur préfère les vignettes, utiliser la miniature (streaming)
|
||||||
if (preferences.showThumbnails) {
|
if (preferences.showThumbnails) {
|
||||||
const response: ImageResponse = await ImageService.getImage(`series/${seriesId}/thumbnail`);
|
return ImageService.streamImage(`series/${seriesId}/thumbnail`);
|
||||||
return new Response(response.buffer.buffer as ArrayBuffer, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": response.contentType || "image/jpeg",
|
|
||||||
"Cache-Control": `public, max-age=${maxAge}, immutable`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sinon, récupérer la première page
|
// Sinon, récupérer la première page (streaming)
|
||||||
const firstBookId = await this.getFirstBook(seriesId);
|
const firstBookId = await this.getFirstBook(seriesId);
|
||||||
const response = await BookService.getPage(firstBookId, 1);
|
return BookService.getPage(firstBookId, 1);
|
||||||
return response;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,730 +0,0 @@
|
|||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
import { PreferencesService } from "./preferences.service";
|
|
||||||
import { getCurrentUser } from "../auth-utils";
|
|
||||||
import logger from "@/lib/logger";
|
|
||||||
|
|
||||||
export type CacheMode = "file" | "memory";
|
|
||||||
|
|
||||||
interface CacheConfig {
|
|
||||||
mode: CacheMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ServerCacheService {
|
|
||||||
private static instance: ServerCacheService;
|
|
||||||
private cacheDir: string;
|
|
||||||
private memoryCache: Map<string, { data: unknown; expiry: number }> = new Map();
|
|
||||||
private config: CacheConfig = {
|
|
||||||
mode: "memory",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Configuration des temps de cache en millisecondes
|
|
||||||
private static readonly oneMinute = 1 * 60 * 1000;
|
|
||||||
private static readonly twoMinutes = 2 * 60 * 1000;
|
|
||||||
private static readonly fiveMinutes = 5 * 60 * 1000;
|
|
||||||
private static readonly tenMinutes = 10 * 60 * 1000;
|
|
||||||
private static readonly twentyFourHours = 24 * 60 * 60 * 1000;
|
|
||||||
private static readonly oneWeek = 7 * 24 * 60 * 60 * 1000;
|
|
||||||
private static readonly noCache = 0;
|
|
||||||
|
|
||||||
// Configuration des temps de cache
|
|
||||||
// Optimisé pour la pagination native Komga :
|
|
||||||
// - Listes paginées (SERIES, BOOKS) : TTL court (2 min) car données fraîches + progression utilisateur
|
|
||||||
// - Données agrégées (HOME) : TTL moyen (10 min) car plusieurs sources
|
|
||||||
// - Données statiques (LIBRARIES) : TTL long (24h) car changent rarement
|
|
||||||
// - Images : TTL très long (7 jours) car immuables
|
|
||||||
private static readonly DEFAULT_TTL = {
|
|
||||||
DEFAULT: ServerCacheService.fiveMinutes,
|
|
||||||
HOME: ServerCacheService.tenMinutes,
|
|
||||||
LIBRARIES: ServerCacheService.twentyFourHours,
|
|
||||||
SERIES: ServerCacheService.twoMinutes, // Listes paginées avec progression
|
|
||||||
BOOKS: ServerCacheService.twoMinutes, // Listes paginées avec progression
|
|
||||||
IMAGES: ServerCacheService.oneWeek,
|
|
||||||
};
|
|
||||||
|
|
||||||
private constructor() {
|
|
||||||
this.cacheDir = path.join(process.cwd(), ".cache");
|
|
||||||
this.ensureCacheDirectory();
|
|
||||||
this.cleanExpiredCache();
|
|
||||||
this.initializeCacheMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async initializeCacheMode(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const user = await getCurrentUser();
|
|
||||||
if (!user) {
|
|
||||||
this.setCacheMode("memory");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const preferences = await PreferencesService.getPreferences();
|
|
||||||
this.setCacheMode(preferences.cacheMode);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Error initializing cache mode from preferences");
|
|
||||||
// Keep default memory mode if preferences can't be loaded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private ensureCacheDirectory(): void {
|
|
||||||
if (!fs.existsSync(this.cacheDir)) {
|
|
||||||
fs.mkdirSync(this.cacheDir, { recursive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCacheFilePath(key: string): string {
|
|
||||||
// Nettoyer la clé des caractères spéciaux et des doubles slashes
|
|
||||||
const sanitizedKey = key.replace(/[<>:"|?*]/g, "_").replace(/\/+/g, "/");
|
|
||||||
|
|
||||||
const filePath = path.join(this.cacheDir, `${sanitizedKey}.json`);
|
|
||||||
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
private cleanExpiredCache(): void {
|
|
||||||
if (!fs.existsSync(this.cacheDir)) return;
|
|
||||||
|
|
||||||
const cleanDirectory = (dirPath: string): boolean => {
|
|
||||||
if (!fs.existsSync(dirPath)) return true;
|
|
||||||
|
|
||||||
const items = fs.readdirSync(dirPath);
|
|
||||||
let isEmpty = true;
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const itemPath = path.join(dirPath, item);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stats = fs.statSync(itemPath);
|
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
|
||||||
const isSubDirEmpty = cleanDirectory(itemPath);
|
|
||||||
if (isSubDirEmpty) {
|
|
||||||
try {
|
|
||||||
fs.rmdirSync(itemPath);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
{ err: error, path: itemPath },
|
|
||||||
`Could not remove directory ${itemPath}`
|
|
||||||
);
|
|
||||||
isEmpty = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
isEmpty = false;
|
|
||||||
}
|
|
||||||
} else if (stats.isFile() && item.endsWith(".json")) {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(itemPath, "utf-8");
|
|
||||||
const cached = JSON.parse(content);
|
|
||||||
if (cached.expiry < Date.now()) {
|
|
||||||
fs.unlinkSync(itemPath);
|
|
||||||
} else {
|
|
||||||
isEmpty = false;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error, path: itemPath }, `Could not parse file ${itemPath}`);
|
|
||||||
// Si le fichier est corrompu, on le supprime
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(itemPath);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error, path: itemPath }, `Could not remove file ${itemPath}`);
|
|
||||||
isEmpty = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
isEmpty = false;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error, path: itemPath }, `Could not access ${itemPath}`);
|
|
||||||
// En cas d'erreur sur le fichier/dossier, on continue
|
|
||||||
isEmpty = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return isEmpty;
|
|
||||||
};
|
|
||||||
|
|
||||||
cleanDirectory(this.cacheDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async getInstance(): Promise<ServerCacheService> {
|
|
||||||
if (!ServerCacheService.instance) {
|
|
||||||
ServerCacheService.instance = new ServerCacheService();
|
|
||||||
await ServerCacheService.instance.initializeCacheMode();
|
|
||||||
}
|
|
||||||
return ServerCacheService.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retourne le TTL pour un type de données spécifique
|
|
||||||
*/
|
|
||||||
public getTTL(type: keyof typeof ServerCacheService.DEFAULT_TTL): number {
|
|
||||||
// Utiliser directement la valeur par défaut
|
|
||||||
return ServerCacheService.DEFAULT_TTL[type];
|
|
||||||
}
|
|
||||||
|
|
||||||
public setCacheMode(mode: CacheMode): void {
|
|
||||||
if (this.config.mode === mode) return;
|
|
||||||
|
|
||||||
// Si on passe de mémoire à fichier, on sauvegarde le cache en mémoire
|
|
||||||
if (mode === "file" && this.config.mode === "memory") {
|
|
||||||
this.memoryCache.forEach((value, key) => {
|
|
||||||
if (value.expiry > Date.now()) {
|
|
||||||
this.saveToFile(key, value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.memoryCache.clear();
|
|
||||||
}
|
|
||||||
// Si on passe de fichier à mémoire, on charge le cache fichier en mémoire
|
|
||||||
else if (mode === "memory" && this.config.mode === "file") {
|
|
||||||
this.loadFileCacheToMemory();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.config.mode = mode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getCacheMode(): CacheMode {
|
|
||||||
return this.config.mode;
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadFileCacheToMemory(): void {
|
|
||||||
if (!fs.existsSync(this.cacheDir)) return;
|
|
||||||
|
|
||||||
const loadDirectory = (dirPath: string) => {
|
|
||||||
const items = fs.readdirSync(dirPath);
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const itemPath = path.join(dirPath, item);
|
|
||||||
try {
|
|
||||||
const stats = fs.statSync(itemPath);
|
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
|
||||||
loadDirectory(itemPath);
|
|
||||||
} else if (stats.isFile() && item.endsWith(".json")) {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(itemPath, "utf-8");
|
|
||||||
const cached = JSON.parse(content);
|
|
||||||
if (cached.expiry > Date.now()) {
|
|
||||||
const key = path.relative(this.cacheDir, itemPath).slice(0, -5); // Remove .json
|
|
||||||
this.memoryCache.set(key, cached);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error, path: itemPath }, `Could not parse file ${itemPath}`);
|
|
||||||
// Ignore les fichiers corrompus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error, path: itemPath }, `Could not access ${itemPath}`);
|
|
||||||
// Ignore les erreurs d'accès
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadDirectory(this.cacheDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
private saveToFile(key: string, value: { data: unknown; expiry: number }): void {
|
|
||||||
const filePath = this.getCacheFilePath(key);
|
|
||||||
const dirPath = path.dirname(filePath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(dirPath)) {
|
|
||||||
fs.mkdirSync(dirPath, { recursive: true });
|
|
||||||
}
|
|
||||||
fs.writeFileSync(filePath, JSON.stringify(value), "utf-8");
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error, path: filePath }, `Could not write cache file ${filePath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Met en cache des données avec une durée de vie
|
|
||||||
*/
|
|
||||||
set(key: string, data: any, type: keyof typeof ServerCacheService.DEFAULT_TTL = "DEFAULT"): void {
|
|
||||||
const cacheData = {
|
|
||||||
data,
|
|
||||||
expiry: Date.now() + this.getTTL(type),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.config.mode === "memory") {
|
|
||||||
this.memoryCache.set(key, cacheData);
|
|
||||||
} else {
|
|
||||||
const filePath = this.getCacheFilePath(key);
|
|
||||||
const dirPath = path.dirname(filePath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(dirPath)) {
|
|
||||||
fs.mkdirSync(dirPath, { recursive: true });
|
|
||||||
}
|
|
||||||
fs.writeFileSync(filePath, JSON.stringify(cacheData), "utf-8");
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error, path: filePath }, `Error writing cache file ${filePath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Récupère des données du cache si elles sont valides
|
|
||||||
*/
|
|
||||||
get(key: string): any | null {
|
|
||||||
if (this.config.mode === "memory") {
|
|
||||||
const cached = this.memoryCache.get(key);
|
|
||||||
if (!cached) return null;
|
|
||||||
|
|
||||||
if (cached.expiry > Date.now()) {
|
|
||||||
return cached.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.memoryCache.delete(key);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = this.getCacheFilePath(key);
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(filePath, "utf-8");
|
|
||||||
const cached = JSON.parse(content);
|
|
||||||
|
|
||||||
if (cached.expiry > Date.now()) {
|
|
||||||
return cached.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.unlinkSync(filePath);
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error, path: filePath }, `Error reading cache file ${filePath}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Récupère des données du cache même si elles sont expirées (stale)
|
|
||||||
* Retourne { data, isStale } ou null si pas de cache
|
|
||||||
*/
|
|
||||||
private getStale(key: string): { data: any; isStale: boolean } | null {
|
|
||||||
if (this.config.mode === "memory") {
|
|
||||||
const cached = this.memoryCache.get(key);
|
|
||||||
if (!cached) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: cached.data,
|
|
||||||
isStale: cached.expiry <= Date.now(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = this.getCacheFilePath(key);
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(filePath, "utf-8");
|
|
||||||
const cached = JSON.parse(content);
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: cached.data,
|
|
||||||
isStale: cached.expiry <= Date.now(),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error, path: filePath }, `Error reading cache file ${filePath}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Supprime une entrée du cache
|
|
||||||
*/
|
|
||||||
async delete(key: string): Promise<void> {
|
|
||||||
const user = await getCurrentUser();
|
|
||||||
if (!user) {
|
|
||||||
throw new Error("Utilisateur non authentifié");
|
|
||||||
}
|
|
||||||
const cacheKey = `${user.id}-${key}`;
|
|
||||||
|
|
||||||
if (this.config.mode === "memory") {
|
|
||||||
this.memoryCache.delete(cacheKey);
|
|
||||||
} else {
|
|
||||||
const filePath = this.getCacheFilePath(cacheKey);
|
|
||||||
if (fs.existsSync(filePath)) {
|
|
||||||
fs.unlinkSync(filePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Supprime toutes les entrées du cache qui commencent par un préfixe
|
|
||||||
*/
|
|
||||||
async deleteAll(prefix: string): Promise<void> {
|
|
||||||
const user = await getCurrentUser();
|
|
||||||
if (!user) {
|
|
||||||
throw new Error("Utilisateur non authentifié");
|
|
||||||
}
|
|
||||||
const prefixKey = `${user.id}-${prefix}`;
|
|
||||||
|
|
||||||
if (this.config.mode === "memory") {
|
|
||||||
this.memoryCache.forEach((value, key) => {
|
|
||||||
if (key.startsWith(prefixKey)) {
|
|
||||||
this.memoryCache.delete(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// En mode fichier, parcourir récursivement tous les fichiers et supprimer ceux qui correspondent
|
|
||||||
if (!fs.existsSync(this.cacheDir)) return;
|
|
||||||
|
|
||||||
const deleteMatchingFiles = (dirPath: string): void => {
|
|
||||||
const items = fs.readdirSync(dirPath);
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const itemPath = path.join(dirPath, item);
|
|
||||||
try {
|
|
||||||
const stats = fs.statSync(itemPath);
|
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
|
||||||
deleteMatchingFiles(itemPath);
|
|
||||||
// Supprimer le répertoire s'il est vide après suppression des fichiers
|
|
||||||
try {
|
|
||||||
const remainingItems = fs.readdirSync(itemPath);
|
|
||||||
if (remainingItems.length === 0) {
|
|
||||||
fs.rmdirSync(itemPath);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore les erreurs de suppression de répertoire
|
|
||||||
}
|
|
||||||
} else if (stats.isFile() && item.endsWith(".json")) {
|
|
||||||
// Extraire la clé du chemin relatif (sans l'extension .json)
|
|
||||||
const relativePath = path.relative(this.cacheDir, itemPath);
|
|
||||||
const key = relativePath.slice(0, -5).replace(/\\/g, "/"); // Remove .json and normalize slashes
|
|
||||||
|
|
||||||
if (key.startsWith(prefixKey)) {
|
|
||||||
fs.unlinkSync(itemPath);
|
|
||||||
if (process.env.CACHE_DEBUG === "true") {
|
|
||||||
logger.debug(`🗑️ [CACHE DELETE] ${key}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error, path: itemPath }, `Could not delete cache file ${itemPath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
deleteMatchingFiles(this.cacheDir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Vide le cache
|
|
||||||
*/
|
|
||||||
clear(): void {
|
|
||||||
if (this.config.mode === "memory") {
|
|
||||||
this.memoryCache.clear();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(this.cacheDir)) return;
|
|
||||||
|
|
||||||
const removeDirectory = (dirPath: string) => {
|
|
||||||
if (!fs.existsSync(dirPath)) return;
|
|
||||||
|
|
||||||
const items = fs.readdirSync(dirPath);
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const itemPath = path.join(dirPath, item);
|
|
||||||
try {
|
|
||||||
const stats = fs.statSync(itemPath);
|
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
|
||||||
removeDirectory(itemPath);
|
|
||||||
try {
|
|
||||||
fs.rmdirSync(itemPath);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
{ err: error, path: itemPath },
|
|
||||||
`Could not remove directory ${itemPath}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(itemPath);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error, path: itemPath }, `Could not remove file ${itemPath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error, path: itemPath }, `Error accessing ${itemPath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
removeDirectory(this.cacheDir);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Error clearing cache");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Récupère des données du cache ou exécute la fonction si nécessaire
|
|
||||||
* Stratégie stale-while-revalidate:
|
|
||||||
* - Cache valide → retourne immédiatement
|
|
||||||
* - Cache expiré → retourne le cache expiré ET revalide en background
|
|
||||||
* - Pas de cache → fetch normalement
|
|
||||||
*/
|
|
||||||
async getOrSet<T>(
|
|
||||||
key: string,
|
|
||||||
fetcher: () => Promise<T>,
|
|
||||||
type: keyof typeof ServerCacheService.DEFAULT_TTL = "DEFAULT"
|
|
||||||
): Promise<T> {
|
|
||||||
const startTime = performance.now();
|
|
||||||
const user = await getCurrentUser();
|
|
||||||
if (!user) {
|
|
||||||
throw new Error("Utilisateur non authentifié");
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheKey = `${user.id}-${key}`;
|
|
||||||
const cachedResult = this.getStale(cacheKey);
|
|
||||||
|
|
||||||
if (cachedResult !== null) {
|
|
||||||
const { data, isStale } = cachedResult;
|
|
||||||
const endTime = performance.now();
|
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
if (process.env.CACHE_DEBUG === "true") {
|
|
||||||
const icon = isStale ? "⚠️" : "✅";
|
|
||||||
const status = isStale ? "STALE" : "HIT";
|
|
||||||
logger.debug(
|
|
||||||
`${icon} [CACHE ${status}] ${key} | ${type} | ${(endTime - startTime).toFixed(2)}ms`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si le cache est expiré, revalider en background sans bloquer la réponse
|
|
||||||
if (isStale) {
|
|
||||||
// Fire and forget - revalidate en background
|
|
||||||
this.revalidateInBackground(cacheKey, fetcher, type, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pas de cache du tout, fetch normalement
|
|
||||||
if (process.env.CACHE_DEBUG === "true") {
|
|
||||||
logger.debug(`❌ [CACHE MISS] ${key} | ${type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await fetcher();
|
|
||||||
this.set(cacheKey, data, type);
|
|
||||||
|
|
||||||
const endTime = performance.now();
|
|
||||||
if (process.env.CACHE_DEBUG === "true") {
|
|
||||||
logger.debug(`💾 [CACHE SET] ${key} | ${type} | ${(endTime - startTime).toFixed(2)}ms`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revalide le cache en background
|
|
||||||
*/
|
|
||||||
private async revalidateInBackground<T>(
|
|
||||||
cacheKey: string,
|
|
||||||
fetcher: () => Promise<T>,
|
|
||||||
type: keyof typeof ServerCacheService.DEFAULT_TTL,
|
|
||||||
debugKey: string
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const startTime = performance.now();
|
|
||||||
const data = await fetcher();
|
|
||||||
this.set(cacheKey, data, type);
|
|
||||||
|
|
||||||
if (process.env.CACHE_DEBUG === "true") {
|
|
||||||
const endTime = performance.now();
|
|
||||||
logger.debug(
|
|
||||||
`🔄 [CACHE REVALIDATE] ${debugKey} | ${type} | ${(endTime - startTime).toFixed(2)}ms`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error, key: debugKey }, `🔴 [CACHE REVALIDATE ERROR] ${debugKey}`);
|
|
||||||
// Ne pas relancer l'erreur car c'est en background
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidate(key: string): void {
|
|
||||||
this.delete(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calcule la taille approximative d'un objet en mémoire
|
|
||||||
*/
|
|
||||||
private calculateObjectSize(obj: unknown): number {
|
|
||||||
if (obj === null || obj === undefined) return 0;
|
|
||||||
|
|
||||||
// Si c'est un Buffer, utiliser sa taille réelle
|
|
||||||
if (Buffer.isBuffer(obj)) {
|
|
||||||
return obj.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si c'est un objet avec une propriété buffer (comme ImageResponse)
|
|
||||||
if (typeof obj === "object" && obj !== null) {
|
|
||||||
const objAny = obj as any;
|
|
||||||
if (objAny.buffer && Buffer.isBuffer(objAny.buffer)) {
|
|
||||||
// Taille du buffer + taille approximative des autres propriétés
|
|
||||||
let size = objAny.buffer.length;
|
|
||||||
// Ajouter la taille du contentType si présent
|
|
||||||
if (objAny.contentType && typeof objAny.contentType === "string") {
|
|
||||||
size += objAny.contentType.length * 2; // UTF-16
|
|
||||||
}
|
|
||||||
return size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pour les autres types, utiliser JSON.stringify comme approximation
|
|
||||||
try {
|
|
||||||
return JSON.stringify(obj).length * 2; // x2 pour UTF-16
|
|
||||||
} catch {
|
|
||||||
// Si l'objet n'est pas sérialisable, retourner une estimation
|
|
||||||
return 1000; // 1KB par défaut
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calcule la taille du cache
|
|
||||||
*/
|
|
||||||
async getCacheSize(): Promise<{ sizeInBytes: number; itemCount: number }> {
|
|
||||||
if (this.config.mode === "memory") {
|
|
||||||
// Calculer la taille approximative en mémoire
|
|
||||||
let sizeInBytes = 0;
|
|
||||||
let itemCount = 0;
|
|
||||||
|
|
||||||
this.memoryCache.forEach((value) => {
|
|
||||||
if (value.expiry > Date.now()) {
|
|
||||||
itemCount++;
|
|
||||||
// Calculer la taille du data + expiry (8 bytes pour le timestamp)
|
|
||||||
sizeInBytes += this.calculateObjectSize(value.data) + 8;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { sizeInBytes, itemCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculer la taille du cache sur disque
|
|
||||||
let sizeInBytes = 0;
|
|
||||||
let itemCount = 0;
|
|
||||||
|
|
||||||
const calculateDirectorySize = (dirPath: string): void => {
|
|
||||||
if (!fs.existsSync(dirPath)) return;
|
|
||||||
|
|
||||||
const items = fs.readdirSync(dirPath);
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const itemPath = path.join(dirPath, item);
|
|
||||||
try {
|
|
||||||
const stats = fs.statSync(itemPath);
|
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
|
||||||
calculateDirectorySize(itemPath);
|
|
||||||
} else if (stats.isFile() && item.endsWith(".json")) {
|
|
||||||
sizeInBytes += stats.size;
|
|
||||||
itemCount++;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error, path: itemPath }, `Could not access ${itemPath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (fs.existsSync(this.cacheDir)) {
|
|
||||||
calculateDirectorySize(this.cacheDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { sizeInBytes, itemCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Liste les entrées du cache avec leurs détails
|
|
||||||
*/
|
|
||||||
async getCacheEntries(): Promise<
|
|
||||||
Array<{
|
|
||||||
key: string;
|
|
||||||
size: number;
|
|
||||||
expiry: number;
|
|
||||||
isExpired: boolean;
|
|
||||||
}>
|
|
||||||
> {
|
|
||||||
const entries: Array<{
|
|
||||||
key: string;
|
|
||||||
size: number;
|
|
||||||
expiry: number;
|
|
||||||
isExpired: boolean;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
if (this.config.mode === "memory") {
|
|
||||||
this.memoryCache.forEach((value, key) => {
|
|
||||||
const size = this.calculateObjectSize(value.data) + 8;
|
|
||||||
entries.push({
|
|
||||||
key,
|
|
||||||
size,
|
|
||||||
expiry: value.expiry,
|
|
||||||
isExpired: value.expiry <= Date.now(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const collectEntries = (dirPath: string): void => {
|
|
||||||
if (!fs.existsSync(dirPath)) return;
|
|
||||||
|
|
||||||
const items = fs.readdirSync(dirPath);
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const itemPath = path.join(dirPath, item);
|
|
||||||
try {
|
|
||||||
const stats = fs.statSync(itemPath);
|
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
|
||||||
collectEntries(itemPath);
|
|
||||||
} else if (stats.isFile() && item.endsWith(".json")) {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(itemPath, "utf-8");
|
|
||||||
const cached = JSON.parse(content);
|
|
||||||
const key = path.relative(this.cacheDir, itemPath).slice(0, -5);
|
|
||||||
|
|
||||||
entries.push({
|
|
||||||
key,
|
|
||||||
size: stats.size,
|
|
||||||
expiry: cached.expiry,
|
|
||||||
isExpired: cached.expiry <= Date.now(),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error, path: itemPath }, `Could not parse file ${itemPath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error, path: itemPath }, `Could not access ${itemPath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (fs.existsSync(this.cacheDir)) {
|
|
||||||
collectEntries(this.cacheDir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries.sort((a, b) => b.expiry - a.expiry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Créer une instance initialisée du service
|
|
||||||
let initializedInstance: Promise<ServerCacheService>;
|
|
||||||
|
|
||||||
export const getServerCacheService = async (): Promise<ServerCacheService> => {
|
|
||||||
if (!initializedInstance) {
|
|
||||||
initializedInstance = ServerCacheService.getInstance();
|
|
||||||
}
|
|
||||||
return initializedInstance;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Exporter aussi la classe pour les tests
|
|
||||||
export { ServerCacheService };
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Génère l'URL de base pour une image (sans cache version)
|
* Génère l'URL pour une image (thumbnail de série ou de livre)
|
||||||
* Utilisez useImageUrl() dans les composants pour obtenir l'URL avec cache busting
|
|
||||||
*/
|
*/
|
||||||
export function getImageUrl(type: "series" | "book", id: string) {
|
export function getImageUrl(type: "series" | "book", id: string) {
|
||||||
if (type === "series") {
|
if (type === "series") {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export type CacheType = "DEFAULT" | "HOME" | "LIBRARIES" | "SERIES" | "BOOKS" | "IMAGES";
|
|
||||||
@@ -15,20 +15,6 @@ export interface KomgaConfig extends KomgaConfigData {
|
|||||||
userId: number;
|
userId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TTLConfigData {
|
|
||||||
defaultTTL: number;
|
|
||||||
homeTTL: number;
|
|
||||||
librariesTTL: number;
|
|
||||||
seriesTTL: number;
|
|
||||||
booksTTL: number;
|
|
||||||
imagesTTL: number;
|
|
||||||
imageCacheMaxAge: number; // en secondes
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TTLConfig extends TTLConfigData {
|
|
||||||
userId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Types liés à l'API Komga
|
// Types liés à l'API Komga
|
||||||
export interface KomgaUser {
|
export interface KomgaUser {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -9,15 +9,8 @@ export interface BackgroundPreferences {
|
|||||||
komgaLibraries?: string[]; // IDs des bibliothèques Komga sélectionnées
|
komgaLibraries?: string[]; // IDs des bibliothèques Komga sélectionnées
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CircuitBreakerConfig {
|
|
||||||
threshold?: number;
|
|
||||||
timeout?: number;
|
|
||||||
resetTimeout?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserPreferences {
|
export interface UserPreferences {
|
||||||
showThumbnails: boolean;
|
showThumbnails: boolean;
|
||||||
cacheMode: "memory" | "file";
|
|
||||||
showOnlyUnread: boolean;
|
showOnlyUnread: boolean;
|
||||||
displayMode: {
|
displayMode: {
|
||||||
compact: boolean;
|
compact: boolean;
|
||||||
@@ -25,14 +18,11 @@ export interface UserPreferences {
|
|||||||
viewMode: "grid" | "list";
|
viewMode: "grid" | "list";
|
||||||
};
|
};
|
||||||
background: BackgroundPreferences;
|
background: BackgroundPreferences;
|
||||||
komgaMaxConcurrentRequests: number;
|
|
||||||
readerPrefetchCount: number;
|
readerPrefetchCount: number;
|
||||||
circuitBreakerConfig: CircuitBreakerConfig;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultPreferences: UserPreferences = {
|
export const defaultPreferences: UserPreferences = {
|
||||||
showThumbnails: true,
|
showThumbnails: true,
|
||||||
cacheMode: "memory",
|
|
||||||
showOnlyUnread: false,
|
showOnlyUnread: false,
|
||||||
displayMode: {
|
displayMode: {
|
||||||
compact: false,
|
compact: false,
|
||||||
@@ -44,13 +34,7 @@ export const defaultPreferences: UserPreferences = {
|
|||||||
opacity: 10,
|
opacity: 10,
|
||||||
blur: 0,
|
blur: 0,
|
||||||
},
|
},
|
||||||
komgaMaxConcurrentRequests: 5,
|
|
||||||
readerPrefetchCount: 5,
|
readerPrefetchCount: 5,
|
||||||
circuitBreakerConfig: {
|
|
||||||
threshold: 5,
|
|
||||||
timeout: 30000,
|
|
||||||
resetTimeout: 60000,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dégradés prédéfinis
|
// Dégradés prédéfinis
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Config } from "tailwindcss";
|
import type { Config } from "tailwindcss";
|
||||||
|
import tailwindcssAnimate from "tailwindcss-animate";
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
darkMode: ["class"],
|
darkMode: ["class"],
|
||||||
@@ -77,7 +78,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate")],
|
plugins: [tailwindcssAnimate],
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -23,5 +23,5 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", "temp"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user