feat(jobs): surface background jobs with progress, logs and controls
Add a central job registry that library scans and search index builds report into, so background work is visible instead of buried in the settings page. - backend/jobs: registry with per-job ring-buffer logs, capability-driven controls, and one coalesced JobsChanged snapshot at 4Hz - pause survives restart via a job_state table; a paused scan is adopted back on launch and skipped by the soft scan - top-bar indicator, popover, details drawer and a Jobs page replacing the config page's scan UI; per-library start/stop retained - scan timing breakdown moves into the job log, Full rescan to the Jobs page; delete the orphaned library-manager component Also add cmd/indexbuild and cmd/indexexport so the explore index can be built once centrally rather than by every install, which today streams ~205GB from the ListenBrainz spark dump on first run. indexbuild picks build/refresh/rebuild from index state; the Gitea workflow runs it on push, weekly, or manually and publishes only when content changed. fresh-install no longer defaults YJ_HOME under /tmp: it is tmpfs on most distros, and the import needs ~6GB of real disk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
name: Search index maintenance
|
||||
|
||||
# indexbuild decides what to do from the index's own state, so every
|
||||
# trigger below runs the same command:
|
||||
#
|
||||
# no completed import -> build (first run, or resume a partial one)
|
||||
# import older than 3mo -> rebuild (re-import from the newest dump)
|
||||
# otherwise -> refresh (fold in new incremental listens)
|
||||
#
|
||||
# A refresh is cheap and no-ops when nothing new has been published, so
|
||||
# running it on every push to main is safe.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
# Weekly update pass. The 3-month rebuild is triggered by the same
|
||||
# command when it notices the import has aged out.
|
||||
- cron: '0 4 * * 1'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: 'auto | build | refresh | rebuild'
|
||||
required: false
|
||||
default: 'auto'
|
||||
budget:
|
||||
description: 'Max build time this run'
|
||||
required: false
|
||||
default: '3h'
|
||||
artists:
|
||||
description: 'Top artists in the core artifact'
|
||||
required: false
|
||||
default: '50000'
|
||||
|
||||
# Runs share one persistent working directory, so they must not overlap.
|
||||
# A push landing mid-build waits rather than corrupting the checkpoint.
|
||||
concurrency:
|
||||
group: search-index
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
maintain-index:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
# CGO is not needed: the project uses the pure-Go modernc sqlite
|
||||
# driver, and neither command imports the Wails app.
|
||||
image: golang:1.25
|
||||
# This host path must exist on the runner and be listed verbatim in
|
||||
# act_runner's container.valid_volumes. It holds explore-staging/
|
||||
# (counts.bin + state.json) and yj.db — the checkpoint that makes
|
||||
# resuming possible. Losing it means re-downloading ~205GB.
|
||||
volumes:
|
||||
- /srv/yellowjacket/index-cache:/cache
|
||||
env:
|
||||
YJ_HOME: /cache
|
||||
CGO_ENABLED: '0'
|
||||
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
MODE: ${{ inputs.mode || 'auto' }}
|
||||
BUDGET: ${{ inputs.budget || '3h' }}
|
||||
ARTISTS: ${{ inputs.artists || '50000' }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Verify the cache volume
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p /cache
|
||||
# A RAM-backed cache would defeat the point: the checkpoint has
|
||||
# to outlive the job, and the import wants real disk headroom.
|
||||
fstype=$(stat -f -c %T /cache || echo unknown)
|
||||
echo "cache fstype: $fstype"
|
||||
case "$fstype" in
|
||||
tmpfs|ramfs)
|
||||
echo "::error::/cache is RAM-backed; use a disk-backed host path."
|
||||
exit 1 ;;
|
||||
esac
|
||||
df -h /cache
|
||||
|
||||
- name: Build tools
|
||||
run: go build -o /usr/local/bin/ ./cmd/indexbuild ./cmd/indexexport
|
||||
|
||||
- name: Maintain index
|
||||
id: maintain
|
||||
run: |
|
||||
set +e
|
||||
indexbuild -mode "$MODE" -budget "$BUDGET"
|
||||
code=$?
|
||||
set -e
|
||||
case "$code" in
|
||||
0) ;;
|
||||
3) echo "::notice::Build checkpointed with work remaining — rerun to continue." ;;
|
||||
*) exit "$code" ;;
|
||||
esac
|
||||
|
||||
# Publishing only on `changed` keeps identical artifacts from
|
||||
# accumulating when a refresh finds nothing new.
|
||||
- name: Export core artifact
|
||||
if: steps.maintain.outputs.complete == 'true' && steps.maintain.outputs.changed == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
command -v zstd >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq zstd; }
|
||||
indexexport -o /tmp/core-index.db -artists "$ARTISTS"
|
||||
zstd -19 -T0 -q -f /tmp/core-index.db -o /tmp/core-index.db.zst
|
||||
sha256sum /tmp/core-index.db.zst | tee /tmp/core-index.db.zst.sha256
|
||||
ls -lh /tmp/core-index.db.zst
|
||||
|
||||
- name: Publish to the Gitea package registry
|
||||
if: steps.maintain.outputs.complete == 'true' && steps.maintain.outputs.changed == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
version="$(date -u +%Y%m%d)"
|
||||
base="${SERVER_URL}/api/packages/${OWNER}/generic/yellowjacket-core-index/${version}"
|
||||
for f in core-index.db.zst core-index.db.zst.sha256; do
|
||||
echo "Uploading $f -> $version"
|
||||
curl --fail-with-body --user "${OWNER}:${PACKAGE_TOKEN}" \
|
||||
--upload-file "/tmp/$f" "${base}/${f}"
|
||||
done
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "complete=${{ steps.maintain.outputs.complete }}"
|
||||
echo "changed=${{ steps.maintain.outputs.changed }}"
|
||||
if [ "${{ steps.maintain.outputs.complete }}" != "true" ]; then
|
||||
echo "Build incomplete — rerun to continue from the checkpoint."
|
||||
echo "Progress lives in /cache/data/explore-staging."
|
||||
elif [ "${{ steps.maintain.outputs.changed }}" != "true" ]; then
|
||||
echo "Nothing new to publish."
|
||||
fi
|
||||
@@ -0,0 +1,281 @@
|
||||
# 001 — Ship a prebuilt "core" explore index
|
||||
|
||||
**Status:** pending
|
||||
**Branch:** wip
|
||||
**Created:** 2026-07-25
|
||||
|
||||
## Problem
|
||||
|
||||
A fresh install has no explore index. `StartIndexBuild()` is called
|
||||
unconditionally from two places in `app.go`, and `runDumpBuild` then
|
||||
downloads gigabytes from `data.metabrainz.org` before Explore can return
|
||||
anything beyond the user's own library:
|
||||
|
||||
| Stage | Source | Cost |
|
||||
|---|---|---|
|
||||
| Listen Counts | ListenBrainz spark full listens dump | **~205 GB streamed** — see below |
|
||||
| Catalog Import | MusicBrainz canonical dump (~2 GB `.tar.zst`) | scan ~30M CSV rows, assemble to budget |
|
||||
| Metadata Patch | MB/LB API | rate-limited at 3 req/s |
|
||||
| Listener Counts | LB API | rate-limited |
|
||||
|
||||
Measured 2026-07-25 against the live dump
|
||||
(`listenbrainz-spark-dump-2593-20260712-000004-full.tar`):
|
||||
|
||||
```
|
||||
content-length: 205073162240 # 205 GB
|
||||
accept-ranges: bytes
|
||||
```
|
||||
|
||||
The stage-1 reader skips non-`.parquet` tar members
|
||||
(`dumpcounts.go:317`), but a tar stream has no seek — skipped bytes
|
||||
still transit the wire. **So a first run on a fresh install pulls
|
||||
~205 GB.** Little of it touches disk (the counts map and checkpoint do,
|
||||
not the dump), but the bandwidth is real and it is per-user.
|
||||
|
||||
Consequences today:
|
||||
|
||||
- Every install pulls ~205 GB to derive a catalog that is **identical
|
||||
for everyone**. On a metered or slow connection this is untenable, and
|
||||
it is unconditional on first run.
|
||||
- **It refuses to start without 6 GB free** (`dumpMinStartFreeBytes`),
|
||||
and aborts below 2 GB (`dumpAbortFreeBytes`). This is what breaks
|
||||
`make fresh-install` on a tmpfs `/tmp`.
|
||||
- First-run Explore is empty for the length of the import.
|
||||
|
||||
The catalog half is **the same for everyone**. Only the local half
|
||||
(`PopulateLocalCrossReferences`, `BackfillLibraryDiscographies`) is
|
||||
per-user. Deriving the shared half on each machine is the waste this
|
||||
plan removes.
|
||||
|
||||
## Goal
|
||||
|
||||
Ship a prebuilt core index so a fresh install has a usable Explore
|
||||
immediately, and the runtime build collapses to the local half plus
|
||||
incremental refresh. The full dump import becomes an opt-in "deep
|
||||
catalog" upgrade rather than a prerequisite.
|
||||
|
||||
## Sizing evidence
|
||||
|
||||
Measured 2026-07-25 with a synthetic harness against the real schema and
|
||||
migrations (2.15M-row full run exceeded a 15-minute budget, so this is a
|
||||
200K-row calibration, `VACUUM`ed):
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| 200,000 rows, with FTS | 85.2 MB |
|
||||
| Cost per row | ~426 B |
|
||||
| zstd -19 | 29.6 MB (2.9x) |
|
||||
|
||||
Extrapolating to the current budgets (`keepRecordings` 1.5M +
|
||||
`keepReleaseGroup` 400K + `keepArtists` 250K = 2.15M rows):
|
||||
|
||||
| Tier | Rows | On disk | zstd -19 |
|
||||
|---|---|---|---|
|
||||
| Full budget | 2.15M | **~900 MB** | ~310 MB |
|
||||
| Core (proposed) | 500K | ~210 MB | **~72 MB** |
|
||||
| Minimal | 250K | ~105 MB | ~36 MB |
|
||||
|
||||
**This corrects an earlier figure.** A ~93 MB index was recorded in the
|
||||
2026-07-16 audit note; that measured the *legacy tier-crawl* index, not
|
||||
the dump-built one. The dump build targets an order of magnitude more
|
||||
rows. Shipping the full index is not viable as a casual download —
|
||||
which is exactly why this plan is scoped to a *core* subset.
|
||||
|
||||
⚠️ Two caveats on these numbers:
|
||||
|
||||
- The harness used a 14-word vocabulary, so its FTS measured only 7% of
|
||||
total size. Real titles have a far larger vocabulary and the real FTS
|
||||
share will be materially higher. **Treat the totals as a floor.**
|
||||
- Row width was estimated from the schema (3 UUIDs at 36 chars dominate);
|
||||
`aliases` was left empty and is populated for real artists.
|
||||
|
||||
Re-measure against a genuine dump-built index before committing to a
|
||||
tier size.
|
||||
|
||||
## What "core" should mean
|
||||
|
||||
`dumpcatalog.go` already has graded per-artist coverage (S2) —
|
||||
`perArtistArtistBudget = 10_000` split into tiers A/B/C with per-tier
|
||||
track and release-group caps. The core index should reuse that machinery
|
||||
rather than invent a second notion of importance:
|
||||
|
||||
- **Artists:** top ~50K by listen count.
|
||||
- **Release groups + recordings:** the S2 per-artist slice for those
|
||||
artists (tier A/B/C caps as they stand).
|
||||
- **Excluded:** the global long tail below the per-artist selection.
|
||||
|
||||
Anything not covered still works — it just resolves through the existing
|
||||
lazy paths (`EnsureArtistDiscography`, `AddFromCache`), which is the
|
||||
behaviour non-covered artists already get today.
|
||||
|
||||
## Distribution: download on first run, not `go:embed`
|
||||
|
||||
**Both packaging paths build from source** — the Homebrew formula builds
|
||||
from a release tarball, the Arch `PKGBUILD` clones the tag. So:
|
||||
|
||||
- Committing the artifact to git bloats the repo and every source tarball.
|
||||
- `go:embed` makes a from-source build require the artifact at build
|
||||
time, so source builds would have to download it anyway — and
|
||||
`build-prod` runs UPX over the binary, which would be pathological
|
||||
with a 70 MB+ embedded blob.
|
||||
|
||||
So "ship with the app" should mean **fetch a prebuilt artifact on first
|
||||
run** from a versioned URL. CI already publishes binary packages to the
|
||||
Gitea package registry (`.gitea/workflows/arch-package.yml`), so there is
|
||||
an existing place to host it.
|
||||
|
||||
Import path: download `.zst` → decompress → `ATTACH` → `INSERT INTO
|
||||
explore_index SELECT ...` through the **existing** `upsertBatch` conflict
|
||||
rules, which already do the right thing (non-empty wins, highest
|
||||
popularity wins, never clobber a good value with an empty one).
|
||||
|
||||
## Artifact contents
|
||||
|
||||
Ship the global catalog columns only. These are **per-user** and must be
|
||||
zeroed in the artifact, then recomputed locally by
|
||||
`PopulateLocalCrossReferences`:
|
||||
|
||||
- `in_library`, `is_similar`
|
||||
- `local_artist_id`, `local_release_group_id`, `local_recording_id`
|
||||
|
||||
`discog_fetched` should ship as `1` for artists whose S2 slice is
|
||||
included, so the backfill doesn't redundantly re-fetch them.
|
||||
|
||||
Also decide per-table whether to include: `similar_artist_map`,
|
||||
`artist_metadata`, `release_to_rg`. `release_to_rg` in particular may
|
||||
rival the index in size — measure before including.
|
||||
|
||||
**Resolved: the artifact ships no FTS.** Rows are inserted into the
|
||||
client's own `explore_index`, whose `AFTER INSERT` trigger populates
|
||||
`explore_index_fts` as a side effect — so shipping a search index would
|
||||
be pure redundant weight. `cmd/indexexport` builds the artifact without
|
||||
FTS or triggers accordingly.
|
||||
|
||||
## Update strategy
|
||||
|
||||
- **Popularity drift** — `dumpincremental.go` already implements
|
||||
incremental listens-dump refresh (`RefreshListenCounts`, weekly
|
||||
cadence). It applies unchanged on top of a shipped baseline, provided
|
||||
`listens_applied_series` is stamped in the artifact so deltas resume
|
||||
from the right point.
|
||||
- **Catalog additions** — new releases arrive via the existing lazy
|
||||
per-artist fetches. A refreshed artifact per app release is enough;
|
||||
no separate cadence needed.
|
||||
- **Schema changes** — `schema_version` exists on `explore_index` but is
|
||||
noted as dead in the audit. Either wire it up or version the artifact
|
||||
filename against the migration number, so an old artifact can't be
|
||||
imported into a newer schema.
|
||||
|
||||
## Build pipeline: build and cache in Gitea CI
|
||||
|
||||
The import is unusually well suited to running as a **series of
|
||||
time-boxed CI jobs against a persistent cache**, because the resumability
|
||||
already exists:
|
||||
|
||||
- Stage 1 streams over a `resumableReader` that reconnects with HTTP
|
||||
`Range` requests, and the live dump advertises `accept-ranges: bytes`.
|
||||
- `counts.bin` checkpoints `Offset` (absolute byte position) and
|
||||
`MemberIdx`, and the applier merges results **in member order** so
|
||||
"every checkpoint is a contiguous prefix of the stream"
|
||||
(`dumpcounts.go`).
|
||||
- Stage 2's canonical scan is deliberately restartable wholesale — "cheap
|
||||
enough to simply restart after an interruption" (`dumpcatalog.go`).
|
||||
|
||||
So a job that hits a runner time limit resumes at its exact byte offset
|
||||
on the next run. **No single multi-hour job is required** — schedule
|
||||
N bounded runs and let them converge.
|
||||
|
||||
What it needs:
|
||||
|
||||
1. **A persistent volume for `explore-staging/` + the DB.** `act_runner`
|
||||
uses the Docker backend and job containers are ephemeral, so bind-mount
|
||||
a host path (or a named Docker volume) and point `YJ_HOME` at it.
|
||||
Prefer this over the Actions cache — cache entries are size-capped and
|
||||
awkward at GB scale, and this is a self-hosted runner anyway.
|
||||
2. **A headless entrypoint** — currently the import only runs from the
|
||||
app lifecycle (`StartIndexBuild` via `OnDomReady`). This is a real gap,
|
||||
but a small one: `NewSearchIndex(db, lb, artistImg, logger)` takes no
|
||||
Wails dependency, and the single `runtime.EventsEmit` in
|
||||
`searchindex.go` sits inside `emitStatus`, which already early-returns
|
||||
when `runtimeCtx == nil`. A `cmd/indexbuild` that opens the DB and
|
||||
calls `StartBuild(context.Background())` — never `SetContext` — should
|
||||
work. Verify `scheduleChampionRebuild` in the `StartBuild` defer is
|
||||
also Wails-free.
|
||||
3. **Triggers.** `indexbuild` decides its own mode from index state, so
|
||||
every trigger runs the same command: push to `main` and a weekly cron
|
||||
both land on a cheap refresh (which no-ops when nothing new is
|
||||
published), and the 3-month rebuild fires when the command notices the
|
||||
import has aged out.
|
||||
|
||||
Then export: subset to core, zero the personal columns, stamp
|
||||
`dump_import_done` / `listens_applied_series` / schema version, `VACUUM`,
|
||||
`zstd -19`, checksum, publish to the Gitea package registry (the Arch
|
||||
workflow already authenticates against it with `PACKAGE_TOKEN`).
|
||||
|
||||
**Be a good citizen about the 205 GB.** Rebuild on the dump cadence
|
||||
(the audit notes a 90-day re-import cadence), never per-commit. Once a
|
||||
baseline exists, the ~180 MB daily incremental dumps already wired in
|
||||
`dumpincremental.go` keep popularity fresh — so the 205 GB is genuinely
|
||||
one-time per rebuild, not per refresh. Also check the runner's own
|
||||
egress if it is self-hosted on a home connection.
|
||||
|
||||
## Licensing
|
||||
|
||||
- MusicBrainz canonical dump is **CC0** — redistribution fine.
|
||||
- ListenBrainz-derived listen counts need their dump licence checked
|
||||
before redistribution, plus attribution in-app either way.
|
||||
- Note the derived counts already differ from LB API values (no MLHD+
|
||||
history) — a known, accepted divergence, but worth stating wherever
|
||||
the numbers are surfaced.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Artifact staleness vs app version** — a user on an old release gets
|
||||
an old catalog. Mitigated by incremental refresh + lazy fetches.
|
||||
- **Download failure / offline install** — must degrade to today's
|
||||
behaviour (local library search), not a broken Explore. The failure is
|
||||
now visible in the Jobs panel, which helps.
|
||||
- **Users who want the full catalog** — keep the existing dump import as
|
||||
an explicit opt-in, gated behind a setting. Note that no such setting
|
||||
exists today: `StartIndexBuild()` is unconditional, and Library Only
|
||||
mode is frontend-`localStorage` only with no backend wiring.
|
||||
|
||||
## Phasing
|
||||
|
||||
1. ✅ **Headless entrypoint.** `cmd/indexbuild` — resumable, budgeted
|
||||
(`-budget 3h`), signal-aware, exit 3 = "more work remains". Verified
|
||||
to run without Wails; builds with `CGO_ENABLED=0` and no build tags.
|
||||
2. ✅ **Export tooling.** `cmd/indexexport` — top-N artists plus a
|
||||
per-artist window of their release groups and recordings, personal
|
||||
columns dropped, metadata stamped, vacuumed. Verified against a
|
||||
synthetic index: no personal columns leak, no orphaned rows, caps
|
||||
respected.
|
||||
3. ⬜ **One real build.** Run `indexbuild` against a persistent volume
|
||||
until it converges. This yields the first genuine dump-built index and
|
||||
with it true row counts, on-disk size, real FTS share, and
|
||||
`release_to_rg` size. **Every tier number above is still an
|
||||
extrapolation from synthetic rows until this exists.**
|
||||
4. ⬜ **Import path.** First-run download + attach + upsert, with checksum
|
||||
verification, resumability, and clean degradation on failure. Report
|
||||
it as a job in the Jobs panel — the plumbing for that already exists.
|
||||
5. ⬜ **Gate the dump build.** Add the setting that makes the full import
|
||||
opt-in, so a shipped core index isn't immediately followed by the
|
||||
multi-GB download it was meant to replace.
|
||||
6. ✅ **CI wiring.** `.gitea/workflows/index-artifact.yml` — push +
|
||||
weekly cron + manual, concurrency-guarded, publishes only when
|
||||
`complete && changed` so identical artifacts don't accumulate.
|
||||
Runner-side prerequisites are in place (cache dir + `valid_volumes`
|
||||
on the VPS runner).
|
||||
|
||||
Step 3 is the gate on everything downstream — and it is worth doing
|
||||
regardless of whether the artifact ever ships, since it is the only way
|
||||
to get real numbers for the index.
|
||||
|
||||
## Related
|
||||
|
||||
- `backend/explore/dumpimport.go` — stage orchestration, disk floors
|
||||
- `backend/explore/dumpcatalog.go` — budgets, S2 per-artist tiers
|
||||
- `backend/explore/dumpincremental.go` — incremental refresh (update path)
|
||||
- `backend/explore/searchindex.go` — `upsertBatch` conflict rules,
|
||||
`PopulateLocalCrossReferences`
|
||||
- Migration 26 in `backend/database/database.go` — `explore_index` schema
|
||||
@@ -2,11 +2,39 @@ VERSION ?= dev
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)'
|
||||
|
||||
# YJ_HOME isolates the dev build's config + database from a packaged
|
||||
# install. Defaults to a sandbox under XDG data; override in .env to
|
||||
# point elsewhere (or unset it there to share the real user dirs).
|
||||
DEV_YJ_HOME ?= $(HOME)/.local/share/yellowjacket-dev
|
||||
|
||||
dev: setup generate clean
|
||||
if [ -f .env ]; then set -a; . ./.env; set +a; fi; go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
|
||||
if [ -f .env ]; then set -a; . ./.env; set +a; fi; : "$${YJ_HOME:=$(DEV_YJ_HOME)}"; export YJ_HOME; go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
|
||||
|
||||
dev-debug: setup generate clean
|
||||
if [ -f .env ]; then set -a; . ./.env; set +a; fi; YJ_LOG_LEVEL=debug go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
|
||||
if [ -f .env ]; then set -a; . ./.env; set +a; fi; : "$${YJ_HOME:=$(DEV_YJ_HOME)}"; export YJ_HOME; YJ_LOG_LEVEL=debug go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
|
||||
|
||||
# Base directory for fresh-install sandboxes. Deliberately NOT $TMPDIR:
|
||||
# on most Linux distros /tmp is tmpfs (RAM-backed) and only a few GB, so
|
||||
# the search index dump import — which wants 6GB free before it will even
|
||||
# start, then streams multi-GB dumps through explore-staging/ — either
|
||||
# fails its precheck or eats that much RAM. XDG cache is disk-backed
|
||||
# everywhere and still throwaway.
|
||||
FRESH_HOME_BASE ?= $(if $(XDG_CACHE_HOME),$(XDG_CACHE_HOME),$(HOME)/.cache)
|
||||
|
||||
# fresh-install runs dev against a brand-new YJ_HOME so every launch
|
||||
# starts from a clean first-run state (no config.toml, no yj.db). The dir
|
||||
# is not cleaned up automatically, so you can inspect it afterward; the
|
||||
# printed path tells you where it is. Override the location with
|
||||
# FRESH_HOME_BASE=/some/disk make fresh-install.
|
||||
fresh-install: setup generate clean
|
||||
if [ -f .env ]; then set -a; . ./.env; set +a; fi; \
|
||||
mkdir -p "$(FRESH_HOME_BASE)"; \
|
||||
export YJ_HOME="$$(mktemp -d "$(FRESH_HOME_BASE)/yellowjacket-fresh.XXXXXX")"; \
|
||||
echo "==> fresh YJ_HOME=$$YJ_HOME"; \
|
||||
case "$$(findmnt -no FSTYPE -T "$$YJ_HOME" 2>/dev/null)" in \
|
||||
tmpfs|ramfs) echo "==> WARNING: $$YJ_HOME is RAM-backed; the search index import needs ~6GB of real disk. Set FRESH_HOME_BASE to a disk-backed path." ;; \
|
||||
esac; \
|
||||
go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
|
||||
|
||||
build-dev: generate
|
||||
go tool wails build -tags webkit2_41 -debug -clean -ldflags "$(LDFLAGS)"
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"yellowjacket/backend/database"
|
||||
"yellowjacket/backend/explore"
|
||||
"yellowjacket/backend/frontendutil"
|
||||
"yellowjacket/backend/jobs"
|
||||
"yellowjacket/backend/library"
|
||||
"yellowjacket/backend/mediacontrols"
|
||||
"yellowjacket/backend/player"
|
||||
@@ -44,6 +45,7 @@ type YellowJacketApp struct {
|
||||
queue *queue.Queue
|
||||
explore *explore.Service
|
||||
autotag *autotagservice.Service
|
||||
jobs *jobs.Registry
|
||||
mediaControls mediacontrols.Handler
|
||||
tagWriter *tagwriter.TagWriter
|
||||
appContext context.Context
|
||||
@@ -148,6 +150,16 @@ func NewYellowJacketApp(
|
||||
yjApp.logger.WithGroup("explore"), yjApp.database,
|
||||
)
|
||||
|
||||
// create the background job registry and wire it into the
|
||||
// subsystems that run long jobs, so scans and index builds all
|
||||
// report through one surface.
|
||||
yjApp.jobs = jobs.NewRegistry(
|
||||
yjApp.logger.WithGroup("jobs"),
|
||||
jobs.NewStore(yjApp.database, yjApp.logger.WithGroup("jobs")),
|
||||
)
|
||||
yjApp.library.SetJobRegistry(yjApp.jobs)
|
||||
yjApp.explore.SetJobRegistry(yjApp.jobs)
|
||||
|
||||
// create autotag service (depends on explore + tagWriter)
|
||||
yjApp.autotag = autotagservice.NewService(
|
||||
yjApp.logger.WithGroup("autotag"),
|
||||
@@ -166,6 +178,7 @@ func NewYellowJacketApp(
|
||||
yjApp.tagWriter,
|
||||
yjApp.explore,
|
||||
yjApp.autotag,
|
||||
jobs.NewService(yjApp.jobs),
|
||||
}
|
||||
|
||||
return yjApp, nil
|
||||
@@ -219,6 +232,13 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
yj.tagWriter.SetContext(ctx)
|
||||
yj.explore.SetContext(ctx)
|
||||
yj.autotag.SetContext(ctx)
|
||||
yj.jobs.SetContext(ctx)
|
||||
|
||||
// Bring back jobs the user paused before the last shutdown, still
|
||||
// paused. Must run before the soft scan in OnDomReady, which
|
||||
// checks these records so it does not restart a paused library.
|
||||
yj.library.RestorePausedScans()
|
||||
yj.explore.AdoptPausedIndexBuild()
|
||||
|
||||
// Wire queue (created in NewYellowJacketApp for Wails binding)
|
||||
yj.queue.SetContext(ctx)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Durable state for background jobs. Currently holds one row per job
|
||||
-- that the user paused, so a paused library scan or search index build
|
||||
-- comes back paused after a restart instead of silently resuming (or
|
||||
-- silently never running again).
|
||||
--
|
||||
-- Rows are written when a durable job enters the paused state and
|
||||
-- deleted on resume, cancel, or completion — this is not a job history
|
||||
-- table, and it stays at zero rows in the common case.
|
||||
CREATE TABLE IF NOT EXISTS job_state (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
subtitle TEXT NOT NULL DEFAULT '',
|
||||
paused_at TEXT NOT NULL
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"yellowjacket/backend/database"
|
||||
)
|
||||
|
||||
// Persisted is a job whose paused state outlived the process that
|
||||
// created it. The owning subsystem adopts these back into the registry
|
||||
// during startup, re-attaching the controls needed to resume.
|
||||
type Persisted struct {
|
||||
ID string `json:"id"`
|
||||
Kind Kind `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
}
|
||||
|
||||
// Store persists durable job state to the job_state table.
|
||||
type Store struct {
|
||||
db *database.DB
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewStore creates a job state store backed by the application database.
|
||||
func NewStore(db *database.DB, logger *slog.Logger) *Store {
|
||||
return &Store{db: db, logger: logger}
|
||||
}
|
||||
|
||||
// SetPaused records that a job is paused.
|
||||
func (s *Store) SetPaused(p Persisted) {
|
||||
if s == nil || s.db == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.db.ExecContext(
|
||||
`INSERT OR REPLACE INTO job_state`+
|
||||
` (id, kind, title, subtitle, paused_at)`+
|
||||
` VALUES (?, ?, ?, ?, ?)`,
|
||||
p.ID, string(p.Kind), p.Title, p.Subtitle,
|
||||
time.Now().UTC().Format(time.RFC3339),
|
||||
); err != nil {
|
||||
s.logger.Warn("jobs: could not persist paused job",
|
||||
"id", p.ID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ClearPaused removes a job's durable pause record.
|
||||
func (s *Store) ClearPaused(id string) {
|
||||
if s == nil || s.db == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.db.ExecContext(
|
||||
"DELETE FROM job_state WHERE id = ?", id,
|
||||
); err != nil {
|
||||
s.logger.Warn("jobs: could not clear paused job",
|
||||
"id", id, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// PausedEntries returns every persisted paused job of the given kind.
|
||||
func (s *Store) PausedEntries(kind Kind) []Persisted {
|
||||
if s == nil || s.db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(
|
||||
`SELECT id, kind, title, subtitle FROM job_state`+
|
||||
` WHERE kind = ? ORDER BY paused_at`,
|
||||
string(kind),
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Warn("jobs: could not read paused jobs",
|
||||
"kind", kind, "err", err)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []Persisted
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
p Persisted
|
||||
kindText string
|
||||
)
|
||||
|
||||
if scanErr := rows.Scan(
|
||||
&p.ID, &kindText, &p.Title, &p.Subtitle,
|
||||
); scanErr != nil {
|
||||
s.logger.Warn("jobs: could not scan paused job", "err", scanErr)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
p.Kind = Kind(kindText)
|
||||
out = append(out, p)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// IsPaused reports whether the given job ID has a durable pause record.
|
||||
// Subsystems check this before auto-starting work at launch.
|
||||
func (s *Store) IsPaused(id string) bool {
|
||||
if s == nil || s.db == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(
|
||||
"SELECT 1 FROM job_state WHERE id = ?", id,
|
||||
)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
return rows.Next()
|
||||
}
|
||||
+42
-20
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"yellowjacket/backend/jobs"
|
||||
)
|
||||
|
||||
// scanJobPrefix namespaces library scan jobs in the shared registry.
|
||||
const scanJobPrefix = "scan:"
|
||||
|
||||
// scanPhaseLabels maps internal scan phase identifiers to the labels
|
||||
// shown in the jobs UI.
|
||||
var scanPhaseLabels = map[string]string{
|
||||
"counting": "Counting files",
|
||||
"scanning": "Reading metadata",
|
||||
"thumbnails": "Generating thumbnails",
|
||||
"orphans": "Cleaning up removed files",
|
||||
}
|
||||
|
||||
// SetJobRegistry wires the background job registry so scans report
|
||||
// progress, logs, and pause/cancel controls to the frontend.
|
||||
func (l *Library) SetJobRegistry(reg *jobs.Registry) {
|
||||
l.mu.Lock()
|
||||
l.jobs = reg
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// jobRegistry returns the registry, or nil when none is wired.
|
||||
func (l *Library) jobRegistry() *jobs.Registry {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
return l.jobs
|
||||
}
|
||||
|
||||
// scanJobID returns the stable registry ID for a library's scan job.
|
||||
// It is stable across restarts so a durable pause can be matched back
|
||||
// to the library it belongs to.
|
||||
func scanJobID(libraryID int64) string {
|
||||
return scanJobPrefix + strconv.FormatInt(libraryID, 10)
|
||||
}
|
||||
|
||||
// libraryIDFromJobID parses a library ID back out of a scan job ID.
|
||||
func libraryIDFromJobID(id string) (int64, bool) {
|
||||
if len(id) <= len(scanJobPrefix) || id[:len(scanJobPrefix)] != scanJobPrefix {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
parsed, err := strconv.ParseInt(id[len(scanJobPrefix):], 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// scanJobSpec builds the registry spec for a library scan. Controls are
|
||||
// bound to the library ID rather than "the current scan" so that a
|
||||
// control arriving for a queued or stale job cannot disturb a different
|
||||
// library's scan.
|
||||
func (l *Library) scanJobSpec(
|
||||
entry scanQueueEntry,
|
||||
state jobs.State,
|
||||
) jobs.Spec {
|
||||
return jobs.Spec{
|
||||
ID: scanJobID(entry.libraryID),
|
||||
Kind: jobs.KindLibraryScan,
|
||||
Title: "Scanning " + entry.libraryName,
|
||||
Subtitle: entry.libraryPath,
|
||||
State: state,
|
||||
Caps: jobs.Caps{
|
||||
Pausable: true,
|
||||
Cancellable: true,
|
||||
},
|
||||
Durable: true,
|
||||
Controls: jobs.Controls{
|
||||
Pause: func() { l.pauseScanForLibrary(entry.libraryID) },
|
||||
Resume: func() { l.resumeScanForLibrary(entry) },
|
||||
Cancel: func() { l.cancelScanForLibrary(entry.libraryID) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// registerQueuedScanJob records a library that is waiting behind another
|
||||
// scan, so the user can see the whole pipeline rather than just the head.
|
||||
func (l *Library) registerQueuedScanJob(entry scanQueueEntry) {
|
||||
reg := l.jobRegistry()
|
||||
if reg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
h := reg.Start(l.scanJobSpec(entry, jobs.StateQueued))
|
||||
h.SetPhase("Waiting for other scans")
|
||||
h.Logf(jobs.LevelInfo, "Queued behind an in-progress scan")
|
||||
}
|
||||
|
||||
// startScanJob registers (or re-registers, for a queued job now starting)
|
||||
// the running job for a scan and returns its handle.
|
||||
func (l *Library) startScanJob(entry scanQueueEntry) *jobs.Handle {
|
||||
reg := l.jobRegistry()
|
||||
if reg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
h := reg.Start(l.scanJobSpec(entry, jobs.StateRunning))
|
||||
h.SetProgress(0, 0)
|
||||
h.Logf(jobs.LevelInfo, "Scan started for "+entry.libraryPath)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// reportScanProgress mirrors a ScanProgress payload into the job
|
||||
// registry. Called from the same places that emit LibraryScanProgress
|
||||
// so the two views never disagree.
|
||||
func reportScanProgress(h *jobs.Handle, p ScanProgress) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
|
||||
label, ok := scanPhaseLabels[p.Phase]
|
||||
if !ok {
|
||||
label = p.Phase
|
||||
}
|
||||
|
||||
h.SetPhase(label)
|
||||
|
||||
// The counting pre-walk has no denominator to report against, so
|
||||
// leave the job indeterminate until the total is known.
|
||||
if p.Phase == "counting" {
|
||||
h.SetProgress(0, 0)
|
||||
} else {
|
||||
h.SetProgress(p.Processed, p.Total)
|
||||
}
|
||||
|
||||
h.SetStats([]jobs.Stat{
|
||||
{Label: "Added", Value: strconv.FormatInt(p.Added, 10)},
|
||||
{Label: "Updated", Value: strconv.FormatInt(p.Updated, 10)},
|
||||
{Label: "Skipped", Value: strconv.FormatInt(p.Skipped, 10)},
|
||||
})
|
||||
}
|
||||
|
||||
// finishScanJob applies the terminal state and summary for a completed,
|
||||
// cancelled, or paused-then-abandoned scan.
|
||||
func finishScanJob(h *jobs.Handle, m *ScanMetrics, cancelled bool) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.SetStats([]jobs.Stat{
|
||||
{Label: "Added", Value: strconv.FormatInt(m.Added, 10)},
|
||||
{Label: "Updated", Value: strconv.FormatInt(m.Updated, 10)},
|
||||
{Label: "Skipped", Value: strconv.FormatInt(m.Skipped, 10)},
|
||||
{Label: "Removed", Value: strconv.FormatInt(m.Removed, 10)},
|
||||
{Label: "Duration", Value: m.Total.Round(time.Millisecond).String()},
|
||||
{Label: "Walk", Value: m.WalkDuration.Round(time.Millisecond).String()},
|
||||
{Label: "Metadata", Value: m.ExtractionWallClock.Round(time.Millisecond).String()},
|
||||
{Label: "DB writes", Value: m.DBWritesWallClock.Round(time.Millisecond).String()},
|
||||
{Label: "Thumbnails", Value: m.ThumbnailWallClock.Round(time.Millisecond).String()},
|
||||
})
|
||||
|
||||
// The full timing breakdown goes into the log rather than the stats
|
||||
// grid: it is a profiling aid, wanted rarely and in full, and the
|
||||
// log pane already has a copy-to-clipboard button.
|
||||
h.LogDetail(jobs.LevelInfo, "Timing breakdown", m.timingBreakdown())
|
||||
|
||||
if cancelled {
|
||||
h.Logf(jobs.LevelInfo, "Scan cancelled")
|
||||
h.Cancelled()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.Logf(jobs.LevelInfo,
|
||||
"Scan complete — "+
|
||||
strconv.FormatInt(m.Added, 10)+" added, "+
|
||||
strconv.FormatInt(m.Updated, 10)+" updated, "+
|
||||
strconv.FormatInt(m.Removed, 10)+" removed",
|
||||
)
|
||||
h.Complete()
|
||||
}
|
||||
|
||||
// pauseScanForLibrary pauses the running scan, but only when the library
|
||||
// asking is the one currently being scanned.
|
||||
func (l *Library) pauseScanForLibrary(libraryID int64) {
|
||||
l.mu.Lock()
|
||||
current := l.currentScanLibraryID
|
||||
l.mu.Unlock()
|
||||
|
||||
if current != libraryID {
|
||||
return
|
||||
}
|
||||
|
||||
l.PauseScan()
|
||||
}
|
||||
|
||||
// cancelScanForLibrary cancels a scan for one library. A queued library
|
||||
// is dropped from the queue; the running one is cancelled outright.
|
||||
func (l *Library) cancelScanForLibrary(libraryID int64) {
|
||||
l.mu.Lock()
|
||||
|
||||
current := l.currentScanLibraryID
|
||||
|
||||
if current != libraryID {
|
||||
// Not running — drop it from the queue if it is waiting there.
|
||||
kept := l.scanQueue[:0]
|
||||
|
||||
for _, entry := range l.scanQueue {
|
||||
if entry.libraryID != libraryID {
|
||||
kept = append(kept, entry)
|
||||
}
|
||||
}
|
||||
|
||||
l.scanQueue = kept
|
||||
l.mu.Unlock()
|
||||
|
||||
if reg := l.jobRegistry(); reg != nil {
|
||||
if h := reg.Get(scanJobID(libraryID)); h != nil {
|
||||
h.Cancelled()
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
cancel := l.scanCancel
|
||||
paused := l.scanPaused
|
||||
pauseCh := l.scanPauseCh
|
||||
|
||||
// Unblock paused workers so they observe the cancelled context
|
||||
// instead of sitting on the pause channel forever.
|
||||
if paused {
|
||||
l.scanPaused = false
|
||||
|
||||
if pauseCh != nil {
|
||||
close(pauseCh)
|
||||
}
|
||||
|
||||
l.scanPauseCh = nil
|
||||
}
|
||||
|
||||
l.mu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// No live scan goroutine — this is a scan that was paused before a
|
||||
// restart and never resumed. Retire the adopted job directly.
|
||||
if reg := l.jobRegistry(); reg != nil {
|
||||
if h := reg.Get(scanJobID(libraryID)); h != nil {
|
||||
h.Cancelled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resumeScanForLibrary continues a paused scan. When the scan goroutine
|
||||
// is still alive it is simply unblocked. When the pause outlived the
|
||||
// process, a fresh scan is queued instead: scans are incremental, so
|
||||
// already-imported files are skipped on the second pass and the effect
|
||||
// is a resume rather than a restart.
|
||||
func (l *Library) resumeScanForLibrary(entry scanQueueEntry) {
|
||||
l.mu.Lock()
|
||||
live := l.scanActive && l.currentScanLibraryID == entry.libraryID
|
||||
l.mu.Unlock()
|
||||
|
||||
if live {
|
||||
l.ResumeScan()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if reg := l.jobRegistry(); reg != nil {
|
||||
if h := reg.Get(scanJobID(entry.libraryID)); h != nil {
|
||||
h.Logf(jobs.LevelInfo,
|
||||
"Resuming from a previous session — already-imported "+
|
||||
"files are skipped")
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the durable pause record before restarting, otherwise
|
||||
// RestorePausedScans would adopt it again on the next launch.
|
||||
if reg := l.jobRegistry(); reg != nil {
|
||||
reg.Remove(scanJobID(entry.libraryID))
|
||||
}
|
||||
|
||||
if err := l.ScanLibrary(entry.libraryID); err != nil {
|
||||
l.logger.Warn("could not resume scan",
|
||||
"libraryID", entry.libraryID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// RestorePausedScans adopts scans that were paused when the app last
|
||||
// shut down back into the job registry, still paused. Call during
|
||||
// startup before SoftScanAllLibraries so the soft scan does not restart
|
||||
// a library the user deliberately paused.
|
||||
func (l *Library) RestorePausedScans() {
|
||||
reg := l.jobRegistry()
|
||||
if reg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, p := range reg.PausedEntries(jobs.KindLibraryScan) {
|
||||
libraryID, ok := libraryIDFromJobID(p.ID)
|
||||
if !ok {
|
||||
reg.Remove(p.ID)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
lib, err := l.db.Queries.GetLibrary(l.ctx, libraryID)
|
||||
if err != nil {
|
||||
// The library was removed while the scan was paused.
|
||||
l.logger.Info("dropping paused scan for missing library",
|
||||
"libraryID", libraryID)
|
||||
reg.Remove(p.ID)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
entry := scanQueueEntry{
|
||||
libraryID: lib.ID,
|
||||
libraryName: lib.Name,
|
||||
libraryPath: lib.Path,
|
||||
}
|
||||
|
||||
h := reg.Start(l.scanJobSpec(entry, jobs.StatePaused))
|
||||
h.SetPhase("Paused")
|
||||
h.Logf(jobs.LevelInfo, "Paused in a previous session — resume to continue")
|
||||
|
||||
l.logger.Info("restored paused library scan",
|
||||
"libraryID", lib.ID, "libraryName", lib.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// isScanPausedPersistently reports whether a library's scan was left
|
||||
// paused by a previous session.
|
||||
func (l *Library) isScanPausedPersistently(libraryID int64) bool {
|
||||
reg := l.jobRegistry()
|
||||
if reg == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return reg.IsPersistentlyPaused(scanJobID(libraryID))
|
||||
}
|
||||
@@ -26,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,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
@@ -22,14 +23,15 @@ const (
|
||||
dirTypeData dirType = "data"
|
||||
)
|
||||
|
||||
// envHomeOverride is the environment variable that, when set, relocates
|
||||
// all YellowJacket config and data under a single base directory. It
|
||||
// exists so a development build can run against an isolated sandbox
|
||||
// without touching the current user's real config.toml or yj.db.
|
||||
const envHomeOverride = "YJ_HOME"
|
||||
|
||||
// getUserDirPath returns and creates the path for a user directory.
|
||||
func getUserDirPath(dt dirType) (string, error) {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not get current user: %w", err)
|
||||
}
|
||||
|
||||
path, err := buildUserDirPath(currentUser.Username, dt)
|
||||
path, err := resolveUserDirPath(dt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -50,6 +52,22 @@ func getUserDirPath(dt dirType) (string, error) {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// resolveUserDirPath picks the base path for a user directory. When
|
||||
// YJ_HOME is set it wins for every OS, mapping to <YJ_HOME>/<dirType>;
|
||||
// otherwise the standard OS-specific location is used.
|
||||
func resolveUserDirPath(dt dirType) (string, error) {
|
||||
if home := os.Getenv(envHomeOverride); home != "" {
|
||||
return filepath.Join(home, string(dt)), nil
|
||||
}
|
||||
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not get current user: %w", err)
|
||||
}
|
||||
|
||||
return buildUserDirPath(currentUser.Username, dt)
|
||||
}
|
||||
|
||||
// buildUserDirPath constructs the OS-specific path for a user directory.
|
||||
func buildUserDirPath(username string, dt dirType) (string, error) {
|
||||
// Map directory types to their Unix subdirectory paths
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveUserDirPath_HomeOverride(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv(envHomeOverride, home)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dt dirType
|
||||
want string
|
||||
}{
|
||||
{name: "config", dt: dirTypeConfig, want: filepath.Join(home, "config")},
|
||||
{name: "data", dt: dirTypeData, want: filepath.Join(home, "data")},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := resolveUserDirPath(tt.dt)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveUserDirPath(%q) returned error: %v", tt.dt, err)
|
||||
}
|
||||
|
||||
if got != tt.want {
|
||||
t.Errorf("resolveUserDirPath(%q) = %q, want %q", tt.dt, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUserDirPath_NoOverrideUsesOSPath(t *testing.T) {
|
||||
t.Setenv(envHomeOverride, "")
|
||||
|
||||
got, err := resolveUserDirPath(dirTypeConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveUserDirPath returned error: %v", err)
|
||||
}
|
||||
|
||||
// Without the override the path must fall back to the OS-specific
|
||||
// yellowjacket location, not a bare "<home>/config" base dir.
|
||||
if !filepath.IsAbs(got) || !strings.HasSuffix(got, "yellowjacket") {
|
||||
t.Errorf(
|
||||
"resolveUserDirPath fallback = %q, want absolute path ending in %q",
|
||||
got, "yellowjacket",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
</div>
|
||||
<library-filter></library-filter>
|
||||
<search-bar></search-bar>
|
||||
<job-indicator></job-indicator>
|
||||
</header>
|
||||
<div class="sidebar">
|
||||
<app-sidebar></app-sidebar>
|
||||
@@ -41,6 +42,7 @@
|
||||
<wa-icon name="list"></wa-icon>
|
||||
</button>
|
||||
</footer>
|
||||
<first-run-wizard></first-run-wizard>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -20,6 +20,9 @@ import '@components/explore-view/explore-view.ts';
|
||||
import '@components/explore-artist-details/explore-artist-details.js';
|
||||
import '@components/explore-album-details/explore-album-details.js';
|
||||
import '@components/autotag-view/autotag-view.ts';
|
||||
import '@components/first-run-wizard/first-run-wizard.ts';
|
||||
import '@components/jobs/job-indicator.ts';
|
||||
import '@components/jobs/jobs-view.ts';
|
||||
import '@awesome.me/webawesome/dist/styles/themes/default.css';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
|
||||
@@ -61,6 +64,7 @@ const VIEW_TAGS: Record<string, string> = {
|
||||
playlists: 'playlist-view',
|
||||
explore: 'explore-view',
|
||||
autotag: 'autotag-view',
|
||||
jobs: 'jobs-view',
|
||||
settings: 'config-page',
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,272 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, state, query } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import {
|
||||
GetLibraryDirectory,
|
||||
SetLibraryDirectory,
|
||||
} from '@go/config/Config';
|
||||
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
|
||||
|
||||
/**
|
||||
* First-run setup wizard.
|
||||
*
|
||||
* On startup it checks whether a library directory has already been
|
||||
* configured. If one exists the wizard stays hidden and the app
|
||||
* proceeds as normal. If none is set (fresh install), it presents a
|
||||
* non-dismissable modal prompting the user to pick their music folder,
|
||||
* saves it to the config, and dismisses itself. Saving the directory
|
||||
* emits LibraryConfigChanged on the backend, which kicks off the
|
||||
* initial scan automatically.
|
||||
*/
|
||||
@customElement('first-run-wizard')
|
||||
export class FirstRunWizard extends LitElement {
|
||||
@query('wa-dialog')
|
||||
private dialog!: HTMLElement & { open: boolean };
|
||||
|
||||
/** Whether the wizard should be shown at all (no library configured). */
|
||||
@state() private active = false;
|
||||
|
||||
/** Directory chosen in the picker, not yet saved. */
|
||||
@state() private selectedDirectory = '';
|
||||
|
||||
/** True while SetLibraryDirectory is in flight. */
|
||||
@state() private saving = false;
|
||||
|
||||
/** Error message from a failed pick/save, if any. */
|
||||
@state() private errorMessage = '';
|
||||
|
||||
override async connectedCallback(): Promise<void> {
|
||||
super.connectedCallback();
|
||||
|
||||
try {
|
||||
const existing = await GetLibraryDirectory();
|
||||
|
||||
// A configured directory means setup is already complete.
|
||||
if (existing) return;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'First-run wizard: failed to read library directory:',
|
||||
err,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.active = true;
|
||||
|
||||
await this.updateComplete;
|
||||
|
||||
if (this.dialog) this.dialog.open = true;
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
wa-dialog {
|
||||
--width: 480px;
|
||||
}
|
||||
|
||||
wa-dialog::part(dialog) {
|
||||
background: var(--yj-bg-surface, #212529);
|
||||
color: var(--yj-text-primary, #fff);
|
||||
border: 1px solid var(--yj-border, #444);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
wa-dialog::part(body) {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.welcome wa-icon {
|
||||
font-size: 40px;
|
||||
color: var(--yj-accent, #f5c518);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.welcome h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.welcome p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
}
|
||||
|
||||
.chosen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
background: var(--yj-bg-elevated, #2a2f34);
|
||||
border: 1px solid var(--yj-border, #444);
|
||||
border-radius: 6px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.chosen wa-icon {
|
||||
color: var(--yj-accent, #f5c518);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 13px;
|
||||
color: var(--yj-danger, #e5484d);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--yj-border, #444);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--yj-bg-elevated, #2a2f34);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--yj-accent, #f5c518);
|
||||
border-color: var(--yj-accent, #f5c518);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
if (!this.active) return nothing;
|
||||
|
||||
return html`
|
||||
<wa-dialog
|
||||
label="Welcome to YellowJacket"
|
||||
without-header
|
||||
@wa-hide=${this.preventClose}
|
||||
>
|
||||
<div class="welcome">
|
||||
<wa-icon name="music"></wa-icon>
|
||||
<h2>Welcome to YellowJacket</h2>
|
||||
<p>
|
||||
Choose the folder where your music lives to
|
||||
get started.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="chosen">
|
||||
<wa-icon name="folder"></wa-icon>
|
||||
${this.selectedDirectory
|
||||
? html`<span>${this.selectedDirectory}</span>`
|
||||
: html`<span class="placeholder"
|
||||
>No folder selected yet</span
|
||||
>`}
|
||||
</div>
|
||||
|
||||
${this.errorMessage
|
||||
? html`<div class="error">${this.errorMessage}</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${this.saving}
|
||||
@click=${this.handleChoose}
|
||||
>
|
||||
${this.selectedDirectory
|
||||
? 'Change Folder'
|
||||
: 'Choose Folder'}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
?disabled=${!this.selectedDirectory || this.saving}
|
||||
@click=${this.handleFinish}
|
||||
>
|
||||
${this.saving ? 'Saving…' : 'Get Started'}
|
||||
</button>
|
||||
</div>
|
||||
</wa-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Set once setup is finished so we allow the dialog to close. */
|
||||
private finished = false;
|
||||
|
||||
/**
|
||||
* Block every close attempt (Escape, programmatic, backdrop) until
|
||||
* setup is finished, so the user can't skip picking a folder.
|
||||
* wa-hide is cancelable via preventDefault().
|
||||
*/
|
||||
private preventClose = (e: Event): void => {
|
||||
if (!this.finished) e.preventDefault();
|
||||
};
|
||||
|
||||
private handleChoose = async (): Promise<void> => {
|
||||
this.errorMessage = '';
|
||||
|
||||
try {
|
||||
const dir = await DirectoryPicker();
|
||||
|
||||
if (dir) this.selectedDirectory = dir;
|
||||
} catch (err) {
|
||||
this.errorMessage = `Could not open folder picker: ${err}`;
|
||||
console.error('First-run wizard: directory picker failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
private handleFinish = async (): Promise<void> => {
|
||||
if (!this.selectedDirectory) return;
|
||||
|
||||
this.saving = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
try {
|
||||
await SetLibraryDirectory(this.selectedDirectory);
|
||||
|
||||
this.finished = true;
|
||||
|
||||
if (this.dialog) this.dialog.open = false;
|
||||
|
||||
this.active = false;
|
||||
} catch (err) {
|
||||
this.errorMessage = `Could not save the folder: ${err}`;
|
||||
console.error('First-run wizard: save failed:', err);
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'first-run-wizard': FirstRunWizard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { jobStore } from '@store/job-store';
|
||||
|
||||
export type JobControlAction = 'pause' | 'resume' | 'cancel' | 'dismiss';
|
||||
|
||||
export interface JobControlDetail {
|
||||
id: string;
|
||||
action: JobControlAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a `job-control` event emitted by a `<job-row>`.
|
||||
*
|
||||
* Shared by every host that renders job rows — the top-bar popover, the
|
||||
* jobs page, and the details drawer — so a control behaves identically
|
||||
* wherever it is pressed, and so no host can forget to wire one up.
|
||||
*/
|
||||
export async function applyJobControl(e: Event): Promise<void> {
|
||||
const { id, action } = (e as CustomEvent).detail as JobControlDetail;
|
||||
|
||||
if (action === 'cancel' && !confirmCancel(id)) return;
|
||||
|
||||
switch (action) {
|
||||
case 'pause':
|
||||
await jobStore.pause(id);
|
||||
break;
|
||||
case 'resume':
|
||||
await jobStore.resume(id);
|
||||
break;
|
||||
case 'cancel':
|
||||
await jobStore.cancel(id);
|
||||
break;
|
||||
case 'dismiss':
|
||||
await jobStore.dismiss(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stopping an index build feels like discarding hours of downloading,
|
||||
* so it is worth a confirmation even though the checkpoint survives. A
|
||||
* library scan is cheap to re-run — don't nag for that one.
|
||||
*/
|
||||
function confirmCancel(id: string): boolean {
|
||||
const job = jobStore.getJob(id);
|
||||
|
||||
if (job?.kind !== 'index-build') return true;
|
||||
|
||||
return window.confirm(
|
||||
'Stop building the search index?\n\n' +
|
||||
'Progress is checkpointed, so you can resume later without ' +
|
||||
're-downloading. Until it finishes, search results stay ' +
|
||||
'limited to your own library.',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/drawer/drawer.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { jobStore } from '@store/job-store';
|
||||
import type { Job } from '@store/job-store';
|
||||
import './job-row';
|
||||
import './job-log-view';
|
||||
import { applyJobControl } from './job-controls';
|
||||
import {
|
||||
stateLabel,
|
||||
stateTone,
|
||||
formatElapsed,
|
||||
jobStateStyles,
|
||||
} from './job-format';
|
||||
|
||||
/** How often the open drawer re-fetches the job log. */
|
||||
const LOG_POLL_MS = 1500;
|
||||
|
||||
/**
|
||||
* Right-hand drawer showing everything known about one job: its
|
||||
* progress row, stage breakdown, statistics, and log tail.
|
||||
*
|
||||
* A drawer rather than a route, because these jobs run *while* the user
|
||||
* is doing something else — navigating away from their music to read a
|
||||
* scan log would defeat the purpose.
|
||||
*/
|
||||
@customElement('job-details-drawer')
|
||||
export class JobDetailsDrawer extends LitElement {
|
||||
/** ID of the job to show. Empty string closes the drawer. */
|
||||
@property({ type: String, attribute: 'job-id' })
|
||||
jobId = '';
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
open = false;
|
||||
|
||||
@state()
|
||||
private job: Job | null = null;
|
||||
|
||||
@state()
|
||||
private logVersion = 0;
|
||||
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
jobStateStyles,
|
||||
css`
|
||||
wa-drawer {
|
||||
--size: 34rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1em;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--yj-text-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--yj-text-tertiary, #868e96);
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(
|
||||
auto-fit,
|
||||
minmax(7.5rem, 1fr)
|
||||
);
|
||||
gap: 0.6em 1em;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15em;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: var(--yj-text-xs);
|
||||
color: var(--yj-text-tertiary, #868e96);
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: var(--yj-text-md);
|
||||
color: var(--yj-text-primary, #e9ecef);
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.summary-value.tone {
|
||||
color: var(--job-tone);
|
||||
}
|
||||
|
||||
.stages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4em;
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2em 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
font-size: var(--yj-text-md);
|
||||
color: var(--yj-text-secondary, #adb5bd);
|
||||
}
|
||||
|
||||
.stage-icon {
|
||||
font-size: var(--yj-icon-sm);
|
||||
}
|
||||
|
||||
.stage.running {
|
||||
color: var(--yj-text-primary, #e9ecef);
|
||||
}
|
||||
|
||||
.stage.running .stage-icon {
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.stage.complete .stage-icon {
|
||||
color: #1db954;
|
||||
}
|
||||
|
||||
.stage.error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.stage-count {
|
||||
font-size: var(--yj-text-sm);
|
||||
color: var(--yj-text-tertiary, #868e96);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stage-error {
|
||||
grid-column: 2 / -1;
|
||||
font-size: var(--yj-text-sm);
|
||||
color: #ff6b6b;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.log-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
job-log-view {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: var(--yj-text-sm);
|
||||
color: var(--yj-text-tertiary, #868e96);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.unsubscribe = jobStore.subscribe(() => this.syncJob());
|
||||
this.syncJob();
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
this.stopPolling();
|
||||
}
|
||||
|
||||
override updated(changed: Map<string, unknown>) {
|
||||
if (changed.has('jobId') || changed.has('open')) {
|
||||
this.syncJob();
|
||||
|
||||
if (this.open && this.jobId) {
|
||||
void this.refreshLog();
|
||||
this.startPolling();
|
||||
} else {
|
||||
this.stopPolling();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private syncJob() {
|
||||
this.job = this.jobId ? (jobStore.getJob(this.jobId) ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs are polled while the drawer is open rather than pushed with
|
||||
* every snapshot — a scan can emit hundreds of warnings and only
|
||||
* this panel ever renders them.
|
||||
*/
|
||||
private startPolling() {
|
||||
this.stopPolling();
|
||||
this.pollTimer = setInterval(() => void this.refreshLog(), LOG_POLL_MS);
|
||||
}
|
||||
|
||||
private stopPolling() {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshLog() {
|
||||
if (!this.jobId) return;
|
||||
|
||||
await jobStore.loadLog(this.jobId);
|
||||
this.logVersion += 1;
|
||||
}
|
||||
|
||||
private onHide = () => {
|
||||
this.open = false;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('drawer-closed', { bubbles: true, composed: true }),
|
||||
);
|
||||
};
|
||||
|
||||
private stageIcon(state: string): string {
|
||||
switch (state) {
|
||||
case 'complete':
|
||||
return 'circle-check';
|
||||
case 'running':
|
||||
return 'arrows-rotate';
|
||||
case 'error':
|
||||
return 'triangle-exclamation';
|
||||
case 'skipped':
|
||||
return 'circle-minus';
|
||||
default:
|
||||
return 'circle-info';
|
||||
}
|
||||
}
|
||||
|
||||
private renderStages(job: Job) {
|
||||
if (!job.stages?.length) return nothing;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div class="section-title">Stages</div>
|
||||
<div class="stages">
|
||||
${job.stages.map(
|
||||
(stage) => html`
|
||||
<div class="stage ${stage.state}">
|
||||
<wa-icon
|
||||
class="stage-icon"
|
||||
name=${this.stageIcon(stage.state)}
|
||||
></wa-icon>
|
||||
<span>${stage.name}</span>
|
||||
<span class="stage-count">
|
||||
${stage.total > 0
|
||||
? `${stage.current.toLocaleString()} / ${stage.total.toLocaleString()}`
|
||||
: stage.state}
|
||||
</span>
|
||||
${stage.error
|
||||
? html`<div class="stage-error">
|
||||
${stage.error}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSummary(job: Job) {
|
||||
const items = [
|
||||
{ label: 'Status', value: stateLabel(job), tone: true },
|
||||
{ label: 'Elapsed', value: formatElapsed(job), tone: false },
|
||||
...(job.stats ?? []).map((s) => ({
|
||||
label: s.label,
|
||||
value: s.value,
|
||||
tone: false,
|
||||
})),
|
||||
];
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div class="section-title">Summary</div>
|
||||
<div class="summary">
|
||||
${items.map(
|
||||
(item) => html`
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">${item.label}</span>
|
||||
<span
|
||||
class="summary-value ${item.tone
|
||||
? 'tone'
|
||||
: ''}"
|
||||
>${item.value}</span
|
||||
>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const job = this.job;
|
||||
|
||||
return html`
|
||||
<wa-drawer
|
||||
?open=${this.open}
|
||||
label=${job?.title ?? 'Job details'}
|
||||
@wa-hide=${this.onHide}
|
||||
class="tone-${job ? stateTone(job) : 'muted'}"
|
||||
>
|
||||
${job
|
||||
? html`
|
||||
<div class="content">
|
||||
${job.subtitle
|
||||
? html`<div class="subtitle">
|
||||
${job.subtitle}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<job-row
|
||||
.job=${job}
|
||||
variant="full"
|
||||
@job-control=${applyJobControl}
|
||||
></job-row>
|
||||
|
||||
${this.renderSummary(job)}
|
||||
${this.renderStages(job)}
|
||||
|
||||
<div class="log-section">
|
||||
<div class="section-title">Output</div>
|
||||
<job-log-view
|
||||
.job=${job}
|
||||
.entries=${jobStore.cachedLog(job.id)}
|
||||
data-version=${this.logVersion}
|
||||
></job-log-view>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`<p>This job is no longer available.</p>`}
|
||||
</wa-drawer>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'job-details-drawer': JobDetailsDrawer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { css } from 'lit';
|
||||
import type { Job, JobLogEntry } from '@store/job-store';
|
||||
import { isIndeterminate, progressFraction } from '@store/job-store';
|
||||
|
||||
/** Icon for a job kind, used in the indicator and job rows. */
|
||||
export function jobIcon(job: Job): string {
|
||||
switch (job.kind) {
|
||||
case 'library-scan':
|
||||
return 'folder';
|
||||
case 'index-build':
|
||||
return 'database';
|
||||
default:
|
||||
return 'gear';
|
||||
}
|
||||
}
|
||||
|
||||
/** Human-readable label for a job state. */
|
||||
export function stateLabel(job: Job): string {
|
||||
switch (job.state) {
|
||||
case 'queued':
|
||||
return 'Queued';
|
||||
case 'running':
|
||||
return 'Running';
|
||||
case 'pausing':
|
||||
return 'Pausing…';
|
||||
case 'paused':
|
||||
return 'Paused';
|
||||
case 'cancelling':
|
||||
return 'Cancelling…';
|
||||
case 'complete':
|
||||
return 'Complete';
|
||||
case 'cancelled':
|
||||
return 'Stopped';
|
||||
case 'error':
|
||||
return 'Failed';
|
||||
default:
|
||||
return job.state;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic colour name for a state, mapped to CSS custom properties by
|
||||
* the `jobStateStyles` block below.
|
||||
*/
|
||||
export function stateTone(
|
||||
job: Job,
|
||||
): 'active' | 'paused' | 'danger' | 'success' | 'muted' {
|
||||
switch (job.state) {
|
||||
case 'running':
|
||||
return 'active';
|
||||
case 'pausing':
|
||||
case 'paused':
|
||||
return 'paused';
|
||||
case 'error':
|
||||
return 'danger';
|
||||
case 'complete':
|
||||
return 'success';
|
||||
default:
|
||||
return 'muted';
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact "1,204 / 12,880" progress text, or null when indeterminate. */
|
||||
export function progressText(job: Job): string | null {
|
||||
if (isIndeterminate(job)) return null;
|
||||
|
||||
return `${formatCount(job.current)} / ${formatCount(job.total)}`;
|
||||
}
|
||||
|
||||
/** Progress as a whole-number percentage, or null when indeterminate. */
|
||||
export function progressPercent(job: Job): number | null {
|
||||
const fraction = progressFraction(job);
|
||||
|
||||
return fraction === null ? null : Math.round(fraction * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-line status shown under a job title: phase plus counts, with
|
||||
* the state folded in when it is something other than plain running.
|
||||
*/
|
||||
export function statusLine(job: Job): string {
|
||||
const parts: string[] = [];
|
||||
const label = stateLabel(job);
|
||||
|
||||
if (job.state !== 'running' && job.state !== 'queued') {
|
||||
parts.push(label);
|
||||
}
|
||||
|
||||
// A paused job's phase is often just "Paused", which would render
|
||||
// as "Paused · Paused" alongside the state label.
|
||||
if (job.phase && job.phase !== label) parts.push(job.phase);
|
||||
|
||||
const progress = progressText(job);
|
||||
|
||||
if (progress && job.state !== 'complete') parts.push(progress);
|
||||
|
||||
if (parts.length === 0) parts.push(stateLabel(job));
|
||||
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
export function formatCount(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
/** Wall-clock duration of a job, as "1m 24s". */
|
||||
export function formatElapsed(job: Job): string {
|
||||
const end = job.endedAt && job.endedAt > 0 ? job.endedAt : Date.now();
|
||||
const seconds = Math.max(0, Math.round((end - job.startedAt) / 1000));
|
||||
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = seconds % 60;
|
||||
|
||||
if (minutes < 60) return `${minutes}m ${remainder}s`;
|
||||
|
||||
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
||||
}
|
||||
|
||||
/** Clock time for a log entry, e.g. "14:03:21". */
|
||||
export function formatLogTime(entry: JobLogEntry): string {
|
||||
return new Date(entry.time).toLocaleTimeString(undefined, {
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Renders a job log as plain text for the clipboard. */
|
||||
export function logToText(job: Job, entries: JobLogEntry[]): string {
|
||||
const header = `${job.title} — ${stateLabel(job)}`;
|
||||
const lines = entries.map((entry) => {
|
||||
const detail = entry.detail ? ` (${entry.detail})` : '';
|
||||
|
||||
return `${formatLogTime(entry)} [${entry.level}] ${entry.message}${detail}`;
|
||||
});
|
||||
|
||||
return [header, ...lines].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared state-tone colour variables. Include in a component's styles
|
||||
* array so `.tone-active`, `.tone-paused` etc. resolve consistently
|
||||
* wherever job state is rendered.
|
||||
*/
|
||||
export const jobStateStyles = css`
|
||||
.tone-active {
|
||||
--job-tone: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.tone-paused {
|
||||
--job-tone: #f5a623;
|
||||
}
|
||||
|
||||
.tone-danger {
|
||||
--job-tone: #ff6b6b;
|
||||
}
|
||||
|
||||
.tone-success {
|
||||
--job-tone: #1db954;
|
||||
}
|
||||
|
||||
.tone-muted {
|
||||
--job-tone: var(--yj-text-tertiary, #868e96);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,440 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { jobStore } from '@store/job-store';
|
||||
import type { Job } from '@store/job-store';
|
||||
import { isIndeterminate, progressFraction } from '@store/job-store';
|
||||
import './job-row';
|
||||
import './job-details-drawer';
|
||||
import { applyJobControl } from './job-controls';
|
||||
import {
|
||||
jobIcon,
|
||||
stateTone,
|
||||
stateLabel,
|
||||
jobStateStyles,
|
||||
} from './job-format';
|
||||
|
||||
/** Circumference of the progress ring at r=9. */
|
||||
const RING_CIRCUMFERENCE = 2 * Math.PI * 9;
|
||||
|
||||
/**
|
||||
* Persistent background-job indicator for the top bar.
|
||||
*
|
||||
* Hidden entirely when nothing is running, so it costs no attention in
|
||||
* the common case. When work is in flight it shows a determinate ring
|
||||
* for a single job, or a count badge for several. Clicking opens a
|
||||
* popover with inline pause/stop controls; "Details" opens the drawer.
|
||||
*/
|
||||
@customElement('job-indicator')
|
||||
export class JobIndicator extends LitElement {
|
||||
@state()
|
||||
private jobs: Job[] = [];
|
||||
|
||||
@state()
|
||||
private popoverOpen = false;
|
||||
|
||||
@state()
|
||||
private drawerJobId = '';
|
||||
|
||||
@state()
|
||||
private drawerOpen = false;
|
||||
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
jobStateStyles,
|
||||
css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:host([hidden]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
padding: 0.3em 0.7em 0.3em 0.35em;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--yj-text-secondary, #adb5bd);
|
||||
cursor: pointer;
|
||||
font-size: var(--yj-text-sm);
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
border-color 140ms ease,
|
||||
color 140ms ease;
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--yj-text-primary, #e9ecef);
|
||||
}
|
||||
|
||||
.trigger:focus-visible {
|
||||
outline: 2px solid var(--yj-accent, #ffd43b);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ring-wrap {
|
||||
position: relative;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.ring-track {
|
||||
fill: none;
|
||||
stroke: rgba(255, 255, 255, 0.14);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.ring-value {
|
||||
fill: none;
|
||||
stroke: var(--job-tone);
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 240ms ease;
|
||||
}
|
||||
|
||||
/* Indeterminate work spins the whole ring instead of
|
||||
* advancing it, so it never implies false precision. */
|
||||
.ring-wrap.spin svg {
|
||||
animation: spin 1.1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ring-wrap.spin svg {
|
||||
animation-duration: 3s;
|
||||
}
|
||||
}
|
||||
|
||||
.ring-glyph {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
color: var(--job-tone);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.label {
|
||||
white-space: nowrap;
|
||||
max-width: 12rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.alert-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #ff6b6b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: 24rem;
|
||||
max-width: 92vw;
|
||||
background: var(--yj-surface, #212529);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
padding: 0.4em;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.4em 0.6em 0.5em;
|
||||
font-size: var(--yj-text-sm);
|
||||
color: var(--yj-text-tertiary, #868e96);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.panel-header button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--yj-text-secondary, #adb5bd);
|
||||
font-size: var(--yj-text-sm);
|
||||
cursor: pointer;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.panel-header button:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--yj-text-primary, #e9ecef);
|
||||
}
|
||||
|
||||
.job-entry {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.job-entry + .job-entry {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.details-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
font-size: var(--yj-text-sm);
|
||||
cursor: pointer;
|
||||
padding: 0 0.75em 0.6em 3.2em;
|
||||
}
|
||||
|
||||
.details-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 0.8em;
|
||||
font-size: var(--yj-text-sm);
|
||||
color: var(--yj-text-tertiary, #868e96);
|
||||
font-style: italic;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.unsubscribe = jobStore.subscribe(() => this.syncJobs());
|
||||
void jobStore.init();
|
||||
this.syncJobs();
|
||||
document.addEventListener('click', this.onDocumentClick);
|
||||
document.addEventListener('keydown', this.onKeydown);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
document.removeEventListener('click', this.onDocumentClick);
|
||||
document.removeEventListener('keydown', this.onKeydown);
|
||||
}
|
||||
|
||||
private syncJobs() {
|
||||
this.jobs = jobStore.jobs;
|
||||
// The drawer stays mounted so it can animate closed; the pill
|
||||
// itself disappears once nothing is happening.
|
||||
this.hidden = !jobStore.shouldShowIndicator && !this.drawerOpen;
|
||||
|
||||
if (this.hidden) this.popoverOpen = false;
|
||||
}
|
||||
|
||||
private onDocumentClick = (e: MouseEvent) => {
|
||||
if (!this.popoverOpen) return;
|
||||
if (e.composedPath().includes(this)) return;
|
||||
|
||||
this.popoverOpen = false;
|
||||
};
|
||||
|
||||
private onKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && this.popoverOpen) this.popoverOpen = false;
|
||||
};
|
||||
|
||||
private onTriggerClick = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
this.popoverOpen = !this.popoverOpen;
|
||||
};
|
||||
|
||||
private openDetails(id: string) {
|
||||
this.drawerJobId = id;
|
||||
this.drawerOpen = true;
|
||||
this.popoverOpen = false;
|
||||
}
|
||||
|
||||
private onDrawerClosed = () => {
|
||||
this.drawerOpen = false;
|
||||
this.syncJobs();
|
||||
};
|
||||
|
||||
private async clearFinished(e: Event) {
|
||||
e.stopPropagation();
|
||||
await jobStore.clearFinished();
|
||||
}
|
||||
|
||||
/** The job whose progress the ring represents. */
|
||||
private get primaryJob(): Job | null {
|
||||
const working = jobStore.workingJobs;
|
||||
|
||||
if (working.length > 0) return working[0] ?? null;
|
||||
|
||||
const active = jobStore.activeJobs;
|
||||
|
||||
return active[0] ?? null;
|
||||
}
|
||||
|
||||
private renderRing(job: Job | null) {
|
||||
const activeCount = jobStore.activeJobs.length;
|
||||
const fraction = job ? progressFraction(job) : null;
|
||||
const tone = job ? stateTone(job) : 'success';
|
||||
|
||||
// Only spin for work that is actually moving. A paused or
|
||||
// queued job spinning would say "busy" when nothing is running.
|
||||
const spin = Boolean(
|
||||
job && job.state === 'running' && isIndeterminate(job),
|
||||
);
|
||||
|
||||
const offset =
|
||||
fraction === null
|
||||
? RING_CIRCUMFERENCE * 0.72
|
||||
: RING_CIRCUMFERENCE * (1 - fraction);
|
||||
|
||||
return html`
|
||||
<div class="ring-wrap tone-${tone} ${spin ? 'spin' : ''}">
|
||||
<svg viewBox="0 0 22 22" aria-hidden="true">
|
||||
<circle class="ring-track" cx="11" cy="11" r="9"></circle>
|
||||
<circle
|
||||
class="ring-value"
|
||||
cx="11"
|
||||
cy="11"
|
||||
r="9"
|
||||
stroke-dasharray=${RING_CIRCUMFERENCE}
|
||||
stroke-dashoffset=${offset}
|
||||
></circle>
|
||||
</svg>
|
||||
<div class="ring-glyph">
|
||||
${activeCount > 1
|
||||
? activeCount
|
||||
: html`<wa-icon
|
||||
name=${job ? jobIcon(job) : 'check'}
|
||||
></wa-icon>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTrigger() {
|
||||
const job = this.primaryJob;
|
||||
const activeCount = jobStore.activeJobs.length;
|
||||
const hasFailure = jobStore.failedJobs.length > 0;
|
||||
|
||||
let label: string;
|
||||
|
||||
if (activeCount > 1) {
|
||||
label = `${activeCount} background jobs`;
|
||||
} else if (job && job.state === 'running') {
|
||||
label = job.title;
|
||||
} else if (job) {
|
||||
// "Scanning Music" would be a lie for a job that is paused
|
||||
// or queued, so lead with the state instead.
|
||||
label = `${stateLabel(job)} · ${job.title}`;
|
||||
} else {
|
||||
label = 'Finished';
|
||||
}
|
||||
|
||||
return html`
|
||||
<button
|
||||
class="trigger"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded=${this.popoverOpen}
|
||||
title="Background jobs"
|
||||
@click=${this.onTriggerClick}
|
||||
>
|
||||
${this.renderRing(job)}
|
||||
<span class="label">${label}</span>
|
||||
${hasFailure ? html`<span class="alert-dot"></span>` : nothing}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPanel() {
|
||||
const finished = jobStore.finishedJobs;
|
||||
|
||||
return html`
|
||||
<div class="panel" role="dialog" aria-label="Background jobs">
|
||||
<div class="panel-header">
|
||||
<span>Background jobs</span>
|
||||
${finished.length > 0
|
||||
? html`<button @click=${this.clearFinished}>
|
||||
Clear finished
|
||||
</button>`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
${this.jobs.length === 0
|
||||
? html`<div class="empty">Nothing running.</div>`
|
||||
: this.jobs.map(
|
||||
(job) => html`
|
||||
<div class="job-entry">
|
||||
<job-row
|
||||
.job=${job}
|
||||
variant="compact"
|
||||
open-on-click
|
||||
@job-control=${applyJobControl}
|
||||
@job-open=${() =>
|
||||
this.openDetails(job.id)}
|
||||
></job-row>
|
||||
<button
|
||||
class="details-link"
|
||||
@click=${() => this.openDetails(job.id)}
|
||||
>
|
||||
Details${job.warnCount
|
||||
? ` · ${job.warnCount} warning${job.warnCount === 1 ? '' : 's'}`
|
||||
: ''}
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<wa-popup
|
||||
placement="bottom-end"
|
||||
distance="8"
|
||||
?active=${this.popoverOpen}
|
||||
>
|
||||
<span slot="anchor">${this.renderTrigger()}</span>
|
||||
${this.popoverOpen ? this.renderPanel() : nothing}
|
||||
</wa-popup>
|
||||
|
||||
<job-details-drawer
|
||||
job-id=${this.drawerJobId}
|
||||
?open=${this.drawerOpen}
|
||||
@drawer-closed=${this.onDrawerClosed}
|
||||
></job-details-drawer>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'job-indicator': JobIndicator;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import type { Job, JobLogEntry } from '@store/job-store';
|
||||
import { formatLogTime, logToText } from './job-format';
|
||||
|
||||
type LevelFilter = 'all' | 'warn' | 'error';
|
||||
|
||||
/**
|
||||
* Scrolling tail of a job's output log, with a severity filter and a
|
||||
* copy-to-clipboard button.
|
||||
*
|
||||
* The buffer is bounded on the backend (500 entries), so this is a tail
|
||||
* rather than a complete transcript — the header says so when entries
|
||||
* have been dropped, rather than silently showing a partial log.
|
||||
*/
|
||||
@customElement('job-log-view')
|
||||
export class JobLogView extends LitElement {
|
||||
@property({ type: Object })
|
||||
job!: Job;
|
||||
|
||||
@property({ type: Array })
|
||||
entries: JobLogEntry[] = [];
|
||||
|
||||
@state()
|
||||
private filter: LevelFilter = 'all';
|
||||
|
||||
@state()
|
||||
private copied = false;
|
||||
|
||||
/** Set while the user has scrolled up, which suspends auto-follow. */
|
||||
@state()
|
||||
private following = true;
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.2em;
|
||||
}
|
||||
|
||||
.filters button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--yj-text-secondary, #adb5bd);
|
||||
font-size: var(--yj-text-sm);
|
||||
padding: 0.25em 0.6em;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filters button.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--yj-text-primary, #e9ecef);
|
||||
}
|
||||
|
||||
.filters button:focus-visible {
|
||||
outline: 2px solid var(--yj-accent, #ffd43b);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.copy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--yj-text-secondary, #adb5bd);
|
||||
font-size: var(--yj-text-sm);
|
||||
padding: 0.25em 0.5em;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copy:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--yj-text-primary, #e9ecef);
|
||||
}
|
||||
|
||||
.log {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 8px;
|
||||
padding: 0.6em 0.75em;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: var(--yj-text-sm);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: 0.7em;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: var(--yj-text-tertiary, #868e96);
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.message {
|
||||
color: var(--yj-text-secondary, #adb5bd);
|
||||
}
|
||||
|
||||
.entry.warn .message {
|
||||
color: #f5a623;
|
||||
}
|
||||
|
||||
.entry.error .message {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.detail {
|
||||
color: var(--yj-text-tertiary, #868e96);
|
||||
padding-left: 1em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--yj-text-tertiary, #868e96);
|
||||
font-style: italic;
|
||||
padding: 0.5em 0;
|
||||
}
|
||||
|
||||
.truncation-note {
|
||||
color: var(--yj-text-tertiary, #868e96);
|
||||
font-style: italic;
|
||||
padding-bottom: 0.4em;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
private get filtered(): JobLogEntry[] {
|
||||
switch (this.filter) {
|
||||
case 'warn':
|
||||
return this.entries.filter(
|
||||
(e) => e.level === 'warn' || e.level === 'error',
|
||||
);
|
||||
case 'error':
|
||||
return this.entries.filter((e) => e.level === 'error');
|
||||
default:
|
||||
return this.entries;
|
||||
}
|
||||
}
|
||||
|
||||
/** Entries the backend ring buffer dropped before we fetched it. */
|
||||
private get droppedCount(): number {
|
||||
return Math.max(0, (this.job?.logCount ?? 0) - this.entries.length);
|
||||
}
|
||||
|
||||
override updated() {
|
||||
if (!this.following) return;
|
||||
|
||||
const log = this.renderRoot.querySelector('.log');
|
||||
|
||||
if (log) log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
private onScroll = (e: Event) => {
|
||||
const el = e.target as HTMLElement;
|
||||
// Re-engage auto-follow when the user returns to the bottom.
|
||||
this.following =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight < 24;
|
||||
};
|
||||
|
||||
private setFilter(filter: LevelFilter) {
|
||||
this.filter = filter;
|
||||
}
|
||||
|
||||
private async copyLog() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(
|
||||
logToText(this.job, this.entries),
|
||||
);
|
||||
this.copied = true;
|
||||
setTimeout(() => {
|
||||
this.copied = false;
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy job log:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private renderFilterButton(value: LevelFilter, label: string) {
|
||||
return html`
|
||||
<button
|
||||
class=${this.filter === value ? 'active' : ''}
|
||||
@click=${() => this.setFilter(value)}
|
||||
>
|
||||
${label}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const entries = this.filtered;
|
||||
const dropped = this.droppedCount;
|
||||
|
||||
return html`
|
||||
<div class="toolbar">
|
||||
<div class="filters">
|
||||
${this.renderFilterButton('all', 'All')}
|
||||
${this.renderFilterButton(
|
||||
'warn',
|
||||
`Warnings${this.job?.warnCount ? ` (${this.job.warnCount})` : ''}`,
|
||||
)}
|
||||
${this.renderFilterButton(
|
||||
'error',
|
||||
`Errors${this.job?.errorCount ? ` (${this.job.errorCount})` : ''}`,
|
||||
)}
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<button class="copy" @click=${this.copyLog}>
|
||||
${this.copied
|
||||
? html`<wa-icon name="check"></wa-icon>Copied`
|
||||
: 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="log" @scroll=${this.onScroll}>
|
||||
${dropped > 0
|
||||
? html`
|
||||
<div class="truncation-note">
|
||||
${dropped.toLocaleString()} earlier
|
||||
${dropped === 1 ? 'entry' : 'entries'} dropped —
|
||||
showing the most recent output
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${entries.length === 0
|
||||
? html`<div class="empty">No output yet.</div>`
|
||||
: entries.map(
|
||||
(entry) => html`
|
||||
<div class="entry ${entry.level}">
|
||||
<span class="time"
|
||||
>${formatLogTime(entry)}</span
|
||||
>
|
||||
<span class="message">${entry.message}</span>
|
||||
</div>
|
||||
${entry.detail
|
||||
? html`<div class="detail">
|
||||
${entry.detail}
|
||||
</div>`
|
||||
: nothing}
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'job-log-view': JobLogView;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@awesome.me/webawesome/dist/components/progress-bar/progress-bar.js';
|
||||
import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import type { Job } from '@store/job-store';
|
||||
import { isTerminal, isIndeterminate } from '@store/job-store';
|
||||
import {
|
||||
jobIcon,
|
||||
stateTone,
|
||||
statusLine,
|
||||
progressPercent,
|
||||
formatElapsed,
|
||||
jobStateStyles,
|
||||
} from './job-format';
|
||||
|
||||
/**
|
||||
* A single background job: icon, title, status line, progress bar, and
|
||||
* whichever controls the job declares support for.
|
||||
*
|
||||
* Controls are rendered from `job.caps` rather than from the job kind,
|
||||
* so a job that gains pause support on the backend needs no change
|
||||
* here. The row emits `job-control` and `job-open`; the host decides
|
||||
* what to do with them, which is what lets the same row serve both the
|
||||
* top-bar popover and the full jobs page.
|
||||
*/
|
||||
@customElement('job-row')
|
||||
export class JobRow extends LitElement {
|
||||
@property({ type: Object })
|
||||
job!: Job;
|
||||
|
||||
/**
|
||||
* `compact` is the popover density — one line of status, small
|
||||
* controls. `full` adds elapsed time and per-job statistics.
|
||||
*/
|
||||
@property({ type: String })
|
||||
variant: 'compact' | 'full' = 'compact';
|
||||
|
||||
/** Whether clicking the row should emit `job-open`. */
|
||||
@property({ type: Boolean, attribute: 'open-on-click' })
|
||||
openOnClick = false;
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
jobStateStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 0.75em;
|
||||
align-items: start;
|
||||
padding: 0.6em 0.75em;
|
||||
border-radius: 8px;
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
|
||||
.row.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row.clickable:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--job-tone);
|
||||
font-size: var(--yj-icon-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--yj-text-md);
|
||||
color: var(--yj-text-primary, #e9ecef);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: var(--yj-text-sm);
|
||||
color: var(--yj-text-secondary, #adb5bd);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 0.15em;
|
||||
}
|
||||
|
||||
.status .tone {
|
||||
color: var(--job-tone);
|
||||
}
|
||||
|
||||
wa-progress-bar {
|
||||
margin-top: 0.45em;
|
||||
--height: 4px;
|
||||
--indicator-color: var(--job-tone);
|
||||
--track-color: rgba(255, 255, 255, 0.09);
|
||||
}
|
||||
|
||||
.indeterminate {
|
||||
margin-top: 0.5em;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* A slider that sweeps left to right, for work with no
|
||||
* known denominator (the pre-walk file count, for one).
|
||||
* Static unless the job is actually moving. */
|
||||
/* Stopped: a dim full-width bar, which reads as "no progress
|
||||
* information" rather than a partial fill implying a
|
||||
* percentage the job never reported. */
|
||||
.indeterminate::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--job-tone);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.indeterminate.moving::after {
|
||||
width: 35%;
|
||||
opacity: 1;
|
||||
animation: sweep 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes sweep {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(320%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.indeterminate.moving::after {
|
||||
animation: none;
|
||||
width: 100%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25em 1.1em;
|
||||
margin-top: 0.5em;
|
||||
font-size: var(--yj-text-sm);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--yj-text-tertiary, #868e96);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: var(--yj-text-primary, #e9ecef);
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-left: 0.35em;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 0.45em;
|
||||
font-size: var(--yj-text-sm);
|
||||
color: #ff6b6b;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.15em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--yj-text-secondary, #adb5bd);
|
||||
cursor: pointer;
|
||||
font-size: var(--yj-icon-sm);
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
color 120ms ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--yj-text-primary, #e9ecef);
|
||||
}
|
||||
|
||||
button.danger:hover:not(:disabled) {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--yj-accent, #ffd43b);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
private emitControl(action: 'pause' | 'resume' | 'cancel' | 'dismiss') {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('job-control', {
|
||||
detail: { id: this.job.id, action },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private emitOpen() {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('job-open', {
|
||||
detail: { id: this.job.id },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private renderProgress() {
|
||||
const job = this.job;
|
||||
|
||||
if (isTerminal(job)) return nothing;
|
||||
|
||||
if (isIndeterminate(job)) {
|
||||
// Only sweep while work is actually moving — an animated bar
|
||||
// on a paused job reads as progress that isn't happening.
|
||||
return html`
|
||||
<div
|
||||
class="indeterminate ${job.state === 'running'
|
||||
? 'moving'
|
||||
: ''}"
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<wa-progress-bar
|
||||
value=${progressPercent(job) ?? 0}
|
||||
></wa-progress-bar>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderStats() {
|
||||
if (this.variant !== 'full') return nothing;
|
||||
if (!this.job.stats?.length) return nothing;
|
||||
|
||||
return html`
|
||||
<div class="stats">
|
||||
${this.job.stats.map(
|
||||
(stat) => html`
|
||||
<div>
|
||||
<span class="stat-label">${stat.label}</span>
|
||||
<span class="stat-value">${stat.value}</span>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderControls() {
|
||||
const job = this.job;
|
||||
|
||||
// A finished job offers only dismissal.
|
||||
if (isTerminal(job)) {
|
||||
return html`
|
||||
<button
|
||||
class="danger"
|
||||
title="Dismiss"
|
||||
aria-label="Dismiss ${job.title}"
|
||||
@click=${this.onDismiss}
|
||||
>
|
||||
<wa-icon name="xmark"></wa-icon>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
const paused = job.state === 'paused';
|
||||
const settling = job.state === 'pausing' || job.state === 'cancelling';
|
||||
|
||||
return html`
|
||||
${job.caps.pausable
|
||||
? html`
|
||||
<button
|
||||
title=${paused ? 'Resume' : 'Pause'}
|
||||
aria-label=${paused
|
||||
? `Resume ${job.title}`
|
||||
: `Pause ${job.title}`}
|
||||
?disabled=${settling}
|
||||
@click=${paused ? this.onResume : this.onPause}
|
||||
>
|
||||
<wa-icon name=${paused ? 'play' : 'pause'}></wa-icon>
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
${job.caps.cancellable
|
||||
? html`
|
||||
<button
|
||||
class="danger"
|
||||
title="Stop"
|
||||
aria-label="Stop ${job.title}"
|
||||
?disabled=${job.state === 'cancelling'}
|
||||
@click=${this.onCancel}
|
||||
>
|
||||
<wa-icon name="stop"></wa-icon>
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private onPause = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
this.emitControl('pause');
|
||||
};
|
||||
|
||||
private onResume = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
this.emitControl('resume');
|
||||
};
|
||||
|
||||
private onCancel = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
this.emitControl('cancel');
|
||||
};
|
||||
|
||||
private onDismiss = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
this.emitControl('dismiss');
|
||||
};
|
||||
|
||||
private onRowClick = () => {
|
||||
if (this.openOnClick) this.emitOpen();
|
||||
};
|
||||
|
||||
override render() {
|
||||
const job = this.job;
|
||||
|
||||
if (!job) return nothing;
|
||||
|
||||
const tone = stateTone(job);
|
||||
const elapsed =
|
||||
this.variant === 'full' ? ` · ${formatElapsed(job)}` : '';
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="row tone-${tone} ${this.openOnClick ? 'clickable' : ''}"
|
||||
@click=${this.onRowClick}
|
||||
>
|
||||
<div class="icon">
|
||||
<wa-icon name=${jobIcon(job)}></wa-icon>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div class="title">${job.title}</div>
|
||||
<div class="status">
|
||||
<span class="tone">${statusLine(job)}</span>${elapsed}
|
||||
</div>
|
||||
${this.renderProgress()} ${this.renderStats()}
|
||||
${job.error
|
||||
? html`<div class="error">${job.error}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
<div class="controls">${this.renderControls()}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'job-row': JobRow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import {
|
||||
GetAllLibrariesWithTrackCounts,
|
||||
ScanLibrary,
|
||||
ScanAllLibraries,
|
||||
FullRescan,
|
||||
} from '@go/library/Library';
|
||||
import type { library } from '@go/models';
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import { Events } from '../../events';
|
||||
import { jobStore } from '@store/job-store';
|
||||
import type { Job } from '@store/job-store';
|
||||
import './job-row';
|
||||
import './job-details-drawer';
|
||||
import { applyJobControl } from './job-controls';
|
||||
import { jobStateStyles } from './job-format';
|
||||
|
||||
type LibraryInfo = library.Info;
|
||||
|
||||
/** Job states meaning the job will not progress further. */
|
||||
const TERMINAL_STATES: ReadonlySet<string> = new Set([
|
||||
'complete',
|
||||
'cancelled',
|
||||
'error',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Full-page view of background work: everything running right now, the
|
||||
* per-library scan controls that used to live in Settings, and a short
|
||||
* history of what recently finished.
|
||||
*
|
||||
* This is the same job rows as the top-bar popover at a larger density —
|
||||
* one implementation, two placements, so the two can never disagree.
|
||||
*/
|
||||
@customElement('jobs-view')
|
||||
export class JobsView extends LitElement {
|
||||
@state()
|
||||
private jobs: Job[] = [];
|
||||
|
||||
@state()
|
||||
private libraries: LibraryInfo[] = [];
|
||||
|
||||
@state()
|
||||
private drawerJobId = '';
|
||||
|
||||
@state()
|
||||
private drawerOpen = false;
|
||||
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
private eventCleanups: Array<() => void> = [];
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
jobStateStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
padding: 1.5em 1.75em 3em;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--yj-text-xl);
|
||||
color: var(--yj-text-primary, #e9ecef);
|
||||
margin: 0 0 0.2em;
|
||||
}
|
||||
|
||||
.page-sub {
|
||||
font-size: var(--yj-text-md);
|
||||
color: var(--yj-text-tertiary, #868e96);
|
||||
margin: 0 0 1.75em;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--yj-text-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--yj-text-tertiary, #868e96);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card > * + * {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.job-entry {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
padding-right: 0.75em;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 1.1em;
|
||||
font-size: var(--yj-text-md);
|
||||
color: var(--yj-text-tertiary, #868e96);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.library-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
padding: 0.75em 0.9em;
|
||||
}
|
||||
|
||||
.library-name {
|
||||
font-size: var(--yj-text-md);
|
||||
color: var(--yj-text-primary, #e9ecef);
|
||||
}
|
||||
|
||||
.library-meta {
|
||||
font-size: var(--yj-text-sm);
|
||||
color: var(--yj-text-tertiary, #868e96);
|
||||
margin-top: 0.15em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.library-state {
|
||||
font-size: var(--yj-text-sm);
|
||||
color: var(--job-tone);
|
||||
margin-top: 0.15em;
|
||||
}
|
||||
|
||||
button.action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45em;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 7px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--yj-text-primary, #e9ecef);
|
||||
font-size: var(--yj-text-sm);
|
||||
padding: 0.42em 0.85em;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
border-color 120ms ease;
|
||||
}
|
||||
|
||||
button.action:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.11);
|
||||
}
|
||||
|
||||
button.action:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button.action:focus-visible {
|
||||
outline: 2px solid var(--yj-accent, #ffd43b);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button.action.danger {
|
||||
color: #ff6b6b;
|
||||
border-color: rgba(255, 107, 107, 0.35);
|
||||
}
|
||||
|
||||
button.action.danger:hover:not(:disabled) {
|
||||
background: rgba(255, 107, 107, 0.12);
|
||||
}
|
||||
|
||||
button.link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
font-size: var(--yj-text-sm);
|
||||
cursor: pointer;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
button.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.details-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--yj-text-secondary, #adb5bd);
|
||||
font-size: var(--yj-text-sm);
|
||||
cursor: pointer;
|
||||
padding: 0.3em 0.5em;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.details-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--yj-text-primary, #e9ecef);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.unsubscribe = jobStore.subscribe(() => {
|
||||
this.jobs = jobStore.jobs;
|
||||
});
|
||||
void jobStore.init();
|
||||
this.jobs = jobStore.jobs;
|
||||
void this.loadLibraries();
|
||||
|
||||
// Library CRUD happens elsewhere; keep the picker in step.
|
||||
for (const event of [
|
||||
Events.LibraryAdded,
|
||||
Events.LibraryRemoved,
|
||||
Events.LibraryRenamed,
|
||||
Events.LibraryScanComplete,
|
||||
]) {
|
||||
this.eventCleanups.push(
|
||||
EventsOn(event, () => void this.loadLibraries()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
this.eventCleanups.forEach((off) => off());
|
||||
this.eventCleanups = [];
|
||||
}
|
||||
|
||||
private async loadLibraries(): Promise<void> {
|
||||
try {
|
||||
this.libraries = (await GetAllLibrariesWithTrackCounts()) ?? [];
|
||||
} catch (err) {
|
||||
console.error('Failed to load libraries:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** The scan job for a library, if one is registered. */
|
||||
private jobForLibrary(id: number): Job | undefined {
|
||||
return jobStore.getJob(`scan:${id}`);
|
||||
}
|
||||
|
||||
private openDetails(id: string) {
|
||||
this.drawerJobId = id;
|
||||
this.drawerOpen = true;
|
||||
}
|
||||
|
||||
private onDrawerClosed = () => {
|
||||
this.drawerOpen = false;
|
||||
};
|
||||
|
||||
private async startScan(id: number) {
|
||||
try {
|
||||
await ScanLibrary(id);
|
||||
} catch (err) {
|
||||
console.error('Failed to start scan:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private async startAllScans() {
|
||||
try {
|
||||
await ScanAllLibraries();
|
||||
} catch (err) {
|
||||
console.error('Failed to start scans:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private async clearFinished() {
|
||||
await jobStore.clearFinished();
|
||||
}
|
||||
|
||||
private async fullRescan() {
|
||||
if (
|
||||
!window.confirm(
|
||||
'Full rescan deletes ALL library data — including ' +
|
||||
'downloaded cover art — and rebuilds it from your ' +
|
||||
'files.\n\nThis is not the same as "Scan now", which ' +
|
||||
'only picks up what changed. Continue?',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await FullRescan();
|
||||
} catch (err) {
|
||||
console.error('Full rescan failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private renderJobList(list: Job[], emptyText: string) {
|
||||
if (list.length === 0) {
|
||||
return html`<div class="card">
|
||||
<div class="empty">${emptyText}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
${list.map(
|
||||
(job) => html`
|
||||
<div class="job-entry">
|
||||
<job-row
|
||||
.job=${job}
|
||||
variant="full"
|
||||
@job-control=${applyJobControl}
|
||||
></job-row>
|
||||
<button
|
||||
class="details-btn"
|
||||
@click=${() => this.openDetails(job.id)}
|
||||
>
|
||||
Details${job.warnCount
|
||||
? ` · ${job.warnCount}⚠`
|
||||
: ''}
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/** The status line under a library name in the scan-control list. */
|
||||
private libraryStatus(job: Job | undefined): string | null {
|
||||
if (!job) return null;
|
||||
|
||||
switch (job.state) {
|
||||
case 'running':
|
||||
return job.phase ? `Scanning · ${job.phase}` : 'Scanning';
|
||||
case 'queued':
|
||||
return 'Queued';
|
||||
case 'paused':
|
||||
return 'Paused';
|
||||
case 'pausing':
|
||||
return 'Pausing…';
|
||||
case 'cancelling':
|
||||
return 'Stopping…';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private renderLibraryRow(lib: LibraryInfo) {
|
||||
const job = this.jobForLibrary(lib.id);
|
||||
const status = this.libraryStatus(job);
|
||||
const busy = status !== null;
|
||||
|
||||
return html`
|
||||
<div class="library-row">
|
||||
<div>
|
||||
<div class="library-name">${lib.name}</div>
|
||||
<div class="library-meta">
|
||||
${lib.trackCount.toLocaleString()} tracks · ${lib.path}
|
||||
</div>
|
||||
${status
|
||||
? html`<div class="library-state">${status}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
${busy
|
||||
? html`
|
||||
<button
|
||||
class="link"
|
||||
@click=${() => this.openDetails(`scan:${lib.id}`)}
|
||||
>
|
||||
View progress
|
||||
</button>
|
||||
`
|
||||
: html`
|
||||
<button
|
||||
class="action"
|
||||
@click=${() => this.startScan(lib.id)}
|
||||
>
|
||||
<wa-icon name="arrows-rotate"></wa-icon>
|
||||
Scan now
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
// Derived from `this.jobs` rather than the store getters so Lit
|
||||
// sees the reactive dependency and re-renders on every snapshot.
|
||||
const active = this.jobs.filter((j) => !TERMINAL_STATES.has(j.state));
|
||||
const finished = this.jobs.filter((j) => TERMINAL_STATES.has(j.state));
|
||||
const anyScanning = this.libraries.some((lib) =>
|
||||
Boolean(this.libraryStatus(this.jobForLibrary(lib.id))),
|
||||
);
|
||||
|
||||
return html`
|
||||
<h1>Background jobs</h1>
|
||||
<p class="page-sub">
|
||||
Library scans and search index builds, with their progress and
|
||||
output.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<div class="section-head">
|
||||
<h2>Running now</h2>
|
||||
</div>
|
||||
${this.renderJobList(active, 'Nothing is running.')}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-head">
|
||||
<h2>Libraries</h2>
|
||||
<button
|
||||
class="action"
|
||||
?disabled=${anyScanning || this.libraries.length === 0}
|
||||
@click=${this.startAllScans}
|
||||
>
|
||||
<wa-icon name="arrows-rotate"></wa-icon>
|
||||
Scan all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
${this.libraries.length === 0
|
||||
? html`<div class="empty">
|
||||
No libraries yet — add one in Settings.
|
||||
</div>`
|
||||
: this.libraries.map((lib) =>
|
||||
this.renderLibraryRow(lib),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-head">
|
||||
<h2>Maintenance</h2>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="library-row">
|
||||
<div>
|
||||
<div class="library-name">Full rescan</div>
|
||||
<div class="library-meta">
|
||||
Wipes all library data and cover art, then
|
||||
rebuilds from your files. Only needed when the
|
||||
library is corrupt — a normal scan already
|
||||
picks up changes.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="action danger"
|
||||
?disabled=${anyScanning}
|
||||
@click=${this.fullRescan}
|
||||
>
|
||||
<wa-icon name="triangle-exclamation"></wa-icon>
|
||||
Full rescan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
${finished.length > 0
|
||||
? html`
|
||||
<section>
|
||||
<div class="section-head">
|
||||
<h2>Recently finished</h2>
|
||||
<button
|
||||
class="link"
|
||||
@click=${this.clearFinished}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
${this.renderJobList(finished, '')}
|
||||
</section>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
<job-details-drawer
|
||||
job-id=${this.drawerJobId}
|
||||
?open=${this.drawerOpen}
|
||||
@drawer-closed=${this.onDrawerClosed}
|
||||
></job-details-drawer>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'jobs-view': JobsView;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ import { designTokens } from '../../styles/tokens.css';
|
||||
|
||||
import type { DragActiveDetail } from '@utils/drag-controller';
|
||||
|
||||
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'autotag' | 'settings';
|
||||
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'autotag' | 'jobs' | 'settings';
|
||||
|
||||
interface NavItem {
|
||||
id: View;
|
||||
@@ -150,6 +150,7 @@ export class AppSidebar extends LitElement {
|
||||
{ id: 'tracks', label: 'Tracks', icon: 'music' },
|
||||
{ id: 'explore', label: 'Explore', icon: 'globe' },
|
||||
{ id: 'autotag', label: 'Autotag', icon: 'tag' },
|
||||
{ id: 'jobs', label: 'Jobs', icon: 'list-check' },
|
||||
{ id: 'settings', label: 'Settings', icon: 'gear' },
|
||||
];
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@ export const Events = {
|
||||
AutotagPrefetchProgress: "AutotagPrefetchProgress",
|
||||
AutotagPrefetchFinished: "AutotagPrefetchFinished",
|
||||
|
||||
// Background job events
|
||||
JobsChanged: "JobsChanged",
|
||||
|
||||
// Explore / search index events
|
||||
IndexStatusChanged: "IndexStatusChanged",
|
||||
ArtistDiscographyReady: "ArtistDiscographyReady",
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import {
|
||||
GetJobs,
|
||||
GetJobLog,
|
||||
PauseJob,
|
||||
ResumeJob,
|
||||
CancelJob,
|
||||
DismissJob,
|
||||
ClearFinishedJobs,
|
||||
} from '@go/jobs/Service';
|
||||
import type { jobs } from '@go/models';
|
||||
import { Events } from '../events';
|
||||
|
||||
export type Job = jobs.Job;
|
||||
export type JobLogEntry = jobs.LogEntry;
|
||||
export type JobStage = jobs.Stage;
|
||||
|
||||
/** Lifecycle states a job can be in. Mirrors backend/jobs.State. */
|
||||
export type JobState =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'pausing'
|
||||
| 'paused'
|
||||
| 'cancelling'
|
||||
| 'complete'
|
||||
| 'cancelled'
|
||||
| 'error';
|
||||
|
||||
/** Job kinds. Mirrors backend/jobs.Kind. */
|
||||
export type JobKind = 'library-scan' | 'index-build';
|
||||
|
||||
type Subscriber = () => void;
|
||||
|
||||
/** States meaning the job will not progress further. */
|
||||
const TERMINAL_STATES: ReadonlySet<string> = new Set([
|
||||
'complete',
|
||||
'cancelled',
|
||||
'error',
|
||||
]);
|
||||
|
||||
/**
|
||||
* How long a finished job keeps the indicator visible before it fades
|
||||
* out. Without this a fast scan would flash on and off, which reads as
|
||||
* a glitch rather than as progress.
|
||||
*/
|
||||
const FINISHED_LINGER_MS = 4000;
|
||||
|
||||
export function isTerminal(job: Job): boolean {
|
||||
return TERMINAL_STATES.has(job.state);
|
||||
}
|
||||
|
||||
export function isActive(job: Job): boolean {
|
||||
return !isTerminal(job);
|
||||
}
|
||||
|
||||
/** True when a job's progress bar has no meaningful denominator. */
|
||||
export function isIndeterminate(job: Job): boolean {
|
||||
return !job.total || job.total <= 0;
|
||||
}
|
||||
|
||||
/** Fractional progress in [0, 1], or null when indeterminate. */
|
||||
export function progressFraction(job: Job): number | null {
|
||||
if (isIndeterminate(job)) return null;
|
||||
|
||||
return Math.min(1, Math.max(0, job.current / job.total));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive singleton mirroring the backend job registry.
|
||||
*
|
||||
* The backend pushes a full snapshot on every JobsChanged event rather
|
||||
* than a delta, so a component that mounts mid-scan is correct from the
|
||||
* first event it receives. The initial GetJobs() call only covers the
|
||||
* window before the first event arrives.
|
||||
*/
|
||||
class JobStore {
|
||||
private jobsValue: Job[] = [];
|
||||
|
||||
private logs = new Map<string, JobLogEntry[]>();
|
||||
|
||||
private subscribers = new Set<Subscriber>();
|
||||
|
||||
private notifyScheduled = false;
|
||||
|
||||
/** Timer that clears the lingering "just finished" indicator. */
|
||||
private lingerTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Set while a finished job should still keep the indicator up. */
|
||||
private lingering = false;
|
||||
|
||||
private initialized = false;
|
||||
|
||||
constructor() {
|
||||
EventsOn(Events.JobsChanged, (snapshot: Job[]) => {
|
||||
this.applySnapshot(snapshot ?? []);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current snapshot once. Safe to call from every
|
||||
* component's connectedCallback — subsequent calls are no-ops.
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
this.initialized = true;
|
||||
|
||||
try {
|
||||
this.applySnapshot((await GetJobs()) ?? []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load background jobs:', err);
|
||||
}
|
||||
}
|
||||
|
||||
get jobs(): Job[] {
|
||||
return this.jobsValue;
|
||||
}
|
||||
|
||||
get activeJobs(): Job[] {
|
||||
return this.jobsValue.filter(isActive);
|
||||
}
|
||||
|
||||
get finishedJobs(): Job[] {
|
||||
return this.jobsValue.filter(isTerminal);
|
||||
}
|
||||
|
||||
/** Jobs that are running or queued — excludes paused ones. */
|
||||
get workingJobs(): Job[] {
|
||||
return this.jobsValue.filter(
|
||||
(j) => j.state === 'running' || j.state === 'queued',
|
||||
);
|
||||
}
|
||||
|
||||
get pausedJobs(): Job[] {
|
||||
return this.jobsValue.filter(
|
||||
(j) => j.state === 'paused' || j.state === 'pausing',
|
||||
);
|
||||
}
|
||||
|
||||
get failedJobs(): Job[] {
|
||||
return this.jobsValue.filter((j) => j.state === 'error');
|
||||
}
|
||||
|
||||
/** Whether anything is in flight, including paused work. */
|
||||
get hasActive(): boolean {
|
||||
return this.jobsValue.some(isActive);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the persistent indicator should be shown at all: any
|
||||
* active job, or a recently finished one still lingering.
|
||||
*/
|
||||
get shouldShowIndicator(): boolean {
|
||||
return this.hasActive || this.lingering;
|
||||
}
|
||||
|
||||
getJob(id: string): Job | undefined {
|
||||
return this.jobsValue.find((j) => j.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached log for a job, fetching it if not yet loaded.
|
||||
* Logs are pulled on demand rather than pushed with every snapshot —
|
||||
* a scan can emit hundreds of warnings, and only the detail pane
|
||||
* ever renders them.
|
||||
*/
|
||||
async loadLog(id: string): Promise<JobLogEntry[]> {
|
||||
try {
|
||||
const entries = (await GetJobLog(id)) ?? [];
|
||||
this.logs.set(id, entries);
|
||||
this.notify();
|
||||
|
||||
return entries;
|
||||
} catch (err) {
|
||||
console.error('Failed to load job log:', err);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Cached log entries for a job, or an empty array if unfetched. */
|
||||
cachedLog(id: string): JobLogEntry[] {
|
||||
return this.logs.get(id) ?? [];
|
||||
}
|
||||
|
||||
async pause(id: string): Promise<void> {
|
||||
await PauseJob(id);
|
||||
}
|
||||
|
||||
async resume(id: string): Promise<void> {
|
||||
await ResumeJob(id);
|
||||
}
|
||||
|
||||
async cancel(id: string): Promise<void> {
|
||||
await CancelJob(id);
|
||||
}
|
||||
|
||||
async dismiss(id: string): Promise<void> {
|
||||
this.logs.delete(id);
|
||||
await DismissJob(id);
|
||||
}
|
||||
|
||||
async clearFinished(): Promise<void> {
|
||||
for (const job of this.finishedJobs) {
|
||||
this.logs.delete(job.id);
|
||||
}
|
||||
|
||||
await ClearFinishedJobs();
|
||||
}
|
||||
|
||||
subscribe(fn: Subscriber): () => void {
|
||||
this.subscribers.add(fn);
|
||||
|
||||
return () => this.subscribers.delete(fn);
|
||||
}
|
||||
|
||||
private applySnapshot(snapshot: Job[]): void {
|
||||
const hadActive = this.jobsValue.some(isActive);
|
||||
this.jobsValue = snapshot;
|
||||
const hasActiveNow = this.hasActive;
|
||||
|
||||
// The last active job just finished — keep the indicator up
|
||||
// briefly so the completion is actually seen.
|
||||
if (hadActive && !hasActiveNow) {
|
||||
this.startLinger();
|
||||
} else if (hasActiveNow) {
|
||||
this.clearLinger();
|
||||
}
|
||||
|
||||
// Drop cached logs for jobs the backend has forgotten.
|
||||
const known = new Set(snapshot.map((j) => j.id));
|
||||
|
||||
for (const id of this.logs.keys()) {
|
||||
if (!known.has(id)) this.logs.delete(id);
|
||||
}
|
||||
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private startLinger(): void {
|
||||
this.lingering = true;
|
||||
|
||||
if (this.lingerTimer) clearTimeout(this.lingerTimer);
|
||||
|
||||
this.lingerTimer = setTimeout(() => {
|
||||
this.lingering = false;
|
||||
this.lingerTimer = null;
|
||||
this.notify();
|
||||
}, FINISHED_LINGER_MS);
|
||||
}
|
||||
|
||||
private clearLinger(): void {
|
||||
this.lingering = false;
|
||||
|
||||
if (this.lingerTimer) {
|
||||
clearTimeout(this.lingerTimer);
|
||||
this.lingerTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesces notifications to one per microtask. The backend already
|
||||
* throttles JobsChanged to 4Hz, but a burst of loadLog resolutions
|
||||
* can still stack up.
|
||||
*/
|
||||
private notify(): void {
|
||||
if (this.notifyScheduled) return;
|
||||
|
||||
this.notifyScheduled = true;
|
||||
|
||||
queueMicrotask(() => {
|
||||
this.notifyScheduled = false;
|
||||
this.subscribers.forEach((fn) => fn());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const jobStore = new JobStore();
|
||||
+7
@@ -2,6 +2,11 @@
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {explore} from '../models';
|
||||
import {context} from '../models';
|
||||
import {jobs} from '../models';
|
||||
|
||||
export function AdoptPausedIndexBuild():Promise<void>;
|
||||
|
||||
export function BackfillLibraryDiscographies():Promise<void>;
|
||||
|
||||
export function BackfillLibraryLyrics():Promise<void>;
|
||||
|
||||
@@ -81,6 +86,8 @@ export function SearchLyrics(arg1:string):Promise<Array<explore.LyricsResult>>;
|
||||
|
||||
export function SetContext(arg1:context.Context):Promise<void>;
|
||||
|
||||
export function SetJobRegistry(arg1:jobs.Registry):Promise<void>;
|
||||
|
||||
export function SimilarArtists(arg1:string):Promise<Array<explore.LBSimilarArtist>>;
|
||||
|
||||
export function StartIndexBuild():Promise<void>;
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export function AdoptPausedIndexBuild() {
|
||||
return window['go']['explore']['Service']['AdoptPausedIndexBuild']();
|
||||
}
|
||||
|
||||
export function BackfillLibraryDiscographies() {
|
||||
return window['go']['explore']['Service']['BackfillLibraryDiscographies']();
|
||||
}
|
||||
|
||||
export function BackfillLibraryLyrics() {
|
||||
return window['go']['explore']['Service']['BackfillLibraryLyrics']();
|
||||
}
|
||||
@@ -158,6 +166,10 @@ export function SetContext(arg1) {
|
||||
return window['go']['explore']['Service']['SetContext'](arg1);
|
||||
}
|
||||
|
||||
export function SetJobRegistry(arg1) {
|
||||
return window['go']['explore']['Service']['SetJobRegistry'](arg1);
|
||||
}
|
||||
|
||||
export function SimilarArtists(arg1) {
|
||||
return window['go']['explore']['Service']['SimilarArtists'](arg1);
|
||||
}
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {jobs} from '../models';
|
||||
|
||||
export function CancelJob(arg1:string):Promise<void>;
|
||||
|
||||
export function ClearFinishedJobs():Promise<void>;
|
||||
|
||||
export function DismissJob(arg1:string):Promise<void>;
|
||||
|
||||
export function GetJobLog(arg1:string):Promise<Array<jobs.LogEntry>>;
|
||||
|
||||
export function GetJobs():Promise<Array<jobs.Job>>;
|
||||
|
||||
export function PauseJob(arg1:string):Promise<void>;
|
||||
|
||||
export function ResumeJob(arg1:string):Promise<void>;
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
// @ts-check
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export function CancelJob(arg1) {
|
||||
return window['go']['jobs']['Service']['CancelJob'](arg1);
|
||||
}
|
||||
|
||||
export function ClearFinishedJobs() {
|
||||
return window['go']['jobs']['Service']['ClearFinishedJobs']();
|
||||
}
|
||||
|
||||
export function DismissJob(arg1) {
|
||||
return window['go']['jobs']['Service']['DismissJob'](arg1);
|
||||
}
|
||||
|
||||
export function GetJobLog(arg1) {
|
||||
return window['go']['jobs']['Service']['GetJobLog'](arg1);
|
||||
}
|
||||
|
||||
export function GetJobs() {
|
||||
return window['go']['jobs']['Service']['GetJobs']();
|
||||
}
|
||||
|
||||
export function PauseJob(arg1) {
|
||||
return window['go']['jobs']['Service']['PauseJob'](arg1);
|
||||
}
|
||||
|
||||
export function ResumeJob(arg1) {
|
||||
return window['go']['jobs']['Service']['ResumeJob'](arg1);
|
||||
}
|
||||
+5
@@ -3,6 +3,7 @@
|
||||
import {sqlcgen} from '../models';
|
||||
import {library} from '../models';
|
||||
import {context} from '../models';
|
||||
import {jobs} from '../models';
|
||||
|
||||
export function AcquirePipelineLock():Promise<void>;
|
||||
|
||||
@@ -66,6 +67,8 @@ export function RemoveLibrary(arg1:number):Promise<library.RemovalSummary>;
|
||||
|
||||
export function RenameLibrary(arg1:number,arg2:string):Promise<void>;
|
||||
|
||||
export function RestorePausedScans():Promise<void>;
|
||||
|
||||
export function ResumeScan():Promise<void>;
|
||||
|
||||
export function ScanAllLibraries():Promise<void>;
|
||||
@@ -78,6 +81,8 @@ export function SearchTracksByLibrary(arg1:string,arg2:number):Promise<Array<lib
|
||||
|
||||
export function SetContext(arg1:context.Context):Promise<void>;
|
||||
|
||||
export function SetJobRegistry(arg1:jobs.Registry):Promise<void>;
|
||||
|
||||
export function SetRemovalHooks(arg1:library.RemovalHooks):Promise<void>;
|
||||
|
||||
export function SetRescanHooks(arg1:library.RescanHooks):Promise<void>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -777,6 +777,154 @@ export namespace explore {
|
||||
|
||||
}
|
||||
|
||||
export namespace jobs {
|
||||
|
||||
export class Caps {
|
||||
pausable: boolean;
|
||||
cancellable: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Caps(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.pausable = source["pausable"];
|
||||
this.cancellable = source["cancellable"];
|
||||
}
|
||||
}
|
||||
export class Stat {
|
||||
label: string;
|
||||
value: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Stat(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.label = source["label"];
|
||||
this.value = source["value"];
|
||||
}
|
||||
}
|
||||
export class Stage {
|
||||
name: string;
|
||||
state: string;
|
||||
current: number;
|
||||
total: number;
|
||||
error?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Stage(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.name = source["name"];
|
||||
this.state = source["state"];
|
||||
this.current = source["current"];
|
||||
this.total = source["total"];
|
||||
this.error = source["error"];
|
||||
}
|
||||
}
|
||||
export class Job {
|
||||
id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
state: string;
|
||||
phase?: string;
|
||||
current: number;
|
||||
total: number;
|
||||
caps: Caps;
|
||||
stages: Stage[];
|
||||
stats: Stat[];
|
||||
error?: string;
|
||||
startedAt: number;
|
||||
updatedAt: number;
|
||||
endedAt?: number;
|
||||
logCount: number;
|
||||
warnCount: number;
|
||||
errorCount: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Job(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.kind = source["kind"];
|
||||
this.title = source["title"];
|
||||
this.subtitle = source["subtitle"];
|
||||
this.state = source["state"];
|
||||
this.phase = source["phase"];
|
||||
this.current = source["current"];
|
||||
this.total = source["total"];
|
||||
this.caps = this.convertValues(source["caps"], Caps);
|
||||
this.stages = this.convertValues(source["stages"], Stage);
|
||||
this.stats = this.convertValues(source["stats"], Stat);
|
||||
this.error = source["error"];
|
||||
this.startedAt = source["startedAt"];
|
||||
this.updatedAt = source["updatedAt"];
|
||||
this.endedAt = source["endedAt"];
|
||||
this.logCount = source["logCount"];
|
||||
this.warnCount = source["warnCount"];
|
||||
this.errorCount = source["errorCount"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class LogEntry {
|
||||
time: number;
|
||||
level: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new LogEntry(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.time = source["time"];
|
||||
this.level = source["level"];
|
||||
this.message = source["message"];
|
||||
this.detail = source["detail"];
|
||||
}
|
||||
}
|
||||
export class Registry {
|
||||
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Registry(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
export namespace library {
|
||||
|
||||
export class Album {
|
||||
|
||||
+2
@@ -44,6 +44,8 @@ export function ImportPlaylist(arg1:string):Promise<playlist.Summary>;
|
||||
|
||||
export function ImportPlaylists(arg1:Array<string>):Promise<Array<playlist.Summary>>;
|
||||
|
||||
export function MaterializeUnmaterializedSmartPlaylists():Promise<void>;
|
||||
|
||||
export function PreviewSmartPlaylist(arg1:string):Promise<Array<library.Track>>;
|
||||
|
||||
export function RefreshSmartPlaylist(arg1:number):Promise<void>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user