@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.
- package/AGENTS.md +32 -12
- package/CHANGELOG.md +2340 -2330
- package/assets/calibration/audio/NOTICE.md +19 -19
- package/assets/calibration/audio/manifest.json +98 -98
- package/biome.json +412 -412
- package/docs/browser-proxy-contract.md +155 -155
- package/docs/container-architecture.md +273 -273
- package/docs/encode-run-state.md +1 -1
- package/docs/playback-start-forecast.md +113 -98
- package/package.json +51 -50
- package/routes/api/sources/files/get.js +106 -106
- package/scripts/build-audio-samples.mjs +31 -31
- package/services/encode/EncodeRun.js +67 -15
- package/services/encode/EncodeRuns.js +19 -7
- package/services/encode/OutputOpening.js +784 -784
- package/services/encode/audio-calibration.js +67 -67
- package/services/encode/audio-work-rate.js +15 -15
- package/services/encode/encode-exit.js +2 -0
- package/services/encode/hwaccel.js +1894 -1894
- package/services/encode/output/index.js +24 -24
- package/services/encode/piece-completeness.js +60 -2
- package/services/encode/run-command.js +757 -755
- package/services/encode/segment-formats/index.js +1 -0
- package/services/encode/segment-formats/mp4-boxes.js +8 -1
- package/services/media/ContainerOrchestrator.js +202 -202
- package/services/media/KeyframeTables.js +356 -356
- package/services/media/SubtitleCues.js +592 -592
- package/services/media/audio-inventory.js +299 -299
- package/services/media/container/AviContainer.js +268 -268
- package/services/media/container/Container.js +632 -632
- package/services/media/container/ContainerFactory.js +38 -38
- package/services/media/container/MatroskaContainer.js +1031 -1031
- package/services/media/container/Mp4Container.js +1354 -1354
- package/services/media/container/SubtitleFileContainer.js +339 -339
- package/services/media/container/ebml-stream.js +174 -174
- package/services/media/container/matroska-clusters.js +1165 -1165
- package/services/media/container/unavailable.js +77 -77
- package/services/media/tracks/ContainerTrack.js +84 -84
- package/services/media/tracks/TextSubtitleTrack.js +295 -295
- package/services/storage/piece-store/shared-piece-store.js +1937 -1937
- package/services/storage/segment-store/SegmentStore.js +1294 -1294
- package/services/torrent/Contents.js +382 -382
- package/services/torrent/episode-naming.js +182 -182
- package/services/torrent/torrent-pool.js +2688 -2688
- package/services/torrent/worker/client.js +836 -836
- package/services/torrent/worker/held-bytes.js +231 -231
- package/services/torrent/worker/worker.js +1137 -1137
- package/services/transport/data-channel-handler.js +1373 -1373
- package/services/viewer/PriorityMap.js +421 -421
- package/test/audio-calibration.test.js +23 -23
- package/test/auto-quality-step.test.js +762 -762
- package/test/coalesce.test.js +39 -39
- package/test/coverage-follows-the-disk.test.js +5 -0
- package/test/decode-cost.test.js +475 -475
- package/test/encode-run.test.js +58 -0
- package/test/episode-naming.test.js +131 -131
- package/test/failed-encoder-still-serves-what-exists.test.js +1 -1
- package/test/fixtures/audio-frame-boundary.json +1 -1
- package/test/fixtures/refused-audio-20261002.json +10 -0
- package/test/fmp4-presentation-clock.test.js +4 -2
- package/test/format-keyed-outputs.test.js +78 -78
- package/test/health-metrics.test.js +11 -13
- package/test/held-read-ownership.test.js +77 -77
- package/test/helpers/encode-run.js +14 -6
- package/test/helpers/held-file.js +77 -77
- package/test/helpers/manager.js +62 -62
- package/test/helpers/matroska-file.js +296 -296
- package/test/keyframe-table-budget.test.js +123 -123
- package/test/matroska-cluster-discovery.test.js +323 -323
- package/test/one-output-one-session.test.js +273 -273
- package/test/output-speed.test.js +6 -1
- package/test/piece-completeness.test.js +37 -1
- package/test/quality-variants.test.js +1274 -1270
- package/test/rate-control-in-the-output.test.js +4 -3
- package/test/reads-not-yet-arrived.test.js +218 -218
- package/test/seek-frees-the-viewers-requests.test.js +1 -1
- package/test/segment-closed-on-both-branches.test.js +114 -114
- package/test/segment-judged-by-its-own-header.test.js +222 -222
- package/test/segment-serve-wiring.test.js +481 -481
- package/test/serving-existing-output.test.js +111 -111
- package/test/source-files-route.test.js +113 -113
- package/test/subtitle-cue-walk.test.js +385 -385
- package/test/subtitle-metadata.test.js +15 -15
- package/test/subtitle-plan-waits.test.js +125 -125
- package/test/subtitle-track-numbering.test.js +391 -391
- package/test/two-viewers-one-picture.test.js +508 -508
- package/test/viewer-requests-admission.test.js +92 -92
- package/test/viewer-starts-on-map.test.js +27 -27
- package/test/viewer-states.test.js +5 -2
- package/test/viewing-generations.test.js +390 -390
- package/test/whole-file-held-read.test.js +30 -30
- 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
|
|
90
|
-
the delta the muxer is configured with. That is a fact of production, so no
|
|
91
|
-
browser
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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.
|
|
4
|
-
"description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
|
|
5
|
-
"license": "GPL-3.0-or-later",
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
},
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"@fastify/
|
|
29
|
-
"@fastify/
|
|
30
|
-
"@
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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`);
|