Files
unshackle/tests/core/test_hls_merge_segments.py
T
imSp4rky 235839abc8 feat(audio): support DTS:X Profile 2 with MP4 muxing
Route titles with a dtsx/dtsy sample entry to MP4 via MP4Box, since Matroska has no CodecID for DTS-UHD. Add per-rendition HLS audio codec selection, fix merge_segments progress and its cleanup path.
2026-09-04 14:19:29 -06:00

141 lines
5.1 KiB
Python

"""HLS.merge_segments must keep the ffmpeg child off the terminal.
merge_segments runs while the download progress tree is live. A child that inherits the
terminal moves the cursor out from under rich, which leaves an orphaned copy of the tree
on screen. Real trigger: ffmpeg refuses DTS-UHD audio with "Cannot map stream #0:0".
"""
import os
import subprocess
import tempfile
from pathlib import Path
import pytest
from unshackle.core.manifests.hls import HLS
FFMPEG_ERROR = "[out#0/mp4 @ 0x0] Cannot map stream #0:0 - unsupported type."
@pytest.fixture
def segments(tmp_path: Path) -> list[Path]:
save_dir = tmp_path / "Audio_segments"
save_dir.mkdir()
paths = []
for i in range(3):
path = save_dir / f"{i:05}.mp4"
path.write_bytes(f"segment-{i}".encode())
paths.append(path)
return paths
def _capture_fd(fd: int):
"""Redirect a raw file descriptor to a temp file, returning (restore, read)."""
sink = tempfile.TemporaryFile()
saved = os.dup(fd)
os.dup2(sink.fileno(), fd)
def restore() -> bytes:
os.dup2(saved, fd)
os.close(saved)
sink.seek(0)
data = sink.read()
sink.close()
return data
return restore
def test_failed_ffmpeg_concat_writes_nothing_to_the_terminal(
tmp_path: Path, segments: list[Path], monkeypatch: pytest.MonkeyPatch
) -> None:
def fake_run(args, **kwargs):
# emulate the child: with no capture it writes straight to the inherited terminal
if not kwargs.get("capture_output") and kwargs.get("stderr") is None:
os.write(2, (FFMPEG_ERROR + "\n").encode())
raise subprocess.CalledProcessError(1, args)
raise subprocess.CalledProcessError(1, args, output="", stderr=FFMPEG_ERROR)
monkeypatch.setattr("unshackle.core.manifests.hls.binaries.FFMPEG", Path("ffmpeg"))
monkeypatch.setattr("unshackle.core.manifests.hls.subprocess.run", fake_run)
save_path = tmp_path / "Audio.mp4"
read_stderr = _capture_fd(2)
try:
HLS.merge_segments(segments=segments, save_path=save_path)
finally:
leaked = read_stderr()
assert leaked == b"", f"ffmpeg output reached the terminal: {leaked!r}"
assert save_path.read_bytes() == b"segment-0segment-1segment-2"
def test_failed_ffmpeg_concat_logs_the_reason(
tmp_path: Path, segments: list[Path], monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
def fake_run(args, **kwargs):
raise subprocess.CalledProcessError(1, args, output="", stderr=FFMPEG_ERROR)
monkeypatch.setattr("unshackle.core.manifests.hls.binaries.FFMPEG", Path("ffmpeg"))
monkeypatch.setattr("unshackle.core.manifests.hls.subprocess.run", fake_run)
with caplog.at_level("DEBUG", logger="HLS"):
HLS.merge_segments(segments=segments, save_path=tmp_path / "Audio.mp4")
assert FFMPEG_ERROR in caplog.text
def test_binary_concat_advances_progress_per_segment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""FFmpeg concat reports nothing, so the fallback is the only place a count can come from."""
segments = []
for i in range(4):
seg = tmp_path / f"seg{i}.mp4"
seg.write_bytes(b"\x00" * 16)
segments.append(seg)
calls: list[dict] = []
# no ffmpeg binary means merge_segments takes the binary fallback
monkeypatch.setattr("unshackle.core.manifests.hls.binaries.FFMPEG", None)
HLS.merge_segments(
segments=segments,
save_path=tmp_path / "out.mp4",
progress=lambda **kwargs: calls.append(kwargs),
)
assert calls.count({"advance": 1}) == len(segments), f"expected one advance per segment, got {calls}"
assert (tmp_path / "out.mp4").stat().st_size == 4 * 16
def test_merge_segments_without_progress_still_works(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""progress stays optional; other callers pass nothing."""
seg = tmp_path / "seg0.mp4"
seg.write_bytes(b"\x01" * 8)
monkeypatch.setattr("unshackle.core.manifests.hls.binaries.FFMPEG", None)
size = HLS.merge_segments(segments=[seg], save_path=tmp_path / "out.mp4")
assert size == 8
def test_cleanup_never_climbs_past_the_segment_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""
A download directory may itself say "_segments". Only the track's own segment store
may be removed, never a parent that merely contains the word.
"""
library = tmp_path / "media_segments"
store = library / "temp" / "task_x" / "Audio_1.mp4_segments" / "segments"
store.mkdir(parents=True)
keep = library / "Some Other Movie.mkv"
keep.write_bytes(b"do not delete me")
segments = []
for i in range(2):
seg = store / f"{i:05}.mp4"
seg.write_bytes(b"\x00" * 8)
segments.append(seg)
monkeypatch.setattr("unshackle.core.manifests.hls.binaries.FFMPEG", None)
HLS.merge_segments(segments=segments, save_path=library / "out.mp4")
assert keep.exists(), "cleanup deleted an unrelated file in the download directory"
assert library.exists()
assert not (library / "temp" / "task_x" / "Audio_1.mp4_segments").exists(), "the segment store was left behind"