import { createStore, set as idbSet, get as idbGet, del as idbDel } from 'idb-keyval'; const store = createStore('animetix-offline', 'kv'); export interface DownloadablePage { number: number; image_url: string; } export interface ChapterMeta { mediaId: string; mediaTitle: string; chapterNumber: number; chapterTitle: string; } export interface DownloadedChapter { mediaId: string; mediaTitle: string; chapterNumber: number; chapterTitle: string; pageCount: number; pageKeys: string[]; downloadedAt: number; totalBytes: number; } export class OfflineQuotaError extends Error { constructor(message = 'Espace de stockage insuffisant pour ce chapitre.') { super(message); this.name = 'OfflineQuotaError'; } } const INDEX_KEY = 'index'; const chapterId = (mediaId: string, chapterNumber: number) => `${mediaId}:${chapterNumber}`; const metaKey = (mediaId: string, chapterNumber: number) => `meta:${chapterId(mediaId, chapterNumber)}`; const pageKey = (mediaId: string, chapterNumber: number, pageNumber: number) => `img:${chapterId(mediaId, chapterNumber)}:${pageNumber}`; async function readIndex(): Promise { return (await idbGet(INDEX_KEY, store)) ?? []; } async function writeIndex(ids: string[]): Promise { await idbSet(INDEX_KEY, ids, store); } function isQuotaError(err: unknown): boolean { return err instanceof Error && (err.name === 'QuotaExceededError' || err.name === 'OfflineQuotaError'); } export async function isChapterDownloaded(mediaId: string, chapterNumber: number): Promise { return (await readIndex()).includes(chapterId(mediaId, chapterNumber)); } export async function getDownloadedChapter( mediaId: string, chapterNumber: number, ): Promise { return (await idbGet(metaKey(mediaId, chapterNumber), store)) ?? null; } // Internal storage format — ArrayBuffer survives structuredClone in all environments (jsdom, Node, browsers). interface StoredPage { buffer: ArrayBuffer; type: string } export async function getChapterPageBlobs(mediaId: string, chapterNumber: number): Promise { const meta = await getDownloadedChapter(mediaId, chapterNumber); if (!meta) return []; const storedItems = await Promise.all(meta.pageKeys.map((k) => idbGet(k, store))); return storedItems .filter((item): item is StoredPage => item != null && typeof item.type === 'string' && typeof item.buffer === 'object' && item.buffer !== null && typeof (item.buffer as ArrayBuffer).byteLength === 'number') .map((item) => new Blob([item.buffer], { type: item.type })); } export async function deleteChapter(mediaId: string, chapterNumber: number): Promise { const meta = await getDownloadedChapter(mediaId, chapterNumber); if (meta) { await Promise.all(meta.pageKeys.map((k) => idbDel(k, store))); } await idbDel(metaKey(mediaId, chapterNumber), store); const id = chapterId(mediaId, chapterNumber); await writeIndex((await readIndex()).filter((x) => x !== id)); } export async function listDownloads(): Promise { const ids = await readIndex(); const metas = await Promise.all( ids.map((id) => { // chapterId is `${mediaId}:${chapterNumber}`; split on the LAST colon so // mediaIds that themselves contain a colon still parse correctly. const lastColon = id.lastIndexOf(':'); const mediaId = id.slice(0, lastColon); const num = Number(id.slice(lastColon + 1)); return getDownloadedChapter(mediaId, num); }), ); return metas.filter((m): m is DownloadedChapter => m !== null); } export async function downloadChapter( meta: ChapterMeta, pages: DownloadablePage[], onProgress?: (ratio: number) => void, ): Promise { // Start clean so a previous partial/old copy never lingers. await deleteChapter(meta.mediaId, meta.chapterNumber); const ordered = [...pages].sort((a, b) => a.number - b.number); const writtenKeys: string[] = []; let totalBytes = 0; try { for (let i = 0; i < ordered.length; i++) { const page = ordered[i]; const res = await fetch(page.image_url, { credentials: 'include' }); if (!res.ok) throw new Error(`Échec du téléchargement de la page ${page.number} (${res.status})`); const blob = await res.blob(); const buffer = await blob.arrayBuffer(); const key = pageKey(meta.mediaId, meta.chapterNumber, page.number); await idbSet(key, { buffer, type: blob.type }, store); writtenKeys.push(key); totalBytes += blob.size; onProgress?.((i + 1) / ordered.length); } const record: DownloadedChapter = { mediaId: meta.mediaId, mediaTitle: meta.mediaTitle, chapterNumber: meta.chapterNumber, chapterTitle: meta.chapterTitle, pageCount: ordered.length, pageKeys: writtenKeys, downloadedAt: Date.now(), totalBytes, }; await idbSet(metaKey(meta.mediaId, meta.chapterNumber), record, store); const id = chapterId(meta.mediaId, meta.chapterNumber); const index = await readIndex(); if (!index.includes(id)) await writeIndex([...index, id]); return record; } catch (err) { // Roll back any partially written blobs; nothing should look "downloaded". await Promise.all(writtenKeys.map((k) => idbDel(k, store).catch(() => {}))); if (isQuotaError(err)) throw new OfflineQuotaError(); throw err; } }