Skip to content
Open
37 changes: 23 additions & 14 deletions packages/app-expo/src/lib/book/auto-metadata.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { extractBookMetadata } from "@/lib/book/metadata-extractor";
import {
createRangeReadableFile,
extractBookMetadataFromFile,
} from "@/lib/book/metadata-extractor";
import { getPlatformService } from "@readany/core/services";
import type { Book } from "@readany/core/types";
import type { ExtractedBookMetadata } from "@readany/core/utils";
import type { ExtractedMeta } from "./metadata-extractor";

const MOBILE_DETAILS_METADATA_MAX_BYTES = 32 * 1024 * 1024;
export type MobileExtractedBookMetadata = ExtractedBookMetadata &
Pick<ExtractedMeta, "coverBytes" | "coverMimeType">;

export async function extractLocalBookMetadata(book: Book): Promise<ExtractedBookMetadata | null> {
if (book.syncStatus === "remote" || book.format !== "epub" || !book.filePath) return null;
export async function extractLocalBookMetadata(
book: Book,
): Promise<MobileExtractedBookMetadata | null> {
if (book.syncStatus === "remote" || !isRepairableFormat(book.format) || !book.filePath) {
return null;
}

try {
const platform = getPlatformService();
Expand All @@ -15,21 +24,21 @@ export async function extractLocalBookMetadata(book: Book): Promise<ExtractedBoo
? await platform.joinPath(appData, book.filePath)
: book.filePath;
const fileSize = await getMobileFileSize(filePath);
if (fileSize > MOBILE_DETAILS_METADATA_MAX_BYTES) {
console.warn(
`[BookMetadata] Skip details metadata for large EPUB: ${book.meta.title} (${fileSize} bytes)`,
);
return null;
}
if (fileSize == null) return null;

const fileName = book.filePath.split("/").pop() || `${book.id}.epub`;
return extractBookMetadata(await platform.readFile(filePath), book.format, fileName);
const fileName = book.filePath.split(/[\\/]/).pop() || `${book.id}.${book.format}`;
const rangeReadable = await createRangeReadableFile(filePath, fileSize);
return extractBookMetadataFromFile(rangeReadable, book.format, fileName);
} catch (error) {
console.warn("[BookMetadata] Failed to extract local metadata:", error);
return null;
}
}

function isRepairableFormat(format: Book["format"]): boolean {
return format === "epub" || format === "mobi" || format === "azw" || format === "azw3";
}

function isRelativeAppPath(path: string): boolean {
return (
!path.startsWith("/") &&
Expand All @@ -39,8 +48,8 @@ function isRelativeAppPath(path: string): boolean {
);
}

async function getMobileFileSize(path: string): Promise<number> {
async function getMobileFileSize(path: string): Promise<number | null> {
const LegacyFileSystem = await import("expo-file-system/legacy");
const info = await LegacyFileSystem.getInfoAsync(path);
return info.exists && !info.isDirectory ? (info.size ?? 0) : 0;
return info.exists && !info.isDirectory ? (info.size ?? 0) : null;
}
113 changes: 113 additions & 0 deletions packages/app-expo/src/lib/book/cover-storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const platform = vi.hoisted(() => ({
getAppDataDir: vi.fn(async () => "/app"),
joinPath: vi.fn(async (...parts: string[]) => parts.join("/")),
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async () => undefined),
deleteFile: vi.fn(async () => undefined),
}));

vi.mock("@readany/core/services", () => ({ getPlatformService: () => platform }));
import * as coverStorage from "./cover-storage";

describe("mobile cover file extensions", () => {
const getCoverFileExtension = (
coverStorage as typeof coverStorage & {
getCoverFileExtension?: (bytes: Uint8Array, mimeType?: string | null) => string;
}
).getCoverFileExtension;

beforeEach(() => {
vi.clearAllMocks();
});

it("maps recognized image MIME types", () => {
expect(getCoverFileExtension).toBeTypeOf("function");
if (!getCoverFileExtension) return;

expect(getCoverFileExtension(new Uint8Array(), "image/webp")).toBe("webp");
expect(getCoverFileExtension(new Uint8Array(), "image/gif")).toBe("gif");
expect(getCoverFileExtension(new Uint8Array(), "image/png")).toBe("png");
expect(getCoverFileExtension(new Uint8Array(), "image/jpeg")).toBe("jpg");
});

it("sniffs image bytes when the MIME type is absent", () => {
expect(getCoverFileExtension).toBeTypeOf("function");
if (!getCoverFileExtension) return;

expect(
getCoverFileExtension(
new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]),
),
).toBe("webp");
expect(getCoverFileExtension(new TextEncoder().encode("GIF89a"))).toBe("gif");
expect(
getCoverFileExtension(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])),
).toBe("png");
expect(getCoverFileExtension(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]))).toBe("jpg");
});

it("deletes only a newly extracted cover when a custom cover wins during persistence", async () => {
const saveExtractedCoverIfStillMissing = (
coverStorage as typeof coverStorage & {
saveExtractedCoverIfStillMissing?: (
bookId: string,
bytes: Uint8Array,
mimeType: string | null,
getCurrentCoverUrl: () => string | undefined,
) => Promise<string | undefined>;
}
).saveExtractedCoverIfStillMissing;
expect(saveExtractedCoverIfStillMissing).toBeTypeOf("function");
if (!saveExtractedCoverIfStillMissing) return;

let currentCoverUrl = "";
platform.writeFile.mockImplementationOnce(async () => {
currentCoverUrl = "covers/book-custom-user.webp";
});

await expect(
saveExtractedCoverIfStillMissing(
"book",
new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]),
null,
() => currentCoverUrl,
),
).resolves.toBeUndefined();
expect(platform.deleteFile).toHaveBeenCalledWith("/app/covers/book.webp");
expect(platform.deleteFile).not.toHaveBeenCalledWith("/app/covers/book-custom-user.webp");
});

it("cleans an extracted cover when custom selection completes after persistence", async () => {
const commitCustomCover = (
coverStorage as typeof coverStorage & {
commitCustomCover?: (
bookId: string,
customCoverUrl: string,
persist: (coverUrl: string) => Promise<void>,
) => Promise<void>;
}
).commitCustomCover;
expect(commitCustomCover).toBeTypeOf("function");
if (!commitCustomCover) return;

await expect(
coverStorage.saveExtractedCoverIfStillMissing(
"book",
new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]),
null,
() => "",
),
).resolves.toBe("covers/book.webp");

const persisted: string[] = [];
await commitCustomCover("book", "covers/book-custom-user.png", async (coverUrl) => {
persisted.push(coverUrl);
});

expect(persisted).toEqual(["covers/book-custom-user.png"]);
expect(platform.deleteFile).toHaveBeenCalledWith("/app/covers/book.webp");
expect(platform.deleteFile).not.toHaveBeenCalledWith("/app/covers/book-custom-user.png");
});
});
123 changes: 123 additions & 0 deletions packages/app-expo/src/lib/book/cover-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { getPlatformService } from "@readany/core/services";

const extractedCoverPaths = new Map<string, Set<string>>();

export async function saveCoverBytesToAppData(
bookId: string,
coverBytes: Uint8Array,
coverMimeType?: string | null,
): Promise<string> {
const platform = getPlatformService();
const appData = await platform.getAppDataDir();
const coversDir = await platform.joinPath(appData, "covers");
try {
await platform.mkdir(coversDir);
} catch {
// Directory may already exist.
}

const extension = getCoverFileExtension(coverBytes, coverMimeType);
const relativePath = `covers/${bookId}.${extension}`;
const absolutePath = await platform.joinPath(appData, relativePath);
await platform.writeFile(absolutePath, coverBytes);
return relativePath;
}

export function getCoverFileExtension(
coverBytes: Uint8Array,
coverMimeType?: string | null,
): string {
switch (coverMimeType?.toLowerCase().split(";", 1)[0]?.trim()) {
case "image/webp":
return "webp";
case "image/gif":
return "gif";
case "image/png":
return "png";
case "image/jpg":
case "image/jpeg":
return "jpg";
}

if (coverBytes[0] === 0xff && coverBytes[1] === 0xd8 && coverBytes[2] === 0xff) return "jpg";
if (
coverBytes[0] === 0x89 &&
coverBytes[1] === 0x50 &&
coverBytes[2] === 0x4e &&
coverBytes[3] === 0x47
) {
return "png";
}
if (
coverBytes[0] === 0x47 &&
coverBytes[1] === 0x49 &&
coverBytes[2] === 0x46 &&
coverBytes[3] === 0x38
) {
return "gif";
}
if (
coverBytes[0] === 0x52 &&
coverBytes[1] === 0x49 &&
coverBytes[2] === 0x46 &&
coverBytes[3] === 0x46 &&
coverBytes[8] === 0x57 &&
coverBytes[9] === 0x45 &&
coverBytes[10] === 0x42 &&
coverBytes[11] === 0x50
) {
return "webp";
}
return "jpg";
}

export async function saveExtractedCoverIfStillMissing(
bookId: string,
coverBytes: Uint8Array,
coverMimeType: string | null | undefined,
getCurrentCoverUrl: () => string | undefined,
): Promise<string | undefined> {
if (getCurrentCoverUrl()?.trim()) return undefined;

const relativePath = await saveCoverBytesToAppData(bookId, coverBytes, coverMimeType);
trackExtractedCover(bookId, relativePath);
if (!getCurrentCoverUrl()?.trim()) return relativePath;

await deleteTrackedExtractedCover(bookId, relativePath);
return undefined;
}

export async function commitCustomCover(
bookId: string,
customCoverUrl: string,
persist: (coverUrl: string) => Promise<void>,
): Promise<void> {
await persist(customCoverUrl);
const paths = extractedCoverPaths.get(bookId);
if (!paths) return;

for (const relativePath of [...paths]) {
if (relativePath !== customCoverUrl) {
await deleteTrackedExtractedCover(bookId, relativePath);
}
}
}

function trackExtractedCover(bookId: string, relativePath: string): void {
const paths = extractedCoverPaths.get(bookId) ?? new Set<string>();
paths.add(relativePath);
extractedCoverPaths.set(bookId, paths);
}

async function deleteTrackedExtractedCover(bookId: string, relativePath: string): Promise<void> {
try {
const platform = getPlatformService();
const appData = await platform.getAppDataDir();
await platform.deleteFile(await platform.joinPath(appData, relativePath));
const paths = extractedCoverPaths.get(bookId);
paths?.delete(relativePath);
if (paths?.size === 0) extractedCoverPaths.delete(bookId);
} catch (error) {
console.warn("[BookMetadata] Failed to clean up rejected extracted cover:", error);
}
}
Loading