@torrent-tv/proxy 2.76.1 → 2.76.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## 2.76.2
|
|
2
|
+
|
|
3
|
+
- **Fix**: How long a session waits for a film's keyframe table is bounded, and the bound is one the read already had rather than a new number. That table decides which branch a picture takes — with it the picture is passed through untouched, without it the whole picture is re-encoded — and nothing limited the wait, while the file comes off a torrent and the bytes the table lives in may still be arriving. Measured on the addon host over seventeen files from four containers, pieces from 0.25 to 16 MB (`research/keyframe-table-read-2026-09-04.md`): every table that arrived did so within 24.8 s and most within half a second, while two files answered nothing for 120.9 s and 120.5 s — which is exactly TWO of the sixty-second bound the read already has, one for the wait on the file's edges and one for the read, in series. A session now waits for one of them. The read is not cancelled: it goes on, is remembered on the file, and the next session of that file gets the copy.
|
|
4
|
+
- **Fix**: A table that has not arrived is not written onto the file as an absence. It would make a passing shortage of bytes look like a property of the bytes, and every later session of the file would then re-encode a picture that can be copied.
|
|
5
|
+
- **Chore**: The two lines about that read say which quantity each is. They differ by up to sixty seconds — one is the swarm delivering the file's edges plus the parse, the other only the parse — and reading them as one figure produced a wrong conclusion the same day.
|
|
6
|
+
- **Chore**: What a playlist says is written where a playlist belongs (`services/output/playlists.js`), the second part taken out of the session manager: the media playlist, the master with its quality steps and soundtrack group, and the lookup from a time to a segment. All three are statements about a timeline and about nothing else — not about a session, a viewer, an encoder or a disk — and they were private details of an eleven-thousand-line class, along with the HLS attribute quoting, the language tags and the bitrate a variant declares.
|
|
7
|
+
|
|
1
8
|
## 2.76.1
|
|
2
9
|
|
|
3
10
|
- **New**: How long it takes to read a file's keyframe table is recorded, once per file, with the container that answered and how many times it found. That read decides which branch a picture takes — with the table it is copied, without it the whole picture is re-encoded — and nothing measured it. The one figure printed until now, `keyframes=` on the session-create line, is what the SESSION waited for, which is the remainder of a read the playback plan had already started, and reads zero whenever the plan finished first.
|
package/package.json
CHANGED
|
@@ -64,6 +64,7 @@ import { AudioOutput, CutGrid, OutputSpec, VideoOutput } from "./output/index.js
|
|
|
64
64
|
import { newIndexCheck, Timeline, Timelines } from "./output/Timeline.js";
|
|
65
65
|
export { newIndexCheck };
|
|
66
66
|
import { Output, Outputs } from "./output/Output.js";
|
|
67
|
+
import { masterPlaylistText, mediaPlaylistText, segmentIndexForTime } from "./output/playlists.js";
|
|
67
68
|
import { SourceFiles, sourceDecodeCharacteristics } from "./source/SourceFile.js";
|
|
68
69
|
import { ProducedIndex } from "./produced-index.js";
|
|
69
70
|
import { SegmentStore } from "./encode/SegmentStore.js";
|
|
@@ -118,51 +119,10 @@ export function isInputUnavailable(message) {
|
|
|
118
119
|
// The index of variants. Served from the same route as the media playlist, so
|
|
119
120
|
// it needs no path of its own.
|
|
120
121
|
const MASTER_PLAYLIST_FILE_NAME = "master.m3u8";
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
// Where an audio rendition lives, and the name the variants refer to it by. One
|
|
126
|
-
// directory level under the base session, exactly as a quality variant is, so
|
|
127
|
-
// every relative name inside its playlist resolves to it unchanged.
|
|
128
|
-
const AUDIO_PATH_PREFIX = "a";
|
|
129
|
-
const AUDIO_GROUP_ID = "aud";
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Quote a value for an HLS attribute list. Only the quote itself can end the
|
|
133
|
-
* attribute early, and a track title comes from the file, so it is not ours to
|
|
134
|
-
* trust.
|
|
135
|
-
*
|
|
136
|
-
* @param {string} value
|
|
137
|
-
* @returns {string}
|
|
138
|
-
*/
|
|
139
|
-
function escapeAttribute(value) {
|
|
140
|
-
// The quote would end the attribute early; a line break would end the LINE,
|
|
141
|
-
// splitting one `#EXT-X-MEDIA` into two and corrupting the master. Both come
|
|
142
|
-
// from the file's own metadata, which is not ours to trust.
|
|
143
|
-
return String(value ?? "").replace(/"/g, "'").replace(/[\u0000-\u001f\u007f]/g, " ").trim();
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// ISO 639-2 codes as ffmpeg reports them, against the RFC 5646 tags HLS asks
|
|
147
|
-
// for. Only the languages this serves in practice; anything else is passed
|
|
148
|
-
// through, which is what players other than iOS accept anyway.
|
|
149
|
-
const LANGUAGE_TAGS = new Map([
|
|
150
|
-
["rus", "ru"], ["eng", "en"], ["ukr", "uk"], ["deu", "de"], ["ger", "de"],
|
|
151
|
-
["fra", "fr"], ["fre", "fr"], ["spa", "es"], ["ita", "it"], ["jpn", "ja"],
|
|
152
|
-
["kor", "ko"], ["zho", "zh"], ["chi", "zh"], ["pol", "pl"], ["por", "pt"],
|
|
153
|
-
["tur", "tr"], ["ces", "cs"], ["cze", "cs"], ["nld", "nl"], ["dut", "nl"]
|
|
154
|
-
]);
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* The RFC 5646 tag for a language ffmpeg named, or the name unchanged.
|
|
158
|
-
*
|
|
159
|
-
* @param {string} language
|
|
160
|
-
* @returns {string}
|
|
161
|
-
*/
|
|
162
|
-
function languageTag(language) {
|
|
163
|
-
const code = String(language ?? "").toLowerCase();
|
|
164
|
-
return LANGUAGE_TAGS.get(code) ?? code;
|
|
165
|
-
}
|
|
122
|
+
// Where a variant and an audio rendition live under a session — `v/<height>/…`
|
|
123
|
+
// and `a/<track>/…` — is stated in `output/playlists.js`, beside the lines that
|
|
124
|
+
// write those addresses into a master playlist. The routes that parse them back
|
|
125
|
+
// are in `server.js`.
|
|
166
126
|
|
|
167
127
|
/**
|
|
168
128
|
* The last index of the unbroken run of segments starting at `from`.
|
|
@@ -448,21 +408,27 @@ export function variantConsumerId(baseSessionId) {
|
|
|
448
408
|
return `variant-of:${baseSessionId}`;
|
|
449
409
|
}
|
|
450
410
|
|
|
451
|
-
/**
|
|
452
|
-
* A rough bitrate for a height, in bits per second.
|
|
453
|
-
*
|
|
454
|
-
* `BANDWIDTH` is required on every variant by the HLS specification, and the
|
|
455
|
-
* player uses it to order them. It does not have to be exact — nothing here
|
|
456
|
-
* adapts on it, because the viewer chooses — so it is the usual H.264 rule of
|
|
457
|
-
* thumb rather than a measurement we do not have before encoding starts.
|
|
458
|
-
*
|
|
459
|
-
* @param {number} height
|
|
460
|
-
* @returns {number}
|
|
461
|
-
*/
|
|
462
|
-
export function estimatedBitrateFor(height) {
|
|
463
|
-
return Math.max(400_000, Math.round(height * height * 3.2));
|
|
464
|
-
}
|
|
465
411
|
const CLEANUP_INTERVAL_MS = 30_000;
|
|
412
|
+
|
|
413
|
+
// How long a session waits for the file's keyframe table before giving up on
|
|
414
|
+
// copying the picture and re-encoding it instead.
|
|
415
|
+
//
|
|
416
|
+
// Measured on the addon host, 2026-09-04, over seventeen files from
|
|
417
|
+
// `Dropbox/trn` — four containers, pieces from 0.25 to 16 MB, files from 0.36 to
|
|
418
|
+
// 20 GB, each torrent registered fresh so nothing of it was downloaded
|
|
419
|
+
// (`research/keyframe-table-read-2026-09-04.md`). Every table that arrived did
|
|
420
|
+
// so within 24.8 s, most within half a second; the two files that answered
|
|
421
|
+
// nothing took 120.9 s and 120.5 s.
|
|
422
|
+
//
|
|
423
|
+
// Those two figures are not a coincidence and they are what fixes this one:
|
|
424
|
+
// they are TWO of the bound the read already has — `READ_ABANDON_MS` in
|
|
425
|
+
// `torrent-worker/container-tracks.js`, one for the wait on the file's edges and
|
|
426
|
+
// one for the read itself, in series. A session waiting for two of them is the
|
|
427
|
+
// defect; waiting for one is the bound, and it leaves 2.4x over the slowest
|
|
428
|
+
// table that did arrive. The line printed when it fires names which case
|
|
429
|
+
// happened, so the field can move it rather than an argument.
|
|
430
|
+
const KEYFRAME_TABLE_BUDGET_MS = 60_000;
|
|
431
|
+
|
|
466
432
|
const DEFAULT_SEGMENT_DURATION_SEC = 4;
|
|
467
433
|
// How many segments ahead of the current encode head a missing-segment request
|
|
468
434
|
// is allowed to be before we restart ffmpeg at that position (server-side seek).
|
|
@@ -1702,6 +1668,11 @@ export class HlsSessionManager {
|
|
|
1702
1668
|
segmentDurationSec = DEFAULT_SEGMENT_DURATION_SEC,
|
|
1703
1669
|
sessionTtlMs = DEFAULT_SESSION_TTL_MS,
|
|
1704
1670
|
startupWaitMs = DEFAULT_STARTUP_WAIT_MS,
|
|
1671
|
+
// How long a session waits for the file's keyframe table before it
|
|
1672
|
+
// re-encodes the picture instead of copying it. Measured, not chosen —
|
|
1673
|
+
// see KEYFRAME_TABLE_BUDGET_MS. A parameter so a check can name a
|
|
1674
|
+
// shorter one rather than sitting out the real wait.
|
|
1675
|
+
keyframeTableBudgetMs = KEYFRAME_TABLE_BUDGET_MS,
|
|
1705
1676
|
videoEncoder = null,
|
|
1706
1677
|
softwarePresetBenchmark = null,
|
|
1707
1678
|
decodeCostModel = null,
|
|
@@ -1722,6 +1693,9 @@ export class HlsSessionManager {
|
|
|
1722
1693
|
getTorrentTotals}) {
|
|
1723
1694
|
this.enabled = Boolean(enabled);
|
|
1724
1695
|
this.ffmpegBin = ffmpegBin;
|
|
1696
|
+
this.keyframeTableBudgetMs = Number.isFinite(keyframeTableBudgetMs) && keyframeTableBudgetMs > 0
|
|
1697
|
+
? keyframeTableBudgetMs
|
|
1698
|
+
: KEYFRAME_TABLE_BUDGET_MS;
|
|
1725
1699
|
// Where measurements about this host are kept between runs. Empty means
|
|
1726
1700
|
// beside the installed proxy; a deployment with somewhere persistent to
|
|
1727
1701
|
// write names it (--state-dir).
|
|
@@ -2283,15 +2257,12 @@ export class HlsSessionManager {
|
|
|
2283
2257
|
// boundaries (the playlist itself), so this MUST block session creation —
|
|
2284
2258
|
// an incorrect playlist is worse than a slower start.
|
|
2285
2259
|
//
|
|
2286
|
-
//
|
|
2287
|
-
//
|
|
2288
|
-
//
|
|
2289
|
-
//
|
|
2290
|
-
//
|
|
2291
|
-
//
|
|
2292
|
-
// bound should be has to come from what this read costs on real hosts —
|
|
2293
|
-
// roadmap item 74, and the reading it needs is the line printed by
|
|
2294
|
-
// #readContainerKeyframes.
|
|
2260
|
+
// The wait is bounded (KEYFRAME_TABLE_BUDGET_MS), and the bound is what
|
|
2261
|
+
// the read costs on a real host rather than a figure picked here. A
|
|
2262
|
+
// comment in this place used to promise a short timeout and "never more
|
|
2263
|
+
// than ~6 s to session start" when no timeout existed at all; the file
|
|
2264
|
+
// comes off a torrent, so the bytes the table lives in may still be
|
|
2265
|
+
// arriving, and a session used to wait for them without limit.
|
|
2295
2266
|
const keyframeStartMs = Date.now();
|
|
2296
2267
|
// Read the container's OWN keyframe table (Cues/stss) rather than
|
|
2297
2268
|
// scanning the media. On the copy path ffmpeg can only cut at the
|
|
@@ -2303,15 +2274,27 @@ export class HlsSessionManager {
|
|
|
2303
2274
|
// the file comes off a torrent, and a full packet scan of 5.5 GB found 77
|
|
2304
2275
|
// keyframes in 45 s without finishing, while the container index yields
|
|
2305
2276
|
// all 570 in 0.8 s from two point reads (16 KB).
|
|
2306
|
-
const index = await this
|
|
2277
|
+
const index = await this.readKeyframeTableWithin({ sourceKey, fileIndex, inputUrl, logName });
|
|
2307
2278
|
keyframeTimes = index.times;
|
|
2308
2279
|
containerFormat = index.format;
|
|
2309
2280
|
keyframeTolerance = Number.isFinite(index.tolerance) ? index.tolerance : 0;
|
|
2310
2281
|
keyframeMs = Date.now() - keyframeStartMs;
|
|
2311
|
-
// Onto the file
|
|
2312
|
-
//
|
|
2313
|
-
//
|
|
2314
|
-
file
|
|
2282
|
+
// Onto the file only when the file has ANSWERED. A read that ran out of
|
|
2283
|
+
// its budget is still running, and writing its absence onto the file would
|
|
2284
|
+
// make a passing shortage of bytes look like a property of the bytes —
|
|
2285
|
+
// every later session of the file would then re-encode a picture that can
|
|
2286
|
+
// be copied.
|
|
2287
|
+
if (index.arrived) {
|
|
2288
|
+
// The table is a property of immutable bytes, like the duration and the
|
|
2289
|
+
// track list, and every session of the file reads the one answer.
|
|
2290
|
+
file.learn({ keyframeTimes, keyframeTolerance, containerFormat });
|
|
2291
|
+
} else {
|
|
2292
|
+
logger.warn(
|
|
2293
|
+
`transcode ${sessionId}: the keyframe table for "${logName}" has not arrived in ` +
|
|
2294
|
+
`${Math.round(this.keyframeTableBudgetMs / 1000)}s, so this session re-encodes the picture ` +
|
|
2295
|
+
"instead of copying it; the read goes on and the next session of this file gets the copy"
|
|
2296
|
+
);
|
|
2297
|
+
}
|
|
2315
2298
|
if (!keyframeTimes) {
|
|
2316
2299
|
// No index, so there is no honest grid for a COPY: a copied picture can
|
|
2317
2300
|
// only be cut at the source's own keyframes, and we do not know where
|
|
@@ -2328,11 +2311,13 @@ export class HlsSessionManager {
|
|
|
2328
2311
|
// could not be read in the budget lands here too, which is right for
|
|
2329
2312
|
// the same reason.
|
|
2330
2313
|
transcodeVideo = true;
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
`
|
|
2334
|
-
|
|
2335
|
-
|
|
2314
|
+
if (index.arrived) {
|
|
2315
|
+
logger.warn(
|
|
2316
|
+
`transcode ${sessionId}: no keyframe index in the ${containerFormat} container for ` +
|
|
2317
|
+
`"${logName}" — a copied picture has no honest grid without one, so the video is ` +
|
|
2318
|
+
"re-encoded instead and its keyframes are placed on our own cuts"
|
|
2319
|
+
);
|
|
2320
|
+
}
|
|
2336
2321
|
}
|
|
2337
2322
|
} else if (hasDuration && transcodeVideo) {
|
|
2338
2323
|
// Re-encode path: keyframeTimes are ONLY used to snap a LATER seek (see
|
|
@@ -2695,7 +2680,7 @@ export class HlsSessionManager {
|
|
|
2695
2680
|
// read; this counts them so a session can report what it found. It is
|
|
2696
2681
|
// what decides whether a re-encoded rung can be cut on this same grid and
|
|
2697
2682
|
// spliced into the copy (roadmap item 28).
|
|
2698
|
-
playlistText: hasDuration ?
|
|
2683
|
+
playlistText: hasDuration ? mediaPlaylistText({ boundaries: publishedGrid, segmentFormat }) : "",
|
|
2699
2684
|
// The table AS PUBLISHED — what every playlist of this family states, and
|
|
2700
2685
|
// what every segment of it is stamped against. Inherited whole from the
|
|
2701
2686
|
// base when there is one, so a rung or a soundtrack created later
|
|
@@ -3137,12 +3122,12 @@ export class HlsSessionManager {
|
|
|
3137
3122
|
if (running) {
|
|
3138
3123
|
return running;
|
|
3139
3124
|
}
|
|
3140
|
-
//
|
|
3141
|
-
//
|
|
3142
|
-
// the
|
|
3143
|
-
//
|
|
3144
|
-
// the
|
|
3145
|
-
//
|
|
3125
|
+
// The WHOLE wait, as whoever asked for the table experiences it: the swarm
|
|
3126
|
+
// delivering the head and tail of the file, the parse over those bytes, and
|
|
3127
|
+
// the crossing to the torrent thread and back. The worker's own line
|
|
3128
|
+
// (`container-keyframes:`) reports the last two apart from the first, and
|
|
3129
|
+
// reading the two lines as one figure is what led to a wrong conclusion on
|
|
3130
|
+
// 2026-09-04 — they differ by up to sixty seconds on a thin swarm.
|
|
3146
3131
|
const startedMs = Date.now();
|
|
3147
3132
|
const work = this.#readContainerKeyframesOnce({ sourceKey, fileIndex, inputUrl, logName })
|
|
3148
3133
|
.then((result) => {
|
|
@@ -3151,7 +3136,7 @@ export class HlsSessionManager {
|
|
|
3151
3136
|
const found = Array.isArray(result?.times) ? result.times.length : 0;
|
|
3152
3137
|
logger.info(
|
|
3153
3138
|
`keyframe index "${logName}": ${found > 0 ? `${found} times` : "none"} from the ` +
|
|
3154
|
-
`${result?.format ?? "unrecognised"} container
|
|
3139
|
+
`${result?.format ?? "unrecognised"} container, waited ${tookMs}ms`
|
|
3155
3140
|
);
|
|
3156
3141
|
return result;
|
|
3157
3142
|
})
|
|
@@ -3169,6 +3154,40 @@ export class HlsSessionManager {
|
|
|
3169
3154
|
return work;
|
|
3170
3155
|
}
|
|
3171
3156
|
|
|
3157
|
+
/**
|
|
3158
|
+
* The same read, with a bound on how long a session will wait for it.
|
|
3159
|
+
*
|
|
3160
|
+
* The read itself is NOT cancelled when the bound is reached — it goes on in
|
|
3161
|
+
* the background, is memoized on the file, and is there for the next session
|
|
3162
|
+
* of it. What the bound decides is only whether THIS session waits: a copied
|
|
3163
|
+
* picture cannot be cut without the table, so the answer to "not yet" is to
|
|
3164
|
+
* re-encode, which needs no table because it places the keyframes itself.
|
|
3165
|
+
*
|
|
3166
|
+
* Public because it is what decides which branch a picture takes, and a
|
|
3167
|
+
* private method cannot be pinned by a check.
|
|
3168
|
+
*
|
|
3169
|
+
* @param {{ sourceKey: string, fileIndex: number, inputUrl?: URL, logName: string }} params
|
|
3170
|
+
* @returns {Promise<{ times: number[] | null, format: string, tolerance?: number, arrived: boolean }>}
|
|
3171
|
+
*/
|
|
3172
|
+
async readKeyframeTableWithin(params) {
|
|
3173
|
+
const read = this.#readContainerKeyframes(params);
|
|
3174
|
+
let timer = null;
|
|
3175
|
+
const budget = new Promise((resolve) => {
|
|
3176
|
+
timer = setTimeout(() => resolve(null), this.keyframeTableBudgetMs);
|
|
3177
|
+
// A session must not be held open by this timer alone.
|
|
3178
|
+
timer?.unref?.();
|
|
3179
|
+
});
|
|
3180
|
+
const answer = await Promise.race([read.then((result) => ({ ...result, arrived: true })), budget]);
|
|
3181
|
+
clearTimeout(timer);
|
|
3182
|
+
if (answer) {
|
|
3183
|
+
return answer;
|
|
3184
|
+
}
|
|
3185
|
+
// Nothing is added here to swallow a late rejection: the race is holding a
|
|
3186
|
+
// handler on that promise already, and a second one would only look like it
|
|
3187
|
+
// was doing something.
|
|
3188
|
+
return { times: null, format: "not yet read", tolerance: 0, arrived: false };
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3172
3191
|
/**
|
|
3173
3192
|
* The read itself, made exactly once per file by the caller above.
|
|
3174
3193
|
*
|
|
@@ -3222,36 +3241,6 @@ export class HlsSessionManager {
|
|
|
3222
3241
|
return ContainerFactory.readKeyframeIndex({ readRange, fileSize, label: logName });
|
|
3223
3242
|
}
|
|
3224
3243
|
|
|
3225
|
-
#buildVodPlaylist(boundaries, segmentFormat) {
|
|
3226
|
-
const count = Math.max(0, boundaries.length - 1);
|
|
3227
|
-
let maxDuration = 0;
|
|
3228
|
-
for (let index = 0; index < count; index += 1) {
|
|
3229
|
-
const duration = Math.max(0.1, boundaries[index + 1] - boundaries[index]);
|
|
3230
|
-
if (duration > maxDuration) {
|
|
3231
|
-
maxDuration = duration;
|
|
3232
|
-
}
|
|
3233
|
-
}
|
|
3234
|
-
const lines = [
|
|
3235
|
-
"#EXTM3U",
|
|
3236
|
-
// The container decides the minimum version (fMP4 + `#EXT-X-MAP` needs 7,
|
|
3237
|
-
// MPEG-TS is fine at 3).
|
|
3238
|
-
`#EXT-X-VERSION:${segmentFormat.playlistVersion}`,
|
|
3239
|
-
`#EXT-X-TARGETDURATION:${Math.ceil(maxDuration)}`,
|
|
3240
|
-
"#EXT-X-MEDIA-SEQUENCE:0",
|
|
3241
|
-
"#EXT-X-PLAYLIST-TYPE:VOD",
|
|
3242
|
-
"#EXT-X-INDEPENDENT-SEGMENTS",
|
|
3243
|
-
// Container-specific header lines (e.g. fMP4's `#EXT-X-MAP`).
|
|
3244
|
-
...segmentFormat.playlistHeaderLines()
|
|
3245
|
-
];
|
|
3246
|
-
for (let index = 0; index < count; index += 1) {
|
|
3247
|
-
const duration = Math.max(0.1, boundaries[index + 1] - boundaries[index]);
|
|
3248
|
-
lines.push(`#EXTINF:${duration.toFixed(6)},`);
|
|
3249
|
-
lines.push(segmentFormat.segmentFileName(index));
|
|
3250
|
-
}
|
|
3251
|
-
lines.push("#EXT-X-ENDLIST");
|
|
3252
|
-
return `${lines.join("\n")}\n`;
|
|
3253
|
-
}
|
|
3254
|
-
|
|
3255
3244
|
/**
|
|
3256
3245
|
* Start time (seconds, 0-based) of segment `index`, from the session's
|
|
3257
3246
|
* boundary table. Clamped to valid range.
|
|
@@ -3357,24 +3346,7 @@ export class HlsSessionManager {
|
|
|
3357
3346
|
// The player's grid, for the same reason the cut list uses it: the time
|
|
3358
3347
|
// being resolved came from the playlist the player holds, so the index it
|
|
3359
3348
|
// means is the index that playlist gives it.
|
|
3360
|
-
|
|
3361
|
-
if (boundaries.length < 2) {
|
|
3362
|
-
return Math.max(0, Math.floor(t / this.segmentDurationSec));
|
|
3363
|
-
}
|
|
3364
|
-
// boundaries is sorted ascending; find the last boundary <= t.
|
|
3365
|
-
let lo = 0;
|
|
3366
|
-
let hi = boundaries.length - 1;
|
|
3367
|
-
let result = 0;
|
|
3368
|
-
while (lo <= hi) {
|
|
3369
|
-
const mid = (lo + hi) >> 1;
|
|
3370
|
-
if (boundaries[mid] <= t) {
|
|
3371
|
-
result = mid;
|
|
3372
|
-
lo = mid + 1;
|
|
3373
|
-
} else {
|
|
3374
|
-
hi = mid - 1;
|
|
3375
|
-
}
|
|
3376
|
-
}
|
|
3377
|
-
return Math.min(result, boundaries.length - 2);
|
|
3349
|
+
return segmentIndexForTime(this.publishedGridFor(session), t, this.segmentDurationSec);
|
|
3378
3350
|
}
|
|
3379
3351
|
|
|
3380
3352
|
/**
|
|
@@ -9216,7 +9188,6 @@ export class HlsSessionManager {
|
|
|
9216
9188
|
// existence made a live session answer 404 to its own published address.
|
|
9217
9189
|
const rungs = this.liveOutputs.splicableHeights(session);
|
|
9218
9190
|
const sourceWidth = Number(session.file.width) || 0;
|
|
9219
|
-
const lines = ["#EXTM3U", `#EXT-X-VERSION:${session.segmentFormat.playlistVersion}`];
|
|
9220
9191
|
// The audio tracks, published once for the whole file rather than muxed
|
|
9221
9192
|
// into every rung. Two things follow from that: the same track is not
|
|
9222
9193
|
// encoded once per rung on a host that struggles to encode it once, and
|
|
@@ -9233,27 +9204,14 @@ export class HlsSessionManager {
|
|
|
9233
9204
|
// field would start the second viewer in the first viewer's language.
|
|
9234
9205
|
? this.#audioRenditionsOf(session, this.#audioChoiceOf(session, consumerId).trackIndex)
|
|
9235
9206
|
: [];
|
|
9236
|
-
|
|
9237
|
-
|
|
9238
|
-
|
|
9239
|
-
|
|
9240
|
-
|
|
9241
|
-
|
|
9242
|
-
|
|
9243
|
-
|
|
9244
|
-
}
|
|
9245
|
-
for (const height of rungs) {
|
|
9246
|
-
const width = sourceHeight > 0 && sourceWidth > 0
|
|
9247
|
-
? Math.round((sourceWidth / sourceHeight) * height / 2) * 2
|
|
9248
|
-
: 0;
|
|
9249
|
-
lines.push(
|
|
9250
|
-
`#EXT-X-STREAM-INF:BANDWIDTH=${estimatedBitrateFor(height)}` +
|
|
9251
|
-
(width > 0 ? `,RESOLUTION=${width}x${height}` : "") +
|
|
9252
|
-
(audioGroup ? `,AUDIO="${audioGroup}"` : "")
|
|
9253
|
-
);
|
|
9254
|
-
lines.push(`${VARIANT_PATH_PREFIX}/${height}/${PLAYLIST_FILE_NAME}`);
|
|
9255
|
-
}
|
|
9256
|
-
return `${lines.join("\n")}\n`;
|
|
9207
|
+
return masterPlaylistText({
|
|
9208
|
+
playlistVersion: session.segmentFormat.playlistVersion,
|
|
9209
|
+
heights: rungs,
|
|
9210
|
+
sourceWidth,
|
|
9211
|
+
sourceHeight,
|
|
9212
|
+
renditions,
|
|
9213
|
+
playlistFileName: PLAYLIST_FILE_NAME
|
|
9214
|
+
});
|
|
9257
9215
|
}
|
|
9258
9216
|
|
|
9259
9217
|
/**
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What an output tells a player it consists of.
|
|
3
|
+
*
|
|
4
|
+
* Two texts and one lookup, and all three are statements about a TIMELINE — how
|
|
5
|
+
* the file is cut, and which of the file's heights and soundtracks are being
|
|
6
|
+
* published beside it. None of them is about a session, a viewer, an encoder or
|
|
7
|
+
* a disk, which is why they live here: the manager resolved a session, walked
|
|
8
|
+
* the live outputs and then also wrote HLS by hand, so the format of a playlist
|
|
9
|
+
* was a private detail of an eleven-thousand-line class.
|
|
10
|
+
*
|
|
11
|
+
* Every input is passed in. This layer reads no file, holds no state, and knows
|
|
12
|
+
* nothing of the classes above it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Where a rung and a soundtrack live under a session's own address. The player
|
|
16
|
+
// only ever sees them joined to it, so they are written once, here, beside the
|
|
17
|
+
// lines that use them.
|
|
18
|
+
const VARIANT_PATH_PREFIX = "v";
|
|
19
|
+
const AUDIO_PATH_PREFIX = "a";
|
|
20
|
+
|
|
21
|
+
// One group for every soundtrack of one picture: a rendition group is what
|
|
22
|
+
// makes changing language the player's own switch instead of this proxy
|
|
23
|
+
// rebuilding the session with another track number.
|
|
24
|
+
const AUDIO_GROUP_ID = "aud";
|
|
25
|
+
|
|
26
|
+
// ISO 639-2 codes as ffmpeg reports them, against the RFC 5646 tags HLS asks
|
|
27
|
+
// for. Only the languages this serves in practice; anything else is passed
|
|
28
|
+
// through, which is what players other than iOS accept anyway.
|
|
29
|
+
const LANGUAGE_TAGS = new Map([
|
|
30
|
+
["rus", "ru"], ["eng", "en"], ["ukr", "uk"], ["deu", "de"], ["ger", "de"],
|
|
31
|
+
["fra", "fr"], ["fre", "fr"], ["spa", "es"], ["ita", "it"], ["jpn", "ja"],
|
|
32
|
+
["kor", "ko"], ["zho", "zh"], ["chi", "zh"], ["pol", "pl"], ["por", "pt"],
|
|
33
|
+
["tur", "tr"], ["ces", "cs"], ["cze", "cs"], ["nld", "nl"], ["dut", "nl"]
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The RFC 5646 tag for a language ffmpeg named, or the name unchanged.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} language
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
export function languageTag(language) {
|
|
43
|
+
const code = String(language ?? "").toLowerCase();
|
|
44
|
+
return LANGUAGE_TAGS.get(code) ?? code;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Quote a value for an HLS attribute list.
|
|
49
|
+
*
|
|
50
|
+
* The quote would end the attribute early; a line break would end the LINE,
|
|
51
|
+
* splitting one `#EXT-X-MEDIA` into two and corrupting the master. Both come
|
|
52
|
+
* from the file's own metadata, which is not ours to trust.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} value
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
export function escapeAttribute(value) {
|
|
58
|
+
return String(value ?? "").replace(/"/g, "'").replace(/[\u0000-\u001f\u007f]/g, " ").trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A rough bitrate for a height, in bits per second.
|
|
63
|
+
*
|
|
64
|
+
* `BANDWIDTH` is required on every variant by the HLS specification, and the
|
|
65
|
+
* player uses it to order them. It does not have to be exact — nothing here
|
|
66
|
+
* adapts on it, because the viewer chooses — so it is the usual H.264 rule of
|
|
67
|
+
* thumb rather than a measurement we do not have before encoding starts.
|
|
68
|
+
*
|
|
69
|
+
* @param {number} height
|
|
70
|
+
* @returns {number}
|
|
71
|
+
*/
|
|
72
|
+
export function estimatedBitrateFor(height) {
|
|
73
|
+
return Math.max(400_000, Math.round(height * height * 3.2));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The media playlist: the whole film, as a VOD list of segments that mostly do
|
|
78
|
+
* not exist yet.
|
|
79
|
+
*
|
|
80
|
+
* Synthetic and complete by design — every segment listed and `#EXT-X-ENDLIST`
|
|
81
|
+
* at the end — so the player knows the length and can seek at once. An `event`
|
|
82
|
+
* playlist gives neither, which is the bug this replaced.
|
|
83
|
+
*
|
|
84
|
+
* @param {{ boundaries: number[], segmentFormat: { playlistVersion: number, playlistHeaderLines: () => string[], segmentFileName: (index: number) => string } }} params
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
export function mediaPlaylistText({ boundaries, segmentFormat }) {
|
|
88
|
+
const count = Math.max(0, boundaries.length - 1);
|
|
89
|
+
let maxDuration = 0;
|
|
90
|
+
for (let index = 0; index < count; index += 1) {
|
|
91
|
+
const duration = Math.max(0.1, boundaries[index + 1] - boundaries[index]);
|
|
92
|
+
if (duration > maxDuration) {
|
|
93
|
+
maxDuration = duration;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const lines = [
|
|
97
|
+
"#EXTM3U",
|
|
98
|
+
// The container decides the minimum version (fMP4 + `#EXT-X-MAP` needs 7,
|
|
99
|
+
// MPEG-TS is fine at 3).
|
|
100
|
+
`#EXT-X-VERSION:${segmentFormat.playlistVersion}`,
|
|
101
|
+
`#EXT-X-TARGETDURATION:${Math.ceil(maxDuration)}`,
|
|
102
|
+
"#EXT-X-MEDIA-SEQUENCE:0",
|
|
103
|
+
"#EXT-X-PLAYLIST-TYPE:VOD",
|
|
104
|
+
"#EXT-X-INDEPENDENT-SEGMENTS",
|
|
105
|
+
// Container-specific header lines (e.g. fMP4's `#EXT-X-MAP`).
|
|
106
|
+
...segmentFormat.playlistHeaderLines()
|
|
107
|
+
];
|
|
108
|
+
for (let index = 0; index < count; index += 1) {
|
|
109
|
+
const duration = Math.max(0.1, boundaries[index + 1] - boundaries[index]);
|
|
110
|
+
lines.push(`#EXTINF:${duration.toFixed(6)},`);
|
|
111
|
+
lines.push(segmentFormat.segmentFileName(index));
|
|
112
|
+
}
|
|
113
|
+
lines.push("#EXT-X-ENDLIST");
|
|
114
|
+
return `${lines.join("\n")}\n`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The master playlist: the heights a player may switch between, and the
|
|
119
|
+
* soundtracks published beside them.
|
|
120
|
+
*
|
|
121
|
+
* The soundtracks are published once for the whole file rather than muxed into
|
|
122
|
+
* every rung. Two things follow: the same track is not encoded once per rung on
|
|
123
|
+
* a host that struggles to encode it once, and changing track becomes the
|
|
124
|
+
* player switching rendition instead of this proxy rebuilding the session.
|
|
125
|
+
*
|
|
126
|
+
* Which rendition is marked DEFAULT is decided by the caller, per VIEWER: one
|
|
127
|
+
* picture is shared by everyone watching it and each of them may have chosen a
|
|
128
|
+
* different language, so a default taken from the session's own field would
|
|
129
|
+
* start the second viewer in the first viewer's language.
|
|
130
|
+
*
|
|
131
|
+
* @param {{
|
|
132
|
+
* playlistVersion: number,
|
|
133
|
+
* heights: number[],
|
|
134
|
+
* sourceWidth: number,
|
|
135
|
+
* sourceHeight: number,
|
|
136
|
+
* renditions?: Array<{ trackIndex: number, name: string, language: string, isDefault: boolean }>,
|
|
137
|
+
* playlistFileName: string
|
|
138
|
+
* }} params
|
|
139
|
+
* @returns {string}
|
|
140
|
+
*/
|
|
141
|
+
export function masterPlaylistText({
|
|
142
|
+
playlistVersion,
|
|
143
|
+
heights,
|
|
144
|
+
sourceWidth,
|
|
145
|
+
sourceHeight,
|
|
146
|
+
renditions = [],
|
|
147
|
+
playlistFileName
|
|
148
|
+
}) {
|
|
149
|
+
const lines = ["#EXTM3U", `#EXT-X-VERSION:${playlistVersion}`];
|
|
150
|
+
const audioGroup = renditions.length > 0 ? AUDIO_GROUP_ID : "";
|
|
151
|
+
for (const rendition of renditions) {
|
|
152
|
+
lines.push(
|
|
153
|
+
`#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="${audioGroup}",NAME="${escapeAttribute(rendition.name)}"` +
|
|
154
|
+
(rendition.language ? `,LANGUAGE="${escapeAttribute(languageTag(rendition.language))}"` : "") +
|
|
155
|
+
`,AUTOSELECT=YES,DEFAULT=${rendition.isDefault ? "YES" : "NO"}` +
|
|
156
|
+
`,URI="${AUDIO_PATH_PREFIX}/${rendition.trackIndex}/${playlistFileName}"`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
for (const height of heights) {
|
|
160
|
+
const width = sourceHeight > 0 && sourceWidth > 0
|
|
161
|
+
? Math.round((sourceWidth / sourceHeight) * height / 2) * 2
|
|
162
|
+
: 0;
|
|
163
|
+
lines.push(
|
|
164
|
+
`#EXT-X-STREAM-INF:BANDWIDTH=${estimatedBitrateFor(height)}` +
|
|
165
|
+
(width > 0 ? `,RESOLUTION=${width}x${height}` : "") +
|
|
166
|
+
(audioGroup ? `,AUDIO="${audioGroup}"` : "")
|
|
167
|
+
);
|
|
168
|
+
lines.push(`${VARIANT_PATH_PREFIX}/${height}/${playlistFileName}`);
|
|
169
|
+
}
|
|
170
|
+
return `${lines.join("\n")}\n`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The segment whose span contains `seconds`, by a boundary table.
|
|
175
|
+
*
|
|
176
|
+
* The table to pass is the one the PLAYER holds: the time being resolved came
|
|
177
|
+
* from the playlist the player was given, so the index it means is the index
|
|
178
|
+
* that playlist gives it. Falls back to an even grid of `segmentDurationSec`
|
|
179
|
+
* when there is no table, which is a session with no known duration.
|
|
180
|
+
*
|
|
181
|
+
* @param {number[]} boundaries
|
|
182
|
+
* @param {number} seconds
|
|
183
|
+
* @param {number} segmentDurationSec
|
|
184
|
+
* @returns {number}
|
|
185
|
+
*/
|
|
186
|
+
export function segmentIndexForTime(boundaries, seconds, segmentDurationSec) {
|
|
187
|
+
if (!Array.isArray(boundaries) || boundaries.length < 2) {
|
|
188
|
+
return Math.max(0, Math.floor(seconds / segmentDurationSec));
|
|
189
|
+
}
|
|
190
|
+
// boundaries is sorted ascending; find the last boundary <= t.
|
|
191
|
+
let lo = 0;
|
|
192
|
+
let hi = boundaries.length - 1;
|
|
193
|
+
let result = 0;
|
|
194
|
+
while (lo <= hi) {
|
|
195
|
+
const mid = (lo + hi) >> 1;
|
|
196
|
+
if (boundaries[mid] <= seconds) {
|
|
197
|
+
result = mid;
|
|
198
|
+
lo = mid + 1;
|
|
199
|
+
} else {
|
|
200
|
+
hi = mid - 1;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return Math.min(result, boundaries.length - 2);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export { AUDIO_GROUP_ID, AUDIO_PATH_PREFIX, VARIANT_PATH_PREFIX };
|
|
@@ -309,6 +309,13 @@ export async function containerKeyframesOf(torrent, fileIndex, sourceKey, option
|
|
|
309
309
|
if (!file || !Number.isFinite(file.length) || file.length <= 0) {
|
|
310
310
|
return null;
|
|
311
311
|
}
|
|
312
|
+
// Timed apart from the read below, and reported apart from it, because the
|
|
313
|
+
// two are different quantities and reading them as one led to a wrong
|
|
314
|
+
// conclusion on 2026-09-04: this wait is the SWARM delivering the head and
|
|
315
|
+
// tail of the file, and it reached 60 s on a torrent with one peer, while the
|
|
316
|
+
// read that follows it is parsing bytes already in hand and never passed
|
|
317
|
+
// 8.4 s across four containers. A bound belongs on the first, not the second.
|
|
318
|
+
const edgesStartedAt = Date.now();
|
|
312
319
|
if (typeof options.prefetchEdges === "function") {
|
|
313
320
|
try {
|
|
314
321
|
await options.prefetchEdges();
|
|
@@ -317,6 +324,7 @@ export async function containerKeyframesOf(torrent, fileIndex, sourceKey, option
|
|
|
317
324
|
// fetches what it needs itself, only more slowly.
|
|
318
325
|
}
|
|
319
326
|
}
|
|
327
|
+
const edgesMs = Date.now() - edgesStartedAt;
|
|
320
328
|
const readRange = async (start, end) =>
|
|
321
329
|
readFetching(file, start, Math.min(end, file.length - 1));
|
|
322
330
|
const startedAt = Date.now();
|
|
@@ -339,7 +347,8 @@ export async function containerKeyframesOf(torrent, fileIndex, sourceKey, option
|
|
|
339
347
|
`container-keyframes: "${String(file.name).slice(0, 40)}" ` +
|
|
340
348
|
(times
|
|
341
349
|
? `${times.length} keyframes from the ${format} index in ${Date.now() - startedAt}ms`
|
|
342
|
-
: `has no readable index (${format}, ${Date.now() - startedAt}ms)`)
|
|
350
|
+
: `has no readable index (${format}, ${Date.now() - startedAt}ms)`) +
|
|
351
|
+
`, after ${edgesMs}ms waiting for the file's edges`
|
|
343
352
|
);
|
|
344
353
|
return {
|
|
345
354
|
times,
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file How long a session waits for the file's keyframe table.
|
|
3
|
+
*
|
|
4
|
+
* With the table a picture is copied; without it the whole picture is
|
|
5
|
+
* re-encoded, which on a weak host is the difference between almost free and
|
|
6
|
+
* more than the machine has. Until 2.76.1 there was no bound on that wait at
|
|
7
|
+
* all, and the file comes off a torrent — so the bytes the table lives in may
|
|
8
|
+
* still be arriving, and a session could sit there for as long as they took.
|
|
9
|
+
*
|
|
10
|
+
* Measured on the addon host 2026-09-04 over fifteen torrents
|
|
11
|
+
* (`research/keyframe-table-read-2026-09-04.md`): every read whose bytes were
|
|
12
|
+
* there finished within 8.8 s, while reads on swarms of one to four peers were
|
|
13
|
+
* still waiting at 60-121 s. The bound sits between those two, and what it must
|
|
14
|
+
* do is pinned here.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import test from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { HlsSessionManager } from "../services/hls-session-manager.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {number} budgetMs
|
|
23
|
+
* @returns {HlsSessionManager}
|
|
24
|
+
*/
|
|
25
|
+
function manager(budgetMs) {
|
|
26
|
+
return new HlsSessionManager({
|
|
27
|
+
enabled: true,
|
|
28
|
+
ffmpegBin: "ffmpeg",
|
|
29
|
+
localBindHost: "127.0.0.1",
|
|
30
|
+
localPort: 9090,
|
|
31
|
+
keyframeTableBudgetMs: budgetMs
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const FILE = { sourceKey: "torrent:abc", fileIndex: 0, logName: "a.mkv" };
|
|
36
|
+
|
|
37
|
+
test("a table that arrives inside the budget is the answer", async () => {
|
|
38
|
+
const sessions = manager(1_000);
|
|
39
|
+
sessions.getContainerKeyframes = async () => ({ times: [0, 4, 8], tolerance: 0, format: "matroska" });
|
|
40
|
+
|
|
41
|
+
const answer = await sessions.readKeyframeTableWithin(FILE);
|
|
42
|
+
|
|
43
|
+
assert.deepEqual(answer.times, [0, 4, 8]);
|
|
44
|
+
assert.equal(answer.arrived, true, "the file has answered, so what it said may be written onto it");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a table that has not arrived gives up on copying rather than on the session", async () => {
|
|
48
|
+
const sessions = manager(60);
|
|
49
|
+
// The bytes it needs are still coming off the swarm. On the field host this
|
|
50
|
+
// is a torrent with one peer: the read was still waiting after two minutes.
|
|
51
|
+
sessions.getContainerKeyframes = () => new Promise(() => {});
|
|
52
|
+
|
|
53
|
+
const startedAt = Date.now();
|
|
54
|
+
const answer = await sessions.readKeyframeTableWithin(FILE);
|
|
55
|
+
const waited = Date.now() - startedAt;
|
|
56
|
+
|
|
57
|
+
assert.equal(answer.times, null, "no table, so this session cannot copy the picture");
|
|
58
|
+
assert.equal(
|
|
59
|
+
answer.arrived,
|
|
60
|
+
false,
|
|
61
|
+
"and it says the file has NOT answered — an absence written onto the file would make " +
|
|
62
|
+
"a passing shortage of bytes look like a property of the bytes"
|
|
63
|
+
);
|
|
64
|
+
assert.ok(waited < 2_000, `the wait ended at the bound, not at the read (${waited}ms)`);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("the read goes on after the budget, so the next session of the file gets the copy", async () => {
|
|
68
|
+
const sessions = manager(40);
|
|
69
|
+
let reads = 0;
|
|
70
|
+
let answerLate = null;
|
|
71
|
+
sessions.getContainerKeyframes = () => {
|
|
72
|
+
reads += 1;
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
answerLate = resolve;
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const first = await sessions.readKeyframeTableWithin(FILE);
|
|
79
|
+
assert.equal(first.arrived, false);
|
|
80
|
+
|
|
81
|
+
answerLate({ times: [0, 5, 10], tolerance: 0, format: "matroska" });
|
|
82
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
83
|
+
|
|
84
|
+
const second = await sessions.readKeyframeTableWithin(FILE);
|
|
85
|
+
assert.deepEqual(second.times, [0, 5, 10], "the late answer was kept, not thrown away");
|
|
86
|
+
assert.equal(second.arrived, true);
|
|
87
|
+
assert.equal(reads, 1, "and it was not read a second time");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a read that fails is not turned into a bounded wait's silence", async () => {
|
|
91
|
+
const sessions = manager(1_000);
|
|
92
|
+
sessions.getContainerKeyframes = async () => {
|
|
93
|
+
throw new Error("the head is not downloaded");
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
await assert.rejects(
|
|
97
|
+
() => sessions.readKeyframeTableWithin(FILE),
|
|
98
|
+
/the head is not downloaded/,
|
|
99
|
+
"a read that threw is a different thing from a read that is still running"
|
|
100
|
+
);
|
|
101
|
+
});
|