cyreader 0.1.1 → 0.2.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/bundle/server/dist/config/paths.js +6 -0
- package/bundle/server/dist/index.js +1 -1
- package/bundle/server/dist/library/cache.js +70 -0
- package/bundle/server/dist/library/scan.js +53 -8
- package/bundle/server/dist/library/scan.test.js +99 -18
- package/bundle/server/dist/library/service.js +16 -4
- package/bundle/server/dist/library/service.test.js +60 -11
- package/bundle/server/dist/library/storage.js +76 -0
- package/bundle/server/dist/library/storage.test.js +74 -0
- package/bundle/server/dist/reading/progress/merge.js +5 -4
- package/bundle/server/dist/reading/progress/schema.js +39 -3
- package/bundle/server/dist/reading/progress/schema.test.js +36 -3
- package/bundle/server/dist/reading/progress/service.js +2 -3
- package/bundle/server/dist/reading/progress/service.test.js +11 -10
- package/bundle/server/dist/reading/recent/service.test.js +1 -1
- package/bundle/server/dist/routes/config.test.js +1 -1
- package/bundle/server/dist/routes/library.test.js +9 -2
- package/bundle/server/dist/routes/reading.test.js +1 -1
- package/bundle/web/dist/assets/_shell-CQkt24cc.js +5 -0
- package/bundle/web/dist/assets/_shell-N_UfyRoX.js +1 -0
- package/bundle/web/dist/assets/comic._id-CVm6n_JY.js +1 -0
- package/bundle/web/dist/assets/dist-DzLEGVNH.js +1 -0
- package/bundle/web/dist/assets/dist-QfKKfHSL.js +1 -0
- package/bundle/web/dist/assets/index-BS403FO9.js +10 -0
- package/bundle/web/dist/assets/index-D9hL3zrL.css +2 -0
- package/bundle/web/dist/assets/library-D_1sil_O.js +1 -0
- package/bundle/web/dist/assets/library-utils-BRrejTkM.js +1 -0
- package/bundle/web/dist/assets/mutation-ChAIwNv3.js +1 -0
- package/bundle/web/dist/assets/novel._id-Bgt8GkIZ.js +4 -0
- package/bundle/web/dist/assets/query-keys-CbbgiZx4.js +1 -0
- package/bundle/web/dist/assets/reading-BozmJNi1.js +1 -0
- package/bundle/web/dist/assets/reading-progress-C5bkK-vF.js +1 -0
- package/bundle/web/dist/assets/save-reading-progress-DntE0i4u.js +1 -0
- package/bundle/web/dist/assets/scroll-area-BUlmpLx6.js +1 -0
- package/bundle/web/dist/assets/{settings-2-CCv0KiQI.js → settings-2-8mnAuSax.js} +1 -1
- package/bundle/web/dist/assets/settings-DopKc4yx.js +1 -0
- package/bundle/web/dist/assets/shell-context-Buw4m44t.js +41 -0
- package/bundle/web/dist/assets/use-library-Bh7Bunn1.js +1 -0
- package/bundle/web/dist/assets/{useNavigate-BMyovDLu.js → useNavigate-B29ssGbr.js} +1 -1
- package/bundle/web/dist/assets/useRouterState-DDtlowFh.js +1 -0
- package/bundle/web/dist/assets/utils-ChkMmPzE.js +1 -0
- package/bundle/web/dist/index.html +5 -5
- package/package.json +2 -2
- package/bundle/server/dist/app.test.js +0 -9
- package/bundle/web/dist/assets/_shell-Co1KI-wo.js +0 -5
- package/bundle/web/dist/assets/_shell-bNqNRnHj.js +0 -1
- package/bundle/web/dist/assets/comic._id-CC6qdNbU.js +0 -1
- package/bundle/web/dist/assets/dist-CqonX7c8.js +0 -1
- package/bundle/web/dist/assets/image-BZKAkGUy.js +0 -1
- package/bundle/web/dist/assets/index-BQZoypuf.css +0 -2
- package/bundle/web/dist/assets/index-Pdit8x1d.js +0 -10
- package/bundle/web/dist/assets/input-tgPw9P7N.js +0 -41
- package/bundle/web/dist/assets/library-g-QZ29Jj.js +0 -1
- package/bundle/web/dist/assets/mutation-CwzbY9ri.js +0 -1
- package/bundle/web/dist/assets/novel._id-ByrQLxAT.js +0 -4
- package/bundle/web/dist/assets/query-keys-CDsPIOEO.js +0 -1
- package/bundle/web/dist/assets/reading-BseNE7P3.js +0 -1
- package/bundle/web/dist/assets/reading-progress-BcmbUd_d.js +0 -1
- package/bundle/web/dist/assets/save-reading-progress-yvAw7ZNh.js +0 -1
- package/bundle/web/dist/assets/scroll-area-awhNOWo3.js +0 -1
- package/bundle/web/dist/assets/settings-DmjzIwkt.js +0 -1
- package/bundle/web/dist/assets/shell-context-BT0BT8PF.js +0 -1
- package/bundle/web/dist/assets/toggle-group-CLpSuWfq.js +0 -1
- package/bundle/web/dist/assets/utils-BmM72imW.js +0 -1
|
@@ -16,3 +16,9 @@ export function getRecentFilePath() {
|
|
|
16
16
|
export function getProgressFilePath() {
|
|
17
17
|
return path.join(getConfigDir(), 'progress.json');
|
|
18
18
|
}
|
|
19
|
+
export function getLibraryFilePath() {
|
|
20
|
+
return path.join(getConfigDir(), 'library.json');
|
|
21
|
+
}
|
|
22
|
+
export function getLegacyLibraryCacheFilePath() {
|
|
23
|
+
return path.join(getConfigDir(), 'library-cache.json');
|
|
24
|
+
}
|
|
@@ -7,7 +7,7 @@ import { ProgressService } from './reading/progress/service.js';
|
|
|
7
7
|
import { RecentService } from './reading/recent/service.js';
|
|
8
8
|
async function main() {
|
|
9
9
|
const configService = await ConfigService.create();
|
|
10
|
-
const libraryService =
|
|
10
|
+
const libraryService = await LibraryService.create(() => configService.get());
|
|
11
11
|
const recentService = await RecentService.create(libraryService);
|
|
12
12
|
const progressService = await ProgressService.create(libraryService);
|
|
13
13
|
const app = createApp(configService, libraryService, recentService, progressService);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getConfigDir, getLibraryCacheFilePath } from '../config/paths.js';
|
|
4
|
+
const comicPageSchema = z.object({
|
|
5
|
+
filename: z.string().min(1),
|
|
6
|
+
width: z.number().int().positive(),
|
|
7
|
+
height: z.number().int().positive(),
|
|
8
|
+
});
|
|
9
|
+
const novelCacheItemSchema = z.object({
|
|
10
|
+
id: z.string().min(1),
|
|
11
|
+
type: z.literal('novel'),
|
|
12
|
+
title: z.string(),
|
|
13
|
+
path: z.string().min(1),
|
|
14
|
+
addedAt: z.string().datetime(),
|
|
15
|
+
directoryId: z.string().min(1),
|
|
16
|
+
filename: z.string().min(1),
|
|
17
|
+
});
|
|
18
|
+
const comicCacheItemSchema = z.object({
|
|
19
|
+
id: z.string().min(1),
|
|
20
|
+
type: z.literal('comic'),
|
|
21
|
+
title: z.string(),
|
|
22
|
+
path: z.string().min(1),
|
|
23
|
+
addedAt: z.string().datetime(),
|
|
24
|
+
directoryId: z.string().min(1),
|
|
25
|
+
pages: z.array(comicPageSchema),
|
|
26
|
+
coverFilename: z.string().nullable(),
|
|
27
|
+
});
|
|
28
|
+
const libraryCacheSchema = z.object({
|
|
29
|
+
items: z.array(z.discriminatedUnion('type', [novelCacheItemSchema, comicCacheItemSchema])),
|
|
30
|
+
});
|
|
31
|
+
async function fileExists(filePath) {
|
|
32
|
+
try {
|
|
33
|
+
await access(filePath);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function createEmptyLibraryScanCache() {
|
|
41
|
+
return { items: [] };
|
|
42
|
+
}
|
|
43
|
+
export function parseLibraryScanCache(data) {
|
|
44
|
+
const parsed = libraryCacheSchema.safeParse(data);
|
|
45
|
+
if (!parsed.success) {
|
|
46
|
+
return createEmptyLibraryScanCache();
|
|
47
|
+
}
|
|
48
|
+
return { items: parsed.data.items };
|
|
49
|
+
}
|
|
50
|
+
export async function readLibraryScanCache() {
|
|
51
|
+
const cacheFile = getLibraryCacheFilePath();
|
|
52
|
+
if (!(await fileExists(cacheFile))) {
|
|
53
|
+
return createEmptyLibraryScanCache();
|
|
54
|
+
}
|
|
55
|
+
let data;
|
|
56
|
+
try {
|
|
57
|
+
data = JSON.parse(await readFile(cacheFile, 'utf-8'));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return createEmptyLibraryScanCache();
|
|
61
|
+
}
|
|
62
|
+
return parseLibraryScanCache(data);
|
|
63
|
+
}
|
|
64
|
+
export async function writeLibraryScanCache(cache) {
|
|
65
|
+
const configDir = getConfigDir();
|
|
66
|
+
const cacheFile = getLibraryCacheFilePath();
|
|
67
|
+
const normalized = parseLibraryScanCache(cache);
|
|
68
|
+
await mkdir(configDir, { recursive: true });
|
|
69
|
+
await writeFile(cacheFile, `${JSON.stringify(normalized, null, 2)}\n`, 'utf-8');
|
|
70
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import { readLibrary } from './storage.js';
|
|
3
5
|
import { generateComicCoverIfMissing, resolveComicCoverFilename } from './cover.js';
|
|
4
6
|
import { isComicCoverFile, isHiddenName, isImageFile, isTxtFile, sortByNaturalName, } from './files.js';
|
|
5
7
|
import { makeLocalItemId } from './id.js';
|
|
@@ -44,7 +46,7 @@ async function shouldIncludeNovel(childPath, gbkNovelHandling) {
|
|
|
44
46
|
return false;
|
|
45
47
|
}
|
|
46
48
|
}
|
|
47
|
-
async function scanNovelsInDirectory(directoryId, root, gbkNovelHandling) {
|
|
49
|
+
async function scanNovelsInDirectory(directoryId, root, gbkNovelHandling, cacheByPath) {
|
|
48
50
|
const children = await listDirectChildren(root);
|
|
49
51
|
const stats = await Promise.all(children.map(async (childPath) => ({
|
|
50
52
|
childPath,
|
|
@@ -56,11 +58,21 @@ async function scanNovelsInDirectory(directoryId, root, gbkNovelHandling) {
|
|
|
56
58
|
}
|
|
57
59
|
return isTxtFile(path.basename(childPath));
|
|
58
60
|
});
|
|
59
|
-
const
|
|
61
|
+
const cachedNovels = [];
|
|
62
|
+
const uncachedTxtFiles = [];
|
|
63
|
+
for (const entry of txtFiles) {
|
|
64
|
+
const cached = cacheByPath.get(entry.childPath);
|
|
65
|
+
if (cached?.type === 'novel') {
|
|
66
|
+
cachedNovels.push({ ...cached, directoryId });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
uncachedTxtFiles.push(entry);
|
|
70
|
+
}
|
|
71
|
+
const inclusion = await Promise.all(uncachedTxtFiles.map(async ({ childPath }) => ({
|
|
60
72
|
childPath,
|
|
61
73
|
include: await shouldIncludeNovel(childPath, gbkNovelHandling),
|
|
62
74
|
})));
|
|
63
|
-
|
|
75
|
+
const scannedNovels = inclusion
|
|
64
76
|
.filter((entry) => entry.include)
|
|
65
77
|
.map(({ childPath }) => {
|
|
66
78
|
const name = path.basename(childPath);
|
|
@@ -70,10 +82,12 @@ async function scanNovelsInDirectory(directoryId, root, gbkNovelHandling) {
|
|
|
70
82
|
type: 'novel',
|
|
71
83
|
title,
|
|
72
84
|
path: childPath,
|
|
85
|
+
addedAt: new Date().toISOString(),
|
|
73
86
|
directoryId,
|
|
74
87
|
filename: name,
|
|
75
88
|
};
|
|
76
89
|
});
|
|
90
|
+
return [...cachedNovels, ...scannedNovels];
|
|
77
91
|
}
|
|
78
92
|
async function listDirectImageFiles(dir) {
|
|
79
93
|
const children = await listDirectChildren(dir);
|
|
@@ -94,7 +108,26 @@ async function listDirectImageFiles(dir) {
|
|
|
94
108
|
}
|
|
95
109
|
return sortByNaturalName(images);
|
|
96
110
|
}
|
|
97
|
-
async function
|
|
111
|
+
async function resolveComicPage(imagePath) {
|
|
112
|
+
try {
|
|
113
|
+
const metadata = await sharp(imagePath).metadata();
|
|
114
|
+
const width = metadata.width;
|
|
115
|
+
const height = metadata.height;
|
|
116
|
+
if (!width || !height) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
filename: path.basename(imagePath),
|
|
121
|
+
width,
|
|
122
|
+
height,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
console.error(`Failed to read comic page metadata: ${imagePath}`, error);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async function scanComicsInDirectory(directoryId, root, cacheByPath) {
|
|
98
131
|
const children = await listDirectChildren(root);
|
|
99
132
|
const stats = await Promise.all(children.map(async (childPath) => ({
|
|
100
133
|
childPath,
|
|
@@ -104,10 +137,18 @@ async function scanComicsInDirectory(directoryId, root) {
|
|
|
104
137
|
.filter(({ childStat }) => childStat?.isDirectory())
|
|
105
138
|
.map(({ childPath }) => childPath);
|
|
106
139
|
const comics = await Promise.all(comicDirs.map(async (childPath) => {
|
|
140
|
+
const cached = cacheByPath.get(childPath);
|
|
141
|
+
if (cached?.type === 'comic') {
|
|
142
|
+
return Object.assign({}, cached, { directoryId });
|
|
143
|
+
}
|
|
107
144
|
const images = await listDirectImageFiles(childPath);
|
|
108
145
|
if (images.length === 0) {
|
|
109
146
|
return null;
|
|
110
147
|
}
|
|
148
|
+
const pages = (await Promise.all(images.map((imagePath) => resolveComicPage(imagePath)))).filter((page) => page !== null);
|
|
149
|
+
if (pages.length === 0) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
111
152
|
await generateComicCoverIfMissing(childPath, images[0]);
|
|
112
153
|
const coverFilename = await resolveComicCoverFilename(childPath);
|
|
113
154
|
return {
|
|
@@ -115,17 +156,21 @@ async function scanComicsInDirectory(directoryId, root) {
|
|
|
115
156
|
type: 'comic',
|
|
116
157
|
title: path.basename(childPath),
|
|
117
158
|
path: childPath,
|
|
159
|
+
addedAt: new Date().toISOString(),
|
|
118
160
|
directoryId,
|
|
119
|
-
pages
|
|
161
|
+
pages,
|
|
120
162
|
coverFilename,
|
|
121
163
|
};
|
|
122
164
|
}));
|
|
123
165
|
return comics.filter((comic) => comic !== null);
|
|
124
166
|
}
|
|
125
167
|
export async function scanLibrary(config) {
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
|
|
168
|
+
const cache = await readLibrary();
|
|
169
|
+
const cacheByPath = new Map(cache.items.map((item) => [item.path, item]));
|
|
170
|
+
const novelResults = await Promise.all(config.novelDirectories.map((directory) => scanNovelsInDirectory(directory.id, directory.path, config.gbkNovelHandling, cacheByPath)));
|
|
171
|
+
const comicResults = await Promise.all(config.comicDirectories.map((directory) => scanComicsInDirectory(directory.id, directory.path, cacheByPath)));
|
|
172
|
+
const items = [...novelResults.flat(), ...comicResults.flat()];
|
|
173
|
+
return items;
|
|
129
174
|
}
|
|
130
175
|
export async function listComicPages(comicDir) {
|
|
131
176
|
return listDirectImageFiles(comicDir);
|
|
@@ -5,13 +5,23 @@ import sharp from 'sharp';
|
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
6
|
import { COMIC_COVER_FILENAME } from './files.js';
|
|
7
7
|
import { listComicPages, scanLibrary } from './scan.js';
|
|
8
|
+
import { readLibrary, writeLibrary } from './storage.js';
|
|
8
9
|
import { writeTestImage } from './test-images.js';
|
|
9
10
|
describe('scanLibrary', () => {
|
|
10
11
|
let tempDir;
|
|
12
|
+
let previousConfigDir;
|
|
11
13
|
beforeEach(async () => {
|
|
14
|
+
previousConfigDir = process.env.CYREADER_CONFIG_DIR;
|
|
12
15
|
tempDir = await mkdtemp(path.join(tmpdir(), 'cyreader-scan-'));
|
|
16
|
+
process.env.CYREADER_CONFIG_DIR = tempDir;
|
|
13
17
|
});
|
|
14
18
|
afterEach(async () => {
|
|
19
|
+
if (previousConfigDir === undefined) {
|
|
20
|
+
delete process.env.CYREADER_CONFIG_DIR;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
process.env.CYREADER_CONFIG_DIR = previousConfigDir;
|
|
24
|
+
}
|
|
15
25
|
await rm(tempDir, { recursive: true, force: true });
|
|
16
26
|
});
|
|
17
27
|
it('ignores GBK txt files when gbkNovelHandling is undefined', async () => {
|
|
@@ -97,10 +107,10 @@ describe('scanLibrary', () => {
|
|
|
97
107
|
const mangaDir = path.join(comicRoot, 'manga-a');
|
|
98
108
|
const nested = path.join(mangaDir, 'chapter');
|
|
99
109
|
await mkdir(nested, { recursive: true });
|
|
100
|
-
await
|
|
101
|
-
await
|
|
102
|
-
await
|
|
103
|
-
await
|
|
110
|
+
await writeTestImage(path.join(mangaDir, '10.jpg'), 1000, 1200);
|
|
111
|
+
await writeTestImage(path.join(mangaDir, '2.png'), 200, 300);
|
|
112
|
+
await writeTestImage(path.join(mangaDir, '1.webp'), 100, 150);
|
|
113
|
+
await writeTestImage(path.join(nested, 'ignored.jpeg'), 50, 50);
|
|
104
114
|
const items = await scanLibrary({
|
|
105
115
|
novelDirectories: [],
|
|
106
116
|
comicDirectories: [{ id: 'comic-root', path: comicRoot }],
|
|
@@ -111,8 +121,13 @@ describe('scanLibrary', () => {
|
|
|
111
121
|
type: 'comic',
|
|
112
122
|
title: 'manga-a',
|
|
113
123
|
directoryId: 'comic-root',
|
|
114
|
-
pages: [
|
|
115
|
-
|
|
124
|
+
pages: [
|
|
125
|
+
{ filename: '1.webp', width: 100, height: 150 },
|
|
126
|
+
{ filename: '2.png', width: 200, height: 300 },
|
|
127
|
+
{ filename: '10.jpg', width: 1000, height: 1200 },
|
|
128
|
+
],
|
|
129
|
+
coverFilename: COMIC_COVER_FILENAME,
|
|
130
|
+
addedAt: expect.any(String),
|
|
116
131
|
});
|
|
117
132
|
});
|
|
118
133
|
it('ignores root-level images and folders without direct images', async () => {
|
|
@@ -149,7 +164,10 @@ describe('scanLibrary', () => {
|
|
|
149
164
|
expect(items[0]).toMatchObject({
|
|
150
165
|
type: 'comic',
|
|
151
166
|
title: 'manga-cover',
|
|
152
|
-
pages: [
|
|
167
|
+
pages: [
|
|
168
|
+
{ filename: '1.jpg', width: 300, height: 399 },
|
|
169
|
+
{ filename: '2.jpg', width: 301, height: 400 },
|
|
170
|
+
],
|
|
153
171
|
coverFilename: COMIC_COVER_FILENAME,
|
|
154
172
|
});
|
|
155
173
|
});
|
|
@@ -182,11 +200,14 @@ describe('scanLibrary', () => {
|
|
|
182
200
|
});
|
|
183
201
|
expect(items[0]).toMatchObject({
|
|
184
202
|
type: 'comic',
|
|
185
|
-
pages: [
|
|
203
|
+
pages: [
|
|
204
|
+
{ filename: '1.jpg', width: 300, height: 300 },
|
|
205
|
+
{ filename: '2.jpg', width: 300, height: 300 },
|
|
206
|
+
],
|
|
186
207
|
coverFilename: COMIC_COVER_FILENAME,
|
|
187
208
|
});
|
|
188
209
|
});
|
|
189
|
-
it('
|
|
210
|
+
it('skips comic images when metadata cannot be read', async () => {
|
|
190
211
|
const comicRoot = path.join(tempDir, 'comics');
|
|
191
212
|
const mangaDir = path.join(comicRoot, 'manga-invalid');
|
|
192
213
|
await mkdir(mangaDir, { recursive: true });
|
|
@@ -196,20 +217,14 @@ describe('scanLibrary', () => {
|
|
|
196
217
|
comicDirectories: [{ id: 'comic-root', path: comicRoot }],
|
|
197
218
|
gbkNovelHandling: 'ignore',
|
|
198
219
|
});
|
|
199
|
-
expect(items).toHaveLength(
|
|
200
|
-
expect(items[0]).toMatchObject({
|
|
201
|
-
type: 'comic',
|
|
202
|
-
title: 'manga-invalid',
|
|
203
|
-
pages: ['1.jpg'],
|
|
204
|
-
coverFilename: null,
|
|
205
|
-
});
|
|
220
|
+
expect(items).toHaveLength(0);
|
|
206
221
|
await expect(access(path.join(mangaDir, COMIC_COVER_FILENAME))).rejects.toThrow();
|
|
207
222
|
});
|
|
208
223
|
it('supports jpeg extension for comic cover', async () => {
|
|
209
224
|
const comicRoot = path.join(tempDir, 'comics');
|
|
210
225
|
const mangaDir = path.join(comicRoot, 'manga-b');
|
|
211
226
|
await mkdir(mangaDir, { recursive: true });
|
|
212
|
-
await
|
|
227
|
+
await writeTestImage(path.join(mangaDir, 'cover.jpeg'), 320, 480);
|
|
213
228
|
const items = await scanLibrary({
|
|
214
229
|
novelDirectories: [],
|
|
215
230
|
comicDirectories: [{ id: 'comic-root', path: comicRoot }],
|
|
@@ -219,8 +234,74 @@ describe('scanLibrary', () => {
|
|
|
219
234
|
expect(items[0]).toMatchObject({
|
|
220
235
|
type: 'comic',
|
|
221
236
|
directoryId: 'comic-root',
|
|
222
|
-
pages: ['cover.jpeg'],
|
|
237
|
+
pages: [{ filename: 'cover.jpeg', width: 320, height: 480 }],
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
it('reuses cached comic directories from persisted library', async () => {
|
|
241
|
+
const comicRoot = path.join(tempDir, 'comics');
|
|
242
|
+
const mangaDir = path.join(comicRoot, 'manga-cached');
|
|
243
|
+
await mkdir(mangaDir, { recursive: true });
|
|
244
|
+
await writeTestImage(path.join(mangaDir, '1.jpg'), 300, 450);
|
|
245
|
+
const first = await scanLibrary({
|
|
246
|
+
novelDirectories: [],
|
|
247
|
+
comicDirectories: [{ id: 'comic-root', path: comicRoot }],
|
|
248
|
+
gbkNovelHandling: 'ignore',
|
|
249
|
+
});
|
|
250
|
+
expect(first[0]).toMatchObject({
|
|
251
|
+
type: 'comic',
|
|
252
|
+
pages: [{ filename: '1.jpg', width: 300, height: 450 }],
|
|
253
|
+
coverFilename: COMIC_COVER_FILENAME,
|
|
254
|
+
addedAt: expect.any(String),
|
|
255
|
+
});
|
|
256
|
+
await writeLibrary({ items: first });
|
|
257
|
+
await rm(path.join(mangaDir, '1.jpg'));
|
|
258
|
+
await rm(path.join(mangaDir, COMIC_COVER_FILENAME));
|
|
259
|
+
const second = await scanLibrary({
|
|
260
|
+
novelDirectories: [],
|
|
261
|
+
comicDirectories: [{ id: 'comic-root', path: comicRoot }],
|
|
262
|
+
gbkNovelHandling: 'ignore',
|
|
263
|
+
});
|
|
264
|
+
expect(second).toEqual(first);
|
|
265
|
+
await expect(access(path.join(mangaDir, COMIC_COVER_FILENAME))).rejects.toThrow();
|
|
266
|
+
});
|
|
267
|
+
it('reuses cached novels without rechecking encoding', async () => {
|
|
268
|
+
const novelRoot = path.join(tempDir, 'novels');
|
|
269
|
+
const filePath = path.join(novelRoot, 'book.txt');
|
|
270
|
+
await mkdir(novelRoot);
|
|
271
|
+
await writeFile(filePath, 'content');
|
|
272
|
+
const first = await scanLibrary({
|
|
273
|
+
novelDirectories: [{ id: 'novel-root', path: novelRoot }],
|
|
274
|
+
comicDirectories: [],
|
|
275
|
+
gbkNovelHandling: 'ignore',
|
|
276
|
+
});
|
|
277
|
+
await writeLibrary({ items: first });
|
|
278
|
+
await writeFile(filePath, Buffer.from([0xd6, 0xd0, 0xce, 0xc4]));
|
|
279
|
+
const second = await scanLibrary({
|
|
280
|
+
novelDirectories: [{ id: 'novel-root', path: novelRoot }],
|
|
281
|
+
comicDirectories: [],
|
|
282
|
+
gbkNovelHandling: 'ignore',
|
|
223
283
|
});
|
|
284
|
+
expect(second).toEqual(first);
|
|
285
|
+
});
|
|
286
|
+
it('removes deleted entries after scanning persisted library', async () => {
|
|
287
|
+
const novelRoot = path.join(tempDir, 'novels');
|
|
288
|
+
const filePath = path.join(novelRoot, 'book.txt');
|
|
289
|
+
await mkdir(novelRoot);
|
|
290
|
+
await writeFile(filePath, 'content');
|
|
291
|
+
const first = await scanLibrary({
|
|
292
|
+
novelDirectories: [{ id: 'novel-root', path: novelRoot }],
|
|
293
|
+
comicDirectories: [],
|
|
294
|
+
gbkNovelHandling: 'ignore',
|
|
295
|
+
});
|
|
296
|
+
await writeLibrary({ items: first });
|
|
297
|
+
await rm(filePath);
|
|
298
|
+
const items = await scanLibrary({
|
|
299
|
+
novelDirectories: [{ id: 'novel-root', path: novelRoot }],
|
|
300
|
+
comicDirectories: [],
|
|
301
|
+
gbkNovelHandling: 'ignore',
|
|
302
|
+
});
|
|
303
|
+
expect(items).toHaveLength(0);
|
|
304
|
+
expect((await readLibrary()).items).toHaveLength(1);
|
|
224
305
|
});
|
|
225
306
|
});
|
|
226
307
|
describe('listComicPages', () => {
|
|
@@ -2,19 +2,22 @@ import { attachProgressToDetail, attachProgressToListItem } from '../reading/pro
|
|
|
2
2
|
import { buildComicImageUrl } from './comic-url.js';
|
|
3
3
|
import { NotFoundError, ScanInProgressError } from './errors.js';
|
|
4
4
|
import { scanLibrary } from './scan.js';
|
|
5
|
+
import { readLibrary, writeLibrary } from './storage.js';
|
|
5
6
|
export function toListItemBase(item) {
|
|
6
7
|
if (item.type === 'novel') {
|
|
7
8
|
return {
|
|
8
9
|
id: item.id,
|
|
9
10
|
type: 'novel',
|
|
10
11
|
title: item.title,
|
|
12
|
+
addedAt: item.addedAt,
|
|
11
13
|
};
|
|
12
14
|
}
|
|
13
|
-
const coverPage = item.coverFilename ?? item.pages[0];
|
|
15
|
+
const coverPage = item.coverFilename ?? item.pages[0]?.filename;
|
|
14
16
|
return {
|
|
15
17
|
id: item.id,
|
|
16
18
|
type: 'comic',
|
|
17
19
|
title: item.title,
|
|
20
|
+
addedAt: item.addedAt,
|
|
18
21
|
cover: coverPage ? buildComicImageUrl(item.directoryId, item.title, coverPage) : '',
|
|
19
22
|
pageCount: item.pages.length,
|
|
20
23
|
};
|
|
@@ -25,6 +28,7 @@ function toItemDetailBase(item) {
|
|
|
25
28
|
id: item.id,
|
|
26
29
|
type: 'novel',
|
|
27
30
|
title: item.title,
|
|
31
|
+
addedAt: item.addedAt,
|
|
28
32
|
directoryId: item.directoryId,
|
|
29
33
|
filename: item.filename,
|
|
30
34
|
};
|
|
@@ -33,16 +37,22 @@ function toItemDetailBase(item) {
|
|
|
33
37
|
id: item.id,
|
|
34
38
|
type: 'comic',
|
|
35
39
|
title: item.title,
|
|
40
|
+
addedAt: item.addedAt,
|
|
36
41
|
directoryId: item.directoryId,
|
|
37
42
|
pages: item.pages,
|
|
38
43
|
};
|
|
39
44
|
}
|
|
40
45
|
export class LibraryService {
|
|
41
46
|
getConfig;
|
|
42
|
-
items
|
|
47
|
+
items;
|
|
43
48
|
scanning = false;
|
|
44
|
-
constructor(getConfig) {
|
|
49
|
+
constructor(getConfig, items) {
|
|
45
50
|
this.getConfig = getConfig;
|
|
51
|
+
this.items = items;
|
|
52
|
+
}
|
|
53
|
+
static async create(getConfig) {
|
|
54
|
+
const library = await readLibrary();
|
|
55
|
+
return new LibraryService(getConfig, library.items);
|
|
46
56
|
}
|
|
47
57
|
getState(progressMap = {}) {
|
|
48
58
|
return {
|
|
@@ -63,7 +73,9 @@ export class LibraryService {
|
|
|
63
73
|
}
|
|
64
74
|
this.scanning = true;
|
|
65
75
|
try {
|
|
66
|
-
|
|
76
|
+
const items = await scanLibrary(this.getConfig());
|
|
77
|
+
await writeLibrary({ items });
|
|
78
|
+
this.items = items;
|
|
67
79
|
}
|
|
68
80
|
finally {
|
|
69
81
|
this.scanning = false;
|
|
@@ -2,15 +2,25 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { getLibraryFilePath } from '../config/paths.js';
|
|
5
6
|
import { NotFoundError, ScanInProgressError } from './errors.js';
|
|
6
7
|
import { LibraryService } from './service.js';
|
|
8
|
+
import { readLibrary } from './storage.js';
|
|
7
9
|
import { writeTestImage } from './test-images.js';
|
|
8
10
|
describe('LibraryService', () => {
|
|
9
11
|
let tempDir;
|
|
12
|
+
let previousConfigDir;
|
|
10
13
|
let novelRoot;
|
|
11
14
|
let comicRoot;
|
|
15
|
+
const config = () => ({
|
|
16
|
+
novelDirectories: [{ id: 'novel-root', path: novelRoot }],
|
|
17
|
+
comicDirectories: [{ id: 'comic-root', path: comicRoot }],
|
|
18
|
+
gbkNovelHandling: 'ignore',
|
|
19
|
+
});
|
|
12
20
|
beforeEach(async () => {
|
|
21
|
+
previousConfigDir = process.env.CYREADER_CONFIG_DIR;
|
|
13
22
|
tempDir = await mkdtemp(path.join(tmpdir(), 'cyreader-service-'));
|
|
23
|
+
process.env.CYREADER_CONFIG_DIR = tempDir;
|
|
14
24
|
novelRoot = path.join(tempDir, 'novels');
|
|
15
25
|
comicRoot = path.join(tempDir, 'comics');
|
|
16
26
|
await mkdir(novelRoot);
|
|
@@ -18,17 +28,42 @@ describe('LibraryService', () => {
|
|
|
18
28
|
await writeFile(path.join(novelRoot, 'book.txt'), 'content');
|
|
19
29
|
});
|
|
20
30
|
afterEach(async () => {
|
|
31
|
+
if (previousConfigDir === undefined) {
|
|
32
|
+
delete process.env.CYREADER_CONFIG_DIR;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
process.env.CYREADER_CONFIG_DIR = previousConfigDir;
|
|
36
|
+
}
|
|
21
37
|
await rm(tempDir, { recursive: true, force: true });
|
|
22
38
|
});
|
|
23
39
|
function createService() {
|
|
24
|
-
return
|
|
25
|
-
novelDirectories: [{ id: 'novel-root', path: novelRoot }],
|
|
26
|
-
comicDirectories: [{ id: 'comic-root', path: comicRoot }],
|
|
27
|
-
gbkNovelHandling: 'ignore',
|
|
28
|
-
}));
|
|
40
|
+
return LibraryService.create(config);
|
|
29
41
|
}
|
|
42
|
+
it('returns persisted items before scan', async () => {
|
|
43
|
+
const scanned = await createService();
|
|
44
|
+
await scanned.scan();
|
|
45
|
+
const preloaded = await createService();
|
|
46
|
+
expect(preloaded.getState()).toEqual({
|
|
47
|
+
scanning: false,
|
|
48
|
+
items: scanned.getState().items,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
it('keeps persisted items visible while scan is in progress', async () => {
|
|
52
|
+
const service = await createService();
|
|
53
|
+
await service.scan();
|
|
54
|
+
const before = service.getState().items;
|
|
55
|
+
await writeFile(path.join(novelRoot, 'newbook.txt'), 'more content');
|
|
56
|
+
const scanPromise = service.scan();
|
|
57
|
+
expect(service.getState()).toEqual({
|
|
58
|
+
scanning: true,
|
|
59
|
+
items: before,
|
|
60
|
+
});
|
|
61
|
+
await scanPromise;
|
|
62
|
+
expect(service.getState().scanning).toBe(false);
|
|
63
|
+
expect(service.getState().items).toHaveLength(2);
|
|
64
|
+
});
|
|
30
65
|
it('populates list items after scan', async () => {
|
|
31
|
-
const service = createService();
|
|
66
|
+
const service = await createService();
|
|
32
67
|
await service.scan();
|
|
33
68
|
expect(service.getState()).toEqual({
|
|
34
69
|
scanning: false,
|
|
@@ -37,25 +72,35 @@ describe('LibraryService', () => {
|
|
|
37
72
|
id: expect.any(String),
|
|
38
73
|
type: 'novel',
|
|
39
74
|
title: 'book',
|
|
75
|
+
addedAt: expect.any(String),
|
|
40
76
|
progress: null,
|
|
41
77
|
},
|
|
42
78
|
],
|
|
43
79
|
});
|
|
44
80
|
});
|
|
81
|
+
it('writes library.json after scan', async () => {
|
|
82
|
+
const service = await createService();
|
|
83
|
+
await service.scan();
|
|
84
|
+
const library = await readLibrary();
|
|
85
|
+
expect(library.items).toHaveLength(1);
|
|
86
|
+
expect(library.items[0]).toMatchObject({ type: 'novel', title: 'book' });
|
|
87
|
+
expect(getLibraryFilePath()).toContain('library.json');
|
|
88
|
+
});
|
|
45
89
|
it('rejects concurrent scan', async () => {
|
|
46
|
-
const service = createService();
|
|
90
|
+
const service = await createService();
|
|
47
91
|
const firstScan = service.scan();
|
|
48
92
|
await expect(service.scan()).rejects.toBeInstanceOf(ScanInProgressError);
|
|
49
93
|
await firstScan;
|
|
50
94
|
});
|
|
51
95
|
it('returns novel detail for a known id', async () => {
|
|
52
|
-
const service = createService();
|
|
96
|
+
const service = await createService();
|
|
53
97
|
await service.scan();
|
|
54
98
|
const novel = service.getState().items[0];
|
|
55
99
|
expect(service.getItemDetail(novel.id)).toEqual({
|
|
56
100
|
id: novel.id,
|
|
57
101
|
type: 'novel',
|
|
58
102
|
title: 'book',
|
|
103
|
+
addedAt: expect.any(String),
|
|
59
104
|
directoryId: 'novel-root',
|
|
60
105
|
filename: 'book.txt',
|
|
61
106
|
progress: null,
|
|
@@ -66,7 +111,7 @@ describe('LibraryService', () => {
|
|
|
66
111
|
await mkdir(mangaDir);
|
|
67
112
|
await writeTestImage(path.join(mangaDir, '2.jpg'), 300, 400);
|
|
68
113
|
await writeTestImage(path.join(mangaDir, '1.jpg'), 300, 400);
|
|
69
|
-
const service = createService();
|
|
114
|
+
const service = await createService();
|
|
70
115
|
await service.scan();
|
|
71
116
|
const comic = service.getState().items.find((item) => item.type === 'comic');
|
|
72
117
|
expect(comic).toBeDefined();
|
|
@@ -74,8 +119,12 @@ describe('LibraryService', () => {
|
|
|
74
119
|
id: comic.id,
|
|
75
120
|
type: 'comic',
|
|
76
121
|
title: 'manga',
|
|
122
|
+
addedAt: expect.any(String),
|
|
77
123
|
directoryId: 'comic-root',
|
|
78
|
-
pages: [
|
|
124
|
+
pages: [
|
|
125
|
+
{ filename: '1.jpg', width: 300, height: 400 },
|
|
126
|
+
{ filename: '2.jpg', width: 300, height: 400 },
|
|
127
|
+
],
|
|
79
128
|
progress: null,
|
|
80
129
|
});
|
|
81
130
|
expect(comic).toMatchObject({
|
|
@@ -85,7 +134,7 @@ describe('LibraryService', () => {
|
|
|
85
134
|
});
|
|
86
135
|
});
|
|
87
136
|
it('throws when detail is requested for unknown id', async () => {
|
|
88
|
-
const service = createService();
|
|
137
|
+
const service = await createService();
|
|
89
138
|
await service.scan();
|
|
90
139
|
expect(() => service.getItemDetail('missing')).toThrow(NotFoundError);
|
|
91
140
|
});
|