Files
alembic/app/services/playlist_service.py
T
andrew c87ddc2649 Dashboard detail, library filtering/pagination, simplified playlist times,
job descriptions

Dashboard: format breakdown (FLAC/MP3/etc. counts), approximate library
size (bitrate*length/8, same approximation `beet stats` itself uses --
verified to match its "100.5 GiB" output exactly against the real
library), and a credential auth-state summary per scope (configured y/n
plus, for Bandcamp, cookie expiry parsed locally from the stored cookie
jar -- deliberately not a live network probe like pipeline-status.sh's
Qobuz check, since this renders on every dashboard load).

Library: was one unfiltered page dumping all 4072 tracks. Added search
(artist/title/album), playlist and format filter dropdowns, and real
SQL-level pagination (LIMIT/OFFSET, not fetch-everything-then-slice).
beets_service gained count_items()/distinct_formats() to support this.

Playlists: cron_expr is still the stored/scheduled representation, but the
UI now shows and edits a plain daily time picker instead of raw cron
syntax -- every playlist schedule today is a simple daily HH:MM anyway.
playlist_service.cron_to_time()/time_to_cron() convert at the router
boundary; verified round-trip against all real playlist cron values.

Jobs: each maintenance job now shows a short one-line description of what
it actually does (MAINTENANCE_JOB_DESCRIPTIONS), and "next run" is
formatted consistently with playlists' time style (HH:MM, with a day
qualifier for non-today runs -- maintenance jobs can be weekly/monthly,
unlike playlists' plain daily schedule).

Verified end-to-end against the real production data (4072 tracks, 100.5
GiB, real credentials): stats/format-breakdown/search/pagination all
correct, all 7 credential scopes report configured with Bandcamp's real
cookie expiry (325 days), and a full authenticated page-render sweep
(dashboard, library with every filter combination, playlist detail,
jobs) all returned 200 with no template errors.
2026-07-08 15:39:22 -06:00

218 lines
8.7 KiB
Python

import json
import subprocess
import time
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Playlist
from app.settings import settings
def cron_to_time(cron_expr: str | None) -> str:
"""'30 1 * * *' -> '01:30'. Every playlist schedule today is a simple
daily HH:MM (no day-of-week/day-of-month complexity -- that's only used
by a couple of maintenance jobs), so the UI can offer a plain time
picker instead of raw cron syntax. Returns '' for anything else
(unscheduled, or a cron expression more complex than daily-at-HH:MM)."""
if not cron_expr:
return ""
parts = cron_expr.split()
if len(parts) == 5 and parts[2] == "*" and parts[3] == "*" and parts[4] == "*":
try:
hour, minute = int(parts[1]), int(parts[0])
if 0 <= hour < 24 and 0 <= minute < 60:
return f"{hour:02d}:{minute:02d}"
except ValueError:
pass
return ""
def time_to_cron(time_str: str | None) -> str | None:
"""'01:30' -> '30 1 * * *'. Empty/invalid input means unscheduled."""
if not time_str:
return None
try:
hour_str, minute_str = time_str.split(":")
hour, minute = int(hour_str), int(minute_str)
except ValueError:
return None
if not (0 <= hour < 24 and 0 <= minute < 60):
return None
return f"{minute} {hour} * * *"
# The 17-entry array this project is migrating off of (was hardcoded in
# /opt/sldl/configs/regen.sh). Used once by seed_legacy() during Stage 1/2
# of the migration; the DB is the source of truth from then on. cron_expr
# values are the exact staggered slots from /etc/cron.d/sldl-maintenance,
# preserved so cutover doesn't change anyone's download schedule.
LEGACY_PLAYLISTS = {
"digicore": ("https://open.spotify.com/playlist/6tWHtPBECZTkZHPKFA3Fq4", "0 0 * * *", False),
"techno": ("https://open.spotify.com/playlist/5GlAkczmLWJFIJO4FvkLEZ", "0 1 * * *", False),
"house": ("https://open.spotify.com/playlist/2XF8ye6O5xqzT4NSfIXdfJ", "30 1 * * *", False),
"lofi": ("https://open.spotify.com/playlist/0qnyF6TAmuT3U6hFH2fiF1", "0 2 * * *", False),
"y2k": ("https://open.spotify.com/playlist/0iaA7ZJS00aRxhMwZn2GCU", "30 2 * * *", False),
"weeb": ("https://open.spotify.com/playlist/0599AbpsKRjsp2rlJI8jhf", "0 3 * * *", False),
"shoegaze": ("https://open.spotify.com/playlist/3R4S3tmbVKjXQ1RPAmryC5", "30 3 * * *", False),
"bass": ("https://open.spotify.com/playlist/2zkF4S16efaMmmzdIOe5w2", "0 4 * * *", False),
"hiphop": ("https://open.spotify.com/playlist/42JxTtOLJ7gG1ybfyy7Vmi", "30 4 * * *", False),
"modular": ("https://open.spotify.com/playlist/2kOfP2EM8dzdMYC6fXvlLb", "0 5 * * *", False),
"hotdog": ("https://open.spotify.com/playlist/53HrOL0qw47G9ywYZz3kgX", "30 5 * * *", False),
"kpop": ("https://open.spotify.com/playlist/31e2V512TIqr5JIfgkyseo", "0 6 * * *", False),
"jungle": ("https://open.spotify.com/playlist/4LjLeXg6ElneQiTaIcAHhE", "30 6 * * *", False),
"goldenera": ("https://open.spotify.com/playlist/57vArhigysJfgwB6CY4VXR", "0 7 * * *", False),
"botanica": ("https://open.spotify.com/playlist/5KgQT9YWz3EqhIN4Jh69IU", "30 7 * * *", False),
"hardcore": ("https://open.spotify.com/playlist/1J1lbzlQKligzr2LWaq9Ex", "0 23 * * *", False),
"liked": ("https://open.spotify.com/playlist/4Z3qCYuU1sjNeNYO3Amzeo", "30 23 * * *", True),
}
def list_all(db: Session) -> list[Playlist]:
return list(db.execute(select(Playlist).order_by(Playlist.name)).scalars())
def get(db: Session, playlist_id: int) -> Playlist | None:
return db.get(Playlist, playlist_id)
def get_by_name(db: Session, name: str) -> Playlist | None:
return db.execute(select(Playlist).where(Playlist.name == name)).scalar_one_or_none()
def create(
db: Session,
name: str,
spotify_url: str,
active: bool = True,
no_m3u: bool = False,
notes: str | None = None,
cron_expr: str | None = None,
) -> Playlist:
now = time.time()
playlist = Playlist(
name=name,
spotify_url=spotify_url,
active=active,
no_m3u=no_m3u,
notes=notes,
cron_expr=cron_expr,
created_at=now,
updated_at=now,
)
db.add(playlist)
db.commit()
db.refresh(playlist)
_sync_to_disk(db)
return playlist
def update(db: Session, playlist_id: int, **fields) -> Playlist:
playlist = db.get(Playlist, playlist_id)
if playlist is None:
raise ValueError(f"no playlist with id={playlist_id}")
for key, value in fields.items():
setattr(playlist, key, value)
playlist.updated_at = time.time()
db.commit()
db.refresh(playlist)
_sync_to_disk(db)
return playlist
def delete(db: Session, playlist_id: int) -> None:
"""Remove the playlist from the DB and delete its rendered .conf file.
Does NOT touch anything already downloaded/imported for it — that's a
library decision, not a playlist-definition one."""
playlist = db.get(Playlist, playlist_id)
if playlist is None:
return
name = playlist.name
db.delete(playlist)
db.commit()
conf_path = settings.pipeline_config_dir / f"{name}.conf"
conf_path.unlink(missing_ok=True)
_write_playlists_json(db)
from app.services import scheduler_service
scheduler = scheduler_service.get_scheduler()
if scheduler is not None:
scheduler_service.sync_playlist_jobs(scheduler)
def seed_legacy(db: Session) -> int:
"""One-time import of the legacy hardcoded array (migration Stage 1/2).
Skips any name that already exists. Returns the number of rows created."""
created = 0
for name, (url, cron_expr, no_m3u) in LEGACY_PLAYLISTS.items():
if get_by_name(db, name) is not None:
continue
create(db, name=name, spotify_url=url, no_m3u=no_m3u, cron_expr=cron_expr)
created += 1
return created
def _write_playlists_json(db: Session) -> None:
"""The file regen.sh reads. Every active-or-not playlist is included
(see regen.sh's header comment) so a manual run is always possible;
the scheduler is what actually skips inactive ones."""
playlists = list_all(db)
payload = [
{
"name": p.name,
"spotify_url": p.spotify_url,
"active": p.active,
"no_m3u": p.no_m3u,
}
for p in playlists
]
settings.pipeline_config_dir.mkdir(parents=True, exist_ok=True)
out_path = settings.pipeline_config_dir / "playlists.json"
out_path.write_text(json.dumps(payload, indent=2))
def regenerate_confs(db: Session) -> subprocess.CompletedProcess:
"""Write playlists.json, then run regen.sh to render/refresh every
playlist's .conf file. Newly-created .conf files still have unpatched
SOULSEEK_USER/SOULSEEK_PASS/SPOTIFY_CLIENT_ID/SPOTIFY_CLIENT_SECRET
placeholders at this point -- _sync_to_disk() (this function's caller)
re-renders credentials into them immediately afterward."""
_write_playlists_json(db)
regen_script = settings.pipeline_dir / "configs" / "regen.sh"
return subprocess.run(
[str(regen_script)],
capture_output=True,
text=True,
env={
"ALEMBIC_CONFIG_DIR": str(settings.alembic_config_dir),
"MUSIC_DATA_DIR": str(settings.music_data_dir),
"PIPELINE_DIR": str(settings.pipeline_dir),
# Must include /opt/venv/bin: regen.sh shells out to `python3`
# to parse playlists.json, and that's where this image's Python
# actually lives (confirmed against the real container -- a
# bare /usr/bin:/bin PATH made every regen.sh call silently
# generate zero .conf files, no error surfaced to the caller).
"PATH": "/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
},
)
def _sync_to_disk(db: Session) -> None:
"""Convenience wrapper used by create()/update(): regenerate confs,
immediately re-patch credentials into any newly-rendered file, and push
the change to the live scheduler if one is running -- this is what
makes add/remove-in-the-UI take effect without a redeploy."""
regenerate_confs(db)
# Imported lazily to avoid a circular import (credential_service and
# scheduler_service don't depend on playlist_service, but importing at
# module load time would still work here — done lazily anyway to keep
# the dependency direction obvious as all three services grow).
from app.services import credential_service, scheduler_service
credential_service.render_scope(db, "soulseek")
credential_service.render_scope(db, "spotify")
scheduler = scheduler_service.get_scheduler()
if scheduler is not None:
scheduler_service.sync_playlist_jobs(scheduler)