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.
Files changed (64) hide show
  1. package/bundle/server/dist/config/paths.js +6 -0
  2. package/bundle/server/dist/index.js +1 -1
  3. package/bundle/server/dist/library/cache.js +70 -0
  4. package/bundle/server/dist/library/scan.js +53 -8
  5. package/bundle/server/dist/library/scan.test.js +99 -18
  6. package/bundle/server/dist/library/service.js +16 -4
  7. package/bundle/server/dist/library/service.test.js +60 -11
  8. package/bundle/server/dist/library/storage.js +76 -0
  9. package/bundle/server/dist/library/storage.test.js +74 -0
  10. package/bundle/server/dist/reading/progress/merge.js +5 -4
  11. package/bundle/server/dist/reading/progress/schema.js +39 -3
  12. package/bundle/server/dist/reading/progress/schema.test.js +36 -3
  13. package/bundle/server/dist/reading/progress/service.js +2 -3
  14. package/bundle/server/dist/reading/progress/service.test.js +11 -10
  15. package/bundle/server/dist/reading/recent/service.test.js +1 -1
  16. package/bundle/server/dist/routes/config.test.js +1 -1
  17. package/bundle/server/dist/routes/library.test.js +9 -2
  18. package/bundle/server/dist/routes/reading.test.js +1 -1
  19. package/bundle/web/dist/assets/_shell-CQkt24cc.js +5 -0
  20. package/bundle/web/dist/assets/_shell-N_UfyRoX.js +1 -0
  21. package/bundle/web/dist/assets/comic._id-CVm6n_JY.js +1 -0
  22. package/bundle/web/dist/assets/dist-DzLEGVNH.js +1 -0
  23. package/bundle/web/dist/assets/dist-QfKKfHSL.js +1 -0
  24. package/bundle/web/dist/assets/index-BS403FO9.js +10 -0
  25. package/bundle/web/dist/assets/index-D9hL3zrL.css +2 -0
  26. package/bundle/web/dist/assets/library-D_1sil_O.js +1 -0
  27. package/bundle/web/dist/assets/library-utils-BRrejTkM.js +1 -0
  28. package/bundle/web/dist/assets/mutation-ChAIwNv3.js +1 -0
  29. package/bundle/web/dist/assets/novel._id-Bgt8GkIZ.js +4 -0
  30. package/bundle/web/dist/assets/query-keys-CbbgiZx4.js +1 -0
  31. package/bundle/web/dist/assets/reading-BozmJNi1.js +1 -0
  32. package/bundle/web/dist/assets/reading-progress-C5bkK-vF.js +1 -0
  33. package/bundle/web/dist/assets/save-reading-progress-DntE0i4u.js +1 -0
  34. package/bundle/web/dist/assets/scroll-area-BUlmpLx6.js +1 -0
  35. package/bundle/web/dist/assets/{settings-2-CCv0KiQI.js → settings-2-8mnAuSax.js} +1 -1
  36. package/bundle/web/dist/assets/settings-DopKc4yx.js +1 -0
  37. package/bundle/web/dist/assets/shell-context-Buw4m44t.js +41 -0
  38. package/bundle/web/dist/assets/use-library-Bh7Bunn1.js +1 -0
  39. package/bundle/web/dist/assets/{useNavigate-BMyovDLu.js → useNavigate-B29ssGbr.js} +1 -1
  40. package/bundle/web/dist/assets/useRouterState-DDtlowFh.js +1 -0
  41. package/bundle/web/dist/assets/utils-ChkMmPzE.js +1 -0
  42. package/bundle/web/dist/index.html +5 -5
  43. package/package.json +2 -2
  44. package/bundle/server/dist/app.test.js +0 -9
  45. package/bundle/web/dist/assets/_shell-Co1KI-wo.js +0 -5
  46. package/bundle/web/dist/assets/_shell-bNqNRnHj.js +0 -1
  47. package/bundle/web/dist/assets/comic._id-CC6qdNbU.js +0 -1
  48. package/bundle/web/dist/assets/dist-CqonX7c8.js +0 -1
  49. package/bundle/web/dist/assets/image-BZKAkGUy.js +0 -1
  50. package/bundle/web/dist/assets/index-BQZoypuf.css +0 -2
  51. package/bundle/web/dist/assets/index-Pdit8x1d.js +0 -10
  52. package/bundle/web/dist/assets/input-tgPw9P7N.js +0 -41
  53. package/bundle/web/dist/assets/library-g-QZ29Jj.js +0 -1
  54. package/bundle/web/dist/assets/mutation-CwzbY9ri.js +0 -1
  55. package/bundle/web/dist/assets/novel._id-ByrQLxAT.js +0 -4
  56. package/bundle/web/dist/assets/query-keys-CDsPIOEO.js +0 -1
  57. package/bundle/web/dist/assets/reading-BseNE7P3.js +0 -1
  58. package/bundle/web/dist/assets/reading-progress-BcmbUd_d.js +0 -1
  59. package/bundle/web/dist/assets/save-reading-progress-yvAw7ZNh.js +0 -1
  60. package/bundle/web/dist/assets/scroll-area-awhNOWo3.js +0 -1
  61. package/bundle/web/dist/assets/settings-DmjzIwkt.js +0 -1
  62. package/bundle/web/dist/assets/shell-context-BT0BT8PF.js +0 -1
  63. package/bundle/web/dist/assets/toggle-group-CLpSuWfq.js +0 -1
  64. 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 = new LibraryService(() => configService.get());
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 inclusion = await Promise.all(txtFiles.map(async ({ childPath }) => ({
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
- return inclusion
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 scanComicsInDirectory(directoryId, root) {
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: images.map((imagePath) => path.basename(imagePath)),
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 novelResults = await Promise.all(config.novelDirectories.map((directory) => scanNovelsInDirectory(directory.id, directory.path, config.gbkNovelHandling)));
127
- const comicResults = await Promise.all(config.comicDirectories.map((directory) => scanComicsInDirectory(directory.id, directory.path)));
128
- return [...novelResults.flat(), ...comicResults.flat()];
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 writeFile(path.join(mangaDir, '10.jpg'), '');
101
- await writeFile(path.join(mangaDir, '2.png'), '');
102
- await writeFile(path.join(mangaDir, '1.webp'), '');
103
- await writeFile(path.join(nested, 'ignored.jpeg'), '');
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: ['1.webp', '2.png', '10.jpg'],
115
- coverFilename: null,
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: ['1.jpg', '2.jpg'],
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: ['1.jpg', '2.jpg'],
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('keeps scanning comics when cover generation fails', async () => {
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(1);
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 writeFile(path.join(mangaDir, 'cover.jpeg'), '');
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
- this.items = await scanLibrary(this.getConfig());
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 new LibraryService(() => ({
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: ['1.jpg', '2.jpg'],
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
  });