@torrent-tv/proxy 2.57.0 → 2.57.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.57.0",
3
+ "version": "2.57.1",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -34,6 +34,29 @@ const ID_CODEC_PRIVATE = 0x63a2;
34
34
  const ID_LANGUAGE = 0x22b59c;
35
35
  const ID_NAME = 0x536e;
36
36
  const ID_FLAG_DEFAULT = 0x88;
37
+ /**
38
+ * The rest of what a TrackEntry says about itself, RFC 9559 §5.1.4.1. Read
39
+ * because the file states them and a releaser's own wording in `Name` is the
40
+ * only thing we had before: "fors" and "SDH" in a menu were whatever text
41
+ * someone happened to type.
42
+ *
43
+ * `FlagEnabled` defaults to 1 and means "the track is usable"; a track that
44
+ * says 0 is counted but not offered. `FlagForced` applies only to subtitles and
45
+ * defaults to 0. `FlagHearingImpaired` is set "if and only if the track is
46
+ * suitable for users with hearing impairments". `FlagVisualImpaired`,
47
+ * `FlagOriginal` and `FlagCommentary` bear on the AUDIO choice and are read
48
+ * with that work, not here — see roadmap item 55.
49
+ */
50
+ const ID_FLAG_ENABLED = 0xb9;
51
+ const ID_FLAG_FORCED = 0x55aa;
52
+ const ID_FLAG_HEARING_IMPAIRED = 0x55ab;
53
+ /**
54
+ * The language as RFC 5646 writes it. The specification is a MUST: "If this
55
+ * element is used, then any Language elements used in the same TrackEntry MUST
56
+ * be ignored" — so where both are present, this one is the answer and the
57
+ * three-letter code is not.
58
+ */
59
+ const ID_LANGUAGE_BCP47 = 0x22b59d;
37
60
  const ID_CUES = 0x1c53bb6b;
38
61
  const ID_CUE_POINT = 0xbb;
39
62
  const ID_CUE_TRACK_POSITIONS = 0xb7;
@@ -69,9 +92,18 @@ const TEXT_CODECS = new Set(["S_TEXT/UTF8", "S_TEXT/ASS", "S_TEXT/SSA"]);
69
92
  * ever names. Text tracks alone are not a numbering: a file whose PGS track
70
93
  * comes first would have every text track one lower here than in the browser.
71
94
  * @property {string} codecId
72
- * @property {string} language - The three-letter code the file declares.
95
+ * @property {string} language - The language the file declares: its RFC 5646
96
+ * tag where it writes one, and the three-letter code otherwise. The
97
+ * specification requires that order — where `LanguageBCP47` is present, the
98
+ * `Language` element MUST be ignored.
99
+ * @property {string} languageBcp47 - The RFC 5646 tag alone, or "".
73
100
  * @property {string} name - What the file calls the track, if anything.
74
101
  * @property {boolean} isDefault
102
+ * @property {boolean} isForced - `FlagForced`: the track carries what a viewer
103
+ * needs even when they asked for no subtitles — signs, and dialogue in
104
+ * another language. It does NOT carry the film's own dialogue.
105
+ * @property {boolean} isHearingImpaired - `FlagHearingImpaired`: suitable for
106
+ * viewers who cannot hear, so it carries non-speech sound as well as speech.
75
107
  * @property {string} codecPrivate - The ASS/SSA header, base64, or "".
76
108
  * @property {number[]} clusterPositions - File offsets of clusters whose cue
77
109
  * points name this track, ascending. Empty when the file indexes only its
@@ -156,6 +188,12 @@ export async function readSubtitlePlan(readRange, fileSize) {
156
188
  // amounts to, and whether the file said anything at all.
157
189
  let isDefault = true;
158
190
  let declaresDefault = false;
191
+ // Defaults straight from RFC 9559: a track is usable and not forced unless
192
+ // the file says otherwise, and the impaired flags are absent until claimed.
193
+ let isEnabled = true;
194
+ let isForced = false;
195
+ let isHearingImpaired = false;
196
+ let languageBcp47 = "";
159
197
  for (const field of iterateElements(head, entry.dataOffset, entryEnd)) {
160
198
  if (field.id === ID_TRACK_NUMBER) {
161
199
  trackNumber = readUint(head, field.dataOffset, field.size);
@@ -165,11 +203,23 @@ export async function readSubtitlePlan(readRange, fileSize) {
165
203
  codecId = readString(head, field);
166
204
  } else if (field.id === ID_LANGUAGE) {
167
205
  language = readString(head, field);
206
+ } else if (field.id === ID_LANGUAGE_BCP47) {
207
+ languageBcp47 = readString(head, field);
168
208
  } else if (field.id === ID_NAME) {
169
209
  name = readString(head, field);
170
210
  } else if (field.id === ID_FLAG_DEFAULT) {
171
211
  isDefault = readUint(head, field.dataOffset, field.size) === 1;
172
212
  declaresDefault = true;
213
+ } else if (field.id === ID_FLAG_ENABLED) {
214
+ // An element written with zero length carries its default, which for
215
+ // this one is 1 — so an empty element must not read as "unusable", and
216
+ // neither must a value outside the declared 0-1 range. Only an explicit
217
+ // zero takes a track away.
218
+ isEnabled = field.size === 0 || readUint(head, field.dataOffset, field.size) !== 0;
219
+ } else if (field.id === ID_FLAG_FORCED) {
220
+ isForced = field.size > 0 && readUint(head, field.dataOffset, field.size) !== 0;
221
+ } else if (field.id === ID_FLAG_HEARING_IMPAIRED) {
222
+ isHearingImpaired = field.size > 0 && readUint(head, field.dataOffset, field.size) !== 0;
173
223
  } else if (field.id === ID_CODEC_PRIVATE) {
174
224
  codecPrivate = head.toString("base64", field.dataOffset, field.dataOffset + field.size);
175
225
  }
@@ -177,17 +227,46 @@ export async function readSubtitlePlan(readRange, fileSize) {
177
227
  if (type !== TRACK_TYPE_SUBTITLE || trackNumber === null) {
178
228
  continue;
179
229
  }
180
- declared.push({ trackNumber, codecId, language, name, isDefault, declaresDefault });
181
- if (!TEXT_CODECS.has(codecId)) {
230
+ // A track the file marks unusable is still COUNTED. FlagEnabled says "the
231
+ // track is usable", and a player should not offer it — but ffmpeg does not
232
+ // drop it: `matroskadec.c` parses `MATROSKA_ID_TRACKFLAGENABLED` as
233
+ // `EBML_NONE`, reading the element and keeping nothing, so the stream is
234
+ // created and numbered like any other. Leaving it out of this list would
235
+ // therefore shift `declaredIndex` off ffmpeg's `0:s:N` for every track
236
+ // after it, which is the numbering defect this file was fixed for a day
237
+ // earlier. It is counted here and refused where it is offered instead.
238
+ //
239
+ // `language` here stays the three-letter code, because this list exists to
240
+ // be lined up against ffmpeg's banner, which prints that code. The RFC 5646
241
+ // tag rides beside it for whoever displays the track.
242
+ declared.push({
243
+ trackNumber,
244
+ codecId,
245
+ language,
246
+ languageBcp47,
247
+ name,
248
+ isDefault,
249
+ declaresDefault,
250
+ isEnabled,
251
+ isForced,
252
+ isHearingImpaired
253
+ });
254
+ if (!TEXT_CODECS.has(codecId) || !isEnabled) {
182
255
  continue;
183
256
  }
184
257
  tracks.push({
185
258
  trackNumber,
186
259
  declaredIndex: declared.length - 1,
187
260
  codecId,
188
- language,
261
+ // This list is ours and is not compared with ffmpeg's, so it carries the
262
+ // language the file states most precisely: where RFC 5646 is written, the
263
+ // three-letter code MUST be ignored.
264
+ language: languageBcp47 || language,
265
+ languageBcp47,
189
266
  name,
190
267
  isDefault,
268
+ isForced,
269
+ isHearingImpaired,
191
270
  codecPrivate,
192
271
  clusterPositions: []
193
272
  });
@@ -58,16 +58,33 @@ export const PROBE_INTERVAL_MS = 500;
58
58
  * bytes away. Add one round trip for the echo to come back. A probe is behind
59
59
  * only when it is later than that.
60
60
  *
61
- * @param {{ queuedBytes: number, bytesPerSecond: number, rttMs: number, intervalMs?: number }} state
61
+ * There is a third term, and leaving it out made this worse than the constant
62
+ * it replaced. The browser answers on ITS own schedule, not ours: it batches
63
+ * what it has seen and echoes on a timer, and that timer is throttled to about
64
+ * once a second whenever the tab is hidden. So with an empty queue the
65
+ * allowance collapsed to one probe and the bound to half a second, while echoes
66
+ * legitimately arrived every second - measured 2026-08-27, 67 `association-
67
+ * stopped` and 66 `reverse-direction-gone` against 84 `flowing` on a connection
68
+ * carrying 3.4 MB/s with every queue at zero. The peer's own cadence is
69
+ * measurable on the same connection, so it is measured and added rather than
70
+ * assumed.
71
+ *
72
+ * @param {{ queuedBytes: number, bytesPerSecond: number, rttMs: number, echoIntervalMs?: number, intervalMs?: number }} state
62
73
  * @returns {number | null} Probes that may legitimately be outstanding, or null
63
74
  * when no rate has been measured yet and nothing can be said.
64
75
  */
65
- export function allowedGap({ queuedBytes, bytesPerSecond, rttMs, intervalMs = PROBE_INTERVAL_MS }) {
76
+ export function allowedGap({
77
+ queuedBytes,
78
+ bytesPerSecond,
79
+ rttMs,
80
+ echoIntervalMs = 0,
81
+ intervalMs = PROBE_INTERVAL_MS
82
+ }) {
66
83
  if (!(bytesPerSecond > 0) || !(intervalMs > 0)) {
67
84
  return null;
68
85
  }
69
86
  const drainMs = (Math.max(queuedBytes, 0) / bytesPerSecond) * 1000;
70
- const waitMs = drainMs + Math.max(rttMs, 0);
87
+ const waitMs = drainMs + Math.max(rttMs, 0) + Math.max(echoIntervalMs, 0);
71
88
  // At least one: a probe sent and not yet echoed is the ordinary state.
72
89
  return Math.max(1, Math.ceil(waitMs / intervalMs));
73
90
  }
@@ -231,7 +248,13 @@ export function createDeliveryProbe({ log, intervalMs = PROBE_INTERVAL_MS, readD
231
248
  } catch {
232
249
  queuedBytes = 0;
233
250
  }
234
- const allowance = allowedGap({ queuedBytes, bytesPerSecond, rttMs, intervalMs });
251
+ const allowance = allowedGap({
252
+ queuedBytes,
253
+ bytesPerSecond,
254
+ rttMs,
255
+ echoIntervalMs: connection.echoIntervalMs,
256
+ intervalMs
257
+ });
235
258
  // Several channels can carry one label only in malformed cases; the
236
259
  // larger allowance is the safer of the two.
237
260
  const held = allowed.get(label);
@@ -252,7 +275,9 @@ export function createDeliveryProbe({ log, intervalMs = PROBE_INTERVAL_MS, readD
252
275
  echoes: connection.echoes,
253
276
  echoAgeMs: connection.echoAt === 0 ? null : now - connection.echoAt,
254
277
  allowed,
255
- echoStaleMs: widest > 0 ? widest * intervalMs + rttMs : 0
278
+ // Same arithmetic for the echo's own age: the peer cannot answer sooner
279
+ // than its own cadence allows, and a hidden tab's is about a second.
280
+ echoStaleMs: widest > 0 ? widest * intervalMs + rttMs + connection.echoIntervalMs : 0
256
281
  });
257
282
  if (verdict !== connection.verdict || now - connection.reportedAt >= REPORT_INTERVAL_MS) {
258
283
  connection.verdict = verdict;
@@ -268,6 +293,10 @@ export function createDeliveryProbe({ log, intervalMs = PROBE_INTERVAL_MS, readD
268
293
  connection = {
269
294
  id: sessionId,
270
295
  tag,
296
+ // The longest this peer has ever taken between two echoes. Its own
297
+ // schedule, measured rather than assumed - a hidden tab answers
298
+ // about once a second because the browser throttles the timer.
299
+ echoIntervalMs: 0,
271
300
  channels: new Map(),
272
301
  seq: 0,
273
302
  sentAt: 0,
@@ -321,7 +350,14 @@ export function createDeliveryProbe({ log, intervalMs = PROBE_INTERVAL_MS, readD
321
350
  }
322
351
  }
323
352
  }
324
- connection.echoAt = Date.now();
353
+ const now = Date.now();
354
+ if (connection.echoAt !== 0) {
355
+ const sinceLast = now - connection.echoAt;
356
+ if (sinceLast > connection.echoIntervalMs) {
357
+ connection.echoIntervalMs = sinceLast;
358
+ }
359
+ }
360
+ connection.echoAt = now;
325
361
  connection.echoes += 1;
326
362
  },
327
363
 
@@ -95,7 +95,20 @@ export function mergeContainerSubtitleFlags(bannerTracks, declared) {
95
95
  const banner = Array.isArray(bannerTracks) ? bannerTracks : [];
96
96
  const container = Array.isArray(declared) ? declared : [];
97
97
  const undecided = () => ({
98
- tracks: banner.map((track) => ({ ...track, declaresDefault: false }))
98
+ // The container reading could not be lined up, so nothing of it is used —
99
+ // including the flags, which would otherwise be attributed to the wrong
100
+ // track.
101
+ tracks: banner.map((track) => ({
102
+ ...track,
103
+ declaresDefault: false,
104
+ isForced: false,
105
+ isHearingImpaired: false,
106
+ // Not "the container says this track is unusable" — nothing of the
107
+ // container is being used here. A track is offered unless it was read to
108
+ // say otherwise.
109
+ isEnabled: true,
110
+ languageBcp47: ""
111
+ }))
99
112
  });
100
113
  if (container.length === 0) {
101
114
  return { ...undecided(), aligned: false, reason: "the container declares no subtitle track" };
@@ -123,7 +136,20 @@ export function mergeContainerSubtitleFlags(bannerTracks, declared) {
123
136
  tracks: banner.map((track, order) => ({
124
137
  ...track,
125
138
  isDefault: container[order].isDefault === true,
126
- declaresDefault: container[order].declaresDefault === true
139
+ declaresDefault: container[order].declaresDefault === true,
140
+ // Read from the file rather than guessed from the track's name. Both are
141
+ // stated by the container itself (RFC 9559 §5.1.4.1) and neither reaches
142
+ // ffmpeg's `-i` banner, which is where every other field here comes from.
143
+ isForced: container[order].isForced === true,
144
+ isHearingImpaired: container[order].isHearingImpaired === true,
145
+ // FlagEnabled, so the browser can leave an unusable track out of the
146
+ // menu. It stays in this list and keeps its number: ffmpeg creates a
147
+ // stream for it either way.
148
+ isEnabled: container[order].isEnabled !== false,
149
+ // The RFC 5646 tag, where the file writes one. Kept beside the code
150
+ // rather than replacing it: what this list is aligned against is ffmpeg's
151
+ // banner, which prints the three-letter form.
152
+ languageBcp47: typeof container[order].languageBcp47 === "string" ? container[order].languageBcp47 : ""
127
153
  })),
128
154
  aligned: true,
129
155
  reason: ""
@@ -122,3 +122,37 @@ test("a burst big enough to explain the lag is not called a stopped association"
122
122
  );
123
123
  assert.equal(verdict, "flowing");
124
124
  });
125
+
126
+ test("the peer's own answering cadence counts toward the allowance", () => {
127
+ // Field case 2026-08-27: queues empty, 3.4 MB/s crossing, tab hidden so the
128
+ // browser echoed about once a second. Without the peer's cadence the
129
+ // allowance is one probe and every other line read `association-stopped`.
130
+ const withoutCadence = allowedGap({
131
+ queuedBytes: 0,
132
+ bytesPerSecond: 3.4 * 1024 * 1024,
133
+ rttMs: 9
134
+ });
135
+ assert.equal(withoutCadence, 1);
136
+ const withCadence = allowedGap({
137
+ queuedBytes: 0,
138
+ bytesPerSecond: 3.4 * 1024 * 1024,
139
+ rttMs: 9,
140
+ echoIntervalMs: 1000
141
+ });
142
+ assert.ok(withCadence >= 3, `a second of cadence must allow more than ${withCadence}`);
143
+ const allowed = Object.fromEntries(ALL.map((label) => [label, withCadence]));
144
+ const { verdict } = readProbeState(
145
+ state({ proxy: 98, "proxy-control": 98, "proxy-fast": 98 }, { allowed, echoAgeMs: 977 })
146
+ );
147
+ assert.equal(verdict, "flowing");
148
+ });
149
+
150
+ test("a stale echo is judged against the peer's cadence, not a fixed half second", () => {
151
+ // The same numbers with the cadence unknown must still be able to say the
152
+ // reverse direction is gone — the bound rises with the cadence, it does not
153
+ // disappear.
154
+ const { verdict } = readProbeState(
155
+ state({ proxy: 99, "proxy-control": 99, "proxy-fast": 99 }, { echoAgeMs: 60_000, echoStaleMs: 2000 })
156
+ );
157
+ assert.equal(verdict, "reverse-direction-gone");
158
+ });
@@ -37,6 +37,10 @@ const ID_TRACK_NUMBER = 0xd7;
37
37
  const ID_TRACK_TYPE = 0x83;
38
38
  const ID_CODEC_ID = 0x86;
39
39
  const ID_LANGUAGE = 0x22b59c;
40
+ const ID_LANGUAGE_BCP47 = 0x22b59d;
41
+ const ID_FLAG_ENABLED = 0xb9;
42
+ const ID_FLAG_FORCED = 0x55aa;
43
+ const ID_FLAG_HEARING_IMPAIRED = 0x55ab;
40
44
  const ID_CUES = 0x1c53bb6b;
41
45
  const ID_CUE_POINT = 0xbb;
42
46
  const ID_CUE_TIME = 0xb3;
@@ -95,13 +99,26 @@ function uint32Element(id, value) {
95
99
  return element(id, payload);
96
100
  }
97
101
 
98
- function trackEntry({ number, type, codecId, language }) {
99
- return element(ID_TRACK_ENTRY, Buffer.concat([
102
+ function trackEntry({ number, type, codecId, language, flags = {}, languageBcp47 = null }) {
103
+ const parts = [
100
104
  uintElement(ID_TRACK_NUMBER, number),
101
105
  uintElement(ID_TRACK_TYPE, type),
102
106
  stringElement(ID_CODEC_ID, codecId),
103
107
  stringElement(ID_LANGUAGE, language)
104
- ]));
108
+ ];
109
+ if (languageBcp47 !== null) {
110
+ parts.push(stringElement(ID_LANGUAGE_BCP47, languageBcp47));
111
+ }
112
+ for (const [id, value] of [
113
+ [ID_FLAG_ENABLED, flags.enabled],
114
+ [ID_FLAG_FORCED, flags.forced],
115
+ [ID_FLAG_HEARING_IMPAIRED, flags.hearingImpaired]
116
+ ]) {
117
+ if (value !== undefined) {
118
+ parts.push(uintElement(id, value ? 1 : 0));
119
+ }
120
+ }
121
+ return element(ID_TRACK_ENTRY, Buffer.concat(parts));
105
122
  }
106
123
 
107
124
  /**
@@ -260,3 +277,94 @@ test("two walks of one file at the same time read each cluster once", async () =
260
277
  );
261
278
  forgetSubtitles(sourceKey);
262
279
  });
280
+
281
+ /**
282
+ * A file whose subtitle tracks carry the flags RFC 9559 defines for them: one
283
+ * forced, one for viewers who cannot hear, one the file marks unusable, and one
284
+ * writing its language as RFC 5646 alongside the three-letter code.
285
+ *
286
+ * @returns {Buffer}
287
+ */
288
+ function fileWithFlags() {
289
+ const info = element(ID_INFO, uintElement(ID_TIMESTAMP_SCALE, 1_000_000));
290
+ const tracks = element(ID_TRACKS, Buffer.concat([
291
+ trackEntry({ number: 1, type: 1, codecId: "V_MPEG4/ISO/AVC", language: "und" }),
292
+ trackEntry({ number: 2, type: 17, codecId: "S_TEXT/UTF8", language: "rus", flags: { forced: true } }),
293
+ trackEntry({ number: 3, type: 17, codecId: "S_TEXT/UTF8", language: "eng", flags: { hearingImpaired: true } }),
294
+ trackEntry({ number: 4, type: 17, codecId: "S_TEXT/UTF8", language: "fre", flags: { enabled: false } }),
295
+ trackEntry({ number: 5, type: 17, codecId: "S_TEXT/ASS", language: "por", languageBcp47: "pt-BR" })
296
+ ]));
297
+ const seekEntry = (targetId, position) => element(ID_SEEK, Buffer.concat([
298
+ element(ID_SEEK_ID, idBytes(targetId)),
299
+ uint32Element(ID_SEEK_POSITION, position)
300
+ ]));
301
+ const seekHeadWith = (infoAt, tracksAt) => element(ID_SEEK_HEAD, Buffer.concat([
302
+ seekEntry(ID_INFO, infoAt),
303
+ seekEntry(ID_TRACKS, tracksAt)
304
+ ]));
305
+ const headLength = seekHeadWith(0, 0).length;
306
+ const segmentPayload = Buffer.concat([
307
+ seekHeadWith(headLength, headLength + info.length),
308
+ info,
309
+ tracks
310
+ ]);
311
+ const ebml = element(ID_EBML, Buffer.from([0x42, 0x86, 0x81, 0x01]));
312
+ return Buffer.concat([ebml, element(ID_SEGMENT, segmentPayload)]);
313
+ }
314
+
315
+ test("the flags the file states about a track are read, not guessed from its name", async () => {
316
+ const plan = await readSubtitlePlan(readerOver(fileWithFlags()), fileWithFlags().length);
317
+
318
+ const forced = plan.tracks.find((track) => track.trackNumber === 2);
319
+ assert.equal(forced.isForced, true, "FlagForced 0x55AA");
320
+ assert.equal(forced.isHearingImpaired, false);
321
+
322
+ const sdh = plan.tracks.find((track) => track.trackNumber === 3);
323
+ assert.equal(sdh.isHearingImpaired, true, "FlagHearingImpaired 0x55AB");
324
+ assert.equal(sdh.isForced, false);
325
+ });
326
+
327
+ test("a track the file marks unusable is not offered, but is still counted", async () => {
328
+ // FlagEnabled (0xB9): "Set to 1 if the track is usable." Track 4 says 0, so
329
+ // it is not offered — but it KEEPS its place in the numbering, because ffmpeg
330
+ // keeps it: `matroskadec.c` parses MATROSKA_ID_TRACKFLAGENABLED as EBML_NONE,
331
+ // reading the element and storing nothing, so the stream is created and gets
332
+ // its own `0:s:N`. Dropping it here would shift every track after it.
333
+ const plan = await readSubtitlePlan(readerOver(fileWithFlags()), fileWithFlags().length);
334
+
335
+ assert.equal(plan.tracks.some((track) => track.trackNumber === 4), false, "not offered for extraction");
336
+ const counted = plan.declared.find((track) => track.trackNumber === 4);
337
+ assert.ok(counted, "still declared, so the numbering does not move");
338
+ assert.equal(counted.isEnabled, false);
339
+ });
340
+
341
+ test("an unusable track keeps its place, so the tracks after it keep theirs", async () => {
342
+ const plan = await readSubtitlePlan(readerOver(fileWithFlags()), fileWithFlags().length);
343
+
344
+ // s:0 forced, s:1 SDH, s:2 the unusable one, s:3 the Brazilian track.
345
+ assert.deepEqual(
346
+ plan.tracks.map((track) => [track.trackNumber, track.declaredIndex]),
347
+ [[2, 0], [3, 1], [5, 3]]
348
+ );
349
+ });
350
+
351
+ test("the list ffmpeg is lined up against still speaks ffmpeg's language codes", async () => {
352
+ // `declared` exists to be paired with the `-i` banner, which prints the
353
+ // three-letter code; reporting "pt-BR" there would break the pairing and cost
354
+ // the FlagDefault reading with it.
355
+ const plan = await readSubtitlePlan(readerOver(fileWithFlags()), fileWithFlags().length);
356
+
357
+ const declared = plan.declared.find((track) => track.trackNumber === 5);
358
+ assert.equal(declared.language, "por");
359
+ assert.equal(declared.languageBcp47, "pt-BR");
360
+ });
361
+
362
+ test("where the file writes RFC 5646, that is the language", async () => {
363
+ // "If this element is used, then any Language elements used in the same
364
+ // TrackEntry MUST be ignored."
365
+ const plan = await readSubtitlePlan(readerOver(fileWithFlags()), fileWithFlags().length);
366
+
367
+ const track = plan.tracks.find((entry) => entry.trackNumber === 5);
368
+ assert.equal(track.language, "pt-BR", "not the three-letter por");
369
+ assert.equal(track.languageBcp47, "pt-BR");
370
+ });