@digitalpaws/starr 0.0.1

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 (290) hide show
  1. package/README.rst +75 -0
  2. package/data/.nfs.811bbb0d.43c34.4 +0 -0
  3. package/data/.nfs.81ba4cd0.ffff87804.4 +0 -0
  4. package/data/.nfs.82d127f7.fffff5704.4 +0 -0
  5. package/data/.nfs.82d127fe.fffff5704.4 +0 -0
  6. package/data/.nfs.82d159a5.fffff5aa4.4 +0 -0
  7. package/data/.nfs.82d159aa.fffff5aa4.4 +0 -0
  8. package/data/.nfs.82d15a9c.fffff5bb4.4 +0 -0
  9. package/data/.nfs.82d15aa1.fffff5bb4.4 +0 -0
  10. package/data/.nfs.82d15c65.fffff5c24.4 +0 -0
  11. package/data/.nfs.82d1852c.fffff5e24.4 +0 -0
  12. package/data/.nfs.82d18536.fffff5e24.4 +0 -0
  13. package/data/.nfs.82d18dbe.fffff5e44.4 +0 -0
  14. package/data/.nfs.82d18dc6.fffff5e44.4 +0 -0
  15. package/data/.nfs.8309a3f5.fffff82f4.4 +0 -0
  16. package/data/.nfs.8309a3fe.fffff82f4.4 +0 -0
  17. package/data/.nfs.8314122e.fffffb044.4 +0 -0
  18. package/data/.nfs.83141236.fffffb044.4 +0 -0
  19. package/data/.nfs.833c4fe9.fffffbc34.4 +0 -0
  20. package/data/.nfs.833c4ff3.fffffbc34.4 +0 -0
  21. package/data/logs/starr.log +66244 -0
  22. package/data/starr.db +0 -0
  23. package/data/starr.db-shm +0 -0
  24. package/data/starr.db-wal +0 -0
  25. package/data/starr.jwt +1 -0
  26. package/data/starr.key +1 -0
  27. package/dist/auth/auth-service.d.ts +42 -0
  28. package/dist/auth/auth-service.d.ts.map +1 -0
  29. package/dist/auth/auth-service.js +226 -0
  30. package/dist/auth/auth-service.js.map +1 -0
  31. package/dist/auth/http-server.d.ts +16 -0
  32. package/dist/auth/http-server.d.ts.map +1 -0
  33. package/dist/auth/http-server.js +207 -0
  34. package/dist/auth/http-server.js.map +1 -0
  35. package/dist/auth/jwt-secret.d.ts +6 -0
  36. package/dist/auth/jwt-secret.d.ts.map +1 -0
  37. package/dist/auth/jwt-secret.js +51 -0
  38. package/dist/auth/jwt-secret.js.map +1 -0
  39. package/dist/auth/oidc/oidc-flow.d.ts +60 -0
  40. package/dist/auth/oidc/oidc-flow.d.ts.map +1 -0
  41. package/dist/auth/oidc/oidc-flow.js +201 -0
  42. package/dist/auth/oidc/oidc-flow.js.map +1 -0
  43. package/dist/auth/oidc/oidc-provider-service.d.ts +59 -0
  44. package/dist/auth/oidc/oidc-provider-service.d.ts.map +1 -0
  45. package/dist/auth/oidc/oidc-provider-service.js +124 -0
  46. package/dist/auth/oidc/oidc-provider-service.js.map +1 -0
  47. package/dist/auth/oidc/user-service.d.ts +67 -0
  48. package/dist/auth/oidc/user-service.d.ts.map +1 -0
  49. package/dist/auth/oidc/user-service.js +177 -0
  50. package/dist/auth/oidc/user-service.js.map +1 -0
  51. package/dist/auth/rate-limiter.d.ts +31 -0
  52. package/dist/auth/rate-limiter.d.ts.map +1 -0
  53. package/dist/auth/rate-limiter.js +70 -0
  54. package/dist/auth/rate-limiter.js.map +1 -0
  55. package/dist/config/config-service.d.ts +83 -0
  56. package/dist/config/config-service.d.ts.map +1 -0
  57. package/dist/config/config-service.js +309 -0
  58. package/dist/config/config-service.js.map +1 -0
  59. package/dist/config/validate.d.ts +12 -0
  60. package/dist/config/validate.d.ts.map +1 -0
  61. package/dist/config/validate.js +140 -0
  62. package/dist/config/validate.js.map +1 -0
  63. package/dist/content-types/registry.d.ts +75 -0
  64. package/dist/content-types/registry.d.ts.map +1 -0
  65. package/dist/content-types/registry.js +171 -0
  66. package/dist/content-types/registry.js.map +1 -0
  67. package/dist/datastore/db.d.ts +9 -0
  68. package/dist/datastore/db.d.ts.map +1 -0
  69. package/dist/datastore/db.js +27 -0
  70. package/dist/datastore/db.js.map +1 -0
  71. package/dist/datastore/encrypt.d.ts +9 -0
  72. package/dist/datastore/encrypt.d.ts.map +1 -0
  73. package/dist/datastore/encrypt.js +84 -0
  74. package/dist/datastore/encrypt.js.map +1 -0
  75. package/dist/datastore/migrations.d.ts +10 -0
  76. package/dist/datastore/migrations.d.ts.map +1 -0
  77. package/dist/datastore/migrations.js +340 -0
  78. package/dist/datastore/migrations.js.map +1 -0
  79. package/dist/datastore/storage.d.ts +4 -0
  80. package/dist/datastore/storage.d.ts.map +1 -0
  81. package/dist/datastore/storage.js +60 -0
  82. package/dist/datastore/storage.js.map +1 -0
  83. package/dist/events/event-bus.d.ts +15 -0
  84. package/dist/events/event-bus.d.ts.map +1 -0
  85. package/dist/events/event-bus.js +140 -0
  86. package/dist/events/event-bus.js.map +1 -0
  87. package/dist/events/spine.d.ts +31 -0
  88. package/dist/events/spine.d.ts.map +1 -0
  89. package/dist/events/spine.js +68 -0
  90. package/dist/events/spine.js.map +1 -0
  91. package/dist/events/trace.d.ts +24 -0
  92. package/dist/events/trace.d.ts.map +1 -0
  93. package/dist/events/trace.js +33 -0
  94. package/dist/events/trace.js.map +1 -0
  95. package/dist/host.d.ts +65 -0
  96. package/dist/host.d.ts.map +1 -0
  97. package/dist/host.js +94 -0
  98. package/dist/host.js.map +1 -0
  99. package/dist/library/importer.d.ts +29 -0
  100. package/dist/library/importer.d.ts.map +1 -0
  101. package/dist/library/importer.js +145 -0
  102. package/dist/library/importer.js.map +1 -0
  103. package/dist/library/library-cache.d.ts +46 -0
  104. package/dist/library/library-cache.d.ts.map +1 -0
  105. package/dist/library/library-cache.js +152 -0
  106. package/dist/library/library-cache.js.map +1 -0
  107. package/dist/library/library-config.d.ts +12 -0
  108. package/dist/library/library-config.d.ts.map +1 -0
  109. package/dist/library/library-config.js +10 -0
  110. package/dist/library/library-config.js.map +1 -0
  111. package/dist/library/library-scanner.d.ts +62 -0
  112. package/dist/library/library-scanner.d.ts.map +1 -0
  113. package/dist/library/library-scanner.js +336 -0
  114. package/dist/library/library-scanner.js.map +1 -0
  115. package/dist/library/library-service.d.ts +65 -0
  116. package/dist/library/library-service.d.ts.map +1 -0
  117. package/dist/library/library-service.js +197 -0
  118. package/dist/library/library-service.js.map +1 -0
  119. package/dist/library/metadata-matcher.d.ts +56 -0
  120. package/dist/library/metadata-matcher.d.ts.map +1 -0
  121. package/dist/library/metadata-matcher.js +207 -0
  122. package/dist/library/metadata-matcher.js.map +1 -0
  123. package/dist/library/naming.d.ts +8 -0
  124. package/dist/library/naming.d.ts.map +1 -0
  125. package/dist/library/naming.js +47 -0
  126. package/dist/library/naming.js.map +1 -0
  127. package/dist/library/scan-controller.d.ts +95 -0
  128. package/dist/library/scan-controller.d.ts.map +1 -0
  129. package/dist/library/scan-controller.js +379 -0
  130. package/dist/library/scan-controller.js.map +1 -0
  131. package/dist/library/source-resolver.d.ts +17 -0
  132. package/dist/library/source-resolver.d.ts.map +1 -0
  133. package/dist/library/source-resolver.js +75 -0
  134. package/dist/library/source-resolver.js.map +1 -0
  135. package/dist/logging/logger.d.ts +3 -0
  136. package/dist/logging/logger.d.ts.map +1 -0
  137. package/dist/logging/logger.js +107 -0
  138. package/dist/logging/logger.js.map +1 -0
  139. package/dist/main.d.ts +36 -0
  140. package/dist/main.d.ts.map +1 -0
  141. package/dist/main.js +229 -0
  142. package/dist/main.js.map +1 -0
  143. package/dist/plugins/context.d.ts +10 -0
  144. package/dist/plugins/context.d.ts.map +1 -0
  145. package/dist/plugins/context.js +110 -0
  146. package/dist/plugins/context.js.map +1 -0
  147. package/dist/plugins/discovery.d.ts +2 -0
  148. package/dist/plugins/discovery.d.ts.map +1 -0
  149. package/dist/plugins/discovery.js +32 -0
  150. package/dist/plugins/discovery.js.map +1 -0
  151. package/dist/plugins/loader.d.ts +9 -0
  152. package/dist/plugins/loader.d.ts.map +1 -0
  153. package/dist/plugins/loader.js +245 -0
  154. package/dist/plugins/loader.js.map +1 -0
  155. package/dist/state/request-intake.d.ts +26 -0
  156. package/dist/state/request-intake.d.ts.map +1 -0
  157. package/dist/state/request-intake.js +74 -0
  158. package/dist/state/request-intake.js.map +1 -0
  159. package/dist/state/state-store.d.ts +146 -0
  160. package/dist/state/state-store.d.ts.map +1 -0
  161. package/dist/state/state-store.js +465 -0
  162. package/dist/state/state-store.js.map +1 -0
  163. package/dist/state/trace-store.d.ts +12 -0
  164. package/dist/state/trace-store.d.ts.map +1 -0
  165. package/dist/state/trace-store.js +24 -0
  166. package/dist/state/trace-store.js.map +1 -0
  167. package/dist/types/plugin.d.ts +93 -0
  168. package/dist/types/plugin.d.ts.map +1 -0
  169. package/dist/types/plugin.js +4 -0
  170. package/dist/types/plugin.js.map +1 -0
  171. package/dist/web/index.d.ts +10 -0
  172. package/dist/web/index.d.ts.map +1 -0
  173. package/dist/web/index.js +153 -0
  174. package/dist/web/index.js.map +1 -0
  175. package/dist/web/routes/activity.d.ts +6 -0
  176. package/dist/web/routes/activity.d.ts.map +1 -0
  177. package/dist/web/routes/activity.js +31 -0
  178. package/dist/web/routes/activity.js.map +1 -0
  179. package/dist/web/routes/auth.d.ts +6 -0
  180. package/dist/web/routes/auth.d.ts.map +1 -0
  181. package/dist/web/routes/auth.js +142 -0
  182. package/dist/web/routes/auth.js.map +1 -0
  183. package/dist/web/routes/config.d.ts +7 -0
  184. package/dist/web/routes/config.d.ts.map +1 -0
  185. package/dist/web/routes/config.js +204 -0
  186. package/dist/web/routes/config.js.map +1 -0
  187. package/dist/web/routes/content-types.d.ts +6 -0
  188. package/dist/web/routes/content-types.d.ts.map +1 -0
  189. package/dist/web/routes/content-types.js +29 -0
  190. package/dist/web/routes/content-types.js.map +1 -0
  191. package/dist/web/routes/discovery.d.ts +6 -0
  192. package/dist/web/routes/discovery.d.ts.map +1 -0
  193. package/dist/web/routes/discovery.js +111 -0
  194. package/dist/web/routes/discovery.js.map +1 -0
  195. package/dist/web/routes/library-import.d.ts +6 -0
  196. package/dist/web/routes/library-import.d.ts.map +1 -0
  197. package/dist/web/routes/library-import.js +120 -0
  198. package/dist/web/routes/library-import.js.map +1 -0
  199. package/dist/web/routes/library-management.d.ts +6 -0
  200. package/dist/web/routes/library-management.d.ts.map +1 -0
  201. package/dist/web/routes/library-management.js +120 -0
  202. package/dist/web/routes/library-management.js.map +1 -0
  203. package/dist/web/routes/library.d.ts +6 -0
  204. package/dist/web/routes/library.d.ts.map +1 -0
  205. package/dist/web/routes/library.js +28 -0
  206. package/dist/web/routes/library.js.map +1 -0
  207. package/dist/web/routes/oidc.d.ts +9 -0
  208. package/dist/web/routes/oidc.d.ts.map +1 -0
  209. package/dist/web/routes/oidc.js +203 -0
  210. package/dist/web/routes/oidc.js.map +1 -0
  211. package/dist/web/routes/requests.d.ts +6 -0
  212. package/dist/web/routes/requests.d.ts.map +1 -0
  213. package/dist/web/routes/requests.js +183 -0
  214. package/dist/web/routes/requests.js.map +1 -0
  215. package/dist/web/routes/sse.d.ts +6 -0
  216. package/dist/web/routes/sse.d.ts.map +1 -0
  217. package/dist/web/routes/sse.js +54 -0
  218. package/dist/web/routes/sse.js.map +1 -0
  219. package/package.json +33 -0
  220. package/package.json.license +2 -0
  221. package/src/auth/auth-service.ts +333 -0
  222. package/src/auth/http-server.ts +249 -0
  223. package/src/auth/jwt-secret.ts +65 -0
  224. package/src/auth/oidc/oidc-flow.ts +266 -0
  225. package/src/auth/oidc/oidc-provider-service.ts +174 -0
  226. package/src/auth/oidc/user-service.ts +227 -0
  227. package/src/auth/rate-limiter.ts +85 -0
  228. package/src/config/config-service.ts +434 -0
  229. package/src/config/validate.ts +153 -0
  230. package/src/content-types/registry.ts +194 -0
  231. package/src/datastore/db.ts +38 -0
  232. package/src/datastore/encrypt.ts +107 -0
  233. package/src/datastore/migrations.ts +367 -0
  234. package/src/datastore/storage.ts +83 -0
  235. package/src/events/event-bus.ts +180 -0
  236. package/src/events/spine.ts +87 -0
  237. package/src/events/trace.ts +38 -0
  238. package/src/host.ts +130 -0
  239. package/src/library/importer.ts +174 -0
  240. package/src/library/library-cache.ts +212 -0
  241. package/src/library/library-config.ts +22 -0
  242. package/src/library/library-scanner.ts +404 -0
  243. package/src/library/library-service.ts +282 -0
  244. package/src/library/metadata-matcher.ts +285 -0
  245. package/src/library/naming.ts +59 -0
  246. package/src/library/scan-controller.ts +490 -0
  247. package/src/library/source-resolver.ts +92 -0
  248. package/src/logging/logger.ts +115 -0
  249. package/src/main.ts +364 -0
  250. package/src/plugins/context.ts +138 -0
  251. package/src/plugins/discovery.ts +29 -0
  252. package/src/plugins/loader.ts +309 -0
  253. package/src/state/request-intake.ts +101 -0
  254. package/src/state/state-store.ts +621 -0
  255. package/src/state/trace-store.ts +27 -0
  256. package/src/types/plugin.ts +110 -0
  257. package/src/web/index.ts +182 -0
  258. package/src/web/routes/activity.ts +41 -0
  259. package/src/web/routes/auth.ts +170 -0
  260. package/src/web/routes/config.ts +282 -0
  261. package/src/web/routes/content-types.ts +42 -0
  262. package/src/web/routes/discovery.ts +156 -0
  263. package/src/web/routes/library-import.ts +196 -0
  264. package/src/web/routes/library-management.ts +140 -0
  265. package/src/web/routes/library.ts +46 -0
  266. package/src/web/routes/oidc.ts +260 -0
  267. package/src/web/routes/requests.ts +250 -0
  268. package/src/web/routes/sse.ts +92 -0
  269. package/tests/auth.test.ts +510 -0
  270. package/tests/config-route.test.ts +345 -0
  271. package/tests/config.test.ts +612 -0
  272. package/tests/content-types.test.ts +376 -0
  273. package/tests/datastore.test.ts +345 -0
  274. package/tests/e2e.test.ts +430 -0
  275. package/tests/event-bus.test.ts +925 -0
  276. package/tests/host.test.ts +449 -0
  277. package/tests/library-cache.test.ts +171 -0
  278. package/tests/library-scanner.test.ts +177 -0
  279. package/tests/library.test.ts +210 -0
  280. package/tests/loader-context.test.ts +644 -0
  281. package/tests/logger-jsonl.test.ts +154 -0
  282. package/tests/metadata-matcher.test.ts +198 -0
  283. package/tests/oidc.test.ts +218 -0
  284. package/tests/scan-controller.test.ts +328 -0
  285. package/tests/source-resolver.test.ts +100 -0
  286. package/tests/starr-video-integration.test.ts +153 -0
  287. package/tests/state-store.test.ts +700 -0
  288. package/tests/trace-history.test.ts +356 -0
  289. package/tsconfig.json +8 -0
  290. package/tsconfig.json.license +2 -0
@@ -0,0 +1,177 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
5
+ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import path from "node:path";
8
+ import Database from "better-sqlite3";
9
+ import { createLogger } from "../src/logging/logger.js";
10
+ import { runMigrations, CORE_MIGRATIONS } from "../src/datastore/migrations.js";
11
+ import { LibraryScanner } from "../src/library/library-scanner.js";
12
+ import type { LibraryConfig } from "../src/library/library-config.js";
13
+
14
+ let tmpDir: string;
15
+ let db: Database.Database;
16
+ let scanner: LibraryScanner;
17
+
18
+ beforeAll(async () => {
19
+ tmpDir = await mkdtemp(path.join(tmpdir(), "starr-scanner-test-"));
20
+
21
+ db = new Database(":memory:");
22
+ db.pragma("journal_mode = WAL");
23
+ const logger = createLogger("test", "silent");
24
+ runMigrations(db, CORE_MIGRATIONS, logger);
25
+
26
+ // Create library structure
27
+ const moviesDir = path.join(tmpDir, "Movies");
28
+ const booksDir = path.join(tmpDir, "Books");
29
+ await mkdir(moviesDir, { recursive: true });
30
+ await mkdir(booksDir, { recursive: true });
31
+
32
+ // Movie 1: Dune (2021)
33
+ const duneDir = path.join(moviesDir, "Dune (2021)");
34
+ await mkdir(duneDir, { recursive: true });
35
+ await writeFile(path.join(duneDir, "Dune (2021) [2160p].mkv"), "movie1");
36
+ await writeFile(path.join(duneDir, "Dune (2021).en.srt"), "subs1");
37
+
38
+ // Movie 2: The Matrix (1999)
39
+ const matrixDir = path.join(moviesDir, "The Matrix (1999)");
40
+ await mkdir(matrixDir, { recursive: true });
41
+ await writeFile(path.join(matrixDir, "The Matrix (1999) [1080p].mp4"), "movie2");
42
+ await writeFile(path.join(matrixDir, "The Matrix (1999).en.srt"), "subs2");
43
+
44
+ // Movie 3: Hidden dir (should be skipped)
45
+ const hiddenDir = path.join(moviesDir, ".hidden_movie");
46
+ await mkdir(hiddenDir, { recursive: true });
47
+ await writeFile(path.join(hiddenDir, "hidden.mkv"), "hidden");
48
+
49
+ // Movie 4: Extras dir (should be skipped)
50
+ const extrasDir = path.join(moviesDir, "Extras");
51
+ await mkdir(extrasDir, { recursive: true });
52
+ await writeFile(path.join(extrasDir, "extra.mkv"), "extra");
53
+
54
+ // Movie 5: Empty dir (no media files)
55
+ const emptyDir = path.join(moviesDir, "Empty Folder");
56
+ await mkdir(emptyDir, { recursive: true });
57
+ await writeFile(path.join(emptyDir, "readme.txt"), "not media");
58
+
59
+ // Book 1: Some Book (use mkv so scanner recognizes it)
60
+ const bookDir = path.join(booksDir, "Some Author", "Some Title");
61
+ await mkdir(bookDir, { recursive: true });
62
+ await writeFile(path.join(bookDir, "Some Title.mkv"), "book1");
63
+
64
+ // Insert a library_paths record to test already-imported detection
65
+ db.prepare(`INSERT INTO tracked_requests (request_id, content_type_id, content_identity, item, owner_user_id, status, library_path)
66
+ VALUES (?,?,?,?,?,?,?)`).run(
67
+ "existing-movie",
68
+ "starr-video/movie@1",
69
+ "id::1",
70
+ JSON.stringify({ title: "Existing", contentTypeId: "starr-video/movie@1", externalIds: {} }),
71
+ "admin",
72
+ "imported",
73
+ path.join(duneDir, "Dune (2021) [2160p].mkv"),
74
+ );
75
+ db.prepare(`INSERT INTO library_paths (request_id, library_path, library_root, imported_at)
76
+ VALUES (?,?,?,?)`).run(
77
+ "existing-movie",
78
+ path.join(duneDir, "Dune (2021) [2160p].mkv"),
79
+ moviesDir,
80
+ "2026-01-01",
81
+ );
82
+
83
+ const getConfig = (): LibraryConfig => ({
84
+ defaultLibraryRoot: moviesDir,
85
+ roots: {
86
+ "starr-video/movie@1": moviesDir,
87
+ "starr-books/book@1": booksDir,
88
+ },
89
+ linkType: "copy",
90
+ importSubtitles: true,
91
+ subtitleExtensions: ["srt", "ass"],
92
+ });
93
+
94
+ scanner = new LibraryScanner(db, logger, getConfig);
95
+ });
96
+
97
+ afterAll(async () => {
98
+ await rm(tmpDir, { recursive: true, force: true });
99
+ db.close();
100
+ });
101
+
102
+ describe("LibraryScanner", () => {
103
+ it("discovers media groups from library roots", async () => {
104
+ const groups = await scanner.discoverGroups();
105
+ expect(groups.length).toBeGreaterThanOrEqual(2);
106
+
107
+ const dune = groups.find((g) => g.folderName === "Dune (2021)");
108
+ expect(dune).toBeDefined();
109
+ expect(dune!.files).toHaveLength(1);
110
+ expect(dune!.files[0]).toContain("Dune (2021) [2160p].mkv");
111
+ expect(dune!.subtitleFiles).toHaveLength(1);
112
+ expect(dune!.contentTypeId).toBe("starr-video/movie@1");
113
+
114
+ const matrix = groups.find((g) => g.folderName === "The Matrix (1999)");
115
+ expect(matrix).toBeDefined();
116
+ expect(matrix!.files).toHaveLength(1);
117
+ expect(matrix!.contentTypeId).toBe("starr-video/movie@1");
118
+ });
119
+
120
+ it("scans only specified content types when contentTypeIds provided", async () => {
121
+ const groups = await scanner.discoverGroups({ contentTypeIds: ["starr-books/book@1"] });
122
+ // The books root has a nested structure: Some Author/Some Title/
123
+ // The scanner recurses into subdirectories, so this should find the book
124
+ expect(groups.length).toBeGreaterThanOrEqual(1);
125
+ for (const g of groups) {
126
+ expect(g.contentTypeId).toBe("starr-books/book@1");
127
+ }
128
+ });
129
+
130
+ it("skips hidden directories", async () => {
131
+ const groups = await scanner.discoverGroups();
132
+ const hidden = groups.find((g) => g.folderName.startsWith("."));
133
+ expect(hidden).toBeUndefined();
134
+ });
135
+
136
+ it("skips Extras and Sample directories", async () => {
137
+ const groups = await scanner.discoverGroups();
138
+ const extras = groups.find((g) => g.folderName === "Extras");
139
+ expect(extras).toBeUndefined();
140
+ });
141
+
142
+ it("skips directories with no media files", async () => {
143
+ const groups = await scanner.discoverGroups();
144
+ const empty = groups.find((g) => g.folderName === "Empty Folder");
145
+ expect(empty).toBeUndefined();
146
+ });
147
+
148
+ it("detects already-imported directories", async () => {
149
+ const groups = await scanner.discoverGroups();
150
+ const dirs = groups.map((g) => g.directory);
151
+ const existing = scanner.findExisting(dirs);
152
+ const duneDir = groups.find((g) => g.folderName === "Dune (2021)")?.directory;
153
+ expect(duneDir).toBeDefined();
154
+ expect(existing.has(duneDir!)).toBe(true);
155
+ });
156
+
157
+ it("includes subtitle files in discovered groups", async () => {
158
+ const groups = await scanner.discoverGroups();
159
+ const dune = groups.find((g) => g.folderName === "Dune (2021)");
160
+ expect(dune).toBeDefined();
161
+ expect(dune!.subtitleFiles.length).toBeGreaterThan(0);
162
+ expect(dune!.subtitleFiles[0]).toContain(".srt");
163
+ });
164
+
165
+ it("groups nested content directories by item name, not category", async () => {
166
+ // Simulate root/Category/Title/file.mkv structure
167
+ const groups = await scanner.discoverGroups();
168
+ // The test data has: moviesDir/Dune (2021)/file.mkv
169
+ // where moviesDir IS the defaultLibraryRoot
170
+ const dune = groups.find((g) => g.folderName === "Dune (2021)");
171
+ expect(dune).toBeDefined();
172
+ expect(dune!.directory).toContain("Dune (2021)");
173
+ // Ensure no group is accidentally named after the parent category
174
+ const moviesGroup = groups.find((g) => g.folderName === "Movies");
175
+ expect(moviesGroup).toBeUndefined();
176
+ });
177
+ });
@@ -0,0 +1,210 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
5
+ import { mkdtemp, mkdir, writeFile, rm, copyFile } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import path from "node:path";
8
+ import Database from "better-sqlite3";
9
+ import { resolveNaming } from "../src/library/naming.js";
10
+ import { Importer } from "../src/library/importer.js";
11
+ import { EventBusImpl } from "../src/events/event-bus.js";
12
+ import { createLogger } from "../src/logging/logger.js";
13
+ import { runMigrations, CORE_MIGRATIONS } from "../src/datastore/migrations.js";
14
+ import type { NamingRule, ContentItemBase } from "@digitalpaws/starr-types-core";
15
+
16
+ // ── Helpers ──────────────────────────────────────────────────────────
17
+
18
+ function movieItem(overrides?: Partial<ContentItemBase>): any {
19
+ return {
20
+ contentTypeId: "starr-video/movie@1",
21
+ title: "Dune",
22
+ externalIds: { tmdb: "123", imdb: "tt123" },
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ // ── Naming tests (Phase 1c) ─────────────────────────────────────────
28
+
29
+ describe("resolveNaming", () => {
30
+ it("resolves default naming: {Title}/{Title}.{ext}", () => {
31
+ const rule: NamingRule = {
32
+ directory: [[{ type: "field", value: "Title" }]],
33
+ filename: [[{ type: "field", value: "Title" }]],
34
+ };
35
+ expect(resolveNaming(rule, movieItem(), "mkv")).toBe("Dune/Dune.mkv");
36
+ });
37
+
38
+ it("resolves multi-segment naming with year and quality", () => {
39
+ const rule: NamingRule = {
40
+ directory: [[{ type: "field", value: "Title" }, { type: "literal", value: " (" }, { type: "field", value: "Year" }, { type: "literal", value: ")" }]],
41
+ filename: [[{ type: "field", value: "Title" }, { type: "literal", value: " (" }, { type: "field", value: "Year" }, { type: "literal", value: ") [" }, { type: "field", value: "Quality" }, { type: "literal", value: "]" }]],
42
+ };
43
+ const item = movieItem({ fields: { Quality: "2160p" }, year: 2021 });
44
+ expect(resolveNaming(rule, item, "mkv")).toBe("Dune (2021)/Dune (2021) [2160p].mkv");
45
+ });
46
+
47
+ it("sanitises problematic characters", () => {
48
+ const item = movieItem({ title: 'Movie: The <Best> "Ever"' });
49
+ const rule: NamingRule = {
50
+ directory: [[{ type: "field", value: "Title" }]],
51
+ filename: [[{ type: "field", value: "Title" }]],
52
+ };
53
+ expect(resolveNaming(rule, item, "mp4")).toBe("Movie The Best Ever/Movie The Best Ever.mp4");
54
+ });
55
+
56
+ it("resolves external ids and ContentType", () => {
57
+ const rule: NamingRule = {
58
+ directory: [[{ type: "field", value: "ContentType" }]],
59
+ filename: [[{ type: "field", value: "tmdb" }]],
60
+ };
61
+ expect(resolveNaming(rule, movieItem(), "mkv")).toBe("starr-video/movie@1/123.mkv");
62
+ });
63
+ });
64
+
65
+ // ── Importer tests (real fs with temp dirs) ─────────────────────────
66
+
67
+ let tmpDirBase: string;
68
+ let counter = 0;
69
+
70
+ beforeAll(async () => {
71
+ tmpDirBase = await mkdtemp(path.join(tmpdir(), "starr-library-test-"));
72
+ });
73
+
74
+ afterAll(async () => {
75
+ await rm(tmpDirBase, { recursive: true, force: true });
76
+ });
77
+
78
+ /**
79
+ * Set up a fresh test environment with:
80
+ * - In-memory SQLite database with migrations
81
+ * - Event bus
82
+ * - A temp download directory with a source file
83
+ * - A temp library root
84
+ * - A tracked request in "downloaded" state
85
+ */
86
+ async function setup() {
87
+ const db = new Database(":memory:");
88
+ db.pragma("journal_mode = WAL");
89
+ db.pragma("foreign_keys = ON");
90
+ const logger = createLogger("test", "silent");
91
+ runMigrations(db, CORE_MIGRATIONS, logger);
92
+
93
+ const impl = new EventBusImpl(logger);
94
+ const coreBus = impl.coreBus();
95
+
96
+ // Temp directories unique per test
97
+ const n = ++counter;
98
+ const downloadDir = path.join(tmpDirBase, `download-${n}`);
99
+ const libraryRoot = path.join(tmpDirBase, `library-${n}`);
100
+ await mkdir(downloadDir, { recursive: true });
101
+ await mkdir(libraryRoot, { recursive: true });
102
+
103
+ // Create a source file
104
+ const sourceFile = path.join(downloadDir, "Dune.mkv");
105
+ await writeFile(sourceFile, "fake media content");
106
+
107
+ // Insert a tracked request
108
+ db.prepare(`INSERT INTO tracked_requests (request_id, content_type_id, content_identity, item, owner_user_id, status)
109
+ VALUES (?,?,?,?,?,?)`).run(
110
+ "req-001",
111
+ "starr-video/movie@1",
112
+ "starr-video/movie@1::tmdb=123",
113
+ JSON.stringify({ title: "Dune", contentTypeId: "starr-video/movie@1", externalIds: { tmdb: "123" } }),
114
+ "admin",
115
+ "downloaded",
116
+ );
117
+
118
+ const getConfig = () => ({
119
+ defaultLibraryRoot: libraryRoot,
120
+ roots: {} as Record<string, string>,
121
+ linkType: "copy" as const,
122
+ importSubtitles: false,
123
+ subtitleExtensions: ["srt"],
124
+ });
125
+ const getNamingRule = () => ({
126
+ directory: [[{ type: "field", value: "Title" }]],
127
+ filename: [[{ type: "field", value: "Title" }]],
128
+ } as NamingRule);
129
+
130
+ const importer = new Importer(db, coreBus, logger, getConfig, getNamingRule);
131
+
132
+ return { db, coreBus, importer, sourceFile, libraryRoot };
133
+ }
134
+
135
+ describe("Importer.import", () => {
136
+ it("successfully imports a file via copy", async () => {
137
+ const { importer, coreBus, sourceFile, libraryRoot } = await setup();
138
+ const events: string[] = [];
139
+ coreBus.on("import.started", () => events.push("import.started"));
140
+ coreBus.on("import.completed", () => events.push("import.completed"));
141
+
142
+ const result = await importer.import(
143
+ "req-001",
144
+ sourceFile,
145
+ { title: "Dune", contentTypeId: "starr-video/movie@1", externalIds: { tmdb: "123" } },
146
+ "starr-video/movie@1",
147
+ "mkv",
148
+ "starr-video/movie@1::tmdb=123",
149
+ );
150
+
151
+ expect(result.success).toBe(true);
152
+ expect(result.libraryPath).toBe(path.join(libraryRoot, "Dune", "Dune.mkv"));
153
+ expect(events).toContain("import.started");
154
+ expect(events).toContain("import.completed");
155
+ });
156
+
157
+ it("emits import.failed on error (non-existent source)", async () => {
158
+ const { importer, coreBus, libraryRoot } = await setup();
159
+ const events: string[] = [];
160
+ coreBus.on("import.failed", () => events.push("import.failed"));
161
+
162
+ const result = await importer.import(
163
+ "req-001",
164
+ "/nonexistent/path/file.mkv",
165
+ { title: "D", contentTypeId: "starr-video/movie@1", externalIds: { tmdb: "123" } },
166
+ "starr-video/movie@1",
167
+ "mkv",
168
+ "starr-video/movie@1::tmdb=123",
169
+ );
170
+
171
+ expect(result.success).toBe(false);
172
+ expect(result.error).toBeDefined();
173
+ expect(events).toContain("import.failed");
174
+ });
175
+
176
+ it("records library_path in the database", async () => {
177
+ const { importer, db, sourceFile, libraryRoot } = await setup();
178
+
179
+ await importer.import(
180
+ "req-001",
181
+ sourceFile,
182
+ { title: "Dune", contentTypeId: "starr-video/movie@1", externalIds: { tmdb: "123" } },
183
+ "starr-video/movie@1",
184
+ "mkv",
185
+ "starr-video/movie@1::tmdb=123",
186
+ );
187
+
188
+ const row = db.prepare("SELECT library_path FROM library_paths WHERE request_id = ?").get("req-001") as any;
189
+ expect(row).toBeDefined();
190
+ expect(row!.library_path).toBe(path.join(libraryRoot, "Dune", "Dune.mkv"));
191
+ });
192
+
193
+ it("creates the destination file on disk", async () => {
194
+ const { importer, sourceFile, libraryRoot } = await setup();
195
+
196
+ const result = await importer.import(
197
+ "req-001",
198
+ sourceFile,
199
+ { title: "Dune", contentTypeId: "starr-video/movie@1", externalIds: { tmdb: "123" } },
200
+ "starr-video/movie@1",
201
+ "mkv",
202
+ "starr-video/movie@1::tmdb=123",
203
+ );
204
+
205
+ expect(result.success).toBe(true);
206
+ // Verify the file actually exists
207
+ const { access } = await import("node:fs/promises");
208
+ await expect(access(result.libraryPath!)).resolves.toBeUndefined();
209
+ });
210
+ });