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.
- package/README.md +208 -0
- package/dist/app.js +59 -0
- package/dist/cli.js +196 -0
- package/dist/components/List.js +41 -0
- package/dist/components/screens/ContinueScreen.js +66 -0
- package/dist/components/screens/HomeScreen.js +59 -0
- package/dist/components/screens/LibraryScreen.js +69 -0
- package/dist/components/screens/LoginScreen.js +85 -0
- package/dist/components/screens/MangaScreen.js +107 -0
- package/dist/components/screens/ReaderScreen.js +195 -0
- package/dist/components/screens/SearchScreen.js +111 -0
- package/dist/components/screens/SettingsScreen.js +144 -0
- package/dist/components/ui.js +33 -0
- package/dist/config.js +51 -0
- package/dist/domain/shape.js +44 -0
- package/dist/hooks/useStdoutDimensions.js +17 -0
- package/dist/lib/AppError.js +37 -0
- package/dist/lib/cache.js +54 -0
- package/dist/lib/catchAsync.js +12 -0
- package/dist/lib/envelope.js +15 -0
- package/dist/lib/fetchWithBackoff.js +65 -0
- package/dist/lib/logger.js +41 -0
- package/dist/lib/natsort.js +7 -0
- package/dist/lib/text.js +20 -0
- package/dist/render/chafa.js +56 -0
- package/dist/render/detect.js +86 -0
- package/dist/render/halfblock.js +42 -0
- package/dist/render/image.js +23 -0
- package/dist/render/sixel.js +88 -0
- package/dist/sixel-reader.js +309 -0
- package/dist/sources/index.js +17 -0
- package/dist/sources/local/archive.js +68 -0
- package/dist/sources/local/index.js +147 -0
- package/dist/sources/mangadex/auth.js +102 -0
- package/dist/sources/mangadex/client.js +76 -0
- package/dist/sources/mangadex/index.js +156 -0
- package/dist/sources/mangadex/normalize.js +54 -0
- package/dist/state/store.js +91 -0
- package/dist/ui-context.js +11 -0
- package/package.json +50 -0
- package/src/app.js +73 -0
- package/src/cli.js +218 -0
- package/src/components/List.js +60 -0
- package/src/components/screens/ContinueScreen.js +73 -0
- package/src/components/screens/HomeScreen.js +54 -0
- package/src/components/screens/LibraryScreen.js +79 -0
- package/src/components/screens/LoginScreen.js +92 -0
- package/src/components/screens/MangaScreen.js +125 -0
- package/src/components/screens/ReaderScreen.js +230 -0
- package/src/components/screens/SearchScreen.js +123 -0
- package/src/components/screens/SettingsScreen.js +146 -0
- package/src/components/ui.js +42 -0
- package/src/config.js +49 -0
- package/src/domain/shape.js +47 -0
- package/src/hooks/useStdoutDimensions.js +19 -0
- package/src/lib/AppError.js +26 -0
- package/src/lib/cache.js +57 -0
- package/src/lib/catchAsync.js +12 -0
- package/src/lib/envelope.js +14 -0
- package/src/lib/fetchWithBackoff.js +74 -0
- package/src/lib/logger.js +41 -0
- package/src/lib/natsort.js +7 -0
- package/src/lib/text.js +18 -0
- package/src/render/chafa.js +64 -0
- package/src/render/detect.js +112 -0
- package/src/render/halfblock.js +46 -0
- package/src/render/image.js +24 -0
- package/src/render/sixel.js +141 -0
- package/src/sixel-reader.js +359 -0
- package/src/sources/index.js +17 -0
- package/src/sources/local/archive.js +74 -0
- package/src/sources/local/index.js +155 -0
- package/src/sources/mangadex/auth.js +125 -0
- package/src/sources/mangadex/client.js +83 -0
- package/src/sources/mangadex/index.js +166 -0
- package/src/sources/mangadex/normalize.js +70 -0
- package/src/state/store.js +90 -0
- package/src/ui-context.js +12 -0
|
@@ -0,0 +1,147 @@
|
|
|
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
|
+
const id = "local";
|
|
12
|
+
const label = "Local library";
|
|
13
|
+
const remote = false;
|
|
14
|
+
const IMAGE_RE = /\.(jpe?g|png|gif|webp|bmp|avif)$/i;
|
|
15
|
+
let model = null;
|
|
16
|
+
function listDir(dir) {
|
|
17
|
+
try {
|
|
18
|
+
return fs.readdirSync(dir, { withFileTypes: true });
|
|
19
|
+
} catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const hasImages = (dir) => listDir(dir).some((d) => d.isFile() && IMAGE_RE.test(d.name));
|
|
24
|
+
const cleanName = (name) => name.replace(/\.(cbz|zip|cbr|rar)$/i, "");
|
|
25
|
+
const expandHome = (p) => p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p;
|
|
26
|
+
function buildMangaFromDir(dirPath) {
|
|
27
|
+
const entries = listDir(dirPath);
|
|
28
|
+
const chapters = [];
|
|
29
|
+
const chapterDirs = entries.filter((e) => e.isDirectory() && hasImages(path.join(dirPath, e.name)));
|
|
30
|
+
for (const d of naturalSort(chapterDirs, (e) => e.name)) {
|
|
31
|
+
chapters.push({ kind: "dir", dir: path.join(dirPath, d.name), name: d.name });
|
|
32
|
+
}
|
|
33
|
+
const archives = entries.filter((e) => e.isFile() && isArchive(e.name));
|
|
34
|
+
for (const f of naturalSort(archives, (e) => e.name)) {
|
|
35
|
+
chapters.push({ kind: "archive", file: path.join(dirPath, f.name), name: f.name });
|
|
36
|
+
}
|
|
37
|
+
if (chapters.length === 0 && hasImages(dirPath)) {
|
|
38
|
+
chapters.push({ kind: "dir", dir: dirPath, name: path.basename(dirPath) });
|
|
39
|
+
}
|
|
40
|
+
return chapters.length ? { mangaId: dirPath, title: path.basename(dirPath), chapters } : null;
|
|
41
|
+
}
|
|
42
|
+
function buildMangaFromArchive(filePath) {
|
|
43
|
+
return {
|
|
44
|
+
mangaId: filePath,
|
|
45
|
+
title: cleanName(path.basename(filePath)),
|
|
46
|
+
chapters: [{ kind: "archive", file: filePath, name: path.basename(filePath) }]
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function scan() {
|
|
50
|
+
model = /* @__PURE__ */ new Map();
|
|
51
|
+
for (const libPath of getConfig().localLibraryPaths || []) {
|
|
52
|
+
const abs = path.resolve(expandHome(libPath));
|
|
53
|
+
for (const entry of naturalSort(listDir(abs), (e) => e.name)) {
|
|
54
|
+
const full = path.join(abs, entry.name);
|
|
55
|
+
const built = entry.isDirectory() ? buildMangaFromDir(full) : isArchive(entry.name) ? buildMangaFromArchive(full) : null;
|
|
56
|
+
if (built) model.set(built.mangaId, built);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return model;
|
|
60
|
+
}
|
|
61
|
+
function ensureModel() {
|
|
62
|
+
if (!model) scan();
|
|
63
|
+
return model;
|
|
64
|
+
}
|
|
65
|
+
function toManga(built) {
|
|
66
|
+
return makeManga({
|
|
67
|
+
source: id,
|
|
68
|
+
id: built.mangaId,
|
|
69
|
+
title: built.title,
|
|
70
|
+
status: "local",
|
|
71
|
+
chaptersCount: built.chapters.length,
|
|
72
|
+
language: getConfig().language,
|
|
73
|
+
raw: { path: built.mangaId }
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
async function search(query, { offset = 0, limit = 1e3 } = {}) {
|
|
77
|
+
ensureModel();
|
|
78
|
+
const q = (query || "").toLowerCase();
|
|
79
|
+
let list = [...model.values()];
|
|
80
|
+
if (q) list = list.filter((b) => b.title.toLowerCase().includes(q));
|
|
81
|
+
list.sort((a, b) => naturalCompare(a.title, b.title));
|
|
82
|
+
const data = list.slice(offset, offset + limit).map(toManga);
|
|
83
|
+
return envelope(data, {
|
|
84
|
+
pagination: paginate({ offset, limit, total: list.length }),
|
|
85
|
+
meta: { source: id, query }
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
async function getManga(mangaId) {
|
|
89
|
+
ensureModel();
|
|
90
|
+
const built = model.get(mangaId);
|
|
91
|
+
if (!built) throw new NotFoundError(`Local manga not found: ${mangaId}`);
|
|
92
|
+
return toManga(built);
|
|
93
|
+
}
|
|
94
|
+
async function listChapters(mangaId, { offset = 0, limit = 1e5 } = {}) {
|
|
95
|
+
ensureModel();
|
|
96
|
+
const built = model.get(mangaId);
|
|
97
|
+
if (!built) throw new NotFoundError(`Local manga not found: ${mangaId}`);
|
|
98
|
+
const mangaKey = globalKey(id, mangaId);
|
|
99
|
+
const data = built.chapters.map(
|
|
100
|
+
(ch, i) => makeChapter({
|
|
101
|
+
source: id,
|
|
102
|
+
id: `${mangaId}#${i}`,
|
|
103
|
+
mangaKey,
|
|
104
|
+
number: built.chapters.length > 1 ? String(i + 1) : null,
|
|
105
|
+
title: cleanName(ch.name),
|
|
106
|
+
raw: ch
|
|
107
|
+
})
|
|
108
|
+
);
|
|
109
|
+
return envelope(data.slice(offset, offset + limit), {
|
|
110
|
+
pagination: paginate({ offset, limit, total: data.length }),
|
|
111
|
+
meta: { source: id, mangaId }
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
async function getPages(chapterId) {
|
|
115
|
+
ensureModel();
|
|
116
|
+
const hash = chapterId.lastIndexOf("#");
|
|
117
|
+
const mangaId = chapterId.slice(0, hash);
|
|
118
|
+
const chIndex = Number(chapterId.slice(hash + 1));
|
|
119
|
+
const built = model.get(mangaId);
|
|
120
|
+
if (!built) throw new NotFoundError(`Local manga not found: ${mangaId}`);
|
|
121
|
+
const ch = built.chapters[chIndex];
|
|
122
|
+
if (!ch) throw new NotFoundError(`Chapter not found: ${chapterId}`);
|
|
123
|
+
if (ch.kind === "dir") {
|
|
124
|
+
const files = naturalSort(
|
|
125
|
+
listDir(ch.dir).filter((e) => e.isFile() && IMAGE_RE.test(e.name)).map((e) => e.name)
|
|
126
|
+
);
|
|
127
|
+
return files.map((name, index) => ({ index, kind: "file", file: path.join(ch.dir, name) }));
|
|
128
|
+
}
|
|
129
|
+
const names = await listArchiveImages(ch.file);
|
|
130
|
+
return names.map((entry, index) => ({ index, kind: "archive", file: ch.file, entry }));
|
|
131
|
+
}
|
|
132
|
+
async function loadPageBuffer(page) {
|
|
133
|
+
if (page.kind === "file") return fsp.readFile(page.file);
|
|
134
|
+
if (page.kind === "archive") return readArchiveEntry(page.file, page.entry);
|
|
135
|
+
throw new NotFoundError("Unknown local page descriptor");
|
|
136
|
+
}
|
|
137
|
+
export {
|
|
138
|
+
getManga,
|
|
139
|
+
getPages,
|
|
140
|
+
id,
|
|
141
|
+
label,
|
|
142
|
+
listChapters,
|
|
143
|
+
loadPageBuffer,
|
|
144
|
+
remote,
|
|
145
|
+
scan,
|
|
146
|
+
search
|
|
147
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
let access = { token: null, expiresAt: 0 };
|
|
7
|
+
let refreshing = null;
|
|
8
|
+
const FORM = { "Content-Type": "application/x-www-form-urlencoded" };
|
|
9
|
+
const SKEW_MS = 3e4;
|
|
10
|
+
function isLoggedIn() {
|
|
11
|
+
return !!getCredentials();
|
|
12
|
+
}
|
|
13
|
+
function postToken(params, signal) {
|
|
14
|
+
return fetchWithBackoff(MANGADEX.auth, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: FORM,
|
|
17
|
+
body: new URLSearchParams(params).toString(),
|
|
18
|
+
signal,
|
|
19
|
+
retries: 1
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
async function tokenRequest(params, { signal } = {}) {
|
|
23
|
+
let res = await postToken({ ...params, scope: "offline_access" }, signal);
|
|
24
|
+
if (res.status === 400) {
|
|
25
|
+
const probe = await res.clone().json().catch(() => ({}));
|
|
26
|
+
if (probe.error === "invalid_scope") res = await postToken(params, signal);
|
|
27
|
+
}
|
|
28
|
+
const json = await res.json().catch(() => ({}));
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
const detail = json.error_description || json.error || `HTTP ${res.status}`;
|
|
31
|
+
throw new AuthError(authMessage(res.status, detail), {
|
|
32
|
+
meta: { statusCode: res.status, oauthError: json.error }
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return json;
|
|
36
|
+
}
|
|
37
|
+
function applyTokens(creds, json) {
|
|
38
|
+
access = {
|
|
39
|
+
token: json.access_token,
|
|
40
|
+
expiresAt: Date.now() + (Number(json.expires_in) || 900) * 1e3
|
|
41
|
+
};
|
|
42
|
+
setCredentials({ ...creds, refreshToken: json.refresh_token || creds.refreshToken });
|
|
43
|
+
}
|
|
44
|
+
async function login({ clientId, clientSecret, username, password }, { signal } = {}) {
|
|
45
|
+
const json = await tokenRequest({
|
|
46
|
+
grant_type: "password",
|
|
47
|
+
username,
|
|
48
|
+
password,
|
|
49
|
+
client_id: clientId,
|
|
50
|
+
client_secret: clientSecret
|
|
51
|
+
}, { signal });
|
|
52
|
+
applyTokens({ clientId, clientSecret, refreshToken: json.refresh_token }, json);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
function logout() {
|
|
56
|
+
access = { token: null, expiresAt: 0 };
|
|
57
|
+
refreshing = null;
|
|
58
|
+
clearCredentials();
|
|
59
|
+
}
|
|
60
|
+
async function getAccessToken({ signal, force = false } = {}) {
|
|
61
|
+
const creds = getCredentials();
|
|
62
|
+
if (!creds) return null;
|
|
63
|
+
if (!force && access.token && Date.now() < access.expiresAt - SKEW_MS) {
|
|
64
|
+
return access.token;
|
|
65
|
+
}
|
|
66
|
+
if (!refreshing) {
|
|
67
|
+
refreshing = (async () => {
|
|
68
|
+
try {
|
|
69
|
+
const json = await tokenRequest({
|
|
70
|
+
grant_type: "refresh_token",
|
|
71
|
+
refresh_token: creds.refreshToken,
|
|
72
|
+
client_id: creds.clientId,
|
|
73
|
+
client_secret: creds.clientSecret
|
|
74
|
+
}, { signal });
|
|
75
|
+
applyTokens(creds, json);
|
|
76
|
+
return access.token;
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (err.oauthError === "invalid_grant") {
|
|
79
|
+
logger.warn("MangaDex refresh token expired/revoked \u2014 logging out", err);
|
|
80
|
+
logout();
|
|
81
|
+
}
|
|
82
|
+
throw err;
|
|
83
|
+
} finally {
|
|
84
|
+
refreshing = null;
|
|
85
|
+
}
|
|
86
|
+
})();
|
|
87
|
+
}
|
|
88
|
+
return refreshing;
|
|
89
|
+
}
|
|
90
|
+
function authMessage(status, detail) {
|
|
91
|
+
if (status === 401) return "Login failed: check your username, password, and client credentials.";
|
|
92
|
+
if (status === 400 && /client/i.test(detail)) {
|
|
93
|
+
return "Login failed: client not approved yet, or wrong client id / secret.";
|
|
94
|
+
}
|
|
95
|
+
return `Login failed (${status}): ${detail}`;
|
|
96
|
+
}
|
|
97
|
+
export {
|
|
98
|
+
getAccessToken,
|
|
99
|
+
isLoggedIn,
|
|
100
|
+
login,
|
|
101
|
+
logout
|
|
102
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
const headers = {
|
|
6
|
+
"User-Agent": MANGADEX.userAgent,
|
|
7
|
+
Accept: "application/json"
|
|
8
|
+
};
|
|
9
|
+
function qs(params) {
|
|
10
|
+
const sp = new URLSearchParams();
|
|
11
|
+
for (const [key, value] of Object.entries(params)) {
|
|
12
|
+
if (value == null) continue;
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
value.forEach((item) => sp.append(`${key}[]`, item));
|
|
15
|
+
} else if (typeof value === "object") {
|
|
16
|
+
for (const [ik, iv] of Object.entries(value)) sp.append(`${key}[${ik}]`, iv);
|
|
17
|
+
} else {
|
|
18
|
+
sp.append(key, value);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return sp.toString();
|
|
22
|
+
}
|
|
23
|
+
async function authHeader({ auth, signal }) {
|
|
24
|
+
if (auth) {
|
|
25
|
+
const token = await getAccessToken({ signal });
|
|
26
|
+
if (!token) throw new AuthError("Log in to MangaDex to use this feature.");
|
|
27
|
+
return { Authorization: `Bearer ${token}` };
|
|
28
|
+
}
|
|
29
|
+
if (isLoggedIn()) {
|
|
30
|
+
const token = await getAccessToken({ signal }).catch(() => null);
|
|
31
|
+
if (token) return { Authorization: `Bearer ${token}` };
|
|
32
|
+
}
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
async function mdGet(path, params, { signal, auth = false } = {}) {
|
|
36
|
+
const url = `${MANGADEX.api}${path}${params ? `?${qs(params)}` : ""}`;
|
|
37
|
+
const h = { ...headers, ...await authHeader({ auth, signal }) };
|
|
38
|
+
try {
|
|
39
|
+
return await fetchJson(url, { headers: h, signal });
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (auth && err.statusCode === 401 && isLoggedIn()) {
|
|
42
|
+
const token = await getAccessToken({ signal, force: true });
|
|
43
|
+
return fetchJson(url, { headers: { ...h, Authorization: `Bearer ${token}` }, signal });
|
|
44
|
+
}
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function mdSend(method, path, body, { signal, auth = true } = {}) {
|
|
49
|
+
const url = `${MANGADEX.api}${path}`;
|
|
50
|
+
const send = async (h) => {
|
|
51
|
+
const res = await fetchWithBackoff(url, {
|
|
52
|
+
method,
|
|
53
|
+
headers: h,
|
|
54
|
+
body: body == null ? void 0 : JSON.stringify(body),
|
|
55
|
+
signal
|
|
56
|
+
});
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
throw new SourceError(`HTTP ${res.status} for ${url}`, { meta: { statusCode: res.status } });
|
|
59
|
+
}
|
|
60
|
+
return res.json().catch(() => ({}));
|
|
61
|
+
};
|
|
62
|
+
const base = { ...headers, "Content-Type": "application/json", ...await authHeader({ auth, signal }) };
|
|
63
|
+
try {
|
|
64
|
+
return await send(base);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (auth && err.statusCode === 401 && isLoggedIn()) {
|
|
67
|
+
const token = await getAccessToken({ signal, force: true });
|
|
68
|
+
return send({ ...base, Authorization: `Bearer ${token}` });
|
|
69
|
+
}
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export {
|
|
74
|
+
mdGet,
|
|
75
|
+
mdSend
|
|
76
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
const id = "mangadex";
|
|
13
|
+
const label = "MangaDex";
|
|
14
|
+
const remote = true;
|
|
15
|
+
const cache = createCache({ ttlMs: 5 * 6e4, negativeTtlMs: 15e3 });
|
|
16
|
+
async function search(query, { offset = 0, limit = MANGADEX.pageLimit, signal } = {}) {
|
|
17
|
+
const cfg = getConfig();
|
|
18
|
+
const order = query ? { relevance: "desc" } : { followedCount: "desc" };
|
|
19
|
+
const key = `search:${query}:${offset}:${limit}:${cfg.contentRating.join(",")}`;
|
|
20
|
+
const res = await cache.wrap(
|
|
21
|
+
key,
|
|
22
|
+
() => mdGet("/manga", {
|
|
23
|
+
title: query || void 0,
|
|
24
|
+
limit,
|
|
25
|
+
offset,
|
|
26
|
+
includes: ["cover_art", "author", "artist"],
|
|
27
|
+
contentRating: cfg.contentRating,
|
|
28
|
+
hasAvailableChapters: "true",
|
|
29
|
+
order
|
|
30
|
+
}, { signal })
|
|
31
|
+
);
|
|
32
|
+
const data = (res.data || []).map(normalizeManga);
|
|
33
|
+
return envelope(data, {
|
|
34
|
+
pagination: paginate({ offset: res.offset, limit: res.limit, total: res.total }),
|
|
35
|
+
meta: { source: id, query }
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
async function getManga(mangaId, { signal } = {}) {
|
|
39
|
+
const res = await cache.wrap(
|
|
40
|
+
`manga:${mangaId}`,
|
|
41
|
+
() => mdGet(`/manga/${mangaId}`, { includes: ["cover_art", "author", "artist"] }, { signal })
|
|
42
|
+
);
|
|
43
|
+
if (!res.data) throw new NotFoundError(`Manga ${mangaId} not found`);
|
|
44
|
+
return normalizeManga(res.data);
|
|
45
|
+
}
|
|
46
|
+
async function listChapters(mangaId, { offset = 0, limit = 96, language, signal } = {}) {
|
|
47
|
+
const cfg = getConfig();
|
|
48
|
+
const lang = language || cfg.language;
|
|
49
|
+
const key = `chapters:${mangaId}:${lang}:${offset}:${limit}`;
|
|
50
|
+
const res = await cache.wrap(
|
|
51
|
+
key,
|
|
52
|
+
() => mdGet(`/manga/${mangaId}/feed`, {
|
|
53
|
+
limit,
|
|
54
|
+
offset,
|
|
55
|
+
translatedLanguage: lang ? [lang] : void 0,
|
|
56
|
+
contentRating: cfg.contentRating,
|
|
57
|
+
includes: ["scanlation_group"],
|
|
58
|
+
order: { volume: "asc", chapter: "asc" }
|
|
59
|
+
}, { signal })
|
|
60
|
+
);
|
|
61
|
+
const mangaKey = globalKey(id, mangaId);
|
|
62
|
+
const seen = /* @__PURE__ */ new Set();
|
|
63
|
+
const data = [];
|
|
64
|
+
for (const entry of res.data || []) {
|
|
65
|
+
if (entry.attributes?.externalUrl) continue;
|
|
66
|
+
const ch = normalizeChapter(entry, mangaKey);
|
|
67
|
+
const dedup = `${ch.volume}:${ch.number}`;
|
|
68
|
+
if (ch.number != null && seen.has(dedup)) continue;
|
|
69
|
+
seen.add(dedup);
|
|
70
|
+
data.push(ch);
|
|
71
|
+
}
|
|
72
|
+
return envelope(data, {
|
|
73
|
+
pagination: paginate({ offset: res.offset, limit: res.limit, total: res.total }),
|
|
74
|
+
meta: { source: id, mangaId, language: lang }
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
async function getPages(chapterId, { signal } = {}) {
|
|
78
|
+
const cfg = getConfig();
|
|
79
|
+
const server = await cache.wrap(
|
|
80
|
+
`pages:${chapterId}:${cfg.dataSaver ? "ds" : "hq"}`,
|
|
81
|
+
() => mdGet(`/at-home/server/${chapterId}`, null, { signal }),
|
|
82
|
+
6e4
|
|
83
|
+
);
|
|
84
|
+
if (!server.chapter) throw new NotFoundError(`No pages for chapter ${chapterId}`);
|
|
85
|
+
let mode = cfg.dataSaver ? "data-saver" : "data";
|
|
86
|
+
let files = cfg.dataSaver ? server.chapter.dataSaver : server.chapter.data;
|
|
87
|
+
if (!files || files.length === 0) {
|
|
88
|
+
mode = cfg.dataSaver ? "data" : "data-saver";
|
|
89
|
+
files = cfg.dataSaver ? server.chapter.data : server.chapter.dataSaver;
|
|
90
|
+
}
|
|
91
|
+
if (!files || files.length === 0) {
|
|
92
|
+
throw new NotFoundError(`Chapter ${chapterId} has no hosted pages`);
|
|
93
|
+
}
|
|
94
|
+
return files.map((file, index) => ({
|
|
95
|
+
index,
|
|
96
|
+
url: `${server.baseUrl}/${mode}/${server.chapter.hash}/${file}`
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
async function loadPageBuffer(page, { signal } = {}) {
|
|
100
|
+
const res = await fetchWithBackoff(page.url, {
|
|
101
|
+
headers: { "User-Agent": MANGADEX.userAgent },
|
|
102
|
+
timeoutMs: 3e4,
|
|
103
|
+
signal
|
|
104
|
+
});
|
|
105
|
+
if (!res.ok) throw new NotFoundError(`Failed to load page ${page.index} (${res.status})`);
|
|
106
|
+
return Buffer.from(await res.arrayBuffer());
|
|
107
|
+
}
|
|
108
|
+
async function getFollows({ offset = 0, limit = MANGADEX.pageLimit, signal } = {}) {
|
|
109
|
+
const res = await mdGet("/user/follows/manga", {
|
|
110
|
+
limit,
|
|
111
|
+
offset,
|
|
112
|
+
includes: ["cover_art", "author", "artist"]
|
|
113
|
+
}, { signal, auth: true });
|
|
114
|
+
const data = (res.data || []).map(normalizeManga);
|
|
115
|
+
return envelope(data, {
|
|
116
|
+
pagination: paginate({ offset: res.offset, limit: res.limit, total: res.total }),
|
|
117
|
+
meta: { source: id }
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
async function getReadMarkers(mangaId, { signal } = {}) {
|
|
121
|
+
const res = await mdGet(`/manga/${mangaId}/read`, null, { signal, auth: true });
|
|
122
|
+
return res.data || [];
|
|
123
|
+
}
|
|
124
|
+
async function markChaptersRead(mangaId, chapterIdsRead, { signal } = {}) {
|
|
125
|
+
if (!chapterIdsRead?.length) return;
|
|
126
|
+
await mdSend("POST", `/manga/${mangaId}/read`, {
|
|
127
|
+
chapterIdsRead,
|
|
128
|
+
chapterIdsUnread: []
|
|
129
|
+
}, { signal });
|
|
130
|
+
}
|
|
131
|
+
const pushedRead = /* @__PURE__ */ new Set();
|
|
132
|
+
async function syncChapterRead(mangaId, chapterId) {
|
|
133
|
+
if (!chapterId || pushedRead.has(chapterId)) return;
|
|
134
|
+
if (!isLoggedIn() || !getConfig().syncProgress) return;
|
|
135
|
+
pushedRead.add(chapterId);
|
|
136
|
+
try {
|
|
137
|
+
await markChaptersRead(mangaId, [chapterId]);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
pushedRead.delete(chapterId);
|
|
140
|
+
logger.warn("failed to push read marker", err);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export {
|
|
144
|
+
getFollows,
|
|
145
|
+
getManga,
|
|
146
|
+
getPages,
|
|
147
|
+
getReadMarkers,
|
|
148
|
+
id,
|
|
149
|
+
label,
|
|
150
|
+
listChapters,
|
|
151
|
+
loadPageBuffer,
|
|
152
|
+
markChaptersRead,
|
|
153
|
+
remote,
|
|
154
|
+
search,
|
|
155
|
+
syncChapterRead
|
|
156
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { makeManga, makeChapter, globalKey } from "../../domain/shape.js";
|
|
2
|
+
import { MANGADEX } from "../../config.js";
|
|
3
|
+
const SOURCE = "mangadex";
|
|
4
|
+
function localized(map) {
|
|
5
|
+
if (!map || typeof map !== "object") return "";
|
|
6
|
+
return map.en || map[Object.keys(map)[0]] || "";
|
|
7
|
+
}
|
|
8
|
+
function normalizeManga(entry) {
|
|
9
|
+
const attr = entry.attributes || {};
|
|
10
|
+
const rels = entry.relationships || [];
|
|
11
|
+
const cover = rels.find((r) => r.type === "cover_art");
|
|
12
|
+
const coverFile = cover?.attributes?.fileName;
|
|
13
|
+
const coverUrl = coverFile ? `${MANGADEX.uploads}/covers/${entry.id}/${coverFile}.512.jpg` : null;
|
|
14
|
+
const authors = [
|
|
15
|
+
...new Set(
|
|
16
|
+
rels.filter((r) => r.type === "author" || r.type === "artist").map((r) => r.attributes?.name).filter(Boolean)
|
|
17
|
+
)
|
|
18
|
+
];
|
|
19
|
+
const altTitles = (attr.altTitles || []).map((o) => Object.values(o)[0]).filter(Boolean);
|
|
20
|
+
const tags = (attr.tags || []).map((t) => t.attributes?.name?.en).filter(Boolean);
|
|
21
|
+
return makeManga({
|
|
22
|
+
source: SOURCE,
|
|
23
|
+
id: entry.id,
|
|
24
|
+
title: localized(attr.title) || altTitles[0] || "Untitled",
|
|
25
|
+
altTitles,
|
|
26
|
+
description: localized(attr.description),
|
|
27
|
+
authors,
|
|
28
|
+
status: attr.status || "unknown",
|
|
29
|
+
tags,
|
|
30
|
+
coverUrl,
|
|
31
|
+
language: attr.originalLanguage || "en",
|
|
32
|
+
raw: entry
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function normalizeChapter(entry, mangaKey) {
|
|
36
|
+
const attr = entry.attributes || {};
|
|
37
|
+
const mangaRel = (entry.relationships || []).find((r) => r.type === "manga");
|
|
38
|
+
return makeChapter({
|
|
39
|
+
source: SOURCE,
|
|
40
|
+
id: entry.id,
|
|
41
|
+
mangaKey: mangaKey || (mangaRel ? globalKey(SOURCE, mangaRel.id) : null),
|
|
42
|
+
number: attr.chapter ?? null,
|
|
43
|
+
volume: attr.volume ?? null,
|
|
44
|
+
title: attr.title || "",
|
|
45
|
+
language: attr.translatedLanguage || "en",
|
|
46
|
+
pages: attr.pages ?? null,
|
|
47
|
+
publishedAt: attr.publishAt ? new Date(attr.publishAt) : null,
|
|
48
|
+
raw: entry
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export {
|
|
52
|
+
normalizeChapter,
|
|
53
|
+
normalizeManga
|
|
54
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { paths, DEFAULT_CONFIG, ensureDirs } from "../config.js";
|
|
3
|
+
import { logger } from "../lib/logger.js";
|
|
4
|
+
function readJson(file, fallback) {
|
|
5
|
+
try {
|
|
6
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
7
|
+
} catch (err) {
|
|
8
|
+
if (err.code !== "ENOENT") logger.warn(`failed to read ${file}`, err);
|
|
9
|
+
return fallback;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function writeJsonAtomic(file, data) {
|
|
13
|
+
ensureDirs();
|
|
14
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
15
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
16
|
+
fs.renameSync(tmp, file);
|
|
17
|
+
}
|
|
18
|
+
let config = null;
|
|
19
|
+
function getConfig() {
|
|
20
|
+
if (!config) config = { ...DEFAULT_CONFIG, ...readJson(paths.configFile, {}) };
|
|
21
|
+
return config;
|
|
22
|
+
}
|
|
23
|
+
function setConfig(patch) {
|
|
24
|
+
config = { ...getConfig(), ...patch };
|
|
25
|
+
writeJsonAtomic(paths.configFile, config);
|
|
26
|
+
return config;
|
|
27
|
+
}
|
|
28
|
+
let progress = null;
|
|
29
|
+
function loadProgress() {
|
|
30
|
+
if (!progress) progress = readJson(paths.progressFile, {});
|
|
31
|
+
return progress;
|
|
32
|
+
}
|
|
33
|
+
let saveTimer = null;
|
|
34
|
+
function scheduleSave() {
|
|
35
|
+
clearTimeout(saveTimer);
|
|
36
|
+
saveTimer = setTimeout(() => writeJsonAtomic(paths.progressFile, progress), 400);
|
|
37
|
+
saveTimer.unref?.();
|
|
38
|
+
}
|
|
39
|
+
function getProgress(mangaKey) {
|
|
40
|
+
return loadProgress()[mangaKey] || null;
|
|
41
|
+
}
|
|
42
|
+
function getAllProgress() {
|
|
43
|
+
return Object.values(loadProgress()).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
44
|
+
}
|
|
45
|
+
function setProgress(mangaKey, entry) {
|
|
46
|
+
loadProgress()[mangaKey] = { ...entry, updatedAt: Date.now() };
|
|
47
|
+
scheduleSave();
|
|
48
|
+
}
|
|
49
|
+
function flushProgress() {
|
|
50
|
+
clearTimeout(saveTimer);
|
|
51
|
+
if (progress) writeJsonAtomic(paths.progressFile, progress);
|
|
52
|
+
}
|
|
53
|
+
let credentials = null;
|
|
54
|
+
function writeCredentialsAtomic(data) {
|
|
55
|
+
ensureDirs();
|
|
56
|
+
const file = paths.credentialsFile;
|
|
57
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
58
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 384 });
|
|
59
|
+
fs.renameSync(tmp, file);
|
|
60
|
+
try {
|
|
61
|
+
fs.chmodSync(file, 384);
|
|
62
|
+
} catch {
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function getCredentials() {
|
|
66
|
+
if (credentials === null) credentials = readJson(paths.credentialsFile, {});
|
|
67
|
+
return credentials.refreshToken ? credentials : null;
|
|
68
|
+
}
|
|
69
|
+
function setCredentials(next) {
|
|
70
|
+
credentials = { ...next };
|
|
71
|
+
writeCredentialsAtomic(credentials);
|
|
72
|
+
return credentials;
|
|
73
|
+
}
|
|
74
|
+
function clearCredentials() {
|
|
75
|
+
credentials = {};
|
|
76
|
+
try {
|
|
77
|
+
fs.rmSync(paths.credentialsFile, { force: true });
|
|
78
|
+
} catch {
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export {
|
|
82
|
+
clearCredentials,
|
|
83
|
+
flushProgress,
|
|
84
|
+
getAllProgress,
|
|
85
|
+
getConfig,
|
|
86
|
+
getCredentials,
|
|
87
|
+
getProgress,
|
|
88
|
+
setConfig,
|
|
89
|
+
setCredentials,
|
|
90
|
+
setProgress
|
|
91
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
const UIContext = createContext(null);
|
|
3
|
+
function useUI() {
|
|
4
|
+
const ctx = useContext(UIContext);
|
|
5
|
+
if (!ctx) throw new Error("useUI must be used within <UIContext.Provider>");
|
|
6
|
+
return ctx;
|
|
7
|
+
}
|
|
8
|
+
export {
|
|
9
|
+
UIContext,
|
|
10
|
+
useUI
|
|
11
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "komado",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A terminal manga reader (MangaDex + local files) rendered with Ink.",
|
|
5
|
+
"keywords": ["manga", "mangadex", "reader", "terminal", "tui", "cli", "sixel", "cbz", "comic"],
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"komado": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "node scripts/build.mjs",
|
|
17
|
+
"prepare": "node scripts/build.mjs",
|
|
18
|
+
"prestart": "node scripts/build.mjs",
|
|
19
|
+
"start": "node dist/cli.js",
|
|
20
|
+
"dev": "node scripts/build.mjs && node dist/cli.js",
|
|
21
|
+
"doctor": "node scripts/build.mjs && node dist/cli.js doctor",
|
|
22
|
+
"lint": "eslint src test",
|
|
23
|
+
"test": "vitest run"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": { "type": "git", "url": "git+https://github.com/RyuPrad/komado.git" },
|
|
30
|
+
"homepage": "https://github.com/RyuPrad/komado#readme",
|
|
31
|
+
"bugs": "https://github.com/RyuPrad/komado/issues",
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"adm-zip": "^0.5.16",
|
|
34
|
+
"ink": "^5.1.0",
|
|
35
|
+
"ink-text-input": "^6.0.0",
|
|
36
|
+
"node-unrar-js": "^2.0.2",
|
|
37
|
+
"react": "^18.3.1",
|
|
38
|
+
"sharp": "^0.33.5"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@eslint/js": "^9.39.4",
|
|
42
|
+
"esbuild": "^0.28.1",
|
|
43
|
+
"eslint": "^9.39.4",
|
|
44
|
+
"eslint-plugin-react": "^7.37.5",
|
|
45
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
46
|
+
"globals": "^17.7.0",
|
|
47
|
+
"ink-testing-library": "^4.0.0",
|
|
48
|
+
"vitest": "^3.2.6"
|
|
49
|
+
}
|
|
50
|
+
}
|