Files
alembic/app/services/genre_review_service.py
T
andrew bcd07750bf R7: consolidate duplicated Spotify-token and NDJSON-parse logic
- 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>
2026-07-10 16:08:15 -06:00

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)