@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,328 @@
|
|
|
1
|
+
// SPDX-License-Identifier: 0BSD
|
|
2
|
+
// SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
|
5
|
+
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import Database from "better-sqlite3";
|
|
9
|
+
import { createLogger } from "../src/logging/logger.js";
|
|
10
|
+
import { runMigrations, CORE_MIGRATIONS } from "../src/datastore/migrations.js";
|
|
11
|
+
import { LibraryScanner } from "../src/library/library-scanner.js";
|
|
12
|
+
import { MetadataMatcher } from "../src/library/metadata-matcher.js";
|
|
13
|
+
import { ScanController } from "../src/library/scan-controller.js";
|
|
14
|
+
import { LibraryCache } from "../src/library/library-cache.js";
|
|
15
|
+
import { EventBusImpl } from "../src/events/event-bus.js";
|
|
16
|
+
import { ContentTypeRegistryImpl } from "../src/content-types/registry.js";
|
|
17
|
+
import type { LibraryConfig } from "../src/library/library-config.js";
|
|
18
|
+
import type { ContentItemBase } from "@digitalpaws/starr-types-core";
|
|
19
|
+
|
|
20
|
+
let tmpDir: string;
|
|
21
|
+
|
|
22
|
+
beforeAll(async () => {
|
|
23
|
+
tmpDir = await mkdtemp(path.join(tmpdir(), "starr-scan-ctrl-test-"));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterAll(async () => {
|
|
27
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function setup() {
|
|
31
|
+
const db = new Database(":memory:");
|
|
32
|
+
db.pragma("journal_mode = WAL");
|
|
33
|
+
db.pragma("foreign_keys = ON");
|
|
34
|
+
const logger = createLogger("test", "silent");
|
|
35
|
+
runMigrations(db, CORE_MIGRATIONS, logger);
|
|
36
|
+
|
|
37
|
+
// Create library structure with unique subdir per test call
|
|
38
|
+
const moviesDir = path.join(tmpDir, `Movies-${Math.random().toString(36).slice(2, 8)}`);
|
|
39
|
+
const duneDir = path.join(moviesDir, "Dune (2021)");
|
|
40
|
+
mkdirSync(moviesDir, { recursive: true });
|
|
41
|
+
mkdirSync(duneDir, { recursive: true });
|
|
42
|
+
writeFileSync(path.join(duneDir, "Dune (2021) [2160p].mkv"), "content");
|
|
43
|
+
|
|
44
|
+
const getConfig = (): LibraryConfig => ({
|
|
45
|
+
defaultLibraryRoot: moviesDir,
|
|
46
|
+
roots: { "starr-video/movie@1": moviesDir },
|
|
47
|
+
linkType: "copy",
|
|
48
|
+
importSubtitles: false,
|
|
49
|
+
subtitleExtensions: [],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const impl = new EventBusImpl(logger);
|
|
53
|
+
const coreBus = impl.coreBus();
|
|
54
|
+
const registry = new ContentTypeRegistryImpl();
|
|
55
|
+
registry.register({
|
|
56
|
+
id: "starr-video/movie@1",
|
|
57
|
+
name: "Movie",
|
|
58
|
+
identity: ["tmdb", "imdb"],
|
|
59
|
+
fields: [],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const scanner = new LibraryScanner(db, logger, getConfig);
|
|
63
|
+
const matcher = new MetadataMatcher(coreBus, registry, logger, 100); // short timeout for tests
|
|
64
|
+
const cache = new LibraryCache(db, coreBus, logger);
|
|
65
|
+
|
|
66
|
+
const controller = new ScanController(
|
|
67
|
+
scanner,
|
|
68
|
+
matcher,
|
|
69
|
+
db,
|
|
70
|
+
coreBus,
|
|
71
|
+
logger,
|
|
72
|
+
cache,
|
|
73
|
+
registry,
|
|
74
|
+
getConfig,
|
|
75
|
+
1, // concurrency = 1 for deterministic tests
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return { db, coreBus, controller, registry, moviesDir };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Need sync versions for setup
|
|
82
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
83
|
+
|
|
84
|
+
describe("ScanController", () => {
|
|
85
|
+
it("starts a scan and returns scanId", async () => {
|
|
86
|
+
const { controller } = setup();
|
|
87
|
+
const scanId = controller.startScan();
|
|
88
|
+
expect(scanId).toBeDefined();
|
|
89
|
+
expect(typeof scanId).toBe("string");
|
|
90
|
+
|
|
91
|
+
// Give the scan time to discover groups (sync part happens first tick)
|
|
92
|
+
const state = controller.getScan(scanId);
|
|
93
|
+
expect(state).toBeDefined();
|
|
94
|
+
expect(state!.scanId).toBe(scanId);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns scan progress with candidates", { timeout: 15_000 }, async () => {
|
|
98
|
+
const { controller } = setup();
|
|
99
|
+
const scanId = controller.startScan();
|
|
100
|
+
|
|
101
|
+
// Wait for scan to complete
|
|
102
|
+
await vi.waitFor(
|
|
103
|
+
() => {
|
|
104
|
+
const state = controller.getScan(scanId);
|
|
105
|
+
expect(state?.status).toBe("complete");
|
|
106
|
+
},
|
|
107
|
+
{ timeout: 15_000, interval: 500 },
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const state = controller.getScan(scanId)!;
|
|
111
|
+
expect(state.progress.total).toBeGreaterThanOrEqual(1);
|
|
112
|
+
expect(state.candidates.length).toBeGreaterThanOrEqual(1);
|
|
113
|
+
expect(state.candidates[0].directory).toContain("Dune (2021)");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("imports accepted candidates into the library", { timeout: 15_000 }, async () => {
|
|
117
|
+
const { controller, db } = setup();
|
|
118
|
+
const scanId = controller.startScan();
|
|
119
|
+
|
|
120
|
+
// Wait for scan to complete
|
|
121
|
+
await vi.waitFor(
|
|
122
|
+
() => {
|
|
123
|
+
const state = controller.getScan(scanId);
|
|
124
|
+
expect(state?.status).toBe("complete");
|
|
125
|
+
},
|
|
126
|
+
{ timeout: 15_000, interval: 500 },
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const state = controller.getScan(scanId)!;
|
|
130
|
+
const candidateIds = state.candidates
|
|
131
|
+
.filter((c) => c.status === "matched" || c.status === "no-match")
|
|
132
|
+
.map((c) => c.id);
|
|
133
|
+
|
|
134
|
+
if (candidateIds.length > 0) {
|
|
135
|
+
const result = controller.importCandidates(scanId, candidateIds);
|
|
136
|
+
expect(result.imported).toBeGreaterThanOrEqual(1);
|
|
137
|
+
|
|
138
|
+
// Check that tracked_requests has the new entry
|
|
139
|
+
const row = db
|
|
140
|
+
.prepare("SELECT status, library_path FROM tracked_requests WHERE content_identity LIKE ?")
|
|
141
|
+
.get(`%Dune%`) as any;
|
|
142
|
+
expect(row).toBeDefined();
|
|
143
|
+
expect(row.status).toBe("downloaded");
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("returns empty result for unknown scanId", () => {
|
|
148
|
+
const { controller } = setup();
|
|
149
|
+
const result = controller.importCandidates("nonexistent", ["candidate-1"]);
|
|
150
|
+
expect(result.imported).toBe(0);
|
|
151
|
+
expect(result.failed).toBe(0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("cancelScan returns false for unknown scanId", () => {
|
|
155
|
+
const { controller } = setup();
|
|
156
|
+
const result = controller.cancelScan("nonexistent");
|
|
157
|
+
expect(result).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("candidates have matches array after scan completes", { timeout: 15_000 }, async () => {
|
|
161
|
+
const { controller, coreBus } = setup();
|
|
162
|
+
|
|
163
|
+
// Register a mock metadata plugin so matches are found
|
|
164
|
+
coreBus.on("discovery.search", (event: any) => {
|
|
165
|
+
coreBus.emit("discovery.results", {
|
|
166
|
+
contentTypeId: "starr-video/movie@1",
|
|
167
|
+
items: [
|
|
168
|
+
{
|
|
169
|
+
contentTypeId: "starr-video/movie@1",
|
|
170
|
+
title: "Dune",
|
|
171
|
+
releaseDate: "2021-10-22",
|
|
172
|
+
externalIds: { tmdb: "438631" },
|
|
173
|
+
images: [],
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const scanId = controller.startScan();
|
|
180
|
+
|
|
181
|
+
await vi.waitFor(
|
|
182
|
+
() => {
|
|
183
|
+
const state = controller.getScan(scanId);
|
|
184
|
+
expect(state?.status).toBe("complete");
|
|
185
|
+
},
|
|
186
|
+
{ timeout: 15_000, interval: 500 },
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const state = controller.getScan(scanId)!;
|
|
190
|
+
const candidate = state.candidates[0];
|
|
191
|
+
expect(candidate).toBeDefined();
|
|
192
|
+
expect(Array.isArray(candidate.matches)).toBe(true);
|
|
193
|
+
expect(candidate.matches.length).toBeGreaterThanOrEqual(1);
|
|
194
|
+
expect(candidate.selectedMatchIndex).toBe(0);
|
|
195
|
+
expect(candidate.matches[0].item.title).toBe("Dune");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("selectMatch changes the selected match index", { timeout: 15_000 }, async () => {
|
|
199
|
+
const { controller, coreBus } = setup();
|
|
200
|
+
|
|
201
|
+
// Register a mock metadata plugin returning multiple results
|
|
202
|
+
coreBus.on("discovery.search", (event: any) => {
|
|
203
|
+
coreBus.emit("discovery.results", {
|
|
204
|
+
contentTypeId: "starr-video/movie@1",
|
|
205
|
+
items: [
|
|
206
|
+
{
|
|
207
|
+
contentTypeId: "starr-video/movie@1",
|
|
208
|
+
title: "Dune",
|
|
209
|
+
releaseDate: "2021-10-22",
|
|
210
|
+
externalIds: { tmdb: "438631" },
|
|
211
|
+
images: [],
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
contentTypeId: "starr-video/movie@1",
|
|
215
|
+
title: "Dune: Part Two",
|
|
216
|
+
releaseDate: "2024-03-01",
|
|
217
|
+
externalIds: { tmdb: "693134" },
|
|
218
|
+
images: [],
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const scanId = controller.startScan();
|
|
225
|
+
|
|
226
|
+
await vi.waitFor(
|
|
227
|
+
() => {
|
|
228
|
+
const state = controller.getScan(scanId);
|
|
229
|
+
expect(state?.status).toBe("complete");
|
|
230
|
+
},
|
|
231
|
+
{ timeout: 15_000, interval: 500 },
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const state = controller.getScan(scanId)!;
|
|
235
|
+
const candidateId = state.candidates[0].id;
|
|
236
|
+
|
|
237
|
+
// Select the second match
|
|
238
|
+
const ok = controller.selectMatch(scanId, candidateId, 1);
|
|
239
|
+
expect(ok).toBe(true);
|
|
240
|
+
|
|
241
|
+
const updated = controller.getScan(scanId)!;
|
|
242
|
+
const candidate = updated.candidates[0];
|
|
243
|
+
expect(candidate.selectedMatchIndex).toBe(1);
|
|
244
|
+
expect(candidate.matches[1].item.title).toBe("Dune: Part Two");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("selectMatch rejects out-of-range index", { timeout: 15_000 }, async () => {
|
|
248
|
+
const { controller, coreBus } = setup();
|
|
249
|
+
|
|
250
|
+
coreBus.on("discovery.search", (event: any) => {
|
|
251
|
+
coreBus.emit("discovery.results", {
|
|
252
|
+
contentTypeId: "starr-video/movie@1",
|
|
253
|
+
items: [
|
|
254
|
+
{
|
|
255
|
+
contentTypeId: "starr-video/movie@1",
|
|
256
|
+
title: "Dune",
|
|
257
|
+
releaseDate: "2021-10-22",
|
|
258
|
+
externalIds: { tmdb: "438631" },
|
|
259
|
+
images: [],
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const scanId = controller.startScan();
|
|
266
|
+
|
|
267
|
+
await vi.waitFor(
|
|
268
|
+
() => {
|
|
269
|
+
const state = controller.getScan(scanId);
|
|
270
|
+
expect(state?.status).toBe("complete");
|
|
271
|
+
},
|
|
272
|
+
{ timeout: 15_000, interval: 500 },
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const state = controller.getScan(scanId)!;
|
|
276
|
+
const candidateId = state.candidates[0].id;
|
|
277
|
+
|
|
278
|
+
// Out-of-range index should fail
|
|
279
|
+
const ok = controller.selectMatch(scanId, candidateId, 99);
|
|
280
|
+
expect(ok).toBe(false);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("searchCandidate updates matches list", { timeout: 15_000 }, async () => {
|
|
284
|
+
const { controller, coreBus } = setup();
|
|
285
|
+
|
|
286
|
+
// No metadata plugin for initial scan — will get no matches
|
|
287
|
+
const scanId = controller.startScan();
|
|
288
|
+
|
|
289
|
+
await vi.waitFor(
|
|
290
|
+
() => {
|
|
291
|
+
const state = controller.getScan(scanId);
|
|
292
|
+
expect(state?.status).toBe("complete");
|
|
293
|
+
},
|
|
294
|
+
{ timeout: 15_000, interval: 500 },
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
let state = controller.getScan(scanId)!;
|
|
298
|
+
const candidateId = state.candidates[0].id;
|
|
299
|
+
|
|
300
|
+
// Initial state: no matches
|
|
301
|
+
expect(state.candidates[0].matches.length).toBe(0);
|
|
302
|
+
|
|
303
|
+
// Now register a metadata plugin and run custom search
|
|
304
|
+
coreBus.on("discovery.search", (event: any) => {
|
|
305
|
+
coreBus.emit("discovery.results", {
|
|
306
|
+
contentTypeId: "starr-video/movie@1",
|
|
307
|
+
items: [
|
|
308
|
+
{
|
|
309
|
+
contentTypeId: "starr-video/movie@1",
|
|
310
|
+
title: "Dune",
|
|
311
|
+
releaseDate: "2021-10-22",
|
|
312
|
+
externalIds: { tmdb: "438631" },
|
|
313
|
+
images: [],
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const ok = await controller.searchCandidate(scanId, candidateId, "Dune 2021");
|
|
320
|
+
expect(ok).toBe(true);
|
|
321
|
+
|
|
322
|
+
state = controller.getScan(scanId)!;
|
|
323
|
+
const candidate = state.candidates[0];
|
|
324
|
+
expect(candidate.matches.length).toBeGreaterThanOrEqual(1);
|
|
325
|
+
expect(candidate.matches[0].item.title).toBe("Dune");
|
|
326
|
+
expect(candidate.status).toBe("matched");
|
|
327
|
+
});
|
|
328
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// SPDX-License-Identifier: 0BSD
|
|
2
|
+
// SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
5
|
+
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { resolveSourcePath } from "../src/library/source-resolver.js";
|
|
9
|
+
|
|
10
|
+
let tmpDir: string;
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
tmpDir = await mkdtemp(path.join(tmpdir(), "starr-source-resolver-"));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterAll(async () => {
|
|
17
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("resolveSourcePath", () => {
|
|
21
|
+
it("resolves a single file path", async () => {
|
|
22
|
+
const filePath = path.join(tmpDir, "test.mkv");
|
|
23
|
+
await writeFile(filePath, "content");
|
|
24
|
+
|
|
25
|
+
const result = await resolveSourcePath(`qbittorrent:${filePath}`);
|
|
26
|
+
expect(result.files).toHaveLength(1);
|
|
27
|
+
expect(result.files[0]).toBe(filePath);
|
|
28
|
+
expect(result.sourceDir).toBe(tmpDir);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("resolves a directory by scanning for media files", async () => {
|
|
32
|
+
const mediaDir = path.join(tmpDir, "multi");
|
|
33
|
+
await mkdir(mediaDir, { recursive: true });
|
|
34
|
+
await writeFile(path.join(mediaDir, "movie.mkv"), "movie");
|
|
35
|
+
await writeFile(path.join(mediaDir, "sample.mp4"), "sample");
|
|
36
|
+
await writeFile(path.join(mediaDir, "info.nfo"), "info"); // non-media, skipped
|
|
37
|
+
|
|
38
|
+
const result = await resolveSourcePath(`qbittorrent:${mediaDir}`);
|
|
39
|
+
expect(result.files).toHaveLength(2);
|
|
40
|
+
expect(result.files).toContain(path.join(mediaDir, "movie.mkv"));
|
|
41
|
+
expect(result.files).toContain(path.join(mediaDir, "sample.mp4"));
|
|
42
|
+
expect(result.sourceDir).toBe(mediaDir);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("throws when location is not provided", async () => {
|
|
46
|
+
await expect(resolveSourcePath(undefined)).rejects.toThrow(
|
|
47
|
+
"no location provided",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("throws when path does not exist", async () => {
|
|
52
|
+
await expect(
|
|
53
|
+
resolveSourcePath(`protocol:/nonexistent/path/file.mkv`),
|
|
54
|
+
).rejects.toThrow("Cannot access source path");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("handles protocol-prefixed and non-prefixed paths", async () => {
|
|
58
|
+
const filePath = path.join(tmpDir, "noprefix.mkv");
|
|
59
|
+
await writeFile(filePath, "content");
|
|
60
|
+
|
|
61
|
+
// With protocol prefix
|
|
62
|
+
const result1 = await resolveSourcePath(`qbittorrent:${filePath}`);
|
|
63
|
+
expect(result1.files).toHaveLength(1);
|
|
64
|
+
|
|
65
|
+
// Without protocol prefix
|
|
66
|
+
const result2 = await resolveSourcePath(filePath);
|
|
67
|
+
expect(result2.files).toHaveLength(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("skips hidden directories and Extras/Sample folders", async () => {
|
|
71
|
+
const mediaDir = path.join(tmpDir, "skips");
|
|
72
|
+
await mkdir(mediaDir, { recursive: true });
|
|
73
|
+
await writeFile(path.join(mediaDir, "main.mkv"), "main");
|
|
74
|
+
|
|
75
|
+
// Hidden dir
|
|
76
|
+
const hiddenDir = path.join(mediaDir, ".hidden");
|
|
77
|
+
await mkdir(hiddenDir, { recursive: true });
|
|
78
|
+
await writeFile(path.join(hiddenDir, "hidden.mkv"), "hidden");
|
|
79
|
+
|
|
80
|
+
// Extras dir
|
|
81
|
+
const extrasDir = path.join(mediaDir, "Extras");
|
|
82
|
+
await mkdir(extrasDir, { recursive: true });
|
|
83
|
+
await writeFile(path.join(extrasDir, "extra.mkv"), "extra");
|
|
84
|
+
|
|
85
|
+
const result = await resolveSourcePath(`qbittorrent:${mediaDir}`);
|
|
86
|
+
// Should only find main.mkv, not hidden/extra
|
|
87
|
+
expect(result.files).toHaveLength(1);
|
|
88
|
+
expect(result.files[0]).toBe(path.join(mediaDir, "main.mkv"));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("throws when a directory has no media files", async () => {
|
|
92
|
+
const emptyDir = path.join(tmpDir, "empty");
|
|
93
|
+
await mkdir(emptyDir, { recursive: true });
|
|
94
|
+
await writeFile(path.join(emptyDir, "readme.txt"), "not media");
|
|
95
|
+
|
|
96
|
+
await expect(
|
|
97
|
+
resolveSourcePath(`qbittorrent:${emptyDir}`),
|
|
98
|
+
).rejects.toThrow("No media files found in directory");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// SPDX-License-Identifier: 0BSD
|
|
2
|
+
// SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
5
|
+
import Database from "better-sqlite3";
|
|
6
|
+
import { runMigrations, CORE_MIGRATIONS } from "../src/datastore/migrations.js";
|
|
7
|
+
import { createLogger } from "../src/logging/logger.js";
|
|
8
|
+
import { createContext } from "../src/plugins/context.js";
|
|
9
|
+
import { Starr } from "../src/host.js";
|
|
10
|
+
import {
|
|
11
|
+
ContentTypeRegistryImpl,
|
|
12
|
+
computeContentIdentity,
|
|
13
|
+
areSameItem,
|
|
14
|
+
} from "../src/content-types/registry.js";
|
|
15
|
+
|
|
16
|
+
// Import the real descriptors to test them end-to-end
|
|
17
|
+
// (types-only; the plugin module imports at compile time)
|
|
18
|
+
import type { ContentTypeDescriptor } from "@digitalpaws/starr-types-core";
|
|
19
|
+
import type { Movie, Show, Season, Episode } from "@digitalpaws/starr-types-video";
|
|
20
|
+
|
|
21
|
+
describe("starr-video integration", () => {
|
|
22
|
+
let db: Database.Database;
|
|
23
|
+
let registry: ContentTypeRegistryImpl;
|
|
24
|
+
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
db = new Database(":memory:");
|
|
27
|
+
db.pragma("journal_mode = WAL");
|
|
28
|
+
db.pragma("foreign_keys = ON");
|
|
29
|
+
runMigrations(db, CORE_MIGRATIONS, createLogger("test", "silent"));
|
|
30
|
+
registry = new ContentTypeRegistryImpl();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterAll(() => {
|
|
34
|
+
db.close();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ── 3.2.1 ── All four descriptors register without errors
|
|
38
|
+
|
|
39
|
+
it("accepts all four starr-video descriptors", () => {
|
|
40
|
+
const descriptors: ContentTypeDescriptor[] = [
|
|
41
|
+
{
|
|
42
|
+
id: "starr-video/movie@1",
|
|
43
|
+
name: "Movie",
|
|
44
|
+
pluralName: "Movies",
|
|
45
|
+
icon: "film",
|
|
46
|
+
identity: ["tmdb", "imdb"],
|
|
47
|
+
fields: [
|
|
48
|
+
{ key: "genres", label: "Genres", type: "list", itemType: "string", group: "Details", order: 10 },
|
|
49
|
+
{ key: "studio", label: "Studio", type: "string", group: "Details", order: 20 },
|
|
50
|
+
{ key: "releaseStatus", label: "Status", type: "string", group: "Details", order: 30 },
|
|
51
|
+
],
|
|
52
|
+
display: { primaryImageRole: "poster", subtitle: "releaseDate" },
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "starr-video/show@1",
|
|
56
|
+
name: "TV Show",
|
|
57
|
+
pluralName: "TV Shows",
|
|
58
|
+
icon: "tv",
|
|
59
|
+
identity: ["tmdb"],
|
|
60
|
+
fields: [
|
|
61
|
+
{ key: "genres", label: "Genres", type: "list", itemType: "string", group: "Details", order: 10 },
|
|
62
|
+
{ key: "firstAirDate", label: "First Aired", type: "date", group: "Details", order: 20 },
|
|
63
|
+
{ key: "lastAirDate", label: "Last Aired", type: "date", group: "Details", order: 30 },
|
|
64
|
+
{ key: "status", label: "Status", type: "string", group: "Details", order: 40 },
|
|
65
|
+
],
|
|
66
|
+
display: { primaryImageRole: "poster", subtitle: "fields.firstAirDate" },
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "starr-video/season@1",
|
|
70
|
+
name: "Season",
|
|
71
|
+
pluralName: "Seasons",
|
|
72
|
+
icon: "layers",
|
|
73
|
+
identity: ["tmdb"],
|
|
74
|
+
fields: [
|
|
75
|
+
{ key: "seasonNumber", label: "Season No.", type: "number", group: "Details", order: 10 },
|
|
76
|
+
{ key: "episodeCount", label: "Episodes", type: "number", group: "Details", order: 20 },
|
|
77
|
+
{ key: "firstAirDate", label: "First Aired", type: "date", group: "Details", order: 30 },
|
|
78
|
+
],
|
|
79
|
+
display: { primaryImageRole: "poster", subtitle: "fields.seasonNumber" },
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "starr-video/episode@1",
|
|
83
|
+
name: "Episode",
|
|
84
|
+
pluralName: "Episodes",
|
|
85
|
+
icon: "play",
|
|
86
|
+
identity: ["tmdb"],
|
|
87
|
+
fields: [
|
|
88
|
+
{ key: "seasonNumber", label: "Season No.", type: "number", group: "Details", order: 10 },
|
|
89
|
+
{ key: "episodeNumber", label: "Episode No.", type: "number", group: "Details", order: 20 },
|
|
90
|
+
{ key: "airDate", label: "Air Date", type: "date", group: "Details", order: 30 },
|
|
91
|
+
],
|
|
92
|
+
display: { primaryImageRole: "thumbnail", subtitle: "fields.episodeNumber" },
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
for (const desc of descriptors) {
|
|
97
|
+
expect(() => registry.register(desc, "starr-video")).not.toThrow();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
expect(registry.list()).toHaveLength(4);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ── 3.2.2 ── Content identity for TV hierarchy
|
|
104
|
+
|
|
105
|
+
it("identifies a season by its tmdb id", () => {
|
|
106
|
+
const seasonItem = {
|
|
107
|
+
contentTypeId: "starr-video/season@1",
|
|
108
|
+
externalIds: { tmdb: "3624" },
|
|
109
|
+
};
|
|
110
|
+
const identity = computeContentIdentity(seasonItem, registry);
|
|
111
|
+
expect(identity).toBe("starr-video/season@1::tmdb=3624");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("identifies a movie with tmdb primary, imdb fallback", () => {
|
|
115
|
+
const movieWithImdb = {
|
|
116
|
+
contentTypeId: "starr-video/movie@1",
|
|
117
|
+
externalIds: { imdb: "tt1160419" },
|
|
118
|
+
};
|
|
119
|
+
const identity = computeContentIdentity(movieWithImdb, registry);
|
|
120
|
+
// tmdb key is first in identity array but absent → falls to imdb
|
|
121
|
+
expect(identity).toBe("starr-video/movie@1::imdb=tt1160419");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("treats different contentTypeIds as not same (ID2)", () => {
|
|
125
|
+
const movie = {
|
|
126
|
+
contentTypeId: "starr-video/movie@1",
|
|
127
|
+
externalIds: { tmdb: "438631" },
|
|
128
|
+
};
|
|
129
|
+
const show = {
|
|
130
|
+
contentTypeId: "starr-video/show@1",
|
|
131
|
+
externalIds: { tmdb: "438631" }, // same tmdb, different type
|
|
132
|
+
};
|
|
133
|
+
expect(areSameItem(movie, show, registry)).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── 3.2.3 ── All descriptors pass host validation
|
|
137
|
+
|
|
138
|
+
it("all descriptors have non-empty identity", () => {
|
|
139
|
+
for (const desc of registry.list()) {
|
|
140
|
+
expect(desc.identity.length).toBeGreaterThan(0);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("season and episode have a parent relationship (by convention)", () => {
|
|
145
|
+
// The descriptor doesn't enforce parent; producers must set it.
|
|
146
|
+
// This test documents the expectation.
|
|
147
|
+
const season = registry.get("starr-video/season@1")!;
|
|
148
|
+
const episode = registry.get("starr-video/episode@1")!;
|
|
149
|
+
expect(season.id).toBe("starr-video/season@1");
|
|
150
|
+
expect(episode.id).toBe("starr-video/episode@1");
|
|
151
|
+
// Both exist — their items should carry parent at runtime.
|
|
152
|
+
});
|
|
153
|
+
});
|