@torrent-tv/proxy 2.80.17 → 2.80.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## 2.80.19
2
+
3
+ - **Fix**: HALF A SEGMENT WAS SERVED TO THE PLAYER, AND THE SESSION NEVER RECOVERED. A piece was taken as finished when the NEXT number existed. That is sound for one writer walking forward and false the moment two runs share an output — which is what the plan gives an output whenever it places a second encoder, and what a one-segment interval guarantees. Field 2026-09-08: `segment-00057.mp4` was served at 2 268 361 bytes and then at 4 510 940, exactly half; `segment-00055.mp4` at 211 957 and then 2 620 617. The browser appended the half, refused the whole one for the rest of the session and repeated `bufferAppendError` to the end of the log with the picture frozen at 319.66 s.
4
+ **A piece's NAME is the proof now, and there is no second one.** It is written as `making-<from>-00057.mp4` — tagged with the first number of its run's stretch — and takes `segment-00057.mp4` when the encoder says on its own channel that it has closed it, which is one rename inside one directory. The `hls` branch needed nothing added: its muxer already writes through a temporary name of its own. One rule for both branches, and true whether or not this process is alive.
5
+ - **Fix**: The last piece of every run is provable. Under the successor rule nothing followed it, so it never was — which is the resume case that held a segment for 46 s and then answered 404 to a browser that had given up.
6
+ - **Fix**: Clearing up after a dead run is a name match against that run's own tag: no stretch to search, no bytes to judge, and no way to remove a complete piece another run closed. `services/encode/open-piece.js` did all three by guessing, and under the naming rule its guess would have removed a finished segment — the highest served name in a dead run's stretch is a piece that run closed. It is deleted, together with the session manager's own copy of it, which fetched the output's init bytes to judge a file by its contents. 135 lines of service code and 152 of tests for the guessing are gone; the manager is 83 lines shorter and 5 longer.
7
+ - **Fix**: One owner for "this piece is closed". A set of statements was kept beside the disk and told by whoever noticed a piece being produced — the same two-owner fault 2.80.11 removed from the coverage map, one layer down. A rename cannot go out of step with itself.
8
+ - **New**: `encode-plan on <output>` carries every term the decision was made from: `[speed=4.45x firstByte=1.26s kill=0.04s refetch=0.000s/s maxRuns=2 live=1]`. An interval says what was decided; only these say why. A decision of this plan is `delay + (index - at) / rate + madeBetween * refetch` against a deadline, so a recorded decision without the rate can be re-read and not recomputed — which is exactly what happened with the one-piece intervals of 2026-09-08, where the rate was substituted six times from the speeds the session reported elsewhere and none of them gave the answer the plan had given. A zero in the last three is a measurement nobody has taken, not a free operation, and it is printed so that reading it as free is a choice.
9
+ - **Chore**: 965 checks pass, biome clean. NOT yet seen in the field. What the next session must show: `making-*` files present in an output's directory while it is being written and none left after it; no `bufferAppendError` following a segment served twice at different sizes; and the terms above beside every placement.
10
+
11
+ ## 2.80.18
12
+
13
+ - **Fix**: The `Infinity` is gone, and with it a double count I had introduced. The measured "time to a first piece" already CONTAINS one piece's encoding, and I was adding another; separated, the two scale differently — a piece costs more when encoders share the machine, a spawn does not. So a fresh encoder owes `spawn overhead + the piece at the rate in force`, a moved one owes the kill and then the same. With nothing measured the overhead is zero and a fresh encoder owes exactly one piece, which is the floor and is DERIVED rather than chosen: a piece cannot appear before it is encoded, and how fast this host encodes is measured before any viewer exists. The `Infinity` was an exception in a model that needs none. Verified by simulation over eighty ticks against the map's real shape — zero moves and zero one-piece intervals at a budget of one encoder and of three.
14
+ - **New**: `encode-plan on <output>: start #58..#481, stop #?..#?` — every action with its INTERVAL, which is what a run is, printed on any pass that does something. Its absence cost three wrong diagnoses of one field session: the line printed the windows, the budget and where the live runs stood, and not the intervals the actions carried.
15
+ - **Chore**: THE FIELD DIAGNOSIS OF 2026-09-08, corrected. What that session did was give every encoder an interval of exactly ONE segment — 63 runs, each spending 1.26 s reaching its first piece, making that one piece, reaching the end of its interval and exiting, twelve of them normally and thirteen killed. The reasons printed beside them read as moves back and forth, so I diagnosed an oscillating placement three times over and shipped 2.80.15 through 2.80.17 against it. Those releases stand on their own — the objective now follows the map's own rank order, the arrival arithmetic no longer charges a run for a piece it is already making, and the split point is derived — but none of them touches the caravan. Which line assigns an interval of one segment is not yet named: on a clean model it does not happen, three candidate explanations were checked and all three fell, and the line above is what will name it on the next session.
16
+
1
17
  ## 2.80.17
2
18
 
3
19
  - **Fix**: Where a second encoder joins a stretch is DERIVED, and whether it is worth having is asked at all. It was the midpoint of the widest unmade run, unconditionally. Both encoders close their halves at a time that rises with the split point for one and falls for the other, so the stretch is closed soonest where they cross: `x* = (from + to + 1)/2 + (d - w) * r / 2` — the midpoint, shifted forward by half the difference between what a fresh encoder owes and what the one already there owes, in pieces. Halving is the special case where both are fresh and owe the same.
@@ -242,12 +242,28 @@ piece costs — because only there is it known what the body IS, and at the poin
242
242
  of pricing only how many there are. So there is no kind, no tag and no case
243
243
  analysis.
244
244
 
245
- **What a move costs is `Infinity` until something has been measured**, because a
246
- move is irreversible while leaving the encoder alone is always available. Placing
247
- one where there is none takes the unknown the other way: the film gets made or it
248
- does not. And a run killed before producing anything is a measurement too — a
249
- lower bound on the first output, and the only reading a thrash can supply, since
250
- every run in one is killed before it finishes anything.
245
+ **The measured start is separated from the piece it contains**, because the two
246
+ scale differently: a piece costs more when encoders share the machine, a spawn
247
+ does not.
248
+
249
+ ```
250
+ spawn overhead = measured first output - what one piece costs alone
251
+ a fresh encoder = spawn overhead + the piece at the rate in force
252
+ a moved one = the kill, and then the same
253
+ ```
254
+
255
+ With nothing measured the overhead is zero and a fresh encoder owes exactly one
256
+ piece. **That is the floor, and it is derived rather than chosen:** a piece
257
+ cannot appear before it is encoded, and how fast this host encodes is measured
258
+ before any viewer exists. There was an `Infinity` here for the cost of a move,
259
+ on the reasoning that an unmeasured price must not license an irreversible act —
260
+ an exception in a model that needs none, and this is the same statement made by
261
+ arithmetic.
262
+
263
+ And a run killed before producing anything is a measurement too — a lower bound
264
+ on the first output, and the only reading a thrash can supply, since every run in
265
+ one is killed before it finishes anything. So a thrash makes its own moves
266
+ progressively dearer until it stops.
251
267
 
252
268
  ### What that was for
253
269
 
@@ -262,7 +278,7 @@ IMMEDIATELY and was the most urgent material in the file: it bought encoders, an
262
278
  it took the run standing in front of the viewer because that run was the nearest
263
279
  body to it.
264
280
 
265
- Checked by simulation over sixty ticks against the map's real shape — ten zones
281
+ Checked by simulation over eighty ticks against the map's real shape — ten zones
266
282
  doubling ahead of the viewer, one behind — at both one and three runs: the
267
283
  encoder is placed once, left alone, and moved exactly once, at the viewer's own
268
284
  seek.
@@ -301,6 +317,69 @@ penalty for a second encoder on the addon host is 1.98 — it takes very nearly
301
317
  all of the first's speed — and the objective keeps one; where a second is free it
302
318
  places three.
303
319
 
320
+ ## What the log says when anything is placed or taken away
321
+
322
+ ```
323
+ encode-plan on <output>: start #58..#481, stop #?..#?
324
+ [speed=4.45x firstByte=1.26s kill=0.04s refetch=0.000s/s maxRuns=2 live=1]
325
+ ```
326
+
327
+ Every action with its INTERVAL, which is what a run is, and then every term the
328
+ decision was made from. Printed on any pass that does something — a session
329
+ where nothing changes says nothing.
330
+
331
+ The terms are there because an interval says WHAT was decided and only these say
332
+ WHY. A decision of this plan is
333
+
334
+ delay + (index - at) / rate + madeBetween * refetch against a deadline
335
+
336
+ so a recorded decision without the rate can be re-read and not recomputed. That
337
+ is not hypothetical: the one-piece intervals of 2026-09-08 were diagnosed by
338
+ substituting the rate from the speeds the session reported elsewhere — six
339
+ different figures, none of which reproduced the answer the plan had given.
340
+
341
+ A zero in `firstByte`, `kill` or `refetch` is a measurement nobody has taken, not
342
+ a free operation. It is printed so that reading it as free is a choice.
343
+
344
+ It was missing, and its absence cost three wrong diagnoses of one field session.
345
+ The line printed the windows, the budget and where the live runs stood; the
346
+ intervals the actions carried were the one thing it did not print. What that
347
+ session actually did was give every encoder an interval of exactly ONE segment —
348
+ 63 runs, each spending 1.26 s reaching its first piece, making that one piece,
349
+ reaching the end of its interval and exiting, twelve of them normally. The
350
+ reasons printed beside them read as moves back and forth, so the fault was read
351
+ as an oscillating placement three times over. An interval of one segment turns
352
+ the protection against two encoders writing one name into a mill for processes.
353
+
354
+ ## What proves a segment is finished
355
+
356
+ Its NAME, and there is nothing else. A piece being written is called
357
+ `making-<from>-00042.mp4` — the tag is the first number of the stretch its run
358
+ was given — and it takes `segment-00042.mp4` when the encoder says it has closed
359
+ it, which it does on a channel of its own (`-segment_list pipe:3`). Making it
360
+ servable is therefore one rename inside one directory, performed by the store
361
+ because the store owns the disk. The `hls` branch needs nothing extra: its muxer
362
+ writes through a temporary name of its own, so its files appear under their final
363
+ name whole.
364
+
365
+ Three things follow, and each replaced a guess:
366
+
367
+ 1. **a request can never reach a half-written piece.** Closure used to be
368
+ inferred from the NEXT number existing — sound for one writer walking forward,
369
+ false the moment two runs share an output, which is what the plan gives an
370
+ output whenever it places a second encoder. Field 2026-09-08:
371
+ `segment-00057.mp4` served at 2 268 361 bytes and then at 4 510 940, exactly
372
+ half; the browser appended the half and refused the whole for the rest of the
373
+ session, with the picture frozen at 319.66 s;
374
+ 2. **the last piece of a run is provable.** Under the successor rule nothing
375
+ followed it, so it never was — the resume case that held one segment for 46 s
376
+ and then answered 404;
377
+ 3. **clearing up after a dead run is a name match.** Its unfinished pieces are
378
+ the ones carrying its own tag: no stretch to search, no bytes to judge, and no
379
+ way to remove a complete piece somebody else closed. `services/encode/
380
+ open-piece.js` did all three of those by guessing and is gone, along with the
381
+ session manager's copy of it.
382
+
304
383
  ## What is checked
305
384
 
306
385
  `test/one-authority.test.js` holds the shape: one caller of `#startEncodeRun`,
@@ -313,3 +392,8 @@ viewer registry, the real `LiveOutputs` and the real `PriorityOrchestrator`.
313
392
 
314
393
  `test/encode-plan.test.js` holds the arithmetic, including that every encoder
315
394
  stops when nobody is watching the output.
395
+
396
+ `test/segment-store.test.js` holds the naming rule: a piece under its served
397
+ name is finished — the last one of a run included — one under a working name is
398
+ not and cannot be reached, closing it is one rename, and clearing up after one
399
+ run leaves every other run's work alone.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.80.17",
3
+ "version": "2.80.19",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -102,9 +102,6 @@
102
102
  * measured on this host from its own runs. Zero until something has measured
103
103
  * it, which makes moving one look cheaper than it is and is said here so the
104
104
  * bias is known.
105
- * @param {number} [params.moveCostSec] - What moving a running encoder costs on
106
- * this host, measured. `Infinity` until something has been measured, because a
107
- * move is irreversible and leaving the encoder alone is always available.
108
105
  * @param {number} [params.now] - The clock, injected. This layer is arithmetic
109
106
  * and reads no clock of its own; how old a run is is one of its inputs.
110
107
  * @param {number} [params.firstByteWaitSec] - How long a fresh encoder takes to
@@ -128,7 +125,6 @@ export function planEncoders({
128
125
  segmentSeconds,
129
126
  killCostSec = 0,
130
127
  firstByteWaitSec = 0,
131
- moveCostSec = Number.POSITIVE_INFINITY,
132
128
  now = Date.now(),
133
129
  refetchSecPerFilmSecond = 0,
134
130
  contentionPenaltyFor = () => 1,
@@ -185,12 +181,26 @@ export function planEncoders({
185
181
  // its death, the start of another, and the wait for the first bytes there.
186
182
  // Taking an encoder somewhere else is stopping this one and waiting for the
187
183
  // next to produce. Both halves are measured on this host.
188
- // WHAT A MOVE COSTS. Killing an encoder and waiting for a fresh one's first
189
- // piece is the price; until something has produced anything on this host that
190
- // price is unknown, and a move is then refused rather than priced at zero.
191
- // Placing one where there is none is the other question and takes the unknown
192
- // the other way see `run-costs.js`.
193
- const moveSec = Number.isFinite(moveCostSec) ? moveCostSec : killCostSec + firstByteWaitSec;
184
+ // WHAT A START AND A MOVE OWE BEFORE THE PIECE THEY STAND ON EXISTS, and
185
+ // neither is infinite.
186
+ //
187
+ // The measured `firstByteWaitSec` is spawn to first piece, so it already
188
+ // contains one piece's encoding. Separated, because the two scale differently:
189
+ // the piece costs more when encoders share the machine, the spawn does not.
190
+ //
191
+ // spawn overhead = measured first output - what one piece costs alone
192
+ // a fresh encoder owes spawn overhead + the piece at the rate in force
193
+ // a moved one owes the kill, and then the same
194
+ //
195
+ // With nothing measured the overhead is zero and a fresh encoder owes exactly
196
+ // one piece — which is the honest floor rather than a guess: a piece cannot
197
+ // appear before it is encoded, and how fast this host encodes is measured
198
+ // before any viewer exists. There was an `Infinity` here for the cost of a
199
+ // move, on the reasoning that an unmeasured price must not license an
200
+ // irreversible act; it was an exception in a model that needs none, and this
201
+ // is the same statement made by arithmetic.
202
+ const spawnOverheadSec = Math.max(0, firstByteWaitSec - (rate > 0 ? 1 / rate : 0));
203
+ const moveSec = killCostSec + spawnOverheadSec;
194
204
 
195
205
  // WHAT A RUN STILL HAS TO GO BEFORE IT PRODUCES ANYTHING — the measured time
196
206
  // to a first piece, less the time it has already been alive.
@@ -393,7 +403,7 @@ export function planEncoders({
393
403
  }
394
404
  if (filler === "new") {
395
405
  // A encoder that does not exist yet owes its own start and then the piece.
396
- encoders.push({ at: positions[index], owes: (piece) => firstByteWaitSec + piece });
406
+ encoders.push({ at: positions[index], owes: (piece) => spawnOverheadSec + piece });
397
407
  continue;
398
408
  }
399
409
  const head = Number(filler.head);
@@ -472,7 +482,7 @@ export function planEncoders({
472
482
  }
473
483
  for (let index = 0; index < positions.length; index += 1) {
474
484
  if ((best ? best.fill[index] : null) === "new") {
475
- encoders.push({ at: positions[index], owes: (piece) => firstByteWaitSec + piece });
485
+ encoders.push({ at: positions[index], owes: (piece) => spawnOverheadSec + piece });
476
486
  }
477
487
  }
478
488
  return priced(encoders);
@@ -181,9 +181,11 @@ export class EncodeRun {
181
181
  * @param {{ info: (line: string) => void, warn: (line: string) => void, error?: (line: string) => void }} params.logger
182
182
  * @param {() => number} [params.now]
183
183
  * @param {(ended: RunEnded) => void} [params.onEnded]
184
- * @param {(name: string) => void} [params.onClosed] - Called with the file name
185
- * of every piece the encoder has FINISHED writing, as the encoder itself
186
- * names it on its own channel.
184
+ * @param {(name: string) => string | null} [params.onClosed] - Called with the
185
+ * WORKING name of every piece the encoder has finished writing, as the
186
+ * encoder itself names it on its own channel, and answers with the name that
187
+ * piece is served under — because making it servable is a rename, and only
188
+ * whoever owns the disk can perform one.
187
189
  * @param {(progress: { processedSeconds: number | null, speed: string | null }) => void} [params.onProgress]
188
190
  * Called for every `-progress` report. Seconds count from the START OF THIS
189
191
  * RUN on both branches — neither `-output_ts_offset` nor `-copyts` changes
@@ -473,8 +475,13 @@ export class EncodeRun {
473
475
  if (name.length === 0) {
474
476
  continue;
475
477
  }
478
+ // ITS SERVED NAME, which is what whoever owns the disk gives it in answer.
479
+ // ffmpeg writes a piece under a working name and reports that; the piece
480
+ // becomes servable by being renamed, and everything below works in the
481
+ // name a request can actually ask for.
482
+ const served = this.onClosed(name) ?? name;
476
483
  if (!this.#stopping) {
477
- this.#provenName = name;
484
+ this.#provenName = served;
478
485
  }
479
486
  // WHAT THIS RUN HAS MADE IS THIS RUN'S OWN FACT, and this channel is where
480
487
  // it learns it. It used to be told from outside, by whoever listed the
@@ -484,11 +491,10 @@ export class EncodeRun {
484
491
  // (483 segment(s))", having produced none of them, and its head therefore
485
492
  // described somebody else's work. Both the claim it holds and the cleanup
486
493
  // after it read that head.
487
- const index = this.indexOfName(name);
494
+ const index = this.indexOfName(served);
488
495
  if (Number.isInteger(index)) {
489
496
  this.noteProduced(index);
490
497
  }
491
- this.onClosed(name);
492
498
  }
493
499
  }
494
500