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)