Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
9f6dc2a
fix(metadata): preserve ordered book details
cha1latte Aug 16, 2026
27e5dd9
fix(metadata): ignore empty tags during autofill
cha1latte Aug 16, 2026
f3be192
fix(metadata): retain mobile import details
cha1latte Aug 16, 2026
b7463f2
fix(metadata): retain restored book details
cha1latte Aug 16, 2026
92aac49
fix(metadata): retain desktop import details
cha1latte Aug 16, 2026
e721bd0
fix(metadata): repair missing legacy details
cha1latte Aug 16, 2026
0fe032c
fix(metadata): repair covers without edit races
cha1latte Aug 16, 2026
cb4ed0b
fix(metadata): address final review findings
cha1latte Aug 16, 2026
a529886
fix(metadata): validate dates and clean cover races
cha1latte Aug 16, 2026
8c9b245
fix(metadata): normalize nonpadded publication dates
cha1latte Aug 16, 2026
665ef96
chore: remove internal review reports
cha1latte Aug 16, 2026
3248a5f
feat(opds): add shared catalog parser
cha1latte Aug 17, 2026
b289780
fix(opds): harden catalog parsing
cha1latte Aug 17, 2026
b371ed6
fix(opds): validate readable publications
cha1latte Aug 17, 2026
17677e5
feat(opds): fetch catalogs safely
cha1latte Aug 17, 2026
2ea1a78
fix(opds): enforce redirect safety
cha1latte Aug 17, 2026
8489bbe
fix(opds): abort discarded transports
cha1latte Aug 17, 2026
ff29111
fix(opds): manage asset responses
cha1latte Aug 17, 2026
e49cfff
fix(opds): make asset cancel atomic
cha1latte Aug 17, 2026
3de821d
fix(opds): close asset cancel gap
cha1latte Aug 17, 2026
1565eb1
feat(opds): store catalog credentials securely
cha1latte Aug 17, 2026
1ba07c0
fix(opds): make catalog secrets transactional
cha1latte Aug 17, 2026
57505b5
fix(opds): persist pending secret cleanup
cha1latte Aug 17, 2026
785c841
fix(opds): quarantine uncertain secret cleanup
cha1latte Aug 17, 2026
239c86e
fix(opds): keep cleanup CRUD available
cha1latte Aug 17, 2026
dcdb1c8
feat(opds): import catalog downloads
cha1latte Aug 17, 2026
4e8c345
fix(opds): harden catalog imports
cha1latte Aug 17, 2026
d6a99dd
fix(opds): preserve restored files
cha1latte Aug 17, 2026
8b8d32f
feat(opds): add mobile catalog browser
cha1latte Aug 17, 2026
057f801
fix(opds): harden mobile catalog browser
cha1latte Aug 17, 2026
5e90318
feat(opds): add desktop catalog browser
cha1latte Aug 17, 2026
84516a8
fix(opds): harden desktop catalog interactions
cha1latte Aug 17, 2026
c893f7d
fix(opds): serialize catalog saves
cha1latte Aug 17, 2026
e330f65
fix(opds): lock pending catalog form
cha1latte Aug 17, 2026
a243d3d
fix(opds): address final review findings
cha1latte Aug 17, 2026
e6fa68e
fix(opds): queue covers and select catalog search
cha1latte Aug 17, 2026
a560c0e
fix(opds): validate Atom semantic links
cha1latte Aug 17, 2026
ba7aefb
fix(opds): store readable book descriptions
cha1latte Aug 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libasound2-dev
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libasound2-dev pkg-config libdbus-1-dev

- name: Setup Node.js
uses: actions/setup-node@v4
Expand Down
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