@torrent-tv/proxy 2.74.1 → 2.76.0
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 +1504 -1453
- package/CLAUDE.md +19 -6
- package/biome.json +182 -1
- package/docs/container-architecture.md +27 -6
- package/docs/encode-run-state.md +1 -1
- package/knip.json +14 -0
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +1 -1
- package/routes/api/transcode-sessions/post.js +185 -185
- package/routes/api/transcode-sessions/progress/get.js +5 -1
- package/routes/transcode/audio-file/get.js +11 -1
- package/routes/transcode/audio-warm/get.js +11 -1
- package/routes/transcode/session-file/get.js +1 -1
- package/routes/transcode/variant-file/get.js +10 -1
- package/scripts/render-run-graph.js +2 -2
- package/server.js +25 -0
- package/services/audio-inventory.js +9 -214
- package/services/container/AviContainer.js +266 -81
- package/services/container/Container.js +281 -80
- package/services/container/ContainerFactory.js +67 -0
- package/services/container/MatroskaContainer.js +327 -8
- package/services/container/Mp4Container.js +373 -27
- package/services/container/SubtitleFileContainer.js +0 -1
- package/services/controllers/SubtitleController.js +128 -128
- package/services/demand/index.js +7 -10
- package/services/download/registry.js +0 -14
- package/services/encode/CoverageMap.js +281 -0
- package/services/encode/EncodePlan.js +255 -0
- package/services/encode/EncodeRun.js +587 -0
- package/services/encode/Encoder.js +84 -0
- package/services/encode/NvencEncoder.js +45 -0
- package/services/encode/QsvEncoder.js +47 -0
- package/services/encode/SegmentDemand.js +0 -0
- package/services/encode/SegmentStore.js +529 -0
- package/services/encode/SoftwareEncoder.js +111 -0
- package/services/encode/V4l2m2mEncoder.js +53 -0
- package/services/encode/VaapiEncoder.js +53 -0
- package/services/encode/args.js +200 -0
- package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
- package/services/encode/index.js +9 -0
- package/services/encode/run-command.js +647 -0
- package/services/hls-session-manager.js +11073 -10711
- package/services/hwaccel.js +1688 -1992
- package/services/orchestrators/EncodeOrchestrator.js +359 -0
- package/services/output/LiveOutputs.js +213 -0
- package/services/output/Output.js +94 -0
- package/services/output/OutputSpec.js +195 -0
- package/services/output/Timeline.js +220 -0
- package/services/output/index.js +1 -0
- package/services/output/ladder.js +26 -0
- package/services/playback-planner.js +806 -747
- package/services/produced-index.js +222 -300
- package/services/source/SourceFile.js +346 -0
- package/services/{sidecar-files.js → torrent/files.js} +107 -11
- package/services/torrent/naming.js +619 -0
- package/services/torrent-worker/client.js +10 -0
- package/services/torrent-worker/container-tracks.js +71 -43
- package/services/torrent-worker/pool-adapter.js +370 -333
- package/services/torrent-worker/protocol.js +7 -0
- package/services/torrent-worker/subtitle-cues.js +549 -549
- package/services/torrent-worker/worker.js +18 -0
- package/services/tracks/AudioTrack.js +131 -40
- package/services/tracks/TextSubtitleTrack.js +287 -287
- package/services/tracks/index.js +15 -14
- package/services/viewer/Viewer.js +145 -0
- package/services/viewer/Viewers.js +124 -0
- package/test/audio-inventory.test.js +176 -177
- package/test/auto-quality-step.test.js +508 -506
- package/test/behind-head-repair.test.js +17 -7
- package/test/coverage-map.test.js +153 -0
- package/test/cut-times-timeline.test.js +6 -5
- package/test/cuts-follow-published-grid.test.js +4 -4
- package/test/decode-cost.test.js +31 -12
- package/test/encode-exit.test.js +1 -1
- package/test/encode-orchestrator.test.js +196 -0
- package/test/encode-plan.test.js +245 -0
- package/test/encode-run-state.test.js +2 -2
- package/test/encode-run.test.js +168 -0
- package/test/encoder-kinds.test.js +122 -0
- package/test/held-request-width.test.js +9 -3
- package/test/helpers/encode-run.js +128 -0
- package/test/keyframe-index-accuracy.test.js +19 -12
- package/test/keyframes-belong-to-the-file.test.js +132 -0
- package/test/matroska-cues-track.test.js +192 -192
- package/test/mp4-composition-times.test.js +0 -0
- package/test/orchestrator-wired.test.js +164 -0
- package/test/output-shape.test.js +68 -0
- package/test/output-spec.test.js +157 -0
- package/test/produced-copy-choice.test.js +58 -92
- package/test/produced-index.test.js +142 -188
- package/test/quality-variants.test.js +1079 -1075
- package/test/run-graph-drift.test.js +1 -1
- package/test/run-intervals.test.js +329 -0
- package/test/run-position-follows-published-grid.test.js +4 -4
- package/test/seek-landing.test.js +8 -8
- package/test/seek-target-not-superseded.test.js +21 -9
- package/test/segment-demand.test.js +82 -0
- package/test/segment-serve-wiring.test.js +47 -52
- package/test/segment-store.test.js +187 -0
- package/test/segments-are-shared.test.js +175 -0
- package/test/sidecar-naming.test.js +142 -0
- package/test/source-file.test.js +133 -0
- package/test/stale-request-after-seek.test.js +18 -12
- package/test/subtitle-language.test.js +252 -252
- package/test/timeline.test.js +95 -0
- package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
- package/test/torrent-naming.test.js +255 -0
- package/test/tracks-begin-together.test.js +44 -32
- package/test/two-viewers-one-picture.test.js +347 -0
- package/test/video-facts.test.js +102 -0
- package/test/viewer-outputs.test.js +273 -0
- package/test/viewer.test.js +91 -0
- package/utils/perf.js +1 -63
- package/services/container/index.js +0 -6
- package/services/container-index/avi.js +0 -167
- package/services/container-index/index.js +0 -118
- package/services/container-index/matroska.js +0 -336
- package/services/container-index/mp4.js +0 -358
- package/services/controllers/index.js +0 -2
- package/services/download/index.js +0 -8
- package/services/orchestrators/index.js +0 -2
- /package/services/{container-index → container}/ebml-reader.js +0 -0
- /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
- /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What a file NAME says about a track shipped beside the video.
|
|
3
|
+
*
|
|
4
|
+
* A name is written by the person who made the file, and reading it costs
|
|
5
|
+
* nothing. Content detection is a guess — and a guess that is measurably wrong
|
|
6
|
+
* on short text (`research/subtitle-language-ass-markup-2026-09-01.md` §6) — so
|
|
7
|
+
* every name we can read is one track that never has to be guessed at.
|
|
8
|
+
*
|
|
9
|
+
* The grammar here is the UNION of what five players already implement, and
|
|
10
|
+
* every rule in it is in at least two of them. Nothing is invented. Sources,
|
|
11
|
+
* all read 2026-09-01 and quoted in
|
|
12
|
+
* `research/sidecar-naming-conventions-2026-09-01.md`:
|
|
13
|
+
*
|
|
14
|
+
* - **Jellyfin** (`Emby.Naming/ExternalFiles/ExternalPathParser.cs`,
|
|
15
|
+
* `Common/NamingOptions.cs`): the `.` delimiter, the three flag lists, tokens
|
|
16
|
+
* read from the END backwards, the last language winning, the `hi`/Hindi
|
|
17
|
+
* collision resolved by whether another language is present, and leftover
|
|
18
|
+
* text becoming the stream title.
|
|
19
|
+
* - **Plex** ("Adding Local Subtitles to Your Media"): the language code is
|
|
20
|
+
* "ISO-639-1 (2-letter) or ISO-639-2/B (3-letter)", flags after it.
|
|
21
|
+
* - **Kodi** (wiki, "Subtitles"): `<movie name>.<language>.<ext>`, where the
|
|
22
|
+
* language "can be an ISO 639-1 or ISO 639-2 code, a BCP 47 tag (with `-`
|
|
23
|
+
* characters replaced with `_`) or the English name of a language"; the
|
|
24
|
+
* forced flag may be separated by a dot, a space or a dash.
|
|
25
|
+
* - **mpv** (`misc/language.c`): the delimiter is taken from the LAST
|
|
26
|
+
* character — `(` when the name ends `)`, `[` when it ends `]` — which covers
|
|
27
|
+
* `Movie (Russian)` and `Movie [rus]` with one rule instead of a list of
|
|
28
|
+
* bracket shapes; and BCP-47 shape, primary subtag of 2 or 3 letters.
|
|
29
|
+
* - **VLC** (`src/libvlc-module.c`): the folders that mean "subtitles live
|
|
30
|
+
* here" and NOT a language — its default `sub-autodetect-path` is
|
|
31
|
+
* `./Subtitles, ./subtitles, ./Subs, ./subs`.
|
|
32
|
+
*
|
|
33
|
+
* Used for BOTH subtitles and soundtracks, because both are the same question
|
|
34
|
+
* about the same kind of path.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One language, with every spelling a release might use for it.
|
|
39
|
+
*
|
|
40
|
+
* `aliases` carries: the ISO 639-1 code, BOTH ISO 639-2 sets where they differ
|
|
41
|
+
* (Plex names the bibliographic set, ffmpeg and Matroska write either), the
|
|
42
|
+
* English name, and — for the languages these trackers actually carry — the
|
|
43
|
+
* name in Russian, because a Russian release names its folders in Russian.
|
|
44
|
+
*
|
|
45
|
+
* @type {Array<{ code: string, name: string, aliases: string[] }>}
|
|
46
|
+
*/
|
|
47
|
+
const LANGUAGES = [
|
|
48
|
+
{ code: "en", name: "English", aliases: ["en", "eng", "english", "английский", "англ", "англи", "английские", "инглиш"] },
|
|
49
|
+
{ code: "ru", name: "Russian", aliases: ["ru", "rus", "russian", "русский", "рус", "русские", "русск"] },
|
|
50
|
+
{ code: "uk", name: "Ukrainian", aliases: ["uk", "ukr", "ukrainian", "українська", "укр", "украинский"] },
|
|
51
|
+
{ code: "be", name: "Belarusian", aliases: ["be", "bel", "belarusian", "беларуская", "белорусский"] },
|
|
52
|
+
{ code: "ja", name: "Japanese", aliases: ["ja", "jpn", "japanese", "японский", "яп", "японские"] },
|
|
53
|
+
{ code: "ko", name: "Korean", aliases: ["ko", "kor", "korean", "корейский"] },
|
|
54
|
+
{ code: "zh", name: "Chinese", aliases: ["zh", "chi", "zho", "chinese", "китайский"] },
|
|
55
|
+
{ code: "zh-Hans", name: "Chinese (Simplified)", aliases: ["chs", "hans", "simplified"] },
|
|
56
|
+
{ code: "zh-Hant", name: "Chinese (Traditional)", aliases: ["cht", "hant", "traditional"] },
|
|
57
|
+
{ code: "es", name: "Spanish", aliases: ["es", "spa", "spanish", "espanol", "español", "испанский"] },
|
|
58
|
+
{ code: "fr", name: "French", aliases: ["fr", "fre", "fra", "french", "francais", "français", "французский"] },
|
|
59
|
+
{ code: "de", name: "German", aliases: ["de", "ger", "deu", "german", "deutsch", "немецкий"] },
|
|
60
|
+
{ code: "it", name: "Italian", aliases: ["it", "ita", "italian", "italiano", "итальянский"] },
|
|
61
|
+
{ code: "pt", name: "Portuguese", aliases: ["pt", "por", "portuguese", "portugues", "português", "португальский"] },
|
|
62
|
+
{ code: "pl", name: "Polish", aliases: ["pl", "pol", "polish", "polski", "польский"] },
|
|
63
|
+
{ code: "nl", name: "Dutch", aliases: ["nl", "dut", "nld", "dutch", "nederlands", "голландский", "нидерландский"] },
|
|
64
|
+
{ code: "ar", name: "Arabic", aliases: ["ar", "ara", "arabic", "арабский"] },
|
|
65
|
+
{ code: "tr", name: "Turkish", aliases: ["tr", "tur", "turkish", "türkçe", "турецкий"] },
|
|
66
|
+
{ code: "vi", name: "Vietnamese", aliases: ["vi", "vie", "vietnamese", "вьетнамский"] },
|
|
67
|
+
{ code: "th", name: "Thai", aliases: ["th", "tha", "thai", "тайский"] },
|
|
68
|
+
{ code: "hi", name: "Hindi", aliases: ["hi", "hin", "hindi", "хинди"] },
|
|
69
|
+
{ code: "id", name: "Indonesian", aliases: ["id", "ind", "indonesian", "индонезийский"] },
|
|
70
|
+
{ code: "ms", name: "Malay", aliases: ["ms", "may", "msa", "malay", "малайский"] },
|
|
71
|
+
{ code: "cs", name: "Czech", aliases: ["cs", "cze", "ces", "czech", "чешский"] },
|
|
72
|
+
{ code: "sk", name: "Slovak", aliases: ["sk", "slo", "slk", "slovak", "словацкий"] },
|
|
73
|
+
{ code: "ro", name: "Romanian", aliases: ["ro", "rum", "ron", "romanian", "румынский"] },
|
|
74
|
+
{ code: "hu", name: "Hungarian", aliases: ["hu", "hun", "hungarian", "венгерский"] },
|
|
75
|
+
{ code: "sr", name: "Serbian", aliases: ["sr", "srp", "serbian", "српски", "сербский"] },
|
|
76
|
+
{ code: "hr", name: "Croatian", aliases: ["hr", "hrv", "croatian", "hrvatski", "хорватский"] },
|
|
77
|
+
{ code: "bs", name: "Bosnian", aliases: ["bs", "bos", "bosnian", "боснийский"] },
|
|
78
|
+
{ code: "sl", name: "Slovenian", aliases: ["sl", "slv", "slovenian", "словенский"] },
|
|
79
|
+
{ code: "bg", name: "Bulgarian", aliases: ["bg", "bul", "bulgarian", "български", "болгарский"] },
|
|
80
|
+
{ code: "mk", name: "Macedonian", aliases: ["mk", "mac", "mkd", "macedonian", "македонский"] },
|
|
81
|
+
{ code: "el", name: "Greek", aliases: ["el", "gre", "ell", "greek", "греческий"] },
|
|
82
|
+
{ code: "he", name: "Hebrew", aliases: ["he", "heb", "hebrew", "иврит"] },
|
|
83
|
+
{ code: "da", name: "Danish", aliases: ["da", "dan", "danish", "датский"] },
|
|
84
|
+
{ code: "fi", name: "Finnish", aliases: ["fi", "fin", "finnish", "suomi", "финский"] },
|
|
85
|
+
{ code: "no", name: "Norwegian", aliases: ["no", "nor", "nob", "nno", "norwegian", "норвежский"] },
|
|
86
|
+
{ code: "sv", name: "Swedish", aliases: ["sv", "swe", "swedish", "svenska", "шведский"] },
|
|
87
|
+
{ code: "et", name: "Estonian", aliases: ["et", "est", "estonian", "эстонский"] },
|
|
88
|
+
{ code: "lv", name: "Latvian", aliases: ["lv", "lav", "latvian", "латышский"] },
|
|
89
|
+
{ code: "lt", name: "Lithuanian", aliases: ["lt", "lit", "lithuanian", "литовский"] },
|
|
90
|
+
{ code: "fa", name: "Persian", aliases: ["fa", "per", "fas", "persian", "farsi", "персидский"] },
|
|
91
|
+
{ code: "ka", name: "Georgian", aliases: ["ka", "geo", "kat", "georgian", "грузинский"] },
|
|
92
|
+
{ code: "hy", name: "Armenian", aliases: ["hy", "arm", "hye", "armenian", "армянский"] },
|
|
93
|
+
{ code: "az", name: "Azerbaijani", aliases: ["az", "aze", "azerbaijani", "азербайджанский"] },
|
|
94
|
+
{ code: "kk", name: "Kazakh", aliases: ["kk", "kaz", "kazakh", "казахский"] },
|
|
95
|
+
{ code: "is", name: "Icelandic", aliases: ["is", "ice", "isl", "icelandic", "исландский"] },
|
|
96
|
+
{ code: "sq", name: "Albanian", aliases: ["sq", "alb", "sqi", "albanian", "албанский"] },
|
|
97
|
+
{ code: "eu", name: "Basque", aliases: ["eu", "baq", "eus", "basque"] },
|
|
98
|
+
{ code: "cy", name: "Welsh", aliases: ["cy", "wel", "cym", "welsh"] },
|
|
99
|
+
{ code: "ca", name: "Catalan", aliases: ["ca", "cat", "catalan", "català"] },
|
|
100
|
+
{ code: "gl", name: "Galician", aliases: ["gl", "glg", "galician"] },
|
|
101
|
+
{ code: "my", name: "Burmese", aliases: ["my", "bur", "mya", "burmese"] },
|
|
102
|
+
{ code: "bo", name: "Tibetan", aliases: ["bo", "tib", "bod", "tibetan"] },
|
|
103
|
+
{ code: "mi", name: "Maori", aliases: ["mi", "mao", "mri", "maori"] }
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
/** alias → { code, name }. Built once. */
|
|
107
|
+
const BY_ALIAS = new Map();
|
|
108
|
+
for (const language of LANGUAGES) {
|
|
109
|
+
for (const alias of language.aliases) {
|
|
110
|
+
BY_ALIAS.set(alias.toLowerCase(), { code: language.code, name: language.name });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Words that appear where a language would and are NOT one.
|
|
116
|
+
*
|
|
117
|
+
* Two kinds, and both are needed. A folder can say only that a KIND of track
|
|
118
|
+
* lives in it — VLC's own default search path is `./Subtitles, ./subtitles,
|
|
119
|
+
* ./Subs, ./subs`, and a Russian release writes the same thing in Russian. And
|
|
120
|
+
* a name can carry a technical word that happens to sit where a language token
|
|
121
|
+
* does. Today `Subs/` is safe only because no such word happens to be in the
|
|
122
|
+
* language table; that is an accident, and this makes it a statement.
|
|
123
|
+
*/
|
|
124
|
+
const NEUTRAL_WORDS = new Set([
|
|
125
|
+
"sub", "subs", "subtitle", "subtitles", "subtitulos", "sous", "titres",
|
|
126
|
+
"sound", "audio", "dub", "dubs", "dubbed", "track", "tracks", "voice",
|
|
127
|
+
"субтитры", "субтитр", "суб", "сабы", "саб", "титры",
|
|
128
|
+
"звук", "звуковая", "звуковые", "дорожка", "дорожки", "аудио",
|
|
129
|
+
"озвучка", "озвучание", "перевод", "переводы", "дубляж"
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
/** Flags, per Jellyfin's `NamingOptions` and mpv's `language.c`. */
|
|
133
|
+
const FORCED_FLAGS = new Set(["forced", "foreign"]);
|
|
134
|
+
const HEARING_IMPAIRED_FLAGS = new Set(["sdh", "cc", "hi"]);
|
|
135
|
+
const DEFAULT_FLAGS = new Set(["default"]);
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Look up one bare token as a language.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} token
|
|
141
|
+
* @returns {{ code: string, name: string } | null}
|
|
142
|
+
*/
|
|
143
|
+
export function lookupLanguage(token) {
|
|
144
|
+
if (typeof token !== "string") {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return BY_ALIAS.get(token.trim().toLowerCase()) ?? null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Is this a word that names a KIND of track rather than a language?
|
|
152
|
+
*
|
|
153
|
+
* @param {string} token
|
|
154
|
+
* @returns {boolean}
|
|
155
|
+
*/
|
|
156
|
+
export function isNeutralWord(token) {
|
|
157
|
+
return typeof token === "string" && NEUTRAL_WORDS.has(token.trim().toLowerCase());
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Read one token as a language tag, keeping a region or script subtag.
|
|
162
|
+
*
|
|
163
|
+
* Kodi writes BCP-47 with `_` where the standard has `-` (its own example is
|
|
164
|
+
* `The Matrix.zh_yue.srt`), so both separators are accepted. The whole tag is
|
|
165
|
+
* KEPT — `pt-BR` against `pt-PT` is a distinction a viewer can see — while the
|
|
166
|
+
* lookup is done on the primary subtag alone. Per BCP-47 and mpv's reading of
|
|
167
|
+
* it: the primary subtag is 2 or 3 letters, later subtags are 1 to 8
|
|
168
|
+
* alphanumerics.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} token
|
|
171
|
+
* @returns {{ code: string, name: string } | null}
|
|
172
|
+
*/
|
|
173
|
+
export function parseLanguageTag(token) {
|
|
174
|
+
if (typeof token !== "string") {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const trimmed = token.trim();
|
|
178
|
+
if (trimmed.length === 0 || isNeutralWord(trimmed)) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
const whole = lookupLanguage(trimmed);
|
|
182
|
+
if (whole) {
|
|
183
|
+
return whole;
|
|
184
|
+
}
|
|
185
|
+
const parts = trimmed.split(/[-_]/);
|
|
186
|
+
if (parts.length < 2) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
const primary = lookupLanguage(parts[0]);
|
|
190
|
+
if (!primary || !/^[A-Za-z]{2,3}$/.test(parts[0])) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
for (const subtag of parts.slice(1)) {
|
|
194
|
+
if (!/^[A-Za-z0-9]{1,8}$/.test(subtag)) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const base = primary.code.split("-")[0];
|
|
199
|
+
return { code: [base, ...parts.slice(1)].join("-"), name: primary.name };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Split a base name into the tokens a language or a flag could be.
|
|
204
|
+
*
|
|
205
|
+
* The delimiter follows mpv: `.` normally, but `(` when the name ends with `)`
|
|
206
|
+
* and `[` when it ends with `]`, so `Movie (Russian)` and `Movie [rus]` need no
|
|
207
|
+
* rule of their own. `_` is accepted alongside `.` because releases on these
|
|
208
|
+
* trackers use it interchangeably (`Avatar_The_Last_Airbender_S02E01_1080p_rus`),
|
|
209
|
+
* and neither character occurs inside a language code or a flag word.
|
|
210
|
+
*
|
|
211
|
+
* A trailing flag separated by a space or a dash is peeled off the last token,
|
|
212
|
+
* which is Kodi's rule for the forced flag: it "can use a '.' (dot), ' '
|
|
213
|
+
* (space) or '-' (dash) to separate them from the name of the movie and
|
|
214
|
+
* language of the subtitles".
|
|
215
|
+
*
|
|
216
|
+
* @param {string} baseName - File name with its extension already removed.
|
|
217
|
+
* @returns {string[]} Tokens in name order.
|
|
218
|
+
*/
|
|
219
|
+
export function tokenizeName(baseName) {
|
|
220
|
+
const text = String(baseName ?? "").trim();
|
|
221
|
+
if (text.length === 0) {
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
// A bracketed tail is a token of its own; what comes BEFORE it is still split
|
|
225
|
+
// the ordinary way, so `Film.rus [1080p]` yields `rus` rather than `.rus`.
|
|
226
|
+
const bracketed = /^(.*)[([]([^()[\]]*)[)\]]$/.exec(text);
|
|
227
|
+
const head = bracketed ? bracketed[1] : text;
|
|
228
|
+
const tail = bracketed ? splitTrailingFlags(bracketed[2]) : [];
|
|
229
|
+
const parts = head.split(/[._]/);
|
|
230
|
+
const last = parts.pop() ?? "";
|
|
231
|
+
return [...parts, ...splitTrailingFlags(last), ...tail];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Peel flag words off the end of one token when a space or a dash separates
|
|
236
|
+
* them — `"English Forced"` is two tokens, `"English"` is one.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} token
|
|
239
|
+
* @returns {string[]}
|
|
240
|
+
*/
|
|
241
|
+
function splitTrailingFlags(token) {
|
|
242
|
+
let text = String(token ?? "").trim();
|
|
243
|
+
const flags = [];
|
|
244
|
+
for (;;) {
|
|
245
|
+
// Only a trailing FLAG is peeled. The token is otherwise left exactly as it
|
|
246
|
+
// is, because a dash is also what BCP-47 puts between subtags: splitting
|
|
247
|
+
// `pt-BR` here would destroy the region before anything could read it.
|
|
248
|
+
const match = /^(.*\S)[ -]+([A-Za-z]+)$/.exec(text);
|
|
249
|
+
if (!match) {
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
const candidate = match[2].toLowerCase();
|
|
253
|
+
if (!FORCED_FLAGS.has(candidate) && !HEARING_IMPAIRED_FLAGS.has(candidate) && !DEFAULT_FLAGS.has(candidate)) {
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
flags.unshift(match[2]);
|
|
257
|
+
text = match[1];
|
|
258
|
+
}
|
|
259
|
+
return [text, ...flags].filter((word) => word.length > 0);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* @typedef {object} SidecarNameReading
|
|
264
|
+
* @property {{ code: string, name: string } | null} language
|
|
265
|
+
* @property {boolean} isForced
|
|
266
|
+
* @property {boolean} isHearingImpaired
|
|
267
|
+
* @property {boolean} isDefault
|
|
268
|
+
* @property {string | null} title - Text that is neither a language nor a flag.
|
|
269
|
+
*/
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Read the language and the flags a sidecar's file name states.
|
|
273
|
+
*
|
|
274
|
+
* Two readings, and which one applies depends on whether the picture's name is
|
|
275
|
+
* known — this is Jellyfin's own safeguard, and without it a name is dangerous
|
|
276
|
+
* to read. Jellyfin parses only the part of the name that FOLLOWS the video's
|
|
277
|
+
* name, because everything before it belongs to the film: `Movie.It.Follows.
|
|
278
|
+
* 2014.srt` would otherwise be read as Italian.
|
|
279
|
+
*
|
|
280
|
+
* 1. **The name begins with the video's name.** Everything after it is the
|
|
281
|
+
* releaser speaking about this track, so every token in it is read, the last
|
|
282
|
+
* language token wins, and what is neither language nor flag is the title.
|
|
283
|
+
* 2. **It does not** (or the video is unknown). Then tokens are read from the
|
|
284
|
+
* END backwards and reading STOPS at the first token that is neither a flag
|
|
285
|
+
* nor a language — mpv's rule, and the only safe one when the boundary
|
|
286
|
+
* between the film's name and the track's description is not known.
|
|
287
|
+
*
|
|
288
|
+
* @param {string} baseName - The sidecar's file name, extension removed.
|
|
289
|
+
* @param {string} [videoBaseName] - The picture's file name, extension removed.
|
|
290
|
+
* @returns {SidecarNameReading}
|
|
291
|
+
*/
|
|
292
|
+
/**
|
|
293
|
+
* Whether a sidecar's base name continues the video's base name — and continues
|
|
294
|
+
* it at a token boundary.
|
|
295
|
+
*
|
|
296
|
+
* The boundary is the whole of the rule's safety. Without it, "begins with the
|
|
297
|
+
* video's name" pairs `Film.20.rus.srt` with `Film.2.mkv`: measured, and the
|
|
298
|
+
* grammar then reports the leftover `0` as the track's title, which is the tell
|
|
299
|
+
* that the remainder is a fragment of another film's name rather than anything
|
|
300
|
+
* a releaser wrote about this track.
|
|
301
|
+
*
|
|
302
|
+
* The delimiters are the ones every surveyed player splits on — `.`, `_`, `-`,
|
|
303
|
+
* space — plus mpv's bracket rule, so `Movie (Russian).srt` and `Movie [rus].srt`
|
|
304
|
+
* are read as continuations too.
|
|
305
|
+
*
|
|
306
|
+
* @param {string} base - The sidecar's base name.
|
|
307
|
+
* @param {string} video - The picture's base name.
|
|
308
|
+
* @returns {boolean}
|
|
309
|
+
*/
|
|
310
|
+
export function nameFollows(base, video) {
|
|
311
|
+
if (video.length === 0 || base.length < video.length) {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
if (!base.toLowerCase().startsWith(video.toLowerCase())) {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
if (base.length === video.length) {
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
return /^[._\-\s([]/.test(base.slice(video.length));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function readSidecarName(baseName, videoBaseName = "") {
|
|
324
|
+
const reading = {
|
|
325
|
+
language: null,
|
|
326
|
+
isForced: false,
|
|
327
|
+
isHearingImpaired: false,
|
|
328
|
+
isDefault: false,
|
|
329
|
+
title: null
|
|
330
|
+
};
|
|
331
|
+
const base = String(baseName ?? "").trim();
|
|
332
|
+
if (base.length === 0) {
|
|
333
|
+
return reading;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const video = String(videoBaseName ?? "").trim();
|
|
337
|
+
const followsVideo = nameFollows(base, video);
|
|
338
|
+
const text = followsVideo ? base.slice(video.length) : base;
|
|
339
|
+
const tokens = tokenizeName(text);
|
|
340
|
+
if (tokens.length === 0) {
|
|
341
|
+
return reading;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const leftovers = [];
|
|
345
|
+
let sawHiToken = false;
|
|
346
|
+
// Backwards: the LAST language in the name is the one that counts, which is
|
|
347
|
+
// Jellyfin's documented rule and what its parser does by walking this way.
|
|
348
|
+
for (let index = tokens.length - 1; index >= 0; index -= 1) {
|
|
349
|
+
const token = tokens[index];
|
|
350
|
+
const word = token.trim().toLowerCase();
|
|
351
|
+
if (word.length === 0) {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (FORCED_FLAGS.has(word)) {
|
|
355
|
+
reading.isForced = true;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (DEFAULT_FLAGS.has(word)) {
|
|
359
|
+
reading.isDefault = true;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
// `hi` is both Hindi and "hearing impaired". Which it is depends on whether
|
|
363
|
+
// another language is named — Jellyfin: "`hi` by itself will resolve as a
|
|
364
|
+
// Hindi language track, while `hi` in conjunction with another language
|
|
365
|
+
// identifier … will use the other language and tag it as hearing impaired."
|
|
366
|
+
// It cannot be settled here, because the other language may still be to the
|
|
367
|
+
// left, so it is remembered and settled below.
|
|
368
|
+
if (word === "hi") {
|
|
369
|
+
sawHiToken = true;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (HEARING_IMPAIRED_FLAGS.has(word)) {
|
|
373
|
+
reading.isHearingImpaired = true;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const language = parseLanguageTag(token);
|
|
377
|
+
if (language) {
|
|
378
|
+
if (reading.language === null) {
|
|
379
|
+
reading.language = language;
|
|
380
|
+
}
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (!followsVideo) {
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
leftovers.unshift(token.trim());
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (sawHiToken) {
|
|
390
|
+
if (reading.language === null) {
|
|
391
|
+
reading.language = lookupLanguage("hi");
|
|
392
|
+
} else {
|
|
393
|
+
reading.isHearingImpaired = true;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const title = leftovers.filter((part) => part.length > 0).join(" ").trim();
|
|
398
|
+
reading.title = title.length > 0 ? title : null;
|
|
399
|
+
return reading;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The language a path's FOLDERS state, looking from the innermost outwards.
|
|
404
|
+
*
|
|
405
|
+
* A whole segment is tried first (`ENG`, `Russian`, `рус`), then its words —
|
|
406
|
+
* which is what reads `Rus Sound` as Russian. A word is only accepted from a
|
|
407
|
+
* multi-word segment when it is at least three letters, because two-letter
|
|
408
|
+
* codes are also ordinary words (`no`, `id`, `it`) and a folder called
|
|
409
|
+
* `No Subs` does not mean Norwegian.
|
|
410
|
+
*
|
|
411
|
+
* A segment made only of words that name a KIND of track — `Subs`, `Субтитры` —
|
|
412
|
+
* states no language and is skipped rather than searched, so a future table
|
|
413
|
+
* entry cannot turn one into a language by accident.
|
|
414
|
+
*
|
|
415
|
+
* @param {string[]} folders - Folders above the file, outermost first.
|
|
416
|
+
* @returns {{ code: string, name: string } | null}
|
|
417
|
+
*/
|
|
418
|
+
export function languageFromFolderNames(folders) {
|
|
419
|
+
const segments = Array.isArray(folders) ? folders : [];
|
|
420
|
+
for (let index = segments.length - 1; index >= 0; index -= 1) {
|
|
421
|
+
const segment = String(segments[index] ?? "").trim();
|
|
422
|
+
if (segment.length === 0 || isNeutralWord(segment)) {
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
const whole = parseLanguageTag(segment);
|
|
426
|
+
if (whole) {
|
|
427
|
+
return whole;
|
|
428
|
+
}
|
|
429
|
+
const words = segment.split(/[^\p{L}]+/u).filter((word) => word.length >= 3);
|
|
430
|
+
for (const word of words) {
|
|
431
|
+
const found = lookupLanguage(word);
|
|
432
|
+
if (found) {
|
|
433
|
+
return found;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
// Reading a whole sidecar path: its name AND the folders above it.
|
|
442
|
+
//
|
|
443
|
+
// This half used to live in the browser (`subtitle-utils.js`), which is also
|
|
444
|
+
// where the pairing was decided — two answers about the same file, computed on
|
|
445
|
+
// two sides, compared nowhere. Measured 2026-09-04 over the 115 torrents in
|
|
446
|
+
// `Dropbox/trn`: 1249 video files, 1239 pairings agreed and 10 did not, every
|
|
447
|
+
// one of the ten a `<video base>.<language>.<ext>` name that the browser paired
|
|
448
|
+
// and the proxy did not. The proxy warms what it paired and the browser offers
|
|
449
|
+
// what it paired, so a disagreement reached the viewer as a track nobody had
|
|
450
|
+
// warmed — a wait of 27.7 s in the field measurement of 2026-08-31.
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Remove the file extension from a name.
|
|
455
|
+
*
|
|
456
|
+
* @param {string} name
|
|
457
|
+
* @returns {string}
|
|
458
|
+
*/
|
|
459
|
+
export function stripExtension(name) {
|
|
460
|
+
const text = typeof name === "string" ? name : "";
|
|
461
|
+
const dot = text.lastIndexOf(".");
|
|
462
|
+
return dot > 0 ? text.slice(0, dot) : text;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The bracketed groups of a name, in order.
|
|
467
|
+
*
|
|
468
|
+
* @param {string} text
|
|
469
|
+
* @returns {string[]}
|
|
470
|
+
*/
|
|
471
|
+
function bracketTokens(text) {
|
|
472
|
+
const source = typeof text === "string" ? text : "";
|
|
473
|
+
const tokens = [];
|
|
474
|
+
for (const match of source.matchAll(/\[([^\]]+)\]/g)) {
|
|
475
|
+
const token = match[1].trim();
|
|
476
|
+
if (token.length > 0) {
|
|
477
|
+
tokens.push(token);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return tokens;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Bracketed groups that describe the ENCODE rather than a team — resolution,
|
|
485
|
+
* codec, source, audio format, bit depth. A releaser's name is what is left
|
|
486
|
+
* after these are set aside.
|
|
487
|
+
*
|
|
488
|
+
* @param {string} token
|
|
489
|
+
* @returns {boolean}
|
|
490
|
+
*/
|
|
491
|
+
function isTechnicalToken(token) {
|
|
492
|
+
const text = String(token ?? "").trim().toLowerCase();
|
|
493
|
+
if (text.length === 0) {
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
// A release hash: `[78EFD746]`. It identifies the file, not its author.
|
|
497
|
+
if (/^[0-9a-f]{4,10}$/.test(text) && /\d/.test(text)) {
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
if (/^\d{3,4}[pi]$/.test(text) || /^\d{3,4}x\d{3,4}$/.test(text)) {
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
503
|
+
if (/^(x|h)\.?26[45]$/.test(text) || /^(hevc|avc|av1|vp9|xvid|divx)$/.test(text)) {
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
if (/^(aac|ac3|eac3|dts(-?hd)?|flac|mp3|opus|truehd|atmos|pcm|\d\.\d)$/.test(text)) {
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
509
|
+
if (/^(web-?rip|web-?dl|bd-?rip|blu-?ray|hdtv|dvd-?rip|remux|hdr\d*|dv|sdr|\d{1,2}bit)$/.test(text)) {
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
// A bracket that names a language names a language, not a team; and one that
|
|
513
|
+
// names the KIND of track (`[Subs]`) names neither.
|
|
514
|
+
return lookupLanguage(text) !== null || isNeutralWord(text);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* What is left of a name after the language and the flags, as a TITLE — or
|
|
519
|
+
* nothing, when what is left only describes the encode.
|
|
520
|
+
*
|
|
521
|
+
* Jellyfin keeps every leftover word ("Any arbitrary text not parsable to a
|
|
522
|
+
* language or flag will be combined and used as the title of the stream"), and
|
|
523
|
+
* on its own library that is right, because the part it reads is what the owner
|
|
524
|
+
* deliberately appended. A torrent is not that tidy: `Film.1080p.rus.srt` beside
|
|
525
|
+
* `Film.mkv` leaves `1080p`, and a track labelled "Russian (1080p)" is worse
|
|
526
|
+
* than one labelled "Russian".
|
|
527
|
+
*
|
|
528
|
+
* @param {string | null} title
|
|
529
|
+
* @returns {string | null}
|
|
530
|
+
*/
|
|
531
|
+
function titleOf(title) {
|
|
532
|
+
if (typeof title !== "string") {
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
const words = title.split(/\s+/).filter((word) => word.length > 0);
|
|
536
|
+
if (words.length === 0) {
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
// ALL of it, or none. A word is only weighed on its own when it stands on its
|
|
540
|
+
// own: inside a phrase, a word that happens to name a language is part of the
|
|
541
|
+
// phrase — `English Commentary` is a title, and dropping `English` from it
|
|
542
|
+
// would leave a label nobody wrote.
|
|
543
|
+
return words.every((word) => isTechnicalToken(word)) ? null : words.join(" ");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Who made a track that ships as its own file, when the torrent says so.
|
|
548
|
+
*
|
|
549
|
+
* The rule that keeps this honest: a bracketed group is only a releaser of the
|
|
550
|
+
* SIDECAR when it is not also in the video's own name. In the field case that
|
|
551
|
+
* settles it — a Russian dub named exactly like the picture,
|
|
552
|
+
* `[HorribleSubs] Drifters - 02 [1080p].mka`, sitting in `Rus Sound/` — every
|
|
553
|
+
* bracket it carries is the picture's, and attributing the dub to HorribleSubs
|
|
554
|
+
* would be inventing an author. The subtitles of the same release, in
|
|
555
|
+
* `Sub/[Stan WarHammer & Nesitach]/`, carry a bracket the video does not, and
|
|
556
|
+
* that one IS their author.
|
|
557
|
+
*
|
|
558
|
+
* The innermost folder is looked at before the file name, because that is where
|
|
559
|
+
* a release puts the team when several of them contributed.
|
|
560
|
+
*
|
|
561
|
+
* @param {object} params
|
|
562
|
+
* @param {string[]} params.folders - Folders above the file, innermost last.
|
|
563
|
+
* @param {string} params.fileName
|
|
564
|
+
* @param {string} [params.videoName] - The picture's own file name.
|
|
565
|
+
* @returns {string | null}
|
|
566
|
+
*/
|
|
567
|
+
export function releaserFrom({ folders, fileName, videoName = "" }) {
|
|
568
|
+
const shared = new Set(
|
|
569
|
+
bracketTokens(stripExtension(String(videoName ?? ""))).map((token) => token.toLowerCase())
|
|
570
|
+
);
|
|
571
|
+
const sources = [...(Array.isArray(folders) ? folders : [])].reverse();
|
|
572
|
+
sources.push(stripExtension(String(fileName ?? "")));
|
|
573
|
+
for (const source of sources) {
|
|
574
|
+
for (const token of bracketTokens(source)) {
|
|
575
|
+
if (shared.has(token.toLowerCase()) || isTechnicalToken(token)) {
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
return token;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Language, flags and releaser for a track that ships as its own file.
|
|
586
|
+
*
|
|
587
|
+
* Reading order, and the reason for it: the FILE NAME first, then the folders.
|
|
588
|
+
* All five players surveyed read the name
|
|
589
|
+
* (`research/sidecar-naming-conventions-2026-09-01.md`); only VLC and Kodi treat
|
|
590
|
+
* a folder as a place rather than as a statement about language. A name is also
|
|
591
|
+
* per-file where a folder is per-group, so where both speak the name is the more
|
|
592
|
+
* specific of the two.
|
|
593
|
+
*
|
|
594
|
+
* A soundtrack is named by the same conventions a subtitle is — Jellyfin's own
|
|
595
|
+
* example set carries `Film.en.ac3` and `Film.german.ac3` beside `Film.de.srt` —
|
|
596
|
+
* so it is the same grammar over the same path.
|
|
597
|
+
*
|
|
598
|
+
* @param {object} params
|
|
599
|
+
* @param {string[]} params.folders
|
|
600
|
+
* @param {string} params.fileName
|
|
601
|
+
* @param {string} [params.videoName]
|
|
602
|
+
* @returns {{ code: string | null, name: string | null, releaser: string | null,
|
|
603
|
+
* isForced: boolean, isHearingImpaired: boolean, isDefault: boolean }}
|
|
604
|
+
*/
|
|
605
|
+
export function sidecarNaming({ folders, fileName, videoName = "" }) {
|
|
606
|
+
const fromName = readSidecarName(
|
|
607
|
+
stripExtension(String(fileName ?? "")),
|
|
608
|
+
stripExtension(String(videoName ?? ""))
|
|
609
|
+
);
|
|
610
|
+
const language = fromName.language ?? languageFromFolderNames(folders);
|
|
611
|
+
return {
|
|
612
|
+
code: language?.code ?? null,
|
|
613
|
+
name: language?.name ?? null,
|
|
614
|
+
releaser: titleOf(fromName.title) ?? releaserFrom({ folders, fileName, videoName }),
|
|
615
|
+
isForced: fromName.isForced,
|
|
616
|
+
isHearingImpaired: fromName.isHearingImpaired,
|
|
617
|
+
isDefault: fromName.isDefault
|
|
618
|
+
};
|
|
619
|
+
}
|
|
@@ -360,6 +360,16 @@ export class TorrentWorkerClient {
|
|
|
360
360
|
return this.#caller.call(Command.CONTAINER_MEDIA_INFO, { sourceKey, fileIndex });
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Where one file's keyframes are, from the container's own table.
|
|
365
|
+
*
|
|
366
|
+
* @param {{ sourceKey: string, fileIndex: number }} params
|
|
367
|
+
* @returns {Promise<{ index: { times: number[], tolerance: number } | null }>}
|
|
368
|
+
*/
|
|
369
|
+
async getContainerKeyframes({ sourceKey, fileIndex }) {
|
|
370
|
+
return this.#caller.call(Command.CONTAINER_KEYFRAMES, { sourceKey, fileIndex });
|
|
371
|
+
}
|
|
372
|
+
|
|
363
373
|
/**
|
|
364
374
|
* Start fetching the region a viewer is about to resume at.
|
|
365
375
|
*
|