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:
Viktor Petersson
2026-05-07 09:12:22 +00:00
parent 778d5c9f6a
commit 42697452ea
2 changed files with 73 additions and 7 deletions

View File

@@ -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',

View File

@@ -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'),
[