feat(jobs): surface background jobs with progress, logs and controls

Add a central job registry that library scans and search index builds
report into, so background work is visible instead of buried in the
settings page.

- backend/jobs: registry with per-job ring-buffer logs, capability-driven
  controls, and one coalesced JobsChanged snapshot at 4Hz
- pause survives restart via a job_state table; a paused scan is adopted
  back on launch and skipped by the soft scan
- top-bar indicator, popover, details drawer and a Jobs page replacing
  the config page's scan UI; per-library start/stop retained
- scan timing breakdown moves into the job log, Full rescan to the Jobs
  page; delete the orphaned library-manager component

Also add cmd/indexbuild and cmd/indexexport so the explore index can be
built once centrally rather than by every install, which today streams
~205GB from the ListenBrainz spark dump on first run. indexbuild picks
build/refresh/rebuild from index state; the Gitea workflow runs it on
push, weekly, or manually and publishes only when content changed.

fresh-install no longer defaults YJ_HOME under /tmp: it is tmpfs on most
distros, and the import needs ~6GB of real disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 14:42:22 -04:00
co-authored by Claude Opus 5
parent aead8eaef4
commit 01bc5f2094
48 changed files with 6656 additions and 2271 deletions
+131
View File
@@ -0,0 +1,131 @@
name: Search index maintenance
# indexbuild decides what to do from the index's own state, so every
# trigger below runs the same command:
#
# no completed import -> build (first run, or resume a partial one)
# import older than 3mo -> rebuild (re-import from the newest dump)
# otherwise -> refresh (fold in new incremental listens)
#
# A refresh is cheap and no-ops when nothing new has been published, so
# running it on every push to main is safe.
on:
push:
branches: [main]
schedule:
# Weekly update pass. The 3-month rebuild is triggered by the same
# command when it notices the import has aged out.
- cron: '0 4 * * 1'
workflow_dispatch:
inputs:
mode:
description: 'auto | build | refresh | rebuild'
required: false
default: 'auto'
budget:
description: 'Max build time this run'
required: false
default: '3h'
artists:
description: 'Top artists in the core artifact'
required: false
default: '50000'
# Runs share one persistent working directory, so they must not overlap.
# A push landing mid-build waits rather than corrupting the checkpoint.
concurrency:
group: search-index
cancel-in-progress: false
jobs:
maintain-index:
runs-on: ubuntu-latest
container:
# CGO is not needed: the project uses the pure-Go modernc sqlite
# driver, and neither command imports the Wails app.
image: golang:1.25
# This host path must exist on the runner and be listed verbatim in
# act_runner's container.valid_volumes. It holds explore-staging/
# (counts.bin + state.json) and yj.db — the checkpoint that makes
# resuming possible. Losing it means re-downloading ~205GB.
volumes:
- /srv/yellowjacket/index-cache:/cache
env:
YJ_HOME: /cache
CGO_ENABLED: '0'
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
SERVER_URL: ${{ github.server_url }}
OWNER: ${{ github.repository_owner }}
MODE: ${{ inputs.mode || 'auto' }}
BUDGET: ${{ inputs.budget || '3h' }}
ARTISTS: ${{ inputs.artists || '50000' }}
steps:
- name: Check out
uses: actions/checkout@v4
- name: Verify the cache volume
run: |
set -eu
mkdir -p /cache
# A RAM-backed cache would defeat the point: the checkpoint has
# to outlive the job, and the import wants real disk headroom.
fstype=$(stat -f -c %T /cache || echo unknown)
echo "cache fstype: $fstype"
case "$fstype" in
tmpfs|ramfs)
echo "::error::/cache is RAM-backed; use a disk-backed host path."
exit 1 ;;
esac
df -h /cache
- name: Build tools
run: go build -o /usr/local/bin/ ./cmd/indexbuild ./cmd/indexexport
- name: Maintain index
id: maintain
run: |
set +e
indexbuild -mode "$MODE" -budget "$BUDGET"
code=$?
set -e
case "$code" in
0) ;;
3) echo "::notice::Build checkpointed with work remaining — rerun to continue." ;;
*) exit "$code" ;;
esac
# Publishing only on `changed` keeps identical artifacts from
# accumulating when a refresh finds nothing new.
- name: Export core artifact
if: steps.maintain.outputs.complete == 'true' && steps.maintain.outputs.changed == 'true'
run: |
set -eu
command -v zstd >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq zstd; }
indexexport -o /tmp/core-index.db -artists "$ARTISTS"
zstd -19 -T0 -q -f /tmp/core-index.db -o /tmp/core-index.db.zst
sha256sum /tmp/core-index.db.zst | tee /tmp/core-index.db.zst.sha256
ls -lh /tmp/core-index.db.zst
- name: Publish to the Gitea package registry
if: steps.maintain.outputs.complete == 'true' && steps.maintain.outputs.changed == 'true'
run: |
set -eu
version="$(date -u +%Y%m%d)"
base="${SERVER_URL}/api/packages/${OWNER}/generic/yellowjacket-core-index/${version}"
for f in core-index.db.zst core-index.db.zst.sha256; do
echo "Uploading $f -> $version"
curl --fail-with-body --user "${OWNER}:${PACKAGE_TOKEN}" \
--upload-file "/tmp/$f" "${base}/${f}"
done
- name: Summary
if: always()
run: |
echo "complete=${{ steps.maintain.outputs.complete }}"
echo "changed=${{ steps.maintain.outputs.changed }}"
if [ "${{ steps.maintain.outputs.complete }}" != "true" ]; then
echo "Build incomplete — rerun to continue from the checkpoint."
echo "Progress lives in /cache/data/explore-staging."
elif [ "${{ steps.maintain.outputs.changed }}" != "true" ]; then
echo "Nothing new to publish."
fi
@@ -0,0 +1,281 @@
# 001 — Ship a prebuilt "core" explore index
**Status:** pending
**Branch:** wip
**Created:** 2026-07-25
## Problem
A fresh install has no explore index. `StartIndexBuild()` is called
unconditionally from two places in `app.go`, and `runDumpBuild` then
downloads gigabytes from `data.metabrainz.org` before Explore can return
anything beyond the user's own library:
| Stage | Source | Cost |
|---|---|---|
| Listen Counts | ListenBrainz spark full listens dump | **~205 GB streamed** — see below |
| Catalog Import | MusicBrainz canonical dump (~2 GB `.tar.zst`) | scan ~30M CSV rows, assemble to budget |
| Metadata Patch | MB/LB API | rate-limited at 3 req/s |
| Listener Counts | LB API | rate-limited |
Measured 2026-07-25 against the live dump
(`listenbrainz-spark-dump-2593-20260712-000004-full.tar`):
```
content-length: 205073162240 # 205 GB
accept-ranges: bytes
```
The stage-1 reader skips non-`.parquet` tar members
(`dumpcounts.go:317`), but a tar stream has no seek — skipped bytes
still transit the wire. **So a first run on a fresh install pulls
~205 GB.** Little of it touches disk (the counts map and checkpoint do,
not the dump), but the bandwidth is real and it is per-user.
Consequences today:
- Every install pulls ~205 GB to derive a catalog that is **identical
for everyone**. On a metered or slow connection this is untenable, and
it is unconditional on first run.
- **It refuses to start without 6 GB free** (`dumpMinStartFreeBytes`),
and aborts below 2 GB (`dumpAbortFreeBytes`). This is what breaks
`make fresh-install` on a tmpfs `/tmp`.
- First-run Explore is empty for the length of the import.
The catalog half is **the same for everyone**. Only the local half
(`PopulateLocalCrossReferences`, `BackfillLibraryDiscographies`) is
per-user. Deriving the shared half on each machine is the waste this
plan removes.
## Goal
Ship a prebuilt core index so a fresh install has a usable Explore
immediately, and the runtime build collapses to the local half plus
incremental refresh. The full dump import becomes an opt-in "deep
catalog" upgrade rather than a prerequisite.
## Sizing evidence
Measured 2026-07-25 with a synthetic harness against the real schema and
migrations (2.15M-row full run exceeded a 15-minute budget, so this is a
200K-row calibration, `VACUUM`ed):
| Metric | Value |
|---|---|
| 200,000 rows, with FTS | 85.2 MB |
| Cost per row | ~426 B |
| zstd -19 | 29.6 MB (2.9x) |
Extrapolating to the current budgets (`keepRecordings` 1.5M +
`keepReleaseGroup` 400K + `keepArtists` 250K = 2.15M rows):
| Tier | Rows | On disk | zstd -19 |
|---|---|---|---|
| Full budget | 2.15M | **~900 MB** | ~310 MB |
| Core (proposed) | 500K | ~210 MB | **~72 MB** |
| Minimal | 250K | ~105 MB | ~36 MB |
**This corrects an earlier figure.** A ~93 MB index was recorded in the
2026-07-16 audit note; that measured the *legacy tier-crawl* index, not
the dump-built one. The dump build targets an order of magnitude more
rows. Shipping the full index is not viable as a casual download —
which is exactly why this plan is scoped to a *core* subset.
⚠️ Two caveats on these numbers:
- The harness used a 14-word vocabulary, so its FTS measured only 7% of
total size. Real titles have a far larger vocabulary and the real FTS
share will be materially higher. **Treat the totals as a floor.**
- Row width was estimated from the schema (3 UUIDs at 36 chars dominate);
`aliases` was left empty and is populated for real artists.
Re-measure against a genuine dump-built index before committing to a
tier size.
## What "core" should mean
`dumpcatalog.go` already has graded per-artist coverage (S2) —
`perArtistArtistBudget = 10_000` split into tiers A/B/C with per-tier
track and release-group caps. The core index should reuse that machinery
rather than invent a second notion of importance:
- **Artists:** top ~50K by listen count.
- **Release groups + recordings:** the S2 per-artist slice for those
artists (tier A/B/C caps as they stand).
- **Excluded:** the global long tail below the per-artist selection.
Anything not covered still works — it just resolves through the existing
lazy paths (`EnsureArtistDiscography`, `AddFromCache`), which is the
behaviour non-covered artists already get today.
## Distribution: download on first run, not `go:embed`
**Both packaging paths build from source** — the Homebrew formula builds
from a release tarball, the Arch `PKGBUILD` clones the tag. So:
- Committing the artifact to git bloats the repo and every source tarball.
- `go:embed` makes a from-source build require the artifact at build
time, so source builds would have to download it anyway — and
`build-prod` runs UPX over the binary, which would be pathological
with a 70 MB+ embedded blob.
So "ship with the app" should mean **fetch a prebuilt artifact on first
run** from a versioned URL. CI already publishes binary packages to the
Gitea package registry (`.gitea/workflows/arch-package.yml`), so there is
an existing place to host it.
Import path: download `.zst` → decompress → `ATTACH``INSERT INTO
explore_index SELECT ...` through the **existing** `upsertBatch` conflict
rules, which already do the right thing (non-empty wins, highest
popularity wins, never clobber a good value with an empty one).
## Artifact contents
Ship the global catalog columns only. These are **per-user** and must be
zeroed in the artifact, then recomputed locally by
`PopulateLocalCrossReferences`:
- `in_library`, `is_similar`
- `local_artist_id`, `local_release_group_id`, `local_recording_id`
`discog_fetched` should ship as `1` for artists whose S2 slice is
included, so the backfill doesn't redundantly re-fetch them.
Also decide per-table whether to include: `similar_artist_map`,
`artist_metadata`, `release_to_rg`. `release_to_rg` in particular may
rival the index in size — measure before including.
**Resolved: the artifact ships no FTS.** Rows are inserted into the
client's own `explore_index`, whose `AFTER INSERT` trigger populates
`explore_index_fts` as a side effect — so shipping a search index would
be pure redundant weight. `cmd/indexexport` builds the artifact without
FTS or triggers accordingly.
## Update strategy
- **Popularity drift** — `dumpincremental.go` already implements
incremental listens-dump refresh (`RefreshListenCounts`, weekly
cadence). It applies unchanged on top of a shipped baseline, provided
`listens_applied_series` is stamped in the artifact so deltas resume
from the right point.
- **Catalog additions** — new releases arrive via the existing lazy
per-artist fetches. A refreshed artifact per app release is enough;
no separate cadence needed.
- **Schema changes** — `schema_version` exists on `explore_index` but is
noted as dead in the audit. Either wire it up or version the artifact
filename against the migration number, so an old artifact can't be
imported into a newer schema.
## Build pipeline: build and cache in Gitea CI
The import is unusually well suited to running as a **series of
time-boxed CI jobs against a persistent cache**, because the resumability
already exists:
- Stage 1 streams over a `resumableReader` that reconnects with HTTP
`Range` requests, and the live dump advertises `accept-ranges: bytes`.
- `counts.bin` checkpoints `Offset` (absolute byte position) and
`MemberIdx`, and the applier merges results **in member order** so
"every checkpoint is a contiguous prefix of the stream"
(`dumpcounts.go`).
- Stage 2's canonical scan is deliberately restartable wholesale — "cheap
enough to simply restart after an interruption" (`dumpcatalog.go`).
So a job that hits a runner time limit resumes at its exact byte offset
on the next run. **No single multi-hour job is required** — schedule
N bounded runs and let them converge.
What it needs:
1. **A persistent volume for `explore-staging/` + the DB.** `act_runner`
uses the Docker backend and job containers are ephemeral, so bind-mount
a host path (or a named Docker volume) and point `YJ_HOME` at it.
Prefer this over the Actions cache — cache entries are size-capped and
awkward at GB scale, and this is a self-hosted runner anyway.
2. **A headless entrypoint** — currently the import only runs from the
app lifecycle (`StartIndexBuild` via `OnDomReady`). This is a real gap,
but a small one: `NewSearchIndex(db, lb, artistImg, logger)` takes no
Wails dependency, and the single `runtime.EventsEmit` in
`searchindex.go` sits inside `emitStatus`, which already early-returns
when `runtimeCtx == nil`. A `cmd/indexbuild` that opens the DB and
calls `StartBuild(context.Background())` — never `SetContext` — should
work. Verify `scheduleChampionRebuild` in the `StartBuild` defer is
also Wails-free.
3. **Triggers.** `indexbuild` decides its own mode from index state, so
every trigger runs the same command: push to `main` and a weekly cron
both land on a cheap refresh (which no-ops when nothing new is
published), and the 3-month rebuild fires when the command notices the
import has aged out.
Then export: subset to core, zero the personal columns, stamp
`dump_import_done` / `listens_applied_series` / schema version, `VACUUM`,
`zstd -19`, checksum, publish to the Gitea package registry (the Arch
workflow already authenticates against it with `PACKAGE_TOKEN`).
**Be a good citizen about the 205 GB.** Rebuild on the dump cadence
(the audit notes a 90-day re-import cadence), never per-commit. Once a
baseline exists, the ~180 MB daily incremental dumps already wired in
`dumpincremental.go` keep popularity fresh — so the 205 GB is genuinely
one-time per rebuild, not per refresh. Also check the runner's own
egress if it is self-hosted on a home connection.
## Licensing
- MusicBrainz canonical dump is **CC0** — redistribution fine.
- ListenBrainz-derived listen counts need their dump licence checked
before redistribution, plus attribution in-app either way.
- Note the derived counts already differ from LB API values (no MLHD+
history) — a known, accepted divergence, but worth stating wherever
the numbers are surfaced.
## Risks
- **Artifact staleness vs app version** — a user on an old release gets
an old catalog. Mitigated by incremental refresh + lazy fetches.
- **Download failure / offline install** — must degrade to today's
behaviour (local library search), not a broken Explore. The failure is
now visible in the Jobs panel, which helps.
- **Users who want the full catalog** — keep the existing dump import as
an explicit opt-in, gated behind a setting. Note that no such setting
exists today: `StartIndexBuild()` is unconditional, and Library Only
mode is frontend-`localStorage` only with no backend wiring.
## Phasing
1.**Headless entrypoint.** `cmd/indexbuild` — resumable, budgeted
(`-budget 3h`), signal-aware, exit 3 = "more work remains". Verified
to run without Wails; builds with `CGO_ENABLED=0` and no build tags.
2.**Export tooling.** `cmd/indexexport` — top-N artists plus a
per-artist window of their release groups and recordings, personal
columns dropped, metadata stamped, vacuumed. Verified against a
synthetic index: no personal columns leak, no orphaned rows, caps
respected.
3.**One real build.** Run `indexbuild` against a persistent volume
until it converges. This yields the first genuine dump-built index and
with it true row counts, on-disk size, real FTS share, and
`release_to_rg` size. **Every tier number above is still an
extrapolation from synthetic rows until this exists.**
4.**Import path.** First-run download + attach + upsert, with checksum
verification, resumability, and clean degradation on failure. Report
it as a job in the Jobs panel — the plumbing for that already exists.
5.**Gate the dump build.** Add the setting that makes the full import
opt-in, so a shipped core index isn't immediately followed by the
multi-GB download it was meant to replace.
6.**CI wiring.** `.gitea/workflows/index-artifact.yml` — push +
weekly cron + manual, concurrency-guarded, publishes only when
`complete && changed` so identical artifacts don't accumulate.
Runner-side prerequisites are in place (cache dir + `valid_volumes`
on the VPS runner).
Step 3 is the gate on everything downstream — and it is worth doing
regardless of whether the artifact ever ships, since it is the only way
to get real numbers for the index.
## Related
- `backend/explore/dumpimport.go` — stage orchestration, disk floors
- `backend/explore/dumpcatalog.go` — budgets, S2 per-artist tiers
- `backend/explore/dumpincremental.go` — incremental refresh (update path)
- `backend/explore/searchindex.go``upsertBatch` conflict rules,
`PopulateLocalCrossReferences`
- Migration 26 in `backend/database/database.go``explore_index` schema
+30 -2
View File
@@ -2,11 +2,39 @@ VERSION ?= dev
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)'
# YJ_HOME isolates the dev build's config + database from a packaged
# install. Defaults to a sandbox under XDG data; override in .env to
# point elsewhere (or unset it there to share the real user dirs).
DEV_YJ_HOME ?= $(HOME)/.local/share/yellowjacket-dev
dev: setup generate clean
if [ -f .env ]; then set -a; . ./.env; set +a; fi; go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
if [ -f .env ]; then set -a; . ./.env; set +a; fi; : "$${YJ_HOME:=$(DEV_YJ_HOME)}"; export YJ_HOME; go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
dev-debug: setup generate clean
if [ -f .env ]; then set -a; . ./.env; set +a; fi; YJ_LOG_LEVEL=debug go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
if [ -f .env ]; then set -a; . ./.env; set +a; fi; : "$${YJ_HOME:=$(DEV_YJ_HOME)}"; export YJ_HOME; YJ_LOG_LEVEL=debug go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
# Base directory for fresh-install sandboxes. Deliberately NOT $TMPDIR:
# on most Linux distros /tmp is tmpfs (RAM-backed) and only a few GB, so
# the search index dump import — which wants 6GB free before it will even
# start, then streams multi-GB dumps through explore-staging/ — either
# fails its precheck or eats that much RAM. XDG cache is disk-backed
# everywhere and still throwaway.
FRESH_HOME_BASE ?= $(if $(XDG_CACHE_HOME),$(XDG_CACHE_HOME),$(HOME)/.cache)
# fresh-install runs dev against a brand-new YJ_HOME so every launch
# starts from a clean first-run state (no config.toml, no yj.db). The dir
# is not cleaned up automatically, so you can inspect it afterward; the
# printed path tells you where it is. Override the location with
# FRESH_HOME_BASE=/some/disk make fresh-install.
fresh-install: setup generate clean
if [ -f .env ]; then set -a; . ./.env; set +a; fi; \
mkdir -p "$(FRESH_HOME_BASE)"; \
export YJ_HOME="$$(mktemp -d "$(FRESH_HOME_BASE)/yellowjacket-fresh.XXXXXX")"; \
echo "==> fresh YJ_HOME=$$YJ_HOME"; \
case "$$(findmnt -no FSTYPE -T "$$YJ_HOME" 2>/dev/null)" in \
tmpfs|ramfs) echo "==> WARNING: $$YJ_HOME is RAM-backed; the search index import needs ~6GB of real disk. Set FRESH_HOME_BASE to a disk-backed path." ;; \
esac; \
go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
build-dev: generate
go tool wails build -tags webkit2_41 -debug -clean -ldflags "$(LDFLAGS)"
+20
View File
@@ -20,6 +20,7 @@ import (
"yellowjacket/backend/database"
"yellowjacket/backend/explore"
"yellowjacket/backend/frontendutil"
"yellowjacket/backend/jobs"
"yellowjacket/backend/library"
"yellowjacket/backend/mediacontrols"
"yellowjacket/backend/player"
@@ -44,6 +45,7 @@ type YellowJacketApp struct {
queue *queue.Queue
explore *explore.Service
autotag *autotagservice.Service
jobs *jobs.Registry
mediaControls mediacontrols.Handler
tagWriter *tagwriter.TagWriter
appContext context.Context
@@ -148,6 +150,16 @@ func NewYellowJacketApp(
yjApp.logger.WithGroup("explore"), yjApp.database,
)
// create the background job registry and wire it into the
// subsystems that run long jobs, so scans and index builds all
// report through one surface.
yjApp.jobs = jobs.NewRegistry(
yjApp.logger.WithGroup("jobs"),
jobs.NewStore(yjApp.database, yjApp.logger.WithGroup("jobs")),
)
yjApp.library.SetJobRegistry(yjApp.jobs)
yjApp.explore.SetJobRegistry(yjApp.jobs)
// create autotag service (depends on explore + tagWriter)
yjApp.autotag = autotagservice.NewService(
yjApp.logger.WithGroup("autotag"),
@@ -166,6 +178,7 @@ func NewYellowJacketApp(
yjApp.tagWriter,
yjApp.explore,
yjApp.autotag,
jobs.NewService(yjApp.jobs),
}
return yjApp, nil
@@ -219,6 +232,13 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
yj.tagWriter.SetContext(ctx)
yj.explore.SetContext(ctx)
yj.autotag.SetContext(ctx)
yj.jobs.SetContext(ctx)
// Bring back jobs the user paused before the last shutdown, still
// paused. Must run before the soft scan in OnDomReady, which
// checks these records so it does not restart a paused library.
yj.library.RestorePausedScans()
yj.explore.AdoptPausedIndexBuild()
// Wire queue (created in NewYellowJacketApp for Wails binding)
yj.queue.SetContext(ctx)
@@ -0,0 +1,15 @@
-- Durable state for background jobs. Currently holds one row per job
-- that the user paused, so a paused library scan or search index build
-- comes back paused after a restart instead of silently resuming (or
-- silently never running again).
--
-- Rows are written when a durable job enters the paused state and
-- deleted on resume, cancel, or completion — this is not a job history
-- table, and it stays at zero rows in the common case.
CREATE TABLE IF NOT EXISTS job_state (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
subtitle TEXT NOT NULL DEFAULT '',
paused_at TEXT NOT NULL
);
+8
View File
@@ -77,6 +77,14 @@ type HttpCache struct {
EntityType string
}
type JobState struct {
ID string
Kind string
Title string
Subtitle string
PausedAt string
}
type Library struct {
ID int64
Name string
+9
View File
@@ -87,6 +87,15 @@ const (
AutotagPrefetchFinished = "AutotagPrefetchFinished" // {processed, total}
)
// Background job events.
const (
// JobsChanged carries a full snapshot of every known background job
// (see backend/jobs). A full snapshot rather than a delta means a
// component mounting mid-scan is correct from its first event.
// Emitted coalesced, at most every 250ms.
JobsChanged = "JobsChanged"
)
// Explore / search index events.
const (
IndexStatusChanged = "IndexStatusChanged"
+47
View File
@@ -14,6 +14,7 @@ import (
"yellowjacket/backend/database"
"yellowjacket/backend/events"
"yellowjacket/backend/jobs"
)
// Service is the Wails-bound service for the explore feature.
@@ -148,6 +149,52 @@ func (e *Service) StopIndexBuild() {
e.index.StopBuild()
}
// SetJobRegistry wires the background job registry into the search
// index so its build reports progress and controls to the frontend.
func (e *Service) SetJobRegistry(reg *jobs.Registry) {
e.index.SetJobRegistry(reg)
}
// AdoptPausedIndexBuild re-registers a build paused in a previous
// session so it appears in the jobs panel, still paused.
func (e *Service) AdoptPausedIndexBuild() {
e.index.AdoptPausedBuild()
}
// IndexImportComplete reports whether the dump import has finished all
// of its stages. Distinct from IsIndexReady, which only means the index
// holds enough rows to answer queries — a partially imported index is
// ready but not complete. Used by the headless builder to decide
// whether another run is needed.
func (e *Service) IndexImportComplete() bool {
return e.index.ImportComplete()
}
// IndexBaselineSeries returns the incremental listens series the index's
// popularity is caught up to. A change across a refresh means new data
// was folded in.
func (e *Service) IndexBaselineSeries() int {
return e.index.BaselineSeries()
}
// IndexLastImported returns when the dump import last completed, or the
// zero time if it never has.
func (e *Service) IndexLastImported() time.Time {
return e.index.LastImported()
}
// PrepareIndexRebuild clears the completion marker so the next build
// re-imports from the newest published dump.
func (e *Service) PrepareIndexRebuild() {
e.index.PrepareRebuild()
}
// RefreshIndexNow folds newly published incremental listens dumps into
// the index synchronously. Pass 0 to bypass the cadence gate.
func (e *Service) RefreshIndexNow(minInterval time.Duration) {
e.index.RefreshNow(e.ctx, minInterval)
}
// IsIndexReady returns true once the index has been populated.
func (e *Service) IsIndexReady() bool {
return e.index.IsReady()
+380
View File
@@ -0,0 +1,380 @@
package explore
import (
"context"
"errors"
"fmt"
"strconv"
"time"
"yellowjacket/backend/jobs"
)
// errIndexStageFailed wraps the per-stage error text reported by the
// dump importer so the job carries a typed failure.
var errIndexStageFailed = errors.New("index build stage failed")
// indexJobID is the stable registry ID for the search index build.
// There is only ever one, so the ID is a constant.
const indexJobID = "index:build"
// indexPausedKey marks a build the user paused. It lives in
// explore_index_meta alongside the import's other checkpoints so a
// paused build stays paused across a restart instead of resuming on
// the next launch.
const indexPausedKey = "index_build_paused"
// SetJobRegistry wires the background job registry so index builds
// report progress, stage state, logs, and pause/cancel controls.
func (si *SearchIndex) SetJobRegistry(reg *jobs.Registry) {
si.mu.Lock()
si.jobs = reg
si.mu.Unlock()
}
// jobRegistry returns the registry, or nil when none is wired.
func (si *SearchIndex) jobRegistry() *jobs.Registry {
si.mu.RLock()
defer si.mu.RUnlock()
return si.jobs
}
// logIndexJob appends a line to the index build's job log, if a build
// job is currently registered.
func (si *SearchIndex) logIndexJob(level jobs.Level, message string) {
reg := si.jobRegistry()
if reg == nil {
return
}
if h := reg.Get(indexJobID); h != nil {
h.Logf(level, message)
}
}
// indexJobSpec builds the registry spec for the index build.
//
// The build is genuinely pausable rather than merely cancellable: the
// dump importer checkpoints its listen-count offset to counts.bin and
// its stage to state.json, so stopping and restarting picks up where it
// left off instead of re-downloading multiple gigabytes.
func (si *SearchIndex) indexJobSpec(state jobs.State) jobs.Spec {
return jobs.Spec{
ID: indexJobID,
Kind: jobs.KindIndexBuild,
Title: "Building search index",
Subtitle: "MusicBrainz catalog + ListenBrainz popularity",
State: state,
Caps: jobs.Caps{
Pausable: true,
Cancellable: true,
},
Durable: true,
Controls: jobs.Controls{
Pause: si.PauseBuild,
Resume: si.ResumeBuild,
Cancel: si.CancelBuild,
},
}
}
// syncIndexJob mirrors an IndexStatus snapshot into the job registry.
// It is driven from emitStatus, which every status mutation already
// funnels through, so there is no path that updates one view and not
// the other.
func (si *SearchIndex) syncIndexJob(status IndexStatus) {
reg := si.jobRegistry()
if reg == nil {
return
}
si.mu.RLock()
paused := si.buildPaused
si.mu.RUnlock()
h := reg.Get(indexJobID)
// A build with no stages is the early-return path in runDumpBuild
// (the catalog import is already done). Nothing to show.
if h == nil {
if !status.Building || len(status.Tiers) == 0 {
return
}
h = reg.Start(si.indexJobSpec(jobs.StateRunning))
h.Logf(jobs.LevelInfo, "Index build started")
}
// A finished job is immutable. Without this guard the 3-second
// status ticker would keep touching it forever, re-emitting
// JobsChanged long after the build ended.
if h.State().IsTerminal() {
return
}
si.applyStagesToJob(h, status)
if status.Building {
// Don't stomp a pause or cancel that is still settling; those
// transitions are confirmed by their own control paths.
if h.State() == jobs.StateQueued {
h.SetState(jobs.StateRunning)
}
return
}
si.finishIndexJob(h, paused)
}
// applyStagesToJob maps index tiers onto job stages and derives the
// headline progress bar from whichever tier is currently running.
func (si *SearchIndex) applyStagesToJob(h *jobs.Handle, status IndexStatus) {
stages := make([]jobs.Stage, 0, len(status.Tiers))
var (
phase string
current, total int64
foundRunningTier bool
)
for _, t := range status.Tiers {
stages = append(stages, jobs.Stage{
Name: t.Name,
State: t.State,
Current: int64(t.Completed),
Total: int64(t.Total),
Error: t.Error,
})
if t.State == "running" && !foundRunningTier {
foundRunningTier = true
phase = t.Name
current = int64(t.Completed)
total = int64(t.Total)
}
}
h.SetStages(stages)
if foundRunningTier {
h.SetPhase(phase)
h.SetProgress(current, total)
}
h.SetStats([]jobs.Stat{
{Label: "Artists", Value: strconv.Itoa(status.Artists)},
{Label: "Release groups", Value: strconv.Itoa(status.ReleaseGroups)},
{Label: "Recordings", Value: strconv.Itoa(status.Recordings)},
{Label: "Total rows", Value: strconv.Itoa(status.TotalRows)},
})
}
// finishIndexJob resolves a build that is no longer running into the
// right terminal (or paused) state. A stopped build that never wrote
// the done marker is reported as cancelled rather than complete —
// claiming success for a half-finished import would be a lie.
func (si *SearchIndex) finishIndexJob(h *jobs.Handle, paused bool) {
if h.State().IsTerminal() || h.State() == jobs.StatePaused {
return
}
// A stage that errored means the build failed; reporting that as
// "stopped" would hide a real failure behind a neutral word.
if !paused {
for _, stage := range h.Snapshot().Stages {
if stage.State == "error" {
h.Fail(fmt.Errorf("%w: %s: %s",
errIndexStageFailed, stage.Name, stage.Error))
return
}
}
}
if paused {
h.SetPhase("Paused")
h.SetState(jobs.StatePaused)
h.Logf(jobs.LevelInfo,
"Build paused — progress is checkpointed and will resume "+
"from here")
return
}
if si.hasMeta(dumpImportDoneKey) {
h.Logf(jobs.LevelInfo, "Index build complete")
h.Complete()
return
}
h.Logf(jobs.LevelInfo, "Build stopped before finishing")
h.Cancelled()
}
// PauseBuild stops the in-flight index build and remembers that the
// user asked for it, so it is not restarted automatically — including
// on the next launch. Blocks until the build goroutine exits; the job
// registry invokes controls on their own goroutine.
func (si *SearchIndex) PauseBuild() {
si.mu.Lock()
si.buildPaused = true
si.mu.Unlock()
si.setMeta(indexPausedKey, "1")
si.StopBuild()
si.emitStatus()
}
// ResumeBuild clears the pause and restarts the build, which picks up
// from the importer's last checkpoint.
func (si *SearchIndex) ResumeBuild() {
si.mu.Lock()
si.buildPaused = false
ctx := si.runtimeCtx
si.mu.Unlock()
si.deleteMeta(indexPausedKey)
if reg := si.jobRegistry(); reg != nil {
if h := reg.Get(indexJobID); h != nil {
h.SetState(jobs.StateRunning)
h.Logf(jobs.LevelInfo, "Resuming from last checkpoint")
}
}
if ctx != nil {
si.StartBuild(ctx)
}
}
// CancelBuild stops the build without marking it paused. The importer's
// on-disk checkpoints are left in place, so starting a new build later
// still resumes rather than re-downloading — cancel here means "stop
// working now", not "throw away the progress".
func (si *SearchIndex) CancelBuild() {
si.mu.Lock()
si.buildPaused = false
si.mu.Unlock()
si.deleteMeta(indexPausedKey)
si.StopBuild()
si.emitStatus()
}
// ImportComplete reports whether the dump import wrote its done marker,
// meaning every stage finished. A resumable import that was interrupted
// leaves this false even though the index may already be queryable.
func (si *SearchIndex) ImportComplete() bool {
return si.hasMeta(dumpImportDoneKey)
}
// BaselineSeries returns the incremental listens series the index's
// popularity numbers are currently caught up to, or 0 when no baseline
// import has completed. A change in this value between two runs is the
// signal that a refresh actually folded in new data.
func (si *SearchIndex) BaselineSeries() int {
series, _ := si.metaInt(listensAppliedSeriesKey)
return series
}
// LastImported returns when the dump import last completed, or the zero
// time if it never has. Drives the rebuild cadence.
func (si *SearchIndex) LastImported() time.Time {
rows, err := si.db.QueryContext(
"SELECT value FROM explore_index_meta WHERE key = ?", dumpImportDoneKey,
)
if err != nil {
return time.Time{}
}
defer func() { _ = rows.Close() }()
if !rows.Next() {
return time.Time{}
}
var raw string
if err := rows.Scan(&raw); err != nil {
return time.Time{}
}
parsed, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}
}
return parsed
}
// PrepareRebuild clears the completion marker so the next StartBuild
// re-imports from the newest published dump instead of short-circuiting.
//
// The importer deletes its staging directory on completion, so there is
// no stale checkpoint to clear as well — a rebuild rediscovers the
// current dump and starts from offset zero. Existing rows are left in
// place: assembly upserts by MBID, so the index stays queryable
// throughout rather than going empty for the length of a re-import.
func (si *SearchIndex) PrepareRebuild() {
si.deleteMeta(dumpImportDoneKey)
si.logger.Info("search index: cleared completion marker for rebuild")
}
// RefreshNow folds any newly published incremental listens dumps into
// the index's popularity numbers, synchronously. Pass 0 to bypass the
// cadence gate.
func (si *SearchIndex) RefreshNow(ctx context.Context, minInterval time.Duration) {
si.RefreshListenCounts(ctx, minInterval)
}
// buildPausedByUser reports whether a build was paused and not resumed,
// including by a previous session.
func (si *SearchIndex) buildPausedByUser() bool {
si.mu.RLock()
paused := si.buildPaused
si.mu.RUnlock()
if paused {
return true
}
return si.hasMeta(indexPausedKey)
}
// AdoptPausedBuild re-registers a build that was paused when the app
// last shut down, so it shows up in the jobs panel with a resume button
// instead of silently not running. Called during startup.
func (si *SearchIndex) AdoptPausedBuild() {
reg := si.jobRegistry()
if reg == nil {
return
}
// A completed import cannot be meaningfully paused; clear a stale
// marker rather than showing a job that would never do anything.
if si.hasMeta(dumpImportDoneKey) {
si.deleteMeta(indexPausedKey)
reg.Remove(indexJobID)
return
}
if !si.hasMeta(indexPausedKey) {
return
}
si.mu.Lock()
si.buildPaused = true
si.mu.Unlock()
h := reg.Start(si.indexJobSpec(jobs.StatePaused))
h.SetPhase("Paused")
h.Logf(jobs.LevelInfo,
"Paused in a previous session — resume to continue from the "+
"last checkpoint")
si.logger.Info("search index: restored paused build")
}
+28
View File
@@ -17,6 +17,7 @@ import (
"yellowjacket/backend/database"
"yellowjacket/backend/events"
"yellowjacket/backend/jobs"
)
// Index build parameters.
@@ -217,6 +218,12 @@ type SearchIndex struct {
// Build status tracking — read by GetIndexStatus for the UI.
buildStatus IndexStatus
// jobs is the background job registry; buildPaused records that the
// user paused the build, distinguishing a deliberate stop from a
// build that merely finished. Both are protected by mu.
jobs *jobs.Registry
buildPaused bool
}
// prefixCacheEntry is one memoised generic-query result.
@@ -495,6 +502,15 @@ func (si *SearchIndex) PersistSimilarArtists(sourceMBID string, similar []LBSimi
// StartBuild launches the background index build goroutine.
// Returns immediately.
func (si *SearchIndex) StartBuild(ctx context.Context) {
// A build the user paused stays paused until they resume it —
// including across restarts, where the marker is read back from
// explore_index_meta. ResumeBuild clears it before calling here.
if si.buildPausedByUser() {
si.logger.Info("search index: build is paused, not starting")
return
}
si.mu.Lock()
// Don't start if already running.
if si.cancel != nil {
@@ -639,10 +655,16 @@ func (si *SearchIndex) setTierStatus(name, state string, total, completed int) {
for i := range si.buildStatus.Tiers {
if si.buildStatus.Tiers[i].Name == name {
transitioned := si.buildStatus.Tiers[i].State != state
si.buildStatus.Tiers[i].State = state
si.buildStatus.Tiers[i].Total = total
si.buildStatus.Tiers[i].Completed = completed
si.mu.Unlock()
if transitioned {
si.logIndexJob(jobs.LevelInfo, "Stage "+state+": "+name)
}
si.emitStatus()
return
@@ -669,6 +691,8 @@ func (si *SearchIndex) setTierError(name, errMsg string) {
si.buildStatus.Tiers[i].State = "error"
si.buildStatus.Tiers[i].Error = errMsg
si.mu.Unlock()
si.logIndexJob(jobs.LevelError, name+": "+errMsg)
si.emitStatus()
return
@@ -691,6 +715,10 @@ func (si *SearchIndex) emitStatus() {
si.mu.RUnlock()
runtime.EventsEmit(si.runtimeCtx, events.IndexStatusChanged, status)
// Mirror into the shared job registry. Every status mutation goes
// through emitStatus, so hooking here covers all update paths.
si.syncIndexJob(status)
}
// GetPopularity returns the cached popularity (listen count) for
+795
View File
@@ -0,0 +1,795 @@
// Package jobs provides a central registry for long-running background
// work — library scans, search index builds, and anything else that runs
// while the user is doing something else. Producers report progress
// through a Handle; the registry coalesces those updates into a single
// JobsChanged event so the frontend can render one indicator, one job
// list, and one log viewer regardless of which subsystem is working.
package jobs
import (
"context"
"log/slog"
"sync"
"sync/atomic"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/events"
)
// Kind identifies the subsystem that owns a job. The frontend uses it
// to pick an icon and to route "view details" to the right panel.
type Kind string
// Job kinds.
const (
KindLibraryScan Kind = "library-scan"
KindIndexBuild Kind = "index-build"
)
// State is the lifecycle position of a job.
type State string
// Job states. Queued, Running, Paused and Pausing are live; Complete,
// Cancelled and Error are terminal.
const (
StateQueued State = "queued"
StateRunning State = "running"
StatePausing State = "pausing"
StatePaused State = "paused"
StateCancelling State = "cancelling"
StateComplete State = "complete"
StateCancelled State = "cancelled"
StateError State = "error"
)
// IsTerminal reports whether the state means the job will not progress
// further without being started again from scratch.
func (s State) IsTerminal() bool {
return s == StateComplete || s == StateCancelled || s == StateError
}
// Level is the severity of a job log entry.
type Level string
// Log levels.
const (
LevelInfo Level = "info"
LevelWarn Level = "warn"
LevelError Level = "error"
)
// maxLogEntries bounds the per-job log ring buffer. Scans can emit a
// warning per unreadable file, so the buffer is a tail, not an archive.
const maxLogEntries = 500
// emitInterval is how often a dirty registry is flushed to the frontend.
// Progress tickers run at 300ms, so this keeps re-render cost bounded
// without making the UI feel laggy.
const emitInterval = 250 * time.Millisecond
// finishedRetention is how long terminal jobs stay in the registry so
// the user can read their logs after the fact.
const finishedRetention = 30 * time.Minute
// maxFinished caps how many terminal jobs are retained regardless of age.
const maxFinished = 25
// Caps describes which controls a job supports. The frontend renders
// buttons from these rather than switching on Kind, so a job that gains
// pause support later needs no frontend change.
type Caps struct {
Pausable bool `json:"pausable"`
Cancellable bool `json:"cancellable"`
}
// Stage is one named sub-step of a multi-stage job, such as an index
// build tier. Jobs with a single linear phase leave Stages empty.
type Stage struct {
Name string `json:"name"`
State string `json:"state"` // pending, running, complete, error, skipped
Current int64 `json:"current"`
Total int64 `json:"total"`
Error string `json:"error,omitempty"`
}
// Stat is a display-only key/value pair shown in the job detail panel
// (e.g. "Added" / "1,204").
type Stat struct {
Label string `json:"label"`
Value string `json:"value"`
}
// LogEntry is one line of a job's output log.
type LogEntry struct {
Time int64 `json:"time"` // unix milliseconds
Level Level `json:"level"`
Message string `json:"message"`
Detail string `json:"detail,omitempty"`
}
// Job is the frontend-facing snapshot of a single background job.
type Job struct {
ID string `json:"id"`
Kind Kind `json:"kind"`
Title string `json:"title"`
Subtitle string `json:"subtitle,omitempty"`
State State `json:"state"`
Phase string `json:"phase,omitempty"`
// Current/Total drive the progress bar. Total == 0 means the job
// is indeterminate and the frontend should render a spinner.
Current int64 `json:"current"`
Total int64 `json:"total"`
Caps Caps `json:"caps"`
Stages []Stage `json:"stages"`
Stats []Stat `json:"stats"`
Error string `json:"error,omitempty"`
StartedAt int64 `json:"startedAt"` // unix milliseconds
UpdatedAt int64 `json:"updatedAt"`
EndedAt int64 `json:"endedAt,omitempty"`
LogCount int `json:"logCount"`
WarnCount int `json:"warnCount"`
ErrorCount int `json:"errorCount"`
}
// Controls holds the callbacks the registry invokes when the user asks
// for a job to be paused, resumed, or cancelled. All three are optional;
// a nil callback means the corresponding capability is unavailable.
//
// Callbacks are invoked on a dedicated goroutine, so implementations may
// block (StopBuild waits for its build goroutine to exit, for instance)
// without stalling the Wails call dispatcher.
type Controls struct {
Pause func()
Resume func()
Cancel func()
}
// Spec describes a job at registration time.
type Spec struct {
ID string
Kind Kind
Title string
Subtitle string
Total int64
State State
Caps Caps
Controls Controls
// Durable marks a job whose paused state should survive an app
// restart. On the next launch the owning subsystem adopts it back
// into the registry as paused instead of silently resuming.
Durable bool
}
// Handle is a producer's write side of a registered job. Every mutator
// marks the registry dirty; the emitter coalesces those into one event.
type Handle struct {
reg *Registry
id string
mu sync.Mutex
job Job
controls Controls
durable bool
log []LogEntry
}
// Registry owns every known job and pushes coalesced snapshots to the
// frontend. It is safe for concurrent use.
type Registry struct {
logger *slog.Logger
store *Store
mu sync.RWMutex
ctx context.Context
jobs map[string]*Handle
order []string
dirty atomic.Bool
}
// NewRegistry creates a registry. Pass a nil store to disable
// pause-across-restart persistence (tests do this).
func NewRegistry(logger *slog.Logger, store *Store) *Registry {
return &Registry{
logger: logger,
store: store,
jobs: make(map[string]*Handle),
}
}
// SetContext injects the Wails runtime context and starts the coalescing
// emitter. Until it is called, updates are recorded but not pushed.
func (r *Registry) SetContext(ctx context.Context) {
r.mu.Lock()
r.ctx = ctx
r.mu.Unlock()
go r.emitLoop(ctx)
}
// emitLoop flushes the registry to the frontend whenever it is dirty.
func (r *Registry) emitLoop(ctx context.Context) {
ticker := time.NewTicker(emitInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if r.dirty.Swap(false) {
r.emit()
}
}
}
}
// emit pushes a full snapshot to the frontend. A full snapshot (rather
// than a delta) means a component that mounts mid-scan is correct from
// the first event it receives.
func (r *Registry) emit() {
r.mu.RLock()
ctx := r.ctx
r.mu.RUnlock()
if ctx == nil {
return
}
runtime.EventsEmit(ctx, events.JobsChanged, r.Snapshot())
}
// touch marks the registry dirty so the next emitter tick publishes it.
func (r *Registry) touch() {
r.dirty.Store(true)
}
// flush publishes immediately. Used for state transitions, where a
// quarter-second of lag would make a button press feel unresponsive.
func (r *Registry) flush() {
r.dirty.Store(false)
r.emit()
}
// Start registers a job and returns its handle. Re-registering an
// existing ID reuses the handle and its log, which is what happens when
// a queued scan is popped off the queue and actually begins.
func (r *Registry) Start(spec Spec) *Handle {
now := nowMillis()
state := spec.State
if state == "" {
state = StateRunning
}
r.mu.Lock()
h, existing := r.jobs[spec.ID]
if !existing {
h = &Handle{reg: r, id: spec.ID}
r.jobs[spec.ID] = h
r.order = append(r.order, spec.ID)
}
r.mu.Unlock()
h.mu.Lock()
if !existing {
h.job = Job{
ID: spec.ID,
StartedAt: now,
Stages: []Stage{},
Stats: []Stat{},
}
}
h.job.Kind = spec.Kind
h.job.Title = spec.Title
h.job.Subtitle = spec.Subtitle
h.job.State = state
h.job.Caps = spec.Caps
h.job.Total = spec.Total
h.job.UpdatedAt = now
h.job.EndedAt = 0
h.job.Error = ""
h.controls = spec.Controls
h.durable = spec.Durable
h.mu.Unlock()
r.persistPause(spec.ID, state)
r.pruneFinished()
r.flush()
return h
}
// Get returns the handle for an ID, or nil when unknown.
func (r *Registry) Get(id string) *Handle {
r.mu.RLock()
defer r.mu.RUnlock()
return r.jobs[id]
}
// Snapshot returns every known job, oldest registration first.
func (r *Registry) Snapshot() []Job {
r.mu.RLock()
out := make([]Job, 0, len(r.order))
handles := make([]*Handle, 0, len(r.order))
for _, id := range r.order {
if h, ok := r.jobs[id]; ok {
handles = append(handles, h)
}
}
r.mu.RUnlock()
for _, h := range handles {
out = append(out, h.Snapshot())
}
return out
}
// Logs returns the retained log tail for a job, oldest entry first.
func (r *Registry) Logs(id string) []LogEntry {
h := r.Get(id)
if h == nil {
return []LogEntry{}
}
h.mu.Lock()
defer h.mu.Unlock()
out := make([]LogEntry, len(h.log))
copy(out, h.log)
return out
}
// HasActive reports whether any job is in a non-terminal state.
func (r *Registry) HasActive() bool {
for _, j := range r.Snapshot() {
if !j.State.IsTerminal() {
return true
}
}
return false
}
// Pause asks the owning subsystem to pause a job. The job moves to
// "pausing" immediately for UI feedback; the producer confirms the
// transition to "paused" when it actually stops.
func (r *Registry) Pause(id string) {
h := r.Get(id)
if h == nil {
return
}
h.mu.Lock()
pause := h.controls.Pause
pausable := h.job.Caps.Pausable
live := !h.job.State.IsTerminal()
h.mu.Unlock()
if pause == nil || !pausable || !live {
return
}
h.SetState(StatePausing)
h.Logf(LevelInfo, "Pause requested")
go pause()
}
// Resume asks the owning subsystem to continue a paused job.
func (r *Registry) Resume(id string) {
h := r.Get(id)
if h == nil {
return
}
h.mu.Lock()
resume := h.controls.Resume
paused := h.job.State == StatePaused || h.job.State == StatePausing
h.mu.Unlock()
if resume == nil || !paused {
return
}
h.Logf(LevelInfo, "Resume requested")
go resume()
}
// Cancel asks the owning subsystem to abandon a job.
func (r *Registry) Cancel(id string) {
h := r.Get(id)
if h == nil {
return
}
h.mu.Lock()
cancel := h.controls.Cancel
cancellable := h.job.Caps.Cancellable
live := !h.job.State.IsTerminal()
h.mu.Unlock()
if cancel == nil || !cancellable || !live {
return
}
h.SetState(StateCancelling)
h.Logf(LevelInfo, "Cancel requested")
go cancel()
}
// Remove drops a job from the registry entirely, discarding its log.
func (r *Registry) Remove(id string) {
r.mu.Lock()
delete(r.jobs, id)
for i, existing := range r.order {
if existing == id {
r.order = append(r.order[:i], r.order[i+1:]...)
break
}
}
r.mu.Unlock()
if r.store != nil {
r.store.ClearPaused(id)
}
r.flush()
}
// ClearFinished drops every terminal job. Bound to the "clear" action
// in the jobs panel.
func (r *Registry) ClearFinished() {
r.mu.Lock()
kept := r.order[:0]
for _, id := range r.order {
h, ok := r.jobs[id]
if !ok {
continue
}
h.mu.Lock()
terminal := h.job.State.IsTerminal()
h.mu.Unlock()
if terminal {
delete(r.jobs, id)
continue
}
kept = append(kept, id)
}
r.order = kept
r.mu.Unlock()
r.flush()
}
// pruneFinished evicts terminal jobs that are older than the retention
// window, and trims the oldest when too many have accumulated.
func (r *Registry) pruneFinished() {
cutoff := nowMillis() - finishedRetention.Milliseconds()
r.mu.Lock()
defer r.mu.Unlock()
var finished []string
for _, id := range r.order {
h, ok := r.jobs[id]
if !ok {
continue
}
h.mu.Lock()
terminal := h.job.State.IsTerminal()
ended := h.job.EndedAt
h.mu.Unlock()
if terminal && ended > 0 && ended < cutoff {
delete(r.jobs, id)
continue
}
if terminal {
finished = append(finished, id)
}
}
// Trim the oldest terminal jobs beyond the cap.
if excess := len(finished) - maxFinished; excess > 0 {
for _, id := range finished[:excess] {
delete(r.jobs, id)
}
}
kept := r.order[:0]
for _, id := range r.order {
if _, ok := r.jobs[id]; ok {
kept = append(kept, id)
}
}
r.order = kept
}
// persistPause writes or clears the durable pause record for a job so a
// paused job comes back paused after a restart instead of silently
// resuming (or silently never running again).
func (r *Registry) persistPause(id string, state State) {
if r.store == nil {
return
}
h := r.Get(id)
if h == nil {
return
}
h.mu.Lock()
durable := h.durable
job := h.job
h.mu.Unlock()
if !durable {
return
}
if state != StatePaused {
r.store.ClearPaused(id)
return
}
r.store.SetPaused(Persisted{
ID: job.ID,
Kind: job.Kind,
Title: job.Title,
Subtitle: job.Subtitle,
})
}
// PausedEntries returns the jobs of the given kind that were paused when
// the app last shut down. Subsystems call this during startup and adopt
// each entry back into the registry with its controls attached.
func (r *Registry) PausedEntries(kind Kind) []Persisted {
if r.store == nil {
return nil
}
return r.store.PausedEntries(kind)
}
// IsPersistentlyPaused reports whether the given job ID was paused when
// the app last shut down. Subsystems check this before auto-starting
// work at launch, so a paused job stays paused.
func (r *Registry) IsPersistentlyPaused(id string) bool {
if r.store == nil {
return false
}
return r.store.IsPaused(id)
}
// ---------------------------------------------------------------------------
// Handle
// ---------------------------------------------------------------------------
// Snapshot returns a copy of the job's current state.
func (h *Handle) Snapshot() Job {
h.mu.Lock()
defer h.mu.Unlock()
job := h.job
job.Stages = make([]Stage, len(h.job.Stages))
copy(job.Stages, h.job.Stages)
job.Stats = make([]Stat, len(h.job.Stats))
copy(job.Stats, h.job.Stats)
return job
}
// State returns the job's current state.
func (h *Handle) State() State {
h.mu.Lock()
defer h.mu.Unlock()
return h.job.State
}
// SetState moves the job to a new state. Terminal states stamp EndedAt.
// Transitions flush immediately so controls feel responsive.
func (h *Handle) SetState(state State) {
h.mu.Lock()
if h.job.State == state {
h.mu.Unlock()
return
}
h.job.State = state
h.job.UpdatedAt = nowMillis()
if state.IsTerminal() {
h.job.EndedAt = h.job.UpdatedAt
} else {
h.job.EndedAt = 0
}
h.mu.Unlock()
h.reg.persistPause(h.id, state)
h.reg.flush()
}
// SetPhase records the human-readable phase label ("Scanning files").
func (h *Handle) SetPhase(phase string) {
h.mu.Lock()
changed := h.job.Phase != phase
h.job.Phase = phase
h.job.UpdatedAt = nowMillis()
h.mu.Unlock()
if changed {
h.reg.flush()
return
}
h.reg.touch()
}
// SetProgress updates the progress numerator and denominator. Pass a
// total of zero to render the job as indeterminate.
func (h *Handle) SetProgress(current, total int64) {
h.mu.Lock()
h.job.Current = current
h.job.Total = total
h.job.UpdatedAt = nowMillis()
h.mu.Unlock()
h.reg.touch()
}
// SetSubtitle updates the secondary line shown under the job title.
func (h *Handle) SetSubtitle(subtitle string) {
h.mu.Lock()
h.job.Subtitle = subtitle
h.job.UpdatedAt = nowMillis()
h.mu.Unlock()
h.reg.touch()
}
// SetStats replaces the job's display statistics.
func (h *Handle) SetStats(stats []Stat) {
h.mu.Lock()
h.job.Stats = stats
h.job.UpdatedAt = nowMillis()
h.mu.Unlock()
h.reg.touch()
}
// SetStages replaces the job's stage list. Used by multi-tier jobs such
// as the index build.
func (h *Handle) SetStages(stages []Stage) {
h.mu.Lock()
h.job.Stages = stages
h.job.UpdatedAt = nowMillis()
h.mu.Unlock()
h.reg.touch()
}
// SetCaps updates which controls the job currently supports.
func (h *Handle) SetCaps(caps Caps) {
h.mu.Lock()
h.job.Caps = caps
h.mu.Unlock()
h.reg.touch()
}
// Logf appends a line to the job's log ring buffer.
func (h *Handle) Logf(level Level, message string) {
h.logEntry(level, message, "")
}
// LogDetail appends a log line carrying a secondary detail string, such
// as the file path a warning refers to.
func (h *Handle) LogDetail(level Level, message, detail string) {
h.logEntry(level, message, detail)
}
func (h *Handle) logEntry(level Level, message, detail string) {
h.mu.Lock()
if len(h.log) >= maxLogEntries {
// Drop the oldest entry. Log volume is low enough (phase
// transitions and per-file warnings) that the copy is cheaper
// than maintaining an explicit ring index.
h.log = append(h.log[:0], h.log[1:]...)
}
h.log = append(h.log, LogEntry{
Time: nowMillis(),
Level: level,
Message: message,
Detail: detail,
})
h.job.LogCount++
switch level {
case LevelWarn:
h.job.WarnCount++
case LevelError:
h.job.ErrorCount++
case LevelInfo:
}
h.mu.Unlock()
h.reg.touch()
}
// Complete marks the job finished successfully.
func (h *Handle) Complete() {
h.SetPhase("")
h.SetState(StateComplete)
}
// Cancelled marks the job as abandoned at the user's request.
func (h *Handle) Cancelled() {
h.SetPhase("")
h.SetState(StateCancelled)
}
// Fail marks the job as errored and records the message.
func (h *Handle) Fail(err error) {
h.mu.Lock()
h.job.Error = err.Error()
h.mu.Unlock()
h.Logf(LevelError, err.Error())
h.SetState(StateError)
}
func nowMillis() int64 {
return time.Now().UnixMilli()
}
+326
View File
@@ -0,0 +1,326 @@
package jobs
import (
"errors"
"log/slog"
"sync"
"testing"
)
var errBoom = errors.New("boom")
func testRegistry() *Registry {
return NewRegistry(slog.New(slog.DiscardHandler), nil)
}
func TestStartRegistersJob(t *testing.T) {
t.Parallel()
r := testRegistry()
h := r.Start(Spec{
ID: "scan:1",
Kind: KindLibraryScan,
Title: "Scanning Music",
Caps: Caps{Pausable: true, Cancellable: true},
})
if h == nil {
t.Fatal("expected a handle")
}
snap := r.Snapshot()
if len(snap) != 1 {
t.Fatalf("expected 1 job, got %d", len(snap))
}
if snap[0].State != StateRunning {
t.Errorf("expected default state running, got %q", snap[0].State)
}
if snap[0].Title != "Scanning Music" {
t.Errorf("unexpected title %q", snap[0].Title)
}
}
func TestStartReusesHandleAndLog(t *testing.T) {
t.Parallel()
r := testRegistry()
h := r.Start(Spec{ID: "scan:1", Kind: KindLibraryScan, State: StateQueued})
h.Logf(LevelInfo, "queued")
// A queued scan being popped off the queue re-registers the same ID.
again := r.Start(Spec{ID: "scan:1", Kind: KindLibraryScan})
if again != h {
t.Fatal("expected the same handle to be reused")
}
if got := len(r.Logs("scan:1")); got != 1 {
t.Errorf("expected the log to survive re-registration, got %d entries", got)
}
if again.State() != StateRunning {
t.Errorf("expected state running after restart, got %q", again.State())
}
if len(r.Snapshot()) != 1 {
t.Error("re-registering should not duplicate the job")
}
}
func TestLogRingIsBounded(t *testing.T) {
t.Parallel()
r := testRegistry()
h := r.Start(Spec{ID: "scan:1", Kind: KindLibraryScan})
for range maxLogEntries + 50 {
h.Logf(LevelWarn, "warning")
}
logs := r.Logs("scan:1")
if len(logs) != maxLogEntries {
t.Errorf("expected log capped at %d, got %d", maxLogEntries, len(logs))
}
// LogCount keeps counting past the ring so the UI can show that
// entries were dropped.
if got := h.Snapshot().LogCount; got != maxLogEntries+50 {
t.Errorf("expected LogCount %d, got %d", maxLogEntries+50, got)
}
if got := h.Snapshot().WarnCount; got != maxLogEntries+50 {
t.Errorf("expected WarnCount %d, got %d", maxLogEntries+50, got)
}
}
func TestTerminalStatesStampEndedAt(t *testing.T) {
t.Parallel()
tests := []struct {
name string
apply func(*Handle)
want State
}{
{"complete", func(h *Handle) { h.Complete() }, StateComplete},
{"cancelled", func(h *Handle) { h.Cancelled() }, StateCancelled},
{"failed", func(h *Handle) { h.Fail(errBoom) }, StateError},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
r := testRegistry()
h := r.Start(Spec{ID: "scan:1", Kind: KindLibraryScan})
tt.apply(h)
snap := h.Snapshot()
if snap.State != tt.want {
t.Errorf("expected state %q, got %q", tt.want, snap.State)
}
if !snap.State.IsTerminal() {
t.Error("expected a terminal state")
}
if snap.EndedAt == 0 {
t.Error("expected EndedAt to be stamped")
}
})
}
}
func TestFailRecordsErrorMessage(t *testing.T) {
t.Parallel()
r := testRegistry()
h := r.Start(Spec{ID: "index:build", Kind: KindIndexBuild})
h.Fail(errBoom)
if got := h.Snapshot().Error; got != "boom" {
t.Errorf("expected error %q, got %q", "boom", got)
}
if got := h.Snapshot().ErrorCount; got != 1 {
t.Errorf("expected the failure to be logged, got ErrorCount %d", got)
}
}
func TestPauseInvokesControlAndMarksPausing(t *testing.T) {
t.Parallel()
r := testRegistry()
var wg sync.WaitGroup
wg.Add(1)
r.Start(Spec{
ID: "scan:1",
Kind: KindLibraryScan,
Caps: Caps{Pausable: true},
Controls: Controls{Pause: wg.Done},
})
r.Pause("scan:1")
wg.Wait()
// The producer confirms StatePaused; the registry only promises
// the intermediate "pausing" state.
if got := r.Get("scan:1").State(); got != StatePausing {
t.Errorf("expected state pausing, got %q", got)
}
}
func TestControlsRespectCapabilities(t *testing.T) {
t.Parallel()
r := testRegistry()
called := false
r.Start(Spec{
ID: "scan:1",
Kind: KindLibraryScan,
Caps: Caps{Pausable: false, Cancellable: false},
Controls: Controls{Pause: func() { called = true }, Cancel: func() { called = true }},
})
r.Pause("scan:1")
r.Cancel("scan:1")
if called {
t.Error("controls must not fire for a job that declares no capability")
}
if got := r.Get("scan:1").State(); got != StateRunning {
t.Errorf("expected state to be unchanged, got %q", got)
}
}
func TestControlsIgnoreTerminalJobs(t *testing.T) {
t.Parallel()
r := testRegistry()
called := false
h := r.Start(Spec{
ID: "scan:1",
Kind: KindLibraryScan,
Caps: Caps{Pausable: true, Cancellable: true},
Controls: Controls{Pause: func() { called = true }, Cancel: func() { called = true }},
})
h.Complete()
r.Pause("scan:1")
r.Cancel("scan:1")
if called {
t.Error("controls must not fire for a finished job")
}
}
func TestControlsOnUnknownJobAreNoOps(t *testing.T) {
t.Parallel()
r := testRegistry()
// Stale IDs arrive from a frontend holding an old snapshot.
r.Pause("scan:404")
r.Resume("scan:404")
r.Cancel("scan:404")
if got := len(r.Logs("scan:404")); got != 0 {
t.Errorf("expected no logs for an unknown job, got %d", got)
}
}
func TestClearFinishedKeepsActiveJobs(t *testing.T) {
t.Parallel()
r := testRegistry()
done := r.Start(Spec{ID: "scan:1", Kind: KindLibraryScan})
done.Complete()
r.Start(Spec{ID: "scan:2", Kind: KindLibraryScan})
r.ClearFinished()
snap := r.Snapshot()
if len(snap) != 1 {
t.Fatalf("expected 1 job to survive, got %d", len(snap))
}
if snap[0].ID != "scan:2" {
t.Errorf("expected the active job to survive, kept %q", snap[0].ID)
}
}
func TestHasActive(t *testing.T) {
t.Parallel()
r := testRegistry()
if r.HasActive() {
t.Error("an empty registry has no active jobs")
}
h := r.Start(Spec{ID: "scan:1", Kind: KindLibraryScan})
if !r.HasActive() {
t.Error("expected the running job to count as active")
}
h.Complete()
if r.HasActive() {
t.Error("a completed job is not active")
}
}
func TestSnapshotIsADeepCopy(t *testing.T) {
t.Parallel()
r := testRegistry()
h := r.Start(Spec{ID: "index:build", Kind: KindIndexBuild})
h.SetStages([]Stage{{Name: "Catalog Import", State: "running"}})
snap := h.Snapshot()
snap.Stages[0].State = "mutated"
if got := h.Snapshot().Stages[0].State; got != "running" {
t.Errorf("mutating a snapshot leaked into the job: %q", got)
}
}
func TestConcurrentUpdatesAreSafe(t *testing.T) {
t.Parallel()
r := testRegistry()
h := r.Start(Spec{ID: "scan:1", Kind: KindLibraryScan})
var wg sync.WaitGroup
for i := range 8 {
wg.Add(1)
go func(n int) {
defer wg.Done()
for range 100 {
h.SetProgress(int64(n), 100)
h.Logf(LevelWarn, "concurrent")
_ = r.Snapshot()
}
}(i)
}
wg.Wait()
if got := h.Snapshot().WarnCount; got != 800 {
t.Errorf("expected 800 warnings, got %d", got)
}
}
+50
View File
@@ -0,0 +1,50 @@
package jobs
// Service is the Wails-bound facade over the registry. It deliberately
// exposes only the read and control surface — producers get a *Handle
// through the registry instead, so the frontend cannot invent jobs.
type Service struct {
reg *Registry
}
// NewService wraps a registry for frontend binding.
func NewService(reg *Registry) *Service {
return &Service{reg: reg}
}
// GetJobs returns every known job — active first by registration order,
// including recently finished ones so the panel can show outcomes.
func (s *Service) GetJobs() []Job {
return s.reg.Snapshot()
}
// GetJobLog returns the retained log tail for one job.
func (s *Service) GetJobLog(id string) []LogEntry {
return s.reg.Logs(id)
}
// PauseJob asks the owning subsystem to pause a job. Returns
// immediately; the job reports "paused" once it actually stops.
func (s *Service) PauseJob(id string) {
s.reg.Pause(id)
}
// ResumeJob continues a paused job.
func (s *Service) ResumeJob(id string) {
s.reg.Resume(id)
}
// CancelJob abandons a job.
func (s *Service) CancelJob(id string) {
s.reg.Cancel(id)
}
// DismissJob removes a single finished job from the list.
func (s *Service) DismissJob(id string) {
s.reg.Remove(id)
}
// ClearFinishedJobs removes every terminal job from the list.
func (s *Service) ClearFinishedJobs() {
s.reg.ClearFinished()
}
+123
View File
@@ -0,0 +1,123 @@
package jobs
import (
"log/slog"
"time"
"yellowjacket/backend/database"
)
// Persisted is a job whose paused state outlived the process that
// created it. The owning subsystem adopts these back into the registry
// during startup, re-attaching the controls needed to resume.
type Persisted struct {
ID string `json:"id"`
Kind Kind `json:"kind"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
}
// Store persists durable job state to the job_state table.
type Store struct {
db *database.DB
logger *slog.Logger
}
// NewStore creates a job state store backed by the application database.
func NewStore(db *database.DB, logger *slog.Logger) *Store {
return &Store{db: db, logger: logger}
}
// SetPaused records that a job is paused.
func (s *Store) SetPaused(p Persisted) {
if s == nil || s.db == nil {
return
}
if _, err := s.db.ExecContext(
`INSERT OR REPLACE INTO job_state`+
` (id, kind, title, subtitle, paused_at)`+
` VALUES (?, ?, ?, ?, ?)`,
p.ID, string(p.Kind), p.Title, p.Subtitle,
time.Now().UTC().Format(time.RFC3339),
); err != nil {
s.logger.Warn("jobs: could not persist paused job",
"id", p.ID, "err", err)
}
}
// ClearPaused removes a job's durable pause record.
func (s *Store) ClearPaused(id string) {
if s == nil || s.db == nil {
return
}
if _, err := s.db.ExecContext(
"DELETE FROM job_state WHERE id = ?", id,
); err != nil {
s.logger.Warn("jobs: could not clear paused job",
"id", id, "err", err)
}
}
// PausedEntries returns every persisted paused job of the given kind.
func (s *Store) PausedEntries(kind Kind) []Persisted {
if s == nil || s.db == nil {
return nil
}
rows, err := s.db.QueryContext(
`SELECT id, kind, title, subtitle FROM job_state`+
` WHERE kind = ? ORDER BY paused_at`,
string(kind),
)
if err != nil {
s.logger.Warn("jobs: could not read paused jobs",
"kind", kind, "err", err)
return nil
}
defer func() { _ = rows.Close() }()
var out []Persisted
for rows.Next() {
var (
p Persisted
kindText string
)
if scanErr := rows.Scan(
&p.ID, &kindText, &p.Title, &p.Subtitle,
); scanErr != nil {
s.logger.Warn("jobs: could not scan paused job", "err", scanErr)
continue
}
p.Kind = Kind(kindText)
out = append(out, p)
}
return out
}
// IsPaused reports whether the given job ID has a durable pause record.
// Subsystems check this before auto-starting work at launch.
func (s *Store) IsPaused(id string) bool {
if s == nil || s.db == nil {
return false
}
rows, err := s.db.QueryContext(
"SELECT 1 FROM job_state WHERE id = ?", id,
)
if err != nil {
return false
}
defer func() { _ = rows.Close() }()
return rows.Next()
}
+42 -20
View File
@@ -23,6 +23,7 @@ import (
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/events"
"yellowjacket/backend/jobs"
"yellowjacket/backend/metadata"
"yellowjacket/backend/system"
)
@@ -106,6 +107,10 @@ type Library struct {
scanPaused bool
scanPauseCh chan struct{}
// jobs is the background job registry. Nil in tests that do not
// exercise progress reporting; every use site must nil-check.
jobs *jobs.Registry
// Scan queue fields — protected by mu.
scanQueue []scanQueueEntry
currentScanLibraryID int64
@@ -218,6 +223,25 @@ func (l *Library) scanInternal(
metrics.LibraryName = libraryName
scanStart := time.Now()
// Register the background job before any work starts so the UI
// indicator appears immediately, even during the pre-walk count.
jobHandle := l.startScanJob(scanQueueEntry{
libraryID: libraryID,
libraryName: libraryName,
libraryPath: libraryPath,
})
// Stream non-fatal issues into the job log as they happen rather
// than dumping them all at completion — the point of the log pane
// is to answer "what is it doing right now".
metrics.onWarning = func(w ScanWarning) {
if jobHandle == nil {
return
}
jobHandle.LogDetail(jobs.LevelWarn, w.Phase+": "+w.Err, w.FilePath)
}
scanCtx, scanCancel := context.WithCancel(l.ctx)
defer scanCancel()
@@ -281,6 +305,14 @@ func (l *Library) scanInternal(
}
}
// emitProgress publishes one progress update to both consumers: the
// legacy LibraryScanProgress event and the shared job registry.
// Routing everything through here keeps the two from drifting.
emitProgress := func(p ScanProgress) {
runtime.EventsEmit(l.ctx, events.LibraryScanProgress, p)
reportScanProgress(jobHandle, p)
}
runtime.EventsEmit(l.ctx, events.LibraryScanStarted, map[string]any{
"libraryId": libraryID,
"libraryName": libraryName,
@@ -289,9 +321,7 @@ func (l *Library) scanInternal(
basePath := libraryPath
// --- Pre-walk: count audio files for progress reporting ---
runtime.EventsEmit(l.ctx, events.LibraryScanProgress,
mkProgress("counting", 0, 0, 0, 0, 0),
)
emitProgress(mkProgress("counting", 0, 0, 0, 0, 0))
totalFiles := countAudioFiles(basePath)
@@ -488,14 +518,10 @@ func (l *Library) scanInternal(
s := skipped.Load()
u := updated.Load()
runtime.EventsEmit(
l.ctx,
events.LibraryScanProgress,
mkProgress(
"scanning", totalFiles,
a+s+u, a, s, u,
),
)
emitProgress(mkProgress(
"scanning", totalFiles,
a+s+u, a, s, u,
))
case <-stopProgress:
return
}
@@ -618,18 +644,14 @@ func (l *Library) scanInternal(
s := skipped.Load()
u := updated.Load()
runtime.EventsEmit(l.ctx, events.LibraryScanProgress,
mkProgress("scanning", totalFiles, a+s+u, a, s, u),
)
emitProgress(mkProgress("scanning", totalFiles, a+s+u, a, s, u))
// Close thumbnail channel and wait for all thumbnail workers
// to finish. The DB writer has stopped sending work at this
// point so it is safe to close.
thumbStart := time.Now()
runtime.EventsEmit(l.ctx, events.LibraryScanProgress,
mkProgress("thumbnails", totalFiles, a+s+u, a, s, u),
)
emitProgress(mkProgress("thumbnails", totalFiles, a+s+u, a, s, u))
close(thumbChan)
thumbWg.Wait()
@@ -648,9 +670,7 @@ func (l *Library) scanInternal(
l.logger.Info("scan cancelled, skipping orphan cleanup")
} else {
// --- Phase 5: orphan cleanup ---
runtime.EventsEmit(l.ctx, events.LibraryScanProgress,
mkProgress("orphans", totalFiles, a+s+u, a, s, u),
)
emitProgress(mkProgress("orphans", totalFiles, a+s+u, a, s, u))
orphanStart := time.Now()
@@ -753,6 +773,8 @@ func (l *Library) scanInternal(
"total", metrics.Total,
)
finishScanJob(jobHandle, metrics, cancelled)
if cancelled {
runtime.EventsEmit(
l.ctx, events.LibraryScanCancelled, metrics,
+73 -5
View File
@@ -1,6 +1,8 @@
package library
import (
"strconv"
"strings"
"sync"
"time"
)
@@ -59,6 +61,11 @@ type ScanMetrics struct {
// Non-fatal issues encountered during scanning.
Warnings []ScanWarning `json:"warnings"`
// onWarning, when set, receives each warning as it is recorded so
// the scan's job log can show problems live instead of only in the
// completion payload. Invoked outside the metrics lock.
onWarning func(ScanWarning) `json:"-"`
}
// ScanProgress is the payload emitted periodically during a scan to
@@ -82,6 +89,59 @@ type ScanWarning struct {
Err string `json:"err"`
}
// timingBreakdown renders the full per-phase timing profile as plain
// text for the job log. This replaces the metrics table that used to
// live on the settings page: same numbers, but attached to the scan
// that produced them and copyable from the job's output pane.
func (m *ScanMetrics) timingBreakdown() string {
var b strings.Builder
line := func(label string, d time.Duration) {
b.WriteString(label)
b.WriteString(": ")
b.WriteString(d.Round(time.Millisecond).String())
b.WriteString("\n")
}
line("Total", m.Total)
// Full-rescan-only phases; zero on an incremental scan.
if m.ClearQueue > 0 || m.ClearDatabase > 0 || m.ClearCoverFiles > 0 {
line(" Clear queue", m.ClearQueue)
line(" Clear database", m.ClearDatabase)
line(" Clear cover files", m.ClearCoverFiles)
}
line(" Load existing files", m.LoadExisting)
line(" Directory walk", m.WalkDuration)
line(" Metadata extraction (wall clock)", m.ExtractionWallClock)
line(" Tag extraction (cumulative)", m.TagExtraction)
line(" Duration extraction (cumulative)", m.DurationExtraction)
for format, ms := range m.FormatExtraction {
b.WriteString(" ")
b.WriteString(format)
b.WriteString(" (")
b.WriteString(strconv.FormatInt(m.FormatCount[format], 10))
b.WriteString(" files): ")
b.WriteString((time.Duration(ms) * time.Millisecond).String())
b.WriteString("\n")
}
line(" DB writes (wall clock)", m.DBWritesWallClock)
line(" Batch commits", m.BatchCommits)
line(" Save cover originals", m.CoverArtSave)
line(" Thumbnails (wall clock)", m.ThumbnailWallClock)
line(" Cumulative CPU time", m.ThumbnailGeneration)
line(" Small", m.ThumbnailSmall)
line(" Medium", m.ThumbnailMedium)
line(" Large", m.ThumbnailLarge)
line(" Orphan cleanup", m.OrphanCleanup)
line(" Post-scan variants", m.PostScanVariants)
return b.String()
}
func newScanMetrics() *ScanMetrics {
return &ScanMetrics{
FormatExtraction: make(map[string]int64),
@@ -113,14 +173,22 @@ func (m *ScanMetrics) addCoverArtSave(d time.Duration) {
// addWarning records a non-fatal scan issue. Safe for concurrent use.
func (m *ScanMetrics) addWarning(filePath, phase string, err error) {
m.mu.Lock()
defer m.mu.Unlock()
m.Warnings = append(m.Warnings, ScanWarning{
warning := ScanWarning{
FilePath: filePath,
Phase: phase,
Err: err.Error(),
})
}
m.mu.Lock()
m.Warnings = append(m.Warnings, warning)
notify := m.onWarning
m.mu.Unlock()
// Called outside the lock: the job registry takes its own locks and
// must never be able to deadlock against a scan worker.
if notify != nil {
notify(warning)
}
}
// addThumbnailTier records the time spent generating a single
+38 -2
View File
@@ -6,6 +6,7 @@ import (
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/events"
"yellowjacket/backend/jobs"
)
// CancelScan cancels an in-progress scan. Returns immediately;
@@ -27,31 +28,66 @@ func (l *Library) CancelScan() {
// next pause checkpoint until ResumeScan is called.
func (l *Library) PauseScan() {
l.mu.Lock()
defer l.mu.Unlock()
if !l.scanActive || l.scanPaused {
l.mu.Unlock()
return
}
l.scanPaused = true
l.scanPauseCh = make(chan struct{})
pausedID := l.currentScanLibraryID
reg := l.jobs
l.mu.Unlock()
runtime.EventsEmit(l.ctx, events.LibraryScanPaused)
// Confirm the pause on the job — the registry moved it to "pausing"
// when the request came in. Writing the durable pause record is a
// side effect of reaching StatePaused, so a scan paused now comes
// back paused after a restart. Done after releasing l.mu: this
// writes to the database, and workers take l.mu on every pause
// checkpoint.
if reg == nil {
return
}
if h := reg.Get(scanJobID(pausedID)); h != nil {
h.SetState(jobs.StatePaused)
h.SetPhase("Paused")
h.Logf(jobs.LevelInfo, "Scan paused")
}
}
// ResumeScan unblocks a paused scan.
func (l *Library) ResumeScan() {
l.mu.Lock()
defer l.mu.Unlock()
if !l.scanPaused {
l.mu.Unlock()
return
}
l.scanPaused = false
close(l.scanPauseCh) // unblocks all waiting workers
resumedID := l.currentScanLibraryID
reg := l.jobs
l.mu.Unlock()
runtime.EventsEmit(l.ctx, events.LibraryScanResumed)
// Clears the durable pause record as a side effect of leaving
// StatePaused.
if reg == nil {
return
}
if h := reg.Get(scanJobID(resumedID)); h != nil {
h.SetState(jobs.StateRunning)
h.Logf(jobs.LevelInfo, "Scan resumed")
}
}
// IsScanActive returns whether a scan is currently running.
+348
View File
@@ -0,0 +1,348 @@
package library
import (
"strconv"
"time"
"yellowjacket/backend/jobs"
)
// scanJobPrefix namespaces library scan jobs in the shared registry.
const scanJobPrefix = "scan:"
// scanPhaseLabels maps internal scan phase identifiers to the labels
// shown in the jobs UI.
var scanPhaseLabels = map[string]string{
"counting": "Counting files",
"scanning": "Reading metadata",
"thumbnails": "Generating thumbnails",
"orphans": "Cleaning up removed files",
}
// SetJobRegistry wires the background job registry so scans report
// progress, logs, and pause/cancel controls to the frontend.
func (l *Library) SetJobRegistry(reg *jobs.Registry) {
l.mu.Lock()
l.jobs = reg
l.mu.Unlock()
}
// jobRegistry returns the registry, or nil when none is wired.
func (l *Library) jobRegistry() *jobs.Registry {
l.mu.Lock()
defer l.mu.Unlock()
return l.jobs
}
// scanJobID returns the stable registry ID for a library's scan job.
// It is stable across restarts so a durable pause can be matched back
// to the library it belongs to.
func scanJobID(libraryID int64) string {
return scanJobPrefix + strconv.FormatInt(libraryID, 10)
}
// libraryIDFromJobID parses a library ID back out of a scan job ID.
func libraryIDFromJobID(id string) (int64, bool) {
if len(id) <= len(scanJobPrefix) || id[:len(scanJobPrefix)] != scanJobPrefix {
return 0, false
}
parsed, err := strconv.ParseInt(id[len(scanJobPrefix):], 10, 64)
if err != nil {
return 0, false
}
return parsed, true
}
// scanJobSpec builds the registry spec for a library scan. Controls are
// bound to the library ID rather than "the current scan" so that a
// control arriving for a queued or stale job cannot disturb a different
// library's scan.
func (l *Library) scanJobSpec(
entry scanQueueEntry,
state jobs.State,
) jobs.Spec {
return jobs.Spec{
ID: scanJobID(entry.libraryID),
Kind: jobs.KindLibraryScan,
Title: "Scanning " + entry.libraryName,
Subtitle: entry.libraryPath,
State: state,
Caps: jobs.Caps{
Pausable: true,
Cancellable: true,
},
Durable: true,
Controls: jobs.Controls{
Pause: func() { l.pauseScanForLibrary(entry.libraryID) },
Resume: func() { l.resumeScanForLibrary(entry) },
Cancel: func() { l.cancelScanForLibrary(entry.libraryID) },
},
}
}
// registerQueuedScanJob records a library that is waiting behind another
// scan, so the user can see the whole pipeline rather than just the head.
func (l *Library) registerQueuedScanJob(entry scanQueueEntry) {
reg := l.jobRegistry()
if reg == nil {
return
}
h := reg.Start(l.scanJobSpec(entry, jobs.StateQueued))
h.SetPhase("Waiting for other scans")
h.Logf(jobs.LevelInfo, "Queued behind an in-progress scan")
}
// startScanJob registers (or re-registers, for a queued job now starting)
// the running job for a scan and returns its handle.
func (l *Library) startScanJob(entry scanQueueEntry) *jobs.Handle {
reg := l.jobRegistry()
if reg == nil {
return nil
}
h := reg.Start(l.scanJobSpec(entry, jobs.StateRunning))
h.SetProgress(0, 0)
h.Logf(jobs.LevelInfo, "Scan started for "+entry.libraryPath)
return h
}
// reportScanProgress mirrors a ScanProgress payload into the job
// registry. Called from the same places that emit LibraryScanProgress
// so the two views never disagree.
func reportScanProgress(h *jobs.Handle, p ScanProgress) {
if h == nil {
return
}
label, ok := scanPhaseLabels[p.Phase]
if !ok {
label = p.Phase
}
h.SetPhase(label)
// The counting pre-walk has no denominator to report against, so
// leave the job indeterminate until the total is known.
if p.Phase == "counting" {
h.SetProgress(0, 0)
} else {
h.SetProgress(p.Processed, p.Total)
}
h.SetStats([]jobs.Stat{
{Label: "Added", Value: strconv.FormatInt(p.Added, 10)},
{Label: "Updated", Value: strconv.FormatInt(p.Updated, 10)},
{Label: "Skipped", Value: strconv.FormatInt(p.Skipped, 10)},
})
}
// finishScanJob applies the terminal state and summary for a completed,
// cancelled, or paused-then-abandoned scan.
func finishScanJob(h *jobs.Handle, m *ScanMetrics, cancelled bool) {
if h == nil {
return
}
h.SetStats([]jobs.Stat{
{Label: "Added", Value: strconv.FormatInt(m.Added, 10)},
{Label: "Updated", Value: strconv.FormatInt(m.Updated, 10)},
{Label: "Skipped", Value: strconv.FormatInt(m.Skipped, 10)},
{Label: "Removed", Value: strconv.FormatInt(m.Removed, 10)},
{Label: "Duration", Value: m.Total.Round(time.Millisecond).String()},
{Label: "Walk", Value: m.WalkDuration.Round(time.Millisecond).String()},
{Label: "Metadata", Value: m.ExtractionWallClock.Round(time.Millisecond).String()},
{Label: "DB writes", Value: m.DBWritesWallClock.Round(time.Millisecond).String()},
{Label: "Thumbnails", Value: m.ThumbnailWallClock.Round(time.Millisecond).String()},
})
// The full timing breakdown goes into the log rather than the stats
// grid: it is a profiling aid, wanted rarely and in full, and the
// log pane already has a copy-to-clipboard button.
h.LogDetail(jobs.LevelInfo, "Timing breakdown", m.timingBreakdown())
if cancelled {
h.Logf(jobs.LevelInfo, "Scan cancelled")
h.Cancelled()
return
}
h.Logf(jobs.LevelInfo,
"Scan complete — "+
strconv.FormatInt(m.Added, 10)+" added, "+
strconv.FormatInt(m.Updated, 10)+" updated, "+
strconv.FormatInt(m.Removed, 10)+" removed",
)
h.Complete()
}
// pauseScanForLibrary pauses the running scan, but only when the library
// asking is the one currently being scanned.
func (l *Library) pauseScanForLibrary(libraryID int64) {
l.mu.Lock()
current := l.currentScanLibraryID
l.mu.Unlock()
if current != libraryID {
return
}
l.PauseScan()
}
// cancelScanForLibrary cancels a scan for one library. A queued library
// is dropped from the queue; the running one is cancelled outright.
func (l *Library) cancelScanForLibrary(libraryID int64) {
l.mu.Lock()
current := l.currentScanLibraryID
if current != libraryID {
// Not running — drop it from the queue if it is waiting there.
kept := l.scanQueue[:0]
for _, entry := range l.scanQueue {
if entry.libraryID != libraryID {
kept = append(kept, entry)
}
}
l.scanQueue = kept
l.mu.Unlock()
if reg := l.jobRegistry(); reg != nil {
if h := reg.Get(scanJobID(libraryID)); h != nil {
h.Cancelled()
}
}
return
}
cancel := l.scanCancel
paused := l.scanPaused
pauseCh := l.scanPauseCh
// Unblock paused workers so they observe the cancelled context
// instead of sitting on the pause channel forever.
if paused {
l.scanPaused = false
if pauseCh != nil {
close(pauseCh)
}
l.scanPauseCh = nil
}
l.mu.Unlock()
if cancel != nil {
cancel()
return
}
// No live scan goroutine — this is a scan that was paused before a
// restart and never resumed. Retire the adopted job directly.
if reg := l.jobRegistry(); reg != nil {
if h := reg.Get(scanJobID(libraryID)); h != nil {
h.Cancelled()
}
}
}
// resumeScanForLibrary continues a paused scan. When the scan goroutine
// is still alive it is simply unblocked. When the pause outlived the
// process, a fresh scan is queued instead: scans are incremental, so
// already-imported files are skipped on the second pass and the effect
// is a resume rather than a restart.
func (l *Library) resumeScanForLibrary(entry scanQueueEntry) {
l.mu.Lock()
live := l.scanActive && l.currentScanLibraryID == entry.libraryID
l.mu.Unlock()
if live {
l.ResumeScan()
return
}
if reg := l.jobRegistry(); reg != nil {
if h := reg.Get(scanJobID(entry.libraryID)); h != nil {
h.Logf(jobs.LevelInfo,
"Resuming from a previous session — already-imported "+
"files are skipped")
}
}
// Clear the durable pause record before restarting, otherwise
// RestorePausedScans would adopt it again on the next launch.
if reg := l.jobRegistry(); reg != nil {
reg.Remove(scanJobID(entry.libraryID))
}
if err := l.ScanLibrary(entry.libraryID); err != nil {
l.logger.Warn("could not resume scan",
"libraryID", entry.libraryID, "err", err)
}
}
// RestorePausedScans adopts scans that were paused when the app last
// shut down back into the job registry, still paused. Call during
// startup before SoftScanAllLibraries so the soft scan does not restart
// a library the user deliberately paused.
func (l *Library) RestorePausedScans() {
reg := l.jobRegistry()
if reg == nil {
return
}
for _, p := range reg.PausedEntries(jobs.KindLibraryScan) {
libraryID, ok := libraryIDFromJobID(p.ID)
if !ok {
reg.Remove(p.ID)
continue
}
lib, err := l.db.Queries.GetLibrary(l.ctx, libraryID)
if err != nil {
// The library was removed while the scan was paused.
l.logger.Info("dropping paused scan for missing library",
"libraryID", libraryID)
reg.Remove(p.ID)
continue
}
entry := scanQueueEntry{
libraryID: lib.ID,
libraryName: lib.Name,
libraryPath: lib.Path,
}
h := reg.Start(l.scanJobSpec(entry, jobs.StatePaused))
h.SetPhase("Paused")
h.Logf(jobs.LevelInfo, "Paused in a previous session — resume to continue")
l.logger.Info("restored paused library scan",
"libraryID", lib.ID, "libraryName", lib.Name)
}
}
// isScanPausedPersistently reports whether a library's scan was left
// paused by a previous session.
func (l *Library) isScanPausedPersistently(libraryID int64) bool {
reg := l.jobRegistry()
if reg == nil {
return false
}
return reg.IsPersistentlyPaused(scanJobID(libraryID))
}
+26 -2
View File
@@ -26,16 +26,19 @@ func (l *Library) ScanLibrary(id int64) error {
}
l.mu.Lock()
defer l.mu.Unlock()
// Silent dedup: already scanning this library.
if l.currentScanLibraryID == id {
l.mu.Unlock()
return nil
}
// Silent dedup: already queued.
for _, entry := range l.scanQueue {
if entry.libraryID == id {
l.mu.Unlock()
return nil
}
}
@@ -50,6 +53,7 @@ func (l *Library) ScanLibrary(id int64) error {
l.scanActive = true
l.currentScanLibraryID = entry.libraryID
l.currentScanLibraryName = entry.libraryName
l.mu.Unlock()
go l.startScan(entry)
@@ -58,13 +62,19 @@ func (l *Library) ScanLibrary(id int64) error {
// A scan is already running — queue this library.
l.scanQueue = append(l.scanQueue, entry)
queueLength := len(l.scanQueue)
l.mu.Unlock()
runtime.EventsEmit(l.ctx, events.LibraryScanQueued, map[string]any{
"libraryId": lib.ID,
"libraryName": lib.Name,
"queueLength": len(l.scanQueue),
"queueLength": queueLength,
})
// Registering the queued job takes l.mu again, so it must happen
// after the unlock above.
l.registerQueuedScanJob(entry)
return nil
}
@@ -127,6 +137,20 @@ func (l *Library) SoftScanAllLibraries() error {
}
for _, lib := range libs {
// A scan the user paused stays paused across restarts — the
// soft scan must not quietly start it again behind their back.
// RestorePausedScans has already surfaced it in the jobs panel
// with a resume button.
if l.isScanPausedPersistently(lib.ID) {
l.logger.Info(
"soft scan: library scan is paused, skipping",
"libraryID", lib.ID,
"libraryName", lib.Name,
)
continue
}
dbCount, countErr := l.db.Queries.CountAudioFilesByLibrary(
l.ctx, lib.ID,
)
+24 -6
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"os/user"
"path/filepath"
"runtime"
)
@@ -22,14 +23,15 @@ const (
dirTypeData dirType = "data"
)
// envHomeOverride is the environment variable that, when set, relocates
// all YellowJacket config and data under a single base directory. It
// exists so a development build can run against an isolated sandbox
// without touching the current user's real config.toml or yj.db.
const envHomeOverride = "YJ_HOME"
// getUserDirPath returns and creates the path for a user directory.
func getUserDirPath(dt dirType) (string, error) {
currentUser, err := user.Current()
if err != nil {
return "", fmt.Errorf("could not get current user: %w", err)
}
path, err := buildUserDirPath(currentUser.Username, dt)
path, err := resolveUserDirPath(dt)
if err != nil {
return "", err
}
@@ -50,6 +52,22 @@ func getUserDirPath(dt dirType) (string, error) {
return path, nil
}
// resolveUserDirPath picks the base path for a user directory. When
// YJ_HOME is set it wins for every OS, mapping to <YJ_HOME>/<dirType>;
// otherwise the standard OS-specific location is used.
func resolveUserDirPath(dt dirType) (string, error) {
if home := os.Getenv(envHomeOverride); home != "" {
return filepath.Join(home, string(dt)), nil
}
currentUser, err := user.Current()
if err != nil {
return "", fmt.Errorf("could not get current user: %w", err)
}
return buildUserDirPath(currentUser.Username, dt)
}
// buildUserDirPath constructs the OS-specific path for a user directory.
func buildUserDirPath(username string, dt dirType) (string, error) {
// Map directory types to their Unix subdirectory paths
+52
View File
@@ -0,0 +1,52 @@
package system
import (
"path/filepath"
"strings"
"testing"
)
func TestResolveUserDirPath_HomeOverride(t *testing.T) {
home := t.TempDir()
t.Setenv(envHomeOverride, home)
tests := []struct {
name string
dt dirType
want string
}{
{name: "config", dt: dirTypeConfig, want: filepath.Join(home, "config")},
{name: "data", dt: dirTypeData, want: filepath.Join(home, "data")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveUserDirPath(tt.dt)
if err != nil {
t.Fatalf("resolveUserDirPath(%q) returned error: %v", tt.dt, err)
}
if got != tt.want {
t.Errorf("resolveUserDirPath(%q) = %q, want %q", tt.dt, got, tt.want)
}
})
}
}
func TestResolveUserDirPath_NoOverrideUsesOSPath(t *testing.T) {
t.Setenv(envHomeOverride, "")
got, err := resolveUserDirPath(dirTypeConfig)
if err != nil {
t.Fatalf("resolveUserDirPath returned error: %v", err)
}
// Without the override the path must fall back to the OS-specific
// yellowjacket location, not a bare "<home>/config" base dir.
if !filepath.IsAbs(got) || !strings.HasSuffix(got, "yellowjacket") {
t.Errorf(
"resolveUserDirPath fallback = %q, want absolute path ending in %q",
got, "yellowjacket",
)
}
}
+103
View File
@@ -0,0 +1,103 @@
package main
import (
"testing"
"time"
)
// stubState lets the decision table run without a database. It mirrors
// the three pieces of index state `decide` consults.
type stubState struct {
complete bool
last time.Time
}
func (s stubState) IndexImportComplete() bool { return s.complete }
func (s stubState) IndexLastImported() time.Time { return s.last }
func TestDecide(t *testing.T) {
t.Parallel()
const (
rebuildAfter = 90 * 24 * time.Hour
refreshAfter = 7 * 24 * time.Hour
)
tests := []struct {
name string
mode mode
state stubState
want mode
}{
{
name: "first run with no import builds",
mode: modeAuto,
state: stubState{complete: false},
want: modeBuild,
},
{
name: "partial import resumes as a build",
mode: modeAuto,
state: stubState{complete: false, last: time.Now().Add(-time.Hour)},
want: modeBuild,
},
{
name: "recent import refreshes",
mode: modeAuto,
state: stubState{complete: true, last: time.Now().Add(-24 * time.Hour)},
want: modeRefresh,
},
{
name: "import just under the rebuild age still refreshes",
mode: modeAuto,
state: stubState{complete: true, last: time.Now().Add(-89 * 24 * time.Hour)},
want: modeRefresh,
},
{
name: "import past the rebuild age rebuilds",
mode: modeAuto,
state: stubState{complete: true, last: time.Now().Add(-91 * 24 * time.Hour)},
want: modeRebuild,
},
{
name: "unreadable timestamp rebuilds rather than wedging",
mode: modeAuto,
state: stubState{complete: true},
want: modeRebuild,
},
{
name: "explicit mode overrides state",
mode: modeRefresh,
state: stubState{complete: false},
want: modeRefresh,
},
{
name: "explicit rebuild overrides a fresh import",
mode: modeRebuild,
state: stubState{complete: true, last: time.Now()},
want: modeRebuild,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, why := decideFrom(
opts{
mode: tt.mode,
rebuildAfter: rebuildAfter,
refreshAfter: refreshAfter,
},
tt.state,
)
if got != tt.want {
t.Errorf("decide = %q (%s), want %q", got, why, tt.want)
}
if why == "" {
t.Error("expected a non-empty reason")
}
})
}
}
+360
View File
@@ -0,0 +1,360 @@
// Command indexbuild maintains the explore search index outside the
// desktop app, so the catalog can be built once centrally instead of by
// every install.
//
// It decides what to do from the index's own state rather than needing
// the caller to know:
//
// no completed import → build (first run, or resume a partial one)
// import older than 3mo → rebuild (re-import from the newest dump)
// otherwise → refresh (fold in new incremental listens)
//
// A full build streams ~205GB from the ListenBrainz spark dump — far
// more than one CI job should attempt — so builds are budgeted and
// resumable: the importer checkpoints its absolute stream offset, and
// each run continues where the last stopped. A refresh is cheap
// (~250MB incremental dumps) and finishes in one run.
//
// Usage:
//
// YJ_HOME=/cache indexbuild -budget 3h
//
// Exit codes:
//
// 0 up to date — nothing left to do
// 3 build incomplete — schedule another run to resume
// 1 error
//
// When GITHUB_OUTPUT is set, `complete` and `changed` are appended to it
// so a workflow can decide whether to publish a new artifact.
package main
import (
"errors"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
"path/filepath"
"strconv"
"syscall"
"time"
"yellowjacket/backend/database"
"yellowjacket/backend/explore"
"yellowjacket/backend/system"
)
// exitIncomplete tells the caller the build made progress but has not
// finished, so another run should follow. Distinct from a failure: the
// checkpoint is valid and resuming is the correct action.
const exitIncomplete = 3
// mode is what this run decided to do.
type mode string
const (
modeAuto mode = "auto"
modeBuild mode = "build"
modeRefresh mode = "refresh"
modeRebuild mode = "rebuild"
)
// errNoHome is returned when YJ_HOME is unset. The default per-user data
// directory is deliberately not used: a build host should always write
// to an explicit, persistent location.
var errNoHome = errors.New(
"YJ_HOME must be set to a persistent directory on real disk",
)
// errIncomplete signals a clean stop with work remaining.
var errIncomplete = errors.New("build incomplete")
var errBadMode = errors.New("unknown mode")
type opts struct {
budget time.Duration
mode mode
rebuildAfter time.Duration
refreshAfter time.Duration
verbose bool
}
func main() {
var (
budget = flag.Duration("budget", 3*time.Hour,
"stop and checkpoint a build after this long (0 = no limit)")
modeFlag = flag.String("mode", string(modeAuto),
"auto | build | refresh | rebuild")
rebuildAfter = flag.Duration("rebuild-after", 90*24*time.Hour,
"re-import from a fresh dump once the last import is older than this")
refreshAfter = flag.Duration("refresh-after", 7*24*time.Hour,
"minimum gap between incremental refreshes (0 = always)")
verbose = flag.Bool("v", false, "debug logging")
)
flag.Parse()
err := run(opts{
budget: *budget,
mode: mode(*modeFlag),
rebuildAfter: *rebuildAfter,
refreshAfter: *refreshAfter,
verbose: *verbose,
})
if err != nil {
if errors.Is(err, errIncomplete) {
os.Exit(exitIncomplete)
}
fmt.Fprintln(os.Stderr, "indexbuild:", err)
os.Exit(1)
}
}
func run(o opts) error {
logger := newLogger(o.verbose)
if os.Getenv("YJ_HOME") == "" {
return errNoHome
}
dataDir, err := system.GetUserDataDirPath()
if err != nil {
return fmt.Errorf("resolve data dir: %w", err)
}
db, err := database.NewDB(logger)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
// NewExploreService is reused rather than reconstructing its
// dependency graph here, so the headless path cannot drift from what
// the app does. SetContext is never called: it installs the Wails
// runtime context, and emitting Wails events against a non-Wails
// context terminates the process.
svc := explore.NewExploreService(logger.WithGroup("explore"), db)
chosen, why := decide(o, svc)
logger.Info("index maintenance",
"mode", string(chosen),
"reason", why,
"dataDir", dataDir,
"staging", filepath.Join(dataDir, "explore-staging"),
"lastImported", stamp(svc.IndexLastImported()),
"baselineSeries", svc.IndexBaselineSeries(),
)
seriesBefore := svc.IndexBaselineSeries()
switch chosen {
case modeRefresh:
err = doRefresh(logger, svc, o.refreshAfter)
case modeRebuild:
svc.PrepareIndexRebuild()
err = doBuild(logger, svc, o.budget)
case modeBuild:
err = doBuild(logger, svc, o.budget)
case modeAuto:
return fmt.Errorf("%w: auto should have resolved", errBadMode)
default:
return fmt.Errorf("%w: %q", errBadMode, chosen)
}
complete := svc.IndexImportComplete() && !errors.Is(err, errIncomplete)
// "Changed" means there is something new worth publishing, so it is
// only ever true for a finished import: a build stamps the listens
// series early, long before its rows are assembled, and reporting a
// change off that would be a lie about a half-built index.
changed := complete &&
(svc.IndexBaselineSeries() != seriesBefore || chosen != modeRefresh)
report(logger, svc, chosen, complete, changed)
if writeErr := writeOutputs(complete, changed); writeErr != nil {
logger.Warn("could not write workflow outputs", "err", writeErr)
}
return err
}
func newLogger(verbose bool) *slog.Logger {
level := slog.LevelInfo
if verbose {
level = slog.LevelDebug
}
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
}))
}
// indexState is the slice of the index that the mode decision depends
// on. Narrowing it to an interface keeps the decision table testable
// without standing up a database.
type indexState interface {
IndexImportComplete() bool
IndexLastImported() time.Time
}
// decide picks the mode from the index's own state, so callers (a cron,
// a push hook, a human) need no knowledge of where the index stands.
func decide(o opts, svc *explore.Service) (mode, string) {
return decideFrom(o, svc)
}
func decideFrom(o opts, state indexState) (mode, string) {
if o.mode != modeAuto {
return o.mode, "explicitly requested"
}
if !state.IndexImportComplete() {
return modeBuild, "no completed import yet"
}
last := state.IndexLastImported()
if last.IsZero() {
// Marker present but unparseable — treat as due rather than
// letting a malformed timestamp wedge the rebuild cadence.
return modeRebuild, "import timestamp unreadable"
}
if age := time.Since(last); age >= o.rebuildAfter {
return modeRebuild, fmt.Sprintf("last import %s ago (>= %s)",
age.Round(time.Hour), o.rebuildAfter)
}
return modeRefresh, "import current, folding in new listens"
}
func doBuild(
logger *slog.Logger, svc *explore.Service, budget time.Duration,
) error {
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(stop)
var timer <-chan time.Time
if budget > 0 {
t := time.NewTimer(budget)
defer t.Stop()
timer = t.C
}
// finished is closed by this function alone, so the watchdog only
// reads it — no double close, and it exits whether the build ended
// on its own or was stopped.
finished := make(chan struct{})
go func() {
select {
case <-finished:
case sig := <-stop:
logger.Info("build: signal received, checkpointing",
"signal", sig.String())
svc.StopIndexBuild()
case <-timer:
logger.Info("build: budget reached, checkpointing")
svc.StopIndexBuild()
}
}()
start := time.Now()
svc.StartIndexBuild()
svc.WaitForIndexIdle()
close(finished)
logger.Info("build stopped", "elapsed", time.Since(start).Round(time.Second))
if !svc.IndexImportComplete() {
return errIncomplete
}
return nil
}
func doRefresh(
logger *slog.Logger, svc *explore.Service, minInterval time.Duration,
) error {
start := time.Now()
svc.RefreshIndexNow(minInterval)
logger.Info("refresh finished",
"elapsed", time.Since(start).Round(time.Second))
return nil
}
func report(
logger *slog.Logger, svc *explore.Service,
chosen mode, complete, changed bool,
) {
status := svc.GetIndexStatus()
logger.Info("index state",
"mode", string(chosen),
"complete", complete,
"changed", changed,
"artists", status.Artists,
"releaseGroups", status.ReleaseGroups,
"recordings", status.Recordings,
"totalRows", status.TotalRows,
"baselineSeries", svc.IndexBaselineSeries(),
)
for _, tier := range status.Tiers {
logger.Info(" stage",
"name", tier.Name,
"state", tier.State,
"completed", tier.Completed,
"total", tier.Total,
"error", tier.Error,
)
}
if !complete {
logger.Info("build incomplete — rerun to resume from checkpoint")
}
}
// writeOutputs appends step outputs when running under a workflow, so
// the caller can publish only when something actually changed.
func writeOutputs(complete, changed bool) error {
path := os.Getenv("GITHUB_OUTPUT")
if path == "" {
return nil
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("open outputs: %w", err)
}
defer func() { _ = f.Close() }()
_, err = fmt.Fprintf(f, "complete=%s\nchanged=%s\n",
strconv.FormatBool(complete), strconv.FormatBool(changed))
if err != nil {
return fmt.Errorf("write outputs: %w", err)
}
return nil
}
func stamp(t time.Time) string {
if t.IsZero() {
return "never"
}
return t.UTC().Format(time.RFC3339)
}
+321
View File
@@ -0,0 +1,321 @@
// Command indexexport turns a fully built explore index into the
// compact "core" artifact that ships to users.
//
// The full dump-built index is far too large to distribute (~900MB by
// current budget estimates). The core artifact keeps the most-listened
// artists and their discography slice — enough for Explore to be useful
// on a fresh install — and leaves the long tail to the existing lazy
// per-artist fetch paths.
//
// The artifact deliberately contains no FTS table. The importing client
// inserts these rows into its own explore_index, whose AFTER INSERT
// trigger populates explore_index_fts as a side effect, so shipping a
// search index would be redundant weight.
//
// Usage:
//
// YJ_HOME=/var/cache/yellowjacket-index indexexport -o core-index.db
package main
import (
"database/sql"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strconv"
"time"
_ "modernc.org/sqlite"
"yellowjacket/backend/system"
)
// Columns copied into the artifact: the global catalog only.
//
// Deliberately excluded are the per-user columns — in_library,
// is_similar, local_artist_id, local_release_group_id,
// local_recording_id — which describe one person's library and are
// recomputed locally by PopulateLocalCrossReferences after import.
const catalogColumns = `entity_type, mbid, title, artist_name, artist_mbid,
aliases, popularity, listener_count, duration, caa_release_mbid,
release_name, primary_type, secondary_types, release_date,
artist_type, country, disambiguation, sort_name, discog_fetched`
var errNoHome = errors.New(
"YJ_HOME must be set to the directory holding the built index",
)
var errEmptyIndex = errors.New(
"source index has no rows — run indexbuild to completion first",
)
func main() {
out := flag.String("o", "core-index.db", "output artifact path")
artists := flag.Int("artists", 50_000,
"number of top artists (by listen count) to include")
perArtistRGs := flag.Int("rgs-per-artist", 15,
"max release groups per included artist")
perArtistRecs := flag.Int("recs-per-artist", 30,
"max recordings per included artist")
flag.Parse()
if err := run(*out, *artists, *perArtistRGs, *perArtistRecs); err != nil {
fmt.Fprintln(os.Stderr, "indexexport:", err)
os.Exit(1)
}
}
func run(out string, artists, perArtistRGs, perArtistRecs int) error {
if os.Getenv("YJ_HOME") == "" {
return errNoHome
}
dataDir, err := system.GetUserDataDirPath()
if err != nil {
return fmt.Errorf("resolve data dir: %w", err)
}
srcPath := filepath.Join(dataDir, "yj.db")
// Read-only so an export can never disturb a build that is still
// running against the same working directory.
db, err := sql.Open("sqlite",
"file:"+srcPath+"?_pragma=busy_timeout(10000)&mode=ro")
if err != nil {
return fmt.Errorf("open source index: %w", err)
}
defer func() { _ = db.Close() }()
var srcRows int
if err := db.QueryRow(
"SELECT COUNT(*) FROM explore_index",
).Scan(&srcRows); err != nil {
return fmt.Errorf("count source rows: %w", err)
}
if srcRows == 0 {
return errEmptyIndex
}
fmt.Printf("source: %s (%d rows)\n", srcPath, srcRows)
if err := os.Remove(out); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("clear output: %w", err)
}
if _, err := db.Exec(`ATTACH DATABASE ? AS core`, out); err != nil {
return fmt.Errorf("attach output: %w", err)
}
if err := createSchema(db); err != nil {
return err
}
if err := copyRows(db, artists, perArtistRGs, perArtistRecs); err != nil {
return err
}
if err := stampMeta(db, srcRows); err != nil {
return err
}
// DETACH before VACUUM: sqlite cannot vacuum an attached database.
if _, err := db.Exec(`DETACH DATABASE core`); err != nil {
return fmt.Errorf("detach output: %w", err)
}
if err := vacuum(out); err != nil {
return err
}
return report(out)
}
// createSchema builds the artifact's tables. No FTS and no triggers —
// the importing client's own trigger rebuilds its FTS on insert.
func createSchema(db *sql.DB) error {
stmts := []string{
`CREATE TABLE core.explore_index (
entity_type TEXT NOT NULL,
mbid TEXT NOT NULL,
title TEXT NOT NULL,
artist_name TEXT NOT NULL,
artist_mbid TEXT NOT NULL,
aliases TEXT NOT NULL DEFAULT '',
popularity INTEGER NOT NULL DEFAULT 0,
listener_count INTEGER NOT NULL DEFAULT 0,
duration INTEGER NOT NULL DEFAULT 0,
caa_release_mbid TEXT NOT NULL DEFAULT '',
release_name TEXT NOT NULL DEFAULT '',
primary_type TEXT NOT NULL DEFAULT '',
secondary_types TEXT NOT NULL DEFAULT '',
release_date TEXT NOT NULL DEFAULT '',
artist_type TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
disambiguation TEXT NOT NULL DEFAULT '',
sort_name TEXT NOT NULL DEFAULT '',
discog_fetched INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (mbid)
) WITHOUT ROWID`,
`CREATE TABLE core.artifact_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)`,
}
for _, stmt := range stmts {
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("create artifact schema: %w", err)
}
}
return nil
}
// copyRows selects the core subset: the top artists by listen count,
// then a bounded slice of each one's release groups and recordings.
//
// The per-artist window mirrors the S2 coverage already in
// dumpcatalog.go — a flat global top-N would give a handful of
// superstars everything and everyone else nothing.
func copyRows(db *sql.DB, artists, perArtistRGs, perArtistRecs int) error {
if _, err := db.Exec(`
CREATE TEMP TABLE core_artists AS
SELECT mbid FROM main.explore_index
WHERE entity_type = 'artist'
ORDER BY popularity DESC
LIMIT ?`, artists,
); err != nil {
return fmt.Errorf("select core artists: %w", err)
}
copied, err := insertSelect(db, `
INSERT INTO core.explore_index (`+catalogColumns+`)
SELECT `+catalogColumns+`
FROM main.explore_index
WHERE entity_type = 'artist'
AND mbid IN (SELECT mbid FROM core_artists)`)
if err != nil {
return err
}
fmt.Printf(" artists: %d\n", copied)
for _, sel := range []struct {
label string
entity string
limit int
}{
{"release groups", "release_group", perArtistRGs},
{"recordings", "recording", perArtistRecs},
} {
// The window is over artist_mbid so each artist contributes at
// most `limit` rows, ranked by their own listen counts.
n, err := insertSelect(db, `
INSERT INTO core.explore_index (`+catalogColumns+`)
SELECT `+catalogColumns+` FROM (
SELECT *, ROW_NUMBER() OVER (
PARTITION BY artist_mbid ORDER BY popularity DESC
) AS rn
FROM main.explore_index
WHERE entity_type = ?
AND artist_mbid IN (SELECT mbid FROM core_artists)
) WHERE rn <= ?`, sel.entity, sel.limit)
if err != nil {
return err
}
fmt.Printf(" %-15s %d\n", sel.label+":", n)
}
return nil
}
func insertSelect(db *sql.DB, query string, args ...any) (int64, error) {
res, err := db.Exec(query, args...)
if err != nil {
return 0, fmt.Errorf("copy rows: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("rows affected: %w", err)
}
return n, nil
}
// stampMeta records what the importing client needs to know: that the
// catalog half is already populated, and which incremental listens
// series the popularity numbers are baselined on, so the incremental
// refresh resumes from the right point instead of reapplying deltas.
func stampMeta(db *sql.DB, srcRows int) error {
series := lookupMeta(db, "listens_applied_series")
built := lookupMeta(db, "dump_import_done")
if built == "" {
built = time.Now().UTC().Format(time.RFC3339)
}
entries := map[string]string{
"artifact_version": "1",
"built_at": built,
"source_rows": strconv.Itoa(srcRows),
"listens_applied_series": series,
}
for k, v := range entries {
if _, err := db.Exec(
`INSERT OR REPLACE INTO core.artifact_meta (key, value) VALUES (?, ?)`,
k, v,
); err != nil {
return fmt.Errorf("stamp %s: %w", k, err)
}
}
return nil
}
func lookupMeta(db *sql.DB, key string) string {
var value string
row := db.QueryRow(
`SELECT value FROM main.explore_index_meta WHERE key = ?`, key)
if err := row.Scan(&value); err != nil {
return ""
}
return value
}
func vacuum(path string) error {
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
return fmt.Errorf("reopen artifact: %w", err)
}
defer func() { _ = db.Close() }()
if _, err := db.Exec("VACUUM"); err != nil {
return fmt.Errorf("vacuum artifact: %w", err)
}
return nil
}
func report(path string) error {
fi, err := os.Stat(path)
if err != nil {
return fmt.Errorf("stat artifact: %w", err)
}
fmt.Printf("\nartifact: %s (%.1f MB)\n",
path, float64(fi.Size())/(1<<20))
fmt.Println("compress with: zstd -19 -T0", path)
return nil
}
+2
View File
@@ -24,6 +24,7 @@
</div>
<library-filter></library-filter>
<search-bar></search-bar>
<job-indicator></job-indicator>
</header>
<div class="sidebar">
<app-sidebar></app-sidebar>
@@ -41,6 +42,7 @@
<wa-icon name="list"></wa-icon>
</button>
</footer>
<first-run-wizard></first-run-wizard>
</body>
</html>
+4
View File
@@ -20,6 +20,9 @@ import '@components/explore-view/explore-view.ts';
import '@components/explore-artist-details/explore-artist-details.js';
import '@components/explore-album-details/explore-album-details.js';
import '@components/autotag-view/autotag-view.ts';
import '@components/first-run-wizard/first-run-wizard.ts';
import '@components/jobs/job-indicator.ts';
import '@components/jobs/jobs-view.ts';
import '@awesome.me/webawesome/dist/styles/themes/default.css';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
@@ -61,6 +64,7 @@ const VIEW_TAGS: Record<string, string> = {
playlists: 'playlist-view',
explore: 'explore-view',
autotag: 'autotag-view',
jobs: 'jobs-view',
settings: 'config-page',
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,272 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import {
GetLibraryDirectory,
SetLibraryDirectory,
} from '@go/config/Config';
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
/**
* First-run setup wizard.
*
* On startup it checks whether a library directory has already been
* configured. If one exists the wizard stays hidden and the app
* proceeds as normal. If none is set (fresh install), it presents a
* non-dismissable modal prompting the user to pick their music folder,
* saves it to the config, and dismisses itself. Saving the directory
* emits LibraryConfigChanged on the backend, which kicks off the
* initial scan automatically.
*/
@customElement('first-run-wizard')
export class FirstRunWizard extends LitElement {
@query('wa-dialog')
private dialog!: HTMLElement & { open: boolean };
/** Whether the wizard should be shown at all (no library configured). */
@state() private active = false;
/** Directory chosen in the picker, not yet saved. */
@state() private selectedDirectory = '';
/** True while SetLibraryDirectory is in flight. */
@state() private saving = false;
/** Error message from a failed pick/save, if any. */
@state() private errorMessage = '';
override async connectedCallback(): Promise<void> {
super.connectedCallback();
try {
const existing = await GetLibraryDirectory();
// A configured directory means setup is already complete.
if (existing) return;
} catch (err) {
console.error(
'First-run wizard: failed to read library directory:',
err,
);
return;
}
this.active = true;
await this.updateComplete;
if (this.dialog) this.dialog.open = true;
}
static override styles = css`
wa-dialog {
--width: 480px;
}
wa-dialog::part(dialog) {
background: var(--yj-bg-surface, #212529);
color: var(--yj-text-primary, #fff);
border: 1px solid var(--yj-border, #444);
border-radius: 8px;
}
wa-dialog::part(body) {
padding: 24px;
}
.welcome {
text-align: center;
margin-bottom: 20px;
}
.welcome wa-icon {
font-size: 40px;
color: var(--yj-accent, #f5c518);
margin-bottom: 8px;
}
.welcome h2 {
margin: 0 0 4px;
font-size: 20px;
font-weight: 600;
}
.welcome p {
margin: 0;
font-size: 13px;
color: var(--yj-text-secondary, #b3b3b3);
}
.chosen {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin-bottom: 16px;
font-size: 13px;
background: var(--yj-bg-elevated, #2a2f34);
border: 1px solid var(--yj-border, #444);
border-radius: 6px;
word-break: break-all;
}
.chosen wa-icon {
color: var(--yj-accent, #f5c518);
flex-shrink: 0;
}
.placeholder {
color: var(--yj-text-tertiary, #888);
}
.error {
font-size: 13px;
color: var(--yj-danger, #e5484d);
margin-bottom: 16px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.btn {
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
border: 1px solid var(--yj-border, #444);
border-radius: 6px;
background: transparent;
color: var(--yj-text-primary, #fff);
cursor: pointer;
}
.btn:hover:not(:disabled) {
background: var(--yj-bg-elevated, #2a2f34);
}
.btn-primary {
background: var(--yj-accent, #f5c518);
border-color: var(--yj-accent, #f5c518);
color: #000;
}
.btn-primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
override render() {
if (!this.active) return nothing;
return html`
<wa-dialog
label="Welcome to YellowJacket"
without-header
@wa-hide=${this.preventClose}
>
<div class="welcome">
<wa-icon name="music"></wa-icon>
<h2>Welcome to YellowJacket</h2>
<p>
Choose the folder where your music lives to
get started.
</p>
</div>
<div class="chosen">
<wa-icon name="folder"></wa-icon>
${this.selectedDirectory
? html`<span>${this.selectedDirectory}</span>`
: html`<span class="placeholder"
>No folder selected yet</span
>`}
</div>
${this.errorMessage
? html`<div class="error">${this.errorMessage}</div>`
: nothing}
<div class="actions">
<button
class="btn"
?disabled=${this.saving}
@click=${this.handleChoose}
>
${this.selectedDirectory
? 'Change Folder'
: 'Choose Folder'}
</button>
<button
class="btn btn-primary"
?disabled=${!this.selectedDirectory || this.saving}
@click=${this.handleFinish}
>
${this.saving ? 'Saving…' : 'Get Started'}
</button>
</div>
</wa-dialog>
`;
}
/** Set once setup is finished so we allow the dialog to close. */
private finished = false;
/**
* Block every close attempt (Escape, programmatic, backdrop) until
* setup is finished, so the user can't skip picking a folder.
* wa-hide is cancelable via preventDefault().
*/
private preventClose = (e: Event): void => {
if (!this.finished) e.preventDefault();
};
private handleChoose = async (): Promise<void> => {
this.errorMessage = '';
try {
const dir = await DirectoryPicker();
if (dir) this.selectedDirectory = dir;
} catch (err) {
this.errorMessage = `Could not open folder picker: ${err}`;
console.error('First-run wizard: directory picker failed:', err);
}
};
private handleFinish = async (): Promise<void> => {
if (!this.selectedDirectory) return;
this.saving = true;
this.errorMessage = '';
try {
await SetLibraryDirectory(this.selectedDirectory);
this.finished = true;
if (this.dialog) this.dialog.open = false;
this.active = false;
} catch (err) {
this.errorMessage = `Could not save the folder: ${err}`;
console.error('First-run wizard: save failed:', err);
} finally {
this.saving = false;
}
};
}
declare global {
interface HTMLElementTagNameMap {
'first-run-wizard': FirstRunWizard;
}
}
@@ -0,0 +1,54 @@
import { jobStore } from '@store/job-store';
export type JobControlAction = 'pause' | 'resume' | 'cancel' | 'dismiss';
export interface JobControlDetail {
id: string;
action: JobControlAction;
}
/**
* Applies a `job-control` event emitted by a `<job-row>`.
*
* Shared by every host that renders job rows — the top-bar popover, the
* jobs page, and the details drawer — so a control behaves identically
* wherever it is pressed, and so no host can forget to wire one up.
*/
export async function applyJobControl(e: Event): Promise<void> {
const { id, action } = (e as CustomEvent).detail as JobControlDetail;
if (action === 'cancel' && !confirmCancel(id)) return;
switch (action) {
case 'pause':
await jobStore.pause(id);
break;
case 'resume':
await jobStore.resume(id);
break;
case 'cancel':
await jobStore.cancel(id);
break;
case 'dismiss':
await jobStore.dismiss(id);
break;
}
}
/**
* Stopping an index build feels like discarding hours of downloading,
* so it is worth a confirmation even though the checkpoint survives. A
* library scan is cheap to re-run — don't nag for that one.
*/
function confirmCancel(id: string): boolean {
const job = jobStore.getJob(id);
if (job?.kind !== 'index-build') return true;
return window.confirm(
'Stop building the search index?\n\n' +
'Progress is checkpointed, so you can resume later without ' +
're-downloading. Until it finishes, search results stay ' +
'limited to your own library.',
);
}
@@ -0,0 +1,361 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/drawer/drawer.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import { jobStore } from '@store/job-store';
import type { Job } from '@store/job-store';
import './job-row';
import './job-log-view';
import { applyJobControl } from './job-controls';
import {
stateLabel,
stateTone,
formatElapsed,
jobStateStyles,
} from './job-format';
/** How often the open drawer re-fetches the job log. */
const LOG_POLL_MS = 1500;
/**
* Right-hand drawer showing everything known about one job: its
* progress row, stage breakdown, statistics, and log tail.
*
* A drawer rather than a route, because these jobs run *while* the user
* is doing something else — navigating away from their music to read a
* scan log would defeat the purpose.
*/
@customElement('job-details-drawer')
export class JobDetailsDrawer extends LitElement {
/** ID of the job to show. Empty string closes the drawer. */
@property({ type: String, attribute: 'job-id' })
jobId = '';
@property({ type: Boolean, reflect: true })
open = false;
@state()
private job: Job | null = null;
@state()
private logVersion = 0;
private unsubscribe: (() => void) | null = null;
private pollTimer: ReturnType<typeof setInterval> | null = null;
static override styles = [
designTokens,
jobStateStyles,
css`
wa-drawer {
--size: 34rem;
}
.content {
display: flex;
flex-direction: column;
gap: 1.1em;
height: 100%;
min-height: 0;
}
.section-title {
font-size: var(--yj-text-sm);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--yj-text-tertiary, #868e96);
margin-bottom: 0.5em;
}
.summary {
display: grid;
grid-template-columns: repeat(
auto-fit,
minmax(7.5rem, 1fr)
);
gap: 0.6em 1em;
}
.summary-item {
display: flex;
flex-direction: column;
gap: 0.15em;
}
.summary-label {
font-size: var(--yj-text-xs);
color: var(--yj-text-tertiary, #868e96);
}
.summary-value {
font-size: var(--yj-text-md);
color: var(--yj-text-primary, #e9ecef);
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.summary-value.tone {
color: var(--job-tone);
}
.stages {
display: flex;
flex-direction: column;
gap: 0.4em;
}
.stage {
display: grid;
grid-template-columns: 1.2em 1fr auto;
align-items: center;
gap: 0.6em;
font-size: var(--yj-text-md);
color: var(--yj-text-secondary, #adb5bd);
}
.stage-icon {
font-size: var(--yj-icon-sm);
}
.stage.running {
color: var(--yj-text-primary, #e9ecef);
}
.stage.running .stage-icon {
color: var(--yj-accent, #ffd43b);
}
.stage.complete .stage-icon {
color: #1db954;
}
.stage.error {
color: #ff6b6b;
}
.stage-count {
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #868e96);
font-variant-numeric: tabular-nums;
}
.stage-error {
grid-column: 2 / -1;
font-size: var(--yj-text-sm);
color: #ff6b6b;
overflow-wrap: anywhere;
}
.log-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 12rem;
}
job-log-view {
flex: 1;
min-height: 0;
}
.subtitle {
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #868e96);
overflow-wrap: anywhere;
}
`,
];
override connectedCallback(): void {
super.connectedCallback();
this.unsubscribe = jobStore.subscribe(() => this.syncJob());
this.syncJob();
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribe?.();
this.unsubscribe = null;
this.stopPolling();
}
override updated(changed: Map<string, unknown>) {
if (changed.has('jobId') || changed.has('open')) {
this.syncJob();
if (this.open && this.jobId) {
void this.refreshLog();
this.startPolling();
} else {
this.stopPolling();
}
}
}
private syncJob() {
this.job = this.jobId ? (jobStore.getJob(this.jobId) ?? null) : null;
}
/**
* Logs are polled while the drawer is open rather than pushed with
* every snapshot — a scan can emit hundreds of warnings and only
* this panel ever renders them.
*/
private startPolling() {
this.stopPolling();
this.pollTimer = setInterval(() => void this.refreshLog(), LOG_POLL_MS);
}
private stopPolling() {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
}
private async refreshLog() {
if (!this.jobId) return;
await jobStore.loadLog(this.jobId);
this.logVersion += 1;
}
private onHide = () => {
this.open = false;
this.dispatchEvent(
new CustomEvent('drawer-closed', { bubbles: true, composed: true }),
);
};
private stageIcon(state: string): string {
switch (state) {
case 'complete':
return 'circle-check';
case 'running':
return 'arrows-rotate';
case 'error':
return 'triangle-exclamation';
case 'skipped':
return 'circle-minus';
default:
return 'circle-info';
}
}
private renderStages(job: Job) {
if (!job.stages?.length) return nothing;
return html`
<div>
<div class="section-title">Stages</div>
<div class="stages">
${job.stages.map(
(stage) => html`
<div class="stage ${stage.state}">
<wa-icon
class="stage-icon"
name=${this.stageIcon(stage.state)}
></wa-icon>
<span>${stage.name}</span>
<span class="stage-count">
${stage.total > 0
? `${stage.current.toLocaleString()} / ${stage.total.toLocaleString()}`
: stage.state}
</span>
${stage.error
? html`<div class="stage-error">
${stage.error}
</div>`
: nothing}
</div>
`,
)}
</div>
</div>
`;
}
private renderSummary(job: Job) {
const items = [
{ label: 'Status', value: stateLabel(job), tone: true },
{ label: 'Elapsed', value: formatElapsed(job), tone: false },
...(job.stats ?? []).map((s) => ({
label: s.label,
value: s.value,
tone: false,
})),
];
return html`
<div>
<div class="section-title">Summary</div>
<div class="summary">
${items.map(
(item) => html`
<div class="summary-item">
<span class="summary-label">${item.label}</span>
<span
class="summary-value ${item.tone
? 'tone'
: ''}"
>${item.value}</span
>
</div>
`,
)}
</div>
</div>
`;
}
override render() {
const job = this.job;
return html`
<wa-drawer
?open=${this.open}
label=${job?.title ?? 'Job details'}
@wa-hide=${this.onHide}
class="tone-${job ? stateTone(job) : 'muted'}"
>
${job
? html`
<div class="content">
${job.subtitle
? html`<div class="subtitle">
${job.subtitle}
</div>`
: nothing}
<job-row
.job=${job}
variant="full"
@job-control=${applyJobControl}
></job-row>
${this.renderSummary(job)}
${this.renderStages(job)}
<div class="log-section">
<div class="section-title">Output</div>
<job-log-view
.job=${job}
.entries=${jobStore.cachedLog(job.id)}
data-version=${this.logVersion}
></job-log-view>
</div>
</div>
`
: html`<p>This job is no longer available.</p>`}
</wa-drawer>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'job-details-drawer': JobDetailsDrawer;
}
}
+165
View File
@@ -0,0 +1,165 @@
import { css } from 'lit';
import type { Job, JobLogEntry } from '@store/job-store';
import { isIndeterminate, progressFraction } from '@store/job-store';
/** Icon for a job kind, used in the indicator and job rows. */
export function jobIcon(job: Job): string {
switch (job.kind) {
case 'library-scan':
return 'folder';
case 'index-build':
return 'database';
default:
return 'gear';
}
}
/** Human-readable label for a job state. */
export function stateLabel(job: Job): string {
switch (job.state) {
case 'queued':
return 'Queued';
case 'running':
return 'Running';
case 'pausing':
return 'Pausing…';
case 'paused':
return 'Paused';
case 'cancelling':
return 'Cancelling…';
case 'complete':
return 'Complete';
case 'cancelled':
return 'Stopped';
case 'error':
return 'Failed';
default:
return job.state;
}
}
/**
* Semantic colour name for a state, mapped to CSS custom properties by
* the `jobStateStyles` block below.
*/
export function stateTone(
job: Job,
): 'active' | 'paused' | 'danger' | 'success' | 'muted' {
switch (job.state) {
case 'running':
return 'active';
case 'pausing':
case 'paused':
return 'paused';
case 'error':
return 'danger';
case 'complete':
return 'success';
default:
return 'muted';
}
}
/** Compact "1,204 / 12,880" progress text, or null when indeterminate. */
export function progressText(job: Job): string | null {
if (isIndeterminate(job)) return null;
return `${formatCount(job.current)} / ${formatCount(job.total)}`;
}
/** Progress as a whole-number percentage, or null when indeterminate. */
export function progressPercent(job: Job): number | null {
const fraction = progressFraction(job);
return fraction === null ? null : Math.round(fraction * 100);
}
/**
* The one-line status shown under a job title: phase plus counts, with
* the state folded in when it is something other than plain running.
*/
export function statusLine(job: Job): string {
const parts: string[] = [];
const label = stateLabel(job);
if (job.state !== 'running' && job.state !== 'queued') {
parts.push(label);
}
// A paused job's phase is often just "Paused", which would render
// as "Paused · Paused" alongside the state label.
if (job.phase && job.phase !== label) parts.push(job.phase);
const progress = progressText(job);
if (progress && job.state !== 'complete') parts.push(progress);
if (parts.length === 0) parts.push(stateLabel(job));
return parts.join(' · ');
}
export function formatCount(n: number): string {
return n.toLocaleString();
}
/** Wall-clock duration of a job, as "1m 24s". */
export function formatElapsed(job: Job): string {
const end = job.endedAt && job.endedAt > 0 ? job.endedAt : Date.now();
const seconds = Math.max(0, Math.round((end - job.startedAt) / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
if (minutes < 60) return `${minutes}m ${remainder}s`;
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
}
/** Clock time for a log entry, e.g. "14:03:21". */
export function formatLogTime(entry: JobLogEntry): string {
return new Date(entry.time).toLocaleTimeString(undefined, {
hour12: false,
});
}
/** Renders a job log as plain text for the clipboard. */
export function logToText(job: Job, entries: JobLogEntry[]): string {
const header = `${job.title}${stateLabel(job)}`;
const lines = entries.map((entry) => {
const detail = entry.detail ? ` (${entry.detail})` : '';
return `${formatLogTime(entry)} [${entry.level}] ${entry.message}${detail}`;
});
return [header, ...lines].join('\n');
}
/**
* Shared state-tone colour variables. Include in a component's styles
* array so `.tone-active`, `.tone-paused` etc. resolve consistently
* wherever job state is rendered.
*/
export const jobStateStyles = css`
.tone-active {
--job-tone: var(--yj-accent, #ffd43b);
}
.tone-paused {
--job-tone: #f5a623;
}
.tone-danger {
--job-tone: #ff6b6b;
}
.tone-success {
--job-tone: #1db954;
}
.tone-muted {
--job-tone: var(--yj-text-tertiary, #868e96);
}
`;
@@ -0,0 +1,440 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import { designTokens } from '../../styles/tokens.css';
import { jobStore } from '@store/job-store';
import type { Job } from '@store/job-store';
import { isIndeterminate, progressFraction } from '@store/job-store';
import './job-row';
import './job-details-drawer';
import { applyJobControl } from './job-controls';
import {
jobIcon,
stateTone,
stateLabel,
jobStateStyles,
} from './job-format';
/** Circumference of the progress ring at r=9. */
const RING_CIRCUMFERENCE = 2 * Math.PI * 9;
/**
* Persistent background-job indicator for the top bar.
*
* Hidden entirely when nothing is running, so it costs no attention in
* the common case. When work is in flight it shows a determinate ring
* for a single job, or a count badge for several. Clicking opens a
* popover with inline pause/stop controls; "Details" opens the drawer.
*/
@customElement('job-indicator')
export class JobIndicator extends LitElement {
@state()
private jobs: Job[] = [];
@state()
private popoverOpen = false;
@state()
private drawerJobId = '';
@state()
private drawerOpen = false;
private unsubscribe: (() => void) | null = null;
static override styles = [
designTokens,
jobStateStyles,
css`
:host {
display: inline-flex;
align-items: center;
position: relative;
}
:host([hidden]) {
display: none;
}
.trigger {
display: inline-flex;
align-items: center;
gap: 0.5em;
padding: 0.3em 0.7em 0.3em 0.35em;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
color: var(--yj-text-secondary, #adb5bd);
cursor: pointer;
font-size: var(--yj-text-sm);
transition:
background-color 140ms ease,
border-color 140ms ease,
color 140ms ease;
}
.trigger:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--yj-text-primary, #e9ecef);
}
.trigger:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
.ring-wrap {
position: relative;
width: 22px;
height: 22px;
flex-shrink: 0;
}
svg {
width: 22px;
height: 22px;
transform: rotate(-90deg);
}
.ring-track {
fill: none;
stroke: rgba(255, 255, 255, 0.14);
stroke-width: 2.5;
}
.ring-value {
fill: none;
stroke: var(--job-tone);
stroke-width: 2.5;
stroke-linecap: round;
transition: stroke-dashoffset 240ms ease;
}
/* Indeterminate work spins the whole ring instead of
* advancing it, so it never implies false precision. */
.ring-wrap.spin svg {
animation: spin 1.1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(270deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ring-wrap.spin svg {
animation-duration: 3s;
}
}
.ring-glyph {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
color: var(--job-tone);
font-variant-numeric: tabular-nums;
}
.label {
white-space: nowrap;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
}
.alert-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #ff6b6b;
flex-shrink: 0;
}
.panel {
width: 24rem;
max-width: 92vw;
background: var(--yj-surface, #212529);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
padding: 0.4em;
max-height: 70vh;
overflow-y: auto;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4em 0.6em 0.5em;
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #868e96);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.panel-header button {
border: none;
background: transparent;
color: var(--yj-text-secondary, #adb5bd);
font-size: var(--yj-text-sm);
cursor: pointer;
text-transform: none;
letter-spacing: normal;
padding: 0.15em 0.4em;
border-radius: 5px;
}
.panel-header button:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--yj-text-primary, #e9ecef);
}
.job-entry {
border-radius: 8px;
}
.job-entry + .job-entry {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.details-link {
display: block;
width: 100%;
text-align: left;
border: none;
background: transparent;
color: var(--yj-accent, #ffd43b);
font-size: var(--yj-text-sm);
cursor: pointer;
padding: 0 0.75em 0.6em 3.2em;
}
.details-link:hover {
text-decoration: underline;
}
.empty {
padding: 0.8em;
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #868e96);
font-style: italic;
}
`,
];
override connectedCallback(): void {
super.connectedCallback();
this.unsubscribe = jobStore.subscribe(() => this.syncJobs());
void jobStore.init();
this.syncJobs();
document.addEventListener('click', this.onDocumentClick);
document.addEventListener('keydown', this.onKeydown);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribe?.();
this.unsubscribe = null;
document.removeEventListener('click', this.onDocumentClick);
document.removeEventListener('keydown', this.onKeydown);
}
private syncJobs() {
this.jobs = jobStore.jobs;
// The drawer stays mounted so it can animate closed; the pill
// itself disappears once nothing is happening.
this.hidden = !jobStore.shouldShowIndicator && !this.drawerOpen;
if (this.hidden) this.popoverOpen = false;
}
private onDocumentClick = (e: MouseEvent) => {
if (!this.popoverOpen) return;
if (e.composedPath().includes(this)) return;
this.popoverOpen = false;
};
private onKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && this.popoverOpen) this.popoverOpen = false;
};
private onTriggerClick = (e: Event) => {
e.stopPropagation();
this.popoverOpen = !this.popoverOpen;
};
private openDetails(id: string) {
this.drawerJobId = id;
this.drawerOpen = true;
this.popoverOpen = false;
}
private onDrawerClosed = () => {
this.drawerOpen = false;
this.syncJobs();
};
private async clearFinished(e: Event) {
e.stopPropagation();
await jobStore.clearFinished();
}
/** The job whose progress the ring represents. */
private get primaryJob(): Job | null {
const working = jobStore.workingJobs;
if (working.length > 0) return working[0] ?? null;
const active = jobStore.activeJobs;
return active[0] ?? null;
}
private renderRing(job: Job | null) {
const activeCount = jobStore.activeJobs.length;
const fraction = job ? progressFraction(job) : null;
const tone = job ? stateTone(job) : 'success';
// Only spin for work that is actually moving. A paused or
// queued job spinning would say "busy" when nothing is running.
const spin = Boolean(
job && job.state === 'running' && isIndeterminate(job),
);
const offset =
fraction === null
? RING_CIRCUMFERENCE * 0.72
: RING_CIRCUMFERENCE * (1 - fraction);
return html`
<div class="ring-wrap tone-${tone} ${spin ? 'spin' : ''}">
<svg viewBox="0 0 22 22" aria-hidden="true">
<circle class="ring-track" cx="11" cy="11" r="9"></circle>
<circle
class="ring-value"
cx="11"
cy="11"
r="9"
stroke-dasharray=${RING_CIRCUMFERENCE}
stroke-dashoffset=${offset}
></circle>
</svg>
<div class="ring-glyph">
${activeCount > 1
? activeCount
: html`<wa-icon
name=${job ? jobIcon(job) : 'check'}
></wa-icon>`}
</div>
</div>
`;
}
private renderTrigger() {
const job = this.primaryJob;
const activeCount = jobStore.activeJobs.length;
const hasFailure = jobStore.failedJobs.length > 0;
let label: string;
if (activeCount > 1) {
label = `${activeCount} background jobs`;
} else if (job && job.state === 'running') {
label = job.title;
} else if (job) {
// "Scanning Music" would be a lie for a job that is paused
// or queued, so lead with the state instead.
label = `${stateLabel(job)} · ${job.title}`;
} else {
label = 'Finished';
}
return html`
<button
class="trigger"
aria-haspopup="dialog"
aria-expanded=${this.popoverOpen}
title="Background jobs"
@click=${this.onTriggerClick}
>
${this.renderRing(job)}
<span class="label">${label}</span>
${hasFailure ? html`<span class="alert-dot"></span>` : nothing}
</button>
`;
}
private renderPanel() {
const finished = jobStore.finishedJobs;
return html`
<div class="panel" role="dialog" aria-label="Background jobs">
<div class="panel-header">
<span>Background jobs</span>
${finished.length > 0
? html`<button @click=${this.clearFinished}>
Clear finished
</button>`
: nothing}
</div>
${this.jobs.length === 0
? html`<div class="empty">Nothing running.</div>`
: this.jobs.map(
(job) => html`
<div class="job-entry">
<job-row
.job=${job}
variant="compact"
open-on-click
@job-control=${applyJobControl}
@job-open=${() =>
this.openDetails(job.id)}
></job-row>
<button
class="details-link"
@click=${() => this.openDetails(job.id)}
>
Details${job.warnCount
? ` · ${job.warnCount} warning${job.warnCount === 1 ? '' : 's'}`
: ''}
</button>
</div>
`,
)}
</div>
`;
}
override render() {
return html`
<wa-popup
placement="bottom-end"
distance="8"
?active=${this.popoverOpen}
>
<span slot="anchor">${this.renderTrigger()}</span>
${this.popoverOpen ? this.renderPanel() : nothing}
</wa-popup>
<job-details-drawer
job-id=${this.drawerJobId}
?open=${this.drawerOpen}
@drawer-closed=${this.onDrawerClosed}
></job-details-drawer>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'job-indicator': JobIndicator;
}
}
@@ -0,0 +1,283 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import type { Job, JobLogEntry } from '@store/job-store';
import { formatLogTime, logToText } from './job-format';
type LevelFilter = 'all' | 'warn' | 'error';
/**
* Scrolling tail of a job's output log, with a severity filter and a
* copy-to-clipboard button.
*
* The buffer is bounded on the backend (500 entries), so this is a tail
* rather than a complete transcript — the header says so when entries
* have been dropped, rather than silently showing a partial log.
*/
@customElement('job-log-view')
export class JobLogView extends LitElement {
@property({ type: Object })
job!: Job;
@property({ type: Array })
entries: JobLogEntry[] = [];
@state()
private filter: LevelFilter = 'all';
@state()
private copied = false;
/** Set while the user has scrolled up, which suspends auto-follow. */
@state()
private following = true;
static override styles = [
designTokens,
css`
:host {
display: flex;
flex-direction: column;
min-height: 0;
}
.toolbar {
display: flex;
align-items: center;
gap: 0.5em;
padding-bottom: 0.5em;
}
.filters {
display: flex;
gap: 0.2em;
}
.filters button {
border: none;
background: transparent;
color: var(--yj-text-secondary, #adb5bd);
font-size: var(--yj-text-sm);
padding: 0.25em 0.6em;
border-radius: 6px;
cursor: pointer;
}
.filters button.active {
background: rgba(255, 255, 255, 0.1);
color: var(--yj-text-primary, #e9ecef);
}
.filters button:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 1px;
}
.spacer {
flex: 1;
}
.copy {
display: inline-flex;
align-items: center;
gap: 0.4em;
border: none;
background: transparent;
color: var(--yj-text-secondary, #adb5bd);
font-size: var(--yj-text-sm);
padding: 0.25em 0.5em;
border-radius: 6px;
cursor: pointer;
}
.copy:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--yj-text-primary, #e9ecef);
}
.log {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: auto;
background: rgba(0, 0, 0, 0.25);
border-radius: 8px;
padding: 0.6em 0.75em;
font-family:
ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: var(--yj-text-sm);
line-height: 1.55;
}
.entry {
display: flex;
gap: 0.7em;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.time {
color: var(--yj-text-tertiary, #868e96);
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.message {
color: var(--yj-text-secondary, #adb5bd);
}
.entry.warn .message {
color: #f5a623;
}
.entry.error .message {
color: #ff6b6b;
}
.detail {
color: var(--yj-text-tertiary, #868e96);
padding-left: 1em;
overflow-wrap: anywhere;
}
.empty {
color: var(--yj-text-tertiary, #868e96);
font-style: italic;
padding: 0.5em 0;
}
.truncation-note {
color: var(--yj-text-tertiary, #868e96);
font-style: italic;
padding-bottom: 0.4em;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
margin-bottom: 0.4em;
}
`,
];
private get filtered(): JobLogEntry[] {
switch (this.filter) {
case 'warn':
return this.entries.filter(
(e) => e.level === 'warn' || e.level === 'error',
);
case 'error':
return this.entries.filter((e) => e.level === 'error');
default:
return this.entries;
}
}
/** Entries the backend ring buffer dropped before we fetched it. */
private get droppedCount(): number {
return Math.max(0, (this.job?.logCount ?? 0) - this.entries.length);
}
override updated() {
if (!this.following) return;
const log = this.renderRoot.querySelector('.log');
if (log) log.scrollTop = log.scrollHeight;
}
private onScroll = (e: Event) => {
const el = e.target as HTMLElement;
// Re-engage auto-follow when the user returns to the bottom.
this.following =
el.scrollHeight - el.scrollTop - el.clientHeight < 24;
};
private setFilter(filter: LevelFilter) {
this.filter = filter;
}
private async copyLog() {
try {
await navigator.clipboard.writeText(
logToText(this.job, this.entries),
);
this.copied = true;
setTimeout(() => {
this.copied = false;
}, 1500);
} catch (err) {
console.error('Failed to copy job log:', err);
}
}
private renderFilterButton(value: LevelFilter, label: string) {
return html`
<button
class=${this.filter === value ? 'active' : ''}
@click=${() => this.setFilter(value)}
>
${label}
</button>
`;
}
override render() {
const entries = this.filtered;
const dropped = this.droppedCount;
return html`
<div class="toolbar">
<div class="filters">
${this.renderFilterButton('all', 'All')}
${this.renderFilterButton(
'warn',
`Warnings${this.job?.warnCount ? ` (${this.job.warnCount})` : ''}`,
)}
${this.renderFilterButton(
'error',
`Errors${this.job?.errorCount ? ` (${this.job.errorCount})` : ''}`,
)}
</div>
<div class="spacer"></div>
<button class="copy" @click=${this.copyLog}>
${this.copied
? html`<wa-icon name="check"></wa-icon>Copied`
: 'Copy'}
</button>
</div>
<div class="log" @scroll=${this.onScroll}>
${dropped > 0
? html`
<div class="truncation-note">
${dropped.toLocaleString()} earlier
${dropped === 1 ? 'entry' : 'entries'} dropped —
showing the most recent output
</div>
`
: nothing}
${entries.length === 0
? html`<div class="empty">No output yet.</div>`
: entries.map(
(entry) => html`
<div class="entry ${entry.level}">
<span class="time"
>${formatLogTime(entry)}</span
>
<span class="message">${entry.message}</span>
</div>
${entry.detail
? html`<div class="detail">
${entry.detail}
</div>`
: nothing}
`,
)}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'job-log-view': JobLogView;
}
}
+408
View File
@@ -0,0 +1,408 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/progress-bar/progress-bar.js';
import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
import { designTokens } from '../../styles/tokens.css';
import type { Job } from '@store/job-store';
import { isTerminal, isIndeterminate } from '@store/job-store';
import {
jobIcon,
stateTone,
statusLine,
progressPercent,
formatElapsed,
jobStateStyles,
} from './job-format';
/**
* A single background job: icon, title, status line, progress bar, and
* whichever controls the job declares support for.
*
* Controls are rendered from `job.caps` rather than from the job kind,
* so a job that gains pause support on the backend needs no change
* here. The row emits `job-control` and `job-open`; the host decides
* what to do with them, which is what lets the same row serve both the
* top-bar popover and the full jobs page.
*/
@customElement('job-row')
export class JobRow extends LitElement {
@property({ type: Object })
job!: Job;
/**
* `compact` is the popover density — one line of status, small
* controls. `full` adds elapsed time and per-job statistics.
*/
@property({ type: String })
variant: 'compact' | 'full' = 'compact';
/** Whether clicking the row should emit `job-open`. */
@property({ type: Boolean, attribute: 'open-on-click' })
openOnClick = false;
static override styles = [
designTokens,
jobStateStyles,
css`
:host {
display: block;
}
.row {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.75em;
align-items: start;
padding: 0.6em 0.75em;
border-radius: 8px;
transition: background-color 120ms ease;
}
.row.clickable {
cursor: pointer;
}
.row.clickable:hover {
background: rgba(255, 255, 255, 0.05);
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
color: var(--job-tone);
font-size: var(--yj-icon-sm);
flex-shrink: 0;
}
.body {
min-width: 0;
}
.title {
font-size: var(--yj-text-md);
color: var(--yj-text-primary, #e9ecef);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status {
font-size: var(--yj-text-sm);
color: var(--yj-text-secondary, #adb5bd);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 0.15em;
}
.status .tone {
color: var(--job-tone);
}
wa-progress-bar {
margin-top: 0.45em;
--height: 4px;
--indicator-color: var(--job-tone);
--track-color: rgba(255, 255, 255, 0.09);
}
.indeterminate {
margin-top: 0.5em;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.09);
overflow: hidden;
}
/* A slider that sweeps left to right, for work with no
* known denominator (the pre-walk file count, for one).
* Static unless the job is actually moving. */
/* Stopped: a dim full-width bar, which reads as "no progress
* information" rather than a partial fill implying a
* percentage the job never reported. */
.indeterminate::after {
content: '';
display: block;
width: 100%;
height: 100%;
border-radius: 2px;
background: var(--job-tone);
opacity: 0.3;
}
.indeterminate.moving::after {
width: 35%;
opacity: 1;
animation: sweep 1.4s ease-in-out infinite;
}
@keyframes sweep {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(320%);
}
}
@media (prefers-reduced-motion: reduce) {
.indeterminate.moving::after {
animation: none;
width: 100%;
opacity: 0.5;
}
}
.stats {
display: flex;
flex-wrap: wrap;
gap: 0.25em 1.1em;
margin-top: 0.5em;
font-size: var(--yj-text-sm);
}
.stat-label {
color: var(--yj-text-tertiary, #868e96);
}
.stat-value {
color: var(--yj-text-primary, #e9ecef);
font-variant-numeric: tabular-nums;
margin-left: 0.35em;
}
.error {
margin-top: 0.45em;
font-size: var(--yj-text-sm);
color: #ff6b6b;
overflow-wrap: anywhere;
}
.controls {
display: flex;
align-items: center;
gap: 0.15em;
flex-shrink: 0;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--yj-text-secondary, #adb5bd);
cursor: pointer;
font-size: var(--yj-icon-sm);
transition:
background-color 120ms ease,
color 120ms ease;
}
button:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: var(--yj-text-primary, #e9ecef);
}
button.danger:hover:not(:disabled) {
color: #ff6b6b;
}
button:disabled {
opacity: 0.35;
cursor: default;
}
button:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 1px;
}
`,
];
private emitControl(action: 'pause' | 'resume' | 'cancel' | 'dismiss') {
this.dispatchEvent(
new CustomEvent('job-control', {
detail: { id: this.job.id, action },
bubbles: true,
composed: true,
}),
);
}
private emitOpen() {
this.dispatchEvent(
new CustomEvent('job-open', {
detail: { id: this.job.id },
bubbles: true,
composed: true,
}),
);
}
private renderProgress() {
const job = this.job;
if (isTerminal(job)) return nothing;
if (isIndeterminate(job)) {
// Only sweep while work is actually moving — an animated bar
// on a paused job reads as progress that isn't happening.
return html`
<div
class="indeterminate ${job.state === 'running'
? 'moving'
: ''}"
></div>
`;
}
return html`
<wa-progress-bar
value=${progressPercent(job) ?? 0}
></wa-progress-bar>
`;
}
private renderStats() {
if (this.variant !== 'full') return nothing;
if (!this.job.stats?.length) return nothing;
return html`
<div class="stats">
${this.job.stats.map(
(stat) => html`
<div>
<span class="stat-label">${stat.label}</span>
<span class="stat-value">${stat.value}</span>
</div>
`,
)}
</div>
`;
}
private renderControls() {
const job = this.job;
// A finished job offers only dismissal.
if (isTerminal(job)) {
return html`
<button
class="danger"
title="Dismiss"
aria-label="Dismiss ${job.title}"
@click=${this.onDismiss}
>
<wa-icon name="xmark"></wa-icon>
</button>
`;
}
const paused = job.state === 'paused';
const settling = job.state === 'pausing' || job.state === 'cancelling';
return html`
${job.caps.pausable
? html`
<button
title=${paused ? 'Resume' : 'Pause'}
aria-label=${paused
? `Resume ${job.title}`
: `Pause ${job.title}`}
?disabled=${settling}
@click=${paused ? this.onResume : this.onPause}
>
<wa-icon name=${paused ? 'play' : 'pause'}></wa-icon>
</button>
`
: nothing}
${job.caps.cancellable
? html`
<button
class="danger"
title="Stop"
aria-label="Stop ${job.title}"
?disabled=${job.state === 'cancelling'}
@click=${this.onCancel}
>
<wa-icon name="stop"></wa-icon>
</button>
`
: nothing}
`;
}
private onPause = (e: Event) => {
e.stopPropagation();
this.emitControl('pause');
};
private onResume = (e: Event) => {
e.stopPropagation();
this.emitControl('resume');
};
private onCancel = (e: Event) => {
e.stopPropagation();
this.emitControl('cancel');
};
private onDismiss = (e: Event) => {
e.stopPropagation();
this.emitControl('dismiss');
};
private onRowClick = () => {
if (this.openOnClick) this.emitOpen();
};
override render() {
const job = this.job;
if (!job) return nothing;
const tone = stateTone(job);
const elapsed =
this.variant === 'full' ? ` · ${formatElapsed(job)}` : '';
return html`
<div
class="row tone-${tone} ${this.openOnClick ? 'clickable' : ''}"
@click=${this.onRowClick}
>
<div class="icon">
<wa-icon name=${jobIcon(job)}></wa-icon>
</div>
<div class="body">
<div class="title">${job.title}</div>
<div class="status">
<span class="tone">${statusLine(job)}</span>${elapsed}
</div>
${this.renderProgress()} ${this.renderStats()}
${job.error
? html`<div class="error">${job.error}</div>`
: nothing}
</div>
<div class="controls">${this.renderControls()}</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'job-row': JobRow;
}
}
+510
View File
@@ -0,0 +1,510 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import {
GetAllLibrariesWithTrackCounts,
ScanLibrary,
ScanAllLibraries,
FullRescan,
} from '@go/library/Library';
import type { library } from '@go/models';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import { jobStore } from '@store/job-store';
import type { Job } from '@store/job-store';
import './job-row';
import './job-details-drawer';
import { applyJobControl } from './job-controls';
import { jobStateStyles } from './job-format';
type LibraryInfo = library.Info;
/** Job states meaning the job will not progress further. */
const TERMINAL_STATES: ReadonlySet<string> = new Set([
'complete',
'cancelled',
'error',
]);
/**
* Full-page view of background work: everything running right now, the
* per-library scan controls that used to live in Settings, and a short
* history of what recently finished.
*
* This is the same job rows as the top-bar popover at a larger density —
* one implementation, two placements, so the two can never disagree.
*/
@customElement('jobs-view')
export class JobsView extends LitElement {
@state()
private jobs: Job[] = [];
@state()
private libraries: LibraryInfo[] = [];
@state()
private drawerJobId = '';
@state()
private drawerOpen = false;
private unsubscribe: (() => void) | null = null;
private eventCleanups: Array<() => void> = [];
static override styles = [
designTokens,
jobStateStyles,
css`
:host {
display: block;
overflow-y: auto;
height: 100%;
padding: 1.5em 1.75em 3em;
box-sizing: border-box;
}
h1 {
font-size: var(--yj-text-xl);
color: var(--yj-text-primary, #e9ecef);
margin: 0 0 0.2em;
}
.page-sub {
font-size: var(--yj-text-md);
color: var(--yj-text-tertiary, #868e96);
margin: 0 0 1.75em;
}
section {
margin-bottom: 2em;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
margin-bottom: 0.75em;
}
h2 {
font-size: var(--yj-text-sm);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--yj-text-tertiary, #868e96);
margin: 0;
}
.card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 10px;
overflow: hidden;
}
.card > * + * {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.job-entry {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 0.5em;
padding-right: 0.75em;
}
.empty {
padding: 1.1em;
font-size: var(--yj-text-md);
color: var(--yj-text-tertiary, #868e96);
font-style: italic;
}
.library-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 1em;
padding: 0.75em 0.9em;
}
.library-name {
font-size: var(--yj-text-md);
color: var(--yj-text-primary, #e9ecef);
}
.library-meta {
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #868e96);
margin-top: 0.15em;
overflow-wrap: anywhere;
}
.library-state {
font-size: var(--yj-text-sm);
color: var(--job-tone);
margin-top: 0.15em;
}
button.action {
display: inline-flex;
align-items: center;
gap: 0.45em;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 7px;
background: rgba(255, 255, 255, 0.05);
color: var(--yj-text-primary, #e9ecef);
font-size: var(--yj-text-sm);
padding: 0.42em 0.85em;
cursor: pointer;
white-space: nowrap;
transition:
background-color 120ms ease,
border-color 120ms ease;
}
button.action:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.11);
}
button.action:disabled {
opacity: 0.4;
cursor: default;
}
button.action:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
button.action.danger {
color: #ff6b6b;
border-color: rgba(255, 107, 107, 0.35);
}
button.action.danger:hover:not(:disabled) {
background: rgba(255, 107, 107, 0.12);
}
button.link {
border: none;
background: transparent;
color: var(--yj-accent, #ffd43b);
font-size: var(--yj-text-sm);
cursor: pointer;
padding: 0.2em 0.4em;
border-radius: 5px;
}
button.link:hover {
text-decoration: underline;
}
.details-btn {
border: none;
background: transparent;
color: var(--yj-text-secondary, #adb5bd);
font-size: var(--yj-text-sm);
cursor: pointer;
padding: 0.3em 0.5em;
border-radius: 6px;
white-space: nowrap;
}
.details-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--yj-text-primary, #e9ecef);
}
`,
];
override connectedCallback(): void {
super.connectedCallback();
this.unsubscribe = jobStore.subscribe(() => {
this.jobs = jobStore.jobs;
});
void jobStore.init();
this.jobs = jobStore.jobs;
void this.loadLibraries();
// Library CRUD happens elsewhere; keep the picker in step.
for (const event of [
Events.LibraryAdded,
Events.LibraryRemoved,
Events.LibraryRenamed,
Events.LibraryScanComplete,
]) {
this.eventCleanups.push(
EventsOn(event, () => void this.loadLibraries()),
);
}
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribe?.();
this.unsubscribe = null;
this.eventCleanups.forEach((off) => off());
this.eventCleanups = [];
}
private async loadLibraries(): Promise<void> {
try {
this.libraries = (await GetAllLibrariesWithTrackCounts()) ?? [];
} catch (err) {
console.error('Failed to load libraries:', err);
}
}
/** The scan job for a library, if one is registered. */
private jobForLibrary(id: number): Job | undefined {
return jobStore.getJob(`scan:${id}`);
}
private openDetails(id: string) {
this.drawerJobId = id;
this.drawerOpen = true;
}
private onDrawerClosed = () => {
this.drawerOpen = false;
};
private async startScan(id: number) {
try {
await ScanLibrary(id);
} catch (err) {
console.error('Failed to start scan:', err);
}
}
private async startAllScans() {
try {
await ScanAllLibraries();
} catch (err) {
console.error('Failed to start scans:', err);
}
}
private async clearFinished() {
await jobStore.clearFinished();
}
private async fullRescan() {
if (
!window.confirm(
'Full rescan deletes ALL library data — including ' +
'downloaded cover art — and rebuilds it from your ' +
'files.\n\nThis is not the same as "Scan now", which ' +
'only picks up what changed. Continue?',
)
) {
return;
}
try {
await FullRescan();
} catch (err) {
console.error('Full rescan failed:', err);
}
}
private renderJobList(list: Job[], emptyText: string) {
if (list.length === 0) {
return html`<div class="card">
<div class="empty">${emptyText}</div>
</div>`;
}
return html`
<div class="card">
${list.map(
(job) => html`
<div class="job-entry">
<job-row
.job=${job}
variant="full"
@job-control=${applyJobControl}
></job-row>
<button
class="details-btn"
@click=${() => this.openDetails(job.id)}
>
Details${job.warnCount
? ` · ${job.warnCount}`
: ''}
</button>
</div>
`,
)}
</div>
`;
}
/** The status line under a library name in the scan-control list. */
private libraryStatus(job: Job | undefined): string | null {
if (!job) return null;
switch (job.state) {
case 'running':
return job.phase ? `Scanning · ${job.phase}` : 'Scanning';
case 'queued':
return 'Queued';
case 'paused':
return 'Paused';
case 'pausing':
return 'Pausing…';
case 'cancelling':
return 'Stopping…';
default:
return null;
}
}
private renderLibraryRow(lib: LibraryInfo) {
const job = this.jobForLibrary(lib.id);
const status = this.libraryStatus(job);
const busy = status !== null;
return html`
<div class="library-row">
<div>
<div class="library-name">${lib.name}</div>
<div class="library-meta">
${lib.trackCount.toLocaleString()} tracks · ${lib.path}
</div>
${status
? html`<div class="library-state">${status}</div>`
: nothing}
</div>
${busy
? html`
<button
class="link"
@click=${() => this.openDetails(`scan:${lib.id}`)}
>
View progress
</button>
`
: html`
<button
class="action"
@click=${() => this.startScan(lib.id)}
>
<wa-icon name="arrows-rotate"></wa-icon>
Scan now
</button>
`}
</div>
`;
}
override render() {
// Derived from `this.jobs` rather than the store getters so Lit
// sees the reactive dependency and re-renders on every snapshot.
const active = this.jobs.filter((j) => !TERMINAL_STATES.has(j.state));
const finished = this.jobs.filter((j) => TERMINAL_STATES.has(j.state));
const anyScanning = this.libraries.some((lib) =>
Boolean(this.libraryStatus(this.jobForLibrary(lib.id))),
);
return html`
<h1>Background jobs</h1>
<p class="page-sub">
Library scans and search index builds, with their progress and
output.
</p>
<section>
<div class="section-head">
<h2>Running now</h2>
</div>
${this.renderJobList(active, 'Nothing is running.')}
</section>
<section>
<div class="section-head">
<h2>Libraries</h2>
<button
class="action"
?disabled=${anyScanning || this.libraries.length === 0}
@click=${this.startAllScans}
>
<wa-icon name="arrows-rotate"></wa-icon>
Scan all
</button>
</div>
<div class="card">
${this.libraries.length === 0
? html`<div class="empty">
No libraries yet — add one in Settings.
</div>`
: this.libraries.map((lib) =>
this.renderLibraryRow(lib),
)}
</div>
</section>
<section>
<div class="section-head">
<h2>Maintenance</h2>
</div>
<div class="card">
<div class="library-row">
<div>
<div class="library-name">Full rescan</div>
<div class="library-meta">
Wipes all library data and cover art, then
rebuilds from your files. Only needed when the
library is corrupt — a normal scan already
picks up changes.
</div>
</div>
<button
class="action danger"
?disabled=${anyScanning}
@click=${this.fullRescan}
>
<wa-icon name="triangle-exclamation"></wa-icon>
Full rescan
</button>
</div>
</div>
</section>
${finished.length > 0
? html`
<section>
<div class="section-head">
<h2>Recently finished</h2>
<button
class="link"
@click=${this.clearFinished}
>
Clear
</button>
</div>
${this.renderJobList(finished, '')}
</section>
`
: nothing}
<job-details-drawer
job-id=${this.drawerJobId}
?open=${this.drawerOpen}
@drawer-closed=${this.onDrawerClosed}
></job-details-drawer>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'jobs-view': JobsView;
}
}
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,7 @@ import { designTokens } from '../../styles/tokens.css';
import type { DragActiveDetail } from '@utils/drag-controller';
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'autotag' | 'settings';
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'autotag' | 'jobs' | 'settings';
interface NavItem {
id: View;
@@ -150,6 +150,7 @@ export class AppSidebar extends LitElement {
{ id: 'tracks', label: 'Tracks', icon: 'music' },
{ id: 'explore', label: 'Explore', icon: 'globe' },
{ id: 'autotag', label: 'Autotag', icon: 'tag' },
{ id: 'jobs', label: 'Jobs', icon: 'list-check' },
{ id: 'settings', label: 'Settings', icon: 'gear' },
];
+3
View File
@@ -61,6 +61,9 @@ export const Events = {
AutotagPrefetchProgress: "AutotagPrefetchProgress",
AutotagPrefetchFinished: "AutotagPrefetchFinished",
// Background job events
JobsChanged: "JobsChanged",
// Explore / search index events
IndexStatusChanged: "IndexStatusChanged",
ArtistDiscographyReady: "ArtistDiscographyReady",
+278
View File
@@ -0,0 +1,278 @@
import { EventsOn } from '@runtime/runtime';
import {
GetJobs,
GetJobLog,
PauseJob,
ResumeJob,
CancelJob,
DismissJob,
ClearFinishedJobs,
} from '@go/jobs/Service';
import type { jobs } from '@go/models';
import { Events } from '../events';
export type Job = jobs.Job;
export type JobLogEntry = jobs.LogEntry;
export type JobStage = jobs.Stage;
/** Lifecycle states a job can be in. Mirrors backend/jobs.State. */
export type JobState =
| 'queued'
| 'running'
| 'pausing'
| 'paused'
| 'cancelling'
| 'complete'
| 'cancelled'
| 'error';
/** Job kinds. Mirrors backend/jobs.Kind. */
export type JobKind = 'library-scan' | 'index-build';
type Subscriber = () => void;
/** States meaning the job will not progress further. */
const TERMINAL_STATES: ReadonlySet<string> = new Set([
'complete',
'cancelled',
'error',
]);
/**
* How long a finished job keeps the indicator visible before it fades
* out. Without this a fast scan would flash on and off, which reads as
* a glitch rather than as progress.
*/
const FINISHED_LINGER_MS = 4000;
export function isTerminal(job: Job): boolean {
return TERMINAL_STATES.has(job.state);
}
export function isActive(job: Job): boolean {
return !isTerminal(job);
}
/** True when a job's progress bar has no meaningful denominator. */
export function isIndeterminate(job: Job): boolean {
return !job.total || job.total <= 0;
}
/** Fractional progress in [0, 1], or null when indeterminate. */
export function progressFraction(job: Job): number | null {
if (isIndeterminate(job)) return null;
return Math.min(1, Math.max(0, job.current / job.total));
}
/**
* Reactive singleton mirroring the backend job registry.
*
* The backend pushes a full snapshot on every JobsChanged event rather
* than a delta, so a component that mounts mid-scan is correct from the
* first event it receives. The initial GetJobs() call only covers the
* window before the first event arrives.
*/
class JobStore {
private jobsValue: Job[] = [];
private logs = new Map<string, JobLogEntry[]>();
private subscribers = new Set<Subscriber>();
private notifyScheduled = false;
/** Timer that clears the lingering "just finished" indicator. */
private lingerTimer: ReturnType<typeof setTimeout> | null = null;
/** Set while a finished job should still keep the indicator up. */
private lingering = false;
private initialized = false;
constructor() {
EventsOn(Events.JobsChanged, (snapshot: Job[]) => {
this.applySnapshot(snapshot ?? []);
});
}
/**
* Fetches the current snapshot once. Safe to call from every
* component's connectedCallback — subsequent calls are no-ops.
*/
async init(): Promise<void> {
if (this.initialized) return;
this.initialized = true;
try {
this.applySnapshot((await GetJobs()) ?? []);
} catch (err) {
console.error('Failed to load background jobs:', err);
}
}
get jobs(): Job[] {
return this.jobsValue;
}
get activeJobs(): Job[] {
return this.jobsValue.filter(isActive);
}
get finishedJobs(): Job[] {
return this.jobsValue.filter(isTerminal);
}
/** Jobs that are running or queued — excludes paused ones. */
get workingJobs(): Job[] {
return this.jobsValue.filter(
(j) => j.state === 'running' || j.state === 'queued',
);
}
get pausedJobs(): Job[] {
return this.jobsValue.filter(
(j) => j.state === 'paused' || j.state === 'pausing',
);
}
get failedJobs(): Job[] {
return this.jobsValue.filter((j) => j.state === 'error');
}
/** Whether anything is in flight, including paused work. */
get hasActive(): boolean {
return this.jobsValue.some(isActive);
}
/**
* Whether the persistent indicator should be shown at all: any
* active job, or a recently finished one still lingering.
*/
get shouldShowIndicator(): boolean {
return this.hasActive || this.lingering;
}
getJob(id: string): Job | undefined {
return this.jobsValue.find((j) => j.id === id);
}
/**
* Returns the cached log for a job, fetching it if not yet loaded.
* Logs are pulled on demand rather than pushed with every snapshot —
* a scan can emit hundreds of warnings, and only the detail pane
* ever renders them.
*/
async loadLog(id: string): Promise<JobLogEntry[]> {
try {
const entries = (await GetJobLog(id)) ?? [];
this.logs.set(id, entries);
this.notify();
return entries;
} catch (err) {
console.error('Failed to load job log:', err);
return [];
}
}
/** Cached log entries for a job, or an empty array if unfetched. */
cachedLog(id: string): JobLogEntry[] {
return this.logs.get(id) ?? [];
}
async pause(id: string): Promise<void> {
await PauseJob(id);
}
async resume(id: string): Promise<void> {
await ResumeJob(id);
}
async cancel(id: string): Promise<void> {
await CancelJob(id);
}
async dismiss(id: string): Promise<void> {
this.logs.delete(id);
await DismissJob(id);
}
async clearFinished(): Promise<void> {
for (const job of this.finishedJobs) {
this.logs.delete(job.id);
}
await ClearFinishedJobs();
}
subscribe(fn: Subscriber): () => void {
this.subscribers.add(fn);
return () => this.subscribers.delete(fn);
}
private applySnapshot(snapshot: Job[]): void {
const hadActive = this.jobsValue.some(isActive);
this.jobsValue = snapshot;
const hasActiveNow = this.hasActive;
// The last active job just finished — keep the indicator up
// briefly so the completion is actually seen.
if (hadActive && !hasActiveNow) {
this.startLinger();
} else if (hasActiveNow) {
this.clearLinger();
}
// Drop cached logs for jobs the backend has forgotten.
const known = new Set(snapshot.map((j) => j.id));
for (const id of this.logs.keys()) {
if (!known.has(id)) this.logs.delete(id);
}
this.notify();
}
private startLinger(): void {
this.lingering = true;
if (this.lingerTimer) clearTimeout(this.lingerTimer);
this.lingerTimer = setTimeout(() => {
this.lingering = false;
this.lingerTimer = null;
this.notify();
}, FINISHED_LINGER_MS);
}
private clearLinger(): void {
this.lingering = false;
if (this.lingerTimer) {
clearTimeout(this.lingerTimer);
this.lingerTimer = null;
}
}
/**
* Coalesces notifications to one per microtask. The backend already
* throttles JobsChanged to 4Hz, but a burst of loadLog resolutions
* can still stack up.
*/
private notify(): void {
if (this.notifyScheduled) return;
this.notifyScheduled = true;
queueMicrotask(() => {
this.notifyScheduled = false;
this.subscribers.forEach((fn) => fn());
});
}
}
export const jobStore = new JobStore();
+7
View File
@@ -2,6 +2,11 @@
// This file is automatically generated. DO NOT EDIT
import {explore} from '../models';
import {context} from '../models';
import {jobs} from '../models';
export function AdoptPausedIndexBuild():Promise<void>;
export function BackfillLibraryDiscographies():Promise<void>;
export function BackfillLibraryLyrics():Promise<void>;
@@ -81,6 +86,8 @@ export function SearchLyrics(arg1:string):Promise<Array<explore.LyricsResult>>;
export function SetContext(arg1:context.Context):Promise<void>;
export function SetJobRegistry(arg1:jobs.Registry):Promise<void>;
export function SimilarArtists(arg1:string):Promise<Array<explore.LBSimilarArtist>>;
export function StartIndexBuild():Promise<void>;
+12
View File
@@ -2,6 +2,14 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function AdoptPausedIndexBuild() {
return window['go']['explore']['Service']['AdoptPausedIndexBuild']();
}
export function BackfillLibraryDiscographies() {
return window['go']['explore']['Service']['BackfillLibraryDiscographies']();
}
export function BackfillLibraryLyrics() {
return window['go']['explore']['Service']['BackfillLibraryLyrics']();
}
@@ -158,6 +166,10 @@ export function SetContext(arg1) {
return window['go']['explore']['Service']['SetContext'](arg1);
}
export function SetJobRegistry(arg1) {
return window['go']['explore']['Service']['SetJobRegistry'](arg1);
}
export function SimilarArtists(arg1) {
return window['go']['explore']['Service']['SimilarArtists'](arg1);
}
+17
View File
@@ -0,0 +1,17 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {jobs} from '../models';
export function CancelJob(arg1:string):Promise<void>;
export function ClearFinishedJobs():Promise<void>;
export function DismissJob(arg1:string):Promise<void>;
export function GetJobLog(arg1:string):Promise<Array<jobs.LogEntry>>;
export function GetJobs():Promise<Array<jobs.Job>>;
export function PauseJob(arg1:string):Promise<void>;
export function ResumeJob(arg1:string):Promise<void>;
+31
View File
@@ -0,0 +1,31 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function CancelJob(arg1) {
return window['go']['jobs']['Service']['CancelJob'](arg1);
}
export function ClearFinishedJobs() {
return window['go']['jobs']['Service']['ClearFinishedJobs']();
}
export function DismissJob(arg1) {
return window['go']['jobs']['Service']['DismissJob'](arg1);
}
export function GetJobLog(arg1) {
return window['go']['jobs']['Service']['GetJobLog'](arg1);
}
export function GetJobs() {
return window['go']['jobs']['Service']['GetJobs']();
}
export function PauseJob(arg1) {
return window['go']['jobs']['Service']['PauseJob'](arg1);
}
export function ResumeJob(arg1) {
return window['go']['jobs']['Service']['ResumeJob'](arg1);
}
+5
View File
@@ -3,6 +3,7 @@
import {sqlcgen} from '../models';
import {library} from '../models';
import {context} from '../models';
import {jobs} from '../models';
export function AcquirePipelineLock():Promise<void>;
@@ -66,6 +67,8 @@ export function RemoveLibrary(arg1:number):Promise<library.RemovalSummary>;
export function RenameLibrary(arg1:number,arg2:string):Promise<void>;
export function RestorePausedScans():Promise<void>;
export function ResumeScan():Promise<void>;
export function ScanAllLibraries():Promise<void>;
@@ -78,6 +81,8 @@ export function SearchTracksByLibrary(arg1:string,arg2:number):Promise<Array<lib
export function SetContext(arg1:context.Context):Promise<void>;
export function SetJobRegistry(arg1:jobs.Registry):Promise<void>;
export function SetRemovalHooks(arg1:library.RemovalHooks):Promise<void>;
export function SetRescanHooks(arg1:library.RescanHooks):Promise<void>;
+8
View File
@@ -126,6 +126,10 @@ export function RenameLibrary(arg1, arg2) {
return window['go']['library']['Library']['RenameLibrary'](arg1, arg2);
}
export function RestorePausedScans() {
return window['go']['library']['Library']['RestorePausedScans']();
}
export function ResumeScan() {
return window['go']['library']['Library']['ResumeScan']();
}
@@ -150,6 +154,10 @@ export function SetContext(arg1) {
return window['go']['library']['Library']['SetContext'](arg1);
}
export function SetJobRegistry(arg1) {
return window['go']['library']['Library']['SetJobRegistry'](arg1);
}
export function SetRemovalHooks(arg1) {
return window['go']['library']['Library']['SetRemovalHooks'](arg1);
}
+148
View File
@@ -777,6 +777,154 @@ export namespace explore {
}
export namespace jobs {
export class Caps {
pausable: boolean;
cancellable: boolean;
static createFrom(source: any = {}) {
return new Caps(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pausable = source["pausable"];
this.cancellable = source["cancellable"];
}
}
export class Stat {
label: string;
value: string;
static createFrom(source: any = {}) {
return new Stat(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.label = source["label"];
this.value = source["value"];
}
}
export class Stage {
name: string;
state: string;
current: number;
total: number;
error?: string;
static createFrom(source: any = {}) {
return new Stage(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.state = source["state"];
this.current = source["current"];
this.total = source["total"];
this.error = source["error"];
}
}
export class Job {
id: string;
kind: string;
title: string;
subtitle?: string;
state: string;
phase?: string;
current: number;
total: number;
caps: Caps;
stages: Stage[];
stats: Stat[];
error?: string;
startedAt: number;
updatedAt: number;
endedAt?: number;
logCount: number;
warnCount: number;
errorCount: number;
static createFrom(source: any = {}) {
return new Job(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.kind = source["kind"];
this.title = source["title"];
this.subtitle = source["subtitle"];
this.state = source["state"];
this.phase = source["phase"];
this.current = source["current"];
this.total = source["total"];
this.caps = this.convertValues(source["caps"], Caps);
this.stages = this.convertValues(source["stages"], Stage);
this.stats = this.convertValues(source["stats"], Stat);
this.error = source["error"];
this.startedAt = source["startedAt"];
this.updatedAt = source["updatedAt"];
this.endedAt = source["endedAt"];
this.logCount = source["logCount"];
this.warnCount = source["warnCount"];
this.errorCount = source["errorCount"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class LogEntry {
time: number;
level: string;
message: string;
detail?: string;
static createFrom(source: any = {}) {
return new LogEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.time = source["time"];
this.level = source["level"];
this.message = source["message"];
this.detail = source["detail"];
}
}
export class Registry {
static createFrom(source: any = {}) {
return new Registry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
}
}
}
export namespace library {
export class Album {
+2
View File
@@ -44,6 +44,8 @@ export function ImportPlaylist(arg1:string):Promise<playlist.Summary>;
export function ImportPlaylists(arg1:Array<string>):Promise<Array<playlist.Summary>>;
export function MaterializeUnmaterializedSmartPlaylists():Promise<void>;
export function PreviewSmartPlaylist(arg1:string):Promise<Array<library.Track>>;
export function RefreshSmartPlaylist(arg1:number):Promise<void>;
+4
View File
@@ -82,6 +82,10 @@ export function ImportPlaylists(arg1) {
return window['go']['playlist']['Service']['ImportPlaylists'](arg1);
}
export function MaterializeUnmaterializedSmartPlaylists() {
return window['go']['playlist']['Service']['MaterializeUnmaterializedSmartPlaylists']();
}
export function PreviewSmartPlaylist(arg1) {
return window['go']['playlist']['Service']['PreviewSmartPlaylist'](arg1);
}