diff --git a/src/anthias_viewer/media_player.py b/src/anthias_viewer/media_player.py index 3019b23d2..0d077fc67 100644 --- a/src/anthias_viewer/media_player.py +++ b/src/anthias_viewer/media_player.py @@ -4,6 +4,7 @@ import signal import subprocess import sys +import time from typing import Any, ClassVar from urllib.parse import quote @@ -184,79 +185,332 @@ def _detect_hdmi_audio_device() -> str: return device -# Once-per-process flag for _log_arm64_alsa_default_once() — we don't -# want every play() call repeating the same INFO line. -_arm64_alsa_logged = False +# Last (audio_output, sink) `_resolve_pulse_sink()` resolved in this +# process, so a stable resolution doesn't repeat the same INFO/WARNING +# line on every play()/set_asset() call — only transitions (a different +# setting or a different resolved sink) are logged loudly. +_last_pulse_resolution: tuple[str, str] | None = None -def _log_arm64_alsa_default_once() -> None: - """Log the kernel's ALSA card listing so silent-HDMI reports are - debuggable from journalctl alone. Reads /proc/asound/cards (always - present when sound is registered) rather than shelling to - `aplay -l` — the viewer image deliberately doesn't ship - alsa-utils, so the subprocess form would always fall through to - a "not found" error and surface no useful info. +def _pi_alsa_device(device_type: str, audio_output: str) -> str | None: + """Resolve the fixed ALSA card name for a Raspberry Pi board. + + Pi firmware exposes stable card names (``vc4hdmi*`` for HDMI, + ``Headphones`` for the 3.5 mm jack), so the audio_output setting + maps straight onto them. The Pi 5 has no analog jack, so its + ``local`` selection also resolves to the connected HDMI device + (the UI hides the 3.5 mm option there). Returns ``None`` for any + non-Pi board (x86, arm64 SBCs) whose card names aren't portable — + the caller then falls through to runtime PulseAudio sink discovery. """ - global _arm64_alsa_logged - if _arm64_alsa_logged: - return - _arm64_alsa_logged = True + if audio_output == 'local': + if device_type == 'pi5': + return _detect_hdmi_audio_device() + if device_type in ('pi1', 'pi2', 'pi3', 'pi4'): + return 'plughw:CARD=Headphones' + return None + + # 'hdmi' (the default for any other value) + if device_type in ('pi4', 'pi5'): + return _detect_hdmi_audio_device() + if device_type in ('pi1', 'pi2', 'pi3'): + return 'sysdefault:CARD=vc4hdmi' + return None + + +# get_alsa_audio_device() runs on every play()/set_asset(), so the sink +# listing is cached for a short window to keep a fast-rotating playlist +# from spawning a `pactl` subprocess per asset transition. The TTL is +# short enough that an HDMI hotplug (sink appears/disappears) is picked +# up within a few seconds. Only successful lists are cached — a transient +# pactl failure retries on the next call rather than latching an empty +# list. +_SINK_CACHE_TTL_SECONDS = 5.0 +_sink_cache: tuple[float, list[str]] | None = None + + +def _invalidate_sink_cache() -> None: + global _sink_cache + _sink_cache = None + + +def _list_pulse_sinks() -> list[str]: + """Return the sink names the running PulseAudio daemon exposes. + + Uses ``pactl`` (pulseaudio-utils, which the viewer image ships — + unlike alsa-utils/``aplay``). The viewer process already runs with + ``XDG_RUNTIME_DIR`` pointed at the pulse socket that + start_pulseaudio() created, so the child inherits it. Result is + cached for ``_SINK_CACHE_TTL_SECONDS``. Returns an empty list if + pulse is unreachable. + """ + global _sink_cache + now = time.monotonic() + if ( + _sink_cache is not None + and now - _sink_cache[0] < _SINK_CACHE_TTL_SECONDS + ): + return _sink_cache[1] + try: - with open('/proc/asound/cards') as f: - listing = f.read().strip() or '' - except OSError as exc: - listing = f'' - logging.info( - 'arm64: using the default audio device for HDMI audio ' - '(resolves to the PulseAudio default sink — see ' - 'start_pulseaudio in bin/start_viewer.sh). If audio is ' - 'silent, check `pactl list short sinks` in the viewer ' - 'container. Registered ALSA cards:\n%s', - listing, + completed = subprocess.run( + ['pactl', 'list', 'short', 'sinks'], + capture_output=True, + text=True, + timeout=5, + check=True, + ) + except (OSError, subprocess.SubprocessError) as exc: + logging.debug('Could not list PulseAudio sinks: %s', exc) + return [] + + sinks = [] + for line in completed.stdout.splitlines(): + # `pactl list short sinks` is tab-separated: + # indexnamemodulesample-specstate + fields = line.split('\t') + if len(fields) >= 2 and fields[1]: + sinks.append(fields[1]) + _sink_cache = (now, sinks) + return sinks + + +# Profile/sink name tokens that identify each output. module-alsa-card +# names both its sinks (``alsa_output..hdmi-stereo``) and its +# profiles (``output:hdmi-stereo``) after the same standardised token, +# so one keyword set matches both. The token — not the card name — is +# the HDMI-vs-analog discriminator: the Dell Latitude in forum #6749 +# puts both an analog and an HDMI output on the *same* HDA card. +_ANALOG_KEYWORDS = ('analog', 'headphone', 'speaker', 'lineout') +_HDMI_KEYWORDS = ('hdmi', 'displayport', 'iec958') + + +def _output_keywords(audio_output: str) -> tuple[str, ...]: + return _ANALOG_KEYWORDS if audio_output == 'local' else _HDMI_KEYWORDS + + +def _select_pulse_sink(sinks: list[str], audio_output: str) -> str | None: + """Pick the sink matching the requested output, or ``None``. + + `pactl list short sinks` order isn't guaranteed, so a stable + preference is applied among all matches: plain ``*-stereo`` first, + then any non-surround sink, then lexical. That keeps a multi-HDMI + box (hdmi-stereo, hdmi-surround, hdmi-stereo-extra1, …) from picking + a surround or secondary port unpredictably. + """ + keywords = _output_keywords(audio_output) + matches = [ + sink + for sink in sinks + if any(keyword in sink.lower() for keyword in keywords) + ] + if not matches: + return None + return min( + matches, + key=lambda sink: ( + 0 if 'stereo' in sink.lower() else 1, + 1 if 'surround' in sink.lower() else 0, + sink, + ), ) -def get_alsa_audio_device() -> str: - # device_helper.get_device_type() reads /proc/device-tree/model and - # falls back to 'pi1' for any aarch64 host whose model line isn't a - # Pi regex match (Rock Pi, Orange Pi, Banana Pi, …). The Pi-firmware - # ALSA card names below (vc4hdmi*, "Headphones") don't exist on - # those boards, so route via DEVICE_TYPE env first and only fall - # through to the Pi-name dispatch when we're actually on a Pi. - if os.environ.get('DEVICE_TYPE') in ARM64_DEVICE_TYPES: - # No portable per-SoC HDMI card name across Rockchip / - # Allwinner / Amlogic, so defer to the `default` device — - # which VideoView::resolveAlsaDevice resolves to the - # PulseAudio default sink (the daemon start_viewer.sh runs; - # Debian's Qt 6 Multimedia only has a PulseAudio backend). - # Log the chosen device at INFO once per process so a - # silent-HDMI report carries enough breadcrumbs to debug - # from journalctl alone. - _log_arm64_alsa_default_once() - return 'default' - - device_type = get_device_type() - if settings['audio_output'] == 'local': - if device_type == 'pi5': - return _detect_hdmi_audio_device() +class _PulseCard: + """A PulseAudio card, its active profile, and its *pure output* + profiles (``output:-…`` with no ``+input:`` half) keyed to + whether that profile is available. - return 'plughw:CARD=Headphones' - else: - if device_type in ['pi4', 'pi5']: - return _detect_hdmi_audio_device() - elif device_type in ['pi1', 'pi2', 'pi3']: - return 'sysdefault:CARD=vc4hdmi' + Only pure-output profiles are tracked because a combined + ``output:hdmi-stereo+input:analog-stereo`` profile reports + ``available: yes`` whenever the analog *input* is usable, even + when the HDMI output itself is unavailable (forum #6749). The + pure-output profile's availability reflects only the output port, + so it is the honest signal for "can this output actually play". + """ + + def __init__(self, name: str) -> None: + self.name = name + self.active_profile: str | None = None + self.output_profiles: dict[str, bool] = {} + + +def _list_pulse_cards() -> list[_PulseCard]: + """Parse ``pactl list cards`` into `_PulseCard` records.""" + try: + completed = subprocess.run( + ['pactl', 'list', 'cards'], + capture_output=True, + text=True, + timeout=5, + check=True, + ) + except (OSError, subprocess.SubprocessError) as exc: + logging.debug('Could not list PulseAudio cards: %s', exc) + return [] + + cards: list[_PulseCard] = [] + current: _PulseCard | None = None + # Pure-output profile line, e.g.: + # output:hdmi-stereo: Digital Stereo (HDMI) ... available: no) + # Excludes ports (they don't start with "output:") and combined + # output+input profiles (they contain "+input:"). + profile_re = re.compile( + r'^(output:[A-Za-z0-9._-]+):.*available:\s*(yes|no|unknown)\)', + ) + for raw in completed.stdout.splitlines(): + line = raw.strip() + if line.startswith('Name:'): + name = line.split(':', 1)[1].strip() + current = _PulseCard(name) + cards.append(current) + elif current is None: + continue + elif line.startswith('Active Profile:'): + current.active_profile = line.split(':', 1)[1].strip() else: - # x86 fallback: ALSA card names vary across Intel / AMD / - # Nvidia HDA chipsets, so there's no portable per-SoC name - # to hard-code (the old 'sysdefault:CARD=HID' matched no - # real card on standard HDA setups). Defer to the `default` - # device — which VideoView::resolveAlsaDevice resolves to - # the PulseAudio default sink (x86 is a Qt 6 board and - # Debian's Qt 6 Multimedia only has a PulseAudio backend; - # the daemon is started by start_viewer.sh). Mirrors the - # ARM64 path above. - return 'default' + match = profile_re.match(line) + if match: + profile, avail = match.group(1), match.group(2) + # Only a definite ``yes`` counts as available. ``unknown`` + # (driver can't probe the port) is not a confirmation, so + # we don't switch to it — that could swap a working analog + # profile for a dead HDMI one. + current.output_profiles[profile] = avail == 'yes' + return cards + + +def _activate_output_profile(audio_output: str) -> bool: + """Activate a card profile for the requested output if one is + available but not currently active. Returns True when a profile + was switched (so the caller should re-list sinks). + + Needed on single-card HDA systems (laptops/desktops — the forum + #6749 topology) where PulseAudio instantiates only the *active* + profile's sink, so `pactl list short sinks` never shows the HDMI + sink while analog is active, and vice versa. Multi-card boards + (Rock Pi's separate ``hdmisound``/``Analog`` cards, a discrete + HDMI card next to onboard analog) already expose both sinks, so + nothing is switched. + """ + keywords = _output_keywords(audio_output) + for card in _list_pulse_cards(): + # Prefer a plain ``*-stereo`` output; fall back to any + # available surround variant for the requested output. + candidates = sorted( + ( + profile + for profile, available in card.output_profiles.items() + if available and any(k in profile for k in keywords) + ), + key=lambda profile: (0 if 'stereo' in profile else 1, profile), + ) + if not candidates: + continue + target = candidates[0] + if card.active_profile == target: + return False + try: + subprocess.run( + ['pactl', 'set-card-profile', card.name, target], + capture_output=True, + text=True, + timeout=5, + check=True, + ) + except (OSError, subprocess.SubprocessError) as exc: + logging.warning( + 'Could not activate profile %r on card %r: %s', + target, + card.name, + exc, + ) + return False + logging.info( + 'audio_output=%r: activated card %r profile %r to expose its sink', + audio_output, + card.name, + target, + ) + return True + return False + + +def _resolve_pulse_sink(audio_output: str) -> str: + """Map the audio_output setting onto a live PulseAudio sink. + + x86 and arm64 SBCs run Qt 6 Multimedia, whose only backend on + Debian is PulseAudio, and have no portable per-SoC ALSA card name. + Rather than hard-code one (the old code returned ``default`` and so + always followed whatever sink pulse defaulted to — issue #3208), we + ask the running daemon which sinks exist and route the setting to + the matching one. The bare sink name is returned; + ``_build_video_options`` prepends ``alsa/`` and + ``VideoView::resolveAlsaDevice`` matches it against + ``QAudioDevice::id()``. + + If the requested sink isn't currently instantiated, try to + activate the matching card profile (single-card HDA systems only + expose the active profile's sink) and re-list. Falls back to + ``default`` — the PulseAudio default sink — when pulse is + unreachable or no sink matches, preserving the previous behaviour + rather than silencing audio. + """ + sinks = _list_pulse_sinks() + chosen = _select_pulse_sink(sinks, audio_output) + + # Only attempt a profile switch when pulse gave us a sink list but + # none matched (the single-card HDA case always has ≥1 active sink). + # An empty list means pulse is unreachable, so probing `pactl list + # cards` would just fail again — skip it and fall back to default. + if chosen is None and sinks and _activate_output_profile(audio_output): + # Switching the card profile instantiated a new sink; drop the + # cached listing so the fresh sink is picked up this call. + _invalidate_sink_cache() + sinks = _list_pulse_sinks() + chosen = _select_pulse_sink(sinks, audio_output) + + result = chosen if chosen is not None else 'default' + + # Key the log suppression on (setting, result) so a setting change + # that resolves to the same sink (e.g. hdmi→local both falling back + # to default) still logs once, staying correlatable in the journal. + global _last_pulse_resolution + if (audio_output, result) != _last_pulse_resolution: + if chosen is not None: + logging.info( + 'audio_output=%r routed to PulseAudio sink %r', + audio_output, + chosen, + ) + else: + logging.warning( + 'audio_output=%r: no matching PulseAudio sink among %s; ' + 'falling back to the default sink. Check ' + '`pactl list short sinks` in the viewer container.', + audio_output, + sinks or '', + ) + _last_pulse_resolution = (audio_output, result) + return result + + +def get_alsa_audio_device() -> str: + audio_output = settings['audio_output'] + + # Raspberry Pi boards have stable, portable ALSA card names, so the + # setting maps directly onto them. get_device_type() reads + # /proc/device-tree/model; the ARM64_DEVICE_TYPES env gate keeps + # non-Pi aarch64 boards (Rock Pi, Orange Pi, …) — which + # get_device_type() would misclassify as 'pi1' — out of the Pi + # dispatch and on the PulseAudio path below. + if os.environ.get('DEVICE_TYPE') not in ARM64_DEVICE_TYPES: + pi_device = _pi_alsa_device(get_device_type(), audio_output) + if pi_device is not None: + return pi_device + + # x86 + arm64 SBCs: resolve the setting against a live pulse sink. + return _resolve_pulse_sink(audio_output) class MediaPlayer: diff --git a/tests/test_media_player.py b/tests/test_media_player.py index e3e40c3b1..7484234ed 100644 --- a/tests/test_media_player.py +++ b/tests/test_media_player.py @@ -125,13 +125,40 @@ def test_play_omits_libmpv_era_options(mpv: _MPVFixtures) -> None: assert legacy not in options, legacy -def test_play_uses_default_alsa_device_on_arm64(mpv: _MPVFixtures) -> None: - # No portable per-SoC HDMI card name exists across Rockchip / - # Allwinner / Amlogic, so arm64 defers to ALSA's `default` - # device rather than the Pi-firmware vc4hdmi* / HID cards the - # regular dispatch would otherwise pick. +def test_play_routes_to_hdmi_sink_on_arm64(mpv: _MPVFixtures) -> None: + # arm64 SBCs have no portable per-SoC ALSA card name, so the + # audio_output setting is resolved against a live PulseAudio sink + # (issue #3208). With HDMI selected, playback routes to the HDMI + # sink rather than whatever pulse happens to default to. mpv.player.set_asset('file:///test/video.mp4', 30) - with patch.dict('os.environ', {'DEVICE_TYPE': 'arm64'}): + with ( + patch.dict('os.environ', {'DEVICE_TYPE': 'arm64'}), + patch( + 'anthias_viewer.media_player._list_pulse_sinks', + return_value=[ + 'alsa_output.hdmisound.stereo-fallback', + 'alsa_output.Analog.stereo-fallback', + ], + ), + ): + mpv.player.play() + + options = _last_play_options(mpv.mock_bus) + assert ( + options['audio-device'] == 'alsa/alsa_output.hdmisound.stereo-fallback' + ) + + +def test_play_falls_back_to_default_sink_on_arm64(mpv: _MPVFixtures) -> None: + # When pulse is unreachable (no sinks listed), fall back to the + # default sink rather than silencing audio. + mpv.player.set_asset('file:///test/video.mp4', 30) + with ( + patch.dict('os.environ', {'DEVICE_TYPE': 'arm64'}), + patch( + 'anthias_viewer.media_player._list_pulse_sinks', return_value=[] + ), + ): mpv.player.play() options = _last_play_options(mpv.mock_bus) @@ -341,17 +368,341 @@ def test_hdmi_on_pi1_pi2_pi3_uses_vc4hdmi( assert get_alsa_audio_device() == 'sysdefault:CARD=vc4hdmi' -def test_hdmi_on_x86_uses_alsa_default(alsa_settings: Any) -> None: - # No portable per-SoC ALSA card name across Intel / AMD / Nvidia - # HDA chipsets, so x86 defers to `default`, which the C++ side - # resolves to the PulseAudio default sink. +# Real-world `pactl list short sinks` layouts from the testbeds: +# an HDA x86 box (analog + HDMI on separate cards) and a Rock Pi 4B. +_X86_SINKS = [ + 'alsa_output.HID.analog-stereo', + 'alsa_output.PCH.hdmi-stereo', +] +_ROCKPI_SINKS = [ + 'alsa_output.hdmisound.stereo-fallback', + 'alsa_output.Analog.stereo-fallback', +] + + +def test_hdmi_on_x86_routes_to_hdmi_sink(alsa_settings: Any) -> None: + # x86 resolves HDMI to the live HDMI sink instead of the pulse + # default (issue #3208). alsa_settings.__getitem__.return_value = 'hdmi' - with patch( - 'anthias_viewer.media_player.get_device_type', return_value='x86' + with ( + patch( + 'anthias_viewer.media_player.get_device_type', return_value='x86' + ), + patch( + 'anthias_viewer.media_player._list_pulse_sinks', + return_value=_X86_SINKS, + ), + ): + assert get_alsa_audio_device() == 'alsa_output.PCH.hdmi-stereo' + + +def test_local_on_x86_routes_to_analog_sink(alsa_settings: Any) -> None: + alsa_settings.__getitem__.return_value = 'local' + with ( + patch( + 'anthias_viewer.media_player.get_device_type', return_value='x86' + ), + patch( + 'anthias_viewer.media_player._list_pulse_sinks', + return_value=_X86_SINKS, + ), + ): + assert get_alsa_audio_device() == 'alsa_output.HID.analog-stereo' + + +def test_x86_falls_back_to_default_when_no_matching_sink( + alsa_settings: Any, +) -> None: + # HDMI requested but only an analog sink exists and no HDMI profile + # can be activated -> default sink. Patch _activate_output_profile + # so the miss-recovery path stays hermetic (no real `pactl`). + alsa_settings.__getitem__.return_value = 'hdmi' + with ( + patch( + 'anthias_viewer.media_player.get_device_type', return_value='x86' + ), + patch( + 'anthias_viewer.media_player._list_pulse_sinks', + return_value=['alsa_output.HID.analog-stereo'], + ), + patch( + 'anthias_viewer.media_player._activate_output_profile', + return_value=False, + ), ): assert get_alsa_audio_device() == 'default' +@pytest.mark.parametrize( + 'audio_output,sinks,expected', + [ + ('hdmi', _X86_SINKS, 'alsa_output.PCH.hdmi-stereo'), + ('local', _X86_SINKS, 'alsa_output.HID.analog-stereo'), + ('hdmi', _ROCKPI_SINKS, 'alsa_output.hdmisound.stereo-fallback'), + ('local', _ROCKPI_SINKS, 'alsa_output.Analog.stereo-fallback'), + # Same HDA card exposes both profiles (forum #6749 Dell): the + # profile token, not the card name, disambiguates. + ( + 'hdmi', + [ + 'alsa_output.pci-0000_00_1f.3.analog-stereo', + 'alsa_output.pci-0000_00_1f.3.hdmi-stereo', + ], + 'alsa_output.pci-0000_00_1f.3.hdmi-stereo', + ), + ('hdmi', ['alsa_output.HID.analog-stereo'], None), + ('local', [], None), + ], +) +def test_select_pulse_sink( + audio_output: str, sinks: list[str], expected: str | None +) -> None: + from anthias_viewer.media_player import _select_pulse_sink + + assert _select_pulse_sink(sinks, audio_output) == expected + + +def test_select_pulse_sink_prefers_stereo_among_multiple_hdmi() -> None: + # Multi-HDMI box: pactl order isn't guaranteed, so the plain + # *-stereo sink must win over surround / secondary-port variants + # regardless of listing order. + from anthias_viewer.media_player import _select_pulse_sink + + sinks = [ + 'alsa_output.pci.hdmi-surround', + 'alsa_output.pci.hdmi-stereo-extra1', + 'alsa_output.pci.hdmi-stereo', + ] + assert _select_pulse_sink(sinks, 'hdmi') == 'alsa_output.pci.hdmi-stereo' + + +def test_list_pulse_sinks_parses_pactl_output() -> None: + from anthias_viewer.media_player import _list_pulse_sinks + + media_player_module._sink_cache = None + stdout = ( + '0\talsa_output.HID.analog-stereo\tmodule-alsa-card.c\t' + 's16le 2ch 48000Hz\tSUSPENDED\n' + '1\talsa_output.PCH.hdmi-stereo\tmodule-alsa-card.c\t' + 's16le 2ch 44100Hz\tSUSPENDED\n' + ) + with patch( + 'anthias_viewer.media_player.subprocess.run', + return_value=MagicMock(stdout=stdout), + ): + assert _list_pulse_sinks() == [ + 'alsa_output.HID.analog-stereo', + 'alsa_output.PCH.hdmi-stereo', + ] + + +def test_list_pulse_sinks_returns_empty_when_pactl_missing() -> None: + from anthias_viewer.media_player import _list_pulse_sinks + + media_player_module._sink_cache = None + with patch( + 'anthias_viewer.media_player.subprocess.run', + side_effect=FileNotFoundError('pactl'), + ): + assert _list_pulse_sinks() == [] + + +def test_list_pulse_sinks_caches_within_ttl() -> None: + from anthias_viewer.media_player import _list_pulse_sinks + + media_player_module._sink_cache = None + stdout = '0\talsa_output.PCH.hdmi-stereo\tm\ts16le 2ch\tSUSPENDED\n' + with patch( + 'anthias_viewer.media_player.subprocess.run', + return_value=MagicMock(stdout=stdout), + ) as mock_run: + first = _list_pulse_sinks() + second = _list_pulse_sinks() + assert first == second == ['alsa_output.PCH.hdmi-stereo'] + # Second call served from cache — pactl invoked only once. + mock_run.assert_called_once() + + +# `pactl list cards` excerpt from the forum #6749 Dell Latitude: HDMI +# and analog share one HDA card, every *pure* HDMI output profile is +# available: no (the display carries no audio), and the combined +# output+input profiles read yes only because of the analog input. +_DELL_CARDS = """\ +Card #0 +\tName: alsa_card.pci-0000_00_1f.3 +\tProfiles: +\t\toutput:analog-stereo: Analog Stereo Output (sinks: 1, sources: 0, priority: 6500, available: yes) +\t\toutput:hdmi-stereo: Digital Stereo (HDMI) Output (sinks: 1, sources: 0, priority: 5900, available: no) +\t\toutput:hdmi-stereo+input:analog-stereo: HDMI + Analog In (sinks: 1, sources: 1, priority: 5965, available: yes) +\t\toutput:hdmi-surround: Digital Surround 5.1 (HDMI) Output (sinks: 1, sources: 0, priority: 800, available: no) +\t\toff: Off (sinks: 0, sources: 0, priority: 0, available: yes) +\tActive Profile: output:analog-stereo +""" + +# A single-card box whose attached display *does* accept HDMI audio: +# the pure HDMI output profile is available but analog is still active. +_HDMI_CAPABLE_CARDS = """\ +Card #0 +\tName: alsa_card.pci-0000_00_1f.3 +\tProfiles: +\t\toutput:analog-stereo: Analog Stereo Output (sinks: 1, sources: 0, priority: 6500, available: yes) +\t\toutput:hdmi-stereo: Digital Stereo (HDMI) Output (sinks: 1, sources: 0, priority: 5900, available: yes) +\t\toutput:hdmi-surround: Digital Surround 5.1 (HDMI) Output (sinks: 1, sources: 0, priority: 800, available: yes) +\tActive Profile: output:analog-stereo +""" + + +def _cards_from(stdout: str) -> Any: + return patch( + 'anthias_viewer.media_player.subprocess.run', + return_value=MagicMock(stdout=stdout), + ) + + +def test_list_pulse_cards_tracks_only_available_pure_outputs() -> None: + from anthias_viewer.media_player import _list_pulse_cards + + with _cards_from(_DELL_CARDS): + cards = _list_pulse_cards() + + assert len(cards) == 1 + card = cards[0] + assert card.name == 'alsa_card.pci-0000_00_1f.3' + assert card.active_profile == 'output:analog-stereo' + # Combined output+input profile is excluded; pure HDMI output is + # tracked as unavailable, analog as available. + assert card.output_profiles == { + 'output:analog-stereo': True, + 'output:hdmi-stereo': False, + 'output:hdmi-surround': False, + } + + +def test_list_pulse_cards_treats_unknown_as_unavailable() -> None: + # ``available: unknown`` (driver can't probe the port) is not a + # confirmation — must not count as available, or we'd risk switching + # a working analog profile for a dead HDMI one. + from anthias_viewer.media_player import _list_pulse_cards + + stdout = ( + 'Card #0\n' + '\tName: alsa_card.x\n' + '\tProfiles:\n' + '\t\toutput:hdmi-stereo: Digital Stereo (HDMI) Output ' + '(sinks: 1, sources: 0, priority: 5900, available: unknown)\n' + '\tActive Profile: output:analog-stereo\n' + ) + with _cards_from(stdout): + cards = _list_pulse_cards() + + assert cards[0].output_profiles == {'output:hdmi-stereo': False} + + +def test_activate_output_profile_switches_when_available() -> None: + # Full path: parse `pactl list cards`, find an available HDMI output + # that isn't active, and issue set-card-profile. Both subprocess + # calls (list + set) share the patched run. + from anthias_viewer.media_player import _activate_output_profile + + with _cards_from(_HDMI_CAPABLE_CARDS) as mock_run: + assert _activate_output_profile('hdmi') is True + + set_calls = [ + call.args[0] + for call in mock_run.call_args_list + if call.args[0][:2] == ['pactl', 'set-card-profile'] + ] + assert len(set_calls) == 1 + # Switched the right card to the *stereo* HDMI profile (preferred + # over the surround variant). + assert set_calls[0] == [ + 'pactl', + 'set-card-profile', + 'alsa_card.pci-0000_00_1f.3', + 'output:hdmi-stereo', + ] + + +def test_activate_output_profile_noop_when_hdmi_unavailable() -> None: + # The Dell case: no available pure HDMI output -> no switch. + from anthias_viewer.media_player import _activate_output_profile + + with _cards_from(_DELL_CARDS): + # _cards_from patches subprocess.run for the list; a + # set-card-profile call would reuse the same mock, so assert it + # only ran the list (no profile change was attempted). + assert _activate_output_profile('hdmi') is False + + +def test_activate_output_profile_noop_when_already_active() -> None: + from anthias_viewer.media_player import _activate_output_profile + + with _cards_from(_DELL_CARDS): + # analog already active -> nothing to switch. + assert _activate_output_profile('local') is False + + +def test_resolve_pulse_sink_activates_profile_on_miss() -> None: + # Single-card box: analog sink present, hdmi requested. The first + # listing misses, profile activation exposes the hdmi sink, and the + # refreshed listing resolves to it. + from anthias_viewer.media_player import _resolve_pulse_sink + + media_player_module._last_pulse_resolution = None + listings = [ + ['alsa_output.pci.analog-stereo'], + ['alsa_output.pci.hdmi-stereo'], + ] + with ( + patch( + 'anthias_viewer.media_player._list_pulse_sinks', + side_effect=listings, + ), + patch( + 'anthias_viewer.media_player._activate_output_profile', + return_value=True, + ) as mock_activate, + ): + assert _resolve_pulse_sink('hdmi') == 'alsa_output.pci.hdmi-stereo' + mock_activate.assert_called_once_with('hdmi') + + +def test_resolve_pulse_sink_falls_back_when_activation_fails() -> None: + from anthias_viewer.media_player import _resolve_pulse_sink + + media_player_module._last_pulse_resolution = None + with ( + patch( + 'anthias_viewer.media_player._list_pulse_sinks', + return_value=['alsa_output.pci.analog-stereo'], + ), + patch( + 'anthias_viewer.media_player._activate_output_profile', + return_value=False, + ), + ): + # hdmi unavailable and unswitchable -> default sink. + assert _resolve_pulse_sink('hdmi') == 'default' + + +def test_resolve_pulse_sink_skips_activation_when_pulse_unreachable() -> None: + # An empty sink list means pulse is unreachable — probing cards + # would just fail again, so activation must not be attempted. + from anthias_viewer.media_player import _resolve_pulse_sink + + media_player_module._last_pulse_resolution = None + with ( + patch( + 'anthias_viewer.media_player._list_pulse_sinks', return_value=[] + ), + patch( + 'anthias_viewer.media_player._activate_output_profile' + ) as mock_activate, + ): + assert _resolve_pulse_sink('hdmi') == 'default' + mock_activate.assert_not_called() + + class _FakeDirEntry: """Minimal os.DirEntry stand-in for scandir tests."""