chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import JOB_ID_RE, STEM_NAMES
|
||||
from app.core.models import Job
|
||||
|
||||
logger = logging.getLogger("stemdeck.registry")
|
||||
|
||||
REGISTRY_VERSION = 1
|
||||
|
||||
_jobs: dict[str, Job] = {}
|
||||
# Active subprocesses keyed by job_id (currently only Demucs). Lets
|
||||
# POST /cancel terminate the running process from the API thread instead
|
||||
# of waiting for the pipeline thread to notice the cancel flag.
|
||||
_procs: dict[str, subprocess.Popen] = {}
|
||||
_lock = threading.Lock()
|
||||
_REGISTRY_FILE = "registry.json"
|
||||
_TERMINAL = {"done"}
|
||||
|
||||
|
||||
def register(job: Job) -> Job:
|
||||
with _lock:
|
||||
_jobs[job.id] = job
|
||||
return job
|
||||
|
||||
|
||||
def register_if_capacity(job: Job, max_pending: int) -> bool:
|
||||
"""Atomically check pending count and register if under capacity.
|
||||
Returns True if registered, False if the queue is full."""
|
||||
with _lock:
|
||||
pending = sum(1 for j in _jobs.values() if j.status == "queued")
|
||||
if pending >= max_pending:
|
||||
return False
|
||||
_jobs[job.id] = job
|
||||
return True
|
||||
|
||||
|
||||
def get(job_id: str) -> Job | None:
|
||||
with _lock:
|
||||
return _jobs.get(job_id)
|
||||
|
||||
|
||||
def remove(job_id: str) -> None:
|
||||
with _lock:
|
||||
_jobs.pop(job_id, None)
|
||||
_procs.pop(job_id, None)
|
||||
|
||||
|
||||
def all_jobs() -> dict[str, Job]:
|
||||
"""Return a snapshot of the registry for sweep / cleanup."""
|
||||
with _lock:
|
||||
return dict(_jobs)
|
||||
|
||||
|
||||
def _migrate(data: dict) -> dict:
|
||||
"""Upgrade registry JSON to REGISTRY_VERSION incrementally.
|
||||
Each block transforms v(n) → v(n+1) so older snapshots always catch up."""
|
||||
version = data.get("version", 0)
|
||||
if version < 1:
|
||||
# v0 → v1: version field was absent; no structural change needed.
|
||||
data["version"] = 1
|
||||
version = 1
|
||||
# Future migrations go here as `if version < N:` blocks.
|
||||
return data
|
||||
|
||||
|
||||
def persist(jobs_dir: Path) -> None:
|
||||
"""Persist terminal jobs so completed library entries survive restarts."""
|
||||
try:
|
||||
jobs_dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
logger.warning("cannot create jobs dir %s; skipping persist", jobs_dir, exc_info=True)
|
||||
return
|
||||
with _lock:
|
||||
records = [
|
||||
job.to_record()
|
||||
for job in sorted(_jobs.values(), key=lambda item: item.created_at)
|
||||
if job.status in _TERMINAL
|
||||
]
|
||||
payload = json.dumps({"version": REGISTRY_VERSION, "jobs": records}, indent=2) + "\n"
|
||||
path = jobs_dir / _REGISTRY_FILE
|
||||
tmp = path.with_suffix(".json.tmp")
|
||||
tmp.write_text(payload, encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def restore(jobs_dir: Path) -> None:
|
||||
"""Load persisted jobs and recover completed orphan jobs from disk."""
|
||||
jobs_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = jobs_dir / _REGISTRY_FILE
|
||||
if path.is_file():
|
||||
try:
|
||||
data = _migrate(json.loads(path.read_text(encoding="utf-8")))
|
||||
to_add = {}
|
||||
for record in data.get("jobs", []):
|
||||
job = Job.from_record(record)
|
||||
if JOB_ID_RE.match(job.id) and job.status in _TERMINAL and job.title:
|
||||
to_add[job.id] = job
|
||||
with _lock:
|
||||
_jobs.update(to_add)
|
||||
except (OSError, json.JSONDecodeError, TypeError, ValueError):
|
||||
logger.warning("failed to load registry from %s", path, exc_info=True)
|
||||
|
||||
with _lock:
|
||||
known = set(_jobs)
|
||||
changed = False
|
||||
for job_dir in jobs_dir.iterdir():
|
||||
if not job_dir.is_dir() or not JOB_ID_RE.match(job_dir.name) or job_dir.name in known:
|
||||
continue
|
||||
recovered = _recover_done_job(job_dir)
|
||||
if recovered is not None:
|
||||
with _lock:
|
||||
_jobs[recovered.id] = recovered
|
||||
changed = True
|
||||
if changed:
|
||||
persist(jobs_dir)
|
||||
|
||||
|
||||
def _recover_done_job(job_dir: Path) -> Job | None:
|
||||
stems_dir = job_dir / "stems"
|
||||
if not stems_dir.is_dir():
|
||||
return None
|
||||
stems = [
|
||||
{"name": name, "url": f"/api/jobs/{job_dir.name}/stems/{name}.wav"}
|
||||
for name in ("original", *STEM_NAMES)
|
||||
if (stems_dir / f"{name}.wav").is_file()
|
||||
]
|
||||
if not stems:
|
||||
return None
|
||||
mix_url = None
|
||||
if (stems_dir / "mix.wav").is_file():
|
||||
mix_url = f"/api/jobs/{job_dir.name}/stems/mix.wav"
|
||||
selected = [stem["name"] for stem in stems if stem["name"] in STEM_NAMES] or list(STEM_NAMES)
|
||||
meta_path = job_dir / "metadata.json"
|
||||
if not meta_path.is_file():
|
||||
return None
|
||||
meta: dict = {}
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return Job(
|
||||
id=job_dir.name,
|
||||
status="done",
|
||||
progress=1.0,
|
||||
stage_message="Done",
|
||||
stems=stems,
|
||||
selected_stems=selected,
|
||||
mix_url=mix_url,
|
||||
created_at=job_dir.stat().st_mtime,
|
||||
title=meta.get("title"),
|
||||
thumbnail=meta.get("thumbnail"),
|
||||
duration_sec=meta.get("duration_sec"),
|
||||
bpm=meta.get("bpm"),
|
||||
key=meta.get("key"),
|
||||
scale=meta.get("scale"),
|
||||
key_confidence=meta.get("key_confidence"),
|
||||
lufs=meta.get("lufs"),
|
||||
peak_db=meta.get("peak_db"),
|
||||
dynamic_range=meta.get("dynamic_range"),
|
||||
tempo_stability=meta.get("tempo_stability"),
|
||||
stem_presence=meta.get("stem_presence"),
|
||||
sections=meta.get("sections"),
|
||||
tags=meta.get("tags"),
|
||||
)
|
||||
|
||||
|
||||
def set_proc(job_id: str, proc: subprocess.Popen | None) -> None:
|
||||
with _lock:
|
||||
if proc is None:
|
||||
_procs.pop(job_id, None)
|
||||
else:
|
||||
_procs[job_id] = proc
|
||||
|
||||
|
||||
def get_proc(job_id: str) -> subprocess.Popen | None:
|
||||
with _lock:
|
||||
return _procs.get(job_id)
|
||||
Reference in New Issue
Block a user