@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,111 +1,111 @@
1
- /**
2
- * @file An output already made serves a viewer when the rules decided with the
3
- * user 2026-09-16 allow it, through the paths a real request takes.
4
- */
5
-
6
- import test from "node:test";
7
- import assert from "node:assert/strict";
8
- import { writeFileSync } from "node:fs";
9
- import path from "node:path";
10
- import { managerWithOwnStore } from "./helpers/manager.js";
11
- import { outputSpec } from "./helpers/output-spec.js";
12
- import { SourceFile } from "../services/media/SourceFile.js";
13
- import { Timeline } from "../services/encode/output/Timeline.js";
14
- import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
15
- import { Output } from "../services/encode/output/Output.js";
16
- import { qualityStateOf } from "../services/encode/quality/OutputQualityState.js";
17
-
18
- const TORRENT = "torrent:11f0929918e2b5aa2e5b71ecdbe5c0f1a4bbf7d1";
19
-
20
- function hostWithA1080pOutput(t) {
21
- const { manager, store, cleanup } = managerWithOwnStore({
22
- startupWaitMs: 0,
23
- videoEncoder: { kind: "vaapi", name: "h264_vaapi", inputArgs: [] }
24
- });
25
- t.after(async () => {
26
- await manager.lifecycle.disposeAll();
27
- cleanup();
28
- });
29
- manager.encodeRuns.planEncodersNow = () => {};
30
- manager.encodeRuns.planEncodersSoon = () => {};
31
- manager.getCachedMediaInfo = () => ({ durationSeconds: 400, width: 1920, height: 1080, fps: 24 });
32
- // The soundtrack the plan listed, at the rate the file states: a copy of it
33
- // has a figure, so it stays a copy.
34
- manager.getCachedAudioTracks = () => [{ index: 0, fileIndex: 0, sourceTrackIndex: 0, bitrateKbps: 128 }];
35
- const spec = outputSpec({
36
- sourceKey: TORRENT,
37
- transcodeVideo: true,
38
- transcodeAudio: false,
39
- encoder: "h264_vaapi",
40
- width: 1920,
41
- height: 1080,
42
- fps: 24,
43
- cutGrid: "uniform"
44
- });
45
- const key = spec.toKey();
46
- store.useFormat(key, fmp4Format);
47
- // The piece at the start of the film is made.
48
- writeFileSync(path.join(store.directoryFor(key), fmp4Format.segmentFileName(0)), Buffer.alloc(100));
49
- const file = new SourceFile({ sourceKey: TORRENT, fileIndex: 0, name: "film.mkv" }).learn({ width: 1920, height: 1080, durationSeconds: 400 });
50
- const made = {
51
- id: spec.toName(),
52
- spec,
53
- get outputKey() { return this.spec.toKey(); },
54
- file,
55
- timeline: new Timeline({ boundaries: Array.from({ length: 101 }, (_, index) => index * 4), cutGrid: "uniform" }),
56
- output: new Output({ encodeWidth: 1920, encodeHeight: 1080, outputFps: 24 }),
57
- segmentFormat: fmp4Format,
58
- useSyntheticPlaylist: true
59
- };
60
- manager.outputs.set(made.id, made);
61
- // WHAT ITS ENCODER WAS SEEN DOING. Its first piece is made, so an encoder ran
62
- // on it here; a viewer put on it now needs one again, and a place for one is
63
- // asked by what one costs — with no measured cost the proxy refuses (roadmap
64
- // item 97, step 14).
65
- qualityStateOf(made).lastAloneSpeed = 2;
66
- return { manager, made };
67
- }
68
-
69
- function request(over) {
70
- return {
71
- sourceKey: TORRENT,
72
- fileIndex: 0,
73
- fileName: "film.mkv",
74
- transcodeVideo: true,
75
- transcodeAudio: false,
76
- segmentFormatId: "fmp4",
77
- ...over
78
- };
79
- }
80
-
81
- test("the automatic choice is served by a better output whose piece at the viewer's position is made", async (t) => {
82
- const { manager, made } = hostWithA1080pOutput(t);
83
- const answered = await manager.viewerRequests.createOrGetSession(
84
- request({ consumerId: "auto-viewer", targetWidth: 1280, targetHeight: 720 })
85
- );
86
- assert.equal(answered, made);
87
- });
88
-
89
- test("a size picked by hand is not served by another size", async (t) => {
90
- const { manager, made } = hostWithA1080pOutput(t);
91
- let answered = null;
92
- try {
93
- answered = await manager.viewerRequests.createOrGetSession(
94
- request({ consumerId: "picker", targetWidth: 1280, targetHeight: 720, exactSize: true })
95
- );
96
- } catch {
97
- answered = null;
98
- }
99
- assert.notEqual(answered, made);
100
- });
101
-
102
- test("a step asked for by an automatic viewer and served by the picture leaves the picture a picture", async (t) => {
103
- const { manager, made } = hostWithA1080pOutput(t);
104
- manager.viewers.of(made, "auto-viewer").qualityMode = "auto";
105
-
106
- const variant = await manager.renditions.resolveVariantSession(made.id, 720, 0, "auto-viewer");
107
-
108
- assert.equal(variant, made, "the picture already made at 1080p serves the 720p request");
109
- assert.notEqual(made.isStep, true, "and is not turned into a step of itself");
110
- assert.ok(manager.viewers.forOutput(made).has("auto-viewer"));
111
- });
1
+ /**
2
+ * @file An output already made serves a viewer when the rules decided with the
3
+ * user 2026-09-16 allow it, through the paths a real request takes.
4
+ */
5
+
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { writeFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { managerWithOwnStore } from "./helpers/manager.js";
11
+ import { outputSpec } from "./helpers/output-spec.js";
12
+ import { SourceFile } from "../services/media/SourceFile.js";
13
+ import { Timeline } from "../services/encode/output/Timeline.js";
14
+ import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
15
+ import { Output } from "../services/encode/output/Output.js";
16
+ import { qualityStateOf } from "../services/encode/quality/OutputQualityState.js";
17
+
18
+ const TORRENT = "torrent:11f0929918e2b5aa2e5b71ecdbe5c0f1a4bbf7d1";
19
+
20
+ function hostWithA1080pOutput(t) {
21
+ const { manager, store, cleanup } = managerWithOwnStore({
22
+ startupWaitMs: 0,
23
+ videoEncoder: { kind: "vaapi", name: "h264_vaapi", inputArgs: [] }
24
+ });
25
+ t.after(async () => {
26
+ await manager.lifecycle.disposeAll();
27
+ cleanup();
28
+ });
29
+ manager.encodeRuns.planEncodersNow = () => {};
30
+ manager.encodeRuns.planEncodersSoon = () => {};
31
+ manager.getCachedMediaInfo = () => ({ durationSeconds: 400, width: 1920, height: 1080, fps: 24 });
32
+ // The soundtrack the plan listed, at the rate the file states: a copy of it
33
+ // has a figure, so it stays a copy.
34
+ manager.getCachedAudioTracks = () => [{ index: 0, fileIndex: 0, sourceTrackIndex: 0, bitrateKbps: 128 }];
35
+ const spec = outputSpec({
36
+ sourceKey: TORRENT,
37
+ transcodeVideo: true,
38
+ transcodeAudio: false,
39
+ encoder: "h264_vaapi",
40
+ width: 1920,
41
+ height: 1080,
42
+ fps: 24,
43
+ cutGrid: "uniform"
44
+ });
45
+ const key = spec.toKey();
46
+ store.useFormat(key, fmp4Format);
47
+ // The piece at the start of the film is made.
48
+ writeFileSync(path.join(store.directoryFor(key), fmp4Format.segmentFileName(0)), Buffer.alloc(100));
49
+ const file = new SourceFile({ sourceKey: TORRENT, fileIndex: 0, name: "film.mkv" }).learn({ width: 1920, height: 1080, durationSeconds: 400 });
50
+ const made = {
51
+ id: spec.toName(),
52
+ spec,
53
+ get outputKey() { return this.spec.toKey(); },
54
+ file,
55
+ timeline: new Timeline({ boundaries: Array.from({ length: 101 }, (_, index) => index * 4), cutGrid: "uniform" }),
56
+ output: new Output({ encodeWidth: 1920, encodeHeight: 1080, outputFps: 24 }),
57
+ segmentFormat: fmp4Format,
58
+ useSyntheticPlaylist: true
59
+ };
60
+ manager.outputs.set(made.id, made);
61
+ // WHAT ITS ENCODER WAS SEEN DOING. Its first piece is made, so an encoder ran
62
+ // on it here; a viewer put on it now needs one again, and a place for one is
63
+ // asked by what one costs — with no measured cost the proxy refuses (roadmap
64
+ // item 97, step 14).
65
+ qualityStateOf(made).lastAloneSpeed = 2;
66
+ return { manager, made };
67
+ }
68
+
69
+ function request(over) {
70
+ return {
71
+ sourceKey: TORRENT,
72
+ fileIndex: 0,
73
+ fileName: "film.mkv",
74
+ transcodeVideo: true,
75
+ transcodeAudio: false,
76
+ segmentFormatId: "fmp4",
77
+ ...over
78
+ };
79
+ }
80
+
81
+ test("the automatic choice is served by a better output whose piece at the viewer's position is made", async (t) => {
82
+ const { manager, made } = hostWithA1080pOutput(t);
83
+ const answered = await manager.viewerRequests.createOrGetSession(
84
+ request({ consumerId: "auto-viewer", targetWidth: 1280, targetHeight: 720 })
85
+ );
86
+ assert.equal(answered, made);
87
+ });
88
+
89
+ test("a size picked by hand is not served by another size", async (t) => {
90
+ const { manager, made } = hostWithA1080pOutput(t);
91
+ let answered = null;
92
+ try {
93
+ answered = await manager.viewerRequests.createOrGetSession(
94
+ request({ consumerId: "picker", targetWidth: 1280, targetHeight: 720, exactSize: true })
95
+ );
96
+ } catch {
97
+ answered = null;
98
+ }
99
+ assert.notEqual(answered, made);
100
+ });
101
+
102
+ test("a step asked for by an automatic viewer and served by the picture leaves the picture a picture", async (t) => {
103
+ const { manager, made } = hostWithA1080pOutput(t);
104
+ manager.viewers.of(made, "auto-viewer").qualityMode = "auto";
105
+
106
+ const variant = await manager.renditions.resolveVariantSession(made.id, 720, 0, "auto-viewer");
107
+
108
+ assert.equal(variant, made, "the picture already made at 1080p serves the 720p request");
109
+ assert.notEqual(made.isStep, true, "and is not turned into a step of itself");
110
+ assert.ok(manager.viewers.forOutput(made).has("auto-viewer"));
111
+ });
@@ -1,113 +1,113 @@
1
- /**
2
- * @file WHAT THE BROWSER IS TOLD IS IN A TORRENT.
3
- *
4
- * This is the one place the product answers it. The browser used to answer it
5
- * as well — a list of video extensions in its parser and a second, shorter pair
6
- * inside its picker — and the three had already diverged: measured 2026-09-12,
7
- * `.dat` was offered there as video and not counted here, which also decides
8
- * whether a sidecar whose name matches nothing can belong to the only video
9
- * present.
10
- *
11
- * So the shape of this answer is a contract with the page, and the page shows
12
- * what it is given without looking at a name.
13
- */
14
-
15
- import test from "node:test";
16
- import assert from "node:assert/strict";
17
- import { handleApiSourceFilesGet } from "../routes/api/sources/files/get.js";
18
-
19
- /**
20
- * A request, a reply and the torrent behind them.
21
- *
22
- * @param {Array<{ path: string, length: number }>} files
23
- * @returns {{ req: object, reply: object, sent: { body: object | null, code: number }, deps: object }}
24
- */
25
- function harness(files) {
26
- const sent = { body: null, code: 200 };
27
- const reply = {
28
- code(value) {
29
- sent.code = value;
30
- return reply;
31
- },
32
- send(body) {
33
- sent.body = body;
34
- return reply;
35
- }
36
- };
37
- const deps = {
38
- sourceRegistry: { get: () => ({ sourceType: "magnet", source: "magnet:?xt=urn:btih:abc" }) },
39
- torrentPool: {
40
- async getTorrent() {
41
- return { name: "Drifters", infoHash: "abc", files };
42
- }
43
- }
44
- };
45
- return { req: { params: { sourceKey: "key" }, query: {} }, reply, sent, deps };
46
- }
47
-
48
- test("the files come back in reading order, each saying what it is", () => {
49
- const { req, reply, sent, deps } = harness([
50
- { path: "Drifters/ep 10.mkv", length: 9 },
51
- { path: "Drifters/ep 2.mkv", length: 9 },
52
- { path: "Drifters/Rus Sound/ep 2.mka", length: 2 },
53
- { path: "Drifters/notes.nfo", length: 1 }
54
- ]);
55
-
56
- return handleApiSourceFilesGet(req, reply, deps).then(() => {
57
- assert.equal(sent.code, 200);
58
- assert.deepEqual(
59
- sent.body.files.map((file) => [file.relativePath, file.kind]),
60
- [
61
- // Runs of digits compare as numbers, so 2 comes before 10; the torrent's
62
- // own order is whatever the tool that made it chose.
63
- ["ep 2.mkv", "video"],
64
- ["ep 10.mkv", "video"],
65
- ["notes.nfo", "other"],
66
- ["Rus Sound/ep 2.mka", "audio"]
67
- ]
68
- );
69
- // Relative to the torrent root: WebTorrent prefixes its own name to every
70
- // path, and stripping it here means one rule in the product rather than two
71
- // that can disagree.
72
- assert.ok(sent.body.files.every((file) => !file.relativePath.startsWith("Drifters/")));
73
- });
74
- });
75
-
76
- test("each picture is named with what belongs to it, by index", () => {
77
- const { req, reply, sent, deps } = harness([
78
- { path: "Drifters/ep 1.mkv", length: 9 },
79
- { path: "Drifters/Rus Sound/ep 1.mka", length: 2 },
80
- { path: "Drifters/Sub/ep 1.ass", length: 1 },
81
- { path: "Drifters/ep 2.mkv", length: 9 }
82
- ]);
83
-
84
- return handleApiSourceFilesGet(req, reply, deps).then(() => {
85
- const episode = (number) => ({
86
- season: null,
87
- episodes: [number],
88
- part: null,
89
- special: false,
90
- showHint: "",
91
- titleHint: ""
92
- });
93
- assert.deepEqual(sent.body.items, [
94
- { fileIndex: 0, audio: [1], subtitles: [2], images: [], episode: episode(1) },
95
- { fileIndex: 3, audio: [], subtitles: [], images: [], episode: episode(2) }
96
- ]);
97
- // Two numbered pictures of one show: the release is a series.
98
- assert.equal(sent.body.shape, "series");
99
- // The files themselves are in the list above; saying them twice is how two
100
- // copies of one fact start.
101
- assert.ok(sent.body.items.every((item) => item.audio.every((one) => Number.isInteger(one))));
102
- });
103
- });
104
-
105
- test("a torrent whose metadata has not arrived says so and lists nothing", () => {
106
- const { req, reply, sent, deps } = harness([]);
107
-
108
- return handleApiSourceFilesGet(req, reply, deps).then(() => {
109
- assert.deepEqual(sent.body.files, []);
110
- assert.deepEqual(sent.body.items, []);
111
- assert.equal(sent.body.name, "Drifters");
112
- });
113
- });
1
+ /**
2
+ * @file WHAT THE BROWSER IS TOLD IS IN A TORRENT.
3
+ *
4
+ * This is the one place the product answers it. The browser used to answer it
5
+ * as well — a list of video extensions in its parser and a second, shorter pair
6
+ * inside its picker — and the three had already diverged: measured 2026-09-12,
7
+ * `.dat` was offered there as video and not counted here, which also decides
8
+ * whether a sidecar whose name matches nothing can belong to the only video
9
+ * present.
10
+ *
11
+ * So the shape of this answer is a contract with the page, and the page shows
12
+ * what it is given without looking at a name.
13
+ */
14
+
15
+ import test from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { handleApiSourceFilesGet } from "../routes/api/sources/files/get.js";
18
+
19
+ /**
20
+ * A request, a reply and the torrent behind them.
21
+ *
22
+ * @param {Array<{ path: string, length: number }>} files
23
+ * @returns {{ req: object, reply: object, sent: { body: object | null, code: number }, deps: object }}
24
+ */
25
+ function harness(files) {
26
+ const sent = { body: null, code: 200 };
27
+ const reply = {
28
+ code(value) {
29
+ sent.code = value;
30
+ return reply;
31
+ },
32
+ send(body) {
33
+ sent.body = body;
34
+ return reply;
35
+ }
36
+ };
37
+ const deps = {
38
+ sourceRegistry: { get: () => ({ sourceType: "magnet", source: "magnet:?xt=urn:btih:abc" }) },
39
+ torrentPool: {
40
+ async getTorrent() {
41
+ return { name: "Drifters", infoHash: "abc", files };
42
+ }
43
+ }
44
+ };
45
+ return { req: { params: { sourceKey: "key" }, query: {} }, reply, sent, deps };
46
+ }
47
+
48
+ test("the files come back in reading order, each saying what it is", () => {
49
+ const { req, reply, sent, deps } = harness([
50
+ { path: "Drifters/ep 10.mkv", length: 9 },
51
+ { path: "Drifters/ep 2.mkv", length: 9 },
52
+ { path: "Drifters/Rus Sound/ep 2.mka", length: 2 },
53
+ { path: "Drifters/notes.nfo", length: 1 }
54
+ ]);
55
+
56
+ return handleApiSourceFilesGet(req, reply, deps).then(() => {
57
+ assert.equal(sent.code, 200);
58
+ assert.deepEqual(
59
+ sent.body.files.map((file) => [file.relativePath, file.kind]),
60
+ [
61
+ // Runs of digits compare as numbers, so 2 comes before 10; the torrent's
62
+ // own order is whatever the tool that made it chose.
63
+ ["ep 2.mkv", "video"],
64
+ ["ep 10.mkv", "video"],
65
+ ["notes.nfo", "other"],
66
+ ["Rus Sound/ep 2.mka", "audio"]
67
+ ]
68
+ );
69
+ // Relative to the torrent root: WebTorrent prefixes its own name to every
70
+ // path, and stripping it here means one rule in the product rather than two
71
+ // that can disagree.
72
+ assert.ok(sent.body.files.every((file) => !file.relativePath.startsWith("Drifters/")));
73
+ });
74
+ });
75
+
76
+ test("each picture is named with what belongs to it, by index", () => {
77
+ const { req, reply, sent, deps } = harness([
78
+ { path: "Drifters/ep 1.mkv", length: 9 },
79
+ { path: "Drifters/Rus Sound/ep 1.mka", length: 2 },
80
+ { path: "Drifters/Sub/ep 1.ass", length: 1 },
81
+ { path: "Drifters/ep 2.mkv", length: 9 }
82
+ ]);
83
+
84
+ return handleApiSourceFilesGet(req, reply, deps).then(() => {
85
+ const episode = (number) => ({
86
+ season: null,
87
+ episodes: [number],
88
+ part: null,
89
+ special: false,
90
+ showHint: "",
91
+ titleHint: ""
92
+ });
93
+ assert.deepEqual(sent.body.items, [
94
+ { fileIndex: 0, audio: [1], subtitles: [2], images: [], episode: episode(1) },
95
+ { fileIndex: 3, audio: [], subtitles: [], images: [], episode: episode(2) }
96
+ ]);
97
+ // Two numbered pictures of one show: the release is a series.
98
+ assert.equal(sent.body.shape, "series");
99
+ // The files themselves are in the list above; saying them twice is how two
100
+ // copies of one fact start.
101
+ assert.ok(sent.body.items.every((item) => item.audio.every((one) => Number.isInteger(one))));
102
+ });
103
+ });
104
+
105
+ test("a torrent whose metadata has not arrived says so and lists nothing", () => {
106
+ const { req, reply, sent, deps } = harness([]);
107
+
108
+ return handleApiSourceFilesGet(req, reply, deps).then(() => {
109
+ assert.deepEqual(sent.body.files, []);
110
+ assert.deepEqual(sent.body.items, []);
111
+ assert.equal(sent.body.name, "Drifters");
112
+ });
113
+ });