timelapse_jobs.py 2.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. """Timelapse editor re-encodes, run as background jobs.
  2. Re-encoding a long timelapse takes minutes. Run inside the request, a browser
  3. or proxy that gave up left ffmpeg running with nobody waiting for the result.
  4. A job runs on its own instead: the editor polls its progress, and cancelling
  5. it stops ffmpeg. One job per archive at a time; the last one is kept so a
  6. reopened editor can show how it ended.
  7. """
  8. import asyncio
  9. import contextlib
  10. import logging
  11. from collections.abc import Awaitable, Callable
  12. from dataclasses import dataclass, field
  13. logger = logging.getLogger(__name__)
  14. RUNNING = "running"
  15. COMPLETED = "completed"
  16. FAILED = "failed"
  17. CANCELLED = "cancelled"
  18. @dataclass
  19. class TimelapseJob:
  20. archive_id: int
  21. status: str = RUNNING
  22. # 0.0 to 1.0; None until ffmpeg reports its first position.
  23. progress: float | None = None
  24. output_path: str | None = None
  25. message: str | None = None
  26. error: str | None = None
  27. task: asyncio.Task | None = field(default=None, repr=False)
  28. def set_progress(self, progress: float) -> None:
  29. self.progress = min(max(progress, 0.0), 1.0)
  30. class JobAlreadyRunning(Exception):
  31. pass
  32. # Runs the re-encode; returns (output path relative to the data dir, message).
  33. JobRunner = Callable[[TimelapseJob], Awaitable[tuple[str, str]]]
  34. _jobs: dict[int, TimelapseJob] = {}
  35. def get_job(archive_id: int) -> TimelapseJob | None:
  36. return _jobs.get(archive_id)
  37. def start_job(archive_id: int, run: JobRunner) -> TimelapseJob:
  38. current = _jobs.get(archive_id)
  39. if current is not None and current.status == RUNNING:
  40. raise JobAlreadyRunning
  41. job = TimelapseJob(archive_id=archive_id)
  42. _jobs[archive_id] = job
  43. job.task = asyncio.create_task(_run(job, run))
  44. return job
  45. async def _run(job: TimelapseJob, run: JobRunner) -> None:
  46. try:
  47. job.output_path, job.message = await run(job)
  48. job.progress = 1.0
  49. job.status = COMPLETED
  50. except asyncio.CancelledError:
  51. job.status = CANCELLED
  52. except Exception as e:
  53. logger.error("Timelapse processing failed for archive %s: %s", job.archive_id, e)
  54. job.error = str(e)
  55. job.status = FAILED
  56. async def cancel_job(archive_id: int) -> bool:
  57. """Cancel the archive's running job; False when none is running."""
  58. job = _jobs.get(archive_id)
  59. if job is None or job.status != RUNNING or job.task is None:
  60. return False
  61. job.task.cancel()
  62. with contextlib.suppress(asyncio.CancelledError):
  63. await job.task
  64. return True
  65. async def cancel_all() -> None:
  66. for archive_id in list(_jobs):
  67. await cancel_job(archive_id)