Compare commits
3 Commits
512e9a480f
...
117ad2d0ce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
117ad2d0ce | ||
|
|
0d7d27ef82 | ||
|
|
e903b55a46 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -54,3 +54,5 @@ prisma/data/
|
|||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
|
temp/
|
||||||
@@ -11,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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export function ClientLibraryPage({
|
|||||||
const effectivePageSize = pageSize || preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
|
const effectivePageSize = pageSize || preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -59,7 +61,9 @@ export function ClientLibraryPage({
|
|||||||
params.append("search", search);
|
params.append("search", search);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`);
|
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
@@ -70,14 +74,24 @@ export function ClientLibraryPage({
|
|||||||
setLibrary(data.library);
|
setLibrary(data.library);
|
||||||
setSeries(data.series);
|
setSeries(data.series);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Ignore abort errors (caused by StrictMode cleanup)
|
||||||
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
logger.error({ err }, "Error fetching library series");
|
logger.error({ err }, "Error fetching library series");
|
||||||
setError(err instanceof Error ? err.message : "SERIES_FETCH_ERROR");
|
setError(err instanceof Error ? err.message : "SERIES_FETCH_ERROR");
|
||||||
} finally {
|
} finally {
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
}, [libraryId, currentPage, unreadOnly, search, effectivePageSize]);
|
}, [libraryId, currentPage, unreadOnly, search, effectivePageSize]);
|
||||||
|
|
||||||
const handleRefresh = async (libraryId: string) => {
|
const handleRefresh = async (libraryId: string) => {
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export function ClientSeriesPage({
|
|||||||
const effectivePageSize = pageSize || preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
|
const effectivePageSize = pageSize || preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -49,7 +51,9 @@ export function ClientSeriesPage({
|
|||||||
unread: String(unreadOnly),
|
unread: String(unreadOnly),
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`);
|
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
@@ -60,14 +64,24 @@ export function ClientSeriesPage({
|
|||||||
setSeries(data.series);
|
setSeries(data.series);
|
||||||
setBooks(data.books);
|
setBooks(data.books);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Ignore abort errors (caused by StrictMode cleanup)
|
||||||
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
logger.error({ err }, "Error fetching series books");
|
logger.error({ err }, "Error fetching series books");
|
||||||
setError(err instanceof Error ? err.message : ERROR_CODES.BOOK.PAGES_FETCH_ERROR);
|
setError(err instanceof Error ? err.message : ERROR_CODES.BOOK.PAGES_FETCH_ERROR);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
}, [seriesId, currentPage, unreadOnly, effectivePageSize]);
|
}, [seriesId, currentPage, unreadOnly, effectivePageSize]);
|
||||||
|
|
||||||
const handleRefresh = async (seriesId: string) => {
|
const handleRefresh = async (seriesId: string) => {
|
||||||
|
|||||||
@@ -17,12 +17,17 @@ export function ClientHomePage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/komga/home");
|
const response = await fetch("/api/komga/home", {
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
@@ -40,17 +45,25 @@ export function ClientHomePage() {
|
|||||||
const homeData = await response.json();
|
const homeData = await response.json();
|
||||||
setData(homeData);
|
setData(homeData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Ignore abort errors (caused by StrictMode cleanup)
|
||||||
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
logger.error({ err }, "Error fetching home data");
|
logger.error({ err }, "Error fetching home data");
|
||||||
setError(err instanceof Error ? err.message : ERROR_CODES.KOMGA.SERVER_UNREACHABLE);
|
setError(err instanceof Error ? err.message : ERROR_CODES.KOMGA.SERVER_UNREACHABLE);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData();
|
fetchData();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
return () => {
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -79,14 +92,39 @@ export function ClientHomePage() {
|
|||||||
enabled: !loading && !error && !!data,
|
enabled: !loading && !error && !!data,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleRetry = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/komga/home");
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
const errorCode = errorData.error?.code || ERROR_CODES.KOMGA.SERVER_UNREACHABLE;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <HomePageSkeleton />;
|
return <HomePageSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRetry = () => {
|
|
||||||
fetchData();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ import { NavButton } from "@/components/ui/nav-button";
|
|||||||
import { IconButton } from "@/components/ui/icon-button";
|
import { IconButton } from "@/components/ui/icon-button";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
|
// Module-level flags to prevent duplicate fetches (survives StrictMode remounts)
|
||||||
|
let sidebarInitialFetchDone = false;
|
||||||
|
let sidebarFetchInProgress = false;
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -112,9 +116,18 @@ export function Sidebar({
|
|||||||
}, [toast]);
|
}, [toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Object.keys(preferences).length > 0) {
|
// Only load once when preferences become available (module-level flag survives StrictMode)
|
||||||
refreshLibraries();
|
if (
|
||||||
refreshFavorites();
|
!sidebarInitialFetchDone &&
|
||||||
|
!sidebarFetchInProgress &&
|
||||||
|
Object.keys(preferences).length > 0
|
||||||
|
) {
|
||||||
|
sidebarFetchInProgress = true;
|
||||||
|
sidebarInitialFetchDone = true;
|
||||||
|
|
||||||
|
Promise.all([refreshLibraries(), refreshFavorites()]).finally(() => {
|
||||||
|
sidebarFetchInProgress = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [preferences, refreshLibraries, refreshFavorites]);
|
}, [preferences, refreshLibraries, refreshFavorites]);
|
||||||
|
|
||||||
@@ -138,6 +151,10 @@ export function Sidebar({
|
|||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
|
// Reset module-level flags to allow refetch on next login
|
||||||
|
sidebarInitialFetchDone = false;
|
||||||
|
sidebarFetchInProgress = false;
|
||||||
|
|
||||||
await signOut({ callbackUrl: "/login" });
|
await signOut({ callbackUrl: "/login" });
|
||||||
setLibraries([]);
|
setLibraries([]);
|
||||||
setFavorites([]);
|
setFavorites([]);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import { BaseApiService } from "./base-api.service";
|
import { BaseApiService } from "./base-api.service";
|
||||||
import type { KomgaBook, KomgaBookWithPages } 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 { 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";
|
||||||
|
|
||||||
// Cache HTTP navigateur : 30 jours (immutable car les images ne changent pas)
|
|
||||||
const IMAGE_CACHE_MAX_AGE = 2592000;
|
|
||||||
|
|
||||||
export class BookService extends BaseApiService {
|
export class BookService extends BaseApiService {
|
||||||
static async getBook(bookId: string): Promise<KomgaBookWithPages> {
|
static async getBook(bookId: string): Promise<KomgaBookWithPages> {
|
||||||
try {
|
try {
|
||||||
@@ -111,22 +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;
|
|
||||||
|
|
||||||
return new Response(arrayBuffer, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": response.contentType || "image/jpeg",
|
|
||||||
"Cache-Control": `public, max-age=${IMAGE_CACHE_MAX_AGE}, 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);
|
||||||
}
|
}
|
||||||
@@ -137,18 +120,12 @@ export class BookService extends BaseApiService {
|
|||||||
// 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();
|
||||||
|
|
||||||
// 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=${IMAGE_CACHE_MAX_AGE}, 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);
|
||||||
@@ -165,16 +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`
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Response(response.buffer.buffer as ArrayBuffer, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": response.contentType || "image/jpeg",
|
|
||||||
"Cache-Control": `public, max-age=${IMAGE_CACHE_MAX_AGE}, 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);
|
||||||
}
|
}
|
||||||
@@ -192,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 = {
|
||||||
|
condition: {
|
||||||
|
libraryId: {
|
||||||
|
operator: "is",
|
||||||
|
value: randomLibraryId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const booksResponse = await this.fetchFromApi<{
|
||||||
|
content: KomgaBook[];
|
||||||
|
totalElements: number;
|
||||||
|
}>(
|
||||||
|
{
|
||||||
|
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) {
|
||||||
|
// Fallback to page 0 if random page was empty
|
||||||
|
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, {
|
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {
|
||||||
message: "Aucune série trouvée dans les bibliothèques sélectionnées",
|
message: "Aucun livre trouvé dans les bibliothèques sélectionnées",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Choisir une série au hasard parmi celles récupérées
|
const randomBookIndex = Math.floor(Math.random() * fallbackResponse.content.length);
|
||||||
const randomSeriesIndex = Math.floor(Math.random() * seriesResponse.content.length);
|
return fallbackResponse.content[randomBookIndex].id;
|
||||||
const randomSeries = seriesResponse.content[randomSeriesIndex];
|
|
||||||
|
|
||||||
// Récupérer les books de cette série avec pagination
|
|
||||||
const booksResponse = await SeriesService.getSeriesBooks(randomSeries.id, 0, 100);
|
|
||||||
|
|
||||||
if (booksResponse.content.length === 0) {
|
|
||||||
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {
|
|
||||||
message: "Aucun livre trouvé dans la série",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const randomBookIndex = Math.floor(Math.random() * booksResponse.content.length);
|
const randomBookIndex = Math.floor(Math.random() * booksResponse.content.length);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,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;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { BaseApiService } from "./base-api.service";
|
|||||||
import type { LibraryResponse } from "@/types/library";
|
import type { LibraryResponse } from "@/types/library";
|
||||||
import type { KomgaBook, KomgaSeries } 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 { ERROR_CODES } from "../../constants/errorCodes";
|
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||||
@@ -10,9 +9,6 @@ import { AppError } from "../../utils/errors";
|
|||||||
import type { UserPreferences } from "@/types/preferences";
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
// Cache HTTP navigateur : 30 jours (immutable car les images ne changent pas)
|
|
||||||
const IMAGE_CACHE_MAX_AGE = 2592000;
|
|
||||||
|
|
||||||
export class SeriesService extends BaseApiService {
|
export class SeriesService extends BaseApiService {
|
||||||
static async getSeries(seriesId: string): Promise<KomgaSeries> {
|
static async getSeries(seriesId: string): Promise<KomgaSeries> {
|
||||||
try {
|
try {
|
||||||
@@ -123,21 +119,14 @@ export class SeriesService extends BaseApiService {
|
|||||||
// 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();
|
||||||
|
|
||||||
// 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=${IMAGE_CACHE_MAX_AGE}, 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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