2a19f84575
genre_review_service.run() and dedup_review_service._run_scan() both unconditionally created a new run row even when the underlying job never actually executed (skipped_lock, or any other non-success status). For genres this was directly user-visible: the review page always shows the most recent run, so a skipped click created an empty run that displaced the real previous preview, making it look like every pending change had vanished. Both now return None (persisting nothing) when the job didn't succeed, and both routers surface a "didn't run, something else was using the pipeline" notice instead of silently redirecting. Cleaned up the one phantom empty genre_runs row already sitting in production, restoring the real 20-change preview. Also added a "Run now" button to each row on the Playlists list page (previously only on a playlist's own detail page), for the common case of adding new tracks and wanting to sync immediately.
94 lines
3.2 KiB
Python
94 lines
3.2 KiB
Python
import json
|
|
|
|
from app.db import SessionLocal
|
|
from app.models import GenreCandidate, GenreRun
|
|
from app.services import genre_fix, pipeline_runner
|
|
from app.settings import settings
|
|
|
|
_SCRIPT = "spotify-genre.py"
|
|
|
|
|
|
def _parse_json_lines(output: str) -> list[dict]:
|
|
candidates = []
|
|
for line in output.splitlines():
|
|
line = line.strip()
|
|
if not line.startswith("{"):
|
|
continue
|
|
try:
|
|
candidates.append(json.loads(line))
|
|
except json.JSONDecodeError:
|
|
continue
|
|
return candidates
|
|
|
|
|
|
async def run(apply: bool, force: bool = False, triggered_by: str = "schedule") -> GenreRun | None:
|
|
"""Run spotify-genre.py --json (dry-run or --apply), persisting the
|
|
diff into a fresh genre_runs/genre_candidates pair either way.
|
|
|
|
Unlike dedup, genre writes stay auto-apply on the weekly schedule
|
|
(low-risk/reversible, GENRE_LOCK-protected) -- this wraps that same
|
|
scheduled run so its diff becomes reviewable after the fact, rather
|
|
than being a separate preview-only mode. scheduler_service's
|
|
maintenance:spotify_genre job calls this with apply=True.
|
|
|
|
Returns None (and persists nothing) if the job never actually ran --
|
|
e.g. skipped_lock because something else was using the pipeline at
|
|
that moment. Without this check, a skipped/failed attempt still
|
|
created an empty genre_runs row, which then became "latest run" on
|
|
the review page (it's always the most recent by id) and hid the real
|
|
previous run's candidates behind a phantom empty one."""
|
|
args = ["--json"]
|
|
if apply:
|
|
args.append("--apply")
|
|
if force:
|
|
args.append("--force")
|
|
|
|
script = str(settings.pipeline_dir / "lib" / _SCRIPT)
|
|
job_run, output = await pipeline_runner.run_job_capture(
|
|
"genre:run", [script] + args, triggered_by=triggered_by
|
|
)
|
|
if job_run.status != "success":
|
|
return None
|
|
|
|
candidates = _parse_json_lines(output)
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
genre_run = GenreRun(
|
|
started_at=job_run.started_at,
|
|
finished_at=job_run.finished_at,
|
|
mode="apply" if apply else "dry_run",
|
|
written=len(candidates) if apply else 0,
|
|
unchanged=None,
|
|
no_match=None,
|
|
no_meta=None,
|
|
log_path=job_run.log_path,
|
|
)
|
|
db.add(genre_run)
|
|
db.commit()
|
|
db.refresh(genre_run)
|
|
|
|
for c in candidates:
|
|
db.add(
|
|
GenreCandidate(
|
|
genre_run_id=genre_run.id,
|
|
artist=c.get("artist"),
|
|
title=c.get("title"),
|
|
old_genre=c.get("old_genre"),
|
|
new_genre=c.get("new_genre"),
|
|
)
|
|
)
|
|
db.commit()
|
|
db.refresh(genre_run)
|
|
return genre_run
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
async def lock_artist_genre(artist: str, genre: str, changed_by: str):
|
|
"""One-click 'lock this artist's genre' action from the review UI, for
|
|
when a scheduled run got something wrong -- wraps
|
|
genre_fix.set_artist_genre (fix-genre.sh sets GENRE_LOCK=1 as part of
|
|
applying, which is what makes future spotify-genre runs leave it alone)."""
|
|
return await genre_fix.set_artist_genre(artist, genre, changed_by)
|