@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,621 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import type Database from "better-sqlite3";
5
+ import type { ContentItemBase } from "@digitalpaws/starr-types-core";
6
+ import type { EventBus, Logger, StarrEvent } from "../types/plugin.js";
7
+ import { env } from "node:process";
8
+
9
+ // ── Types ──────────────────────────────────────────────────
10
+
11
+ /** Union of the v1 status values (§4). */
12
+ export type RequestStatus =
13
+ | "requested"
14
+ | "searching"
15
+ | "downloading"
16
+ | "downloaded"
17
+ | "importing"
18
+ | "imported"
19
+ | "failed";
20
+
21
+ /** The domain record as stored and returned to callers. */
22
+ export interface TrackedRequest {
23
+ requestId: string;
24
+ contentTypeId: string;
25
+ contentIdentity: string;
26
+ item: ContentItemBase;
27
+ ownerUserId: string;
28
+ watchers: string[];
29
+ status: RequestStatus;
30
+ statusDetail?: string;
31
+ chosenReleaseId?: string;
32
+ attempts: number;
33
+ libraryId?: string;
34
+ libraryName?: string;
35
+ createdAt: string;
36
+ updatedAt: string;
37
+ }
38
+
39
+ /** Minimal data needed to insert a new tracked request. */
40
+ export interface TrackedRequestData {
41
+ requestId: string;
42
+ contentTypeId: string;
43
+ contentIdentity: string;
44
+ item: ContentItemBase;
45
+ ownerUserId: string;
46
+ libraryId?: string;
47
+ }
48
+
49
+ /** A single captured spine event stored in the trace log. */
50
+ export interface TraceEntry {
51
+ id: number;
52
+ traceId: string;
53
+ requestId: string;
54
+ eventType: string;
55
+ /** JSON-serialized event envelope (envelope fields + payload). */
56
+ eventData: Record<string, unknown>;
57
+ timestamp: string;
58
+ }
59
+
60
+ // ── Row mapping helper ─────────────────────────────────────
61
+
62
+ function rowToRequest(row: Record<string, unknown>): TrackedRequest {
63
+ return {
64
+ requestId: row.request_id as string,
65
+ contentTypeId: row.content_type_id as string,
66
+ contentIdentity: row.content_identity as string,
67
+ item: JSON.parse(row.item as string) as ContentItemBase,
68
+ ownerUserId: row.owner_user_id as string,
69
+ watchers: JSON.parse(row.watchers as string) as string[],
70
+ status: row.status as RequestStatus,
71
+ statusDetail: (row.status_detail as string) || undefined,
72
+ chosenReleaseId: (row.chosen_release_id as string) || undefined,
73
+ attempts: row.attempts as number,
74
+ libraryId: (row.library_id as string) || undefined,
75
+ createdAt: row.created_at as string,
76
+ updatedAt: row.updated_at as string,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Maximum number of trace entries retained per traceId.
82
+ * Set via STARR_TRACE_HISTORY_LIMIT env var; defaults to 50.
83
+ * Values ≤ 0 disable trace capture entirely.
84
+ */
85
+ const TRACE_HISTORY_LIMIT = (() => {
86
+ const raw = env.STARR_TRACE_HISTORY_LIMIT;
87
+ if (raw === undefined || raw === "") return 50;
88
+ const n = Number(raw);
89
+ if (Number.isNaN(n)) {
90
+ // We don't have a logger at module scope — the state store
91
+ // constructor will log a warning if the env var is invalid.
92
+ return 50;
93
+ }
94
+ return Math.max(0, Math.floor(n));
95
+ })();
96
+
97
+ // ── StateStore class ───────────────────────────────────────
98
+
99
+ export class StateStore {
100
+ private db: Database.Database;
101
+ private bus: EventBus;
102
+ private logger: Logger;
103
+
104
+ // Prepared statements — created once in constructor
105
+ private stmtGetByRequestId: Database.Statement;
106
+ private stmtFindByIdentity: Database.Statement;
107
+ private stmtInsert: Database.Statement;
108
+ private stmtUpdateStatus: Database.Statement;
109
+ private stmtIncrementAttempts: Database.Statement;
110
+ private stmtAddWatcher: Database.Statement;
111
+ private stmtListByUser: Database.Statement;
112
+ private stmtInsertTrace: Database.Statement;
113
+ private stmtGetIdsByTraceId: Database.Statement;
114
+ private stmtGetTraceByRequestId: Database.Statement;
115
+ private stmtResetAttempts: Database.Statement;
116
+ private stmtResetForRetry: Database.Statement;
117
+ private stmtDeleteRequest: Database.Statement;
118
+ private stmtDeleteTraceByRequestId: Database.Statement;
119
+
120
+ constructor(
121
+ db: Database.Database,
122
+ bus: EventBus,
123
+ logger: Logger,
124
+ ) {
125
+ this.db = db;
126
+ this.bus = bus;
127
+ this.logger = logger.child("state-store");
128
+
129
+ // Compile prepared statements once
130
+ this.stmtGetByRequestId = db.prepare(
131
+ "SELECT * FROM tracked_requests WHERE request_id = @requestId",
132
+ );
133
+ this.stmtFindByIdentity = db.prepare(
134
+ `SELECT * FROM tracked_requests
135
+ WHERE content_identity = @identity AND status != 'failed'
136
+ ORDER BY created_at DESC
137
+ LIMIT 1`,
138
+ );
139
+ this.stmtInsert = db.prepare(
140
+ `INSERT INTO tracked_requests
141
+ (request_id, content_type_id, content_identity, item,
142
+ owner_user_id, watchers, status, attempts, library_id, created_at, updated_at)
143
+ VALUES
144
+ (@requestId, @contentTypeId, @contentIdentity, @item,
145
+ @ownerUserId, '[]', 'requested', 0,
146
+ @libraryId,
147
+ datetime('now'), datetime('now'))`,
148
+ );
149
+ this.stmtUpdateStatus = db.prepare(
150
+ `UPDATE tracked_requests
151
+ SET status = @status,
152
+ status_detail = @detail,
153
+ chosen_release_id = COALESCE(@chosenReleaseId, chosen_release_id),
154
+ updated_at = datetime('now')
155
+ WHERE request_id = @requestId`,
156
+ );
157
+ this.stmtIncrementAttempts = db.prepare(
158
+ `UPDATE tracked_requests
159
+ SET attempts = attempts + 1, updated_at = datetime('now')
160
+ WHERE request_id = @requestId`,
161
+ );
162
+ this.stmtAddWatcher = db.prepare(
163
+ `UPDATE tracked_requests
164
+ SET watchers = @watchers, updated_at = datetime('now')
165
+ WHERE request_id = @requestId`,
166
+ );
167
+ this.stmtListByUser = db.prepare(
168
+ `SELECT * FROM tracked_requests
169
+ WHERE owner_user_id = @userId
170
+ OR watchers LIKE @pattern
171
+ ORDER BY created_at DESC`,
172
+ );
173
+
174
+ // Trace log — prepared statements
175
+ this.stmtInsertTrace = db.prepare(
176
+ `INSERT INTO trace_log (trace_id, request_id, event_type, event_data, timestamp)
177
+ VALUES (@traceId, @requestId, @eventType, @eventData, @timestamp)`,
178
+ );
179
+ this.stmtGetIdsByTraceId = db.prepare(
180
+ `SELECT id FROM trace_log
181
+ WHERE trace_id = @traceId
182
+ ORDER BY id ASC`,
183
+ );
184
+ this.stmtGetTraceByRequestId = db.prepare(
185
+ `SELECT * FROM trace_log
186
+ WHERE request_id = @requestId
187
+ ORDER BY id ASC`,
188
+ );
189
+ this.stmtResetAttempts = db.prepare(
190
+ `UPDATE tracked_requests
191
+ SET attempts = 0, updated_at = datetime('now')
192
+ WHERE request_id = @requestId`,
193
+ );
194
+
195
+ this.stmtResetForRetry = db.prepare(
196
+ `UPDATE tracked_requests
197
+ SET status = 'requested',
198
+ status_detail = @detail,
199
+ chosen_release_id = NULL,
200
+ attempts = 0,
201
+ updated_at = datetime('now')
202
+ WHERE request_id = @requestId`,
203
+ );
204
+
205
+ this.stmtDeleteRequest = db.prepare(
206
+ `DELETE FROM tracked_requests WHERE request_id = @requestId`,
207
+ );
208
+ this.stmtDeleteTraceByRequestId = db.prepare(
209
+ `DELETE FROM trace_log WHERE request_id = @requestId`,
210
+ );
211
+
212
+ // Subscribe to acquisition spine events
213
+ this.subscribe();
214
+ }
215
+
216
+ // ── Public API ───────────────────────────────────────────
217
+
218
+ /** Retrieve a single tracked request by its domain id. */
219
+ getByRequestId(requestId: string): TrackedRequest | undefined {
220
+ const row = this.stmtGetByRequestId.get({ requestId }) as
221
+ | Record<string, unknown>
222
+ | undefined;
223
+ return row ? rowToRequest(row) : undefined;
224
+ }
225
+
226
+ /**
227
+ * Find a non-failed tracked request with the given content identity.
228
+ * Used by request intake for de-duplication (DU1).
229
+ * Returns the most recently created match, or undefined.
230
+ */
231
+ findByContentIdentity(contentIdentity: string): TrackedRequest | undefined {
232
+ const row = this.stmtFindByIdentity.get({ identity: contentIdentity }) as
233
+ | Record<string, unknown>
234
+ | undefined;
235
+ return row ? rowToRequest(row) : undefined;
236
+ }
237
+
238
+ /**
239
+ * Insert a brand-new tracked request with status ``requested``.
240
+ * Called by request intake after de-dup check (DU3).
241
+ */
242
+ insert(data: TrackedRequestData): void {
243
+ this.stmtInsert.run({
244
+ requestId: data.requestId,
245
+ contentTypeId: data.contentTypeId,
246
+ contentIdentity: data.contentIdentity,
247
+ item: JSON.stringify(data.item),
248
+ ownerUserId: data.ownerUserId,
249
+ libraryId: data.libraryId ?? null,
250
+ });
251
+ this.logger.debug("tracked request created", {
252
+ requestId: data.requestId,
253
+ contentIdentity: data.contentIdentity,
254
+ libraryId: data.libraryId,
255
+ });
256
+ }
257
+
258
+ /**
259
+ * Add a userId to the watchers array of an existing request.
260
+ * Called by request intake when re-attaching to an existing request (DU2).
261
+ * No-op if the user is already the owner or a watcher.
262
+ */
263
+ addWatcher(requestId: string, userId: string): void {
264
+ const req = this.getByRequestId(requestId);
265
+ if (!req) {
266
+ this.logger.warn("addWatcher: unknown requestId", { requestId });
267
+ return;
268
+ }
269
+ if (req.ownerUserId === userId || req.watchers.includes(userId)) {
270
+ return; // already attached
271
+ }
272
+ const updated = [...req.watchers, userId];
273
+ this.stmtAddWatcher.run({
274
+ requestId,
275
+ watchers: JSON.stringify(updated),
276
+ });
277
+ this.logger.debug("watcher added", { requestId, userId });
278
+ }
279
+
280
+ /**
281
+ * Return the ordered trace history for a tracked request.
282
+ *
283
+ * Returns an array of TraceEntry, oldest first. Returns an empty array
284
+ * if the requestId is unknown or has no captured events.
285
+ *
286
+ * This is the data surface for the admin "activity" view (DBG2).
287
+ * The UI (Task 022) renders the returned data; this task only provides
288
+ * the query.
289
+ */
290
+ getTraceLog(requestId: string): TraceEntry[] {
291
+ const rows = this.stmtGetTraceByRequestId.all({ requestId }) as Array<
292
+ Record<string, unknown>
293
+ >;
294
+ return rows.map((row) => ({
295
+ id: row.id as number,
296
+ traceId: row.trace_id as string,
297
+ requestId: row.request_id as string,
298
+ eventType: row.event_type as string,
299
+ eventData: JSON.parse(row.event_data as string) as Record<string, unknown>,
300
+ timestamp: row.timestamp as string,
301
+ }));
302
+ }
303
+
304
+ // ── Retry ───────────────────────────────────────────────
305
+
306
+ /**
307
+ * Reset a tracked request to 'requested' for a manual retry.
308
+ *
309
+ * Clears chosenReleaseId, resets attempts to 0, sets status to
310
+ * 'requested' with a detail message. This is called from the
311
+ * retry endpoint before re-emitting content.requested.
312
+ *
313
+ * Uses a transaction for atomicity.
314
+ * Emits item.updated so the UI reflects the change immediately.
315
+ */
316
+ resetAndRetry(requestId: string): void {
317
+ const txn = this.db.transaction(() => {
318
+ // Reset status, clear chosenReleaseId, reset attempts in one statement
319
+ this.stmtResetForRetry.run({
320
+ requestId,
321
+ detail: "Retrying — restarted by user",
322
+ });
323
+ });
324
+ txn();
325
+
326
+ // Emit item.updated so the UI updates live
327
+ this.bus.emit("item.updated", {
328
+ requestId,
329
+ status: "requested",
330
+ detail: "Retrying — restarted by user",
331
+ });
332
+
333
+ this.logger.info("request reset for retry", { requestId });
334
+ }
335
+
336
+ // ── Delete ──────────────────────────────────────────────
337
+
338
+ /**
339
+ * Permanently delete a tracked request and its associated
340
+ * trace-log entries. This is unconditional (any status).
341
+ *
342
+ * Returns true if a row was deleted, false if the requestId
343
+ * did not exist.
344
+ */
345
+ deleteRequest(requestId: string): boolean {
346
+ const txn = this.db.transaction(() => {
347
+ // 1. Delete trace log entries for this request
348
+ this.stmtDeleteTraceByRequestId.run({ requestId });
349
+
350
+ // 2. Delete the request itself
351
+ const info = this.stmtDeleteRequest.run({ requestId });
352
+ return info.changes > 0;
353
+ });
354
+
355
+ const deleted = txn();
356
+
357
+ if (deleted) {
358
+ this.logger.info("request deleted", { requestId });
359
+ } else {
360
+ this.logger.warn("deleteRequest: requestId not found", { requestId });
361
+ }
362
+
363
+ // Note: we deliberately do NOT emit item.updated here.
364
+ // Deletion is handled on the UI side by removing the item from
365
+ // the local store and/or navigating away.
366
+
367
+ return deleted;
368
+ }
369
+
370
+ /**
371
+ * Return ALL tracked requests (admin only).
372
+ */
373
+ getLibraryPath(requestId: string): string | undefined {
374
+ const row = this.db
375
+ .prepare("SELECT library_path FROM library_paths WHERE request_id = ?")
376
+ .get(requestId) as { library_path: string } | undefined;
377
+ return row?.library_path;
378
+ }
379
+
380
+ getAllRequests(): TrackedRequest[] {
381
+ const rows = this.db
382
+ .prepare("SELECT * FROM tracked_requests ORDER BY created_at DESC")
383
+ .all() as any[];
384
+ return rows.map((row: any) => rowToRequest(row));
385
+ }
386
+
387
+ /**
388
+ * List tracked requests visible to a given user (owner or watcher).
389
+ * Used by the UI to show the request list (UI-ST2).
390
+ */
391
+ listByUser(userId: string): TrackedRequest[] {
392
+ // The LIKE pattern searches for the userId inside the JSON watchers array.
393
+ // e.g. '%"admin"%' matches ["admin"] or ["alice","admin"].
394
+ const rows = this.stmtListByUser.all({
395
+ userId,
396
+ pattern: `%"${userId}"%`,
397
+ }) as Array<Record<string, unknown>>;
398
+ return rows.map(rowToRequest);
399
+ }
400
+
401
+ // ── Trace capture ────────────────────────────────────────
402
+
403
+ /**
404
+ * Capture a spine event into the trace log. Stores the full event data
405
+ * (envelope fields + payload) as JSON, then prunes if the per-traceId
406
+ * count exceeds TRACE_HISTORY_LIMIT.
407
+ *
408
+ * This is a no-op if TRACE_HISTORY_LIMIT is 0 (trace capture disabled).
409
+ */
410
+ private captureTraceEvent(event: StarrEvent, requestId: string): void {
411
+ if (TRACE_HISTORY_LIMIT === 0) return;
412
+
413
+ const eventData = {
414
+ eventId: event.id,
415
+ traceId: event.traceId,
416
+ correlationId: event.correlationId,
417
+ source: event.source,
418
+ schemaVersion: event.schemaVersion,
419
+ timestamp: event.timestamp,
420
+ payload: event.payload,
421
+ };
422
+
423
+ // 1. Insert the new entry
424
+ this.stmtInsertTrace.run({
425
+ traceId: event.traceId,
426
+ requestId,
427
+ eventType: event.type,
428
+ eventData: JSON.stringify(eventData),
429
+ timestamp: event.timestamp,
430
+ });
431
+
432
+ // 2. Fetch all entry ids for this traceId, oldest first
433
+ const rows = this.stmtGetIdsByTraceId.all({
434
+ traceId: event.traceId,
435
+ }) as Array<{ id: number }>;
436
+
437
+ // 3. Prune if over the limit — delete the oldest excess entries
438
+ if (rows.length > TRACE_HISTORY_LIMIT) {
439
+ const excess = rows.length - TRACE_HISTORY_LIMIT;
440
+ const deleteIds = rows.slice(0, excess).map((r) => r.id);
441
+
442
+ // Use a one-off prepared statement for deletes
443
+ const deleteStmt = this.db.prepare("DELETE FROM trace_log WHERE id = ?");
444
+ for (const id of deleteIds) {
445
+ deleteStmt.run(id);
446
+ }
447
+
448
+ this.logger.debug("trace pruned", {
449
+ traceId: event.traceId,
450
+ deleted: excess,
451
+ remaining: TRACE_HISTORY_LIMIT,
452
+ });
453
+ }
454
+ }
455
+
456
+ // ── Event subscription ───────────────────────────────────
457
+
458
+ private subscribe(): void {
459
+ this.bus.on("release.found", this.onReleaseFound.bind(this));
460
+ this.bus.on("download.started", this.onDownloadStarted.bind(this));
461
+ this.bus.on("download.completed", this.onDownloadCompleted.bind(this));
462
+ this.bus.on("download.failed", this.onDownloadFailed.bind(this));
463
+ this.bus.on("request.failed", this.onRequestFailed.bind(this));
464
+ this.bus.on("import.started", this.onImportStarted.bind(this));
465
+ this.bus.on("import.completed", this.onImportCompleted.bind(this));
466
+ this.bus.on("import.failed", this.onImportFailed.bind(this));
467
+ }
468
+
469
+ private onReleaseFound(event: StarrEvent): void {
470
+ const { requestId } = event.payload as { requestId: string };
471
+ const req = this.getByRequestId(requestId);
472
+ if (!req) {
473
+ this.logger.warn("release.found for unknown requestId", { requestId });
474
+ return;
475
+ }
476
+ this.captureTraceEvent(event, requestId);
477
+ // Transition: requested → searching (first candidate arrived)
478
+ if (req.status === "requested") {
479
+ this.updateStatus(requestId, "searching");
480
+ }
481
+ // If already searching (or beyond), just more candidates — no status change.
482
+ }
483
+
484
+ private onDownloadStarted(event: StarrEvent): void {
485
+ const payload = event.payload as {
486
+ requestId: string;
487
+ release?: { id?: string; title?: string };
488
+ };
489
+ const req = this.getByRequestId(payload.requestId);
490
+ if (!req) {
491
+ this.logger.warn("download.started for unknown requestId", {
492
+ requestId: payload.requestId,
493
+ });
494
+ return;
495
+ }
496
+ this.captureTraceEvent(event, payload.requestId);
497
+
498
+ // Increment the display-only attempts counter (SM4)
499
+ this.incrementAttempts(payload.requestId);
500
+
501
+ // Transition: searching → downloading (defensive: also from 'requested')
502
+ if (req.status === "searching" || req.status === "requested") {
503
+ const releaseId = payload.release?.id ?? payload.release?.title ?? undefined;
504
+ this.updateStatus(payload.requestId, "downloading", undefined, releaseId);
505
+ }
506
+ }
507
+
508
+ private onDownloadCompleted(event: StarrEvent): void {
509
+ const { requestId } = event.payload as { requestId: string };
510
+ const req = this.getByRequestId(requestId);
511
+ if (!req) {
512
+ this.logger.warn("download.completed for unknown requestId", { requestId });
513
+ return;
514
+ }
515
+ this.captureTraceEvent(event, requestId);
516
+ // Terminal success — only if not already terminal
517
+ if (req.status !== "failed" && req.status !== "downloaded" && req.status !== "imported") {
518
+ this.updateStatus(requestId, "downloaded");
519
+ }
520
+ }
521
+
522
+ private onDownloadFailed(event: StarrEvent): void {
523
+ const { requestId } = event.payload as { requestId: string };
524
+ const req = this.getByRequestId(requestId);
525
+ if (!req) {
526
+ this.logger.warn("download.failed for unknown requestId", { requestId });
527
+ return;
528
+ }
529
+ this.captureTraceEvent(event, requestId);
530
+ // Transient failure: go back to searching, NEVER to failed (SM1)
531
+ if (req.status === "downloading") {
532
+ this.updateStatus(
533
+ requestId,
534
+ "searching",
535
+ `retrying (attempt ${req.attempts})`,
536
+ );
537
+ }
538
+ }
539
+
540
+ private onImportStarted(event: StarrEvent): void {
541
+ const { requestId } = event.payload as { requestId: string };
542
+ const req = this.getByRequestId(requestId);
543
+ if (!req) return;
544
+ if (req.status === "downloaded") {
545
+ this.updateStatus(requestId, "importing");
546
+ }
547
+ }
548
+
549
+ private onImportCompleted(event: StarrEvent): void {
550
+ const { requestId } = event.payload as { requestId: string };
551
+ const req = this.getByRequestId(requestId);
552
+ if (!req) return;
553
+ if (req.status === "importing") {
554
+ this.updateStatus(requestId, "imported");
555
+ }
556
+ }
557
+
558
+ private onImportFailed(event: StarrEvent): void {
559
+ const payload = event.payload as { requestId: string; error?: string };
560
+ const req = this.getByRequestId(payload.requestId);
561
+ if (!req) return;
562
+ if (req.status === "importing") {
563
+ this.updateStatus(payload.requestId, "downloaded",
564
+ `Import failed: ${payload.error ?? "unknown error"}`);
565
+ }
566
+ }
567
+
568
+ private onRequestFailed(event: StarrEvent): void {
569
+ const payload = event.payload as { requestId: string; reason?: string };
570
+ const req = this.getByRequestId(payload.requestId);
571
+ if (!req) {
572
+ this.logger.warn("request.failed for unknown requestId", {
573
+ requestId: payload.requestId,
574
+ });
575
+ return;
576
+ }
577
+ this.captureTraceEvent(event, payload.requestId);
578
+ // Terminal failure — the ONLY path to 'failed' (SM2, SP1a)
579
+ if (req.status !== "failed" && req.status !== "downloaded" && req.status !== "imported") {
580
+ this.updateStatus(payload.requestId, "failed", payload.reason);
581
+ }
582
+ }
583
+
584
+ // ── Internal helpers ──────────────────────────────────────
585
+
586
+ /**
587
+ * Update status, optional detail, and optional chosenReleaseId.
588
+ * Emits item.updated on every call (the caller guards transitions,
589
+ * so duplicate emissions are not a concern).
590
+ */
591
+ private updateStatus(
592
+ requestId: string,
593
+ status: RequestStatus,
594
+ detail?: string,
595
+ chosenReleaseId?: string,
596
+ ): void {
597
+ this.stmtUpdateStatus.run({
598
+ requestId,
599
+ status,
600
+ detail: detail ?? null,
601
+ chosenReleaseId: chosenReleaseId ?? null,
602
+ });
603
+
604
+ // Emit item.updated — the single event the UI relies on (SP2, UI-ST1)
605
+ const payload: { requestId: string; status: string; detail?: string } = {
606
+ requestId,
607
+ status,
608
+ };
609
+ if (detail !== undefined) {
610
+ payload.detail = detail;
611
+ }
612
+ this.bus.emit("item.updated", payload);
613
+
614
+ this.logger.debug("status updated", { requestId, status, detail });
615
+ }
616
+
617
+ /** Increment the attempts counter by 1. Called on download.started (SM4). */
618
+ private incrementAttempts(requestId: string): void {
619
+ this.stmtIncrementAttempts.run({ requestId });
620
+ }
621
+ }
@@ -0,0 +1,27 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import type { TraceEntry, StateStore } from "./state-store.js";
5
+
6
+ /**
7
+ * Module-level reference to the state store, set by the host bootstrap.
8
+ * This avoids circular imports between route modules and the host.
9
+ */
10
+ let stateStore: StateStore | null = null;
11
+
12
+ /**
13
+ * Initialize the trace-store wrapper with a reference to the StateStore.
14
+ * Called from main.ts after the web server and state store are created.
15
+ */
16
+ export function initTraceStore(store: StateStore): void {
17
+ stateStore = store;
18
+ }
19
+
20
+ /**
21
+ * Retrieve trace events for a given requestId.
22
+ * Returns an empty array if the state store isn't available or no events exist.
23
+ */
24
+ export function getTraceEvents(requestId: string): TraceEntry[] {
25
+ if (!stateStore) return [];
26
+ return stateStore.getTraceLog(requestId);
27
+ }