c87ddc2649
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.
98 lines
3.9 KiB
Python
98 lines
3.9 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import PlainTextResponse, RedirectResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
from sqlalchemy import select
|
|
|
|
from app.db import get_db
|
|
from app.models import JobRun, ScheduledJob
|
|
from app.security.deps import require_auth
|
|
from app.services import scheduler_service
|
|
|
|
router = APIRouter(prefix="/jobs", tags=["jobs"])
|
|
templates = Jinja2Templates(directory="app/templates")
|
|
|
|
|
|
def _format_next_run(dt) -> str:
|
|
"""Same HH:MM (24h, zero-padded) convention the playlists' simplified
|
|
time field uses, with a short day qualifier when it's not today --
|
|
maintenance jobs can be weekly/monthly, so the date can't just be
|
|
dropped the way it is for playlists' plain daily time."""
|
|
if dt is None:
|
|
return "-"
|
|
now = dt.__class__.now(dt.tzinfo)
|
|
if dt.date() == now.date():
|
|
return dt.strftime("%H:%M")
|
|
return dt.strftime("%a %H:%M")
|
|
|
|
|
|
def _job_list_context(db):
|
|
scheduler = scheduler_service.get_scheduler()
|
|
registered = {j.id: j for j in (scheduler.get_jobs() if scheduler else [])}
|
|
enabled_by_key = {
|
|
row.job_key: row.enabled for row in db.execute(select(ScheduledJob)).scalars()
|
|
}
|
|
maintenance_jobs = []
|
|
for job_key in scheduler_service.MAINTENANCE_JOBS:
|
|
job = registered.get(job_key)
|
|
next_run = getattr(job, "next_run_time", None) if job else None
|
|
maintenance_jobs.append(
|
|
{
|
|
"job_key": job_key,
|
|
"description": scheduler_service.MAINTENANCE_JOB_DESCRIPTIONS.get(job_key, ""),
|
|
"next_run": _format_next_run(next_run),
|
|
"enabled": enabled_by_key.get(job_key, True),
|
|
}
|
|
)
|
|
recent_runs = list(
|
|
db.execute(select(JobRun).order_by(JobRun.started_at.desc()).limit(50)).scalars()
|
|
)
|
|
return {"maintenance_jobs": maintenance_jobs, "recent_runs": recent_runs}
|
|
|
|
|
|
@router.get("")
|
|
async def jobs_index(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)):
|
|
return templates.TemplateResponse(request, "jobs/index.html", _job_list_context(db))
|
|
|
|
|
|
@router.get("/_runs_table")
|
|
async def runs_table_partial(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)):
|
|
"""HTMX polling target: just the recent-runs table body, so the jobs
|
|
page can auto-refresh run status without a full page reload."""
|
|
return templates.TemplateResponse(request, "jobs/_runs_table.html", _job_list_context(db))
|
|
|
|
|
|
@router.post("/{job_key:path}/run")
|
|
async def run_now(job_key: str, user: dict = Depends(require_auth)):
|
|
scheduler = scheduler_service.get_scheduler()
|
|
if scheduler is None:
|
|
raise HTTPException(503, "scheduler not running")
|
|
try:
|
|
await scheduler_service.trigger_now(scheduler, job_key)
|
|
except ValueError as exc:
|
|
raise HTTPException(404, str(exc))
|
|
return RedirectResponse(url="/jobs", status_code=303)
|
|
|
|
|
|
@router.post("/{job_key:path}/toggle")
|
|
async def toggle_enabled(job_key: str, user: dict = Depends(require_auth), db=Depends(get_db)):
|
|
scheduler = scheduler_service.get_scheduler()
|
|
if scheduler is None:
|
|
raise HTTPException(503, "scheduler not running")
|
|
row = db.execute(select(ScheduledJob).where(ScheduledJob.job_key == job_key)).scalar_one_or_none()
|
|
currently_enabled = row.enabled if row else True
|
|
scheduler_service.set_maintenance_enabled(scheduler, job_key, not currently_enabled)
|
|
return RedirectResponse(url="/jobs", status_code=303)
|
|
|
|
|
|
@router.get("/runs/{run_id}/log", response_class=PlainTextResponse)
|
|
async def view_log(run_id: int, user: dict = Depends(require_auth), db=Depends(get_db)):
|
|
run = db.get(JobRun, run_id)
|
|
if run is None or not run.log_path:
|
|
raise HTTPException(404, "no such run/log")
|
|
from pathlib import Path
|
|
|
|
path = Path(run.log_path)
|
|
if not path.exists():
|
|
raise HTTPException(404, "log file no longer exists")
|
|
return path.read_text(errors="replace")
|