@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,430 @@
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 { writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import path from "node:path";
9
+ import { randomBytes } from "node:crypto";
10
+
11
+ import { runMigrations, CORE_MIGRATIONS } from "../src/datastore/migrations.js";
12
+ import { openDatastore, type Datastore } from "../src/datastore/db.js";
13
+ import { createLogger } from "../src/logging/logger.js";
14
+ import { loadJwtSecret, resetJwtSecret } from "../src/auth/jwt-secret.js";
15
+ import { AuthService } from "../src/auth/auth-service.js";
16
+ import { Starr } from "../src/host.js";
17
+ import { ConfigService } from "../src/config/config-service.js";
18
+ import { StateStore } from "../src/state/state-store.js";
19
+ import { loadPlugins, stopPlugins } from "../src/plugins/loader.js";
20
+
21
+ import { createWebServer, initAuthService } from "../src/web/index.js";
22
+ import { initAuthRoutes } from "../src/web/routes/auth.js";
23
+ import { initConfigRoutes } from "../src/web/routes/config.js";
24
+ import { initDiscoveryRoutes } from "../src/web/routes/discovery.js";
25
+ import { initRequestRoutes } from "../src/web/routes/requests.js";
26
+ import { initActivityRoutes } from "../src/web/routes/activity.js";
27
+ import { initSseRoutes } from "../src/web/routes/sse.js";
28
+ import { initContentTypeRoutes } from "../src/web/routes/content-types.js";
29
+ import { initTraceStore } from "../src/state/trace-store.js";
30
+
31
+ const logger = createLogger("test", "silent");
32
+
33
+ /** Set up a temporary JWT secret for the test run. */
34
+ function setupJwtSecret(tmpDir: string): void {
35
+ resetJwtSecret();
36
+ const keyPath = path.join(tmpDir, "starr.jwt");
37
+ const key = randomBytes(32).toString("base64") + "\n";
38
+ writeFileSync(keyPath, key, { mode: 0o600 });
39
+ loadJwtSecret(tmpDir, logger);
40
+ }
41
+
42
+ /** Write a fake plugin (JavaScript) into the temporary plugins directory. */
43
+ async function writePlugin(
44
+ baseDir: string,
45
+ dirName: string,
46
+ code: string,
47
+ ): Promise<void> {
48
+ const dir = path.join(baseDir, dirName);
49
+ await mkdir(dir, { recursive: true });
50
+ await writeFile(
51
+ path.join(dir, "package.json"),
52
+ JSON.stringify({ type: "module", main: "./index.js" }),
53
+ );
54
+ await writeFile(path.join(dir, "index.js"), code);
55
+ }
56
+
57
+ /** Extract the starr_sid cookie value from a Set-Cookie header. */
58
+ function extractCookie(setCookie: string | string[] | undefined): string | null {
59
+ if (!setCookie) return null;
60
+ const header = Array.isArray(setCookie) ? setCookie[0] : setCookie;
61
+ const match = header.match(/starr_sid=([^;]+)/);
62
+ return match ? match[1] : null;
63
+ }
64
+
65
+ // ── End-to-end acquisition flow ─────────────────────────────────────
66
+
67
+ describe("end-to-end acquisition flow", () => {
68
+ let tmpDir: string;
69
+ let pluginsDir: string;
70
+ let datastore: Datastore;
71
+ let db: Database.Database;
72
+ let starr: Starr;
73
+ let webServer: Awaited<ReturnType<typeof createWebServer>>;
74
+ let cookie: string;
75
+
76
+ beforeAll(async () => {
77
+ tmpDir = await mkdtemp(path.join(tmpdir(), "starr-e2e-"));
78
+ pluginsDir = path.join(tmpDir, "plugins");
79
+ await mkdir(pluginsDir, { recursive: true });
80
+
81
+ // Fake metadata plugin: registers a movie type and responds to discovery.search
82
+ await writePlugin(
83
+ pluginsDir,
84
+ "fake-metadata",
85
+ `
86
+ export default function register(ctx) {
87
+ ctx.define({ id: "fake-metadata", version: "1.0.0", starrApiVersion: "^1.0.0" });
88
+ ctx.contentTypes.register({
89
+ id: "fake/movie@1",
90
+ name: "Fake Movie",
91
+ pluralName: "Fake Movies",
92
+ identity: ["tmdb"],
93
+ fields: [],
94
+ display: { primaryImageRole: "poster", subtitle: "releaseDate" },
95
+ });
96
+ ctx.events.on("discovery.search", async (event) => {
97
+ const { contentTypeIds } = event.payload;
98
+ if (!contentTypeIds || contentTypeIds.includes("fake/movie@1")) {
99
+ ctx.events.emit("discovery.results", {
100
+ contentTypeId: "fake/movie@1",
101
+ items: [{
102
+ contentTypeId: "fake/movie@1",
103
+ title: "End-to-End Movie",
104
+ externalIds: { tmdb: "99999" },
105
+ releaseDate: "2026-07-14",
106
+ search: { category: "movie", terms: ["End-to-End Movie"] },
107
+ }],
108
+ });
109
+ }
110
+ });
111
+ }
112
+ `,
113
+ );
114
+
115
+ // Fake duplicate-emitter plugin: registers a show type and emits discovery.results
116
+ // for the SAME contentTypeId TWICE (simulating the old OMDB bug pattern).
117
+ await writePlugin(
118
+ pluginsDir,
119
+ "fake-duplicate",
120
+ `
121
+ export default function register(ctx) {
122
+ ctx.define({ id: "fake-duplicate", version: "1.0.0", starrApiVersion: "^1.0.0" });
123
+ ctx.contentTypes.register({
124
+ id: "fake/show@1",
125
+ name: "Fake Show",
126
+ pluralName: "Fake Shows",
127
+ identity: ["tmdb"],
128
+ fields: [],
129
+ display: { primaryImageRole: "poster", subtitle: "releaseDate" },
130
+ });
131
+ ctx.events.on("discovery.search", async (event) => {
132
+ const { contentTypeIds } = event.payload;
133
+ if (!contentTypeIds || contentTypeIds.includes("fake/show@1")) {
134
+ // First emission — basic show
135
+ ctx.events.emit("discovery.results", {
136
+ contentTypeId: "fake/show@1",
137
+ items: [{
138
+ contentTypeId: "fake/show@1",
139
+ title: "Fake Show",
140
+ externalIds: { tmdb: "88888" },
141
+ releaseDate: "2026-01-01",
142
+ search: { category: "tv", terms: ["Fake Show"] },
143
+ }],
144
+ });
145
+ // Second emission — same contentTypeId, same externalIds (DUPLICATE!)
146
+ ctx.events.emit("discovery.results", {
147
+ contentTypeId: "fake/show@1",
148
+ items: [{
149
+ contentTypeId: "fake/show@1",
150
+ title: "Fake Show (enriched)",
151
+ externalIds: { tmdb: "88888" },
152
+ releaseDate: "2026-01-01",
153
+ search: { category: "tv", terms: ["Fake Show"] },
154
+ }],
155
+ });
156
+ }
157
+ });
158
+ }
159
+ `,
160
+ );
161
+
162
+ // Fake indexer plugin: emits a release.found for every content.requested
163
+ await writePlugin(
164
+ pluginsDir,
165
+ "fake-indexer",
166
+ `
167
+ export default function register(ctx) {
168
+ ctx.define({ id: "fake-indexer", version: "1.0.0", starrApiVersion: "^1.0.0" });
169
+ ctx.events.on("content.requested", async (event) => {
170
+ const { requestId, item } = event.payload;
171
+ ctx.events.emit("release.found", {
172
+ requestId,
173
+ contentTypeId: item.contentTypeId,
174
+ release: {
175
+ id: "release-1",
176
+ indexerId: "fake-indexer",
177
+ title: "End-to-End Release",
178
+ protocol: "torrent",
179
+ downloadUri: "magnet:?xt=urn:btih:e2e",
180
+ sizeBytes: 1073741824,
181
+ seeders: 42,
182
+ },
183
+ });
184
+ });
185
+ }
186
+ `,
187
+ );
188
+
189
+ // Fake selection plugin: immediately picks the first release
190
+ await writePlugin(
191
+ pluginsDir,
192
+ "fake-selection",
193
+ `
194
+ export default function register(ctx) {
195
+ ctx.define({ id: "fake-selection", version: "1.0.0", starrApiVersion: "^1.0.0" });
196
+ ctx.events.on("release.found", async (event) => {
197
+ const { requestId, release } = event.payload;
198
+ ctx.events.emit("release.chosen", { requestId, release });
199
+ });
200
+ }
201
+ `,
202
+ );
203
+
204
+ // Fake downloader plugin: immediately completes any chosen release
205
+ await writePlugin(
206
+ pluginsDir,
207
+ "fake-downloader",
208
+ `
209
+ export default function register(ctx) {
210
+ ctx.define({ id: "fake-downloader", version: "1.0.0", starrApiVersion: "^1.0.0" });
211
+ ctx.events.on("release.chosen", async (event) => {
212
+ const { requestId, release } = event.payload;
213
+ ctx.events.emit("download.started", { requestId, release });
214
+ ctx.events.emit("download.completed", {
215
+ requestId,
216
+ release,
217
+ location: "/downloads/e2e",
218
+ });
219
+ });
220
+ }
221
+ `,
222
+ );
223
+
224
+ datastore = openDatastore(path.join(tmpDir, "data"), logger);
225
+ db = datastore.db;
226
+ runMigrations(db, CORE_MIGRATIONS, logger);
227
+ setupJwtSecret(tmpDir);
228
+ process.env.STARR_DISCOVERY_WINDOW_MS = "200";
229
+
230
+ starr = new Starr({ pluginsDir, apiVersion: "1.0.0", logger });
231
+ starr.datastore = datastore;
232
+
233
+ const configService = new ConfigService(
234
+ db,
235
+ logger.child("config"),
236
+ async (pluginId: string) => {
237
+ const { reloadPlugin } = await import("../src/plugins/loader.js");
238
+ await reloadPlugin(starr, pluginId);
239
+ },
240
+ );
241
+ starr.configService = configService;
242
+
243
+ const stateStore = new StateStore(db, starr.coreBus, logger);
244
+ starr.stateStore = stateStore;
245
+
246
+ const authService = new AuthService(db, logger.child("auth"));
247
+
248
+ webServer = await createWebServer(logger.child("web"));
249
+ starr.webServer = webServer;
250
+
251
+ initAuthService(authService);
252
+ initAuthRoutes(authService);
253
+ initConfigRoutes(configService);
254
+ initTraceStore(stateStore);
255
+
256
+ await loadPlugins(starr);
257
+
258
+ initContentTypeRoutes(
259
+ () => starr.contentTypeRegistry.list(),
260
+ (id: string) => starr.contentTypeRegistry.get(id),
261
+ );
262
+ initDiscoveryRoutes(starr.coreBus);
263
+ initRequestRoutes(starr);
264
+ initActivityRoutes(starr);
265
+ initSseRoutes(starr.coreBus);
266
+
267
+ await webServer.listen({ port: 0, host: "127.0.0.1" });
268
+
269
+ // First-run setup
270
+ const setupRes = await webServer.inject({
271
+ method: "POST",
272
+ url: "/api/auth/setup",
273
+ payload: { username: "admin", password: "hunter2hunter2" },
274
+ });
275
+ expect(setupRes.statusCode).toBe(200);
276
+ cookie = extractCookie(setupRes.headers["set-cookie"]) ?? "";
277
+ expect(cookie).toBeTruthy();
278
+ }, 60_000);
279
+
280
+ afterAll(async () => {
281
+ if (starr) {
282
+ await stopPlugins(starr);
283
+ }
284
+ if (webServer) {
285
+ await webServer.close();
286
+ }
287
+ if (datastore) {
288
+ datastore.close();
289
+ }
290
+ if (tmpDir) {
291
+ await rm(tmpDir, { recursive: true, force: true });
292
+ }
293
+ }, 30_000);
294
+
295
+ it("discovers content via GET /api/discovery/search", async () => {
296
+ const res = await webServer.inject({
297
+ method: "GET",
298
+ url: "/api/discovery/search?q=e2e&type=fake/movie@1",
299
+ headers: { cookie: `starr_sid=${cookie}` },
300
+ });
301
+
302
+ expect(res.statusCode).toBe(200);
303
+ const body = JSON.parse(res.body);
304
+ expect(body.items).toHaveLength(1);
305
+ expect(body.items[0].contentTypeId).toBe("fake/movie@1");
306
+ expect(body.items[0].title).toBe("End-to-End Movie");
307
+ });
308
+
309
+ it("discovers content without a type filter (All tab)", async () => {
310
+ const res = await webServer.inject({
311
+ method: "GET",
312
+ url: "/api/discovery/search?q=e2e", // no &type= parameter
313
+ headers: { cookie: `starr_sid=${cookie}` },
314
+ });
315
+
316
+ expect(res.statusCode).toBe(200);
317
+ const body = JSON.parse(res.body);
318
+ // Both fake-metadata and fake-duplicate plugins respond when
319
+ // contentTypeIds is undefined (no type filter = "All" tab).
320
+ // The ordering depends on handler registration; just verify the
321
+ // expected movie is present among the results.
322
+ expect(body.items.length).toBeGreaterThanOrEqual(1);
323
+ expect(body.items.some((i: any) => i.title === "End-to-End Movie")).toBe(true);
324
+ });
325
+
326
+ it("detects duplicate items in search results (duplicate-emitter plugin)", async () => {
327
+ const res = await webServer.inject({
328
+ method: "GET",
329
+ url: "/api/discovery/search?q=fake&type=fake/show@1",
330
+ headers: { cookie: `starr_sid=${cookie}` },
331
+ });
332
+
333
+ expect(res.statusCode).toBe(200);
334
+ const body = JSON.parse(res.body);
335
+
336
+ // The fake-duplicate plugin emits TWO items with the same contentTypeId
337
+ // and externalIds. The route collector does NOT deduplicate, so both
338
+ // items should appear in the response.
339
+ expect(body.items.length).toBe(2);
340
+
341
+ // Both items should have the same identity key (contentTypeId + first externalId)
342
+ const keys = body.items.map((item: any) => {
343
+ const ext = Object.values(item.externalIds ?? {})[0] ?? "";
344
+ return `${item.contentTypeId}::${ext}`;
345
+ });
346
+ expect(keys[0]).toBe(keys[1]);
347
+
348
+ // This is the exact condition the Browse.svelte dev warning would catch:
349
+ // duplicate itemKey() values indicate a rendering problem.
350
+ const uniqueKeys = new Set(keys);
351
+ expect(uniqueKeys.size).toBeLessThan(keys.length);
352
+ expect(uniqueKeys.size).toBe(1);
353
+
354
+ // The client-side allowedTypes filter in Browse.svelte would keep both
355
+ // (both have contentTypeId "fake/show@1", which is in allowedTypes),
356
+ // confirming the frontend would receive duplicate keys.
357
+ const allowedTypes = ["starr-video/movie@1", "starr-video/show@1", "fake/show@1"];
358
+ const filtered = body.items.filter((item: any) =>
359
+ allowedTypes.includes(item.contentTypeId)
360
+ );
361
+ expect(filtered.length).toBe(2);
362
+ });
363
+
364
+ it("requests content and reaches terminal downloaded status", async () => {
365
+ const item = {
366
+ contentTypeId: "fake/movie@1",
367
+ title: "End-to-End Movie",
368
+ externalIds: { tmdb: "99999" },
369
+ releaseDate: "2026-07-14",
370
+ search: { category: "movie", terms: ["End-to-End Movie"] },
371
+ };
372
+
373
+ const createRes = await webServer.inject({
374
+ method: "POST",
375
+ url: "/api/requests",
376
+ headers: { cookie: `starr_sid=${cookie}` },
377
+ payload: item,
378
+ });
379
+
380
+ expect(createRes.statusCode).toBe(201);
381
+ const { requestId } = JSON.parse(createRes.body);
382
+ expect(requestId).toBeTruthy();
383
+
384
+ // Poll the request endpoint until the status becomes terminal.
385
+ let status = "requested";
386
+ for (let i = 0; i < 50; i++) {
387
+ const res = await webServer.inject({
388
+ method: "GET",
389
+ url: `/api/requests/${requestId}`,
390
+ headers: { cookie: `starr_sid=${cookie}` },
391
+ });
392
+ expect(res.statusCode).toBe(200);
393
+ const req = JSON.parse(res.body);
394
+ status = req.status;
395
+ if (status === "downloaded" || status === "failed") break;
396
+ await new Promise((r) => setTimeout(r, 50));
397
+ }
398
+
399
+ expect(status).toBe("downloaded");
400
+
401
+ // Verify the trace log captured the full spine chain.
402
+ const activityRes = await webServer.inject({
403
+ method: "GET",
404
+ url: `/api/requests/${requestId}/activity`,
405
+ headers: { cookie: `starr_sid=${cookie}` },
406
+ });
407
+ expect(activityRes.statusCode).toBe(200);
408
+ const { events } = JSON.parse(activityRes.body);
409
+ const types = events.map((e: { eventType: string }) => e.eventType);
410
+ expect(types).toContain("release.found");
411
+ expect(types).toContain("download.started");
412
+ expect(types).toContain("download.completed");
413
+
414
+ // All captured events share the same traceId.
415
+ const traceIds = new Set(events.map((e: { traceId: string }) => e.traceId));
416
+ expect(traceIds.size).toBe(1);
417
+ });
418
+
419
+ it("lists tracked requests for the authenticated user", async () => {
420
+ const res = await webServer.inject({
421
+ method: "GET",
422
+ url: "/api/requests",
423
+ headers: { cookie: `starr_sid=${cookie}` },
424
+ });
425
+ expect(res.statusCode).toBe(200);
426
+ const body = JSON.parse(res.body);
427
+ expect(body.requests.length).toBeGreaterThan(0);
428
+ expect(body.requests[0].ownerUserId).toBe("admin");
429
+ });
430
+ });