@torrent-tv/proxy 2.89.18 → 2.89.20
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/package.json +1 -1
- package/services/encode/output/cut-grid.js +35 -0
- package/services/encode/output/index.js +1 -0
- package/services/encode/run-command.js +26 -14
- package/services/server/OutputLifecycle.js +7 -2
- package/services/server/SegmentServing.js +7 -28
- package/services/storage/segment-store/SegmentStore.js +87 -9
- package/utils/client-log-file.js +147 -42
package/package.json
CHANGED
|
@@ -105,3 +105,38 @@ export function computeCutGrid({ useKeyframeGrid, durationSeconds, segDur, keyfr
|
|
|
105
105
|
// all, and an even one serves better than a table with a single entry.
|
|
106
106
|
return boundaries.length >= 2 ? { boundaries, sourceTimes } : uniform();
|
|
107
107
|
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Whether an output is cut at times we hand the muxer, rather than at a
|
|
111
|
+
* duration the muxer chooses for itself.
|
|
112
|
+
*
|
|
113
|
+
* One answer for the command a run is given and for the serving of what it
|
|
114
|
+
* made, because both follow from it: given times mean the `segment` muxer,
|
|
115
|
+
* which writes each piece self-contained under a working name and says when it
|
|
116
|
+
* has closed it; without them the `hls` muxer names its pieces itself and
|
|
117
|
+
* writes one init file beside them.
|
|
118
|
+
*
|
|
119
|
+
* Every grid is handed over, the even one included. The `hls` muxer finishes
|
|
120
|
+
* the piece it has open whenever it ends — on our SIGTERM, and when its input
|
|
121
|
+
* stops — and gives that piece its served name, so a piece shorter than its
|
|
122
|
+
* span became servable with nothing to say so. Field 2026-09-27, on the
|
|
123
|
+
* `segment` muxer before its own guard existed: a run stopped while it wrote
|
|
124
|
+
* #111 left 0.37 s of a 4.2 s piece, the browser appended it, counted the
|
|
125
|
+
* fragment as loaded and went on to the next one, and the picture stood at
|
|
126
|
+
* 463.3 s while the player fetched 515.7 s and beyond.
|
|
127
|
+
*
|
|
128
|
+
* What is left without times is an output with no grid at all, which has no
|
|
129
|
+
* duration to cut.
|
|
130
|
+
*
|
|
131
|
+
* @param {{ segmentFormat?: { explicitTimesMuxerArgs?: () => string[] | null } | null,
|
|
132
|
+
* timeline?: { published?: number[], boundaries?: number[] } | null }} output
|
|
133
|
+
* @returns {boolean}
|
|
134
|
+
*/
|
|
135
|
+
export function cutsAtGivenTimes(output) {
|
|
136
|
+
if (!output?.segmentFormat?.explicitTimesMuxerArgs?.()) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
const published = output.timeline?.published;
|
|
140
|
+
const grid = Array.isArray(published) && published.length > 0 ? published : output.timeline?.boundaries;
|
|
141
|
+
return Array.isArray(grid) && grid.length >= 2;
|
|
142
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { OutputSpec, VideoOutput, AudioOutput, CutGrid, isOutputName } from "./OutputSpec.js";
|
|
2
2
|
export { PLAYLIST_FILE_NAME } from "./playlists.js";
|
|
3
|
+
export { cutsAtGivenTimes } from "./cut-grid.js";
|
|
3
4
|
|
|
4
5
|
// FFmpeg may close a segment this far before its requested cut. Both the
|
|
5
6
|
// command and coverage validation use this existing muxer configuration.
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { PLAYLIST_FILE_NAME } from "./output/playlists.js";
|
|
19
19
|
import { AUDIO_TRANSCODE_KBPS } from "./args.js";
|
|
20
|
-
import { SEGMENT_CUT_TIME_DELTA_SECONDS } from "./output/index.js";
|
|
20
|
+
import { cutsAtGivenTimes, SEGMENT_CUT_TIME_DELTA_SECONDS } from "./output/index.js";
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* What ffmpeg's own CLI subtracts from an input seek, and therefore what has to
|
|
@@ -364,15 +364,26 @@ export function buildRunCommand({
|
|
|
364
364
|
// the first requested cut. It is never published over another run's output.
|
|
365
365
|
const inputIndex = !transcodeVideo && !audioOnly && explicitTimes && safeIndex > 0
|
|
366
366
|
? safeIndex - 1 : safeIndex;
|
|
367
|
-
// A
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
367
|
+
// EVERY OUTPUT WITH A GRID is cut by this list, whatever the grid is and
|
|
368
|
+
// whether the picture is copied or re-encoded (`cutsAtGivenTimes`).
|
|
369
|
+
//
|
|
370
|
+
// A copy needs it even when no keyframe index could be read and the
|
|
371
|
+
// boundaries are a plain grid: saying them outright is what keeps the
|
|
372
|
+
// playlist and the muxer agreeing — ffmpeg moves each cut forward to the
|
|
373
|
+
// first real keyframe, and serving reads back where the piece truly begins.
|
|
374
|
+
// Requiring a keyframe grid here dropped a copy with no index onto the `hls`
|
|
375
|
+
// muxer, which takes no cut list and writes no self-contained pieces, so
|
|
376
|
+
// nothing could read a true start and segments were stamped with times the
|
|
377
|
+
// file does not have — the 4.17 s speech-against-subtitles drift, back again.
|
|
378
|
+
//
|
|
379
|
+
// A re-encode on the even grid needs it for the proof of a closed piece. The
|
|
380
|
+
// `hls` muxer finishes the piece it has open whenever it ends, on our SIGTERM
|
|
381
|
+
// and when its input stops, and renames it to the served name itself, so a
|
|
382
|
+
// stopped run left a piece shorter than its span where any request could
|
|
383
|
+
// take it. Measured 2026-10-05 with ffmpeg 8.1.2 on the addon host, a
|
|
384
|
+
// 4 s piece stopped 3 s in stayed as `segment-00001.mp4` with 3.04 s in it.
|
|
385
|
+
// Through the `segment` muxer the piece is published only on the encoder's
|
|
386
|
+
// word and only after its media is shown to reach its cut.
|
|
376
387
|
//
|
|
377
388
|
// Cut on the grid the PLAYER WAS GIVEN, not on the corrected one. A player
|
|
378
389
|
// places a fragment by the playlist it holds, and that text was written
|
|
@@ -387,7 +398,7 @@ export function buildRunCommand({
|
|
|
387
398
|
// created later inherits the corrected table and PUBLISHES it, so its own
|
|
388
399
|
// playlist and its own cuts agree from the start. What they may not do is
|
|
389
400
|
// move the cuts of a session whose playlist is already being read.
|
|
390
|
-
const gridCutTimes =
|
|
401
|
+
const gridCutTimes = cutsAtGivenTimes({ segmentFormat, timeline })
|
|
391
402
|
? segmentCutTimesFrom(publishedGridFor(timeline), inputIndex)
|
|
392
403
|
: null;
|
|
393
404
|
// Cut times are stated on the grid, for both branches.
|
|
@@ -721,9 +732,10 @@ export function buildRunCommand({
|
|
|
721
732
|
// into the same directory — and the name arrives on the channel above the
|
|
722
733
|
// instant ffmpeg closes it, which is what turns it into the served one.
|
|
723
734
|
//
|
|
724
|
-
// The other branch
|
|
725
|
-
// temporary name of its own (`+temp_file`)
|
|
726
|
-
//
|
|
735
|
+
// The other branch cannot do this: the HLS muxer writes through a
|
|
736
|
+
// temporary name of its own (`+temp_file`) and renames each file itself,
|
|
737
|
+
// including the one it was writing when it ended. It is left for the runs
|
|
738
|
+
// that have no cut list at all.
|
|
727
739
|
//
|
|
728
740
|
// TAGGED WITH THE STRETCH IT WAS GIVEN, which names the run without any
|
|
729
741
|
// counter to keep: intervals never overlap, so two live runs of one output
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { logger } from "../../utils/logger.js";
|
|
11
|
-
import { isOutputName } from "../encode/output/index.js";
|
|
11
|
+
import { isOutputName, PLAYLIST_FILE_NAME } from "../encode/output/index.js";
|
|
12
12
|
import { IDLE_KEEP_MS } from "../storage/keep.js";
|
|
13
13
|
/**
|
|
14
14
|
* How long produced segments are kept after the last request for them.
|
|
@@ -273,7 +273,12 @@ export class OutputLifecycle {
|
|
|
273
273
|
adoptSegmentsLeftBehind() {
|
|
274
274
|
// Which container the segments are in is stated by the key itself; a key
|
|
275
275
|
// that cannot say is a directory that goes (`encode/output-key-format.js`).
|
|
276
|
-
|
|
276
|
+
// The `hls` muxer writes its own list, under the playlist's name, beside the
|
|
277
|
+
// pieces it named itself; none of those pieces is proven whole.
|
|
278
|
+
return this.#host.segmentStore.adoptWhatSurvived(
|
|
279
|
+
(key) => this.#host.segmentFormatOfKey(key),
|
|
280
|
+
{ selfNamedListFileName: PLAYLIST_FILE_NAME }
|
|
281
|
+
);
|
|
277
282
|
}
|
|
278
283
|
|
|
279
284
|
/**
|
|
@@ -13,7 +13,7 @@ import { access, readFile, stat, unlink } from "node:fs/promises";
|
|
|
13
13
|
import { Readable } from "node:stream";
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { logger } from "../../utils/logger.js";
|
|
16
|
-
import { isOutputName, PLAYLIST_FILE_NAME } from "../encode/output/index.js";
|
|
16
|
+
import { cutsAtGivenTimes, isOutputName, PLAYLIST_FILE_NAME } from "../encode/output/index.js";
|
|
17
17
|
// The index of variants. Served from the same route as the media playlist, so
|
|
18
18
|
// it needs no path of its own.
|
|
19
19
|
export const MASTER_PLAYLIST_FILE_NAME = "master.m3u8";
|
|
@@ -50,25 +50,6 @@ function isSafeFileName(fileName, segmentFormat) {
|
|
|
50
50
|
segmentFormat.isSegmentFileName(fileName)
|
|
51
51
|
);
|
|
52
52
|
}
|
|
53
|
-
/**
|
|
54
|
-
* Whether this output is cut at times we hand the muxer, rather than at a
|
|
55
|
-
* duration it chooses for itself.
|
|
56
|
-
*
|
|
57
|
-
* A property of the output and not of a run: the cut grid and the branch decide
|
|
58
|
-
* it, so every run of one output answers alike. It decides how a segment is
|
|
59
|
-
* judged finished — see getFileStream.
|
|
60
|
-
*
|
|
61
|
-
* @param {object} session
|
|
62
|
-
* @returns {boolean}
|
|
63
|
-
*/
|
|
64
|
-
function cutsAtGivenTimes(session) {
|
|
65
|
-
const explicit = session?.segmentFormat?.explicitTimesMuxerArgs?.() ?? null;
|
|
66
|
-
if (!explicit) {
|
|
67
|
-
return false;
|
|
68
|
-
}
|
|
69
|
-
return !session.spec.transcodesVideo || session.timeline?.cutGrid === "keyframe";
|
|
70
|
-
}
|
|
71
|
-
|
|
72
53
|
export class SegmentServing {
|
|
73
54
|
/** Serving state keyed by the output object it belongs to. */
|
|
74
55
|
#states = new WeakMap();
|
|
@@ -275,14 +256,12 @@ export class SegmentServing {
|
|
|
275
256
|
return this.#holdForProduction(session, fileName, isPlaylist, options);
|
|
276
257
|
}
|
|
277
258
|
try {
|
|
278
|
-
// Existing is not the same as finished. The `hls` muxer
|
|
279
|
-
// segment to a temporary name and
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
// running happily ahead. A segment is finished once the NEXT one has been
|
|
285
|
-
// started, or once the run producing it has ended.
|
|
259
|
+
// Existing is not the same as finished. The `hls` muxer writes each
|
|
260
|
+
// segment to a temporary name and renames it when it ends the segment —
|
|
261
|
+
// at its cut, but also on SIGTERM and when its input stops, so a file
|
|
262
|
+
// appearing there proves only that the muxer let go of it. Every output
|
|
263
|
+
// with a grid is therefore cut by the `segment` muxer, whose file appears
|
|
264
|
+
// when writing begins and is served only once it is published below.
|
|
286
265
|
if (!isPlaylist && cutsAtGivenTimes(session)) {
|
|
287
266
|
// WHAT PROVES A PIECE IS WHOLE is the encoder's own word for it: it
|
|
288
267
|
// names each file on a channel of its own the moment it closes it, and
|
|
@@ -403,6 +403,74 @@ export class SegmentStore {
|
|
|
403
403
|
return removed;
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
+
/**
|
|
407
|
+
* Remove the pieces a muxer that names its own pieces left in a directory,
|
|
408
|
+
* and the list it named them in.
|
|
409
|
+
*
|
|
410
|
+
* Asked only of what a previous life of the process left. The `hls` muxer
|
|
411
|
+
* renames the piece it has open when it ends, so a stopped run leaves a
|
|
412
|
+
* piece shorter than its span under the served name — the shape of field
|
|
413
|
+
* 2026-09-27, where #111 held 0.37 s of 4.2 s, the browser appended it and
|
|
414
|
+
* never asked for that stretch again. The list cannot say which piece is the
|
|
415
|
+
* short one, so every piece it names goes and is made again.
|
|
416
|
+
*
|
|
417
|
+
* @param {string} key
|
|
418
|
+
* @param {string} dir
|
|
419
|
+
* @param {{ isSegmentFileName?: (name: string) => boolean, initFileName?: string | null }} format
|
|
420
|
+
* @param {string | null} listFileName
|
|
421
|
+
* @returns {number} How many pieces were removed.
|
|
422
|
+
*/
|
|
423
|
+
#sweepSelfNamed(key, dir, format, listFileName) {
|
|
424
|
+
if (!listFileName) {
|
|
425
|
+
return 0;
|
|
426
|
+
}
|
|
427
|
+
const listPath = path.join(dir, listFileName);
|
|
428
|
+
let text;
|
|
429
|
+
try {
|
|
430
|
+
text = readFileSync(listPath, "utf8");
|
|
431
|
+
} catch {
|
|
432
|
+
return 0;
|
|
433
|
+
}
|
|
434
|
+
let removed = 0;
|
|
435
|
+
for (const line of text.split(/\r?\n/)) {
|
|
436
|
+
const name = line.trim();
|
|
437
|
+
if (name.length === 0 || name.startsWith("#") || !format?.isSegmentFileName?.(name)) {
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
const piecePath = path.join(dir, name);
|
|
441
|
+
if (!existsSync(piecePath)) {
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
try {
|
|
445
|
+
rmSync(piecePath, { force: true });
|
|
446
|
+
removed += 1;
|
|
447
|
+
} catch {
|
|
448
|
+
// Then it stays, and is served as it was before this sweep existed.
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// The list goes with the pieces it named, and so does the init file the
|
|
452
|
+
// same muxer wrote beside them: nothing else writes one into this
|
|
453
|
+
// directory, and a piece cut by the list carries its own header.
|
|
454
|
+
for (const leftover of [listFileName, format?.initFileName]) {
|
|
455
|
+
if (!leftover) {
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
rmSync(path.join(dir, leftover), { force: true });
|
|
460
|
+
} catch {
|
|
461
|
+
// It names or describes pieces that are gone; reading it again removes nothing.
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (removed > 0) {
|
|
465
|
+
this.#held.delete(key);
|
|
466
|
+
this.#logger.info(
|
|
467
|
+
`segment-store removed ${removed} piece(s) of ${path.basename(dir)} that the hls muxer ` +
|
|
468
|
+
"named itself: nothing proves they reach their cut"
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
return removed;
|
|
472
|
+
}
|
|
473
|
+
|
|
406
474
|
/**
|
|
407
475
|
* The encoder has closed a piece: give it the name it is served under.
|
|
408
476
|
*
|
|
@@ -564,10 +632,11 @@ export class SegmentStore {
|
|
|
564
632
|
* muxer writes under a working name and reports the closure on a channel of
|
|
565
633
|
* its own (`publish`); the `hls` muxer writes through a temporary name of its
|
|
566
634
|
* own (`+temp_file`) and reports nothing, so the file simply appears under the
|
|
567
|
-
* name it is served as. That branch
|
|
568
|
-
* even grid — the ordinary quality step — and for those `waitFor` could
|
|
569
|
-
* end on its deadline: the segment was on disk and the request that
|
|
570
|
-
* went on waiting, up to a full minute, and was then answered 503.
|
|
635
|
+
* name it is served as. That branch was then taken by every re-encoded output
|
|
636
|
+
* on the even grid — the ordinary quality step — and for those `waitFor` could
|
|
637
|
+
* only end on its deadline: the segment was on disk and the request that
|
|
638
|
+
* wanted it went on waiting, up to a full minute, and was then answered 503.
|
|
639
|
+
* It is now taken only by a run given no cut list.
|
|
571
640
|
*
|
|
572
641
|
* So the store watches what it owns. The rename into place moves the
|
|
573
642
|
* directory's modification time, the kernel says so, and both branches reach
|
|
@@ -1243,9 +1312,12 @@ export class SegmentStore {
|
|
|
1243
1312
|
* @param {(key: string) => object | null} formatFor - How to read the file
|
|
1244
1313
|
* names of an output, given its key. Null when this proxy cannot serve that
|
|
1245
1314
|
* output at all, and then the directory goes.
|
|
1315
|
+
* @param {{ selfNamedListFileName?: string | null }} [writers] - The list a
|
|
1316
|
+
* muxer that names its own pieces writes beside them. Every piece in it was
|
|
1317
|
+
* given its served name by that muxer and not by a closure we were told of.
|
|
1246
1318
|
* @returns {{ adopted: number, dropped: number, unprovenRemoved: number }}
|
|
1247
1319
|
*/
|
|
1248
|
-
adoptWhatSurvived(formatFor) {
|
|
1320
|
+
adoptWhatSurvived(formatFor, { selfNamedListFileName = null } = {}) {
|
|
1249
1321
|
const swept = this.sweep();
|
|
1250
1322
|
let adopted = 0;
|
|
1251
1323
|
let dropped = 0;
|
|
@@ -1266,15 +1338,21 @@ export class SegmentStore {
|
|
|
1266
1338
|
continue;
|
|
1267
1339
|
}
|
|
1268
1340
|
this.#formats.set(entry.key, format);
|
|
1269
|
-
//
|
|
1270
|
-
//
|
|
1271
|
-
// prove here and nothing to un-prove: what
|
|
1272
|
-
//
|
|
1341
|
+
// A SEGMENT GIVEN ITS SERVED NAME BY US IS COMPLETE, because we give it
|
|
1342
|
+
// only once the encoder has said it is closed and its media reaches its
|
|
1343
|
+
// cut. So there is nothing to prove here and nothing to un-prove: what
|
|
1344
|
+
// the directory holds under those names is what a killed process
|
|
1345
|
+
// finished.
|
|
1273
1346
|
//
|
|
1274
1347
|
// What it may also hold is pieces it was in the middle of, under their
|
|
1275
1348
|
// working names, and those are swept — the file a run was writing when the
|
|
1276
1349
|
// kernel took the process is exactly this.
|
|
1277
1350
|
unprovenRemoved += this.#sweepUnfinished(entry.key, entry.dir, format);
|
|
1351
|
+
// And pieces the `hls` muxer renamed itself. It renames the piece it has
|
|
1352
|
+
// open whenever it ends, on SIGTERM and when its input stops, so nothing
|
|
1353
|
+
// says any of them reaches its cut; an earlier version cut every
|
|
1354
|
+
// re-encode on the even grid that way. The muxer's own list names them.
|
|
1355
|
+
unprovenRemoved += this.#sweepSelfNamed(entry.key, entry.dir, format, selfNamedListFileName);
|
|
1278
1356
|
adopted += 1;
|
|
1279
1357
|
this.#logger.info(
|
|
1280
1358
|
`segment-store adopted ${path.basename(entry.dir)}: ${this.provenNumbers(entry.key).length} ` +
|
package/utils/client-log-file.js
CHANGED
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
* the evidence for exactly this reason.
|
|
10
10
|
*
|
|
11
11
|
* Written here instead, next to the proxy's own log, on the same durable
|
|
12
|
-
* directory (`/data` on the addon host). One file per viewing session
|
|
13
|
-
* the two halves join without guessing: the session's start,
|
|
12
|
+
* directory (`/data` on the addon host). One file per viewing session and
|
|
13
|
+
* torrent, named so the two halves join without guessing: the session's start,
|
|
14
|
+
* then the torrent.
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
|
-
import { createWriteStream, mkdirSync,
|
|
17
|
+
import { createWriteStream, fstatSync, mkdirSync, openSync, renameSync } from "node:fs";
|
|
17
18
|
import { dirname, join } from "node:path";
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -82,45 +83,106 @@ function stamp(iso) {
|
|
|
82
83
|
*
|
|
83
84
|
* @param {string} proxyLogPath - The proxy's own log file; its directory is
|
|
84
85
|
* used, so the two halves are always side by side.
|
|
85
|
-
* @returns {{ write: (session: object, lines: string[]) => void, close: () => void }}
|
|
86
|
+
* @returns {{ write: (session: object, lines: string[]) => void, close: () => Promise<void> }}
|
|
86
87
|
*/
|
|
87
88
|
export function createClientLogFiles(proxyLogPath) {
|
|
88
89
|
const directory = typeof proxyLogPath === "string" && proxyLogPath.length > 0
|
|
89
90
|
? dirname(proxyLogPath)
|
|
90
91
|
: "";
|
|
91
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* @typedef {object} OpenFile
|
|
94
|
+
* @property {import("node:fs").WriteStream} stream
|
|
95
|
+
* @property {string} path
|
|
96
|
+
* @property {number} bytes
|
|
97
|
+
* @property {string} prefix - `client-<start>-<session>`, the part every file
|
|
98
|
+
* of one session shares, so they are found together.
|
|
99
|
+
* @property {string} film - Which torrent the name carries; empty while it
|
|
100
|
+
* still says `no-torrent-yet`.
|
|
101
|
+
*/
|
|
102
|
+
/** @type {Map<string, OpenFile>} */
|
|
92
103
|
const open = new Map();
|
|
104
|
+
/**
|
|
105
|
+
* Files taken out of `open` whose last lines are still being flushed: a
|
|
106
|
+
* rotated file, and the file of a film the session has moved away from.
|
|
107
|
+
*
|
|
108
|
+
* @type {Set<Promise<void>>}
|
|
109
|
+
*/
|
|
110
|
+
const ending = new Set();
|
|
93
111
|
|
|
94
112
|
/**
|
|
95
|
-
*
|
|
113
|
+
* Finish a file that is no longer written to, and remember the wait so that
|
|
114
|
+
* `close()` does not return before its last lines are on disk.
|
|
96
115
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
|
|
100
|
-
|
|
116
|
+
* @param {OpenFile} record
|
|
117
|
+
* @returns {void}
|
|
118
|
+
*/
|
|
119
|
+
function finish(record) {
|
|
120
|
+
const done = new Promise((resolve) => {
|
|
121
|
+
try {
|
|
122
|
+
record.stream.end(() => resolve(undefined));
|
|
123
|
+
} catch {
|
|
124
|
+
// A stream that cannot be ended has nothing left to flush.
|
|
125
|
+
resolve(undefined);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
ending.add(done);
|
|
129
|
+
void done.then(() => ending.delete(done));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Which torrent a batch names, or empty when none is chosen yet.
|
|
101
134
|
*
|
|
102
|
-
* @param {{
|
|
103
|
-
* @returns {
|
|
135
|
+
* @param {{ torrentName: string, infoHash: string }} session
|
|
136
|
+
* @returns {string}
|
|
104
137
|
*/
|
|
105
|
-
function
|
|
106
|
-
if (!
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
138
|
+
function filmOf(session) {
|
|
139
|
+
if (!session.torrentName) return "";
|
|
140
|
+
return session.infoHash || session.torrentName;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {string} prefix
|
|
145
|
+
* @param {{ torrentName: string, infoHash: string }} session
|
|
146
|
+
* @returns {string}
|
|
147
|
+
*/
|
|
148
|
+
function pathFor(prefix, session) {
|
|
110
149
|
const film = session.torrentName
|
|
111
150
|
? `${safeName(session.torrentName, 60)}-${safeName(session.infoHash, 8)}`
|
|
112
151
|
: "no-torrent-yet";
|
|
113
|
-
|
|
152
|
+
return join(directory, `${prefix}-${film}.log`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Open a file for appending.
|
|
157
|
+
*
|
|
158
|
+
* The descriptor is opened synchronously and handed to the stream, so the
|
|
159
|
+
* file exists under its path before this returns. A stream that opens its
|
|
160
|
+
* own file does so later, and a rename made in between would leave the
|
|
161
|
+
* stream creating a second file under the old name.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} key
|
|
164
|
+
* @param {string} prefix
|
|
165
|
+
* @param {{ torrentName: string, infoHash: string }} session
|
|
166
|
+
* @returns {OpenFile | null}
|
|
167
|
+
*/
|
|
168
|
+
function openFile(key, prefix, session) {
|
|
169
|
+
const path = pathFor(prefix, session);
|
|
114
170
|
try {
|
|
115
171
|
mkdirSync(directory, { recursive: true });
|
|
172
|
+
const fd = openSync(path, "a");
|
|
173
|
+
/** @type {OpenFile} */
|
|
116
174
|
const record = {
|
|
117
|
-
stream: createWriteStream(path, {
|
|
175
|
+
stream: createWriteStream(path, { fd }),
|
|
118
176
|
path,
|
|
119
|
-
bytes:
|
|
177
|
+
bytes: fstatSync(fd).size,
|
|
178
|
+
prefix,
|
|
179
|
+
film: filmOf(session)
|
|
120
180
|
};
|
|
121
181
|
// A file that stops being writable must not take the proxy down with it,
|
|
122
182
|
// and must not be retried on every line.
|
|
123
|
-
record.stream.on("error", () =>
|
|
183
|
+
record.stream.on("error", () => {
|
|
184
|
+
if (open.get(key) === record) open.delete(key);
|
|
185
|
+
});
|
|
124
186
|
open.set(key, record);
|
|
125
187
|
return record;
|
|
126
188
|
} catch {
|
|
@@ -128,6 +190,51 @@ export function createClientLogFiles(proxyLogPath) {
|
|
|
128
190
|
}
|
|
129
191
|
}
|
|
130
192
|
|
|
193
|
+
/**
|
|
194
|
+
* The file for this session, opened on first use.
|
|
195
|
+
*
|
|
196
|
+
* Keyed by the session's own id rather than by the name. The first batch
|
|
197
|
+
* always arrives before a torrent is chosen, and the lines it carries — the
|
|
198
|
+
* page opening, the proxy being chosen, a connection failing — belong to the
|
|
199
|
+
* film the session goes on to play. So the file is RENAMED when the first
|
|
200
|
+
* torrent arrives, and those lines stay in it.
|
|
201
|
+
*
|
|
202
|
+
* A different torrent chosen later in the same page starts a file of its
|
|
203
|
+
* own under the same prefix: renaming again would leave the first film's
|
|
204
|
+
* lines under the second film's name, where nobody looking for the first
|
|
205
|
+
* film finds them.
|
|
206
|
+
*
|
|
207
|
+
* @param {{ sessionId: string, startedAt: string, torrentName: string, infoHash: string }} session
|
|
208
|
+
* @returns {OpenFile | null}
|
|
209
|
+
*/
|
|
210
|
+
function fileFor(session) {
|
|
211
|
+
if (!directory) return null;
|
|
212
|
+
const key = session.sessionId;
|
|
213
|
+
const film = filmOf(session);
|
|
214
|
+
const existing = open.get(key);
|
|
215
|
+
if (!existing) {
|
|
216
|
+
const prefix = `client-${stamp(session.startedAt)}-${safeName(session.sessionId, 8)}`;
|
|
217
|
+
return openFile(key, prefix, session);
|
|
218
|
+
}
|
|
219
|
+
if (film === "" || film === existing.film) return existing;
|
|
220
|
+
if (existing.film === "") {
|
|
221
|
+
const path = pathFor(existing.prefix, session);
|
|
222
|
+
try {
|
|
223
|
+
renameSync(existing.path, path);
|
|
224
|
+
existing.path = path;
|
|
225
|
+
} catch {
|
|
226
|
+
// The lines keep going to the file under its old name; a name that
|
|
227
|
+
// cannot change is better than a session's log lost to an exception.
|
|
228
|
+
}
|
|
229
|
+
// Set even when the rename failed, so it is not retried on every batch.
|
|
230
|
+
existing.film = film;
|
|
231
|
+
return existing;
|
|
232
|
+
}
|
|
233
|
+
open.delete(key);
|
|
234
|
+
finish(existing);
|
|
235
|
+
return openFile(key, existing.prefix, session);
|
|
236
|
+
}
|
|
237
|
+
|
|
131
238
|
return {
|
|
132
239
|
write(session, lines) {
|
|
133
240
|
const record = fileFor(session);
|
|
@@ -138,40 +245,38 @@ export function createClientLogFiles(proxyLogPath) {
|
|
|
138
245
|
record.stream.write(text);
|
|
139
246
|
}
|
|
140
247
|
if (record.bytes >= MAX_BYTES) {
|
|
248
|
+
// Renamed before it is ended: the stream writes through its own
|
|
249
|
+
// descriptor, so the lines still in flight follow the file to its new
|
|
250
|
+
// name, and the next batch opens a fresh file under the old one
|
|
251
|
+
// instead of appending to the file that is about to be moved.
|
|
141
252
|
try {
|
|
142
|
-
record.
|
|
143
|
-
try {
|
|
144
|
-
renameSync(record.path, `${record.path}.1`);
|
|
145
|
-
} catch {
|
|
146
|
-
// Renaming a file that has gone leaves nothing to do.
|
|
147
|
-
}
|
|
148
|
-
});
|
|
253
|
+
renameSync(record.path, `${record.path}.1`);
|
|
149
254
|
} catch {
|
|
150
|
-
// A rotation that cannot happen leaves the file growing
|
|
151
|
-
// better than losing the session's log
|
|
255
|
+
// A rotation that cannot happen leaves the file growing under its
|
|
256
|
+
// name, which is better than losing the session's log. It is tried
|
|
257
|
+
// again after as much again, not on every batch.
|
|
258
|
+
record.bytes = 0;
|
|
259
|
+
return;
|
|
152
260
|
}
|
|
153
|
-
open.delete(session.sessionId);
|
|
261
|
+
if (open.get(session.sessionId) === record) open.delete(session.sessionId);
|
|
262
|
+
finish(record);
|
|
154
263
|
}
|
|
155
264
|
},
|
|
156
265
|
/**
|
|
157
266
|
* Finish every open file.
|
|
158
267
|
*
|
|
159
|
-
* Awaitable, and that is not a convenience: a write stream
|
|
160
|
-
*
|
|
161
|
-
*
|
|
268
|
+
* Awaitable, and that is not a convenience: a write stream flushes
|
|
269
|
+
* asynchronously, so a process that ends without waiting loses the last
|
|
270
|
+
* lines of every session — which are the ones a crash is explained by.
|
|
271
|
+
* That includes the files already set aside by a rotation or by a change
|
|
272
|
+
* of film.
|
|
162
273
|
*
|
|
163
274
|
* @returns {Promise<void>}
|
|
164
275
|
*/
|
|
165
276
|
async close() {
|
|
166
|
-
const
|
|
167
|
-
try {
|
|
168
|
-
record.stream.end(resolve);
|
|
169
|
-
} catch {
|
|
170
|
-
resolve(undefined);
|
|
171
|
-
}
|
|
172
|
-
}));
|
|
277
|
+
for (const record of open.values()) finish(record);
|
|
173
278
|
open.clear();
|
|
174
|
-
await Promise.all(ending);
|
|
279
|
+
await Promise.all([...ending]);
|
|
175
280
|
}
|
|
176
281
|
};
|
|
177
282
|
}
|