test_timelapse_editor_job.py 9.3 KB

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