@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.
- package/README.rst +75 -0
- package/data/.nfs.811bbb0d.43c34.4 +0 -0
- package/data/.nfs.81ba4cd0.ffff87804.4 +0 -0
- package/data/.nfs.82d127f7.fffff5704.4 +0 -0
- package/data/.nfs.82d127fe.fffff5704.4 +0 -0
- package/data/.nfs.82d159a5.fffff5aa4.4 +0 -0
- package/data/.nfs.82d159aa.fffff5aa4.4 +0 -0
- package/data/.nfs.82d15a9c.fffff5bb4.4 +0 -0
- package/data/.nfs.82d15aa1.fffff5bb4.4 +0 -0
- package/data/.nfs.82d15c65.fffff5c24.4 +0 -0
- package/data/.nfs.82d1852c.fffff5e24.4 +0 -0
- package/data/.nfs.82d18536.fffff5e24.4 +0 -0
- package/data/.nfs.82d18dbe.fffff5e44.4 +0 -0
- package/data/.nfs.82d18dc6.fffff5e44.4 +0 -0
- package/data/.nfs.8309a3f5.fffff82f4.4 +0 -0
- package/data/.nfs.8309a3fe.fffff82f4.4 +0 -0
- package/data/.nfs.8314122e.fffffb044.4 +0 -0
- package/data/.nfs.83141236.fffffb044.4 +0 -0
- package/data/.nfs.833c4fe9.fffffbc34.4 +0 -0
- package/data/.nfs.833c4ff3.fffffbc34.4 +0 -0
- package/data/logs/starr.log +66244 -0
- package/data/starr.db +0 -0
- package/data/starr.db-shm +0 -0
- package/data/starr.db-wal +0 -0
- package/data/starr.jwt +1 -0
- package/data/starr.key +1 -0
- package/dist/auth/auth-service.d.ts +42 -0
- package/dist/auth/auth-service.d.ts.map +1 -0
- package/dist/auth/auth-service.js +226 -0
- package/dist/auth/auth-service.js.map +1 -0
- package/dist/auth/http-server.d.ts +16 -0
- package/dist/auth/http-server.d.ts.map +1 -0
- package/dist/auth/http-server.js +207 -0
- package/dist/auth/http-server.js.map +1 -0
- package/dist/auth/jwt-secret.d.ts +6 -0
- package/dist/auth/jwt-secret.d.ts.map +1 -0
- package/dist/auth/jwt-secret.js +51 -0
- package/dist/auth/jwt-secret.js.map +1 -0
- package/dist/auth/oidc/oidc-flow.d.ts +60 -0
- package/dist/auth/oidc/oidc-flow.d.ts.map +1 -0
- package/dist/auth/oidc/oidc-flow.js +201 -0
- package/dist/auth/oidc/oidc-flow.js.map +1 -0
- package/dist/auth/oidc/oidc-provider-service.d.ts +59 -0
- package/dist/auth/oidc/oidc-provider-service.d.ts.map +1 -0
- package/dist/auth/oidc/oidc-provider-service.js +124 -0
- package/dist/auth/oidc/oidc-provider-service.js.map +1 -0
- package/dist/auth/oidc/user-service.d.ts +67 -0
- package/dist/auth/oidc/user-service.d.ts.map +1 -0
- package/dist/auth/oidc/user-service.js +177 -0
- package/dist/auth/oidc/user-service.js.map +1 -0
- package/dist/auth/rate-limiter.d.ts +31 -0
- package/dist/auth/rate-limiter.d.ts.map +1 -0
- package/dist/auth/rate-limiter.js +70 -0
- package/dist/auth/rate-limiter.js.map +1 -0
- package/dist/config/config-service.d.ts +83 -0
- package/dist/config/config-service.d.ts.map +1 -0
- package/dist/config/config-service.js +309 -0
- package/dist/config/config-service.js.map +1 -0
- package/dist/config/validate.d.ts +12 -0
- package/dist/config/validate.d.ts.map +1 -0
- package/dist/config/validate.js +140 -0
- package/dist/config/validate.js.map +1 -0
- package/dist/content-types/registry.d.ts +75 -0
- package/dist/content-types/registry.d.ts.map +1 -0
- package/dist/content-types/registry.js +171 -0
- package/dist/content-types/registry.js.map +1 -0
- package/dist/datastore/db.d.ts +9 -0
- package/dist/datastore/db.d.ts.map +1 -0
- package/dist/datastore/db.js +27 -0
- package/dist/datastore/db.js.map +1 -0
- package/dist/datastore/encrypt.d.ts +9 -0
- package/dist/datastore/encrypt.d.ts.map +1 -0
- package/dist/datastore/encrypt.js +84 -0
- package/dist/datastore/encrypt.js.map +1 -0
- package/dist/datastore/migrations.d.ts +10 -0
- package/dist/datastore/migrations.d.ts.map +1 -0
- package/dist/datastore/migrations.js +340 -0
- package/dist/datastore/migrations.js.map +1 -0
- package/dist/datastore/storage.d.ts +4 -0
- package/dist/datastore/storage.d.ts.map +1 -0
- package/dist/datastore/storage.js +60 -0
- package/dist/datastore/storage.js.map +1 -0
- package/dist/events/event-bus.d.ts +15 -0
- package/dist/events/event-bus.d.ts.map +1 -0
- package/dist/events/event-bus.js +140 -0
- package/dist/events/event-bus.js.map +1 -0
- package/dist/events/spine.d.ts +31 -0
- package/dist/events/spine.d.ts.map +1 -0
- package/dist/events/spine.js +68 -0
- package/dist/events/spine.js.map +1 -0
- package/dist/events/trace.d.ts +24 -0
- package/dist/events/trace.d.ts.map +1 -0
- package/dist/events/trace.js +33 -0
- package/dist/events/trace.js.map +1 -0
- package/dist/host.d.ts +65 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +94 -0
- package/dist/host.js.map +1 -0
- package/dist/library/importer.d.ts +29 -0
- package/dist/library/importer.d.ts.map +1 -0
- package/dist/library/importer.js +145 -0
- package/dist/library/importer.js.map +1 -0
- package/dist/library/library-cache.d.ts +46 -0
- package/dist/library/library-cache.d.ts.map +1 -0
- package/dist/library/library-cache.js +152 -0
- package/dist/library/library-cache.js.map +1 -0
- package/dist/library/library-config.d.ts +12 -0
- package/dist/library/library-config.d.ts.map +1 -0
- package/dist/library/library-config.js +10 -0
- package/dist/library/library-config.js.map +1 -0
- package/dist/library/library-scanner.d.ts +62 -0
- package/dist/library/library-scanner.d.ts.map +1 -0
- package/dist/library/library-scanner.js +336 -0
- package/dist/library/library-scanner.js.map +1 -0
- package/dist/library/library-service.d.ts +65 -0
- package/dist/library/library-service.d.ts.map +1 -0
- package/dist/library/library-service.js +197 -0
- package/dist/library/library-service.js.map +1 -0
- package/dist/library/metadata-matcher.d.ts +56 -0
- package/dist/library/metadata-matcher.d.ts.map +1 -0
- package/dist/library/metadata-matcher.js +207 -0
- package/dist/library/metadata-matcher.js.map +1 -0
- package/dist/library/naming.d.ts +8 -0
- package/dist/library/naming.d.ts.map +1 -0
- package/dist/library/naming.js +47 -0
- package/dist/library/naming.js.map +1 -0
- package/dist/library/scan-controller.d.ts +95 -0
- package/dist/library/scan-controller.d.ts.map +1 -0
- package/dist/library/scan-controller.js +379 -0
- package/dist/library/scan-controller.js.map +1 -0
- package/dist/library/source-resolver.d.ts +17 -0
- package/dist/library/source-resolver.d.ts.map +1 -0
- package/dist/library/source-resolver.js +75 -0
- package/dist/library/source-resolver.js.map +1 -0
- package/dist/logging/logger.d.ts +3 -0
- package/dist/logging/logger.d.ts.map +1 -0
- package/dist/logging/logger.js +107 -0
- package/dist/logging/logger.js.map +1 -0
- package/dist/main.d.ts +36 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +229 -0
- package/dist/main.js.map +1 -0
- package/dist/plugins/context.d.ts +10 -0
- package/dist/plugins/context.d.ts.map +1 -0
- package/dist/plugins/context.js +110 -0
- package/dist/plugins/context.js.map +1 -0
- package/dist/plugins/discovery.d.ts +2 -0
- package/dist/plugins/discovery.d.ts.map +1 -0
- package/dist/plugins/discovery.js +32 -0
- package/dist/plugins/discovery.js.map +1 -0
- package/dist/plugins/loader.d.ts +9 -0
- package/dist/plugins/loader.d.ts.map +1 -0
- package/dist/plugins/loader.js +245 -0
- package/dist/plugins/loader.js.map +1 -0
- package/dist/state/request-intake.d.ts +26 -0
- package/dist/state/request-intake.d.ts.map +1 -0
- package/dist/state/request-intake.js +74 -0
- package/dist/state/request-intake.js.map +1 -0
- package/dist/state/state-store.d.ts +146 -0
- package/dist/state/state-store.d.ts.map +1 -0
- package/dist/state/state-store.js +465 -0
- package/dist/state/state-store.js.map +1 -0
- package/dist/state/trace-store.d.ts +12 -0
- package/dist/state/trace-store.d.ts.map +1 -0
- package/dist/state/trace-store.js +24 -0
- package/dist/state/trace-store.js.map +1 -0
- package/dist/types/plugin.d.ts +93 -0
- package/dist/types/plugin.d.ts.map +1 -0
- package/dist/types/plugin.js +4 -0
- package/dist/types/plugin.js.map +1 -0
- package/dist/web/index.d.ts +10 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +153 -0
- package/dist/web/index.js.map +1 -0
- package/dist/web/routes/activity.d.ts +6 -0
- package/dist/web/routes/activity.d.ts.map +1 -0
- package/dist/web/routes/activity.js +31 -0
- package/dist/web/routes/activity.js.map +1 -0
- package/dist/web/routes/auth.d.ts +6 -0
- package/dist/web/routes/auth.d.ts.map +1 -0
- package/dist/web/routes/auth.js +142 -0
- package/dist/web/routes/auth.js.map +1 -0
- package/dist/web/routes/config.d.ts +7 -0
- package/dist/web/routes/config.d.ts.map +1 -0
- package/dist/web/routes/config.js +204 -0
- package/dist/web/routes/config.js.map +1 -0
- package/dist/web/routes/content-types.d.ts +6 -0
- package/dist/web/routes/content-types.d.ts.map +1 -0
- package/dist/web/routes/content-types.js +29 -0
- package/dist/web/routes/content-types.js.map +1 -0
- package/dist/web/routes/discovery.d.ts +6 -0
- package/dist/web/routes/discovery.d.ts.map +1 -0
- package/dist/web/routes/discovery.js +111 -0
- package/dist/web/routes/discovery.js.map +1 -0
- package/dist/web/routes/library-import.d.ts +6 -0
- package/dist/web/routes/library-import.d.ts.map +1 -0
- package/dist/web/routes/library-import.js +120 -0
- package/dist/web/routes/library-import.js.map +1 -0
- package/dist/web/routes/library-management.d.ts +6 -0
- package/dist/web/routes/library-management.d.ts.map +1 -0
- package/dist/web/routes/library-management.js +120 -0
- package/dist/web/routes/library-management.js.map +1 -0
- package/dist/web/routes/library.d.ts +6 -0
- package/dist/web/routes/library.d.ts.map +1 -0
- package/dist/web/routes/library.js +28 -0
- package/dist/web/routes/library.js.map +1 -0
- package/dist/web/routes/oidc.d.ts +9 -0
- package/dist/web/routes/oidc.d.ts.map +1 -0
- package/dist/web/routes/oidc.js +203 -0
- package/dist/web/routes/oidc.js.map +1 -0
- package/dist/web/routes/requests.d.ts +6 -0
- package/dist/web/routes/requests.d.ts.map +1 -0
- package/dist/web/routes/requests.js +183 -0
- package/dist/web/routes/requests.js.map +1 -0
- package/dist/web/routes/sse.d.ts +6 -0
- package/dist/web/routes/sse.d.ts.map +1 -0
- package/dist/web/routes/sse.js +54 -0
- package/dist/web/routes/sse.js.map +1 -0
- package/package.json +33 -0
- package/package.json.license +2 -0
- package/src/auth/auth-service.ts +333 -0
- package/src/auth/http-server.ts +249 -0
- package/src/auth/jwt-secret.ts +65 -0
- package/src/auth/oidc/oidc-flow.ts +266 -0
- package/src/auth/oidc/oidc-provider-service.ts +174 -0
- package/src/auth/oidc/user-service.ts +227 -0
- package/src/auth/rate-limiter.ts +85 -0
- package/src/config/config-service.ts +434 -0
- package/src/config/validate.ts +153 -0
- package/src/content-types/registry.ts +194 -0
- package/src/datastore/db.ts +38 -0
- package/src/datastore/encrypt.ts +107 -0
- package/src/datastore/migrations.ts +367 -0
- package/src/datastore/storage.ts +83 -0
- package/src/events/event-bus.ts +180 -0
- package/src/events/spine.ts +87 -0
- package/src/events/trace.ts +38 -0
- package/src/host.ts +130 -0
- package/src/library/importer.ts +174 -0
- package/src/library/library-cache.ts +212 -0
- package/src/library/library-config.ts +22 -0
- package/src/library/library-scanner.ts +404 -0
- package/src/library/library-service.ts +282 -0
- package/src/library/metadata-matcher.ts +285 -0
- package/src/library/naming.ts +59 -0
- package/src/library/scan-controller.ts +490 -0
- package/src/library/source-resolver.ts +92 -0
- package/src/logging/logger.ts +115 -0
- package/src/main.ts +364 -0
- package/src/plugins/context.ts +138 -0
- package/src/plugins/discovery.ts +29 -0
- package/src/plugins/loader.ts +309 -0
- package/src/state/request-intake.ts +101 -0
- package/src/state/state-store.ts +621 -0
- package/src/state/trace-store.ts +27 -0
- package/src/types/plugin.ts +110 -0
- package/src/web/index.ts +182 -0
- package/src/web/routes/activity.ts +41 -0
- package/src/web/routes/auth.ts +170 -0
- package/src/web/routes/config.ts +282 -0
- package/src/web/routes/content-types.ts +42 -0
- package/src/web/routes/discovery.ts +156 -0
- package/src/web/routes/library-import.ts +196 -0
- package/src/web/routes/library-management.ts +140 -0
- package/src/web/routes/library.ts +46 -0
- package/src/web/routes/oidc.ts +260 -0
- package/src/web/routes/requests.ts +250 -0
- package/src/web/routes/sse.ts +92 -0
- package/tests/auth.test.ts +510 -0
- package/tests/config-route.test.ts +345 -0
- package/tests/config.test.ts +612 -0
- package/tests/content-types.test.ts +376 -0
- package/tests/datastore.test.ts +345 -0
- package/tests/e2e.test.ts +430 -0
- package/tests/event-bus.test.ts +925 -0
- package/tests/host.test.ts +449 -0
- package/tests/library-cache.test.ts +171 -0
- package/tests/library-scanner.test.ts +177 -0
- package/tests/library.test.ts +210 -0
- package/tests/loader-context.test.ts +644 -0
- package/tests/logger-jsonl.test.ts +154 -0
- package/tests/metadata-matcher.test.ts +198 -0
- package/tests/oidc.test.ts +218 -0
- package/tests/scan-controller.test.ts +328 -0
- package/tests/source-resolver.test.ts +100 -0
- package/tests/starr-video-integration.test.ts +153 -0
- package/tests/state-store.test.ts +700 -0
- package/tests/trace-history.test.ts +356 -0
- package/tsconfig.json +8 -0
- package/tsconfig.json.license +2 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// SPDX-License-Identifier: 0BSD
|
|
2
|
+
// SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
|
|
3
|
+
|
|
4
|
+
import type { ContentTypeDescriptor, NamingRule } from "@digitalpaws/starr-types-core";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pure validation function. Returns null if valid, or an error message string.
|
|
8
|
+
*
|
|
9
|
+
* Checks in order:
|
|
10
|
+
* - id is truthy
|
|
11
|
+
* - name is truthy
|
|
12
|
+
* - identity is a non-empty array (DS2)
|
|
13
|
+
* - Each field: key, label, type are truthy
|
|
14
|
+
* - list fields must have itemType (FT2)
|
|
15
|
+
* - enum fields must have non-empty options (FT2)
|
|
16
|
+
*/
|
|
17
|
+
export function validateDescriptor(descriptor: ContentTypeDescriptor): string | null {
|
|
18
|
+
// Check required fields (as unknown for runtime validation of TS-required fields).
|
|
19
|
+
const d = descriptor as unknown as Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
if (!d.id) return "content-type id is required";
|
|
22
|
+
if (!d.name) return "content-type name is required";
|
|
23
|
+
if (!Array.isArray(d.identity) || (d.identity as unknown[]).length === 0) {
|
|
24
|
+
return "content-type identity must be a non-empty array";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const fields = d.fields as Array<Record<string, unknown>> | undefined;
|
|
28
|
+
if (Array.isArray(fields)) {
|
|
29
|
+
for (let i = 0; i < fields.length; i++) {
|
|
30
|
+
const f = fields[i];
|
|
31
|
+
if (!f.key) return `field[${i}]: key is required`;
|
|
32
|
+
if (!f.label) return `field[${i}]: label is required`;
|
|
33
|
+
if (!f.type) return `field[${i}]: type is required`;
|
|
34
|
+
if (f.type === "list" && !f.itemType) {
|
|
35
|
+
return `field[${i}] (list): itemType is required`;
|
|
36
|
+
}
|
|
37
|
+
if (f.type === "enum") {
|
|
38
|
+
if (!Array.isArray(f.options) || (f.options as unknown[]).length === 0) {
|
|
39
|
+
return `field[${i}] (enum): non-empty options is required`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Full content-type registry implementation.
|
|
50
|
+
*
|
|
51
|
+
* Plugins call register() via ctx.contentTypes.register. The host internally
|
|
52
|
+
* uses get(), list(), and rollbackPlugin().
|
|
53
|
+
*/
|
|
54
|
+
export class ContentTypeRegistryImpl {
|
|
55
|
+
private descriptors = new Map<string, ContentTypeDescriptor>();
|
|
56
|
+
private pluginToIds = new Map<string, string[]>();
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Register a descriptor. Optionally associates it with a pluginId for
|
|
60
|
+
* rollback support.
|
|
61
|
+
*/
|
|
62
|
+
register(descriptor: ContentTypeDescriptor, pluginId?: string): void {
|
|
63
|
+
// 1. Validate
|
|
64
|
+
const err = validateDescriptor(descriptor);
|
|
65
|
+
if (err) throw new Error(`Invalid content-type descriptor: ${err}`);
|
|
66
|
+
|
|
67
|
+
// 2. Duplicate check (CT5, RC2)
|
|
68
|
+
if (this.descriptors.has(descriptor.id)) {
|
|
69
|
+
throw new Error(`Duplicate content-type id: ${descriptor.id}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 3. Store
|
|
73
|
+
this.descriptors.set(descriptor.id, descriptor);
|
|
74
|
+
if (pluginId) {
|
|
75
|
+
const ids = this.pluginToIds.get(pluginId) ?? [];
|
|
76
|
+
ids.push(descriptor.id);
|
|
77
|
+
this.pluginToIds.set(pluginId, ids);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Retrieve a descriptor by id. Returns undefined if not found. */
|
|
82
|
+
get(id: string): ContentTypeDescriptor | undefined {
|
|
83
|
+
return this.descriptors.get(id);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** List all registered descriptors. */
|
|
87
|
+
list(): ContentTypeDescriptor[] {
|
|
88
|
+
return [...this.descriptors.values()];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Roll back (remove) all descriptors registered by a given plugin.
|
|
93
|
+
* Safe to call for unknown pluginIds (no-op).
|
|
94
|
+
*/
|
|
95
|
+
rollbackPlugin(pluginId: string): void {
|
|
96
|
+
const ids = this.pluginToIds.get(pluginId);
|
|
97
|
+
if (ids) {
|
|
98
|
+
for (const id of ids) this.descriptors.delete(id);
|
|
99
|
+
this.pluginToIds.delete(pluginId);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build a sorted external ids string for fallback identity computation.
|
|
106
|
+
* Produces: "key1=val1|key2=val2|..." sorted alphabetically by key.
|
|
107
|
+
*/
|
|
108
|
+
function sortedExternalIdsString(externalIds: Record<string, string>): string {
|
|
109
|
+
return Object.keys(externalIds)
|
|
110
|
+
.sort()
|
|
111
|
+
.map((k) => `${k}=${externalIds[k]}`)
|
|
112
|
+
.join("|");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Compute a deterministic content-identity string for an item.
|
|
117
|
+
*
|
|
118
|
+
* Algorithm:
|
|
119
|
+
* 1. Look up the descriptor for item.contentTypeId.
|
|
120
|
+
* 2. If descriptor exists: walk descriptor.identity in order. For each key,
|
|
121
|
+
* if item.externalIds[key] is present and non-empty, return
|
|
122
|
+
* "${contentTypeId}::${key}=${value}".
|
|
123
|
+
* 3. If no identity key is present in the item's externalIds (or descriptor
|
|
124
|
+
* not found): return "${contentTypeId}::undecidable::${sortedExternalIds}"
|
|
125
|
+
* where sortedExternalIds is all externalId pairs sorted by key.
|
|
126
|
+
*/
|
|
127
|
+
export function computeContentIdentity(
|
|
128
|
+
item: { contentTypeId: string; externalIds: Record<string, string> },
|
|
129
|
+
registry: ContentTypeRegistryImpl,
|
|
130
|
+
): string {
|
|
131
|
+
const descriptor = registry.get(item.contentTypeId);
|
|
132
|
+
|
|
133
|
+
if (descriptor) {
|
|
134
|
+
for (const key of descriptor.identity) {
|
|
135
|
+
const value = item.externalIds[key];
|
|
136
|
+
if (value && value.length > 0) {
|
|
137
|
+
return `${item.contentTypeId}::${key}=${value}`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Fallback: undecidable or unknown type
|
|
143
|
+
return `${item.contentTypeId}::undecidable::${sortedExternalIdsString(item.externalIds)}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Compare two content items for identity.
|
|
148
|
+
*
|
|
149
|
+
* Algorithm:
|
|
150
|
+
* 1. If contentTypeId differs → false (ID2).
|
|
151
|
+
* 2. Look up descriptor. If not found → false.
|
|
152
|
+
* 3. Walk descriptor.identity in order. For each key, if BOTH items have a
|
|
153
|
+
* truthy value, return whether they are equal.
|
|
154
|
+
* 4. No mutually-present key → false (ID1 — undecidable).
|
|
155
|
+
*/
|
|
156
|
+
export function areSameItem(
|
|
157
|
+
a: { contentTypeId: string; externalIds: Record<string, string> },
|
|
158
|
+
b: { contentTypeId: string; externalIds: Record<string, string> },
|
|
159
|
+
registry: ContentTypeRegistryImpl,
|
|
160
|
+
): boolean {
|
|
161
|
+
if (a.contentTypeId !== b.contentTypeId) return false; // ID2
|
|
162
|
+
|
|
163
|
+
const descriptor = registry.get(a.contentTypeId);
|
|
164
|
+
if (!descriptor) return false; // Unknown type → conservative
|
|
165
|
+
|
|
166
|
+
for (const key of descriptor.identity) {
|
|
167
|
+
const aVal = a.externalIds[key];
|
|
168
|
+
const bVal = b.externalIds[key];
|
|
169
|
+
if (aVal && aVal.length > 0 && bVal && bVal.length > 0) {
|
|
170
|
+
return aVal === bVal;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return false; // ID1 — no mutually-present key
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Default naming: "{Title}/{Title}.{ext}" */
|
|
178
|
+
function defaultNamingRule(): NamingRule {
|
|
179
|
+
return {
|
|
180
|
+
directory: [[{ type: "field", value: "Title" }]],
|
|
181
|
+
filename: [[{ type: "field", value: "Title" }]],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Get the naming rule for a content type, falling back to a safe default.
|
|
187
|
+
*/
|
|
188
|
+
export function getNamingRule(
|
|
189
|
+
contentTypeId: string,
|
|
190
|
+
registry: ContentTypeRegistryImpl,
|
|
191
|
+
): NamingRule {
|
|
192
|
+
const descriptor = registry.get(contentTypeId);
|
|
193
|
+
return descriptor?.naming ?? defaultNamingRule();
|
|
194
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// SPDX-License-Identifier: 0BSD
|
|
2
|
+
// SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
|
|
3
|
+
|
|
4
|
+
import Database from "better-sqlite3";
|
|
5
|
+
import { mkdirSync, existsSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import type { Logger } from "../types/plugin.js";
|
|
8
|
+
|
|
9
|
+
function ensureDir(dir: string): void {
|
|
10
|
+
if (!existsSync(dir)) {
|
|
11
|
+
mkdirSync(dir, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Datastore {
|
|
16
|
+
readonly db: Database.Database;
|
|
17
|
+
readonly dbPath: string;
|
|
18
|
+
close(): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function openDatastore(dataDir: string, logger: Logger): Datastore {
|
|
22
|
+
ensureDir(dataDir);
|
|
23
|
+
const dbPath = path.join(dataDir, "starr.db");
|
|
24
|
+
logger.info("opening datastore", { dbPath });
|
|
25
|
+
|
|
26
|
+
const db = new Database(dbPath);
|
|
27
|
+
db.pragma("journal_mode = WAL");
|
|
28
|
+
db.pragma("foreign_keys = ON");
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
db,
|
|
32
|
+
dbPath,
|
|
33
|
+
close() {
|
|
34
|
+
logger.info("closing datastore");
|
|
35
|
+
db.close();
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// SPDX-License-Identifier: 0BSD
|
|
2
|
+
// SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createCipheriv,
|
|
6
|
+
createDecipheriv,
|
|
7
|
+
randomBytes,
|
|
8
|
+
} from "node:crypto";
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import type { Logger } from "../types/plugin.js";
|
|
12
|
+
|
|
13
|
+
const ALGORITHM = "aes-256-gcm";
|
|
14
|
+
const IV_LENGTH = 12;
|
|
15
|
+
const AUTH_TAG_LENGTH = 16;
|
|
16
|
+
const KEY_LENGTH = 32; // bytes
|
|
17
|
+
|
|
18
|
+
let _key: Buffer | null = null;
|
|
19
|
+
|
|
20
|
+
export function loadEncryptionKey(
|
|
21
|
+
dataDir: string,
|
|
22
|
+
logger: Logger,
|
|
23
|
+
): Buffer {
|
|
24
|
+
if (_key) return _key;
|
|
25
|
+
|
|
26
|
+
// 1. Env var
|
|
27
|
+
const envKey = process.env.STARR_ENCRYPTION_KEY;
|
|
28
|
+
if (envKey) {
|
|
29
|
+
const buf = Buffer.from(envKey, "base64");
|
|
30
|
+
if (buf.length !== KEY_LENGTH) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`STARR_ENCRYPTION_KEY must decode to ${KEY_LENGTH} bytes, got ${buf.length}`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
_key = buf;
|
|
36
|
+
logger.info("encryption key loaded from STARR_ENCRYPTION_KEY");
|
|
37
|
+
return _key;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 2. Key file
|
|
41
|
+
const keyPath = path.join(dataDir, "starr.key");
|
|
42
|
+
if (existsSync(keyPath)) {
|
|
43
|
+
const fileContent = readFileSync(keyPath, "utf8").trim();
|
|
44
|
+
const buf = Buffer.from(fileContent, "base64");
|
|
45
|
+
if (buf.length !== KEY_LENGTH) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Key file ${keyPath} must decode to ${KEY_LENGTH} bytes, got ${buf.length}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
_key = buf;
|
|
51
|
+
logger.info("encryption key loaded from file", { keyPath });
|
|
52
|
+
return _key;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 3. Auto-generate
|
|
56
|
+
const newKey = randomBytes(KEY_LENGTH);
|
|
57
|
+
const encoded = newKey.toString("base64");
|
|
58
|
+
writeFileSync(keyPath, encoded + "\n", { mode: 0o600 });
|
|
59
|
+
_key = newKey;
|
|
60
|
+
logger.info(
|
|
61
|
+
"auto-generated encryption key (no STARR_ENCRYPTION_KEY set)",
|
|
62
|
+
{ keyPath },
|
|
63
|
+
);
|
|
64
|
+
return _key;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reset the cached encryption key. For testing only.
|
|
69
|
+
*/
|
|
70
|
+
export function resetEncryptionKey(): void {
|
|
71
|
+
_key = null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function encrypt(plaintext: string): string {
|
|
75
|
+
if (!_key) throw new Error("encryption key not loaded");
|
|
76
|
+
const iv = randomBytes(IV_LENGTH);
|
|
77
|
+
const cipher = createCipheriv(ALGORITHM, _key, iv, {
|
|
78
|
+
authTagLength: AUTH_TAG_LENGTH,
|
|
79
|
+
});
|
|
80
|
+
const encrypted = Buffer.concat([
|
|
81
|
+
cipher.update(plaintext, "utf8"),
|
|
82
|
+
cipher.final(),
|
|
83
|
+
]);
|
|
84
|
+
const authTag = cipher.getAuthTag();
|
|
85
|
+
// Pack: iv (12) + authTag (16) + ciphertext (variable)
|
|
86
|
+
const blob = Buffer.concat([iv, authTag, encrypted]);
|
|
87
|
+
return blob.toString("base64");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function decrypt(blob: string): string {
|
|
91
|
+
if (!_key) throw new Error("encryption key not loaded");
|
|
92
|
+
const buf = Buffer.from(blob, "base64");
|
|
93
|
+
if (buf.length < IV_LENGTH + AUTH_TAG_LENGTH) {
|
|
94
|
+
throw new Error("invalid encrypted blob: too short");
|
|
95
|
+
}
|
|
96
|
+
const iv = buf.subarray(0, IV_LENGTH);
|
|
97
|
+
const authTag = buf.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
|
98
|
+
const ciphertext = buf.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
|
99
|
+
const decipher = createDecipheriv(ALGORITHM, _key, iv, {
|
|
100
|
+
authTagLength: AUTH_TAG_LENGTH,
|
|
101
|
+
});
|
|
102
|
+
decipher.setAuthTag(authTag);
|
|
103
|
+
return (
|
|
104
|
+
decipher.update(ciphertext, undefined, "utf8") +
|
|
105
|
+
decipher.final("utf8")
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// SPDX-License-Identifier: 0BSD
|
|
2
|
+
// SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
|
|
3
|
+
|
|
4
|
+
import type Database from "better-sqlite3";
|
|
5
|
+
import type { Logger } from "../types/plugin.js";
|
|
6
|
+
|
|
7
|
+
export interface Migration {
|
|
8
|
+
id: number;
|
|
9
|
+
name: string;
|
|
10
|
+
up: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const CREATE_MIGRATIONS_TABLE = `
|
|
14
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
15
|
+
id INTEGER PRIMARY KEY,
|
|
16
|
+
name TEXT NOT NULL UNIQUE,
|
|
17
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
18
|
+
);
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
export function runMigrations(
|
|
22
|
+
db: Database.Database,
|
|
23
|
+
migrations: Migration[],
|
|
24
|
+
logger: Logger,
|
|
25
|
+
): void {
|
|
26
|
+
db.exec(CREATE_MIGRATIONS_TABLE);
|
|
27
|
+
|
|
28
|
+
const applied = new Set(
|
|
29
|
+
db
|
|
30
|
+
.prepare("SELECT id FROM _migrations")
|
|
31
|
+
.all()
|
|
32
|
+
.map((row: unknown) => (row as { id: number }).id),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const pending = migrations
|
|
36
|
+
.filter((m) => !applied.has(m.id))
|
|
37
|
+
.sort((a, b) => a.id - b.id);
|
|
38
|
+
|
|
39
|
+
if (pending.length === 0) {
|
|
40
|
+
logger.debug("no pending migrations");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
logger.info("running migrations", { count: pending.length });
|
|
45
|
+
|
|
46
|
+
const insert = db.prepare(
|
|
47
|
+
"INSERT INTO _migrations (id, name) VALUES (@id, @name)",
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const runOne = db.transaction((migrations: Migration[]) => {
|
|
51
|
+
for (const m of migrations) {
|
|
52
|
+
logger.debug("applying migration", { id: m.id, name: m.name });
|
|
53
|
+
db.exec(m.up);
|
|
54
|
+
insert.run({ id: m.id, name: m.name });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
runOne(pending);
|
|
59
|
+
logger.info("migrations complete");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Core migrations ──────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
export const CORE_MIGRATIONS: Migration[] = [
|
|
65
|
+
{
|
|
66
|
+
id: 1,
|
|
67
|
+
name: "create_plugin_storage",
|
|
68
|
+
up: `
|
|
69
|
+
CREATE TABLE IF NOT EXISTS plugin_storage (
|
|
70
|
+
plugin_id TEXT NOT NULL,
|
|
71
|
+
key TEXT NOT NULL,
|
|
72
|
+
value TEXT NOT NULL,
|
|
73
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
74
|
+
PRIMARY KEY (plugin_id, key)
|
|
75
|
+
);
|
|
76
|
+
`,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 2,
|
|
80
|
+
name: "create_plugin_config",
|
|
81
|
+
up: `
|
|
82
|
+
CREATE TABLE IF NOT EXISTS plugin_config (
|
|
83
|
+
plugin_id TEXT NOT NULL PRIMARY KEY,
|
|
84
|
+
schema TEXT NOT NULL DEFAULT '{}',
|
|
85
|
+
"values" TEXT NOT NULL DEFAULT '{}',
|
|
86
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
87
|
+
);
|
|
88
|
+
`,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: 3,
|
|
92
|
+
name: "create_tracked_requests",
|
|
93
|
+
up: `
|
|
94
|
+
CREATE TABLE IF NOT EXISTS tracked_requests (
|
|
95
|
+
request_id TEXT NOT NULL PRIMARY KEY,
|
|
96
|
+
content_type_id TEXT NOT NULL,
|
|
97
|
+
content_identity TEXT NOT NULL,
|
|
98
|
+
item TEXT NOT NULL,
|
|
99
|
+
owner_user_id TEXT NOT NULL,
|
|
100
|
+
watchers TEXT NOT NULL DEFAULT '[]',
|
|
101
|
+
status TEXT NOT NULL,
|
|
102
|
+
status_detail TEXT,
|
|
103
|
+
chosen_release_id TEXT,
|
|
104
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
105
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
106
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
107
|
+
);
|
|
108
|
+
CREATE INDEX IF NOT EXISTS idx_tracked_requests_identity
|
|
109
|
+
ON tracked_requests(content_identity);
|
|
110
|
+
`,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: 4,
|
|
114
|
+
name: "create_trace_log",
|
|
115
|
+
up: `
|
|
116
|
+
CREATE TABLE IF NOT EXISTS trace_log (
|
|
117
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
118
|
+
trace_id TEXT NOT NULL,
|
|
119
|
+
request_id TEXT NOT NULL,
|
|
120
|
+
event_type TEXT NOT NULL,
|
|
121
|
+
event_data TEXT NOT NULL,
|
|
122
|
+
timestamp TEXT NOT NULL
|
|
123
|
+
);
|
|
124
|
+
CREATE INDEX IF NOT EXISTS idx_trace_log_request
|
|
125
|
+
ON trace_log(request_id);
|
|
126
|
+
CREATE INDEX IF NOT EXISTS idx_trace_log_trace
|
|
127
|
+
ON trace_log(trace_id, id);
|
|
128
|
+
`,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: 5,
|
|
132
|
+
name: "create_accounts_and_sessions",
|
|
133
|
+
up: `
|
|
134
|
+
CREATE TABLE IF NOT EXISTS admin_accounts (
|
|
135
|
+
username TEXT NOT NULL PRIMARY KEY,
|
|
136
|
+
password_hash TEXT NOT NULL,
|
|
137
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
138
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
142
|
+
session_id TEXT NOT NULL PRIMARY KEY,
|
|
143
|
+
user_id TEXT NOT NULL REFERENCES admin_accounts(username) ON DELETE CASCADE,
|
|
144
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
145
|
+
expires_at TEXT NOT NULL
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_user_id
|
|
149
|
+
ON sessions(user_id);
|
|
150
|
+
`,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: 6,
|
|
154
|
+
name: "create_users_and_oidc_providers",
|
|
155
|
+
up: `
|
|
156
|
+
-- Users table: unified account for local admin and OIDC users
|
|
157
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
158
|
+
id TEXT NOT NULL PRIMARY KEY, -- UUID
|
|
159
|
+
username TEXT NOT NULL UNIQUE,
|
|
160
|
+
display_name TEXT,
|
|
161
|
+
avatar_url TEXT,
|
|
162
|
+
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin', 'user')),
|
|
163
|
+
provider TEXT NOT NULL DEFAULT 'local' CHECK(provider IN ('local', 'oidc')),
|
|
164
|
+
oidc_sub TEXT, -- OIDC 'sub' claim (unique per provider)
|
|
165
|
+
oidc_provider_id TEXT REFERENCES oidc_providers(id) ON DELETE SET NULL,
|
|
166
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
167
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
CREATE INDEX IF NOT EXISTS idx_users_oidc_sub
|
|
171
|
+
ON users(oidc_sub, oidc_provider_id);
|
|
172
|
+
|
|
173
|
+
-- OIDC provider configuration
|
|
174
|
+
CREATE TABLE IF NOT EXISTS oidc_providers (
|
|
175
|
+
id TEXT NOT NULL PRIMARY KEY, -- UUID
|
|
176
|
+
name TEXT NOT NULL,
|
|
177
|
+
issuer_url TEXT NOT NULL,
|
|
178
|
+
client_id TEXT NOT NULL,
|
|
179
|
+
client_secret TEXT NOT NULL, -- encrypted at rest
|
|
180
|
+
scopes TEXT NOT NULL DEFAULT 'openid profile email',
|
|
181
|
+
role_attribute TEXT, -- JSONPath to a claim, e.g. "$.starr_roles"
|
|
182
|
+
default_role TEXT NOT NULL DEFAULT 'user' CHECK(default_role IN ('admin', 'user')),
|
|
183
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
184
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
185
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
-- Temporary state for in-flight OIDC authorization flows
|
|
189
|
+
CREATE TABLE IF NOT EXISTS oidc_auth_states (
|
|
190
|
+
state TEXT NOT NULL PRIMARY KEY, -- random CSRF token
|
|
191
|
+
nonce TEXT NOT NULL,
|
|
192
|
+
code_verifier TEXT NOT NULL,
|
|
193
|
+
provider_id TEXT NOT NULL REFERENCES oidc_providers(id) ON DELETE CASCADE,
|
|
194
|
+
redirect_to TEXT NOT NULL DEFAULT '/browse',
|
|
195
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
CREATE INDEX IF NOT EXISTS idx_oidc_auth_states_created
|
|
199
|
+
ON oidc_auth_states(created_at);
|
|
200
|
+
|
|
201
|
+
-- Recreate sessions table without FK constraint so OIDC user UUIDs work
|
|
202
|
+
CREATE TABLE IF NOT EXISTS sessions_new (
|
|
203
|
+
session_id TEXT NOT NULL PRIMARY KEY,
|
|
204
|
+
user_id TEXT NOT NULL,
|
|
205
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
206
|
+
expires_at TEXT NOT NULL
|
|
207
|
+
);
|
|
208
|
+
INSERT INTO sessions_new (session_id, user_id, created_at, expires_at)
|
|
209
|
+
SELECT session_id, user_id, created_at, expires_at FROM sessions;
|
|
210
|
+
DROP TABLE sessions;
|
|
211
|
+
ALTER TABLE sessions_new RENAME TO sessions;
|
|
212
|
+
|
|
213
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_user_id
|
|
214
|
+
ON sessions(user_id);
|
|
215
|
+
|
|
216
|
+
-- Migrate existing admin to users table
|
|
217
|
+
INSERT OR IGNORE INTO users (id, username, display_name, role, provider, created_at, updated_at)
|
|
218
|
+
SELECT
|
|
219
|
+
'admin', -- id (fixed key for the local admin)
|
|
220
|
+
username, -- username from admin_accounts
|
|
221
|
+
username, -- display_name (same as username)
|
|
222
|
+
'admin', -- role
|
|
223
|
+
'local', -- provider
|
|
224
|
+
COALESCE(created_at, datetime('now')),
|
|
225
|
+
COALESCE(updated_at, datetime('now'))
|
|
226
|
+
FROM admin_accounts;
|
|
227
|
+
`,
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: 7,
|
|
231
|
+
name: "create_library_tracking",
|
|
232
|
+
up: `
|
|
233
|
+
-- Track the on-disk library path for each imported request
|
|
234
|
+
CREATE TABLE IF NOT EXISTS library_paths (
|
|
235
|
+
request_id TEXT NOT NULL PRIMARY KEY REFERENCES tracked_requests(request_id) ON DELETE CASCADE,
|
|
236
|
+
library_path TEXT NOT NULL,
|
|
237
|
+
library_root TEXT NOT NULL,
|
|
238
|
+
file_size INTEGER,
|
|
239
|
+
checksum TEXT,
|
|
240
|
+
imported_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
241
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
-- Add library_path column to tracked_requests for quick access
|
|
245
|
+
ALTER TABLE tracked_requests ADD COLUMN library_path TEXT;
|
|
246
|
+
`,
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
id: 8,
|
|
250
|
+
name: "create_library_cache",
|
|
251
|
+
up: `
|
|
252
|
+
-- Cache of imported item metadata for fast Browse loading.
|
|
253
|
+
-- Populated on import and refreshed on host start.
|
|
254
|
+
CREATE TABLE IF NOT EXISTS library_cache (
|
|
255
|
+
request_id TEXT NOT NULL PRIMARY KEY REFERENCES tracked_requests(request_id) ON DELETE CASCADE,
|
|
256
|
+
content_type_id TEXT NOT NULL,
|
|
257
|
+
content_identity TEXT NOT NULL,
|
|
258
|
+
title TEXT NOT NULL,
|
|
259
|
+
sort_title TEXT,
|
|
260
|
+
summary TEXT,
|
|
261
|
+
release_date TEXT,
|
|
262
|
+
images TEXT, -- JSON array of ContentImage
|
|
263
|
+
external_ids TEXT NOT NULL, -- JSON Record<string, string>
|
|
264
|
+
fields TEXT, -- JSON Record<string, unknown>
|
|
265
|
+
library_path TEXT NOT NULL,
|
|
266
|
+
file_size INTEGER,
|
|
267
|
+
imported_at TEXT NOT NULL,
|
|
268
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
CREATE INDEX IF NOT EXISTS idx_library_cache_content_type
|
|
272
|
+
ON library_cache(content_type_id);
|
|
273
|
+
|
|
274
|
+
CREATE INDEX IF NOT EXISTS idx_library_cache_identity
|
|
275
|
+
ON library_cache(content_identity);
|
|
276
|
+
`,
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
id: 9,
|
|
280
|
+
name: "create_libraries",
|
|
281
|
+
up: `
|
|
282
|
+
CREATE TABLE IF NOT EXISTS libraries (
|
|
283
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
284
|
+
name TEXT NOT NULL UNIQUE,
|
|
285
|
+
description TEXT,
|
|
286
|
+
is_default INTEGER NOT NULL DEFAULT 0,
|
|
287
|
+
default_library_root TEXT NOT NULL DEFAULT '/media/library',
|
|
288
|
+
roots TEXT NOT NULL DEFAULT '{}',
|
|
289
|
+
link_type TEXT NOT NULL DEFAULT 'hardlink' CHECK(link_type IN ('move','hardlink','symlink','copy')),
|
|
290
|
+
import_subtitles INTEGER NOT NULL DEFAULT 1,
|
|
291
|
+
subtitle_extensions TEXT NOT NULL DEFAULT '["srt","ass","ssa","sub","vtt"]',
|
|
292
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
293
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
CREATE TABLE IF NOT EXISTS library_content_type_defaults (
|
|
297
|
+
library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
|
298
|
+
content_type_id TEXT NOT NULL,
|
|
299
|
+
PRIMARY KEY (content_type_id)
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
CREATE INDEX IF NOT EXISTS idx_library_content_type_defaults_library
|
|
303
|
+
ON library_content_type_defaults(library_id);
|
|
304
|
+
`,
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
id: 10,
|
|
308
|
+
name: "add_instance_id_to_plugin_tables",
|
|
309
|
+
up: `
|
|
310
|
+
CREATE TABLE IF NOT EXISTS plugin_config_new (
|
|
311
|
+
plugin_id TEXT NOT NULL,
|
|
312
|
+
instance_id TEXT NOT NULL DEFAULT 'default',
|
|
313
|
+
schema TEXT NOT NULL DEFAULT '{}',
|
|
314
|
+
"values" TEXT NOT NULL DEFAULT '{}',
|
|
315
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
316
|
+
PRIMARY KEY (plugin_id, instance_id)
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
INSERT INTO plugin_config_new (plugin_id, instance_id, schema, "values", updated_at)
|
|
320
|
+
SELECT plugin_id, 'default', schema, "values", updated_at FROM plugin_config;
|
|
321
|
+
|
|
322
|
+
DROP TABLE plugin_config;
|
|
323
|
+
ALTER TABLE plugin_config_new RENAME TO plugin_config;
|
|
324
|
+
|
|
325
|
+
CREATE TABLE IF NOT EXISTS plugin_storage_new (
|
|
326
|
+
plugin_id TEXT NOT NULL,
|
|
327
|
+
instance_id TEXT NOT NULL DEFAULT 'default',
|
|
328
|
+
key TEXT NOT NULL,
|
|
329
|
+
value TEXT NOT NULL,
|
|
330
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
331
|
+
PRIMARY KEY (plugin_id, instance_id, key)
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
INSERT INTO plugin_storage_new (plugin_id, instance_id, key, value, updated_at)
|
|
335
|
+
SELECT plugin_id, 'default', key, value, updated_at FROM plugin_storage;
|
|
336
|
+
|
|
337
|
+
DROP TABLE plugin_storage;
|
|
338
|
+
ALTER TABLE plugin_storage_new RENAME TO plugin_storage;
|
|
339
|
+
`,
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
id: 11,
|
|
343
|
+
name: "add_library_id_to_tracked_requests",
|
|
344
|
+
up: `
|
|
345
|
+
ALTER TABLE tracked_requests ADD COLUMN library_id TEXT REFERENCES libraries(id);
|
|
346
|
+
`,
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
id: 12,
|
|
350
|
+
name: "migrate_existing_imported_to_downloaded",
|
|
351
|
+
up: `
|
|
352
|
+
UPDATE tracked_requests
|
|
353
|
+
SET status = 'downloaded', updated_at = datetime('now')
|
|
354
|
+
WHERE status = 'imported';
|
|
355
|
+
`,
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
id: 13,
|
|
359
|
+
name: "remove_orphaned_core_configs",
|
|
360
|
+
up: `
|
|
361
|
+
-- Remove leftover _core.* config rows that were replaced by the
|
|
362
|
+
-- library service and multi-instance systems (Task 039).
|
|
363
|
+
DELETE FROM plugin_config WHERE plugin_id IN ('_core.library', '_core.downloads');
|
|
364
|
+
DELETE FROM plugin_storage WHERE plugin_id IN ('_core.library', '_core.downloads');
|
|
365
|
+
`,
|
|
366
|
+
},
|
|
367
|
+
];
|