@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,212 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import type Database from "better-sqlite3";
5
+ import type { EventBus, Logger, StarrEvent } from "../types/plugin.js";
6
+
7
+ /**
8
+ * A single row in the library_cache table, exposed to the API.
9
+ */
10
+ export interface CachedLibraryItem {
11
+ requestId: string;
12
+ contentTypeId: string;
13
+ title: string;
14
+ sortTitle?: string;
15
+ summary?: string;
16
+ releaseDate?: string;
17
+ images: any[];
18
+ externalIds: Record<string, string>;
19
+ fields?: Record<string, unknown>;
20
+ libraryPath: string;
21
+ fileSize?: number;
22
+ importedAt: string;
23
+ }
24
+
25
+ export class LibraryCache {
26
+ private db: Database.Database;
27
+ private bus: EventBus;
28
+ private logger: Logger;
29
+ private stmtInsert: Database.Statement;
30
+ private stmtDelete: Database.Statement;
31
+ private stmtGetAll: Database.Statement;
32
+ private stmtGetByType: Database.Statement;
33
+ private stmtCount: Database.Statement;
34
+
35
+ constructor(db: Database.Database, bus: EventBus, logger: Logger) {
36
+ this.db = db;
37
+ this.bus = bus;
38
+ this.logger = logger.child("library-cache");
39
+
40
+ this.stmtInsert = db.prepare(`
41
+ INSERT INTO library_cache
42
+ (request_id, content_type_id, content_identity, title, sort_title,
43
+ summary, release_date, images, external_ids, fields,
44
+ library_path, file_size, imported_at, updated_at)
45
+ VALUES
46
+ (@requestId, @contentTypeId, @contentIdentity, @title, @sortTitle,
47
+ @summary, @releaseDate, @images, @externalIds, @fields,
48
+ @libraryPath, @fileSize, @importedAt, datetime('now'))
49
+ ON CONFLICT(request_id) DO UPDATE SET
50
+ title = excluded.title,
51
+ library_path = excluded.library_path,
52
+ file_size = excluded.file_size,
53
+ updated_at = datetime('now')
54
+ `);
55
+
56
+ this.stmtDelete = db.prepare(
57
+ "DELETE FROM library_cache WHERE request_id = @requestId",
58
+ );
59
+
60
+ this.stmtGetAll = db.prepare(
61
+ "SELECT * FROM library_cache ORDER BY imported_at DESC",
62
+ );
63
+
64
+ this.stmtGetByType = db.prepare(
65
+ "SELECT * FROM library_cache WHERE content_type_id = @type ORDER BY imported_at DESC",
66
+ );
67
+
68
+ this.stmtCount = db.prepare(
69
+ "SELECT COUNT(*) AS count FROM library_cache",
70
+ );
71
+
72
+ // Subscribe to events
73
+ bus.on("library.file_added", this.onFileAdded.bind(this));
74
+ bus.on("request.deleted", this.onRequestDeleted.bind(this));
75
+ }
76
+
77
+ /**
78
+ * Populate the cache from tracked_requests on startup.
79
+ */
80
+ refreshFromDb(): void {
81
+ const rows = this.db
82
+ .prepare(
83
+ `SELECT tr.request_id, tr.content_type_id, tr.content_identity,
84
+ tr.item, tr.updated_at,
85
+ lp.library_path, lp.file_size, lp.imported_at
86
+ FROM tracked_requests tr
87
+ JOIN library_paths lp ON lp.request_id = tr.request_id
88
+ WHERE tr.status = 'imported' OR tr.status = 'downloaded'`,
89
+ )
90
+ .all() as any[];
91
+
92
+ const txn = this.db.transaction(() => {
93
+ this.db.exec("DELETE FROM library_cache");
94
+ for (const row of rows) {
95
+ const item = JSON.parse(row.item);
96
+ this.stmtInsert.run({
97
+ requestId: row.request_id,
98
+ contentTypeId: row.content_type_id,
99
+ contentIdentity: row.content_identity,
100
+ title: item.title ?? "",
101
+ sortTitle: item.sortTitle ?? null,
102
+ summary: item.summary ?? null,
103
+ releaseDate: item.releaseDate ?? null,
104
+ images: item.images ? JSON.stringify(item.images) : "[]",
105
+ externalIds: JSON.stringify(item.externalIds ?? {}),
106
+ fields: item.fields ? JSON.stringify(item.fields) : null,
107
+ libraryPath: row.library_path,
108
+ fileSize: row.file_size ?? null,
109
+ importedAt: row.imported_at,
110
+ });
111
+ }
112
+ });
113
+ txn();
114
+
115
+ const count = (this.stmtCount.get() as { count: number }).count;
116
+ this.logger.info("library cache refreshed", { count });
117
+ }
118
+
119
+ /**
120
+ * Return all cached library items, optionally filtered by content type.
121
+ */
122
+ getItems(contentTypeId?: string): CachedLibraryItem[] {
123
+ const rows = contentTypeId
124
+ ? (this.stmtGetByType.all({ type: contentTypeId }) as any[])
125
+ : (this.stmtGetAll.all() as any[]);
126
+
127
+ return rows.map(this.rowToItem);
128
+ }
129
+
130
+ /**
131
+ * Return total count of cached items.
132
+ */
133
+ count(): number {
134
+ return (this.stmtCount.get() as { count: number }).count;
135
+ }
136
+
137
+ // ── Event handlers ──
138
+
139
+ private onFileAdded(event: StarrEvent): void {
140
+ const payload = event.payload as {
141
+ requestId: string;
142
+ libraryPath: string;
143
+ contentTypeId: string;
144
+ contentIdentity: string;
145
+ };
146
+
147
+ // Fetch the full item from tracked_requests to cache it
148
+ const row = this.db
149
+ .prepare(
150
+ `SELECT tr.request_id, tr.content_type_id, tr.content_identity,
151
+ tr.item, tr.updated_at,
152
+ lp.library_path, lp.file_size, lp.imported_at
153
+ FROM tracked_requests tr
154
+ JOIN library_paths lp ON lp.request_id = tr.request_id
155
+ WHERE tr.request_id = @requestId`,
156
+ )
157
+ .get({ requestId: payload.requestId }) as any;
158
+
159
+ if (!row) {
160
+ this.logger.warn("library.file_added for unknown request", {
161
+ requestId: payload.requestId,
162
+ });
163
+ return;
164
+ }
165
+
166
+ const item = JSON.parse(row.item);
167
+ this.stmtInsert.run({
168
+ requestId: row.request_id,
169
+ contentTypeId: row.content_type_id,
170
+ contentIdentity: row.content_identity,
171
+ title: item.title ?? "",
172
+ sortTitle: item.sortTitle ?? null,
173
+ summary: item.summary ?? null,
174
+ releaseDate: item.releaseDate ?? null,
175
+ images: item.images ? JSON.stringify(item.images) : "[]",
176
+ externalIds: JSON.stringify(item.externalIds ?? {}),
177
+ fields: item.fields ? JSON.stringify(item.fields) : null,
178
+ libraryPath: row.library_path,
179
+ fileSize: row.file_size ?? null,
180
+ importedAt: row.imported_at,
181
+ });
182
+
183
+ this.logger.debug("library cache updated", {
184
+ requestId: payload.requestId,
185
+ title: item.title,
186
+ });
187
+ }
188
+
189
+ private onRequestDeleted(event: StarrEvent): void {
190
+ const payload = event.payload as { requestId: string };
191
+ this.stmtDelete.run({ requestId: payload.requestId });
192
+ }
193
+
194
+ // ── Helpers ──
195
+
196
+ private rowToItem(row: any): CachedLibraryItem {
197
+ return {
198
+ requestId: row.request_id,
199
+ contentTypeId: row.content_type_id,
200
+ title: row.title,
201
+ sortTitle: row.sort_title ?? undefined,
202
+ summary: row.summary ?? undefined,
203
+ releaseDate: row.release_date ?? undefined,
204
+ images: JSON.parse(row.images ?? "[]"),
205
+ externalIds: JSON.parse(row.external_ids ?? "{}"),
206
+ fields: row.fields ? JSON.parse(row.fields) : undefined,
207
+ libraryPath: row.library_path,
208
+ fileSize: row.file_size ?? undefined,
209
+ importedAt: row.imported_at,
210
+ };
211
+ }
212
+ }
@@ -0,0 +1,22 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ export interface LibraryRoots {
5
+ [contentTypeId: string]: string;
6
+ }
7
+
8
+ export interface LibraryConfig {
9
+ defaultLibraryRoot: string;
10
+ roots: LibraryRoots;
11
+ linkType: "move" | "hardlink" | "symlink" | "copy";
12
+ importSubtitles: boolean;
13
+ subtitleExtensions: string[];
14
+ }
15
+
16
+ export const DEFAULT_LIBRARY_CONFIG: LibraryConfig = {
17
+ defaultLibraryRoot: "/media/library",
18
+ roots: {},
19
+ linkType: "hardlink",
20
+ importSubtitles: true,
21
+ subtitleExtensions: ["srt", "ass", "ssa", "sub", "vtt"],
22
+ };
@@ -0,0 +1,404 @@
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 { Logger } from "../types/plugin.js";
8
+ import type { LibraryService, Library } from "./library-service.js";
9
+
10
+ const MEDIA_EXTENSIONS = new Set([
11
+ // Video
12
+ "mkv", "mp4", "avi", "mov", "wmv", "flv", "webm",
13
+ "m4v", "mpg", "mpeg", "ts", "m2ts",
14
+ // Audio / Audiobooks
15
+ "mp3", "m4a", "m4b", "aac", "flac", "ogg", "wav",
16
+ // eBooks
17
+ "epub", "mobi", "azw", "azw3", "pdf", "cbr", "cbz",
18
+ "djvu",
19
+ ]);
20
+
21
+ const SUBTITLE_EXTENSIONS = new Set([
22
+ "srt", "ass", "ssa", "sub", "vtt",
23
+ ]);
24
+
25
+ /** Directories to skip when scanning. */
26
+ const SKIP_DIRS = new Set([
27
+ ".", "..", "Extras", "Sample", "samples", "Behind the Scenes",
28
+ "Featurettes", "Trailers",
29
+ ]);
30
+
31
+ export interface DiscoveredGroup {
32
+ /** Absolute path to the subdirectory. */
33
+ directory: string;
34
+ /** Folder name (last path component). */
35
+ folderName: string;
36
+ /** All media files within this directory. */
37
+ files: string[];
38
+ /** All subtitle files within this directory. */
39
+ subtitleFiles: string[];
40
+ /** Content type id inferred from which library root this falls under. */
41
+ contentTypeId: string;
42
+ /** The library this group belongs to, if known (multi-library support). */
43
+ libraryId?: string;
44
+ }
45
+
46
+ export interface ScanOptions {
47
+ /** Only scan this specific library. If unset, scan all libraries. */
48
+ libraryId?: string;
49
+ /** Only scan these content type ids. If empty, scan all configured roots. */
50
+ contentTypeIds?: string[];
51
+ /** Include items already in the library (for re-scan). */
52
+ includeExisting?: boolean;
53
+ }
54
+
55
+ function isMediaFile(filePath: string): boolean {
56
+ const ext = path.extname(filePath).slice(1).toLowerCase();
57
+ return MEDIA_EXTENSIONS.has(ext);
58
+ }
59
+
60
+ function isSubtitleFile(filePath: string): boolean {
61
+ const ext = path.extname(filePath).slice(1).toLowerCase();
62
+ return SUBTITLE_EXTENSIONS.has(ext);
63
+ }
64
+
65
+ export class LibraryScanner {
66
+ private db: Database.Database;
67
+ private logger: Logger;
68
+ private libraryService: LibraryService;
69
+ private stmtCheckPath: Database.Statement;
70
+
71
+ constructor(
72
+ db: Database.Database,
73
+ logger: Logger,
74
+ libraryService: LibraryService,
75
+ ) {
76
+ this.db = db;
77
+ this.logger = logger.child("library-scanner");
78
+ this.libraryService = libraryService;
79
+
80
+ this.stmtCheckPath = db.prepare(
81
+ "SELECT 1 FROM library_paths WHERE library_path = @path LIMIT 1",
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Scan all configured library roots and discover media groups.
87
+ * Returns grouped results without metadata enrichment.
88
+ * Supports multi-library: scans roots from ALL libraries, or a specific
89
+ * library if options.libraryId is set, or roots matching specific content
90
+ * types if options.contentTypeIds is set.
91
+ */
92
+ async discoverGroups(options?: ScanOptions): Promise<DiscoveredGroup[]> {
93
+ const allGroups: DiscoveredGroup[] = [];
94
+
95
+ // Determine which libraries to scan
96
+ const librariesToScan: Library[] = this.resolveLibrariesToScan(options);
97
+
98
+ // Collect roots to scan: (contentTypeId, rootPath, libraryId)
99
+ const rootsToScan: Array<{ contentTypeId: string; rootPath: string; libraryId: string }> = [];
100
+
101
+ for (const lib of librariesToScan) {
102
+ if (options?.contentTypeIds && options.contentTypeIds.length > 0) {
103
+ // Scan only specified content types within this library
104
+ for (const ctId of options.contentTypeIds) {
105
+ const rootPath = lib.roots[ctId];
106
+ if (rootPath) {
107
+ rootsToScan.push({ contentTypeId: ctId, rootPath, libraryId: lib.id });
108
+ } else {
109
+ this.logger.warn("content type has no root in library", {
110
+ contentTypeId: ctId,
111
+ library: lib.name,
112
+ });
113
+ }
114
+ }
115
+ // Do NOT add default root; only scan explicitly requested roots
116
+ } else {
117
+ // Scan all content-type-specific roots for this library
118
+ for (const [ctId, rootPath] of Object.entries(lib.roots)) {
119
+ rootsToScan.push({ contentTypeId: ctId, rootPath, libraryId: lib.id });
120
+ }
121
+ // Also scan this library's default root
122
+ if (lib.defaultLibraryRoot) {
123
+ rootsToScan.push({
124
+ contentTypeId: "unknown",
125
+ rootPath: lib.defaultLibraryRoot,
126
+ libraryId: lib.id,
127
+ });
128
+ }
129
+ }
130
+ }
131
+
132
+ // De-duplicate root paths (avoid scanning the same dir twice)
133
+ const seenRoots = new Set<string>();
134
+ for (const { contentTypeId, rootPath, libraryId } of rootsToScan) {
135
+ if (seenRoots.has(rootPath)) continue;
136
+ seenRoots.add(rootPath);
137
+
138
+ const lib = this.libraryService.get(libraryId);
139
+ if (!lib) {
140
+ this.logger.warn("library not found for root, skipping", { libraryId, rootPath });
141
+ continue;
142
+ }
143
+
144
+ const normalizedRoot = path.resolve(rootPath);
145
+ this.logger.info("scanning library root", {
146
+ library: lib.name,
147
+ contentTypeId,
148
+ rootPath,
149
+ normalizedRoot,
150
+ });
151
+ let entries: string[];
152
+ try {
153
+ entries = await fs.readdir(normalizedRoot, { withFileTypes: false });
154
+ } catch (err: any) {
155
+ this.logger.warn("cannot read library root directory", {
156
+ library: lib.name,
157
+ root: normalizedRoot,
158
+ error: err.message,
159
+ });
160
+ continue;
161
+ }
162
+
163
+ for (const entry of entries) {
164
+ // Skip hidden files/dirs
165
+ if (entry.startsWith(".")) continue;
166
+ const fullPath = path.join(normalizedRoot, entry);
167
+
168
+ let stat;
169
+ try {
170
+ stat = await fs.stat(fullPath);
171
+ } catch {
172
+ this.logger.debug("cannot stat entry (permission or deleted during scan)", {
173
+ entry,
174
+ reason: "stat failed",
175
+ });
176
+ continue;
177
+ }
178
+
179
+ if (stat.isDirectory()) {
180
+ if (SKIP_DIRS.has(entry)) {
181
+ this.logger.debug("skipping excluded directory", { entry });
182
+ continue;
183
+ }
184
+
185
+ await this.processSubdirectory(fullPath, entry, contentTypeId, libraryId, lib, allGroups);
186
+ } else if (stat.isFile()) {
187
+ // Files directly in the library root → treat as single-item group
188
+ if (isMediaFile(fullPath)) {
189
+ allGroups.push({
190
+ directory: path.dirname(fullPath),
191
+ folderName: entry,
192
+ files: [fullPath],
193
+ subtitleFiles: [],
194
+ contentTypeId: this.resolveContentType(fullPath, contentTypeId, lib),
195
+ libraryId,
196
+ });
197
+ this.logger.debug("discovered media file at root", {
198
+ library: lib.name,
199
+ file: entry,
200
+ contentTypeId: this.resolveContentType(fullPath, contentTypeId, lib),
201
+ });
202
+ } else {
203
+ this.logger.debug("skipping non-media file at root", { entry });
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ return allGroups;
210
+ }
211
+
212
+ /**
213
+ * Resolve which libraries to scan based on options.
214
+ */
215
+ private resolveLibrariesToScan(options?: ScanOptions): Library[] {
216
+ if (options?.libraryId) {
217
+ const lib = this.libraryService.get(options.libraryId);
218
+ return lib ? [lib] : [];
219
+ }
220
+
221
+ if (options?.contentTypeIds && options.contentTypeIds.length > 0) {
222
+ // Find libraries that have roots for any of the requested types
223
+ const allLibs = this.libraryService.list();
224
+ const matchingLibs: Library[] = [];
225
+ for (const lib of allLibs) {
226
+ for (const ctId of options.contentTypeIds) {
227
+ if (lib.roots[ctId]) {
228
+ matchingLibs.push(lib);
229
+ break;
230
+ }
231
+ }
232
+ }
233
+ // If no library has a matching root, fall back to all libraries so the
234
+ // default roots are scanned (the contentTypeIds filter is handled above).
235
+ return matchingLibs.length > 0 ? matchingLibs : allLibs;
236
+ }
237
+
238
+ // No filter — scan all libraries
239
+ return this.libraryService.list();
240
+ }
241
+
242
+ /**
243
+ * Scan a subdirectory for media and subtitle files.
244
+ */
245
+ private async processSubdirectory(
246
+ dirPath: string,
247
+ dirName: string,
248
+ rootContentTypeId: string,
249
+ libraryId: string,
250
+ library: Library,
251
+ allGroups: DiscoveredGroup[],
252
+ ): Promise<void> {
253
+ this.logger.debug("scanning subdirectory", { entry: dirName, fullPath: dirPath });
254
+
255
+ // Files directly in this directory (e.g. file.mkv inside item dir)
256
+ const directFiles: string[] = [];
257
+ const directSubs: string[] = [];
258
+
259
+ try {
260
+ const subEntries = await fs.readdir(dirPath, { withFileTypes: true });
261
+ for (const sub of subEntries) {
262
+ if (sub.isDirectory()) {
263
+ if (sub.name.startsWith(".")) continue;
264
+ if (SKIP_DIRS.has(sub.name)) continue;
265
+
266
+ // This subdirectory IS a content item (e.g. Movies/Dune (2021)/Dune.mkv)
267
+ // Create a separate group for it rather than merging into parent
268
+ const nestedPath = path.join(dirPath, sub.name);
269
+ await this.processItemDirectory(nestedPath, sub.name, rootContentTypeId, libraryId, library, allGroups);
270
+ continue;
271
+ }
272
+ const subFullPath = path.join(dirPath, sub.name);
273
+ if (isMediaFile(subFullPath)) {
274
+ directFiles.push(subFullPath);
275
+ } else if (isSubtitleFile(subFullPath)) {
276
+ directSubs.push(subFullPath);
277
+ }
278
+ }
279
+ } catch {
280
+ return;
281
+ }
282
+
283
+ // Files directly in this dir → treat this dir as the content item
284
+ if (directFiles.length > 0) {
285
+ const resolvedCT = this.resolveContentType(dirPath, rootContentTypeId, library);
286
+ allGroups.push({
287
+ directory: dirPath,
288
+ folderName: dirName,
289
+ files: directFiles,
290
+ subtitleFiles: directSubs,
291
+ contentTypeId: resolvedCT,
292
+ libraryId,
293
+ });
294
+ this.logger.debug("discovered media group in directory", {
295
+ folderName: dirName,
296
+ files: directFiles.length,
297
+ });
298
+ }
299
+
300
+ if (directFiles.length === 0 && !allGroups.some((g) => g.directory.startsWith(dirPath))) {
301
+ this.logger.debug("skipping directory (no media files found)", { entry: dirName, fullPath: dirPath });
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Scan a directory that is expected to be a single content item.
307
+ * Supports nested structures like "Author/Title/file.epub" up to 2 levels deep.
308
+ */
309
+ private async processItemDirectory(
310
+ dirPath: string,
311
+ dirName: string,
312
+ rootContentTypeId: string,
313
+ libraryId: string,
314
+ library: Library,
315
+ allGroups: DiscoveredGroup[],
316
+ ): Promise<void> {
317
+ const files: string[] = [];
318
+ const subtitleFiles: string[] = [];
319
+
320
+ try {
321
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
322
+ for (const entry of entries) {
323
+ if (entry.name.startsWith(".")) continue;
324
+ const fullPath = path.join(dirPath, entry.name);
325
+
326
+ if (entry.isDirectory()) {
327
+ if (SKIP_DIRS.has(entry.name)) continue;
328
+ // One more level for deeply nested structures
329
+ try {
330
+ const deeper = await fs.readdir(fullPath, { withFileTypes: true });
331
+ for (const d of deeper) {
332
+ const dFull = path.join(fullPath, d.name);
333
+ if (d.isFile()) {
334
+ if (isMediaFile(dFull)) files.push(dFull);
335
+ else if (isSubtitleFile(dFull)) subtitleFiles.push(dFull);
336
+ }
337
+ }
338
+ } catch {
339
+ // Ignore — subdirectory may be inaccessible
340
+ }
341
+ continue;
342
+ }
343
+
344
+ if (isMediaFile(fullPath)) files.push(fullPath);
345
+ else if (isSubtitleFile(fullPath)) subtitleFiles.push(fullPath);
346
+ }
347
+ } catch {
348
+ return;
349
+ }
350
+
351
+ if (files.length > 0) {
352
+ const resolvedCT = this.resolveContentType(dirPath, rootContentTypeId, library);
353
+ allGroups.push({
354
+ directory: dirPath,
355
+ folderName: dirName,
356
+ files,
357
+ subtitleFiles,
358
+ contentTypeId: resolvedCT,
359
+ libraryId,
360
+ });
361
+ this.logger.debug("discovered media group in item directory", {
362
+ folderName: dirName,
363
+ files: files.length,
364
+ });
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Determine the content type for a given path.
370
+ */
371
+ private resolveContentType(
372
+ itemPath: string,
373
+ rootContentTypeId: string,
374
+ library: Library,
375
+ ): string {
376
+ if (rootContentTypeId !== "unknown") return rootContentTypeId;
377
+
378
+ for (const [ctId, root] of Object.entries(library.roots)) {
379
+ const normalizedRootPath = path.resolve(root);
380
+ if (itemPath.startsWith(normalizedRootPath)) {
381
+ return ctId;
382
+ }
383
+ }
384
+
385
+ return "unknown";
386
+ }
387
+
388
+ /**
389
+ * Check which directories are already registered in library_paths.
390
+ */
391
+ findExisting(directories: string[]): Set<string> {
392
+ const existing = new Set<string>();
393
+ for (const dir of directories) {
394
+ // Check if any library_path starts with this directory
395
+ const row = this.db
396
+ .prepare("SELECT library_path FROM library_paths WHERE library_path LIKE @pattern LIMIT 1")
397
+ .get({ pattern: `${dir}%` }) as { library_path: string } | undefined;
398
+ if (row) {
399
+ existing.add(dir);
400
+ }
401
+ }
402
+ return existing;
403
+ }
404
+ }