Commit Graph

4 Commits

Author SHA1 Message Date
andrew 2075d6cf66 Add dedup_review_service and genre_review_service
dedup-library.sh: additive --json (one NDJSON line per candidate deletion
to stdout, alongside the unchanged human log) and --only-paths FILE (in
--apply mode, only actually delete entries whose path is in FILE; without
it, --apply deletes everything as before -- existing direct callers are
unaffected). Without --only-paths the safety re-verification is free:
--apply --only-paths re-runs all 4 passes from scratch on every invocation,
so if a group's ranking changed since a scan (e.g. the old keep_path is
gone), the fresh pass assigns the previously-"delete" path the KEEP role
instead and the only-paths allowlist naming it is simply never consulted --
no duplicate ranking logic needed in the review service.

spotify-genre.py: additive --json emitting one JSON line per genre change
(dry-run or --apply) for genre_review_service to persist.

pipeline_runner.run_job_capture(): like run_job() but captures stdout as
text (still under the same shared lock, still writes a job_runs row) for
callers that need to parse structured output rather than just log it.

dedup_review_service.scan() persists dry-run candidates into
dedup_runs/dedup_candidates. confirm_and_apply() re-checks confirmed
candidates still exist before invoking --apply --only-paths, so nothing is
ever deleted without an explicit confirm -- matches the false-negative-
biased dedup preference. scheduler_service's maintenance:dedup job now
goes through this (still dry-run only, every day).

genre_review_service.run() wraps spotify-genre.py for both dry-run preview
and the real scheduled --apply run, persisting every run's diff into
genre_runs/genre_candidates either way -- genre writes keep their current
auto-apply behavior (low-risk, reversible, GENRE_LOCK-protected) but are
now reviewable after the fact. lock_artist_genre() gives a one-click revert
path when a run gets something wrong.

Added minimal routers+templates for /dedup (scan, review, confirm-and-
delete) and /genres (preview, review, lock-old-genre).

Verified end-to-end against REAL duplicate files (not mocked): built an
actual FLAC+MP3 duplicate pair in a real beets library, ran dedup-library.sh
--json and confirmed correct JSON output, verified --apply --only-paths
with an empty confirm list deletes nothing and with the real confirmed path
deletes exactly that file (DB + disk) while preserving the FLAC, and ran
the full dedup_review_service scan->confirm->apply flow through the same
fixture. genre_review_service and spotify-genre.py --json verified against
mocked/direct output (spotify-genre.py's own artist-genre lookup needs a
live Spotify API call, out of reach in this sandbox). Confirmed the full
app boots with all five routers registered.
2026-07-08 14:17:20 -06:00
andrew 270c9c0ed1 Add manual fix / manual import services and routers
library_edit.update_track_fields: single-track tag edits via `beet modify
-y -m` (subprocess with an argument list, not beets.library.Library
in-process -- an arg-list subprocess has no shell-injection surface to
avoid in the first place, and correctly reuses beets' own configured
path-format/move logic rather than reimplementing it against a hand-built
Library object with guessed config). Every changed field gets a
manual_fix_audit row with old/new values; triggers a Navidrome rescan
afterward if anything changed. Uses "genres" (plural) as the field name,
matching this deployment's actual beets column and every existing script's
convention, not the generic beets docs' singular "genre".

genre_fix.set_artist_genre and retag.retag_from_url wrap the existing
fix-genre.sh and fix-track-metadata.py as callable services rather than
reimplementing their per-format mutagen tag-writing logic.

manual_import: ties pipeline_runner.run_import_track to the manual_imports
table, mirrors import-track.sh's own argument-count-based disambiguation
(0/1/2 args), plus import-me/ listing and upload-with-path-traversal-guard.

routers/library.py + routers/import_.py: browsable library list, a track
detail/edit page (tag edit, artist-wide genre override, retag-from-URL
forms), and the import page (upload + trigger). Minimal templates for now,
Task 9 covers full UI polish.

Verified update_track_fields end-to-end against a REAL beets library (not
mocked): generated a tagged FLAC with ffmpeg/metaflac, imported it via a
real `beet import`, edited artist+title through the service, and confirmed
the file was physically moved to the new path-template location, tags were
rewritten on disk, the DB updated, and both changes landed correctly in
manual_fix_audit. Also verified the full app boots with the new routers
registered and all four new routes correctly redirect to login when
unauthenticated.
2026-07-08 14:01:35 -06:00
andrew 4e0cfb8463 Add scheduler_service and pipeline_runner
pipeline_runner: subprocess execution wrapper replacing flock -n with a
single asyncio.Lock (deliberately one global lock, not per-resource --
matches the old flock's all-jobs-share-one-lock behavior rather than
over-engineering it). Records one job_runs row per invocation
(started/finished/status/exit_code/summary/log_path/triggered_by).
If the lock is already held, records status='skipped_lock' immediately
instead of silently dropping the run.

Found and fixed a real concurrency bug via testing: the obvious
asyncio.wait_for(lock.acquire(), timeout=0) idiom for a non-blocking
try-acquire is broken in asyncio -- the wrapping Task's first iteration and
the timeout-0 callback race with no guaranteed ordering, so it timed out on
literally every call, including the very first uncontended one. Fixed using
lock.locked() + acquire(), relying on acquire()'s fast path never
suspending when uncontended.

scheduler_service: AsyncIOScheduler with the default in-memory job store
(NOT SQLAlchemyJobStore -- it pickles job functions to persist them, and
the _lib()/_beet() factory closures here aren't picklable; MemoryJobStore
avoids this since register_all_jobs() rebuilds the whole schedule from code
+ DB on every startup anyway). All 19 maintenance jobs ported 1:1 from
/etc/cron.d/sldl-maintenance with their exact cron expressions; dedup
deliberately has no --apply (dry-run only on schedule, per the
false-negative-biased dedup preference). Playlist jobs are synced live from
the playlists table (sync_playlist_jobs), so add/remove-in-the-UI takes
effect with no redeploy -- wired into playlist_service's create/update/
delete. Maintenance job enable/disable persists to the scheduled_jobs table
so a paused job stays paused across a restart despite the in-memory job
store. trigger_now() supports "Run now" by invoking a job's function
immediately with triggered_by='manual', bypassing its schedule.

Wired into main.py's lifespan (start on boot, shutdown on exit).

Verified via integration tests: 36 jobs register correctly (17 seeded
playlists + 19 maintenance) with correct cron triggers; live playlist
create/delete correctly adds/removes its scheduler job; maintenance
enable/disable persists to the DB and takes effect live; trigger_now
correctly bypasses the schedule; pipeline_runner correctly handles
success/failure/timeout/concurrent-lock-contention with real subprocesses;
and the full app boots with the scheduler running and shuts down cleanly.
2026-07-08 13:52:25 -06:00
andrew bd56c8153f Add FastAPI app skeleton: settings, db, models, security, auth
- settings.py: pydantic-settings config for the two mount points
  (MUSIC_DATA_DIR, ALEMBIC_CONFIG_DIR), OIDC client config, encryption key path
- db.py: SQLite engine with WAL mode, plain-SQL schema migration runner
  (schema/*.sql, tracked by schema_version -- not the alembic migration tool)
- models.py: SQLAlchemy ORM models matching schema/0001_init.sql
- security/crypto.py: Fernet encrypt/decrypt for the secrets table
- security/oidc.py + routers/auth.py: Pocket ID OIDC login/callback/logout
- security/deps.py: require_auth dependency (redirect-to-login on no session)
- main.py: app factory, lifespan (init_db + beets WAL enable), session
  middleware, minimal dashboard route

Verified boots end-to-end via TestClient: unauthenticated GET / redirects to
/auth/login, static files serve, and all 13 schema tables get created on
first run.
2026-07-08 13:24:18 -06:00