Files
andrew 59c369c9e2 Fix playlist status page, dedup delete-by-path bug, duplicate scan rows; add public IP dashboard card
Playlist detail page's status section (and the new two-column compare
view) was never rendering: the router awaited status_service.playlist_status(),
but that function is plain sync, so awaiting its dict return raised
"object dict can't be used in 'await' expression" on every load, silently
caught and shown as a generic fetch error. Removed the erroneous await.

find-fuzzy-dupes.py's --apply and --apply-pairs both deleted by a `path::`
regex query even though the beets id was already available in scope --
same mixed-path-storage issue (pre/post beets-2.11-upgrade items store
absolute vs. library-relative paths) dedup-library.sh already worked
around by deleting via id instead. This silently failed to match for most
confirmed fuzzy-audio deletions (verified: 19 of 20 in one run). Added a
delete_id column to dedup_candidates, threaded through the scan/apply
pipeline, and switched both delete call sites to `id:`.

Dedup scans also never checked whether a pair was already sitting in the
pending list, so every re-scan (including the daily schedule) added a new
row for the same unreviewed duplicate -- cleaned up 38 redundant rows
already in production and added a check so future scans skip a pair
that's already pending.

Dashboard gets a Public IP stat card (cached 10 min, fetched via ipify)
as a quick confidence check that outbound traffic is actually routed
through gluetun's VPN and not the home connection.
2026-07-09 11:13:58 -06:00

164 lines
5.3 KiB
Python

from sqlalchemy import (
Boolean,
Column,
Float,
ForeignKey,
Integer,
LargeBinary,
String,
UniqueConstraint,
)
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class Playlist(Base):
__tablename__ = "playlists"
id = Column(Integer, primary_key=True)
name = Column(String, unique=True, nullable=False)
spotify_url = Column(String, nullable=False)
active = Column(Boolean, nullable=False, default=True)
no_m3u = Column(Boolean, nullable=False, default=False)
cron_expr = Column(String, nullable=True)
notes = Column(String, nullable=True)
created_at = Column(Float, nullable=False)
updated_at = Column(Float, nullable=False)
class Secret(Base):
__tablename__ = "secrets"
__table_args__ = (UniqueConstraint("scope", "key"),)
id = Column(Integer, primary_key=True)
scope = Column(String, nullable=False)
key = Column(String, nullable=False)
value_encrypted = Column(LargeBinary, nullable=False)
updated_at = Column(Float, nullable=False)
class ScheduledJob(Base):
__tablename__ = "scheduled_jobs"
id = Column(Integer, primary_key=True)
job_key = Column(String, unique=True, nullable=False)
job_type = Column(String, nullable=False)
playlist_id = Column(Integer, ForeignKey("playlists.id"), nullable=True)
cron_expr = Column(String, nullable=False)
enabled = Column(Boolean, nullable=False, default=True)
class JobRun(Base):
__tablename__ = "job_runs"
id = Column(Integer, primary_key=True)
job_key = Column(String, nullable=False)
started_at = Column(Float, nullable=False)
finished_at = Column(Float, nullable=True)
status = Column(String, nullable=False) # running|success|failed|skipped_lock
exit_code = Column(Integer, nullable=True)
summary = Column(String, nullable=True)
log_path = Column(String, nullable=True)
triggered_by = Column(String, nullable=False) # schedule|manual|api
class ManualFixAudit(Base):
__tablename__ = "manual_fix_audit"
id = Column(Integer, primary_key=True)
beets_item_id = Column(Integer, nullable=False)
file_path = Column(String, nullable=False)
field = Column(String, nullable=False)
old_value = Column(String, nullable=True)
new_value = Column(String, nullable=True)
changed_by = Column(String, nullable=False)
changed_at = Column(Float, nullable=False)
source = Column(String, nullable=False) # tag_edit|genre_fix|retag_from_url
class ManualImport(Base):
__tablename__ = "manual_imports"
id = Column(Integer, primary_key=True)
source_path = Column(String, nullable=False)
playlist_name = Column(String, nullable=True)
imported_by = Column(String, nullable=False)
imported_at = Column(Float, nullable=False)
status = Column(String, nullable=False)
job_run_id = Column(Integer, ForeignKey("job_runs.id"), nullable=True)
class DedupRun(Base):
__tablename__ = "dedup_runs"
id = Column(Integer, primary_key=True)
started_at = Column(Float, nullable=False)
finished_at = Column(Float, nullable=True)
mode = Column(String, nullable=False) # dry_run|apply
groups_found = Column(Integer, nullable=True)
kept = Column(Integer, nullable=True)
deleted = Column(Integer, nullable=True)
log_path = Column(String, nullable=True)
class DedupCandidate(Base):
__tablename__ = "dedup_candidates"
id = Column(Integer, primary_key=True)
dedup_run_id = Column(Integer, ForeignKey("dedup_runs.id"), nullable=False)
pass_name = Column(String, nullable=False)
keep_path = Column(String, nullable=False)
delete_path = Column(String, nullable=False)
delete_id = Column(Integer, nullable=True)
delete_size_bytes = Column(Integer, nullable=True)
confirmed = Column(Boolean, nullable=False, default=False)
confirmed_by = Column(String, nullable=True)
confirmed_at = Column(Float, nullable=True)
applied = Column(Boolean, nullable=False, default=False)
ignored = Column(Boolean, nullable=False, default=False)
ignored_by = Column(String, nullable=True)
ignored_at = Column(Float, nullable=True)
class GenreRun(Base):
__tablename__ = "genre_runs"
id = Column(Integer, primary_key=True)
started_at = Column(Float, nullable=True)
finished_at = Column(Float, nullable=True)
mode = Column(String, nullable=True)
written = Column(Integer, nullable=True)
unchanged = Column(Integer, nullable=True)
no_match = Column(Integer, nullable=True)
no_meta = Column(Integer, nullable=True)
log_path = Column(String, nullable=True)
class GenreCandidate(Base):
__tablename__ = "genre_candidates"
id = Column(Integer, primary_key=True)
genre_run_id = Column(Integer, ForeignKey("genre_runs.id"), nullable=False)
artist = Column(String, nullable=True)
title = Column(String, nullable=True)
old_genre = Column(String, nullable=True)
new_genre = Column(String, nullable=True)
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
oidc_sub = Column(String, unique=True, nullable=False)
email = Column(String, nullable=True)
name = Column(String, nullable=True)
last_login_at = Column(Float, nullable=True)
class AppSetting(Base):
__tablename__ = "app_settings"
key = Column(String, primary_key=True)
value = Column(String, nullable=True)