Compare commits

..

3 Commits

Author SHA1 Message Date
Julien Froidefond
034aa69f8d feat: update service worker to version 2.5 and enhance caching strategies for network requests, including cache bypass for refresh actions in LibraryClientWrapper, SeriesClientWrapper, and HomeClientWrapper components
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m3s
2026-01-04 11:44:50 +01:00
Julien Froidefond
060dfb3099 fix: adjust thumbnail size and optimize image loading in BookDownloadCard component
Some checks are pending
Deploy with Docker Compose / deploy (push) Has started running
2026-01-04 11:41:13 +01:00
Julien Froidefond
ad11bce308 revert: restore page-by-page download method (old method works better) 2026-01-04 11:39:55 +01:00
10 changed files with 148 additions and 179 deletions

View File

@@ -32,7 +32,6 @@
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"fflate": "^0.8.2",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"i18next": "^24.2.2", "i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.4", "i18next-browser-languagedetector": "^8.0.4",

8
pnpm-lock.yaml generated
View File

@@ -63,9 +63,6 @@ importers:
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
fflate:
specifier: ^0.8.2
version: 0.8.2
framer-motion: framer-motion:
specifier: ^12.23.24 specifier: ^12.23.24
version: 12.23.24(@emotion/is-prop-valid@0.8.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 12.23.24(@emotion/is-prop-valid@0.8.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -1898,9 +1895,6 @@ packages:
picomatch: picomatch:
optional: true optional: true
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
file-entry-cache@6.0.1: file-entry-cache@6.0.1:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
engines: {node: ^10.12.0 || >=12.0.0} engines: {node: ^10.12.0 || >=12.0.0}
@@ -4983,8 +4977,6 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.3 picomatch: 4.0.3
fflate@0.8.2: {}
file-entry-cache@6.0.1: file-entry-cache@6.0.1:
dependencies: dependencies:
flat-cache: 3.2.0 flat-cache: 3.2.0

View File

@@ -1,7 +1,7 @@
// StripStream Service Worker - Version 2 // StripStream Service Worker - Version 2
// Architecture: SWR (Stale-While-Revalidate) for all resources // Architecture: SWR (Stale-While-Revalidate) for all resources
const VERSION = "v2.4"; 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 PAGES_CACHE = `stripstream-pages-${VERSION}`; // Navigation + RSC (client-side navigation)
const API_CACHE = `stripstream-api-${VERSION}`; const API_CACHE = `stripstream-api-${VERSION}`;
@@ -129,10 +129,23 @@ 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 calls, images * Used for: API calls, images
* Respects Cache-Control: no-cache to force network-first (for refresh buttons)
*/ */
async function staleWhileRevalidateStrategy(request, cacheName, options = {}) { 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)

View File

@@ -1,56 +0,0 @@
import { NextResponse } from "next/server";
import { BookService } from "@/lib/services/book.service";
import { ERROR_CODES } from "@/constants/errorCodes";
import { getErrorMessage } from "@/utils/errors";
import { AppError } from "@/utils/errors";
import type { NextRequest } from "next/server";
import logger from "@/lib/logger";
export const dynamic = "force-dynamic";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ bookId: string }> }
) {
try {
const { bookId } = await params;
const response = await BookService.getFile(bookId);
// Stream the file directly to the client
return new NextResponse(response.body, {
status: 200,
headers: {
"Content-Type": response.headers.get("Content-Type") || "application/octet-stream",
"Content-Length": response.headers.get("Content-Length") || "",
"Content-Disposition": response.headers.get("Content-Disposition") || "",
"Cache-Control": "public, max-age=31536000",
},
});
} catch (error) {
logger.error({ err: error }, "API Books File - Erreur:");
if (error instanceof AppError) {
return NextResponse.json(
{
error: {
code: error.code,
name: "Book file fetch error",
message: getErrorMessage(error.code),
} as AppError,
},
{ status: 500 }
);
}
return NextResponse.json(
{
error: {
code: ERROR_CODES.BOOK.NOT_FOUND,
name: "Book file fetch error",
message: getErrorMessage(ERROR_CODES.BOOK.NOT_FOUND),
} as AppError,
},
{ status: 500 }
);
}
}

View File

@@ -17,21 +17,45 @@ interface LibraryClientWrapperProps {
preferences: UserPreferences; preferences: UserPreferences;
} }
export function LibraryClientWrapper({ children }: LibraryClientWrapperProps) { export function LibraryClientWrapper({
children,
libraryId,
currentPage,
unreadOnly,
search,
pageSize,
}: LibraryClientWrapperProps) {
const router = useRouter(); const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => { const handleRefresh = async () => {
try { try {
setIsRefreshing(true); setIsRefreshing(true);
// Revalider la page côté serveur
// 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(); router.refresh();
return { success: true }; return { success: true };
} catch { } catch {
return { success: false, error: "Error refreshing library" }; return { success: false, error: "Error refreshing library" };
} finally { } finally {
// Petit délai pour laisser le temps au serveur de revalider setIsRefreshing(false);
setTimeout(() => setIsRefreshing(false), 500);
} }
}; };

View File

@@ -18,6 +18,10 @@ interface SeriesClientWrapperProps {
export function SeriesClientWrapper({ export function SeriesClientWrapper({
children, children,
seriesId,
currentPage,
unreadOnly,
pageSize,
}: SeriesClientWrapperProps) { }: SeriesClientWrapperProps) {
const router = useRouter(); const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
@@ -25,14 +29,30 @@ export function SeriesClientWrapper({
const handleRefresh = async () => { const handleRefresh = async () => {
try { try {
setIsRefreshing(true); setIsRefreshing(true);
// Revalider la page côté serveur
// 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(); router.refresh();
return { success: true }; return { success: true };
} catch { } catch {
return { success: false, error: "Error refreshing series" }; return { success: false, error: "Error refreshing series" };
} finally { } finally {
// Petit délai pour laisser le temps au serveur de revalider setIsRefreshing(false);
setTimeout(() => setIsRefreshing(false), 500);
} }
}; };
@@ -52,10 +72,7 @@ export function SeriesClientWrapper({
canRefresh={pullToRefresh.canRefresh} canRefresh={pullToRefresh.canRefresh}
isHiding={pullToRefresh.isHiding} isHiding={pullToRefresh.isHiding}
/> />
<RefreshProvider refreshSeries={handleRefresh}> <RefreshProvider refreshSeries={handleRefresh}>{children}</RefreshProvider>
{children}
</RefreshProvider>
</> </>
); );
} }

View File

@@ -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">

View File

@@ -20,15 +20,25 @@ export function HomeClientWrapper({ children }: HomeClientWrapperProps) {
const handleRefresh = async () => { const handleRefresh = async () => {
try { try {
setIsRefreshing(true); setIsRefreshing(true);
// Revalider la page côté serveur
// 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(); router.refresh();
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors du rafraîchissement:"); logger.error({ err: error }, "Erreur lors du rafraîchissement:");
return { success: false, error: "Erreur lors du rafraîchissement de la page d'accueil" }; return { success: false, error: "Erreur lors du rafraîchissement de la page d'accueil" };
} finally { } finally {
// Petit délai pour laisser le temps au serveur de revalider setIsRefreshing(false);
setTimeout(() => setIsRefreshing(false), 500);
} }
}; };

View File

@@ -6,7 +6,6 @@ import { Button } from "./button";
import { useToast } from "./use-toast"; import { useToast } from "./use-toast";
import type { KomgaBook } from "@/types/komga"; import type { KomgaBook } from "@/types/komga";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
import { unzip } from "fflate";
interface BookOfflineButtonProps { interface BookOfflineButtonProps {
book: KomgaBook; book: KomgaBook;
@@ -58,101 +57,92 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
// Marque le début du téléchargement // Marque le début du téléchargement
setBookStatus(book.id, { setBookStatus(book.id, {
status: "downloading", status: "downloading",
progress: 0, progress: ((startFromPage - 1) / book.media.pagesCount) * 100,
timestamp: Date.now(), timestamp: Date.now(),
lastDownloadedPage: 0, lastDownloadedPage: startFromPage - 1,
}); });
// Télécharger le fichier complet // Ajoute le livre au cache si on commence depuis le début
setDownloadProgress(5); if (startFromPage === 1) {
const fileResponse = await fetch(`/api/komga/books/${book.id}/file`); const pagesResponse = await fetch(`/api/komga/images/books/${book.id}/pages/1`);
if (!fileResponse.ok) throw new Error("Erreur lors du téléchargement du fichier"); if (!pagesResponse.ok) throw new Error("Erreur lors de la récupération des pages");
await cache.put(`/api/komga/images/books/${book.id}/pages`, pagesResponse.clone());
setDownloadProgress(20);
const arrayBuffer = await fileResponse.arrayBuffer();
setDownloadProgress(30);
setBookStatus(book.id, {
status: "downloading",
progress: 30,
timestamp: Date.now(),
});
// Décompresser le fichier
const files = await new Promise<Record<string, Uint8Array>>((resolve, reject) => {
unzip(new Uint8Array(arrayBuffer), (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
setDownloadProgress(50);
// Filtrer et trier les images
const imageExtensions = /\.(jpg|jpeg|png|webp|gif)$/i;
const imageFiles = Object.entries(files)
.filter(([name]) => imageExtensions.test(name))
.sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }));
if (imageFiles.length === 0) {
throw new Error("Aucune image trouvée dans le fichier");
} }
// Cache chaque image // Cache chaque page avec retry
for (let i = 0; i < imageFiles.length; i++) { let failedPages = 0;
const [fileName, data] = imageFiles[i]; for (let i = startFromPage; i <= book.media.pagesCount; i++) {
const pageNumber = i + 1; let retryCount = 0;
const maxRetries = 3;
// Déterminer le type MIME while (retryCount < maxRetries) {
const ext = fileName.toLowerCase().split(".").pop(); try {
const mimeType = const pageResponse = await fetch(`/api/komga/images/books/${book.id}/pages/${i}`);
ext === "png" if (!pageResponse.ok) {
? "image/png" retryCount++;
: ext === "webp" if (retryCount === maxRetries) {
? "image/webp" failedPages++;
: ext === "gif" logger.error(
? "image/gif" `Échec du téléchargement de la page ${i} après ${maxRetries} tentatives`
: "image/jpeg"; );
}
const blob = new Blob([data], { type: mimeType }); await new Promise((resolve) => setTimeout(resolve, 1000)); // Attendre 1s avant de réessayer
const fakeResponse = new Response(blob, { continue;
headers: { }
"Content-Type": mimeType, await cache.put(
"Content-Length": String(data.length), `/api/komga/images/books/${book.id}/pages/${i}`,
}, pageResponse.clone()
}); );
break; // Sortir de la boucle si réussi
await cache.put(`/api/komga/images/books/${book.id}/pages/${pageNumber}`, fakeResponse); } catch (error) {
retryCount++;
if (retryCount === maxRetries) {
failedPages++;
logger.error({ err: error }, `Erreur lors du téléchargement de la page ${i}:`);
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
// Mise à jour du statut // Mise à jour du statut
const progress = 50 + ((i + 1) / imageFiles.length) * 50; const progress = (i / book.media.pagesCount) * 100;
setDownloadProgress(progress); setDownloadProgress(progress);
setBookStatus(book.id, { setBookStatus(book.id, {
status: "downloading", status: "downloading",
progress, progress,
timestamp: Date.now(), timestamp: Date.now(),
lastDownloadedPage: pageNumber, lastDownloadedPage: i,
}); });
// Vérifier si le statut a changé pendant le téléchargement // Vérifier si le statut a changé pendant le téléchargement
const currentStatus = getBookStatus(book.id); const currentStatus = getBookStatus(book.id);
if (currentStatus.status === "idle") { if (currentStatus.status === "idle") {
// Le téléchargement a été annulé
throw new Error("Téléchargement annulé"); throw new Error("Téléchargement annulé");
} }
} }
// Marquer comme disponible if (failedPages > 0) {
const pagesMetaResponse = new Response(JSON.stringify({ count: imageFiles.length }), { // Si des pages ont échoué, on supprime tout le cache pour ce livre
headers: { "Content-Type": "application/json" }, await cache.delete(`/api/komga/images/books/${book.id}/pages`);
}); for (let i = 1; i <= book.media.pagesCount; i++) {
await cache.put(`/api/komga/images/books/${book.id}/pages`, pagesMetaResponse); await cache.delete(`/api/komga/images/books/${book.id}/pages/${i}`);
}
setIsAvailableOffline(true); setIsAvailableOffline(false);
setBookStatus(book.id, { status: "available", progress: 100, timestamp: Date.now() }); setBookStatus(book.id, { status: "error", progress: 0, timestamp: Date.now() });
toast({ toast({
title: "Livre téléchargé", title: "Erreur",
description: `${imageFiles.length} pages disponibles hors ligne`, description: `${failedPages} page(s) n'ont pas pu être téléchargées. Le livre ne sera pas disponible hors ligne.`,
}); variant: "destructive",
});
} else {
setIsAvailableOffline(true);
setBookStatus(book.id, { status: "available", progress: 100, timestamp: Date.now() });
toast({
title: "Livre téléchargé",
description: "Le livre est maintenant disponible hors ligne",
});
}
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors du téléchargement:"); logger.error({ err: error }, "Erreur lors du téléchargement:");
// Ne pas changer le statut si le téléchargement a été volontairement annulé // Ne pas changer le statut si le téléchargement a été volontairement annulé
@@ -169,7 +159,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
setDownloadProgress(0); setDownloadProgress(0);
} }
}, },
[book.id, getBookStatus, setBookStatus, toast] [book.id, book.media.pagesCount, getBookStatus, setBookStatus, toast]
); );
const checkOfflineAvailability = useCallback(async () => { const checkOfflineAvailability = useCallback(async () => {

View File

@@ -115,27 +115,6 @@ export class BookService extends BaseApiService {
} }
} }
static async getFile(bookId: string): Promise<Response> {
try {
const config = await this.getKomgaConfig();
const url = this.buildUrl(config, `books/${bookId}/file`);
const headers = this.getAuthHeaders(config);
const response = await fetch(url, {
method: "GET",
headers,
});
if (!response.ok) {
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND);
}
return response;
} catch (error) {
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {}, error);
}
}
static async getCover(bookId: string): Promise<Response> { static async getCover(bookId: string): Promise<Response> {
try { try {
// Récupérer les préférences de l'utilisateur // Récupérer les préférences de l'utilisateur