| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239 |
- """The timelapse editor's re-encode runs as a job: started, polled, cancelled."""
- import asyncio
- from pathlib import Path
- from unittest.mock import patch
- import pytest
- from httpx import AsyncClient
- from backend.app.services import timelapse_jobs
- from backend.app.services.timelapse_processor import TimelapseProcessor
- pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
- @pytest.fixture(autouse=True)
- def _no_jobs():
- timelapse_jobs._jobs.clear()
- yield
- timelapse_jobs._jobs.clear()
- @pytest.fixture
- def data_dir(tmp_path):
- from backend.app.api.routes import archives
- with (
- patch.object(archives.settings, "base_dir", tmp_path),
- patch("backend.app.services.timelapse_processor.get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
- ):
- yield tmp_path
- async def _archive(printer_factory, archive_factory, data_dir: Path):
- printer = await printer_factory()
- video = data_dir / "archive" / "1" / "timelapse.mp4"
- video.parent.mkdir(parents=True)
- video.write_bytes(b"original")
- return await archive_factory(printer.id, timelapse_path="archive/1/timelapse.mp4")
- async def _settled(client: AsyncClient, archive_id: int) -> dict:
- for _ in range(100):
- body = (await client.get(f"/api/v1/archives/{archive_id}/timelapse/job")).json()
- if body["status"] != "running":
- return body
- await asyncio.sleep(0.02)
- raise AssertionError("job still running")
- def _encoder(gate: asyncio.Event | None = None, ok: bool = True):
- async def process(self, output_path, on_progress=None, **kwargs):
- on_progress(0.4)
- if gate is not None:
- await gate.wait()
- output_path.write_bytes(b"edited")
- return ok
- return patch.object(TimelapseProcessor, "process", process)
- async def test_the_request_returns_before_the_encode_and_the_job_reports_how_it_went(
- async_client: AsyncClient, printer_factory, archive_factory, data_dir
- ):
- archive = await _archive(printer_factory, archive_factory, data_dir)
- gate = asyncio.Event()
- with _encoder(gate):
- started = await async_client.post(
- f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace", "speed": "2"}
- )
- assert started.status_code == 202, started.text
- assert started.json()["status"] == "running"
- await asyncio.sleep(0.05)
- running = (await async_client.get(f"/api/v1/archives/{archive.id}/timelapse/job")).json()
- gate.set()
- done = await _settled(async_client, archive.id)
- assert (running["status"], running["progress"]) == ("running", 0.4)
- assert done["status"] == "completed"
- assert done["message"] == "Timelapse replaced successfully"
- assert (data_dir / "archive/1/timelapse.mp4").read_bytes() == b"edited"
- async def test_a_second_start_while_one_runs_is_refused(
- async_client: AsyncClient, printer_factory, archive_factory, data_dir
- ):
- archive = await _archive(printer_factory, archive_factory, data_dir)
- gate = asyncio.Event()
- with _encoder(gate):
- first = await async_client.post(
- f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace"}
- )
- second = await async_client.post(
- f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace"}
- )
- gate.set()
- await _settled(async_client, archive.id)
- assert (first.status_code, second.status_code) == (202, 409)
- async def test_cancelling_keeps_the_original_and_removes_the_temporary_files(
- async_client: AsyncClient, printer_factory, archive_factory, data_dir
- ):
- import tempfile
- archive = await _archive(printer_factory, archive_factory, data_dir)
- before = set(Path(tempfile.gettempdir()).glob(f"*_{archive.id}_*"))
- with _encoder(asyncio.Event()):
- await async_client.post(
- f"/api/v1/archives/{archive.id}/timelapse/process",
- data={"save_mode": "replace"},
- files={"audio": ("music.mp3", b"ID3", "audio/mpeg")},
- )
- cancelled = await async_client.delete(f"/api/v1/archives/{archive.id}/timelapse/job")
- assert cancelled.status_code == 200, cancelled.text
- assert cancelled.json()["status"] == "cancelled"
- assert (data_dir / "archive/1/timelapse.mp4").read_bytes() == b"original"
- assert set(Path(tempfile.gettempdir()).glob(f"*_{archive.id}_*")) == before
- assert (await async_client.delete(f"/api/v1/archives/{archive.id}/timelapse/job")).status_code == 404
- async def test_a_failed_encode_is_reported(async_client: AsyncClient, printer_factory, archive_factory, data_dir):
- import tempfile
- archive = await _archive(printer_factory, archive_factory, data_dir)
- before = set(Path(tempfile.gettempdir()).glob(f"*_{archive.id}_*"))
- with _encoder(ok=False):
- await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace"})
- done = await _settled(async_client, archive.id)
- assert (done["status"], done["error"]) == ("failed", "Video processing failed")
- assert (data_dir / "archive/1/timelapse.mp4").read_bytes() == b"original"
- # The half-written output goes too.
- assert set(Path(tempfile.gettempdir()).glob(f"*_{archive.id}_*")) == before
- async def test_no_job_yet(async_client: AsyncClient, printer_factory, archive_factory, data_dir):
- archive = await _archive(printer_factory, archive_factory, data_dir)
- assert (await async_client.get(f"/api/v1/archives/{archive.id}/timelapse/job")).status_code == 404
- assert (await async_client.get("/api/v1/archives/999999/timelapse/job")).status_code == 404
- async def test_the_music_volume_reaches_the_encode(
- async_client: AsyncClient, printer_factory, archive_factory, data_dir
- ):
- archive = await _archive(printer_factory, archive_factory, data_dir)
- seen = {}
- async def process(self, output_path, on_progress=None, **kwargs):
- seen.update(kwargs)
- output_path.write_bytes(b"edited")
- return True
- with patch.object(TimelapseProcessor, "process", process):
- await async_client.post(
- f"/api/v1/archives/{archive.id}/timelapse/process",
- data={"save_mode": "replace", "audio_volume": "0.3"},
- files={"audio": ("music.mp3", b"ID3", "audio/mpeg")},
- )
- await _settled(async_client, archive.id)
- assert seen["audio_volume"] == 0.3
- @pytest.mark.parametrize("volume", ["-0.1", "1.5"])
- async def test_a_volume_out_of_range_is_refused(
- async_client: AsyncClient, printer_factory, archive_factory, data_dir, volume
- ):
- archive = await _archive(printer_factory, archive_factory, data_dir)
- response = await async_client.post(
- f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace", "audio_volume": volume}
- )
- assert response.status_code == 400
- assert timelapse_jobs.get_job(archive.id) is None
- @pytest.mark.parametrize("change", ["removed", "replaced"])
- async def test_a_timelapse_changed_during_the_edit_is_not_overwritten(
- async_client: AsyncClient, printer_factory, archive_factory, data_dir, db_session, change
- ):
- archive = await _archive(printer_factory, archive_factory, data_dir)
- video = data_dir / "archive/1/timelapse.mp4"
- gate = asyncio.Event()
- with _encoder(gate):
- await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace"})
- if change == "removed":
- assert (await async_client.delete(f"/api/v1/archives/{archive.id}/timelapse")).status_code == 200
- else:
- video.unlink()
- video.write_bytes(b"a newer video")
- gate.set()
- done = await _settled(async_client, archive.id)
- assert done["status"] == "failed"
- assert "removed or replaced" in done["error"]
- if change == "removed":
- assert not video.exists()
- else:
- assert video.read_bytes() == b"a newer video"
- async def test_two_saves_racing_get_one_job_and_a_409(
- async_client: AsyncClient, printer_factory, archive_factory, data_dir
- ):
- """The second passed the early check while the first one's audio upload was read."""
- import tempfile
- archive = await _archive(printer_factory, archive_factory, data_dir)
- before = set(Path(tempfile.gettempdir()).glob(f"audio_{archive.id}_*"))
- real_start = timelapse_jobs.start_job
- async def other_request(job):
- await asyncio.Event().wait()
- return "", ""
- def raced(archive_id, run):
- real_start(archive_id, other_request)
- return real_start(archive_id, run)
- with _encoder(asyncio.Event()), patch.object(timelapse_jobs, "start_job", raced):
- response = await async_client.post(
- f"/api/v1/archives/{archive.id}/timelapse/process",
- data={"save_mode": "replace"},
- files={"audio": ("music.mp3", b"ID3", "audio/mpeg")},
- )
- await timelapse_jobs.cancel_all()
- assert response.status_code == 409
- assert set(Path(tempfile.gettempdir()).glob(f"audio_{archive.id}_*")) == before
|