`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.
388 lines
12 KiB
Python
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)
|