Compare commits
3 Commits
1ffe99285d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
034aa69f8d | ||
|
|
060dfb3099 | ||
|
|
ad11bce308 |
@@ -32,7 +32,6 @@
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"fflate": "^0.8.2",
|
||||
"framer-motion": "^12.23.24",
|
||||
"i18next": "^24.2.2",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -63,9 +63,6 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
fflate:
|
||||
specifier: ^0.8.2
|
||||
version: 0.8.2
|
||||
framer-motion:
|
||||
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)
|
||||
@@ -1898,9 +1895,6 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fflate@0.8.2:
|
||||
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
|
||||
|
||||
file-entry-cache@6.0.1:
|
||||
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
|
||||
engines: {node: ^10.12.0 || >=12.0.0}
|
||||
@@ -4983,8 +4977,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
||||
fflate@0.8.2: {}
|
||||
|
||||
file-entry-cache@6.0.1:
|
||||
dependencies:
|
||||
flat-cache: 3.2.0
|
||||
|
||||
17
public/sw.js
17
public/sw.js
@@ -1,7 +1,7 @@
|
||||
// StripStream Service Worker - Version 2
|
||||
// Architecture: SWR (Stale-While-Revalidate) for all resources
|
||||
|
||||
const VERSION = "v2.4";
|
||||
const VERSION = "v2.5";
|
||||
const STATIC_CACHE = `stripstream-static-${VERSION}`;
|
||||
const PAGES_CACHE = `stripstream-pages-${VERSION}`; // Navigation + RSC (client-side navigation)
|
||||
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
|
||||
* Used for: API calls, images
|
||||
* Respects Cache-Control: no-cache to force network-first (for refresh buttons)
|
||||
*/
|
||||
async function staleWhileRevalidateStrategy(request, cacheName, options = {}) {
|
||||
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)
|
||||
const fetchPromise = fetch(request)
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,21 +17,45 @@ interface LibraryClientWrapperProps {
|
||||
preferences: UserPreferences;
|
||||
}
|
||||
|
||||
export function LibraryClientWrapper({ children }: LibraryClientWrapperProps) {
|
||||
export function LibraryClientWrapper({
|
||||
children,
|
||||
libraryId,
|
||||
currentPage,
|
||||
unreadOnly,
|
||||
search,
|
||||
pageSize,
|
||||
}: LibraryClientWrapperProps) {
|
||||
const router = useRouter();
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
try {
|
||||
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();
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Error refreshing library" };
|
||||
} finally {
|
||||
// Petit délai pour laisser le temps au serveur de revalider
|
||||
setTimeout(() => setIsRefreshing(false), 500);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ interface SeriesClientWrapperProps {
|
||||
|
||||
export function SeriesClientWrapper({
|
||||
children,
|
||||
seriesId,
|
||||
currentPage,
|
||||
unreadOnly,
|
||||
pageSize,
|
||||
}: SeriesClientWrapperProps) {
|
||||
const router = useRouter();
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
@@ -25,14 +29,30 @@ export function SeriesClientWrapper({
|
||||
const handleRefresh = async () => {
|
||||
try {
|
||||
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();
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Error refreshing series" };
|
||||
} finally {
|
||||
// Petit délai pour laisser le temps au serveur de revalider
|
||||
setTimeout(() => setIsRefreshing(false), 500);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -52,10 +72,7 @@ export function SeriesClientWrapper({
|
||||
canRefresh={pullToRefresh.canRefresh}
|
||||
isHiding={pullToRefresh.isHiding}
|
||||
/>
|
||||
<RefreshProvider refreshSeries={handleRefresh}>
|
||||
{children}
|
||||
</RefreshProvider>
|
||||
<RefreshProvider refreshSeries={handleRefresh}>{children}</RefreshProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -311,14 +311,15 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
|
||||
return (
|
||||
<Card className="p-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
|
||||
src={`/api/komga/images/books/${book.id}/thumbnail`}
|
||||
alt={t("books.coverAlt", { title: book.metadata?.title })}
|
||||
className="object-cover"
|
||||
fill
|
||||
sizes="48px"
|
||||
sizes="64px"
|
||||
priority={false}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -20,15 +20,25 @@ export function HomeClientWrapper({ children }: HomeClientWrapperProps) {
|
||||
const handleRefresh = async () => {
|
||||
try {
|
||||
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();
|
||||
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 {
|
||||
// Petit délai pour laisser le temps au serveur de revalider
|
||||
setTimeout(() => setIsRefreshing(false), 500);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Button } from "./button";
|
||||
import { useToast } from "./use-toast";
|
||||
import type { KomgaBook } from "@/types/komga";
|
||||
import logger from "@/lib/logger";
|
||||
import { unzip } from "fflate";
|
||||
|
||||
interface BookOfflineButtonProps {
|
||||
book: KomgaBook;
|
||||
@@ -58,101 +57,92 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
|
||||
// Marque le début du téléchargement
|
||||
setBookStatus(book.id, {
|
||||
status: "downloading",
|
||||
progress: 0,
|
||||
progress: ((startFromPage - 1) / book.media.pagesCount) * 100,
|
||||
timestamp: Date.now(),
|
||||
lastDownloadedPage: 0,
|
||||
lastDownloadedPage: startFromPage - 1,
|
||||
});
|
||||
|
||||
// Télécharger le fichier complet
|
||||
setDownloadProgress(5);
|
||||
const fileResponse = await fetch(`/api/komga/books/${book.id}/file`);
|
||||
if (!fileResponse.ok) throw new Error("Erreur lors du téléchargement du fichier");
|
||||
|
||||
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");
|
||||
// Ajoute le livre au cache si on commence depuis le début
|
||||
if (startFromPage === 1) {
|
||||
const pagesResponse = await fetch(`/api/komga/images/books/${book.id}/pages/1`);
|
||||
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());
|
||||
}
|
||||
|
||||
// Cache chaque image
|
||||
for (let i = 0; i < imageFiles.length; i++) {
|
||||
const [fileName, data] = imageFiles[i];
|
||||
const pageNumber = i + 1;
|
||||
// Cache chaque page avec retry
|
||||
let failedPages = 0;
|
||||
for (let i = startFromPage; i <= book.media.pagesCount; i++) {
|
||||
let retryCount = 0;
|
||||
const maxRetries = 3;
|
||||
|
||||
// Déterminer le type MIME
|
||||
const ext = fileName.toLowerCase().split(".").pop();
|
||||
const mimeType =
|
||||
ext === "png"
|
||||
? "image/png"
|
||||
: ext === "webp"
|
||||
? "image/webp"
|
||||
: ext === "gif"
|
||||
? "image/gif"
|
||||
: "image/jpeg";
|
||||
|
||||
const blob = new Blob([data], { type: mimeType });
|
||||
const fakeResponse = new Response(blob, {
|
||||
headers: {
|
||||
"Content-Type": mimeType,
|
||||
"Content-Length": String(data.length),
|
||||
},
|
||||
});
|
||||
|
||||
await cache.put(`/api/komga/images/books/${book.id}/pages/${pageNumber}`, fakeResponse);
|
||||
while (retryCount < maxRetries) {
|
||||
try {
|
||||
const pageResponse = await fetch(`/api/komga/images/books/${book.id}/pages/${i}`);
|
||||
if (!pageResponse.ok) {
|
||||
retryCount++;
|
||||
if (retryCount === maxRetries) {
|
||||
failedPages++;
|
||||
logger.error(
|
||||
`Échec du téléchargement de la page ${i} après ${maxRetries} tentatives`
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000)); // Attendre 1s avant de réessayer
|
||||
continue;
|
||||
}
|
||||
await cache.put(
|
||||
`/api/komga/images/books/${book.id}/pages/${i}`,
|
||||
pageResponse.clone()
|
||||
);
|
||||
break; // Sortir de la boucle si réussi
|
||||
} 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
|
||||
const progress = 50 + ((i + 1) / imageFiles.length) * 50;
|
||||
const progress = (i / book.media.pagesCount) * 100;
|
||||
setDownloadProgress(progress);
|
||||
setBookStatus(book.id, {
|
||||
status: "downloading",
|
||||
progress,
|
||||
timestamp: Date.now(),
|
||||
lastDownloadedPage: pageNumber,
|
||||
lastDownloadedPage: i,
|
||||
});
|
||||
|
||||
// Vérifier si le statut a changé pendant le téléchargement
|
||||
const currentStatus = getBookStatus(book.id);
|
||||
if (currentStatus.status === "idle") {
|
||||
// Le téléchargement a été annulé
|
||||
throw new Error("Téléchargement annulé");
|
||||
}
|
||||
}
|
||||
|
||||
// Marquer comme disponible
|
||||
const pagesMetaResponse = new Response(JSON.stringify({ count: imageFiles.length }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
if (failedPages > 0) {
|
||||
// Si des pages ont échoué, on supprime tout le cache pour ce livre
|
||||
await cache.delete(`/api/komga/images/books/${book.id}/pages`);
|
||||
for (let i = 1; i <= book.media.pagesCount; i++) {
|
||||
await cache.delete(`/api/komga/images/books/${book.id}/pages/${i}`);
|
||||
}
|
||||
setIsAvailableOffline(false);
|
||||
setBookStatus(book.id, { status: "error", progress: 0, timestamp: Date.now() });
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: `${failedPages} page(s) n'ont pas pu être téléchargées. Le livre ne sera pas disponible hors ligne.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
await cache.put(`/api/komga/images/books/${book.id}/pages`, pagesMetaResponse);
|
||||
|
||||
} else {
|
||||
setIsAvailableOffline(true);
|
||||
setBookStatus(book.id, { status: "available", progress: 100, timestamp: Date.now() });
|
||||
toast({
|
||||
title: "Livre téléchargé",
|
||||
description: `${imageFiles.length} pages disponibles hors ligne`,
|
||||
description: "Le livre est maintenant disponible hors ligne",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Erreur lors du téléchargement:");
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
[book.id, getBookStatus, setBookStatus, toast]
|
||||
[book.id, book.media.pagesCount, getBookStatus, setBookStatus, toast]
|
||||
);
|
||||
|
||||
const checkOfflineAvailability = useCallback(async () => {
|
||||
|
||||
@@ -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> {
|
||||
try {
|
||||
// Récupérer les préférences de l'utilisateur
|
||||
|
||||
Reference in New Issue
Block a user