@torrent-tv/proxy 2.89.17 → 2.89.19
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.md +14 -3
- package/package.json +10 -2
- package/server.js +0 -11
- package/utils/client-log-file.js +147 -42
- package/.claude/commands/opsx/apply.md +0 -155
- package/.claude/commands/opsx/archive.md +0 -160
- package/.claude/commands/opsx/explore.md +0 -174
- package/.claude/commands/opsx/propose.md +0 -109
- package/.claude/commands/opsx/sync.md +0 -143
- package/.claude/skills/openspec-apply-change/SKILL.md +0 -159
- package/.claude/skills/openspec-archive-change/SKILL.md +0 -117
- package/.claude/skills/openspec-explore/SKILL.md +0 -289
- package/.claude/skills/openspec-propose/SKILL.md +0 -113
- package/.claude/skills/openspec-sync-specs/SKILL.md +0 -147
- package/.dockerignore +0 -5
- package/AGENTS.md +0 -363
- package/CHANGELOG.md +0 -2377
- package/Dockerfile +0 -30
- package/biome.json +0 -412
- package/corpus.tmp.json +0 -1
- package/docs/browser-proxy-contract.md +0 -155
- package/docs/container-architecture.md +0 -273
- package/docs/download-architecture.md +0 -261
- package/docs/encode-architecture.md +0 -765
- package/docs/encode-run-state.md +0 -96
- package/docs/logs.md +0 -151
- package/docs/playback-start-forecast.md +0 -113
- package/docs/socks5-egress-research.md +0 -114
- package/docs/storage-architecture.md +0 -273
- package/knip.json +0 -14
- package/main2.cpuprofile +0 -1
- package/openspec/changes/adaptive-bitrate/.openspec.yaml +0 -0
- package/openspec/changes/adaptive-bitrate/design.md +0 -92
- package/openspec/changes/adaptive-bitrate/proposal.md +0 -56
- package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +0 -51
- package/openspec/changes/adaptive-bitrate/tasks.md +0 -40
- package/openspec/changes/binary-distribution/.openspec.yaml +0 -2
- package/openspec/changes/binary-distribution/proposal.md +0 -53
- package/openspec/changes/chunked-request-bodies/design.md +0 -81
- package/openspec/changes/chunked-request-bodies/proposal.md +0 -53
- package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +0 -30
- package/openspec/changes/chunked-request-bodies/tasks.md +0 -40
- package/openspec/changes/cold-start/design.md +0 -98
- package/openspec/changes/cold-start/proposal.md +0 -62
- package/openspec/changes/cold-start/specs/cold-start/spec.md +0 -47
- package/openspec/changes/cold-start/tasks.md +0 -48
- package/openspec/changes/disk-cap/.openspec.yaml +0 -2
- package/openspec/changes/disk-cap/proposal.md +0 -37
- package/openspec/changes/disk-cap/specs/disk-cap/spec.md +0 -25
- package/openspec/changes/disk-cap/tasks.md +0 -19
- package/openspec/changes/proxy-observability/.openspec.yaml +0 -2
- package/openspec/changes/proxy-observability/design.md +0 -38
- package/openspec/changes/proxy-observability/proposal.md +0 -49
- package/openspec/changes/proxy-observability/specs/observability/spec.md +0 -33
- package/openspec/changes/proxy-observability/tasks.md +0 -21
- package/openspec/changes/seek-debounce/.openspec.yaml +0 -0
- package/openspec/changes/seek-debounce/design.md +0 -118
- package/openspec/changes/seek-debounce/proposal.md +0 -59
- package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +0 -41
- package/openspec/changes/seek-debounce/tasks.md +0 -33
- package/openspec/changes/subtitle-language/.openspec.yaml +0 -2
- package/openspec/changes/subtitle-language/proposal.md +0 -50
- package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +0 -34
- package/openspec/changes/subtitle-language/tasks.md +0 -26
- package/openspec/changes/track-selection/.openspec.yaml +0 -2
- package/openspec/changes/track-selection/design.md +0 -47
- package/openspec/changes/track-selection/proposal.md +0 -45
- package/openspec/changes/track-selection/specs/track-selection/spec.md +0 -41
- package/openspec/changes/track-selection/tasks.md +0 -20
- package/openspec/changes/transcode-quality/.openspec.yaml +0 -2
- package/openspec/changes/transcode-quality/proposal.md +0 -51
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +0 -139
- package/openspec/changes/transcode-quality/tasks.md +0 -63
- package/openspec/config.yaml +0 -38
- package/public/.well-known/appspecific/com.chrome.devtools.json +0 -1
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +0 -329
- package/research/double-spawn-2026-09-10.md +0 -171
- package/research/handover-2026-09-13.md +0 -209
- package/research/handover-reader-claims-removal-2026-09-12.md +0 -166
- package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +0 -175
- package/research/preset-benchmark-size-scaling-2026-08-22.md +0 -55
- package/research/priority-map-is-the-truth-2026-09-12.md +0 -225
- package/research/valgrind-replica-2026-08-27/logs.tar.gz +0 -0
- package/scripts/build-audio-samples.mjs +0 -31
- package/scripts/render-run-graph.js +0 -173
- package/test/asset-paths.test.js +0 -28
- package/test/audio-buffer-continuity.test.js +0 -77
- package/test/audio-calibration.test.js +0 -23
- package/test/audio-inventory.test.js +0 -212
- package/test/audio-track-bitrate.test.js +0 -40
- package/test/audio-warm-recorded.test.js +0 -63
- package/test/auto-quality-step.test.js +0 -762
- package/test/available-share.test.js +0 -76
- package/test/background-fill.test.js +0 -190
- package/test/client-log-file.test.js +0 -126
- package/test/client-logs-route.test.js +0 -34
- package/test/coalesce.test.js +0 -39
- package/test/completed-files.test.js +0 -115
- package/test/concurrent-cost.test.js +0 -148
- package/test/container-media-info.test.js +0 -228
- package/test/contention.test.js +0 -76
- package/test/core-dumps.test.js +0 -57
- package/test/coverage-follows-the-disk.test.js +0 -233
- package/test/coverage-map.test.js +0 -195
- package/test/cue-push-carries-what-the-walk-found.test.js +0 -58
- package/test/cut-times-timeline.test.js +0 -65
- package/test/cuts-follow-published-grid.test.js +0 -109
- package/test/data-channel-frame.test.js +0 -63
- package/test/data-channel-viewer-lifecycle.test.js +0 -193
- package/test/dead-torrent-handle.test.js +0 -52
- package/test/declared-rates.test.js +0 -116
- package/test/declared-tracks.test.js +0 -43
- package/test/decode-cost-fit.test.js +0 -202
- package/test/decode-cost.test.js +0 -475
- package/test/delivery-probe-field.test.js +0 -173
- package/test/delivery-probe.test.js +0 -382
- package/test/demand-register.test.js +0 -206
- package/test/dht-bootstrap.test.js +0 -72
- package/test/drain-threat.test.js +0 -65
- package/test/duplicate-add.test.js +0 -48
- package/test/encode-admission.test.js +0 -170
- package/test/encode-cost.test.js +0 -275
- package/test/encode-exit.test.js +0 -111
- package/test/encode-orchestrator.test.js +0 -426
- package/test/encode-plan-viewers.test.js +0 -725
- package/test/encode-plan.test.js +0 -545
- package/test/encode-run-state.test.js +0 -314
- package/test/encode-run.test.js +0 -248
- package/test/encode-slope.test.js +0 -46
- package/test/encoded-output.test.js +0 -39
- package/test/encoder-kinds.test.js +0 -122
- package/test/encoder-readings.test.js +0 -68
- package/test/episode-naming.test.js +0 -131
- package/test/every-claimant-is-told-its-share.test.js +0 -152
- package/test/failed-encoder-still-serves-what-exists.test.js +0 -354
- package/test/failed-start-breaker.test.js +0 -202
- package/test/fastest-wires.test.js +0 -143
- package/test/ffmpeg-stream-counts.test.js +0 -101
- package/test/file-edges.test.js +0 -191
- package/test/files-in-use.test.js +0 -34
- package/test/fixtures/audio-frame-boundary.json +0 -1
- package/test/fixtures/refused-audio-20261002.json +0 -10
- package/test/fmp4-presentation-clock.test.js +0 -148
- package/test/fmp4-self-contained.test.js +0 -80
- package/test/fmp4-timestamps.test.js +0 -140
- package/test/fmp4-track-completeness.test.js +0 -41
- package/test/format-keyed-outputs.test.js +0 -78
- package/test/health-metrics.test.js +0 -36
- package/test/held-read-ownership.test.js +0 -77
- package/test/held-request-width.test.js +0 -157
- package/test/helpers/encode-run.js +0 -248
- package/test/helpers/held-file.js +0 -77
- package/test/helpers/manager.js +0 -62
- package/test/helpers/matroska-file.js +0 -296
- package/test/helpers/output-spec.js +0 -63
- package/test/host-load.test.js +0 -68
- package/test/index-accuracy-is-about-the-file.test.js +0 -128
- package/test/init-compat.test.js +0 -219
- package/test/input-lost-quiets-the-plan.test.js +0 -261
- package/test/input-unavailable.test.js +0 -48
- package/test/installation-id.test.js +0 -39
- package/test/keeping-period.test.js +0 -83
- package/test/keyframe-index-accuracy.test.js +0 -188
- package/test/keyframe-table-budget.test.js +0 -123
- package/test/keyframes-belong-to-the-file.test.js +0 -249
- package/test/learned-median.test.js +0 -37
- package/test/letting-go-asks-who-is-watching.test.js +0 -191
- package/test/limit-row-by-area.test.js +0 -60
- package/test/link-budget.test.js +0 -111
- package/test/link-probe.test.js +0 -56
- package/test/local-encoding-data.test.js +0 -273
- package/test/logger-repeats.test.js +0 -134
- package/test/look-ahead-coverage.test.js +0 -56
- package/test/manager-surface.test.js +0 -291
- package/test/matroska-blocks.test.js +0 -0
- package/test/matroska-cluster-discovery.test.js +0 -323
- package/test/matroska-cues-track.test.js +0 -192
- package/test/media-time.test.js +0 -62
- package/test/memory-budget.test.js +0 -390
- package/test/move-cost.test.js +0 -216
- package/test/mp4-composition-times.test.js +0 -0
- package/test/mp4-subtitles.test.js +0 -173
- package/test/one-authority.test.js +0 -285
- package/test/one-budget.test.js +0 -149
- package/test/one-output-one-session.test.js +0 -273
- package/test/one-resource-divided.test.js +0 -185
- package/test/one-serving-output-per-height.test.js +0 -458
- package/test/orchestrator-wired.test.js +0 -207
- package/test/output-catalog.test.js +0 -39
- package/test/output-shape.test.js +0 -21
- package/test/output-spec.test.js +0 -251
- package/test/output-speed.test.js +0 -92
- package/test/outputs-being-prepared.test.js +0 -40
- package/test/packet-witness-ring.test.js +0 -236
- package/test/packet-witness.test.js +0 -148
- package/test/piece-completeness.test.js +0 -78
- package/test/piece-disk-store.test.js +0 -401
- package/test/piece-from-whole-file.test.js +0 -129
- package/test/piece-lru.test.js +0 -319
- package/test/piece-reader.test.js +0 -162
- package/test/piece-selection-offset.test.js +0 -85
- package/test/piece-store-eviction.test.js +0 -595
- package/test/piece-store-never-refuses.test.js +0 -153
- package/test/piece-store-reservations.test.js +0 -403
- package/test/piece-store-slow-disk.test.js +0 -159
- package/test/piece-tail.test.js +0 -97
- package/test/plan-host-timings.test.js +0 -68
- package/test/plan-is-steady.test.js +0 -225
- package/test/playback-readiness.test.js +0 -518
- package/test/playback-start-regressions.test.js +0 -101
- package/test/priority-map-download.test.js +0 -174
- package/test/priority-map-emptied.test.js +0 -204
- package/test/priority-map-per-output.test.js +0 -215
- package/test/priority-map.test.js +0 -163
- package/test/probe-wedge-certainty.test.js +0 -60
- package/test/produced-copy-choice.test.js +0 -213
- package/test/published-timeline.test.js +0 -83
- package/test/quality-offer.test.js +0 -160
- package/test/quality-variants.test.js +0 -1274
- package/test/rate-control-in-the-output.test.js +0 -181
- package/test/read-survives-withdrawal.test.js +0 -164
- package/test/read-window.test.js +0 -365
- package/test/reads-not-yet-arrived.test.js +0 -218
- package/test/resume-warm.test.js +0 -39
- package/test/run-graph-drift.test.js +0 -37
- package/test/run-inputs.test.js +0 -80
- package/test/run-intervals.test.js +0 -100
- package/test/run-position-follows-published-grid.test.js +0 -89
- package/test/run-progress.test.js +0 -64
- package/test/same-height-switch.test.js +0 -663
- package/test/seek-frees-the-viewers-requests.test.js +0 -217
- package/test/seek-landing.test.js +0 -109
- package/test/seek-target-not-superseded.test.js +0 -195
- package/test/segment-closed-on-both-branches.test.js +0 -114
- package/test/segment-cut-times.test.js +0 -63
- package/test/segment-demand.test.js +0 -80
- package/test/segment-hold-supersede.test.js +0 -91
- package/test/segment-judged-by-its-own-header.test.js +0 -222
- package/test/segment-serve-wiring.test.js +0 -481
- package/test/segment-store-eviction.test.js +0 -247
- package/test/segment-store.test.js +0 -352
- package/test/segment-true-start.test.js +0 -86
- package/test/segments-are-shared.test.js +0 -176
- package/test/send-chunk-bytes.test.js +0 -86
- package/test/serving-existing-output.test.js +0 -111
- package/test/serving-output.test.js +0 -102
- package/test/shared-piece-store.test.js +0 -310
- package/test/sidecar-naming.test.js +0 -142
- package/test/soundtrack-rate-bound.test.js +0 -289
- package/test/source-file.test.js +0 -133
- package/test/source-files-route.test.js +0 -113
- package/test/source-registry.test.js +0 -18
- package/test/split-a-stretch.test.js +0 -139
- package/test/stale-request-after-seek.test.js +0 -158
- package/test/startup-readings.test.js +0 -124
- package/test/stream-route.test.js +0 -237
- package/test/subtitle-cue-framing.test.js +0 -200
- package/test/subtitle-cue-source.test.js +0 -102
- package/test/subtitle-cue-walk.test.js +0 -385
- package/test/subtitle-cursor.test.js +0 -63
- package/test/subtitle-defaults.test.js +0 -118
- package/test/subtitle-language.test.js +0 -252
- package/test/subtitle-metadata.test.js +0 -15
- package/test/subtitle-plan-waits.test.js +0 -125
- package/test/subtitle-track-numbering.test.js +0 -391
- package/test/supply-margin.test.js +0 -190
- package/test/swarm-follows-readers.test.js +0 -174
- package/test/swarm-reach.test.js +0 -130
- package/test/swarm-selection.test.js +0 -220
- package/test/tail-duplication.test.js +0 -167
- package/test/the-channel-has-both-ends.test.js +0 -91
- package/test/the-torrent-thread-serves-bytes.test.js +0 -107
- package/test/thread-pool.test.js +0 -53
- package/test/timeline.test.js +0 -84
- package/test/torrent-contents.test.js +0 -235
- package/test/torrent-cost.test.js +0 -71
- package/test/torrent-files.test.js +0 -221
- package/test/torrent-naming.test.js +0 -255
- package/test/tracks-begin-together.test.js +0 -209
- package/test/tunnel-renewal.test.js +0 -167
- package/test/two-viewers-one-picture.test.js +0 -508
- package/test/upload-hurry.test.js +0 -186
- package/test/usrsctp-state.test.js +0 -131
- package/test/video-facts.test.js +0 -102
- package/test/viewer-assignments.test.js +0 -143
- package/test/viewer-outputs.test.js +0 -285
- package/test/viewer-presence.test.js +0 -116
- package/test/viewer-requests-admission.test.js +0 -92
- package/test/viewer-starts-on-map.test.js +0 -27
- package/test/viewer-states.test.js +0 -192
- package/test/viewer-subtitle-subscription.test.js +0 -96
- package/test/viewer.test.js +0 -139
- package/test/viewing-generations.test.js +0 -390
- package/test/visible-rung.test.js +0 -37
- package/test/wait-ledger.test.js +0 -100
- package/test/webrtc-shim.test.js +0 -149
- package/test/wedge-certainty.test.js +0 -131
- package/test/whole-file-held-read.test.js +0 -30
- package/test/withdraw-piece-claim.test.js +0 -212
- package/test/worker-channel.test.js +0 -158
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Two axes, kept apart: the framing a CONTAINER puts round a cue, and the
|
|
3
|
-
* markup the CODEC puts inside it.
|
|
4
|
-
*
|
|
5
|
-
* Field 2026-09-03, an English track of an embedded `.mkv`: the viewer was
|
|
6
|
-
* shown `21,0,Default,,0000,0000,0000,,I am the powerful Demon King of the
|
|
7
|
-
* Sixth Heaven.` — the whole dialogue row, fields and all. The cause was one
|
|
8
|
-
* function doing both jobs and deciding which framing it held by counting
|
|
9
|
-
* commas: it expected the ten fields a row has in a FILE, a Matroska block
|
|
10
|
-
* carries nine, so it returned the row untouched.
|
|
11
|
-
*
|
|
12
|
-
* What each check here pins is therefore not "ASS is stripped" but "the right
|
|
13
|
-
* side answers": the container takes off what it put on, and the codec takes
|
|
14
|
-
* off what it put in. The case that matters most is the last one — the same
|
|
15
|
-
* line of dialogue, stored two ways, coming out identical.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import test from "node:test";
|
|
19
|
-
import assert from "node:assert/strict";
|
|
20
|
-
|
|
21
|
-
import { AviContainer } from "../services/media/container/AviContainer.js";
|
|
22
|
-
import { MatroskaContainer } from "../services/media/container/MatroskaContainer.js";
|
|
23
|
-
import { Mp4Container } from "../services/media/container/Mp4Container.js";
|
|
24
|
-
import { SubtitleFileContainer } from "../services/media/container/SubtitleFileContainer.js";
|
|
25
|
-
import { MarkupKind, TextSubtitleTrack } from "../services/media/tracks/TextSubtitleTrack.js";
|
|
26
|
-
|
|
27
|
-
/** The line from the field report, as Matroska stores it. */
|
|
28
|
-
const FIELD_BLOCK =
|
|
29
|
-
"21,0,Default,,0000,0000,0000,,I am the powerful Demon King of the Sixth Heaven. Good... Evil...";
|
|
30
|
-
|
|
31
|
-
function block(text) {
|
|
32
|
-
return Buffer.from(text, "utf8");
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
test("a Matroska ASS block gives up its text and nothing else", () => {
|
|
36
|
-
// Nine fields exactly, and no comma in the dialogue: the shape that defeated
|
|
37
|
-
// the old field count and put the whole row on the viewer's screen.
|
|
38
|
-
assert.equal(
|
|
39
|
-
MatroskaContainer.cueTextOf(block(FIELD_BLOCK), "S_TEXT/ASS"),
|
|
40
|
-
"I am the powerful Demon King of the Sixth Heaven. Good... Evil..."
|
|
41
|
-
);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test("commas in the dialogue are dialogue, not fields", () => {
|
|
45
|
-
assert.equal(
|
|
46
|
-
MatroskaContainer.cueTextOf(block("7,0,Main,Nobunaga,0000,0000,0000,,Yes, of course, my lord"), "S_TEXT/ASS"),
|
|
47
|
-
"Yes, of course, my lord"
|
|
48
|
-
);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("an SSA block is framed the same way as an ASS one", () => {
|
|
52
|
-
// SSA writes `Marked` where ASS writes `Layer`; the count is the same and so
|
|
53
|
-
// is the answer.
|
|
54
|
-
assert.equal(
|
|
55
|
-
MatroskaContainer.cueTextOf(block("3,,Default,,0000,0000,0000,,Toujours rien."), "S_TEXT/SSA"),
|
|
56
|
-
"Toujours rien."
|
|
57
|
-
);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("a row too short to hold a text field yields nothing to show", () => {
|
|
61
|
-
assert.equal(MatroskaContainer.cueTextOf(block("21,0,Default,,0000"), "S_TEXT/ASS"), "");
|
|
62
|
-
assert.equal(MatroskaContainer.cueTextOf(block("21,0,Default,,0000,0000,0000,,"), "S_TEXT/ASS"), "");
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test("a plain-text Matroska block is not touched at all", () => {
|
|
66
|
-
// `S_TEXT/UTF8` has no framing: the block IS the text, commas included.
|
|
67
|
-
const line = "Yes, of course, my lord";
|
|
68
|
-
assert.equal(MatroskaContainer.cueTextOf(block(line), "S_TEXT/UTF8"), line);
|
|
69
|
-
assert.equal(MatroskaContainer.cueTextOf(block(line), "S_TEXT/WEBVTT"), line);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("an MP4 sample is unframed by the MP4, length prefix and all", () => {
|
|
73
|
-
const text = Buffer.from("Yes, of course, my lord", "utf8");
|
|
74
|
-
const sample = Buffer.concat([Buffer.from([0x00, text.length]), text]);
|
|
75
|
-
assert.equal(Mp4Container.cueTextOf(sample, "tx3g"), "Yes, of course, my lord");
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("a container with no framing of its own refuses to guess", () => {
|
|
79
|
-
// AVI declares no subtitle tracks, so no cue can reach it. If one ever did,
|
|
80
|
-
// the answer must be an error and not a guess at somebody else's framing.
|
|
81
|
-
assert.throws(() => AviContainer.cueTextOf(block("x"), "S_TEXT/ASS"), /cueTextOf not implemented/);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test("ASS markup is taken off wherever the text came from", () => {
|
|
85
|
-
assert.equal(TextSubtitleTrack.plainTextOf("{\\pos(640,620)}Hello{\\i1} there{\\i0}", "S_TEXT/ASS"), "Hello there");
|
|
86
|
-
assert.equal(TextSubtitleTrack.plainTextOf("First\\NSecond\\nThird", "S_TEXT/ASS"), "First\nSecond\nThird");
|
|
87
|
-
assert.equal(TextSubtitleTrack.plainTextOf("Wide\\hspace", "S_TEXT/ASS"), "Wide space");
|
|
88
|
-
// The same text under a codec that has no such markup keeps its characters.
|
|
89
|
-
assert.equal(TextSubtitleTrack.plainTextOf("{\\pos(1,2)}Hello", "S_TEXT/UTF8"), "{\\pos(1,2)}Hello");
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test("a codec is known by any of its names", () => {
|
|
93
|
-
assert.equal(TextSubtitleTrack.markupKindOf("S_TEXT/ASS"), MarkupKind.ASS);
|
|
94
|
-
assert.equal(TextSubtitleTrack.markupKindOf(".ASS"), MarkupKind.ASS);
|
|
95
|
-
assert.equal(TextSubtitleTrack.markupKindOf("S_TEXT/UTF8"), MarkupKind.NONE);
|
|
96
|
-
assert.equal(TextSubtitleTrack.markupKindOf("wvtt"), MarkupKind.NONE);
|
|
97
|
-
// Unknown: shown as it is, rather than refused.
|
|
98
|
-
assert.equal(TextSubtitleTrack.markupKindOf("S_TEXT/SOMETHING"), MarkupKind.NONE);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test("a text track answers for its own markup", () => {
|
|
102
|
-
const track = new TextSubtitleTrack({ trackNumber: 3, declaredIndex: 0, codecId: "S_TEXT/ASS" });
|
|
103
|
-
assert.equal(track.markupKind, MarkupKind.ASS);
|
|
104
|
-
assert.equal(track.plainText("{\\be1}Готовьте лучников"), "Готовьте лучников");
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test("a file states its own column order and is obeyed", () => {
|
|
108
|
-
// This `Format:` has no `Name` column, so the text is the ninth field and not
|
|
109
|
-
// the tenth. A reader that assumed a position would take the effect field as
|
|
110
|
-
// the line — which is the file-side form of the defect this suite is about.
|
|
111
|
-
const file = [
|
|
112
|
-
"[Script Info]",
|
|
113
|
-
"ScriptType: v4.00+",
|
|
114
|
-
"[Events]",
|
|
115
|
-
"Format: Layer, Start, End, Style, MarginL, MarginR, MarginV, Effect, Text",
|
|
116
|
-
"Dialogue: 0,0:00:01.00,0:00:03.50,Default,0000,0000,0000,,Ты видишь их?",
|
|
117
|
-
""
|
|
118
|
-
].join("\n");
|
|
119
|
-
|
|
120
|
-
const cues = new SubtitleFileContainer({ extension: ".ass" }).readCues(file);
|
|
121
|
-
assert.deepEqual(cues, [{ startSeconds: 1, endSeconds: 3.5, text: "Ты видишь их?" }]);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
test("a dialogue row before any Format line is not guessed at", () => {
|
|
125
|
-
const file = ["[Events]", "Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,Ignored", ""].join("\n");
|
|
126
|
-
assert.deepEqual(new SubtitleFileContainer({ extension: ".ass" }).readCues(file), []);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
test("SubRip cues keep their words and lose their numbering", () => {
|
|
130
|
-
const file = [
|
|
131
|
-
"1",
|
|
132
|
-
"00:00:01,000 --> 00:00:03,500",
|
|
133
|
-
"Ты видишь их?",
|
|
134
|
-
"",
|
|
135
|
-
"2",
|
|
136
|
-
"00:01:02,250 --> 00:01:04,000",
|
|
137
|
-
"<i>Да.</i>",
|
|
138
|
-
"Но не столько вижу.",
|
|
139
|
-
""
|
|
140
|
-
].join("\n");
|
|
141
|
-
|
|
142
|
-
const cues = new SubtitleFileContainer({ extension: ".srt" }).readCues(file);
|
|
143
|
-
assert.deepEqual(cues, [
|
|
144
|
-
{ startSeconds: 1, endSeconds: 3.5, text: "Ты видишь их?" },
|
|
145
|
-
{ startSeconds: 62.25, endSeconds: 64, text: "<i>Да.</i>\nНо не столько вижу." }
|
|
146
|
-
]);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
test("a SubRip file with no blank line between cues does not swallow the ordinal", () => {
|
|
150
|
-
const file = [
|
|
151
|
-
"1",
|
|
152
|
-
"00:00:01,000 --> 00:00:02,000",
|
|
153
|
-
"First",
|
|
154
|
-
"2",
|
|
155
|
-
"00:00:03,000 --> 00:00:04,000",
|
|
156
|
-
"Second"
|
|
157
|
-
].join("\n");
|
|
158
|
-
|
|
159
|
-
const cues = new SubtitleFileContainer({ extension: ".srt" }).readCues(file);
|
|
160
|
-
assert.deepEqual(cues.map((cue) => cue.text), ["First", "Second"]);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
test("a WebVTT file is passed through, not taken apart", () => {
|
|
164
|
-
const file = "WEBVTT\n\nSTYLE\n::cue { color: yellow }\n\nintro\n00:00:01.000 --> 00:00:02.000\nHello\n";
|
|
165
|
-
assert.equal(SubtitleFileContainer.toVtt(file, ".vtt"), file);
|
|
166
|
-
assert.equal(new SubtitleFileContainer({ extension: ".vtt" }).readCues(file), null);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test("a format nothing here reads is refused rather than mangled", () => {
|
|
170
|
-
assert.equal(SubtitleFileContainer.toVtt("whatever", ".sup"), null);
|
|
171
|
-
assert.equal(SubtitleFileContainer.toVtt("whatever", ".ttml"), null);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
test("one line of dialogue, two framings, one result", () => {
|
|
175
|
-
// The whole point of the split. The same ASS line stored in a Matroska block
|
|
176
|
-
// and in a file must reach the viewer identically, and neither path may know
|
|
177
|
-
// anything about the other.
|
|
178
|
-
const spoken = "Yes, of course, my lord";
|
|
179
|
-
const markup = `{\\pos(640,620)}${spoken}`;
|
|
180
|
-
|
|
181
|
-
const fromBlock = TextSubtitleTrack.finalizeCues(
|
|
182
|
-
[{
|
|
183
|
-
startSeconds: 1,
|
|
184
|
-
endSeconds: 3,
|
|
185
|
-
text: MatroskaContainer.cueTextOf(block(`21,0,Default,,0000,0000,0000,,${markup}`), "S_TEXT/ASS")
|
|
186
|
-
}],
|
|
187
|
-
"S_TEXT/ASS"
|
|
188
|
-
);
|
|
189
|
-
|
|
190
|
-
const fromFile = new SubtitleFileContainer({ extension: ".ass" }).readCues([
|
|
191
|
-
"[Events]",
|
|
192
|
-
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
|
|
193
|
-
`Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0000,0000,0000,,${markup}`,
|
|
194
|
-
""
|
|
195
|
-
].join("\n"));
|
|
196
|
-
|
|
197
|
-
assert.equal(fromBlock[0].text, spoken);
|
|
198
|
-
assert.equal(TextSubtitleTrack.finalizeCues(fromFile, ".ass")[0].text, spoken);
|
|
199
|
-
assert.equal(TextSubtitleTrack.cuesToVtt(fromBlock, "S_TEXT/ASS"), TextSubtitleTrack.cuesToVtt(fromFile, ".ass"));
|
|
200
|
-
});
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file WHAT answers a pull for subtitle cues — the walk, and nothing else.
|
|
3
|
-
*
|
|
4
|
-
* Field 2026-09-03, on `[HorribleSubs] Drifters - 04 [1080p].mkv`. The file had
|
|
5
|
-
* been downloaded in an earlier sitting, so the torrent worker's cluster walk
|
|
6
|
-
* found cues 1.5 s after the file was opened and pushed four batches — cursor 1
|
|
7
|
-
* to 31, covering everything up to 81.7 s — before the browser had subscribed.
|
|
8
|
-
* The browser's catch-up pull, which exists for exactly that case, answered a
|
|
9
|
-
* seven-byte `WEBVTT` with `x-subtitle-covered-clusters: 0` against 283
|
|
10
|
-
* indexed. So the viewer watched the first 82 s with no subtitles and the rest
|
|
11
|
-
* of the episode with them.
|
|
12
|
-
*
|
|
13
|
-
* The pull was answered by something that could not read pieces. Whatever a
|
|
14
|
-
* pull is handed, the walk decides what it may read from it, so every range
|
|
15
|
-
* read as "not downloaded", nothing was walked, and an empty document came
|
|
16
|
-
* back. An empty document is also the right answer for a file that holds no
|
|
17
|
-
* cues yet, which is why nothing reported a failure.
|
|
18
|
-
*
|
|
19
|
-
* The thread stopped being the distinction on 2026-09-15 — the walk runs beside
|
|
20
|
-
* the sessions now, handed which byte ranges of a file are downloaded whole —
|
|
21
|
-
* but the property it was standing for did not: a pull is answered by the SAME
|
|
22
|
-
* walk that pushes, its own figures travel back, and anything that cannot read
|
|
23
|
-
* says so rather than answering emptily.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import test from "node:test";
|
|
27
|
-
import assert from "node:assert/strict";
|
|
28
|
-
|
|
29
|
-
import { SubtitleOrchestrator } from "../services/media/SubtitleOrchestrator.js";
|
|
30
|
-
import { cuesHeldFor } from "../services/media/SubtitleCues.js";
|
|
31
|
-
import { heldFileOver } from "./helpers/held-file.js";
|
|
32
|
-
|
|
33
|
-
/** A torrent that cannot say which of itself is downloaded. */
|
|
34
|
-
function mainThreadTorrent() {
|
|
35
|
-
return {
|
|
36
|
-
infoHash: "0".repeat(40),
|
|
37
|
-
name: "[HorribleSubs] Drifters - 04 [1080p].mkv",
|
|
38
|
-
sourceKey: "a".repeat(40),
|
|
39
|
-
files: [{ name: "[HorribleSubs] Drifters - 04 [1080p].mkv", length: 567_535_843, createReadStream: () => { throw new Error("not reached"); } }]
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
test("a pull is answered by the walk, and the walk's own figures travel back", async () => {
|
|
44
|
-
const asked = [];
|
|
45
|
-
const cues = {
|
|
46
|
-
async held(torrent, fileIndex, sourceKey, trackNumber) {
|
|
47
|
-
asked.push({ sourceKey, fileIndex, trackNumber });
|
|
48
|
-
return {
|
|
49
|
-
cues: [{ startSeconds: 5.4, endSeconds: 9.1, text: "So what if you brought them over?", seq: 1 }],
|
|
50
|
-
coveredClusters: 283,
|
|
51
|
-
indexedClusters: 283,
|
|
52
|
-
track: { codecId: "S_TEXT/ASS", codecPrivate: "", language: "" }
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const orchestrator = new SubtitleOrchestrator({ forget() {} }, cues);
|
|
58
|
-
const held = await orchestrator.getCues(mainThreadTorrent(), 33, "a".repeat(40), 3);
|
|
59
|
-
|
|
60
|
-
assert.deepEqual(asked, [{ sourceKey: "a".repeat(40), fileIndex: 33, trackNumber: 3 }]);
|
|
61
|
-
assert.equal(held.cues.length, 1);
|
|
62
|
-
assert.equal(held.coveredClusters, 283, "the walk's own figure travels back, so the header cannot claim 0 of 283");
|
|
63
|
-
// The worker answers with the track's fields flat — only plain objects cross
|
|
64
|
-
// the boundary — and the caller reads them through `held.track`.
|
|
65
|
-
assert.equal(held.track.codecId, "S_TEXT/ASS");
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test("the cursor of a pulled cue is the found-order the worker assigned", async () => {
|
|
69
|
-
// The browser mixes cursors from pulls and pushes. A second walk would start
|
|
70
|
-
// a second `seq` counter and the two would not be comparable — which is why
|
|
71
|
-
// both ends go through the one walk, with one per-file state.
|
|
72
|
-
const cues = {
|
|
73
|
-
async held() {
|
|
74
|
-
return {
|
|
75
|
-
cues: [
|
|
76
|
-
{ startSeconds: 5.4, endSeconds: 9.1, text: "one", seq: 1 },
|
|
77
|
-
{ startSeconds: 81.7, endSeconds: 84.0, text: "two", seq: 31 }
|
|
78
|
-
],
|
|
79
|
-
coveredClusters: 12,
|
|
80
|
-
indexedClusters: 283,
|
|
81
|
-
track: { codecId: "S_TEXT/ASS", codecPrivate: "", language: "" }
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
};
|
|
85
|
-
const orchestrator = new SubtitleOrchestrator({ forget() {} }, cues);
|
|
86
|
-
const held = await orchestrator.getCues(mainThreadTorrent(), 33, "a".repeat(40), 3);
|
|
87
|
-
assert.deepEqual(held.cues.map((cue) => cue.seq), [1, 31]);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test("an orchestrator with no walk is refused, not answered emptily", async () => {
|
|
91
|
-
const orchestrator = new SubtitleOrchestrator({ forget() {} });
|
|
92
|
-
const held = await orchestrator.getCues(mainThreadTorrent(), 33, "a".repeat(40), 3);
|
|
93
|
-
assert.deepEqual(held, { cues: [], coveredClusters: 0, indexedClusters: 0, track: null });
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test("the walk refuses a torrent that cannot say which pieces it holds", async () => {
|
|
97
|
-
// Called directly. Without this guard the answer is an empty list
|
|
98
|
-
// indistinguishable from a file with no cues, which is what hid the defect
|
|
99
|
-
// for a whole session.
|
|
100
|
-
const held = await cuesHeldFor(heldFileOver(mainThreadTorrent(), 33, "b".repeat(40)), 3);
|
|
101
|
-
assert.deepEqual(held, { cues: [], coveredClusters: 0, indexedClusters: 0, track: null });
|
|
102
|
-
});
|
|
@@ -1,385 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file What comes out of a real cluster walk, end to end.
|
|
3
|
-
*
|
|
4
|
-
* This is the check the walk never had. Everything around it was covered — the
|
|
5
|
-
* numbering a track is asked for by, the framing one cue's bytes carry, the
|
|
6
|
-
* thread a pull is answered on — and the walk itself, six hundred lines of it,
|
|
7
|
-
* had none: a file is built here with real clusters carrying real blocks, and
|
|
8
|
-
* the cues that come back out are stated exactly. Two field defects landed on
|
|
9
|
-
* this path in one week; a move of this code without a check that would catch a
|
|
10
|
-
* regression is a change nobody can verify.
|
|
11
|
-
*
|
|
12
|
-
* The four things asserted are the four the walk exists for: the text of each
|
|
13
|
-
* cue with its times, the found-order cursor a browser follows, the rule that
|
|
14
|
-
* only already-downloaded clusters are read, and the rule that one walk fills
|
|
15
|
-
* every track of the file.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import test from "node:test";
|
|
19
|
-
import assert from "node:assert/strict";
|
|
20
|
-
import { Readable } from "node:stream";
|
|
21
|
-
|
|
22
|
-
import { cuesHeldFor, warmSubtitleCues, forgetSubtitles, subtitleTracksOf } from "../services/media/SubtitleCues.js";
|
|
23
|
-
import { heldFileOver } from "./helpers/held-file.js";
|
|
24
|
-
|
|
25
|
-
const ID_EBML = 0x1a45dfa3;
|
|
26
|
-
const ID_SEGMENT = 0x18538067;
|
|
27
|
-
const ID_SEEK_HEAD = 0x114d9b74;
|
|
28
|
-
const ID_SEEK = 0x4dbb;
|
|
29
|
-
const ID_SEEK_ID = 0x53ab;
|
|
30
|
-
const ID_SEEK_POSITION = 0x53ac;
|
|
31
|
-
const ID_INFO = 0x1549a966;
|
|
32
|
-
const ID_TIMESTAMP_SCALE = 0x2ad7b1;
|
|
33
|
-
const ID_TRACKS = 0x1654ae6b;
|
|
34
|
-
const ID_TRACK_ENTRY = 0xae;
|
|
35
|
-
const ID_TRACK_NUMBER = 0xd7;
|
|
36
|
-
const ID_TRACK_TYPE = 0x83;
|
|
37
|
-
const ID_CODEC_ID = 0x86;
|
|
38
|
-
const ID_LANGUAGE = 0x22b59c;
|
|
39
|
-
const ID_CUES = 0x1c53bb6b;
|
|
40
|
-
const ID_CUE_POINT = 0xbb;
|
|
41
|
-
const ID_CUE_TIME = 0xb3;
|
|
42
|
-
const ID_CUE_TRACK_POSITIONS = 0xb7;
|
|
43
|
-
const ID_CUE_TRACK = 0xf7;
|
|
44
|
-
const ID_CUE_CLUSTER_POSITION = 0xf1;
|
|
45
|
-
const ID_CLUSTER = 0x1f43b675;
|
|
46
|
-
const ID_TIMESTAMP = 0xe7;
|
|
47
|
-
const ID_BLOCK_GROUP = 0xa0;
|
|
48
|
-
const ID_BLOCK = 0xa1;
|
|
49
|
-
const ID_BLOCK_DURATION = 0x9b;
|
|
50
|
-
|
|
51
|
-
function idBytes(id) {
|
|
52
|
-
const bytes = [];
|
|
53
|
-
let rest = id;
|
|
54
|
-
while (rest > 0) {
|
|
55
|
-
bytes.unshift(rest & 0xff);
|
|
56
|
-
rest = Math.floor(rest / 256);
|
|
57
|
-
}
|
|
58
|
-
return Buffer.from(bytes);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** A four-byte size: a valid variable-length integer whatever the payload. */
|
|
62
|
-
function sizeBytes(size) {
|
|
63
|
-
const buffer = Buffer.alloc(4);
|
|
64
|
-
buffer.writeUInt32BE(size, 0);
|
|
65
|
-
buffer[0] |= 0x10;
|
|
66
|
-
return buffer;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function element(id, payload) {
|
|
70
|
-
return Buffer.concat([idBytes(id), sizeBytes(payload.length), payload]);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function uintElement(id, value) {
|
|
74
|
-
const bytes = [];
|
|
75
|
-
let rest = value;
|
|
76
|
-
do {
|
|
77
|
-
bytes.unshift(rest & 0xff);
|
|
78
|
-
rest = Math.floor(rest / 256);
|
|
79
|
-
} while (rest > 0);
|
|
80
|
-
return element(id, Buffer.from(bytes));
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function stringElement(id, value) {
|
|
84
|
-
return element(id, Buffer.from(value, "utf8"));
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** A fixed four bytes, so a table measured twice comes out the same length. */
|
|
88
|
-
function uint32Element(id, value) {
|
|
89
|
-
const payload = Buffer.alloc(4);
|
|
90
|
-
payload.writeUInt32BE(value, 0);
|
|
91
|
-
return element(id, payload);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function trackEntry({ number, type, codecId, language }) {
|
|
95
|
-
return element(ID_TRACK_ENTRY, Buffer.concat([
|
|
96
|
-
uintElement(ID_TRACK_NUMBER, number),
|
|
97
|
-
uintElement(ID_TRACK_TYPE, type),
|
|
98
|
-
stringElement(ID_CODEC_ID, codecId),
|
|
99
|
-
stringElement(ID_LANGUAGE, language)
|
|
100
|
-
]));
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* One cue, as a BlockGroup so it can carry its own duration.
|
|
105
|
-
*
|
|
106
|
-
* @param {{ track: number, relativeTicks: number, durationTicks: number, text: string }} params
|
|
107
|
-
* @returns {Buffer}
|
|
108
|
-
*/
|
|
109
|
-
function cueBlock({ track, relativeTicks, durationTicks, text }) {
|
|
110
|
-
const header = Buffer.alloc(4);
|
|
111
|
-
header[0] = 0x80 | track; // track numbers under 128 are one byte, marker set
|
|
112
|
-
header.writeInt16BE(relativeTicks, 1);
|
|
113
|
-
header[3] = 0;
|
|
114
|
-
const block = element(ID_BLOCK, Buffer.concat([header, Buffer.from(text, "utf8")]));
|
|
115
|
-
return element(ID_BLOCK_GROUP, Buffer.concat([block, uintElement(ID_BLOCK_DURATION, durationTicks)]));
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* A file with two text tracks and two clusters, each carrying a line for each
|
|
120
|
-
* track.
|
|
121
|
-
*
|
|
122
|
-
* Track 2 is `S_TEXT/UTF8`, whose block payload IS the text. Track 3 is
|
|
123
|
-
* `S_TEXT/ASS`, whose payload is the dialogue row without its `Dialogue:`
|
|
124
|
-
* header — eight comma-separated fields and then the words, which is what
|
|
125
|
-
* Matroska states and what the framing rule has to strip.
|
|
126
|
-
*
|
|
127
|
-
* @returns {{ file: Buffer, clusterAt: number[] }}
|
|
128
|
-
*/
|
|
129
|
-
function buildFile() {
|
|
130
|
-
const info = element(ID_INFO, uintElement(ID_TIMESTAMP_SCALE, 1_000_000));
|
|
131
|
-
const tracks = element(ID_TRACKS, Buffer.concat([
|
|
132
|
-
trackEntry({ number: 1, type: 1, codecId: "V_MPEG4/ISO/AVC", language: "und" }),
|
|
133
|
-
trackEntry({ number: 2, type: 17, codecId: "S_TEXT/UTF8", language: "eng" }),
|
|
134
|
-
trackEntry({ number: 3, type: 17, codecId: "S_TEXT/ASS", language: "rus" })
|
|
135
|
-
]));
|
|
136
|
-
|
|
137
|
-
const clusterOne = element(ID_CLUSTER, Buffer.concat([
|
|
138
|
-
uintElement(ID_TIMESTAMP, 1000),
|
|
139
|
-
cueBlock({ track: 2, relativeTicks: 0, durationTicks: 2000, text: "First English line" }),
|
|
140
|
-
cueBlock({
|
|
141
|
-
track: 3,
|
|
142
|
-
relativeTicks: 500,
|
|
143
|
-
durationTicks: 1500,
|
|
144
|
-
// ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV, Effect,
|
|
145
|
-
// then the text — the Dialogue row without its header and its timestamps.
|
|
146
|
-
text: "1,0,Default,,0000,0000,0000,,Первая русская строка"
|
|
147
|
-
})
|
|
148
|
-
]));
|
|
149
|
-
const clusterTwo = element(ID_CLUSTER, Buffer.concat([
|
|
150
|
-
uintElement(ID_TIMESTAMP, 10_000),
|
|
151
|
-
cueBlock({ track: 2, relativeTicks: 0, durationTicks: 1000, text: "Second English line" }),
|
|
152
|
-
cueBlock({
|
|
153
|
-
track: 3,
|
|
154
|
-
relativeTicks: 250,
|
|
155
|
-
durationTicks: 1000,
|
|
156
|
-
text: "2,0,Default,,0000,0000,0000,,Вторая, с запятой"
|
|
157
|
-
})
|
|
158
|
-
]));
|
|
159
|
-
|
|
160
|
-
const cuesWith = (firstAt, secondAt) => element(ID_CUES, Buffer.concat([
|
|
161
|
-
element(ID_CUE_POINT, Buffer.concat([
|
|
162
|
-
uintElement(ID_CUE_TIME, 1000),
|
|
163
|
-
element(ID_CUE_TRACK_POSITIONS, Buffer.concat([
|
|
164
|
-
uintElement(ID_CUE_TRACK, 2),
|
|
165
|
-
uint32Element(ID_CUE_CLUSTER_POSITION, firstAt)
|
|
166
|
-
])),
|
|
167
|
-
element(ID_CUE_TRACK_POSITIONS, Buffer.concat([
|
|
168
|
-
uintElement(ID_CUE_TRACK, 3),
|
|
169
|
-
uint32Element(ID_CUE_CLUSTER_POSITION, firstAt)
|
|
170
|
-
]))
|
|
171
|
-
])),
|
|
172
|
-
element(ID_CUE_POINT, Buffer.concat([
|
|
173
|
-
uintElement(ID_CUE_TIME, 10_000),
|
|
174
|
-
element(ID_CUE_TRACK_POSITIONS, Buffer.concat([
|
|
175
|
-
uintElement(ID_CUE_TRACK, 2),
|
|
176
|
-
uint32Element(ID_CUE_CLUSTER_POSITION, secondAt)
|
|
177
|
-
])),
|
|
178
|
-
element(ID_CUE_TRACK_POSITIONS, Buffer.concat([
|
|
179
|
-
uintElement(ID_CUE_TRACK, 3),
|
|
180
|
-
uint32Element(ID_CUE_CLUSTER_POSITION, secondAt)
|
|
181
|
-
]))
|
|
182
|
-
]))
|
|
183
|
-
]));
|
|
184
|
-
|
|
185
|
-
const seekEntry = (targetId, position) => element(ID_SEEK, Buffer.concat([
|
|
186
|
-
element(ID_SEEK_ID, idBytes(targetId)),
|
|
187
|
-
uint32Element(ID_SEEK_POSITION, position)
|
|
188
|
-
]));
|
|
189
|
-
const seekHeadWith = (infoAt, tracksAt, cuesAt) => element(ID_SEEK_HEAD, Buffer.concat([
|
|
190
|
-
seekEntry(ID_INFO, infoAt),
|
|
191
|
-
seekEntry(ID_TRACKS, tracksAt),
|
|
192
|
-
seekEntry(ID_CUES, cuesAt)
|
|
193
|
-
]));
|
|
194
|
-
|
|
195
|
-
const headLength = seekHeadWith(0, 0, 0).length;
|
|
196
|
-
const infoAt = headLength;
|
|
197
|
-
const tracksAt = infoAt + info.length;
|
|
198
|
-
const cuesAt = tracksAt + tracks.length;
|
|
199
|
-
const firstClusterAt = cuesAt + cuesWith(0, 0).length;
|
|
200
|
-
const secondClusterAt = firstClusterAt + clusterOne.length;
|
|
201
|
-
|
|
202
|
-
const segmentPayload = Buffer.concat([
|
|
203
|
-
seekHeadWith(infoAt, tracksAt, cuesAt),
|
|
204
|
-
info,
|
|
205
|
-
tracks,
|
|
206
|
-
cuesWith(firstClusterAt, secondClusterAt),
|
|
207
|
-
clusterOne,
|
|
208
|
-
clusterTwo
|
|
209
|
-
]);
|
|
210
|
-
const ebml = element(ID_EBML, Buffer.from([0x42, 0x86, 0x81, 0x01]));
|
|
211
|
-
const segment = element(ID_SEGMENT, segmentPayload);
|
|
212
|
-
const segmentDataOffset = ebml.length + segment.length - segmentPayload.length;
|
|
213
|
-
return {
|
|
214
|
-
file: Buffer.concat([ebml, segment]),
|
|
215
|
-
clusterAt: [segmentDataOffset + firstClusterAt, segmentDataOffset + secondClusterAt]
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* A torrent over one file, holding whichever pieces the caller says.
|
|
221
|
-
*
|
|
222
|
-
* @param {Buffer} bytes
|
|
223
|
-
* @param {(index: number) => boolean} [holds] - Whether a piece is downloaded.
|
|
224
|
-
* @returns {{ torrent: object, reads: Array<{ start: number, end: number }>, pieceLength: number }}
|
|
225
|
-
*/
|
|
226
|
-
function torrentOver(bytes, holds = () => true) {
|
|
227
|
-
const pieceLength = 64;
|
|
228
|
-
const reads = [];
|
|
229
|
-
const file = {
|
|
230
|
-
name: "film.mkv",
|
|
231
|
-
length: bytes.length,
|
|
232
|
-
offset: 0,
|
|
233
|
-
createReadStream({ start = 0, end = bytes.length - 1 } = {}) {
|
|
234
|
-
reads.push({ start, end });
|
|
235
|
-
return Readable.from((async function* chunks() {
|
|
236
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
237
|
-
yield bytes.subarray(start, end + 1);
|
|
238
|
-
})());
|
|
239
|
-
}
|
|
240
|
-
};
|
|
241
|
-
return {
|
|
242
|
-
reads,
|
|
243
|
-
pieceLength,
|
|
244
|
-
torrent: {
|
|
245
|
-
name: "film",
|
|
246
|
-
pieceLength,
|
|
247
|
-
bitfield: { get: (index) => holds(index) },
|
|
248
|
-
files: [file]
|
|
249
|
-
}
|
|
250
|
-
};
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
test("a walk of a downloaded file gives up every cue, unframed, with its times", async () => {
|
|
254
|
-
const { file } = buildFile();
|
|
255
|
-
const { torrent } = torrentOver(file);
|
|
256
|
-
const sourceKey = "a".repeat(40);
|
|
257
|
-
forgetSubtitles(sourceKey);
|
|
258
|
-
try {
|
|
259
|
-
const plain = await cuesHeldFor(heldFileOver(torrent, 0, sourceKey), 2);
|
|
260
|
-
assert.deepEqual(
|
|
261
|
-
plain.cues.map((cue) => [cue.startSeconds, cue.endSeconds, cue.text]),
|
|
262
|
-
[
|
|
263
|
-
[1, 3, "First English line"],
|
|
264
|
-
[10, 11, "Second English line"]
|
|
265
|
-
]
|
|
266
|
-
);
|
|
267
|
-
|
|
268
|
-
const ass = await cuesHeldFor(heldFileOver(torrent, 0, sourceKey), 3);
|
|
269
|
-
assert.deepEqual(
|
|
270
|
-
ass.cues.map((cue) => [cue.startSeconds, cue.endSeconds, cue.text]),
|
|
271
|
-
[
|
|
272
|
-
[1.5, 3, "Первая русская строка"],
|
|
273
|
-
[10.25, 11.25, "Вторая, с запятой"]
|
|
274
|
-
],
|
|
275
|
-
"the eight fields Matroska puts before the text are the container's framing, not the words"
|
|
276
|
-
);
|
|
277
|
-
} finally {
|
|
278
|
-
forgetSubtitles(sourceKey);
|
|
279
|
-
}
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
test("the cursor is the order cues were FOUND, and it never repeats", async () => {
|
|
283
|
-
const { file } = buildFile();
|
|
284
|
-
const { torrent } = torrentOver(file);
|
|
285
|
-
const sourceKey = "b".repeat(40);
|
|
286
|
-
forgetSubtitles(sourceKey);
|
|
287
|
-
try {
|
|
288
|
-
const held = await cuesHeldFor(heldFileOver(torrent, 0, sourceKey), 2);
|
|
289
|
-
const cursors = held.cues.map((cue) => cue.seq);
|
|
290
|
-
assert.deepEqual(cursors, [1, 2], "two cues found, in the order they were read");
|
|
291
|
-
assert.equal(new Set(cursors).size, cursors.length, "no cue shares a cursor with another");
|
|
292
|
-
} finally {
|
|
293
|
-
forgetSubtitles(sourceKey);
|
|
294
|
-
}
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
test("a cluster whose bytes are not downloaded is left for next time", async () => {
|
|
298
|
-
const { file, clusterAt } = buildFile();
|
|
299
|
-
// Everything except the pieces the SECOND cluster sits in.
|
|
300
|
-
const { torrent, pieceLength } = torrentOver(file, (index) => index * pieceLength < clusterAt[1]);
|
|
301
|
-
const sourceKey = "c".repeat(40);
|
|
302
|
-
forgetSubtitles(sourceKey);
|
|
303
|
-
try {
|
|
304
|
-
const held = await cuesHeldFor(heldFileOver(torrent, 0, sourceKey), 2);
|
|
305
|
-
assert.deepEqual(
|
|
306
|
-
held.cues.map((cue) => cue.text),
|
|
307
|
-
["First English line"],
|
|
308
|
-
"the downloaded cluster is read and the other is not — nothing is asked of the swarm"
|
|
309
|
-
);
|
|
310
|
-
assert.equal(held.coveredClusters, 1);
|
|
311
|
-
assert.equal(held.indexedClusters, 2, "the file states two, and one is not here yet");
|
|
312
|
-
} finally {
|
|
313
|
-
forgetSubtitles(sourceKey);
|
|
314
|
-
}
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
test("one walk fills every track, and a second call reads no cluster again", async () => {
|
|
318
|
-
const { file, clusterAt } = buildFile();
|
|
319
|
-
const { torrent, reads } = torrentOver(file);
|
|
320
|
-
const sourceKey = "d".repeat(40);
|
|
321
|
-
forgetSubtitles(sourceKey);
|
|
322
|
-
try {
|
|
323
|
-
await cuesHeldFor(heldFileOver(torrent, 0, sourceKey), 2);
|
|
324
|
-
const clusterReads = reads.filter((range) => clusterAt.includes(range.start)).length;
|
|
325
|
-
|
|
326
|
-
const other = await cuesHeldFor(heldFileOver(torrent, 0, sourceKey), 3);
|
|
327
|
-
assert.equal(other.cues.length, 2, "the other track was filled by the same walk");
|
|
328
|
-
assert.equal(
|
|
329
|
-
reads.filter((range) => clusterAt.includes(range.start)).length,
|
|
330
|
-
clusterReads,
|
|
331
|
-
"asking for the second track reads no cluster a second time"
|
|
332
|
-
);
|
|
333
|
-
} finally {
|
|
334
|
-
forgetSubtitles(sourceKey);
|
|
335
|
-
}
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
test("the warm pass walks nothing for a file nobody has asked about", async () => {
|
|
339
|
-
const { file } = buildFile();
|
|
340
|
-
const { torrent, reads } = torrentOver(file);
|
|
341
|
-
const sourceKey = "9".repeat(40);
|
|
342
|
-
forgetSubtitles(sourceKey);
|
|
343
|
-
try {
|
|
344
|
-
assert.deepEqual(await warmSubtitleCues(heldFileOver(torrent, 0, sourceKey)), []);
|
|
345
|
-
assert.equal(reads.length, 0, "neither its head nor its Cues table was read for it");
|
|
346
|
-
} finally {
|
|
347
|
-
forgetSubtitles(sourceKey);
|
|
348
|
-
}
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
test("the warm pass reports what is new, by the number the browser knows", async () => {
|
|
352
|
-
const { file } = buildFile();
|
|
353
|
-
const { torrent } = torrentOver(file);
|
|
354
|
-
const sourceKey = "e".repeat(40);
|
|
355
|
-
forgetSubtitles(sourceKey);
|
|
356
|
-
try {
|
|
357
|
-
// A browser asking for a track is what makes a file one the walk follows.
|
|
358
|
-
await subtitleTracksOf(heldFileOver(torrent, 0, sourceKey));
|
|
359
|
-
const first = await warmSubtitleCues(heldFileOver(torrent, 0, sourceKey));
|
|
360
|
-
assert.deepEqual(
|
|
361
|
-
first.map((entry) => [entry.trackIndex, entry.cues.length, entry.language]).sort(),
|
|
362
|
-
[[0, 2, "eng"], [1, 2, "rus"]].sort(),
|
|
363
|
-
"both text tracks gained two cues, numbered as ffmpeg numbers them"
|
|
364
|
-
);
|
|
365
|
-
|
|
366
|
-
const again = await warmSubtitleCues(heldFileOver(torrent, 0, sourceKey));
|
|
367
|
-
assert.deepEqual(again, [], "nothing is new the second time round");
|
|
368
|
-
} finally {
|
|
369
|
-
forgetSubtitles(sourceKey);
|
|
370
|
-
}
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
test("a torrent that cannot say which pieces it holds is refused, not answered emptily", async () => {
|
|
374
|
-
const { file } = buildFile();
|
|
375
|
-
const { torrent } = torrentOver(file);
|
|
376
|
-
const sourceKey = "f".repeat(40);
|
|
377
|
-
forgetSubtitles(sourceKey);
|
|
378
|
-
try {
|
|
379
|
-
const held = await cuesHeldFor({ ...heldFileOver(torrent, 0, sourceKey), heldRanges: null }, 2);
|
|
380
|
-
assert.deepEqual(held.cues, []);
|
|
381
|
-
assert.equal(held.track, null, "the answer says nothing was read, not that there is nothing");
|
|
382
|
-
} finally {
|
|
383
|
-
forgetSubtitles(sourceKey);
|
|
384
|
-
}
|
|
385
|
-
});
|