Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions src/anthias_server/celery_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -686,15 +686,21 @@ def download_youtube_asset(asset_id: str, uri: str) -> None:
from yt_dlp import YoutubeDL
from yt_dlp.utils import DownloadError

from anthias_server.processing import preferred_download_vcodec

ydl_opts = {
# ``format_sort`` mirrors the previous CLI's `-S
# vcodec:h264,fps,res:1080,acodec:m4a` — bias toward h264
# video and m4a audio, keep resolution at 1080p, prefer
# higher fps. yt-dlp still picks the *best matching* format,
# falling back to whatever is available if no exact match
# exists. Strict `format=` filters would reject videos that
# happen to have only vp9, which we don't want.
'format_sort': ['vcodec:h264', 'fps', 'res:1080', 'acodec:m4a'],
# ``format_sort`` biases toward the board's preferred codec,
# m4a audio, 1080p resolution, and higher fps. yt-dlp still
# picks the *best matching* format, falling back to whatever
# is available if no exact match exists. Strict ``format=``
# filters would reject videos that only have other codecs,
# which we don't want — normalize_video_asset handles that.
'format_sort': [
f'vcodec:{preferred_download_vcodec()}',
'fps',
'res:1080',
'acodec:m4a',
],
# Final filename — yt-dlp writes <location>.part during the
# download and renames on success. cleanup() recognises
# .part / .info.json sidecars and skips them inside the 1h
Expand Down
49 changes: 38 additions & 11 deletions src/anthias_server/processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -885,7 +885,11 @@ def _ffprobe_summary(input_path: str) -> dict[str, Any]:
# V4L2 bcm2835-codec), not the Qt5 GStreamer fbdev path.
'pi3-64': frozenset({'h264'}),
'pi4-64': frozenset({'h264', 'hevc'}),
'pi5': frozenset({'hevc'}),
# hevc: hardware-decoded via v4l2-request (BCM2712 VideoCore VII).
# h264: software-decoded — Cortex-A76 handles 1080p H.264 without
# frame drops, and YouTube rarely serves HEVC so excluding h264
# would block all YouTube downloads on Pi 5.
'pi5': frozenset({'hevc', 'h264'}),
'rockpi4': frozenset({'h264', 'hevc'}),
'x86': frozenset({'h264', 'hevc'}),
}
Expand Down Expand Up @@ -933,6 +937,28 @@ def _hw_decoded_codecs() -> frozenset[str]:
return _HW_DECODE_VIDEO_CODECS.get(resolve_device_key(), frozenset())


# Preferred yt-dlp ``vcodec`` sort key per board. Distinct from the
# accepted-codec gate above: Pi 5 accepts H.264 via software decode
# (Cortex-A76 handles 1080p without frame drops) but HEVC is still the
# hardware path, so downloads should bias toward it when available.
# Boards not listed here default to ``h264`` — it is more widely
# available on YouTube and is their primary hardware decode path.
_PREFERRED_DOWNLOAD_VCODEC: dict[str, str] = {
'pi5': 'hevc',
}


def preferred_download_vcodec() -> str:
"""yt-dlp ``vcodec`` sort preference for the current board.

Returns the codec string to place first in ``format_sort`` so
yt-dlp biases downloads toward the board's best playback path.
Falls back to ``'h264'`` for unknown boards and all boards where
H.264 is the primary hardware decode path.
"""
return _PREFERRED_DOWNLOAD_VCODEC.get(resolve_device_key(), 'h264')


def _ffmpeg_reencode_recipe(
supported: frozenset[str],
source_filename: str = '',
Expand All @@ -945,10 +971,10 @@ def _ffmpeg_reencode_recipe(
Prefers libx264 when H.264 is in the board's supported set —
libx264 is roughly 5-10× faster than libx265 at comparable
quality, which matters when the operator is doing the encode by
hand. Falls back to libx265 + ``-tag:v hvc1`` for Pi 5 (HEVC-
only board). Returns an empty string when the board has no HW
decode set at all — there's nothing the operator can transcode
to that would land in a supported pipe.
hand. Falls back to libx265 + ``-tag:v hvc1`` for HEVC-only boards.
Returns an empty string when the board has no HW decode set at all —
there's nothing the operator can transcode to that would land in a
supported pipe.

``source_filename``, when supplied, substitutes the bare upload
filename (no path) for the ``INPUT`` placeholder and reuses its
Expand Down Expand Up @@ -1025,19 +1051,20 @@ def _handbrake_steps(supported: frozenset[str]) -> list[str]:
— there's no separate downscale step to spell out.

The only board-specific tweak is the encoder: an HEVC-only board
(Pi 5) can't use the preset's default H.264, so the operator flips
``Video Encoder`` to ``H.265 (x265)`` on the Video tab. HandBrake
ships no H.265-at-1080p MP4 preset, so the encoder swap is the
cleanest route to a 1080p HEVC MP4.
(Pi 5 before the H.264 software-decode fallback was added) can't
use the preset's default H.264, so the operator flips ``Video
Encoder`` to ``H.265 (x265)`` on the Video tab. HandBrake ships no
H.265-at-1080p MP4 preset, so the encoder swap is the cleanest
route to a 1080p HEVC MP4.

Returns an empty list when the board has no HW decode set at all —
there's nothing to transcode to, exactly as the recipe returns an
empty string. Step text embeds the download URL verbatim so the
list stands alone when surfaced as plain text via the v2 API.
"""
if 'h264' in supported:
# H.264 board (Pi 2/3/4, x86, ...): the stock preset already
# outputs the codec we want — no encoder change.
# H.264 board (Pi 2/3/4, Pi 5, x86, ...): the stock preset
# already outputs an accepted codec — no encoder change needed.
prefers_h264 = True
elif 'hevc' in supported:
prefers_h264 = False
Expand Down
114 changes: 114 additions & 0 deletions tests/test_celery_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,120 @@ def test_download_youtube_asset_on_failure_writes_error_metadata() -> None:
mock_notify.assert_called_once_with('yt-1')


# ---------------------------------------------------------------------------
# download_youtube_asset — per-board format_sort selection (GH #3092)
# ---------------------------------------------------------------------------


@pytest.mark.django_db
def test_download_youtube_asset_pi5_prefers_hevc_format(
fake_youtube_dl: mock.MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Pi 5 biases yt-dlp toward HEVC — it is the hardware decode path
(VideoCore VII). H.264 is accepted as a software fallback (GH #3092)
but HEVC is preferred when YouTube serves it."""
monkeypatch.setenv('DEVICE_TYPE', 'pi5')
_make_youtube_asset()
fake_youtube_dl.extract_info.return_value = {'title': 't', 'duration': 10}
with (
mock.patch('anthias_server.app.consumers.notify_asset_update'),
mock.patch('anthias_server.processing.dispatch_normalize_video'),
):
download_youtube_asset('yt-1', 'https://www.youtube.com/watch?v=abc')

ydl_opts = fake_youtube_dl._cls.call_args.args[0]
assert ydl_opts['format_sort'] == [
'vcodec:hevc',
'fps',
'res:1080',
'acodec:m4a',
], f'Pi 5 must bias yt-dlp toward HEVC; got: {ydl_opts["format_sort"]!r}'


@pytest.mark.django_db
def test_download_youtube_asset_pi4_prefers_h264_format(
fake_youtube_dl: mock.MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Pi 4 hardware-decodes both H.264 and HEVC, so it keeps the H.264
preference — H.264 is more widely available on YouTube and Pi 4 plays
it just as efficiently as HEVC."""
monkeypatch.setenv('DEVICE_TYPE', 'pi4-64')
_make_youtube_asset()
fake_youtube_dl.extract_info.return_value = {'title': 't', 'duration': 10}
with (
mock.patch('anthias_server.app.consumers.notify_asset_update'),
mock.patch('anthias_server.processing.dispatch_normalize_video'),
):
download_youtube_asset('yt-1', 'https://www.youtube.com/watch?v=abc')

ydl_opts = fake_youtube_dl._cls.call_args.args[0]
assert ydl_opts['format_sort'][0] == 'vcodec:h264'


@pytest.mark.django_db
def test_download_youtube_asset_x86_prefers_h264_format(
fake_youtube_dl: mock.MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""x86 keeps the H.264 preference — HEVC via VAAPI is known to produce
a black screen on x86, so biasing toward HEVC downloads would cause
silent playback failures when YouTube serves HEVC streams."""
monkeypatch.setenv('DEVICE_TYPE', 'x86')
_make_youtube_asset()
fake_youtube_dl.extract_info.return_value = {'title': 't', 'duration': 10}
with (
mock.patch('anthias_server.app.consumers.notify_asset_update'),
mock.patch('anthias_server.processing.dispatch_normalize_video'),
):
download_youtube_asset('yt-1', 'https://www.youtube.com/watch?v=abc')

ydl_opts = fake_youtube_dl._cls.call_args.args[0]
assert ydl_opts['format_sort'][0] == 'vcodec:h264'


@pytest.mark.django_db
def test_download_youtube_asset_pi3_prefers_h264_format(
fake_youtube_dl: mock.MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Pi 3 supports H.264 only; format_sort must request h264."""
monkeypatch.setenv('DEVICE_TYPE', 'pi3')
_make_youtube_asset()
fake_youtube_dl.extract_info.return_value = {'title': 't', 'duration': 10}
with (
mock.patch('anthias_server.app.consumers.notify_asset_update'),
mock.patch('anthias_server.processing.dispatch_normalize_video'),
):
download_youtube_asset('yt-1', 'https://www.youtube.com/watch?v=abc')

ydl_opts = fake_youtube_dl._cls.call_args.args[0]
assert ydl_opts['format_sort'][0] == 'vcodec:h264'


@pytest.mark.django_db
def test_download_youtube_asset_unknown_board_falls_back_to_h264(
fake_youtube_dl: mock.MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An unrecognised DEVICE_TYPE yields an empty HW codec set; the
task must not crash and must fall back to the h264 preference so
the download still proceeds. normalize_video_asset will gate on
the actual codec if the board turns out not to support it."""
monkeypatch.setenv('DEVICE_TYPE', 'unrecognised-board-xyz')
_make_youtube_asset()
fake_youtube_dl.extract_info.return_value = {'title': 't', 'duration': 10}
with (
mock.patch('anthias_server.app.consumers.notify_asset_update'),
mock.patch('anthias_server.processing.dispatch_normalize_video'),
):
download_youtube_asset('yt-1', 'https://www.youtube.com/watch?v=abc')

ydl_opts = fake_youtube_dl._cls.call_args.args[0]
assert ydl_opts['format_sort'][0] == 'vcodec:h264'


# ---------------------------------------------------------------------------
# download_remote_video_asset — generic http(s) single-file video URLs
# ---------------------------------------------------------------------------
Expand Down
Loading