@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,490 @@
|
|
|
1
|
+
// SPDX-License-Identifier: 0BSD
|
|
2
|
+
// SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
|
|
3
|
+
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import type Database from "better-sqlite3";
|
|
7
|
+
import type { EventBus, Logger } from "../types/plugin.js";
|
|
8
|
+
import type { LibraryScanCandidate, ContentItemBase } from "@digitalpaws/starr-types-core";
|
|
9
|
+
import { LibraryScanner, type DiscoveredGroup } from "./library-scanner.js";
|
|
10
|
+
import { MetadataMatcher } from "./metadata-matcher.js";
|
|
11
|
+
import { LibraryCache } from "./library-cache.js";
|
|
12
|
+
import type { ContentTypeRegistryImpl } from "../content-types/registry.js";
|
|
13
|
+
import { computeContentIdentity } from "../content-types/registry.js";
|
|
14
|
+
import type { Importer } from "./importer.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* In-memory scan state, keyed by scanId.
|
|
18
|
+
*/
|
|
19
|
+
interface ScanState {
|
|
20
|
+
scanId: string;
|
|
21
|
+
status: "scanning" | "matching" | "complete" | "cancelled";
|
|
22
|
+
progress: {
|
|
23
|
+
total: number;
|
|
24
|
+
completed: number;
|
|
25
|
+
matched: number;
|
|
26
|
+
noMatch: number;
|
|
27
|
+
alreadyImported: number;
|
|
28
|
+
};
|
|
29
|
+
candidates: LibraryScanCandidate[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const activeScans = new Map<string, ScanState>();
|
|
33
|
+
|
|
34
|
+
const DEFAULT_CONCURRENCY = 3;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Simple semaphore for concurrency control.
|
|
38
|
+
*/
|
|
39
|
+
class Semaphore {
|
|
40
|
+
private current = 0;
|
|
41
|
+
private queue: (() => void)[] = [];
|
|
42
|
+
|
|
43
|
+
constructor(private max: number) {}
|
|
44
|
+
|
|
45
|
+
async acquire(): Promise<void> {
|
|
46
|
+
if (this.current < this.max) {
|
|
47
|
+
this.current++;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
this.queue.push(resolve);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
release(): void {
|
|
56
|
+
if (this.queue.length > 0) {
|
|
57
|
+
const next = this.queue.shift()!;
|
|
58
|
+
next();
|
|
59
|
+
} else {
|
|
60
|
+
this.current--;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class ScanController {
|
|
66
|
+
private scanner: LibraryScanner;
|
|
67
|
+
private matcher: MetadataMatcher;
|
|
68
|
+
private db: Database.Database;
|
|
69
|
+
private bus: EventBus;
|
|
70
|
+
private logger: Logger;
|
|
71
|
+
private libraryCache: LibraryCache;
|
|
72
|
+
private contentTypeRegistry: ContentTypeRegistryImpl;
|
|
73
|
+
private importer: Importer;
|
|
74
|
+
private concurrency: number;
|
|
75
|
+
|
|
76
|
+
constructor(
|
|
77
|
+
scanner: LibraryScanner,
|
|
78
|
+
matcher: MetadataMatcher,
|
|
79
|
+
db: Database.Database,
|
|
80
|
+
bus: EventBus,
|
|
81
|
+
logger: Logger,
|
|
82
|
+
libraryCache: LibraryCache,
|
|
83
|
+
contentTypeRegistry: ContentTypeRegistryImpl,
|
|
84
|
+
importer: Importer,
|
|
85
|
+
concurrency = DEFAULT_CONCURRENCY,
|
|
86
|
+
) {
|
|
87
|
+
this.scanner = scanner;
|
|
88
|
+
this.matcher = matcher;
|
|
89
|
+
this.db = db;
|
|
90
|
+
this.bus = bus;
|
|
91
|
+
this.logger = logger.child("scan-controller");
|
|
92
|
+
this.libraryCache = libraryCache;
|
|
93
|
+
this.contentTypeRegistry = contentTypeRegistry;
|
|
94
|
+
this.importer = importer;
|
|
95
|
+
this.concurrency = concurrency;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Start an async scan. Returns the scanId immediately.
|
|
100
|
+
* The scan runs in the background; progress is polled via getScan().
|
|
101
|
+
*
|
|
102
|
+
* @param libraryId Optional — scan only this library.
|
|
103
|
+
* @param contentTypeIds Optional — scan only roots for these content types.
|
|
104
|
+
*/
|
|
105
|
+
startScan(libraryId?: string, contentTypeIds?: string[]): string {
|
|
106
|
+
const scanId = randomUUID();
|
|
107
|
+
const state: ScanState = {
|
|
108
|
+
scanId,
|
|
109
|
+
status: "scanning",
|
|
110
|
+
progress: { total: 0, completed: 0, matched: 0, noMatch: 0, alreadyImported: 0 },
|
|
111
|
+
candidates: [],
|
|
112
|
+
};
|
|
113
|
+
activeScans.set(scanId, state);
|
|
114
|
+
|
|
115
|
+
// Run scan in background (not awaited)
|
|
116
|
+
this.runScan(scanId, state, libraryId, contentTypeIds).catch((err) => {
|
|
117
|
+
this.logger.error("scan failed", { scanId, error: String(err) });
|
|
118
|
+
state.status = "complete";
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return scanId;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get the current state of a scan.
|
|
126
|
+
*/
|
|
127
|
+
getScan(scanId: string): ScanState | undefined {
|
|
128
|
+
return activeScans.get(scanId);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Cancel a running scan.
|
|
133
|
+
*/
|
|
134
|
+
cancelScan(scanId: string): boolean {
|
|
135
|
+
const state = activeScans.get(scanId);
|
|
136
|
+
if (!state || state.status === "complete") return false;
|
|
137
|
+
state.status = "cancelled";
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Import accepted candidates into the library.
|
|
143
|
+
* Phase 1: Create tracked_request rows in a transaction (sync).
|
|
144
|
+
* Phase 2: Process each via the Importer (async) so library config
|
|
145
|
+
* (roots, link type, subtitles, naming) is applied correctly.
|
|
146
|
+
*/
|
|
147
|
+
async importCandidates(
|
|
148
|
+
scanId: string,
|
|
149
|
+
candidateIds: string[],
|
|
150
|
+
): Promise<{ imported: number; failed: number; errors: Array<{ id: string; error: string }> }> {
|
|
151
|
+
const state = activeScans.get(scanId);
|
|
152
|
+
if (!state) {
|
|
153
|
+
return { imported: 0, failed: 0, errors: [] };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const errors: Array<{ id: string; error: string }> = [];
|
|
157
|
+
|
|
158
|
+
// Phase 1: Create all tracked_request rows in a single transaction.
|
|
159
|
+
// Each entry captures what the Importer needs without doing the actual file I/O.
|
|
160
|
+
const pending: Array<{
|
|
161
|
+
requestId: string;
|
|
162
|
+
primaryFile: string;
|
|
163
|
+
item: ContentItemBase;
|
|
164
|
+
contentTypeId: string;
|
|
165
|
+
extension: string;
|
|
166
|
+
contentIdentity: string;
|
|
167
|
+
libraryId?: string;
|
|
168
|
+
}> = [];
|
|
169
|
+
|
|
170
|
+
const txn = this.db.transaction(() => {
|
|
171
|
+
for (const candidateId of candidateIds) {
|
|
172
|
+
const candidate = state.candidates.find((c) => c.id === candidateId);
|
|
173
|
+
if (!candidate) {
|
|
174
|
+
errors.push({ id: candidateId, error: "candidate not found" });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const result = this.createTrackedRequest(candidate);
|
|
180
|
+
if (result) pending.push(result);
|
|
181
|
+
} catch (err: any) {
|
|
182
|
+
errors.push({ id: candidateId, error: err.message });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
txn();
|
|
188
|
+
|
|
189
|
+
// Phase 2: Process each candidate through the Importer.
|
|
190
|
+
// The Importer handles file transfer (link type), subtitle importing,
|
|
191
|
+
// naming-rule resolution, library_path insertion, and event emission.
|
|
192
|
+
let imported = 0;
|
|
193
|
+
for (const p of pending) {
|
|
194
|
+
try {
|
|
195
|
+
const result = await this.importer.import(
|
|
196
|
+
p.requestId,
|
|
197
|
+
p.primaryFile,
|
|
198
|
+
p.item,
|
|
199
|
+
p.contentTypeId,
|
|
200
|
+
p.extension,
|
|
201
|
+
p.contentIdentity,
|
|
202
|
+
p.libraryId,
|
|
203
|
+
);
|
|
204
|
+
if (result.success) {
|
|
205
|
+
imported++;
|
|
206
|
+
} else {
|
|
207
|
+
errors.push({ id: p.requestId, error: result.error ?? "importer returned failure" });
|
|
208
|
+
}
|
|
209
|
+
} catch (err: any) {
|
|
210
|
+
errors.push({ id: p.requestId, error: err.message });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { imported, failed: errors.length, errors };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Remove a completed scan from memory.
|
|
219
|
+
*/
|
|
220
|
+
cleanupScan(scanId: string): void {
|
|
221
|
+
activeScans.delete(scanId);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Private ──
|
|
225
|
+
|
|
226
|
+
private async runScan(
|
|
227
|
+
scanId: string,
|
|
228
|
+
state: ScanState,
|
|
229
|
+
libraryId?: string,
|
|
230
|
+
contentTypeIds?: string[],
|
|
231
|
+
): Promise<void> {
|
|
232
|
+
// Phase 1: Discover groups
|
|
233
|
+
this.emitProgress(scanId, state);
|
|
234
|
+
const groups = await this.scanner.discoverGroups({ libraryId, contentTypeIds });
|
|
235
|
+
|
|
236
|
+
if (state.status === "cancelled") return;
|
|
237
|
+
|
|
238
|
+
state.candidates = groups.map((g) => this.groupToCandidate(g));
|
|
239
|
+
state.progress.total = groups.length;
|
|
240
|
+
|
|
241
|
+
// Find already-imported items
|
|
242
|
+
const dirs = groups.map((g) => g.directory);
|
|
243
|
+
const existing = this.scanner.findExisting(dirs);
|
|
244
|
+
for (const candidate of state.candidates) {
|
|
245
|
+
if (existing.has(candidate.directory)) {
|
|
246
|
+
candidate.alreadyImported = true;
|
|
247
|
+
candidate.status = "already-imported";
|
|
248
|
+
candidate.accepted = false;
|
|
249
|
+
state.progress.alreadyImported++;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
state.status = "matching";
|
|
254
|
+
this.emitProgress(scanId, state);
|
|
255
|
+
|
|
256
|
+
// Phase 2: Match metadata for non-imported candidates
|
|
257
|
+
const sem = new Semaphore(this.concurrency);
|
|
258
|
+
const matchTasks = state.candidates
|
|
259
|
+
.filter((c) => !c.alreadyImported)
|
|
260
|
+
.map(async (candidate) => {
|
|
261
|
+
await sem.acquire();
|
|
262
|
+
try {
|
|
263
|
+
if (state.status === "cancelled") return;
|
|
264
|
+
|
|
265
|
+
const group = groups.find((g) => g.directory === candidate.directory);
|
|
266
|
+
if (!group) return;
|
|
267
|
+
|
|
268
|
+
const matches = await this.matcher.findMatches(group);
|
|
269
|
+
if (matches.length > 0) {
|
|
270
|
+
candidate.matches = matches;
|
|
271
|
+
candidate.selectedMatchIndex = 0;
|
|
272
|
+
candidate.status = "matched";
|
|
273
|
+
candidate.accepted = true;
|
|
274
|
+
state.progress.matched++;
|
|
275
|
+
} else {
|
|
276
|
+
candidate.matches = [];
|
|
277
|
+
candidate.selectedMatchIndex = -1;
|
|
278
|
+
candidate.status = "no-match";
|
|
279
|
+
state.progress.noMatch++;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
state.progress.completed++;
|
|
283
|
+
this.emitProgress(scanId, state);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
candidate.status = "error";
|
|
286
|
+
candidate.error = String(err);
|
|
287
|
+
state.progress.completed++;
|
|
288
|
+
this.emitProgress(scanId, state);
|
|
289
|
+
} finally {
|
|
290
|
+
sem.release();
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await Promise.all(matchTasks);
|
|
295
|
+
|
|
296
|
+
state.status = "complete";
|
|
297
|
+
this.emitProgress(scanId, state);
|
|
298
|
+
this.logger.info("scan complete", {
|
|
299
|
+
scanId,
|
|
300
|
+
total: state.progress.total,
|
|
301
|
+
matched: state.progress.matched,
|
|
302
|
+
noMatch: state.progress.noMatch,
|
|
303
|
+
alreadyImported: state.progress.alreadyImported,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Run a custom search for a specific candidate and update its matches.
|
|
309
|
+
* Called from the API when the user clicks "Search again".
|
|
310
|
+
*/
|
|
311
|
+
async searchCandidate(
|
|
312
|
+
scanId: string,
|
|
313
|
+
candidateId: string,
|
|
314
|
+
query: string,
|
|
315
|
+
): Promise<boolean> {
|
|
316
|
+
const state = activeScans.get(scanId);
|
|
317
|
+
if (!state) return false;
|
|
318
|
+
|
|
319
|
+
const candidate = state.candidates.find((c) => c.id === candidateId);
|
|
320
|
+
if (!candidate) return false;
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
const matches = await this.matcher.searchCustom(
|
|
324
|
+
candidate.folderName,
|
|
325
|
+
query,
|
|
326
|
+
candidate.contentTypeId,
|
|
327
|
+
);
|
|
328
|
+
candidate.matches = matches;
|
|
329
|
+
candidate.selectedMatchIndex = matches.length > 0 ? 0 : -1;
|
|
330
|
+
candidate.status = matches.length > 0 ? "matched" : "no-match";
|
|
331
|
+
candidate.accepted = matches.length > 0;
|
|
332
|
+
return true;
|
|
333
|
+
} catch (err) {
|
|
334
|
+
this.logger.error("custom search failed", {
|
|
335
|
+
scanId,
|
|
336
|
+
candidateId,
|
|
337
|
+
error: String(err),
|
|
338
|
+
});
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Update the user's selected match index for a candidate.
|
|
345
|
+
*/
|
|
346
|
+
selectMatch(scanId: string, candidateId: string, matchIndex: number): boolean {
|
|
347
|
+
const state = activeScans.get(scanId);
|
|
348
|
+
if (!state) return false;
|
|
349
|
+
const candidate = state.candidates.find((c) => c.id === candidateId);
|
|
350
|
+
if (!candidate) return false;
|
|
351
|
+
if (matchIndex < -1 || matchIndex >= candidate.matches.length) return false;
|
|
352
|
+
candidate.selectedMatchIndex = matchIndex;
|
|
353
|
+
candidate.accepted = matchIndex >= 0;
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private groupToCandidate(group: DiscoveredGroup): LibraryScanCandidate {
|
|
358
|
+
return {
|
|
359
|
+
id: randomUUID(),
|
|
360
|
+
directory: group.directory,
|
|
361
|
+
files: group.files,
|
|
362
|
+
contentTypeId: group.contentTypeId,
|
|
363
|
+
libraryId: group.libraryId,
|
|
364
|
+
folderName: group.folderName,
|
|
365
|
+
matches: [],
|
|
366
|
+
selectedMatchIndex: -1,
|
|
367
|
+
accepted: false,
|
|
368
|
+
alreadyImported: false,
|
|
369
|
+
status: "pending",
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private emitProgress(scanId: string, state: ScanState): void {
|
|
374
|
+
this.bus.emit("library.scan.progress", {
|
|
375
|
+
scanId,
|
|
376
|
+
status: state.status,
|
|
377
|
+
progress: state.progress,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Create a tracked_request row for a scanned candidate.
|
|
383
|
+
* Runs inside a transaction. Returns the data the Importer needs,
|
|
384
|
+
* or null if the candidate has no media files.
|
|
385
|
+
*
|
|
386
|
+
* Unlike the old importSingleCandidate, this does NOT:
|
|
387
|
+
* - Insert into library_paths (the Importer handles that)
|
|
388
|
+
* - Set library_path on tracked_requests (the Importer sets it)
|
|
389
|
+
* - Emit events (the Importer emits them)
|
|
390
|
+
*/
|
|
391
|
+
private createTrackedRequest(candidate: LibraryScanCandidate): {
|
|
392
|
+
requestId: string;
|
|
393
|
+
primaryFile: string;
|
|
394
|
+
item: ContentItemBase;
|
|
395
|
+
contentTypeId: string;
|
|
396
|
+
extension: string;
|
|
397
|
+
contentIdentity: string;
|
|
398
|
+
libraryId?: string;
|
|
399
|
+
} | null {
|
|
400
|
+
const requestId = randomUUID();
|
|
401
|
+
|
|
402
|
+
// Get the selected match, if any
|
|
403
|
+
const selectedMatch =
|
|
404
|
+
candidate.selectedMatchIndex >= 0 && candidate.selectedMatchIndex < candidate.matches.length
|
|
405
|
+
? candidate.matches[candidate.selectedMatchIndex]
|
|
406
|
+
: null;
|
|
407
|
+
|
|
408
|
+
// Determine the correct contentTypeId: prefer the match's value when
|
|
409
|
+
// available (metadata sources return the real ID), otherwise fall back
|
|
410
|
+
// to the scanner-assigned ID (which may be "unknown" for default-root items).
|
|
411
|
+
const resolvedContentTypeId = selectedMatch
|
|
412
|
+
? selectedMatch.item.contentTypeId
|
|
413
|
+
: candidate.contentTypeId;
|
|
414
|
+
|
|
415
|
+
// Build the ContentItemBase from selected match or folder name
|
|
416
|
+
const item: ContentItemBase = selectedMatch
|
|
417
|
+
? { ...selectedMatch.item, contentTypeId: resolvedContentTypeId }
|
|
418
|
+
: {
|
|
419
|
+
contentTypeId: resolvedContentTypeId,
|
|
420
|
+
title: candidate.folderName,
|
|
421
|
+
externalIds: {},
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
// Pick the primary media file
|
|
425
|
+
const primaryFile = this.pickPrimaryFile(candidate.files);
|
|
426
|
+
|
|
427
|
+
// Compute content identity using the resolved content type ID
|
|
428
|
+
const contentIdentity = selectedMatch
|
|
429
|
+
? computeContentIdentity(
|
|
430
|
+
{ contentTypeId: resolvedContentTypeId, externalIds: item.externalIds },
|
|
431
|
+
this.contentTypeRegistry,
|
|
432
|
+
)
|
|
433
|
+
: `${resolvedContentTypeId}::local::${candidate.folderName}`;
|
|
434
|
+
|
|
435
|
+
// Determine file extension for the Importer
|
|
436
|
+
const extension = path.extname(primaryFile).slice(1).toLowerCase() || "mkv";
|
|
437
|
+
|
|
438
|
+
const now = new Date().toISOString();
|
|
439
|
+
|
|
440
|
+
// Insert into tracked_requests — no library_path set here.
|
|
441
|
+
// The Importer will update it after the file transfer.
|
|
442
|
+
this.db
|
|
443
|
+
.prepare(
|
|
444
|
+
`INSERT INTO tracked_requests
|
|
445
|
+
(request_id, content_type_id, content_identity, item,
|
|
446
|
+
owner_user_id, watchers, status, library_id, attempts, created_at, updated_at)
|
|
447
|
+
VALUES
|
|
448
|
+
(@requestId, @contentTypeId, @contentIdentity, @item,
|
|
449
|
+
@ownerUserId, '[]', 'downloaded', @libraryId, 0, @createdAt, @updatedAt)`,
|
|
450
|
+
)
|
|
451
|
+
.run({
|
|
452
|
+
requestId,
|
|
453
|
+
contentTypeId: resolvedContentTypeId,
|
|
454
|
+
contentIdentity,
|
|
455
|
+
item: JSON.stringify(item),
|
|
456
|
+
ownerUserId: "admin",
|
|
457
|
+
libraryId: candidate.libraryId ?? null,
|
|
458
|
+
createdAt: now,
|
|
459
|
+
updatedAt: now,
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
requestId,
|
|
464
|
+
primaryFile,
|
|
465
|
+
item,
|
|
466
|
+
contentTypeId: resolvedContentTypeId,
|
|
467
|
+
extension,
|
|
468
|
+
contentIdentity,
|
|
469
|
+
libraryId: candidate.libraryId,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private pickPrimaryFile(files: string[]): string {
|
|
474
|
+
if (files.length === 0) throw new Error("no media files to import");
|
|
475
|
+
if (files.length === 1) return files[0];
|
|
476
|
+
|
|
477
|
+
// Prefer the file whose basename best matches the parent folder name
|
|
478
|
+
const dirName = path.basename(path.dirname(files[0]));
|
|
479
|
+
const exactMatch = files.find((f) => {
|
|
480
|
+
const base = path.basename(f, path.extname(f));
|
|
481
|
+
return base.toLowerCase() === dirName.toLowerCase();
|
|
482
|
+
});
|
|
483
|
+
if (exactMatch) return exactMatch;
|
|
484
|
+
|
|
485
|
+
// Fall back to first file
|
|
486
|
+
return files[0];
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// SPDX-License-Identifier: 0BSD
|
|
2
|
+
// SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
|
|
3
|
+
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
const MEDIA_EXTENSIONS = new Set([
|
|
8
|
+
"mkv", "mp4", "avi", "mov", "wmv", "flv", "webm",
|
|
9
|
+
"m4v", "mpg", "mpeg", "ts", "m2ts",
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
export interface ResolvedSource {
|
|
13
|
+
/** The directory containing the source files. */
|
|
14
|
+
sourceDir: string;
|
|
15
|
+
/** All media files found at the source location. */
|
|
16
|
+
files: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Return true if the file extension looks like a media file.
|
|
21
|
+
*/
|
|
22
|
+
function isMediaFile(filePath: string): boolean {
|
|
23
|
+
const ext = path.extname(filePath).slice(1).toLowerCase();
|
|
24
|
+
return MEDIA_EXTENSIONS.has(ext);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Scan a directory (non-recursive) for media files.
|
|
29
|
+
* Skips hidden directories and common non-content subdirectories.
|
|
30
|
+
*/
|
|
31
|
+
async function scanDirectory(dir: string): Promise<string[]> {
|
|
32
|
+
const results: string[] = [];
|
|
33
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
const fullPath = path.join(dir, entry.name);
|
|
36
|
+
if (entry.isDirectory()) {
|
|
37
|
+
// Skip common non-content directories
|
|
38
|
+
if (entry.name.startsWith(".")) continue;
|
|
39
|
+
if (entry.name === "Extras" || entry.name === "Sample" || entry.name === "samples") continue;
|
|
40
|
+
const nested = await scanDirectory(fullPath);
|
|
41
|
+
results.push(...nested);
|
|
42
|
+
} else if (entry.isFile() && isMediaFile(fullPath)) {
|
|
43
|
+
results.push(fullPath);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return results;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the source path from a download.completed event.
|
|
51
|
+
*
|
|
52
|
+
* Resolution order:
|
|
53
|
+
* 1. If location is provided and the stripped path exists:
|
|
54
|
+
* - If it is a file → return it as the only file
|
|
55
|
+
* - If it is a directory → scan for media files
|
|
56
|
+
* 2. If all fail → throw with a descriptive message.
|
|
57
|
+
*/
|
|
58
|
+
export async function resolveSourcePath(
|
|
59
|
+
location: string | undefined,
|
|
60
|
+
): Promise<ResolvedSource> {
|
|
61
|
+
if (!location) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
"Cannot resolve source path: no location provided on download.completed",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Strip protocol prefix (e.g. "qbittorrent:/path" → "/path")
|
|
68
|
+
const colonIdx = location.indexOf(":");
|
|
69
|
+
const strippedPath = colonIdx >= 0 ? location.slice(colonIdx + 1) : location;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const stat = await fs.stat(strippedPath);
|
|
73
|
+
if (stat.isFile()) {
|
|
74
|
+
const dir = path.dirname(strippedPath);
|
|
75
|
+
return { sourceDir: dir, files: [strippedPath] };
|
|
76
|
+
}
|
|
77
|
+
if (stat.isDirectory()) {
|
|
78
|
+
const files = await scanDirectory(strippedPath);
|
|
79
|
+
if (files.length === 0) {
|
|
80
|
+
throw new Error(`No media files found in directory: ${strippedPath}`);
|
|
81
|
+
}
|
|
82
|
+
return { sourceDir: strippedPath, files };
|
|
83
|
+
}
|
|
84
|
+
} catch (err: any) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Cannot access source path "${strippedPath}": ${err.message}`,
|
|
87
|
+
{ cause: err },
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
throw new Error(`Source path is neither a file nor a directory: ${strippedPath}`);
|
|
92
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// SPDX-License-Identifier: 0BSD
|
|
2
|
+
// SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
|
|
3
|
+
|
|
4
|
+
import { env } from "node:process";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
|
|
8
|
+
import type { Logger } from "../types/plugin.js";
|
|
9
|
+
|
|
10
|
+
const LEVELS: Record<string, number> = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
11
|
+
|
|
12
|
+
function resolveLogFilePath(): string | undefined {
|
|
13
|
+
const explicit = env.STARR_LOG_FILE;
|
|
14
|
+
if (explicit === "") return undefined; // disabled
|
|
15
|
+
if (explicit) return path.resolve(explicit);
|
|
16
|
+
|
|
17
|
+
const dataDir = env.STARR_DATA_DIR ?? "./data";
|
|
18
|
+
return path.resolve(dataDir, "logs", "starr.log");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ensureLogDir(logPath: string): void {
|
|
22
|
+
const dir = path.dirname(logPath);
|
|
23
|
+
if (!fs.existsSync(dir)) {
|
|
24
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createLogger(tag = "core", levelName = "info"): Logger {
|
|
29
|
+
const consoleLevel = LEVELS[levelName] ?? LEVELS.info;
|
|
30
|
+
const fileLevelName = env.STARR_LOG_FILE_LEVEL ?? levelName;
|
|
31
|
+
const fileLevel = LEVELS[fileLevelName] ?? consoleLevel;
|
|
32
|
+
|
|
33
|
+
const logFilePath = resolveLogFilePath();
|
|
34
|
+
let fileHandle: number | undefined;
|
|
35
|
+
let fileErrorLogged = false;
|
|
36
|
+
|
|
37
|
+
function openFile(): number | undefined {
|
|
38
|
+
if (!logFilePath) return undefined;
|
|
39
|
+
try {
|
|
40
|
+
ensureLogDir(logFilePath);
|
|
41
|
+
return fs.openSync(logFilePath, "a");
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (!fileErrorLogged) {
|
|
44
|
+
console.warn(JSON.stringify({
|
|
45
|
+
timestamp: new Date().toISOString(),
|
|
46
|
+
level: "warn",
|
|
47
|
+
tag: "core/logging",
|
|
48
|
+
message: "Failed to open log file",
|
|
49
|
+
path: logFilePath,
|
|
50
|
+
error: String(err),
|
|
51
|
+
}));
|
|
52
|
+
fileErrorLogged = true;
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function log(level: string, message: string, meta?: Record<string, unknown>) {
|
|
59
|
+
const levelValue = LEVELS[level] ?? LEVELS.info;
|
|
60
|
+
const line: Record<string, unknown> = {
|
|
61
|
+
timestamp: new Date().toISOString(),
|
|
62
|
+
level,
|
|
63
|
+
tag,
|
|
64
|
+
message,
|
|
65
|
+
...(meta ?? {}),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Console sink
|
|
69
|
+
if (levelValue >= consoleLevel) {
|
|
70
|
+
console.log(JSON.stringify(line));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// File sink
|
|
74
|
+
if (logFilePath && levelValue >= fileLevel) {
|
|
75
|
+
if (fileHandle === undefined) {
|
|
76
|
+
fileHandle = openFile();
|
|
77
|
+
}
|
|
78
|
+
if (fileHandle !== undefined) {
|
|
79
|
+
try {
|
|
80
|
+
fs.writeSync(fileHandle, JSON.stringify(line) + "\n");
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (!fileErrorLogged) {
|
|
83
|
+
console.warn(JSON.stringify({
|
|
84
|
+
timestamp: new Date().toISOString(),
|
|
85
|
+
level: "warn",
|
|
86
|
+
tag: "core/logging",
|
|
87
|
+
message: "Failed to write to log file",
|
|
88
|
+
path: logFilePath,
|
|
89
|
+
error: String(err),
|
|
90
|
+
}));
|
|
91
|
+
fileErrorLogged = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (fileHandle !== undefined) {
|
|
99
|
+
process.once("exit", () => {
|
|
100
|
+
try {
|
|
101
|
+
fs.closeSync(fileHandle!);
|
|
102
|
+
} catch {
|
|
103
|
+
// ignore close errors
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
debug: (m, meta) => log("debug", m, meta),
|
|
110
|
+
info: (m, meta) => log("info", m, meta),
|
|
111
|
+
warn: (m, meta) => log("warn", m, meta),
|
|
112
|
+
error: (m, meta) => log("error", m, meta),
|
|
113
|
+
child: (childTag) => createLogger(`${tag}/${childTag}`, levelName),
|
|
114
|
+
};
|
|
115
|
+
}
|