@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,83 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import type Database from "better-sqlite3";
5
+ import type { PluginStorage } from "../types/plugin.js";
6
+
7
+ export function createPluginStorage(
8
+ db: Database.Database,
9
+ pluginId: string,
10
+ instanceId = "default",
11
+ ): PluginStorage {
12
+ const getStmt = db.prepare(
13
+ "SELECT value FROM plugin_storage WHERE plugin_id = @pluginId AND instance_id = @instanceId AND key = @key",
14
+ );
15
+ const setStmt = db.prepare(`
16
+ INSERT INTO plugin_storage (plugin_id, instance_id, key, value, updated_at)
17
+ VALUES (@pluginId, @instanceId, @key, @value, datetime('now'))
18
+ ON CONFLICT(plugin_id, instance_id, key) DO UPDATE SET
19
+ value = @value,
20
+ updated_at = datetime('now')
21
+ `);
22
+ const deleteStmt = db.prepare(
23
+ "DELETE FROM plugin_storage WHERE plugin_id = @pluginId AND instance_id = @instanceId AND key = @key",
24
+ );
25
+ const listAllStmt = db.prepare(
26
+ `SELECT key, value FROM plugin_storage
27
+ WHERE plugin_id = @pluginId AND instance_id = @instanceId
28
+ ORDER BY key`,
29
+ );
30
+ const listPrefixStmt = db.prepare(
31
+ `SELECT key, value FROM plugin_storage
32
+ WHERE plugin_id = @pluginId AND instance_id = @instanceId AND key LIKE @prefix
33
+ ORDER BY key`,
34
+ );
35
+ const deleteAllStmt = db.prepare(
36
+ "DELETE FROM plugin_storage WHERE plugin_id = @pluginId AND instance_id = @instanceId",
37
+ );
38
+
39
+ return {
40
+ async get<T>(key: string): Promise<T | undefined> {
41
+ const row = getStmt.get({ pluginId, instanceId, key }) as
42
+ | { value: string }
43
+ | undefined;
44
+ if (!row) return undefined;
45
+ try {
46
+ return JSON.parse(row.value) as T;
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ },
51
+
52
+ async set<T>(key: string, value: T): Promise<void> {
53
+ setStmt.run({ pluginId, instanceId, key, value: JSON.stringify(value) });
54
+ },
55
+
56
+ async delete(key: string): Promise<void> {
57
+ deleteStmt.run({ pluginId, instanceId, key });
58
+ },
59
+
60
+ async list<T>(
61
+ prefix?: string,
62
+ ): Promise<Array<{ key: string; value: T }>> {
63
+ const stmt = prefix !== undefined ? listPrefixStmt : listAllStmt;
64
+ const params = prefix !== undefined
65
+ ? { pluginId, instanceId, prefix: `${prefix}%` }
66
+ : { pluginId, instanceId };
67
+ const rows = stmt.all(params) as Array<{ key: string; value: string }>;
68
+ return rows.map((row) => {
69
+ let value: T | undefined;
70
+ try {
71
+ value = JSON.parse(row.value) as T;
72
+ } catch {
73
+ value = undefined;
74
+ }
75
+ return { key: row.key, value: value as T };
76
+ });
77
+ },
78
+
79
+ async deleteAll(): Promise<void> {
80
+ deleteAllStmt.run({ pluginId, instanceId });
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,180 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import { randomUUID } from "node:crypto";
5
+ import type { Logger } from "../types/plugin.js";
6
+ import type {
7
+ StarrEvent,
8
+ EventBus,
9
+ EventHandler,
10
+ EmitOpts,
11
+ RequestOpts,
12
+ Unsubscribe,
13
+ } from "../types/plugin.js";
14
+ import { runInTraceContext, getCurrentTraceId } from "./trace.js";
15
+ import { getSpineSchemaVersion, validateSpineEmission } from "./spine.js";
16
+
17
+ export class EventBusImpl {
18
+ private subscriptions = new Map<string, Set<EventHandler>>();
19
+ private logger: Logger;
20
+
21
+ constructor(logger: Logger) {
22
+ this.logger = logger.child("event-bus");
23
+ }
24
+
25
+ // ---- Public factory methods ---- //
26
+
27
+ /** Returns an EventBus bound to the given plugin id. */
28
+ pluginBus(pluginId: string): EventBus {
29
+ return {
30
+ on: <P>(type: string, handler: EventHandler<P>) => this._on(type, handler),
31
+ emit: <P>(type: string, payload: P, opts?: EmitOpts) =>
32
+ this._emit(pluginId, type, payload, opts),
33
+ request: (type: string, payload: unknown, opts: RequestOpts) =>
34
+ this._request(pluginId, type, payload, opts),
35
+ };
36
+ }
37
+
38
+ /** Returns an EventBus with source="core". */
39
+ coreBus(): EventBus {
40
+ return this.pluginBus("core");
41
+ }
42
+
43
+ // ---- Private implementation ---- //
44
+
45
+ private _on<P>(type: string, handler: EventHandler<P>): Unsubscribe {
46
+ if (!this.subscriptions.has(type)) {
47
+ this.subscriptions.set(type, new Set());
48
+ }
49
+ const handlers = this.subscriptions.get(type)!;
50
+ handlers.add(handler as EventHandler);
51
+ return () => {
52
+ handlers.delete(handler as EventHandler);
53
+ if (handlers.size === 0) {
54
+ this.subscriptions.delete(type);
55
+ }
56
+ };
57
+ }
58
+
59
+ private _emit(
60
+ source: string,
61
+ type: string,
62
+ payload: unknown,
63
+ opts?: EmitOpts,
64
+ ): void {
65
+ const event: StarrEvent = {
66
+ id: randomUUID(),
67
+ traceId: getCurrentTraceId(),
68
+ correlationId: opts?.correlationId,
69
+ source,
70
+ type,
71
+ timestamp: new Date().toISOString(),
72
+ schemaVersion: getSpineSchemaVersion(type),
73
+ payload,
74
+ reply: <R>(replyType: string, replyPayload: R) => {
75
+ this._emit(source, replyType, replyPayload, {
76
+ correlationId: event.correlationId,
77
+ });
78
+ },
79
+ };
80
+
81
+ // Immutability (EN2)
82
+ Object.freeze(event);
83
+
84
+ this.logger.debug("emitting event", {
85
+ eventType: type,
86
+ source,
87
+ correlationId: event.correlationId,
88
+ });
89
+
90
+ // Spine validation — log at warn level if suspect (EBQ4, §9 CE2–CE3)
91
+ const violation = validateSpineEmission(source, type);
92
+ if (violation) {
93
+ this.logger.warn("spine emission warning", {
94
+ source,
95
+ eventType: type,
96
+ detail: violation,
97
+ });
98
+ }
99
+
100
+ // Look up handlers
101
+ const handlers = this.subscriptions.get(type);
102
+ if (!handlers || handlers.size === 0) {
103
+ return; // silently dropped (DL5)
104
+ }
105
+
106
+ // Dispatch to each handler — error isolation per handler (DL2)
107
+ const logger = this.logger;
108
+ for (const handler of handlers) {
109
+ try {
110
+ const result = runInTraceContext(event, () => handler(event));
111
+ if (result instanceof Promise) {
112
+ result.catch((err: unknown) => {
113
+ logger.error("event handler rejected", {
114
+ eventType: type,
115
+ handlerSource: source,
116
+ error: String(err),
117
+ });
118
+ });
119
+ }
120
+ } catch (err) {
121
+ logger.error("event handler threw", {
122
+ eventType: type,
123
+ handlerSource: source,
124
+ error: String(err),
125
+ });
126
+ }
127
+ }
128
+ }
129
+
130
+ private _request<Res>(
131
+ source: string,
132
+ type: string,
133
+ payload: unknown,
134
+ opts: RequestOpts,
135
+ ): Promise<StarrEvent<Res>[]> {
136
+ // Validate opts — programming errors, fail fast
137
+ if (!opts.responseType || opts.responseType.trim() === "") {
138
+ throw new Error("request(): responseType is required and must be non-empty");
139
+ }
140
+ if (!Number.isFinite(opts.windowMs) || opts.windowMs <= 0) {
141
+ throw new Error(
142
+ `request(): windowMs must be a positive number, got ${opts.windowMs}`,
143
+ );
144
+ }
145
+ if (opts.max !== undefined && (!Number.isInteger(opts.max) || opts.max < 1)) {
146
+ throw new Error(
147
+ `request(): max must be a positive integer, got ${opts.max}`,
148
+ );
149
+ }
150
+
151
+ const correlationId = randomUUID();
152
+ const replies: StarrEvent<Res>[] = [];
153
+
154
+ return new Promise((resolve) => {
155
+ let settled = false;
156
+ const timer = setTimeout(() => {
157
+ if (settled) return;
158
+ settled = true;
159
+ unsub();
160
+ resolve(replies);
161
+ }, opts.windowMs);
162
+
163
+ const unsub = this._on(opts.responseType, (evt: StarrEvent) => {
164
+ if (settled) return;
165
+ if (evt.correlationId === correlationId) {
166
+ replies.push(evt as StarrEvent<Res>);
167
+ if (opts.max !== undefined && replies.length >= opts.max) {
168
+ settled = true;
169
+ clearTimeout(timer);
170
+ unsub();
171
+ resolve(replies);
172
+ }
173
+ }
174
+ });
175
+
176
+ // Emit AFTER subscribing so synchronous replies are not missed
177
+ this._emit(source, type, payload, { correlationId });
178
+ });
179
+ }
180
+ }
@@ -0,0 +1,87 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ /**
5
+ * Reserved spine namespace prefixes.
6
+ * The dotted unprefixed namespace is reserved for the core spine.
7
+ */
8
+ export const SPINE_NAMESPACES = [
9
+ "content.",
10
+ "release.",
11
+ "download.",
12
+ "import.",
13
+ "library.",
14
+ "discovery.",
15
+ "item.",
16
+ ] as const;
17
+
18
+ export interface SpineEventMeta {
19
+ kind: "request" | "reply" | "notification";
20
+ /** Who is allowed to emit this event. */
21
+ producers:
22
+ | "core"
23
+ | "metadata-plugin"
24
+ | "indexer-plugin"
25
+ | "selection-plugin"
26
+ | "downloader-plugin";
27
+ schemaVersion: number;
28
+ }
29
+
30
+ /**
31
+ * The v1 spine event catalog (spec §8).
32
+ */
33
+ export const SPINE_EVENTS: Record<string, SpineEventMeta> = {
34
+ "discovery.search": { kind: "request", producers: "core", schemaVersion: 1 },
35
+ "discovery.results": { kind: "reply", producers: "metadata-plugin", schemaVersion: 1 },
36
+ "content.requested": { kind: "request", producers: "core", schemaVersion: 2 },
37
+ "release.found": { kind: "reply", producers: "indexer-plugin", schemaVersion: 1 },
38
+ "release.chosen": { kind: "notification", producers: "selection-plugin", schemaVersion: 1 },
39
+ "download.started": { kind: "notification", producers: "downloader-plugin", schemaVersion: 1 },
40
+ "download.completed": { kind: "notification", producers: "downloader-plugin", schemaVersion: 1 },
41
+ "download.failed": { kind: "notification", producers: "downloader-plugin", schemaVersion: 1 },
42
+ "request.failed": { kind: "notification", producers: "selection-plugin", schemaVersion: 1 },
43
+ "item.updated": { kind: "notification", producers: "core", schemaVersion: 1 },
44
+ "request.deleted": { kind: "notification", producers: "core", schemaVersion: 1 },
45
+
46
+ "import.started": { kind: "notification", producers: "core", schemaVersion: 1 },
47
+ "import.completed": { kind: "notification", producers: "core", schemaVersion: 1 },
48
+ "import.failed": { kind: "notification", producers: "core", schemaVersion: 1 },
49
+ "library.file_added": { kind: "notification", producers: "core", schemaVersion: 1 },
50
+ };
51
+
52
+ /**
53
+ * Check whether an event type falls within the reserved spine namespace.
54
+ */
55
+ export function isSpineType(type: string): boolean {
56
+ return SPINE_NAMESPACES.some((ns) => type.startsWith(ns));
57
+ }
58
+
59
+ /**
60
+ * Return the schemaVersion for a spine type, or 1 as default for custom events.
61
+ */
62
+ export function getSpineSchemaVersion(type: string): number {
63
+ return SPINE_EVENTS[type]?.schemaVersion ?? 1;
64
+ }
65
+
66
+ /**
67
+ * Validate whether a source is allowed to emit the given spine event type.
68
+ *
69
+ * Returns an error message string if the emission is suspect, or null if it is fine.
70
+ * Per EBQ4 resolution, this only logs warnings; it does not reject.
71
+ */
72
+ export function validateSpineEmission(source: string, type: string): string | null {
73
+ if (!isSpineType(type)) return null; // custom event, fine
74
+
75
+ const meta = SPINE_EVENTS[type];
76
+ if (!meta) {
77
+ return `Unknown spine event type "${type}" emitted by "${source}"`;
78
+ }
79
+
80
+ // For v1 we are lenient: log but don't block.
81
+ // Only flag if core events are emitted by non-core sources.
82
+ if (meta.producers === "core" && source !== "core") {
83
+ return `Spine event "${type}" is reserved for core, but was emitted by "${source}"`;
84
+ }
85
+
86
+ return null;
87
+ }
@@ -0,0 +1,38 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import { AsyncLocalStorage } from "node:async_hooks";
5
+ import { randomUUID } from "node:crypto";
6
+ import type { StarrEvent } from "../types/plugin.js";
7
+
8
+ /**
9
+ * Singleton AsyncLocalStorage that holds the "current event" during handler dispatch.
10
+ * This is the core trace-propagation mechanism (spec §5 PS2, EBQ5).
11
+ */
12
+ export const traceStore = new AsyncLocalStorage<StarrEvent>();
13
+
14
+ /**
15
+ * Run a function within the trace context of the given event.
16
+ * Any emit() calls inside fn will inherit this event's traceId.
17
+ */
18
+ export function runInTraceContext<R>(event: StarrEvent, fn: () => R): R {
19
+ return traceStore.run(event, fn);
20
+ }
21
+
22
+ /**
23
+ * Return the traceId of the currently handled event, or mint a fresh one
24
+ * if called outside any handler (root emission — spec §5 PS3).
25
+ */
26
+ export function getCurrentTraceId(): string {
27
+ const event = traceStore.getStore();
28
+ return event ? event.traceId : randomUUID();
29
+ }
30
+
31
+ /**
32
+ * Return the correlationId of the currently handled event, or undefined.
33
+ * correlationId is NOT automatically propagated by plain emit() —
34
+ * it is only attached via explicit opts or the reply() sugar (§5 PS2, §6.1).
35
+ */
36
+ export function getCurrentCorrelationId(): string | undefined {
37
+ return traceStore.getStore()?.correlationId;
38
+ }
package/src/host.ts ADDED
@@ -0,0 +1,130 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import type { EventBus, Logger, PluginLifecycle, PluginMeta } from "./types/plugin.js";
5
+ import type { Datastore } from "./datastore/db.js";
6
+ import type { ConfigService } from "./config/config-service.js";
7
+ import type { FastifyInstance } from "fastify";
8
+ import type Database from "better-sqlite3";
9
+ import { EventBusImpl } from "./events/event-bus.js";
10
+ import { ContentTypeRegistryImpl } from "./content-types/registry.js";
11
+ import type { StateStore } from "./state/state-store.js";
12
+ import type { Importer } from "./library/importer.js";
13
+ import type { LibraryCache } from "./library/library-cache.js";
14
+ import type { LibraryService } from "./library/library-service.js";
15
+ import type { ScanController } from "./library/scan-controller.js";
16
+
17
+ export interface StarrOptions {
18
+ pluginsDir: string;
19
+ apiVersion: string;
20
+ logger: Logger;
21
+ }
22
+
23
+ export interface PluginRuntime {
24
+ meta: PluginMeta;
25
+ lifecycle: PluginLifecycle;
26
+ startOrder: number;
27
+ }
28
+
29
+ export class Starr {
30
+ readonly options: StarrOptions;
31
+ readonly logger: Logger;
32
+ readonly apiVersion: string;
33
+
34
+ // Service slots to be wired by later tasks.
35
+ datastore: Datastore | null = null;
36
+ private _eventBus: EventBusImpl;
37
+
38
+ /**
39
+ * Public EventBus interface (exposed for wiring context).
40
+ */
41
+ get eventBus(): EventBus {
42
+ return this._eventBus.coreBus();
43
+ }
44
+
45
+ /**
46
+ * Internal accessor for EventBusImpl (used by context to create plugin-bound buses).
47
+ */
48
+ get busImpl(): EventBusImpl {
49
+ return this._eventBus;
50
+ }
51
+
52
+ /**
53
+ * Core event bus with source="core". Convenience for emitting spine events from the host.
54
+ */
55
+ get coreBus(): EventBus {
56
+ return this._eventBus.coreBus();
57
+ }
58
+ contentTypeRegistry: ContentTypeRegistryImpl = new ContentTypeRegistryImpl();
59
+ configService: ConfigService | null = null;
60
+ stateStore: StateStore | null = null;
61
+ importer: Importer | null = null;
62
+ libraryCache: LibraryCache | null = null;
63
+ libraryService: LibraryService | null = null;
64
+ scanController: ScanController | null = null;
65
+ webServer: FastifyInstance | null = null;
66
+
67
+ private pluginRuntimes: PluginRuntime[] = [];
68
+ private running = false;
69
+
70
+ getScanController(): ScanController {
71
+ if (!this.scanController) throw new Error("scan controller not initialized");
72
+ return this.scanController;
73
+ }
74
+
75
+ getLibraryCache(): LibraryCache {
76
+ if (!this.libraryCache) throw new Error("library cache not initialized");
77
+ return this.libraryCache;
78
+ }
79
+
80
+ getLibraryService(): LibraryService {
81
+ if (!this.libraryService) throw new Error("library service not initialized");
82
+ return this.libraryService;
83
+ }
84
+
85
+ getImporter(): Importer {
86
+ if (!this.importer) throw new Error("importer not initialized");
87
+ return this.importer;
88
+ }
89
+
90
+ /** Convenience: get the state store or throw if not initialized. */
91
+ getStateStore(): StateStore {
92
+ if (!this.stateStore) {
93
+ throw new Error("state store not initialized");
94
+ }
95
+ return this.stateStore;
96
+ }
97
+
98
+ getDatabase(): Database.Database | null {
99
+ return this.datastore?.db ?? null;
100
+ }
101
+
102
+ constructor(options: StarrOptions) {
103
+ this.options = options;
104
+ this.logger = options.logger.child("host");
105
+ this.apiVersion = options.apiVersion;
106
+ this._eventBus = new EventBusImpl(this.logger);
107
+ }
108
+
109
+ markRunning(): void {
110
+ this.running = true;
111
+ this.logger.info("running");
112
+ }
113
+
114
+ markStopped(): void {
115
+ this.running = false;
116
+ this.logger.info("stopped");
117
+ }
118
+
119
+ get isRunning(): boolean {
120
+ return this.running;
121
+ }
122
+
123
+ setRuntimes(runtimes: PluginRuntime[]): void {
124
+ this.pluginRuntimes = runtimes;
125
+ }
126
+
127
+ getRuntimes(): readonly PluginRuntime[] {
128
+ return this.pluginRuntimes;
129
+ }
130
+ }
@@ -0,0 +1,174 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ import type Database from "better-sqlite3";
7
+ import type { EventBus, Logger } from "../types/plugin.js";
8
+ import type { ContentItemBase, NamingRule } from "@digitalpaws/starr-types-core";
9
+ import { resolveNaming } from "./naming.js";
10
+ import type { LibraryConfig } from "./library-config.js";
11
+ import type { LibraryService } from "./library-service.js";
12
+
13
+ export interface ImportResult {
14
+ success: boolean;
15
+ libraryPath?: string;
16
+ error?: string;
17
+ }
18
+
19
+ export class Importer {
20
+ private db: Database.Database;
21
+ private bus: EventBus;
22
+ private logger: Logger;
23
+ private getConfig: () => LibraryConfig;
24
+ private getNamingRule: (contentTypeId: string) => NamingRule;
25
+ private libraryService?: LibraryService;
26
+
27
+ private stmtUpsertPath: Database.Statement;
28
+ private stmtUpdateRequestPath: Database.Statement;
29
+
30
+ constructor(
31
+ db: Database.Database,
32
+ bus: EventBus,
33
+ logger: Logger,
34
+ getConfig: () => LibraryConfig,
35
+ getNamingRule: (contentTypeId: string) => NamingRule,
36
+ libraryService?: LibraryService,
37
+ ) {
38
+ this.db = db;
39
+ this.bus = bus;
40
+ this.logger = logger.child("importer");
41
+ this.getConfig = getConfig;
42
+ this.getNamingRule = getNamingRule;
43
+ this.libraryService = libraryService;
44
+
45
+ this.stmtUpsertPath = db.prepare(`
46
+ INSERT INTO library_paths (request_id, library_path, library_root, imported_at, updated_at)
47
+ VALUES (@requestId, @libraryPath, @libraryRoot, datetime('now'), datetime('now'))
48
+ ON CONFLICT(request_id) DO UPDATE SET
49
+ library_path = excluded.library_path,
50
+ updated_at = datetime('now')
51
+ `);
52
+
53
+ this.stmtUpdateRequestPath = db.prepare(`
54
+ UPDATE tracked_requests
55
+ SET library_path = @libraryPath, updated_at = datetime('now')
56
+ WHERE request_id = @requestId
57
+ `);
58
+ }
59
+
60
+ async import(
61
+ requestId: string,
62
+ sourcePath: string,
63
+ item: ContentItemBase,
64
+ contentTypeId: string,
65
+ extension: string,
66
+ /** The pre-computed content identity for this item. */
67
+ contentIdentity: string,
68
+ /** Optional library ID for multi-library support. */
69
+ libraryId?: string,
70
+ ): Promise<ImportResult> {
71
+ try {
72
+ this.bus.emit("import.started", { requestId });
73
+
74
+ // Determine library config
75
+ let libraryRoot: string;
76
+ let linkType: string;
77
+ let importSubtitles: boolean;
78
+ let subtitleExtensions: string[];
79
+
80
+ if (libraryId && this.libraryService) {
81
+ const lib = this.libraryService.get(libraryId);
82
+ if (lib) {
83
+ libraryRoot = lib.roots[contentTypeId] ?? lib.defaultLibraryRoot;
84
+ linkType = lib.linkType;
85
+ importSubtitles = lib.importSubtitles;
86
+ subtitleExtensions = lib.subtitleExtensions;
87
+ } else {
88
+ // Fallback to global config
89
+ const cfg = this.getConfig();
90
+ libraryRoot = cfg.roots[contentTypeId] ?? cfg.defaultLibraryRoot;
91
+ linkType = cfg.linkType;
92
+ importSubtitles = cfg.importSubtitles;
93
+ subtitleExtensions = cfg.subtitleExtensions;
94
+ }
95
+ } else {
96
+ // Fallback to global config
97
+ const cfg = this.getConfig();
98
+ libraryRoot = cfg.roots[contentTypeId] ?? cfg.defaultLibraryRoot;
99
+ linkType = cfg.linkType;
100
+ importSubtitles = cfg.importSubtitles;
101
+ subtitleExtensions = cfg.subtitleExtensions;
102
+ }
103
+
104
+ const namingRule = this.getNamingRule(contentTypeId);
105
+ const relativePath = resolveNaming(namingRule, item, extension);
106
+ const destPath = path.join(libraryRoot, relativePath);
107
+
108
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
109
+ await this.transferFile(sourcePath, destPath, linkType);
110
+
111
+ if (importSubtitles) {
112
+ await this.importSubtitles(sourcePath, destPath, subtitleExtensions);
113
+ }
114
+
115
+ this.stmtUpsertPath.run({
116
+ requestId,
117
+ libraryPath: destPath,
118
+ libraryRoot,
119
+ });
120
+ this.stmtUpdateRequestPath.run({ requestId, libraryPath: destPath });
121
+
122
+ this.bus.emit("import.completed", { requestId, libraryPath: destPath });
123
+ this.bus.emit("library.file_added", {
124
+ requestId,
125
+ libraryPath: destPath,
126
+ contentTypeId,
127
+ contentIdentity,
128
+ });
129
+
130
+ return { success: true, libraryPath: destPath };
131
+ } catch (err) {
132
+ const error = String(err);
133
+ this.bus.emit("import.failed", { requestId, error });
134
+ return { success: false, error };
135
+ }
136
+ }
137
+
138
+ private async transferFile(src: string, dest: string, linkType: string): Promise<void> {
139
+ switch (linkType) {
140
+ case "move": await fs.rename(src, dest); break;
141
+ case "hardlink": await fs.link(src, dest); break;
142
+ case "symlink": await fs.symlink(src, dest); break;
143
+ case "copy": await fs.copyFile(src, dest); break;
144
+ default: throw new Error(`Unknown link type: ${linkType}`);
145
+ }
146
+ }
147
+
148
+ private async importSubtitles(
149
+ src: string, dest: string, subtitleExtensions: string[],
150
+ ): Promise<void> {
151
+ const srcDir = path.dirname(src);
152
+ const baseName = path.basename(src, path.extname(src));
153
+ const destDir = path.dirname(dest);
154
+ const destBaseName = path.basename(dest, path.extname(dest));
155
+
156
+ const entries = await fs.readdir(srcDir, { withFileTypes: true });
157
+ for (const entry of entries) {
158
+ if (!entry.isFile()) continue;
159
+ const ext = path.extname(entry.name).slice(1).toLowerCase();
160
+ if (!subtitleExtensions.includes(ext)) continue;
161
+ const entryBaseName = path.basename(entry.name, path.extname(entry.name));
162
+ if (!entryBaseName.startsWith(baseName)) continue;
163
+
164
+ const srcSub = path.join(srcDir, entry.name);
165
+ const destSubName = entry.name.replace(baseName, destBaseName);
166
+ const destSub = path.join(destDir, destSubName);
167
+ try {
168
+ await this.transferFile(srcSub, destSub, "copy");
169
+ } catch (err) {
170
+ this.logger.warn("failed to import subtitle", { src: srcSub, error: String(err) });
171
+ }
172
+ }
173
+ }
174
+ }