@torrent-tv/proxy 2.83.6 → 2.83.8
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 +1880 -1868
- package/bin/cli.js +15 -5
- package/package.json +1 -1
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -0
- package/research/handover-2026-09-13.md +209 -0
- package/routes/api/client-logs/post.js +86 -0
- package/routes/api/subtitles/get.js +26 -1
- package/server.js +15 -2
- package/services/data-channel-handler.js +120 -83
- package/services/viewer/Viewer.js +359 -345
- package/services/viewer/Viewers.js +44 -0
- package/test/client-log-file.test.js +110 -0
- package/test/send-chunk-bytes.test.js +86 -0
- package/test/viewer-subtitle-subscription.test.js +96 -0
- package/utils/client-log-file.js +151 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Take the browser's own log and keep it beside the proxy's.
|
|
3
|
+
*
|
|
4
|
+
* The page has always forwarded its console somewhere; until now that was the
|
|
5
|
+
* SERVER's standard output on the droplet, which every release destroys. Both
|
|
6
|
+
* halves of a failure are needed to explain one — what the proxy did and what
|
|
7
|
+
* the page saw — and on 2026-09-13 an investigation ran on half of them for
|
|
8
|
+
* exactly that reason.
|
|
9
|
+
*
|
|
10
|
+
* Here the two land in one directory, on the host's durable disk, in files
|
|
11
|
+
* named so they join without guessing.
|
|
12
|
+
*
|
|
13
|
+
* The server's route stays and is not replaced: a page that has no proxy yet,
|
|
14
|
+
* or whose connection to one just failed, still has something to say, and those
|
|
15
|
+
* are the moments that matter most.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const MAX_LINES = 50;
|
|
19
|
+
const MAX_MSG_LEN = 2000;
|
|
20
|
+
const MAX_TAG_LEN = 40;
|
|
21
|
+
const MAX_SID_LEN = 16;
|
|
22
|
+
const MAX_NAME_LEN = 200;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {unknown} value
|
|
26
|
+
* @param {number} maxLen
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
function safeString(value, maxLen) {
|
|
30
|
+
return (typeof value === "string" ? value : "").slice(0, maxLen);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Replace every control character with a space.
|
|
35
|
+
*
|
|
36
|
+
* These lines are written by a stranger's browser into a file a person will
|
|
37
|
+
* later read in a terminal: a newline would forge a log entry and an escape
|
|
38
|
+
* sequence would drive the terminal.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} value
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function sanitizeLine(value) {
|
|
44
|
+
let out = "";
|
|
45
|
+
for (const character of value) {
|
|
46
|
+
const code = character.codePointAt(0) ?? 0;
|
|
47
|
+
out += code < 0x20 || code === 0x7f ? " " : character;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `POST /api/client-logs`
|
|
54
|
+
*
|
|
55
|
+
* Body: `{ sessionId, startedAt, torrentName, infoHash, tag, signalSessionId,
|
|
56
|
+
* lines: [{ level, ts, msg }] }`.
|
|
57
|
+
*
|
|
58
|
+
* @param {import("fastify").FastifyRequest} req
|
|
59
|
+
* @param {import("fastify").FastifyReply} reply
|
|
60
|
+
* @param {{ clientLogs: { write: (session: object, lines: string[]) => void } | null }} deps
|
|
61
|
+
* @returns {Promise<unknown>}
|
|
62
|
+
*/
|
|
63
|
+
export async function handleApiClientLogsPost(req, reply, { clientLogs }) {
|
|
64
|
+
const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
|
|
65
|
+
const session = {
|
|
66
|
+
sessionId: safeString(body.sessionId, MAX_SID_LEN) || "unknown",
|
|
67
|
+
startedAt: safeString(body.startedAt, 30),
|
|
68
|
+
torrentName: sanitizeLine(safeString(body.torrentName, MAX_NAME_LEN)),
|
|
69
|
+
infoHash: safeString(body.infoHash, 40)
|
|
70
|
+
};
|
|
71
|
+
const tag = sanitizeLine(safeString(body.tag, MAX_TAG_LEN)) || "Unknown/Unknown";
|
|
72
|
+
const rows = Array.isArray(body.lines) ? body.lines.slice(0, MAX_LINES) : [];
|
|
73
|
+
|
|
74
|
+
const lines = rows.map((row) => {
|
|
75
|
+
const entry = row && typeof row === "object" ? row : {};
|
|
76
|
+
const level = safeString(entry.level, 8) || "log";
|
|
77
|
+
const at = sanitizeLine(safeString(entry.ts, 16));
|
|
78
|
+
const message = sanitizeLine(safeString(entry.msg, MAX_MSG_LEN));
|
|
79
|
+
return `[${tag}] ${at} ${level}: ${message}`;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (lines.length > 0) {
|
|
83
|
+
clientLogs?.write(session, lines);
|
|
84
|
+
}
|
|
85
|
+
return reply.code(204).send();
|
|
86
|
+
}
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* @returns {Promise<void>}
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import { deriveSourceKey } from "../../../services/torrent-source-key.js";
|
|
27
28
|
import { spawn } from "node:child_process";
|
|
28
29
|
import { TextSubtitleTrack } from "../../../services/tracks/TextSubtitleTrack.js";
|
|
29
30
|
import { SubtitleController } from "../../../services/controllers/SubtitleController.js";
|
|
@@ -44,7 +45,7 @@ function setLanguageHeaders(reply, lang) {
|
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torrentPool, ffmpegBin, localBaseUrl }) {
|
|
48
|
+
export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torrentPool, ffmpegBin, localBaseUrl, viewers }) {
|
|
48
49
|
const query = req.query ?? {};
|
|
49
50
|
const sourceKey = typeof query.sourceKey === "string" ? query.sourceKey.trim() : "";
|
|
50
51
|
const fileIndex = Number(query.fileIndex);
|
|
@@ -55,6 +56,30 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
|
|
|
55
56
|
return reply.code(400).send({ error: "sourceKey and fileIndex are required." });
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
// Turning subtitles on is a fact about the VIEWER, and this is the only place
|
|
60
|
+
// that knows both the person and the file, so this is where it is recorded.
|
|
61
|
+
//
|
|
62
|
+
// It used to be recorded in the transport, against the CHANNEL, which the
|
|
63
|
+
// transport could only do by sniffing this path out of the request and
|
|
64
|
+
// deriving the torrent key itself. Two costs followed: a reconnect lost the
|
|
65
|
+
// subscription for the rest of the session, and the layer that carries bytes
|
|
66
|
+
// held application routing and torrent identity.
|
|
67
|
+
//
|
|
68
|
+
// The browser sends its registry key; the push side speaks the pool key, so
|
|
69
|
+
// the two are reconciled here — the same reconciliation the transport did.
|
|
70
|
+
const consumerId = typeof query.consumerId === "string" ? query.consumerId.trim() : "";
|
|
71
|
+
if (consumerId && hasTrackIndex && viewers) {
|
|
72
|
+
const record = sourceRegistry?.get(sourceKey);
|
|
73
|
+
if (record) {
|
|
74
|
+
try {
|
|
75
|
+
viewers.wantsCues(consumerId, await deriveSourceKey(record.sourceType, record.source), fileIndex);
|
|
76
|
+
} catch {
|
|
77
|
+
// A subscription that cannot be resolved costs this viewer pushed cues;
|
|
78
|
+
// it must not cost them the subtitles they asked for in this request.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
58
83
|
// Interface layer delegates to SubtitleController (orchestrator + domain),
|
|
59
84
|
// which owns external-file vs embedded-track branching and the cluster walk.
|
|
60
85
|
const controller = new SubtitleController({ sourceRegistry, torrentPool });
|
package/server.js
CHANGED
|
@@ -22,6 +22,8 @@ import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
|
|
|
22
22
|
import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
|
|
23
23
|
import { handleApiSourceWarmPost } from "./routes/api/sources/warm/post.js";
|
|
24
24
|
import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
|
|
25
|
+
import { handleApiClientLogsPost } from "./routes/api/client-logs/post.js";
|
|
26
|
+
import { createClientLogFiles } from "./utils/client-log-file.js";
|
|
25
27
|
import { handleApiSubtitlesGet } from "./routes/api/subtitles/get.js";
|
|
26
28
|
import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
|
|
27
29
|
import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
|
|
@@ -84,7 +86,11 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
|
|
|
84
86
|
*/
|
|
85
87
|
export async function startProxyServer({
|
|
86
88
|
host, port, transcodeAudio, ffmpegBin, maxDiskBytes, memoryBytes, segmentFormat, stateDir, onSubtitleCues,
|
|
87
|
-
deliverySink = false
|
|
89
|
+
deliverySink = false,
|
|
90
|
+
// Where the proxy writes its own log. Its DIRECTORY is what matters here:
|
|
91
|
+
// the browser's half of every session is written beside it, so the two are
|
|
92
|
+
// on one durable disk and join by name.
|
|
93
|
+
logFile = ""
|
|
88
94
|
}) {
|
|
89
95
|
const app = Fastify({
|
|
90
96
|
// No practical body-size limit — the proxy server is localhost-only and
|
|
@@ -346,6 +352,12 @@ export async function startProxyServer({
|
|
|
346
352
|
app.post("/api/sources/:sourceKey/warm", async (req, reply) =>
|
|
347
353
|
handleApiSourceWarmPost(req, reply, { sourceRegistry, torrentPool })
|
|
348
354
|
);
|
|
355
|
+
// The browser's own log, kept beside the proxy's. See the route's own file
|
|
356
|
+
// for why it is here and not only on the registry server.
|
|
357
|
+
const clientLogs = logFile ? createClientLogFiles(logFile) : null;
|
|
358
|
+
app.post("/api/client-logs", async (req, reply) =>
|
|
359
|
+
handleApiClientLogsPost(req, reply, { clientLogs })
|
|
360
|
+
);
|
|
349
361
|
app.post("/api/playback-plan", async (req, reply) =>
|
|
350
362
|
handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
|
|
351
363
|
);
|
|
@@ -354,7 +366,8 @@ export async function startProxyServer({
|
|
|
354
366
|
sourceRegistry,
|
|
355
367
|
torrentPool,
|
|
356
368
|
ffmpegBin,
|
|
357
|
-
localBaseUrl: hlsSessionManager.localBaseUrl
|
|
369
|
+
localBaseUrl: hlsSessionManager.localBaseUrl,
|
|
370
|
+
viewers: hlsSessionManager.viewers
|
|
358
371
|
})
|
|
359
372
|
);
|
|
360
373
|
app.get("/stream", async (req, reply) =>
|
|
@@ -45,7 +45,6 @@
|
|
|
45
45
|
|
|
46
46
|
/** @import { DataChannel } from 'node-datachannel' */
|
|
47
47
|
|
|
48
|
-
import { deriveSourceKey } from "./torrent-source-key.js";
|
|
49
48
|
import { createDeliveryProbe, PROBE_INTERVAL_MS } from "./delivery-probe.js";
|
|
50
49
|
|
|
51
50
|
/**
|
|
@@ -582,11 +581,79 @@ export function encodeFrame(idBytes, bytes, done) {
|
|
|
582
581
|
return frame;
|
|
583
582
|
}
|
|
584
583
|
|
|
584
|
+
/**
|
|
585
|
+
* Reshapes a body into messages of `sizeBytes` each, handing every finished one
|
|
586
|
+
* to `sendOne`. A size of zero passes each body read through untouched, which
|
|
587
|
+
* is what the field does today — 10 657 210 bytes over 163 messages, so 65 382
|
|
588
|
+
* each, measured 2026-09-12.
|
|
589
|
+
*
|
|
590
|
+
* Only the SIZE changes: the same bytes reach the far end in the same order,
|
|
591
|
+
* over the same channel. That is what makes a comparison between two sizes a
|
|
592
|
+
* comparison of one thing, and it is why the wedge's dependence on the size can
|
|
593
|
+
* be measured at all — the wedge of 2026-09-12 arrived in the middle of one
|
|
594
|
+
* message, with 63 % of a segment on the wire and then nothing.
|
|
595
|
+
*
|
|
596
|
+
* @param {(bytes: Uint8Array) => void} sendOne - Given each finished message.
|
|
597
|
+
* @param {number} sizeBytes - 0 to leave reads as they are.
|
|
598
|
+
* @returns {{ push: (bytes: Uint8Array) => number, flush: () => number }}
|
|
599
|
+
* Both answer how many messages they sent, so a caller counting messages
|
|
600
|
+
* counts messages rather than body reads.
|
|
601
|
+
*/
|
|
602
|
+
export function bodySender(sendOne, sizeBytes) {
|
|
603
|
+
if (!(sizeBytes > 0)) {
|
|
604
|
+
return {
|
|
605
|
+
push(bytes) {
|
|
606
|
+
sendOne(bytes);
|
|
607
|
+
return 1;
|
|
608
|
+
},
|
|
609
|
+
flush() {
|
|
610
|
+
return 0;
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
/** @type {Uint8Array[]} */
|
|
615
|
+
let held = [];
|
|
616
|
+
let heldBytes = 0;
|
|
617
|
+
const release = () => {
|
|
618
|
+
const message = held.length === 1 ? held[0] : Buffer.concat(held);
|
|
619
|
+
held = [];
|
|
620
|
+
heldBytes = 0;
|
|
621
|
+
sendOne(message);
|
|
622
|
+
};
|
|
623
|
+
return {
|
|
624
|
+
push(bytes) {
|
|
625
|
+
let sent = 0;
|
|
626
|
+
let offset = 0;
|
|
627
|
+
while (offset < bytes.length) {
|
|
628
|
+
const take = Math.min(sizeBytes - heldBytes, bytes.length - offset);
|
|
629
|
+
held.push(bytes.subarray(offset, offset + take));
|
|
630
|
+
heldBytes += take;
|
|
631
|
+
offset += take;
|
|
632
|
+
if (heldBytes === sizeBytes) {
|
|
633
|
+
release();
|
|
634
|
+
sent += 1;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return sent;
|
|
638
|
+
},
|
|
639
|
+
flush() {
|
|
640
|
+
if (heldBytes === 0) return 0;
|
|
641
|
+
release();
|
|
642
|
+
return 1;
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
585
647
|
export function createDataChannelHandler({
|
|
586
648
|
proxyPort,
|
|
587
649
|
onLog,
|
|
588
650
|
getTransportSnapshot,
|
|
589
|
-
|
|
651
|
+
// Who wants pushed subtitle cues for a file, BY NAME. A plain function
|
|
652
|
+
// returning opaque ids: this layer never learns what a viewer is, and the
|
|
653
|
+
// subscription it used to hold against a channel — and had to sniff
|
|
654
|
+
// `/api/subtitles` to build — now belongs to the person and outlives the
|
|
655
|
+
// channel, a reconnect and a deliberate rotation alike.
|
|
656
|
+
viewersWantingCues = () => [],
|
|
590
657
|
witness,
|
|
591
658
|
// Reads usrsctp's own association state (services/usrsctp-state.js) the
|
|
592
659
|
// moment a wedge is declared, from either detector below. Optional: a host
|
|
@@ -599,7 +666,14 @@ export function createDataChannelHandler({
|
|
|
599
666
|
// released a viewer when their connection closed: the only exits were the
|
|
600
667
|
// browser's own `release` and a silence long enough to be called an absence.
|
|
601
668
|
onViewerPresent = () => {},
|
|
602
|
-
onViewerGone = () => {}
|
|
669
|
+
onViewerGone = () => {},
|
|
670
|
+
// The size of one data-channel message carrying body bytes. Zero keeps
|
|
671
|
+
// whatever the body read hands over, which in the field is 65 382 bytes —
|
|
672
|
+
// 10 657 210 over 163 chunks, measured 2026-09-12. It is settable because
|
|
673
|
+
// that day's wedge arrived in the MIDDLE of one message (63 % of a segment
|
|
674
|
+
// on the wire, then nothing), and whether the fault depends on the size is
|
|
675
|
+
// the one branch no capture has been able to close.
|
|
676
|
+
sendChunkBytes = 0
|
|
603
677
|
}) {
|
|
604
678
|
/**
|
|
605
679
|
* Who is on each connection, by WebRTC session id.
|
|
@@ -639,40 +713,30 @@ export function createDataChannelHandler({
|
|
|
639
713
|
}
|
|
640
714
|
}
|
|
641
715
|
/**
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
* embedded track — there is no separate subscribe message on the wire, the
|
|
645
|
-
* existing `/api/subtitles` request already says which file a viewer opened
|
|
646
|
-
* subtitles for. Pruned on channel close and, defensively, on a failed send.
|
|
716
|
+
* The channel each viewer is reachable on right now, by the name the far end
|
|
717
|
+
* presented when it opened.
|
|
647
718
|
*
|
|
648
|
-
*
|
|
719
|
+
* The transport knows a NAME and nothing else about a viewer: what it means,
|
|
720
|
+
* who holds it and what they are watching are somebody else's facts. Keeping
|
|
721
|
+
* only "this name is reachable here" is what lets a subscription made once
|
|
722
|
+
* outlive the channel it was made on — a reconnect, and a rotation done on
|
|
723
|
+
* purpose, both just rewrite this entry.
|
|
724
|
+
*
|
|
725
|
+
* @type {Map<string, DataChannel>}
|
|
649
726
|
*/
|
|
650
|
-
const
|
|
727
|
+
const channelOfViewer = new Map();
|
|
651
728
|
|
|
652
729
|
/**
|
|
653
|
-
*
|
|
654
|
-
*
|
|
730
|
+
* Forget every name that was reachable on this channel.
|
|
731
|
+
*
|
|
655
732
|
* @param {DataChannel} channel
|
|
656
733
|
* @returns {void}
|
|
657
734
|
*/
|
|
658
|
-
function
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
subtitleSubscribers.set(key, set);
|
|
664
|
-
}
|
|
665
|
-
const isNew = !set.has(channel);
|
|
666
|
-
set.add(channel);
|
|
667
|
-
if (isNew) {
|
|
668
|
-
log(`[dc] subtitle push: channel subscribed to ${key} (${set.size} channel(s) now)`);
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
/** @param {DataChannel} channel */
|
|
673
|
-
function unsubscribeSubtitlesAll(channel) {
|
|
674
|
-
for (const set of subtitleSubscribers.values()) {
|
|
675
|
-
set.delete(channel);
|
|
735
|
+
function forgetChannel(channel) {
|
|
736
|
+
for (const [consumerId, on] of channelOfViewer) {
|
|
737
|
+
if (on === channel) {
|
|
738
|
+
channelOfViewer.delete(consumerId);
|
|
739
|
+
}
|
|
676
740
|
}
|
|
677
741
|
}
|
|
678
742
|
|
|
@@ -688,30 +752,37 @@ export function createDataChannelHandler({
|
|
|
688
752
|
* @returns {void}
|
|
689
753
|
*/
|
|
690
754
|
function publishSubtitleCues({ sourceKey, fileIndex, trackIndex, cues, language, detectedLanguage, cursor }) {
|
|
691
|
-
const
|
|
692
|
-
if (
|
|
755
|
+
const names = viewersWantingCues(sourceKey, fileIndex);
|
|
756
|
+
if (names.length === 0) {
|
|
693
757
|
log(
|
|
694
758
|
`[dc] subtitle push: ${cues.length} cue(s) for ${sourceKey.slice(0, 8)}:${fileIndex} track ${trackIndex} ` +
|
|
695
|
-
"found
|
|
759
|
+
"found nobody who wants them"
|
|
696
760
|
);
|
|
697
761
|
return;
|
|
698
762
|
}
|
|
699
|
-
const message = {
|
|
700
|
-
|
|
763
|
+
const message = JSON.stringify({
|
|
764
|
+
type: "subtitle-cues", fileIndex, trackIndex, cues, language, detectedLanguage, cursor
|
|
765
|
+
});
|
|
701
766
|
let sent = 0;
|
|
702
|
-
for (const
|
|
767
|
+
for (const consumerId of names) {
|
|
768
|
+
const channel = channelOfViewer.get(consumerId);
|
|
769
|
+
if (!channel) {
|
|
770
|
+
// Subscribed but not reachable this instant — between connections, or
|
|
771
|
+
// rotating. Nothing is dropped: they are still subscribed, and the walk
|
|
772
|
+
// keeps a per-viewer cursor, so what they missed comes on the next ask.
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
703
775
|
try {
|
|
704
|
-
channel.sendMessage(
|
|
776
|
+
channel.sendMessage(message);
|
|
705
777
|
sent += 1;
|
|
706
778
|
} catch {
|
|
707
|
-
// Closed between the
|
|
708
|
-
//
|
|
709
|
-
set.delete(channel);
|
|
779
|
+
// Closed between the lookup and the send. The subscription belongs to
|
|
780
|
+
// the person and stays; only this delivery is lost.
|
|
710
781
|
}
|
|
711
782
|
}
|
|
712
783
|
log(
|
|
713
784
|
`[dc] subtitle push: sent ${cues.length} cue(s) for ${sourceKey.slice(0, 8)}:${fileIndex} track ${trackIndex} ` +
|
|
714
|
-
`to ${sent}/${
|
|
785
|
+
`to ${sent}/${names.length} viewer(s)`
|
|
715
786
|
);
|
|
716
787
|
}
|
|
717
788
|
|
|
@@ -914,6 +985,7 @@ export function createDataChannelHandler({
|
|
|
914
985
|
const isNew = !known.has(message.consumerId);
|
|
915
986
|
known.add(message.consumerId);
|
|
916
987
|
viewersOnConnection.set(sessionId, known);
|
|
988
|
+
channelOfViewer.set(message.consumerId, channel);
|
|
917
989
|
if (isNew) {
|
|
918
990
|
log(`[dc] Session ${tag}: viewer ${message.consumerId} is on this connection`);
|
|
919
991
|
}
|
|
@@ -952,7 +1024,7 @@ export function createDataChannelHandler({
|
|
|
952
1024
|
clearTimeout(entry.timer);
|
|
953
1025
|
}
|
|
954
1026
|
partials.clear();
|
|
955
|
-
|
|
1027
|
+
forgetChannel(channel);
|
|
956
1028
|
log(`[dc] Session ${tag}: channel closed`);
|
|
957
1029
|
// The ordinary way a viewer leaves, and the only one that is immediate.
|
|
958
1030
|
// Everything else — a silence long enough to be called an absence, the
|
|
@@ -988,45 +1060,6 @@ export function createDataChannelHandler({
|
|
|
988
1060
|
return;
|
|
989
1061
|
}
|
|
990
1062
|
|
|
991
|
-
// Piggy-backs on the browser's own request for an EMBEDDED track — no
|
|
992
|
-
// separate subscribe message. `trackIndex` is what tells the two request
|
|
993
|
-
// shapes apart: an external subtitle FILE (no trackIndex) names a
|
|
994
|
-
// different file's own index in `fileIndex` — the subtitle file's, not the
|
|
995
|
-
// video's — and subscribing under that would just be a key nothing ever
|
|
996
|
-
// publishes to (an external file is one whole-file read, not something
|
|
997
|
-
// this walks incrementally). `fileIndex` alone would also scope this to
|
|
998
|
-
// the wrong grain for the real case — a torrent can carry several playable
|
|
999
|
-
// files — so the pair is what a push is ever addressed to.
|
|
1000
|
-
//
|
|
1001
|
-
// The browser's `sourceKey` is a REGISTRY key — a hash of the raw request
|
|
1002
|
-
// bytes, one per (magnet-or-.torrent, this API session). The torrent pool
|
|
1003
|
-
// publishes under its OWN key — the content's infohash, deliberately the
|
|
1004
|
-
// SAME for a magnet and a `.torrent` naming the same film, so the two
|
|
1005
|
-
// share one swarm (item 10). The two are different strings for the same
|
|
1006
|
-
// torrent whenever a source was added by its `.torrent` file (a `.torrent`
|
|
1007
|
-
// and a magnet are different request bytes, same infohash) — subscribing
|
|
1008
|
-
// under the registry key found no publisher for that reason, not because
|
|
1009
|
-
// nothing was ever read: field case 2026-08-22, cues were found and
|
|
1010
|
-
// logged, every push answered "found no subscribed channel". Resolved to
|
|
1011
|
-
// the pool's key here, the one place both are in hand.
|
|
1012
|
-
if (path === "/api/subtitles" && typeof query === "string") {
|
|
1013
|
-
const params = new URLSearchParams(query);
|
|
1014
|
-
const registrySourceKey = params.get("sourceKey");
|
|
1015
|
-
const fileIndex = Number(params.get("fileIndex"));
|
|
1016
|
-
const hasTrackIndex = params.get("trackIndex") !== null && params.get("trackIndex") !== "";
|
|
1017
|
-
if (registrySourceKey && Number.isInteger(fileIndex) && hasTrackIndex) {
|
|
1018
|
-
const record = sourceRegistry?.get(registrySourceKey);
|
|
1019
|
-
if (record) {
|
|
1020
|
-
try {
|
|
1021
|
-
const poolSourceKey = await deriveSourceKey(record.sourceType, record.source);
|
|
1022
|
-
subscribeSubtitles(poolSourceKey, fileIndex, channel);
|
|
1023
|
-
} catch (error) {
|
|
1024
|
-
log(`[dc] subtitle push: could not resolve ${registrySourceKey.slice(0, 8)} to a pool key: ` +
|
|
1025
|
-
`${error instanceof Error ? error.message : error}`);
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
1063
|
|
|
1031
1064
|
const queryInfo = query ? `?${query}` : "";
|
|
1032
1065
|
const bodyInfo =
|
|
@@ -1092,12 +1125,14 @@ export function createDataChannelHandler({
|
|
|
1092
1125
|
let readMs = 0;
|
|
1093
1126
|
let sendMs2 = 0;
|
|
1094
1127
|
let drainMs = 0;
|
|
1128
|
+
const body = bodySender((bytes) => sendChunk(channel, requestId, bytes, false), sendChunkBytes);
|
|
1095
1129
|
resetEventLoopDelay();
|
|
1096
1130
|
while (true) {
|
|
1097
1131
|
const readStartedAt = performance.now();
|
|
1098
1132
|
const { done, value } = await reader.read();
|
|
1099
1133
|
readMs += performance.now() - readStartedAt;
|
|
1100
1134
|
if (done) {
|
|
1135
|
+
chunks += body.flush();
|
|
1101
1136
|
sendChunk(channel, requestId, null, true);
|
|
1102
1137
|
const elapsedMs = Date.now() - sendStartedAt;
|
|
1103
1138
|
let bufferedNow = 0;
|
|
@@ -1107,6 +1142,9 @@ export function createDataChannelHandler({
|
|
|
1107
1142
|
log(
|
|
1108
1143
|
`[net-debug] sent ${path}${queryInfo} bytes=${totalBytes} fetchMs=${fetchMs} ` +
|
|
1109
1144
|
`ttfbMs=${firstByteMs} sendMs=${elapsedMs} chunks=${chunks} ` +
|
|
1145
|
+
// The size of one message, so a reading can be attributed to the
|
|
1146
|
+
// size it was taken at rather than to the run it came from.
|
|
1147
|
+
`msgBytes=${sendChunkBytes > 0 ? sendChunkBytes : "asread"} ` +
|
|
1110
1148
|
`maxBuffered=${maxBuffered} bufferedAtEnd=${bufferedNow} ` +
|
|
1111
1149
|
// Where the time went: reading the body from the local route,
|
|
1112
1150
|
// handing chunks to the channel, or waiting for its queue. Plus
|
|
@@ -1120,14 +1158,13 @@ export function createDataChannelHandler({
|
|
|
1120
1158
|
break;
|
|
1121
1159
|
}
|
|
1122
1160
|
if (firstByteMs < 0) firstByteMs = Date.now() - sendStartedAt;
|
|
1123
|
-
chunks += 1;
|
|
1124
1161
|
totalBytes += value.length;
|
|
1125
1162
|
try {
|
|
1126
1163
|
const b = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
|
|
1127
1164
|
if (b > maxBuffered) maxBuffered = b;
|
|
1128
1165
|
} catch { /* ignore */ }
|
|
1129
1166
|
const sendStepAt = performance.now();
|
|
1130
|
-
|
|
1167
|
+
chunks += body.push(value);
|
|
1131
1168
|
sendMs2 += performance.now() - sendStepAt;
|
|
1132
1169
|
// Backpressure: do not keep queuing chunks once the channel's outgoing
|
|
1133
1170
|
// buffer is large — wait for it to drain. Prevents the SCTP send buffer
|