mirror of
https://github.com/Screenly/Anthias.git
synced 2026-06-10 09:08:09 -04:00
fix(processing): address Copilot review on commit 778d5c9f
Three code fixes plus a PR-description sync: * ``_run_image_normalisation`` no-op path (when src_ext isn't in NORMALIZE_IMAGE_EXTS) now also clears ``metadata.error_message``. Without this, a row re-uploaded as a JPEG/PNG after a previously-failed HEIC conversion would drop is_processing but keep showing the "Failed" pill — the operator's table would lie about the row's current state. * ``_ffprobe_summary`` now catches ``sh.CommandNotFound`` in addition to ``TimeoutException`` / ``ErrorReturnCode``. A stripped-down image / dev box without ffprobe in PATH used to crash the task with an unhandled CommandNotFound; now it collapses to the same all-'unknown' summary so the runner falls through to the transcode branch (which itself fails clean if ffmpeg is also missing — same on_failure contract). * Rewrote the ``_ffprobe_summary`` docstring: the actual behaviour is "unknown" for missing video stream, "none" only for genuinely missing audio stream. The previous "''" claim was wrong and would have misled callers / future maintainers. Tests: * ``test_image_no_op_path_clears_stale_error_message`` — JPEG re-uploaded over a row whose previous attempt failed; the no-op branch must wipe the stale error_message. * ``test_ffprobe_summary_handles_missing_ffprobe_binary`` — CommandNotFound side-effect; asserts all-'unknown' summary rather than a propagating exception.
This commit is contained in:
@@ -504,7 +504,15 @@ def _run_image_normalisation(asset: Asset) -> None:
|
||||
if src_ext not in NORMALIZE_IMAGE_EXTS:
|
||||
# Defensive: caller routed something we don't convert.
|
||||
# Treat as a no-op success rather than re-encoding a JPEG.
|
||||
Asset.objects.filter(asset_id=asset_id).update(is_processing=False)
|
||||
# Clearing ``error_message`` matters when the row is being
|
||||
# re-uploaded after a previous failed attempt — without it
|
||||
# the operator would still see the "Failed" pill on a row
|
||||
# that's now actually fine.
|
||||
metadata = dict(asset.metadata or {})
|
||||
metadata.pop('error_message', None)
|
||||
Asset.objects.filter(asset_id=asset_id).update(
|
||||
is_processing=False, metadata=metadata
|
||||
)
|
||||
_notify(asset_id)
|
||||
return
|
||||
|
||||
@@ -623,15 +631,29 @@ def _ffprobe_streams(input_path: str) -> dict[str, Any]:
|
||||
def _ffprobe_summary(input_path: str) -> dict[str, str]:
|
||||
"""Reduce ffprobe's payload to the three dimensions we branch on.
|
||||
|
||||
Returns a dict with ``container`` (lowercase ext, no dot),
|
||||
``video_codec`` (or ``''`` if no video stream), and
|
||||
``audio_codec`` (``'none'`` if no audio stream). Anything missing
|
||||
from the probe output is treated as 'unknown' so the caller can
|
||||
fall through to transcode.
|
||||
Returns a dict with three keys, all populated:
|
||||
* ``container`` — lowercase format token, ``'unknown'`` if
|
||||
ffprobe couldn't decide.
|
||||
* ``video_codec`` — lowercase codec name, ``'unknown'`` if
|
||||
the file has no video stream or the probe failed.
|
||||
* ``audio_codec`` — lowercase codec name, ``'none'`` when the
|
||||
file genuinely carries no audio stream, or ``'unknown'`` if
|
||||
the audio stream existed but ffprobe couldn't name its
|
||||
codec.
|
||||
|
||||
Any failure (timeout, ffprobe non-zero exit, ffprobe missing
|
||||
from PATH) collapses to all-'unknown' so the caller falls
|
||||
through to the transcode branch — better to spend the cycles
|
||||
re-encoding than to let an unplayable file sit in rotation.
|
||||
"""
|
||||
try:
|
||||
probe = _ffprobe_streams(input_path)
|
||||
except (sh.TimeoutException, sh.ErrorReturnCode):
|
||||
except (sh.TimeoutException, sh.ErrorReturnCode, sh.CommandNotFound):
|
||||
# ``CommandNotFound`` covers stripped-down images / dev
|
||||
# boxes without ffprobe in PATH — same recovery as a probe
|
||||
# that ran but couldn't decide: report 'unknown' across the
|
||||
# board so the runner falls through to the transcode (which
|
||||
# itself ultimately fails clean if ffmpeg is also missing).
|
||||
return {
|
||||
'container': 'unknown',
|
||||
'video_codec': 'unknown',
|
||||
|
||||
@@ -402,6 +402,30 @@ def test_image_jpeg_routes_no_op(asset_dir: str) -> None:
|
||||
notify.assert_called_once_with('img-jpeg')
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_image_no_op_path_clears_stale_error_message(asset_dir: str) -> None:
|
||||
"""A row being re-uploaded after a previous failed conversion
|
||||
carries ``metadata.error_message``. When the new upload is a
|
||||
format the pipeline doesn't convert (JPEG/PNG/etc.), the no-op
|
||||
branch must still wipe the stale error so the operator's table
|
||||
doesn't keep showing the "Failed" pill on a row that's now
|
||||
fine."""
|
||||
src = path.join(asset_dir, 'photo.jpg')
|
||||
_write_image(src, 'JPEG', mode='RGB')
|
||||
asset = _make_processing_asset(
|
||||
'img-retry-jpeg',
|
||||
src,
|
||||
metadata={'error_message': 'previous attempt: libheif crashed'},
|
||||
)
|
||||
|
||||
with mock.patch.object(processing, '_notify'):
|
||||
processing._run_image_normalisation(asset)
|
||||
|
||||
asset.refresh_from_db()
|
||||
assert asset.is_processing is False
|
||||
assert 'error_message' not in asset.metadata
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_image_row_already_finalized_no_op(asset_dir: str) -> None:
|
||||
"""Duplicate task fire on an already-finalised row → no-op. Same
|
||||
@@ -980,6 +1004,26 @@ def test_ffprobe_summary_handles_probe_failure() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_ffprobe_summary_handles_missing_ffprobe_binary() -> None:
|
||||
"""A stripped-down container or dev box without ffprobe in PATH
|
||||
must not crash the normalisation task. ``sh.CommandNotFound``
|
||||
is raised before any subprocess starts; the helper collapses
|
||||
it to the same all-'unknown' summary as a probe-runtime error,
|
||||
so the caller falls through to the transcode branch (which
|
||||
will then itself fail clean if ffmpeg is also missing)."""
|
||||
with mock.patch.object(
|
||||
processing,
|
||||
'_ffprobe_streams',
|
||||
side_effect=sh.CommandNotFound('ffprobe not on PATH'),
|
||||
):
|
||||
summary = processing._ffprobe_summary('fixture.mp4')
|
||||
assert summary == {
|
||||
'container': 'unknown',
|
||||
'video_codec': 'unknown',
|
||||
'audio_codec': 'unknown',
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('summary', 'expected'),
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user