mirror of https://github.com/Nezreka/SoulSync.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
389 lines
12 KiB
389 lines
12 KiB
"""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)
|