komado 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +208 -0
  2. package/dist/app.js +59 -0
  3. package/dist/cli.js +196 -0
  4. package/dist/components/List.js +41 -0
  5. package/dist/components/screens/ContinueScreen.js +66 -0
  6. package/dist/components/screens/HomeScreen.js +59 -0
  7. package/dist/components/screens/LibraryScreen.js +69 -0
  8. package/dist/components/screens/LoginScreen.js +85 -0
  9. package/dist/components/screens/MangaScreen.js +107 -0
  10. package/dist/components/screens/ReaderScreen.js +195 -0
  11. package/dist/components/screens/SearchScreen.js +111 -0
  12. package/dist/components/screens/SettingsScreen.js +144 -0
  13. package/dist/components/ui.js +33 -0
  14. package/dist/config.js +51 -0
  15. package/dist/domain/shape.js +44 -0
  16. package/dist/hooks/useStdoutDimensions.js +17 -0
  17. package/dist/lib/AppError.js +37 -0
  18. package/dist/lib/cache.js +54 -0
  19. package/dist/lib/catchAsync.js +12 -0
  20. package/dist/lib/envelope.js +15 -0
  21. package/dist/lib/fetchWithBackoff.js +65 -0
  22. package/dist/lib/logger.js +41 -0
  23. package/dist/lib/natsort.js +7 -0
  24. package/dist/lib/text.js +20 -0
  25. package/dist/render/chafa.js +56 -0
  26. package/dist/render/detect.js +86 -0
  27. package/dist/render/halfblock.js +42 -0
  28. package/dist/render/image.js +23 -0
  29. package/dist/render/sixel.js +88 -0
  30. package/dist/sixel-reader.js +309 -0
  31. package/dist/sources/index.js +17 -0
  32. package/dist/sources/local/archive.js +68 -0
  33. package/dist/sources/local/index.js +147 -0
  34. package/dist/sources/mangadex/auth.js +102 -0
  35. package/dist/sources/mangadex/client.js +76 -0
  36. package/dist/sources/mangadex/index.js +156 -0
  37. package/dist/sources/mangadex/normalize.js +54 -0
  38. package/dist/state/store.js +91 -0
  39. package/dist/ui-context.js +11 -0
  40. package/package.json +50 -0
  41. package/src/app.js +73 -0
  42. package/src/cli.js +218 -0
  43. package/src/components/List.js +60 -0
  44. package/src/components/screens/ContinueScreen.js +73 -0
  45. package/src/components/screens/HomeScreen.js +54 -0
  46. package/src/components/screens/LibraryScreen.js +79 -0
  47. package/src/components/screens/LoginScreen.js +92 -0
  48. package/src/components/screens/MangaScreen.js +125 -0
  49. package/src/components/screens/ReaderScreen.js +230 -0
  50. package/src/components/screens/SearchScreen.js +123 -0
  51. package/src/components/screens/SettingsScreen.js +146 -0
  52. package/src/components/ui.js +42 -0
  53. package/src/config.js +49 -0
  54. package/src/domain/shape.js +47 -0
  55. package/src/hooks/useStdoutDimensions.js +19 -0
  56. package/src/lib/AppError.js +26 -0
  57. package/src/lib/cache.js +57 -0
  58. package/src/lib/catchAsync.js +12 -0
  59. package/src/lib/envelope.js +14 -0
  60. package/src/lib/fetchWithBackoff.js +74 -0
  61. package/src/lib/logger.js +41 -0
  62. package/src/lib/natsort.js +7 -0
  63. package/src/lib/text.js +18 -0
  64. package/src/render/chafa.js +64 -0
  65. package/src/render/detect.js +112 -0
  66. package/src/render/halfblock.js +46 -0
  67. package/src/render/image.js +24 -0
  68. package/src/render/sixel.js +141 -0
  69. package/src/sixel-reader.js +359 -0
  70. package/src/sources/index.js +17 -0
  71. package/src/sources/local/archive.js +74 -0
  72. package/src/sources/local/index.js +155 -0
  73. package/src/sources/mangadex/auth.js +125 -0
  74. package/src/sources/mangadex/client.js +83 -0
  75. package/src/sources/mangadex/index.js +166 -0
  76. package/src/sources/mangadex/normalize.js +70 -0
  77. package/src/state/store.js +90 -0
  78. package/src/ui-context.js +12 -0
@@ -0,0 +1,155 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { makeManga, makeChapter, globalKey } from '../../domain/shape.js';
6
+ import { envelope, paginate } from '../../lib/envelope.js';
7
+ import { naturalSort, naturalCompare } from '../../lib/natsort.js';
8
+ import { NotFoundError } from '../../lib/AppError.js';
9
+ import { getConfig } from '../../state/store.js';
10
+ import { listArchiveImages, readArchiveEntry, isArchive } from './archive.js';
11
+
12
+ export const id = 'local';
13
+ export const label = 'Local library';
14
+ export const remote = false;
15
+
16
+ const IMAGE_RE = /\.(jpe?g|png|gif|webp|bmp|avif)$/i;
17
+
18
+ // Built model: mangaId(=path) -> { mangaId, title, chapters: [{ kind, dir|file, name }] }
19
+ let model = null;
20
+
21
+ function listDir(dir) {
22
+ try { return fs.readdirSync(dir, { withFileTypes: true }); }
23
+ catch { return []; }
24
+ }
25
+ const hasImages = (dir) => listDir(dir).some((d) => d.isFile() && IMAGE_RE.test(d.name));
26
+ const cleanName = (name) => name.replace(/\.(cbz|zip|cbr|rar)$/i, '');
27
+ const expandHome = (p) => (p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p);
28
+
29
+ // A directory is a manga. Its chapters are: image subfolders, then archive
30
+ // files; or — if neither — the loose images in the folder become one chapter.
31
+ function buildMangaFromDir(dirPath) {
32
+ const entries = listDir(dirPath);
33
+ const chapters = [];
34
+
35
+ const chapterDirs = entries.filter((e) => e.isDirectory() && hasImages(path.join(dirPath, e.name)));
36
+ for (const d of naturalSort(chapterDirs, (e) => e.name)) {
37
+ chapters.push({ kind: 'dir', dir: path.join(dirPath, d.name), name: d.name });
38
+ }
39
+ const archives = entries.filter((e) => e.isFile() && isArchive(e.name));
40
+ for (const f of naturalSort(archives, (e) => e.name)) {
41
+ chapters.push({ kind: 'archive', file: path.join(dirPath, f.name), name: f.name });
42
+ }
43
+ if (chapters.length === 0 && hasImages(dirPath)) {
44
+ chapters.push({ kind: 'dir', dir: dirPath, name: path.basename(dirPath) });
45
+ }
46
+ return chapters.length ? { mangaId: dirPath, title: path.basename(dirPath), chapters } : null;
47
+ }
48
+
49
+ // A standalone .cbz/.zip is a single-chapter manga.
50
+ function buildMangaFromArchive(filePath) {
51
+ return {
52
+ mangaId: filePath,
53
+ title: cleanName(path.basename(filePath)),
54
+ chapters: [{ kind: 'archive', file: filePath, name: path.basename(filePath) }],
55
+ };
56
+ }
57
+
58
+ export function scan() {
59
+ model = new Map();
60
+ for (const libPath of getConfig().localLibraryPaths || []) {
61
+ const abs = path.resolve(expandHome(libPath));
62
+ for (const entry of naturalSort(listDir(abs), (e) => e.name)) {
63
+ const full = path.join(abs, entry.name);
64
+ const built = entry.isDirectory()
65
+ ? buildMangaFromDir(full)
66
+ : isArchive(entry.name) ? buildMangaFromArchive(full) : null;
67
+ if (built) model.set(built.mangaId, built);
68
+ }
69
+ }
70
+ return model;
71
+ }
72
+
73
+ function ensureModel() {
74
+ if (!model) scan();
75
+ return model;
76
+ }
77
+
78
+ function toManga(built) {
79
+ return makeManga({
80
+ source: id,
81
+ id: built.mangaId,
82
+ title: built.title,
83
+ status: 'local',
84
+ chaptersCount: built.chapters.length,
85
+ language: getConfig().language,
86
+ raw: { path: built.mangaId },
87
+ });
88
+ }
89
+
90
+ export async function search(query, { offset = 0, limit = 1000 } = {}) {
91
+ ensureModel();
92
+ const q = (query || '').toLowerCase();
93
+ let list = [...model.values()];
94
+ if (q) list = list.filter((b) => b.title.toLowerCase().includes(q));
95
+ list.sort((a, b) => naturalCompare(a.title, b.title));
96
+ const data = list.slice(offset, offset + limit).map(toManga);
97
+ return envelope(data, {
98
+ pagination: paginate({ offset, limit, total: list.length }),
99
+ meta: { source: id, query },
100
+ });
101
+ }
102
+
103
+ export async function getManga(mangaId) {
104
+ ensureModel();
105
+ const built = model.get(mangaId);
106
+ if (!built) throw new NotFoundError(`Local manga not found: ${mangaId}`);
107
+ return toManga(built);
108
+ }
109
+
110
+ export async function listChapters(mangaId, { offset = 0, limit = 100_000 } = {}) {
111
+ ensureModel();
112
+ const built = model.get(mangaId);
113
+ if (!built) throw new NotFoundError(`Local manga not found: ${mangaId}`);
114
+ const mangaKey = globalKey(id, mangaId);
115
+ const data = built.chapters.map((ch, i) =>
116
+ makeChapter({
117
+ source: id,
118
+ id: `${mangaId}#${i}`,
119
+ mangaKey,
120
+ number: built.chapters.length > 1 ? String(i + 1) : null,
121
+ title: cleanName(ch.name),
122
+ raw: ch,
123
+ }),
124
+ );
125
+ return envelope(data.slice(offset, offset + limit), {
126
+ pagination: paginate({ offset, limit, total: data.length }),
127
+ meta: { source: id, mangaId },
128
+ });
129
+ }
130
+
131
+ export async function getPages(chapterId) {
132
+ ensureModel();
133
+ const hash = chapterId.lastIndexOf('#');
134
+ const mangaId = chapterId.slice(0, hash);
135
+ const chIndex = Number(chapterId.slice(hash + 1));
136
+ const built = model.get(mangaId);
137
+ if (!built) throw new NotFoundError(`Local manga not found: ${mangaId}`);
138
+ const ch = built.chapters[chIndex];
139
+ if (!ch) throw new NotFoundError(`Chapter not found: ${chapterId}`);
140
+
141
+ if (ch.kind === 'dir') {
142
+ const files = naturalSort(
143
+ listDir(ch.dir).filter((e) => e.isFile() && IMAGE_RE.test(e.name)).map((e) => e.name),
144
+ );
145
+ return files.map((name, index) => ({ index, kind: 'file', file: path.join(ch.dir, name) }));
146
+ }
147
+ const names = await listArchiveImages(ch.file);
148
+ return names.map((entry, index) => ({ index, kind: 'archive', file: ch.file, entry }));
149
+ }
150
+
151
+ export async function loadPageBuffer(page) {
152
+ if (page.kind === 'file') return fsp.readFile(page.file);
153
+ if (page.kind === 'archive') return readArchiveEntry(page.file, page.entry);
154
+ throw new NotFoundError('Unknown local page descriptor');
155
+ }
@@ -0,0 +1,125 @@
1
+ import { fetchWithBackoff } from '../../lib/fetchWithBackoff.js';
2
+ import { MANGADEX } from '../../config.js';
3
+ import { AuthError } from '../../lib/AppError.js';
4
+ import { getCredentials, setCredentials, clearCredentials } from '../../state/store.js';
5
+ import { logger } from '../../lib/logger.js';
6
+
7
+ // MangaDex auth is OAuth2 "personal clients" (Keycloak). The user registers a
8
+ // client at mangadex.org/settings, then we exchange client id/secret + their
9
+ // username/password for a short-lived (15-min) access token and a rotating
10
+ // refresh token. Reading manga needs none of this — login only unlocks the
11
+ // user's follows/library and read-marker sync.
12
+
13
+ // The access token lives in memory only; the refresh token is the durable
14
+ // secret (persisted by the credential store).
15
+ let access = { token: null, expiresAt: 0 };
16
+ let refreshing = null; // shared in-flight refresh promise (stampede guard)
17
+
18
+ const FORM = { 'Content-Type': 'application/x-www-form-urlencoded' };
19
+ const SKEW_MS = 30_000; // refresh this far ahead of the real expiry
20
+
21
+ export function isLoggedIn() {
22
+ return !!getCredentials();
23
+ }
24
+
25
+ function postToken(params, signal) {
26
+ return fetchWithBackoff(MANGADEX.auth, {
27
+ method: 'POST',
28
+ headers: FORM,
29
+ body: new URLSearchParams(params).toString(),
30
+ signal,
31
+ retries: 1,
32
+ });
33
+ }
34
+
35
+ async function tokenRequest(params, { signal } = {}) {
36
+ // Ask for an OFFLINE refresh token so the session survives app restarts and
37
+ // long idle gaps (it's refreshed on each use, ~30-day idle window) instead of
38
+ // dying with the short-lived SSO session. If the client isn't allowed that
39
+ // scope, transparently fall back to a normal token so login still works.
40
+ let res = await postToken({ ...params, scope: 'offline_access' }, signal);
41
+ if (res.status === 400) {
42
+ const probe = await res.clone().json().catch(() => ({}));
43
+ if (probe.error === 'invalid_scope') res = await postToken(params, signal);
44
+ }
45
+ const json = await res.json().catch(() => ({}));
46
+ if (!res.ok) {
47
+ const detail = json.error_description || json.error || `HTTP ${res.status}`;
48
+ throw new AuthError(authMessage(res.status, detail), {
49
+ meta: { statusCode: res.status, oauthError: json.error },
50
+ });
51
+ }
52
+ return json;
53
+ }
54
+
55
+ function applyTokens(creds, json) {
56
+ access = {
57
+ token: json.access_token,
58
+ expiresAt: Date.now() + (Number(json.expires_in) || 900) * 1000,
59
+ };
60
+ // The refresh token rotates on each use — persist the latest one.
61
+ setCredentials({ ...creds, refreshToken: json.refresh_token || creds.refreshToken });
62
+ }
63
+
64
+ export async function login({ clientId, clientSecret, username, password }, { signal } = {}) {
65
+ const json = await tokenRequest({
66
+ grant_type: 'password',
67
+ username,
68
+ password,
69
+ client_id: clientId,
70
+ client_secret: clientSecret,
71
+ }, { signal });
72
+ applyTokens({ clientId, clientSecret, refreshToken: json.refresh_token }, json);
73
+ return true;
74
+ }
75
+
76
+ export function logout() {
77
+ access = { token: null, expiresAt: 0 };
78
+ refreshing = null;
79
+ clearCredentials();
80
+ }
81
+
82
+ // Returns a currently-valid access token, refreshing if needed, or null when
83
+ // not logged in. `force` ignores the cached token (used after a 401).
84
+ export async function getAccessToken({ signal, force = false } = {}) {
85
+ const creds = getCredentials();
86
+ if (!creds) return null;
87
+ if (!force && access.token && Date.now() < access.expiresAt - SKEW_MS) {
88
+ return access.token;
89
+ }
90
+ if (!refreshing) {
91
+ refreshing = (async () => {
92
+ try {
93
+ const json = await tokenRequest({
94
+ grant_type: 'refresh_token',
95
+ refresh_token: creds.refreshToken,
96
+ client_id: creds.clientId,
97
+ client_secret: creds.clientSecret,
98
+ }, { signal });
99
+ applyTokens(creds, json);
100
+ return access.token;
101
+ } catch (err) {
102
+ // Only drop the saved session when the refresh token is DEFINITIVELY
103
+ // dead (expired/revoked → invalid_grant). Transient or unexpected
104
+ // failures keep the credentials so the next call — or next launch — can
105
+ // recover, instead of silently logging the user out.
106
+ if (err.oauthError === 'invalid_grant') {
107
+ logger.warn('MangaDex refresh token expired/revoked — logging out', err);
108
+ logout();
109
+ }
110
+ throw err;
111
+ } finally {
112
+ refreshing = null;
113
+ }
114
+ })();
115
+ }
116
+ return refreshing;
117
+ }
118
+
119
+ function authMessage(status, detail) {
120
+ if (status === 401) return 'Login failed: check your username, password, and client credentials.';
121
+ if (status === 400 && /client/i.test(detail)) {
122
+ return 'Login failed: client not approved yet, or wrong client id / secret.';
123
+ }
124
+ return `Login failed (${status}): ${detail}`;
125
+ }
@@ -0,0 +1,83 @@
1
+ import { fetchJson, fetchWithBackoff } from '../../lib/fetchWithBackoff.js';
2
+ import { MANGADEX } from '../../config.js';
3
+ import { AuthError, SourceError } from '../../lib/AppError.js';
4
+ import { getAccessToken, isLoggedIn } from './auth.js';
5
+
6
+ const headers = {
7
+ 'User-Agent': MANGADEX.userAgent,
8
+ Accept: 'application/json',
9
+ };
10
+
11
+ // MangaDex uses PHP-style array/object query params:
12
+ // includes[]=cover_art contentRating[]=safe order[chapter]=asc
13
+ function qs(params) {
14
+ const sp = new URLSearchParams();
15
+ for (const [key, value] of Object.entries(params)) {
16
+ if (value == null) continue;
17
+ if (Array.isArray(value)) {
18
+ value.forEach((item) => sp.append(`${key}[]`, item));
19
+ } else if (typeof value === 'object') {
20
+ for (const [ik, iv] of Object.entries(value)) sp.append(`${key}[${ik}]`, iv);
21
+ } else {
22
+ sp.append(key, value);
23
+ }
24
+ }
25
+ return sp.toString();
26
+ }
27
+
28
+ // Resolve the Authorization header. `auth:true` endpoints REQUIRE a token (and
29
+ // error clearly without one); public endpoints attach it best-effort when the
30
+ // user is logged in, but never block browsing if the session can't refresh.
31
+ async function authHeader({ auth, signal }) {
32
+ if (auth) {
33
+ const token = await getAccessToken({ signal });
34
+ if (!token) throw new AuthError('Log in to MangaDex to use this feature.');
35
+ return { Authorization: `Bearer ${token}` };
36
+ }
37
+ if (isLoggedIn()) {
38
+ const token = await getAccessToken({ signal }).catch(() => null);
39
+ if (token) return { Authorization: `Bearer ${token}` };
40
+ }
41
+ return {};
42
+ }
43
+
44
+ export async function mdGet(path, params, { signal, auth = false } = {}) {
45
+ const url = `${MANGADEX.api}${path}${params ? `?${qs(params)}` : ''}`;
46
+ const h = { ...headers, ...(await authHeader({ auth, signal })) };
47
+ try {
48
+ return await fetchJson(url, { headers: h, signal });
49
+ } catch (err) {
50
+ // Token revoked server-side mid-session: force a refresh and retry once.
51
+ if (auth && err.statusCode === 401 && isLoggedIn()) {
52
+ const token = await getAccessToken({ signal, force: true });
53
+ return fetchJson(url, { headers: { ...h, Authorization: `Bearer ${token}` }, signal });
54
+ }
55
+ throw err;
56
+ }
57
+ }
58
+
59
+ export async function mdSend(method, path, body, { signal, auth = true } = {}) {
60
+ const url = `${MANGADEX.api}${path}`;
61
+ const send = async (h) => {
62
+ const res = await fetchWithBackoff(url, {
63
+ method,
64
+ headers: h,
65
+ body: body == null ? undefined : JSON.stringify(body),
66
+ signal,
67
+ });
68
+ if (!res.ok) {
69
+ throw new SourceError(`HTTP ${res.status} for ${url}`, { meta: { statusCode: res.status } });
70
+ }
71
+ return res.json().catch(() => ({}));
72
+ };
73
+ const base = { ...headers, 'Content-Type': 'application/json', ...(await authHeader({ auth, signal })) };
74
+ try {
75
+ return await send(base);
76
+ } catch (err) {
77
+ if (auth && err.statusCode === 401 && isLoggedIn()) {
78
+ const token = await getAccessToken({ signal, force: true });
79
+ return send({ ...base, Authorization: `Bearer ${token}` });
80
+ }
81
+ throw err;
82
+ }
83
+ }
@@ -0,0 +1,166 @@
1
+ import { mdGet, mdSend } from './client.js';
2
+ import { isLoggedIn } from './auth.js';
3
+ import { normalizeManga, normalizeChapter } from './normalize.js';
4
+ import { fetchWithBackoff } from '../../lib/fetchWithBackoff.js';
5
+ import { createCache } from '../../lib/cache.js';
6
+ import { envelope, paginate } from '../../lib/envelope.js';
7
+ import { NotFoundError } from '../../lib/AppError.js';
8
+ import { MANGADEX } from '../../config.js';
9
+ import { globalKey } from '../../domain/shape.js';
10
+ import { getConfig } from '../../state/store.js';
11
+ import { logger } from '../../lib/logger.js';
12
+
13
+ export const id = 'mangadex';
14
+ export const label = 'MangaDex';
15
+ export const remote = true;
16
+
17
+ const cache = createCache({ ttlMs: 5 * 60_000, negativeTtlMs: 15_000 });
18
+
19
+ export async function search(query, { offset = 0, limit = MANGADEX.pageLimit, signal } = {}) {
20
+ const cfg = getConfig();
21
+ const order = query ? { relevance: 'desc' } : { followedCount: 'desc' };
22
+ const key = `search:${query}:${offset}:${limit}:${cfg.contentRating.join(',')}`;
23
+ const res = await cache.wrap(key, () =>
24
+ mdGet('/manga', {
25
+ title: query || undefined,
26
+ limit,
27
+ offset,
28
+ includes: ['cover_art', 'author', 'artist'],
29
+ contentRating: cfg.contentRating,
30
+ hasAvailableChapters: 'true',
31
+ order,
32
+ }, { signal }),
33
+ );
34
+ const data = (res.data || []).map(normalizeManga);
35
+ return envelope(data, {
36
+ pagination: paginate({ offset: res.offset, limit: res.limit, total: res.total }),
37
+ meta: { source: id, query },
38
+ });
39
+ }
40
+
41
+ export async function getManga(mangaId, { signal } = {}) {
42
+ const res = await cache.wrap(`manga:${mangaId}`, () =>
43
+ mdGet(`/manga/${mangaId}`, { includes: ['cover_art', 'author', 'artist'] }, { signal }),
44
+ );
45
+ if (!res.data) throw new NotFoundError(`Manga ${mangaId} not found`);
46
+ return normalizeManga(res.data);
47
+ }
48
+
49
+ export async function listChapters(mangaId, { offset = 0, limit = 96, language, signal } = {}) {
50
+ const cfg = getConfig();
51
+ const lang = language || cfg.language;
52
+ const key = `chapters:${mangaId}:${lang}:${offset}:${limit}`;
53
+ const res = await cache.wrap(key, () =>
54
+ mdGet(`/manga/${mangaId}/feed`, {
55
+ limit,
56
+ offset,
57
+ translatedLanguage: lang ? [lang] : undefined,
58
+ contentRating: cfg.contentRating,
59
+ includes: ['scanlation_group'],
60
+ order: { volume: 'asc', chapter: 'asc' },
61
+ }, { signal }),
62
+ );
63
+
64
+ const mangaKey = globalKey(id, mangaId);
65
+ // MangaDex returns one row per scanlation; collapse duplicates by chapter no.
66
+ const seen = new Set();
67
+ const data = [];
68
+ for (const entry of res.data || []) {
69
+ // Externally-hosted chapters (MangaPlus etc.) have no pages we can render.
70
+ if (entry.attributes?.externalUrl) continue;
71
+ const ch = normalizeChapter(entry, mangaKey);
72
+ const dedup = `${ch.volume}:${ch.number}`;
73
+ if (ch.number != null && seen.has(dedup)) continue;
74
+ seen.add(dedup);
75
+ data.push(ch);
76
+ }
77
+ return envelope(data, {
78
+ pagination: paginate({ offset: res.offset, limit: res.limit, total: res.total }),
79
+ meta: { source: id, mangaId, language: lang },
80
+ });
81
+ }
82
+
83
+ // Page descriptors with a directly fetchable URL. The at-home token in baseUrl
84
+ // expires fast, so this is only briefly cached.
85
+ export async function getPages(chapterId, { signal } = {}) {
86
+ const cfg = getConfig();
87
+ const server = await cache.wrap(
88
+ `pages:${chapterId}:${cfg.dataSaver ? 'ds' : 'hq'}`,
89
+ () => mdGet(`/at-home/server/${chapterId}`, null, { signal }),
90
+ 60_000,
91
+ );
92
+ if (!server.chapter) throw new NotFoundError(`No pages for chapter ${chapterId}`);
93
+
94
+ // Prefer the configured quality, but fall back to the other if it's empty.
95
+ let mode = cfg.dataSaver ? 'data-saver' : 'data';
96
+ let files = cfg.dataSaver ? server.chapter.dataSaver : server.chapter.data;
97
+ if (!files || files.length === 0) {
98
+ mode = cfg.dataSaver ? 'data' : 'data-saver';
99
+ files = cfg.dataSaver ? server.chapter.data : server.chapter.dataSaver;
100
+ }
101
+ if (!files || files.length === 0) {
102
+ throw new NotFoundError(`Chapter ${chapterId} has no hosted pages`);
103
+ }
104
+ return files.map((file, index) => ({
105
+ index,
106
+ url: `${server.baseUrl}/${mode}/${server.chapter.hash}/${file}`,
107
+ }));
108
+ }
109
+
110
+ export async function loadPageBuffer(page, { signal } = {}) {
111
+ const res = await fetchWithBackoff(page.url, {
112
+ headers: { 'User-Agent': MANGADEX.userAgent },
113
+ timeoutMs: 30_000,
114
+ signal,
115
+ });
116
+ if (!res.ok) throw new NotFoundError(`Failed to load page ${page.index} (${res.status})`);
117
+ return Buffer.from(await res.arrayBuffer());
118
+ }
119
+
120
+ // ---- Authenticated features (require login) ----------------------------
121
+
122
+ // The signed-in user's followed manga, normalized like search results.
123
+ export async function getFollows({ offset = 0, limit = MANGADEX.pageLimit, signal } = {}) {
124
+ // /user/follows/manga rejects contentRating[] (HTTP 400) — it returns ALL of
125
+ // the user's follows regardless of rating, which is what we want here anyway.
126
+ const res = await mdGet('/user/follows/manga', {
127
+ limit,
128
+ offset,
129
+ includes: ['cover_art', 'author', 'artist'],
130
+ }, { signal, auth: true });
131
+ const data = (res.data || []).map(normalizeManga);
132
+ return envelope(data, {
133
+ pagination: paginate({ offset: res.offset, limit: res.limit, total: res.total }),
134
+ meta: { source: id },
135
+ });
136
+ }
137
+
138
+ // Chapter IDs the user has marked read for this manga (for decorating the list).
139
+ export async function getReadMarkers(mangaId, { signal } = {}) {
140
+ const res = await mdGet(`/manga/${mangaId}/read`, null, { signal, auth: true });
141
+ return res.data || [];
142
+ }
143
+
144
+ export async function markChaptersRead(mangaId, chapterIdsRead, { signal } = {}) {
145
+ if (!chapterIdsRead?.length) return;
146
+ await mdSend('POST', `/manga/${mangaId}/read`, {
147
+ chapterIdsRead,
148
+ chapterIdsUnread: [],
149
+ }, { signal });
150
+ }
151
+
152
+ // Fire-and-forget read-marker push when a chapter is finished. Self-guards on
153
+ // login + the syncProgress setting and dedupes per session, so a reader can
154
+ // call it freely (e.g. on every settle at the last page) without spamming.
155
+ const pushedRead = new Set();
156
+ export async function syncChapterRead(mangaId, chapterId) {
157
+ if (!chapterId || pushedRead.has(chapterId)) return;
158
+ if (!isLoggedIn() || !getConfig().syncProgress) return;
159
+ pushedRead.add(chapterId);
160
+ try {
161
+ await markChaptersRead(mangaId, [chapterId]);
162
+ } catch (err) {
163
+ pushedRead.delete(chapterId); // let a later attempt retry
164
+ logger.warn('failed to push read marker', err);
165
+ }
166
+ }
@@ -0,0 +1,70 @@
1
+ import { makeManga, makeChapter, globalKey } from '../../domain/shape.js';
2
+ import { MANGADEX } from '../../config.js';
3
+
4
+ const SOURCE = 'mangadex';
5
+
6
+ // MangaDex localizes strings as { en: "...", ja: "..." }. Prefer English, then
7
+ // whatever's first.
8
+ function localized(map) {
9
+ if (!map || typeof map !== 'object') return '';
10
+ return map.en || map[Object.keys(map)[0]] || '';
11
+ }
12
+
13
+ export function normalizeManga(entry) {
14
+ const attr = entry.attributes || {};
15
+ const rels = entry.relationships || [];
16
+
17
+ const cover = rels.find((r) => r.type === 'cover_art');
18
+ const coverFile = cover?.attributes?.fileName;
19
+ const coverUrl = coverFile
20
+ ? `${MANGADEX.uploads}/covers/${entry.id}/${coverFile}.512.jpg`
21
+ : null;
22
+
23
+ const authors = [
24
+ ...new Set(
25
+ rels
26
+ .filter((r) => r.type === 'author' || r.type === 'artist')
27
+ .map((r) => r.attributes?.name)
28
+ .filter(Boolean),
29
+ ),
30
+ ];
31
+
32
+ const altTitles = (attr.altTitles || [])
33
+ .map((o) => Object.values(o)[0])
34
+ .filter(Boolean);
35
+
36
+ const tags = (attr.tags || [])
37
+ .map((t) => t.attributes?.name?.en)
38
+ .filter(Boolean);
39
+
40
+ return makeManga({
41
+ source: SOURCE,
42
+ id: entry.id,
43
+ title: localized(attr.title) || altTitles[0] || 'Untitled',
44
+ altTitles,
45
+ description: localized(attr.description),
46
+ authors,
47
+ status: attr.status || 'unknown',
48
+ tags,
49
+ coverUrl,
50
+ language: attr.originalLanguage || 'en',
51
+ raw: entry,
52
+ });
53
+ }
54
+
55
+ export function normalizeChapter(entry, mangaKey) {
56
+ const attr = entry.attributes || {};
57
+ const mangaRel = (entry.relationships || []).find((r) => r.type === 'manga');
58
+ return makeChapter({
59
+ source: SOURCE,
60
+ id: entry.id,
61
+ mangaKey: mangaKey || (mangaRel ? globalKey(SOURCE, mangaRel.id) : null),
62
+ number: attr.chapter ?? null,
63
+ volume: attr.volume ?? null,
64
+ title: attr.title || '',
65
+ language: attr.translatedLanguage || 'en',
66
+ pages: attr.pages ?? null,
67
+ publishedAt: attr.publishAt ? new Date(attr.publishAt) : null,
68
+ raw: entry,
69
+ });
70
+ }
@@ -0,0 +1,90 @@
1
+ import fs from 'node:fs';
2
+ import { paths, DEFAULT_CONFIG, ensureDirs } from '../config.js';
3
+ import { logger } from '../lib/logger.js';
4
+
5
+ function readJson(file, fallback) {
6
+ try {
7
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
8
+ } catch (err) {
9
+ if (err.code !== 'ENOENT') logger.warn(`failed to read ${file}`, err);
10
+ return fallback;
11
+ }
12
+ }
13
+
14
+ // Write-to-temp + rename so a crash mid-write never corrupts the file.
15
+ function writeJsonAtomic(file, data) {
16
+ ensureDirs();
17
+ const tmp = `${file}.${process.pid}.tmp`;
18
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
19
+ fs.renameSync(tmp, file);
20
+ }
21
+
22
+ // ---- Config (user prefs + library paths) -------------------------------
23
+ let config = null;
24
+ export function getConfig() {
25
+ if (!config) config = { ...DEFAULT_CONFIG, ...readJson(paths.configFile, {}) };
26
+ return config;
27
+ }
28
+ export function setConfig(patch) {
29
+ config = { ...getConfig(), ...patch };
30
+ writeJsonAtomic(paths.configFile, config);
31
+ return config;
32
+ }
33
+
34
+ // ---- Reading progress --------------------------------------------------
35
+ // Shape: { [mangaKey]: { source, mangaId, mangaTitle, chapterId, chapterNumber, page, updatedAt } }
36
+ let progress = null;
37
+ function loadProgress() {
38
+ if (!progress) progress = readJson(paths.progressFile, {});
39
+ return progress;
40
+ }
41
+
42
+ // Debounced save — the reader updates progress on every page turn, so coalesce.
43
+ let saveTimer = null;
44
+ function scheduleSave() {
45
+ clearTimeout(saveTimer);
46
+ saveTimer = setTimeout(() => writeJsonAtomic(paths.progressFile, progress), 400);
47
+ saveTimer.unref?.();
48
+ }
49
+
50
+ export function getProgress(mangaKey) {
51
+ return loadProgress()[mangaKey] || null;
52
+ }
53
+ export function getAllProgress() {
54
+ return Object.values(loadProgress()).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
55
+ }
56
+ export function setProgress(mangaKey, entry) {
57
+ loadProgress()[mangaKey] = { ...entry, updatedAt: Date.now() };
58
+ scheduleSave();
59
+ }
60
+ export function flushProgress() {
61
+ clearTimeout(saveTimer);
62
+ if (progress) writeJsonAtomic(paths.progressFile, progress);
63
+ }
64
+
65
+ // ---- MangaDex credentials (OAuth2 personal client) ---------------------
66
+ // Only the durable secrets live here: the client id/secret and the rotating
67
+ // refresh token. The 15-min access token is kept in memory by auth.js and
68
+ // never written to disk. File is 0600 since it holds a long-lived secret.
69
+ let credentials = null;
70
+ function writeCredentialsAtomic(data) {
71
+ ensureDirs();
72
+ const file = paths.credentialsFile;
73
+ const tmp = `${file}.${process.pid}.tmp`;
74
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
75
+ fs.renameSync(tmp, file);
76
+ try { fs.chmodSync(file, 0o600); } catch { /* best effort (e.g. Windows) */ }
77
+ }
78
+ export function getCredentials() {
79
+ if (credentials === null) credentials = readJson(paths.credentialsFile, {});
80
+ return credentials.refreshToken ? credentials : null;
81
+ }
82
+ export function setCredentials(next) {
83
+ credentials = { ...next };
84
+ writeCredentialsAtomic(credentials);
85
+ return credentials;
86
+ }
87
+ export function clearCredentials() {
88
+ credentials = {};
89
+ try { fs.rmSync(paths.credentialsFile, { force: true }); } catch { /* ignore */ }
90
+ }