@torrent-tv/proxy 2.44.0 → 2.46.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.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Which subtitle track the FILE says to show, read from the file rather than
3
+ * from ffmpeg's description of it.
4
+ *
5
+ * Why this exists. The browser decides which subtitle track to turn on from
6
+ * `isDefault`, and until now that came from ffmpeg's `-i` banner, which prints
7
+ * `(default)`. In Matroska `FlagDefault` DEFAULTS TO 1 and ffmpeg has already
8
+ * applied that default by the time it prints — so a file whose muxer wrote the
9
+ * flag on no track arrives looking exactly like one that wrote it on every
10
+ * track: everything marked. The banner cannot tell the two apart, and the
11
+ * difference is the whole question, because one of them means "show this one"
12
+ * and the other means "the file has no opinion".
13
+ *
14
+ * The container itself can be asked, and the EBML reader already walks the
15
+ * Tracks element for subtitle extraction. What it now also records is whether
16
+ * the element was WRITTEN, which is the fact the banner destroys.
17
+ *
18
+ * The awkward part is lining the two readings up. ffmpeg numbers its subtitle
19
+ * streams `0:s:0`, `0:s:1`, … over EVERY subtitle stream, picture-based ones
20
+ * included, in the order the container declares them; the container reading is
21
+ * a list in that same order. So position is the correspondence — but a position
22
+ * match that is merely assumed is worth nothing, so it is CHECKED: each pair
23
+ * has to agree on language or on title. One pair that agrees on neither, or a
24
+ * length that differs, means the two readings are not describing the same
25
+ * thing in the same order, and then the container reading is not used at all.
26
+ */
27
+
28
+ /**
29
+ * Language codes that carry no information, and so cannot confirm a pairing.
30
+ *
31
+ * ffmpeg prints `und` for a stream with no language; Matroska's own default
32
+ * for `Language` is `eng`, which is why an absent element cannot be read as a
33
+ * statement either — but `eng` is also a real answer, so it is not listed here
34
+ * and is compared like any other.
35
+ */
36
+ const EMPTY_LANGUAGES = new Set(["", "und", "unknown"]);
37
+
38
+ /**
39
+ * @param {unknown} value
40
+ * @returns {string}
41
+ */
42
+ function normalise(value) {
43
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
44
+ }
45
+
46
+ /**
47
+ * Whether one banner stream and one container track can be the same track.
48
+ *
49
+ * Agreement on either the language or the name is enough; both being empty is
50
+ * not agreement, because two tracks that say nothing about themselves say
51
+ * nothing about their pairing either.
52
+ *
53
+ * @param {{ language?: string, title?: string }} banner
54
+ * @param {{ language?: string, name?: string }} container
55
+ * @returns {boolean}
56
+ */
57
+ export function pairingHolds(banner, container) {
58
+ const bannerLanguage = normalise(banner?.language);
59
+ const containerLanguage = normalise(container?.language);
60
+ if (
61
+ !EMPTY_LANGUAGES.has(bannerLanguage) &&
62
+ !EMPTY_LANGUAGES.has(containerLanguage) &&
63
+ bannerLanguage === containerLanguage
64
+ ) {
65
+ return true;
66
+ }
67
+ const bannerTitle = normalise(banner?.title);
68
+ const containerName = normalise(container?.name);
69
+ if (bannerTitle.length > 0 && bannerTitle === containerName) {
70
+ return true;
71
+ }
72
+ // Nothing to compare on either side. Not a disagreement — a file may name
73
+ // neither — so it does not break the alignment; it simply adds no support.
74
+ return (
75
+ (EMPTY_LANGUAGES.has(bannerLanguage) || EMPTY_LANGUAGES.has(containerLanguage)) &&
76
+ (bannerTitle.length === 0 || containerName.length === 0)
77
+ );
78
+ }
79
+
80
+ /**
81
+ * The banner's subtitle tracks, with what the container says about each.
82
+ *
83
+ * Every returned track gains `declaresDefault`: whether the FILE wrote the flag
84
+ * for it. When the container reading cannot be trusted — no declarations, a
85
+ * different number of them, or a pair that agrees on neither language nor name
86
+ * — every track gets `declaresDefault: false` and its `isDefault` is left as
87
+ * the banner had it. That is the honest answer for a file we cannot read this
88
+ * way: the container has not been heard from, so nothing is shown unasked.
89
+ *
90
+ * @param {Array<{ index?: number, language?: string, title?: string, isDefault?: boolean }>} bannerTracks
91
+ * @param {Array<{ language?: string, name?: string, isDefault?: boolean, declaresDefault?: boolean }>} declared
92
+ * @returns {{ tracks: object[], aligned: boolean, reason: string }}
93
+ */
94
+ export function mergeContainerSubtitleFlags(bannerTracks, declared) {
95
+ const banner = Array.isArray(bannerTracks) ? bannerTracks : [];
96
+ const container = Array.isArray(declared) ? declared : [];
97
+ const undecided = () => ({
98
+ tracks: banner.map((track) => ({ ...track, declaresDefault: false }))
99
+ });
100
+ if (container.length === 0) {
101
+ return { ...undecided(), aligned: false, reason: "the container declares no subtitle track" };
102
+ }
103
+ if (container.length !== banner.length) {
104
+ return {
105
+ ...undecided(),
106
+ aligned: false,
107
+ reason: `the container declares ${container.length} subtitle tracks and the probe found ${banner.length}`
108
+ };
109
+ }
110
+ for (const [order, track] of banner.entries()) {
111
+ if (!pairingHolds(track, container[order])) {
112
+ return {
113
+ ...undecided(),
114
+ aligned: false,
115
+ reason:
116
+ `subtitle ${order} is "${normalise(track?.title) || "-"}"/${normalise(track?.language) || "-"} ` +
117
+ `in the probe and "${normalise(container[order]?.name) || "-"}"/` +
118
+ `${normalise(container[order]?.language) || "-"} in the container`
119
+ };
120
+ }
121
+ }
122
+ return {
123
+ tracks: banner.map((track, order) => ({
124
+ ...track,
125
+ isDefault: container[order].isDefault === true,
126
+ declaresDefault: container[order].declaresDefault === true
127
+ })),
128
+ aligned: true,
129
+ reason: ""
130
+ };
131
+ }
@@ -157,6 +157,24 @@ export class WorkerTorrentPool {
157
157
  return Array.isArray(answer?.tracks) ? answer.tracks : [];
158
158
  }
159
159
 
160
+ /**
161
+ * What the container itself declares about its subtitle tracks, in its own
162
+ * order and including the picture-based ones — for lining up against
163
+ * ffmpeg's own numbering.
164
+ *
165
+ * @param {object} torrent
166
+ * @param {number} fileIndex
167
+ * @returns {Promise<object[]>}
168
+ */
169
+ async getDeclaredSubtitleTracks(torrent, fileIndex) {
170
+ const sourceKey = torrent?.sourceKey;
171
+ if (!sourceKey) {
172
+ return [];
173
+ }
174
+ const answer = await this.#client.getSubtitleTracks({ sourceKey, fileIndex });
175
+ return Array.isArray(answer?.declared) ? answer.declared : [];
176
+ }
177
+
160
178
  /**
161
179
  * The cues of one subtitle track that the downloaded clusters already carry.
162
180
  *
@@ -106,7 +106,12 @@ async function planFor(torrent, fileIndex, key) {
106
106
  return state.plan;
107
107
  }
108
108
  const file = torrent?.files?.[fileIndex];
109
- const empty = { tracks: [], secondsPerTick: 0.001, segmentDataOffset: 0 };
109
+ // `declared` is what the container itself says about its subtitle tracks, in
110
+ // its own order. Empty means the container said nothing — which is a real
111
+ // answer and not a missing one: nothing is then shown unasked. An MP4 has no
112
+ // element that means "show this subtitle track by default", so it declares
113
+ // nothing however many tracks it carries.
114
+ const empty = { tracks: [], declared: [], secondsPerTick: 0.001, segmentDataOffset: 0 };
110
115
  if (!file) {
111
116
  state.plan = empty;
112
117
  return state.plan;
@@ -331,6 +336,25 @@ export async function subtitleTracksOf(torrent, fileIndex, sourceKey) {
331
336
  }));
332
337
  }
333
338
 
339
+ /**
340
+ * What the container itself says about its subtitle tracks, in its own order
341
+ * and including the picture-based ones.
342
+ *
343
+ * Separate from `subtitleTracksOf`, which lists only what can be turned into
344
+ * WebVTT and is indexed by position in the subtitle API. This one exists to be
345
+ * lined up against ffmpeg's `0:s:N` numbering, which counts every subtitle
346
+ * stream, so leaving the picture ones out would shift it.
347
+ *
348
+ * @param {object} torrent
349
+ * @param {number} fileIndex
350
+ * @param {string} sourceKey
351
+ * @returns {Promise<object[]>}
352
+ */
353
+ export async function declaredSubtitleTracksOf(torrent, fileIndex, sourceKey) {
354
+ const plan = await planFor(torrent, fileIndex, `${sourceKey}:${fileIndex}`);
355
+ return plan?.declared ?? [];
356
+ }
357
+
334
358
  /**
335
359
  * Forget a file's cues — the torrent is gone, and holding them would keep the
336
360
  * text of a film nobody is watching.
@@ -27,7 +27,7 @@ import { parentPort, workerData } from "node:worker_threads";
27
27
  import { createSendStream } from "./channel.js";
28
28
  import { createFileClaims } from "./file-claims.js";
29
29
  import { readFragments, supplyFiguresFor } from "./piece-reader.js";
30
- import { cuesHeldFor, subtitleTracksOf } from "./subtitle-cues.js";
30
+ import { cuesHeldFor, declaredSubtitleTracksOf, subtitleTracksOf } from "./subtitle-cues.js";
31
31
  import { Command, Event } from "./protocol.js";
32
32
 
33
33
  // Imported dynamically, and that is load-bearing: static imports are RESOLVED
@@ -359,7 +359,10 @@ async function runCommand(command, params, id) {
359
359
 
360
360
  case Command.SUBTITLE_TRACKS: {
361
361
  const torrent = await requireTorrent(params.sourceKey);
362
- return { tracks: await subtitleTracksOf(torrent, params.fileIndex, params.sourceKey) };
362
+ return {
363
+ tracks: await subtitleTracksOf(torrent, params.fileIndex, params.sourceKey),
364
+ declared: await declaredSubtitleTracksOf(torrent, params.fileIndex, params.sourceKey)
365
+ };
363
366
  }
364
367
 
365
368
  case Command.SUBTITLE_CUES: {
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @file A run cuts where the player was told the cuts are.
3
+ *
4
+ * There are two boundary tables. The live one is corrected as produced segments
5
+ * reveal where the file's cuts truly are; the published one is the snapshot the
6
+ * playlist text was written from, and a player places every fragment by that
7
+ * text and nothing else. The cut list handed to ffmpeg used to come from the
8
+ * live table, so every correction moved the run away from the timeline the
9
+ * player is reading.
10
+ *
11
+ * Field 2026-08-20, `Minions.and.Monsters.1080p.mkv`: the picture's segments
12
+ * arrived a uniform 2.002 s before the times its playlist named — 119 of 125 of
13
+ * them — against the 0.5 s hls.js bridges. A fragment that does not land is
14
+ * fetched again, and on 2026-08-17 two of them were fetched 1908 times each.
15
+ */
16
+
17
+ import assert from "node:assert/strict";
18
+ import test from "node:test";
19
+
20
+ import { HlsSessionManager, segmentCutTimesFrom } from "../services/hls-session-manager.js";
21
+
22
+ /** What the playlist in the player's hands says. */
23
+ const PUBLISHED = [0, 4.004, 8.008, 12.012, 16.016, 20.02];
24
+ /** The same grid after produced segments moved two of its cuts. */
25
+ const CORRECTED = [0, 4.004, 6.006, 12.012, 14.014, 20.02];
26
+
27
+ /**
28
+ * A session that has published one grid and since corrected another.
29
+ *
30
+ * @returns {{ manager: HlsSessionManager, session: object }}
31
+ */
32
+ function sessionWithDriftedGrid() {
33
+ const manager = new HlsSessionManager({
34
+ enabled: true,
35
+ ffmpegBin: "ffmpeg",
36
+ localBindHost: "127.0.0.1",
37
+ localPort: 9090
38
+ });
39
+ const session = {
40
+ id: "picture",
41
+ segmentBoundaries: [...CORRECTED],
42
+ publishedBoundaries: [...PUBLISHED]
43
+ };
44
+ return { manager, session };
45
+ }
46
+
47
+ test("the cut list is the one the playlist was written from", () => {
48
+ const { manager, session } = sessionWithDriftedGrid();
49
+ const grid = manager.publishedGridFor(session);
50
+ assert.deepEqual(grid, PUBLISHED);
51
+ // Interior cuts of a run starting at #1, rebased on the run's own start
52
+ // (4.004 s), and stopping before the last entry, which is the file's end.
53
+ assert.deepEqual(
54
+ segmentCutTimesFrom(grid, 1).map((time) => Number(time.toFixed(3))),
55
+ [4.004, 8.008, 12.012]
56
+ );
57
+ });
58
+
59
+ test("a corrected grid does not move the cuts of a session already being read", () => {
60
+ const { manager, session } = sessionWithDriftedGrid();
61
+ const fromCorrected = segmentCutTimesFrom(session.segmentBoundaries, 1);
62
+ const fromPublished = segmentCutTimesFrom(manager.publishedGridFor(session), 1);
63
+ assert.notDeepEqual(fromCorrected, fromPublished);
64
+ // The gap the field measured: the corrected table would have cut #2 two
65
+ // seconds early, which is four times what a player bridges.
66
+ assert.equal(Number((fromPublished[0] - fromCorrected[0]).toFixed(3)), 2.002);
67
+ });
68
+
69
+ test("a session that published no grid falls back to the live one", () => {
70
+ const { manager } = sessionWithDriftedGrid();
71
+ const session = { id: "no-playlist", segmentBoundaries: [...CORRECTED], publishedBoundaries: [] };
72
+ assert.deepEqual(manager.publishedGridFor(session), CORRECTED);
73
+ });
@@ -17,6 +17,7 @@ import assert from "node:assert/strict";
17
17
  import { createRequire } from "node:module";
18
18
  import { spawn } from "node:child_process";
19
19
  import { mkdtemp, rm } from "node:fs/promises";
20
+ import { decodeCostOf, decodeFamilyOf } from "../services/decode-cost-fit.js";
20
21
  import os from "node:os";
21
22
  import path from "node:path";
22
23
  import {
@@ -282,8 +283,22 @@ test("a banner with no bitrate reads as no bitrate, not as zero", () => {
282
283
  test("the source's decode figures come off the probe, or not at all", () => {
283
284
  assert.deepEqual(sourceDecodeCharacteristics({ width: 1920, height: 1080, fps: 24, bitrateKbps: 8000 }), {
284
285
  megapixelsPerSecond: (1920 * 1080 * 24) / 1e6,
285
- megabitsPerSecond: 8
286
+ megabitsPerSecond: 8,
287
+ // Which measurement of this host applies. Absent on the probe means absent
288
+ // here — the caller then prices the source as H.264 8-bit, which is what
289
+ // every source was priced as before the model was fitted per family.
290
+ codec: "",
291
+ bitDepth: null
286
292
  });
293
+ assert.deepEqual(
294
+ sourceDecodeCharacteristics({ width: 1920, height: 1080, fps: 24, bitrateKbps: 8000, codec: "hevc", bitDepth: 10 }),
295
+ {
296
+ megapixelsPerSecond: (1920 * 1080 * 24) / 1e6,
297
+ megabitsPerSecond: 8,
298
+ codec: "hevc",
299
+ bitDepth: 10
300
+ }
301
+ );
287
302
  assert.equal(sourceDecodeCharacteristics({ width: 1920, height: 1080, fps: 24, bitrateKbps: null }), null);
288
303
  assert.equal(sourceDecodeCharacteristics(null), null);
289
304
  });
@@ -391,3 +406,52 @@ test("the OFFER drops the rungs the host cannot hold, and the master keeps addre
391
406
  "the copied height costs no encoder and stays; nothing re-encoded survives that supply"
392
407
  );
393
408
  });
409
+
410
+ test("a source is priced by its own codec family when that family was measured", () => {
411
+ // A model as the startup benchmark now returns it: H.264 terms at the top
412
+ // level, for a caller that knows nothing about codecs, and the measured
413
+ // families beside them.
414
+ const model = {
415
+ pixelTerm: 0.006, bitrateTerm: 0.012, constantTerm: 0,
416
+ families: {
417
+ h264: { pixelTerm: 0.006, bitrateTerm: 0.012, constantTerm: 0 },
418
+ hevc: { pixelTerm: 0.011, bitrateTerm: 0.020, constantTerm: 0 },
419
+ hevc10: { pixelTerm: 0.017, bitrateTerm: 0.026, constantTerm: 0 }
420
+ }
421
+ };
422
+ const rates = { megapixelsPerSecond: 50, megabitsPerSecond: 9 };
423
+ const asH264 = decodeCostOf(model, { ...rates, codec: "h264", bitDepth: 8 });
424
+ const asHevc = decodeCostOf(model, { ...rates, codec: "hevc", bitDepth: 8 });
425
+ const asHevc10 = decodeCostOf(model, { ...rates, codec: "hevc", bitDepth: 10 });
426
+ assert.equal(Number(asH264.toFixed(4)), 0.408);
427
+ assert.equal(Number(asHevc.toFixed(4)), 0.73);
428
+ assert.equal(Number(asHevc10.toFixed(4)), 1.084);
429
+ // The whole point: the same file costs more as HEVC than as H.264, and more
430
+ // again at ten bits. A single fit could not say that.
431
+ assert.ok(asHevc > asH264 && asHevc10 > asHevc);
432
+ });
433
+
434
+ test("a family with no clips is priced as H.264, and a model with no families still works", () => {
435
+ const withFamilies = {
436
+ pixelTerm: 0.006, bitrateTerm: 0.012, constantTerm: 0,
437
+ families: { h264: { pixelTerm: 0.006, bitrateTerm: 0.012, constantTerm: 0 } }
438
+ };
439
+ const rates = { megapixelsPerSecond: 50, megabitsPerSecond: 9 };
440
+ // AV1 has no set of its own yet.
441
+ assert.equal(
442
+ decodeCostOf(withFamilies, { ...rates, codec: "av1", bitDepth: 8 }),
443
+ decodeCostOf(withFamilies, { ...rates, codec: "h264", bitDepth: 8 })
444
+ );
445
+ // And a flat model — every model before this release — is unchanged.
446
+ const flat = { pixelTerm: 0.006, bitrateTerm: 0.012, constantTerm: 0 };
447
+ assert.equal(decodeCostOf(flat, { ...rates, codec: "hevc", bitDepth: 10 }), 0.408);
448
+ });
449
+
450
+ test("the family is chosen by codec and depth, and unknown names fall to H.264", () => {
451
+ assert.equal(decodeFamilyOf({ codec: "hevc", bitDepth: 8 }), "hevc");
452
+ assert.equal(decodeFamilyOf({ codec: "HEVC", bitDepth: 10 }), "hevc10");
453
+ assert.equal(decodeFamilyOf({ codec: "h265", bitDepth: 12 }), "hevc10");
454
+ assert.equal(decodeFamilyOf({ codec: "h264", bitDepth: 10 }), "h264");
455
+ assert.equal(decodeFamilyOf({ codec: "vc1", bitDepth: null }), "h264");
456
+ assert.equal(decodeFamilyOf({}), "h264");
457
+ });
@@ -0,0 +1,97 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { mergeContainerSubtitleFlags, pairingHolds } from "../services/subtitle-defaults.js";
4
+
5
+ test("a flag the container wrote is carried through, and one it did not is not", () => {
6
+ const banner = [
7
+ { index: 0, language: "rus", title: "Forced", isDefault: true },
8
+ { index: 1, language: "eng", title: "SDH", isDefault: true }
9
+ ];
10
+ const declared = [
11
+ { language: "rus", name: "Forced", isDefault: true, declaresDefault: true },
12
+ { language: "eng", name: "SDH", isDefault: true, declaresDefault: false }
13
+ ];
14
+ const merged = mergeContainerSubtitleFlags(banner, declared);
15
+ assert.equal(merged.aligned, true);
16
+ assert.deepEqual(
17
+ merged.tracks.map((track) => [track.isDefault, track.declaresDefault]),
18
+ [[true, true], [true, false]]
19
+ );
20
+ });
21
+
22
+ test("a file that wrote nothing is reported as having written nothing, though ffmpeg marked everything", () => {
23
+ // This is the case the banner cannot express: `FlagDefault` defaults to 1, so
24
+ // ffmpeg prints `(default)` against every track of a file that chose none.
25
+ const banner = [
26
+ { index: 0, language: "rus", title: "", isDefault: true },
27
+ { index: 1, language: "eng", title: "", isDefault: true }
28
+ ];
29
+ const declared = [
30
+ { language: "rus", name: "", isDefault: true, declaresDefault: false },
31
+ { language: "eng", name: "", isDefault: true, declaresDefault: false }
32
+ ];
33
+ const merged = mergeContainerSubtitleFlags(banner, declared);
34
+ assert.equal(merged.aligned, true);
35
+ assert.deepEqual(merged.tracks.map((track) => track.declaresDefault), [false, false]);
36
+ });
37
+
38
+ test("the container's own answer overrides the banner's, in both directions", () => {
39
+ const banner = [
40
+ { index: 0, language: "rus", title: "a", isDefault: true },
41
+ { index: 1, language: "eng", title: "b", isDefault: false }
42
+ ];
43
+ const declared = [
44
+ { language: "rus", name: "a", isDefault: false, declaresDefault: true },
45
+ { language: "eng", name: "b", isDefault: true, declaresDefault: true }
46
+ ];
47
+ const merged = mergeContainerSubtitleFlags(banner, declared);
48
+ assert.deepEqual(merged.tracks.map((track) => track.isDefault), [false, true]);
49
+ });
50
+
51
+ test("a count that differs means the two readings are not the same list", () => {
52
+ // The container declares a picture track the probe did not report; position
53
+ // is then not the correspondence and nothing may be read across.
54
+ const banner = [{ index: 0, language: "rus", title: "", isDefault: true }];
55
+ const declared = [
56
+ { language: "rus", name: "", isDefault: false, declaresDefault: true },
57
+ { language: "eng", name: "", isDefault: true, declaresDefault: true }
58
+ ];
59
+ const merged = mergeContainerSubtitleFlags(banner, declared);
60
+ assert.equal(merged.aligned, false);
61
+ assert.match(merged.reason, /declares 2 subtitle tracks and the probe found 1/);
62
+ assert.deepEqual(merged.tracks.map((track) => [track.isDefault, track.declaresDefault]), [[true, false]]);
63
+ });
64
+
65
+ test("a pair agreeing on neither language nor name refuses the whole alignment", () => {
66
+ const banner = [
67
+ { index: 0, language: "rus", title: "", isDefault: true },
68
+ { index: 1, language: "eng", title: "", isDefault: true }
69
+ ];
70
+ const declared = [
71
+ { language: "eng", name: "", isDefault: true, declaresDefault: true },
72
+ { language: "rus", name: "", isDefault: false, declaresDefault: true }
73
+ ];
74
+ const merged = mergeContainerSubtitleFlags(banner, declared);
75
+ assert.equal(merged.aligned, false);
76
+ assert.deepEqual(merged.tracks.map((track) => track.declaresDefault), [false, false]);
77
+ });
78
+
79
+ test("a container that declares nothing leaves the banner alone", () => {
80
+ const banner = [{ index: 0, language: "rus", title: "", isDefault: true }];
81
+ const merged = mergeContainerSubtitleFlags(banner, []);
82
+ assert.equal(merged.aligned, false);
83
+ assert.equal(merged.tracks[0].isDefault, true);
84
+ assert.equal(merged.tracks[0].declaresDefault, false);
85
+ });
86
+
87
+ test("a name confirms a pairing when the languages are unstated", () => {
88
+ assert.equal(pairingHolds({ language: "und", title: "Forced" }, { language: "", name: "Forced" }), true);
89
+ });
90
+
91
+ test("two tracks that say nothing about themselves do not break the alignment", () => {
92
+ assert.equal(pairingHolds({ language: "und", title: "" }, { language: "", name: "" }), true);
93
+ });
94
+
95
+ test("a stated language that differs is a disagreement", () => {
96
+ assert.equal(pairingHolds({ language: "rus", title: "" }, { language: "eng", name: "" }), false);
97
+ });