@torrent-tv/proxy 2.89.6 → 2.89.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.
Files changed (92) hide show
  1. package/AGENTS.md +32 -12
  2. package/CHANGELOG.md +2340 -2330
  3. package/assets/calibration/audio/NOTICE.md +19 -19
  4. package/assets/calibration/audio/manifest.json +98 -98
  5. package/biome.json +412 -412
  6. package/docs/browser-proxy-contract.md +155 -155
  7. package/docs/container-architecture.md +273 -273
  8. package/docs/encode-run-state.md +1 -1
  9. package/docs/playback-start-forecast.md +113 -98
  10. package/package.json +51 -50
  11. package/routes/api/sources/files/get.js +106 -106
  12. package/scripts/build-audio-samples.mjs +31 -31
  13. package/services/encode/EncodeRun.js +67 -15
  14. package/services/encode/EncodeRuns.js +19 -7
  15. package/services/encode/OutputOpening.js +784 -784
  16. package/services/encode/audio-calibration.js +67 -67
  17. package/services/encode/audio-work-rate.js +15 -15
  18. package/services/encode/encode-exit.js +2 -0
  19. package/services/encode/hwaccel.js +1894 -1894
  20. package/services/encode/output/index.js +24 -24
  21. package/services/encode/piece-completeness.js +60 -2
  22. package/services/encode/run-command.js +757 -755
  23. package/services/encode/segment-formats/index.js +1 -0
  24. package/services/encode/segment-formats/mp4-boxes.js +8 -1
  25. package/services/media/ContainerOrchestrator.js +202 -202
  26. package/services/media/KeyframeTables.js +356 -356
  27. package/services/media/SubtitleCues.js +592 -592
  28. package/services/media/audio-inventory.js +299 -299
  29. package/services/media/container/AviContainer.js +268 -268
  30. package/services/media/container/Container.js +632 -632
  31. package/services/media/container/ContainerFactory.js +38 -38
  32. package/services/media/container/MatroskaContainer.js +1031 -1031
  33. package/services/media/container/Mp4Container.js +1354 -1354
  34. package/services/media/container/SubtitleFileContainer.js +339 -339
  35. package/services/media/container/ebml-stream.js +174 -174
  36. package/services/media/container/matroska-clusters.js +1165 -1165
  37. package/services/media/container/unavailable.js +77 -77
  38. package/services/media/tracks/ContainerTrack.js +84 -84
  39. package/services/media/tracks/TextSubtitleTrack.js +295 -295
  40. package/services/storage/piece-store/shared-piece-store.js +1937 -1937
  41. package/services/storage/segment-store/SegmentStore.js +1294 -1294
  42. package/services/torrent/Contents.js +382 -382
  43. package/services/torrent/episode-naming.js +182 -182
  44. package/services/torrent/torrent-pool.js +2688 -2688
  45. package/services/torrent/worker/client.js +836 -836
  46. package/services/torrent/worker/held-bytes.js +231 -231
  47. package/services/torrent/worker/worker.js +1137 -1137
  48. package/services/transport/data-channel-handler.js +1373 -1373
  49. package/services/viewer/PriorityMap.js +421 -421
  50. package/test/audio-calibration.test.js +23 -23
  51. package/test/auto-quality-step.test.js +762 -762
  52. package/test/coalesce.test.js +39 -39
  53. package/test/coverage-follows-the-disk.test.js +5 -0
  54. package/test/decode-cost.test.js +475 -475
  55. package/test/encode-run.test.js +58 -0
  56. package/test/episode-naming.test.js +131 -131
  57. package/test/failed-encoder-still-serves-what-exists.test.js +1 -1
  58. package/test/fixtures/audio-frame-boundary.json +1 -1
  59. package/test/fixtures/refused-audio-20261002.json +10 -0
  60. package/test/fmp4-presentation-clock.test.js +4 -2
  61. package/test/format-keyed-outputs.test.js +78 -78
  62. package/test/health-metrics.test.js +11 -13
  63. package/test/held-read-ownership.test.js +77 -77
  64. package/test/helpers/encode-run.js +14 -6
  65. package/test/helpers/held-file.js +77 -77
  66. package/test/helpers/manager.js +62 -62
  67. package/test/helpers/matroska-file.js +296 -296
  68. package/test/keyframe-table-budget.test.js +123 -123
  69. package/test/matroska-cluster-discovery.test.js +323 -323
  70. package/test/one-output-one-session.test.js +273 -273
  71. package/test/output-speed.test.js +6 -1
  72. package/test/piece-completeness.test.js +37 -1
  73. package/test/quality-variants.test.js +1274 -1270
  74. package/test/rate-control-in-the-output.test.js +4 -3
  75. package/test/reads-not-yet-arrived.test.js +218 -218
  76. package/test/seek-frees-the-viewers-requests.test.js +1 -1
  77. package/test/segment-closed-on-both-branches.test.js +114 -114
  78. package/test/segment-judged-by-its-own-header.test.js +222 -222
  79. package/test/segment-serve-wiring.test.js +481 -481
  80. package/test/serving-existing-output.test.js +111 -111
  81. package/test/source-files-route.test.js +113 -113
  82. package/test/subtitle-cue-walk.test.js +385 -385
  83. package/test/subtitle-metadata.test.js +15 -15
  84. package/test/subtitle-plan-waits.test.js +125 -125
  85. package/test/subtitle-track-numbering.test.js +391 -391
  86. package/test/two-viewers-one-picture.test.js +508 -508
  87. package/test/viewer-requests-admission.test.js +92 -92
  88. package/test/viewer-starts-on-map.test.js +27 -27
  89. package/test/viewer-states.test.js +5 -2
  90. package/test/viewing-generations.test.js +390 -390
  91. package/test/whole-file-held-read.test.js +30 -30
  92. package/utils/coalesce.js +41 -41
@@ -1,98 +1,113 @@
1
- # Playback start forecast
2
-
3
- For resource r, measured service is S_r(t) = rate_r * t. New observations
4
- replace the conditional future rate; polling cannot invent acceleration,
5
- exponential recovery or a new physical measurement.
6
-
7
- Source work uses the file's measured byte density, missing local byte ranges
8
- and the observed torrent download rate. Shared input ranges are downloaded
9
- once. Files on the same torrent share a queue; distinct torrents use distinct
10
- observed services. Local input reading is included in effective processing
11
- speed, rather than charged again as a separate disk or memory delay.
12
-
13
- Each output's remaining processing work subtracts progress only within its
14
- actual live run intervals. Initial video rates use existing codec/size/encoder
15
- calibration. Initial audio rates use short packaged per-codec measurements and
16
- sample work (encoding) or encoded bytes (copying). Actual output progress
17
- replaces these initial rates. That rate is the run's processing speed: film
18
- made over the run's own working time, with the time its input waited for the
19
- swarm and the time it was stopped taken out (`RunClock`, fed by the stream
20
- route, which marks each wait of a run's input read). The download is charged
21
- once, by the source service, and not a second time inside the processing rate. Startup is charged once, separately from processing
22
- throughput, and disappears once actual run progress establishes processing.
23
-
24
- Input arrival and processing overlap. With arrival time D(m), processing rate
25
- e and previous completion C, completion for an interval [a,b] is
26
-
27
- max(C + (b-a)/e, sup_m(D(m) + (b-m)/e)).
28
-
29
- Between service boundaries these functions are linear, so only endpoints are
30
- needed. Client delivery follows production and shares the observed link queue.
31
- Measured client ranges and prepared media establish each track's deadlines.
32
-
33
- The minimum start delay for this conditional trajectory is
34
-
35
- max(0, max_j(completion_j - presentation_deadline_j)).
36
-
37
- This covers the remaining finite movie, including rates below real time. Slow
38
- positive service gives a finite preproduction delay; it is not rejected by a
39
- browser-capacity threshold or a capped search. Observed supply-interruption
40
- coverage is held in prepared proxy media and client media, without requiring
41
- the browser to hold it all.
42
-
43
- Critical review: future rates can change, average byte density is an initial
44
- mapping rather than an exact packet index, initial audio sample-work scaling
45
- does not model every decoder's nonlinear cost, and client receive/append time
46
- is not separately measured. These are approximation limits, not hidden weights.
47
- Actual progress, segment sizes and link observations refine the prediction.
48
- The client countdown uses elapsed local monotonic time; expiration cannot
49
- authorize playback. Only the proxy's readiness and real track coverage can.
50
-
51
- Zero service, missing media facts and a genuine timestamp gap remain explicit
52
- noncomputable states. The UI reports their causes instead of an estimating
53
- placeholder or a fabricated duration.
54
-
55
- ## Continuity is decided in exact time
56
-
57
- The forecast lives in the viewer component (`services/viewer/`), because what
58
- it predicts is how the viewer's browser will play; the request operation
59
- (`ViewerRequests`) is handed it by `wire-outputs.js`.
60
-
61
- 1. **The file states integers.** A piece's media is read as presentation
62
- intervals in the integer ticks of each track's timescale
63
- (`readPresentationRanges`, `services/encode/segment-formats/mp4-boxes.js`).
64
- Intervals are joined there only where they touch or overlap; the format
65
- states what the samples say and nothing about browsers.
66
- 2. **One function places a piece.** The position written into `tfdt` when a
67
- piece is served and the position its coverage is read at both come from
68
- `readTrackEdits`, which converts the movie-timescale edit into track ticks
69
- with one stated rounding. The reader and the writer cannot disagree.
70
- 3. **Seconds from the page are exact fractions.** A buffered range, a position
71
- or a cut time arrives as a JavaScript number, which is a binary fraction;
72
- `services/viewer/media-time.js` converts it to that fraction exactly. No unit
73
- is chosen. A buffered range is widened by `REPORTED_TIME_ERROR`, the bound of
74
- Chromium's three truncations to whole microseconds, so that it denotes every
75
- exact time the report can stand for.
76
- 4. **The join rule holds in every engine.** The Media Source specification
77
- leaves the threshold to the implementation, so a gap counts as joined only
78
- when Chromium, Gecko and WebKit all join it: twice the gap within the two
79
- ranges' longest frames since a keyframe (Gecko's fuzz), and the gap within
80
- 2002/24000 s (WebKit's `timeFudgeFactor`). Chromium's own bound follows from
81
- the first. Old iOS without Media Source plays HLS in a closed player, and
82
- nothing is derived for it.
83
-
84
- Seconds appear again only for rates, schedules and the answer.
85
-
86
- ## A piece is finished when its frames reach its cut
87
-
88
- A closed non-final fragment is reusable only if the end of its last frame, on
89
- every track, reaches its next declared cut less `SEGMENT_CUT_TIME_DELTA_SECONDS`,
90
- the delta the muxer is configured with. That is a fact of production, so no
91
- browser rule enters it, and the encoding decides it
92
- (`encode/piece-completeness.js`): `EncodeRuns` judges a closed file before it is
93
- published, and judges a stored piece left by an earlier process when the
94
- forecast asks for it, taking a short one off the disk. The segment store keeps
95
- the bytes and the media intervals read from them and judges nothing. Interrupted
96
- fragments are therefore never served or counted, so the production schedule
97
- treats their media as unfinished work. The final fragment uses its actual media
98
- end rather than the container's approximate duration.
1
+ # Playback start forecast
2
+
3
+ For resource r, measured service is S_r(t) = rate_r * t. New observations
4
+ replace the conditional future rate; polling cannot invent acceleration,
5
+ exponential recovery or a new physical measurement.
6
+
7
+ Source work uses the file's measured byte density, missing local byte ranges
8
+ and the observed torrent download rate. Shared input ranges are downloaded
9
+ once. Files on the same torrent share a queue; distinct torrents use distinct
10
+ observed services. Local input reading is included in effective processing
11
+ speed, rather than charged again as a separate disk or memory delay.
12
+
13
+ Each output's remaining processing work subtracts progress only within its
14
+ actual live run intervals. Initial video rates use existing codec/size/encoder
15
+ calibration. Initial audio rates use short packaged per-codec measurements and
16
+ sample work (encoding) or encoded bytes (copying). Actual output progress
17
+ replaces these initial rates. That rate is the run's processing speed: film
18
+ made over the run's own working time, with the time its input waited for the
19
+ swarm and the time it was stopped taken out (`RunClock`, fed by the stream
20
+ route, which marks each wait of a run's input read). The download is charged
21
+ once, by the source service, and not a second time inside the processing rate. Startup is charged once, separately from processing
22
+ throughput, and disappears once actual run progress establishes processing.
23
+
24
+ Input arrival and processing overlap. With arrival time D(m), processing rate
25
+ e and previous completion C, completion for an interval [a,b] is
26
+
27
+ max(C + (b-a)/e, sup_m(D(m) + (b-m)/e)).
28
+
29
+ Between service boundaries these functions are linear, so only endpoints are
30
+ needed. Client delivery follows production and shares the observed link queue.
31
+ Measured client ranges and prepared media establish each track's deadlines.
32
+
33
+ The minimum start delay for this conditional trajectory is
34
+
35
+ max(0, max_j(completion_j - presentation_deadline_j)).
36
+
37
+ This covers the remaining finite movie, including rates below real time. Slow
38
+ positive service gives a finite preproduction delay; it is not rejected by a
39
+ browser-capacity threshold or a capped search. Observed supply-interruption
40
+ coverage is held in prepared proxy media and client media, without requiring
41
+ the browser to hold it all.
42
+
43
+ Critical review: future rates can change, average byte density is an initial
44
+ mapping rather than an exact packet index, initial audio sample-work scaling
45
+ does not model every decoder's nonlinear cost, and client receive/append time
46
+ is not separately measured. These are approximation limits, not hidden weights.
47
+ Actual progress, segment sizes and link observations refine the prediction.
48
+ The client countdown uses elapsed local monotonic time; expiration cannot
49
+ authorize playback. Only the proxy's readiness and real track coverage can.
50
+
51
+ Zero service, missing media facts and a genuine timestamp gap remain explicit
52
+ noncomputable states. The UI reports their causes instead of an estimating
53
+ placeholder or a fabricated duration.
54
+
55
+ ## Continuity is decided in exact time
56
+
57
+ The forecast lives in the viewer component (`services/viewer/`), because what
58
+ it predicts is how the viewer's browser will play; the request operation
59
+ (`ViewerRequests`) is handed it by `wire-outputs.js`.
60
+
61
+ 1. **The file states integers.** A piece's media is read as presentation
62
+ intervals in the integer ticks of each track's timescale
63
+ (`readPresentationRanges`, `services/encode/segment-formats/mp4-boxes.js`).
64
+ Intervals are joined there only where they touch or overlap; the format
65
+ states what the samples say and nothing about browsers.
66
+ 2. **One function places a piece.** The position written into `tfdt` when a
67
+ piece is served and the position its coverage is read at both come from
68
+ `readTrackEdits`, which converts the movie-timescale edit into track ticks
69
+ with one stated rounding. The reader and the writer cannot disagree.
70
+ 3. **Seconds from the page are exact fractions.** A buffered range, a position
71
+ or a cut time arrives as a JavaScript number, which is a binary fraction;
72
+ `services/viewer/media-time.js` converts it to that fraction exactly. No unit
73
+ is chosen. A buffered range is widened by `REPORTED_TIME_ERROR`, the bound of
74
+ Chromium's three truncations to whole microseconds, so that it denotes every
75
+ exact time the report can stand for.
76
+ 4. **The join rule holds in every engine.** The Media Source specification
77
+ leaves the threshold to the implementation, so a gap counts as joined only
78
+ when Chromium, Gecko and WebKit all join it: twice the gap within the two
79
+ ranges' longest frames since a keyframe (Gecko's fuzz), and the gap within
80
+ 2002/24000 s (WebKit's `timeFudgeFactor`). Chromium's own bound follows from
81
+ the first. Old iOS without Media Source plays HLS in a closed player, and
82
+ nothing is derived for it.
83
+
84
+ Seconds appear again only for rates, schedules and the answer.
85
+
86
+ ## A piece is finished when its frames reach its cut
87
+
88
+ A closed non-final fragment is reusable only if the end of its last frame, on
89
+ every track, reaches its muxer's cut less `SEGMENT_CUT_TIME_DELTA_SECONDS`,
90
+ the delta the muxer is configured with. That is a fact of production, so no
91
+ browser frame allowance is used to decide completeness. For explicit cuts the
92
+ closure channel is CSV: its reference packet end has not lost the fraction of
93
+ a movie tick that MP4's empty edit loses. The first reference packet's time is
94
+ recovered from that end minus the first piece's exact sample span (the first
95
+ CSV start is zero even after a seek), and is added to the relative cut times
96
+ handed to FFmpeg. Comparisons use integer microseconds, as FFmpeg's segment
97
+ muxer does. A stored piece without this closure reading carries the upper
98
+ uncertainty of its empty edit, read from the movie and track timescales.
99
+
100
+ The last frame must still reach the cut on every track. No playable media, a
101
+ missing full frame, or a failed rename remains a publication failure. Such a
102
+ failure stops the run, records that cause and releases its claimed interval;
103
+ the existing repeated-start failure limit applies to subsequent attempts. A
104
+ refused file cannot stay claimed while the encoder finishes the whole film.
105
+
106
+ The encoding decides completeness
107
+ (`encode/piece-completeness.js`): `EncodeRuns` judges a closed file before it is
108
+ published, and judges a stored piece left by an earlier process when the
109
+ forecast asks for it, taking a short one off the disk. The segment store keeps
110
+ the bytes and the media intervals read from them and judges nothing. Interrupted
111
+ fragments are therefore never served or counted, so the production schedule
112
+ treats their media as unfinished work. The final fragment uses its actual media
113
+ end rather than the container's approximate duration.
package/package.json CHANGED
@@ -1,50 +1,51 @@
1
- {
2
- "name": "@torrent-tv/proxy",
3
- "version": "2.89.6",
4
- "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
- "license": "GPL-3.0-or-later",
6
- "publishConfig": {
7
- "access": "public"
8
- },
9
- "type": "module",
10
- "engines": {
11
- "node": ">=24"
12
- },
13
- "bin": {
14
- "torrent-tv-proxy": "./bin/cli.js"
15
- },
16
- "scripts": {
17
- "patch": "npm whoami && npm version patch && npm publish && git push --follow-tags",
18
- "minor": "npm whoami && npm version minor && npm publish && git push --follow-tags",
19
- "major": "npm whoami && npm version major && npm publish && git push --follow-tags",
20
- "start": "node ./bin/cli.js",
21
- "dev": "node --inspect=0 --experimental-network-inspection ./bin/cli.js",
22
- "test": "npm run lint && node --test",
23
- "graph": "node scripts/render-run-graph.js",
24
- "lint": "biome lint ."
25
- },
26
- "dependencies": {
27
- "@fastify/cors": "^11.2.0",
28
- "@fastify/helmet": "^13.0.2",
29
- "@fastify/static": "^10.1.2",
30
- "@silentbot1/nat-api": "^0.4.9",
31
- "chalk": "^5.4.1",
32
- "commander": "^12.1.0",
33
- "fastify": "^5.8.5",
34
- "ffmpeg-static": "^5.3.0",
35
- "franc": "^6.2.0",
36
- "get-port": "^7.1.0",
37
- "node-datachannel": "^0.32.0",
38
- "parse-torrent": "^11.0.23",
39
- "uint8-util": "2.2.6",
40
- "webtorrent": "2.8.5",
41
- "werift": "^0.24.2",
42
- "ws": "^8.18.2"
43
- },
44
- "devDependencies": {
45
- "@biomejs/biome": "^2.5.7"
46
- },
47
- "overrides": {
48
- "utp-native": "npm:@torrent-tv/utp-native@2.5.3-ttv.8"
49
- }
50
- }
1
+ {
2
+ "name": "@torrent-tv/proxy",
3
+ "version": "2.89.8",
4
+ "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
+ "license": "GPL-3.0-or-later",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/torrent-tv/proxy.git"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "type": "module",
14
+ "engines": {
15
+ "node": ">=24"
16
+ },
17
+ "bin": {
18
+ "torrent-tv-proxy": "bin/cli.js"
19
+ },
20
+ "scripts": {
21
+ "start": "node ./bin/cli.js",
22
+ "dev": "node --inspect=0 --experimental-network-inspection ./bin/cli.js",
23
+ "test": "npm run lint && node --test",
24
+ "graph": "node scripts/render-run-graph.js",
25
+ "lint": "biome lint ."
26
+ },
27
+ "dependencies": {
28
+ "@fastify/cors": "^11.2.0",
29
+ "@fastify/helmet": "^13.0.2",
30
+ "@fastify/static": "^10.1.2",
31
+ "@silentbot1/nat-api": "^0.4.9",
32
+ "chalk": "^5.4.1",
33
+ "commander": "^12.1.0",
34
+ "fastify": "^5.8.5",
35
+ "ffmpeg-static": "^5.3.0",
36
+ "franc": "^6.2.0",
37
+ "get-port": "^7.1.0",
38
+ "node-datachannel": "^0.32.0",
39
+ "parse-torrent": "^11.0.23",
40
+ "uint8-util": "2.2.6",
41
+ "webtorrent": "2.8.5",
42
+ "werift": "^0.24.2",
43
+ "ws": "^8.18.2"
44
+ },
45
+ "devDependencies": {
46
+ "@biomejs/biome": "^2.5.7"
47
+ },
48
+ "overrides": {
49
+ "utp-native": "npm:@torrent-tv/utp-native@2.5.3-ttv.8"
50
+ }
51
+ }
@@ -1,106 +1,106 @@
1
- import { contentsOf } from "../../../../services/torrent/Contents.js";
2
-
3
- /**
4
- * List the files of a registered source (torrent file OR magnet).
5
- *
6
- * GET /api/sources/:sourceKey/files
7
- *
8
- * The browser parses `.torrent` files locally, but a magnet URI carries no
9
- * file list — the metadata comes from the swarm and can take a while to
10
- * arrive on a cold magnet. Rather than block the request until it does (a
11
- * single long request racing the transport timeout, which surfaced as a
12
- * premature "no peers" error while the metadata was in fact still arriving),
13
- * this waits only a short per-request budget: if the metadata is not ready it
14
- * returns `{ pending: true }` while the fetch continues in the background. The
15
- * caller polls again until the file list comes back — mirroring the cold-
16
- * torrent playback-plan poll.
17
- *
18
- * @param {import("fastify").FastifyRequest} req
19
- * @param {import("fastify").FastifyReply} reply
20
- * @param {{
21
- * sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
22
- * torrentPool: import("../../../../services/torrent/torrent-pool.js").TorrentPool
23
- * }} deps
24
- * @returns {Promise<void>}
25
- */
26
-
27
- /** Sentinel resolved when the per-request wait elapses before metadata. */
28
- const PENDING = Symbol("pending");
29
-
30
- export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool }) {
31
- const sourceKey = typeof req.params?.sourceKey === "string" ? req.params.sourceKey.trim() : "";
32
- if (!sourceKey) {
33
- return reply.code(400).send({ error: "sourceKey is required." });
34
- }
35
- const sourceRecord = sourceRegistry.get(sourceKey);
36
- if (!sourceRecord) {
37
- return reply.code(404).send({ error: "Source key was not found." });
38
- }
39
-
40
- // How long to wait within THIS request before returning `pending`. Well
41
- // under the transport's request timeout so a single poll never races it.
42
- const rawWait = Number(req.query?.maxWaitMs);
43
- const maxWaitMs = Number.isFinite(rawWait) && rawWait > 0 ? Math.min(rawWait, 20_000) : 8_000;
44
-
45
- // getTorrent dedupes concurrent/repeated calls via the pool's in-flight map,
46
- // so polling keeps joining the same background fetch (metadata keeps
47
- // downloading between polls). Race it against the wait budget; if the wait
48
- // wins, the fetch is left running for the next poll to observe.
49
- let timer;
50
- const waitPromise = new Promise((resolve) => {
51
- timer = setTimeout(() => resolve(PENDING), maxWaitMs);
52
- timer.unref?.();
53
- });
54
- const torrentPromise = torrentPool
55
- .getTorrent(sourceRecord.sourceType, sourceRecord.source)
56
- // Swallow so a rejection that loses the race is not an unhandled rejection;
57
- // the next poll re-issues getTorrent and re-observes any real error.
58
- .catch((error) => (error instanceof Error ? error : new Error(String(error))));
59
-
60
- let result;
61
- try {
62
- result = await Promise.race([torrentPromise, waitPromise]);
63
- } finally {
64
- clearTimeout(timer);
65
- }
66
-
67
- if (result === PENDING) {
68
- return reply.send({ pending: true });
69
- }
70
- if (result instanceof Error) {
71
- return reply.code(502).send({ error: `Could not load torrent metadata: ${result.message}` });
72
- }
73
-
74
- const torrent = result;
75
- // WHAT IS IN THIS TORRENT, decided here and nowhere else. The browser used to
76
- // decide it again — a list of video extensions in its parser and a second,
77
- // shorter pair inside its picker — and the three answers had already diverged
78
- // (measured 2026-09-12: `.dat` was offered there and not counted here, which
79
- // also decides whether a sidecar whose name matches nothing can belong to the
80
- // only video present). It ships the paths already relative to the torrent
81
- // root, so there is no stripping rule on the other side either.
82
- const contents = contentsOf(torrent);
83
- return reply.send({
84
- name: torrent.name ?? "",
85
- infoHash: torrent.infoHash ?? "",
86
- // In the order a person reads them — by folder, then by name, with runs of
87
- // digits compared as numbers. A torrent's own order is whatever the tool
88
- // that made it chose, and it is routinely by size.
89
- files: contents.files(),
90
- // The pictures, each with what belongs to it. By index, because the files
91
- // themselves are in the list above and saying them twice is how two copies
92
- // of one fact start.
93
- items: contents.items.map((item) => ({
94
- fileIndex: item.fileIndex,
95
- audio: item.audio.map((part) => part.fileIndex),
96
- subtitles: item.subtitles.map((part) => part.fileIndex),
97
- images: item.images.map((part) => part.fileIndex),
98
- // What the name says about which episode this is, in the release's own
99
- // numbering — read by the page to ask which episode of the show it is.
100
- episode: item.episode
101
- })),
102
- // One film, a series, or not known — whether one identification of the
103
- // whole release applies to its pictures at all.
104
- shape: contents.shape
105
- });
106
- }
1
+ import { contentsOf } from "../../../../services/torrent/Contents.js";
2
+
3
+ /**
4
+ * List the files of a registered source (torrent file OR magnet).
5
+ *
6
+ * GET /api/sources/:sourceKey/files
7
+ *
8
+ * The browser parses `.torrent` files locally, but a magnet URI carries no
9
+ * file list — the metadata comes from the swarm and can take a while to
10
+ * arrive on a cold magnet. Rather than block the request until it does (a
11
+ * single long request racing the transport timeout, which surfaced as a
12
+ * premature "no peers" error while the metadata was in fact still arriving),
13
+ * this waits only a short per-request budget: if the metadata is not ready it
14
+ * returns `{ pending: true }` while the fetch continues in the background. The
15
+ * caller polls again until the file list comes back — mirroring the cold-
16
+ * torrent playback-plan poll.
17
+ *
18
+ * @param {import("fastify").FastifyRequest} req
19
+ * @param {import("fastify").FastifyReply} reply
20
+ * @param {{
21
+ * sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
22
+ * torrentPool: import("../../../../services/torrent/torrent-pool.js").TorrentPool
23
+ * }} deps
24
+ * @returns {Promise<void>}
25
+ */
26
+
27
+ /** Sentinel resolved when the per-request wait elapses before metadata. */
28
+ const PENDING = Symbol("pending");
29
+
30
+ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool }) {
31
+ const sourceKey = typeof req.params?.sourceKey === "string" ? req.params.sourceKey.trim() : "";
32
+ if (!sourceKey) {
33
+ return reply.code(400).send({ error: "sourceKey is required." });
34
+ }
35
+ const sourceRecord = sourceRegistry.get(sourceKey);
36
+ if (!sourceRecord) {
37
+ return reply.code(404).send({ error: "Source key was not found." });
38
+ }
39
+
40
+ // How long to wait within THIS request before returning `pending`. Well
41
+ // under the transport's request timeout so a single poll never races it.
42
+ const rawWait = Number(req.query?.maxWaitMs);
43
+ const maxWaitMs = Number.isFinite(rawWait) && rawWait > 0 ? Math.min(rawWait, 20_000) : 8_000;
44
+
45
+ // getTorrent dedupes concurrent/repeated calls via the pool's in-flight map,
46
+ // so polling keeps joining the same background fetch (metadata keeps
47
+ // downloading between polls). Race it against the wait budget; if the wait
48
+ // wins, the fetch is left running for the next poll to observe.
49
+ let timer;
50
+ const waitPromise = new Promise((resolve) => {
51
+ timer = setTimeout(() => resolve(PENDING), maxWaitMs);
52
+ timer.unref?.();
53
+ });
54
+ const torrentPromise = torrentPool
55
+ .getTorrent(sourceRecord.sourceType, sourceRecord.source)
56
+ // Swallow so a rejection that loses the race is not an unhandled rejection;
57
+ // the next poll re-issues getTorrent and re-observes any real error.
58
+ .catch((error) => (error instanceof Error ? error : new Error(String(error))));
59
+
60
+ let result;
61
+ try {
62
+ result = await Promise.race([torrentPromise, waitPromise]);
63
+ } finally {
64
+ clearTimeout(timer);
65
+ }
66
+
67
+ if (result === PENDING) {
68
+ return reply.send({ pending: true });
69
+ }
70
+ if (result instanceof Error) {
71
+ return reply.code(502).send({ error: `Could not load torrent metadata: ${result.message}` });
72
+ }
73
+
74
+ const torrent = result;
75
+ // WHAT IS IN THIS TORRENT, decided here and nowhere else. The browser used to
76
+ // decide it again — a list of video extensions in its parser and a second,
77
+ // shorter pair inside its picker — and the three answers had already diverged
78
+ // (measured 2026-09-12: `.dat` was offered there and not counted here, which
79
+ // also decides whether a sidecar whose name matches nothing can belong to the
80
+ // only video present). It ships the paths already relative to the torrent
81
+ // root, so there is no stripping rule on the other side either.
82
+ const contents = contentsOf(torrent);
83
+ return reply.send({
84
+ name: torrent.name ?? "",
85
+ infoHash: torrent.infoHash ?? "",
86
+ // In the order a person reads them — by folder, then by name, with runs of
87
+ // digits compared as numbers. A torrent's own order is whatever the tool
88
+ // that made it chose, and it is routinely by size.
89
+ files: contents.files(),
90
+ // The pictures, each with what belongs to it. By index, because the files
91
+ // themselves are in the list above and saying them twice is how two copies
92
+ // of one fact start.
93
+ items: contents.items.map((item) => ({
94
+ fileIndex: item.fileIndex,
95
+ audio: item.audio.map((part) => part.fileIndex),
96
+ subtitles: item.subtitles.map((part) => part.fileIndex),
97
+ images: item.images.map((part) => part.fileIndex),
98
+ // What the name says about which episode this is, in the release's own
99
+ // numbering — read by the page to ask which episode of the show it is.
100
+ episode: item.episode
101
+ })),
102
+ // One film, a series, or not known — whether one identification of the
103
+ // whole release applies to its pictures at all.
104
+ shape: contents.shape
105
+ });
106
+ }
@@ -1,31 +1,31 @@
1
- /** Build-time only. No sample generation runs when the proxy starts. */
2
- import { spawnSync } from "node:child_process";
3
- import { mkdirSync, writeFileSync } from "node:fs";
4
- import path from "node:path";
5
- import { fileURLToPath } from "node:url";
6
-
7
- const directory = fileURLToPath(new URL("../assets/calibration/audio/", import.meta.url));
8
- const formats = [
9
- ["aac", "aac", "m4a"], ["ac3", "ac3", "ac3"], ["eac3", "eac3", "eac3"],
10
- ["dts", "dca", "dts"], ["flac", "flac", "flac"], ["opus", "libopus", "opus"],
11
- ["vorbis", "libvorbis", "ogg"], ["truehd", "truehd", "thd"], ["mp3", "libmp3lame", "mp3"],
12
- ["mp2", "mp2", "mp2"], ["alac", "alac", "m4a"], ["pcm", "pcm_s16le", "wav"]
13
- ];
14
- mkdirSync(directory, { recursive: true });
15
- const manifest = [];
16
- for (const [codec, encoder, extension] of formats) {
17
- const file = `${codec}.${extension}`;
18
- const result = spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y",
19
- "-f", "lavfi", "-i", "anoisesrc=color=pink:seed=1:sample_rate=48000",
20
- "-t", "4", "-ac", "2", "-strict", "-2", "-c:a", encoder, path.join(directory, file)],
21
- { encoding: "utf8", windowsHide: true });
22
- if (result.status !== 0) throw new Error(`${codec}: ${result.stderr}`);
23
- const probe = spawnSync("ffprobe", ["-v", "error", "-show_streams", "-show_format", "-of", "json",
24
- path.join(directory, file)], { encoding: "utf8", windowsHide: true });
25
- if (probe.status !== 0) throw new Error(`${codec}: ${probe.stderr}`);
26
- const { streams, format } = JSON.parse(probe.stdout);
27
- const stream = streams.find(({ codec_type }) => codec_type === "audio");
28
- manifest.push({ codec, file, durationSeconds: Number(format.duration), channels: stream.channels,
29
- samplingFrequency: Number(stream.sample_rate), bytes: Number(format.size) });
30
- }
31
- writeFileSync(path.join(directory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
1
+ /** Build-time only. No sample generation runs when the proxy starts. */
2
+ import { spawnSync } from "node:child_process";
3
+ import { mkdirSync, writeFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const directory = fileURLToPath(new URL("../assets/calibration/audio/", import.meta.url));
8
+ const formats = [
9
+ ["aac", "aac", "m4a"], ["ac3", "ac3", "ac3"], ["eac3", "eac3", "eac3"],
10
+ ["dts", "dca", "dts"], ["flac", "flac", "flac"], ["opus", "libopus", "opus"],
11
+ ["vorbis", "libvorbis", "ogg"], ["truehd", "truehd", "thd"], ["mp3", "libmp3lame", "mp3"],
12
+ ["mp2", "mp2", "mp2"], ["alac", "alac", "m4a"], ["pcm", "pcm_s16le", "wav"]
13
+ ];
14
+ mkdirSync(directory, { recursive: true });
15
+ const manifest = [];
16
+ for (const [codec, encoder, extension] of formats) {
17
+ const file = `${codec}.${extension}`;
18
+ const result = spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y",
19
+ "-f", "lavfi", "-i", "anoisesrc=color=pink:seed=1:sample_rate=48000",
20
+ "-t", "4", "-ac", "2", "-strict", "-2", "-c:a", encoder, path.join(directory, file)],
21
+ { encoding: "utf8", windowsHide: true });
22
+ if (result.status !== 0) throw new Error(`${codec}: ${result.stderr}`);
23
+ const probe = spawnSync("ffprobe", ["-v", "error", "-show_streams", "-show_format", "-of", "json",
24
+ path.join(directory, file)], { encoding: "utf8", windowsHide: true });
25
+ if (probe.status !== 0) throw new Error(`${codec}: ${probe.stderr}`);
26
+ const { streams, format } = JSON.parse(probe.stdout);
27
+ const stream = streams.find(({ codec_type }) => codec_type === "audio");
28
+ manifest.push({ codec, file, durationSeconds: Number(format.duration), channels: stream.channels,
29
+ samplingFrequency: Number(stream.sample_rate), bytes: Number(format.size) });
30
+ }
31
+ writeFileSync(path.join(directory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);