@torrent-tv/proxy 2.88.3 → 2.88.5

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 (61) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/assets/calibration/audio/NOTICE.md +19 -0
  3. package/assets/calibration/audio/aac.m4a +0 -0
  4. package/assets/calibration/audio/ac3.ac3 +0 -0
  5. package/assets/calibration/audio/alac.m4a +0 -0
  6. package/assets/calibration/audio/dts.dts +0 -0
  7. package/assets/calibration/audio/eac3.eac3 +0 -0
  8. package/assets/calibration/audio/flac.flac +0 -0
  9. package/assets/calibration/audio/manifest.json +98 -0
  10. package/assets/calibration/audio/mp2.mp2 +0 -0
  11. package/assets/calibration/audio/mp3.mp3 +0 -0
  12. package/assets/calibration/audio/opus.opus +0 -0
  13. package/assets/calibration/audio/pcm.wav +0 -0
  14. package/assets/calibration/audio/truehd.thd +0 -0
  15. package/assets/calibration/audio/vorbis.ogg +0 -0
  16. package/biome.json +1 -0
  17. package/docs/browser-proxy-contract.md +155 -133
  18. package/docs/playback-start-forecast.md +56 -0
  19. package/package.json +1 -1
  20. package/routes/api/transcode-sessions/post.js +5 -0
  21. package/routes/transcode/audio-warm/get.js +14 -3
  22. package/scripts/build-audio-samples.mjs +31 -0
  23. package/server.js +5 -0
  24. package/services/encode/EncodeRuns.js +3 -1
  25. package/services/encode/OutputOpening.js +40 -15
  26. package/services/encode/Renditions.js +129 -31
  27. package/services/encode/args.js +5 -3
  28. package/services/encode/audio-calibration.js +67 -0
  29. package/services/encode/audio-work-rate.js +15 -0
  30. package/services/encode/hwaccel.js +1 -1
  31. package/services/encode/quality/EncodeCost.js +29 -3
  32. package/services/encode/quality/link-budget.js +110 -15
  33. package/services/encode/quality/output-format.js +10 -6
  34. package/services/encode/segment-formats/fmp4.js +2 -0
  35. package/services/encode/segment-formats/mp4-boxes.js +12 -1
  36. package/services/media/audio-inventory.js +60 -6
  37. package/services/media/container/Container.js +7 -1
  38. package/services/media/container/Mp4Container.js +103 -3
  39. package/services/media/tracks/AudioTrack.js +12 -0
  40. package/services/media/tracks/audio-bound.js +233 -0
  41. package/services/server/ViewerRequests.js +30 -5
  42. package/services/server/playback-readiness.js +516 -708
  43. package/services/server/wire-outputs.js +31 -3
  44. package/services/storage/piece-store/shared-piece-store.js +6 -0
  45. package/services/storage/segment-store/SegmentStore.js +19 -4
  46. package/services/torrent/episode-naming.js +182 -182
  47. package/services/torrent/torrent-pool.js +13 -0
  48. package/test/audio-buffer-continuity.test.js +70 -70
  49. package/test/audio-calibration.test.js +23 -0
  50. package/test/episode-naming.test.js +131 -131
  51. package/test/fixtures/audio-frame-boundary.json +1 -1
  52. package/test/fmp4-presentation-clock.test.js +17 -2
  53. package/test/link-budget.test.js +7 -6
  54. package/test/one-output-one-session.test.js +77 -8
  55. package/test/playback-readiness.test.js +36 -6
  56. package/test/quality-variants.test.js +36 -8
  57. package/test/segment-store.test.js +15 -0
  58. package/test/serving-existing-output.test.js +3 -0
  59. package/test/soundtrack-rate-bound.test.js +289 -0
  60. package/test/source-files-route.test.js +113 -113
  61. package/test/two-viewers-one-picture.test.js +97 -0
@@ -84,7 +84,8 @@ import { OutputOpening } from "../encode/OutputOpening.js";
84
84
  import { segmentFormatOfKey } from "../encode/output-key-format.js";
85
85
  import { OutputSpec } from "../encode/output/OutputSpec.js";
86
86
  import { compareInits } from "../encode/segment-formats/init-compat.js";
87
- import { nominalKbpsFor } from "../encode/args.js";
87
+ import { nominalKbpsFor, AUDIO_TRANSCODE_KBPS } from "../encode/args.js";
88
+ import { audioReadingFor } from "../encode/audio-work-rate.js";
88
89
  import { probeInputMediaInfo } from "../media/media-info-probe.js";
89
90
  import { probeVideoKeyframeTimes } from "../media/keyframe-probe.js";
90
91
  import { wireMachineBudget } from "../storage/wire.js";
@@ -210,6 +211,7 @@ export function wireOutputs({
210
211
  // is inventing a penalty, which is the same fault as inventing a fill rate.
211
212
  contentionPenalties = null,
212
213
  copySpeedX = null,
214
+ audioCalibration = [],
213
215
  tonemapSupported = false,
214
216
  getCachedMediaInfo = null,
215
217
  getCachedAudioTracks = null,
@@ -227,6 +229,10 @@ export function wireOutputs({
227
229
  memoryClaimant = null,
228
230
  budgetPolicy = null}) {
229
231
  const parts = {};
232
+ const audioDescriptionOf = (output) => (getCachedAudioTracks?.({
233
+ sourceKey: output.file.sourceKey, fileIndex: output.spec.grid?.fileIndex ?? output.file.fileIndex
234
+ }) ?? []).find((track) => track.fileIndex === output.spec.audio?.fileIndex &&
235
+ track.sourceTrackIndex === output.spec.audio?.trackIndex) ?? null;
230
236
  // Encode components receive only the storage operations they use. The
231
237
  // concrete SegmentStore remains owned by this assembly and is never handed
232
238
  // to the encode layer.
@@ -240,7 +246,7 @@ export function wireOutputs({
240
246
  pathFor: (address) => parts.segmentStore.pathFor(address),
241
247
  initOf: (address) => parts.segmentStore.initOf(address),
242
248
  directoryFor: (address) => parts.segmentStore.directoryFor(address),
243
- publish: (address, makingName, format) => parts.segmentStore.publish(address, makingName, format)
249
+ publish: (address, makingName, format, coverage) => parts.segmentStore.publish(address, makingName, format, coverage)
244
250
  };
245
251
  const segmentOutputFiles = {
246
252
  addresses: () => parts.segmentStore.addresses(),
@@ -353,6 +359,9 @@ export function wireOutputs({
353
359
  worstSupplyWaitSec: parts.hostLoad.supplyFor(output.file)?.worstWaitSec
354
360
  })?.seconds ?? null;
355
361
  parts.renditions = new Renditions({
362
+ // Whether this proxy re-encodes at all: a soundtrack with no stated rate is
363
+ // re-encoded where it may be, and copied where it may not.
364
+ get transcodeEnabled() { return parts.enabled; },
356
365
  observedPeakMbps: (spec) => parts.localObservations?.peakMbps(spec) ?? null,
357
366
  // A viewer has been moved onto an output prepared for them: how long it
358
367
  // took, and what they held.
@@ -545,7 +554,24 @@ export function wireOutputs({
545
554
  waitUntilReady: (...args) => parts.serving.waitUntilReady(...args),
546
555
  get encodeRuns() { return parts.encodeRuns; },
547
556
  encodeSpeedReadingOf: (output) => parts.encodeCost.latestSpeedReadingOf(output),
548
- projectedEncodeSpeedOf: () => parts.admission.projectedSpeedX(),
557
+ projectedEncodeSpeedOf: (output) => parts.encodeCost.projectedSpeedOf(output),
558
+ audioDescriptionOf,
559
+ processingStartupSecondsOf: (output) => output.spec.carries === "audio-only" ?
560
+ audioReadingFor(audioCalibration, { ...audioDescriptionOf(output),
561
+ codec: audioDescriptionOf(output)?.codec ?? output.file.media?.audioCodec,
562
+ transcode: output.spec.audio?.transcode })?.startupSeconds ?? 0 :
563
+ Math.max(0, (startStopCost?.firstByteWaitSec ?? 0) - parts.segmentDurationSec /
564
+ parts.encodeCost.projectedSpeedOf(output)),
565
+ initialOutputBitsPerMediaSecondOf: (output) => {
566
+ const description = audioDescriptionOf(output);
567
+ const reference = audioReadingFor(audioCalibration, { codec: description?.codec, transcode: false });
568
+ const audio = output.spec.audio ? output.spec.audio.transcode ? AUDIO_TRANSCODE_KBPS * 1000 :
569
+ description?.bitrateKbps > 0 ? description.bitrateKbps * 1000 :
570
+ reference ? reference.bytes * 8 / reference.durationSeconds : 0 : 0;
571
+ const video = output.spec.video ? (output.spec.video.encode?.rateControl?.maxrateKbps ??
572
+ output.file.media?.bitrateKbps ?? 0) * 1000 : 0;
573
+ return video + audio;
574
+ },
549
575
  get opening() { return parts.opening; },
550
576
  get outputTimes() { return parts.outputTimes; },
551
577
  get outputs() { return parts.outputs; },
@@ -761,6 +787,8 @@ export function wireOutputs({
761
787
  decodeModel: parts.decodeCostModel,
762
788
  contentionPenalties: parts.contentionPenalties,
763
789
  copySpeedX: parts.copySpeedX,
790
+ audioCalibration,
791
+ audioDescriptionOf,
764
792
  availability: parts.hostLoad.hostAvailability,
765
793
  // WHICH encoder this host settled on. Only the software ladder is
766
794
  // benchmarked, so a reading taken off a hardware encoder cannot be
@@ -814,6 +814,12 @@ export class SharedPieceStore {
814
814
  return this.#buffers.get(index);
815
815
  }
816
816
 
817
+ /** Availability belongs to the store, not to a second cached bitfield. */
818
+ locationOf(index) {
819
+ return this.#buffers.has(index) ? "memory" : this.#evicting.has(index) ? "writing" :
820
+ this.#disk.has(index) ? "disk" : this.isInWholeFiles(index) ? "whole-file" : "missing";
821
+ }
822
+
817
823
  pin(index) {
818
824
  this.#lru.pin(index);
819
825
  // A piece reader pins before it waits for the bytes to arrive or return
@@ -426,7 +426,7 @@ export class SegmentStore {
426
426
  * @param {{ servedNameOf?: (name: string) => string | null }} format
427
427
  * @returns {string | null} The served name, or null where nothing was renamed.
428
428
  */
429
- publish(key, makingName, format) {
429
+ publish(key, makingName, format, { endSeconds } = {}) {
430
430
  const served = format?.servedNameOf?.(makingName) ?? null;
431
431
  if (!served) {
432
432
  return null;
@@ -438,6 +438,11 @@ export class SegmentStore {
438
438
  try {
439
439
  mediaRanges = format.readMediaRanges(readFileSync(path.join(dir, makingName)));
440
440
  if (mediaRanges.length === 0) throw new Error("The closed file contains no playable media intervals.");
441
+ const end = format.mediaCoverageEnd?.(mediaRanges);
442
+ if (Number.isFinite(endSeconds) && Number.isFinite(end) && end < endSeconds) {
443
+ this.#logger?.warn?.(`segment store: refusing short segment ${index}: coverage=${end}s cut=${endSeconds}s`);
444
+ return null;
445
+ }
441
446
  } catch (error) {
442
447
  this.#logger?.warn?.(`segment store: could not read media coverage of ${makingName}: ${error.message}`);
443
448
  return null;
@@ -471,11 +476,20 @@ export class SegmentStore {
471
476
  return served;
472
477
  }
473
478
 
474
- mediaRangesOf(key, index, { startSeconds = 0 } = {}) {
479
+ mediaRangesOf(key, index, { startSeconds = 0, endSeconds } = {}) {
475
480
  const known = this.#mediaRanges.get(key)?.get(index);
476
- if (known) return known;
477
481
  const format = this.#formats.get(key);
478
482
  if (!format?.readMediaRanges) return undefined;
483
+ const checked = (ranges) => {
484
+ const end = format.mediaCoverageEnd?.(ranges);
485
+ if (Number.isFinite(endSeconds) && Number.isFinite(end) && end < endSeconds) {
486
+ this.#logger?.warn?.(`segment store: removing short cached segment ${index}: coverage=${end}s cut=${endSeconds}s`);
487
+ this.#removeSegment(key, index);
488
+ return undefined;
489
+ }
490
+ return ranges;
491
+ };
492
+ if (known) return checked(known);
479
493
  const filePath = this.pathOf(key, index);
480
494
  if (!filePath) return undefined;
481
495
  try {
@@ -492,7 +506,7 @@ export class SegmentStore {
492
506
  const byIndex = this.#mediaRanges.get(key) ?? new Map();
493
507
  byIndex.set(index, ranges);
494
508
  this.#mediaRanges.set(key, byIndex);
495
- return ranges;
509
+ return checked(ranges);
496
510
  } catch {
497
511
  return [];
498
512
  }
@@ -1092,6 +1106,7 @@ export class SegmentStore {
1092
1106
  // Gone already, or refused. The next refresh reports what is really there.
1093
1107
  }
1094
1108
  this.#held.delete(key);
1109
+ this.#mediaRanges.get(key)?.delete(index);
1095
1110
  return size;
1096
1111
  }
1097
1112
 
@@ -1,182 +1,182 @@
1
- /**
2
- * @file What a picture's NAME says about which episode it is.
3
- *
4
- * A season pack states its structure in names — `s01e02_Murder.in.the.Mews.avi`,
5
- * `Reacher.S04E01.1080p.rus.LostFilm.TV.mkv`, `[HorribleSubs] Drifters - 01
6
- * [1080p].mkv` under a folder, or an episode number under `Season_01/`. This
7
- * reads that statement and nothing else: which season, which episode numbers,
8
- * which part of an episode, whether it is a special, and the text on either
9
- * side of the marker.
10
- *
11
- * The numbers are the RELEASE's numbering. They are often the same as a
12
- * metadata provider's and sometimes not — one release numbers the two parts of
13
- * a feature-length episode as two episodes, which shifts every number after
14
- * them — so nothing here claims to know which episode of the show a file is.
15
- * That is decided later, against the provider's own list, by whoever holds it.
16
- *
17
- * A name without a marker returns `null`: absence of a number is absence of
18
- * information, not evidence that the file is not an episode.
19
- */
20
-
21
- import { stripExtension } from "./naming.js";
22
-
23
- /** Most episodes one file may claim before the claim is read as noise. */
24
- const MAX_EPISODES_PER_FILE = 8;
25
-
26
- /** Longest hint returned, in characters; a name longer than this is not a title. */
27
- const MAX_HINT_LENGTH = 160;
28
-
29
- /** `S01E02`, `s03.e01`, `S01E01E02`, `S01E01-E02`, `S01E01-02`. */
30
- const SEASON_EPISODE = /(?<![a-z0-9])s(\d{1,3})[ ._]?e(\d{1,4})((?:-?e\d{1,4}|-\d{1,4})*)(?![0-9])/i;
31
-
32
- /** `1x02`; four-digit sides are picture sizes (`1920x1080`) and never match. */
33
- const CROSS = /(?<![a-z0-9])(\d{1,2})x(\d{2,3})(?![0-9a-z])/i;
34
-
35
- /** `E05`, `Ep 5`, `Episode.05`, `Серия 5` — an episode number with no season in it. */
36
- const EPISODE_ONLY = /(?<![\p{L}0-9])(?:e|ep|episode|серия)[ ._-]?(\d{1,4})(?![0-9])/iu;
37
-
38
- /** `Show - 01`, `Show - 01v2` — the numbering most anime releases use. */
39
- const DASH_NUMBER = / - (\d{1,4})(?:v\d)?(?=[ .[(]|$)/;
40
-
41
- /** A folder that names a season: `Season_01`, `Season 1`, `S01`, `Сезон 1`, `1 сезон`. */
42
- const SEASON_FOLDER = /^(?:season|сезон|s)[ ._-]*(\d{1,3})$|^(\d{1,3})[ ._-]*(?:season|сезон)$/iu;
43
-
44
- /** `Part.1`, `Pt 2`, `Часть 1`. */
45
- const PART = /(?<![\p{L}0-9])(?:part|pt|часть)[ ._-]?(\d{1,2})(?![0-9])/iu;
46
-
47
- /** `Special`, `Specials`, `SP01`. */
48
- const SPECIAL = /(?<![\p{L}])(?:specials?|sp\d{1,3})(?![\p{L}])/iu;
49
-
50
- /** Separators a release puts between words, trimmed off the ends of a hint. */
51
- const EDGE_SEPARATORS = /^[\s._\-–—]+|[\s._\-–—]+$/g;
52
-
53
- /**
54
- * An episode marker read from a name.
55
- *
56
- * @typedef {object} EpisodeMarker
57
- * @property {number | null} season - `null` when neither the name nor a folder states one.
58
- * @property {number[]} episodes - The release's numbers, one or more.
59
- * @property {number | null} part - Which part of one episode, when the name says so.
60
- * @property {boolean} special - Season 0, or a name calling itself a special.
61
- * @property {string} showHint - The name's text before the marker, as written.
62
- * @property {string} titleHint - The name's text after the marker, part removed, as written.
63
- */
64
-
65
- /**
66
- * Trim a piece of a name to a hint.
67
- *
68
- * @param {string} text
69
- * @returns {string}
70
- */
71
- function hint(text) {
72
- return String(text ?? "").replace(EDGE_SEPARATORS, "").slice(0, MAX_HINT_LENGTH);
73
- }
74
-
75
- /**
76
- * The numbers after the first episode in `S01E01E02` or `S01E01-03`.
77
- *
78
- * A `-` joins a range, a repeated `E` lists another episode.
79
- *
80
- * @param {number} first
81
- * @param {string} rest
82
- * @returns {number[] | null} `null` when the claim is longer than any file carries.
83
- */
84
- function episodesFrom(first, rest) {
85
- const episodes = [first];
86
- for (const token of rest.match(/-?e?\d+/gi) ?? []) {
87
- const value = Number(token.replace(/[^0-9]/g, ""));
88
- const last = episodes[episodes.length - 1];
89
- if (token.startsWith("-") && value > last) {
90
- for (let next = last + 1; next <= value; next += 1) {
91
- episodes.push(next);
92
- if (episodes.length > MAX_EPISODES_PER_FILE) {
93
- return null;
94
- }
95
- }
96
- } else if (value !== last) {
97
- episodes.push(value);
98
- }
99
- }
100
- return episodes.length > MAX_EPISODES_PER_FILE ? null : episodes;
101
- }
102
-
103
- /**
104
- * The season the deepest season-naming folder states, if any does.
105
- *
106
- * @param {string[]} folders
107
- * @returns {number | null}
108
- */
109
- function seasonFromFolders(folders) {
110
- for (let index = folders.length - 1; index >= 0; index -= 1) {
111
- const match = SEASON_FOLDER.exec(String(folders[index] ?? "").trim());
112
- if (match) {
113
- return Number(match[1] ?? match[2]);
114
- }
115
- }
116
- return null;
117
- }
118
-
119
- /**
120
- * Which episode a picture's name says it is.
121
- *
122
- * @param {{ name: string, folders?: string[] }} file - File name and the folders above it.
123
- * @returns {EpisodeMarker | null}
124
- */
125
- export function readEpisodeMarker({ name, folders = [] }) {
126
- const stem = stripExtension(String(name ?? ""));
127
- const folderSeason = seasonFromFolders(Array.isArray(folders) ? folders : []);
128
-
129
- let season = null;
130
- let episodes = null;
131
- let before = "";
132
- let after = "";
133
-
134
- const seasonEpisode = SEASON_EPISODE.exec(stem);
135
- const cross = seasonEpisode ? null : CROSS.exec(stem);
136
- const episodeOnly = seasonEpisode || cross ? null : EPISODE_ONLY.exec(stem);
137
- const dash = seasonEpisode || cross || episodeOnly ? null : DASH_NUMBER.exec(stem);
138
- const match = seasonEpisode ?? cross ?? episodeOnly ?? dash;
139
- if (!match) {
140
- return null;
141
- }
142
- before = stem.slice(0, match.index);
143
- after = stem.slice(match.index + match[0].length);
144
-
145
- if (seasonEpisode) {
146
- season = Number(seasonEpisode[1]);
147
- episodes = episodesFrom(Number(seasonEpisode[2]), seasonEpisode[3] ?? "");
148
- } else if (cross) {
149
- season = Number(cross[1]);
150
- episodes = [Number(cross[2])];
151
- } else {
152
- // `Show S1 - 01` states its season just before the number; a folder is the
153
- // other place a release puts it.
154
- const trailingSeason = /(?<![a-z0-9])s(\d{1,3})[\s._-]*$/i.exec(before);
155
- season = trailingSeason ? Number(trailingSeason[1]) : folderSeason;
156
- if (trailingSeason) {
157
- before = before.slice(0, trailingSeason.index);
158
- }
159
- episodes = [Number(match[1])];
160
- }
161
- // A year where an episode number would be is a year far more often than an
162
- // episode: `Film - 2024 [1080p]`, and `WALL-E.2008.mkv` reads as `E` then
163
- // `2008`. Only the markers without a season can be fooled this way.
164
- if ((dash || episodeOnly) && /^(?:19|20)\d\d$/.test(match[1])) {
165
- return null;
166
- }
167
- if (!episodes) {
168
- return null;
169
- }
170
-
171
- const part = PART.exec(after);
172
- const titleText = part ? `${after.slice(0, part.index)} ${after.slice(part.index + part[0].length)}` : after;
173
-
174
- return {
175
- season,
176
- episodes,
177
- part: part ? Number(part[1]) : null,
178
- special: season === 0 || SPECIAL.test(stem),
179
- showHint: hint(before),
180
- titleHint: hint(titleText)
181
- };
182
- }
1
+ /**
2
+ * @file What a picture's NAME says about which episode it is.
3
+ *
4
+ * A season pack states its structure in names — `s01e02_Murder.in.the.Mews.avi`,
5
+ * `Reacher.S04E01.1080p.rus.LostFilm.TV.mkv`, `[HorribleSubs] Drifters - 01
6
+ * [1080p].mkv` under a folder, or an episode number under `Season_01/`. This
7
+ * reads that statement and nothing else: which season, which episode numbers,
8
+ * which part of an episode, whether it is a special, and the text on either
9
+ * side of the marker.
10
+ *
11
+ * The numbers are the RELEASE's numbering. They are often the same as a
12
+ * metadata provider's and sometimes not — one release numbers the two parts of
13
+ * a feature-length episode as two episodes, which shifts every number after
14
+ * them — so nothing here claims to know which episode of the show a file is.
15
+ * That is decided later, against the provider's own list, by whoever holds it.
16
+ *
17
+ * A name without a marker returns `null`: absence of a number is absence of
18
+ * information, not evidence that the file is not an episode.
19
+ */
20
+
21
+ import { stripExtension } from "./naming.js";
22
+
23
+ /** Most episodes one file may claim before the claim is read as noise. */
24
+ const MAX_EPISODES_PER_FILE = 8;
25
+
26
+ /** Longest hint returned, in characters; a name longer than this is not a title. */
27
+ const MAX_HINT_LENGTH = 160;
28
+
29
+ /** `S01E02`, `s03.e01`, `S01E01E02`, `S01E01-E02`, `S01E01-02`. */
30
+ const SEASON_EPISODE = /(?<![a-z0-9])s(\d{1,3})[ ._]?e(\d{1,4})((?:-?e\d{1,4}|-\d{1,4})*)(?![0-9])/i;
31
+
32
+ /** `1x02`; four-digit sides are picture sizes (`1920x1080`) and never match. */
33
+ const CROSS = /(?<![a-z0-9])(\d{1,2})x(\d{2,3})(?![0-9a-z])/i;
34
+
35
+ /** `E05`, `Ep 5`, `Episode.05`, `Серия 5` — an episode number with no season in it. */
36
+ const EPISODE_ONLY = /(?<![\p{L}0-9])(?:e|ep|episode|серия)[ ._-]?(\d{1,4})(?![0-9])/iu;
37
+
38
+ /** `Show - 01`, `Show - 01v2` — the numbering most anime releases use. */
39
+ const DASH_NUMBER = / - (\d{1,4})(?:v\d)?(?=[ .[(]|$)/;
40
+
41
+ /** A folder that names a season: `Season_01`, `Season 1`, `S01`, `Сезон 1`, `1 сезон`. */
42
+ const SEASON_FOLDER = /^(?:season|сезон|s)[ ._-]*(\d{1,3})$|^(\d{1,3})[ ._-]*(?:season|сезон)$/iu;
43
+
44
+ /** `Part.1`, `Pt 2`, `Часть 1`. */
45
+ const PART = /(?<![\p{L}0-9])(?:part|pt|часть)[ ._-]?(\d{1,2})(?![0-9])/iu;
46
+
47
+ /** `Special`, `Specials`, `SP01`. */
48
+ const SPECIAL = /(?<![\p{L}])(?:specials?|sp\d{1,3})(?![\p{L}])/iu;
49
+
50
+ /** Separators a release puts between words, trimmed off the ends of a hint. */
51
+ const EDGE_SEPARATORS = /^[\s._\-–—]+|[\s._\-–—]+$/g;
52
+
53
+ /**
54
+ * An episode marker read from a name.
55
+ *
56
+ * @typedef {object} EpisodeMarker
57
+ * @property {number | null} season - `null` when neither the name nor a folder states one.
58
+ * @property {number[]} episodes - The release's numbers, one or more.
59
+ * @property {number | null} part - Which part of one episode, when the name says so.
60
+ * @property {boolean} special - Season 0, or a name calling itself a special.
61
+ * @property {string} showHint - The name's text before the marker, as written.
62
+ * @property {string} titleHint - The name's text after the marker, part removed, as written.
63
+ */
64
+
65
+ /**
66
+ * Trim a piece of a name to a hint.
67
+ *
68
+ * @param {string} text
69
+ * @returns {string}
70
+ */
71
+ function hint(text) {
72
+ return String(text ?? "").replace(EDGE_SEPARATORS, "").slice(0, MAX_HINT_LENGTH);
73
+ }
74
+
75
+ /**
76
+ * The numbers after the first episode in `S01E01E02` or `S01E01-03`.
77
+ *
78
+ * A `-` joins a range, a repeated `E` lists another episode.
79
+ *
80
+ * @param {number} first
81
+ * @param {string} rest
82
+ * @returns {number[] | null} `null` when the claim is longer than any file carries.
83
+ */
84
+ function episodesFrom(first, rest) {
85
+ const episodes = [first];
86
+ for (const token of rest.match(/-?e?\d+/gi) ?? []) {
87
+ const value = Number(token.replace(/[^0-9]/g, ""));
88
+ const last = episodes[episodes.length - 1];
89
+ if (token.startsWith("-") && value > last) {
90
+ for (let next = last + 1; next <= value; next += 1) {
91
+ episodes.push(next);
92
+ if (episodes.length > MAX_EPISODES_PER_FILE) {
93
+ return null;
94
+ }
95
+ }
96
+ } else if (value !== last) {
97
+ episodes.push(value);
98
+ }
99
+ }
100
+ return episodes.length > MAX_EPISODES_PER_FILE ? null : episodes;
101
+ }
102
+
103
+ /**
104
+ * The season the deepest season-naming folder states, if any does.
105
+ *
106
+ * @param {string[]} folders
107
+ * @returns {number | null}
108
+ */
109
+ function seasonFromFolders(folders) {
110
+ for (let index = folders.length - 1; index >= 0; index -= 1) {
111
+ const match = SEASON_FOLDER.exec(String(folders[index] ?? "").trim());
112
+ if (match) {
113
+ return Number(match[1] ?? match[2]);
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * Which episode a picture's name says it is.
121
+ *
122
+ * @param {{ name: string, folders?: string[] }} file - File name and the folders above it.
123
+ * @returns {EpisodeMarker | null}
124
+ */
125
+ export function readEpisodeMarker({ name, folders = [] }) {
126
+ const stem = stripExtension(String(name ?? ""));
127
+ const folderSeason = seasonFromFolders(Array.isArray(folders) ? folders : []);
128
+
129
+ let season = null;
130
+ let episodes = null;
131
+ let before = "";
132
+ let after = "";
133
+
134
+ const seasonEpisode = SEASON_EPISODE.exec(stem);
135
+ const cross = seasonEpisode ? null : CROSS.exec(stem);
136
+ const episodeOnly = seasonEpisode || cross ? null : EPISODE_ONLY.exec(stem);
137
+ const dash = seasonEpisode || cross || episodeOnly ? null : DASH_NUMBER.exec(stem);
138
+ const match = seasonEpisode ?? cross ?? episodeOnly ?? dash;
139
+ if (!match) {
140
+ return null;
141
+ }
142
+ before = stem.slice(0, match.index);
143
+ after = stem.slice(match.index + match[0].length);
144
+
145
+ if (seasonEpisode) {
146
+ season = Number(seasonEpisode[1]);
147
+ episodes = episodesFrom(Number(seasonEpisode[2]), seasonEpisode[3] ?? "");
148
+ } else if (cross) {
149
+ season = Number(cross[1]);
150
+ episodes = [Number(cross[2])];
151
+ } else {
152
+ // `Show S1 - 01` states its season just before the number; a folder is the
153
+ // other place a release puts it.
154
+ const trailingSeason = /(?<![a-z0-9])s(\d{1,3})[\s._-]*$/i.exec(before);
155
+ season = trailingSeason ? Number(trailingSeason[1]) : folderSeason;
156
+ if (trailingSeason) {
157
+ before = before.slice(0, trailingSeason.index);
158
+ }
159
+ episodes = [Number(match[1])];
160
+ }
161
+ // A year where an episode number would be is a year far more often than an
162
+ // episode: `Film - 2024 [1080p]`, and `WALL-E.2008.mkv` reads as `E` then
163
+ // `2008`. Only the markers without a season can be fooled this way.
164
+ if ((dash || episodeOnly) && /^(?:19|20)\d\d$/.test(match[1])) {
165
+ return null;
166
+ }
167
+ if (!episodes) {
168
+ return null;
169
+ }
170
+
171
+ const part = PART.exec(after);
172
+ const titleText = part ? `${after.slice(0, part.index)} ${after.slice(part.index + part[0].length)}` : after;
173
+
174
+ return {
175
+ season,
176
+ episodes,
177
+ part: part ? Number(part[1]) : null,
178
+ special: season === 0 || SPECIAL.test(stem),
179
+ showHint: hint(before),
180
+ titleHint: hint(titleText)
181
+ };
182
+ }
@@ -2233,12 +2233,25 @@ export class TorrentPool {
2233
2233
  // deselected null piece — see fileDownloadedBytes).
2234
2234
  const fileLength = typeof file.length === "number" ? file.length : 0;
2235
2235
  const fileDownloaded = fileDownloadedBytes(torrent, file);
2236
+ const residence = [];
2237
+ const store = pieceStoreOf(torrent);
2238
+ const firstPiece = Math.floor(file.offset / torrent.pieceLength);
2239
+ const lastPiece = Math.ceil((file.offset + fileLength) / torrent.pieceLength);
2240
+ for (let index = firstPiece; index < lastPiece; index += 1) {
2241
+ const start = Math.max(0, index * torrent.pieceLength - file.offset);
2242
+ const end = Math.min(fileLength, (index + 1) * torrent.pieceLength - file.offset);
2243
+ const location = store?.locationOf?.(index) ?? (torrent.bitfield?.get(index) ? "available" : "missing");
2244
+ const previous = residence.at(-1);
2245
+ if (previous?.location === location && previous.end === start) previous.end = end;
2246
+ else residence.push({ start, end, location });
2247
+ }
2236
2248
 
2237
2249
  return {
2238
2250
  ...base,
2239
2251
  fileProgress: fileLength > 0 ? Math.max(0, Math.min(1, fileDownloaded / fileLength)) : 0,
2240
2252
  fileDownloaded,
2241
2253
  fileLength,
2254
+ residence,
2242
2255
  // Resume window (ahead of the anchor): bytes needed vs downloaded, plus
2243
2256
  // the anchor itself so the caller can pin it for the rest of one episode.
2244
2257
  resumeNeededBytes: resume ? resume.totalBytes : null,