@torrent-tv/proxy 2.87.1 → 2.87.2
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/CHANGELOG.md +6 -0
- package/bin/cli.js +2 -2
- package/package.json +1 -1
- package/services/identity/installation-id.js +38 -0
- package/services/media/container/Container.js +7 -0
- package/services/media/container/MatroskaContainer.js +9 -0
- package/services/media/tracks/ContainerTrack.js +3 -0
- package/services/storage/segment-store/SegmentStore.js +1 -1
- package/test/installation-id.test.js +39 -0
- package/test/segment-store.test.js +1 -0
- package/test/subtitle-defaults.test.js +21 -0
- package/test/subtitle-track-numbering.test.js +21 -6
- /package/{CLAUDE.md → AGENTS.md} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## 2.87.2
|
|
2
|
+
|
|
3
|
+
- **Fix**: Close an output directory's file-system watch before removing the directory, so `SegmentStore.drop` actually frees it on Windows.
|
|
4
|
+
- **Fix**: Keep the proxy's registry id in `--state-dir` across restarts. An explicit `--id` remains authoritative; without a state directory, ids remain ephemeral.
|
|
5
|
+
- **Fix**: Do not present Matroska's implicit `eng` language default as a language stated by the subtitle track; let the measured cue-text detector answer when enough text is available.
|
|
6
|
+
|
|
1
7
|
## 2.87.1
|
|
2
8
|
|
|
3
9
|
- **Chore**: Keep HTTP route modules independent by moving shared response and transcode-file handling into server services. Pass encoding components narrow segment-storage interfaces and document the browser-facing HTTP contract.
|
package/bin/cli.js
CHANGED
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
// for the measurement that made it necessary.
|
|
15
15
|
import "./thread-pool.js";
|
|
16
16
|
import { Command } from "commander";
|
|
17
|
-
import crypto from "node:crypto";
|
|
18
17
|
import os from "node:os";
|
|
19
18
|
import { spawnSync } from "node:child_process";
|
|
20
19
|
import { createRequire } from "node:module";
|
|
@@ -33,6 +32,7 @@ import { startMemoryReport } from "../services/storage/memory-report.js";
|
|
|
33
32
|
import { fragmentBufferCollection } from "../services/torrent/worker/client.js";
|
|
34
33
|
import { collectHealthMetrics } from "../services/transport/health-collector.js";
|
|
35
34
|
import { createPortMapper } from "../services/transport/port-mapper.js";
|
|
35
|
+
import { installationId } from "../services/identity/installation-id.js";
|
|
36
36
|
import { classifyNat } from "../services/transport/nat-classifier.js";
|
|
37
37
|
import { DEFAULT_SEGMENT_FORMAT_ID, SEGMENT_FORMAT_IDS } from "../services/encode/segment-formats/index.js";
|
|
38
38
|
import { logToFile, logger } from "../utils/logger.js";
|
|
@@ -132,7 +132,7 @@ const bindHost = String(options.host);
|
|
|
132
132
|
const explicitBaseUrl = options.publicBaseUrl
|
|
133
133
|
? String(options.publicBaseUrl).replace(/\/+$/, "")
|
|
134
134
|
: "";
|
|
135
|
-
const clientId = options.
|
|
135
|
+
const clientId = installationId({ stateDir: options.stateDir, explicitId: options.id });
|
|
136
136
|
const clientName = options.name ? String(options.name) : `proxy-${clientId.slice(0, 8)}`;
|
|
137
137
|
const token = String(options.token ?? "");
|
|
138
138
|
const transcodeAudio = options.transcodeAudio !== false;
|
package/package.json
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const ID_FILE = "proxy-identity.json";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve this installation's registry id. Without a state directory the id
|
|
9
|
+
* remains ephemeral; with one, the id survives process restarts.
|
|
10
|
+
*
|
|
11
|
+
* @param {{ stateDir?: string, explicitId?: string, makeId?: () => string }} options
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
export function installationId({ stateDir, explicitId, makeId = randomUUID } = {}) {
|
|
15
|
+
const requestedId = typeof explicitId === "string" ? explicitId.trim() : "";
|
|
16
|
+
if (!stateDir) {
|
|
17
|
+
return requestedId || makeId();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const file = path.join(stateDir, ID_FILE);
|
|
21
|
+
let savedId = "";
|
|
22
|
+
if (existsSync(file)) {
|
|
23
|
+
const saved = JSON.parse(readFileSync(file, "utf8"));
|
|
24
|
+
savedId = typeof saved?.id === "string" ? saved.id.trim() : "";
|
|
25
|
+
if (!savedId) {
|
|
26
|
+
throw new Error(`Invalid proxy identity file: ${file}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const id = requestedId || savedId || makeId();
|
|
31
|
+
if (id !== savedId) {
|
|
32
|
+
mkdirSync(stateDir, { recursive: true });
|
|
33
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
34
|
+
writeFileSync(temporary, `${JSON.stringify({ id })}\n`, { mode: 0o600 });
|
|
35
|
+
renameSync(temporary, file);
|
|
36
|
+
}
|
|
37
|
+
return id;
|
|
38
|
+
}
|
|
@@ -272,6 +272,13 @@ export class Container {
|
|
|
272
272
|
declared,
|
|
273
273
|
"subtitle",
|
|
274
274
|
(track) => ({
|
|
275
|
+
// Matroska applies `eng` when Language is absent, and ffmpeg reports
|
|
276
|
+
// that default as if the file had stated it. Preserve only an explicit
|
|
277
|
+
// container value; otherwise let cue-text detection decide later.
|
|
278
|
+
language: track.declaresLanguage === true
|
|
279
|
+
? (track.languageBcp47 || track.language || "")
|
|
280
|
+
: "",
|
|
281
|
+
declaresLanguage: track.declaresLanguage === true,
|
|
275
282
|
isDefault: track.isDefault === true,
|
|
276
283
|
declaresDefault: track.declaresDefault === true,
|
|
277
284
|
// Read from the file rather than guessed from the track's name. Both
|
|
@@ -538,6 +538,7 @@ export class MatroskaContainer extends Container {
|
|
|
538
538
|
// RFC 9559 LanguageBCP47 MUST — when present, Language ignored
|
|
539
539
|
const resolvedLang = languageBcp47 || language;
|
|
540
540
|
const bcpTag = languageBcp47;
|
|
541
|
+
const declaresLanguage = resolvedLang.length > 0;
|
|
541
542
|
|
|
542
543
|
if (typeNum === 1) {
|
|
543
544
|
videoDeclaredIndex += 1;
|
|
@@ -547,6 +548,7 @@ export class MatroskaContainer extends Container {
|
|
|
547
548
|
codecId,
|
|
548
549
|
language: resolvedLang,
|
|
549
550
|
languageBcp47: bcpTag,
|
|
551
|
+
declaresLanguage,
|
|
550
552
|
name,
|
|
551
553
|
isEnabled,
|
|
552
554
|
isDefault,
|
|
@@ -565,6 +567,7 @@ export class MatroskaContainer extends Container {
|
|
|
565
567
|
codecId,
|
|
566
568
|
language: resolvedLang,
|
|
567
569
|
languageBcp47: bcpTag,
|
|
570
|
+
declaresLanguage,
|
|
568
571
|
name,
|
|
569
572
|
isEnabled,
|
|
570
573
|
isDefault,
|
|
@@ -597,6 +600,7 @@ export class MatroskaContainer extends Container {
|
|
|
597
600
|
codecId,
|
|
598
601
|
language: resolvedLang,
|
|
599
602
|
languageBcp47: bcpTag,
|
|
603
|
+
declaresLanguage,
|
|
600
604
|
name,
|
|
601
605
|
isEnabled: finalEnabled,
|
|
602
606
|
isDefault: finalDefault,
|
|
@@ -615,6 +619,7 @@ export class MatroskaContainer extends Container {
|
|
|
615
619
|
codecId,
|
|
616
620
|
language: resolvedLang,
|
|
617
621
|
languageBcp47: bcpTag,
|
|
622
|
+
declaresLanguage,
|
|
618
623
|
name,
|
|
619
624
|
isEnabled: finalEnabled,
|
|
620
625
|
isDefault: finalDefault,
|
|
@@ -634,6 +639,7 @@ export class MatroskaContainer extends Container {
|
|
|
634
639
|
codecId,
|
|
635
640
|
language: resolvedLang,
|
|
636
641
|
languageBcp47: bcpTag,
|
|
642
|
+
declaresLanguage,
|
|
637
643
|
name,
|
|
638
644
|
isEnabled,
|
|
639
645
|
isDefault,
|
|
@@ -1047,11 +1053,13 @@ async function readSubtitlePlan(readRange, fileSize) {
|
|
|
1047
1053
|
// `language` here stays the three-letter code, because this list exists to
|
|
1048
1054
|
// be lined up against ffmpeg's banner, which prints that code. The RFC 5646
|
|
1049
1055
|
// tag rides beside it for whoever displays the track.
|
|
1056
|
+
const declaresLanguage = languageBcp47.length > 0 || language.length > 0;
|
|
1050
1057
|
declared.push({
|
|
1051
1058
|
trackNumber,
|
|
1052
1059
|
codecId,
|
|
1053
1060
|
language,
|
|
1054
1061
|
languageBcp47,
|
|
1062
|
+
declaresLanguage,
|
|
1055
1063
|
name,
|
|
1056
1064
|
isDefault,
|
|
1057
1065
|
declaresDefault,
|
|
@@ -1071,6 +1079,7 @@ async function readSubtitlePlan(readRange, fileSize) {
|
|
|
1071
1079
|
// three-letter code MUST be ignored.
|
|
1072
1080
|
language: languageBcp47 || language,
|
|
1073
1081
|
languageBcp47,
|
|
1082
|
+
declaresLanguage,
|
|
1074
1083
|
name,
|
|
1075
1084
|
isDefault,
|
|
1076
1085
|
isForced,
|
|
@@ -25,6 +25,7 @@ export class ContainerTrack {
|
|
|
25
25
|
* @param {string} params.codecId - Matroska CodecID or MP4 sample entry type (e.g. "S_TEXT/UTF8", "avc1", "mp4a").
|
|
26
26
|
* @param {string} params.language - Three-letter code or packed mdhd code; empty when absent. For Matroska, when LanguageBCP47 is present this is the BCP47 value ignored per MUST — caller stores both, but `language` here is the resolved one.
|
|
27
27
|
* @param {string} params.languageBcp47 - RFC 5646 tag from LanguageBCP47 / elng, or "".
|
|
28
|
+
* @param {boolean} params.declaresLanguage - Whether the file explicitly supplies a language rather than relying on a container default.
|
|
28
29
|
* @param {string} params.name - Track Name / title, or "".
|
|
29
30
|
* @param {boolean} params.isEnabled - FlagEnabled / tkhd track_enabled. Default true per both specs. Matroska zero-length element means default, not disabled.
|
|
30
31
|
* @param {boolean} params.isDefault - FlagDefault / tkhd? For MP4, derived from handler default? For Matroska, after applying default 1.
|
|
@@ -39,6 +40,7 @@ export class ContainerTrack {
|
|
|
39
40
|
codecId,
|
|
40
41
|
language,
|
|
41
42
|
languageBcp47,
|
|
43
|
+
declaresLanguage,
|
|
42
44
|
name,
|
|
43
45
|
isEnabled,
|
|
44
46
|
isDefault,
|
|
@@ -52,6 +54,7 @@ export class ContainerTrack {
|
|
|
52
54
|
this.codecId = codecId ?? "";
|
|
53
55
|
this.language = language ?? "";
|
|
54
56
|
this.languageBcp47 = languageBcp47 ?? "";
|
|
57
|
+
this.declaresLanguage = declaresLanguage === true;
|
|
55
58
|
this.name = name ?? "";
|
|
56
59
|
this.isEnabled = isEnabled !== false;
|
|
57
60
|
this.isDefault = isDefault === true;
|
|
@@ -834,12 +834,12 @@ export class SegmentStore {
|
|
|
834
834
|
*/
|
|
835
835
|
drop(key, because) {
|
|
836
836
|
const dir = path.join(this.#root, directoryNameFor(key));
|
|
837
|
+
this.#unwatch(key);
|
|
837
838
|
try {
|
|
838
839
|
rmSync(dir, { recursive: true, force: true });
|
|
839
840
|
} catch {
|
|
840
841
|
// Already gone, or in use; the next sweep sees it either way.
|
|
841
842
|
}
|
|
842
|
-
this.#unwatch(key);
|
|
843
843
|
this.#held.delete(key);
|
|
844
844
|
this.#formats.delete(key);
|
|
845
845
|
this.#touched.delete(key);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { installationId } from "../services/identity/installation-id.js";
|
|
7
|
+
|
|
8
|
+
test("an installation id is reused from its state directory", (t) => {
|
|
9
|
+
const stateDir = mkdtempSync(path.join(os.tmpdir(), "proxy-identity-"));
|
|
10
|
+
t.after(() => rmSync(stateDir, { recursive: true, force: true }));
|
|
11
|
+
|
|
12
|
+
assert.equal(installationId({ stateDir, makeId: () => "first-id" }), "first-id");
|
|
13
|
+
assert.equal(installationId({ stateDir, makeId: () => "unexpected-id" }), "first-id");
|
|
14
|
+
assert.deepEqual(JSON.parse(readFileSync(path.join(stateDir, "proxy-identity.json"), "utf8")), { id: "first-id" });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("an explicit id takes precedence and becomes the saved installation id", (t) => {
|
|
18
|
+
const stateDir = mkdtempSync(path.join(os.tmpdir(), "proxy-identity-"));
|
|
19
|
+
t.after(() => rmSync(stateDir, { recursive: true, force: true }));
|
|
20
|
+
|
|
21
|
+
assert.equal(installationId({ stateDir, explicitId: " configured-id " }), "configured-id");
|
|
22
|
+
assert.equal(installationId({ stateDir, makeId: () => "unexpected-id" }), "configured-id");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("without a state directory the default id stays ephemeral", () => {
|
|
26
|
+
let calls = 0;
|
|
27
|
+
const makeId = () => `id-${++calls}`;
|
|
28
|
+
|
|
29
|
+
assert.equal(installationId({ makeId }), "id-1");
|
|
30
|
+
assert.equal(installationId({ makeId }), "id-2");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a damaged saved identity is reported instead of silently replaced", (t) => {
|
|
34
|
+
const stateDir = mkdtempSync(path.join(os.tmpdir(), "proxy-identity-"));
|
|
35
|
+
t.after(() => rmSync(stateDir, { recursive: true, force: true }));
|
|
36
|
+
writeFileSync(path.join(stateDir, "proxy-identity.json"), "{}\n");
|
|
37
|
+
|
|
38
|
+
assert.throws(() => installationId({ stateDir, makeId: () => "replacement" }), /Invalid proxy identity file/);
|
|
39
|
+
});
|
|
@@ -319,4 +319,5 @@ test("what the store weighs is reported, and dropping one output frees it", (t)
|
|
|
319
319
|
assert.equal(store.stats().bytes, 2000);
|
|
320
320
|
store.drop(KEY, "nobody is watching it");
|
|
321
321
|
assert.equal(store.stats().outputs, 0);
|
|
322
|
+
assert.equal(readdirSync(root).length, 0, "the watched output directory was removed");
|
|
322
323
|
});
|
|
@@ -84,6 +84,27 @@ test("a container that declares nothing leaves the banner alone", () => {
|
|
|
84
84
|
assert.equal(merged.tracks[0].declaresDefault, false);
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
test("an implicit Matroska English default does not become the subtitle language", () => {
|
|
88
|
+
const banner = [{ index: 0, language: "eng", title: "", isDefault: true }];
|
|
89
|
+
const declared = [{ language: "", languageBcp47: "", declaresLanguage: false, name: "" }];
|
|
90
|
+
|
|
91
|
+
const merged = Container.mergeSubtitleFlags(banner, declared);
|
|
92
|
+
|
|
93
|
+
assert.equal(merged.aligned, true);
|
|
94
|
+
assert.equal(merged.tracks[0].language, "");
|
|
95
|
+
assert.equal(merged.tracks[0].declaresLanguage, false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("an explicitly declared subtitle language is retained", () => {
|
|
99
|
+
const banner = [{ index: 0, language: "rus", title: "", isDefault: true }];
|
|
100
|
+
const declared = [{ language: "rus", languageBcp47: "", declaresLanguage: true, name: "" }];
|
|
101
|
+
|
|
102
|
+
const merged = Container.mergeSubtitleFlags(banner, declared);
|
|
103
|
+
|
|
104
|
+
assert.equal(merged.tracks[0].language, "rus");
|
|
105
|
+
assert.equal(merged.tracks[0].declaresLanguage, true);
|
|
106
|
+
});
|
|
107
|
+
|
|
87
108
|
test("a name confirms a pairing when the languages are unstated", () => {
|
|
88
109
|
assert.equal(Container.pairingHolds({ language: "und", title: "Forced" }, { language: "", name: "Forced" }), true);
|
|
89
110
|
});
|
|
@@ -104,9 +104,11 @@ function trackEntry({ number, type, codecId, language, flags = {}, languageBcp47
|
|
|
104
104
|
const parts = [
|
|
105
105
|
uintElement(ID_TRACK_NUMBER, number),
|
|
106
106
|
uintElement(ID_TRACK_TYPE, type),
|
|
107
|
-
stringElement(ID_CODEC_ID, codecId)
|
|
108
|
-
stringElement(ID_LANGUAGE, language)
|
|
107
|
+
stringElement(ID_CODEC_ID, codecId)
|
|
109
108
|
];
|
|
109
|
+
if (language !== undefined && language !== null) {
|
|
110
|
+
parts.push(stringElement(ID_LANGUAGE, language));
|
|
111
|
+
}
|
|
110
112
|
if (languageBcp47 !== null) {
|
|
111
113
|
parts.push(stringElement(ID_LANGUAGE_BCP47, languageBcp47));
|
|
112
114
|
}
|
|
@@ -281,8 +283,9 @@ test("two walks of one file at the same time read each cluster once", async () =
|
|
|
281
283
|
|
|
282
284
|
/**
|
|
283
285
|
* A file whose subtitle tracks carry the flags RFC 9559 defines for them: one
|
|
284
|
-
* forced, one for viewers who cannot hear, one the file marks unusable,
|
|
285
|
-
* writing its language as RFC 5646 alongside the three-letter code
|
|
286
|
+
* forced, one for viewers who cannot hear, one the file marks unusable, one
|
|
287
|
+
* writing its language as RFC 5646 alongside the three-letter code, and one
|
|
288
|
+
* relying on Matroska's default language.
|
|
286
289
|
*
|
|
287
290
|
* @returns {Buffer}
|
|
288
291
|
*/
|
|
@@ -293,7 +296,8 @@ function fileWithFlags() {
|
|
|
293
296
|
trackEntry({ number: 2, type: 17, codecId: "S_TEXT/UTF8", language: "rus", flags: { forced: true } }),
|
|
294
297
|
trackEntry({ number: 3, type: 17, codecId: "S_TEXT/UTF8", language: "eng", flags: { hearingImpaired: true } }),
|
|
295
298
|
trackEntry({ number: 4, type: 17, codecId: "S_TEXT/UTF8", language: "fre", flags: { enabled: false } }),
|
|
296
|
-
trackEntry({ number: 5, type: 17, codecId: "S_TEXT/ASS", language: "por", languageBcp47: "pt-BR" })
|
|
299
|
+
trackEntry({ number: 5, type: 17, codecId: "S_TEXT/ASS", language: "por", languageBcp47: "pt-BR" }),
|
|
300
|
+
trackEntry({ number: 6, type: 17, codecId: "S_TEXT/UTF8", language: null })
|
|
297
301
|
]));
|
|
298
302
|
const seekEntry = (targetId, position) => element(ID_SEEK, Buffer.concat([
|
|
299
303
|
element(ID_SEEK_ID, idBytes(targetId)),
|
|
@@ -345,7 +349,7 @@ test("an unusable track keeps its place, so the tracks after it keep theirs", as
|
|
|
345
349
|
// s:0 forced, s:1 SDH, s:2 the unusable one, s:3 the Brazilian track.
|
|
346
350
|
assert.deepEqual(
|
|
347
351
|
plan.tracks.map((track) => [track.trackNumber, track.declaredIndex]),
|
|
348
|
-
[[2, 0], [3, 1], [5, 3]]
|
|
352
|
+
[[2, 0], [3, 1], [5, 3], [6, 4]]
|
|
349
353
|
);
|
|
350
354
|
});
|
|
351
355
|
|
|
@@ -369,3 +373,14 @@ test("where the file writes RFC 5646, that is the language", async () => {
|
|
|
369
373
|
assert.equal(track.language, "pt-BR", "not the three-letter por");
|
|
370
374
|
assert.equal(track.languageBcp47, "pt-BR");
|
|
371
375
|
});
|
|
376
|
+
|
|
377
|
+
test("an omitted Matroska Language element stays unstated rather than becoming the RFC default", async () => {
|
|
378
|
+
const file = fileWithFlags();
|
|
379
|
+
const plan = await MatroskaContainer.readSubtitlePlan(readerOver(file), file.length);
|
|
380
|
+
|
|
381
|
+
const track = plan.tracks.find((entry) => entry.trackNumber === 6);
|
|
382
|
+
const declared = plan.declared.find((entry) => entry.trackNumber === 6);
|
|
383
|
+
assert.equal(track.language, "");
|
|
384
|
+
assert.equal(track.declaresLanguage, false);
|
|
385
|
+
assert.equal(declared.declaresLanguage, false);
|
|
386
|
+
});
|
/package/{CLAUDE.md → AGENTS.md}
RENAMED
|
File without changes
|