diff --git a/.gitea/workflows/index-artifact.yml b/.gitea/workflows/index-artifact.yml new file mode 100644 index 0000000..a30aa97 --- /dev/null +++ b/.gitea/workflows/index-artifact.yml @@ -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 diff --git a/.planning/plans/pending/001-ship-core-index.md b/.planning/plans/pending/001-ship-core-index.md new file mode 100644 index 0000000..35f29c0 --- /dev/null +++ b/.planning/plans/pending/001-ship-core-index.md @@ -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 diff --git a/Makefile b/Makefile index 5ef4bf1..ff7737b 100644 --- a/Makefile +++ b/Makefile @@ -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)" diff --git a/backend/app.go b/backend/app.go index e82e8c4..da4e83d 100644 --- a/backend/app.go +++ b/backend/app.go @@ -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) diff --git a/backend/database/sql/schemas/job_state.sql b/backend/database/sql/schemas/job_state.sql new file mode 100644 index 0000000..6a5caee --- /dev/null +++ b/backend/database/sql/schemas/job_state.sql @@ -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 +); diff --git a/backend/database/sql/sqlcgen/models.go b/backend/database/sql/sqlcgen/models.go index 9f86982..ff7ab29 100644 --- a/backend/database/sql/sqlcgen/models.go +++ b/backend/database/sql/sqlcgen/models.go @@ -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 diff --git a/backend/events/events.go b/backend/events/events.go index cbe1908..10944f5 100644 --- a/backend/events/events.go +++ b/backend/events/events.go @@ -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" diff --git a/backend/explore/explore.go b/backend/explore/explore.go index e368f4c..4107515 100644 --- a/backend/explore/explore.go +++ b/backend/explore/explore.go @@ -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() diff --git a/backend/explore/indexjob.go b/backend/explore/indexjob.go new file mode 100644 index 0000000..e67f8ae --- /dev/null +++ b/backend/explore/indexjob.go @@ -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") +} diff --git a/backend/explore/searchindex.go b/backend/explore/searchindex.go index 354f417..f2c7fa4 100644 --- a/backend/explore/searchindex.go +++ b/backend/explore/searchindex.go @@ -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 diff --git a/backend/jobs/jobs.go b/backend/jobs/jobs.go new file mode 100644 index 0000000..88d58f6 --- /dev/null +++ b/backend/jobs/jobs.go @@ -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() +} diff --git a/backend/jobs/jobs_test.go b/backend/jobs/jobs_test.go new file mode 100644 index 0000000..8f37535 --- /dev/null +++ b/backend/jobs/jobs_test.go @@ -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) + } +} diff --git a/backend/jobs/service.go b/backend/jobs/service.go new file mode 100644 index 0000000..506f240 --- /dev/null +++ b/backend/jobs/service.go @@ -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() +} diff --git a/backend/jobs/store.go b/backend/jobs/store.go new file mode 100644 index 0000000..2afbcdd --- /dev/null +++ b/backend/jobs/store.go @@ -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() +} diff --git a/backend/library/library.go b/backend/library/library.go index d77cbcb..fe560b8 100644 --- a/backend/library/library.go +++ b/backend/library/library.go @@ -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, diff --git a/backend/library/metrics.go b/backend/library/metrics.go index 9d552a4..7973801 100644 --- a/backend/library/metrics.go +++ b/backend/library/metrics.go @@ -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 diff --git a/backend/library/scan_control.go b/backend/library/scan_control.go index 5a8b311..b36f698 100644 --- a/backend/library/scan_control.go +++ b/backend/library/scan_control.go @@ -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. diff --git a/backend/library/scan_jobs.go b/backend/library/scan_jobs.go new file mode 100644 index 0000000..b57c4a7 --- /dev/null +++ b/backend/library/scan_jobs.go @@ -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)) +} diff --git a/backend/library/scan_queue.go b/backend/library/scan_queue.go index 3c3e3b4..f08a004 100644 --- a/backend/library/scan_queue.go +++ b/backend/library/scan_queue.go @@ -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, ) diff --git a/backend/system/userdata.go b/backend/system/userdata.go index c7ffad2..39dde59 100644 --- a/backend/system/userdata.go +++ b/backend/system/userdata.go @@ -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 /; +// 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 diff --git a/backend/system/userdata_test.go b/backend/system/userdata_test.go new file mode 100644 index 0000000..b24abc0 --- /dev/null +++ b/backend/system/userdata_test.go @@ -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 "/config" base dir. + if !filepath.IsAbs(got) || !strings.HasSuffix(got, "yellowjacket") { + t.Errorf( + "resolveUserDirPath fallback = %q, want absolute path ending in %q", + got, "yellowjacket", + ) + } +} diff --git a/cmd/indexbuild/decide_test.go b/cmd/indexbuild/decide_test.go new file mode 100644 index 0000000..088fa9f --- /dev/null +++ b/cmd/indexbuild/decide_test.go @@ -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") + } + }) + } +} diff --git a/cmd/indexbuild/main.go b/cmd/indexbuild/main.go new file mode 100644 index 0000000..69f2b59 --- /dev/null +++ b/cmd/indexbuild/main.go @@ -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) +} diff --git a/cmd/indexexport/main.go b/cmd/indexexport/main.go new file mode 100644 index 0000000..89c14ff --- /dev/null +++ b/cmd/indexexport/main.go @@ -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 +} diff --git a/frontend/index.html b/frontend/index.html index c494472..c76149d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -24,6 +24,7 @@ + ` @@ -2656,145 +2076,6 @@ export class ConfigPage extends LitElement { @config-change=${this.handleConcurrencyChange} > -
- ${this.scanPaused - ? 'Scan paused.' - : this.statusMessage || 'Ready.'} -
- - ${this.scanErrors - ? html` -
-
- - Scan Errors - - -
-
-
${this.scanErrors}
-
-
- ` - : ''} - - ${this.renderMetrics()} - ${this.showCancelDialog - ? html` -
-
e.stopPropagation()} - > - ${this.scanQueuedCount > 0 - ? html` -
- Cancel Scanning -
-
- A scan is in - progress with - ${this - .scanQueuedCount} - ${this - .scanQueuedCount === - 1 - ? 'library' - : 'libraries'} - still queued. -
-
- - - -
- ` - : html` -
- Cancel Scan -
-
- ${this - .cancelMetrics - ?.added - ? `Keep ${this.cancelMetrics.added} tracks found so far, or discard?` - : 'Cancel the current scan?'} -
-
- - - -
- `} -
-
- ` - : nothing} - ${this.removingLibraryId !== null && this.removalImpact ? html`
- ${label} - ${value} -
- `; - } - - private renderMetrics() { - const m = this.metrics; - - if (!m) return nothing; - - const formatEntries = Object.entries( - m.formatExtraction ?? {}, - ).sort(([, a], [, b]) => b - a); - - const pureDb = Math.max( - 0, - m.batchCommits - m.coverArtSave, - ); - - return html` -
-
-

- Scan Results -

- -
- - ${this.renderMetricRow('Total', fmtNs(m.total), true)} - -
- File Counts -
- Added - ${m.added} - Updated - ${m.updated} - Skipped - ${m.skipped} - Removed - ${m.removed} -
-
- - ${this.hasRescanPhases - ? html` -
- - Clear Phases - - ${this.renderMetricRow('Clear Queue', fmtNs(m.clearQueue))} - ${this.renderMetricRow('Clear Database', fmtNs(m.clearDatabase))} - ${this.renderMetricRow('Clear Cover Files', fmtNs(m.clearCoverFiles))} -
- ` - : nothing} - - ${this.renderMetricRow('Load Existing Files', fmtNs(m.loadExisting))} - ${this.renderMetricRow('Directory Walk', fmtNs(m.walkDuration))} - -
- - Metadata Extraction — - ${fmtNs(m.extractionWallClock)} - wall-clock - -

- Per-format and per-operation times - are cumulative across - ${Object.values( - m.formatCount ?? {}, - ).reduce((a, b) => a + b, 0)} - files -

- - ${formatEntries.length > 0 - ? html` -
- - By Format - - ${formatEntries.map( - ([ext, ms]) => - this.renderMetricRow( - `${ext} (${m.formatCount?.[ext] ?? 0} files)`, - fmtMs( - ms, - ), - ), - )} -
- ` - : nothing} - -
- By Operation - ${this.renderMetricRow('Tag Extraction', fmtNs(m.tagExtraction))} - ${this.renderMetricRow('Duration Extraction', fmtNs(m.durationExtraction))} -
-
- -
- - Database Writes — - ${fmtNs(m.dbWritesWallClock)} - wall-clock - - ${this.renderMetricRow('Batch Commits', fmtNs(m.batchCommits))} - ${this.renderMetricRow('Pure DB Operations', fmtNs(pureDb))} - ${this.renderMetricRow('Save Cover Originals', fmtNs(m.coverArtSave))} -
- -
- - Thumbnail Generation — - ${fmtNs(m.thumbnailWallClock)} - wall-clock - -

- Generated concurrently; cumulative - CPU time may exceed wall-clock -

- ${this.renderMetricRow('Cumulative CPU Time', fmtNs(m.thumbnailGeneration))} - ${this.renderMetricRow('Small (_sm)', fmtNs(m.thumbnailSmall))} - ${this.renderMetricRow('Medium (_md)', fmtNs(m.thumbnailMedium))} - ${this.renderMetricRow('Large (_lg)', fmtNs(m.thumbnailLarge))} -
- - ${this.renderMetricRow('Orphan Cleanup', fmtNs(m.orphanCleanup))} - ${this.renderMetricRow('Post-Scan Variants', fmtNs(m.postScanVariants))} -
- `; - } } declare global { diff --git a/frontend/src/components/first-run-wizard/first-run-wizard.ts b/frontend/src/components/first-run-wizard/first-run-wizard.ts new file mode 100644 index 0000000..3c0b8a3 --- /dev/null +++ b/frontend/src/components/first-run-wizard/first-run-wizard.ts @@ -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 { + 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` + +
+ +

Welcome to YellowJacket

+

+ Choose the folder where your music lives to + get started. +

+
+ +
+ + ${this.selectedDirectory + ? html`${this.selectedDirectory}` + : html`No folder selected yet`} +
+ + ${this.errorMessage + ? html`
${this.errorMessage}
` + : nothing} + +
+ + +
+
+ `; + } + + /** 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 => { + 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 => { + 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; + } +} diff --git a/frontend/src/components/jobs/job-controls.ts b/frontend/src/components/jobs/job-controls.ts new file mode 100644 index 0000000..8b94368 --- /dev/null +++ b/frontend/src/components/jobs/job-controls.ts @@ -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 ``. + * + * 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 { + 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.', + ); +} diff --git a/frontend/src/components/jobs/job-details-drawer.ts b/frontend/src/components/jobs/job-details-drawer.ts new file mode 100644 index 0000000..71cf7c7 --- /dev/null +++ b/frontend/src/components/jobs/job-details-drawer.ts @@ -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 | 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) { + 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` +
+
Stages
+
+ ${job.stages.map( + (stage) => html` +
+ + ${stage.name} + + ${stage.total > 0 + ? `${stage.current.toLocaleString()} / ${stage.total.toLocaleString()}` + : stage.state} + + ${stage.error + ? html`
+ ${stage.error} +
` + : nothing} +
+ `, + )} +
+
+ `; + } + + 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` +
+
Summary
+
+ ${items.map( + (item) => html` +
+ ${item.label} + ${item.value} +
+ `, + )} +
+
+ `; + } + + override render() { + const job = this.job; + + return html` + + ${job + ? html` +
+ ${job.subtitle + ? html`
+ ${job.subtitle} +
` + : nothing} + + + + ${this.renderSummary(job)} + ${this.renderStages(job)} + +
+
Output
+ +
+
+ ` + : html`

This job is no longer available.

`} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'job-details-drawer': JobDetailsDrawer; + } +} diff --git a/frontend/src/components/jobs/job-format.ts b/frontend/src/components/jobs/job-format.ts new file mode 100644 index 0000000..1bacd9f --- /dev/null +++ b/frontend/src/components/jobs/job-format.ts @@ -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); + } +`; diff --git a/frontend/src/components/jobs/job-indicator.ts b/frontend/src/components/jobs/job-indicator.ts new file mode 100644 index 0000000..aa2f546 --- /dev/null +++ b/frontend/src/components/jobs/job-indicator.ts @@ -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` +
+ +
+ ${activeCount > 1 + ? activeCount + : html``} +
+
+ `; + } + + 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` + + `; + } + + private renderPanel() { + const finished = jobStore.finishedJobs; + + return html` + + `; + } + + override render() { + return html` + + ${this.renderTrigger()} + ${this.popoverOpen ? this.renderPanel() : nothing} + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'job-indicator': JobIndicator; + } +} diff --git a/frontend/src/components/jobs/job-log-view.ts b/frontend/src/components/jobs/job-log-view.ts new file mode 100644 index 0000000..b4ffbfb --- /dev/null +++ b/frontend/src/components/jobs/job-log-view.ts @@ -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` + + `; + } + + override render() { + const entries = this.filtered; + const dropped = this.droppedCount; + + return html` +
+
+ ${this.renderFilterButton('all', 'All')} + ${this.renderFilterButton( + 'warn', + `Warnings${this.job?.warnCount ? ` (${this.job.warnCount})` : ''}`, + )} + ${this.renderFilterButton( + 'error', + `Errors${this.job?.errorCount ? ` (${this.job.errorCount})` : ''}`, + )} +
+
+ +
+ +
+ ${dropped > 0 + ? html` +
+ ${dropped.toLocaleString()} earlier + ${dropped === 1 ? 'entry' : 'entries'} dropped — + showing the most recent output +
+ ` + : nothing} + ${entries.length === 0 + ? html`
No output yet.
` + : entries.map( + (entry) => html` +
+ ${formatLogTime(entry)} + ${entry.message} +
+ ${entry.detail + ? html`
+ ${entry.detail} +
` + : nothing} + `, + )} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'job-log-view': JobLogView; + } +} diff --git a/frontend/src/components/jobs/job-row.ts b/frontend/src/components/jobs/job-row.ts new file mode 100644 index 0000000..5cf1a9e --- /dev/null +++ b/frontend/src/components/jobs/job-row.ts @@ -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` +
+ `; + } + + return html` + + `; + } + + private renderStats() { + if (this.variant !== 'full') return nothing; + if (!this.job.stats?.length) return nothing; + + return html` +
+ ${this.job.stats.map( + (stat) => html` +
+ ${stat.label} + ${stat.value} +
+ `, + )} +
+ `; + } + + private renderControls() { + const job = this.job; + + // A finished job offers only dismissal. + if (isTerminal(job)) { + return html` + + `; + } + + const paused = job.state === 'paused'; + const settling = job.state === 'pausing' || job.state === 'cancelling'; + + return html` + ${job.caps.pausable + ? html` + + ` + : nothing} + ${job.caps.cancellable + ? html` + + ` + : 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` +
+
+ +
+ +
+
${job.title}
+
+ ${statusLine(job)}${elapsed} +
+ ${this.renderProgress()} ${this.renderStats()} + ${job.error + ? html`
${job.error}
` + : nothing} +
+ +
${this.renderControls()}
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'job-row': JobRow; + } +} diff --git a/frontend/src/components/jobs/jobs-view.ts b/frontend/src/components/jobs/jobs-view.ts new file mode 100644 index 0000000..ee4b692 --- /dev/null +++ b/frontend/src/components/jobs/jobs-view.ts @@ -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 = 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 { + 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`
+
${emptyText}
+
`; + } + + return html` +
+ ${list.map( + (job) => html` +
+ + +
+ `, + )} +
+ `; + } + + /** 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` +
+
+
${lib.name}
+
+ ${lib.trackCount.toLocaleString()} tracks · ${lib.path} +
+ ${status + ? html`
${status}
` + : nothing} +
+ + ${busy + ? html` + + ` + : html` + + `} +
+ `; + } + + 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` +

Background jobs

+

+ Library scans and search index builds, with their progress and + output. +

+ +
+
+

Running now

+
+ ${this.renderJobList(active, 'Nothing is running.')} +
+ +
+
+

Libraries

+ +
+ +
+ ${this.libraries.length === 0 + ? html`
+ No libraries yet — add one in Settings. +
` + : this.libraries.map((lib) => + this.renderLibraryRow(lib), + )} +
+
+ +
+
+

Maintenance

+
+
+
+
+
Full rescan
+
+ 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. +
+
+ +
+
+
+ + ${finished.length > 0 + ? html` +
+
+

Recently finished

+ +
+ ${this.renderJobList(finished, '')} +
+ ` + : nothing} + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'jobs-view': JobsView; + } +} diff --git a/frontend/src/components/library-manager/library-manager.ts b/frontend/src/components/library-manager/library-manager.ts deleted file mode 100644 index 00d2cae..0000000 --- a/frontend/src/components/library-manager/library-manager.ts +++ /dev/null @@ -1,1335 +0,0 @@ -import { LitElement, html, css, nothing } from 'lit'; -import { customElement, state } from 'lit/decorators.js'; -import { EventsOn } from '@runtime/runtime'; -import { - FullRescan, - ScanAllLibraries, -} from '@go/library/Library'; -import { - GetLibraryDirectory, - SetLibraryDirectory, - GetScanConcurrency, - SetScanConcurrency, -} from '@go/config/Config'; -import { DirectoryPicker } from '@go/frontendutil/FrontendUtil'; -import { Events } from '../../events'; - -/** Go time.Duration serialises as nanoseconds. */ -const NS_PER_MS = 1_000_000; - -/** - * Shape of the ScanMetrics struct emitted by the backend. - * All duration fields are nanoseconds (Go time.Duration JSON). - * FormatExtraction values are milliseconds (int64 set from Go). - */ -interface ScanProgress { - phase: 'counting' | 'scanning' | 'orphans' | 'thumbnails'; - total: number; - processed: number; - added: number; - skipped: number; - updated: number; - libraryId: number; - libraryName: string; - queuedCount: number; -} - -interface ScanMetrics { - total: number; - loadExisting: number; - walkDuration: number; - extractionWallClock: number; - dbWritesWallClock: number; - orphanCleanup: number; - postScanVariants: number; - formatExtraction: Record; - formatCount: Record; - tagExtraction: number; - durationExtraction: number; - batchCommits: number; - coverArtSave: number; - thumbnailWallClock: number; - thumbnailGeneration: number; - thumbnailSmall: number; - thumbnailMedium: number; - thumbnailLarge: number; - clearQueue: number; - clearDatabase: number; - clearCoverFiles: number; - added: number; - updated: number; - skipped: number; - removed: number; -} - -/** Format nanoseconds into a human-readable duration. */ -function fmtNs(ns: number): string { - if (ns <= 0) return '<1ms'; - - const ms = ns / NS_PER_MS; - - if (ms < 1) return '<1ms'; - if (ms < 1000) return `${ms.toFixed(0)}ms`; - - const s = ms / 1000; - - if (s < 60) return `${s.toFixed(2)}s`; - - const m = Math.floor(s / 60); - const rem = s % 60; - - return `${m}m ${rem.toFixed(1)}s`; -} - -/** Format milliseconds (used for formatExtraction which stores ms). */ -function fmtMs(ms: number): string { - if (ms <= 0) return '<1ms'; - if (ms < 1000) return `${ms.toFixed(0)}ms`; - - const s = ms / 1000; - - if (s < 60) return `${s.toFixed(2)}s`; - - const m = Math.floor(s / 60); - const rem = s % 60; - - return `${m}m ${rem.toFixed(1)}s`; -} - -/** - * Build a plain-text representation of scan metrics suitable for - * pasting into a chat, issue tracker, or notes. - */ -function formatMetricsText(m: ScanMetrics): string { - const lines: string[] = []; - const pad = (label: string, value: string) => - ` ${label.padEnd(28)} ${value}`; - - lines.push(`Scan Results`); - lines.push(`${'='.repeat(42)}`); - lines.push(pad('Total', fmtNs(m.total))); - lines.push(''); - - // File counts. - lines.push('File Counts'); - lines.push( - pad('Added', String(m.added)), - pad('Updated', String(m.updated)), - pad('Skipped', String(m.skipped)), - pad('Removed', String(m.removed)), - ); - lines.push(''); - - // Clear phases (full rescan only). - if ( - m.clearQueue > 0 || - m.clearDatabase > 0 || - m.clearCoverFiles > 0 - ) { - lines.push('Clear Phases'); - lines.push( - pad('Clear Queue', fmtNs(m.clearQueue)), - pad('Clear Database', fmtNs(m.clearDatabase)), - pad( - 'Clear Cover Files', - fmtNs(m.clearCoverFiles), - ), - ); - lines.push(''); - } - - lines.push( - pad('Load Existing Files', fmtNs(m.loadExisting)), - ); - lines.push( - pad('Directory Walk', fmtNs(m.walkDuration)), - ); - lines.push(''); - - // Metadata extraction. - const totalFiles = Object.values( - m.formatCount ?? {}, - ).reduce((a, b) => a + b, 0); - - lines.push( - `Metadata Extraction -- ${fmtNs(m.extractionWallClock)} wall-clock`, - ); - lines.push( - ` (cumulative across ${totalFiles} files)`, - ); - - const formatEntries = Object.entries( - m.formatExtraction ?? {}, - ).sort(([, a], [, b]) => b - a); - - if (formatEntries.length > 0) { - lines.push(' By Format'); - - for (const [ext, ms] of formatEntries) { - const count = m.formatCount?.[ext] ?? 0; - - lines.push( - pad( - `${ext} (${count} files)`, - fmtMs(ms), - ), - ); - } - } - - lines.push(' By Operation'); - lines.push( - pad('Tag Extraction', fmtNs(m.tagExtraction)), - ); - lines.push( - pad( - 'Duration Extraction', - fmtNs(m.durationExtraction), - ), - ); - lines.push(''); - - // Database writes. - const pureDb = Math.max( - 0, - m.batchCommits - m.coverArtSave, - ); - - lines.push( - `Database Writes -- ${fmtNs(m.dbWritesWallClock)} wall-clock`, - ); - lines.push( - pad('Batch Commits', fmtNs(m.batchCommits)), - ); - lines.push(pad('Pure DB Operations', fmtNs(pureDb))); - lines.push( - pad('Save Cover Originals', fmtNs(m.coverArtSave)), - ); - lines.push(''); - - // Thumbnails. - lines.push( - `Thumbnail Generation -- ${fmtNs(m.thumbnailWallClock)} wall-clock`, - ); - lines.push( - pad( - 'Cumulative CPU Time', - fmtNs(m.thumbnailGeneration), - ), - ); - lines.push( - pad('Small (_sm)', fmtNs(m.thumbnailSmall)), - ); - lines.push( - pad('Medium (_md)', fmtNs(m.thumbnailMedium)), - ); - lines.push( - pad('Large (_lg)', fmtNs(m.thumbnailLarge)), - ); - lines.push(''); - - lines.push( - pad('Orphan Cleanup', fmtNs(m.orphanCleanup)), - ); - lines.push( - pad( - 'Post-Scan Variants', - fmtNs(m.postScanVariants), - ), - ); - - return lines.join('\n'); -} - -@customElement('library-manager') -export class LibraryManager extends LitElement { - @state() private libraryDirectory = ''; - @state() private selectedDirectory = ''; - @state() private scanning = false; - @state() private statusMessage = ''; - @state() private scanProgress: ScanProgress | null = null; - @state() private metrics: ScanMetrics | null = null; - @state() private copied = false; - @state() private errorsCopied = false; - @state() private scanErrors = ''; - @state() private concurrencyMode = 'auto'; - @state() private scanQueuedCount = 0; - private cancelScanStarted?: () => void; - private cancelScanProgress?: () => void; - private cancelScanComplete?: () => void; - private cancelScanQueued?: () => void; - private cancelScanQueueDrained?: () => void; - - static override styles = css` - :host { - display: block; - padding: 1.5em; - color: var(--yj-text-primary, #e9ecef); - font-family: system-ui, -apple-system, sans-serif; - overflow-y: auto; - } - - h2 { - margin: 0 0 1em 0; - font-size: 1.4em; - font-weight: 600; - color: var(--yj-text-primary, #f8f9fa); - } - - .section { - margin-bottom: 2em; - padding: 1.25em; - background: var(--yj-bg-surface, #2b3035); - border-radius: 8px; - } - - .section-title { - margin: 0 0 0.75em 0; - font-size: 1em; - font-weight: 600; - color: var(--yj-text-primary, #dee2e6); - } - - .section-header { - display: flex; - align-items: center; - justify-content: space-between; - margin: 0 0 0.75em 0; - } - - .section-header .section-title { - margin: 0; - } - - .section-description { - margin: 0 0 1em 0; - font-size: 0.85em; - color: var(--yj-text-tertiary, #868e96); - line-height: 1.4; - } - - .directory-row { - display: flex; - align-items: center; - gap: 0.75em; - margin-bottom: 1em; - } - - .directory-path { - flex: 1; - padding: 0.5em 0.75em; - background: var(--yj-bg-elevated, #1a1d20); - border: 1px solid var(--yj-bg-overlay, #495057); - border-radius: 4px; - color: var(--yj-text-secondary, #adb5bd); - font-size: 0.85em; - font-family: monospace; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-height: 1.2em; - } - - .directory-path.has-value { - color: var(--yj-text-primary, #e9ecef); - } - - button { - padding: 0.5em 1.25em; - border: none; - border-radius: 4px; - font-size: 0.85em; - font-weight: 500; - cursor: pointer; - transition: background-color 0.15s ease; - white-space: nowrap; - } - - button:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - .btn-primary { - background: var(--yj-info, #4263eb); - color: white; - } - - .btn-primary:hover:not(:disabled) { - background: var(--yj-info-hover, #3b5bdb); - } - - .btn-success { - background: var(--yj-success, #2f9e44); - color: white; - } - - .btn-success:hover:not(:disabled) { - background: var(--yj-success-hover, #2b8a3e); - } - - .btn-warning { - background: var(--yj-warning, #e8590c); - color: white; - } - - .btn-warning:hover:not(:disabled) { - background: var(--yj-warning-hover, #d9480f); - } - - .btn-danger { - background: var(--yj-error, #e03131); - color: white; - } - - .btn-danger:hover:not(:disabled) { - background: var(--yj-error-hover, #c92a2a); - } - - .btn-ghost { - background: transparent; - color: var(--yj-text-tertiary, #868e96); - padding: 0.3em 0.75em; - font-size: 0.75em; - border: 1px solid var(--yj-bg-overlay, #495057); - } - - .btn-ghost:hover:not(:disabled) { - background: var(--yj-bg-overlay, #495057); - color: var(--yj-text-primary, #e9ecef); - } - - .btn-ghost.copied { - border-color: var(--yj-success, #2f9e44); - color: var(--yj-success, #2f9e44); - } - - .setting-row { - display: flex; - align-items: center; - gap: 1em; - font-size: 0.85em; - } - - .setting-row label { - color: var(--yj-text-secondary, #adb5bd); - min-width: 8em; - } - - .setting-row select { - padding: 0.4em 0.6em; - background: var(--yj-bg-elevated, #1a1d20); - border: 1px solid var(--yj-bg-overlay, #495057); - border-radius: 4px; - color: var(--yj-text-primary, #e9ecef); - font-size: 1em; - font-family: inherit; - cursor: pointer; - color-scheme: dark; - } - - .setting-row select:focus { - outline: none; - border-color: var(--yj-info, #4263eb); - } - - .setting-row select option { - background: var(--yj-bg-surface, #2b3035); - color: var(--yj-text-primary, #e9ecef); - } - - .scan-actions { - display: flex; - gap: 0.75em; - flex-wrap: wrap; - } - - .status-bar { - margin-top: 1.5em; - padding: 0.75em 1em; - background: var(--yj-bg-elevated, #1a1d20); - border-radius: 4px; - font-size: 0.85em; - color: var(--yj-text-tertiary, #868e96); - min-height: 1.2em; - } - - .status-bar.active { - color: var(--yj-accent, #ffd43b); - } - - /* Progress bar */ - .progress-info { - display: flex; - align-items: baseline; - gap: 0.5em; - margin-bottom: 0.5em; - } - - .progress-label { - font-weight: 500; - } - - .progress-detail { - color: var(--yj-text-tertiary, #868e96); - font-size: 0.95em; - } - - .progress-percent { - margin-left: auto; - font-variant-numeric: tabular-nums; - } - - .progress-phase { - font-weight: 500; - } - - .progress-track { - height: 6px; - background: var(--yj-bg-base, #1a1b1e); - border-radius: 3px; - overflow: hidden; - } - - .progress-fill { - height: 100%; - background: var(--yj-accent, #ffd43b); - border-radius: 3px; - transition: width 300ms ease; - } - - /* --- Error block --- */ - .error-block { - margin-top: 1em; - border: 1px solid var(--yj-error, #e03131); - border-radius: 4px; - overflow: hidden; - } - - .error-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.5em 1em; - background: color-mix( - in srgb, - var(--yj-error, #e03131) 15%, - var(--yj-bg-elevated, #1a1d20) - ); - } - - .error-title { - font-size: 0.8em; - font-weight: 600; - color: var(--yj-error, #e03131); - } - - .error-body { - max-height: 200px; - overflow-y: auto; - padding: 0.75em 1em; - background: var(--yj-bg-elevated, #1a1d20); - } - - .error-body pre { - margin: 0; - font-size: 0.8em; - font-family: inherit; - white-space: pre-wrap; - word-break: break-word; - color: var(--yj-text-secondary, #adb5bd); - line-height: 1.6; - } - - /* --- Metrics tree --- */ - .metrics-section { - margin-top: 1.5em; - } - - details { - margin-left: 1em; - } - - details.root { - margin-left: 0; - } - - summary { - cursor: pointer; - padding: 0.25em 0; - font-size: 0.85em; - color: var(--yj-text-secondary, #ced4da); - list-style: none; - } - - summary::-webkit-details-marker { - display: none; - } - - summary::before { - content: '\\25B6'; - display: inline-block; - width: 1em; - font-size: 0.6em; - vertical-align: middle; - transition: transform 0.15s ease; - margin-right: 0.35em; - } - - details[open] > summary::before { - transform: rotate(90deg); - } - - .metric-row { - display: flex; - justify-content: space-between; - padding: 0.2em 0; - padding-left: 1.35em; - font-size: 0.85em; - } - - .metric-label { - color: var(--yj-text-secondary, #adb5bd); - } - - .metric-value { - color: var(--yj-text-primary, #e9ecef); - font-family: monospace; - font-weight: 500; - } - - .metric-value.highlight { - color: var(--yj-accent, #ffd43b); - } - - .metric-note { - color: var(--yj-text-tertiary, #868e96); - font-size: 0.75em; - font-style: italic; - padding-left: 1.35em; - } - - .counts-grid { - display: grid; - grid-template-columns: repeat(4, auto); - gap: 0.25em 1.5em; - padding-left: 1.35em; - font-size: 0.85em; - } - - .count-label { - color: var(--yj-text-secondary, #adb5bd); - } - - .count-value { - color: var(--yj-text-primary, #e9ecef); - font-family: monospace; - } - `; - - override connectedCallback(): void { - super.connectedCallback(); - this.loadCurrentDirectory(); - this.loadConcurrencyMode(); - - this.cancelScanStarted = EventsOn( - Events.LibraryScanStarted, - this.handleScanStarted, - ); - this.cancelScanProgress = EventsOn( - Events.LibraryScanProgress, - this.handleScanProgress, - ); - this.cancelScanComplete = EventsOn( - Events.LibraryScanComplete, - this.handleScanComplete, - ); - this.cancelScanQueued = EventsOn( - Events.LibraryScanQueued, - this.handleScanQueued, - ); - this.cancelScanQueueDrained = EventsOn( - Events.LibraryScanQueueDrained, - this.handleScanQueueDrained, - ); - } - - override disconnectedCallback(): void { - super.disconnectedCallback(); - this.cancelScanStarted?.(); - this.cancelScanProgress?.(); - this.cancelScanComplete?.(); - this.cancelScanQueued?.(); - this.cancelScanQueueDrained?.(); - } - - private async loadCurrentDirectory(): Promise { - try { - const dir = await GetLibraryDirectory(); - this.libraryDirectory = dir; - this.selectedDirectory = dir; - } catch (err) { - console.error( - 'Failed to load library directory:', - err, - ); - } - } - - private async loadConcurrencyMode(): Promise { - try { - this.concurrencyMode = - await GetScanConcurrency(); - } catch (err) { - console.error( - 'Failed to load scan concurrency:', - err, - ); - } - } - - private handleConcurrencyChange = async ( - e: Event, - ): Promise => { - const select = e.target as HTMLSelectElement; - const mode = select.value; - - try { - await SetScanConcurrency(mode); - this.concurrencyMode = mode; - this.statusMessage = - 'Storage type saved. Takes effect on next scan.'; - } catch (err) { - this.statusMessage = `Failed to save storage type: ${err}`; - console.error( - 'Failed to set scan concurrency:', - err, - ); - } - }; - - private handleScanStarted = (): void => { - this.scanning = true; - this.statusMessage = ''; - this.scanProgress = null; - this.metrics = null; - this.copied = false; - this.scanErrors = ''; - this.errorsCopied = false; - }; - - private handleScanProgress = ( - progress?: ScanProgress, - ): void => { - if (progress) { - this.scanProgress = progress; - this.scanQueuedCount = - progress.queuedCount ?? 0; - } - }; - - private handleScanComplete = ( - metrics?: ScanMetrics, - ): void => { - this.scanProgress = null; - - // If queue still has entries, don't fully reset — next scan will fire ScanStarted - if (this.scanQueuedCount > 0) { - if (metrics) { - this.metrics = metrics; - } - return; - } - - this.scanning = false; - this.statusMessage = 'Scan complete.'; - - if (metrics) { - this.metrics = metrics; - } - }; - - private handleScanQueued = (): void => { - this.scanQueuedCount++; - }; - - private handleScanQueueDrained = (): void => { - this.scanQueuedCount = 0; - this.scanning = false; - }; - - private handleSelectDirectory = - async (): Promise => { - try { - const dir = await DirectoryPicker(); - - if (dir) { - this.selectedDirectory = dir; - } - } catch (err) { - console.error( - 'Directory picker failed:', - err, - ); - } - }; - - private handleSaveDirectory = - async (): Promise => { - if (!this.selectedDirectory) return; - - try { - await SetLibraryDirectory( - this.selectedDirectory, - ); - this.libraryDirectory = - this.selectedDirectory; - this.statusMessage = - 'Library directory saved. A scan will start automatically if the directory changed.'; - } catch (err) { - this.statusMessage = `Failed to save directory: ${err}`; - console.error( - 'Failed to save directory:', - err, - ); - } - }; - - private handleScanAll = async (): Promise => { - try { - await ScanAllLibraries(); - } catch (err) { - this.statusMessage = - 'Scan all libraries completed with errors.'; - this.scanErrors = String(err); - console.error( - 'Scan all libraries failed:', - err, - ); - } - }; - - private handleSoftScan = async (): Promise => { - try { - await ScanAllLibraries(); - } catch (err) { - this.statusMessage = - 'Scan completed with errors.'; - this.scanErrors = String(err); - console.error('Soft scan failed:', err); - } - }; - - private handleFullRescan = - async (): Promise => { - const confirmed = confirm( - 'This will delete ALL library data including cover art and re-scan from scratch. Continue?', - ); - - if (!confirmed) return; - - try { - await FullRescan(); - } catch (err) { - this.statusMessage = - 'Full rescan completed with errors.'; - this.scanErrors = String(err); - console.error( - 'Full rescan failed:', - err, - ); - } - }; - - private handleCopyMetrics = - async (): Promise => { - if (!this.metrics) return; - - try { - const text = formatMetricsText( - this.metrics, - ); - - await navigator.clipboard.writeText(text); - this.copied = true; - - setTimeout(() => { - this.copied = false; - }, 2000); - } catch (err) { - console.error( - 'Failed to copy metrics:', - err, - ); - } - }; - - private handleCopyErrors = - async (): Promise => { - if (!this.scanErrors) return; - - try { - await navigator.clipboard.writeText( - this.scanErrors, - ); - this.errorsCopied = true; - - setTimeout(() => { - this.errorsCopied = false; - }, 2000); - } catch (err) { - console.error( - 'Failed to copy errors:', - err, - ); - } - }; - - private get directoryChanged(): boolean { - return ( - this.selectedDirectory !== - this.libraryDirectory - ); - } - - private get hasRescanPhases(): boolean { - if (!this.metrics) return false; - - const m = this.metrics; - - return ( - m.clearQueue > 0 || - m.clearDatabase > 0 || - m.clearCoverFiles > 0 - ); - } - - // --- Render helpers --- - - private renderMetricRow( - label: string, - value: string, - highlight = false, - ) { - return html` -
- ${label} - ${value} -
- `; - } - - private renderScanProgress() { - const p = this.scanProgress; - - if (!p) return nothing; - - const libraryPrefix = p.libraryName - ? `Scanning: ${p.libraryName}` - : ''; - - if (p.phase === 'counting') { - return html` -
- ${libraryPrefix - ? html`${libraryPrefix} \u2014 ` - : ''} - Counting files\u2026 -
- ${p.queuedCount > 0 - ? html`
- ${p.queuedCount} - ${p.queuedCount === 1 ? 'library' : 'libraries'} - queued -
` - : nothing} - `; - } - - const percent = - p.total > 0 - ? Math.min( - 100, - Math.round( - (p.processed / p.total) * 100, - ), - ) - : 0; - - const phaseLabel: Record = { - scanning: 'Scanning', - orphans: 'Cleaning up', - thumbnails: 'Generating thumbnails', - }; - - const baseLabel = - phaseLabel[p.phase] ?? 'Scanning'; - const label = p.libraryName - ? `${baseLabel}: ${p.libraryName}` - : baseLabel; - - const parts: string[] = []; - - if (p.added > 0) - parts.push(`${p.added.toLocaleString()} new`); - if (p.updated > 0) - parts.push( - `${p.updated.toLocaleString()} updated`, - ); - if (p.skipped > 0) - parts.push( - `${p.skipped.toLocaleString()} skipped`, - ); - - const detail = - p.phase === 'scanning' && p.total > 0 - ? html` - ${p.processed.toLocaleString()} / - ${p.total.toLocaleString()} files${parts.length - ? ` (${parts.join(', ')})` - : ''} - ` - : nothing; - - return html` -
- - ${label}\u2026 - - ${detail} - - ${percent}% - -
-
-
-
- ${p.queuedCount > 0 - ? html`
- ${p.queuedCount} - ${p.queuedCount === 1 ? 'library' : 'libraries'} - queued -
` - : nothing} - `; - } - - private renderMetrics() { - const m = this.metrics; - - if (!m) return nothing; - - const formatEntries = Object.entries( - m.formatExtraction ?? {}, - ).sort(([, a], [, b]) => b - a); - - const pureDb = Math.max( - 0, - m.batchCommits - m.coverArtSave, - ); - - return html` -
-
-

- Scan Results -

- -
- - ${this.renderMetricRow('Total', fmtNs(m.total), true)} - - -
- File Counts -
- Added - ${m.added} - Updated - ${m.updated} - Skipped - ${m.skipped} - Removed - ${m.removed} -
-
- - - ${this.hasRescanPhases - ? html` -
- - Clear Phases - - ${this.renderMetricRow('Clear Queue', fmtNs(m.clearQueue))} - ${this.renderMetricRow('Clear Database', fmtNs(m.clearDatabase))} - ${this.renderMetricRow('Clear Cover Files', fmtNs(m.clearCoverFiles))} -
- ` - : nothing} - - - ${this.renderMetricRow('Load Existing Files', fmtNs(m.loadExisting))} - ${this.renderMetricRow('Directory Walk', fmtNs(m.walkDuration))} - - -
- - Metadata Extraction - — - ${fmtNs(m.extractionWallClock)} - wall-clock - -

- Per-format and per-operation times - are cumulative across - ${Object.values( - m.formatCount ?? {}, - ).reduce( - (a, b) => a + b, - 0, - )} - files -

- - ${formatEntries.length > 0 - ? html` -
- - By Format - - ${formatEntries.map( - ([ext, ms]) => - this.renderMetricRow( - `${ext} (${m.formatCount?.[ext] ?? 0} files)`, - fmtMs(ms), - ), - )} -
- ` - : nothing} - -
- By Operation - ${this.renderMetricRow('Tag Extraction', fmtNs(m.tagExtraction))} - ${this.renderMetricRow('Duration Extraction', fmtNs(m.durationExtraction))} -
-
- - -
- - Database Writes — - ${fmtNs(m.dbWritesWallClock)} - wall-clock - - ${this.renderMetricRow('Batch Commits', fmtNs(m.batchCommits))} - ${this.renderMetricRow('Pure DB Operations', fmtNs(pureDb))} - ${this.renderMetricRow('Save Cover Originals', fmtNs(m.coverArtSave))} -
- - -
- - Thumbnail Generation — - ${fmtNs(m.thumbnailWallClock)} - wall-clock - -

- Generated concurrently; cumulative - CPU time may exceed wall-clock -

- ${this.renderMetricRow('Cumulative CPU Time', fmtNs(m.thumbnailGeneration))} - ${this.renderMetricRow('Small (_sm)', fmtNs(m.thumbnailSmall))} - ${this.renderMetricRow('Medium (_md)', fmtNs(m.thumbnailMedium))} - ${this.renderMetricRow('Large (_lg)', fmtNs(m.thumbnailLarge))} -
- - ${this.renderMetricRow('Orphan Cleanup', fmtNs(m.orphanCleanup))} - ${this.renderMetricRow('Post-Scan Variants', fmtNs(m.postScanVariants))} -
- `; - } - - override render() { - return html` -

Library Manager

- -
-

- Library Directory -

-

- Select the root directory containing your - music files. Changing this will - automatically trigger a scan. -

-
-
- ${this.selectedDirectory || - 'No directory selected'} -
- - -
-
- -
-

- Scan Settings -

-

- Choose how the scanner reads files. - Auto-detect reads the disk type - automatically. Select HDD if your music - is on a spinning disk, or SSD for - solid-state storage. -

-
- - -
-
- -
-

Scan Actions

-

- Soft scan finds new files, skips existing - ones, and removes orphaned entries. Full - rescan clears the entire database and - cover art cache, then re-imports - everything. -

-
- - - -
-
- -
- ${this.scanProgress - ? this.renderScanProgress() - : this.statusMessage || 'Ready.'} -
- - ${this.scanErrors - ? html` -
-
- - Scan Errors - - -
-
-
${this.scanErrors}
-
-
- ` - : ''} - - ${this.renderMetrics()} - `; - } -} diff --git a/frontend/src/components/sidebar/app-sidebar.ts b/frontend/src/components/sidebar/app-sidebar.ts index 3c697db..1a6f4b3 100644 --- a/frontend/src/components/sidebar/app-sidebar.ts +++ b/frontend/src/components/sidebar/app-sidebar.ts @@ -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' }, ]; diff --git a/frontend/src/events.ts b/frontend/src/events.ts index 448898d..2430506 100644 --- a/frontend/src/events.ts +++ b/frontend/src/events.ts @@ -61,6 +61,9 @@ export const Events = { AutotagPrefetchProgress: "AutotagPrefetchProgress", AutotagPrefetchFinished: "AutotagPrefetchFinished", + // Background job events + JobsChanged: "JobsChanged", + // Explore / search index events IndexStatusChanged: "IndexStatusChanged", ArtistDiscographyReady: "ArtistDiscographyReady", diff --git a/frontend/src/store/job-store.ts b/frontend/src/store/job-store.ts new file mode 100644 index 0000000..f86fc8e --- /dev/null +++ b/frontend/src/store/job-store.ts @@ -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 = 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(); + + private subscribers = new Set(); + + private notifyScheduled = false; + + /** Timer that clears the lingering "just finished" indicator. */ + private lingerTimer: ReturnType | 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 { + 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 { + 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 { + await PauseJob(id); + } + + async resume(id: string): Promise { + await ResumeJob(id); + } + + async cancel(id: string): Promise { + await CancelJob(id); + } + + async dismiss(id: string): Promise { + this.logs.delete(id); + await DismissJob(id); + } + + async clearFinished(): Promise { + 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(); diff --git a/frontend/wailsjs/go/explore/Service.d.ts b/frontend/wailsjs/go/explore/Service.d.ts index ae93992..ad37294 100755 --- a/frontend/wailsjs/go/explore/Service.d.ts +++ b/frontend/wailsjs/go/explore/Service.d.ts @@ -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; + +export function BackfillLibraryDiscographies():Promise; export function BackfillLibraryLyrics():Promise; @@ -81,6 +86,8 @@ export function SearchLyrics(arg1:string):Promise>; export function SetContext(arg1:context.Context):Promise; +export function SetJobRegistry(arg1:jobs.Registry):Promise; + export function SimilarArtists(arg1:string):Promise>; export function StartIndexBuild():Promise; diff --git a/frontend/wailsjs/go/explore/Service.js b/frontend/wailsjs/go/explore/Service.js index 5c52560..01eec9d 100755 --- a/frontend/wailsjs/go/explore/Service.js +++ b/frontend/wailsjs/go/explore/Service.js @@ -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); } diff --git a/frontend/wailsjs/go/jobs/Service.d.ts b/frontend/wailsjs/go/jobs/Service.d.ts new file mode 100755 index 0000000..8beaa9f --- /dev/null +++ b/frontend/wailsjs/go/jobs/Service.d.ts @@ -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; + +export function ClearFinishedJobs():Promise; + +export function DismissJob(arg1:string):Promise; + +export function GetJobLog(arg1:string):Promise>; + +export function GetJobs():Promise>; + +export function PauseJob(arg1:string):Promise; + +export function ResumeJob(arg1:string):Promise; diff --git a/frontend/wailsjs/go/jobs/Service.js b/frontend/wailsjs/go/jobs/Service.js new file mode 100755 index 0000000..523f94c --- /dev/null +++ b/frontend/wailsjs/go/jobs/Service.js @@ -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); +} diff --git a/frontend/wailsjs/go/library/Library.d.ts b/frontend/wailsjs/go/library/Library.d.ts index 599f83a..5ffd6df 100755 --- a/frontend/wailsjs/go/library/Library.d.ts +++ b/frontend/wailsjs/go/library/Library.d.ts @@ -3,6 +3,7 @@ import {sqlcgen} from '../models'; import {library} from '../models'; import {context} from '../models'; +import {jobs} from '../models'; export function AcquirePipelineLock():Promise; @@ -66,6 +67,8 @@ export function RemoveLibrary(arg1:number):Promise; export function RenameLibrary(arg1:number,arg2:string):Promise; +export function RestorePausedScans():Promise; + export function ResumeScan():Promise; export function ScanAllLibraries():Promise; @@ -78,6 +81,8 @@ export function SearchTracksByLibrary(arg1:string,arg2:number):Promise; +export function SetJobRegistry(arg1:jobs.Registry):Promise; + export function SetRemovalHooks(arg1:library.RemovalHooks):Promise; export function SetRescanHooks(arg1:library.RescanHooks):Promise; diff --git a/frontend/wailsjs/go/library/Library.js b/frontend/wailsjs/go/library/Library.js index a7c0f1d..5abfac9 100755 --- a/frontend/wailsjs/go/library/Library.js +++ b/frontend/wailsjs/go/library/Library.js @@ -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); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index a16b5e4..9e9c1e5 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -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 { diff --git a/frontend/wailsjs/go/playlist/Service.d.ts b/frontend/wailsjs/go/playlist/Service.d.ts index 9e0ddb4..b569a8c 100755 --- a/frontend/wailsjs/go/playlist/Service.d.ts +++ b/frontend/wailsjs/go/playlist/Service.d.ts @@ -44,6 +44,8 @@ export function ImportPlaylist(arg1:string):Promise; export function ImportPlaylists(arg1:Array):Promise>; +export function MaterializeUnmaterializedSmartPlaylists():Promise; + export function PreviewSmartPlaylist(arg1:string):Promise>; export function RefreshSmartPlaylist(arg1:number):Promise; diff --git a/frontend/wailsjs/go/playlist/Service.js b/frontend/wailsjs/go/playlist/Service.js index 474100b..c144cb4 100755 --- a/frontend/wailsjs/go/playlist/Service.js +++ b/frontend/wailsjs/go/playlist/Service.js @@ -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); }