Three defects found by truncating a real download and killing one mid-flight. Matroska and WebM write the duration into the header, so ffprobe reports the full 212.8s of a video.mkv cut off after a kilobyte -- exit 0, plausible answer. verify_video therefore returned OK, the corrupt file was never re-downloaded, ffmpeg extracted the 0.02s of audio it could find, and the run reported "1 ok" with an empty transcript. The size recorded at download time is the only evidence the bytes are still there, so it is now checked whenever it is known rather than only as a fallback when the duration is unreadable. The audio stage now takes the expected duration and rejects an extraction that does not match it. ffmpeg exits 0 on a truncated container, so without this a damaged source yields a confident transcript of near-silence, which is a worse outcome than a failed job. It compares against the video's own probed duration rather than state.duration_s, which can come from playlist metadata. aria2 saves its control file every 60s by default. Since that file is what a resume reads, a kill -9 inside the first minute preserved a control file recording zero completed pieces: measured 0/13 on the sample, so the "resume" re-downloaded the lot while reporting a partial. At --auto-save-interval=20 the same kill preserves 2/13 pieces and the resumed download is byte-identical to a clean one. The duration tolerance moves to media/ffmpeg.py, which both callers already import, instead of being restated per call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
95 lines
2.2 KiB
Python
95 lines
2.2 KiB
Python
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
def _run(args: list[str]) -> None:
|
|
subprocess.run(args, capture_output=True, check=True) # noqa: S603
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def sine_wav(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|
"""One second of 440 Hz at 44.1 kHz stereo -- a real file, not a mock."""
|
|
path = tmp_path_factory.mktemp("media") / "sine.wav"
|
|
_run(
|
|
[
|
|
"ffmpeg",
|
|
"-nostdin",
|
|
"-loglevel",
|
|
"error",
|
|
"-y",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"sine=frequency=440:duration=1:sample_rate=44100",
|
|
"-ac",
|
|
"2",
|
|
str(path),
|
|
]
|
|
)
|
|
return path
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def silent_video(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|
"""A video with no audio track at all."""
|
|
path = tmp_path_factory.mktemp("media") / "mute.mkv"
|
|
_run(
|
|
[
|
|
"ffmpeg",
|
|
"-nostdin",
|
|
"-loglevel",
|
|
"error",
|
|
"-y",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"color=c=black:s=64x64:d=1",
|
|
"-r",
|
|
"5",
|
|
str(path),
|
|
]
|
|
)
|
|
return path
|
|
|
|
|
|
@pytest.fixture
|
|
def not_media(tmp_path: Path) -> Path:
|
|
path = tmp_path / "notes.txt"
|
|
path.write_text("this is definitely not a media file")
|
|
return path
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def sine_video(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|
"""A real mkv with audio -- truncating it leaves a readable header duration."""
|
|
path = tmp_path_factory.mktemp("media") / "clip.mkv"
|
|
_run(
|
|
[
|
|
"ffmpeg",
|
|
"-nostdin",
|
|
"-loglevel",
|
|
"error",
|
|
"-y",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"color=c=black:s=64x64:d=1",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"sine=frequency=440:duration=1:sample_rate=44100",
|
|
"-r",
|
|
"5",
|
|
"-shortest",
|
|
str(path),
|
|
]
|
|
)
|
|
return path
|