@nemu.pm/tachiyomi-cli 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.
@@ -0,0 +1,495 @@
1
+ import { buildCommand } from "@stricli/core";
2
+ import { select, input, confirm } from "@inquirer/prompts";
3
+ import pc from "picocolors";
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import { loadOutputConfig } from "../config";
7
+ import { listExtensionsWithInfo, loadExtension, unwrapResult, type MangasPage, type TachiyomiExports } from "../lib/extension-loader";
8
+
9
+ interface Manga {
10
+ title: string;
11
+ url: string;
12
+ thumbnailUrl?: string;
13
+ author?: string;
14
+ artist?: string;
15
+ description?: string;
16
+ status?: number;
17
+ genres?: string[];
18
+ }
19
+
20
+ interface Chapter {
21
+ name: string;
22
+ url: string;
23
+ dateUpload?: number;
24
+ chapterNumber?: number;
25
+ scanlator?: string;
26
+ }
27
+
28
+ interface Page {
29
+ index: number;
30
+ imageUrl?: string;
31
+ url?: string;
32
+ }
33
+
34
+ export const explore = buildCommand({
35
+ docs: {
36
+ brief: "Interactive mode to explore manga",
37
+ fullDescription: "Browse manga interactively - select extensions, browse popular/latest, search, view details and chapters.",
38
+ },
39
+ parameters: {
40
+ positional: {
41
+ kind: "tuple",
42
+ parameters: [],
43
+ },
44
+ flags: {},
45
+ },
46
+ func: async () => {
47
+ const config = loadOutputConfig();
48
+
49
+ // List available extensions
50
+ const extensions = listExtensionsWithInfo(config.output);
51
+ if (extensions.length === 0) {
52
+ console.log(pc.red("No extensions found. Build some first with: tachiyomi build <extension>"));
53
+ return;
54
+ }
55
+
56
+ console.log(pc.cyan("\nšŸ” Tachiyomi Extension Explorer\n"));
57
+
58
+ // Select extension
59
+ const extensionId = await select({
60
+ message: "Select an extension",
61
+ choices: extensions.map((ext) => ({
62
+ name: `${ext.name} (${ext.lang})${ext.isNsfw ? " [NSFW]" : ""}`,
63
+ value: ext.id,
64
+ })),
65
+ });
66
+
67
+ const { exports, sources } = loadExtension(config.output, extensionId);
68
+ if (sources.length === 0) {
69
+ console.log(pc.red("No sources found in extension"));
70
+ return;
71
+ }
72
+
73
+ const source = sources[0];
74
+ const sourceId = source.id;
75
+ console.log(pc.green(`\nLoaded: ${source.name}`));
76
+
77
+ // Main loop
78
+ while (true) {
79
+ const action = await select({
80
+ message: "What would you like to do?",
81
+ choices: [
82
+ { name: "šŸ”„ Browse Popular", value: "popular" },
83
+ { name: "šŸ†• Browse Latest", value: "latest" },
84
+ { name: "šŸ”Ž Search", value: "search" },
85
+ { name: "🚪 Exit", value: "exit" },
86
+ ],
87
+ });
88
+
89
+ if (action === "exit") break;
90
+
91
+ let mangas: Manga[] = [];
92
+ let page = 1;
93
+ let hasNextPage = true;
94
+
95
+ if (action === "search") {
96
+ const query = await input({ message: "Search query:" });
97
+ if (!query.trim()) continue;
98
+
99
+ console.log(pc.dim(`\nSearching for "${query}"...`));
100
+ const result = unwrapResult<MangasPage>(exports.searchManga(sourceId, page, query));
101
+ mangas = result.mangas ?? [];
102
+ hasNextPage = result.hasNextPage ?? false;
103
+ } else if (action === "popular") {
104
+ console.log(pc.dim("\nFetching popular manga..."));
105
+ const result = unwrapResult<MangasPage>(exports.getPopularManga(sourceId, page));
106
+ mangas = result.mangas ?? [];
107
+ hasNextPage = result.hasNextPage ?? false;
108
+ } else if (action === "latest") {
109
+ console.log(pc.dim("\nFetching latest updates..."));
110
+ const result = unwrapResult<MangasPage>(exports.getLatestUpdates(sourceId, page));
111
+ mangas = result.mangas ?? [];
112
+ hasNextPage = result.hasNextPage ?? false;
113
+ }
114
+
115
+ if (mangas.length === 0) {
116
+ console.log(pc.yellow("\nNo manga found."));
117
+ continue;
118
+ }
119
+
120
+ // Manga selection loop
121
+ while (true) {
122
+ const choices = [
123
+ ...mangas.map((m, i) => ({
124
+ name: `${i + 1}. ${m.title}`,
125
+ value: String(i),
126
+ })),
127
+ ...(hasNextPage ? [{ name: "šŸ“„ Load more", value: "more" }] : []),
128
+ { name: "⬅ Back", value: "back" },
129
+ ];
130
+
131
+ const mangaChoice = await select({
132
+ message: `Found ${mangas.length} manga:`,
133
+ choices,
134
+ pageSize: 15,
135
+ });
136
+
137
+ if (mangaChoice === "back") break;
138
+ if (mangaChoice === "more") {
139
+ page++;
140
+ console.log(pc.dim(`\nLoading page ${page}...`));
141
+ let result: MangasPage;
142
+ if (action === "popular") {
143
+ result = unwrapResult<MangasPage>(exports.getPopularManga(sourceId, page));
144
+ } else if (action === "latest") {
145
+ result = unwrapResult<MangasPage>(exports.getLatestUpdates(sourceId, page));
146
+ } else {
147
+ // search - would need to store query
148
+ continue;
149
+ }
150
+ mangas = [...mangas, ...(result.mangas ?? [])];
151
+ hasNextPage = result.hasNextPage ?? false;
152
+ continue;
153
+ }
154
+
155
+ const manga = mangas[parseInt(mangaChoice)];
156
+ await showMangaDetails(exports, sourceId, manga);
157
+ }
158
+ }
159
+
160
+ console.log(pc.dim("\nBye! šŸ‘‹"));
161
+ },
162
+ });
163
+
164
+ async function showMangaDetails(
165
+ exports: TachiyomiExports,
166
+ sourceId: string,
167
+ manga: Manga
168
+ ) {
169
+ console.log(pc.dim("\nFetching details..."));
170
+
171
+ const details = unwrapResult<Manga>(
172
+ exports.getMangaDetails(sourceId, manga.url)
173
+ );
174
+
175
+ console.log("\n" + pc.bold(pc.cyan(details.title || manga.title)));
176
+ if (details.author) console.log(pc.dim(`Author: ${details.author}`));
177
+ if (details.artist && details.artist !== details.author) {
178
+ console.log(pc.dim(`Artist: ${details.artist}`));
179
+ }
180
+ if (details.status !== undefined) {
181
+ const statusMap: Record<number, string> = { 1: "Ongoing", 2: "Completed", 3: "Licensed", 4: "Publishing finished", 5: "Cancelled", 6: "On hiatus" };
182
+ console.log(pc.dim(`Status: ${statusMap[details.status] || "Unknown"}`));
183
+ }
184
+ if (details.genres?.length) {
185
+ console.log(pc.dim(`Genres: ${details.genres.join(", ")}`));
186
+ }
187
+ if (details.description) {
188
+ const desc = details.description.replace(/<[^>]*>/g, "").slice(0, 300);
189
+ console.log(pc.dim(`\n${desc}${details.description.length > 300 ? "..." : ""}`));
190
+ }
191
+
192
+ while (true) {
193
+ const detailAction = await select({
194
+ message: "What next?",
195
+ choices: [
196
+ { name: "šŸ“š View Chapters", value: "chapters" },
197
+ { name: "šŸ”— Show URL", value: "url" },
198
+ { name: "šŸ“‹ Raw JSON", value: "json" },
199
+ { name: "⬅ Back", value: "back" },
200
+ ],
201
+ });
202
+
203
+ if (detailAction === "back") break;
204
+
205
+ if (detailAction === "url") {
206
+ console.log(pc.cyan(`\nURL: ${manga.url}`));
207
+ if (manga.thumbnailUrl) console.log(pc.cyan(`Thumbnail: ${manga.thumbnailUrl}`));
208
+ } else if (detailAction === "json") {
209
+ console.log("\n" + JSON.stringify(details, null, 2));
210
+ } else if (detailAction === "chapters") {
211
+ await showChapters(exports, sourceId, manga);
212
+ }
213
+ }
214
+ }
215
+
216
+ async function showChapters(
217
+ exports: TachiyomiExports,
218
+ sourceId: string,
219
+ manga: Manga
220
+ ) {
221
+ console.log(pc.dim("\nFetching chapters..."));
222
+
223
+ const chapters = unwrapResult<Chapter[]>(
224
+ exports.getChapterList(sourceId, manga.url)
225
+ );
226
+
227
+ if (!chapters?.length) {
228
+ console.log(pc.yellow("No chapters found."));
229
+ return;
230
+ }
231
+
232
+ console.log(pc.green(`\nFound ${chapters.length} chapters`));
233
+
234
+ while (true) {
235
+ const choices = [
236
+ ...chapters.slice(0, 20).map((c, i) => ({
237
+ name: `${i + 1}. ${c.name}`,
238
+ value: String(i),
239
+ })),
240
+ ...(chapters.length > 20 ? [{ name: `... and ${chapters.length - 20} more`, value: "info" }] : []),
241
+ { name: "⬅ Back", value: "back" },
242
+ ];
243
+
244
+ const chapterChoice = await select({
245
+ message: "Select a chapter to view pages:",
246
+ choices,
247
+ pageSize: 15,
248
+ });
249
+
250
+ if (chapterChoice === "back") break;
251
+ if (chapterChoice === "info") {
252
+ console.log(pc.dim(`\nTotal: ${chapters.length} chapters`));
253
+ console.log(pc.dim(`First: ${chapters[0].name}`));
254
+ console.log(pc.dim(`Last: ${chapters[chapters.length - 1].name}`));
255
+ continue;
256
+ }
257
+
258
+ const chapter = chapters[parseInt(chapterChoice)];
259
+ await showPages(exports, sourceId, manga, chapter);
260
+ }
261
+ }
262
+
263
+ async function showPages(
264
+ exports: TachiyomiExports,
265
+ sourceId: string,
266
+ manga: Manga,
267
+ chapter: Chapter
268
+ ) {
269
+ console.log(pc.dim("\nFetching pages..."));
270
+
271
+ const pages = unwrapResult<Page[]>(
272
+ exports.getPageList(sourceId, chapter.url)
273
+ );
274
+
275
+ if (!pages?.length) {
276
+ console.log(pc.yellow("No pages found."));
277
+ return;
278
+ }
279
+
280
+ console.log(pc.green(`\n${chapter.name} - ${pages.length} pages`));
281
+
282
+ while (true) {
283
+ const choices = [
284
+ { name: "šŸ“„ Download all pages", value: "download-all" },
285
+ { name: "šŸ–¼ļø Browse pages...", value: "browse" },
286
+ { name: "šŸ“‹ Show all URLs (JSON)", value: "json" },
287
+ { name: "⬅ Back", value: "back" },
288
+ ];
289
+
290
+ const action = await select({
291
+ message: `${pages.length} pages:`,
292
+ choices,
293
+ });
294
+
295
+ if (action === "back") break;
296
+
297
+ if (action === "json") {
298
+ console.log("\n" + JSON.stringify(pages, null, 2));
299
+ } else if (action === "download-all") {
300
+ await downloadChapter(exports, sourceId, manga, chapter, pages);
301
+ } else if (action === "browse") {
302
+ await browsePagesMenu(exports, sourceId, manga, chapter, pages);
303
+ }
304
+ }
305
+ }
306
+
307
+ async function browsePagesMenu(
308
+ exports: TachiyomiExports,
309
+ sourceId: string,
310
+ manga: Manga,
311
+ chapter: Chapter,
312
+ pages: Page[]
313
+ ) {
314
+ let lastSelectedIdx = 0;
315
+
316
+ while (true) {
317
+ const choices = [
318
+ ...pages.map((p) => ({
319
+ name: `Page ${String(p.index + 1).padStart(3, "0")}: ${(p.imageUrl || "").slice(0, 60)}${(p.imageUrl || "").length > 60 ? "..." : ""}`,
320
+ value: String(p.index),
321
+ })),
322
+ { name: "⬅ Back", value: "back" },
323
+ ];
324
+
325
+ const pageChoice = await select({
326
+ message: "Select a page:",
327
+ choices,
328
+ pageSize: 20,
329
+ default: String(lastSelectedIdx),
330
+ });
331
+
332
+ if (pageChoice === "back") break;
333
+
334
+ lastSelectedIdx = parseInt(pageChoice);
335
+ const page = pages[lastSelectedIdx];
336
+ await showSinglePage(exports, sourceId, manga, chapter, page);
337
+ }
338
+ }
339
+
340
+ async function showSinglePage(
341
+ exports: TachiyomiExports,
342
+ sourceId: string,
343
+ manga: Manga,
344
+ chapter: Chapter,
345
+ page: Page
346
+ ) {
347
+ const pageNum = String(page.index + 1).padStart(3, "0");
348
+ const imageUrl = page.imageUrl || "";
349
+
350
+ console.log(pc.cyan(`\nPage ${pageNum}`));
351
+ console.log(pc.dim(`Image: ${imageUrl || "(no direct URL)"}`));
352
+
353
+ const action = await select({
354
+ message: "Options:",
355
+ choices: [
356
+ { name: "šŸ“„ Download this page", value: "download" },
357
+ { name: "šŸ“‹ Copy image URL", value: "copy" },
358
+ { name: "⬅ Back", value: "back" },
359
+ ],
360
+ });
361
+
362
+ if (action === "back") return;
363
+
364
+ if (action === "copy") {
365
+ if (imageUrl) {
366
+ console.log(pc.green(`\n${imageUrl}`));
367
+ } else {
368
+ console.log(pc.yellow("\nNo direct image URL available"));
369
+ }
370
+ } else if (action === "download") {
371
+ await downloadSinglePage(exports, sourceId, manga, chapter, page);
372
+ }
373
+ // Returns to page list after action
374
+ }
375
+
376
+ async function downloadSinglePage(
377
+ exports: TachiyomiExports,
378
+ sourceId: string,
379
+ manga: Manga,
380
+ chapter: Chapter,
381
+ page: Page
382
+ ) {
383
+ const pageNum = String(page.index + 1).padStart(3, "0");
384
+ const imageUrl = page.imageUrl || page.url || "";
385
+
386
+ if (!imageUrl) {
387
+ console.log(pc.red("No image URL available"));
388
+ return;
389
+ }
390
+
391
+ const mangaFolder = sanitizeFilename(manga.title);
392
+ const chapterFolder = sanitizeFilename(chapter.name);
393
+ const ext = imageUrl.match(/\.(jpe?g|png|gif|webp)/i)?.[1]?.toLowerCase() || "jpg";
394
+ const defaultPath = path.join("downloads", mangaFolder, chapterFolder, `${pageNum}.${ext}`);
395
+
396
+ const outputPath = await input({
397
+ message: "Save as:",
398
+ default: defaultPath,
399
+ });
400
+
401
+ // Create directory
402
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
403
+
404
+ console.log(pc.dim("\nDownloading..."));
405
+
406
+ try {
407
+ const base64 = unwrapResult<string>(
408
+ exports.fetchImage(sourceId, page.url || "", imageUrl)
409
+ );
410
+
411
+ const buffer = Buffer.from(base64, "base64");
412
+ fs.writeFileSync(outputPath, buffer);
413
+
414
+ console.log(pc.green(`āœ“ Saved ${outputPath} (${(buffer.length / 1024).toFixed(1)}KB)`));
415
+ } catch (err) {
416
+ const msg = err instanceof Error ? err.message : String(err);
417
+ console.log(pc.red(`āœ— Failed: ${msg}`));
418
+ }
419
+ }
420
+
421
+ function sanitizeFilename(name: string): string {
422
+ return name
423
+ .replace(/[<>:"/\\|?*]/g, "_")
424
+ .replace(/\s+/g, " ")
425
+ .trim()
426
+ .slice(0, 100);
427
+ }
428
+
429
+ async function downloadChapter(
430
+ exports: TachiyomiExports,
431
+ sourceId: string,
432
+ manga: Manga,
433
+ chapter: Chapter,
434
+ pages: Page[]
435
+ ) {
436
+ const mangaFolder = sanitizeFilename(manga.title);
437
+ const chapterFolder = sanitizeFilename(chapter.name);
438
+ const defaultDir = path.join("downloads", mangaFolder, chapterFolder);
439
+
440
+ const outputDir = await input({
441
+ message: "Download directory:",
442
+ default: defaultDir,
443
+ });
444
+
445
+ // Create directory
446
+ fs.mkdirSync(outputDir, { recursive: true });
447
+
448
+ console.log(pc.cyan(`\nDownloading ${pages.length} pages to ${outputDir}...\n`));
449
+
450
+ let downloaded = 0;
451
+ let failed = 0;
452
+
453
+ for (const page of pages) {
454
+ const pageNum = String(page.index + 1).padStart(3, "0");
455
+ const imageUrl = page.imageUrl || page.url || "";
456
+
457
+ if (!imageUrl) {
458
+ console.log(pc.red(`Page ${pageNum}: No image URL`));
459
+ failed++;
460
+ continue;
461
+ }
462
+
463
+ try {
464
+ process.stdout.write(pc.dim(`Page ${pageNum}...`));
465
+
466
+ // Fetch image through extension (handles headers, interceptors)
467
+ const base64 = unwrapResult<string>(
468
+ exports.fetchImage(sourceId, page.url || "", imageUrl)
469
+ );
470
+
471
+ // Detect format from URL or default to jpg
472
+ const ext = imageUrl.match(/\.(jpe?g|png|gif|webp)/i)?.[1]?.toLowerCase() || "jpg";
473
+ const filename = `${pageNum}.${ext}`;
474
+ const filepath = path.join(outputDir, filename);
475
+
476
+ // Decode base64 and save
477
+ const buffer = Buffer.from(base64, "base64");
478
+ fs.writeFileSync(filepath, buffer);
479
+
480
+ process.stdout.write(pc.green(` āœ“ ${filename} (${(buffer.length / 1024).toFixed(1)}KB)\n`));
481
+ downloaded++;
482
+ } catch (err) {
483
+ const msg = err instanceof Error ? err.message : String(err);
484
+ process.stdout.write(pc.red(` āœ— ${msg.slice(0, 50)}\n`));
485
+ failed++;
486
+ }
487
+ }
488
+
489
+ console.log(pc.green(`\nāœ“ Downloaded ${downloaded}/${pages.length} pages`));
490
+ if (failed > 0) {
491
+ console.log(pc.yellow(`⚠ ${failed} pages failed`));
492
+ }
493
+ console.log(pc.dim(`Saved to: ${path.resolve(outputDir)}`));
494
+ }
495
+
@@ -0,0 +1,59 @@
1
+ import { buildCommand } from "@stricli/core";
2
+ import { loadOutputConfig } from "../config";
3
+ import { loadExtension } from "../lib/extension-loader";
4
+ import { printField, printHeader, printJson, printListItem } from "../lib/output";
5
+
6
+ export const info = buildCommand({
7
+ docs: {
8
+ brief: "Show extension information",
9
+ },
10
+ parameters: {
11
+ positional: {
12
+ kind: "tuple",
13
+ parameters: [
14
+ {
15
+ brief: "Extension path (e.g., en/mangapill)",
16
+ parse: String,
17
+ placeholder: "extension",
18
+ },
19
+ ],
20
+ },
21
+ flags: {
22
+ json: {
23
+ kind: "boolean",
24
+ brief: "Output as JSON",
25
+ optional: true,
26
+ },
27
+ },
28
+ },
29
+ func: async (flags: { json?: boolean }, extensionId: string) => {
30
+ const config = loadOutputConfig();
31
+ const { manifest, sources } = loadExtension(config.output, extensionId);
32
+
33
+ if (flags.json) {
34
+ printJson({ manifest, sources });
35
+ return;
36
+ }
37
+
38
+ printHeader("Extension Info");
39
+ printField("Name", manifest.name);
40
+ printField("Package", manifest.pkg);
41
+ printField("Version", manifest.version);
42
+ printField("NSFW", manifest.nsfw);
43
+
44
+ printHeader(`Sources (${sources.length})`);
45
+ for (const s of sources) {
46
+ printListItem(`${s.name} (${s.lang}): ${s.baseUrl}`);
47
+ console.log(` ID: ${s.id}`);
48
+ console.log(` Supports Latest: ${s.supportsLatest}`);
49
+ }
50
+
51
+ if (manifest.authors?.length) {
52
+ printHeader(`Authors (${manifest.authors.length})`);
53
+ for (const a of manifest.authors) {
54
+ printListItem(`${a.github || a.name}: ${a.commits} commits (since ${a.firstCommit})`);
55
+ }
56
+ }
57
+ },
58
+ });
59
+
@@ -0,0 +1,39 @@
1
+ import { buildCommand } from "@stricli/core";
2
+ import { loadOutputConfig } from "../config";
3
+ import { listExtensions } from "../lib/extension-loader";
4
+ import { printHeader, printJson, printListItem } from "../lib/output";
5
+
6
+ export const list = buildCommand({
7
+ docs: {
8
+ brief: "List available built extensions",
9
+ },
10
+ parameters: {
11
+ flags: {
12
+ json: {
13
+ kind: "boolean",
14
+ brief: "Output as JSON",
15
+ optional: true,
16
+ },
17
+ },
18
+ },
19
+ func: async (flags: { json?: boolean }) => {
20
+ const config = loadOutputConfig();
21
+ const extensions = listExtensions(config.output);
22
+
23
+ if (flags.json) {
24
+ printJson(extensions);
25
+ return;
26
+ }
27
+
28
+ printHeader(`Extensions (${extensions.length})`);
29
+ if (extensions.length === 0) {
30
+ console.log("No extensions found.");
31
+ console.log(`Looked in: ${config.output}`);
32
+ } else {
33
+ for (const ext of extensions) {
34
+ printListItem(ext);
35
+ }
36
+ }
37
+ },
38
+ });
39
+