@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,282 @@
|
|
|
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 } from "../types/plugin.js";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
export interface Library {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
isDefault: boolean;
|
|
13
|
+
defaultLibraryRoot: string;
|
|
14
|
+
roots: Record<string, string>;
|
|
15
|
+
linkType: "move" | "hardlink" | "symlink" | "copy";
|
|
16
|
+
importSubtitles: boolean;
|
|
17
|
+
subtitleExtensions: string[];
|
|
18
|
+
createdAt: string;
|
|
19
|
+
updatedAt: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LibraryInput {
|
|
23
|
+
name: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
isDefault?: boolean;
|
|
26
|
+
defaultLibraryRoot?: string;
|
|
27
|
+
roots?: Record<string, string>;
|
|
28
|
+
linkType?: "move" | "hardlink" | "symlink" | "copy";
|
|
29
|
+
importSubtitles?: boolean;
|
|
30
|
+
subtitleExtensions?: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class LibraryService {
|
|
34
|
+
private db: Database.Database;
|
|
35
|
+
private bus: EventBus;
|
|
36
|
+
private logger: Logger;
|
|
37
|
+
|
|
38
|
+
// Prepared statements
|
|
39
|
+
private stmtList: Database.Statement;
|
|
40
|
+
private stmtGetById: Database.Statement;
|
|
41
|
+
private stmtInsert: Database.Statement;
|
|
42
|
+
private stmtUpdate: Database.Statement;
|
|
43
|
+
private stmtDelete: Database.Statement;
|
|
44
|
+
private stmtGetDefault: Database.Statement;
|
|
45
|
+
private stmtClearDefault: Database.Statement;
|
|
46
|
+
private stmtSetDefault: Database.Statement;
|
|
47
|
+
private stmtGetContentTypeDefaults: Database.Statement;
|
|
48
|
+
private stmtSetContentTypeDefault: Database.Statement;
|
|
49
|
+
private stmtDeleteContentTypeDefaults: Database.Statement;
|
|
50
|
+
private stmtCountConnectedDownloaders: Database.Statement;
|
|
51
|
+
private stmtCountContentTypeDefaults: Database.Statement;
|
|
52
|
+
|
|
53
|
+
constructor(
|
|
54
|
+
db: Database.Database,
|
|
55
|
+
bus: EventBus,
|
|
56
|
+
logger: Logger,
|
|
57
|
+
) {
|
|
58
|
+
this.db = db;
|
|
59
|
+
this.bus = bus;
|
|
60
|
+
this.logger = logger.child("library-service");
|
|
61
|
+
|
|
62
|
+
this.stmtList = db.prepare("SELECT * FROM libraries ORDER BY name");
|
|
63
|
+
this.stmtGetById = db.prepare("SELECT * FROM libraries WHERE id = ?");
|
|
64
|
+
this.stmtInsert = db.prepare(`
|
|
65
|
+
INSERT INTO libraries (id, name, description, is_default, default_library_root, roots, link_type, import_subtitles, subtitle_extensions, created_at, updated_at)
|
|
66
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
|
67
|
+
`);
|
|
68
|
+
this.stmtUpdate = db.prepare(`
|
|
69
|
+
UPDATE libraries
|
|
70
|
+
SET name = ?, description = ?, is_default = ?, default_library_root = ?, roots = ?, link_type = ?, import_subtitles = ?, subtitle_extensions = ?, updated_at = datetime('now')
|
|
71
|
+
WHERE id = ?
|
|
72
|
+
`);
|
|
73
|
+
this.stmtDelete = db.prepare("DELETE FROM libraries WHERE id = ?");
|
|
74
|
+
this.stmtGetDefault = db.prepare("SELECT * FROM libraries WHERE is_default = 1 LIMIT 1");
|
|
75
|
+
this.stmtClearDefault = db.prepare("UPDATE libraries SET is_default = 0 WHERE is_default = 1");
|
|
76
|
+
this.stmtSetDefault = db.prepare("UPDATE libraries SET is_default = 1, updated_at = datetime('now') WHERE id = ?");
|
|
77
|
+
this.stmtGetContentTypeDefaults = db.prepare("SELECT content_type_id FROM library_content_type_defaults WHERE library_id = ?");
|
|
78
|
+
this.stmtSetContentTypeDefault = db.prepare("INSERT OR REPLACE INTO library_content_type_defaults (library_id, content_type_id) VALUES (?, ?)");
|
|
79
|
+
this.stmtDeleteContentTypeDefaults = db.prepare("DELETE FROM library_content_type_defaults WHERE library_id = ?");
|
|
80
|
+
this.stmtCountConnectedDownloaders = db.prepare("SELECT COUNT(*) AS count FROM plugin_config WHERE json_extract(\"values\", '$.libraryId') = ?");
|
|
81
|
+
this.stmtCountContentTypeDefaults = db.prepare("SELECT COUNT(*) AS count FROM library_content_type_defaults WHERE library_id = ?");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── CRUD ─────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
list(): Library[] {
|
|
87
|
+
const rows = this.stmtList.all() as Array<Record<string, unknown>>;
|
|
88
|
+
return rows.map(this.rowToLibrary);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
get(id: string): Library | undefined {
|
|
92
|
+
const row = this.stmtGetById.get(id) as Record<string, unknown> | undefined;
|
|
93
|
+
return row ? this.rowToLibrary(row) : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
create(data: LibraryInput): Library {
|
|
97
|
+
const id = randomUUID();
|
|
98
|
+
const txn = this.db.transaction(() => {
|
|
99
|
+
const isDefault = data.isDefault ?? false;
|
|
100
|
+
|
|
101
|
+
// If this library is being set as default, clear any existing default
|
|
102
|
+
if (isDefault) {
|
|
103
|
+
this.stmtClearDefault.run();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Parse roots
|
|
107
|
+
const roots = data.roots ?? {};
|
|
108
|
+
const subtitleExtensions = data.subtitleExtensions ?? ["srt", "ass", "ssa", "sub", "vtt"];
|
|
109
|
+
|
|
110
|
+
this.stmtInsert.run(
|
|
111
|
+
id,
|
|
112
|
+
data.name,
|
|
113
|
+
data.description ?? null,
|
|
114
|
+
isDefault ? 1 : 0,
|
|
115
|
+
data.defaultLibraryRoot ?? "/media/library",
|
|
116
|
+
JSON.stringify(roots),
|
|
117
|
+
data.linkType ?? "hardlink",
|
|
118
|
+
data.importSubtitles ?? true ? 1 : 0,
|
|
119
|
+
JSON.stringify(subtitleExtensions),
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
txn();
|
|
123
|
+
|
|
124
|
+
const lib = this.get(id);
|
|
125
|
+
if (!lib) throw new Error("failed to create library");
|
|
126
|
+
this.bus.emit("library.created", { libraryId: id, library: lib });
|
|
127
|
+
return lib;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
update(id: string, data: LibraryInput): Library {
|
|
131
|
+
const existing = this.get(id);
|
|
132
|
+
if (!existing) throw new Error(`Library not found: ${id}`);
|
|
133
|
+
|
|
134
|
+
const txn = this.db.transaction(() => {
|
|
135
|
+
const isDefault = data.isDefault ?? existing.isDefault;
|
|
136
|
+
|
|
137
|
+
// If setting as default, clear any previous default
|
|
138
|
+
if (isDefault && !existing.isDefault) {
|
|
139
|
+
this.stmtClearDefault.run();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const roots = data.roots ?? existing.roots;
|
|
143
|
+
const subtitleExtensions = data.subtitleExtensions ?? existing.subtitleExtensions;
|
|
144
|
+
|
|
145
|
+
this.stmtUpdate.run(
|
|
146
|
+
data.name ?? existing.name,
|
|
147
|
+
data.description !== undefined ? data.description : existing.description ?? null,
|
|
148
|
+
isDefault ? 1 : 0,
|
|
149
|
+
data.defaultLibraryRoot ?? existing.defaultLibraryRoot,
|
|
150
|
+
JSON.stringify(roots),
|
|
151
|
+
data.linkType ?? existing.linkType,
|
|
152
|
+
data.importSubtitles ?? existing.importSubtitles ? 1 : 0,
|
|
153
|
+
JSON.stringify(subtitleExtensions),
|
|
154
|
+
id,
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
txn();
|
|
158
|
+
|
|
159
|
+
const updated = this.get(id);
|
|
160
|
+
if (!updated) throw new Error("failed to update library");
|
|
161
|
+
this.bus.emit("library.updated", { libraryId: id, library: updated });
|
|
162
|
+
return updated;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
delete(id: string): void {
|
|
166
|
+
const lib = this.get(id);
|
|
167
|
+
if (!lib) throw new Error(`Library not found: ${id}`);
|
|
168
|
+
|
|
169
|
+
const txn = this.db.transaction(() => {
|
|
170
|
+
// 1. Check for downloader instances connected to this library
|
|
171
|
+
const connected = this.stmtCountConnectedDownloaders.get(id) as { count: number };
|
|
172
|
+
if (connected.count > 0) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`Cannot delete library: ${connected.count} downloader instance(s) are connected to it. Disconnect them first.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 2. Check for content type defaults
|
|
179
|
+
const defaults = this.stmtCountContentTypeDefaults.get(id) as { count: number };
|
|
180
|
+
if (defaults.count > 0) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`Cannot delete library: ${defaults.count} content type(s) have this library as default. Reassign them first.`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 3. Delete content type defaults (cascade should handle, but be explicit)
|
|
187
|
+
this.stmtDeleteContentTypeDefaults.run(id);
|
|
188
|
+
|
|
189
|
+
// 4. Delete the library
|
|
190
|
+
this.stmtDelete.run(id);
|
|
191
|
+
});
|
|
192
|
+
txn();
|
|
193
|
+
|
|
194
|
+
this.bus.emit("library.deleted", { libraryId: id, name: lib.name });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Default library ──────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
getGlobalDefault(): Library | undefined {
|
|
200
|
+
const row = this.stmtGetDefault.get() as Record<string, unknown> | undefined;
|
|
201
|
+
return row ? this.rowToLibrary(row) : undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Content type defaults ────────────────────────────────
|
|
205
|
+
|
|
206
|
+
getContentTypeDefaults(libraryId: string): string[] {
|
|
207
|
+
const rows = this.stmtGetContentTypeDefaults.all(libraryId) as Array<{ content_type_id: string }>;
|
|
208
|
+
return rows.map(r => r.content_type_id);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
setContentTypeDefaults(libraryId: string, contentTypeIds: string[]): void {
|
|
212
|
+
const lib = this.get(libraryId);
|
|
213
|
+
if (!lib) throw new Error(`Library not found: ${libraryId}`);
|
|
214
|
+
|
|
215
|
+
const txn = this.db.transaction(() => {
|
|
216
|
+
this.stmtDeleteContentTypeDefaults.run(libraryId);
|
|
217
|
+
for (const ctId of contentTypeIds) {
|
|
218
|
+
this.stmtSetContentTypeDefault.run(libraryId, ctId);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
txn();
|
|
222
|
+
|
|
223
|
+
this.bus.emit("library.content-types.updated", { libraryId, contentTypeIds });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Resolve which library should be used for a given content type.
|
|
228
|
+
* Priority:
|
|
229
|
+
* 1. Content type has explicit default library → use that.
|
|
230
|
+
* 2. Global default library → use that.
|
|
231
|
+
* 3. No libraries → undefined.
|
|
232
|
+
*/
|
|
233
|
+
resolveForContentType(contentTypeId: string): { library: Library; libraryId: string } | undefined {
|
|
234
|
+
// Check content type defaults
|
|
235
|
+
const ctLibRow = this.db
|
|
236
|
+
.prepare("SELECT library_id FROM library_content_type_defaults WHERE content_type_id = ?")
|
|
237
|
+
.get(contentTypeId) as { library_id: string } | undefined;
|
|
238
|
+
|
|
239
|
+
if (ctLibRow) {
|
|
240
|
+
const lib = this.get(ctLibRow.library_id);
|
|
241
|
+
if (lib) return { library: lib, libraryId: lib.id };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Fall back to global default
|
|
245
|
+
const globalDefault = this.getGlobalDefault();
|
|
246
|
+
if (globalDefault) return { library: globalDefault, libraryId: globalDefault.id };
|
|
247
|
+
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Helpers ──────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
private rowToLibrary(row: Record<string, unknown>): Library {
|
|
254
|
+
let roots: Record<string, string>;
|
|
255
|
+
try {
|
|
256
|
+
roots = JSON.parse(row.roots as string);
|
|
257
|
+
} catch {
|
|
258
|
+
roots = {};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let subtitleExtensions: string[];
|
|
262
|
+
try {
|
|
263
|
+
subtitleExtensions = JSON.parse(row.subtitle_extensions as string);
|
|
264
|
+
} catch {
|
|
265
|
+
subtitleExtensions = ["srt", "ass", "ssa", "sub", "vtt"];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
id: row.id as string,
|
|
270
|
+
name: row.name as string,
|
|
271
|
+
description: (row.description as string) || undefined,
|
|
272
|
+
isDefault: (row.is_default as number) === 1,
|
|
273
|
+
defaultLibraryRoot: row.default_library_root as string,
|
|
274
|
+
roots,
|
|
275
|
+
linkType: row.link_type as "move" | "hardlink" | "symlink" | "copy",
|
|
276
|
+
importSubtitles: (row.import_subtitles as number) === 1,
|
|
277
|
+
subtitleExtensions,
|
|
278
|
+
createdAt: row.created_at as string,
|
|
279
|
+
updatedAt: row.updated_at as string,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// SPDX-License-Identifier: 0BSD
|
|
2
|
+
// SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
|
|
3
|
+
|
|
4
|
+
import type { ContentItemBase } from "@digitalpaws/starr-types-core";
|
|
5
|
+
import type { EventBus, Logger, StarrEvent } from "../types/plugin.js";
|
|
6
|
+
import type { ContentTypeRegistryImpl } from "../content-types/registry.js";
|
|
7
|
+
import type { DiscoveredGroup } from "./library-scanner.js";
|
|
8
|
+
|
|
9
|
+
export interface MatchResult {
|
|
10
|
+
item: ContentItemBase;
|
|
11
|
+
source: string; // plugin id
|
|
12
|
+
confidence: number; // 0..1
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Default timeout for collecting discovery.results.
|
|
17
|
+
* Can be overridden via constructor for testing.
|
|
18
|
+
*/
|
|
19
|
+
const DEFAULT_DISCOVERY_WINDOW_MS = 10_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Derive search terms from a folder name by stripping quality tags,
|
|
23
|
+
* group names, and normalizing separators.
|
|
24
|
+
*
|
|
25
|
+
* Examples:
|
|
26
|
+
* "Dune (2021) [2160p]" → { title: "Dune", year: "2021", clean: "Dune" }
|
|
27
|
+
* "The.Matrix.1999.1080p" → { title: "The Matrix", year: "1999", clean: "The Matrix" }
|
|
28
|
+
* "A.Movie.Name.2020.REMASTERED.1080p.BluRay.x264-GROUP"
|
|
29
|
+
* → { title: "A Movie Name", year: "2020", clean: "A Movie Name" }
|
|
30
|
+
*/
|
|
31
|
+
export function deriveSearchTerms(
|
|
32
|
+
folderName: string,
|
|
33
|
+
): { title: string; year?: string; cleanTitle: string } {
|
|
34
|
+
let name = folderName;
|
|
35
|
+
|
|
36
|
+
// Step 1: Remove content in square brackets (quality tags, group names)
|
|
37
|
+
name = name.replace(/\[.*?\]/g, "");
|
|
38
|
+
|
|
39
|
+
// Step 2: Extract year from parentheses (YYYY) and remove them
|
|
40
|
+
let year: string | undefined;
|
|
41
|
+
const parenYearMatch = name.match(/\((\d{4})\)/);
|
|
42
|
+
if (parenYearMatch) {
|
|
43
|
+
year = parenYearMatch[1];
|
|
44
|
+
name = name.replace(/\(\s*\d{4}\s*\)/, "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Step 3: Replace dots and underscores with spaces
|
|
48
|
+
name = name.replace(/[._]/g, " ");
|
|
49
|
+
|
|
50
|
+
// Step 4: Remove trailing scene group tag: -GROUP at end
|
|
51
|
+
name = name.replace(/\s*-\s*\w+$/, "");
|
|
52
|
+
|
|
53
|
+
// Step 5: Remove common quality/resolution/format tags (as whole words)
|
|
54
|
+
name = name.replace(
|
|
55
|
+
/\s*(2160p|1080p|720p|480p|BluRay|WEB-?DL|WEBRip|HDRip|DVDRip|BRRip|x264|x265|HEVC|AAC|DTS|AC3|REMASTERED|EXTENDED|UNCUT|REMUX|PROPER|REPACK|INTERNAL|DIRECTORS?\s*CUT)\s*/gi,
|
|
56
|
+
" ",
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
// Step 6: If no year found yet, look for a bare 4-digit year (1900-2099)
|
|
60
|
+
// that appears as a separate token
|
|
61
|
+
if (!year) {
|
|
62
|
+
const bareYearMatch = name.match(/(?:^|\s)((?:19|20)\d{2})(?:$|\s)/);
|
|
63
|
+
if (bareYearMatch) {
|
|
64
|
+
year = bareYearMatch[1];
|
|
65
|
+
name = name.replace(bareYearMatch[0], " ");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Step 7: Collapse whitespace and trim
|
|
70
|
+
const cleanTitle = name.replace(/\s+/g, " ").trim();
|
|
71
|
+
|
|
72
|
+
return { title: cleanTitle, year, cleanTitle };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Simple title similarity using Jaro-Winkler-like comparison.
|
|
77
|
+
* Returns a score between 0 (completely different) and 1 (exact match).
|
|
78
|
+
*/
|
|
79
|
+
function titleSimilarity(a: string, b: string): number {
|
|
80
|
+
const aLower = a.toLowerCase().trim();
|
|
81
|
+
const bLower = b.toLowerCase().trim();
|
|
82
|
+
|
|
83
|
+
if (aLower === bLower) return 1.0;
|
|
84
|
+
|
|
85
|
+
// Check if one contains the other
|
|
86
|
+
if (aLower.includes(bLower) || bLower.includes(aLower)) {
|
|
87
|
+
const shorter = Math.min(a.length, b.length);
|
|
88
|
+
const longer = Math.max(a.length, b.length);
|
|
89
|
+
return shorter / longer;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Simple character overlap
|
|
93
|
+
const aChars = new Set(aLower.split(""));
|
|
94
|
+
const bChars = new Set(bLower.split(""));
|
|
95
|
+
const intersection = new Set([...aChars].filter((c) => bChars.has(c)));
|
|
96
|
+
const union = new Set([...aChars, ...bChars]);
|
|
97
|
+
|
|
98
|
+
return intersection.size / union.size;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class MetadataMatcher {
|
|
102
|
+
private bus: EventBus;
|
|
103
|
+
private contentTypeRegistry: ContentTypeRegistryImpl;
|
|
104
|
+
private logger: Logger;
|
|
105
|
+
private discoveryWindowMs: number;
|
|
106
|
+
|
|
107
|
+
constructor(
|
|
108
|
+
bus: EventBus,
|
|
109
|
+
contentTypeRegistry: ContentTypeRegistryImpl,
|
|
110
|
+
logger: Logger,
|
|
111
|
+
discoveryWindowMs = DEFAULT_DISCOVERY_WINDOW_MS,
|
|
112
|
+
) {
|
|
113
|
+
this.bus = bus;
|
|
114
|
+
this.contentTypeRegistry = contentTypeRegistry;
|
|
115
|
+
this.logger = logger.child("metadata-matcher");
|
|
116
|
+
this.discoveryWindowMs = discoveryWindowMs;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Attempt to find metadata matches for a discovered group.
|
|
121
|
+
* Returns up to 5 scored candidates, or an empty array if no match found.
|
|
122
|
+
* The first result is the best match. Also supports custom search queries.
|
|
123
|
+
*/
|
|
124
|
+
async findMatches(group: DiscoveredGroup): Promise<MatchResult[]> {
|
|
125
|
+
const { folderName, contentTypeId } = group;
|
|
126
|
+
const searchTerms = deriveSearchTerms(folderName);
|
|
127
|
+
const contentTypeIds =
|
|
128
|
+
contentTypeId !== "unknown" ? [contentTypeId] : undefined;
|
|
129
|
+
|
|
130
|
+
if (!searchTerms.cleanTitle) {
|
|
131
|
+
this.logger.debug("empty search terms for folder", { folderName });
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const terms = [searchTerms.cleanTitle];
|
|
136
|
+
if (searchTerms.year) {
|
|
137
|
+
terms.push(searchTerms.year);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return this.searchAndScore(terms, contentTypeIds, folderName);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Run a custom search for a folder and return scored results.
|
|
145
|
+
* Used when the user clicks "Search again" in the UI.
|
|
146
|
+
*/
|
|
147
|
+
async searchCustom(
|
|
148
|
+
folderName: string,
|
|
149
|
+
query: string,
|
|
150
|
+
contentTypeId?: string,
|
|
151
|
+
): Promise<MatchResult[]> {
|
|
152
|
+
const contentTypeIds =
|
|
153
|
+
contentTypeId && contentTypeId !== "unknown" ? [contentTypeId] : undefined;
|
|
154
|
+
const terms = query.split(/\s+/).filter(Boolean);
|
|
155
|
+
if (terms.length === 0) return [];
|
|
156
|
+
return this.searchAndScore(terms, contentTypeIds, folderName);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Low-level: emit discovery.search, collect results, score, return top N.
|
|
161
|
+
*/
|
|
162
|
+
private async searchAndScore(
|
|
163
|
+
terms: string[],
|
|
164
|
+
contentTypeIds: string[] | undefined,
|
|
165
|
+
folderName: string,
|
|
166
|
+
): Promise<MatchResult[]> {
|
|
167
|
+
if (!terms.length) return [];
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const results = await this.collectResults(terms, contentTypeIds);
|
|
171
|
+
|
|
172
|
+
if (results.length === 0) {
|
|
173
|
+
this.logger.debug("no metadata results for folder", { folderName });
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Score each result and pick the best
|
|
178
|
+
const searchTerms = deriveSearchTerms(folderName);
|
|
179
|
+
const scored = results
|
|
180
|
+
.map((r) => ({
|
|
181
|
+
item: r.item as ContentItemBase,
|
|
182
|
+
source: r.source,
|
|
183
|
+
confidence: this.scoreConfidence(r.item, searchTerms),
|
|
184
|
+
}))
|
|
185
|
+
.sort((a, b) => b.confidence - a.confidence);
|
|
186
|
+
|
|
187
|
+
const aboveThreshold = scored.filter((m) => m.confidence >= 0.1);
|
|
188
|
+
|
|
189
|
+
if (aboveThreshold.length === 0) {
|
|
190
|
+
this.logger.debug("all matches below confidence threshold", {
|
|
191
|
+
folderName,
|
|
192
|
+
});
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
this.logger.debug("metadata matches found", {
|
|
197
|
+
folderName,
|
|
198
|
+
count: aboveThreshold.length,
|
|
199
|
+
best: aboveThreshold[0].item.title,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Return top 5
|
|
203
|
+
return aboveThreshold.slice(0, 5);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
this.logger.error("metadata matching failed", {
|
|
206
|
+
folderName,
|
|
207
|
+
error: String(err),
|
|
208
|
+
});
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Emit discovery.search and collect results within the timeout window.
|
|
215
|
+
*/
|
|
216
|
+
private async collectResults(
|
|
217
|
+
terms: string[],
|
|
218
|
+
contentTypeIds?: string[],
|
|
219
|
+
): Promise<Array<{ item: ContentItemBase; source: string }>> {
|
|
220
|
+
const results: Array<{ item: ContentItemBase; source: string }> = [];
|
|
221
|
+
|
|
222
|
+
return new Promise<Array<{ item: ContentItemBase; source: string }>>(
|
|
223
|
+
(resolve) => {
|
|
224
|
+
const unsub = this.bus.on(
|
|
225
|
+
"discovery.results",
|
|
226
|
+
(event: StarrEvent<{ contentTypeId: string; items: ContentItemBase[] }>) => {
|
|
227
|
+
for (const item of event.payload.items) {
|
|
228
|
+
results.push({ item, source: event.source });
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// Timeout to stop collecting
|
|
234
|
+
const timer = setTimeout(() => {
|
|
235
|
+
unsub();
|
|
236
|
+
resolve(results);
|
|
237
|
+
}, this.discoveryWindowMs);
|
|
238
|
+
|
|
239
|
+
// Also stop on first result to reduce latency
|
|
240
|
+
// (we collect all within the window and score later)
|
|
241
|
+
// No early return — we want to collect all candidates for scoring.
|
|
242
|
+
|
|
243
|
+
// Emit the search
|
|
244
|
+
this.bus.emit("discovery.search", {
|
|
245
|
+
query: terms.join(" "),
|
|
246
|
+
contentTypeIds,
|
|
247
|
+
offset: 0,
|
|
248
|
+
limit: 10,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Ensure cleanup if window expires
|
|
252
|
+
timer.unref?.();
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Score a matched item against the search terms.
|
|
259
|
+
* Returns a value between 0 and 1.
|
|
260
|
+
*/
|
|
261
|
+
private scoreConfidence(
|
|
262
|
+
item: ContentItemBase,
|
|
263
|
+
search: { title: string; year?: string; cleanTitle: string },
|
|
264
|
+
): number {
|
|
265
|
+
let score = 0;
|
|
266
|
+
|
|
267
|
+
// Title similarity (0-0.6)
|
|
268
|
+
const sim = titleSimilarity(item.title, search.cleanTitle);
|
|
269
|
+
score += sim * 0.6;
|
|
270
|
+
|
|
271
|
+
// Year match bonus (0-0.2)
|
|
272
|
+
if (search.year) {
|
|
273
|
+
const itemYear = item.releaseDate?.slice(0, 4);
|
|
274
|
+
if (itemYear === search.year) {
|
|
275
|
+
score += 0.2;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// External ID bonus (0-0.2) — items with more IDs are more complete matches
|
|
280
|
+
const extIdCount = Object.keys(item.externalIds || {}).length;
|
|
281
|
+
score += Math.min(extIdCount / 5, 0.2);
|
|
282
|
+
|
|
283
|
+
return Math.min(score, 1.0);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// SPDX-License-Identifier: 0BSD
|
|
2
|
+
// SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
|
|
3
|
+
|
|
4
|
+
import type { ContentItemBase, NamingRule, NamingSegment } from "@digitalpaws/starr-types-core";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve a naming rule against an item's field values.
|
|
8
|
+
* Returns a relative path like "Movies/Dune (2021)/Dune (2021) [2160p].mkv".
|
|
9
|
+
* The caller joins this with the library root and appends the actual extension.
|
|
10
|
+
*/
|
|
11
|
+
export function resolveNaming(
|
|
12
|
+
rule: NamingRule,
|
|
13
|
+
item: ContentItemBase,
|
|
14
|
+
extension: string,
|
|
15
|
+
): string {
|
|
16
|
+
const resolveSegments = (segments: NamingSegment[][]): string => {
|
|
17
|
+
return segments
|
|
18
|
+
.map((segmentGroup) =>
|
|
19
|
+
segmentGroup
|
|
20
|
+
.map((seg) => {
|
|
21
|
+
if (seg.type === "literal") return seg.value;
|
|
22
|
+
return resolveField(seg.value, item);
|
|
23
|
+
})
|
|
24
|
+
.join(""),
|
|
25
|
+
)
|
|
26
|
+
.join(" ");
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const dirName = resolveSegments(rule.directory);
|
|
30
|
+
const baseName = resolveSegments(rule.filename);
|
|
31
|
+
|
|
32
|
+
return `${dirName}/${baseName}.${extension}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveField(fieldName: string, item: ContentItemBase): string {
|
|
36
|
+
if (fieldName === "Title") return sanitise(item.title);
|
|
37
|
+
if (fieldName === "Year") {
|
|
38
|
+
const year = (item as any).year ?? (item as any).releaseDate?.slice(0, 4);
|
|
39
|
+
return year ? String(year) : "";
|
|
40
|
+
}
|
|
41
|
+
if (fieldName === "ContentType") return item.contentTypeId;
|
|
42
|
+
|
|
43
|
+
if (item.externalIds && fieldName in item.externalIds) {
|
|
44
|
+
return sanitise(String((item.externalIds as Record<string, unknown>)[fieldName]));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const fields = (item as any).fields as Record<string, unknown> | undefined;
|
|
48
|
+
if (fields && fieldName in fields) {
|
|
49
|
+
return sanitise(String(fields[fieldName]));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Remove characters that are problematic in filenames. */
|
|
56
|
+
function sanitise(s: string): string {
|
|
57
|
+
// eslint-disable-next-line no-control-regex
|
|
58
|
+
return s.replace(/[<>:"\\|?*\x00-\x1f]/g, "").replace(/\s+/g, " ").trim();
|
|
59
|
+
}
|