""" Choosing the audio track from the player. A dubbed film carries several audio tracks and the node used to map the first one unconditionally, so it played in whichever language was muxed first. Three things have to hold on this side: **The selector exists only where the node said there was something to choose.** It is drawn from the `audio_tracks` list in `stream_init` and from nothing else — there is no version check in the player — so a node too old to enumerate them draws no selector and is never sent an `audio_track` it would ignore. Ignoring it would not degrade the stream; it would serve a different language in silence, which is the failure this shape exists to make impossible. **Changing track is the seek path.** One ffmpeg produces one audio track, so there is nothing to switch inside a running stream: the node has to be asked again, and the new stream opens with an init segment the SourceBuffer can only accept after `reinitAt`'s abort/remove. **The label is derived, not translated.** ffprobe reports ISO 639-2 in either of its two variants, and the ten catalogues have no room for a language list — `Intl.DisplayNames` does that, from a fold that is real logic and is run here rather than read. """ import json import shutil import subprocess from pathlib import Path import pytest from spa_source import transport_source STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" APP = STATIC / "video-player.js" TRANSPORT = STATIC / "transport.js" pytestmark = pytest.mark.skipif( shutil.which("node") is None or not APP.exists(), reason="node or the SPA sources are not available") @pytest.fixture(scope="module") def app(): return APP.read_text() def _player(app: str) -> str: i = app.index("function VideoPlayer(") nxt = app.find("\nfunction ", i + 1) return app[i:nxt if nxt > 0 else len(app)] def _lift(app: str, name: str) -> str: """One top-level function, as text, for node to execute.""" start = app.index(f"function {name}(") depth, i, seen = 0, start, False while i < len(app): if app[i] == "{": depth += 1 seen = True elif app[i] == "}": depth -= 1 if seen and depth == 0: return app[start:i + 1] i += 1 raise AssertionError(f"{name} never closes") # ── The label, run rather than read ─────────────────────────────────────────── def _label_cases(tmp_path, app, cases, locale="en"): script = tmp_path / "label.mjs" src = "\n".join([ app[app.index("const _ISO639 = {"):app.index("};", app.index("const _ISO639 = {")) + 2], # The language name is shared with `subtitleTrackLabel`, so it lives in # a function of its own and has to be lifted alongside its caller. A # lift that names one function stops exercising anything the moment # logic moves out of it — here it throws, which is the good case; the # bad one is a lift that still runs and no longer covers the rule. _lift(app, "_languageName"), _lift(app, "audioTrackLabel"), ]) script.write_text( # The environment is modelled; the function under test is the shipped # text above. `t` is only reached for a track with no usable language. f"const getLocale = () => '{locale}';\n" "const t = (k, p) => `${k}:${p.n}`;\n" + src + "\nconst out = JSON.parse(process.argv[2]).map(audioTrackLabel);\n" "console.log(JSON.stringify(out));\n") proc = subprocess.run( ["node", str(script), json.dumps(cases)], capture_output=True, text=True) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) def test_both_iso_639_2_variants_name_the_same_language(tmp_path, app): """Real files in one library use each: `fre` and `fra` are both French, `ger` and `deu` both German. A fold that covers only one variant leaves half a collection labelled with a three-letter code.""" out = _label_cases(tmp_path, app, [ {"i": 0, "lang": "fre", "ch": 2}, {"i": 1, "lang": "fra", "ch": 2}, {"i": 2, "lang": "ger", "ch": 2}, {"i": 3, "lang": "deu", "ch": 2}, ]) assert out[0] == out[1], f"fre and fra must agree: {out}" assert out[2] == out[3], f"ger and deu must agree: {out}" assert "French" in out[0] and "German" in out[2], out def test_the_container_title_wins_over_the_language_name(tmp_path, app): """Two tracks in one language are the same menu entry twice without it.""" out = _label_cases(tmp_path, app, [ {"i": 0, "lang": "fre", "title": "Surround", "ch": 6}, {"i": 1, "lang": "fre", "title": "Stereo", "ch": 2}, ]) assert out[0] != out[1] assert "Surround" in out[0] and "Stereo" in out[1] def test_an_untitled_track_falls_back_to_its_channel_layout(tmp_path, app): """Still two distinguishable entries, and "5.1" needs no catalogue entry.""" out = _label_cases(tmp_path, app, [ {"i": 0, "lang": "fre", "ch": 6}, {"i": 1, "lang": "fre", "ch": 2}, ]) assert out[0] != out[1], f"two French tracks must not read alike: {out}" assert "5.1" in out[0] and "2.0" in out[1], out def test_an_untagged_track_is_numbered_not_called_unknown(tmp_path, app): """`und`, or no tag at all — common enough in real files, and a number the viewer can act on beats a word that tells them nothing.""" out = _label_cases(tmp_path, app, [ {"i": 0, "lang": "und", "ch": 2}, {"i": 1, "ch": 2}, ]) assert out[0].startswith("video.audio_track_n:1"), out assert out[1].startswith("video.audio_track_n:2"), out def test_a_language_the_fold_does_not_know_shows_its_tag(tmp_path, app): """More useful than "Unknown": the tag is what the file actually says.""" out = _label_cases(tmp_path, app, [{"i": 0, "lang": "qaa", "ch": 2}]) assert "qaa" in out[0], out def test_a_language_name_is_cased_for_a_menu_not_for_prose(tmp_path, app): """`Intl.DisplayNames` returns "français", which is right in a sentence and wrong in a menu beside "AC3 5.1". The locales where this bites are the ones this project ships most of.""" out = _label_cases(tmp_path, app, [ {"i": 0, "lang": "fre", "ch": 6}, {"i": 1, "lang": "eng", "ch": 6}, ], locale="fr") assert out == ["Français — 5.1", "Anglais — 5.1"], out # ── The capability gate ─────────────────────────────────────────────────────── def test_the_selector_is_drawn_from_the_nodes_list_and_no_version(app): """The whole negotiation. A version comparison anywhere near this is the bug: the floor is below the current version, so an older node is reachable, and the only honest signal that it can switch track is that it enumerated.""" player = _player(app) assert "audioTracks.length > 1" in player, \ "the selector must be gated on the list the node sent" menu = player[player.index("audioTracks.length > 1"):] menu = menu[:menu.index("platform.capabilities.lanCast")] for forbidden in ("MNP_VERSION", "peerVersion", "3.2", "v_min"): assert forbidden not in menu, ( f"the selector branches on {forbidden}, not on what the node said") def test_no_track_is_ever_sent_to_a_node_that_did_not_offer_one(): """`requestStream` must omit the field rather than default it. A node that ignores `audio_track` does not degrade — it serves a different language and says nothing. Sending 0 "harmlessly" to every node is what makes that reachable, so the field has to be absent unless the caller was given a real one. """ src = transport_source() fn = src[src.index("requestStream(fileId"):] fn = fn[:fn.index("\n /** Room for")] assert "Number.isInteger(audioTrack)" in fn, \ "an omitted track must not become 0 on the wire" assert "req.audio_track = audioTrack" in fn assert "audio_track: audioTrack" not in fn, \ "the field must be added conditionally, never built into the literal" def test_changing_track_restarts_the_stream_where_the_film_already_was(app): """Setting the ref alone changes nothing: one ffmpeg carries one audio track, so the node has to be asked again — and at the current position, because the viewer changed language and not place.""" player = _player(app) handler = player[player.index("audioTracks.length > 1"):] handler = handler[:handler.index("platform.capabilities.lanCast")] assert "audioTrackRef.current = track.i" in handler assert "requestSeekRef.current" in handler, \ "a track change must go through the seek path" assert "seek(v.currentTime)" in handler, \ "a track change must resume where the film already was" def test_the_player_believes_the_node_about_which_track_is_playing(app): """`stream_init` reports the track actually used, which is not always the one asked for — a list drawn before the file was replaced on disk can name a track that is gone, and the node falls back to the first. Showing the request instead of the answer would tick the wrong entry for the rest of the film.""" player = _player(app) assert "Number.isInteger(msg.audio_track)" in player init = player[player.index("Number.isInteger(msg.audio_track)"):] init = init[:init.index("}")] assert "audioTrackRef.current = msg.audio_track" in init