diff --git a/src/anthias_server/processing.py b/src/anthias_server/processing.py index f6819fdc..8c088fb4 100644 --- a/src/anthias_server/processing.py +++ b/src/anthias_server/processing.py @@ -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', diff --git a/tests/test_processing.py b/tests/test_processing.py index 17a2ed49..4d70ec8e 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -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'), [