@torrent-tv/proxy 2.87.1 → 2.87.3

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 CHANGED
@@ -1,3 +1,14 @@
1
+ ## 2.87.3
2
+
3
+ - **Chore**: Update Fastify and other lockfile dependencies while keeping WebTorrent pinned to 2.8.5. The remaining `ip` advisory has no fixed npm release and is reachable through WebTorrent's tracker dependencies.
4
+ - **Chore**: Keep the wait ledger in the server service layer and align the manager surface test with the existing seek invalidation behavior.
5
+
6
+ ## 2.87.2
7
+
8
+ - **Fix**: Close an output directory's file-system watch before removing the directory, so `SegmentStore.drop` actually frees it on Windows.
9
+ - **Fix**: Keep the proxy's registry id in `--state-dir` across restarts. An explicit `--id` remains authoritative; without a state directory, ids remain ephemeral.
10
+ - **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.
11
+
1
12
  ## 2.87.1
2
13
 
3
14
  - **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.id ? String(options.id) : crypto.randomUUID();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.87.1",
3
+ "version": "2.87.3",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -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;
@@ -1,5 +1,5 @@
1
1
  import { logger } from "../../utils/logger.js";
2
- import { bandOf, waits } from "../viewer/WaitLedger.js";
2
+ import { bandOf, waits } from "./WaitLedger.js";
3
3
 
4
4
  const HOLD_HEADER = "x-hold-ms";
5
5
  /**
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { logger } from "../../utils/logger.js";
12
12
  import { KeyframeTables } from "../media/KeyframeTables.js";
13
- import { waits } from "../viewer/WaitLedger.js";
13
+ import { waits } from "./WaitLedger.js";
14
14
  import { readMachineState, readProcessCpuSeconds, readProxyCpuSeconds, readSystemCpu, shareOfMachine } from "../encode/host-load.js";
15
15
  import { minimumBufferFrom } from "../torrent/supply-margin.js";
16
16
  import { PriorityOrchestrator } from "../viewer/PriorityOrchestrator.js";
@@ -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
+ });
@@ -222,11 +222,9 @@ test("what a viewer states about themselves is kept and answered", () => {
222
222
  assert.equal(manager.viewerRequests.viewerPositionOf(SESSION_ID, "viewer-one"), 120);
223
223
  assert.equal(manager.viewerRequests.requestSeek("no-such-session", 120, "viewer-one"), false);
224
224
 
225
- // `seekEpoch` is NOT moved by a seek, whatever its name says: its two writers
226
- // are the variant switch and the soundtrack switch, and they move it on the
227
- // output the viewer has LEFT. Recorded as it is, so the dismantling can give
228
- // the fact its real name instead of discovering this by breaking it.
229
- assert.equal(manager.serving.seekEpoch(SESSION_ID), 0, "a seek leaves the wait epoch alone");
225
+ // A seek wakes requests held on each output this viewer watches so each one
226
+ // can check whether it is still the segment this viewer needs.
227
+ assert.equal(manager.serving.seekEpoch(SESSION_ID), 1, "a seek invalidates waits on the watched output");
230
228
  assert.equal(manager.serving.seekEpoch("no-such-session"), 0);
231
229
 
232
230
  manager.encodeRuns.noteInputBytes(SESSION_ID, 4096);
@@ -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, and one
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
+ });
@@ -9,7 +9,7 @@
9
9
 
10
10
  import test from "node:test";
11
11
  import assert from "node:assert/strict";
12
- import { bandOf, WaitLedger } from "../services/viewer/WaitLedger.js";
12
+ import { bandOf, WaitLedger } from "../services/server/WaitLedger.js";
13
13
  import { SegmentDemand } from "../services/encode/SegmentDemand.js";
14
14
 
15
15
  test("the top rank is its own band, and the rest fall behind it", () => {
File without changes
File without changes