@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.
- package/CHANGELOG.md +16 -0
- package/assets/calibration/audio/NOTICE.md +19 -0
- package/assets/calibration/audio/aac.m4a +0 -0
- package/assets/calibration/audio/ac3.ac3 +0 -0
- package/assets/calibration/audio/alac.m4a +0 -0
- package/assets/calibration/audio/dts.dts +0 -0
- package/assets/calibration/audio/eac3.eac3 +0 -0
- package/assets/calibration/audio/flac.flac +0 -0
- package/assets/calibration/audio/manifest.json +98 -0
- package/assets/calibration/audio/mp2.mp2 +0 -0
- package/assets/calibration/audio/mp3.mp3 +0 -0
- package/assets/calibration/audio/opus.opus +0 -0
- package/assets/calibration/audio/pcm.wav +0 -0
- package/assets/calibration/audio/truehd.thd +0 -0
- package/assets/calibration/audio/vorbis.ogg +0 -0
- package/biome.json +1 -0
- package/docs/browser-proxy-contract.md +155 -133
- package/docs/playback-start-forecast.md +56 -0
- package/package.json +1 -1
- package/routes/api/transcode-sessions/post.js +5 -0
- package/routes/transcode/audio-warm/get.js +14 -3
- package/scripts/build-audio-samples.mjs +31 -0
- package/server.js +5 -0
- package/services/encode/EncodeRuns.js +3 -1
- package/services/encode/OutputOpening.js +40 -15
- package/services/encode/Renditions.js +129 -31
- package/services/encode/args.js +5 -3
- package/services/encode/audio-calibration.js +67 -0
- package/services/encode/audio-work-rate.js +15 -0
- package/services/encode/hwaccel.js +1 -1
- package/services/encode/quality/EncodeCost.js +29 -3
- package/services/encode/quality/link-budget.js +110 -15
- package/services/encode/quality/output-format.js +10 -6
- package/services/encode/segment-formats/fmp4.js +2 -0
- package/services/encode/segment-formats/mp4-boxes.js +12 -1
- package/services/media/audio-inventory.js +60 -6
- package/services/media/container/Container.js +7 -1
- package/services/media/container/Mp4Container.js +103 -3
- package/services/media/tracks/AudioTrack.js +12 -0
- package/services/media/tracks/audio-bound.js +233 -0
- package/services/server/ViewerRequests.js +30 -5
- package/services/server/playback-readiness.js +516 -708
- package/services/server/wire-outputs.js +31 -3
- package/services/storage/piece-store/shared-piece-store.js +6 -0
- package/services/storage/segment-store/SegmentStore.js +19 -4
- package/services/torrent/episode-naming.js +182 -182
- package/services/torrent/torrent-pool.js +13 -0
- package/test/audio-buffer-continuity.test.js +70 -70
- package/test/audio-calibration.test.js +23 -0
- package/test/episode-naming.test.js +131 -131
- package/test/fixtures/audio-frame-boundary.json +1 -1
- package/test/fmp4-presentation-clock.test.js +17 -2
- package/test/link-budget.test.js +7 -6
- package/test/one-output-one-session.test.js +77 -8
- package/test/playback-readiness.test.js +36 -6
- package/test/quality-variants.test.js +36 -8
- package/test/segment-store.test.js +15 -0
- package/test/serving-existing-output.test.js +3 -0
- package/test/soundtrack-rate-bound.test.js +289 -0
- package/test/source-files-route.test.js +113 -113
- 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.
|
|
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,
|