bcd07750bf
- The client-credentials token fetch was copy-pasted across spotify-retag.py, spotify-genre.py, and fix-track-metadata.py (and the app's spotify_client). Add pipeline/lib/_spotify_auth.get_token (cached per id/secret); the three scripts now source their own credentials but delegate the request to it. The scripts run with pipeline/lib on sys.path, so the plain `from _spotify_auth import get_token` resolves. - The identical _parse_json_lines helper in dedup_review_service and genre_review_service is now a single app/services/_ndjson.parse_json_lines. Verified: unit test of the token helper (cache + request), the NDJSON parser (tests/test_ndjson.py), full suite green (40), and a live spotify-genre dry-run that fetched a token and queried Spotify through the shared helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
80 lines
2.9 KiB
Python
80 lines
2.9 KiB
Python
from app.db import SessionLocal
|
|
from app.models import GenreCandidate, GenreRun
|
|
from app.services import genre_fix, pipeline_runner
|
|
from app.services._ndjson import parse_json_lines
|
|
from app.settings import settings
|
|
|
|
_SCRIPT = "spotify-genre.py"
|
|
|
|
|
|
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)
|