soulsync/tests/test_hls_parsing.py
Broque Thomas 4feedff8f5 fix: pick OS-specific ffmpeg binary in hls demux fallback test
`test_demux_flac_uses_tools_dir_fallback` hard-coded `tools_dir / "ffmpeg"`
in its `fake_exists` stub, but `_demux_flac` looks for `ffmpeg.exe` on
Windows (os.name == 'nt'). Result: the fake_exists stub never matched,
the code fell through to the "ffmpeg is required" RuntimeError instead
of the expected "ffmpeg failed" subprocess error, and the test failed
on Windows. Linux CI passed because os.name == 'posix' uses bare
"ffmpeg".

Pick the binary name based on `os.name` to match what `_demux_flac`
actually probes for. Asserts on the matching candidate path.

Tests: 20 passing on Windows (was 19/20). Ruff clean.
2026-04-28 21:13:54 -07:00

388 lines
12 KiB
Python

"""Tests for HLS-related methods on ``HiFiClient``:
- ``_parse_hls_playlist(text, playlist_url)`` — parses m3u8 playlists
- ``_demux_flac(input_path, output_path)`` — ffmpeg demuxing error paths
The parsing logic is identical in ``tidal_download_client.py``; these tests
cover the shared behavior via ``hifi_client.HiFiClient``.
"""
import os
import shutil
import subprocess
import sys
import types
from pathlib import Path
from unittest.mock import patch
import pytest
# ── stubs for module-level imports ────────────────────────────────────────
if "spotipy" not in sys.modules:
spotipy = types.ModuleType("spotipy")
spotipy.Spotify = object
oauth2 = types.ModuleType("spotipy.oauth2")
oauth2.SpotifyOAuth = object
oauth2.SpotifyClientCredentials = object
spotipy.oauth2 = oauth2
sys.modules["spotipy"] = spotipy
sys.modules["spotipy.oauth2"] = oauth2
if "config.settings" not in sys.modules:
config_pkg = types.ModuleType("config")
settings_mod = types.ModuleType("config.settings")
class _DummyConfigManager:
def get(self, key, default=None):
return default
def get_active_media_server(self):
return "primary"
settings_mod.config_manager = _DummyConfigManager()
config_pkg.settings = settings_mod
sys.modules["config"] = config_pkg
sys.modules["config.settings"] = settings_mod
from core.hifi_client import HiFiClient # noqa: E402
# ── fixture ───────────────────────────────────────────────────────────────
@pytest.fixture
def client(tmp_path):
"""HiFiClient with a temp download dir and no DB dependency."""
return HiFiClient(download_path=str(tmp_path / "downloads"))
# ── _parse_hls_playlist: master playlist ─────────────────────────────────
MASTER_PLAYLIST = """\
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=256000,CODECS="mp4a.40.2"
stream/low.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=512000,CODECS="mp4a.40.5"
stream/high.m3u8
"""
def test_parse_master_playlist_returns_variant_uri(client):
init, segments = client._parse_hls_playlist(
MASTER_PLAYLIST, "https://cdn.example.com/master.m3u8"
)
assert init is None
assert segments == ["https://cdn.example.com/stream/low.m3u8"]
def test_parse_master_playlist_picks_first_variant(client):
"""Master playlists should return only the first variant URI."""
init, segments = client._parse_hls_playlist(
MASTER_PLAYLIST, "https://cdn.example.com/master.m3u8"
)
assert len(segments) == 1
def test_parse_master_playlist_resolves_relative_uri(client):
init, segments = client._parse_hls_playlist(
MASTER_PLAYLIST, "https://cdn.example.com/playlists/master.m3u8"
)
assert segments[0] == "https://cdn.example.com/playlists/stream/low.m3u8"
# ── _parse_hls_playlist: variant playlist with init segment ──────────────
VARIANT_WITH_INIT = """\
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MAP:URI="init.mp4"
#EXTINF:3.840000,
seg001.m4s
#EXTINF:3.840000,
seg002.m4s
#EXTINF:2.560000,
seg003.m4s
"""
def test_parse_variant_with_init_returns_init_and_segments(client):
init, segments = client._parse_hls_playlist(
VARIANT_WITH_INIT, "https://cdn.example.com/variant.m3u8"
)
assert init == "https://cdn.example.com/init.mp4"
assert segments == [
"https://cdn.example.com/seg001.m4s",
"https://cdn.example.com/seg002.m4s",
"https://cdn.example.com/seg003.m4s",
]
def test_parse_variant_with_init_resolves_relative_uris(client):
init, segments = client._parse_hls_playlist(
VARIANT_WITH_INIT, "https://cdn.example.com/audio/variant.m3u8"
)
assert init == "https://cdn.example.com/audio/init.mp4"
assert segments[0] == "https://cdn.example.com/audio/seg001.m4s"
def test_parse_variant_with_absolute_init_uri(client):
playlist = """\
#EXTM3U
#EXT-X-MAP:URI="https://other.cdn/init.mp4"
#EXTINF:3.0,
https://cdn.example.com/seg001.m4s
"""
init, segments = client._parse_hls_playlist(
playlist, "https://cdn.example.com/variant.m3u8"
)
assert init == "https://other.cdn/init.mp4"
assert segments == ["https://cdn.example.com/seg001.m4s"]
# ── _parse_hls_playlist: variant playlist without init segment ───────────
VARIANT_NO_INIT = """\
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXTINF:9.984000,
seg001.ts
#EXTINF:9.984000,
seg002.ts
#EXT-X-ENDLIST
"""
def test_parse_variant_without_init_returns_none_init(client):
init, segments = client._parse_hls_playlist(
VARIANT_NO_INIT, "https://cdn.example.com/variant.m3u8"
)
assert init is None
assert segments == [
"https://cdn.example.com/seg001.ts",
"https://cdn.example.com/seg002.ts",
]
# ── _parse_hls_playlist: error cases ─────────────────────────────────────
def test_parse_empty_playlist_raises_value_error(client):
with pytest.raises(ValueError, match="No segment URIs"):
client._parse_hls_playlist("#EXTM3U", "https://cdn.example.com/x.m3u8")
def test_parse_only_tags_raises_value_error(client):
"""Playlist with only header and MAP tag but no segment URIs."""
playlist = """\
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-MAP:URI="init.mp4"
"""
with pytest.raises(ValueError, match="No segment URIs"):
client._parse_hls_playlist(playlist, "https://cdn.example.com/x.m3u8")
def test_parse_only_extm3u_raises_value_error(client):
with pytest.raises(ValueError, match="No segment URIs"):
client._parse_hls_playlist("#EXTM3U\n", "https://cdn.example.com/x.m3u8")
# ── _parse_hls_playlist: edge cases ──────────────────────────────────────
def test_parse_skips_blank_lines(client):
playlist = """\
#EXTM3U
#EXT-X-MAP:URI="init.mp4"
#EXTINF:3.0,
seg001.m4s
"""
init, segments = client._parse_hls_playlist(
playlist, "https://cdn.example.com/x.m3u8"
)
assert init == "https://cdn.example.com/init.mp4"
assert segments == ["https://cdn.example.com/seg001.m4s"]
def test_parse_skips_unknown_tags(client):
"""Tags like #EXT-X-VERSION, #EXT-X-TARGETDURATION should be ignored."""
playlist = """\
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:5.0,
seg001.ts
"""
init, segments = client._parse_hls_playlist(
playlist, "https://cdn.example.com/x.m3u8"
)
assert init is None
assert segments == ["https://cdn.example.com/seg001.ts"]
def test_parse_captures_last_map_tag(client):
"""If multiple EXT-X-MAP tags appear, the last one wins (overwrites init_uri)."""
playlist = """\
#EXTM3U
#EXT-X-MAP:URI="init-first.mp4"
#EXTINF:3.0,
seg001.m4s
#EXT-X-MAP:URI="init-second.mp4"
#EXTINF:3.0,
seg002.m4s
"""
init, segments = client._parse_hls_playlist(
playlist, "https://cdn.example.com/x.m3u8"
)
assert init == "https://cdn.example.com/init-second.mp4"
assert len(segments) == 2
def test_parse_master_breaks_on_first_variant(client):
"""Parser should stop after finding the first variant URI."""
playlist = """\
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=256000
variant-low.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=512000
variant-high.m3u8
#EXTINF:3.0,
should-not-appear.ts
"""
init, segments = client._parse_hls_playlist(
playlist, "https://cdn.example.com/master.m3u8"
)
assert init is None
assert segments == ["https://cdn.example.com/variant-low.m3u8"]
def test_parse_master_skips_comment_after_stream_inf(client):
"""The line immediately after #EXT-X-STREAM-INF must be a non-comment URI."""
playlist = """\
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=256000
# some comment
variant.m3u8
"""
init, segments = client._parse_hls_playlist(
playlist, "https://cdn.example.com/master.m3u8"
)
assert segments == ["https://cdn.example.com/variant.m3u8"]
def test_parse_handles_mixed_absolute_and_relative_uris(client):
playlist = """\
#EXTM3U
#EXTINF:3.0,
https://cdn-a.example.com/seg001.m4s
#EXTINF:3.0,
seg002.m4s
#EXTINF:3.0,
https://cdn-b.example.com/seg003.m4s
"""
init, segments = client._parse_hls_playlist(
playlist, "https://cdn.example.com/variant.m3u8"
)
assert segments == [
"https://cdn-a.example.com/seg001.m4s",
"https://cdn.example.com/seg002.m4s",
"https://cdn-b.example.com/seg003.m4s",
]
def test_parse_single_segment(client):
playlist = """\
#EXTM3U
#EXTINF:3.0,
only-segment.m4s
"""
init, segments = client._parse_hls_playlist(
playlist, "https://cdn.example.com/x.m3u8"
)
assert init is None
assert segments == ["https://cdn.example.com/only-segment.m4s"]
# ── _demux_flac: error paths ─────────────────────────────────────────────
def test_demux_flac_raises_when_ffmpeg_not_found(client, tmp_path, monkeypatch):
"""When ffmpeg is nowhere on PATH and not in tools/, RuntimeError is raised."""
monkeypatch.setattr("shutil.which", lambda _: None)
inp = tmp_path / "input.mp4"
inp.touch()
out = tmp_path / "output.flac"
with patch.object(Path, "exists", return_value=False):
with pytest.raises(RuntimeError, match="ffmpeg is required"):
client._demux_flac(inp, out)
def test_demux_flac_raises_on_ffmpeg_failure(client, tmp_path, monkeypatch):
"""When ffmpeg exits non-zero, RuntimeError includes stderr."""
monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/ffmpeg")
inp = tmp_path / "input.mp4"
inp.touch()
out = tmp_path / "output.flac"
fake_result = subprocess.CalledProcessError(
returncode=1,
cmd=["ffmpeg"],
stderr="Invalid data found when processing input",
)
with patch("subprocess.run", side_effect=fake_result):
with pytest.raises(RuntimeError, match="ffmpeg failed"):
client._demux_flac(inp, out)
def test_demux_flac_uses_tools_dir_fallback(client, tmp_path, monkeypatch):
"""When shutil.which fails but tools/ffmpeg exists, it should be used."""
monkeypatch.setattr("shutil.which", lambda _: None)
inp = tmp_path / "input.mp4"
inp.touch()
out = tmp_path / "output.flac"
tools_dir = Path(__file__).parent.parent / "tools"
# _demux_flac picks the OS-specific binary name (ffmpeg.exe on Windows).
candidate_name = "ffmpeg.exe" if os.name == "nt" else "ffmpeg"
expected_candidate = tools_dir / candidate_name
original_exists = Path.exists
original_which = shutil.which
def fake_exists(self):
if str(self) == str(expected_candidate):
return True
return original_exists(self)
def fake_which(name):
if name == "ffmpeg":
return None
return original_which(name)
monkeypatch.setattr(shutil, "which", fake_which)
monkeypatch.setattr(Path, "exists", fake_exists)
fake_result = subprocess.CalledProcessError(
returncode=1, cmd=["ffmpeg"], stderr="fail"
)
with patch("subprocess.run", side_effect=fake_result) as mock_run:
with pytest.raises(RuntimeError, match="ffmpeg failed"):
client._demux_flac(inp, out)
call_args = mock_run.call_args[0][0]
assert call_args[0] == str(expected_candidate)