8f10353f0c
CI / lint (push) Has been cancelled
CI / js-syntax (push) Successful in 10m24s
CI / deps-audit (push) Has been cancelled
CI / test (push) Failing after 24m55s
CI / sast-bandit (push) Failing after 11m13s
CI / trivy (push) Failing after 9m32s
Docker Publish / build-and-push (push) Failing after 34m3s
112 lines
4.3 KiB
Python
112 lines
4.3 KiB
Python
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Literal
|
|
|
|
|
|
class JobCancelled(Exception):
|
|
"""Raised inside a pipeline stage when the job's cancel flag is set."""
|
|
|
|
|
|
JobStatus = Literal[
|
|
"queued", "downloading", "analyzing", "separating", "processing", "done", "error", "cancelled"
|
|
]
|
|
|
|
|
|
def _set(job: Job, **fields: object) -> None:
|
|
"""Mutate Job fields. SSE polling picks up the change automatically."""
|
|
for k, v in fields.items():
|
|
if k == "stage":
|
|
job.stage_message = v # type: ignore[assignment]
|
|
else:
|
|
setattr(job, k, v)
|
|
|
|
|
|
@dataclass
|
|
class Job:
|
|
id: str
|
|
status: JobStatus = "queued"
|
|
progress: float = 0.0
|
|
stage_message: str = "Queued"
|
|
title: str | None = None
|
|
duration_sec: float | None = None
|
|
thumbnail: str | None = None
|
|
bpm: int | None = None
|
|
key: str | None = None
|
|
scale: str | None = None # "Major" / "Natural Minor"
|
|
key_confidence: int | None = None # 0-100 percent
|
|
lufs: float | None = None # ITU-R BS.1770 integrated loudness (dB)
|
|
peak_db: float | None = None # sample peak in dBFS (close to true peak)
|
|
dynamic_range: float | None = None # peak_db - integrated LUFS (dB)
|
|
tempo_stability: int | None = None # 0-100, beat interval consistency
|
|
stem_presence: dict[str, int] | None = None # per-stem RMS 0-100
|
|
sections: list[dict] | None = None # [{id, name, start, end, color}]
|
|
tags: list[str] | None = None # YouTube tags + categories, lowercased, max 8
|
|
stems: list[dict[str, str]] = field(default_factory=list)
|
|
# Subset of stems the user chose at submit. The pipeline produces all
|
|
# 6 regardless (Demucs htdemucs_6s is fixed), but after collect we
|
|
# mix down only the selected ones into mix.wav so the user can
|
|
# download a single track containing just their chosen stems.
|
|
selected_stems: list[str] = field(default_factory=list)
|
|
mix_url: str | None = None # populated when a strict subset was selected
|
|
source_url: str | None = None # original URL or "local:<filename>" for file uploads
|
|
# True when a silent video track (video.mp4) was preserved from an .mp4
|
|
# upload, enabling the "Export Mix (with video)" MP4 export.
|
|
has_video: bool = False
|
|
error: str | None = None
|
|
# Set by POST /api/jobs/{id}/cancel; consumed by pipeline stages.
|
|
# Not surfaced via to_state() -- it's internal control state.
|
|
cancel_requested: bool = False
|
|
# Wall-clock timestamps for metadata-based sweep -- more predictable
|
|
# than directory mtime, which can be touched by unrelated FS events.
|
|
created_at: float = field(default_factory=time.time)
|
|
|
|
def to_state(self) -> dict[str, Any]:
|
|
return {
|
|
"job_id": self.id,
|
|
"status": self.status,
|
|
"progress": self.progress,
|
|
"stage": self.stage_message,
|
|
"title": self.title,
|
|
"duration": self.duration_sec,
|
|
"thumbnail": self.thumbnail,
|
|
"bpm": self.bpm,
|
|
"key": self.key,
|
|
"scale": self.scale,
|
|
"key_confidence": self.key_confidence,
|
|
"lufs": self.lufs,
|
|
"peak_db": self.peak_db,
|
|
"dynamic_range": self.dynamic_range,
|
|
"tempo_stability": self.tempo_stability,
|
|
"stem_presence": self.stem_presence,
|
|
"sections": self.sections,
|
|
"tags": self.tags,
|
|
"stems": self.stems,
|
|
"selected_stems": self.selected_stems,
|
|
"mix_url": self.mix_url,
|
|
"source_url": self.source_url,
|
|
"has_video": self.has_video,
|
|
"error": self.error,
|
|
"created_at": self.created_at,
|
|
}
|
|
|
|
def to_record(self) -> dict[str, Any]:
|
|
return {field: getattr(self, field) for field in _JOB_FIELDS}
|
|
|
|
@classmethod
|
|
def from_record(cls, data: dict[str, Any]) -> Job:
|
|
fields = {key: value for key, value in data.items() if key in _JOB_FIELDS}
|
|
job_id = str(fields.pop("id", "")).strip()
|
|
if not job_id:
|
|
raise ValueError("job record missing id")
|
|
job = cls(id=job_id)
|
|
for key, value in fields.items():
|
|
setattr(job, key, value)
|
|
job.cancel_requested = False
|
|
return job
|
|
|
|
|
|
_JOB_FIELDS = frozenset(f.name for f in dataclasses.fields(Job) if f.name != "cancel_requested")
|