test_timelapse_editor_job.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. """The timelapse editor's re-encode runs as a job: started, polled, cancelled."""
  2. import asyncio
  3. from pathlib import Path
  4. from unittest.mock import patch
  5. import pytest
  6. from httpx import AsyncClient
  7. from backend.app.services import timelapse_jobs
  8. from backend.app.services.timelapse_processor import TimelapseProcessor
  9. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  10. @pytest.fixture(autouse=True)
  11. def _no_jobs():
  12. timelapse_jobs._jobs.clear()
  13. yield
  14. timelapse_jobs._jobs.clear()
  15. @pytest.fixture
  16. def data_dir(tmp_path):
  17. from backend.app.api.routes import archives
  18. with (
  19. patch.object(archives.settings, "base_dir", tmp_path),
  20. patch("backend.app.services.timelapse_processor.get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
  21. ):
  22. yield tmp_path
  23. async def _archive(printer_factory, archive_factory, data_dir: Path):
  24. printer = await printer_factory()
  25. video = data_dir / "archive" / "1" / "timelapse.mp4"
  26. video.parent.mkdir(parents=True)
  27. video.write_bytes(b"original")
  28. return await archive_factory(printer.id, timelapse_path="archive/1/timelapse.mp4")
  29. async def _settled(client: AsyncClient, archive_id: int) -> dict:
  30. for _ in range(100):
  31. body = (await client.get(f"/api/v1/archives/{archive_id}/timelapse/job")).json()
  32. if body["status"] != "running":
  33. return body
  34. await asyncio.sleep(0.02)
  35. raise AssertionError("job still running")
  36. def _encoder(gate: asyncio.Event | None = None, ok: bool = True):
  37. async def process(self, output_path, on_progress=None, **kwargs):
  38. on_progress(0.4)
  39. if gate is not None:
  40. await gate.wait()
  41. output_path.write_bytes(b"edited")
  42. return ok
  43. return patch.object(TimelapseProcessor, "process", process)
  44. async def test_the_request_returns_before_the_encode_and_the_job_reports_how_it_went(
  45. async_client: AsyncClient, printer_factory, archive_factory, data_dir
  46. ):
  47. archive = await _archive(printer_factory, archive_factory, data_dir)
  48. gate = asyncio.Event()
  49. with _encoder(gate):
  50. started = await async_client.post(
  51. f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace", "speed": "2"}
  52. )
  53. assert started.status_code == 202, started.text
  54. assert started.json()["status"] == "running"
  55. await asyncio.sleep(0.05)
  56. running = (await async_client.get(f"/api/v1/archives/{archive.id}/timelapse/job")).json()
  57. gate.set()
  58. done = await _settled(async_client, archive.id)
  59. assert (running["status"], running["progress"]) == ("running", 0.4)
  60. assert done["status"] == "completed"
  61. assert done["message"] == "Timelapse replaced successfully"
  62. assert (data_dir / "archive/1/timelapse.mp4").read_bytes() == b"edited"
  63. async def test_a_second_start_while_one_runs_is_refused(
  64. async_client: AsyncClient, printer_factory, archive_factory, data_dir
  65. ):
  66. archive = await _archive(printer_factory, archive_factory, data_dir)
  67. gate = asyncio.Event()
  68. with _encoder(gate):
  69. first = await async_client.post(
  70. f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace"}
  71. )
  72. second = await async_client.post(
  73. f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace"}
  74. )
  75. gate.set()
  76. await _settled(async_client, archive.id)
  77. assert (first.status_code, second.status_code) == (202, 409)
  78. async def test_cancelling_keeps_the_original_and_removes_the_temporary_files(
  79. async_client: AsyncClient, printer_factory, archive_factory, data_dir
  80. ):
  81. import tempfile
  82. archive = await _archive(printer_factory, archive_factory, data_dir)
  83. before = set(Path(tempfile.gettempdir()).glob(f"*_{archive.id}_*"))
  84. with _encoder(asyncio.Event()):
  85. await async_client.post(
  86. f"/api/v1/archives/{archive.id}/timelapse/process",
  87. data={"save_mode": "replace"},
  88. files={"audio": ("music.mp3", b"ID3", "audio/mpeg")},
  89. )
  90. cancelled = await async_client.delete(f"/api/v1/archives/{archive.id}/timelapse/job")
  91. assert cancelled.status_code == 200, cancelled.text
  92. assert cancelled.json()["status"] == "cancelled"
  93. assert (data_dir / "archive/1/timelapse.mp4").read_bytes() == b"original"
  94. assert set(Path(tempfile.gettempdir()).glob(f"*_{archive.id}_*")) == before
  95. assert (await async_client.delete(f"/api/v1/archives/{archive.id}/timelapse/job")).status_code == 404
  96. async def test_a_failed_encode_is_reported(async_client: AsyncClient, printer_factory, archive_factory, data_dir):
  97. import tempfile
  98. archive = await _archive(printer_factory, archive_factory, data_dir)
  99. before = set(Path(tempfile.gettempdir()).glob(f"*_{archive.id}_*"))
  100. with _encoder(ok=False):
  101. await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace"})
  102. done = await _settled(async_client, archive.id)
  103. assert (done["status"], done["error"]) == ("failed", "Video processing failed")
  104. assert (data_dir / "archive/1/timelapse.mp4").read_bytes() == b"original"
  105. # The half-written output goes too.
  106. assert set(Path(tempfile.gettempdir()).glob(f"*_{archive.id}_*")) == before
  107. async def test_no_job_yet(async_client: AsyncClient, printer_factory, archive_factory, data_dir):
  108. archive = await _archive(printer_factory, archive_factory, data_dir)
  109. assert (await async_client.get(f"/api/v1/archives/{archive.id}/timelapse/job")).status_code == 404
  110. assert (await async_client.get("/api/v1/archives/999999/timelapse/job")).status_code == 404
  111. async def test_the_music_volume_reaches_the_encode(
  112. async_client: AsyncClient, printer_factory, archive_factory, data_dir
  113. ):
  114. archive = await _archive(printer_factory, archive_factory, data_dir)
  115. seen = {}
  116. async def process(self, output_path, on_progress=None, **kwargs):
  117. seen.update(kwargs)
  118. output_path.write_bytes(b"edited")
  119. return True
  120. with patch.object(TimelapseProcessor, "process", process):
  121. await async_client.post(
  122. f"/api/v1/archives/{archive.id}/timelapse/process",
  123. data={"save_mode": "replace", "audio_volume": "0.3"},
  124. files={"audio": ("music.mp3", b"ID3", "audio/mpeg")},
  125. )
  126. await _settled(async_client, archive.id)
  127. assert seen["audio_volume"] == 0.3
  128. @pytest.mark.parametrize("volume", ["-0.1", "1.5"])
  129. async def test_a_volume_out_of_range_is_refused(
  130. async_client: AsyncClient, printer_factory, archive_factory, data_dir, volume
  131. ):
  132. archive = await _archive(printer_factory, archive_factory, data_dir)
  133. response = await async_client.post(
  134. f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace", "audio_volume": volume}
  135. )
  136. assert response.status_code == 400
  137. assert timelapse_jobs.get_job(archive.id) is None
  138. @pytest.mark.parametrize("change", ["removed", "replaced"])
  139. async def test_a_timelapse_changed_during_the_edit_is_not_overwritten(
  140. async_client: AsyncClient, printer_factory, archive_factory, data_dir, db_session, change
  141. ):
  142. archive = await _archive(printer_factory, archive_factory, data_dir)
  143. video = data_dir / "archive/1/timelapse.mp4"
  144. gate = asyncio.Event()
  145. with _encoder(gate):
  146. await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace"})
  147. if change == "removed":
  148. assert (await async_client.delete(f"/api/v1/archives/{archive.id}/timelapse")).status_code == 200
  149. else:
  150. video.unlink()
  151. video.write_bytes(b"a newer video")
  152. gate.set()
  153. done = await _settled(async_client, archive.id)
  154. assert done["status"] == "failed"
  155. assert "removed or replaced" in done["error"]
  156. if change == "removed":
  157. assert not video.exists()
  158. else:
  159. assert video.read_bytes() == b"a newer video"
  160. async def test_two_saves_racing_get_one_job_and_a_409(
  161. async_client: AsyncClient, printer_factory, archive_factory, data_dir
  162. ):
  163. """The second passed the early check while the first one's audio upload was read."""
  164. import tempfile
  165. archive = await _archive(printer_factory, archive_factory, data_dir)
  166. before = set(Path(tempfile.gettempdir()).glob(f"audio_{archive.id}_*"))
  167. real_start = timelapse_jobs.start_job
  168. async def other_request(job):
  169. await asyncio.Event().wait()
  170. return "", ""
  171. def raced(archive_id, run):
  172. real_start(archive_id, other_request)
  173. return real_start(archive_id, run)
  174. with _encoder(asyncio.Event()), patch.object(timelapse_jobs, "start_job", raced):
  175. response = await async_client.post(
  176. f"/api/v1/archives/{archive.id}/timelapse/process",
  177. data={"save_mode": "replace"},
  178. files={"audio": ("music.mp3", b"ID3", "audio/mpeg")},
  179. )
  180. await timelapse_jobs.cancel_all()
  181. assert response.status_code == 409
  182. assert set(Path(tempfile.gettempdir()).glob(f"audio_{archive.id}_*")) == before