Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
399dcc05b3 | ||
|
|
ef89707bb1 | ||
|
|
c88fc1c7c7 | ||
|
|
7a9dd69d30 | ||
|
|
3f23bb4396 | ||
|
|
af2ff17342 | ||
|
|
fc19ca54b7 | ||
|
|
613901847a | ||
|
|
ecf0109331 | ||
|
|
9710c11476 | ||
|
|
5e3ac8fb1b | ||
|
|
f81a950916 | ||
|
|
7fbfd9c105 | ||
|
|
792c2d9fbc | ||
|
|
bb26d5f289 | ||
|
|
7b42b9ce56 | ||
|
|
0a33b9d653 | ||
|
|
fc0121228e | ||
|
|
924097b246 | ||
|
|
1997276def | ||
|
|
5d9c677cf7 | ||
|
|
1e3a490c12 | ||
|
|
d4ea14ca5c | ||
|
|
62c1a95ead | ||
|
|
e67462ab53 | ||
|
|
e5dc54d0ec | ||
|
|
a5c3990d12 | ||
|
|
8dbdb7ad75 | ||
|
|
a205224a26 | ||
|
|
a96cc9be1f | ||
|
|
8db19622b2 | ||
|
|
53f480980f | ||
|
|
1335572f0a | ||
|
|
cf90030463 | ||
|
|
88f5524aa2 | ||
|
|
e745acf88a | ||
|
|
32bb64918c | ||
|
|
1b9868ddd0 | ||
|
|
6aeac42a46 | ||
|
|
68e7edb8c9 | ||
|
|
a3b5b43777 | ||
|
|
5fae61cdf1 | ||
|
|
1f43234b80 | ||
|
|
ca00f8a803 | ||
|
|
0be7b4fdc8 | ||
|
|
18f10e966b | ||
|
|
e897364a73 | ||
|
|
5fa68fcf43 | ||
|
|
b1368bbc7e | ||
|
|
9432f68c8b | ||
|
|
4c921ed1ba | ||
|
|
f8800ca1f8 | ||
|
|
dddc8aaf55 | ||
|
|
bcf3856b6f | ||
|
|
26251badda |
@@ -68,7 +68,7 @@ jobs:
|
|||||||
SHA: ${{ github.sha }}
|
SHA: ${{ github.sha }}
|
||||||
REF_NAME: ${{ github.ref_name }}
|
REF_NAME: ${{ github.ref_name }}
|
||||||
DEBIAN_FRONTEND: noninteractive
|
DEBIAN_FRONTEND: noninteractive
|
||||||
GO_VERSION: '1.25.0'
|
GO_VERSION: '1.26.0'
|
||||||
npm_config_store_dir: /cache/pnpm-store
|
npm_config_store_dir: /cache/pnpm-store
|
||||||
# The Go half wants the NDK; the Gradle half wants a platform.
|
# The Go half wants the NDK; the Gradle half wants a platform.
|
||||||
ANDROID_HOME: /cache/android-sdk
|
ANDROID_HOME: /cache/android-sdk
|
||||||
|
|||||||
+23
-6
@@ -36,7 +36,7 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: '1.25.0'
|
GO_VERSION: '1.26.0'
|
||||||
# Shared by all three Playwright consumers (@playwright/cli, e2e/'s
|
# Shared by all three Playwright consumers (@playwright/cli, e2e/'s
|
||||||
# @playwright/test, frontend/'s Vitest provider). See the browsers
|
# @playwright/test, frontend/'s Vitest provider). See the browsers
|
||||||
# step in job 2 for why that is not the whole story.
|
# step in job 2 for why that is not the whole story.
|
||||||
@@ -53,7 +53,7 @@ jobs:
|
|||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
# Not golang:1.25 — this job runs `make ui-test`, which is Vitest
|
# Not golang:1.26 — this job runs `make ui-test`, which is Vitest
|
||||||
# *browser* mode and needs a Chromium and its system libraries
|
# *browser* mode and needs a Chromium and its system libraries
|
||||||
# anyway, so the "fast job needs no browser" split does not hold.
|
# anyway, so the "fast job needs no browser" split does not hold.
|
||||||
# Not the Playwright image either: e2e/ pins @playwright/test
|
# Not the Playwright image either: e2e/ pins @playwright/test
|
||||||
@@ -107,15 +107,32 @@ jobs:
|
|||||||
# Conventional Commits. `.releaserc.yml` has always derived the
|
# Conventional Commits. `.releaserc.yml` has always derived the
|
||||||
# version from the commit type; until now nothing checked that the
|
# version from the commit type; until now nothing checked that the
|
||||||
# type was one it recognises, so a malformed subject silently meant
|
# type was one it recognises, so a malformed subject silently meant
|
||||||
# "no release". BEFORE is the push's previous tip and is absent or
|
# "no release".
|
||||||
# all-zeros for a new branch, in which case only the tip is linted.
|
#
|
||||||
|
# **On a `pull_request` there is no `before`.** Gitea leaves
|
||||||
|
# `github.event.before` empty for one, so this step fell through to
|
||||||
|
# bare `make commit-check`, which lints `git log -1` — the tip
|
||||||
|
# alone. Every other commit the branch would bring was first
|
||||||
|
# examined by *main's* post-merge run, which is a green PR that
|
||||||
|
# stops being true after the merge, and which happened twice (#254).
|
||||||
|
# The PR's base is the stand-in: the range below already excludes
|
||||||
|
# what the base shares with the branch, because base advances on
|
||||||
|
# main and those commits stay reachable from it.
|
||||||
|
#
|
||||||
|
# Both are handed to the shell rather than chosen in an expression:
|
||||||
|
# `github.event.issue.number` in unclaim.yml is this repo's proof
|
||||||
|
# that payload fields resolve, and the shell then falls back to
|
||||||
|
# today's behaviour for a dispatch run or a missing field instead of
|
||||||
|
# depending on how `&&`/`||` treat an absent context.
|
||||||
- name: Commit messages
|
- name: Commit messages
|
||||||
working-directory: /src
|
working-directory: /src
|
||||||
env:
|
env:
|
||||||
BEFORE: ${{ github.event.before }}
|
PR_BASE: ${{ github.event.pull_request.base.sha }}
|
||||||
|
PUSH_BEFORE: ${{ github.event.before }}
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
if [ -n "${BEFORE:-}" ] && [ "${BEFORE#0000000}" = "$BEFORE" ] \
|
BEFORE="${PR_BASE:-${PUSH_BEFORE:-}}"
|
||||||
|
if [ -n "$BEFORE" ] && [ "${BEFORE#0000000}" = "$BEFORE" ] \
|
||||||
&& git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then
|
&& git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then
|
||||||
make commit-check RANGE="$BEFORE..$SHA"
|
make commit-check RANGE="$BEFORE..$SHA"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
SHA: ${{ github.sha }}
|
SHA: ${{ github.sha }}
|
||||||
REF_NAME: ${{ github.ref_name }}
|
REF_NAME: ${{ github.ref_name }}
|
||||||
DEBIAN_FRONTEND: noninteractive
|
DEBIAN_FRONTEND: noninteractive
|
||||||
GO_VERSION: '1.25.0'
|
GO_VERSION: '1.26.0'
|
||||||
npm_config_store_dir: /cache/pnpm-store
|
npm_config_store_dir: /cache/pnpm-store
|
||||||
steps:
|
steps:
|
||||||
# The same set ci.yml's check job installs: the app is cgo, and
|
# The same set ci.yml's check job installs: the app is cgo, and
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ jobs:
|
|||||||
# claim with a test behind it now (cmd/indexbuild/deps_test.go),
|
# claim with a test behind it now (cmd/indexbuild/deps_test.go),
|
||||||
# because the v3 migration quietly broke it and this job was where
|
# because the v3 migration quietly broke it and this job was where
|
||||||
# that surfaced.
|
# that surfaced.
|
||||||
image: golang:1.25
|
image: golang:1.26
|
||||||
# This host path must exist on the runner and be listed verbatim in
|
# This host path must exist on the runner and be listed verbatim in
|
||||||
# act_runner's container.valid_volumes. It holds explore-staging/
|
# act_runner's container.valid_volumes. It holds explore-staging/
|
||||||
# (counts.bin + state.json) and yj.db — the checkpoint that makes
|
# (counts.bin + state.json) and yj.db — the checkpoint that makes
|
||||||
@@ -148,6 +148,49 @@ jobs:
|
|||||||
sha256sum /tmp/core-index.db.zst | tee /tmp/core-index.db.zst.sha256
|
sha256sum /tmp/core-index.db.zst | tee /tmp/core-index.db.zst.sha256
|
||||||
ls -lh /tmp/core-index.db.zst
|
ls -lh /tmp/core-index.db.zst
|
||||||
|
|
||||||
|
# Nothing is published until it has been imported by the code that
|
||||||
|
# imports it on a user's machine. The exporter and the importer are
|
||||||
|
# two descriptions of one storage format, and every other tier tests
|
||||||
|
# the importer against a *fixture* rather than against the file being
|
||||||
|
# shipped — a second description free to be wrong in the same
|
||||||
|
# direction as the code reading it.
|
||||||
|
#
|
||||||
|
# That is how #258 reached everyone: the importer positioned its batch
|
||||||
|
# walk with a Go `string` cursor against this file's 16-byte `mbid`
|
||||||
|
# column, and SQLite neither coerces between TEXT and BLOB nor
|
||||||
|
# complains about the comparison — so the walk merged no rows and
|
||||||
|
# never advanced, and no install could finish its first index build.
|
||||||
|
# The fixture guarding that walk writes the old text encoding, and the
|
||||||
|
# only compact fixture is one row, below the batch size, so the bound
|
||||||
|
# query never ran. Both were green throughout.
|
||||||
|
#
|
||||||
|
# Running it here is also what keeps the failure cheap: the previous
|
||||||
|
# artifact stays published while this runs, so a failure costs one
|
||||||
|
# stale catalog rather than an empty one for every install.
|
||||||
|
#
|
||||||
|
# `-tags indexbuild` because this container has no GTK and the default
|
||||||
|
# tag set links the app through Wails. The `--- PASS` grep is not
|
||||||
|
# decoration — the test skips without the path, and a skip is
|
||||||
|
# indistinguishable from a pass in a summary line.
|
||||||
|
- name: Import the exported artifact as a client does
|
||||||
|
if: steps.maintain.outputs.complete == 'true' && steps.maintain.outputs.changed == 'true'
|
||||||
|
working-directory: /src
|
||||||
|
env:
|
||||||
|
YJ_CORE_INDEX_ARTIFACT: /tmp/core-index.db.zst
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
log=/tmp/import-check.log
|
||||||
|
if ! go test -tags indexbuild -count=1 -timeout 30m -v \
|
||||||
|
-run TestImportPublishedArtifact ./backend/explore/ > "$log" 2>&1;
|
||||||
|
then
|
||||||
|
tail -60 "$log"
|
||||||
|
echo "::error::The artifact does not import; not publishing it."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cat "$log"
|
||||||
|
grep -qF -- 'PASS: TestImportPublishedArtifact' "$log"
|
||||||
|
echo "::notice::The artifact imports as a client would merge it."
|
||||||
|
|
||||||
- name: Publish to the Gitea package registry
|
- name: Publish to the Gitea package registry
|
||||||
if: steps.maintain.outputs.complete == 'true' && steps.maintain.outputs.changed == 'true'
|
if: steps.maintain.outputs.complete == 'true' && steps.maintain.outputs.changed == 'true'
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -57,6 +57,21 @@ behind `YJ_TESTCTL=1`, which `scripts/dev-headless.sh` sets and
|
|||||||
staging the work that would produce it — job progress, download
|
staging the work that would produce it — job progress, download
|
||||||
progress, scan progress. It calls `events.Deliver`, which *errors*
|
progress, scan progress. It calls `events.Deliver`, which *errors*
|
||||||
when the event reaches nobody, so a `200` means it really arrived.
|
when the event reaches nobody, so a `200` means it really arrived.
|
||||||
|
- **State you stage, you own** (#168). Nothing resets those stores, so
|
||||||
|
clear yours in `test.afterEach` with the same event that staged it
|
||||||
|
(`emit('JobsChanged', [])`) — the store replaces its list from every
|
||||||
|
snapshot, so `testctl` needs no special case. **Measured: this does
|
||||||
|
not currently cross a spec boundary**, because every test gets a fresh
|
||||||
|
page and `JobStore.init()` refetches `GetJobs()` from a backend
|
||||||
|
registry that `/__test/emit` never writes to. Stated anyway, because
|
||||||
|
it costs one line and the leak needs only one spec that keeps a page
|
||||||
|
alive — but do not cite #168 for a symptom you have not reproduced.
|
||||||
|
- **Measure against the thing next to you, not an absolute
|
||||||
|
coordinate.** An absolute number in a shell measurement is also a
|
||||||
|
claim about everything above it — `contentTop === 0` quietly asserts
|
||||||
|
"and no background job is running", which is not what that spec was
|
||||||
|
about or could arrange, while `contentTop === jobBandBottom` is true
|
||||||
|
either way. This is the half of #168 that stands on its own.
|
||||||
- **`restore` is slow** (~40 s in the suite) because it copies every
|
- **`restore` is slow** (~40 s in the suite) because it copies every
|
||||||
table. Prefer snapshotting once and restoring only when a spec
|
table. Prefer snapshotting once and restoring only when a spec
|
||||||
genuinely mutates state.
|
genuinely mutates state.
|
||||||
|
|||||||
@@ -81,6 +81,24 @@ rules. Never "go fix it" — the leg contract is in this file.
|
|||||||
| escalation | `yj-loop.escalate` | go/kimi-k3 (T3) | same leg re-run, seeded with failure summary |
|
| escalation | `yj-loop.escalate` | go/kimi-k3 (T3) | same leg re-run, seeded with failure summary |
|
||||||
| prose (PR body, commit msgs, journal) | `yj-loop.scribe` | go/mimo-v2.5 (T0) | text only, from supplied facts |
|
| prose (PR body, commit msgs, journal) | `yj-loop.scribe` | go/mimo-v2.5 (T0) | text only, from supplied facts |
|
||||||
|
|
||||||
|
**Model fallback on quota exhaustion.** The pinned models are the
|
||||||
|
intent, not a guarantee. The qwen token plan is a weekly pool and has
|
||||||
|
run dry mid-tick (`429 … 1-week quota exhausted`). When a leg's launch
|
||||||
|
fails with a 429, re-run it with a per-run `model` override one rung
|
||||||
|
down and journal the substitution — never spend the T3 escalation
|
||||||
|
model on a quota substitution. The qwen-pinned legs (`work`,
|
||||||
|
`diffreview`) fall back `qwen/deepseek-v4-pro-0813` → `go/deepseek-v4-pro`
|
||||||
|
→ `go/glm-5.3-flash`. Do **not** use the `deepseek/...` provider: it has
|
||||||
|
no models, only catalog overrides, and fails silently (empty artifact,
|
||||||
|
no session) — the model lives on the `go` gateway.
|
||||||
|
|
||||||
|
**Launch legs in the foreground.** The async subagent runner has died
|
||||||
|
without persisting a child session (nothing to resume) and emits
|
||||||
|
spurious "needs attention" nudges on runs that are already complete.
|
||||||
|
Foreground `subagent` calls are the reliable mode here. A worker that
|
||||||
|
dies mid-leg leaves uncommitted work: inspect the tree, then relaunch
|
||||||
|
to *complete* — never to re-implement.
|
||||||
|
|
||||||
Orchestrator-only legs: **claim** (`issue.sh claim --branch` — atomic,
|
Orchestrator-only legs: **claim** (`issue.sh claim --branch` — atomic,
|
||||||
refuses if held), **ship's PR/CI polling** (REST API below — `gitea_ci`
|
refuses if held), **ship's PR/CI polling** (REST API below — `gitea_ci`
|
||||||
job_logs 404s on this Gitea; the REST endpoints are the way), **merge**
|
job_logs 404s on this Gitea; the REST endpoints are the way), **merge**
|
||||||
@@ -168,8 +186,12 @@ Merge when, and only when, **all** hold:
|
|||||||
`block_on_outdated_branch: true` refuses it anyway; never
|
`block_on_outdated_branch: true` refuses it anyway; never
|
||||||
`force_manually_merged` around it.
|
`force_manually_merged` around it.
|
||||||
|
|
||||||
**Refresh before every merge.** In the loop worktree: fetch, then
|
**Refresh before every merge.** In the loop worktree: `git fetch origin`
|
||||||
`git merge origin/main` on the PR branch, push. A textual conflict
|
in the same breath, then `git merge origin/main` on the PR branch,
|
||||||
|
push. The fetch must be immediate — a cached `origin/main` merges
|
||||||
|
against the wrong base, CI goes green on it, and the merge comes back
|
||||||
|
405 "behind base", one whole CI cycle wasted (measured on the adoption
|
||||||
|
wave). A textual conflict
|
||||||
stops the leg there — as diff text, not as a failed merge click: hunks
|
stops the leg there — as diff text, not as a failed merge click: hunks
|
||||||
the loop authored are resolved by the loop; anything else is left with
|
the loop authored are resolved by the loop; anything else is left with
|
||||||
`⟦loop⟧` comment for a human, never forced. After any refresh push,
|
`⟦loop⟧` comment for a human, never forced. After any refresh push,
|
||||||
@@ -247,7 +269,10 @@ AVD), then `make android-emulator` per session.
|
|||||||
|
|
||||||
- **Worktree:** `git worktree add ~/.paseo/worktrees/loop/jumpy-hound
|
- **Worktree:** `git worktree add ~/.paseo/worktrees/loop/jumpy-hound
|
||||||
origin/main` (from any clone; branch from origin/main in the loop
|
origin/main` (from any clone; branch from origin/main in the loop
|
||||||
tree, never `git checkout main`).
|
tree, never `git checkout main`). **Provision it once before the
|
||||||
|
first push:** `make build-frontend` and `make testdata` — the pre-push
|
||||||
|
`go-test` hook needs `frontend/dist` (the `//go:embed` in `main.go`)
|
||||||
|
and the fixture library, and refuses the push without them.
|
||||||
- **Session:** pi in that worktree, `/name loop`. Add the job via
|
- **Session:** pi in that worktree, `/name loop`. Add the job via
|
||||||
`/schedule-prompt` (name `yj-loop`, cron
|
`/schedule-prompt` (name `yj-loop`, cron
|
||||||
`0 0 10-18 * * 1-5`, prompt: "Read `.pi/skills/yj-loop/SKILL.md` and
|
`0 0 10-18 * * 1-5`, prompt: "Read `.pi/skills/yj-loop/SKILL.md` and
|
||||||
@@ -270,4 +295,12 @@ AVD), then `make android-emulator` per session.
|
|||||||
which leg it really is.
|
which leg it really is.
|
||||||
- The job did not fire — the scheduler fires only while a session is
|
- The job did not fire — the scheduler fires only while a session is
|
||||||
open in its directory (documented); "the loop is off" is the correct
|
open in its directory (documented); "the loop is off" is the correct
|
||||||
reading, not a bug.
|
reading, not a bug.
|
||||||
|
- `error: object file … is empty` / `unpack-objects failed` / `bad
|
||||||
|
object refs/heads/…` during a fetch or checkout — the shared object
|
||||||
|
store was corrupted (a killed fetch leaves 0-byte object files, and a
|
||||||
|
local ref can end up pointing at the dead sha1). **Halt and report**;
|
||||||
|
do not retry, the churn only deepens it. Human repair: delete the
|
||||||
|
0-byte objects, `git fetch origin --prune`, delete any ref that
|
||||||
|
still dangles (`git update-ref -d refs/heads/<b>`), re-checkout the
|
||||||
|
worktree at `origin/main`, then `git fsck --full`.
|
||||||
@@ -5078,3 +5078,55 @@ last rendered card.
|
|||||||
("ask the virtualizer for a larger overscan") is therefore not
|
("ask the virtualizer for a larger overscan") is therefore not
|
||||||
available without patching a private, which is why the request is
|
available without patching a private, which is why the request is
|
||||||
issued ahead of the element instead.
|
issued ahead of the element instead.
|
||||||
|
|
||||||
|
## Declined, and recorded nowhere else (2026-09-25, #256)
|
||||||
|
|
||||||
|
When CLAUDE.md was cut down to rules, most of its "considered and
|
||||||
|
declined" paragraphs already had a home in a code or config comment
|
||||||
|
beside what they explain. These three did not:
|
||||||
|
|
||||||
|
- **`touch-action: manipulation` was declined** (#54). The 300ms tap
|
||||||
|
delay it is offered for is already absent on a `width=device-width`
|
||||||
|
viewport; what it would actually change is the gesture stack #63 tuned
|
||||||
|
by measurement on the reference device.
|
||||||
|
- **There is no "Go to Genre"** in the phone row menu (#67). That menu
|
||||||
|
replaces name links a phone cannot use, and there has never been a
|
||||||
|
genre link to replace — it would be new navigation, which wants its
|
||||||
|
own issue.
|
||||||
|
- **The overlaid queue has no tap-outside gutter on a phone** (#171).
|
||||||
|
The drawer-style gutter would buy the affordance by taking width off a
|
||||||
|
full-screen surface on a 424px viewport; back and a 44px close button
|
||||||
|
answer it instead.
|
||||||
|
|
||||||
|
## slskd's API, read from its source rather than a live daemon (2026-09-26)
|
||||||
|
|
||||||
|
`backend/download/provider_slskd.go` had never run against a real slskd
|
||||||
|
when #263–#272 shipped, so its assumptions were checked against slskd
|
||||||
|
0.26.0's source. One was wrong, and one design was only safe by luck.
|
||||||
|
These are properties of someone else's server; re-check on an upgrade.
|
||||||
|
`TestSlskdLive` (env-gated, see its comment) is the way to confirm them
|
||||||
|
against a running one.
|
||||||
|
|
||||||
|
- **`searchTimeout` is seconds, from the last response**, minimum 5
|
||||||
|
(`SearchRequest.cs`). We sent milliseconds (#274). The other search
|
||||||
|
options — `responseLimit`, `fileLimit`, `filterResponses`,
|
||||||
|
`minimumResponseFileCount`, `maximumPeerQueueLength` — are named as we
|
||||||
|
send them; slskd's defaults are 100 responses, 10 000 files, queue
|
||||||
|
1 000 000.
|
||||||
|
- **`GET /searches/{id}/responses` exists**, and `DELETE
|
||||||
|
/transfers/downloads/{user}/{id}?remove=true` cancels and removes.
|
||||||
|
- **A finished download is moved to `<downloads>/<Subdirectory>/`**,
|
||||||
|
where `Destination.Subdirectory` defaults to `${SOURCE_DIRECTORY}`
|
||||||
|
(the remote leaf folder) and is user-configurable. A taken name is
|
||||||
|
written as `name_<ticks>.ext` (`Destination.Exists = rename`, the
|
||||||
|
default). No transfer record says where the file went.
|
||||||
|
- **Batch enqueue (`POST /transfers/downloads/batches`) is new in 0.26.0**
|
||||||
|
and is the only way to choose where a file lands: `options.destination`
|
||||||
|
overrides the subdirectory pattern. A batch's files land flat in it,
|
||||||
|
so one batch per disc. On an older daemon that path is routed to the
|
||||||
|
per-user enqueue as username "batches" and the object body is
|
||||||
|
rejected with 400 — which is why 400 means "no batches" here.
|
||||||
|
- **`GET .../downloads/{user}/{id}/position` asks the peer** and returns
|
||||||
|
a bare integer. slskd's own comment on `PlaceInQueue` is "may be
|
||||||
|
wildly innacurate to the point of uselessness", which is why #275 acts
|
||||||
|
only on two readings in a row.
|
||||||
|
|||||||
@@ -112,7 +112,9 @@ in the job's directory — that limitation is the switch:
|
|||||||
|
|
||||||
- **Worktree:** `git worktree add` a dedicated clone at
|
- **Worktree:** `git worktree add` a dedicated clone at
|
||||||
`~/.paseo/worktrees/loop/jumpy-hound`. Loop edits happen only there; a
|
`~/.paseo/worktrees/loop/jumpy-hound`. Loop edits happen only there; a
|
||||||
dirty tree there is the loop's business and nobody else's.
|
dirty tree there is the loop's business and nobody else's. **Provision
|
||||||
|
it once before its first push:** `make build-frontend` + `make testdata`
|
||||||
|
— the pre-push `go-test` hook needs both and refuses without them.
|
||||||
- **Session:** pi in that worktree, `/name loop`. The job is bound to that
|
- **Session:** pi in that worktree, `/name loop`. The job is bound to that
|
||||||
session, so another pi elsewhere in the same directory does not
|
session, so another pi elsewhere in the same directory does not
|
||||||
double-fire it.
|
double-fire it.
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
# 021 — Listening accounting: smart plays, skips, and a real history
|
||||||
|
|
||||||
|
**Issue:** none yet — open one before the first edit (tracker is the
|
||||||
|
source of truth; `./scripts/issue.sh search "skip play count"` comes
|
||||||
|
back empty as of this writing).
|
||||||
|
|
||||||
|
**Status:** plan — not started.
|
||||||
|
|
||||||
|
**Relates:** play-count rendering (`frontend/src/components/track-list/columns.ts`),
|
||||||
|
smart playlists (`backend/smartplaylist/`), the event contract
|
||||||
|
(`TrackPlayCountChanged`), and any future Wrapped / "minutes listened"
|
||||||
|
surface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What exists now
|
||||||
|
|
||||||
|
Three facts, all load-bearing.
|
||||||
|
|
||||||
|
**A "play" is recorded only on a natural finish.** `recordPlay`
|
||||||
|
(`backend/queue/playhistory.go:9`) is called from exactly one place —
|
||||||
|
`OnPlaybackFinished` (`backend/queue/handlers.go:14`), and only when
|
||||||
|
`srcErr == nil`. A track the user skips past at 90% is *not* a play;
|
||||||
|
neither is one they pause at 60% and abandon. `play_count` /
|
||||||
|
`last_played` on `audio_files` reflect "finished to the end," nothing
|
||||||
|
more.
|
||||||
|
|
||||||
|
**There is no skip concept at all.** Skipping is indistinguishable
|
||||||
|
from a natural finish, a pause, or a shutdown. Nothing records "the
|
||||||
|
user rejected this track," so no downstream feature (smart playlists,
|
||||||
|
shuffle, the revisit shelf, a future skip-rate heuristic) can ask
|
||||||
|
about it.
|
||||||
|
|
||||||
|
**`play_history` is a write-only log.** It holds
|
||||||
|
`(audio_file_id, played_at)` and nothing reads it — no sqlc query
|
||||||
|
touches it, no `PlayHistory` read path exists. Its only recorded
|
||||||
|
purpose is the timestamps a future "minutes listened over time"
|
||||||
|
feature would need. It is classified `Authored, Cascade` in
|
||||||
|
`backend/datamap/datamap.go:272` ("Listening history").
|
||||||
|
|
||||||
|
So the gaps are: (1) skips are invisible, and (2) "played" is
|
||||||
|
under-counted — the opposite of the usual over-counting fear. The
|
||||||
|
scrobble intuition (count a play once `min(50%, 4:00)` has been
|
||||||
|
*heard*, independent of how it ends) is the fix for both.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What we're building
|
||||||
|
|
||||||
|
A single classification of every track *exit*, plus one row per exit in
|
||||||
|
a listening log, plus the existing denormalized `play_count` /
|
||||||
|
`last_played` updated to match the new meaning. Three exit kinds:
|
||||||
|
|
||||||
|
| kind | condition |
|
||||||
|
|---|---|
|
||||||
|
| `complete` | reached natural end, **or** abandoned with `remaining <= tail` |
|
||||||
|
| `play` | heard `>= playThreshold`, abandoned before the tail |
|
||||||
|
| `skip` | user moved to a *different* track before `playThreshold` |
|
||||||
|
|
||||||
|
Not counted, not any kind: decode failure, pause/stop/shutdown before
|
||||||
|
the threshold, and tracks shorter than `minTrackLength`.
|
||||||
|
|
||||||
|
### The thresholds — named judgements, one file
|
||||||
|
|
||||||
|
Follow the `PreviousRestartThreshold` precedent (`backend/queue/queue.go:28`,
|
||||||
|
a bare `const` with a comment). A new `backend/queue/listen.go` (or a
|
||||||
|
tiny `backend/listencount` package) declares:
|
||||||
|
|
||||||
|
```go
|
||||||
|
const (
|
||||||
|
// A track this short is deliberated jingle / interstitial and is
|
||||||
|
// never counted, either way.
|
||||||
|
minTrackLength = 30 * time.Second
|
||||||
|
// The scrobble rule: half the track, or four minutes, whichever
|
||||||
|
// comes first (Last.fm / ListenBrainz).
|
||||||
|
playThresholdMax = 4 * time.Minute
|
||||||
|
// "Finished enough": within 15s of the end, or the last 10%,
|
||||||
|
// whichever is larger. A 10:00 ambient track gets a 60s fade
|
||||||
|
// window; a 2:00 pop song gets 15s.
|
||||||
|
tailWindowFloor = 15 * time.Second
|
||||||
|
tailWindowFraction = 0.10
|
||||||
|
)
|
||||||
|
|
||||||
|
func playThreshold(d time.Duration) time.Duration {
|
||||||
|
return min(d/2, playThresholdMax)
|
||||||
|
}
|
||||||
|
func tailWindow(d time.Duration) time.Duration {
|
||||||
|
return max(d/10, tailWindowFloor)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Classification is a pure function of `(reason, position, duration)` and
|
||||||
|
*therefore unit-testable without a player*:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func classify(reason ExitReason, pos, dur time.Duration) Kind
|
||||||
|
```
|
||||||
|
|
||||||
|
`ExitReason` is `finished | skipped | failed | abandoned`. `skipped`
|
||||||
|
means the queue moved to a different track by user action (Next,
|
||||||
|
Previous past the restart threshold, PlayIndex, queue replacement,
|
||||||
|
select-from-a-list). `failed` is the decode-error path. `abandoned` is
|
||||||
|
pause/stop/unload/shutdown — and in v1 is a no-op (see open question 3).
|
||||||
|
|
||||||
|
**"Heard" is approximated by the position at exit.** We read
|
||||||
|
`player.CurrentPositionSeconds()` at the moment of the transition, not
|
||||||
|
an accumulated listen-time ledger. A user who seeks to 80% and listens
|
||||||
|
5 seconds reads as "heard 80%." That is deliberately accepted for v1:
|
||||||
|
it is how most players actually behave, it is drastically simpler, and
|
||||||
|
the failure mode ("counted a track you skimmed as played") is mild and
|
||||||
|
exactly what the scrobble threshold already forgives. Written down
|
||||||
|
because "position is not listen time" is the one assumption that will
|
||||||
|
look like a bug if it is not.
|
||||||
|
|
||||||
|
**Fires once per listen.** Leaving a track already leaves it; the
|
||||||
|
`chainID` guard in `player.onPlaybackFinished` (`backend/player/player.go:633`)
|
||||||
|
already swallows a stale finish callback, and a transition advances
|
||||||
|
`currentIndex` past the finished track. The classifier needs the same
|
||||||
|
guard so a Next-then-stale-finish cannot produce two rows. Key it on the
|
||||||
|
`(audioFileID, chainID)` the transition was about.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema — resolved: fresh design, no migration
|
||||||
|
|
||||||
|
The A/B migration agonizing is moot. This app has two users and both
|
||||||
|
are devs, and play counts are explicitly not worth preserving yet — so
|
||||||
|
the schema is written as if listening accounting had been designed in
|
||||||
|
from the start, and the existing two databases rebuild what they need
|
||||||
|
(see below). There is no migration step and none is re-introduced.
|
||||||
|
|
||||||
|
**`play_history` is renamed to `listening_events`** and grows the three
|
||||||
|
kinds, plus the raw position/duration the classification was made from:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS listening_events (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
audio_file_id INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'complete'
|
||||||
|
CHECK (kind IN ('complete','play','skip')),
|
||||||
|
position_seconds INTEGER NOT NULL DEFAULT 0,
|
||||||
|
duration_seconds INTEGER NOT NULL DEFAULT 0,
|
||||||
|
occurred_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_listening_events_audio_file_id
|
||||||
|
ON listening_events(audio_file_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_listening_events_occurred_at
|
||||||
|
ON listening_events(occurred_at);
|
||||||
|
```
|
||||||
|
|
||||||
|
`position_seconds`/`duration_seconds` are kept raw so a future re-tune
|
||||||
|
of the threshold does not force the events to be re-recorded. `kind`
|
||||||
|
stays the write-time classification; the raw reading is evidence, not
|
||||||
|
a second copy of the rule.
|
||||||
|
|
||||||
|
**The counters are denormalized onto `audio_files`** — `skip_count` /
|
||||||
|
`last_skipped` join the existing `play_count` / `last_played`, because
|
||||||
|
that is where the hot read path already lives and a log join per track
|
||||||
|
row is not acceptable. This does grow the MIXED-KIND wart (see the
|
||||||
|
survey below for the structural answer), but it is the *continuation* of
|
||||||
|
the existing design, not a new leak: play counts sat on `audio_files`
|
||||||
|
from before this feature existed.
|
||||||
|
|
||||||
|
**What happens to the two real databases on next launch.**
|
||||||
|
`listening_events` is a new table, created verbatim. `audio_files`
|
||||||
|
gains two columns, which `retireStaleTables` treats as a stale Owned
|
||||||
|
table and rebuilds by rescan — dropping `play_count` / `last_played` /
|
||||||
|
`tag_status` with it, which is the accepted cost stated in the issue.
|
||||||
|
`play_history` is gone from the schema and the datamap, so
|
||||||
|
`obsoleteTables` drops it; its (natural-finish-only) timestamp rows go
|
||||||
|
with it. Nothing here is wrong on a fresh install, and on the two dev
|
||||||
|
machines the answer is the documented "delete and rescan."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wiring: where the classifier is called
|
||||||
|
|
||||||
|
The risk is not the classifier — it is that **every track-replacement
|
||||||
|
path must classify the outgoing track**, and there are many: `Next`,
|
||||||
|
`Previous` (past the 3s restart threshold), `PlayIndex`, `playFromStart`,
|
||||||
|
`SetQueue` / clear-and-play, remove-current, and select-from-a-list.
|
||||||
|
Miss one and that path silently never records a skip.
|
||||||
|
|
||||||
|
So the classification is centralized in one queue method —
|
||||||
|
|
||||||
|
```go
|
||||||
|
// leaveCurrent(reason) classifies the track at currentIndex as it is
|
||||||
|
// about to be replaced, and records exactly one listening event.
|
||||||
|
// Must be called without q.mu held (it writes to SQLite).
|
||||||
|
func (q *Queue) leaveCurrent(reason ExitReason)
|
||||||
|
```
|
||||||
|
|
||||||
|
— which reads position/duration from the player, calls `classify`, and
|
||||||
|
emits the play/skip row + `TrackPlayCountChanged` when `kind != skip`.
|
||||||
|
`OnPlaybackFinished(nil)` routes through `leaveCurrent(finished)`, the
|
||||||
|
navigation methods route through `leaveCurrent(skipped)` before they
|
||||||
|
advance, and `recordPlay` becomes the "did a play happen" half of it.
|
||||||
|
|
||||||
|
Because "one path forgot to call it" is the failure mode, a **source
|
||||||
|
sweep** pins it, on the pattern of `TestNoDirectRuntimeEmits`
|
||||||
|
(`backend/events/noemit_test.go`) and `TestCatalogCoversSchema`: a test
|
||||||
|
walks `backend/queue` for assignments to `currentIndex` (and the
|
||||||
|
`SetQueue` / remove paths) and fails if a mutation site does not sit
|
||||||
|
adjacent to a `leaveCurrent` call. The sweep is the enforcement; the
|
||||||
|
central method is the convenience.
|
||||||
|
|
||||||
|
`recordPlay` keeps its existing contract *when a play happens* —
|
||||||
|
`TrackPlayCountChanged` with `{audioFileId, filePath, playCount,
|
||||||
|
lastPlayed}` — so the frontend patch path and
|
||||||
|
`playhistory_test.go` keep passing. A skip emits no per-track event in
|
||||||
|
v1 (open question 4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
1. **The classifier.** `listen.go`: the constants, `playThreshold`,
|
||||||
|
`tailWindow`, `classify`. Table-driven unit tests covering every
|
||||||
|
cell of the tristate, the <30s exemption, the tail window on both a
|
||||||
|
10:00 and a 2:00 track, and the clip at the 4:00 cap. No I/O.
|
||||||
|
2. **Schema.** *Done in this session.* `listening_events` replaces
|
||||||
|
`play_history`; `skip_count` / `last_skipped` added to
|
||||||
|
`audio_files`; datamap entry and `TestAuthoredCascadesAreDeliberate`
|
||||||
|
allow-list renamed; `recordPlay` writes `listening_events
|
||||||
|
('complete')`. `make generate` run; database / datamap / queue
|
||||||
|
tests green.
|
||||||
|
3. **Wiring.** `leaveCurrent`, the navigation/finish/error call sites,
|
||||||
|
the `fires once per listen` guard, and the source sweep. Extend
|
||||||
|
`playhistory_test.go` for skip/complete classification through the
|
||||||
|
queue rather than the pure function.
|
||||||
|
4. **Smart-playlist field.** `skip_count` (and optionally
|
||||||
|
`days_since_skipped`) in `smartplaylist.go` field/numeric maps and
|
||||||
|
the editor's field list, via subquery. A frontend event for skip —
|
||||||
|
if a UI wants a skip column — follows separately.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- **Go:** the classifier is pure and exhaustively unit-tested; the
|
||||||
|
queue wiring is tested in-process with `events.WithSink`
|
||||||
|
(`backend/queue/emit_test.go` is the model), asserting a Next at 90%
|
||||||
|
emits a *play*, a Next at 10% emits a *skip and no play*, a natural
|
||||||
|
finish emits a *complete*.
|
||||||
|
- **Database:** schema + datamap tests fail-loud on any new or
|
||||||
|
reclassified table; `database_test.go`'s listening-events round-trip
|
||||||
|
asserts the new table and the four denormalized counter columns.
|
||||||
|
- **e2e:** `e2e/specs/play-count.spec.ts` already awaits
|
||||||
|
`TrackPlayCountChanged`; add the skip case (advance early, assert no
|
||||||
|
`TrackPlayCountChanged` and a `skip` row via the `__/test/sql`
|
||||||
|
endpoint if convenient, or via the playlist effect).
|
||||||
|
- No visual/component tier needed unless a skip column ships (phase 4).
|
||||||
|
|
||||||
|
## Open questions / decisions needed
|
||||||
|
|
||||||
|
1. **Migration mechanism.** Resolved — fresh design, no migration (see the schema section). Play counts are not worth preserving, both users are devs, and `audio_files` / `play_history` rebuild-or-drop on next launch.
|
||||||
|
2. **"Position is not listen time."** Accept the approximation for v1,
|
||||||
|
or track accumulated listen seconds (a real ledger on the player) now?
|
||||||
|
3. **Abandon on shutdown.** A track paused at 70% and then app-killed:
|
||||||
|
count a `play` (scrobble says heard) or leave it unrecorded? v1
|
||||||
|
proposes *unrecorded* — same as today — to keep the write path off
|
||||||
|
the shutdown critical path.
|
||||||
|
4. **Skip event to the frontend.** Emit now (parallel to
|
||||||
|
`TrackPlayCountChanged`) or only when a surface consumes it?
|
||||||
+1
-1
@@ -13,7 +13,7 @@ frontend, bridged by [Wails v3](https://wails.io/).
|
|||||||
|
|
||||||
| Tool | Version |
|
| Tool | Version |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| Go | 1.25+ |
|
| Go | 1.26+ |
|
||||||
| Node.js | 22+ |
|
| Node.js | 22+ |
|
||||||
| pnpm | 10+ |
|
| pnpm | 10+ |
|
||||||
| Wails CLI | v3 — vendored, no install needed (`go tool wails3`) |
|
| Wails CLI | v3 — vendored, no install needed (`go tool wails3`) |
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ css-check: ## Fail on a css`` literal ended early by a backtick, or a nested rul
|
|||||||
# Every command in them is a make target on purpose, so this is
|
# Every command in them is a make target on purpose, so this is
|
||||||
# checkable. It also asserts AGENTS.md is a symlink to CLAUDE.md, so the
|
# checkable. It also asserts AGENTS.md is a symlink to CLAUDE.md, so the
|
||||||
# two harnesses cannot drift onto two descriptions of one project.
|
# two harnesses cannot drift onto two descriptions of one project.
|
||||||
skill-check: ## Fail if the agent docs name a missing make target, or AGENTS.md is not a symlink
|
skill-check: ## Fail if the docs name a missing make target, or AGENTS.md is not a symlink
|
||||||
@./scripts/skill-check.sh
|
@./scripts/skill-check.sh
|
||||||
|
|
||||||
# Conventional Commits, which CLAUDE.md claimed CI enforced for a long
|
# Conventional Commits, which CLAUDE.md claimed CI enforced for a long
|
||||||
|
|||||||
@@ -499,6 +499,10 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
|||||||
PostRemove: yj.explore.InvalidateLibrarySync,
|
PostRemove: yj.explore.InvalidateLibrarySync,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A deleted playlist must not leave the queue's "Playing from"
|
||||||
|
// label pointing at it.
|
||||||
|
yj.playlist.SetOnPlaylistDeleted(yj.queue.DropSourceForPlaylist)
|
||||||
|
|
||||||
// Register playback finished handler to drive queue auto-advance.
|
// Register playback finished handler to drive queue auto-advance.
|
||||||
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
|
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
|
||||||
|
|
||||||
@@ -775,6 +779,8 @@ func (yj *YellowJacketApp) startJanitor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
yj.janitor.Register(maintenance.ExpiredHTTPCacheJob(yj.database))
|
yj.janitor.Register(maintenance.ExpiredHTTPCacheJob(yj.database))
|
||||||
|
yj.janitor.Register(maintenance.StaleArtistMetadataJob(yj.database))
|
||||||
|
yj.janitor.Register(maintenance.StaleSearchClicksJob(yj.database))
|
||||||
yj.janitor.Register(maintenance.OrphanedCoverFilesJob(
|
yj.janitor.Register(maintenance.OrphanedCoverFilesJob(
|
||||||
yj.database, coversDir, library.CoverArtFileSet,
|
yj.database, coversDir, library.CoverArtFileSet,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -303,6 +303,24 @@ func (c *Config) GetLibraryDirectory() string {
|
|||||||
return string(c.Library.DirectoryPath)
|
return string(c.Library.DirectoryPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A rejected setter puts the old value back, and that is not tidiness
|
||||||
|
// (#231). Save validates the *whole* config, so a value left behind by
|
||||||
|
// a failed write does not merely fail its own call: it fails every
|
||||||
|
// later save, of every unrelated setting, silently and for the rest of
|
||||||
|
// the session. Nothing reaches disk, so a restart clears it -- which
|
||||||
|
// is exactly what makes the fault hard to see and impossible to report.
|
||||||
|
//
|
||||||
|
// The setters below that assign and then validate therefore snapshot
|
||||||
|
// the field first and restore it on the error path. SetLibraryDirectory
|
||||||
|
// is the other safe shape and the better one where the value can be
|
||||||
|
// built on its own: it validates a candidate *before* assigning
|
||||||
|
// anything, so there is nothing to undo.
|
||||||
|
//
|
||||||
|
// Not every setter needs either. A bool, an int64 and the shortcut
|
||||||
|
// bindings pass through no validation that can reject them, and
|
||||||
|
// SetViewVisible refuses an unknown, non-hideable or launch-page view
|
||||||
|
// up front, so GeneralConfig.Validate never sees one it would fail on.
|
||||||
|
|
||||||
// SetLibraryDirectory validates and saves a new library directory,
|
// SetLibraryDirectory validates and saves a new library directory,
|
||||||
// then emits the LibraryConfigChanged event so listeners (e.g. the
|
// then emits the LibraryConfigChanged event so listeners (e.g. the
|
||||||
// Library scanner) can react.
|
// Library scanner) can react.
|
||||||
@@ -360,11 +378,14 @@ func (c *Config) SetScanConcurrency(mode string) error {
|
|||||||
c.Library.ApplyDefaults()
|
c.Library.ApplyDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previous := c.Library.ScanConcurrency
|
||||||
c.Library.ScanConcurrency = library.ScanConcurrency(
|
c.Library.ScanConcurrency = library.ScanConcurrency(
|
||||||
mode,
|
mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := c.Library.Validate(); err != nil {
|
if err := c.Library.Validate(); err != nil {
|
||||||
|
c.Library.ScanConcurrency = previous
|
||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"invalid scan concurrency mode: %w", err,
|
"invalid scan concurrency mode: %w", err,
|
||||||
)
|
)
|
||||||
@@ -455,9 +476,12 @@ func (c *Config) SetThemeAccentColor(
|
|||||||
c.Theme.ApplyDefaults()
|
c.Theme.ApplyDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previous := c.Theme.AccentColor
|
||||||
c.Theme.AccentColor = color
|
c.Theme.AccentColor = color
|
||||||
|
|
||||||
if err := c.Theme.Validate(); err != nil {
|
if err := c.Theme.Validate(); err != nil {
|
||||||
|
c.Theme.AccentColor = previous
|
||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"invalid theme accent color: %w", err,
|
"invalid theme accent color: %w", err,
|
||||||
)
|
)
|
||||||
@@ -488,9 +512,12 @@ func (c *Config) SetThemeBackgroundShade(
|
|||||||
c.Theme.ApplyDefaults()
|
c.Theme.ApplyDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previous := c.Theme.BackgroundShade
|
||||||
c.Theme.BackgroundShade = theme.BackgroundShade(shade)
|
c.Theme.BackgroundShade = theme.BackgroundShade(shade)
|
||||||
|
|
||||||
if err := c.Theme.Validate(); err != nil {
|
if err := c.Theme.Validate(); err != nil {
|
||||||
|
c.Theme.BackgroundShade = previous
|
||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"invalid theme background shade: %w", err,
|
"invalid theme background shade: %w", err,
|
||||||
)
|
)
|
||||||
@@ -544,9 +571,12 @@ func (c *Config) SetDefaultPage(page string) error {
|
|||||||
c.General.ApplyDefaults()
|
c.General.ApplyDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previous := c.General.DefaultPage
|
||||||
c.General.DefaultPage = View(page)
|
c.General.DefaultPage = View(page)
|
||||||
|
|
||||||
if err := c.General.Validate(); err != nil {
|
if err := c.General.Validate(); err != nil {
|
||||||
|
c.General.DefaultPage = previous
|
||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"invalid default page: %w", err,
|
"invalid default page: %w", err,
|
||||||
)
|
)
|
||||||
@@ -591,9 +621,12 @@ func (c *Config) SetQueueFallback(mode string) error {
|
|||||||
c.General.ApplyDefaults()
|
c.General.ApplyDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previous := c.General.QueueFallback
|
||||||
c.General.QueueFallback = QueueFallback(mode)
|
c.General.QueueFallback = QueueFallback(mode)
|
||||||
|
|
||||||
if err := c.General.Validate(); err != nil {
|
if err := c.General.Validate(); err != nil {
|
||||||
|
c.General.QueueFallback = previous
|
||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"invalid queue fallback: %w", err,
|
"invalid queue fallback: %w", err,
|
||||||
)
|
)
|
||||||
@@ -801,9 +834,12 @@ func (c *Config) SetTrackListColumns(
|
|||||||
c.TrackList = &tracklist.Config{}
|
c.TrackList = &tracklist.Config{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previous := c.TrackList.Columns
|
||||||
c.TrackList.Columns = columns
|
c.TrackList.Columns = columns
|
||||||
|
|
||||||
if err := c.TrackList.Validate(); err != nil {
|
if err := c.TrackList.Validate(); err != nil {
|
||||||
|
c.TrackList.Columns = previous
|
||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"invalid track-list columns: %w", err,
|
"invalid track-list columns: %w", err,
|
||||||
)
|
)
|
||||||
@@ -901,9 +937,12 @@ func (c *Config) SetFavoritesIconStyle(
|
|||||||
c.Favorites.ApplyDefaults()
|
c.Favorites.ApplyDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previous := c.Favorites.IconStyle
|
||||||
c.Favorites.IconStyle = favorites.IconStyle(style)
|
c.Favorites.IconStyle = favorites.IconStyle(style)
|
||||||
|
|
||||||
if err := c.Favorites.Validate(); err != nil {
|
if err := c.Favorites.Validate(); err != nil {
|
||||||
|
c.Favorites.IconStyle = previous
|
||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"invalid favorites icon style: %w", err,
|
"invalid favorites icon style: %w", err,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"yellowjacket/backend/library"
|
||||||
|
"yellowjacket/backend/tracklist"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newSavableConfig builds a loaded, valid config in a temp directory,
|
||||||
|
// so Save() writes rather than refusing with errSaveBeforeLoad.
|
||||||
|
//
|
||||||
|
// The library directory is real and set, because Config.Validate only
|
||||||
|
// validates the Library section when DirectoryPath is non-empty -- an
|
||||||
|
// empty one would hide a poisoned ScanConcurrency from the whole-config
|
||||||
|
// save that is the symptom under test.
|
||||||
|
func newSavableConfig(t *testing.T) *Config {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
c := &Config{
|
||||||
|
logger: slog.Default(),
|
||||||
|
filePath: filepath.Join(t.TempDir(), "config.toml"),
|
||||||
|
Library: &library.Config{
|
||||||
|
DirectoryPath: library.Directory(t.TempDir()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
c.applyDefaults()
|
||||||
|
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
t.Fatalf("Load() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
t.Fatalf("Save() on a fresh config error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetterRejectionDoesNotPoisonTheConfig is the whole of #231.
|
||||||
|
//
|
||||||
|
// Every setter here assigns to the in-memory config and then validates.
|
||||||
|
// When the validation rejects the argument, the rejected value has to go
|
||||||
|
// back -- not because the caller sees it (it gets an error either way),
|
||||||
|
// but because Config.Save() validates the *whole* config. A value left
|
||||||
|
// behind by a failed setter therefore fails every later save, of every
|
||||||
|
// unrelated setting, silently and for the rest of the session.
|
||||||
|
//
|
||||||
|
// So each case asserts three things in order: the setter reports the
|
||||||
|
// error, the getter still reports the old value, and an unrelated save
|
||||||
|
// still works. The third is the one the user feels.
|
||||||
|
func TestSetterRejectionDoesNotPoisonTheConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
// reject calls the setter with an argument its own Validate
|
||||||
|
// refuses.
|
||||||
|
reject func(*Config) error
|
||||||
|
// read reports the value the setter writes, so the rollback is
|
||||||
|
// asserted on the config rather than only on the save.
|
||||||
|
read func(*Config) string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "scan concurrency",
|
||||||
|
reject: func(c *Config) error {
|
||||||
|
return c.SetScanConcurrency("telepathy")
|
||||||
|
},
|
||||||
|
read: (*Config).GetScanConcurrency,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "theme accent colour",
|
||||||
|
reject: func(c *Config) error {
|
||||||
|
return c.SetThemeAccentColor("not-a-hex")
|
||||||
|
},
|
||||||
|
read: (*Config).GetThemeAccentColor,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "theme background shade",
|
||||||
|
reject: func(c *Config) error {
|
||||||
|
return c.SetThemeBackgroundShade("chartreuse")
|
||||||
|
},
|
||||||
|
read: (*Config).GetThemeBackgroundShade,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default page",
|
||||||
|
reject: func(c *Config) error {
|
||||||
|
return c.SetDefaultPage("nowhere")
|
||||||
|
},
|
||||||
|
read: (*Config).GetDefaultPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "queue fallback",
|
||||||
|
reject: func(c *Config) error {
|
||||||
|
return c.SetQueueFallback("improvise")
|
||||||
|
},
|
||||||
|
read: (*Config).GetQueueFallback,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "favorites icon style",
|
||||||
|
reject: func(c *Config) error {
|
||||||
|
return c.SetFavoritesIconStyle("asterisk")
|
||||||
|
},
|
||||||
|
read: (*Config).GetFavoritesIconStyle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "track-list columns",
|
||||||
|
reject: func(c *Config) error {
|
||||||
|
// titleArtist is a drawing definition, not a
|
||||||
|
// configurable column (#197), so it is exactly what
|
||||||
|
// the frontend used to be able to send.
|
||||||
|
return c.SetTrackListColumns([]tracklist.Column{
|
||||||
|
{ID: "titleArtist"},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
read: func(c *Config) string {
|
||||||
|
return columnIDs(c.GetTrackListColumns())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "track-list columns, duplicated",
|
||||||
|
reject: func(c *Config) error {
|
||||||
|
// The route #197 closed was one invalid id; a
|
||||||
|
// duplicate is the one still reachable from a client
|
||||||
|
// that assembles the list itself.
|
||||||
|
return c.SetTrackListColumns([]tracklist.Column{
|
||||||
|
{ID: tracklist.ColTrackName},
|
||||||
|
{ID: tracklist.ColTrackName},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
read: func(c *Config) string {
|
||||||
|
return columnIDs(c.GetTrackListColumns())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c := newSavableConfig(t)
|
||||||
|
before := tc.read(c)
|
||||||
|
|
||||||
|
if err := tc.reject(c); err == nil {
|
||||||
|
t.Fatal("setter accepted an invalid value, want an error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if after := tc.read(c); after != before {
|
||||||
|
t.Errorf(
|
||||||
|
"value after a rejected write = %q, want the previous %q",
|
||||||
|
after, before,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The symptom: an unrelated setting can no longer be saved.
|
||||||
|
if err := c.SetPopupVolume(true); err != nil {
|
||||||
|
t.Errorf("an unrelated setter failed after a rejected write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
t.Errorf("Save() failed after a rejected write: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRejectedSetterLeavesNothingOnDisk pairs with the sweep above: the
|
||||||
|
// rollback must not be undone by what the file already holds, so a
|
||||||
|
// config reloaded from disk after a rejected write agrees with memory.
|
||||||
|
func TestRejectedSetterLeavesNothingOnDisk(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c := newSavableConfig(t)
|
||||||
|
|
||||||
|
if err := c.SetThemeAccentColor("#123456"); err != nil {
|
||||||
|
t.Fatalf("SetThemeAccentColor() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.SetThemeAccentColor("not-a-hex"); err == nil {
|
||||||
|
t.Fatal("SetThemeAccentColor accepted a non-colour, want an error")
|
||||||
|
}
|
||||||
|
|
||||||
|
reloaded := &Config{logger: slog.Default(), filePath: c.filePath}
|
||||||
|
reloaded.applyDefaults()
|
||||||
|
|
||||||
|
if err := reloaded.Load(); err != nil {
|
||||||
|
t.Fatalf("Load() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := reloaded.GetThemeAccentColor(); got != "#123456" {
|
||||||
|
t.Errorf("accent colour on disk = %q, want %q", got, "#123456")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.GetThemeAccentColor() != reloaded.GetThemeAccentColor() {
|
||||||
|
t.Errorf(
|
||||||
|
"in-memory accent %q disagrees with disk %q after a rejected write",
|
||||||
|
c.GetThemeAccentColor(), reloaded.GetThemeAccentColor(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetLibraryDirectoryValidatesBeforeAssigning pins the precedent the
|
||||||
|
// seven rolled-back setters follow: this one has always built and
|
||||||
|
// validated a candidate before assigning, so a bad path never reaches
|
||||||
|
// the config at all.
|
||||||
|
func TestSetLibraryDirectoryValidatesBeforeAssigning(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c := newSavableConfig(t)
|
||||||
|
before := c.GetLibraryDirectory()
|
||||||
|
|
||||||
|
if err := c.SetLibraryDirectory(filepath.Join(t.TempDir(), "no-such-dir")); err == nil {
|
||||||
|
t.Fatal("SetLibraryDirectory accepted a missing directory, want an error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if after := c.GetLibraryDirectory(); after != before {
|
||||||
|
t.Errorf("library directory = %q, want the previous %q", after, before)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
t.Errorf("Save() failed after a rejected library directory: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetViewVisibleRefusesBeforeAssigning covers the other setter left
|
||||||
|
// out of the rollback pass: it guards its own argument up front, so
|
||||||
|
// GeneralConfig.Validate never sees a view it would reject.
|
||||||
|
func TestSetViewVisibleRefusesBeforeAssigning(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c := newSavableConfig(t)
|
||||||
|
|
||||||
|
if err := c.SetViewVisible("no-such-view", false); err == nil {
|
||||||
|
t.Fatal("SetViewVisible accepted an unknown view, want an error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.SetViewVisible(c.GetDefaultPage(), false); err == nil {
|
||||||
|
t.Fatal("SetViewVisible hid the launch page, want an error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
t.Errorf("Save() failed after a refused view visibility change: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// columnIDs renders a column list for comparison in the table above.
|
||||||
|
func columnIDs(cols []tracklist.Column) string {
|
||||||
|
ids := make([]byte, 0, len(cols)*8)
|
||||||
|
|
||||||
|
for i, col := range cols {
|
||||||
|
if i > 0 {
|
||||||
|
ids = append(ids, ',')
|
||||||
|
}
|
||||||
|
|
||||||
|
ids = append(ids, col.ID...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(ids)
|
||||||
|
}
|
||||||
@@ -662,19 +662,19 @@ func TestSmartPlaylistColumns(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Migration 10 — play history tracking
|
// Listening events tracking
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestPlayHistoryTable(t *testing.T) {
|
func TestListeningEventsTable(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
db := NewTestDB(t)
|
db := NewTestDB(t)
|
||||||
|
|
||||||
// Verify play_history table exists.
|
// Verify listening_events table exists.
|
||||||
var tableCount int64
|
var tableCount int64
|
||||||
|
|
||||||
tblRows, err := db.QueryContext(
|
tblRows, err := db.QueryContext(
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='play_history'",
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='listening_events'",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("query sqlite_master: %v", err)
|
t.Fatalf("query sqlite_master: %v", err)
|
||||||
@@ -695,12 +695,14 @@ func TestPlayHistoryTable(t *testing.T) {
|
|||||||
_ = tblRows.Close()
|
_ = tblRows.Close()
|
||||||
|
|
||||||
if tableCount != 1 {
|
if tableCount != 1 {
|
||||||
t.Errorf("play_history table count = %d, want 1", tableCount)
|
t.Errorf("listening_events table count = %d, want 1", tableCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify audio_files has play_count and last_played columns.
|
// Verify audio_files has the denormalized listening counters.
|
||||||
hasPlayCount := false
|
hasPlayCount := false
|
||||||
hasLastPlayed := false
|
hasLastPlayed := false
|
||||||
|
hasSkipCount := false
|
||||||
|
hasLastSkipped := false
|
||||||
|
|
||||||
colRows, err := db.QueryContext("PRAGMA table_info(audio_files)")
|
colRows, err := db.QueryContext("PRAGMA table_info(audio_files)")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -732,6 +734,14 @@ func TestPlayHistoryTable(t *testing.T) {
|
|||||||
if name == "last_played" {
|
if name == "last_played" {
|
||||||
hasLastPlayed = true
|
hasLastPlayed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if name == "skip_count" {
|
||||||
|
hasSkipCount = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "last_skipped" {
|
||||||
|
hasLastSkipped = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = colRows.Close()
|
_ = colRows.Close()
|
||||||
@@ -744,6 +754,14 @@ func TestPlayHistoryTable(t *testing.T) {
|
|||||||
t.Error("audio_files missing last_played column")
|
t.Error("audio_files missing last_played column")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !hasSkipCount {
|
||||||
|
t.Error("audio_files missing skip_count column")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasLastSkipped {
|
||||||
|
t.Error("audio_files missing last_skipped column")
|
||||||
|
}
|
||||||
|
|
||||||
// Verify track_metadata VIEW includes play_count and last_played.
|
// Verify track_metadata VIEW includes play_count and last_played.
|
||||||
viewCols := map[string]bool{}
|
viewCols := map[string]bool{}
|
||||||
|
|
||||||
@@ -783,7 +801,7 @@ func TestPlayHistoryTable(t *testing.T) {
|
|||||||
t.Error("track_metadata VIEW missing last_played column")
|
t.Error("track_metadata VIEW missing last_played column")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Round-trip: insert a play_history row and verify play_count update.
|
// Round-trip: insert a listening_events row and verify play_count update.
|
||||||
// First, set up test data. The test DB already has library id=0.
|
// First, set up test data. The test DB already has library id=0.
|
||||||
InsertTestTrack(t, db, TestTrack{
|
InsertTestTrack(t, db, TestTrack{
|
||||||
FilePath: "/test/play_history.mp3",
|
FilePath: "/test/play_history.mp3",
|
||||||
@@ -821,12 +839,12 @@ func TestPlayHistoryTable(t *testing.T) {
|
|||||||
t.Errorf("initial play_count = %d, want 0", playCount)
|
t.Errorf("initial play_count = %d, want 0", playCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert a play_history row and update play_count.
|
// Insert a listening_events row (kind defaults to 'complete').
|
||||||
_, err = db.ExecContext(
|
_, err = db.ExecContext(
|
||||||
"INSERT INTO play_history (audio_file_id) VALUES (1)",
|
"INSERT INTO listening_events (audio_file_id, kind) VALUES (1, 'complete')",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("insert play_history: %v", err)
|
t.Fatalf("insert listening_events: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.ExecContext(
|
_, err = db.ExecContext(
|
||||||
|
|||||||
@@ -151,16 +151,28 @@ func (d *DB) SetLyrics(audioFileID int64, lyrics, source, recordingMBID string)
|
|||||||
return d.upsertLyricsIndex(audioFileID, lyrics)
|
return d.upsertLyricsIndex(audioFileID, lyrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
// upsertLyricsIndex refreshes a single file's entry in the contentless
|
// DeleteLyricsIndex removes one file's entry from the contentless
|
||||||
// lyrics_index. contentless_delete=1 makes the DELETE valid; an empty
|
// lyrics_index. It is called wherever a file row is deleted — the
|
||||||
// lyrics string leaves the row deleted.
|
// `lyrics` table cascades with its file, but the FTS entry does not and
|
||||||
func (d *DB) upsertLyricsIndex(audioFileID int64, lyrics string) error {
|
// would otherwise accumulate for the life of the install (#249).
|
||||||
|
func (d *DB) DeleteLyricsIndex(audioFileID int64) error {
|
||||||
if _, err := d.db.ExecContext(d.Ctx,
|
if _, err := d.db.ExecContext(d.Ctx,
|
||||||
"DELETE FROM lyrics_index WHERE rowid = ?", audioFileID,
|
"DELETE FROM lyrics_index WHERE rowid = ?", audioFileID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("could not delete lyrics_index row: %w", err)
|
return fmt.Errorf("could not delete lyrics_index row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsertLyricsIndex refreshes a single file's entry in the contentless
|
||||||
|
// lyrics_index. contentless_delete=1 makes the DELETE valid; an empty
|
||||||
|
// lyrics string leaves the row deleted.
|
||||||
|
func (d *DB) upsertLyricsIndex(audioFileID int64, lyrics string) error {
|
||||||
|
if err := d.DeleteLyricsIndex(audioFileID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(lyrics) == "" {
|
if strings.TrimSpace(lyrics) == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Preserving a playlist entry across the loss of its track is two
|
||||||
|
// statements, not one, and the split is not tidiness -- it is what
|
||||||
|
// makes the important half work in the situation that needs it most.
|
||||||
|
//
|
||||||
|
// `playlist_tracks.audio_file_id` is ON DELETE SET NULL, so an entry
|
||||||
|
// outlives its file as an id-less row that says nothing about what the
|
||||||
|
// user put in the playlist. The phantom_* columns carry the answer
|
||||||
|
// across and ResolvePhantomTracksAfterScan re-links them afterwards --
|
||||||
|
// but only if something fills them *before* the rows go.
|
||||||
|
//
|
||||||
|
// The two halves are not equally important and are not equally
|
||||||
|
// available:
|
||||||
|
//
|
||||||
|
// - **phantom_file_path is the one that matters.**
|
||||||
|
// ResolvePhantomTracksAfterScan matches it against
|
||||||
|
// `audio_files.file_path`, so without it an entry can never be
|
||||||
|
// re-linked and the playlist is empty for good. It comes straight
|
||||||
|
// off `audio_files`, whose `file_path` is the table's natural key
|
||||||
|
// and has been present in every shape it has ever had -- including
|
||||||
|
// the pre-013 stub of `(id, file_path, recording_id)`.
|
||||||
|
// - The rest is *display* for a phantom entry before a rescan
|
||||||
|
// re-links it, and it comes from the `track_metadata` view, which
|
||||||
|
// is the one definition of a track row and not worth restating.
|
||||||
|
//
|
||||||
|
// Reading the view is what cannot be relied on here, and that is the
|
||||||
|
// whole reason for the split. This runs *before* applySchema, which is
|
||||||
|
// precisely the moment the schema is inconsistent: the view is whatever
|
||||||
|
// the last launch's schema declared, while `audio_files` is whatever
|
||||||
|
// the launch before that left behind. A view over columns the table no
|
||||||
|
// longer has is not merely empty -- `pragma_table_info` on it *errors*,
|
||||||
|
// and so does selecting from it. `cmd/indexbuild`'s fixture is exactly
|
||||||
|
// that shape and is what caught this.
|
||||||
|
//
|
||||||
|
// COALESCE keeps an existing phantom value in both halves: an entry
|
||||||
|
// already phantom is one whose file went missing in an earlier pass,
|
||||||
|
// and its recorded metadata is the only copy left. Overwriting that
|
||||||
|
// from a NULL join erases the rows this exists to protect.
|
||||||
|
const (
|
||||||
|
preservePhantomPathSQL = `
|
||||||
|
UPDATE playlist_tracks
|
||||||
|
SET phantom_file_path = COALESCE(phantom_file_path, (
|
||||||
|
SELECT af.file_path FROM audio_files af
|
||||||
|
WHERE af.id = playlist_tracks.audio_file_id
|
||||||
|
))
|
||||||
|
WHERE audio_file_id IS NOT NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
preservePhantomDisplaySQL = `
|
||||||
|
UPDATE playlist_tracks
|
||||||
|
SET
|
||||||
|
phantom_title = COALESCE(phantom_title, (
|
||||||
|
SELECT tm.title FROM track_metadata tm
|
||||||
|
WHERE tm.id = playlist_tracks.audio_file_id
|
||||||
|
)),
|
||||||
|
phantom_artist = COALESCE(phantom_artist, (
|
||||||
|
SELECT tm.artist_name FROM track_metadata tm
|
||||||
|
WHERE tm.id = playlist_tracks.audio_file_id
|
||||||
|
)),
|
||||||
|
phantom_album = COALESCE(phantom_album, (
|
||||||
|
SELECT tm.album FROM track_metadata tm
|
||||||
|
WHERE tm.id = playlist_tracks.audio_file_id
|
||||||
|
)),
|
||||||
|
phantom_duration_ms = COALESCE(phantom_duration_ms, (
|
||||||
|
SELECT af.length_milliseconds FROM audio_files af
|
||||||
|
WHERE af.id = playlist_tracks.audio_file_id
|
||||||
|
)),
|
||||||
|
phantom_genre = COALESCE(phantom_genre, (
|
||||||
|
SELECT tm.genre FROM track_metadata tm
|
||||||
|
WHERE tm.id = playlist_tracks.audio_file_id
|
||||||
|
)),
|
||||||
|
phantom_cover_art_path = COALESCE(phantom_cover_art_path, (
|
||||||
|
SELECT tm.cover_art_path FROM track_metadata tm
|
||||||
|
WHERE tm.id = playlist_tracks.audio_file_id
|
||||||
|
))
|
||||||
|
WHERE audio_file_id IS NOT NULL
|
||||||
|
`
|
||||||
|
)
|
||||||
|
|
||||||
|
// PreservePlaylistPhantoms records every linked playlist entry's track
|
||||||
|
// metadata on the entry itself, so the entry survives the rows being
|
||||||
|
// deleted underneath it.
|
||||||
|
//
|
||||||
|
// Every path that empties `audio_files` must call this (or the scoped
|
||||||
|
// variant below) first, inside the same transaction as the delete.
|
||||||
|
// These paths have drifted before: the full rescan in backend/library
|
||||||
|
// did this and the stale-shape retire in this package did not, so the
|
||||||
|
// *documented* repair ("delete and rescan") preserved playlists while
|
||||||
|
// the automatic one that exists to spare the user that work silently
|
||||||
|
// emptied them (#183). The incremental scan's orphan cleanup and
|
||||||
|
// RemoveFromLibrary drifted the same way and are #246.
|
||||||
|
//
|
||||||
|
// The display half is skipped, with a warning, when `track_metadata`
|
||||||
|
// cannot answer -- see the note above. Skipping it costs a phantom
|
||||||
|
// entry its title until a rescan re-links it; skipping the path half
|
||||||
|
// would cost the entry outright, so that one is an error.
|
||||||
|
func PreservePlaylistPhantoms(
|
||||||
|
ctx context.Context, tx *sql.Tx, logger *slog.Logger,
|
||||||
|
) error {
|
||||||
|
return preservePlaylistPhantoms(ctx, tx, nil, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreservePlaylistPhantomsForFiles is PreservePlaylistPhantoms scoped to
|
||||||
|
// the given audio file ids, for the two removal paths that delete a
|
||||||
|
// known subset of the table rather than all of it: the incremental
|
||||||
|
// scan's orphan cleanup and RemoveFromLibrary. A bulk pass there would
|
||||||
|
// rewrite every linked playlist row on every scan for nothing.
|
||||||
|
func PreservePlaylistPhantomsForFiles(
|
||||||
|
ctx context.Context, tx *sql.Tx, ids []int64, logger *slog.Logger,
|
||||||
|
) error {
|
||||||
|
return preservePlaylistPhantoms(ctx, tx, ids, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func preservePlaylistPhantoms(
|
||||||
|
ctx context.Context, tx *sql.Tx, ids []int64, logger *slog.Logger,
|
||||||
|
) error {
|
||||||
|
clause, args, skip := phantomIDFilter(ids)
|
||||||
|
if skip {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(
|
||||||
|
ctx, preservePhantomPathSQL+clause, args...,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"could not preserve playlist track file paths: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(
|
||||||
|
ctx, preservePhantomDisplaySQL+clause, args...,
|
||||||
|
); err != nil {
|
||||||
|
// A failed statement does not roll back a SQLite transaction,
|
||||||
|
// so the path half above stands and the entries remain
|
||||||
|
// re-linkable.
|
||||||
|
logger.Warn(
|
||||||
|
"could not record display metadata for playlist entries; "+
|
||||||
|
"they will be re-linked by the next scan but read as "+
|
||||||
|
"unknown until then",
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// phantomIDFilter builds the extra WHERE terms and arguments that scope
|
||||||
|
// a preservation pass to a set of audio file ids. A nil ids returns the
|
||||||
|
// empty clause (a bulk run over every linked entry); an empty slice
|
||||||
|
// reports skip, since there is nothing to preserve.
|
||||||
|
func phantomIDFilter(ids []int64) (clause string, args []any, skip bool) {
|
||||||
|
switch {
|
||||||
|
case ids == nil:
|
||||||
|
return "", nil, false
|
||||||
|
case len(ids) == 0:
|
||||||
|
return "", nil, true
|
||||||
|
}
|
||||||
|
|
||||||
|
clause = " AND audio_file_id IN (" +
|
||||||
|
strings.Repeat("?,", len(ids)-1) + "?)"
|
||||||
|
|
||||||
|
args = make([]any, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
args[i] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
return clause, args, false
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPreservePlaylistPhantomsForFilesScopesToTheRequestedIDs is the
|
||||||
|
// scoping half of the scoped variant: a run over one file's id must
|
||||||
|
// fill that file's playlist entries and leave every other entry alone,
|
||||||
|
// because the incremental scan calls this once per orphan batch and a
|
||||||
|
// pass that rewrote the whole table would touch every playlist row on
|
||||||
|
// every scan.
|
||||||
|
func TestPreservePlaylistPhantomsForFilesScopesToTheRequestedIDs(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
db := openRaw(t, t.TempDir())
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
|
||||||
|
t.Fatalf("pragma: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := applySchema(ctx, db); err != nil {
|
||||||
|
t.Fatalf("applySchema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO playlists (id, name) VALUES (1, 'keepme');
|
||||||
|
INSERT INTO libraries (id, name, path) VALUES (0, 'test', '/music');
|
||||||
|
INSERT INTO artists (id, name) VALUES (3, 'Aurora Fields');
|
||||||
|
INSERT INTO cover_art (id, file_path, mime_type)
|
||||||
|
VALUES (9, 'covers/7.jpg', 'image/jpeg');
|
||||||
|
INSERT INTO albums (id, name, artist_id, cover_art_id)
|
||||||
|
VALUES (4, 'Tideline', 3, 9);
|
||||||
|
INSERT INTO audio_files
|
||||||
|
(id, file_path, file_type_id, length_milliseconds,
|
||||||
|
title, artist_credit, artist_id, album_id)
|
||||||
|
VALUES
|
||||||
|
(7, '/music/a.flac', 1, 1000,
|
||||||
|
'Slack Water', 'Aurora Fields', 3, 4),
|
||||||
|
(8, '/music/b.flac', 1, 2000,
|
||||||
|
'Second Tide', 'Aurora Fields', 3, 4);
|
||||||
|
INSERT INTO playlist_tracks (playlist_id, audio_file_id, position)
|
||||||
|
VALUES (1, 7, 0), (1, 8, 1);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("begin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if err := PreservePlaylistPhantomsForFiles(
|
||||||
|
ctx, tx, []int64{7}, testLogger(),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("preserve: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
t.Fatalf("commit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var filled, untouched sql.NullString
|
||||||
|
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT phantom_file_path FROM playlist_tracks WHERE audio_file_id = 7",
|
||||||
|
).Scan(&filled); err != nil {
|
||||||
|
t.Fatalf("read the requested entry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filled.String != "/music/a.flac" {
|
||||||
|
t.Errorf(
|
||||||
|
"requested entry phantom_file_path = %q, want %q",
|
||||||
|
filled.String, "/music/a.flac",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT phantom_file_path FROM playlist_tracks WHERE audio_file_id = 8",
|
||||||
|
).Scan(&untouched); err != nil {
|
||||||
|
t.Fatalf("read the untouched entry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if untouched.Valid {
|
||||||
|
t.Errorf(
|
||||||
|
"untouched entry got phantom_file_path = %q, want NULL "+
|
||||||
|
"(a scoped run must not rewrite the whole table)",
|
||||||
|
untouched.String,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,8 +68,14 @@ CREATE TABLE IF NOT EXISTS audio_files (
|
|||||||
-- compared against the on-disk mtime during a scan to detect files
|
-- compared against the on-disk mtime during a scan to detect files
|
||||||
-- another application retagged in place.
|
-- another application retagged in place.
|
||||||
modified_at INTEGER NOT NULL DEFAULT 0,
|
modified_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
-- Listening counts, denormalized from listening_events so the hot
|
||||||
|
-- read path (track list sort, shelves, smart playlists) never joins
|
||||||
|
-- a log table. Authored: a rescan cannot rebuild them. This is the
|
||||||
|
-- "MIXED KIND" half of audio_files the datamap notes.
|
||||||
play_count INTEGER NOT NULL DEFAULT 0,
|
play_count INTEGER NOT NULL DEFAULT 0,
|
||||||
last_played DATETIME,
|
last_played DATETIME,
|
||||||
|
skip_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_skipped DATETIME,
|
||||||
tag_status TEXT NOT NULL DEFAULT 'untagged'
|
tag_status TEXT NOT NULL DEFAULT 'untagged'
|
||||||
CHECK(tag_status IN (
|
CHECK(tag_status IN (
|
||||||
'untagged', 'auto_matched', 'user_confirmed', 'user_skipped_permanent'
|
'untagged', 'auto_matched', 'user_confirmed', 'user_skipped_permanent'
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ CREATE INDEX IF NOT EXISTS idx_download_items_live
|
|||||||
CREATE INDEX IF NOT EXISTS idx_download_items_state
|
CREATE INDEX IF NOT EXISTS idx_download_items_state
|
||||||
ON download_items(state);
|
ON download_items(state);
|
||||||
|
|
||||||
-- idx_download_items_download is deliberately NOT declared here: on an
|
-- ListDownloadItemsForDownload filters on the parent download.
|
||||||
-- existing database this table already exists at schema-pass time with
|
CREATE INDEX IF NOT EXISTS idx_download_items_download
|
||||||
-- its old column still named request_id, so an inline CREATE INDEX on
|
ON download_items(download_id);
|
||||||
-- download_id would fail outright. See ensureDownloadIndexes in
|
|
||||||
-- backend/database/download_rename_migration.go.
|
|
||||||
|
|||||||
@@ -66,14 +66,11 @@ CREATE TABLE IF NOT EXISTS download_requests (
|
|||||||
FOREIGN KEY(parent_id) REFERENCES download_requests(id) ON DELETE CASCADE
|
FOREIGN KEY(parent_id) REFERENCES download_requests(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
-- idx_download_requests_{due,entity,parent} are deliberately NOT
|
CREATE INDEX IF NOT EXISTS idx_download_requests_due
|
||||||
-- declared here. This table name is reused from the old one-shot
|
ON download_requests(state, next_try_at);
|
||||||
-- attempt table (also called download_requests before the Want/Request
|
|
||||||
-- rename), so on an existing database this CREATE TABLE is a no-op
|
CREATE INDEX IF NOT EXISTS idx_download_requests_entity
|
||||||
-- against a table that, at schema-pass time, is still shaped like the
|
ON download_requests(entity, state);
|
||||||
-- OLD attempts table and lacks these columns entirely — an inline
|
|
||||||
-- CREATE INDEX here would fail outright rather than just no-op. See
|
CREATE INDEX IF NOT EXISTS idx_download_requests_parent
|
||||||
-- migrateDownloadRename/ensureDownloadIndexes in
|
ON download_requests(parent_id) WHERE parent_id IS NOT NULL;
|
||||||
-- backend/database/download_rename_migration.go, which create these
|
|
||||||
-- once the rename has actually happened (or immediately, on a fresh
|
|
||||||
-- database where the columns exist from the start).
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
-- One row per track *exit*, three ways a listen can end: it reached
|
||||||
|
-- the end, it was heard enough to count and then skipped past, or it
|
||||||
|
-- was abandoned for another track before anyone had really listened.
|
||||||
|
--
|
||||||
|
-- This is the source of truth for listening behaviour. The
|
||||||
|
-- denormalized `play_count` / `last_played` / `skip_count` /
|
||||||
|
-- `last_skipped` on audio_files are materialized from it, because the
|
||||||
|
-- hot read path (track-list sort, the shelves, smart playlists) must
|
||||||
|
-- not join a log that grows by one row per song forever.
|
||||||
|
--
|
||||||
|
-- `kind` is the classification, applied at write time:
|
||||||
|
--
|
||||||
|
-- complete the track reached its natural end, or was skipped in
|
||||||
|
-- its tail window (the last few seconds of a long fade).
|
||||||
|
-- play the scrobble threshold was heard — half the track or
|
||||||
|
-- four minutes, whichever is less — and the user moved on
|
||||||
|
-- before the end.
|
||||||
|
-- skip the user moved to a different track before that.
|
||||||
|
--
|
||||||
|
-- `position_seconds` / `duration_seconds` are the raw reading the
|
||||||
|
-- classification was made from, kept so a future re-tune of the
|
||||||
|
-- threshold does not need the events re-recorded. 0/0 on a row means
|
||||||
|
-- "not captured for this event" (e.g. a natural finish recorded before
|
||||||
|
-- these columns existed), not "a zero-second track".
|
||||||
|
CREATE TABLE IF NOT EXISTS listening_events (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
audio_file_id INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'complete'
|
||||||
|
CHECK (kind IN ('complete', 'play', 'skip')),
|
||||||
|
position_seconds INTEGER NOT NULL DEFAULT 0,
|
||||||
|
duration_seconds INTEGER NOT NULL DEFAULT 0,
|
||||||
|
occurred_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_listening_events_audio_file_id
|
||||||
|
ON listening_events(audio_file_id);
|
||||||
|
|
||||||
|
-- "What did I listen to this month" walks this, rather than the
|
||||||
|
-- per-track index above.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_listening_events_occurred_at
|
||||||
|
ON listening_events(occurred_at);
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS play_history (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
audio_file_id INTEGER NOT NULL,
|
|
||||||
played_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_play_history_audio_file_id
|
|
||||||
ON play_history(audio_file_id);
|
|
||||||
@@ -1,18 +1,15 @@
|
|||||||
CREATE TABLE IF NOT EXISTS queue (
|
CREATE TABLE IF NOT EXISTS queue (
|
||||||
id INTEGER PRIMARY KEY CHECK(id = 1),
|
id INTEGER PRIMARY KEY CHECK(id = 1),
|
||||||
source_playlist_id INTEGER,
|
|
||||||
current_position INTEGER NOT NULL DEFAULT 0,
|
current_position INTEGER NOT NULL DEFAULT 0,
|
||||||
shuffle_mode BOOLEAN NOT NULL DEFAULT false,
|
shuffle_mode BOOLEAN NOT NULL DEFAULT false,
|
||||||
repeat_mode TEXT NOT NULL DEFAULT 'off',
|
repeat_mode TEXT NOT NULL DEFAULT 'off',
|
||||||
shuffle_order TEXT,
|
shuffle_order TEXT,
|
||||||
-- source_playlist_id above is unused dead weight (nothing has ever
|
-- What the queue was built from ("Playing from: X"): an album,
|
||||||
-- written it a nonzero value); source_type/source_id/source_label
|
-- playlist, smart playlist, genre or artist, identified by the id
|
||||||
-- below are its generalized replacement, covering albums, playlists,
|
-- that source_type's namespace gives it.
|
||||||
-- smart playlists, genres and artists rather than playlists alone.
|
|
||||||
source_type TEXT NOT NULL DEFAULT '',
|
source_type TEXT NOT NULL DEFAULT '',
|
||||||
source_id INTEGER NOT NULL DEFAULT 0,
|
source_id INTEGER NOT NULL DEFAULT 0,
|
||||||
source_label TEXT NOT NULL DEFAULT '',
|
source_label TEXT NOT NULL DEFAULT ''
|
||||||
FOREIGN KEY(source_playlist_id) REFERENCES playlists(id) ON DELETE SET NULL
|
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Singleton row: there is exactly one playback queue.
|
-- Singleton row: there is exactly one playback queue.
|
||||||
|
|||||||
@@ -26,17 +26,10 @@ CREATE TABLE IF NOT EXISTS tagging_items (
|
|||||||
-- complete rip of their own directory. parent_group_key is the
|
-- complete rip of their own directory. parent_group_key is the
|
||||||
-- original folder group they were split from.
|
-- original folder group they were split from.
|
||||||
--
|
--
|
||||||
-- These two columns are declared LAST, after created_at, even
|
-- These columns are appended after created_at rather than grouped
|
||||||
-- though that reads oddly next to the rest of the table: sql/
|
-- with the rest of the row: sqlc's `SELECT *` scans (GetTaggingItem)
|
||||||
-- migrations/0001 brings a pre-existing tagging_items up to date
|
-- bind column order positionally, so new columns always go at the
|
||||||
-- with `ALTER TABLE ADD COLUMN`, which SQLite always appends at
|
-- end.
|
||||||
-- the end of the column list. A fresh install (this file) and an
|
|
||||||
-- upgraded database (this file + the migration) must end up with
|
|
||||||
-- IDENTICAL column order, because sqlc-generated `SELECT *` scans
|
|
||||||
-- (e.g. GetTaggingItem) bind columns positionally — see the
|
|
||||||
-- schema/migration column-order test in database_test.go. Put
|
|
||||||
-- new columns wherever reads best when adding a table for the
|
|
||||||
-- first time; append-only from the second migration on.
|
|
||||||
synthetic INTEGER NOT NULL DEFAULT 0,
|
synthetic INTEGER NOT NULL DEFAULT 0,
|
||||||
parent_group_key TEXT NOT NULL DEFAULT '',
|
parent_group_key TEXT NOT NULL DEFAULT '',
|
||||||
-- album_artist_conflict latches to 1 the first time two tracks
|
-- album_artist_conflict latches to 1 the first time two tracks
|
||||||
@@ -58,10 +51,3 @@ CREATE INDEX IF NOT EXISTS idx_tagging_items_library_status
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tagging_items_status_pending
|
CREATE INDEX IF NOT EXISTS idx_tagging_items_status_pending
|
||||||
ON tagging_items(library_id) WHERE status = 'pending';
|
ON tagging_items(library_id) WHERE status = 'pending';
|
||||||
|
|
||||||
-- idx_tagging_items_parent_group_key is NOT declared here on
|
|
||||||
-- purpose: this file runs unconditionally, before migrations, even
|
|
||||||
-- against a database that hasn't run 0001 yet — an index predicate
|
|
||||||
-- referencing parent_group_key would fail on that table. It lives
|
|
||||||
-- solely in sql/migrations/0001_tagging_items_synthetic.sql, which
|
|
||||||
-- runs after the column exists either way (see database.go).
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ INSERT INTO audio_files (
|
|||||||
?, ?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?, ?,
|
||||||
?, ?, ?, ?, ?
|
?, ?, ?, ?, ?
|
||||||
)
|
)
|
||||||
RETURNING id, file_path, library_id, file_type_id, length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, title, artist_credit, artist_id, album_id, track_number, disc_number, total_tracks, year, composer, comment, recording_mbid, basename, group_key, modified_at, play_count, last_played, tag_status
|
RETURNING id, file_path, library_id, file_type_id, length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, title, artist_credit, artist_id, album_id, track_number, disc_number, total_tracks, year, composer, comment, recording_mbid, basename, group_key, modified_at, play_count, last_played, skip_count, last_skipped, tag_status
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateAudioFileParams struct {
|
type CreateAudioFileParams struct {
|
||||||
@@ -135,6 +135,8 @@ func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams
|
|||||||
&i.ModifiedAt,
|
&i.ModifiedAt,
|
||||||
&i.PlayCount,
|
&i.PlayCount,
|
||||||
&i.LastPlayed,
|
&i.LastPlayed,
|
||||||
|
&i.SkipCount,
|
||||||
|
&i.LastSkipped,
|
||||||
&i.TagStatus,
|
&i.TagStatus,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
@@ -192,7 +194,7 @@ func (q *Queries) GetAllAudioFilePaths(ctx context.Context) ([]GetAllAudioFilePa
|
|||||||
|
|
||||||
const getAudioFile = `-- name: GetAudioFile :one
|
const getAudioFile = `-- name: GetAudioFile :one
|
||||||
|
|
||||||
SELECT id, file_path, library_id, file_type_id, length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, title, artist_credit, artist_id, album_id, track_number, disc_number, total_tracks, year, composer, comment, recording_mbid, basename, group_key, modified_at, play_count, last_played, tag_status FROM audio_files WHERE id = ? LIMIT 1
|
SELECT id, file_path, library_id, file_type_id, length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, title, artist_credit, artist_id, album_id, track_number, disc_number, total_tracks, year, composer, comment, recording_mbid, basename, group_key, modified_at, play_count, last_played, skip_count, last_skipped, tag_status FROM audio_files WHERE id = ? LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -228,13 +230,15 @@ func (q *Queries) GetAudioFile(ctx context.Context, id int64) (AudioFile, error)
|
|||||||
&i.ModifiedAt,
|
&i.ModifiedAt,
|
||||||
&i.PlayCount,
|
&i.PlayCount,
|
||||||
&i.LastPlayed,
|
&i.LastPlayed,
|
||||||
|
&i.SkipCount,
|
||||||
|
&i.LastSkipped,
|
||||||
&i.TagStatus,
|
&i.TagStatus,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAudioFileByPath = `-- name: GetAudioFileByPath :one
|
const getAudioFileByPath = `-- name: GetAudioFileByPath :one
|
||||||
SELECT id, file_path, library_id, file_type_id, length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, title, artist_credit, artist_id, album_id, track_number, disc_number, total_tracks, year, composer, comment, recording_mbid, basename, group_key, modified_at, play_count, last_played, tag_status FROM audio_files WHERE file_path = ? LIMIT 1
|
SELECT id, file_path, library_id, file_type_id, length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, title, artist_credit, artist_id, album_id, track_number, disc_number, total_tracks, year, composer, comment, recording_mbid, basename, group_key, modified_at, play_count, last_played, skip_count, last_skipped, tag_status FROM audio_files WHERE file_path = ? LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetAudioFileByPath(ctx context.Context, filePath string) (AudioFile, error) {
|
func (q *Queries) GetAudioFileByPath(ctx context.Context, filePath string) (AudioFile, error) {
|
||||||
@@ -267,6 +271,8 @@ func (q *Queries) GetAudioFileByPath(ctx context.Context, filePath string) (Audi
|
|||||||
&i.ModifiedAt,
|
&i.ModifiedAt,
|
||||||
&i.PlayCount,
|
&i.PlayCount,
|
||||||
&i.LastPlayed,
|
&i.LastPlayed,
|
||||||
|
&i.SkipCount,
|
||||||
|
&i.LastSkipped,
|
||||||
&i.TagStatus,
|
&i.TagStatus,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
@@ -334,7 +340,7 @@ func (q *Queries) GetAudioFilesByPaths(ctx context.Context, paths []string) ([]G
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getAudioFilesInLibrary = `-- name: GetAudioFilesInLibrary :many
|
const getAudioFilesInLibrary = `-- name: GetAudioFilesInLibrary :many
|
||||||
SELECT id, file_path, library_id, file_type_id, length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, title, artist_credit, artist_id, album_id, track_number, disc_number, total_tracks, year, composer, comment, recording_mbid, basename, group_key, modified_at, play_count, last_played, tag_status FROM audio_files WHERE library_id = ?
|
SELECT id, file_path, library_id, file_type_id, length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, title, artist_credit, artist_id, album_id, track_number, disc_number, total_tracks, year, composer, comment, recording_mbid, basename, group_key, modified_at, play_count, last_played, skip_count, last_skipped, tag_status FROM audio_files WHERE library_id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetAudioFilesInLibrary(ctx context.Context, libraryID int64) ([]AudioFile, error) {
|
func (q *Queries) GetAudioFilesInLibrary(ctx context.Context, libraryID int64) ([]AudioFile, error) {
|
||||||
@@ -373,6 +379,8 @@ func (q *Queries) GetAudioFilesInLibrary(ctx context.Context, libraryID int64) (
|
|||||||
&i.ModifiedAt,
|
&i.ModifiedAt,
|
||||||
&i.PlayCount,
|
&i.PlayCount,
|
||||||
&i.LastPlayed,
|
&i.LastPlayed,
|
||||||
|
&i.SkipCount,
|
||||||
|
&i.LastSkipped,
|
||||||
&i.TagStatus,
|
&i.TagStatus,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ type AudioFile struct {
|
|||||||
ModifiedAt int64
|
ModifiedAt int64
|
||||||
PlayCount int64
|
PlayCount int64
|
||||||
LastPlayed sql.NullTime
|
LastPlayed sql.NullTime
|
||||||
|
SkipCount int64
|
||||||
|
LastSkipped sql.NullTime
|
||||||
TagStatus string
|
TagStatus string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +263,15 @@ type Library struct {
|
|||||||
AutotagWarningAcked int64
|
AutotagWarningAcked int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ListeningEvent struct {
|
||||||
|
ID int64
|
||||||
|
AudioFileID int64
|
||||||
|
Kind string
|
||||||
|
PositionSeconds int64
|
||||||
|
DurationSeconds int64
|
||||||
|
OccurredAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type Lyric struct {
|
type Lyric struct {
|
||||||
AudioFileID int64
|
AudioFileID int64
|
||||||
Text string
|
Text string
|
||||||
@@ -273,12 +284,6 @@ type LyricsIndex struct {
|
|||||||
Lyrics string
|
Lyrics string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlayHistory struct {
|
|
||||||
ID int64
|
|
||||||
AudioFileID int64
|
|
||||||
PlayedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type PlayerState struct {
|
type PlayerState struct {
|
||||||
ID int64
|
ID int64
|
||||||
Volume int64
|
Volume int64
|
||||||
@@ -312,15 +317,14 @@ type PlaylistTrack struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Queue struct {
|
type Queue struct {
|
||||||
ID int64
|
ID int64
|
||||||
SourcePlaylistID sql.NullInt64
|
CurrentPosition int64
|
||||||
CurrentPosition int64
|
ShuffleMode bool
|
||||||
ShuffleMode bool
|
RepeatMode string
|
||||||
RepeatMode string
|
ShuffleOrder sql.NullString
|
||||||
ShuffleOrder sql.NullString
|
SourceType string
|
||||||
SourceType string
|
SourceID int64
|
||||||
SourceID int64
|
SourceLabel string
|
||||||
SourceLabel string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type QueueTrack struct {
|
type QueueTrack struct {
|
||||||
|
|||||||
@@ -245,6 +245,13 @@ func dropDeferred(
|
|||||||
ctx context.Context, db *sql.DB, logger *slog.Logger,
|
ctx context.Context, db *sql.DB, logger *slog.Logger,
|
||||||
drop map[string]string,
|
drop map[string]string,
|
||||||
) error {
|
) error {
|
||||||
|
// Asked before the transaction opens, because the answer is about
|
||||||
|
// which tables are live and that cannot change underneath us here.
|
||||||
|
preserve, err := shouldPreservePhantoms(ctx, db, drop)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
tx, err := db.BeginTx(ctx, nil)
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not begin the retire transaction: %w", err)
|
return fmt.Errorf("could not begin the retire transaction: %w", err)
|
||||||
@@ -256,6 +263,22 @@ func dropDeferred(
|
|||||||
return fmt.Errorf("could not defer foreign keys: %w", err)
|
return fmt.Errorf("could not defer foreign keys: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Before any drop, so every entry still has a track to read. It is
|
||||||
|
// in this transaction rather than beside it because the preservation
|
||||||
|
// and the delete have to succeed or fail together: a commit that
|
||||||
|
// dropped the files without the phantoms is the bug, and a commit
|
||||||
|
// that wrote phantoms without dropping anything is a lie about rows
|
||||||
|
// that are still there.
|
||||||
|
if preserve {
|
||||||
|
logger.Info(
|
||||||
|
"preserving playlist entries across the retire of audio_files",
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := PreservePlaylistPhantoms(ctx, tx, logger); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sorted, so a failure is reproducible. Map order is random, and a
|
// Sorted, so a failure is reproducible. Map order is random, and a
|
||||||
// bug that depends on which table happens to go first reproduces on
|
// bug that depends on which table happens to go first reproduces on
|
||||||
// one run in three and passes review on the other two -- which is
|
// one run in three and passes review on the other two -- which is
|
||||||
@@ -283,6 +306,38 @@ func dropDeferred(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shouldPreservePhantoms reports whether this retire is about to take
|
||||||
|
// `audio_files` out from under the playlists.
|
||||||
|
//
|
||||||
|
// The `playlist_tracks` check is not defensive padding. This runs
|
||||||
|
// *before* applySchema, which is the moment the schema is by definition
|
||||||
|
// mid-repair, and the preservation reads a table it does not drop. A
|
||||||
|
// database old enough not to have it would otherwise fail here, and
|
||||||
|
// failing here means the app does not open at all -- while nothing is
|
||||||
|
// lost by skipping, since an absent `playlist_tracks` holds no
|
||||||
|
// playlists to save.
|
||||||
|
//
|
||||||
|
// It deliberately does *not* ask after `track_metadata`. Whether that
|
||||||
|
// view can answer is PreservePlaylistPhantoms's own business, because a
|
||||||
|
// view broken against an older `audio_files` is a state this function
|
||||||
|
// cannot detect without hitting the same error it is trying to avoid:
|
||||||
|
// pragma_table_info on such a view errors rather than reporting no
|
||||||
|
// columns.
|
||||||
|
func shouldPreservePhantoms(
|
||||||
|
ctx context.Context, db *sql.DB, drop map[string]string,
|
||||||
|
) (bool, error) {
|
||||||
|
if _, going := drop["audio_files"]; !going {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cols, err := liveColumns(ctx, db, "playlist_tracks")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(cols) > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// staleReason reports why a live table disagrees with its declaration,
|
// staleReason reports why a live table disagrees with its declaration,
|
||||||
// or "" when it agrees. A column the live table does not have is the
|
// or "" when it agrees. A column the live table does not have is the
|
||||||
// additive case; a column whose declared type changed is the one an
|
// additive case; a column whose declared type changed is the one an
|
||||||
|
|||||||
@@ -497,3 +497,180 @@ func TestParseCreateTablesReadsTheRealSchema(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRetiringAudioFilesKeepsPlaylistContents is the symptom this
|
||||||
|
// repair exists for: a playlist survived the retire as a row count and
|
||||||
|
// nothing else.
|
||||||
|
//
|
||||||
|
// TestRetiringOwnedTablesDoesNotDangle already asserts the entry does
|
||||||
|
// not keep a stale id, which is the *dangerous* half. It is satisfied
|
||||||
|
// just as well by an entry that says nothing at all, which is the
|
||||||
|
// half that quietly emptied every playlist -- so this asserts what the
|
||||||
|
// entry still knows, and specifically phantom_file_path, because that
|
||||||
|
// is the column ResolvePhantomTracksAfterScan matches back against
|
||||||
|
// audio_files.file_path.
|
||||||
|
//
|
||||||
|
// Note the seed drops `comment`, not `artist_credit`: the mutation has
|
||||||
|
// to leave `track_metadata` standing, since a real launch reaches the
|
||||||
|
// retire with the view the previous launch created. A test that drops
|
||||||
|
// the view first is testing the skip path, not this one.
|
||||||
|
func TestRetiringAudioFilesKeepsPlaylistContents(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
db := openRaw(t, t.TempDir())
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
|
||||||
|
t.Fatalf("pragma: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := applySchema(ctx, db); err != nil {
|
||||||
|
t.Fatalf("applySchema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO playlists (id, name) VALUES (1, 'keepme');
|
||||||
|
INSERT INTO libraries (id, name, path) VALUES (0, 'test', '/music');
|
||||||
|
INSERT INTO artists (id, name) VALUES (3, 'Aurora Fields');
|
||||||
|
INSERT INTO cover_art (id, file_path, mime_type)
|
||||||
|
VALUES (9, 'covers/7.jpg', 'image/jpeg');
|
||||||
|
INSERT INTO genres (id, name) VALUES (5, 'Ambient');
|
||||||
|
INSERT INTO albums (id, name, artist_id, cover_art_id)
|
||||||
|
VALUES (4, 'Tideline', 3, 9);
|
||||||
|
INSERT INTO audio_files
|
||||||
|
(id, file_path, file_type_id, length_milliseconds,
|
||||||
|
title, artist_credit, artist_id, album_id)
|
||||||
|
VALUES (7, '/music/a.flac', 1, 1000,
|
||||||
|
'Slack Water', 'Aurora Fields', 3, 4);
|
||||||
|
INSERT INTO file_genres (audio_file_id, genre_id) VALUES (7, 5);
|
||||||
|
INSERT INTO playlist_tracks (playlist_id, audio_file_id, position)
|
||||||
|
VALUES (1, 7, 0);
|
||||||
|
ALTER TABLE audio_files DROP COLUMN comment;
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := retireStaleTables(ctx, db, testLogger()); err != nil {
|
||||||
|
t.Fatalf("retire: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := applySchema(ctx, db); err != nil {
|
||||||
|
t.Fatalf("applySchema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
path, title, artist, album, genre, cover sql.NullString
|
||||||
|
duration sql.NullInt64
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT phantom_file_path, phantom_title, phantom_artist,
|
||||||
|
phantom_album, phantom_duration_ms, phantom_genre,
|
||||||
|
phantom_cover_art_path
|
||||||
|
FROM playlist_tracks WHERE playlist_id = 1
|
||||||
|
`).Scan(&path, &title, &artist, &album, &duration, &genre, &cover); err != nil {
|
||||||
|
t.Fatalf("read the surviving entry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one that matters: without it the entry can never be re-linked
|
||||||
|
// by the rescan the retire itself provokes.
|
||||||
|
if path.String != "/music/a.flac" {
|
||||||
|
t.Fatalf(
|
||||||
|
"phantom_file_path is %q, want %q -- the playlist entry "+
|
||||||
|
"cannot be re-linked and the playlist is empty for good",
|
||||||
|
path.String, "/music/a.flac",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if title.String != "Slack Water" {
|
||||||
|
t.Errorf("phantom_title is %q, want %q", title.String, "Slack Water")
|
||||||
|
}
|
||||||
|
|
||||||
|
if artist.String != "Aurora Fields" {
|
||||||
|
t.Errorf("phantom_artist is %q, want %q", artist.String, "Aurora Fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
if album.String != "Tideline" {
|
||||||
|
t.Errorf("phantom_album is %q, want %q", album.String, "Tideline")
|
||||||
|
}
|
||||||
|
|
||||||
|
if duration.Int64 != 1000 {
|
||||||
|
t.Errorf("phantom_duration_ms is %d, want 1000", duration.Int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
if genre.String != "Ambient" {
|
||||||
|
t.Errorf("phantom_genre is %q, want %q", genre.String, "Ambient")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cover.String != "covers/7.jpg" {
|
||||||
|
t.Errorf("phantom_cover_art_path is %q, want %q", cover.String, "covers/7.jpg")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRetiringAudioFilesKeepsPathsWhenTheViewCannotAnswer is the case
|
||||||
|
// that broke cmd/indexbuild: this repair runs *before* applySchema, so
|
||||||
|
// `track_metadata` is whatever the last launch declared while
|
||||||
|
// `audio_files` is whatever the launch before that left behind, and a
|
||||||
|
// view over columns the table no longer has does not read as empty --
|
||||||
|
// it errors.
|
||||||
|
//
|
||||||
|
// The pre-013 stub shape below is the real one that fixture carries.
|
||||||
|
// What must survive is phantom_file_path, because `file_path` is the
|
||||||
|
// table's natural key and has been in every shape it ever had; the
|
||||||
|
// display columns are allowed to be absent, and the open must not fail.
|
||||||
|
func TestRetiringAudioFilesKeepsPathsWhenTheViewCannotAnswer(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
db := openRaw(t, t.TempDir())
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
|
||||||
|
t.Fatalf("pragma: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := applySchema(ctx, db); err != nil {
|
||||||
|
t.Fatalf("applySchema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rows go in *after* the reshape: dropping audio_files with
|
||||||
|
// foreign keys on would fire the ON DELETE SET NULL and null the
|
||||||
|
// entry this test is about, which would pass for the wrong reason.
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
DROP TABLE audio_files;
|
||||||
|
CREATE TABLE audio_files (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
file_path TEXT NOT NULL UNIQUE,
|
||||||
|
recording_id INTEGER
|
||||||
|
);
|
||||||
|
INSERT INTO playlists (id, name) VALUES (1, 'keepme');
|
||||||
|
INSERT INTO audio_files (id, file_path) VALUES (7, '/music/a.flac');
|
||||||
|
INSERT INTO playlist_tracks (playlist_id, audio_file_id, position)
|
||||||
|
VALUES (1, 7, 0);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The symptom this guards: the repair must not turn a recoverable
|
||||||
|
// database into one the app refuses to open.
|
||||||
|
if err := retireStaleTables(ctx, db, testLogger()); err != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"the retire failed on a view it could not read, so the app "+
|
||||||
|
"would not open at all: %v", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := applySchema(ctx, db); err != nil {
|
||||||
|
t.Fatalf("applySchema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var path sql.NullString
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT phantom_file_path FROM playlist_tracks WHERE playlist_id = 1",
|
||||||
|
).Scan(&path); err != nil {
|
||||||
|
t.Fatalf("read the surviving entry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if path.String != "/music/a.flac" {
|
||||||
|
t.Fatalf(
|
||||||
|
"phantom_file_path is %q, want %q -- the display half being "+
|
||||||
|
"unavailable must not cost the entry its one re-link key",
|
||||||
|
path.String, "/music/a.flac",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -269,10 +269,11 @@ var tables = []Table{
|
|||||||
"from owned files plus the LRCLIB backfill.",
|
"from owned files plus the LRCLIB backfill.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "play_history", Kind: Authored, Lifetime: Cascade,
|
Name: "listening_events", Kind: Authored, Lifetime: Cascade,
|
||||||
Note: "Listening history. Authored, but intentionally cascades " +
|
Note: "Listening history, one row per track exit (complete, play " +
|
||||||
"with its track — history for a file no longer in the library " +
|
"or skip). Authored, but intentionally cascades with its " +
|
||||||
"has nothing to point at.",
|
"track — history for a file no longer in the library has " +
|
||||||
|
"nothing to point at.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "player_state", Kind: Authored, Lifetime: Retained,
|
Name: "player_state", Kind: Authored, Lifetime: Retained,
|
||||||
|
|||||||
@@ -212,14 +212,14 @@ func TestLifetimesMatchSchema(t *testing.T) {
|
|||||||
|
|
||||||
// Authored data is unrecoverable, so it must never be removed as a side
|
// Authored data is unrecoverable, so it must never be removed as a side
|
||||||
// effect of deleting owned data. Cascade is allowed only where the
|
// effect of deleting owned data. Cascade is allowed only where the
|
||||||
// catalog explains why (play_history, queue_tracks); this test pins the
|
// catalog explains why (listening_events, queue_tracks); this test pins the
|
||||||
// set so a new cascade onto authored data is a deliberate decision.
|
// set so a new cascade onto authored data is a deliberate decision.
|
||||||
func TestAuthoredCascadesAreDeliberate(t *testing.T) {
|
func TestAuthoredCascadesAreDeliberate(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
allowed := map[string]bool{
|
allowed := map[string]bool{
|
||||||
"play_history": true,
|
"listening_events": true,
|
||||||
"queue_tracks": true,
|
"queue_tracks": true,
|
||||||
|
|
||||||
// Download history is scoped to the library it imported into.
|
// Download history is scoped to the library it imported into.
|
||||||
// When that library is removed the files it acquired go with
|
// When that library is removed the files it acquired go with
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -144,17 +145,36 @@ func (c *apiClient) checkStatus(resp *http.Response) error {
|
|||||||
case resp.StatusCode >= 400:
|
case resp.StatusCode >= 400:
|
||||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf("%w: %w", c.errUnreachable, &httpStatusError{
|
||||||
"%w: HTTP %d: %s",
|
code: resp.StatusCode,
|
||||||
c.errUnreachable,
|
body: strings.TrimSpace(string(snippet)),
|
||||||
resp.StatusCode,
|
})
|
||||||
strings.TrimSpace(string(snippet)),
|
|
||||||
)
|
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// httpStatusError is a non-2xx answer, kept typed so a caller can tell
|
||||||
|
// a missing endpoint from a daemon that is down.
|
||||||
|
type httpStatusError struct {
|
||||||
|
code int
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *httpStatusError) Error() string {
|
||||||
|
return fmt.Sprintf("HTTP %d: %s", e.code, e.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusCode returns the HTTP status an error carries, or 0.
|
||||||
|
func statusCode(err error) int {
|
||||||
|
var se *httpStatusError
|
||||||
|
if errors.As(err, &se) {
|
||||||
|
return se.code
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// decodeJSON decodes a JSON string into out. Providers whose auth or
|
// decodeJSON decodes a JSON string into out. Providers whose auth or
|
||||||
// response handling does not fit apiClient still parse bodies the same
|
// response handling does not fit apiClient still parse bodies the same
|
||||||
// way, so the helper lives here rather than being repeated.
|
// way, so the helper lives here rather than being repeated.
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Multi-disc rips, single-track results and coverage counted in tracks
|
||||||
|
// rather than files (#270).
|
||||||
|
|
||||||
|
func TestParsePathReadsTheDiscFromItsFolder(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
disc int
|
||||||
|
track int
|
||||||
|
folder string
|
||||||
|
}{
|
||||||
|
{`\share\Pink Floyd - The Wall (1979)\CD2\03 Hey You.flac`, 2, 3, "The Wall"},
|
||||||
|
{`\share\The Wall\Disc 1\01 In The Flesh.flac`, 1, 1, "The Wall"},
|
||||||
|
{`\share\The Wall\[Disk-2]\01 Hey You.flac`, 2, 1, "The Wall"},
|
||||||
|
{`\share\The Wall\CD1 - Live\04 Mother.flac`, 1, 4, "The Wall"},
|
||||||
|
// The filename's own disc number is more specific than the folder.
|
||||||
|
{`\share\The Wall\CD1\2-05 Comfortably Numb.flac`, 2, 5, "The Wall"},
|
||||||
|
// Not a disc folder: a number is required.
|
||||||
|
{`\share\CDs\The Wall\01 In The Flesh.flac`, 0, 1, "The Wall"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.path, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := ParsePath(tc.path)
|
||||||
|
if got.Disc != tc.disc || got.Track != tc.track || got.Folder != tc.folder {
|
||||||
|
t.Errorf(
|
||||||
|
"ParsePath = disc %d track %d folder %q, want %d %d %q",
|
||||||
|
got.Disc, got.Track, got.Folder, tc.disc, tc.track, tc.folder,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlbumDir(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := map[string]string{
|
||||||
|
`\share\Album\CD1\01 A.flac`: "/share/Album",
|
||||||
|
`\share\Album\01 A.flac`: "/share/Album",
|
||||||
|
`CD1\01 A.flac`: "CD1",
|
||||||
|
`\share\CD Collection\01.mp3`: "/share/CD Collection",
|
||||||
|
}
|
||||||
|
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := AlbumDir(in); got != want {
|
||||||
|
t.Errorf("AlbumDir(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One album shared as CD1/CD2 is one candidate, named after the album.
|
||||||
|
func TestSlskdGroupsDiscFoldersIntoOneCandidate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
stub.responses = []slskdResponse{{
|
||||||
|
Username: "peer",
|
||||||
|
Files: []slskdFile{
|
||||||
|
{Filename: `\m\The Wall\CD1\01 In The Flesh.flac`, Size: 1},
|
||||||
|
{Filename: `\m\The Wall\CD1\02 The Thin Ice.flac`, Size: 1},
|
||||||
|
{Filename: `\m\The Wall\CD2\01 Hey You.flac`, Size: 1},
|
||||||
|
{Filename: `\m\The Wall\CD2\02 Is There Anybody Out There.flac`, Size: 1},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
s, _ := newStubSlskd(t, stub)
|
||||||
|
|
||||||
|
got, err := s.Search(context.Background(), Download{Query: "the wall"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("got %d candidates, want the two discs as one", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
if got[0].Title != "The Wall" || len(got[0].Files) != 4 {
|
||||||
|
t.Errorf(
|
||||||
|
"candidate = %q with %d files, want \"The Wall\" with 4",
|
||||||
|
got[0].Title, len(got[0].Files),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A track search matches one file per folder, so a single-track request
|
||||||
|
// must accept a one-file folder that an album request rightly drops.
|
||||||
|
func TestSlskdKeepsASingleFileForATrackRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
stub.responses = []slskdResponse{{
|
||||||
|
Username: "peer",
|
||||||
|
Files: []slskdFile{
|
||||||
|
{Filename: `\m\OK Computer\02 Paranoid Android.flac`, Size: 1},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
s, _ := newStubSlskd(t, stub)
|
||||||
|
|
||||||
|
track, err := s.Search(context.Background(), Download{
|
||||||
|
RecordingMBID: "rec-1", Artist: "Radiohead", Album: "Paranoid Android",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(track) != 1 {
|
||||||
|
t.Errorf("track request: got %d candidates, want 1", len(track))
|
||||||
|
}
|
||||||
|
|
||||||
|
album, err := s.Search(context.Background(), Download{
|
||||||
|
ReleaseMBID: "rel-1", Artist: "Radiohead", Album: "OK Computer",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(album) != 0 {
|
||||||
|
t.Errorf("album request: got %d candidates, want the one-file folder dropped", len(album))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two discs with a file of the same name both reach staging, each under
|
||||||
|
// its disc folder, where the importer reads the disc number from.
|
||||||
|
func TestSlskdCollectKeepsDiscFolders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
s, downloads := newStubSlskd(t, stub)
|
||||||
|
|
||||||
|
for _, disc := range []string{"CD1", "CD2"} {
|
||||||
|
dir := filepath.Join(downloads, disc)
|
||||||
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(
|
||||||
|
filepath.Join(dir, "01 Intro.flac"), []byte(disc), 0o600,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := t.TempDir()
|
||||||
|
|
||||||
|
got, err := s.collect(Candidate{Files: []CandidateFile{
|
||||||
|
{Path: `\m\Album\CD1\01 Intro.flac`, IsAudio: true},
|
||||||
|
{Path: `\m\Album\CD2\01 Intro.flac`, IsAudio: true},
|
||||||
|
}}, dst, "", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("collect: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Files) != 2 {
|
||||||
|
t.Fatalf("collected %d files, want 2", len(got.Files))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, disc := range []string{"CD1", "CD2"} {
|
||||||
|
data, err := os.ReadFile(filepath.Join(dst, disc, "01 Intro.flac"))
|
||||||
|
if err != nil || string(data) != disc {
|
||||||
|
t.Errorf("%s's file missing or overwritten: %q, %v", disc, data, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hint := ParsePath(filepath.Join(dst, disc, "01 Intro.flac")); hint.Disc == 0 {
|
||||||
|
t.Errorf("staged %s file lost its disc number", disc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A two-disc release whose discs both number from 01 aligns completely
|
||||||
|
// once the disc comes from the folder; before, disc 2's 01 collided with
|
||||||
|
// disc 1's.
|
||||||
|
func TestMultiDiscCandidateAlignsEveryTrack(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dl := Download{
|
||||||
|
ReleaseMBID: "the-wall",
|
||||||
|
Artist: "Pink Floyd",
|
||||||
|
Album: "The Wall",
|
||||||
|
Expected: []ExpectedTrack{
|
||||||
|
{DiscNumber: 1, Position: 1, Title: "In the Flesh?"},
|
||||||
|
{DiscNumber: 1, Position: 2, Title: "The Thin Ice"},
|
||||||
|
{DiscNumber: 2, Position: 1, Title: "Hey You"},
|
||||||
|
{DiscNumber: 2, Position: 2, Title: "Is There Anybody Out There?"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
c := Candidate{
|
||||||
|
Title: "The Wall",
|
||||||
|
Files: []CandidateFile{
|
||||||
|
{Path: `\m\Pink Floyd - The Wall\CD1\01 In the Flesh.flac`, Size: 1},
|
||||||
|
{Path: `\m\Pink Floyd - The Wall\CD1\02 The Thin Ice.flac`, Size: 1},
|
||||||
|
{Path: `\m\Pink Floyd - The Wall\CD2\01 Hey You.flac`, Size: 1},
|
||||||
|
{Path: `\m\Pink Floyd - The Wall\CD2\02 Is There Anybody Out There.flac`, Size: 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := Score(dl, c, 50, AutoDownloadPrefs{})
|
||||||
|
|
||||||
|
if got.Match.Completeness != 1 {
|
||||||
|
t.Errorf("completeness = %f, want 1", got.Match.Completeness)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Match.AlbumFit < 0.99 {
|
||||||
|
t.Errorf("album fit = %f, want the album's own name to match", got.Match.AlbumFit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Match.Overall < minMatch {
|
||||||
|
t.Errorf("match = %f, want it to clear the auto-pick bar %f", got.Match.Overall, minMatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ten files against a ten-track album is not a complete album when only
|
||||||
|
// three of them are its tracks.
|
||||||
|
func TestCompletenessCountsTracksNotFiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dl := okComputer()
|
||||||
|
|
||||||
|
c := Candidate{Title: "OK Computer", Files: []CandidateFile{
|
||||||
|
{Path: `\m\Radiohead - OK Computer\Airbag.flac`, Size: 1},
|
||||||
|
{Path: `\m\Radiohead - OK Computer\Paranoid Android.flac`, Size: 1},
|
||||||
|
{Path: `\m\Radiohead - OK Computer\Exit Music (For a Film).flac`, Size: 1},
|
||||||
|
{Path: `\m\Radiohead - OK Computer\Creep.flac`, Size: 1},
|
||||||
|
}}
|
||||||
|
|
||||||
|
got := Score(dl, c, 50, AutoDownloadPrefs{})
|
||||||
|
|
||||||
|
if got.Match.Completeness > 0.76 {
|
||||||
|
t.Errorf(
|
||||||
|
"completeness = %f with 3 of 4 tracks present, want at most 0.75",
|
||||||
|
got.Match.Completeness,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ func grabAll(
|
|||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
f.manager.grab(ctx, dl, candidate, nil)
|
f.manager.grab(ctx, dl, candidate, nil, false)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,9 +79,9 @@ func TestConcurrencyForPrefersOverrideThenKind(t *testing.T) {
|
|||||||
want int
|
want int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "slskd defaults to one",
|
name: "slskd defaults to a few peers",
|
||||||
cfg: Config{Kind: KindSlskd},
|
cfg: Config{Kind: KindSlskd},
|
||||||
want: 1,
|
want: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "usenet defaults higher",
|
name: "usenet defaults higher",
|
||||||
@@ -92,9 +92,9 @@ func TestConcurrencyForPrefersOverrideThenKind(t *testing.T) {
|
|||||||
name: "explicit override wins",
|
name: "explicit override wins",
|
||||||
cfg: Config{
|
cfg: Config{
|
||||||
Kind: KindSlskd,
|
Kind: KindSlskd,
|
||||||
Settings: map[string]string{concurrencyKey: "3"},
|
Settings: map[string]string{concurrencyKey: "1"},
|
||||||
},
|
},
|
||||||
want: 3,
|
want: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "nonsense override falls back",
|
name: "nonsense override falls back",
|
||||||
@@ -102,7 +102,7 @@ func TestConcurrencyForPrefersOverrideThenKind(t *testing.T) {
|
|||||||
Kind: KindSlskd,
|
Kind: KindSlskd,
|
||||||
Settings: map[string]string{concurrencyKey: "not a number"},
|
Settings: map[string]string{concurrencyKey: "not a number"},
|
||||||
},
|
},
|
||||||
want: 1,
|
want: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "zero override falls back",
|
name: "zero override falls back",
|
||||||
@@ -110,7 +110,7 @@ func TestConcurrencyForPrefersOverrideThenKind(t *testing.T) {
|
|||||||
Kind: KindSlskd,
|
Kind: KindSlskd,
|
||||||
Settings: map[string]string{concurrencyKey: "0"},
|
Settings: map[string]string{concurrencyKey: "0"},
|
||||||
},
|
},
|
||||||
want: 1,
|
want: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "unknown kind falls back to the global default",
|
name: "unknown kind falls back to the global default",
|
||||||
@@ -126,9 +126,9 @@ func TestConcurrencyForPrefersOverrideThenKind(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The reason the per-provider cap exists: a Soulseek daemon capped at
|
// The reason the per-provider cap exists: a daemon capped at one
|
||||||
// one transfer must serialize, even when the global cap would allow
|
// transfer must serialize, even when the global cap would allow more and
|
||||||
// more and the user has queued several albums at once.
|
// the user has queued several albums at once.
|
||||||
func TestPerProviderCapSerializesTransfers(t *testing.T) {
|
func TestPerProviderCapSerializesTransfers(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -142,6 +142,7 @@ func TestPerProviderCapSerializesTransfers(t *testing.T) {
|
|||||||
ID: 1,
|
ID: 1,
|
||||||
Kind: KindSlskd,
|
Kind: KindSlskd,
|
||||||
Priority: 50,
|
Priority: 50,
|
||||||
|
Settings: map[string]string{concurrencyKey: "1"},
|
||||||
}, slow)
|
}, slow)
|
||||||
|
|
||||||
// Three requests against the same one-at-a-time provider.
|
// Three requests against the same one-at-a-time provider.
|
||||||
@@ -210,8 +211,8 @@ func TestSyncSemaphoresReplacesChangedLimits(t *testing.T) {
|
|||||||
f.manager.installProvider(Config{ID: 1, Kind: KindSlskd}, nil)
|
f.manager.installProvider(Config{ID: 1, Kind: KindSlskd}, nil)
|
||||||
|
|
||||||
first := f.manager.semaphoreFor(1)
|
first := f.manager.semaphoreFor(1)
|
||||||
if cap(first) != 1 {
|
if want := kindConcurrency[KindSlskd]; cap(first) != want {
|
||||||
t.Fatalf("slskd semaphore cap = %d, want 1", cap(first))
|
t.Fatalf("slskd semaphore cap = %d, want %d", cap(first), want)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same limit: the semaphore is kept, so in-flight accounting is not
|
// Same limit: the semaphore is kept, so in-flight accounting is not
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A transfer that fails on one copy of an album is not a failed
|
||||||
|
// download while another acceptable copy exists. On Soulseek the usual
|
||||||
|
// failure is one peer being offline, with several others offering the
|
||||||
|
// same folder.
|
||||||
|
|
||||||
|
var errPeerOffline = errors.New("peer went offline")
|
||||||
|
|
||||||
|
func TestManagerFallsBackToTheNextCandidate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newManagerFixture(t)
|
||||||
|
|
||||||
|
// The failing source ranks first on priority, so the fallback is
|
||||||
|
// what reaches the one that works.
|
||||||
|
bad := fakeWithAlbum(1, "offline-peer", ".flac")
|
||||||
|
bad.GrabErr = errPeerOffline
|
||||||
|
good := fakeWithAlbum(2, "online-peer", ".flac")
|
||||||
|
|
||||||
|
f.manager.installProvider(Config{ID: 1, Priority: 90}, bad)
|
||||||
|
f.manager.installProvider(Config{ID: 2, Priority: 10}, good)
|
||||||
|
|
||||||
|
dl := fourTrackDownload()
|
||||||
|
|
||||||
|
if _, err := f.manager.Start(context.Background(), dl); err != nil {
|
||||||
|
t.Fatalf("Start: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForDownloadState(t, f.store, dl.ID, StateComplete)
|
||||||
|
|
||||||
|
if bad.GrabCalls != 1 || good.GrabCalls != 1 {
|
||||||
|
t.Errorf(
|
||||||
|
"grabs: failing=%d working=%d, want 1 and 1",
|
||||||
|
bad.GrabCalls, good.GrabCalls,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The abandoned attempt's staging goes with it; only a request that
|
||||||
|
// fails outright keeps its staging for inspection.
|
||||||
|
waitFor(t, func() bool {
|
||||||
|
entries, err := os.ReadDir(f.staging.Root())
|
||||||
|
|
||||||
|
return err == nil && len(entries) == 0
|
||||||
|
}, "the failed attempt's staging was never released")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Falling back must not lower the bar. A second choice outside the
|
||||||
|
// user's guardrails is not a choice auto-pick may make, first or second.
|
||||||
|
func TestManagerFallbackRespectsTheGuardrails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newManagerFixture(t)
|
||||||
|
f.manager.SetPreferences(AutoDownloadPrefs{MaxSizeMB: 50})
|
||||||
|
|
||||||
|
bad := fakeWithAlbum(1, "offline-peer", ".flac")
|
||||||
|
bad.GrabErr = errPeerOffline
|
||||||
|
bad.Candidates[0].TotalSize = 40 << 20
|
||||||
|
|
||||||
|
huge := fakeWithAlbum(2, "oversized", ".flac")
|
||||||
|
huge.Candidates[0].TotalSize = 900 << 20
|
||||||
|
|
||||||
|
f.manager.installProvider(Config{ID: 1, Priority: 90}, bad)
|
||||||
|
f.manager.installProvider(Config{ID: 2, Priority: 10}, huge)
|
||||||
|
|
||||||
|
dl := fourTrackDownload()
|
||||||
|
|
||||||
|
if _, err := f.manager.Start(context.Background(), dl); err != nil {
|
||||||
|
t.Fatalf("Start: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForDownloadState(t, f.store, dl.ID, StateFailed)
|
||||||
|
|
||||||
|
if huge.GrabCalls != 0 {
|
||||||
|
t.Errorf("fell back to a candidate over the size ceiling")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A copy the user picked by hand is the copy they asked for. Quietly
|
||||||
|
// substituting another is a decision they did not make.
|
||||||
|
func TestManagerPickDoesNotFallBack(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newManagerFixture(t)
|
||||||
|
|
||||||
|
bad := fakeWithAlbum(1, "offline-peer", ".flac")
|
||||||
|
bad.GrabErr = errPeerOffline
|
||||||
|
good := fakeWithAlbum(2, "online-peer", ".flac")
|
||||||
|
|
||||||
|
f.manager.installProvider(Config{ID: 1, Priority: 90}, bad)
|
||||||
|
f.manager.installProvider(Config{ID: 2, Priority: 10}, good)
|
||||||
|
|
||||||
|
// A ceiling below both copies parks the result set for the user.
|
||||||
|
f.manager.SetPreferences(AutoDownloadPrefs{MaxSizeMB: 1})
|
||||||
|
|
||||||
|
bad.Candidates[0].TotalSize = 30 << 20
|
||||||
|
good.Candidates[0].TotalSize = 30 << 20
|
||||||
|
|
||||||
|
dl := fourTrackDownload()
|
||||||
|
|
||||||
|
if _, err := f.manager.Start(context.Background(), dl); err != nil {
|
||||||
|
t.Fatalf("Start: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.manager.Pick(
|
||||||
|
context.Background(), dl.ID, "offline-peer-cand",
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("Pick: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForDownloadState(t, f.store, dl.ID, StateFailed)
|
||||||
|
|
||||||
|
if good.GrabCalls != 0 {
|
||||||
|
t.Errorf("a hand-picked grab fell back to another candidate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback is for surviving an offline peer or two, not for walking a
|
||||||
|
// forty-peer list for six hours.
|
||||||
|
func TestManagerFallbackIsBounded(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newManagerFixture(t)
|
||||||
|
|
||||||
|
var providers []*FakeProvider
|
||||||
|
|
||||||
|
for i := int64(1); i <= maxGrabAttempts+2; i++ {
|
||||||
|
p := fakeWithAlbum(i, "peer-"+itoa(int(i)), ".flac")
|
||||||
|
p.GrabErr = errPeerOffline
|
||||||
|
|
||||||
|
f.manager.installProvider(Config{ID: i, Priority: 50}, p)
|
||||||
|
providers = append(providers, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
dl := fourTrackDownload()
|
||||||
|
|
||||||
|
if _, err := f.manager.Start(context.Background(), dl); err != nil {
|
||||||
|
t.Fatalf("Start: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForDownloadState(t, f.store, dl.ID, StateFailed)
|
||||||
|
|
||||||
|
grabs := 0
|
||||||
|
for _, p := range providers {
|
||||||
|
grabs += p.GrabCalls
|
||||||
|
}
|
||||||
|
|
||||||
|
if grabs != maxGrabAttempts {
|
||||||
|
t.Errorf("grabs = %d, want %d", grabs, maxGrabAttempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The veto judges the best candidate *inside* the guardrails, so the
|
||||||
|
// grab has to take that one — not the overall best, which may be the
|
||||||
|
// very copy the user said not to take unattended.
|
||||||
|
func TestManagerAutoPickTakesTheBestEligibleCandidate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newManagerFixture(t)
|
||||||
|
f.manager.SetPreferences(AutoDownloadPrefs{MaxSizeMB: 50})
|
||||||
|
|
||||||
|
huge := fakeWithAlbum(1, "oversized", ".flac")
|
||||||
|
huge.Candidates[0].TotalSize = 900 << 20
|
||||||
|
|
||||||
|
fits := fakeWithAlbum(2, "fits", ".flac")
|
||||||
|
fits.Candidates[0].TotalSize = 40 << 20
|
||||||
|
|
||||||
|
f.manager.installProvider(Config{ID: 1, Priority: 90}, huge)
|
||||||
|
f.manager.installProvider(Config{ID: 2, Priority: 10}, fits)
|
||||||
|
|
||||||
|
dl := fourTrackDownload()
|
||||||
|
|
||||||
|
ranked, err := f.manager.Start(context.Background(), dl)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Start: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ranked[0].ID != "oversized-cand" {
|
||||||
|
t.Fatalf("fixture: best overall is %s, want the oversized copy", ranked[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForDownloadState(t, f.store, dl.ID, StateComplete)
|
||||||
|
|
||||||
|
if huge.GrabCalls != 0 || fits.GrabCalls != 1 {
|
||||||
|
t.Errorf(
|
||||||
|
"grabs: oversized=%d fits=%d, want 0 and 1",
|
||||||
|
huge.GrabCalls, fits.GrabCalls,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On Soulseek a failure is the peer's, so every folder that peer offered
|
||||||
|
// goes with it. Elsewhere a failure is the release's, and one indexer's
|
||||||
|
// other releases are still worth trying.
|
||||||
|
func TestRuledOutBy(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
failed := []Candidate{
|
||||||
|
{ID: "slskd:alice:Album", Kind: KindSlskd, ProviderID: 1, Origin: "alice"},
|
||||||
|
{ID: "tracker-1", Kind: KindProwlarr, ProviderID: 2, Origin: "indexer"},
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
c Candidate
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "the same candidate",
|
||||||
|
c: Candidate{ID: "tracker-1", Kind: KindProwlarr, ProviderID: 2, Origin: "indexer"},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "another folder from a failed peer",
|
||||||
|
c: Candidate{
|
||||||
|
ID: "slskd:alice:Album (2)",
|
||||||
|
Kind: KindSlskd,
|
||||||
|
ProviderID: 1,
|
||||||
|
Origin: "alice",
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "another peer",
|
||||||
|
c: Candidate{ID: "slskd:bob:Album", Kind: KindSlskd, ProviderID: 1, Origin: "bob"},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "another release from the same indexer",
|
||||||
|
c: Candidate{ID: "tracker-2", Kind: KindProwlarr, ProviderID: 2, Origin: "indexer"},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a peer of the same name on a different daemon",
|
||||||
|
c: Candidate{
|
||||||
|
ID: "slskd:alice:Album",
|
||||||
|
Kind: KindSlskd,
|
||||||
|
ProviderID: 3,
|
||||||
|
Origin: "alice",
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := ruledOutBy(tc.c, failed); got != tc.want {
|
||||||
|
t.Errorf("ruledOutBy = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"yellowjacket/backend/jobs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filling in an almost-complete album (#276).
|
||||||
|
//
|
||||||
|
// A grab that delivers nine of twelve tracks clears the completeness
|
||||||
|
// floor and is imported, and before this the other three were never
|
||||||
|
// looked for. On Soulseek that is the commonest way an album ends up
|
||||||
|
// almost right: one peer's folder is missing a track, or one file
|
||||||
|
// failed. So after an import, each missing track is searched for on
|
||||||
|
// its own and fetched from somewhere else, into the same album.
|
||||||
|
|
||||||
|
// maxFillInTracks bounds how many tracks are fetched one by one. An
|
||||||
|
// album missing more than a few is a different candidate's job, not a
|
||||||
|
// dozen single-track grabs.
|
||||||
|
const maxFillInTracks = 3
|
||||||
|
|
||||||
|
// fillIn fetches the tracks a successful import did not deliver. It
|
||||||
|
// never fails the download: the album is already imported, and a track
|
||||||
|
// it cannot find is logged and left.
|
||||||
|
func (m *Manager) fillIn(
|
||||||
|
ctx context.Context,
|
||||||
|
dl Download,
|
||||||
|
main Candidate,
|
||||||
|
imported ImportResult,
|
||||||
|
job *jobs.Handle,
|
||||||
|
) {
|
||||||
|
missing := missingTracks(dl, imported.Matched)
|
||||||
|
if len(missing) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range missing {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
paths, err := m.fillInTrack(ctx, dl, main, t, job)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Info(
|
||||||
|
"could not fill in a missing track",
|
||||||
|
"download", dl.ID,
|
||||||
|
"track", t.Title,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
if job != nil {
|
||||||
|
job.Logf(jobs.LevelWarn, fmt.Sprintf(
|
||||||
|
"Could not find %q elsewhere: %v", t.Title, err,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if job != nil {
|
||||||
|
job.Logf(jobs.LevelInfo, fmt.Sprintf(
|
||||||
|
"Filled in %q from another source (%d file)", t.Title, len(paths),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// missingTracks is what an import left out, when filling it in is
|
||||||
|
// worth trying: an album with a tracklist, a few tracks short.
|
||||||
|
func missingTracks(dl Download, matched []ExpectedTrack) []ExpectedTrack {
|
||||||
|
// A recording request is one track; there is no album to complete.
|
||||||
|
if dl.RecordingMBID != "" || len(dl.Expected) < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
have := make(map[trackKey]bool, len(matched))
|
||||||
|
for _, t := range matched {
|
||||||
|
have[keyOf(t)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var missing []ExpectedTrack
|
||||||
|
|
||||||
|
for _, t := range dl.Expected {
|
||||||
|
if !have[keyOf(t)] {
|
||||||
|
missing = append(missing, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A half-empty album was a poor copy, not a nearly complete one.
|
||||||
|
if len(missing) > maxFillInTracks || 2*len(missing) >= len(dl.Expected) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillInTrack searches for one track and grabs the first acceptable
|
||||||
|
// copy that is not from the source that already failed to supply it.
|
||||||
|
// One attempt: a fill-in that walks a candidate list per track would
|
||||||
|
// multiply a download's grabs by the number of gaps.
|
||||||
|
func (m *Manager) fillInTrack(
|
||||||
|
ctx context.Context,
|
||||||
|
dl Download,
|
||||||
|
main Candidate,
|
||||||
|
t ExpectedTrack,
|
||||||
|
job *jobs.Handle,
|
||||||
|
) ([]string, error) {
|
||||||
|
want := trackRequest(dl, t)
|
||||||
|
|
||||||
|
ranked, err := m.Search(ctx, want)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs := m.preferences()
|
||||||
|
|
||||||
|
for _, c := range ranked {
|
||||||
|
if ruledOutBy(c, []Candidate{main}) || !autoAcceptable(want, c, prefs) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// The track is being put into an album this app placed; a
|
||||||
|
// delegate would put it in its own library instead.
|
||||||
|
if plan, err := m.planTransfer(want, c); err != nil || plan.delegated() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
narrowed, ok := narrowTo(c, t)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out := m.attemptGrab(ctx, dl, narrowed, job, []ExpectedTrack{t})
|
||||||
|
|
||||||
|
if out.item.StagingDir != "" {
|
||||||
|
if err := m.staging.Release(out.item.StagingDir); err != nil {
|
||||||
|
m.logger.Warn("could not release staging dir", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.err != nil {
|
||||||
|
return nil, out.err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.store.SetItemImported(
|
||||||
|
ctx, out.item.ID, out.imported.Paths,
|
||||||
|
); err != nil {
|
||||||
|
m.logger.Warn("could not record imported paths", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.imported.Paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, ErrNoCandidates
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackRequest is the search for one track of an album: the track's
|
||||||
|
// artist and title as the query, the album kept so a copy from that
|
||||||
|
// album outranks the same song off a compilation, and one expected
|
||||||
|
// track so a single file is a complete answer.
|
||||||
|
func trackRequest(dl Download, t ExpectedTrack) Download {
|
||||||
|
artist := cmp.Or(t.Artist, dl.Artist)
|
||||||
|
|
||||||
|
want := dl
|
||||||
|
want.Query = strings.TrimSpace(artist + " " + t.Title)
|
||||||
|
want.Expected = []ExpectedTrack{t}
|
||||||
|
|
||||||
|
return want
|
||||||
|
}
|
||||||
|
|
||||||
|
// narrowTo trims a candidate to the one file that aligns to t, so the
|
||||||
|
// grab fetches a track rather than whatever else the folder offered.
|
||||||
|
func narrowTo(c Candidate, t ExpectedTrack) (Candidate, bool) {
|
||||||
|
aligned, _ := matchFiles(c.Files, []ExpectedTrack{t})
|
||||||
|
|
||||||
|
for _, f := range aligned {
|
||||||
|
if f.IsAudio && f.MatchedTo == t.Position {
|
||||||
|
c.Files = []CandidateFile{f}
|
||||||
|
c.TotalSize = f.Size
|
||||||
|
|
||||||
|
return c, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Candidate{}, false
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filling in the tracks an almost-complete album is missing (#276).
|
||||||
|
|
||||||
|
func fiveTrackTitles() []string {
|
||||||
|
return append(allTitles(), "Let Down")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fiveTrackDownload() Download {
|
||||||
|
dl := fourTrackDownload()
|
||||||
|
dl.Expected = append(dl.Expected, ExpectedTrack{Position: 5, Title: "Let Down"})
|
||||||
|
|
||||||
|
return dl
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMissingTracks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dl := fiveTrackDownload()
|
||||||
|
got := func(positions ...int) []ExpectedTrack {
|
||||||
|
out := make([]ExpectedTrack, 0, len(positions))
|
||||||
|
for _, p := range positions {
|
||||||
|
out = append(out, dl.Expected[p-1])
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
dl Download
|
||||||
|
matched []ExpectedTrack
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"complete", dl, got(1, 2, 3, 4, 5), 0},
|
||||||
|
{"one short", dl, got(1, 2, 3, 4), 1},
|
||||||
|
{"two short", dl, got(1, 2, 3), 2},
|
||||||
|
{"half gone is a poor copy", dl, got(1, 2), 0},
|
||||||
|
{"a recording is not an album", func() Download {
|
||||||
|
d := dl
|
||||||
|
d.RecordingMBID = "rec"
|
||||||
|
|
||||||
|
return d
|
||||||
|
}(), got(1, 2, 3, 4), 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
if n := len(missingTracks(tc.dl, tc.matched)); n != tc.want {
|
||||||
|
t.Errorf("%s: %d missing, want %d", tc.name, n, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
big := fiveTrackDownload()
|
||||||
|
for i := 6; i <= 20; i++ {
|
||||||
|
big.Expected = append(big.Expected, ExpectedTrack{Position: i, Title: "T" + itoa(i)})
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := len(missingTracks(big, big.Expected[:16])); n != 0 {
|
||||||
|
t.Errorf("four of twenty missing: %d filled in, want none past %d", n, maxFillInTracks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fill-in import takes only the file that is the missing track, and
|
||||||
|
// places it in the album with the album's tags; anything else the grab
|
||||||
|
// brought is left out.
|
||||||
|
func TestImportOnlyTakesTheMissingTrack(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newImportFixture(t,
|
||||||
|
"05 - Let Down.flac",
|
||||||
|
"02 - Paranoid Android.flac",
|
||||||
|
)
|
||||||
|
|
||||||
|
dl := fiveTrackDownload()
|
||||||
|
|
||||||
|
got, err := f.importer.Import(
|
||||||
|
context.Background(), dl,
|
||||||
|
Result{Dir: f.dir, Files: f.files},
|
||||||
|
ImportOptions{LibraryRoot: f.root, WriteTags: true, Only: dl.Expected[4:]},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Import: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := filepath.Join(f.root, "Radiohead", "OK Computer", "05 Let Down.flac")
|
||||||
|
if len(got.Paths) != 1 || got.Paths[0] != want {
|
||||||
|
t.Errorf("imported %q, want only %s", got.Paths, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file that is not the missing track is not imported at all.
|
||||||
|
g := newImportFixture(t, "02 - Paranoid Android.flac")
|
||||||
|
|
||||||
|
if _, err := g.importer.Import(
|
||||||
|
context.Background(), dl,
|
||||||
|
Result{Dir: g.dir, Files: g.files},
|
||||||
|
ImportOptions{LibraryRoot: g.root, WriteTags: true, Only: dl.Expected[4:]},
|
||||||
|
); !errors.Is(err, ErrTooIncomplete) {
|
||||||
|
t.Errorf("Import = %v, want nothing matched", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The album comes from one source missing its fifth track; the fifth is
|
||||||
|
// then found on its own at another and lands in the same album.
|
||||||
|
func TestManagerFillsInAMissingTrack(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newManagerFixture(t)
|
||||||
|
|
||||||
|
titles := fiveTrackTitles()
|
||||||
|
|
||||||
|
album := NewFakeProvider(1, "album", Caps{CanSearch: true, CanTransport: true})
|
||||||
|
ac := candidateFor("album-cand", titles, ".flac", 30_000_000)
|
||||||
|
ac.ProviderID = 1
|
||||||
|
album.Candidates = []Candidate{ac}
|
||||||
|
|
||||||
|
for i, tt := range titles[:4] {
|
||||||
|
album.Written[trackToken(i+1)+" - "+tt+".flac"] = []byte("audio-data")
|
||||||
|
}
|
||||||
|
|
||||||
|
single := NewFakeProvider(2, "single", Caps{CanSearch: true, CanTransport: true})
|
||||||
|
sc := Candidate{
|
||||||
|
ID: "single-cand",
|
||||||
|
Protocol: ProtocolDirect,
|
||||||
|
Title: "Radiohead - OK Computer",
|
||||||
|
Artist: "Radiohead",
|
||||||
|
Files: []CandidateFile{{
|
||||||
|
Path: "Radiohead - OK Computer/05 - Let Down.flac",
|
||||||
|
Size: 30_000_000,
|
||||||
|
}},
|
||||||
|
Health: 0.5,
|
||||||
|
ProviderID: 2,
|
||||||
|
}
|
||||||
|
single.Candidates = []Candidate{sc}
|
||||||
|
single.Written["05 - Let Down.flac"] = []byte("audio-data")
|
||||||
|
|
||||||
|
f.manager.installProvider(Config{ID: 1, Priority: 90}, album)
|
||||||
|
f.manager.installProvider(Config{ID: 2, Priority: 10}, single)
|
||||||
|
|
||||||
|
dl := fiveTrackDownload()
|
||||||
|
|
||||||
|
if _, err := f.manager.Start(context.Background(), dl); err != nil {
|
||||||
|
t.Fatalf("Start: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForDownloadState(t, f.store, dl.ID, StateComplete)
|
||||||
|
|
||||||
|
if album.GrabCallCount() != 1 || single.GrabCallCount() != 1 {
|
||||||
|
t.Errorf(
|
||||||
|
"grabs: album=%d single=%d, want 1 and 1",
|
||||||
|
album.GrabCallCount(), single.GrabCallCount(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, tt := range titles {
|
||||||
|
p := filepath.Join(f.root, "Radiohead", "OK Computer", trackToken(i+1)+" "+tt+".flac")
|
||||||
|
if _, err := os.Stat(p); err != nil {
|
||||||
|
t.Errorf("track %d not in the library: %v", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,12 @@ type ImportOptions struct {
|
|||||||
// them. Off for delegate providers, which have already imported
|
// them. Off for delegate providers, which have already imported
|
||||||
// and tagged the files themselves.
|
// and tagged the files themselves.
|
||||||
WriteTags bool
|
WriteTags bool
|
||||||
|
|
||||||
|
// Only, when set, imports just the files that align to these
|
||||||
|
// tracks of the download and skips the completeness check: it is a
|
||||||
|
// fill-in for tracks an earlier grab of the same album did not
|
||||||
|
// deliver (#276), tagged and placed as part of that album.
|
||||||
|
Only []ExpectedTrack
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultPathTemplate is the layout used when none is configured.
|
// DefaultPathTemplate is the layout used when none is configured.
|
||||||
@@ -118,6 +124,10 @@ type ImportResult struct {
|
|||||||
// Skipped counts non-audio files left in staging (logs, cue sheets,
|
// Skipped counts non-audio files left in staging (logs, cue sheets,
|
||||||
// scene .nfo files) — deliberately not imported.
|
// scene .nfo files) — deliberately not imported.
|
||||||
Skipped int
|
Skipped int
|
||||||
|
|
||||||
|
// Matched are the expected tracks an imported file was aligned to,
|
||||||
|
// which is how a caller learns what the grab did not deliver.
|
||||||
|
Matched []ExpectedTrack
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import verifies, tags and moves a completed grab into the library.
|
// Import verifies, tags and moves a completed grab into the library.
|
||||||
@@ -140,14 +150,25 @@ func (i *Importer) Import(
|
|||||||
return ImportResult{}, ErrNoAudio
|
return ImportResult{}, ErrNoAudio
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := checkCompleteness(len(audio), dl); err != nil {
|
if len(opts.Only) == 0 {
|
||||||
return ImportResult{}, err
|
if err := checkCompleteness(len(audio), dl); err != nil {
|
||||||
|
return ImportResult{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Align staged files to the expected tracklist so tags and
|
// Align staged files to the expected tracklist so tags and
|
||||||
// filenames reflect the release, not the uploader's naming.
|
// filenames reflect the release, not the uploader's naming.
|
||||||
plan := i.planFiles(audio, dl)
|
plan := i.planFiles(audio, dl)
|
||||||
|
|
||||||
|
if len(opts.Only) > 0 {
|
||||||
|
plan = onlyTracks(plan, opts.Only)
|
||||||
|
if len(plan) == 0 {
|
||||||
|
return ImportResult{}, fmt.Errorf(
|
||||||
|
"%w: no file matched the missing track", ErrTooIncomplete,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
out := ImportResult{
|
out := ImportResult{
|
||||||
Paths: make([]string, 0, len(plan)),
|
Paths: make([]string, 0, len(plan)),
|
||||||
Skipped: skipped,
|
Skipped: skipped,
|
||||||
@@ -184,11 +205,49 @@ func (i *Importer) Import(
|
|||||||
}
|
}
|
||||||
|
|
||||||
out.Paths = append(out.Paths, dest)
|
out.Paths = append(out.Paths, dest)
|
||||||
|
|
||||||
|
if p.Matched {
|
||||||
|
out.Matched = append(out.Matched, p.Track)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// trackKey identifies an expected track within a release.
|
||||||
|
type trackKey struct{ disc, position int }
|
||||||
|
|
||||||
|
func keyOf(t ExpectedTrack) trackKey {
|
||||||
|
return trackKey{disc: t.DiscNumber, position: t.Position}
|
||||||
|
}
|
||||||
|
|
||||||
|
// onlyTracks keeps the planned files aligned to one of want. A fill-in
|
||||||
|
// grab can bring more than the one file it was after — a folder where
|
||||||
|
// the title also matched a live take — and anything else would land in
|
||||||
|
// the album as a duplicate or a stranger.
|
||||||
|
func onlyTracks(plan []plannedFile, want []ExpectedTrack) []plannedFile {
|
||||||
|
keys := make(map[trackKey]bool, len(want))
|
||||||
|
for _, t := range want {
|
||||||
|
keys[keyOf(t)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]plannedFile, 0, len(want))
|
||||||
|
seen := map[trackKey]bool{}
|
||||||
|
|
||||||
|
for _, p := range plan {
|
||||||
|
k := keyOf(p.Track)
|
||||||
|
if !p.Matched || !keys[k] || seen[k] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seen[k] = true
|
||||||
|
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// plannedFile pairs a staged file with the expected track it matched.
|
// plannedFile pairs a staged file with the expected track it matched.
|
||||||
type plannedFile struct {
|
type plannedFile struct {
|
||||||
Source string
|
Source string
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// keyedLock is a set of mutexes created on demand, one per key, that
|
||||||
|
// honour a context while waiting. An entry lives only while someone
|
||||||
|
// holds or waits on it, so a key per Soulseek peer or per folder name
|
||||||
|
// does not accumulate for the life of the process.
|
||||||
|
type keyedLock[K comparable] struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
held map[K]*keyedEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type keyedEntry struct {
|
||||||
|
ch chan struct{}
|
||||||
|
|
||||||
|
// refs counts holders and waiters; the entry is dropped at zero.
|
||||||
|
refs int
|
||||||
|
}
|
||||||
|
|
||||||
|
// acquire blocks until k is free or ctx ends, and returns the function
|
||||||
|
// that frees it.
|
||||||
|
func (l *keyedLock[K]) acquire(ctx context.Context, k K) (func(), error) {
|
||||||
|
l.mu.Lock()
|
||||||
|
|
||||||
|
if l.held == nil {
|
||||||
|
l.held = map[K]*keyedEntry{}
|
||||||
|
}
|
||||||
|
|
||||||
|
e, ok := l.held[k]
|
||||||
|
if !ok {
|
||||||
|
e = &keyedEntry{ch: make(chan struct{}, 1)}
|
||||||
|
l.held[k] = e
|
||||||
|
}
|
||||||
|
|
||||||
|
e.refs++
|
||||||
|
|
||||||
|
l.mu.Unlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case e.ch <- struct{}{}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
l.drop(k, e)
|
||||||
|
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
var once sync.Once
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
once.Do(func() {
|
||||||
|
<-e.ch
|
||||||
|
l.drop(k, e)
|
||||||
|
})
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *keyedLock[K]) drop(k K, e *keyedEntry) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
e.refs--
|
||||||
|
if e.refs == 0 {
|
||||||
|
delete(l.held, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// size reports how many keys are held or awaited, for tests.
|
||||||
|
func (l *keyedLock[K]) size() int {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
return len(l.held)
|
||||||
|
}
|
||||||
+248
-65
@@ -59,13 +59,15 @@ const concurrencyKey = "maxConcurrent"
|
|||||||
// A single global cap is the wrong shape here: usenet and torrent
|
// A single global cap is the wrong shape here: usenet and torrent
|
||||||
// clients are built to run many transfers at once and are throttled by
|
// clients are built to run many transfers at once and are throttled by
|
||||||
// bandwidth, while Soulseek transfers come from one person's home
|
// bandwidth, while Soulseek transfers come from one person's home
|
||||||
// upload slot. Hitting the same peer with parallel requests gets you
|
// upload slot. Politeness there is per *peer* — asking one user for two
|
||||||
// queued behind everyone else at best and banned at worst, so slskd is
|
// folders at once gets you queued behind everyone else at best and
|
||||||
// capped at one — the polite number, and the one that actually
|
// banned at worst — and the manager holds that line separately, one
|
||||||
// completes fastest, because a Soulseek peer serves one file at a time
|
// grab per peer (peerLocks). Two different users do not compete for
|
||||||
// regardless of how many you ask for.
|
// anyone's slot, so the daemon-wide number only bounds how many peers
|
||||||
|
// are asked at once, and one slow peer no longer serialises every other
|
||||||
|
// Soulseek download behind it.
|
||||||
var kindConcurrency = map[Kind]int{
|
var kindConcurrency = map[Kind]int{
|
||||||
KindSlskd: 1,
|
KindSlskd: 3,
|
||||||
KindYtDlp: 2,
|
KindYtDlp: 2,
|
||||||
KindQBittorrent: 4,
|
KindQBittorrent: 4,
|
||||||
KindSABnzbd: 4,
|
KindSABnzbd: 4,
|
||||||
@@ -155,6 +157,11 @@ type Manager struct {
|
|||||||
semMu sync.Mutex
|
semMu sync.Mutex
|
||||||
provSem map[int64]chan struct{}
|
provSem map[int64]chan struct{}
|
||||||
|
|
||||||
|
// peerLocks holds one grab per Soulseek peer, taken before any
|
||||||
|
// slot: a grab waiting for a busy peer must not sit on a provider
|
||||||
|
// slot another peer could be using.
|
||||||
|
peerLocks keyedLock[peerKey]
|
||||||
|
|
||||||
// delegatePoll is how often delegating managers are asked for
|
// delegatePoll is how often delegating managers are asked for
|
||||||
// status. A field rather than the constant so tests can drive the
|
// status. A field rather than the constant so tests can drive the
|
||||||
// full delegate flow without sleeping through it.
|
// full delegate flow without sleeping through it.
|
||||||
@@ -577,12 +584,12 @@ func (m *Manager) Start(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.AutoPickable(dl, ranked) {
|
if pick, ok := autoPick(dl, ranked, m.preferences()); ok {
|
||||||
if job != nil {
|
if job != nil {
|
||||||
job.Logf(jobs.LevelInfo, "Auto-selected best candidate")
|
job.Logf(jobs.LevelInfo, "Auto-selected best candidate")
|
||||||
}
|
}
|
||||||
|
|
||||||
go m.grab(context.WithoutCancel(ctx), dl, ranked[0], job)
|
go m.grab(context.WithoutCancel(ctx), dl, pick, job, true)
|
||||||
|
|
||||||
return ranked, nil
|
return ranked, nil
|
||||||
}
|
}
|
||||||
@@ -622,6 +629,13 @@ func (m *Manager) Attempt(
|
|||||||
return false, veto, nil
|
return false, veto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pick, ok := autoPick(dl, ranked, m.preferences())
|
||||||
|
if !ok {
|
||||||
|
// Unreachable while autoPick and AutoPickVeto agree; kept so a
|
||||||
|
// future divergence refuses rather than grabbing blind.
|
||||||
|
return false, "no candidate clears the auto-download bar", nil
|
||||||
|
}
|
||||||
|
|
||||||
if err := m.store.CreateDownload(ctx, dl); err != nil {
|
if err := m.store.CreateDownload(ctx, dl); err != nil {
|
||||||
return false, "", err
|
return false, "", err
|
||||||
}
|
}
|
||||||
@@ -643,7 +657,7 @@ func (m *Manager) Attempt(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
go m.grab(context.WithoutCancel(ctx), dl, ranked[0], job)
|
go m.grab(context.WithoutCancel(ctx), dl, pick, job, true)
|
||||||
|
|
||||||
return true, "", nil
|
return true, "", nil
|
||||||
}
|
}
|
||||||
@@ -678,7 +692,7 @@ func (m *Manager) Pick(
|
|||||||
|
|
||||||
job := m.startJob(dl)
|
job := m.startJob(dl)
|
||||||
|
|
||||||
go m.grab(context.WithoutCancel(ctx), dl, *chosen, job)
|
go m.grab(context.WithoutCancel(ctx), dl, *chosen, job, false)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -702,13 +716,20 @@ func (m *Manager) Cancel(ctx context.Context, downloadID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// grab drives one candidate all the way to the library. It runs on its
|
// grab drives one request all the way to the library. It runs on its
|
||||||
// own goroutine and owns the job from here on.
|
// own goroutine and owns the job from here on.
|
||||||
|
//
|
||||||
|
// When fallback is set and a candidate's transfer fails, the next
|
||||||
|
// candidate that auto-pick would itself have accepted is tried in its
|
||||||
|
// place (see nextCandidate). It is set for the two unattended routes
|
||||||
|
// and not for a candidate the user picked by hand: they chose that copy,
|
||||||
|
// and quietly substituting another is a decision they did not make.
|
||||||
func (m *Manager) grab(
|
func (m *Manager) grab(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
dl Download,
|
dl Download,
|
||||||
c Candidate,
|
c Candidate,
|
||||||
job *jobs.Handle,
|
job *jobs.Handle,
|
||||||
|
fallback bool,
|
||||||
) {
|
) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, grabTimeout)
|
ctx, cancel := context.WithTimeout(ctx, grabTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -723,6 +744,101 @@ func (m *Manager) grab(
|
|||||||
m.actMu.Unlock()
|
m.actMu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var failed []Candidate
|
||||||
|
|
||||||
|
for {
|
||||||
|
out := m.attemptGrab(ctx, dl, c, job, nil)
|
||||||
|
if out.err == nil {
|
||||||
|
m.fillIn(ctx, dl, c, out.imported, job)
|
||||||
|
m.finishGrab(ctx, dl, out.item, out.imported, job)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
failed = append(failed, c)
|
||||||
|
|
||||||
|
next, ok := m.nextCandidate(ctx, dl, failed, out, fallback)
|
||||||
|
if !ok {
|
||||||
|
m.failDownload(ctx, job, dl.ID, out.err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.logger.Info(
|
||||||
|
"download candidate failed; trying the next",
|
||||||
|
"download", dl.ID,
|
||||||
|
"failed", c.ID,
|
||||||
|
"next", next.ID,
|
||||||
|
"error", out.err,
|
||||||
|
)
|
||||||
|
|
||||||
|
if job != nil {
|
||||||
|
job.Logf(jobs.LevelWarn, fmt.Sprintf(
|
||||||
|
"%s failed (%v); trying %s instead",
|
||||||
|
describeCandidate(c), out.err, describeCandidate(next),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The failed attempt's staging holds at most a partial folder
|
||||||
|
// nobody is going to import, and the next attempt reserves its
|
||||||
|
// own. Only the final failure keeps its staging for inspection.
|
||||||
|
if out.item.StagingDir != "" {
|
||||||
|
if err := m.staging.Release(out.item.StagingDir); err != nil {
|
||||||
|
m.logger.Warn("could not release staging dir", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxGrabAttempts bounds how many candidates one request will try. A
|
||||||
|
// popular album can have dozens of peers; the point of falling back is
|
||||||
|
// to survive the ordinary one or two that are offline, not to walk the
|
||||||
|
// whole list for six hours.
|
||||||
|
const maxGrabAttempts = 3
|
||||||
|
|
||||||
|
// peerKey names one Soulseek user on one daemon. The same username on
|
||||||
|
// two daemons is two logins and two queues.
|
||||||
|
type peerKey struct {
|
||||||
|
provider int64
|
||||||
|
peer string
|
||||||
|
}
|
||||||
|
|
||||||
|
// peerKeyFor returns the peer a candidate is fetched from, when the
|
||||||
|
// source is one where asking a peer for two things at once is rude.
|
||||||
|
func peerKeyFor(c Candidate) (peerKey, bool) {
|
||||||
|
if c.Kind != KindSlskd || c.Origin == "" {
|
||||||
|
return peerKey{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return peerKey{provider: c.ProviderID, peer: c.Origin}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// grabOutcome is how one candidate's attempt ended.
|
||||||
|
type grabOutcome struct {
|
||||||
|
item DownloadItem
|
||||||
|
imported ImportResult
|
||||||
|
err error
|
||||||
|
|
||||||
|
// retryable reports whether another candidate might succeed where
|
||||||
|
// this one failed: the transfer failed, or delivered too little of
|
||||||
|
// the album. Anything else — no staging space, no library root, a
|
||||||
|
// tag write failing — would fail the next candidate identically.
|
||||||
|
retryable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// attemptGrab takes one candidate through transfer and import. It
|
||||||
|
// records the item's own failure, but not the download's: whether the
|
||||||
|
// download has failed is the caller's decision, since another candidate
|
||||||
|
// may yet succeed.
|
||||||
|
func (m *Manager) attemptGrab(
|
||||||
|
ctx context.Context,
|
||||||
|
dl Download,
|
||||||
|
c Candidate,
|
||||||
|
job *jobs.Handle,
|
||||||
|
only []ExpectedTrack,
|
||||||
|
) grabOutcome {
|
||||||
// Who will move the bytes is decided before any slot is taken, so
|
// Who will move the bytes is decided before any slot is taken, so
|
||||||
// the transfer waits in its own provider's queue rather than in a
|
// the transfer waits in its own provider's queue rather than in a
|
||||||
// global one. A delegate takes no slot at all: the transfer is
|
// global one. A delegate takes no slot at all: the transfer is
|
||||||
@@ -731,21 +847,26 @@ func (m *Manager) grab(
|
|||||||
// work against our budget.
|
// work against our budget.
|
||||||
plan, err := m.planTransfer(dl, c)
|
plan, err := m.planTransfer(dl, c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.failDownload(ctx, job, dl.ID, err)
|
return grabOutcome{err: err}
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !plan.delegated() {
|
if !plan.delegated() {
|
||||||
|
if key, ok := peerKeyFor(c); ok {
|
||||||
|
release, err := m.peerLocks.acquire(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return grabOutcome{err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
defer release()
|
||||||
|
}
|
||||||
|
|
||||||
provSem := m.semaphoreFor(plan.transportID)
|
provSem := m.semaphoreFor(plan.transportID)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case provSem <- struct{}{}:
|
case provSem <- struct{}{}:
|
||||||
defer func() { <-provSem }()
|
defer func() { <-provSem }()
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
m.failDownload(ctx, job, dl.ID, ctx.Err())
|
return grabOutcome{err: ctx.Err()}
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
globalSem := m.globalSem()
|
globalSem := m.globalSem()
|
||||||
@@ -754,9 +875,7 @@ func (m *Manager) grab(
|
|||||||
case globalSem <- struct{}{}:
|
case globalSem <- struct{}{}:
|
||||||
defer func() { <-globalSem }()
|
defer func() { <-globalSem }()
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
m.failDownload(ctx, job, dl.ID, ctx.Err())
|
return grabOutcome{err: ctx.Err()}
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,24 +890,30 @@ func (m *Manager) grab(
|
|||||||
|
|
||||||
dir, err := m.staging.Reserve(item.ID)
|
dir, err := m.staging.Reserve(item.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.failDownload(ctx, job, dl.ID, err)
|
return grabOutcome{err: err}
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
item.StagingDir = dir
|
item.StagingDir = dir
|
||||||
|
|
||||||
if err := m.store.CreateItem(ctx, item); err != nil {
|
if err := m.store.CreateItem(ctx, item); err != nil {
|
||||||
m.failDownload(ctx, job, dl.ID, err)
|
return grabOutcome{item: item, err: err}
|
||||||
|
}
|
||||||
|
|
||||||
return
|
fail := func(err error, retryable bool) grabOutcome {
|
||||||
|
if serr := m.store.SetItemState(
|
||||||
|
ctx, item.ID, StateFailed, err.Error(),
|
||||||
|
); serr != nil {
|
||||||
|
m.logger.Warn("could not record item failure", "error", serr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return grabOutcome{item: item, err: err, retryable: retryable}
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := m.transfer(ctx, dl, item, plan, job)
|
result, err := m.transfer(ctx, dl, item, plan, job)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.failItem(ctx, job, item, dl.ID, err)
|
// A delegate's failure is the external manager's verdict on the
|
||||||
|
// whole request, not on one copy of it.
|
||||||
return
|
return fail(err, !plan.delegated())
|
||||||
}
|
}
|
||||||
|
|
||||||
m.setStates(ctx, dl.ID, item.ID, StateImporting)
|
m.setStates(ctx, dl.ID, item.ID, StateImporting)
|
||||||
@@ -798,42 +923,117 @@ func (m *Manager) grab(
|
|||||||
job.SetStages(importStages(2))
|
job.SetStages(importStages(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
var imported ImportResult
|
|
||||||
|
|
||||||
if result.Delegated {
|
if result.Delegated {
|
||||||
// The external manager already placed and tagged these files in
|
// The external manager already placed and tagged these files in
|
||||||
// its own library. Moving them out from under a system that is
|
// its own library. Moving them out from under a system that is
|
||||||
// still managing them would be worse than useless, so the files
|
// still managing them would be worse than useless, so the files
|
||||||
// are recorded where they are and the library scan picks them
|
// are recorded where they are and the library scan picks them
|
||||||
// up in place.
|
// up in place.
|
||||||
imported = ImportResult{Paths: result.Files}
|
|
||||||
|
|
||||||
if job != nil {
|
if job != nil {
|
||||||
job.Logf(jobs.LevelInfo, fmt.Sprintf(
|
job.Logf(jobs.LevelInfo, fmt.Sprintf(
|
||||||
"External manager imported %d files; recording them in place",
|
"External manager imported %d files; recording them in place",
|
||||||
len(result.Files),
|
len(result.Files),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
opts := m.importOptions()
|
|
||||||
opts.WriteTags = true
|
|
||||||
|
|
||||||
opts.LibraryRoot, err = m.library.LibraryPath(dl.LibraryID)
|
return grabOutcome{
|
||||||
if err != nil {
|
item: item,
|
||||||
m.failItem(ctx, job, item, dl.ID,
|
imported: ImportResult{Paths: result.Files},
|
||||||
fmt.Errorf("resolve library root: %w", err))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
imported, err = m.importer.Import(ctx, dl, result, opts)
|
|
||||||
if err != nil {
|
|
||||||
m.failItem(ctx, job, item, dl.ID, err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
opts := m.importOptions()
|
||||||
|
opts.WriteTags = true
|
||||||
|
opts.Only = only
|
||||||
|
|
||||||
|
opts.LibraryRoot, err = m.library.LibraryPath(dl.LibraryID)
|
||||||
|
if err != nil {
|
||||||
|
return fail(fmt.Errorf("resolve library root: %w", err), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
imported, err := m.importer.Import(ctx, dl, result, opts)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err, errors.Is(err, ErrTooIncomplete))
|
||||||
|
}
|
||||||
|
|
||||||
|
return grabOutcome{item: item, imported: imported}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextCandidate picks the candidate to try after the ones in failed.
|
||||||
|
//
|
||||||
|
// It only ever offers a candidate auto-pick would have taken on its own
|
||||||
|
// (autoAcceptable), so falling back cannot lower the bar an unattended
|
||||||
|
// download is held to: the second choice has to clear the same gates
|
||||||
|
// the first did.
|
||||||
|
//
|
||||||
|
// On Soulseek a failure belongs to the *peer* — offline, refusing, or
|
||||||
|
// holding us in a queue — so every folder that peer offered is skipped
|
||||||
|
// with it. Elsewhere a failure belongs to the release, and only that
|
||||||
|
// candidate is.
|
||||||
|
func (m *Manager) nextCandidate(
|
||||||
|
ctx context.Context,
|
||||||
|
dl Download,
|
||||||
|
failed []Candidate,
|
||||||
|
out grabOutcome,
|
||||||
|
fallback bool,
|
||||||
|
) (Candidate, bool) {
|
||||||
|
if !fallback || !out.retryable || ctx.Err() != nil ||
|
||||||
|
len(failed) >= maxGrabAttempts {
|
||||||
|
return Candidate{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
m.resMu.RLock()
|
||||||
|
ranked := m.results[dl.ID]
|
||||||
|
m.resMu.RUnlock()
|
||||||
|
|
||||||
|
prefs := m.preferences()
|
||||||
|
|
||||||
|
for _, c := range ranked {
|
||||||
|
if ruledOutBy(c, failed) || !autoAcceptable(dl, c, prefs) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return c, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return Candidate{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ruledOutBy reports whether a failure among failed also rules out c.
|
||||||
|
func ruledOutBy(c Candidate, failed []Candidate) bool {
|
||||||
|
for _, f := range failed {
|
||||||
|
if c.ID == f.ID && c.ProviderID == f.ProviderID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Kind == KindSlskd && f.Kind == KindSlskd &&
|
||||||
|
c.ProviderID == f.ProviderID && c.Origin != "" &&
|
||||||
|
c.Origin == f.Origin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeCandidate names a candidate for the job log.
|
||||||
|
func describeCandidate(c Candidate) string {
|
||||||
|
if c.Origin != "" {
|
||||||
|
return fmt.Sprintf("%q from %s", c.Title, c.Origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%q", c.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishGrab records a successful import and retires what the request
|
||||||
|
// was holding.
|
||||||
|
func (m *Manager) finishGrab(
|
||||||
|
ctx context.Context,
|
||||||
|
dl Download,
|
||||||
|
item DownloadItem,
|
||||||
|
imported ImportResult,
|
||||||
|
job *jobs.Handle,
|
||||||
|
) {
|
||||||
if err := m.store.SetItemImported(
|
if err := m.store.SetItemImported(
|
||||||
ctx, item.ID, imported.Paths,
|
ctx, item.ID, imported.Paths,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -1177,23 +1377,6 @@ func (m *Manager) failDownload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// failItem records an item-level failure and fails its download.
|
|
||||||
func (m *Manager) failItem(
|
|
||||||
ctx context.Context,
|
|
||||||
job *jobs.Handle,
|
|
||||||
item DownloadItem,
|
|
||||||
downloadID string,
|
|
||||||
err error,
|
|
||||||
) {
|
|
||||||
if serr := m.store.SetItemState(
|
|
||||||
ctx, item.ID, StateFailed, err.Error(),
|
|
||||||
); serr != nil {
|
|
||||||
m.logger.Warn("could not record item failure", "error", serr)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.failDownload(ctx, job, downloadID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// startJob registers the request in the background jobs panel.
|
// startJob registers the request in the background jobs panel.
|
||||||
func (m *Manager) startJob(dl Download) *jobs.Handle {
|
func (m *Manager) startJob(dl Download) *jobs.Handle {
|
||||||
if m.jobsReg == nil {
|
if m.jobsReg == nil {
|
||||||
|
|||||||
@@ -66,6 +66,14 @@ var (
|
|||||||
|
|
||||||
// separatorPattern splits "Artist - Album" style folder names.
|
// separatorPattern splits "Artist - Album" style folder names.
|
||||||
separatorPattern = regexp.MustCompile(`\s+[-–—]\s+`)
|
separatorPattern = regexp.MustCompile(`\s+[-–—]\s+`)
|
||||||
|
|
||||||
|
// discFolderPattern matches a directory that holds one disc of an
|
||||||
|
// album rather than the album: "CD1", "CD 2", "Disc 3", "Disk-1",
|
||||||
|
// "[Disc 2]", "CD1 - The Early Years". A number is required, so a
|
||||||
|
// folder merely called "CDs" is not one.
|
||||||
|
discFolderPattern = regexp.MustCompile(
|
||||||
|
`(?i)^\s*[\[(]?\s*(?:cd|disc|disk)\s*[-_.#]?\s*(\d{1,2})\b`,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// FormatForPath returns the audio format implied by a path's extension,
|
// FormatForPath returns the audio format implied by a path's extension,
|
||||||
@@ -94,16 +102,63 @@ type TrackHint struct {
|
|||||||
Folder string
|
Folder string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// discFolder reports whether a directory name is one disc of an album,
|
||||||
|
// and which.
|
||||||
|
func discFolder(name string) (int, bool) {
|
||||||
|
m := discFolderPattern.FindStringSubmatch(name)
|
||||||
|
if m == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := strconv.Atoi(m[1])
|
||||||
|
if err != nil || n == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlbumDir is the directory that holds a file's *album*: its parent,
|
||||||
|
// or its grandparent when the parent is a disc folder.
|
||||||
|
//
|
||||||
|
// Multi-disc rips are shared as `Album/CD1/…` and `Album/CD2/…`, and
|
||||||
|
// grouping candidates by the immediate parent split one album into two
|
||||||
|
// half-albums, each titled "CD1". Neither could clear the completeness
|
||||||
|
// or album-title bars, so a multi-disc release could not be auto-picked
|
||||||
|
// at all. A disc folder at the root has no album above it and is
|
||||||
|
// returned as it is.
|
||||||
|
func AlbumDir(p string) string {
|
||||||
|
dir := path.Dir(strings.ReplaceAll(p, `\`, "/"))
|
||||||
|
|
||||||
|
if _, ok := discFolder(path.Base(dir)); !ok {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
parent := path.Dir(dir)
|
||||||
|
if parent == "." || parent == "/" || parent == "" {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent
|
||||||
|
}
|
||||||
|
|
||||||
// ParsePath extracts what it can from one candidate file path.
|
// ParsePath extracts what it can from one candidate file path.
|
||||||
func ParsePath(p string) TrackHint {
|
func ParsePath(p string) TrackHint {
|
||||||
// Soulseek paths are Windows-style; normalize before splitting.
|
// Soulseek paths are Windows-style; normalize before splitting.
|
||||||
norm := strings.ReplaceAll(p, `\`, "/")
|
norm := strings.ReplaceAll(p, `\`, "/")
|
||||||
base := path.Base(norm)
|
base := path.Base(norm)
|
||||||
folder := path.Base(path.Dir(norm))
|
|
||||||
|
|
||||||
name := strings.TrimSuffix(base, path.Ext(base))
|
name := strings.TrimSuffix(base, path.Ext(base))
|
||||||
|
|
||||||
hint := TrackHint{Folder: cleanAlbumName(folder)}
|
// The album's name is the album directory's, not a disc folder's,
|
||||||
|
// and the disc folder is where a multi-disc rip says which disc a
|
||||||
|
// file is on. A disc number in the filename ("2-01 …") is more
|
||||||
|
// specific and overrides it below.
|
||||||
|
hint := TrackHint{Folder: cleanAlbumName(path.Base(AlbumDir(norm)))}
|
||||||
|
|
||||||
|
if disc, ok := discFolder(path.Base(path.Dir(norm))); ok {
|
||||||
|
hint.Disc = disc
|
||||||
|
}
|
||||||
|
|
||||||
if m := trackNumPattern.FindStringSubmatch(name); m != nil {
|
if m := trackNumPattern.FindStringSubmatch(name); m != nil {
|
||||||
if m[1] != "" {
|
if m[1] != "" {
|
||||||
@@ -203,21 +258,72 @@ func AnnotateFiles(files []CandidateFile) []CandidateFile {
|
|||||||
|
|
||||||
// matchFiles aligns a candidate's audio files to the expected tracklist
|
// matchFiles aligns a candidate's audio files to the expected tracklist
|
||||||
// and returns the per-file assignment plus the mean title similarity of
|
// and returns the per-file assignment plus the mean title similarity of
|
||||||
// the aligned pairs.
|
// the aligned pairs. alignFiles is the same alignment with the
|
||||||
|
// duration evidence as well.
|
||||||
|
func matchFiles(
|
||||||
|
files []CandidateFile,
|
||||||
|
expected []ExpectedTrack,
|
||||||
|
) ([]CandidateFile, float64) {
|
||||||
|
a := alignFiles(files, expected)
|
||||||
|
|
||||||
|
return a.files, a.titleFit
|
||||||
|
}
|
||||||
|
|
||||||
|
// alignment is what aligning a candidate to a tracklist found.
|
||||||
|
type alignment struct {
|
||||||
|
files []CandidateFile
|
||||||
|
|
||||||
|
// titleFit is the mean title similarity over aligned pairs.
|
||||||
|
titleFit float64
|
||||||
|
|
||||||
|
// durationFit is the mean duration agreement over aligned pairs
|
||||||
|
// where both sides state a length, and timedPairs is how many such
|
||||||
|
// pairs there were.
|
||||||
|
durationFit float64
|
||||||
|
timedPairs int
|
||||||
|
aligned int
|
||||||
|
}
|
||||||
|
|
||||||
|
// durationAgreement scores how well a file's length matches the
|
||||||
|
// expected track's, in 0..1. Rips of the same master differ by a
|
||||||
|
// second or two of silence; a different edit, a live take or a
|
||||||
|
// truncated file differs by tens of seconds.
|
||||||
|
func durationAgreement(got, want int64) float64 {
|
||||||
|
const (
|
||||||
|
exactMillis = 3_000
|
||||||
|
wrongMillis = 30_000
|
||||||
|
)
|
||||||
|
|
||||||
|
d := got - want
|
||||||
|
if d < 0 {
|
||||||
|
d = -d
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case d <= exactMillis:
|
||||||
|
return 1
|
||||||
|
case d >= wrongMillis:
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
return 1 - float64(d-exactMillis)/float64(wrongMillis-exactMillis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// alignFiles aligns a candidate's audio files to the expected tracklist.
|
||||||
//
|
//
|
||||||
// Alignment is greedy by score rather than optimal: candidate folders
|
// Alignment is greedy by score rather than optimal: candidate folders
|
||||||
// are small (a few dozen files at most) and the common cases — correct
|
// are small (a few dozen files at most) and the common cases — correct
|
||||||
// track numbers, or clean "NN Title" names — are unambiguous, so the
|
// track numbers, or clean "NN Title" names — are unambiguous, so the
|
||||||
// extra machinery of Hungarian assignment buys nothing here.
|
// extra machinery of Hungarian assignment buys nothing here.
|
||||||
func matchFiles(
|
func alignFiles(
|
||||||
files []CandidateFile,
|
files []CandidateFile,
|
||||||
expected []ExpectedTrack,
|
expected []ExpectedTrack,
|
||||||
) ([]CandidateFile, float64) {
|
) alignment {
|
||||||
annotated := make([]CandidateFile, len(files))
|
annotated := make([]CandidateFile, len(files))
|
||||||
copy(annotated, files)
|
copy(annotated, files)
|
||||||
|
|
||||||
if len(expected) == 0 {
|
if len(expected) == 0 {
|
||||||
return annotated, 0
|
return alignment{files: annotated}
|
||||||
}
|
}
|
||||||
|
|
||||||
hints := make([]TrackHint, len(annotated))
|
hints := make([]TrackHint, len(annotated))
|
||||||
@@ -230,8 +336,19 @@ func matchFiles(
|
|||||||
var (
|
var (
|
||||||
total float64
|
total float64
|
||||||
matched int
|
matched int
|
||||||
|
|
||||||
|
durTotal float64
|
||||||
|
timed int
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// timing adds a pair's duration evidence when both sides state one.
|
||||||
|
timing := func(f CandidateFile, e ExpectedTrack) {
|
||||||
|
if f.LengthMillis > 0 && e.LengthMillis > 0 {
|
||||||
|
durTotal += durationAgreement(f.LengthMillis, e.LengthMillis)
|
||||||
|
timed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pass 1: trust explicit track numbers when they are unique and in
|
// Pass 1: trust explicit track numbers when they are unique and in
|
||||||
// range. A folder that numbers its files correctly is the strong
|
// range. A folder that numbers its files correctly is the strong
|
||||||
// case, and title comparison only adds noise there.
|
// case, and title comparison only adds noise there.
|
||||||
@@ -250,6 +367,8 @@ func matchFiles(
|
|||||||
|
|
||||||
total += autotag.TitleSimilarity(hints[i].Title, expected[idx].Title)
|
total += autotag.TitleSimilarity(hints[i].Title, expected[idx].Title)
|
||||||
matched++
|
matched++
|
||||||
|
|
||||||
|
timing(annotated[i], expected[idx])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass 2: title similarity for whatever is left.
|
// Pass 2: title similarity for whatever is left.
|
||||||
@@ -284,13 +403,26 @@ func matchFiles(
|
|||||||
|
|
||||||
total += bestSim
|
total += bestSim
|
||||||
matched++
|
matched++
|
||||||
|
|
||||||
|
timing(annotated[i], expected[bestIdx])
|
||||||
}
|
}
|
||||||
|
|
||||||
if matched == 0 {
|
if matched == 0 {
|
||||||
return annotated, 0
|
return alignment{files: annotated}
|
||||||
}
|
}
|
||||||
|
|
||||||
return annotated, total / float64(matched)
|
a := alignment{
|
||||||
|
files: annotated,
|
||||||
|
titleFit: total / float64(matched),
|
||||||
|
timedPairs: timed,
|
||||||
|
aligned: matched,
|
||||||
|
}
|
||||||
|
|
||||||
|
if timed > 0 {
|
||||||
|
a.durationFit = durTotal / float64(timed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
// indexForPosition finds the expected track at a disc/track position.
|
// indexForPosition finds the expected track at a disc/track position.
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Soulseek politeness is per peer, not per daemon (#272).
|
||||||
|
|
||||||
|
func TestKeyedLockSerialisesOneKeyOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var l keyedLock[string]
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
releaseA, err := l.acquire(ctx, "a")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("acquire a: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another key is free while "a" is held.
|
||||||
|
releaseB, err := l.acquire(ctx, "b")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("acquire b: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseB()
|
||||||
|
|
||||||
|
// The same key waits, and gives up with its context.
|
||||||
|
short, cancel := context.WithTimeout(ctx, 20*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if _, err := l.acquire(short, "a"); !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("second acquire of a held key = %v, want the deadline", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseA()
|
||||||
|
releaseA() // Idempotent: a second call must not free someone else's hold.
|
||||||
|
|
||||||
|
if n := l.size(); n != 0 {
|
||||||
|
t.Errorf("%d keys left behind, want none once nobody holds or waits", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// grabEach runs one grab per candidate and returns a function that waits
|
||||||
|
// for all of them; grabAll's reasons for waiting apply.
|
||||||
|
func grabEach(t *testing.T, f managerFixture, cands []Candidate) func() {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i, c := range cands {
|
||||||
|
dl := fourTrackDownload()
|
||||||
|
dl.ID = "dl-" + string(rune('a'+i))
|
||||||
|
|
||||||
|
if err := f.store.CreateDownload(ctx, dl); err != nil {
|
||||||
|
t.Fatalf("CreateDownload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
f.manager.grab(ctx, dl, c, nil, false)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Error("transfers did not finish")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func slskdCandidates(p *FakeProvider, peers ...string) []Candidate {
|
||||||
|
out := make([]Candidate, 0, len(peers))
|
||||||
|
|
||||||
|
for i, peer := range peers {
|
||||||
|
c := p.Candidates[0]
|
||||||
|
c.ID = c.ID + "-" + itoa(i)
|
||||||
|
c.Kind = KindSlskd
|
||||||
|
c.ProviderID = 1
|
||||||
|
c.Origin = peer
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three albums from one user are asked for one at a time, even though
|
||||||
|
// the daemon would allow three transfers.
|
||||||
|
func TestOnePeerIsAskedForOneThingAtATime(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newManagerFixture(t)
|
||||||
|
f.manager.SetMaxConcurrent(4)
|
||||||
|
|
||||||
|
p := fakeWithAlbum(1, "slskd", ".flac")
|
||||||
|
p.GrabGate = make(chan struct{})
|
||||||
|
|
||||||
|
f.manager.installProvider(Config{ID: 1, Kind: KindSlskd, Priority: 50}, p)
|
||||||
|
|
||||||
|
wait := grabEach(t, f, slskdCandidates(p, "alice", "alice", "alice"))
|
||||||
|
|
||||||
|
waitFor(t, func() bool { return p.GrabCallCount() >= 1 }, "no grab started")
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
|
||||||
|
if got := p.MaxParallelGrabs(); got != 1 {
|
||||||
|
t.Errorf("%d simultaneous grabs from one peer, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(p.GrabGate)
|
||||||
|
|
||||||
|
waitFor(t, func() bool { return p.GrabCallCount() == 3 }, "queued grabs never ran")
|
||||||
|
wait()
|
||||||
|
|
||||||
|
if n := f.manager.peerLocks.size(); n != 0 {
|
||||||
|
t.Errorf("%d peer locks left behind", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different users run at once, up to the daemon's cap — the point of
|
||||||
|
// the change: one slow peer no longer holds up every other.
|
||||||
|
func TestDifferentPeersRunTogether(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newManagerFixture(t)
|
||||||
|
f.manager.SetMaxConcurrent(8)
|
||||||
|
|
||||||
|
p := fakeWithAlbum(1, "slskd", ".flac")
|
||||||
|
p.GrabGate = make(chan struct{})
|
||||||
|
|
||||||
|
f.manager.installProvider(Config{ID: 1, Kind: KindSlskd, Priority: 50}, p)
|
||||||
|
|
||||||
|
wait := grabEach(t, f, slskdCandidates(p, "alice", "bob", "carol", "dave"))
|
||||||
|
|
||||||
|
waitFor(
|
||||||
|
t,
|
||||||
|
func() bool { return p.MaxParallelGrabs() >= kindConcurrency[KindSlskd] },
|
||||||
|
"different peers were serialised",
|
||||||
|
)
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
if got := p.MaxParallelGrabs(); got != kindConcurrency[KindSlskd] {
|
||||||
|
t.Errorf("%d simultaneous grabs, want the daemon cap %d", got, kindConcurrency[KindSlskd])
|
||||||
|
}
|
||||||
|
|
||||||
|
close(p.GrabGate)
|
||||||
|
wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlskdLocalFolders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := &slskd{downloadsPath: "/dl"}
|
||||||
|
|
||||||
|
got := s.localFolders(Candidate{Files: []CandidateFile{
|
||||||
|
{Path: `\m\The Wall\CD2\01 Hey You.flac`},
|
||||||
|
{Path: `\m\The Wall\CD1\01 In The Flesh.flac`},
|
||||||
|
{Path: `\m\The Wall\CD1\02 The Thin Ice.flac`},
|
||||||
|
}})
|
||||||
|
|
||||||
|
want := []string{filepath.Join("/dl", "CD1"), filepath.Join("/dl", "CD2")}
|
||||||
|
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||||
|
t.Errorf("localFolders = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two peers' "Greatest Hits" land in one slskd directory, so the second
|
||||||
|
// grab does not enqueue until the first has collected its files.
|
||||||
|
func TestSlskdSameFolderNameWaits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
s, downloads := newStubSlskd(t, stub)
|
||||||
|
|
||||||
|
c := Candidate{
|
||||||
|
Payload: map[string]string{"username": "bob"},
|
||||||
|
Files: []CandidateFile{
|
||||||
|
{Path: `\music\Greatest Hits\01 Intro.flac`, Size: 1, IsAudio: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
release, err := lockSlskdFolders(
|
||||||
|
context.Background(), []string{filepath.Join(downloads, "Greatest Hits")},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lock: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if _, err := s.Grab(ctx, c, t.TempDir(), nil); !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("Grab = %v, want it to wait on the held folder", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
release()
|
||||||
|
|
||||||
|
stub.mu.Lock()
|
||||||
|
posted := stub.posted
|
||||||
|
stub.mu.Unlock()
|
||||||
|
|
||||||
|
if posted {
|
||||||
|
t.Error("enqueued transfers into a folder another grab held")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -236,17 +236,18 @@ func Register(d Descriptor, c Constructor) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// concurrencyField describes the per-provider transfer limit, with help
|
// concurrencyField describes the per-provider transfer limit, with help
|
||||||
// text explaining why the default is what it is — a user who raises
|
// text explaining what the number means where it means something
|
||||||
// slskd from 1 to 8 and gets themselves queued behind every other
|
// unusual: on slskd it counts peers, since each peer is only ever asked
|
||||||
// Soulseek user deserves to have been warned.
|
// for one folder at a time whatever it is set to.
|
||||||
func concurrencyField(k Kind) Field {
|
func concurrencyField(k Kind) Field {
|
||||||
help := "Maximum simultaneous transfers from this client."
|
help := "Maximum simultaneous transfers from this client."
|
||||||
|
|
||||||
if k == KindSlskd {
|
if k == KindSlskd {
|
||||||
help = "Maximum simultaneous transfers. Soulseek peers serve " +
|
help = "How many Soulseek users to download from at once. " +
|
||||||
"one file at a time and queue or ban clients that ask for " +
|
"Each user is only ever asked for one album at a time, " +
|
||||||
"more, so 1 is both the polite setting and usually the " +
|
"since peers queue or ban clients that ask for more; " +
|
||||||
"fastest."
|
"this bounds how many different users are asked in " +
|
||||||
|
"parallel."
|
||||||
}
|
}
|
||||||
|
|
||||||
return Field{
|
return Field{
|
||||||
|
|||||||
+1031
-90
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSlskdLive runs the provider against a real slskd daemon. It is
|
||||||
|
// skipped unless YJ_SLSKD_URL, YJ_SLSKD_API_KEY and YJ_SLSKD_DOWNLOADS
|
||||||
|
// are set, and it downloads something only when YJ_SLSKD_GRAB=1 — then
|
||||||
|
// the smallest candidate the search returns, from whichever stranger
|
||||||
|
// is sharing it.
|
||||||
|
//
|
||||||
|
// Everything else here tests the provider against a stub written from
|
||||||
|
// reading slskd's source. This is where those readings are checked:
|
||||||
|
// the search options, the responses endpoint, the batch destination,
|
||||||
|
// the cancel.
|
||||||
|
//
|
||||||
|
// YJ_SLSKD_URL=http://localhost:5030 YJ_SLSKD_API_KEY=… \
|
||||||
|
// YJ_SLSKD_DOWNLOADS=/path/to/slskd/downloads YJ_SLSKD_GRAB=1 \
|
||||||
|
// go test -run TestSlskdLive -v ./backend/download/
|
||||||
|
func TestSlskdLive(t *testing.T) {
|
||||||
|
base, key, downloads := os.Getenv("YJ_SLSKD_URL"),
|
||||||
|
os.Getenv("YJ_SLSKD_API_KEY"), os.Getenv("YJ_SLSKD_DOWNLOADS")
|
||||||
|
if base == "" || key == "" || downloads == "" {
|
||||||
|
t.Skip(
|
||||||
|
"set YJ_SLSKD_URL, YJ_SLSKD_API_KEY and YJ_SLSKD_DOWNLOADS to run against a real slskd",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := os.Getenv("YJ_SLSKD_QUERY")
|
||||||
|
if query == "" {
|
||||||
|
query = "Radiohead OK Computer"
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := newSlskd(
|
||||||
|
Config{
|
||||||
|
ID: 1, Kind: KindSlskd, Name: "live", Enabled: true,
|
||||||
|
Settings: map[string]string{"url": base, "downloadsPath": downloads},
|
||||||
|
},
|
||||||
|
func(string) (string, error) { return key, nil },
|
||||||
|
slogDiscard(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newSlskd: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, ok := p.(*slskd)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("provider is %T", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := s.Check(ctx); err != nil {
|
||||||
|
t.Fatalf("Check: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
|
|
||||||
|
got, err := s.Search(ctx, Download{Query: query})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf(
|
||||||
|
"search %q: %d candidates in %s",
|
||||||
|
query,
|
||||||
|
len(got),
|
||||||
|
time.Since(started).Round(time.Millisecond),
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(got) == 0 {
|
||||||
|
t.Fatal("no candidates; try a more common YJ_SLSKD_QUERY")
|
||||||
|
}
|
||||||
|
|
||||||
|
timed := 0
|
||||||
|
|
||||||
|
for _, c := range got {
|
||||||
|
for _, f := range c.Files {
|
||||||
|
if f.LengthMillis > 0 {
|
||||||
|
timed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("%d files carry a length", timed)
|
||||||
|
|
||||||
|
if os.Getenv("YJ_SLSKD_GRAB") != "1" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
smallest := slices.MinFunc(got, func(a, b Candidate) int {
|
||||||
|
return int(a.TotalSize - b.TotalSize)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Logf("grabbing %q from %s (%d files, %d bytes)",
|
||||||
|
smallest.Title, smallest.Origin, len(smallest.Files), smallest.TotalSize)
|
||||||
|
|
||||||
|
s.stallAfter = 3 * time.Minute
|
||||||
|
|
||||||
|
gctx, cancel := context.WithTimeout(ctx, 15*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
res, err := s.Grab(gctx, smallest, t.TempDir(), func(p Progress) {
|
||||||
|
t.Logf("%s: %d/%d bytes", p.Phase, p.Current, p.Total)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// A stranger going offline is not a defect; what matters is
|
||||||
|
// that the transfers were cancelled, which slskd's UI shows.
|
||||||
|
t.Fatalf("Grab: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("batches: %v", s.batches.Load() == batchesSupported)
|
||||||
|
|
||||||
|
for _, f := range res.Files {
|
||||||
|
info, err := os.Stat(f)
|
||||||
|
if err != nil || info.Size() == 0 {
|
||||||
|
t.Errorf("collected %s is missing or empty: %v", f, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries, _ := os.ReadDir(filepath.Join(downloads, slskdDestRoot)); len(entries) != 0 {
|
||||||
|
t.Errorf("%d batch folders left in slskd's downloads", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -31,11 +33,45 @@ type slskdStub struct {
|
|||||||
transfers [][]slskdTransfer
|
transfers [][]slskdTransfer
|
||||||
pollCount int
|
pollCount int
|
||||||
|
|
||||||
|
// before is what the downloads endpoint reports until something is
|
||||||
|
// enqueued: records slskd already held from earlier attempts.
|
||||||
|
before []slskdTransfer
|
||||||
|
|
||||||
// enqueued records what was requested for download.
|
// enqueued records what was requested for download.
|
||||||
enqueued []map[string]any
|
enqueued []map[string]any
|
||||||
|
posted bool
|
||||||
|
|
||||||
|
// paths records the escaped path of every transfers call, and
|
||||||
|
// cancelled the escaped request URI of every DELETE.
|
||||||
|
paths []string
|
||||||
|
cancelled []string
|
||||||
|
|
||||||
// unauthorized makes every call return 401.
|
// unauthorized makes every call return 401.
|
||||||
unauthorized bool
|
unauthorized bool
|
||||||
|
|
||||||
|
// searches records every search request body, and searchGets the
|
||||||
|
// request URI of every search GET.
|
||||||
|
searches []map[string]any
|
||||||
|
searchGets []string
|
||||||
|
|
||||||
|
// noResponsesEndpoint makes /searches/{id}/responses 404, as an
|
||||||
|
// older daemon would.
|
||||||
|
noResponsesEndpoint bool
|
||||||
|
|
||||||
|
// batches makes the daemon take batch downloads, as 0.26 does.
|
||||||
|
// Without it the batch endpoint answers 400, which is what an older
|
||||||
|
// daemon's per-user route does with a batch body. batchBodies
|
||||||
|
// records each batch, and delivered is written into its destination
|
||||||
|
// under downloads when it is enqueued, keyed by file base name.
|
||||||
|
batches bool
|
||||||
|
batchBodies []map[string]any
|
||||||
|
|
||||||
|
// positions is what the queue-position endpoint answers, in order;
|
||||||
|
// the last repeats. positionAsks counts the calls.
|
||||||
|
positions []int
|
||||||
|
positionAsks int
|
||||||
|
delivered map[string]string
|
||||||
|
downloads string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSlskdStub(t *testing.T) *slskdStub {
|
func newSlskdStub(t *testing.T) *slskdStub {
|
||||||
@@ -57,6 +93,16 @@ func newSlskdStub(t *testing.T) *slskdStub {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
t.Errorf("decode search body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.searches = append(s.searches, body)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -73,8 +119,26 @@ func newSlskdStub(t *testing.T) *slskdStub {
|
|||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
responses := s.responses
|
responses := s.responses
|
||||||
|
noEndpoint := s.noResponsesEndpoint
|
||||||
|
s.searchGets = append(s.searchGets, r.URL.RequestURI())
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/responses") {
|
||||||
|
if noEndpoint {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(t, w, responses)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.URL.Query().Get("includeResponses") != "true" {
|
||||||
|
responses = nil
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(t, w, slskdSearch{
|
writeJSON(t, w, slskdSearch{
|
||||||
ID: "search-1",
|
ID: "search-1",
|
||||||
IsComplete: true,
|
IsComplete: true,
|
||||||
@@ -87,7 +151,35 @@ func newSlskdStub(t *testing.T) *slskdStub {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == http.MethodPost {
|
s.mu.Lock()
|
||||||
|
s.paths = append(s.paths, r.URL.EscapedPath())
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/position") {
|
||||||
|
s.mu.Lock()
|
||||||
|
idx := min(s.positionAsks, len(s.positions)-1)
|
||||||
|
s.positionAsks++
|
||||||
|
place := 0
|
||||||
|
|
||||||
|
if idx >= 0 {
|
||||||
|
place = s.positions[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
writeJSON(t, w, place)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/batches") {
|
||||||
|
s.enqueueBatch(t, w, r)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPost:
|
||||||
var body []map[string]any
|
var body []map[string]any
|
||||||
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
@@ -96,15 +188,42 @@ func newSlskdStub(t *testing.T) *slskdStub {
|
|||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.enqueued = body
|
s.enqueued = body
|
||||||
|
s.posted = true
|
||||||
|
|
||||||
|
for _, file := range body {
|
||||||
|
name, _ := file["filename"].(string)
|
||||||
|
norm := strings.ReplaceAll(name, `\`, "/")
|
||||||
|
s.write(t, path.Base(path.Dir(norm)), path.Base(norm))
|
||||||
|
}
|
||||||
|
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
|
||||||
|
return
|
||||||
|
case http.MethodDelete:
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cancelled = append(s.cancelled, r.URL.RequestURI())
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|
||||||
|
if !s.posted {
|
||||||
|
before := s.before
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
writeJSON(t, w, map[string]any{
|
||||||
|
"directories": []map[string]any{{"files": before}},
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
idx := s.pollCount
|
idx := s.pollCount
|
||||||
if idx >= len(s.transfers) {
|
if idx >= len(s.transfers) {
|
||||||
idx = len(s.transfers) - 1
|
idx = len(s.transfers) - 1
|
||||||
@@ -130,6 +249,89 @@ func newSlskdStub(t *testing.T) *slskdStub {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enqueueBatch answers the batch endpoint.
|
||||||
|
func (s *slskdStub) enqueueBatch(t *testing.T, w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if !s.batches {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
t.Errorf("decode batch body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.batchBodies = append(s.batchBodies, body)
|
||||||
|
s.posted = true
|
||||||
|
|
||||||
|
files, _ := body["files"].([]any)
|
||||||
|
options, _ := body["options"].(map[string]any)
|
||||||
|
dest, _ := options["destination"].(string)
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
file, _ := f.(map[string]any)
|
||||||
|
s.enqueued = append(s.enqueued, file)
|
||||||
|
|
||||||
|
name, _ := file["filename"].(string)
|
||||||
|
base := path.Base(strings.ReplaceAll(name, `\`, "/"))
|
||||||
|
|
||||||
|
s.write(t, filepath.FromSlash(dest), base)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliver names the files that arrive once enqueued.
|
||||||
|
func (s *slskdStub) deliver(names ...string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.delivered == nil {
|
||||||
|
s.delivered = map[string]string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range names {
|
||||||
|
s.delivered[n] = "audio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// write puts a delivered file where slskd would: under dir in the
|
||||||
|
// downloads folder, renamed name_<ticks>.ext when the name is taken, as
|
||||||
|
// slskd's default Destination.Exists does. Callers hold s.mu.
|
||||||
|
func (s *slskdStub) write(t *testing.T, dir, base string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
content, ok := s.delivered[base]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
full := filepath.Join(s.downloads, dir)
|
||||||
|
if err := os.MkdirAll(full, 0o750); err != nil {
|
||||||
|
t.Errorf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
target := filepath.Join(full, base)
|
||||||
|
if _, err := os.Stat(target); err == nil {
|
||||||
|
ext := filepath.Ext(base)
|
||||||
|
target = filepath.Join(
|
||||||
|
full,
|
||||||
|
strings.TrimSuffix(base, ext)+"_"+strconv.FormatInt(time.Now().UnixNano(), 10)+ext,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(target, []byte(content), 0o600); err != nil {
|
||||||
|
t.Errorf("write: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// reject enforces API-key auth like the real daemon.
|
// reject enforces API-key auth like the real daemon.
|
||||||
func (s *slskdStub) reject(w http.ResponseWriter, r *http.Request) bool {
|
func (s *slskdStub) reject(w http.ResponseWriter, r *http.Request) bool {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -162,6 +364,10 @@ func newStubSlskd(t *testing.T, stub *slskdStub) (*slskd, string) {
|
|||||||
|
|
||||||
downloads := t.TempDir()
|
downloads := t.TempDir()
|
||||||
|
|
||||||
|
stub.mu.Lock()
|
||||||
|
stub.downloads = downloads
|
||||||
|
stub.mu.Unlock()
|
||||||
|
|
||||||
p, err := newSlskd(
|
p, err := newSlskd(
|
||||||
Config{
|
Config{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
@@ -191,6 +397,13 @@ func newStubSlskd(t *testing.T, stub *slskdStub) (*slskd, string) {
|
|||||||
s.searchWait = 200 * time.Millisecond
|
s.searchWait = 200 * time.Millisecond
|
||||||
s.transferPoll = time.Millisecond
|
s.transferPoll = time.Millisecond
|
||||||
|
|
||||||
|
// Long enough that no existing test trips them by accident; the
|
||||||
|
// tests about stalls and absences set their own.
|
||||||
|
s.stallAfter = time.Minute
|
||||||
|
s.absentGrace = time.Minute
|
||||||
|
s.positionPoll = time.Millisecond
|
||||||
|
s.queueCeiling = time.Hour
|
||||||
|
|
||||||
return s, downloads
|
return s, downloads
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,24 +607,9 @@ func TestSlskdGrabCollectsFromDownloadsFolder(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
s, downloads := newStubSlskd(t, stub)
|
s, _ := newStubSlskd(t, stub)
|
||||||
|
|
||||||
// slskd writes into <downloads>/<folder>/<file>.
|
stub.deliver("01 Airbag.flac", "02 Paranoid Android.flac")
|
||||||
folder := filepath.Join(downloads, "OK Computer")
|
|
||||||
if err := os.MkdirAll(folder, 0o750); err != nil {
|
|
||||||
t.Fatalf("mkdir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, name := range []string{
|
|
||||||
"01 Airbag.flac",
|
|
||||||
"02 Paranoid Android.flac",
|
|
||||||
} {
|
|
||||||
if err := os.WriteFile(
|
|
||||||
filepath.Join(folder, name), []byte("audio"), 0o600,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("write: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c := Candidate{
|
c := Candidate{
|
||||||
ID: "slskd:peer:OK Computer",
|
ID: "slskd:peer:OK Computer",
|
||||||
@@ -470,18 +668,9 @@ func TestSlskdGrabToleratesPartialFailure(t *testing.T) {
|
|||||||
{Filename: `\s\Album\02 B.flac`, State: "Completed, Errored"},
|
{Filename: `\s\Album\02 B.flac`, State: "Completed, Errored"},
|
||||||
}}
|
}}
|
||||||
|
|
||||||
s, downloads := newStubSlskd(t, stub)
|
s, _ := newStubSlskd(t, stub)
|
||||||
|
|
||||||
folder := filepath.Join(downloads, "Album")
|
stub.deliver("01 A.flac")
|
||||||
if err := os.MkdirAll(folder, 0o750); err != nil {
|
|
||||||
t.Fatalf("mkdir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(
|
|
||||||
filepath.Join(folder, "01 A.flac"), []byte("audio"), 0o600,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("write: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c := Candidate{
|
c := Candidate{
|
||||||
Files: []CandidateFile{
|
Files: []CandidateFile{
|
||||||
@@ -565,3 +754,284 @@ func TestSlskdRequiresConfiguration(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// slskdAlbum is a two-file candidate from peer, whose arrived files
|
||||||
|
// the stub writes where slskd would once they are enqueued.
|
||||||
|
func slskdAlbum(t *testing.T, stub *slskdStub, peer string, arrived ...string) Candidate {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
stub.deliver(arrived...)
|
||||||
|
|
||||||
|
return Candidate{
|
||||||
|
Files: []CandidateFile{
|
||||||
|
{Path: `\s\Album\01 A.flac`, Size: 500, IsAudio: true},
|
||||||
|
{Path: `\s\Album\02 B.flac`, Size: 500, IsAudio: true},
|
||||||
|
},
|
||||||
|
TotalSize: 1000,
|
||||||
|
Payload: map[string]string{"username": peer},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cancelledURIs returns what the stub was asked to cancel.
|
||||||
|
func (s *slskdStub) cancelledURIs() []string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
return append([]string(nil), s.cancelled...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A peer that queues us and never sends a byte is given up on, and the
|
||||||
|
// queued transfers are cancelled in slskd rather than left to start
|
||||||
|
// hours later for a request nobody is waiting on.
|
||||||
|
func TestSlskdGrabGivesUpOnAStalledPeer(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
stub.transfers = [][]slskdTransfer{{
|
||||||
|
{ID: "t1", Filename: `\s\Album\01 A.flac`, State: "Queued, Remotely"},
|
||||||
|
{ID: "t2", Filename: `\s\Album\02 B.flac`, State: "Queued, Remotely"},
|
||||||
|
}}
|
||||||
|
|
||||||
|
s, _ := newStubSlskd(t, stub)
|
||||||
|
s.stallAfter = 30 * time.Millisecond
|
||||||
|
|
||||||
|
_, err := s.Grab(
|
||||||
|
context.Background(), slskdAlbum(t, stub, "peer"), t.TempDir(), nil,
|
||||||
|
)
|
||||||
|
if !errors.Is(err, ErrSlskdTimeout) {
|
||||||
|
t.Fatalf("error = %v, want ErrSlskdTimeout", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := stub.cancelledURIs()
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("cancelled %v, want both queued transfers", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, uri := range got {
|
||||||
|
if !strings.HasSuffix(uri, "?remove=true") {
|
||||||
|
t.Errorf("cancel %s does not remove the record", uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A folder that stalls on its last track goes forward with what
|
||||||
|
// arrived, the same as one whose last track failed; the importer's
|
||||||
|
// completeness check decides whether that is enough.
|
||||||
|
func TestSlskdGrabKeepsWhatArrivedBeforeAStall(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
stub.transfers = [][]slskdTransfer{{
|
||||||
|
{
|
||||||
|
ID: "t1", Filename: `\s\Album\01 A.flac`,
|
||||||
|
State: "Completed, Succeeded", BytesTransferred: 500,
|
||||||
|
},
|
||||||
|
{ID: "t2", Filename: `\s\Album\02 B.flac`, State: "Queued, Remotely"},
|
||||||
|
}}
|
||||||
|
|
||||||
|
s, _ := newStubSlskd(t, stub)
|
||||||
|
s.stallAfter = 30 * time.Millisecond
|
||||||
|
|
||||||
|
got, err := s.Grab(
|
||||||
|
context.Background(),
|
||||||
|
slskdAlbum(t, stub, "peer", "01 A.flac"),
|
||||||
|
t.TempDir(), nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Grab: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Files) != 1 {
|
||||||
|
t.Errorf("collected %d files, want the 1 that arrived", len(got.Files))
|
||||||
|
}
|
||||||
|
|
||||||
|
if cancelled := stub.cancelledURIs(); len(cancelled) != 1 ||
|
||||||
|
!strings.Contains(cancelled[0], "/t2") {
|
||||||
|
t.Errorf("cancelled %v, want only the stalled t2", cancelled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress is what holds the stall timer off. A transfer that keeps
|
||||||
|
// moving bytes is never abandoned, however long it takes.
|
||||||
|
func TestSlskdGrabWaitsOnATransferThatIsMoving(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
|
||||||
|
for b := int64(1); b <= 100; b++ {
|
||||||
|
stub.transfers = append(stub.transfers, []slskdTransfer{
|
||||||
|
{ID: "t1", Filename: `\s\Album\01 A.flac`, State: "InProgress", BytesTransferred: b},
|
||||||
|
{ID: "t2", Filename: `\s\Album\02 B.flac`, State: "Queued, Remotely"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
stub.transfers = append(stub.transfers, []slskdTransfer{
|
||||||
|
{
|
||||||
|
ID: "t1",
|
||||||
|
Filename: `\s\Album\01 A.flac`,
|
||||||
|
State: "Completed, Succeeded",
|
||||||
|
BytesTransferred: 500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "t2",
|
||||||
|
Filename: `\s\Album\02 B.flac`,
|
||||||
|
State: "Completed, Succeeded",
|
||||||
|
BytesTransferred: 500,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
s, _ := newStubSlskd(t, stub)
|
||||||
|
// A hundred polls take several times the stall window; each one
|
||||||
|
// moves a byte. The window is kept well above one poll so a
|
||||||
|
// descheduled test runner does not read as a stall.
|
||||||
|
s.transferPoll = 5 * time.Millisecond
|
||||||
|
s.stallAfter = 150 * time.Millisecond
|
||||||
|
|
||||||
|
got, err := s.Grab(
|
||||||
|
context.Background(),
|
||||||
|
slskdAlbum(t, stub, "peer", "01 A.flac", "02 B.flac"),
|
||||||
|
t.TempDir(), nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Grab: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Files) != 2 {
|
||||||
|
t.Errorf("collected %d files, want 2", len(got.Files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file slskd never lists was refused at enqueue and will never reach
|
||||||
|
// a terminal state. It counts as failed once the grace period is up,
|
||||||
|
// rather than being waited on until the six-hour ceiling.
|
||||||
|
func TestSlskdGrabCountsAnUnlistedFileAsFailed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
stub.transfers = [][]slskdTransfer{{
|
||||||
|
{
|
||||||
|
ID: "t1", Filename: `\s\Album\01 A.flac`,
|
||||||
|
State: "Completed, Succeeded", BytesTransferred: 500,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
s, _ := newStubSlskd(t, stub)
|
||||||
|
s.absentGrace = 20 * time.Millisecond
|
||||||
|
|
||||||
|
got, err := s.Grab(
|
||||||
|
context.Background(),
|
||||||
|
slskdAlbum(t, stub, "peer", "01 A.flac"),
|
||||||
|
t.TempDir(), nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Grab: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Files) != 1 {
|
||||||
|
t.Errorf("collected %d files, want 1", len(got.Files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A finished record left by an earlier attempt at the same file is not
|
||||||
|
// this attempt's answer. Without the snapshot it would fail the grab on
|
||||||
|
// the first poll, before the new transfer had started.
|
||||||
|
func TestSlskdGrabIgnoresAnEarlierAttemptsRecord(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stale := slskdTransfer{
|
||||||
|
ID: "old", Filename: `\s\Album\01 A.flac`, State: "Completed, Errored",
|
||||||
|
}
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
stub.before = []slskdTransfer{stale}
|
||||||
|
stub.transfers = [][]slskdTransfer{
|
||||||
|
{stale},
|
||||||
|
{
|
||||||
|
stale,
|
||||||
|
{
|
||||||
|
ID: "new", Filename: `\s\Album\01 A.flac`,
|
||||||
|
State: "Completed, Succeeded", BytesTransferred: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
s, _ := newStubSlskd(t, stub)
|
||||||
|
|
||||||
|
c := slskdAlbum(t, stub, "peer", "01 A.flac")
|
||||||
|
c.Files = c.Files[:1]
|
||||||
|
|
||||||
|
got, err := s.Grab(context.Background(), c, t.TempDir(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Grab: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Files) != 1 {
|
||||||
|
t.Errorf("collected %d files, want 1", len(got.Files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancelling the download cancels the transfer in slskd too. The
|
||||||
|
// cleanup must not inherit the cancelled context, or it is never sent.
|
||||||
|
func TestSlskdGrabCancelsTransfersWhenTheCallerGivesUp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
stub.transfers = [][]slskdTransfer{{
|
||||||
|
{ID: "t1", Filename: `\s\Album\01 A.flac`, State: "InProgress", BytesTransferred: 10},
|
||||||
|
{ID: "t2", Filename: `\s\Album\02 B.flac`, State: "Queued, Remotely"},
|
||||||
|
}}
|
||||||
|
|
||||||
|
s, _ := newStubSlskd(t, stub)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := s.Grab(ctx, slskdAlbum(t, stub, "peer"), t.TempDir(), nil)
|
||||||
|
if !errors.Is(err, ErrSlskdTimeout) {
|
||||||
|
t.Fatalf("error = %v, want ErrSlskdTimeout", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := stub.cancelledURIs(); len(got) != 2 {
|
||||||
|
t.Errorf("cancelled %v, want both live transfers", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soulseek usernames carry spaces and punctuation; spliced raw into the
|
||||||
|
// path, a name with a slash addresses a different endpoint entirely.
|
||||||
|
func TestSlskdEscapesTheUsername(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
stub.transfers = [][]slskdTransfer{{
|
||||||
|
{
|
||||||
|
ID: "t1", Filename: `\s\Album\01 A.flac`,
|
||||||
|
State: "Completed, Succeeded", BytesTransferred: 500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "t2", Filename: `\s\Album\02 B.flac`,
|
||||||
|
State: "Completed, Succeeded", BytesTransferred: 500,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
s, _ := newStubSlskd(t, stub)
|
||||||
|
|
||||||
|
if _, err := s.Grab(
|
||||||
|
context.Background(),
|
||||||
|
slskdAlbum(t, stub, "dj a/b", "01 A.flac", "02 B.flac"),
|
||||||
|
t.TempDir(), nil,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("Grab: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stub.mu.Lock()
|
||||||
|
paths := append([]string(nil), stub.paths...)
|
||||||
|
stub.mu.Unlock()
|
||||||
|
|
||||||
|
for _, p := range paths {
|
||||||
|
// The batch endpoint carries the name in its body.
|
||||||
|
if p != "/api/v0/transfers/downloads/dj%20a%2Fb" &&
|
||||||
|
p != "/api/v0/transfers/downloads/batches" {
|
||||||
|
t.Errorf("transfers call went to %s", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+104
-18
@@ -35,6 +35,15 @@ const (
|
|||||||
weightArtistFit = 0.12
|
weightArtistFit = 0.12
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Match sub-weights when the candidate's durations are known. Duration
|
||||||
|
// takes its weight from title fit, the signal it corroborates: a title
|
||||||
|
// says which song a file claims to be, a length says whether it is that
|
||||||
|
// recording — the right edit, the whole file, not the live take.
|
||||||
|
const (
|
||||||
|
timedWeightTitleFit = 0.25
|
||||||
|
timedWeightDurationFit = 0.15
|
||||||
|
)
|
||||||
|
|
||||||
// Quality sub-weights. Each set sums to 1.0.
|
// Quality sub-weights. Each set sums to 1.0.
|
||||||
//
|
//
|
||||||
// There are two of them because a stated preference changes what the
|
// There are two of them because a stated preference changes what the
|
||||||
@@ -319,13 +328,13 @@ func Score(dl Download, c Candidate, priority int, prefs AutoDownloadPrefs) Cand
|
|||||||
|
|
||||||
audio := c.AudioFiles()
|
audio := c.AudioFiles()
|
||||||
|
|
||||||
matched, titleFit := matchFiles(audio, dl.Expected)
|
a := alignFiles(audio, dl.Expected)
|
||||||
|
|
||||||
// Write the alignment back so the picker can show which file maps
|
// Write the alignment back so the picker can show which file maps
|
||||||
// to which track.
|
// to which track.
|
||||||
c.Files = mergeMatched(c.Files, matched)
|
c.Files = mergeMatched(c.Files, a.files)
|
||||||
|
|
||||||
c.Match = scoreMatch(dl, c, audio, titleFit)
|
c.Match = scoreMatch(dl, c, audio, a)
|
||||||
c.Quality = scoreQuality(
|
c.Quality = scoreQuality(
|
||||||
c, audio, priority, prefs, dl.runtimeMillis(),
|
c, audio, priority, prefs, dl.runtimeMillis(),
|
||||||
)
|
)
|
||||||
@@ -340,14 +349,21 @@ func scoreMatch(
|
|||||||
dl Download,
|
dl Download,
|
||||||
c Candidate,
|
c Candidate,
|
||||||
audio []CandidateFile,
|
audio []CandidateFile,
|
||||||
titleFit float64,
|
a alignment,
|
||||||
) MatchScore {
|
) MatchScore {
|
||||||
m := MatchScore{
|
m := MatchScore{
|
||||||
Anchored: dl.Anchored(),
|
Anchored: dl.Anchored(),
|
||||||
TitleFit: titleFit,
|
TitleFit: a.titleFit,
|
||||||
|
DurationFit: a.durationFit,
|
||||||
|
|
||||||
|
// Durations count once at least half the aligned pairs state
|
||||||
|
// one; a single timed pair would be a coin toss carrying 15%.
|
||||||
|
DurationKnown: a.timedPairs > 0 && a.timedPairs*2 >= a.aligned,
|
||||||
}
|
}
|
||||||
|
|
||||||
m.Completeness = completeness(len(audio), len(dl.Expected))
|
m.Completeness = completeness(
|
||||||
|
alignedCount(c.Files), len(audio), len(dl.Expected),
|
||||||
|
)
|
||||||
|
|
||||||
// The candidate's own title, and the folder its files sit in, are
|
// The candidate's own title, and the folder its files sit in, are
|
||||||
// two independent guesses at the album name. Take the better one:
|
// two independent guesses at the album name. Take the better one:
|
||||||
@@ -367,9 +383,16 @@ func scoreMatch(
|
|||||||
// With no expected tracklist there is no title signal at all, so
|
// With no expected tracklist there is no title signal at all, so
|
||||||
// redistribute its weight onto the album/artist evidence rather
|
// redistribute its weight onto the album/artist evidence rather
|
||||||
// than scoring every free-text result as half-wrong.
|
// than scoring every free-text result as half-wrong.
|
||||||
if len(dl.Expected) == 0 {
|
switch {
|
||||||
|
case len(dl.Expected) == 0:
|
||||||
m.Overall = 0.55*m.AlbumFit + 0.45*m.ArtistFit
|
m.Overall = 0.55*m.AlbumFit + 0.45*m.ArtistFit
|
||||||
} else {
|
case m.DurationKnown:
|
||||||
|
m.Overall = timedWeightTitleFit*m.TitleFit +
|
||||||
|
timedWeightDurationFit*m.DurationFit +
|
||||||
|
weightCompleteness*m.Completeness +
|
||||||
|
weightAlbumFit*m.AlbumFit +
|
||||||
|
weightArtistFit*m.ArtistFit
|
||||||
|
default:
|
||||||
m.Overall = weightTitleFit*m.TitleFit +
|
m.Overall = weightTitleFit*m.TitleFit +
|
||||||
weightCompleteness*m.Completeness +
|
weightCompleteness*m.Completeness +
|
||||||
weightAlbumFit*m.AlbumFit +
|
weightAlbumFit*m.AlbumFit +
|
||||||
@@ -415,30 +438,54 @@ func artistFit(want string, c Candidate) float64 {
|
|||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
// completeness scores audio file count against the expected track
|
// completeness scores how much of the expected tracklist a candidate
|
||||||
// count. Extra files are penalized far more gently than missing ones:
|
// covers. Extra files are penalized far more gently than missing ones:
|
||||||
// a folder with bonus tracks or a stray intro is still the album, while
|
// a folder with bonus tracks or a stray intro is still the album, while
|
||||||
// a folder missing half the tracks is not.
|
// a folder missing half the tracks is not.
|
||||||
func completeness(got, want int) float64 {
|
//
|
||||||
|
// **Coverage is counted in aligned tracks, not in files.** It used to
|
||||||
|
// be the audio file count, so any ten files scored full marks against
|
||||||
|
// a ten-track album whether or not they were its tracks — and since
|
||||||
|
// title fit is the mean over the files that *did* align, a folder where
|
||||||
|
// three titles matched read as a near-perfect candidate on both counts.
|
||||||
|
// `aligned` is how many files matchFiles assigned to an expected track;
|
||||||
|
// `audio` still sets the penalty for extras, because a folder of thirty
|
||||||
|
// files holding the ten wanted is a worse copy than one holding ten.
|
||||||
|
func completeness(aligned, audio, want int) float64 {
|
||||||
if want == 0 {
|
if want == 0 {
|
||||||
if got > 0 {
|
if audio > 0 {
|
||||||
return 0.5
|
return 0.5
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if got == 0 {
|
if aligned == 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if got >= want {
|
cover := float64(min(aligned, want)) / float64(want)
|
||||||
extra := float64(got-want) / float64(want)
|
|
||||||
|
|
||||||
return math.Max(0.75, 1.0-0.25*extra)
|
if audio > want {
|
||||||
|
extra := float64(audio-want) / float64(want)
|
||||||
|
cover *= math.Max(0.75, 1.0-0.25*extra)
|
||||||
}
|
}
|
||||||
|
|
||||||
return float64(got) / float64(want)
|
return cover
|
||||||
|
}
|
||||||
|
|
||||||
|
// alignedCount is how many audio files were assigned to an expected
|
||||||
|
// track.
|
||||||
|
func alignedCount(files []CandidateFile) int {
|
||||||
|
n := 0
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
if f.IsAudio && f.MatchedTo != 0 {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// scoreQuality answers whether this is a good copy.
|
// scoreQuality answers whether this is a good copy.
|
||||||
@@ -735,6 +782,45 @@ func AutoPickVeto(
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// autoAcceptable reports whether auto-pick may take this one candidate
|
||||||
|
// without asking: the request is anchored to a tracklist, and the
|
||||||
|
// candidate is inside the user's guardrails and clears the match and
|
||||||
|
// quality bars. It is AutoPickVeto's test applied to a single
|
||||||
|
// candidate, which is what falling back to a second choice needs.
|
||||||
|
func autoAcceptable(dl Download, c Candidate, prefs AutoDownloadPrefs) bool {
|
||||||
|
return dl.Anchored() &&
|
||||||
|
len(dl.Expected) > 0 &&
|
||||||
|
prefs.eligible(c, dl.runtimeMillis()) &&
|
||||||
|
c.Match.Overall >= minMatch &&
|
||||||
|
c.Quality.Overall >= minQuality
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoPick returns the candidate auto-pick takes: the best-ranked one
|
||||||
|
// it may take at all.
|
||||||
|
//
|
||||||
|
// That is not `ranked[0]`. AutoPickVeto judges the best candidate
|
||||||
|
// *inside* the guardrails, so when the overall best is outside them —
|
||||||
|
// over the size ceiling, say — the veto passes on the strength of the
|
||||||
|
// second, and grabbing the first would download exactly the copy the
|
||||||
|
// user said not to take unattended.
|
||||||
|
func autoPick(
|
||||||
|
dl Download,
|
||||||
|
ranked []Candidate,
|
||||||
|
prefs AutoDownloadPrefs,
|
||||||
|
) (Candidate, bool) {
|
||||||
|
if AutoPickVeto(dl, ranked, prefs) != "" {
|
||||||
|
return Candidate{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range ranked {
|
||||||
|
if autoAcceptable(dl, c, prefs) {
|
||||||
|
return c, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Candidate{}, false
|
||||||
|
}
|
||||||
|
|
||||||
// mergeMatched copies MatchedTo assignments from the audio-only slice
|
// mergeMatched copies MatchedTo assignments from the audio-only slice
|
||||||
// back onto the full file list.
|
// back onto the full file list.
|
||||||
func mergeMatched(all, matched []CandidateFile) []CandidateFile {
|
func mergeMatched(all, matched []CandidateFile) []CandidateFile {
|
||||||
|
|||||||
@@ -282,28 +282,32 @@ func TestCompleteness(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
got int
|
aligned int
|
||||||
|
audio int
|
||||||
want int
|
want int
|
||||||
minScore float64
|
minScore float64
|
||||||
maxScore float64
|
maxScore float64
|
||||||
}{
|
}{
|
||||||
{"exact", 10, 10, 1.0, 1.0},
|
{"exact", 10, 10, 10, 1.0, 1.0},
|
||||||
{"half missing", 5, 10, 0.49, 0.51},
|
{"half missing", 5, 5, 10, 0.49, 0.51},
|
||||||
{"one bonus track", 11, 10, 0.95, 1.0},
|
{"one bonus track", 10, 11, 10, 0.95, 1.0},
|
||||||
{"double", 20, 10, 0.74, 0.76},
|
{"double", 10, 20, 10, 0.74, 0.76},
|
||||||
{"nothing", 0, 10, 0, 0},
|
{"nothing", 0, 0, 10, 0, 0},
|
||||||
{"no expectation", 5, 0, 0.5, 0.5},
|
{"no expectation", 0, 5, 0, 0.5, 0.5},
|
||||||
|
// Ten files are not ten tracks: three that align are three.
|
||||||
|
{"right count, wrong tracks", 3, 10, 10, 0.29, 0.31},
|
||||||
|
{"files that align to nothing", 0, 10, 10, 0, 0},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
got := completeness(tt.got, tt.want)
|
got := completeness(tt.aligned, tt.audio, tt.want)
|
||||||
if got < tt.minScore || got > tt.maxScore {
|
if got < tt.minScore || got > tt.maxScore {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"completeness(%d, %d) = %f, want in [%f, %f]",
|
"completeness(%d, %d, %d) = %f, want in [%f, %f]",
|
||||||
tt.got, tt.want, got, tt.minScore, tt.maxScore,
|
tt.aligned, tt.audio, tt.want, got, tt.minScore, tt.maxScore,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What Soulseek is asked, how, and what is kept from the answer (#271).
|
||||||
|
|
||||||
|
func TestSlskdQueries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
dl Download
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "a plain request is searched once",
|
||||||
|
dl: Download{Artist: "Radiohead", Album: "OK Computer"},
|
||||||
|
want: []string{"Radiohead OK Computer"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an edition qualifier gets a second query without it",
|
||||||
|
dl: Download{Artist: "Radiohead", Album: "OK Computer (Collector's Edition)"},
|
||||||
|
want: []string{
|
||||||
|
"Radiohead OK Computer (Collector's Edition)",
|
||||||
|
"Radiohead OK Computer",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a trailing remaster note",
|
||||||
|
dl: Download{Artist: "Pink Floyd", Album: "Animals - 2018 Remaster"},
|
||||||
|
want: []string{
|
||||||
|
"Pink Floyd Animals - 2018 Remaster",
|
||||||
|
"Pink Floyd Animals",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a leading dash would be an exclusion",
|
||||||
|
dl: Download{Artist: "Mocky", Album: "-ism"},
|
||||||
|
want: []string{"Mocky -ism", "Mocky ism"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a compilation is not searched by its placeholder artist",
|
||||||
|
dl: Download{Artist: "Various Artists", Album: "Pulp Fiction"},
|
||||||
|
want: []string{"Various Artists Pulp Fiction", "Pulp Fiction"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "what the user typed is searched as written",
|
||||||
|
dl: Download{Query: "ok computer (deluxe)", Album: "OK Computer (Deluxe)"},
|
||||||
|
want: []string{"ok computer (deluxe)"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := slskdQueries(tc.dl)
|
||||||
|
if strings.Join(got, "|") != strings.Join(tc.want, "|") {
|
||||||
|
t.Errorf("slskdQueries = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both queries run, the options are stated rather than left to the
|
||||||
|
// daemon's defaults, and a folder both queries found is one candidate.
|
||||||
|
func TestSlskdSearchRunsBothQueriesAndMerges(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
stub.responses = []slskdResponse{{
|
||||||
|
Username: "peer",
|
||||||
|
Files: []slskdFile{
|
||||||
|
{Filename: `\m\Radiohead - OK Computer\01 Airbag.flac`, Size: 1, Length: 284},
|
||||||
|
{Filename: `\m\Radiohead - OK Computer\02 Paranoid Android.flac`, Size: 1, Length: 383},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
s, _ := newStubSlskd(t, stub)
|
||||||
|
|
||||||
|
got, err := s.Search(context.Background(), Download{
|
||||||
|
ReleaseMBID: "rel", Artist: "Radiohead", Album: "OK Computer (Deluxe Edition)",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("got %d candidates, want the one folder once", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
if got[0].Files[0].LengthMillis != 284_000 {
|
||||||
|
t.Errorf("length = %d ms, want 284000 from slskd's seconds", got[0].Files[0].LengthMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
stub.mu.Lock()
|
||||||
|
searches := append([]map[string]any(nil), stub.searches...)
|
||||||
|
gets := append([]string(nil), stub.searchGets...)
|
||||||
|
stub.mu.Unlock()
|
||||||
|
|
||||||
|
if len(searches) != 2 {
|
||||||
|
t.Fatalf("ran %d searches, want 2", len(searches))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, body := range searches {
|
||||||
|
for _, key := range []string{
|
||||||
|
"searchTimeout", "responseLimit", "fileLimit",
|
||||||
|
"minimumResponseFileCount", "maximumPeerQueueLength",
|
||||||
|
} {
|
||||||
|
if _, ok := body[key]; !ok {
|
||||||
|
t.Errorf("search %q does not state %s", body["searchText"], key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The responses are fetched once at the end, not with every poll.
|
||||||
|
for _, uri := range gets {
|
||||||
|
if strings.Contains(uri, "includeResponses") {
|
||||||
|
t.Errorf("poll %s asked for every response", uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A daemon without the responses endpoint still returns results.
|
||||||
|
func TestSlskdSearchFallsBackForAnOlderDaemon(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
stub.noResponsesEndpoint = true
|
||||||
|
stub.responses = []slskdResponse{{
|
||||||
|
Username: "peer",
|
||||||
|
Files: []slskdFile{
|
||||||
|
{Filename: `\m\Album\01 A.flac`, Size: 1},
|
||||||
|
{Filename: `\m\Album\02 B.flac`, Size: 1},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
s, _ := newStubSlskd(t, stub)
|
||||||
|
|
||||||
|
got, err := s.Search(context.Background(), Download{Query: "album"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Errorf("got %d candidates, want 1 through the fallback", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDurationAgreement(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
got, want int64
|
||||||
|
score float64
|
||||||
|
}{
|
||||||
|
{300_000, 300_000, 1},
|
||||||
|
{301_500, 300_000, 1}, // a second of silence
|
||||||
|
{300_000, 316_500, 0.5},
|
||||||
|
{300_000, 345_000, 0}, // a different edit
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := durationAgreement(tc.got, tc.want); got < tc.score-0.01 || got > tc.score+0.01 {
|
||||||
|
t.Errorf("durationAgreement(%d, %d) = %f, want %f", tc.got, tc.want, got, tc.score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two folders with the same track names are told apart by their
|
||||||
|
// lengths: one is the album, the other a live record of the same songs.
|
||||||
|
func TestDurationsSeparateTheRightRecording(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dl := okComputer()
|
||||||
|
|
||||||
|
timed := func(id string, lengths ...int64) Candidate {
|
||||||
|
c := candidateFor(id, allTitles(), ".flac", 30_000_000)
|
||||||
|
for i := range c.Files {
|
||||||
|
c.Files[i].LengthMillis = lengths[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
studio := timed("studio", trackMillis, trackMillis+1_000, trackMillis, trackMillis-500)
|
||||||
|
live := timed(
|
||||||
|
"live",
|
||||||
|
trackMillis+60_000,
|
||||||
|
trackMillis+75_000,
|
||||||
|
trackMillis+50_000,
|
||||||
|
trackMillis+90_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
ranked := Rank(dl, []Candidate{live, studio}, nil, AutoDownloadPrefs{})
|
||||||
|
|
||||||
|
if ranked[0].ID != "studio" {
|
||||||
|
t.Fatalf("winner = %s, want the recording whose lengths match", ranked[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ranked[0].Match.DurationKnown || ranked[0].Match.DurationFit < 0.99 {
|
||||||
|
t.Errorf(
|
||||||
|
"studio duration fit = %f known=%v",
|
||||||
|
ranked[0].Match.DurationFit,
|
||||||
|
ranked[0].Match.DurationKnown,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ranked[1].Match.DurationFit != 0 {
|
||||||
|
t.Errorf("live duration fit = %f, want 0", ranked[1].Match.DurationFit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without lengths the score is exactly what it was before durations
|
||||||
|
// were read, so a provider that reports none is not penalised.
|
||||||
|
func TestUnknownDurationsLeaveTheScoreAlone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dl := okComputer()
|
||||||
|
c := Score(dl, candidateFor("c", allTitles(), ".flac", 30_000_000), 50, AutoDownloadPrefs{})
|
||||||
|
|
||||||
|
if c.Match.DurationKnown {
|
||||||
|
t.Fatal("no file states a length, yet durations are known")
|
||||||
|
}
|
||||||
|
|
||||||
|
want := weightTitleFit*c.Match.TitleFit +
|
||||||
|
weightCompleteness*c.Match.Completeness +
|
||||||
|
weightAlbumFit*c.Match.AlbumFit +
|
||||||
|
weightArtistFit*c.Match.ArtistFit
|
||||||
|
|
||||||
|
if c.Match.Overall != want {
|
||||||
|
t.Errorf("match = %f, want the untimed formula's %f", c.Match.Overall, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Where slskd writes a grab's files, and how collect finds them (#274).
|
||||||
|
|
||||||
|
// slskd reads searchTimeout in whole seconds, from the last response.
|
||||||
|
func TestSlskdSearchTimeoutIsInSeconds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
wait time.Duration
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{20 * time.Second, 18},
|
||||||
|
{200 * time.Millisecond, slskdMinSearchTimeout},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
s := &slskd{searchWait: tc.wait}
|
||||||
|
|
||||||
|
got, ok := s.searchRequest("id", "text", 2)["searchTimeout"].(int)
|
||||||
|
if !ok || got != tc.want {
|
||||||
|
t.Errorf("wait %s: searchTimeout = %v, want %d seconds", tc.wait, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsRenamedCopy(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := map[string]bool{
|
||||||
|
"01 A_638912345678901234.flac": true,
|
||||||
|
"01 A.flac": false,
|
||||||
|
"01 A_.flac": false,
|
||||||
|
"01 A_v2.flac": false,
|
||||||
|
"01 A_123.mp3": false,
|
||||||
|
"01 AB_123.flac": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, want := range cases {
|
||||||
|
if got := isRenamedCopy(name, "01 A", ".flac"); got != want {
|
||||||
|
t.Errorf("isRenamedCopy(%q) = %v, want %v", name, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func succeeded(names ...string) [][]slskdTransfer {
|
||||||
|
out := make([]slskdTransfer, 0, len(names))
|
||||||
|
for i, n := range names {
|
||||||
|
out = append(out, slskdTransfer{
|
||||||
|
ID: "t" + itoa(i),
|
||||||
|
Filename: n,
|
||||||
|
State: "Completed, Succeeded",
|
||||||
|
BytesTransferred: 500,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return [][]slskdTransfer{out}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On a daemon with batches, each grab writes into a folder of its own,
|
||||||
|
// collect reads from exactly there, and the folder is gone afterwards.
|
||||||
|
func TestSlskdBatchGrabUsesItsOwnFolder(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
stub.batches = true
|
||||||
|
stub.transfers = succeeded(`\s\Album\01 A.flac`, `\s\Album\02 B.flac`)
|
||||||
|
|
||||||
|
s, downloads := newStubSlskd(t, stub)
|
||||||
|
|
||||||
|
// A same-named file in the folder a per-user enqueue would use is
|
||||||
|
// someone else's, and must not be touched.
|
||||||
|
other := filepath.Join(downloads, "Album", "01 A.flac")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(other), 0o750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(other, []byte("the user's"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := t.TempDir()
|
||||||
|
|
||||||
|
got, err := s.Grab(
|
||||||
|
context.Background(), slskdAlbum(t, stub, "peer", "01 A.flac", "02 B.flac"), dst, nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Grab: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Files) != 2 {
|
||||||
|
t.Fatalf("collected %d files, want 2", len(got.Files))
|
||||||
|
}
|
||||||
|
|
||||||
|
stub.mu.Lock()
|
||||||
|
bodies := append([]map[string]any(nil), stub.batchBodies...)
|
||||||
|
stub.mu.Unlock()
|
||||||
|
|
||||||
|
if len(bodies) != 1 {
|
||||||
|
t.Fatalf("%d batches, want 1", len(bodies))
|
||||||
|
}
|
||||||
|
|
||||||
|
if bodies[0]["username"] != "peer" {
|
||||||
|
t.Errorf("batch username = %v", bodies[0]["username"])
|
||||||
|
}
|
||||||
|
|
||||||
|
dest, _ := bodies[0]["options"].(map[string]any)["destination"].(string)
|
||||||
|
if !strings.HasPrefix(dest, slskdDestRoot+"/") {
|
||||||
|
t.Errorf("destination %q is not under %s", dest, slskdDestRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(downloads, filepath.FromSlash(dest))); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("batch folder left behind: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if data, _ := os.ReadFile(other); string(data) != "the user's" {
|
||||||
|
t.Errorf("the user's own file was taken or changed: %q", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A batch's files land flat in its destination, so a two-disc rip is
|
||||||
|
// two batches, one per disc, or disc 2's "01" is renamed out of the way
|
||||||
|
// of disc 1's.
|
||||||
|
func TestSlskdBatchSplitsDiscs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
stub.batches = true
|
||||||
|
stub.transfers = succeeded(`\s\Wall\CD1\01 In.flac`, `\s\Wall\CD2\01 Hey You.flac`)
|
||||||
|
stub.deliver("01 In.flac", "01 Hey You.flac")
|
||||||
|
|
||||||
|
s, _ := newStubSlskd(t, stub)
|
||||||
|
|
||||||
|
dst := t.TempDir()
|
||||||
|
|
||||||
|
got, err := s.Grab(context.Background(), Candidate{
|
||||||
|
Files: []CandidateFile{
|
||||||
|
{Path: `\s\Wall\CD1\01 In.flac`, Size: 500, IsAudio: true},
|
||||||
|
{Path: `\s\Wall\CD2\01 Hey You.flac`, Size: 500, IsAudio: true},
|
||||||
|
},
|
||||||
|
Payload: map[string]string{"username": "peer"},
|
||||||
|
}, dst, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Grab: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stub.mu.Lock()
|
||||||
|
n := len(stub.batchBodies)
|
||||||
|
stub.mu.Unlock()
|
||||||
|
|
||||||
|
if n != 2 {
|
||||||
|
t.Errorf("%d batches, want one per disc", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
filepath.Join(dst, "CD1", "01 In.flac"),
|
||||||
|
filepath.Join(dst, "CD2", "01 Hey You.flac"),
|
||||||
|
} {
|
||||||
|
found := false
|
||||||
|
|
||||||
|
for _, f := range got.Files {
|
||||||
|
found = found || f == want
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
t.Errorf("%s not collected; got %q", want, got.Files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An abandoned batch grab leaves nothing in slskd's folder either.
|
||||||
|
func TestSlskdBatchFailureDiscardsItsFolder(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
stub.batches = true
|
||||||
|
stub.transfers = [][]slskdTransfer{{
|
||||||
|
{ID: "t0", Filename: `\s\Album\01 A.flac`, State: "Completed, Errored"},
|
||||||
|
{ID: "t1", Filename: `\s\Album\02 B.flac`, State: "Completed, Errored"},
|
||||||
|
}}
|
||||||
|
|
||||||
|
s, downloads := newStubSlskd(t, stub)
|
||||||
|
|
||||||
|
// The stub delivers the file, as a partial slskd left behind would.
|
||||||
|
if _, err := s.Grab(
|
||||||
|
context.Background(), slskdAlbum(t, stub, "peer", "01 A.flac"), t.TempDir(), nil,
|
||||||
|
); err == nil {
|
||||||
|
t.Fatal("Grab succeeded with every transfer failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, _ := os.ReadDir(filepath.Join(downloads, slskdDestRoot))
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Errorf("%d batch folders left behind", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An older daemon answers the batch endpoint with 400; the grab falls
|
||||||
|
// back to the per-user enqueue, and later grabs do not ask again.
|
||||||
|
func TestSlskdFallsBackWithoutBatches(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
stub.transfers = succeeded(`\s\Album\01 A.flac`, `\s\Album\02 B.flac`)
|
||||||
|
|
||||||
|
s, _ := newStubSlskd(t, stub)
|
||||||
|
|
||||||
|
for range 2 {
|
||||||
|
stub.mu.Lock()
|
||||||
|
stub.posted = false
|
||||||
|
stub.pollCount = 0
|
||||||
|
stub.mu.Unlock()
|
||||||
|
|
||||||
|
if _, err := s.Grab(
|
||||||
|
context.Background(), slskdAlbum(t, stub, "peer", "01 A.flac"), t.TempDir(), nil,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("Grab: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stub.mu.Lock()
|
||||||
|
paths := append([]string(nil), stub.paths...)
|
||||||
|
stub.mu.Unlock()
|
||||||
|
|
||||||
|
batchCalls := 0
|
||||||
|
|
||||||
|
for _, p := range paths {
|
||||||
|
if strings.HasSuffix(p, "/batches") {
|
||||||
|
batchCalls++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if batchCalls != 1 {
|
||||||
|
t.Errorf("asked for a batch %d times, want once", batchCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without batches, a file of the same name already in slskd's folder is
|
||||||
|
// not this grab's: slskd wrote ours beside it as name_<ticks>.ext, and
|
||||||
|
// that is the one collected.
|
||||||
|
func TestSlskdCollectsTheRenamedCopyNotTheOldFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
stub.transfers = succeeded(`\s\Album\01 A.flac`, `\s\Album\02 B.flac`)
|
||||||
|
|
||||||
|
s, downloads := newStubSlskd(t, stub)
|
||||||
|
|
||||||
|
old := filepath.Join(downloads, "Album", "01 A.flac")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(old), 0o750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(old, []byte("left by an earlier attempt"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := t.TempDir()
|
||||||
|
|
||||||
|
got, err := s.Grab(
|
||||||
|
context.Background(), slskdAlbum(t, stub, "peer", "01 A.flac", "02 B.flac"), dst, nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Grab: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Files) != 2 {
|
||||||
|
t.Fatalf("collected %d files, want 2", len(got.Files))
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(dst, "01 A.flac"))
|
||||||
|
if err != nil || string(data) != "audio" {
|
||||||
|
t.Errorf("collected %q, want this grab's file", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if data, _ := os.ReadFile(old); string(data) != "left by an earlier attempt" {
|
||||||
|
t.Error("the file that was already there was moved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And when this grab's copy never arrived, the old one is not taken in
|
||||||
|
// its place.
|
||||||
|
func TestSlskdDoesNotCollectAFileThatWasAlreadyThere(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
stub.transfers = [][]slskdTransfer{
|
||||||
|
{
|
||||||
|
{ID: "t0", Filename: `\s\Album\01 A.flac`, State: "Completed, Errored"},
|
||||||
|
{
|
||||||
|
ID: "t1",
|
||||||
|
Filename: `\s\Album\02 B.flac`,
|
||||||
|
State: "Completed, Succeeded",
|
||||||
|
BytesTransferred: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
s, downloads := newStubSlskd(t, stub)
|
||||||
|
|
||||||
|
old := filepath.Join(downloads, "Album", "01 A.flac")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(old), 0o750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(old, []byte("stale"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.Grab(
|
||||||
|
context.Background(), slskdAlbum(t, stub, "peer", "02 B.flac"), t.TempDir(), nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Grab: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Files) != 1 || filepath.Base(got.Files[0]) != "02 B.flac" {
|
||||||
|
t.Errorf("collected %q, want only 02 B.flac", got.Files)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A peer that has queued us is judged by where we are in its queue, not
|
||||||
|
// only by a timer (#275).
|
||||||
|
|
||||||
|
func queuedThen(polls int, final string) [][]slskdTransfer {
|
||||||
|
queued := []slskdTransfer{
|
||||||
|
{ID: "t0", Filename: `\s\Album\01 A.flac`, State: "Queued, Remotely"},
|
||||||
|
{ID: "t1", Filename: `\s\Album\02 B.flac`, State: "Queued, Remotely"},
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([][]slskdTransfer, 0, polls+1)
|
||||||
|
for range polls {
|
||||||
|
out = append(out, queued)
|
||||||
|
}
|
||||||
|
|
||||||
|
return append(out, []slskdTransfer{
|
||||||
|
{ID: "t0", Filename: `\s\Album\01 A.flac`, State: final, BytesTransferred: 500},
|
||||||
|
{ID: "t1", Filename: `\s\Album\02 B.flac`, State: final, BytesTransferred: 500},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func descending(from int) []int {
|
||||||
|
out := make([]int, 0, from)
|
||||||
|
for p := from; p >= 1; p-- {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two readings far back in the queue give the peer up at once, rather
|
||||||
|
// than after the stall timer.
|
||||||
|
func TestSlskdGivesUpOnALongQueue(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
stub.transfers = queuedThen(100_000, "Completed, Succeeded")
|
||||||
|
stub.positions = []int{400}
|
||||||
|
|
||||||
|
s, _ := newStubSlskd(t, stub)
|
||||||
|
s.stallAfter = time.Hour
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
|
|
||||||
|
_, err := s.Grab(context.Background(), slskdAlbum(t, stub, "peer"), t.TempDir(), nil)
|
||||||
|
if !errors.Is(err, ErrSlskdTimeout) || !strings.Contains(err.Error(), "position 400") {
|
||||||
|
t.Fatalf("Grab = %v, want a queue-position give-up", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(started) > 5*time.Second {
|
||||||
|
t.Error("the give-up waited on something other than the position")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(stub.cancelledURIs()) == 0 {
|
||||||
|
t.Error("the queued transfers were not cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One far reading is not enough: slskd says the figure can be wildly
|
||||||
|
// wrong.
|
||||||
|
func TestSlskdOneBadPositionIsNotEnough(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
stub.transfers = queuedThen(20, "Completed, Succeeded")
|
||||||
|
stub.positions = []int{400, 3}
|
||||||
|
|
||||||
|
s, _ := newStubSlskd(t, stub)
|
||||||
|
s.positionPoll = 0
|
||||||
|
|
||||||
|
if _, err := s.Grab(
|
||||||
|
context.Background(),
|
||||||
|
slskdAlbum(t, stub, "peer", "01 A.flac", "02 B.flac"),
|
||||||
|
t.TempDir(), nil,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("Grab: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A queue that is moving is progress: the grab outlives the stall timer
|
||||||
|
// while its position improves.
|
||||||
|
func TestSlskdAMovingQueueIsProgress(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
// Near enough to wait for, with more improving readings (45) than
|
||||||
|
// there are queued polls (30), so the queue outlives the stall timer
|
||||||
|
// (30 polls of at least 2 ms against 40 ms) while still improving,
|
||||||
|
// however slowly the machine runs the loop.
|
||||||
|
stub.transfers = queuedThen(30, "Completed, Succeeded")
|
||||||
|
stub.positions = descending(slskdMaxQueuePosition - 5)
|
||||||
|
|
||||||
|
s, _ := newStubSlskd(t, stub)
|
||||||
|
s.transferPoll = 2 * time.Millisecond
|
||||||
|
s.positionPoll = 0
|
||||||
|
s.stallAfter = 40 * time.Millisecond
|
||||||
|
|
||||||
|
if _, err := s.Grab(
|
||||||
|
context.Background(),
|
||||||
|
slskdAlbum(t, stub, "peer", "01 A.flac", "02 B.flac"),
|
||||||
|
t.TempDir(), nil,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("Grab: %v; a moving queue was treated as a stall", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// However steadily the queue moves, waiting in it has a ceiling.
|
||||||
|
func TestSlskdQueueHasACeiling(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stub := newSlskdStub(t)
|
||||||
|
stub.transfers = queuedThen(100_000, "Completed, Succeeded")
|
||||||
|
stub.positions = descending(100_000)
|
||||||
|
|
||||||
|
s, _ := newStubSlskd(t, stub)
|
||||||
|
s.stallAfter = time.Hour
|
||||||
|
s.queueCeiling = 50 * time.Millisecond
|
||||||
|
|
||||||
|
_, err := s.Grab(context.Background(), slskdAlbum(t, stub, "peer"), t.TempDir(), nil)
|
||||||
|
if !errors.Is(err, ErrSlskdTimeout) {
|
||||||
|
t.Fatalf("Grab = %v, want the queue ceiling", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -232,12 +232,17 @@ type Candidate struct {
|
|||||||
// results give paths and sizes but no tags, so Format and duration are
|
// results give paths and sizes but no tags, so Format and duration are
|
||||||
// inferred from the path and size where possible.
|
// inferred from the path and size where possible.
|
||||||
type CandidateFile struct {
|
type CandidateFile struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
Format Format `json:"format"`
|
Format Format `json:"format"`
|
||||||
Bitrate int `json:"bitrate,omitempty"` // kbps, 0 when unknown
|
Bitrate int `json:"bitrate,omitempty"` // kbps, 0 when unknown
|
||||||
IsAudio bool `json:"isAudio"`
|
IsAudio bool `json:"isAudio"`
|
||||||
MatchedTo int `json:"matchedTo,omitempty"` // expected track position
|
|
||||||
|
// LengthMillis is the file's duration as the source reports it, or
|
||||||
|
// 0 when it does not. Soulseek reports it for most audio files.
|
||||||
|
LengthMillis int64 `json:"lengthMillis,omitempty"`
|
||||||
|
|
||||||
|
MatchedTo int `json:"matchedTo,omitempty"` // expected track position
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format is a normalized audio container/codec name.
|
// Format is a normalized audio container/codec name.
|
||||||
@@ -286,7 +291,14 @@ type MatchScore struct {
|
|||||||
TitleFit float64 `json:"titleFit"` // filenames vs expected titles
|
TitleFit float64 `json:"titleFit"` // filenames vs expected titles
|
||||||
ArtistFit float64 `json:"artistFit"` // path/origin vs expected artist
|
ArtistFit float64 `json:"artistFit"` // path/origin vs expected artist
|
||||||
AlbumFit float64 `json:"albumFit"` // folder name vs album title
|
AlbumFit float64 `json:"albumFit"` // folder name vs album title
|
||||||
Completeness float64 `json:"completeness"` // audio files vs expected count
|
Completeness float64 `json:"completeness"` // aligned tracks vs expected count
|
||||||
|
|
||||||
|
// DurationFit is how well the aligned files' lengths agree with the
|
||||||
|
// expected tracks', and DurationKnown whether enough of them stated
|
||||||
|
// a length for that to count. When it does not, the score is the
|
||||||
|
// four text signals alone, exactly as before durations were read.
|
||||||
|
DurationFit float64 `json:"durationFit"`
|
||||||
|
DurationKnown bool `json:"durationKnown"`
|
||||||
|
|
||||||
// Anchored records whether an MBID drove this score. Unanchored
|
// Anchored records whether an MBID drove this score. Unanchored
|
||||||
// matches are capped, because there is nothing to be right about.
|
// matches are capped, because there is nothing to be right about.
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package explore
|
package explore
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"database/sql/driver"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -283,6 +285,25 @@ func (si *SearchIndex) importCoreArtifact(ctx context.Context, path string) erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
merged, mergeErr := si.mergeArtifactRows(ctx, info.rows)
|
merged, mergeErr := si.mergeArtifactRows(ctx, info.rows)
|
||||||
|
|
||||||
|
// Every row the artifact declares has to land. The walk partitions
|
||||||
|
// the artifact's key space, so a total short of info.rows does not
|
||||||
|
// mean the artifact was smaller than it said -- it means a predicate
|
||||||
|
// filtered rows out, and the catalog is quietly partial. Equality
|
||||||
|
// rather than a lower bound because RowsAffected counts an upsert
|
||||||
|
// that changes nothing, and a row already merged locally is counted
|
||||||
|
// again here.
|
||||||
|
//
|
||||||
|
// One reachable case, so this is not merely a tripwire: a row whose
|
||||||
|
// mbid is empty is excluded by `mbid > ?` in both encodings, and an
|
||||||
|
// artifact carrying one would otherwise import as complete.
|
||||||
|
if mergeErr == nil && merged != info.rows {
|
||||||
|
mergeErr = fmt.Errorf(
|
||||||
|
"%w: merged %d of %d rows — a row the artifact holds was not selected",
|
||||||
|
ErrArtifactUnusable, merged, info.rows,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if mergeErr == nil {
|
if mergeErr == nil {
|
||||||
si.mergeArtifactCredits(ctx)
|
si.mergeArtifactCredits(ctx)
|
||||||
}
|
}
|
||||||
@@ -348,8 +369,12 @@ func (si *SearchIndex) analyzeIndex() {
|
|||||||
// is an index range scan and a cancelled import leaves committed work
|
// is an index range scan and a cancelled import leaves committed work
|
||||||
// behind rather than rolling it all back.
|
// behind rather than rolling it all back.
|
||||||
func (si *SearchIndex) mergeArtifactRows(ctx context.Context, total int) (int, error) {
|
func (si *SearchIndex) mergeArtifactRows(ctx context.Context, total int) (int, error) {
|
||||||
|
// Asked once, because it is a property of the file and it decides
|
||||||
|
// how the walk's own comparisons are typed. See artifactKey.
|
||||||
|
storesText := si.artifactStoresText()
|
||||||
|
|
||||||
selectColumns := artifactSelectColumns(
|
selectColumns := artifactSelectColumns(
|
||||||
si.artifactStoresText(), si.artifactHasTotals(),
|
storesText, si.artifactHasTotals(),
|
||||||
)
|
)
|
||||||
|
|
||||||
insertSQL := `
|
insertSQL := `
|
||||||
@@ -367,7 +392,7 @@ func (si *SearchIndex) mergeArtifactRows(ctx context.Context, total int) (int, e
|
|||||||
WHERE mbid > ? AND mbid <= ?` + upsertIndexConflictSQL
|
WHERE mbid > ? AND mbid <= ?` + upsertIndexConflictSQL
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cursor string
|
cursor artifactKey
|
||||||
merged int
|
merged int
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -376,17 +401,30 @@ func (si *SearchIndex) mergeArtifactRows(ctx context.Context, total int) (int, e
|
|||||||
return merged, err
|
return merged, err
|
||||||
}
|
}
|
||||||
|
|
||||||
upper, hasUpper, err := si.artifactBatchBound(cursor)
|
upper, hasUpper, err := si.artifactBatchBound(storesText, cursor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return merged, err
|
return merged, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hasUpper && bytes.Compare(upper, cursor) <= 0 {
|
||||||
|
// The predicate matched the cursor itself, so the walk can
|
||||||
|
// never advance. SQLite says nothing when a comparison is
|
||||||
|
// made between types it will not coerce - the query simply
|
||||||
|
// answers wrongly - so a mismatch here would otherwise spin
|
||||||
|
// forever behind an unmoving progress bar. Fail instead.
|
||||||
|
return merged, fmt.Errorf(
|
||||||
|
"%w: artifact walk did not advance past %x",
|
||||||
|
ErrArtifactUnusable, []byte(cursor),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var res sql.Result
|
var res sql.Result
|
||||||
|
|
||||||
if hasUpper {
|
if hasUpper {
|
||||||
res, err = si.db.ExecContext(insertRangeSQL, cursor, upper)
|
res, err = si.db.ExecContext(insertRangeSQL,
|
||||||
|
cursor.bind(storesText), upper.bind(storesText))
|
||||||
} else {
|
} else {
|
||||||
res, err = si.db.ExecContext(insertSQL, cursor)
|
res, err = si.db.ExecContext(insertSQL, cursor.bind(storesText))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -413,26 +451,65 @@ func (si *SearchIndex) mergeArtifactRows(ctx context.Context, total int) (int, e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// artifactKey is one MBID as the attached artifact stores it: 16 raw
|
||||||
|
// bytes in a compact artifact, the dashed 36-character form in one
|
||||||
|
// published before that storage change.
|
||||||
|
//
|
||||||
|
// It is a type with a bind method rather than a string because the
|
||||||
|
// comparison it feeds is typed, and the wrong type is silent. SQLite
|
||||||
|
// does not coerce between TEXT and BLOB and orders every blob after
|
||||||
|
// every text value, so a cursor bound as text against a byte column
|
||||||
|
// makes `mbid > ?` true of the whole table - the walk rediscovers the
|
||||||
|
// same batch bound forever, and `mbid <= ?` false of the whole table,
|
||||||
|
// so no batch merges at all. Nothing errors; the import simply never
|
||||||
|
// finishes. bind is the one place that knows which form the column is
|
||||||
|
// in, decided by artifactStoresText, which asks the artifact rather than
|
||||||
|
// trusting a version number.
|
||||||
|
type artifactKey []byte
|
||||||
|
|
||||||
|
// bind renders the key as a statement argument in the artifact's own
|
||||||
|
// encoding.
|
||||||
|
func (k artifactKey) bind(storesText bool) driver.Value {
|
||||||
|
if storesText {
|
||||||
|
return string(k)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never nil. database/sql converts a nil []byte to SQL NULL, and
|
||||||
|
// `mbid > NULL` is NULL for every row - so an unset cursor would
|
||||||
|
// agree with nothing and import nothing, which is the same silently
|
||||||
|
// empty merge this type exists to prevent, one type over.
|
||||||
|
if k == nil {
|
||||||
|
return []byte{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return []byte(k)
|
||||||
|
}
|
||||||
|
|
||||||
// artifactBatchBound returns the MBID that ends the next batch, and
|
// artifactBatchBound returns the MBID that ends the next batch, and
|
||||||
// whether one exists — no bound means the remainder is the last batch.
|
// whether one exists — no bound means the remainder is the last batch.
|
||||||
func (si *SearchIndex) artifactBatchBound(cursor string) (string, bool, error) {
|
//
|
||||||
var bound string
|
// The bound is read out of the artifact and handed back as an
|
||||||
|
// artifactKey, because it becomes the next comparison the walk makes.
|
||||||
|
func (si *SearchIndex) artifactBatchBound(
|
||||||
|
storesText bool, cursor artifactKey,
|
||||||
|
) (artifactKey, bool, error) {
|
||||||
|
var bound []byte
|
||||||
|
|
||||||
err := si.db.QueryRowWriter(
|
err := si.db.QueryRowWriter(
|
||||||
`SELECT mbid FROM core.explore_index
|
`SELECT mbid FROM core.explore_index
|
||||||
WHERE mbid > ? ORDER BY mbid LIMIT 1 OFFSET ?`,
|
WHERE mbid > ? ORDER BY mbid LIMIT 1 OFFSET ?`,
|
||||||
cursor, artifactMergeBatch-1,
|
cursor.bind(storesText), artifactMergeBatch-1,
|
||||||
).Scan(&bound)
|
).Scan(&bound)
|
||||||
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return "", false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false, fmt.Errorf("%w: batch bound: %w", ErrArtifactUnusable, err)
|
return nil, false, fmt.Errorf("%w: batch bound: %w", ErrArtifactUnusable, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return bound, true, nil
|
return artifactKey(bound), true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// stampArtifactMeta records what the merge established: the catalog half
|
// stampArtifactMeta records what the merge established: the catalog half
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package explore
|
package explore
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -71,13 +72,7 @@ func writeTestArtifact(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range meta {
|
stampArtifactMeta(t, db, meta)
|
||||||
if _, err := db.Exec(
|
|
||||||
`INSERT INTO artifact_meta (key, value) VALUES (?, ?)`, k, v,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("stamp artifact meta: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
if _, err := db.Exec(`
|
if _, err := db.Exec(`
|
||||||
@@ -93,6 +88,101 @@ func writeTestArtifact(
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// compactArtifactSchema is the artifact cmd/indexexport publishes: the
|
||||||
|
// catalog's ids as 16 raw bytes, its entity types as codes, and the
|
||||||
|
// per-release-group total_tracks the exporter added after the first
|
||||||
|
// artifact was shipped.
|
||||||
|
//
|
||||||
|
// It matters that a fixture carries this encoding and not the older
|
||||||
|
// text one, because SQLite does not coerce between TEXT and BLOB and
|
||||||
|
// every comparison the importer makes against an mbid is therefore
|
||||||
|
// encoding-sensitive. writeTestArtifact above is the *other* fixture:
|
||||||
|
// it still writes the text form, which is what the first published
|
||||||
|
// artifact carries and what the importer must keep reading.
|
||||||
|
var compactArtifactSchema = []string{
|
||||||
|
`CREATE TABLE explore_index (
|
||||||
|
entity_type INTEGER NOT NULL,
|
||||||
|
mbid BLOB NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
artist_name TEXT NOT NULL,
|
||||||
|
artist_mbid BLOB 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 BLOB NOT NULL DEFAULT x'',
|
||||||
|
release_name TEXT NOT NULL DEFAULT '',
|
||||||
|
primary_type TEXT NOT NULL DEFAULT '',
|
||||||
|
secondary_types TEXT NOT NULL DEFAULT '',
|
||||||
|
release_date TEXT NOT NULL DEFAULT '',
|
||||||
|
total_tracks INTEGER NOT NULL DEFAULT 0,
|
||||||
|
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 artifact_meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCompactTestArtifact builds the artifact the exporter publishes
|
||||||
|
// today, in its own encoding, so the importer is exercised against what
|
||||||
|
// a client actually downloads rather than against what it was written
|
||||||
|
// for.
|
||||||
|
func writeCompactTestArtifact(
|
||||||
|
t *testing.T, meta map[string]string, rows []artifactRow,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "core-index.db")
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", "file:"+path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open artifact: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
|
for _, stmt := range compactArtifactSchema {
|
||||||
|
if _, err := db.Exec(stmt); err != nil {
|
||||||
|
t.Fatalf("create artifact schema: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stampArtifactMeta(t, db, meta)
|
||||||
|
|
||||||
|
for _, r := range rows {
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
INSERT INTO explore_index
|
||||||
|
(entity_type, mbid, title, artist_name, artist_mbid, popularity)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
entityCode(r.entityType), mbidBytes(r.mbid), r.title,
|
||||||
|
r.artistName, mbidBytes(r.artistMBID), r.popularity,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("insert artifact row: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// stampArtifactMeta writes the artifact_meta rows a fixture declares.
|
||||||
|
func stampArtifactMeta(t *testing.T, db *sql.DB, meta map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for k, v := range meta {
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`INSERT INTO artifact_meta (key, value) VALUES (?, ?)`, k, v,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("stamp artifact meta: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// validMeta is the artifact_meta a well-formed artifact carries.
|
// validMeta is the artifact_meta a well-formed artifact carries.
|
||||||
func validMeta() map[string]string {
|
func validMeta() map[string]string {
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
@@ -270,6 +360,71 @@ func TestImportCoreArtifactBatchWalkCoversAllRows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestImportCoreArtifactBatchWalkCoversAllRowsCompact is the batch walk
|
||||||
|
// on the encoding the exporter actually publishes.
|
||||||
|
//
|
||||||
|
// The walk positions itself by comparing the artifact's own mbid column
|
||||||
|
// against the last id it reached, and that column holds 16 raw bytes.
|
||||||
|
// SQLite does not coerce between TEXT and BLOB, and a blob sorts after
|
||||||
|
// every text value, so a cursor bound as text is a predicate that either
|
||||||
|
// matches every row or none: `mbid > ?` with an empty text key is true
|
||||||
|
// of the whole table, so
|
||||||
|
// the 100th row is always the 100th row and the bound never advances,
|
||||||
|
// while `mbid <= <text>` is false of the whole table, so no batch ever
|
||||||
|
// merges. The result is not a wrong import but an unbounded loop that
|
||||||
|
// merges nothing and never fails.
|
||||||
|
//
|
||||||
|
// Both encodings are covered on purpose. The walk was only ever tested
|
||||||
|
// against the text fixture above, which is why it shipped broken on the
|
||||||
|
// one the clients download.
|
||||||
|
func TestImportCoreArtifactBatchWalkCoversAllRowsCompact(t *testing.T) {
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
si := NewSearchIndex(db, nil, nil, testLogger())
|
||||||
|
|
||||||
|
original := artifactMergeBatch
|
||||||
|
artifactMergeBatch = 100
|
||||||
|
|
||||||
|
t.Cleanup(func() { artifactMergeBatch = original })
|
||||||
|
|
||||||
|
const total = 337
|
||||||
|
|
||||||
|
rows := make([]artifactRow, 0, total)
|
||||||
|
for i := range total {
|
||||||
|
rows = append(rows, artifactRow{
|
||||||
|
entityType: EntityRecording,
|
||||||
|
mbid: syntheticMBID(i),
|
||||||
|
title: "Song",
|
||||||
|
artistName: "Artist",
|
||||||
|
artistMBID: artA,
|
||||||
|
popularity: i,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
path := writeCompactTestArtifact(t, validMeta(), rows)
|
||||||
|
|
||||||
|
if err := si.importCoreArtifact(context.Background(), path); err != nil {
|
||||||
|
t.Fatalf("importCoreArtifact: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got, top int
|
||||||
|
|
||||||
|
if err := db.QueryRowWriter(
|
||||||
|
"SELECT COUNT(*), MAX(popularity) FROM explore_index",
|
||||||
|
).Scan(&got, &top); err != nil {
|
||||||
|
t.Fatalf("count rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != total {
|
||||||
|
t.Errorf("merged %d rows, want %d", got, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A count alone would pass if the walk re-merged the same first
|
||||||
|
// batch forever, so the far end of the artifact is checked too.
|
||||||
|
if top != total-1 {
|
||||||
|
t.Errorf("highest popularity = %d, want %d", top, total-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestImportCoreArtifactRejectsBadArtifacts(t *testing.T) {
|
func TestImportCoreArtifactRejectsBadArtifacts(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -454,61 +609,9 @@ func TestArtifactColumnsMatchExporter(t *testing.T) {
|
|||||||
// the importer decides by asking the artifact, not by trusting a
|
// the importer decides by asking the artifact, not by trusting a
|
||||||
// version number, and both must land identically.
|
// version number, and both must land identically.
|
||||||
func TestImportCoreArtifactAcceptsBothEncodings(t *testing.T) {
|
func TestImportCoreArtifactAcceptsBothEncodings(t *testing.T) {
|
||||||
compact := filepath.Join(t.TempDir(), "core-index.db")
|
compact := writeCompactTestArtifact(t, validMeta(), []artifactRow{
|
||||||
|
{EntityArtist, artA, "Artist A", "Artist A", artA, 5000},
|
||||||
db, err := sql.Open("sqlite", "file:"+compact)
|
})
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open artifact: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.Exec(`CREATE TABLE explore_index (
|
|
||||||
entity_type INTEGER NOT NULL,
|
|
||||||
mbid BLOB NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
artist_name TEXT NOT NULL,
|
|
||||||
artist_mbid BLOB 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 BLOB NOT NULL DEFAULT x'',
|
|
||||||
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)
|
|
||||||
)`); err != nil {
|
|
||||||
t.Fatalf("create artifact table: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.Exec(
|
|
||||||
`CREATE TABLE artifact_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("create artifact meta: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for k, v := range validMeta() {
|
|
||||||
if _, err := db.Exec(
|
|
||||||
"INSERT INTO artifact_meta (key, value) VALUES (?, ?)", k, v,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("write artifact meta: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.Exec(`
|
|
||||||
INSERT INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid, popularity)
|
|
||||||
VALUES (1, ?, 'Artist A', 'Artist A', ?, 5000)`,
|
|
||||||
mbidBytes(artA), mbidBytes(artA),
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("write artifact row: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = db.Close()
|
|
||||||
|
|
||||||
live := database.NewTestDB(t)
|
live := database.NewTestDB(t)
|
||||||
si := NewSearchIndex(live, nil, nil, testLogger())
|
si := NewSearchIndex(live, nil, nil, testLogger())
|
||||||
@@ -748,3 +851,88 @@ func TestImportCoreArtifactWithoutCredits(t *testing.T) {
|
|||||||
t.Errorf("credit refs = %d, want 0", refs)
|
t.Errorf("credit refs = %d, want 0", refs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestImportCoreArtifactRefusesAMergeThatLosesRows is the count guard's
|
||||||
|
// positive case.
|
||||||
|
//
|
||||||
|
// The walk's predicates partition the artifact's key space, so a merge
|
||||||
|
// that lands fewer rows than the artifact declares means a predicate
|
||||||
|
// dropped some — and the failure is a catalog that looks populated and
|
||||||
|
// is missing things nobody can name. An empty mbid is the reachable
|
||||||
|
// way to get there: `mbid > ?` is false of it in both encodings, so it
|
||||||
|
// is never selected, and nothing else in the import would notice.
|
||||||
|
func TestImportCoreArtifactRefusesAMergeThatLosesRows(t *testing.T) {
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
si := NewSearchIndex(db, nil, nil, testLogger())
|
||||||
|
|
||||||
|
path := writeCompactTestArtifact(t, validMeta(), []artifactRow{
|
||||||
|
{EntityArtist, artA, "Artist A", "Artist A", artA, 5000},
|
||||||
|
{EntityArtist, "", "Nameless", "Artist A", artA, 4000},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := si.importCoreArtifact(context.Background(), path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a merge that lost a row was reported as a complete import")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "merged 1 of 2 rows") {
|
||||||
|
t.Errorf("error = %v, want it to name the shortfall", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the same rule as every other rejection: a failed merge must not
|
||||||
|
// leave the index claiming it has a catalog, or the real build would
|
||||||
|
// never run again.
|
||||||
|
if si.hasMeta(dumpImportDoneKey) {
|
||||||
|
t.Error("a failed import still stamped dump_import_done")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArtifactKeyBindsInTheArtifactsOwnEncoding pins the one place the
|
||||||
|
// batch walk's comparison type is decided.
|
||||||
|
//
|
||||||
|
// Every wrong answer is silent, which is why it is worth pinning all
|
||||||
|
// four. SQLite does not coerce TEXT to BLOB and orders every blob after
|
||||||
|
// every text value, so a text key against a byte column makes
|
||||||
|
// `mbid > ?` true of the whole artifact - the cursor never advances and
|
||||||
|
// the walk spins forever without merging a row - while a byte key
|
||||||
|
// against a text column makes it false of the whole artifact, so every
|
||||||
|
// batch merges nothing and the import "succeeds" empty. An unset cursor
|
||||||
|
// is the same fault once more: database/sql converts a nil []byte to
|
||||||
|
// SQL NULL, and `mbid > NULL` matches no row at all.
|
||||||
|
func TestArtifactKeyBindsInTheArtifactsOwnEncoding(t *testing.T) {
|
||||||
|
raw := mbidBytes(artA)
|
||||||
|
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
key artifactKey
|
||||||
|
want []byte
|
||||||
|
}{
|
||||||
|
{"unset", nil, []byte{}},
|
||||||
|
{"set", artifactKey(raw), raw},
|
||||||
|
} {
|
||||||
|
t.Run("bytes/"+tt.name, func(t *testing.T) {
|
||||||
|
got, ok := tt.key.bind(false).([]byte)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("bind(false) = %T, want []byte", tt.key.bind(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("bound to SQL NULL, which matches no row")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(got, tt.want) {
|
||||||
|
t.Errorf("bind(false) = %x, want %x", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dashed form is what an artifact published before the storage
|
||||||
|
// change carries, and it has to compare as text against text.
|
||||||
|
if got := artifactKey(nil).bind(true); got != "" {
|
||||||
|
t.Errorf("bind(true) on an unset cursor = %#v, want an empty string", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := artifactKey(artA).bind(true); got != artA {
|
||||||
|
t.Errorf("bind(true) = %#v, want %q", got, artA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package explore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"yellowjacket/backend/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Import of the artifact we actually publish, as a client imports it.
|
||||||
|
//
|
||||||
|
// Every other test here builds a fixture, and a fixture is a second
|
||||||
|
// description of the storage format that can be wrong in the same
|
||||||
|
// direction as the code reading it. That is how #258 shipped: the
|
||||||
|
// importer positioned its batch walk with a Go `string` cursor against
|
||||||
|
// the artifact's 16-byte `mbid` column, and SQLite neither coerces
|
||||||
|
// between TEXT and BLOB nor complains about the comparison — so the walk
|
||||||
|
// merged nothing and never advanced, and no install could finish its
|
||||||
|
// first index build. The fixture that guards the walk writes the old
|
||||||
|
// text encoding; the only compact fixture is one row, below the batch
|
||||||
|
// size, so the bound query never ran. Both passed throughout.
|
||||||
|
//
|
||||||
|
// So this one takes the published file and runs the client's own path
|
||||||
|
// over it — checksum, decompress, merge — and asserts that what the
|
||||||
|
// artifact holds is what the client ends up with.
|
||||||
|
//
|
||||||
|
// It skips without the path, so an ordinary test run pays nothing for
|
||||||
|
// it, and the publish job is where it is meant to run:
|
||||||
|
//
|
||||||
|
// YJ_CORE_INDEX_ARTIFACT=/tmp/core-index.db.zst \
|
||||||
|
// go test -tags indexbuild -run TestImportPublishedArtifact \
|
||||||
|
// ./backend/explore/
|
||||||
|
//
|
||||||
|
// The indexbuild tag is not incidental: that job's container has no GTK,
|
||||||
|
// and the default tag set links the app through Wails.
|
||||||
|
|
||||||
|
// publishedArtifactEnv points at the published artifact: the compressed
|
||||||
|
// core-index.db.zst, or the unpacked core-index.db.
|
||||||
|
const publishedArtifactEnv = "YJ_CORE_INDEX_ARTIFACT"
|
||||||
|
|
||||||
|
// artifactTotals is the pair this test compares across the boundary.
|
||||||
|
//
|
||||||
|
// Rows is the whole point — a merge that lands fewer of them than the
|
||||||
|
// artifact declares is a catalog that looks populated and is missing
|
||||||
|
// things nobody can name — and popularity is the half whose absence was
|
||||||
|
// reported when it happened, because it arrives only through the merge.
|
||||||
|
type artifactTotals struct {
|
||||||
|
rows int
|
||||||
|
withListen int
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportPublishedArtifact(t *testing.T) {
|
||||||
|
published := strings.TrimSpace(os.Getenv(publishedArtifactEnv))
|
||||||
|
if published == "" {
|
||||||
|
t.Skipf("set %s=<core-index.db.zst> to import the published artifact",
|
||||||
|
publishedArtifactEnv)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(published); err != nil {
|
||||||
|
t.Fatalf("%s: %v", publishedArtifactEnv, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file-backed database rather than NewTestDB's in-memory one: the
|
||||||
|
// artifact is ~135MB and a million rows, which is not a thing to hold
|
||||||
|
// in RAM inside a test. YJ_HOME is how NewDB is pointed somewhere
|
||||||
|
// disposable, and going through NewDB means this is the constructor,
|
||||||
|
// the schema and the read pool the app itself opens.
|
||||||
|
//
|
||||||
|
// Nothing closes it, because nothing can: `DB` has no Close and the
|
||||||
|
// app's handles are process-lifetime by design. The directory is
|
||||||
|
// unlinked at cleanup and the file goes with it.
|
||||||
|
t.Setenv("YJ_HOME", t.TempDir())
|
||||||
|
|
||||||
|
db, err := database.NewDB(testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
si := NewSearchIndex(db, nil, nil, testLogger())
|
||||||
|
|
||||||
|
// The checksum the publisher shipped, if it shipped one. Every
|
||||||
|
// client verifies it and refuses the artifact when it does not
|
||||||
|
// match, so a wrong one breaks Explore for everyone who has not
|
||||||
|
// already imported — and nothing else would see it, because the
|
||||||
|
// comparison is between two files only the publisher has.
|
||||||
|
if want, ok := publishedChecksum(published); ok {
|
||||||
|
got, err := fileSHA256(published)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("checksum the artifact: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("published artifact hashes to %s, but its .sha256 says %s",
|
||||||
|
got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unpacked := unpackPublishedArtifact(t, si, published)
|
||||||
|
|
||||||
|
want, err := artifactTotalsOf(unpacked)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count the artifact's rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := si.importCoreArtifact(context.Background(), unpacked); err != nil {
|
||||||
|
t.Fatalf("importCoreArtifact: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := indexTotalsOf(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count the index's rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.rows != want.rows {
|
||||||
|
t.Errorf("merged %d rows, but the artifact holds %d",
|
||||||
|
got.rows, want.rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.withListen != want.withListen {
|
||||||
|
t.Errorf("%d rows carry a listen count, but the artifact holds %d of them",
|
||||||
|
got.withListen, want.withListen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The FTS index is rebuilt from the table once the merge is done, and
|
||||||
|
// it is what search actually reads: a merge that lands without it
|
||||||
|
// leaves Explore silently matching nothing, which is the state #258
|
||||||
|
// produced by a different route.
|
||||||
|
var indexed int
|
||||||
|
if err := db.QueryRowWriter(
|
||||||
|
"SELECT COUNT(*) FROM explore_index_fts",
|
||||||
|
).Scan(&indexed); err != nil {
|
||||||
|
t.Fatalf("count the FTS index: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if indexed != got.rows {
|
||||||
|
t.Errorf("FTS index holds %d rows against the table's %d",
|
||||||
|
indexed, got.rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And one row read back through the app's own path, which is the
|
||||||
|
// other direction of every conversion the merge makes: a byte MBID
|
||||||
|
// out of the table, the app's dashed form, and back in as a lookup.
|
||||||
|
var raw []byte
|
||||||
|
if err := db.QueryRowWriter(`
|
||||||
|
SELECT mbid FROM explore_index
|
||||||
|
WHERE entity_type = 1 /* artist */ AND popularity > 0
|
||||||
|
ORDER BY popularity DESC LIMIT 1`).Scan(&raw); err != nil {
|
||||||
|
t.Fatalf("read a stored mbid: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dashed, err := mbidFromBytes(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the stored mbid is not one: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
artist := si.LookupArtistByMBID(dashed)
|
||||||
|
if artist == nil {
|
||||||
|
t.Fatalf("the artifact's most popular artist %s does not look up", dashed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if artist.Popularity == 0 {
|
||||||
|
t.Errorf("artist %s came back with no popularity", dashed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishedChecksum reads the sha256 the publisher wrote beside the
|
||||||
|
// artifact, in `sha256sum` output form. A missing file is not a
|
||||||
|
// failure: it is only there when the artifact came from the publish job.
|
||||||
|
func publishedChecksum(path string) (string, bool) {
|
||||||
|
body, err := os.ReadFile(path + ".sha256")
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := strings.TrimSpace(string(body))
|
||||||
|
if i := strings.IndexAny(sum, " \t"); i > 0 {
|
||||||
|
sum = sum[:i]
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sum) != 64 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.ToLower(sum), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// unpackPublishedArtifact returns a path to the unpacked database,
|
||||||
|
// going through the client's own decompression when it is handed the
|
||||||
|
// compressed file that is actually published.
|
||||||
|
func unpackPublishedArtifact(t *testing.T, si *SearchIndex, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if strings.HasSuffix(path, ".db") {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied into the test's own directory first: decompress writes
|
||||||
|
// beside the compressed file, and the publisher's directory is not
|
||||||
|
// this test's to write in.
|
||||||
|
staging := t.TempDir()
|
||||||
|
dst := filepath.Join(staging, coreArtifactFile)
|
||||||
|
|
||||||
|
src, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open the published artifact: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = src.Close() }()
|
||||||
|
|
||||||
|
out, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create a staging copy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(out, src); err != nil {
|
||||||
|
t.Fatalf("copy the published artifact: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := out.Close(); err != nil {
|
||||||
|
t.Fatalf("close the staging copy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetcher := &artifactFetcher{si: si, stagingDir: staging}
|
||||||
|
|
||||||
|
if err := fetcher.decompress(context.Background()); err != nil {
|
||||||
|
t.Fatalf("decompress the published artifact: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetcher.unpackedPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
// artifactTotalsOf counts what an artifact file holds, read directly so
|
||||||
|
// the numbers do not depend on anything the client does.
|
||||||
|
func artifactTotalsOf(path string) (artifactTotals, error) {
|
||||||
|
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
|
||||||
|
if err != nil {
|
||||||
|
return artifactTotals{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
|
var totals artifactTotals
|
||||||
|
|
||||||
|
err = db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(popularity > 0), 0)
|
||||||
|
FROM explore_index`).Scan(&totals.rows, &totals.withListen)
|
||||||
|
if err != nil {
|
||||||
|
return artifactTotals{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return totals, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexTotalsOf counts what the client ended up with.
|
||||||
|
func indexTotalsOf(db *database.DB) (artifactTotals, error) {
|
||||||
|
var totals artifactTotals
|
||||||
|
|
||||||
|
err := db.QueryRowWriter(`SELECT COUNT(*), COALESCE(SUM(popularity > 0), 0)
|
||||||
|
FROM explore_index`).Scan(&totals.rows, &totals.withListen)
|
||||||
|
|
||||||
|
return totals, err
|
||||||
|
}
|
||||||
+17
-11
@@ -383,9 +383,13 @@ func (si *SearchIndex) topByPopularity(
|
|||||||
// MusicBrainz IDs, so this never touches the library tables and asks
|
// MusicBrainz IDs, so this never touches the library tables and asks
|
||||||
// one query rather than one per artist.
|
// one query rather than one per artist.
|
||||||
//
|
//
|
||||||
// The artists are drawn most-popular-owned-album first, so a large
|
// **The row is drawn at random, and that is the whole point of it.**
|
||||||
// library's pool is the part of it the user is likeliest to recognise
|
// Ordered by popularity it was a second leaderboard: the same handful of
|
||||||
// rather than whichever artists sort first.
|
// big names appeared every time the page opened, which is not what "you
|
||||||
|
// own one album by these artists" is saying. The pool is still bounded
|
||||||
|
// to `pool` artists — a 4 000-artist library does not need all of them
|
||||||
|
// ranked — but which of them, and which of their albums, is `RANDOM()`,
|
||||||
|
// so the shelf is a different sample each visit.
|
||||||
func (si *SearchIndex) unownedAlbumsBySinglyOwnedArtists(
|
func (si *SearchIndex) unownedAlbumsBySinglyOwnedArtists(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
pool, limit int,
|
pool, limit int,
|
||||||
@@ -396,15 +400,17 @@ func (si *SearchIndex) unownedAlbumsBySinglyOwnedArtists(
|
|||||||
WHERE entity_type = 2 /* release_group */
|
WHERE entity_type = 2 /* release_group */
|
||||||
AND in_library = 0
|
AND in_library = 0
|
||||||
AND artist_mbid IN (
|
AND artist_mbid IN (
|
||||||
SELECT artist_mbid FROM explore_index
|
SELECT artist_mbid FROM (
|
||||||
WHERE entity_type = 2 /* release_group */
|
SELECT artist_mbid FROM explore_index
|
||||||
AND in_library = 1
|
WHERE entity_type = 2 /* release_group */
|
||||||
AND artist_mbid != x''
|
AND in_library = 1
|
||||||
GROUP BY artist_mbid
|
AND artist_mbid != x''
|
||||||
HAVING COUNT(*) = 1
|
GROUP BY artist_mbid
|
||||||
ORDER BY MAX(popularity) DESC
|
HAVING COUNT(*) = 1
|
||||||
|
)
|
||||||
|
ORDER BY RANDOM()
|
||||||
LIMIT ?)
|
LIMIT ?)
|
||||||
ORDER BY popularity DESC
|
ORDER BY RANDOM()
|
||||||
LIMIT ?`,
|
LIMIT ?`,
|
||||||
pool, limit,
|
pool, limit,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package explore
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"yellowjacket/backend/database"
|
"yellowjacket/backend/database"
|
||||||
@@ -202,6 +203,11 @@ func TestShelves_MoreFromOwnedNeedsExactlyOneOwnedAlbum(t *testing.T) {
|
|||||||
titles = append(titles, album.Title)
|
titles = append(titles, album.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The row is a random sample, so the *set* is what is asserted and
|
||||||
|
// not the order — see `unownedAlbumsBySinglyOwnedArtists` for why
|
||||||
|
// the ordering was given up.
|
||||||
|
sort.Strings(titles)
|
||||||
|
|
||||||
if len(titles) != 2 || titles[0] != "Second" || titles[1] != "Third" {
|
if len(titles) != 2 || titles[0] != "Second" || titles[1] != "Third" {
|
||||||
t.Fatalf("albums = %v, want [Second Third]", titles)
|
t.Fatalf("albums = %v, want [Second Third]", titles)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package library
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
@@ -69,6 +70,77 @@ func CoverArtFileSet(coverPath string) []string {
|
|||||||
return paths
|
return paths
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sweepOrphanedCoverArt deletes the cover_art rows no album references
|
||||||
|
// and returns their file paths, for the caller to remove from disk
|
||||||
|
// after the transaction commits. Cover art is referenced only by
|
||||||
|
// albums.cover_art_id, so an orphan is a cover whose album is gone —
|
||||||
|
// which is every album the caller just swept.
|
||||||
|
//
|
||||||
|
// One implementation because the scan path, RemoveFromLibrary and
|
||||||
|
// RemoveLibrary all reach this state, and the scan side used to skip it
|
||||||
|
// entirely while RemoveLibrary did it inline (#247).
|
||||||
|
func (l *Library) sweepOrphanedCoverArt(tx *sql.Tx) ([]string, error) {
|
||||||
|
const orphanSQL = `
|
||||||
|
SELECT file_path FROM cover_art WHERE id NOT IN (
|
||||||
|
SELECT DISTINCT cover_art_id FROM albums
|
||||||
|
WHERE cover_art_id IS NOT NULL
|
||||||
|
)`
|
||||||
|
|
||||||
|
rows, err := tx.QueryContext(l.ctx, orphanSQL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not query orphaned cover art: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var paths []string
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var filePath string
|
||||||
|
|
||||||
|
if err := rows.Scan(&filePath); err != nil {
|
||||||
|
l.logger.Warn("could not scan cover art path", "err", err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
paths = append(paths, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close before the DELETE: the two run on the one writer connection.
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
l.logger.Warn("could not close cover art rows", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(paths) > 0 {
|
||||||
|
if _, err := tx.ExecContext(l.ctx, `
|
||||||
|
DELETE FROM cover_art WHERE id NOT IN (
|
||||||
|
SELECT DISTINCT cover_art_id FROM albums
|
||||||
|
WHERE cover_art_id IS NOT NULL
|
||||||
|
)`); err != nil {
|
||||||
|
return nil, fmt.Errorf("could not delete orphaned cover_art: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeCoverArtFiles removes a cover original and its derived size
|
||||||
|
// variants. Only the original is stored in cover_art.file_path; the
|
||||||
|
// _sm/_md/_lg tiers are derived filenames beside it, so they have to be
|
||||||
|
// removed by name or they accumulate forever.
|
||||||
|
func (l *Library) removeCoverArtFiles(coverPaths []string) {
|
||||||
|
for _, coverPath := range coverPaths {
|
||||||
|
for _, path := range CoverArtFileSet(coverPath) {
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
l.logger.Warn(
|
||||||
|
"could not remove orphaned cover art file",
|
||||||
|
"path", path,
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// saveCoverArt saves embedded cover art to the cache directory.
|
// saveCoverArt saves embedded cover art to the cache directory.
|
||||||
// Returns the file path where the art was saved, or empty string
|
// Returns the file path where the art was saved, or empty string
|
||||||
// if no picture data. Timing is recorded in the provided metrics.
|
// if no picture data. Timing is recorded in the provided metrics.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"yellowjacket/backend/coverart"
|
"yellowjacket/backend/coverart"
|
||||||
"yellowjacket/backend/database/sql/sqlcgen"
|
"yellowjacket/backend/database/sql/sqlcgen"
|
||||||
|
"yellowjacket/internal/testfixtures"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestScan_StoresOnlyCoverTiers pins the size decision: a scan writes
|
// TestScan_StoresOnlyCoverTiers pins the size decision: a scan writes
|
||||||
@@ -26,10 +27,9 @@ func TestScan_StoresOnlyCoverTiers(t *testing.T) {
|
|||||||
|
|
||||||
lib, db := setupTestLibrary(t)
|
lib, db := setupTestLibrary(t)
|
||||||
|
|
||||||
root, err := filepath.Abs("../../test_data/music_library_test")
|
// Load skips when the fixture library has not been generated, as
|
||||||
if err != nil {
|
// every other fixture test does.
|
||||||
t.Fatalf("resolve fixture path: %v", err)
|
root := testfixtures.Load(t).Root()
|
||||||
}
|
|
||||||
|
|
||||||
library, err := db.Queries.CreateLibrary(lib.ctx, sqlcgen.CreateLibraryParams{
|
library, err := db.Queries.CreateLibrary(lib.ctx, sqlcgen.CreateLibraryParams{
|
||||||
Name: "Fixtures",
|
Name: "Fixtures",
|
||||||
|
|||||||
+8
-50
@@ -320,43 +320,12 @@ func (l *Library) RemoveLibrary(id int64) (*RemovalSummary, error) {
|
|||||||
|
|
||||||
genresRemoved, _ := result.RowsAffected()
|
genresRemoved, _ := result.RowsAffected()
|
||||||
|
|
||||||
// 15. Collect orphaned cover_art file paths for post-commit cleanup.
|
// Collect and delete orphaned cover_art rows before the commit. The
|
||||||
// SAFETY: Hand-crafted SELECT for orphaned cover art identification.
|
// shared helper is the one place this sweep lives, so the scan path,
|
||||||
// Parameterless.
|
// RemoveFromLibrary and this removal cannot drift (#247).
|
||||||
rows, err := tx.QueryContext(l.ctx,
|
orphanedCoverArtPaths, err := l.sweepOrphanedCoverArt(tx)
|
||||||
`SELECT file_path FROM cover_art WHERE id NOT IN (
|
|
||||||
SELECT DISTINCT cover_art_id FROM albums
|
|
||||||
WHERE cover_art_id IS NOT NULL
|
|
||||||
)`)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not query orphaned cover art: %w", err)
|
return nil, err
|
||||||
}
|
|
||||||
|
|
||||||
var orphanedCoverArtPaths []string
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var filePath string
|
|
||||||
if err := rows.Scan(&filePath); err != nil {
|
|
||||||
l.logger.Warn("could not scan cover art path", "err", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
orphanedCoverArtPaths = append(orphanedCoverArtPaths, filePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := rows.Close(); err != nil {
|
|
||||||
l.logger.Warn("could not close cover art rows", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 16. Delete orphaned cover_art rows.
|
|
||||||
// SAFETY: Hand-crafted orphan cleanup SQL. Parameterless.
|
|
||||||
if _, err := tx.ExecContext(l.ctx,
|
|
||||||
`DELETE FROM cover_art WHERE id NOT IN (
|
|
||||||
SELECT DISTINCT cover_art_id FROM albums
|
|
||||||
WHERE cover_art_id IS NOT NULL
|
|
||||||
)`); err != nil {
|
|
||||||
return nil, fmt.Errorf("could not delete orphaned cover_art: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 17. Delete the library's tagging queue. tagging_items holds a
|
// 17. Delete the library's tagging queue. tagging_items holds a
|
||||||
@@ -392,20 +361,9 @@ func (l *Library) RemoveLibrary(id int64) (*RemovalSummary, error) {
|
|||||||
// avoids a costly full re-index of all remaining tracks (~10s for
|
// avoids a costly full re-index of all remaining tracks (~10s for
|
||||||
// 25K tracks).
|
// 25K tracks).
|
||||||
|
|
||||||
// 21. Post-commit: Delete orphaned cover art files and their sized
|
// Post-commit: remove the orphaned cover art files and their sized
|
||||||
// variants. Only the original is stored in cover_art.file_path; the
|
// variants.
|
||||||
// _sm/_md/_lg thumbnails are derived filenames beside it, so they
|
l.removeCoverArtFiles(orphanedCoverArtPaths)
|
||||||
// have to be removed by name or they accumulate forever.
|
|
||||||
for _, coverPath := range orphanedCoverArtPaths {
|
|
||||||
for _, path := range CoverArtFileSet(coverPath) {
|
|
||||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
||||||
l.logger.Warn("could not remove orphaned cover art file",
|
|
||||||
"path", path,
|
|
||||||
"err", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 22. Post-commit: Compact queue.
|
// 22. Post-commit: Compact queue.
|
||||||
if l.removalHooks.CompactQueue != nil {
|
if l.removalHooks.CompactQueue != nil {
|
||||||
|
|||||||
+119
-32
@@ -911,30 +911,96 @@ func (l *Library) scanInternal(
|
|||||||
|
|
||||||
orphanStart := time.Now()
|
orphanStart := time.Now()
|
||||||
|
|
||||||
|
// Snapshot the orphan set first. The playlist-phantom
|
||||||
|
// preservation and the deletes are one transaction (the
|
||||||
|
// preservation has to land before the ON DELETE SET NULL, and
|
||||||
|
// both have to succeed or neither does), and the ids are what
|
||||||
|
// scope that preservation to just these files instead of
|
||||||
|
// rewriting every playlist row on a routine scan.
|
||||||
|
var (
|
||||||
|
orphans []sqlcgen.AudioFile
|
||||||
|
orphanPaths []string
|
||||||
|
)
|
||||||
|
|
||||||
existingPaths.Range(func(key, value any) bool {
|
existingPaths.Range(func(key, value any) bool {
|
||||||
path := key.(string)
|
orphanPaths = append(orphanPaths, key.(string))
|
||||||
audioFile := value.(sqlcgen.AudioFile)
|
orphans = append(orphans, value.(sqlcgen.AudioFile))
|
||||||
|
|
||||||
l.logger.Debug(
|
return true
|
||||||
"removing orphaned database entry",
|
})
|
||||||
"path", path, "id", audioFile.ID,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err := l.db.Queries.DeleteAudioFile(
|
deleted := make([]bool, len(orphans))
|
||||||
l.ctx, audioFile.ID,
|
|
||||||
); err != nil {
|
|
||||||
l.logger.Warn(
|
|
||||||
"failed to delete orphaned audio file",
|
|
||||||
"path", path,
|
|
||||||
"id", audioFile.ID,
|
|
||||||
"err", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
metrics.addWarning(path, "orphan", err)
|
if len(orphans) > 0 {
|
||||||
|
orphanIDs := make([]int64, len(orphans))
|
||||||
return true
|
for i, f := range orphans {
|
||||||
|
orphanIDs[i] = f.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tx, beginErr := l.db.BeginTx()
|
||||||
|
if beginErr != nil {
|
||||||
|
metrics.addWarning("", "orphan", beginErr)
|
||||||
|
} else {
|
||||||
|
defer func() { _ = tx.Rollback() }() // no-op after commit
|
||||||
|
|
||||||
|
if err := database.PreservePlaylistPhantomsForFiles(
|
||||||
|
l.ctx, tx, orphanIDs, l.logger,
|
||||||
|
); err != nil {
|
||||||
|
// Deleting without the phantoms is exactly the
|
||||||
|
// playlist-emptying bug the preservation exists to
|
||||||
|
// prevent, so leave the rows for the next scan
|
||||||
|
// rather than empty the playlists now.
|
||||||
|
l.logger.Error(
|
||||||
|
"skipping orphan deletion: could not preserve "+
|
||||||
|
"playlist entries",
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics.addWarning("", "orphan", err)
|
||||||
|
|
||||||
|
_ = tx.Rollback()
|
||||||
|
} else {
|
||||||
|
txq := l.db.Queries.WithTx(tx)
|
||||||
|
|
||||||
|
for i, f := range orphans {
|
||||||
|
if err := txq.DeleteAudioFile(
|
||||||
|
l.ctx, f.ID,
|
||||||
|
); err != nil {
|
||||||
|
l.logger.Warn(
|
||||||
|
"failed to delete orphaned audio file",
|
||||||
|
"path", orphanPaths[i],
|
||||||
|
"id", f.ID,
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics.addWarning(orphanPaths[i], "orphan", err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted[i] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
l.logger.Error(
|
||||||
|
"could not commit orphan deletion",
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics.addWarning("", "orphan", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-commit bookkeeping for the files that actually went.
|
||||||
|
for i, f := range orphans {
|
||||||
|
if !deleted[i] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := orphanPaths[i]
|
||||||
|
|
||||||
// Keep the file's tagging group in sync: drop the group's
|
// Keep the file's tagging group in sync: drop the group's
|
||||||
// track count and clear it out once empty, mirroring the
|
// track count and clear it out once empty, mirroring the
|
||||||
// bookkeeping maybeRebindTaggingGroup does for a group_key
|
// bookkeeping maybeRebindTaggingGroup does for a group_key
|
||||||
@@ -942,25 +1008,25 @@ func (l *Library) scanInternal(
|
|||||||
// and replaced leaves a stale tagging_items row behind —
|
// and replaced leaves a stale tagging_items row behind —
|
||||||
// its track_count still counts the deleted files, and it
|
// its track_count still counts the deleted files, and it
|
||||||
// never clears from the autotag queue.
|
// never clears from the autotag queue.
|
||||||
if audioFile.GroupKey != "" {
|
if f.GroupKey != "" {
|
||||||
if err := l.db.Queries.DecrementTaggingItemTrackCount(
|
if err := l.db.Queries.DecrementTaggingItemTrackCount(
|
||||||
l.ctx, audioFile.GroupKey,
|
l.ctx, f.GroupKey,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
l.logger.Warn(
|
l.logger.Warn(
|
||||||
"failed to decrement tagging group for orphan",
|
"failed to decrement tagging group for orphan",
|
||||||
"path", path,
|
"path", path,
|
||||||
"group_key", audioFile.GroupKey,
|
"group_key", f.GroupKey,
|
||||||
"err", err,
|
"err", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
metrics.addWarning(path, "orphan", err)
|
metrics.addWarning(path, "orphan", err)
|
||||||
} else if err := l.db.Queries.DeleteTaggingItemIfEmpty(
|
} else if err := l.db.Queries.DeleteTaggingItemIfEmpty(
|
||||||
l.ctx, audioFile.GroupKey,
|
l.ctx, f.GroupKey,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
l.logger.Warn(
|
l.logger.Warn(
|
||||||
"failed to clean up emptied tagging group for orphan",
|
"failed to clean up emptied tagging group for orphan",
|
||||||
"path", path,
|
"path", path,
|
||||||
"group_key", audioFile.GroupKey,
|
"group_key", f.GroupKey,
|
||||||
"err", err,
|
"err", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -968,13 +1034,21 @@ func (l *Library) scanInternal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from FTS5 search index.
|
// Remove from FTS5 search index and the lyrics index.
|
||||||
if err := l.db.DeleteSearchIndex(
|
if err := l.db.DeleteSearchIndex(f.ID); err != nil {
|
||||||
audioFile.ID,
|
|
||||||
); err != nil {
|
|
||||||
l.logger.Warn(
|
l.logger.Warn(
|
||||||
"failed to delete FTS entry for orphan",
|
"failed to delete FTS entry for orphan",
|
||||||
"id", audioFile.ID,
|
"id", f.ID,
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics.addWarning(path, "orphan", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := l.db.DeleteLyricsIndex(f.ID); err != nil {
|
||||||
|
l.logger.Warn(
|
||||||
|
"failed to delete lyrics index entry for orphan",
|
||||||
|
"id", f.ID,
|
||||||
"err", err,
|
"err", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -982,9 +1056,7 @@ func (l *Library) scanInternal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
removed.Add(1)
|
removed.Add(1)
|
||||||
|
}
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
metrics.OrphanCleanup = time.Since(orphanStart)
|
metrics.OrphanCleanup = time.Since(orphanStart)
|
||||||
|
|
||||||
@@ -1193,17 +1265,32 @@ func (l *Library) pruneEmptyEntities() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cover art after albums: a cover whose album just went is
|
||||||
|
// unreferenced, and leaving the row behind keeps its files exempt
|
||||||
|
// from the janitor's covers sweep forever (#247).
|
||||||
|
orphanedCovers, err := l.sweepOrphanedCoverArt(tx)
|
||||||
|
if err != nil {
|
||||||
|
l.logger.Warn("could not sweep orphaned cover art", "err", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
l.logger.Warn("could not commit entity cleanup", "err", err)
|
l.logger.Warn("could not commit entity cleanup", "err", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(albumIDs) > 0 || len(artistIDs) > 0 || len(genreIDs) > 0 {
|
// Post-commit: the rows are gone, so their files can go too.
|
||||||
|
l.removeCoverArtFiles(orphanedCovers)
|
||||||
|
|
||||||
|
if len(albumIDs) > 0 || len(artistIDs) > 0 || len(genreIDs) > 0 ||
|
||||||
|
len(orphanedCovers) > 0 {
|
||||||
l.logger.Info("pruned empty library entities",
|
l.logger.Info("pruned empty library entities",
|
||||||
"albums", len(albumIDs),
|
"albums", len(albumIDs),
|
||||||
"artists", len(artistIDs),
|
"artists", len(artistIDs),
|
||||||
"genres", len(genreIDs),
|
"genres", len(genreIDs),
|
||||||
|
"covers", len(orphanedCovers),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,57 @@ func countRows(
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveFromLibrary is the one path that empties a track *deliberately*:
|
||||||
|
// the file stays on disk but is excluded, so nothing re-imports it. The
|
||||||
|
// playlist entry must still survive as a re-linkable phantom rather than
|
||||||
|
// an empty row, because a later full rescan clears the exclusion and is
|
||||||
|
// what re-links the entry then (#246).
|
||||||
|
func TestRemoveFromLibrary_PreservesPlaylistPhantoms(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lib, db := setupTestLibrary(t)
|
||||||
|
|
||||||
|
seedRemovableLibrary(t, lib, "/nonexistent/cover.jpg")
|
||||||
|
|
||||||
|
if _, err := lib.db.ExecContext(
|
||||||
|
`INSERT INTO playlists (name) VALUES ('keepme')`,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed playlist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
playlistID := queryInt(
|
||||||
|
t, db, `SELECT id FROM playlists WHERE name = 'keepme'`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if _, err := lib.db.ExecContext(
|
||||||
|
`INSERT INTO playlist_tracks (playlist_id, audio_file_id, position)
|
||||||
|
SELECT ?, id, 0 FROM audio_files
|
||||||
|
WHERE file_path = '/music/song.mp3'`,
|
||||||
|
playlistID,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed playlist_tracks: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := lib.RemoveFromLibrary([]string{"/music/song.mp3"}); err != nil {
|
||||||
|
t.Fatalf("RemoveFromLibrary: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
phantomPath := queryString(
|
||||||
|
t, db,
|
||||||
|
`SELECT phantom_file_path FROM playlist_tracks
|
||||||
|
WHERE playlist_id = ?`,
|
||||||
|
playlistID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if phantomPath != "/music/song.mp3" {
|
||||||
|
t.Fatalf(
|
||||||
|
"phantom_file_path is %q, want %q -- the entry cannot be "+
|
||||||
|
"re-linked after a later full rescan",
|
||||||
|
phantomPath, "/music/song.mp3",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A library with tagging_items must still be removable. tagging_items
|
// A library with tagging_items must still be removable. tagging_items
|
||||||
// FK-references libraries with no ON DELETE clause, so leaving those
|
// FK-references libraries with no ON DELETE clause, so leaving those
|
||||||
// rows behind fails the DELETE and rolls back the entire removal.
|
// rows behind fails the DELETE and rolls back the entire removal.
|
||||||
@@ -198,3 +249,53 @@ func TestCoverArtFileSet(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Removing the last track of an album must take the album's cover art
|
||||||
|
// with it — both the row and every derived file — or the row keeps its
|
||||||
|
// files exempt from the janitor's covers sweep forever (#247).
|
||||||
|
func TestRemoveFromLibrary_DeletesOrphanedCoverArt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lib, _ := setupTestLibrary(t)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// The largest tier is what cover_art.file_path names; write every
|
||||||
|
// variant so the sweep has a real set to remove.
|
||||||
|
for _, tier := range thumbnailTiers {
|
||||||
|
p := filepath.Join(dir, coverart.SizedFilename("abc123.jpg", tier.Suffix))
|
||||||
|
if err := os.WriteFile(p, []byte("img"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cover := filepath.Join(dir, coverart.SizedFilename("abc123.jpg", "_lg"))
|
||||||
|
|
||||||
|
seedRemovableLibrary(t, lib, cover)
|
||||||
|
|
||||||
|
// Link the album to the cover so it is not orphaned until the track
|
||||||
|
// (and with it the album) goes.
|
||||||
|
if _, err := lib.db.ExecContext(
|
||||||
|
`UPDATE albums SET cover_art_id =
|
||||||
|
(SELECT id FROM cover_art WHERE file_path = ?)
|
||||||
|
WHERE name = 'Test Album'`,
|
||||||
|
cover,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("link cover art: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := lib.RemoveFromLibrary([]string{"/music/song.mp3"}); err != nil {
|
||||||
|
t.Fatalf("RemoveFromLibrary: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, lib, "cover_art"); n != 0 {
|
||||||
|
t.Errorf("cover_art has %d rows after removal, want 0", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tier := range thumbnailTiers {
|
||||||
|
p := filepath.Join(dir, coverart.SizedFilename("abc123.jpg", tier.Suffix))
|
||||||
|
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("cover art file still present: %s", filepath.Base(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"yellowjacket/backend/database"
|
||||||
"yellowjacket/backend/events"
|
"yellowjacket/backend/events"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,6 +58,25 @@ func (l *Library) RemoveFromLibrary(filePaths []string) (*RemovalResult, error)
|
|||||||
|
|
||||||
var result RemovalResult
|
var result RemovalResult
|
||||||
|
|
||||||
|
// Preserve the playlist entries before the rows go, so they survive
|
||||||
|
// as re-linkable phantoms rather than empty rows. The track is
|
||||||
|
// excluded and will not be re-imported on its own, but a later full
|
||||||
|
// rescan clears the exclusion and this is what lets the entry
|
||||||
|
// re-link then — the same preservation every other path that empties
|
||||||
|
// audio_files performs (#246).
|
||||||
|
rowIDs := make([]int64, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
rowIDs[i] = row.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.PreservePlaylistPhantomsForFiles(
|
||||||
|
l.ctx, tx, rowIDs, l.logger,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"could not preserve playlist entries for removal: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Exclude every path the caller named, including one whose row has
|
// Exclude every path the caller named, including one whose row has
|
||||||
// already gone: the user asked for that file to stay out, and a row
|
// already gone: the user asked for that file to stay out, and a row
|
||||||
// that disappeared between the click and the commit is not a reason
|
// that disappeared between the click and the commit is not a reason
|
||||||
@@ -127,6 +147,11 @@ func (l *Library) RemoveFromLibrary(filePaths []string) (*RemovalResult, error)
|
|||||||
l.logger.Warn("could not delete FTS entry for removed track",
|
l.logger.Warn("could not delete FTS entry for removed track",
|
||||||
"path", row.FilePath, "id", row.ID, "err", err)
|
"path", row.FilePath, "id", row.ID, "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := l.db.DeleteLyricsIndex(row.ID); err != nil {
|
||||||
|
l.logger.Warn("could not delete lyrics index entry for removed track",
|
||||||
|
"path", row.FilePath, "id", row.ID, "err", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deleting an audio_files row cascades to queue_tracks, so the
|
// Deleting an audio_files row cascades to queue_tracks, so the
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"yellowjacket/backend/coverart"
|
"yellowjacket/backend/coverart"
|
||||||
|
"yellowjacket/backend/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errNoLibrariesConfigured = errors.New(
|
var errNoLibrariesConfigured = errors.New(
|
||||||
@@ -137,34 +138,12 @@ func (l *Library) clearLibraryTables() error {
|
|||||||
// metadata for all linked tracks before audio_files are deleted.
|
// metadata for all linked tracks before audio_files are deleted.
|
||||||
// ON DELETE SET NULL will null out audio_file_id, converting them
|
// ON DELETE SET NULL will null out audio_file_id, converting them
|
||||||
// to phantoms that ResolvePhantomTracksAfterScan can re-link.
|
// to phantoms that ResolvePhantomTracksAfterScan can re-link.
|
||||||
if _, err := tx.ExecContext(l.ctx, `
|
//
|
||||||
UPDATE playlist_tracks
|
// Shared with the stale-shape retire in backend/database, which is
|
||||||
SET
|
// the other path that empties this table and which did not do this
|
||||||
phantom_title = COALESCE(phantom_title, (
|
// (#183): the statement lives there so the two cannot drift again.
|
||||||
SELECT tm.title FROM track_metadata tm
|
if err := database.PreservePlaylistPhantoms(l.ctx, tx, l.logger); err != nil {
|
||||||
WHERE tm.id = playlist_tracks.audio_file_id
|
return err
|
||||||
)),
|
|
||||||
phantom_artist = COALESCE(phantom_artist, (
|
|
||||||
SELECT tm.artist_name FROM track_metadata tm
|
|
||||||
WHERE tm.id = playlist_tracks.audio_file_id
|
|
||||||
)),
|
|
||||||
phantom_album = COALESCE(phantom_album, (
|
|
||||||
SELECT tm.album FROM track_metadata tm
|
|
||||||
WHERE tm.id = playlist_tracks.audio_file_id
|
|
||||||
)),
|
|
||||||
phantom_duration_ms = COALESCE(phantom_duration_ms, (
|
|
||||||
SELECT af.length_milliseconds FROM audio_files af
|
|
||||||
WHERE af.id = playlist_tracks.audio_file_id
|
|
||||||
)),
|
|
||||||
phantom_file_path = COALESCE(phantom_file_path, (
|
|
||||||
SELECT af.file_path FROM audio_files af
|
|
||||||
WHERE af.id = playlist_tracks.audio_file_id
|
|
||||||
))
|
|
||||||
WHERE audio_file_id IS NOT NULL
|
|
||||||
`); err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"could not preserve playlist track metadata: %w", err,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: the files. file_genres cascades with them.
|
// Phase 2: the files. file_genres cascades with them.
|
||||||
|
|||||||
@@ -231,3 +231,98 @@ func TestScan_MultipleDirectoriesDoNotCrossContaminate(t *testing.T) {
|
|||||||
t.Errorf("Album A and Album B must not share a group_key: %+v", keys)
|
t.Errorf("Album A and Album B must not share a group_key: %+v", keys)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestScan_OrphanCleanupPreservesPlaylistPhantoms guards #246: a file
|
||||||
|
// deleted from the library folder *outside* YellowJacket is discovered
|
||||||
|
// as an orphan by the next scan, and its playlist entry must survive as
|
||||||
|
// a re-linkable phantom — the same preservation the full rescan and
|
||||||
|
// stale-retire paths already perform, scoped here to just the orphaned
|
||||||
|
// file. Before the fix the entry became an empty row (audio_file_id
|
||||||
|
// NULL and no phantom_file_path), which nothing can ever re-link.
|
||||||
|
func TestScan_OrphanCleanupPreservesPlaylistPhantoms(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lib, db := setupTestLibrary(t)
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
track := filepath.Join(root, "gone.mp3")
|
||||||
|
|
||||||
|
writeTestTrack(t, track, 0)
|
||||||
|
|
||||||
|
library, err := db.Queries.CreateLibrary(lib.ctx, sqlcgen.CreateLibraryParams{
|
||||||
|
Name: "orphans",
|
||||||
|
Path: root,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create library: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metrics := lib.scanInternal(library.ID, library.Name, library.Path); metrics == nil {
|
||||||
|
t.Fatal("first scan returned nil metrics")
|
||||||
|
}
|
||||||
|
|
||||||
|
trackID := queryInt(
|
||||||
|
t, db, "SELECT id FROM audio_files WHERE file_path = ?", track,
|
||||||
|
)
|
||||||
|
if trackID == 0 {
|
||||||
|
t.Fatal("first scan did not import the track")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
`INSERT INTO playlists (name) VALUES ('keepme')`,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed playlist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
playlistID := queryInt(
|
||||||
|
t, db, "SELECT id FROM playlists WHERE name = 'keepme'",
|
||||||
|
)
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
`INSERT INTO playlist_tracks (playlist_id, audio_file_id, position)
|
||||||
|
VALUES (?, ?, 0)`,
|
||||||
|
playlistID, trackID,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed playlist_tracks: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The file goes away outside the app.
|
||||||
|
if err := os.Remove(track); err != nil {
|
||||||
|
t.Fatalf("remove track: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metrics := lib.scanInternal(library.ID, library.Name, library.Path); metrics == nil {
|
||||||
|
t.Fatal("second scan returned nil metrics")
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := queryInt(
|
||||||
|
t, db, "SELECT COUNT(*) FROM audio_files WHERE file_path = ?", track,
|
||||||
|
); n != 0 {
|
||||||
|
t.Fatalf("audio_files still holds the removed path: %d rows", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := queryInt(
|
||||||
|
t, db,
|
||||||
|
"SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id = ? "+
|
||||||
|
"AND audio_file_id IS NULL",
|
||||||
|
playlistID,
|
||||||
|
); n != 1 {
|
||||||
|
t.Fatalf(
|
||||||
|
"playlist entry did not become a phantom: %d null-id rows, want 1",
|
||||||
|
n,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
phantomPath := queryString(
|
||||||
|
t, db,
|
||||||
|
"SELECT phantom_file_path FROM playlist_tracks WHERE playlist_id = ?",
|
||||||
|
playlistID,
|
||||||
|
)
|
||||||
|
if phantomPath != track {
|
||||||
|
t.Fatalf(
|
||||||
|
"phantom_file_path = %q, want %q -- the entry cannot be "+
|
||||||
|
"re-linked if the file comes back",
|
||||||
|
phantomPath, track,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -666,3 +666,120 @@ func TestExpiredHTTPCacheJob_TrimsToBudget(t *testing.T) {
|
|||||||
t.Errorf("kept %q, want the longest-lived row", kept)
|
t.Errorf("kept %q, want the longest-lived row", kept)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStaleArtistMetadataJob pins the sweep's two keep rules: an owned
|
||||||
|
// artist's metadata survives, a browsed artist's survives while it still
|
||||||
|
// holds cached artwork, and everything else goes (#248).
|
||||||
|
func TestStaleArtistMetadataJob(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ownedMBID = "11111111-1111-1111-1111-111111111111"
|
||||||
|
browsedMBID = "22222222-2222-2222-2222-222222222222"
|
||||||
|
staleMBID = "33333333-3333-3333-3333-333333333333"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The owned artist is in the library - which means a *file* says
|
||||||
|
// so. An artists row on its own is not ownership.
|
||||||
|
database.InsertTestTrack(t, db, database.TestTrack{
|
||||||
|
FilePath: "/music/owned.mp3",
|
||||||
|
Artist: "Owned",
|
||||||
|
ArtistMBID: ownedMBID,
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, mbid := range []string{ownedMBID, browsedMBID, staleMBID} {
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
`INSERT INTO artist_metadata (mbid, source, data, fetched_at)
|
||||||
|
VALUES (?, 'wikidata-p18', x'00', CURRENT_TIMESTAMP)`,
|
||||||
|
mbid,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed artist_metadata for %s: %v", mbid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The browsed artist holds cached artwork, so its metadata is still
|
||||||
|
// referenced and must survive.
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
`INSERT INTO artist_images
|
||||||
|
(artist_mbid, source, source_url, file_path)
|
||||||
|
VALUES (?, 'test', 'http://x', '/art/primary.jpg')`,
|
||||||
|
browsedMBID,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed artist_images: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := StaleArtistMetadataJob(db).Run(context.Background()); err != nil {
|
||||||
|
t.Fatalf("run job: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
mbid string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{ownedMBID, 1},
|
||||||
|
{browsedMBID, 1},
|
||||||
|
{staleMBID, 0},
|
||||||
|
} {
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRowWriter(
|
||||||
|
"SELECT COUNT(*) FROM artist_metadata WHERE mbid = ?", tc.mbid,
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count %s: %v", tc.mbid, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n != tc.want {
|
||||||
|
t.Errorf("artist_metadata rows for %s = %d, want %d", tc.mbid, n, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStaleSearchClicksJob deletes only the clicks old enough to have
|
||||||
|
// left the retention window (#249).
|
||||||
|
func TestStaleSearchClicksJob(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
|
||||||
|
count := func(mbid string) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRowWriter(
|
||||||
|
"SELECT COUNT(*) FROM search_clicks WHERE entity_mbid = ?", mbid,
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count %s: %v", mbid, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
seed := func(query, mbid, lastClicked string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
`INSERT INTO search_clicks
|
||||||
|
(query, entity_mbid, entity_type, click_count, last_clicked)
|
||||||
|
VALUES (?, ?, 'recording', 1, ?)`,
|
||||||
|
query, mbid, lastClicked,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed search_clicks: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seed("tide", "aaaa", "2024-01-01 00:00:00") // stale
|
||||||
|
seed("tide", "bbbb", "2999-01-01 00:00:00") // recent
|
||||||
|
|
||||||
|
if _, err := StaleSearchClicksJob(db).Run(context.Background()); err != nil {
|
||||||
|
t.Fatalf("run job: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := count("bbbb"); n != 1 {
|
||||||
|
t.Errorf("recent click was deleted: %d rows, want 1", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := count("aaaa"); n != 0 {
|
||||||
|
t.Errorf("stale click survived: %d rows, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -628,3 +628,69 @@ func dirSize(dir string) (bytes, files int64) {
|
|||||||
|
|
||||||
return bytes, files
|
return bytes, files
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StaleArtistMetadataJob evicts long-lived artist metadata (bios, wiki
|
||||||
|
// leads, relationships) for artists the user no longer has any reason
|
||||||
|
// to keep around: not owned and holding no cached artwork.
|
||||||
|
//
|
||||||
|
// artist_metadata has no TTL by design — entity data changes rarely and
|
||||||
|
// re-fetching spends someone else's rate limit — so without a sweep it
|
||||||
|
// grows for the life of the install. This is the "swept when the
|
||||||
|
// artist is no longer referenced" contract the datamap always declared
|
||||||
|
// for it and nothing ever performed (#248).
|
||||||
|
func StaleArtistMetadataJob(db *database.DB) Job {
|
||||||
|
return Job{
|
||||||
|
Name: "artist-metadata-sweep",
|
||||||
|
MinInterval: dailyInterval,
|
||||||
|
Run: func(_ context.Context) (Result, error) {
|
||||||
|
res, err := db.ExecContext(
|
||||||
|
`DELETE FROM artist_metadata
|
||||||
|
WHERE mbid NOT IN (` + ownedArtistMBIDs + `)
|
||||||
|
AND mbid NOT IN (
|
||||||
|
SELECT artist_mbid FROM artist_images
|
||||||
|
)`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf(
|
||||||
|
"delete stale artist_metadata rows: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, _ := res.RowsAffected()
|
||||||
|
|
||||||
|
return Result{RowsDeleted: rows}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchClicksRetention is how long a search-click ranking signal stays
|
||||||
|
// useful. search_clicks is authored behavioural data — nothing that
|
||||||
|
// owns a row ever drops it — so age is the ceiling that keeps the table
|
||||||
|
// from growing without bound for the life of the install (#249).
|
||||||
|
const searchClicksRetention = "-180 days"
|
||||||
|
|
||||||
|
// StaleSearchClicksJob deletes search-click ranking rows older than the
|
||||||
|
// retention window. Rows are small and the table grows slowly, so this
|
||||||
|
// runs daily and does almost nothing most runs.
|
||||||
|
func StaleSearchClicksJob(db *database.DB) Job {
|
||||||
|
return Job{
|
||||||
|
Name: "search-clicks-sweep",
|
||||||
|
MinInterval: dailyInterval,
|
||||||
|
Run: func(_ context.Context) (Result, error) {
|
||||||
|
res, err := db.ExecContext(
|
||||||
|
`DELETE FROM search_clicks
|
||||||
|
WHERE last_clicked < datetime('now', ?)`,
|
||||||
|
searchClicksRetention,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf(
|
||||||
|
"delete stale search_clicks rows: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, _ := res.RowsAffected()
|
||||||
|
|
||||||
|
return Result{RowsDeleted: rows}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -134,6 +134,12 @@ type Service struct {
|
|||||||
libraryDir LibraryDirProvider
|
libraryDir LibraryDirProvider
|
||||||
favoritesConf FavoritesConfigProvider
|
favoritesConf FavoritesConfigProvider
|
||||||
|
|
||||||
|
// onDeleted, when set, is called after a playlist is deleted so
|
||||||
|
// cross-cutting state that points at it (the queue's "Playing
|
||||||
|
// from" label) can stop pointing at a playlist that no longer
|
||||||
|
// exists. Wired from app.go, like Library.SetRemovalHooks.
|
||||||
|
onDeleted func(playlistID int64)
|
||||||
|
|
||||||
// dataDirOverride, when non-empty, replaces the OS user data
|
// dataDirOverride, when non-empty, replaces the OS user data
|
||||||
// directory as the base for the playlists folder. Set by tests to
|
// directory as the base for the playlists folder. Set by tests to
|
||||||
// keep M3U writes out of the real user data directory.
|
// keep M3U writes out of the real user data directory.
|
||||||
@@ -166,6 +172,17 @@ func (s *Service) SetFavoritesConfig(
|
|||||||
s.favoritesConf = provider
|
s.favoritesConf = provider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetOnPlaylistDeleted registers a callback invoked after a playlist is
|
||||||
|
// deleted, for cross-cutting invalidation.
|
||||||
|
//
|
||||||
|
//wails:ignore // internal wiring, not part of the app's IPC surface.
|
||||||
|
func (s *Service) SetOnPlaylistDeleted(onDeleted func(playlistID int64)) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.onDeleted = onDeleted
|
||||||
|
}
|
||||||
|
|
||||||
// ServiceStartup is v3's service lifecycle hook: it runs once the
|
// ServiceStartup is v3's service lifecycle hook: it runs once the
|
||||||
// runtime exists, and ctx is cancelled when the app shuts down. It
|
// runtime exists, and ctx is cancelled when the app shuts down. It
|
||||||
// replaces v2's SetContext, which had to be called by hand from
|
// replaces v2's SetContext, which had to be called by hand from
|
||||||
@@ -766,6 +783,17 @@ func (s *Service) DeletePlaylist(playlistID int64) error {
|
|||||||
|
|
||||||
s.emitEvent(events.PlaylistDeleted, playlistID)
|
s.emitEvent(events.PlaylistDeleted, playlistID)
|
||||||
|
|
||||||
|
// Cross-cutting invalidation: the queue's "Playing from" label may
|
||||||
|
// point at this playlist, and a link to a playlist that no longer
|
||||||
|
// exists is worse than none.
|
||||||
|
s.mu.Lock()
|
||||||
|
onDeleted := s.onDeleted
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if onDeleted != nil {
|
||||||
|
onDeleted(playlistID)
|
||||||
|
}
|
||||||
|
|
||||||
// Recreate the default playlist if we just deleted it.
|
// Recreate the default playlist if we just deleted it.
|
||||||
if s.defaultPlaylistID() == playlistID {
|
if s.defaultPlaylistID() == playlistID {
|
||||||
s.EnsureDefaultPlaylist()
|
s.EnsureDefaultPlaylist()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"yellowjacket/backend/events"
|
"yellowjacket/backend/events"
|
||||||
)
|
)
|
||||||
|
|
||||||
// recordPlay inserts a play_history row and updates the denormalized
|
// recordPlay inserts a listening_events row and updates the denormalized
|
||||||
// play_count / last_played columns on audio_files. Called from
|
// play_count / last_played columns on audio_files. Called from
|
||||||
// OnPlaybackFinished for the track that just finished.
|
// OnPlaybackFinished for the track that just finished.
|
||||||
//
|
//
|
||||||
@@ -20,10 +20,12 @@ func (q *Queue) recordPlay(audioFileID int64) {
|
|||||||
|
|
||||||
now := time.Now().UTC().Format(time.DateTime)
|
now := time.Now().UTC().Format(time.DateTime)
|
||||||
|
|
||||||
// Insert play_history row.
|
// Insert the listening event. A natural finish is a 'complete' by
|
||||||
|
// construction; position/duration are the classifier's to fill once
|
||||||
|
// skips are recorded (see .planning/plans/active/021).
|
||||||
_, err := q.db.ExecContext(
|
_, err := q.db.ExecContext(
|
||||||
`INSERT INTO play_history (audio_file_id, played_at)
|
`INSERT INTO listening_events (audio_file_id, kind, occurred_at)
|
||||||
VALUES (?, ?)`,
|
VALUES (?, 'complete', ?)`,
|
||||||
audioFileID, now,
|
audioFileID, now,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1581,6 +1581,21 @@ func (q *Queue) dropSource() {
|
|||||||
q.source = Source{}
|
q.source = Source{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DropSourceForPlaylist clears the queue's "Playing from" label when
|
||||||
|
// its source playlist is deleted. A link back to a playlist that no
|
||||||
|
// longer exists is worse than none, and the label otherwise survives
|
||||||
|
// the deletion until the next SetQueue (#249).
|
||||||
|
func (q *Queue) DropSourceForPlaylist(playlistID int64) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
if (q.source.Type == "playlist" || q.source.Type == "smartPlaylist") &&
|
||||||
|
q.source.ID == playlistID {
|
||||||
|
q.dropSource()
|
||||||
|
q.persistState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// commitMutation persists the current queue state after a mutation.
|
// commitMutation persists the current queue state after a mutation.
|
||||||
// When reindex is true, track positions are renumbered first.
|
// When reindex is true, track positions are renumbered first.
|
||||||
// The caller must hold q.mu.
|
// The caller must hold q.mu.
|
||||||
|
|||||||
@@ -535,3 +535,35 @@ func TestCycleRepeat_CyclesThroughModes(t *testing.T) {
|
|||||||
t.Errorf("after third cycle: got %q, want %q", state.RepeatMode, RepeatOff)
|
t.Errorf("after third cycle: got %q, want %q", state.RepeatMode, RepeatOff)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDropSourceForPlaylist clears the "Playing from" label when the
|
||||||
|
// queue's source playlist is deleted, and leaves it alone otherwise
|
||||||
|
// (#249).
|
||||||
|
func TestDropSourceForPlaylist(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
q, db := setupTestQueue(t)
|
||||||
|
paths := seedAudioFiles(t, db, 2)
|
||||||
|
|
||||||
|
q.SetQueue(paths, 0, false, Source{Type: "playlist", ID: 42, Label: "Road Trip"})
|
||||||
|
q.DropSourceForPlaylist(42)
|
||||||
|
|
||||||
|
if got := q.GetState().Source; got != (Source{}) {
|
||||||
|
t.Errorf("source = %+v, want empty after playlist 42 deleted", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDropSourceForPlaylistIgnoresOtherPlaylists(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
q, db := setupTestQueue(t)
|
||||||
|
paths := seedAudioFiles(t, db, 2)
|
||||||
|
|
||||||
|
source := Source{Type: "smartPlaylist", ID: 42, Label: "Road Trip"}
|
||||||
|
q.SetQueue(paths, 0, false, source)
|
||||||
|
q.DropSourceForPlaylist(7)
|
||||||
|
|
||||||
|
if got := q.GetState().Source; got != source {
|
||||||
|
t.Errorf("source = %+v, want %+v unchanged for a different playlist", got, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,10 +42,10 @@ const ACTIONS = ['Import', 'New Playlist', 'New Smart Playlist'];
|
|||||||
* because the number this issue is about (a button 48px wider than the
|
* because the number this issue is about (a button 48px wider than the
|
||||||
* box holding it) is not in the accessibility tree at all.
|
* box holding it) is not in the accessibility tree at all.
|
||||||
*/
|
*/
|
||||||
const headerFit = (page: import('@playwright/test').Page) =>
|
const headerFit = (page: import('@playwright/test').Page, view = 'playlist-view') =>
|
||||||
page.evaluate(() => {
|
page.evaluate((tag) => {
|
||||||
const root = document
|
const root = document
|
||||||
.querySelector('[data-testid="main-content"] playlist-view')
|
.querySelector(`[data-testid="main-content"] ${tag}`)
|
||||||
?.shadowRoot?.querySelector('page-header')?.shadowRoot;
|
?.shadowRoot?.querySelector('page-header')?.shadowRoot;
|
||||||
|
|
||||||
if (!root) return null;
|
if (!root) return null;
|
||||||
@@ -76,7 +76,7 @@ const headerFit = (page: import('@playwright/test').Page) =>
|
|||||||
...root.querySelectorAll('#page-header-overflow wa-dropdown-item'),
|
...root.querySelectorAll('#page-header-overflow wa-dropdown-item'),
|
||||||
].map((i) => i.textContent?.trim() ?? ''),
|
].map((i) => i.textContent?.trim() ?? ''),
|
||||||
};
|
};
|
||||||
});
|
}, view);
|
||||||
|
|
||||||
test.describe('the page header never clips an action', () => {
|
test.describe('the page header never clips an action', () => {
|
||||||
test.beforeEach(async ({ app }) => {
|
test.beforeEach(async ({ app }) => {
|
||||||
@@ -316,3 +316,58 @@ test.describe('the page header never clips an action', () => {
|
|||||||
await expect.poll(async () => (await headerFit(app))?.menu).toEqual([]);
|
await expect.poll(async () => (await headerFit(app))?.menu).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Tracks header carries the play-all/shuffle-all pair (#31), so
|
||||||
|
* the promise above has to hold for it too — the same per-button
|
||||||
|
* measurement, one view over. Its two actions are the whole of the
|
||||||
|
* header's declared set, and the pair is what plays the list the row
|
||||||
|
* is in, so a button rendered 20px of its 90px is a queue of nothing.
|
||||||
|
*/
|
||||||
|
const TRACK_ACTIONS = ['Play all', 'Shuffle all'];
|
||||||
|
|
||||||
|
test.describe('the Tracks header never clips an action', () => {
|
||||||
|
test.beforeEach(async ({ app }) => {
|
||||||
|
await app.getByTestId('nav-tracks').click();
|
||||||
|
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||||
|
'data-active-view',
|
||||||
|
'tracks',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ app }) => {
|
||||||
|
await app.setViewportSize({ width: 1280, height: 800 });
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const vp of VIEWPORTS) {
|
||||||
|
test(`every action is reachable at ${vp.name}`, async ({ app }) => {
|
||||||
|
await app.setViewportSize({ width: vp.width, height: vp.height });
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await headerFit(app, 'track-list'))?.clipped)
|
||||||
|
.toEqual([]);
|
||||||
|
|
||||||
|
const fit = (await headerFit(app, 'track-list'))!;
|
||||||
|
|
||||||
|
expect(fit.overflow).toBeLessThanOrEqual(0);
|
||||||
|
|
||||||
|
// Between them, buttons and menu account for both actions —
|
||||||
|
// not "it fits" but "nothing was dropped to make it fit".
|
||||||
|
expect([...fit.buttons, ...fit.menu].sort()).toEqual(
|
||||||
|
[...TRACK_ACTIONS].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pair's names, through the accessibility tree — a shadow query
|
||||||
|
* measures, but it cannot say what a screen reader is offered.
|
||||||
|
*/
|
||||||
|
test('both actions are named controls', async ({ app }) => {
|
||||||
|
for (const label of TRACK_ACTIONS) {
|
||||||
|
await expect(
|
||||||
|
app.getByRole('button', { name: label, exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -60,6 +60,23 @@ const PHONE = { width: 424, height: 439 };
|
|||||||
const DESKTOP = { width: 1100, height: 800 };
|
const DESKTOP = { width: 1100, height: 800 };
|
||||||
|
|
||||||
test.describe('background jobs on a phone', () => {
|
test.describe('background jobs on a phone', () => {
|
||||||
|
/**
|
||||||
|
* **State a spec stages is the spec's to clear.** `/__test/emit` writes
|
||||||
|
* to a store nothing resets, so the event that staged a job is the
|
||||||
|
* event that clears it — `JobStore` replaces its whole list from every
|
||||||
|
* snapshot, so `testctl` needs no special case.
|
||||||
|
*
|
||||||
|
* **Measured on #168: this does not currently outlive the page.** Every
|
||||||
|
* test gets a fresh page, and `JobStore.init()` refetches `GetJobs()`
|
||||||
|
* from a backend registry that `/__test/emit` never writes to, so the
|
||||||
|
* staged job is gone before the next spec starts. Ownership is stated
|
||||||
|
* rather than a live leak repaired — the leak needs a page that
|
||||||
|
* survives its own spec, and there is none today.
|
||||||
|
*/
|
||||||
|
test.afterEach(async ({ testctl }) => {
|
||||||
|
await testctl.emit('JobsChanged', []);
|
||||||
|
});
|
||||||
|
|
||||||
test('are shown in the band, without opening anything', async ({
|
test('are shown in the band, without opening anything', async ({
|
||||||
app,
|
app,
|
||||||
testctl,
|
testctl,
|
||||||
|
|||||||
@@ -171,8 +171,8 @@ test.describe('search on a phone', () => {
|
|||||||
// Attached, not visible: `wa-dialog`'s host is `display: contents`,
|
// Attached, not visible: `wa-dialog`'s host is `display: contents`,
|
||||||
// so the element carrying the testid always reports hidden — what
|
// so the element carrying the testid always reports hidden — what
|
||||||
// is visible is the native `<dialog>` inside it. That awkwardness
|
// is visible is the native `<dialog>` inside it. That awkwardness
|
||||||
// is written down in CLAUDE.md and is why the assertion that this
|
// is why the assertion that this is really up is the role query
|
||||||
// is really up is the role query below.
|
// below.
|
||||||
await expect(dialog).toBeAttached();
|
await expect(dialog).toBeAttached();
|
||||||
|
|
||||||
// Named, which `getByRole` can answer and the a11y snapshot cannot
|
// Named, which `getByRole` can answer and the a11y snapshot cannot
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { test, expect, callBinding, resetEvents, waitForEvent } from '../support/fixtures.js';
|
||||||
|
|
||||||
|
type Page = import('@playwright/test').Page;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play-all/Shuffle-all, asserted on what the backend queued rather than
|
||||||
|
* on playback pixels.
|
||||||
|
*
|
||||||
|
* `SetQueue` reports the queue through `QueueChanged`, and `GetState`
|
||||||
|
* says exactly what it holds: the tracks in order, whether shuffle is
|
||||||
|
* on, and the `Source` the "Playing from" link is built from. That is
|
||||||
|
* the honest contract here — the buttons are only as good as the queue
|
||||||
|
* they build, and the queue is only as good as the source it names.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface QueueState {
|
||||||
|
tracks: { filePath: string; title: string }[];
|
||||||
|
currentIndex: number;
|
||||||
|
shuffleMode: boolean;
|
||||||
|
source: { type: string; id: number; label: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRACKS_SOURCE = { type: 'tracks', id: 0, label: 'All Tracks' };
|
||||||
|
|
||||||
|
const getQueue = (app: Page) =>
|
||||||
|
callBinding<QueueState>(app, 'queue.Queue.GetState');
|
||||||
|
|
||||||
|
/** The track paths a rendered track list shows, in row order. */
|
||||||
|
function displayedPaths(app: Page, scope: string): Promise<string[]> {
|
||||||
|
return app
|
||||||
|
.locator(`${scope} [data-testid="track-row"]`)
|
||||||
|
.evaluateAll((els) =>
|
||||||
|
els.map((el) => el.getAttribute('data-file-path') ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leave shuffle in a known state. The mode persists across specs in
|
||||||
|
* one backend process, so a test that asserts on it has to set it. */
|
||||||
|
async function setShuffleMode(app: Page, on: boolean): Promise<void> {
|
||||||
|
const state = await getQueue(app);
|
||||||
|
|
||||||
|
if (state.shuffleMode !== on) {
|
||||||
|
await resetEvents(app);
|
||||||
|
await callBinding(app, 'queue.Queue.ToggleShuffle');
|
||||||
|
await waitForEvent(app, 'QueueModeChanged');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('play-all/shuffle-all on the track list', () => {
|
||||||
|
test.beforeEach(async ({ app }) => {
|
||||||
|
await callBinding(app, 'queue.Queue.Clear').catch(() => {
|
||||||
|
/* the queue is clearable on every build these specs run against */
|
||||||
|
});
|
||||||
|
await setShuffleMode(app, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Tracks Play all queues the displayed list with an honest source', async ({
|
||||||
|
app,
|
||||||
|
}) => {
|
||||||
|
await app.getByTestId('nav-tracks').click();
|
||||||
|
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||||
|
'data-active-view',
|
||||||
|
'tracks',
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
app.locator('track-list [data-testid="track-row"]').first(),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
const paths = await displayedPaths(app, 'track-list');
|
||||||
|
|
||||||
|
await resetEvents(app);
|
||||||
|
await app.getByTestId('page-action-play-all').click();
|
||||||
|
await waitForEvent(app, 'QueueChanged');
|
||||||
|
|
||||||
|
const state = await getQueue(app);
|
||||||
|
|
||||||
|
expect(state.tracks.map((t) => t.filePath)).toEqual(paths);
|
||||||
|
expect(state.currentIndex).toBe(0);
|
||||||
|
expect(state.shuffleMode).toBe(false);
|
||||||
|
expect(state.source).toEqual(TRACKS_SOURCE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Tracks Shuffle all turns shuffle on and keeps the source', async ({
|
||||||
|
app,
|
||||||
|
}) => {
|
||||||
|
await app.getByTestId('nav-tracks').click();
|
||||||
|
await expect(
|
||||||
|
app.locator('track-list [data-testid="track-row"]').first(),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
const paths = await displayedPaths(app, 'track-list');
|
||||||
|
|
||||||
|
await resetEvents(app);
|
||||||
|
await app.getByTestId('page-action-shuffle-all').click();
|
||||||
|
await waitForEvent(app, 'QueueChanged');
|
||||||
|
|
||||||
|
const state = await getQueue(app);
|
||||||
|
|
||||||
|
expect(state.tracks.map((t) => t.filePath)).toEqual(paths);
|
||||||
|
expect(state.shuffleMode).toBe(true);
|
||||||
|
expect(state.source).toEqual(TRACKS_SOURCE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('play-all on an embedded track list', () => {
|
||||||
|
test.beforeEach(async ({ app }) => {
|
||||||
|
await callBinding(app, 'queue.Queue.Clear').catch(() => {});
|
||||||
|
await setShuffleMode(app, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a genre page queues the genre with its name as the source', async ({
|
||||||
|
app,
|
||||||
|
}) => {
|
||||||
|
await app.getByTestId('nav-genres').click();
|
||||||
|
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||||
|
'data-active-view',
|
||||||
|
'genres',
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = app.locator('genres-view .genre-card').first();
|
||||||
|
|
||||||
|
await expect(first).toBeVisible();
|
||||||
|
await first.click();
|
||||||
|
|
||||||
|
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||||
|
'data-active-view',
|
||||||
|
'genre-details',
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
app.locator('genre-details [data-testid="track-row"]').first(),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
const genreName = (await app
|
||||||
|
.locator('genre-details .genre-title')
|
||||||
|
.textContent())?.trim();
|
||||||
|
const paths = await displayedPaths(app, 'genre-details');
|
||||||
|
|
||||||
|
await resetEvents(app);
|
||||||
|
await app
|
||||||
|
.locator('genre-details [data-testid="page-action-play-all"]')
|
||||||
|
.click();
|
||||||
|
await waitForEvent(app, 'QueueChanged');
|
||||||
|
|
||||||
|
const state = await getQueue(app);
|
||||||
|
|
||||||
|
expect(state.tracks.map((t) => t.filePath)).toEqual(paths);
|
||||||
|
expect(state.currentIndex).toBe(0);
|
||||||
|
expect(state.source).toEqual({ type: 'genre', id: 0, label: genreName });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('play-all on the library artist page', () => {
|
||||||
|
test.beforeEach(async ({ app }) => {
|
||||||
|
await callBinding(app, 'queue.Queue.Clear').catch(() => {});
|
||||||
|
await setShuffleMode(app, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an artist page queues album paths in album order with the artist source', async ({
|
||||||
|
app,
|
||||||
|
}) => {
|
||||||
|
const artists = await callBinding<{ ID: number; Name: string }[]>(
|
||||||
|
app,
|
||||||
|
'library.Library.GetArtists',
|
||||||
|
[0],
|
||||||
|
);
|
||||||
|
const first = artists[0]!;
|
||||||
|
|
||||||
|
await app.evaluate(
|
||||||
|
([id, name]) => {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent('navigate', {
|
||||||
|
detail: {
|
||||||
|
view: 'artist-details',
|
||||||
|
artistId: id,
|
||||||
|
artistName: name,
|
||||||
|
},
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[first.ID, first.Name] as const,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||||
|
'data-active-view',
|
||||||
|
'artist-details',
|
||||||
|
);
|
||||||
|
await expect(app.getByTestId('artist-play-all')).toBeEnabled();
|
||||||
|
|
||||||
|
const albums = await callBinding<{ ID: number }[]>(
|
||||||
|
app,
|
||||||
|
'library.Library.GetAlbumsByArtist',
|
||||||
|
[first.Name, 0],
|
||||||
|
);
|
||||||
|
const byAlbum = await callBinding<Record<string, string[]>>(
|
||||||
|
app,
|
||||||
|
'library.Library.GetFilePathsByAlbums',
|
||||||
|
[albums.map((a) => a.ID), 0],
|
||||||
|
);
|
||||||
|
const expected: string[] = [];
|
||||||
|
|
||||||
|
for (const album of albums) {
|
||||||
|
expected.push(...(byAlbum[String(album.ID)] ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
await resetEvents(app);
|
||||||
|
await app.getByTestId('artist-play-all').click();
|
||||||
|
await waitForEvent(app, 'QueueChanged');
|
||||||
|
|
||||||
|
const state = await getQueue(app);
|
||||||
|
|
||||||
|
expect(state.tracks.map((t) => t.filePath)).toEqual(expected);
|
||||||
|
expect(state.source).toEqual({
|
||||||
|
type: 'artist',
|
||||||
|
id: first.ID,
|
||||||
|
label: first.Name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -137,7 +137,7 @@ test.describe('queue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('shuffle and repeat toggles report their state', async ({ app }) => {
|
test('shuffle and repeat toggles report their state', async ({ app }) => {
|
||||||
const shuffle = app.getByRole('button', { name: 'Shuffle' });
|
const shuffle = app.getByRole('button', { name: 'Shuffle', exact: true });
|
||||||
|
|
||||||
await resetEvents(app);
|
await resetEvents(app);
|
||||||
await shuffle.click();
|
await shuffle.click();
|
||||||
|
|||||||
@@ -102,6 +102,22 @@ const collapsed = (page: Page) =>
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
test.describe('the top bar fits the window', () => {
|
test.describe('the top bar fits the window', () => {
|
||||||
|
/**
|
||||||
|
* **State a spec stages is the spec's to clear** (#168). `/__test/emit`
|
||||||
|
* writes to a store nothing resets, and this file stages the widest job
|
||||||
|
* in the app, so it puts it back — with the same event, since the store
|
||||||
|
* replaces its whole list from every snapshot.
|
||||||
|
*
|
||||||
|
* **Measured: it does not currently outlive the page.** Every test gets
|
||||||
|
* a fresh page and `JobStore.init()` refetches `GetJobs()` from a
|
||||||
|
* backend registry `/__test/emit` never writes to, so nothing is being
|
||||||
|
* repaired here; the rule is stated because it costs one line and the
|
||||||
|
* leak would need only one spec that keeps a page alive.
|
||||||
|
*/
|
||||||
|
test.afterEach(async ({ testctl }) => {
|
||||||
|
await testctl.emit('JobsChanged', []);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The phone's answer, which is not "it fits" (#57).
|
* The phone's answer, which is not "it fits" (#57).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -121,6 +121,12 @@ export interface CandidateFile {
|
|||||||
"bitrate"?: number;
|
"bitrate"?: number;
|
||||||
"isAudio": boolean;
|
"isAudio": boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LengthMillis is the file's duration as the source reports it, or
|
||||||
|
* 0 when it does not. Soulseek reports it for most audio files.
|
||||||
|
*/
|
||||||
|
"lengthMillis"?: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* expected track position
|
* expected track position
|
||||||
*/
|
*/
|
||||||
@@ -453,10 +459,19 @@ export interface MatchScore {
|
|||||||
"albumFit": number;
|
"albumFit": number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* audio files vs expected count
|
* aligned tracks vs expected count
|
||||||
*/
|
*/
|
||||||
"completeness": number;
|
"completeness": number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DurationFit is how well the aligned files' lengths agree with the
|
||||||
|
* expected tracks', and DurationKnown whether enough of them stated
|
||||||
|
* a length for that to count. When it does not, the score is the
|
||||||
|
* four text signals alone, exactly as before durations were read.
|
||||||
|
*/
|
||||||
|
"durationFit": number;
|
||||||
|
"durationKnown": boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anchored records whether an MBID drove this score. Unanchored
|
* Anchored records whether an MBID drove this score. Unanchored
|
||||||
* matches are capped, because there is nothing to be right about.
|
* matches are capped, because there is nothing to be right about.
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ export function CycleRepeat(): $CancellablePromise<void> {
|
|||||||
return $Call.ByID(3510519482);
|
return $Call.ByID(3510519482);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DropSourceForPlaylist clears the queue's "Playing from" label when
|
||||||
|
* its source playlist is deleted. A link back to a playlist that no
|
||||||
|
* longer exists is worse than none, and the label otherwise survives
|
||||||
|
* the deletion until the next SetQueue (#249).
|
||||||
|
*/
|
||||||
|
export function DropSourceForPlaylist(playlistID: number): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(1435106374, playlistID);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EmitCurrentState emits the current queue state to the frontend.
|
* EmitCurrentState emits the current queue state to the frontend.
|
||||||
* This is called after the frontend DOM is ready.
|
* This is called after the frontend DOM is ready.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l192 192c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256 246.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192z"/></svg>
|
||||||
|
After Width: | Height: | Size: 476 B |
@@ -11,11 +11,22 @@ import {
|
|||||||
GetArtistImageCachedPath,
|
GetArtistImageCachedPath,
|
||||||
GetArtistMBID,
|
GetArtistMBID,
|
||||||
} from '@go/explore/service.js';
|
} from '@go/explore/service.js';
|
||||||
|
import { GetFilePathsByAlbums } from '@go/library/library.js';
|
||||||
|
import { libraryStore } from '@store/library-store';
|
||||||
|
import { notificationStore } from '@store/notification-store';
|
||||||
|
import { dict } from '@utils/binding';
|
||||||
|
import { playAll } from '@utils/play-all';
|
||||||
|
import { describeError } from '@utils/describe-error';
|
||||||
|
import { ICON_PLAY, ICON_SHUFFLE } from '@utils/icon-language';
|
||||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
import '@components/cover-grid/cover-grid.js';
|
import '@components/cover-grid/cover-grid.js';
|
||||||
|
import '../notifications/inline-notice';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
import { backButton } from '../../styles/back-button.css';
|
import { backButton } from '../../styles/back-button.css';
|
||||||
|
|
||||||
|
/** The region the artist header's own failures are rendered in. */
|
||||||
|
const ArtistRegion = 'library-artist';
|
||||||
|
|
||||||
@customElement('artist-details')
|
@customElement('artist-details')
|
||||||
export class ArtistDetails extends LitElement {
|
export class ArtistDetails extends LitElement {
|
||||||
@property({ type: Number, attribute: 'artist-id' })
|
@property({ type: Number, attribute: 'artist-id' })
|
||||||
@@ -131,6 +142,39 @@ export class ArtistDetails extends LitElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-action {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--yj-border-subtle, #555);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--yj-text-primary, #fff);
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: var(--yj-text-md, 13px);
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-action:hover {
|
||||||
|
border-color: var(--yj-accent, #ffd43b);
|
||||||
|
color: var(--yj-accent-text, #ffd43b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-action:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
/* ====================================
|
/* ====================================
|
||||||
* Content
|
* Content
|
||||||
* ==================================== */
|
* ==================================== */
|
||||||
@@ -145,6 +189,23 @@ export class ArtistDetails extends LitElement {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Phone widths: the header's flex row squeezed .artist-info to
|
||||||
|
* nothing, so the title ellipsised away entirely and the
|
||||||
|
* actions clipped against the host's own overflow — the album
|
||||||
|
* page's fault one detail view over (#66). The pair takes its
|
||||||
|
* own row instead. Written last, because a media query adds no
|
||||||
|
* specificity and a rule placed above the plain ones it
|
||||||
|
* overrides is silently dead. */
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.artist-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
`];
|
`];
|
||||||
|
|
||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
@@ -302,6 +363,45 @@ export class ArtistDetails extends LitElement {
|
|||||||
return name.charAt(0).toUpperCase();
|
return name.charAt(0).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play every track on this artist's albums, in album order.
|
||||||
|
*
|
||||||
|
* One `GetFilePathsByAlbums` call returns the paths grouped by
|
||||||
|
* album id; the caller owns the ordering, so they are flattened in
|
||||||
|
* `this.albums` order rather than by id.
|
||||||
|
*/
|
||||||
|
private async playAllTracks(shuffle: boolean): Promise<void> {
|
||||||
|
if (this.albums.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const libId = libraryStore.getSelectedLibraryId() ?? 0;
|
||||||
|
const ids = this.albums.map((a) => a.ID);
|
||||||
|
const byAlbum = await dict(
|
||||||
|
GetFilePathsByAlbums(ids, libId),
|
||||||
|
);
|
||||||
|
const paths: string[] = [];
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
paths.push(...(byAlbum[id] ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
playAll(
|
||||||
|
paths,
|
||||||
|
{
|
||||||
|
type: 'artist',
|
||||||
|
id: this.artistId,
|
||||||
|
label: this.artistName,
|
||||||
|
},
|
||||||
|
shuffle,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Could not play artist:', error);
|
||||||
|
notificationStore.inline(ArtistRegion, {
|
||||||
|
text: describeError(error, 'Could not play this artist’s tracks.'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
* Rendering
|
* Rendering
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
@@ -351,12 +451,38 @@ export class ArtistDetails extends LitElement {
|
|||||||
`
|
`
|
||||||
: ''}
|
: ''}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button
|
||||||
|
class="header-action"
|
||||||
|
data-testid="artist-play-all"
|
||||||
|
?disabled=${this.albums.length === 0}
|
||||||
|
@click=${() =>
|
||||||
|
void this.playAllTracks(false)}
|
||||||
|
>
|
||||||
|
<wa-icon name=${ICON_PLAY}></wa-icon>
|
||||||
|
Play all
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="header-action"
|
||||||
|
data-testid="artist-shuffle-all"
|
||||||
|
?disabled=${this.albums.length === 0}
|
||||||
|
@click=${() =>
|
||||||
|
void this.playAllTracks(true)}
|
||||||
|
>
|
||||||
|
<wa-icon name=${ICON_SHUFFLE}></wa-icon>
|
||||||
|
Shuffle all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<cover-grid
|
<cover-grid
|
||||||
.externalAlbums=${this.albums}
|
.externalAlbums=${this.albums}
|
||||||
></cover-grid>
|
></cover-grid>
|
||||||
</div>
|
</div>
|
||||||
|
<inline-notice
|
||||||
|
region=${ArtistRegion}
|
||||||
|
testid="artist-play-message"
|
||||||
|
></inline-notice>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import type * as autotagservice from '@go/autotagservice/models.js';
|
|||||||
import { confirmAction } from '../confirm-dialog/confirm-dialog';
|
import { confirmAction } from '../confirm-dialog/confirm-dialog';
|
||||||
import { queueStore } from '../../store/queue-store';
|
import { queueStore } from '../../store/queue-store';
|
||||||
import type { QueueSource } from '../../store/queue-store';
|
import type { QueueSource } from '../../store/queue-store';
|
||||||
|
import { playAll } from '@utils/play-all';
|
||||||
import { notificationStore } from '../../store/notification-store';
|
import { notificationStore } from '../../store/notification-store';
|
||||||
import '../notifications/inline-notice';
|
import '../notifications/inline-notice';
|
||||||
import {
|
import {
|
||||||
@@ -59,6 +60,7 @@ import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
|||||||
import { dictByName } from '@utils/binding';
|
import { dictByName } from '@utils/binding';
|
||||||
import type { TrackDetails } from '@components/track-details/track-details.js';
|
import type { TrackDetails } from '@components/track-details/track-details.js';
|
||||||
import { showTrackDetailsForPath } from '@utils/track-details-opener.js';
|
import { showTrackDetailsForPath } from '@utils/track-details-opener.js';
|
||||||
|
import { openMusicBrainz } from '@utils/external-link';
|
||||||
import '@components/playlist-picker/playlist-picker.js';
|
import '@components/playlist-picker/playlist-picker.js';
|
||||||
import {
|
import {
|
||||||
ICON_CAN_REQUEST,
|
ICON_CAN_REQUEST,
|
||||||
@@ -2771,20 +2773,11 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
|||||||
|
|
||||||
/** Play what the user owns of this release, optionally shuffled. */
|
/** Play what the user owns of this release, optionally shuffled. */
|
||||||
private playOwned(shuffle: boolean): void {
|
private playOwned(shuffle: boolean): void {
|
||||||
const paths = this.ownedFilePaths();
|
|
||||||
|
|
||||||
// The button is only rendered when there is something to play,
|
// The button is only rendered when there is something to play,
|
||||||
// so an empty set here is not a state the user can reach.
|
// so an empty set here is not a state the user can reach. The
|
||||||
if (paths.length === 0) return;
|
// shuffle-mode semantics live in `playAll`, shared with the
|
||||||
|
// play-all/shuffle-all pair on every track list.
|
||||||
// `shuffleStart` only picks a random first track when shuffle
|
playAll(this.ownedFilePaths(), this.queueSource(), shuffle);
|
||||||
// mode is *already* on — it does not turn it on — so the mode
|
|
||||||
// has to be set before the queue, not after.
|
|
||||||
if (shuffle && !queueStore.getState().shuffleMode) {
|
|
||||||
queueStore.toggleShuffle();
|
|
||||||
}
|
|
||||||
|
|
||||||
queueStore.setQueue(paths, 0, shuffle, this.queueSource());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Append what the user owns of this release to the queue. */
|
/** Append what the user owns of this release to the queue. */
|
||||||
@@ -2950,7 +2943,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
|||||||
|
|
||||||
if (!track?.mbid) return;
|
if (!track?.mbid) return;
|
||||||
|
|
||||||
window.open(`https://musicbrainz.org/recording/${track.mbid}`, '_blank', 'noopener');
|
openMusicBrainz(`/recording/${track.mbid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { customElement, property, state, query } from 'lit/decorators.js';
|
|||||||
import { classMap } from 'lit/directives/class-map.js';
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
import { backButton } from '../../styles/back-button.css';
|
import { backButton } from '../../styles/back-button.css';
|
||||||
|
import { albumCardStyles } from '../../styles/album-card.css';
|
||||||
|
import '../scroll-row/scroll-row.js';
|
||||||
import {
|
import {
|
||||||
LookupArtist,
|
LookupArtist,
|
||||||
BrowseReleaseGroups,
|
BrowseReleaseGroups,
|
||||||
@@ -46,11 +48,8 @@ import {
|
|||||||
libraryStatusFor,
|
libraryStatusFor,
|
||||||
toggleRequest,
|
toggleRequest,
|
||||||
} from '@utils/library-status';
|
} from '@utils/library-status';
|
||||||
import {
|
import { isOwned, ownershipLabel } from '@utils/ownership';
|
||||||
isOwned,
|
import { openMusicBrainz } from '@utils/external-link';
|
||||||
ownershipLabel,
|
|
||||||
unownedStyles,
|
|
||||||
} from '@utils/ownership';
|
|
||||||
import { completenessStore } from '@store/completeness-store';
|
import { completenessStore } from '@store/completeness-store';
|
||||||
import '../catalog-scope-notice/catalog-scope-notice.js';
|
import '../catalog-scope-notice/catalog-scope-notice.js';
|
||||||
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
|
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
|
||||||
@@ -62,6 +61,7 @@ import {
|
|||||||
ContextMenuController,
|
ContextMenuController,
|
||||||
contextMenuStyles,
|
contextMenuStyles,
|
||||||
isContextMenuKey,
|
isContextMenuKey,
|
||||||
|
MenuKeyboard,
|
||||||
} from '@utils/context-menu-controller.js';
|
} from '@utils/context-menu-controller.js';
|
||||||
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
|
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
|
||||||
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||||
@@ -187,11 +187,11 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
@state() private topReleasesExpanded = false;
|
@state() private topReleasesExpanded = false;
|
||||||
private topSectionStacked = false;
|
private topSectionStacked = false;
|
||||||
private topSectionObserver?: ResizeObserver;
|
private topSectionObserver?: ResizeObserver;
|
||||||
@state() private expandedDiscoGroups = new Set<string>();
|
|
||||||
/** Number of album cards that fit in one row of the discography grid. */
|
/** Whether the Play button's Shuffle dropdown is up. */
|
||||||
@state() private discoRowSize = 5;
|
@state() private playMenuOpen = false;
|
||||||
private discoObserver?: ResizeObserver;
|
private playMenuKeyboard = new MenuKeyboard(() => this.closePlayMenu());
|
||||||
@state() private similarExpanded = false;
|
private playOutsideAttached = false;
|
||||||
|
|
||||||
/* ── Release prefetch ── */
|
/* ── Release prefetch ── */
|
||||||
|
|
||||||
@@ -221,6 +221,12 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
@query('#context-menu')
|
@query('#context-menu')
|
||||||
private contextMenuPopup!: MenuSurface;
|
private contextMenuPopup!: MenuSurface;
|
||||||
|
|
||||||
|
@query('.play-menu-button')
|
||||||
|
private playMenuButton?: HTMLButtonElement;
|
||||||
|
|
||||||
|
@query('#artist-play-menu')
|
||||||
|
private playMenuPanel?: HTMLElement;
|
||||||
|
|
||||||
@query('#playlist-submenu')
|
@query('#playlist-submenu')
|
||||||
private playlistSubmenuPopup?: WaPopup;
|
private playlistSubmenuPopup?: WaPopup;
|
||||||
|
|
||||||
@@ -271,7 +277,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
backButton,
|
backButton,
|
||||||
exploreLinkStyles,
|
exploreLinkStyles,
|
||||||
contextMenuStyles,
|
contextMenuStyles,
|
||||||
unownedStyles,
|
albumCardStyles,
|
||||||
css`
|
css`
|
||||||
:host {
|
:host {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -319,10 +325,45 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-follow {
|
.artist-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The Play button and its caret are one control, so they
|
||||||
|
are one box: no gap between them, and the caret carries
|
||||||
|
the same filled appearance as the button it extends. */
|
||||||
|
.play-split {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-menu-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-left: 1px solid rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
background: var(--yj-accent, #ffd43b);
|
||||||
|
color: var(--yj-accent-fg, #000);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-menu-button:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-menu-button:focus-visible {
|
||||||
|
outline: 2px solid var(--yj-accent-text, #ffd43b);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.artist-info {
|
.artist-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -331,7 +372,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
}
|
}
|
||||||
|
|
||||||
.artist-title {
|
.artist-title {
|
||||||
font-size: 24px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--yj-text-primary, #fff);
|
color: var(--yj-text-primary, #fff);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -359,6 +400,13 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The listen count is a headline number, not metadata, so
|
||||||
|
it sits a size above the type/country line. */
|
||||||
|
.artist-listens {
|
||||||
|
font-size: var(--yj-text-lg);
|
||||||
|
color: var(--yj-text-secondary, #b3b3b3);
|
||||||
|
}
|
||||||
|
|
||||||
.meta-separator {
|
.meta-separator {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
@@ -446,23 +494,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-play-actions {
|
|
||||||
margin-top: 10px;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-rank {
|
|
||||||
width: 24px;
|
|
||||||
text-align: right;
|
|
||||||
color: var(--yj-text-tertiary, #888);
|
|
||||||
font-size: var(--yj-text-md);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-art {
|
.track-art {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -489,6 +520,52 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Play where you own the track, the request badge where you
|
||||||
|
do not — over the artwork rather than at the end of the
|
||||||
|
row, where it was a badge beside a row you can already
|
||||||
|
double-click. */
|
||||||
|
.track-art-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-art-play {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.track-item:hover .track-art-overlay,
|
||||||
|
.track-item:focus-within .track-art-overlay {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No hover means no double-click either, so the overlay is
|
||||||
|
the only route to playing a top track and must be there. */
|
||||||
|
@media not all and (hover: hover) {
|
||||||
|
.track-art-overlay {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.track-info {
|
.track-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -522,7 +599,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
.track-item library-status-indicator {
|
.track-item library-status-indicator {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Top section (tracks + releases side-by-side) ── */
|
/* ── Top section (tracks + releases side-by-side) ── */
|
||||||
.top-section-wrapper {
|
.top-section-wrapper {
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
@@ -754,8 +830,30 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-release-meta library-status-indicator {
|
.top-release-art .album-card-badge {
|
||||||
flex-shrink: 0;
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 4px;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.top-release-card:hover .album-card-badge,
|
||||||
|
.top-release-card:focus-within .album-card-badge {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media not all and (hover: hover) {
|
||||||
|
.top-release-art .album-card-badge {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -773,150 +871,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.album-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, 140px);
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-grid.collapsed {
|
|
||||||
grid-template-rows: 1fr;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.disco-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
margin-top: 4px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
|
|
||||||
color: var(--yj-text-secondary, #b3b3b3);
|
|
||||||
font-size: var(--yj-text-xs);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s ease, color 0.15s ease;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.disco-toggle:hover {
|
|
||||||
background: var(--yj-bg-hover, rgba(255, 255, 255, 0.1));
|
|
||||||
color: var(--yj-text-primary, #fff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.disco-toggle wa-icon {
|
|
||||||
font-size: 11px;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.disco-toggle[aria-expanded='true'] wa-icon {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-card:hover {
|
|
||||||
background: var(
|
|
||||||
--yj-bg-overlay,
|
|
||||||
rgba(255, 255, 255, 0.06)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-card:active {
|
|
||||||
transform: scale(0.97);
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-art-container {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 1;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
flex-shrink: 0;
|
|
||||||
position: relative;
|
|
||||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-art-container img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-art-fallback {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-art-fallback wa-icon {
|
|
||||||
color: var(--yj-text-tertiary, #888);
|
|
||||||
font-size: 24px;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-title {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--yj-text-primary, #fff);
|
|
||||||
font-size: var(--yj-text-sm);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 6px;
|
|
||||||
color: var(--yj-text-tertiary, #888);
|
|
||||||
font-size: var(--yj-text-xs);
|
|
||||||
min-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-meta-text {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-meta library-status-indicator {
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Similar artists ── */
|
/* ── Similar artists ── */
|
||||||
.similar-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, 140px);
|
|
||||||
gap: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-row.collapsed {
|
|
||||||
grid-template-rows: 1fr;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-artist-card {
|
.similar-artist-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -926,6 +881,9 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
width: 120px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-shrink: 0;
|
||||||
transition: background 0.15s ease;
|
transition: background 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1056,7 +1014,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
this.unsubSimilarReady?.();
|
this.unsubSimilarReady?.();
|
||||||
if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer);
|
if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer);
|
||||||
this.topSectionObserver?.disconnect();
|
this.topSectionObserver?.disconnect();
|
||||||
this.discoObserver?.disconnect();
|
this.detachPlayOutsideClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1083,17 +1041,14 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
|
|
||||||
protected override firstUpdated() {
|
protected override firstUpdated() {
|
||||||
this.observeTopSectionWidth();
|
this.observeTopSectionWidth();
|
||||||
this.observeDiscoWidth();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override updated() {
|
protected override updated() {
|
||||||
// Re-attach observers if elements appeared after initial render.
|
// Re-attach the observer if the section appeared after initial
|
||||||
|
// render.
|
||||||
if (!this.topSectionObserver) {
|
if (!this.topSectionObserver) {
|
||||||
this.observeTopSectionWidth();
|
this.observeTopSectionWidth();
|
||||||
}
|
}
|
||||||
if (!this.discoObserver) {
|
|
||||||
this.observeDiscoWidth();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1126,32 +1081,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
this.topSectionObserver.observe(wrapper);
|
this.topSectionObserver.observe(wrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Watch the .content width and compute how many album cards
|
|
||||||
* fit in one row of the discography grid.
|
|
||||||
* Grid uses: repeat(auto-fill, minmax(140px, 1fr)) with 16px gap
|
|
||||||
* and album-card has 8px padding on each side.
|
|
||||||
*/
|
|
||||||
private observeDiscoWidth() {
|
|
||||||
const content = this.renderRoot.querySelector('.content');
|
|
||||||
if (!content) return;
|
|
||||||
|
|
||||||
const CARD_MIN = 140;
|
|
||||||
const GAP = 16;
|
|
||||||
|
|
||||||
this.discoObserver = new ResizeObserver((entries) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
const width = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
|
|
||||||
const cols = Math.max(1, Math.floor((width + GAP) / (CARD_MIN + GAP)));
|
|
||||||
if (cols !== this.discoRowSize) {
|
|
||||||
this.discoRowSize = cols;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.discoObserver.observe(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Data Loading ── */
|
/* ── Data Loading ── */
|
||||||
|
|
||||||
private async loadAllData() {
|
private async loadAllData() {
|
||||||
@@ -1862,6 +1791,8 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
} catch {
|
} catch {
|
||||||
// No image — letter avatar stays.
|
// No image — letter avatar stays.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1999,6 +1930,65 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Play / Shuffle split button ── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the Play button's Shuffle dropdown.
|
||||||
|
*
|
||||||
|
* `page-header`'s overflow menu one control over: the same
|
||||||
|
* `MenuKeyboard`, the same document-level outside-close, and the
|
||||||
|
* same `menu-surface`, so the phone gets the bottom sheet rather
|
||||||
|
* than a popup that Chrome 113 clips.
|
||||||
|
*/
|
||||||
|
private togglePlayMenu = (): void => {
|
||||||
|
if (this.playMenuOpen) {
|
||||||
|
this.closePlayMenu();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.playMenuOpen = true;
|
||||||
|
|
||||||
|
void this.updateComplete.then(() => {
|
||||||
|
if (!this.playMenuOpen) return;
|
||||||
|
|
||||||
|
this.playMenuKeyboard.open(
|
||||||
|
this.playMenuPanel ?? null,
|
||||||
|
this.playMenuButton ?? null,
|
||||||
|
);
|
||||||
|
this.attachPlayOutsideClose();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
private closePlayMenu = (): void => {
|
||||||
|
if (!this.playMenuOpen) return;
|
||||||
|
|
||||||
|
this.detachPlayOutsideClose();
|
||||||
|
this.playMenuKeyboard.close();
|
||||||
|
this.playMenuOpen = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
private onPlayOutsideDown = (e: Event): void => {
|
||||||
|
if (e.composedPath().includes(this.playMenuPanel as EventTarget)) return;
|
||||||
|
if (e.composedPath().includes(this.playMenuButton as EventTarget)) return;
|
||||||
|
|
||||||
|
this.closePlayMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
private attachPlayOutsideClose(): void {
|
||||||
|
if (this.playOutsideAttached) return;
|
||||||
|
|
||||||
|
this.playOutsideAttached = true;
|
||||||
|
document.addEventListener('mousedown', this.onPlayOutsideDown, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private detachPlayOutsideClose(): void {
|
||||||
|
if (!this.playOutsideAttached) return;
|
||||||
|
|
||||||
|
this.playOutsideAttached = false;
|
||||||
|
document.removeEventListener('mousedown', this.onPlayOutsideDown, true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* File path for one top track, resolved by recording MBID — the
|
* File path for one top track, resolved by recording MBID — the
|
||||||
* same key `localId` was set from. Works whether or not the
|
* same key `localId` was set from. Works whether or not the
|
||||||
@@ -2240,11 +2230,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
|
|
||||||
if (!release?.mbid) return;
|
if (!release?.mbid) return;
|
||||||
|
|
||||||
window.open(
|
openMusicBrainz(`/release-group/${release.mbid}`);
|
||||||
`https://musicbrainz.org/release-group/${release.mbid}`,
|
|
||||||
'_blank',
|
|
||||||
'noopener',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private onContextMenuAction(
|
private onContextMenuAction(
|
||||||
@@ -2358,7 +2344,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
|
|
||||||
if (!track?.recordingMbid) return;
|
if (!track?.recordingMbid) return;
|
||||||
|
|
||||||
window.open(`https://musicbrainz.org/recording/${track.recordingMbid}`, '_blank', 'noopener');
|
openMusicBrainz(`/recording/${track.recordingMbid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Navigation ── */
|
/* ── Navigation ── */
|
||||||
@@ -2538,10 +2524,12 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
: nothing}
|
: nothing}
|
||||||
${this.renderArtistMeta()}
|
${this.renderArtistMeta()}
|
||||||
${this.artist?.popularity && this.artist.popularity > 0
|
${this.artist?.popularity && this.artist.popularity > 0
|
||||||
? html`<span class="artist-meta">${formatListenCount(this.artist.popularity)} plays on ListenBrainz</span>`
|
? html`<span class="artist-listens">${formatListenCount(this.artist.popularity)} plays on ListenBrainz</span>`
|
||||||
: nothing}
|
: nothing}
|
||||||
${this.renderPlayLibraryAction()}
|
<div class="artist-actions">
|
||||||
${this.renderFollowAction()}
|
${this.renderPlayLibraryAction()}
|
||||||
|
${this.renderFollowAction()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
@@ -2572,25 +2560,53 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
if (this.ownedLocalAlbumIds().length === 0) return nothing;
|
if (this.ownedLocalAlbumIds().length === 0) return nothing;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="artist-play-actions">
|
<div class="play-split">
|
||||||
<wa-button
|
<wa-button
|
||||||
size="small"
|
size="small"
|
||||||
appearance="filled"
|
appearance="filled"
|
||||||
data-testid="artist-play-library"
|
data-testid="artist-play-library"
|
||||||
|
title="Play library tracks"
|
||||||
@click=${() => void this.playLibraryTracks(false)}
|
@click=${() => void this.playLibraryTracks(false)}
|
||||||
>
|
>
|
||||||
<wa-icon slot="start" name="play"></wa-icon>
|
<wa-icon slot="start" name="play"></wa-icon>
|
||||||
Play library tracks
|
Play
|
||||||
</wa-button>
|
</wa-button>
|
||||||
<wa-button
|
<menu-surface
|
||||||
size="small"
|
placement="bottom-start"
|
||||||
appearance="outlined"
|
.active=${this.playMenuOpen}
|
||||||
data-testid="artist-shuffle-library"
|
@menu-dismiss=${this.closePlayMenu}
|
||||||
@click=${() => void this.playLibraryTracks(true)}
|
|
||||||
>
|
>
|
||||||
<wa-icon slot="start" name="shuffle"></wa-icon>
|
<button
|
||||||
Shuffle
|
slot="anchor"
|
||||||
</wa-button>
|
class="play-menu-button"
|
||||||
|
type="button"
|
||||||
|
data-testid="artist-play-menu"
|
||||||
|
aria-label="More play options"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded=${this.playMenuOpen ? 'true' : 'false'}
|
||||||
|
aria-controls="artist-play-menu"
|
||||||
|
@click=${this.togglePlayMenu}
|
||||||
|
>
|
||||||
|
<wa-icon name="chevron-down"></wa-icon>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
id="artist-play-menu"
|
||||||
|
class="context-menu-panel"
|
||||||
|
role="menu"
|
||||||
|
aria-label="Play options"
|
||||||
|
>
|
||||||
|
<wa-dropdown-item
|
||||||
|
data-testid="artist-shuffle-library"
|
||||||
|
@click=${() => {
|
||||||
|
this.closePlayMenu();
|
||||||
|
void this.playLibraryTracks(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<wa-icon slot="icon" name="shuffle"></wa-icon>
|
||||||
|
Shuffle
|
||||||
|
</wa-dropdown-item>
|
||||||
|
</div>
|
||||||
|
</menu-surface>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -2786,25 +2802,27 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
const request = downloadStore.requestFor(this.artistMBID);
|
const request = downloadStore.requestFor(this.artistMBID);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="artist-follow">
|
<wa-button
|
||||||
<wa-button
|
size="small"
|
||||||
size="small"
|
appearance=${request ? 'filled' : 'outlined'}
|
||||||
appearance=${request ? 'filled' : 'outlined'}
|
data-testid="artist-follow"
|
||||||
@click=${() => void this.toggleFollow(request?.id)}
|
title=${request
|
||||||
>
|
? 'Following this artist'
|
||||||
<!-- This was bookmark-check, which is not in
|
: 'Follow this artist for new releases'}
|
||||||
names.txt and so has rendered the missing-icon
|
@click=${() => void this.toggleFollow(request?.id)}
|
||||||
fallback — a circled question mark — on every
|
>
|
||||||
followed artist since it was written. A
|
<!-- This was bookmark-check, which is not in
|
||||||
backtick around that name would end this
|
names.txt and so has rendered the missing-icon
|
||||||
template literal, which is why there is none. -->
|
fallback — a circled question mark — on every
|
||||||
<wa-icon
|
followed artist since it was written. A
|
||||||
slot="start"
|
backtick around that name would end this
|
||||||
name=${request ? ICON_REQUESTED : ICON_CAN_REQUEST}
|
template literal, which is why there is none. -->
|
||||||
></wa-icon>
|
<wa-icon
|
||||||
${request ? 'Following' : 'Follow for new releases'}
|
slot="start"
|
||||||
</wa-button>
|
name=${request ? ICON_REQUESTED : ICON_CAN_REQUEST}
|
||||||
</div>
|
></wa-icon>
|
||||||
|
${request ? 'Following' : 'Follow'}
|
||||||
|
</wa-button>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2888,16 +2906,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
this.topReleasesExpanded = !this.topReleasesExpanded;
|
this.topReleasesExpanded = !this.topReleasesExpanded;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toggleDiscoGroup(type: string) {
|
|
||||||
const next = new Set(this.expandedDiscoGroups);
|
|
||||||
if (next.has(type)) {
|
|
||||||
next.delete(type);
|
|
||||||
} else {
|
|
||||||
next.add(type);
|
|
||||||
}
|
|
||||||
this.expandedDiscoGroups = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderTopSection() {
|
private renderTopSection() {
|
||||||
const hasTracks = !this.loadingTracks && this.topTracks.length > 0;
|
const hasTracks = !this.loadingTracks && this.topTracks.length > 0;
|
||||||
const hasReleases = !this.loadingTopReleases && this.topReleaseGroups.length > 0;
|
const hasReleases = !this.loadingTopReleases && this.topReleaseGroups.length > 0;
|
||||||
@@ -2971,6 +2979,31 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
}} />`
|
}} />`
|
||||||
: html`<wa-icon name="compact-disc"></wa-icon>`;
|
: html`<wa-icon name="compact-disc"></wa-icon>`;
|
||||||
})()}
|
})()}
|
||||||
|
<!-- Over the artwork, not beside the
|
||||||
|
row: play where you own it, the
|
||||||
|
request badge where you do not. -->
|
||||||
|
<div class="track-art-overlay">
|
||||||
|
${owned
|
||||||
|
? html`<button
|
||||||
|
class="track-art-play"
|
||||||
|
type="button"
|
||||||
|
aria-label=${`Play ${t.trackName}`}
|
||||||
|
@click=${(e: Event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void this.playTrack(t);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<wa-icon name="play"></wa-icon>
|
||||||
|
</button>`
|
||||||
|
: html`<library-status-indicator
|
||||||
|
status=${libraryStatusFor(false, t.recordingMbid)}
|
||||||
|
entity-type="track"
|
||||||
|
label=${t.trackName}
|
||||||
|
request-mbid=${t.recordingMbid}
|
||||||
|
request-artist=${t.artistName ?? ''}
|
||||||
|
size="18"
|
||||||
|
></library-status-indicator>`}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="track-info">
|
<div class="track-info">
|
||||||
<div class="track-title">${trackLink(t.trackName, t.releaseName, t.releaseGroupMbid ?? '', t.recordingMbid)}</div>
|
<div class="track-title">${trackLink(t.trackName, t.releaseName, t.releaseGroupMbid ?? '', t.recordingMbid)}</div>
|
||||||
@@ -2979,15 +3012,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
<span class="track-listens">
|
<span class="track-listens">
|
||||||
${formatListenCount(t.totalListenCount)} plays
|
${formatListenCount(t.totalListenCount)} plays
|
||||||
</span>
|
</span>
|
||||||
${owned
|
|
||||||
? nothing
|
|
||||||
: html`<library-status-indicator
|
|
||||||
status=${libraryStatusFor(false, t.recordingMbid)}
|
|
||||||
entity-type="track"
|
|
||||||
label=${t.trackName}
|
|
||||||
request-mbid=${t.recordingMbid}
|
|
||||||
request-artist=${t.artistName ?? ''}
|
|
||||||
></library-status-indicator>`}
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
@@ -3093,6 +3117,18 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
<div class="album-art-fallback" style="${artURL ? 'display: none' : ''}">
|
<div class="album-art-fallback" style="${artURL ? 'display: none' : ''}">
|
||||||
<wa-icon name="compact-disc"></wa-icon>
|
<wa-icon name="compact-disc"></wa-icon>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="album-card-badge">
|
||||||
|
<library-status-indicator
|
||||||
|
status=${badge.status}
|
||||||
|
owned=${badge.owned}
|
||||||
|
expected=${badge.expected}
|
||||||
|
entity-type="album"
|
||||||
|
label=${rg.title}
|
||||||
|
request-mbid=${rg.releaseGroupMbid}
|
||||||
|
request-artist=${this.artist?.name ?? ''}
|
||||||
|
size="21"
|
||||||
|
></library-status-indicator>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-release-text">
|
<div class="top-release-text">
|
||||||
<div class="top-release-title" title="${rg.title}">
|
<div class="top-release-title" title="${rg.title}">
|
||||||
@@ -3102,18 +3138,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
<div class="top-release-meta-text">
|
<div class="top-release-meta-text">
|
||||||
${rg.date ? html`<span>${extractYear(rg.date)}</span>` : nothing}
|
${rg.date ? html`<span>${extractYear(rg.date)}</span>` : nothing}
|
||||||
</div>
|
</div>
|
||||||
${badge.status === 'in-library'
|
|
||||||
? nothing
|
|
||||||
: html`<library-status-indicator
|
|
||||||
status=${badge.status}
|
|
||||||
owned=${badge.owned}
|
|
||||||
expected=${badge.expected}
|
|
||||||
entity-type="album"
|
|
||||||
label=${rg.title}
|
|
||||||
request-mbid=${rg.releaseGroupMbid}
|
|
||||||
request-artist=${this.artist?.name ?? ''}
|
|
||||||
size="18"
|
|
||||||
></library-status-indicator>`}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3164,37 +3188,16 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
<section>
|
<section>
|
||||||
<h3 class="section-header">Discography</h3>
|
<h3 class="section-header">Discography</h3>
|
||||||
${groups.map(
|
${groups.map(
|
||||||
(g) => {
|
(g) => html`
|
||||||
const isExpanded = this.expandedDiscoGroups.has(g.type);
|
<div class="disco-group">
|
||||||
const rowSize = this.discoRowSize;
|
<h4 class="disco-type-header">
|
||||||
const showToggle = g.items.length > rowSize;
|
${g.type === 'Other' ? 'Other Releases' : g.type.endsWith('s') ? g.type : `${g.type}s`}
|
||||||
const visibleItems = isExpanded ? g.items : g.items.slice(0, rowSize);
|
</h4>
|
||||||
|
<scroll-row>
|
||||||
return html`
|
${g.items.map((rg) => this.renderAlbumCard(rg))}
|
||||||
<div class="disco-group">
|
</scroll-row>
|
||||||
<h4 class="disco-type-header">
|
</div>
|
||||||
${g.type === 'Other' ? 'Other Releases' : g.type.endsWith('s') ? g.type : `${g.type}s`}
|
`,
|
||||||
</h4>
|
|
||||||
<div class="album-grid">
|
|
||||||
${visibleItems.map((rg) => this.renderAlbumCard(rg))}
|
|
||||||
</div>
|
|
||||||
${showToggle
|
|
||||||
? html`
|
|
||||||
<button
|
|
||||||
class="disco-toggle"
|
|
||||||
aria-expanded="${isExpanded}"
|
|
||||||
@click=${() => this.toggleDiscoGroup(g.type)}
|
|
||||||
>
|
|
||||||
${isExpanded
|
|
||||||
? 'Show less'
|
|
||||||
: `Show all ${g.items.length}`}
|
|
||||||
<wa-icon name="chevron-down"></wa-icon>
|
|
||||||
</button>
|
|
||||||
`
|
|
||||||
: nothing}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
},
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
@@ -3234,23 +3237,25 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
<div class="album-art-fallback" style="${artURL ? 'display: none' : ''}">
|
<div class="album-art-fallback" style="${artURL ? 'display: none' : ''}">
|
||||||
<wa-icon name="compact-disc"></wa-icon>
|
<wa-icon name="compact-disc"></wa-icon>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="album-card-badge">
|
||||||
|
<library-status-indicator
|
||||||
|
status=${badge.status}
|
||||||
|
owned=${badge.owned}
|
||||||
|
expected=${badge.expected}
|
||||||
|
entity-type="album"
|
||||||
|
label=${rg.title}
|
||||||
|
request-mbid=${rg.mbid}
|
||||||
|
request-artist=${this.artist?.name ?? ''}
|
||||||
|
size="23"
|
||||||
|
></library-status-indicator>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="album-title" title="${rg.title}">${rg.title}</div>
|
<div class="album-title" title="${rg.title}">${rg.title}</div>
|
||||||
|
<div class="album-artist">${rg.artistCredit ?? ''}</div>
|
||||||
<div class="album-meta">
|
<div class="album-meta">
|
||||||
<div class="album-meta-text">
|
<div class="album-meta-text">
|
||||||
${year ? html`<span>${year}</span>` : nothing}
|
${year ? html`<span>${year}</span>` : nothing}
|
||||||
</div>
|
</div>
|
||||||
${badge.status === 'in-library'
|
|
||||||
? nothing
|
|
||||||
: html`<library-status-indicator
|
|
||||||
status=${badge.status}
|
|
||||||
owned=${badge.owned}
|
|
||||||
expected=${badge.expected}
|
|
||||||
entity-type="album"
|
|
||||||
label=${rg.title}
|
|
||||||
request-mbid=${rg.mbid}
|
|
||||||
request-artist=${this.artist?.name ?? ''}
|
|
||||||
></library-status-indicator>`}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -3266,15 +3271,12 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
// Cap the similar-artists list at 10 to avoid a very long list.
|
// Cap the similar-artists list at 10 to avoid a very long list.
|
||||||
const maxSimilar = 10;
|
const maxSimilar = 10;
|
||||||
const artists = this.similarArtists.slice(0, maxSimilar);
|
const artists = this.similarArtists.slice(0, maxSimilar);
|
||||||
const showToggle = artists.length > this.discoRowSize;
|
|
||||||
const collapsed = !this.similarExpanded && showToggle;
|
|
||||||
const visible = collapsed ? artists.slice(0, this.discoRowSize) : artists;
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<section>
|
<section>
|
||||||
<h3 class="section-header">Similar Artists</h3>
|
<h3 class="section-header">Similar Artists</h3>
|
||||||
<div class="similar-row ${collapsed ? 'collapsed' : ''}">
|
<scroll-row>
|
||||||
${visible.map((a) => {
|
${artists.map((a) => {
|
||||||
const imgURL = this.similarImageURLs.get(a.artistMbid);
|
const imgURL = this.similarImageURLs.get(a.artistMbid);
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
@@ -3313,21 +3315,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
</div>
|
</scroll-row>
|
||||||
${showToggle
|
|
||||||
? html`
|
|
||||||
<button
|
|
||||||
class="disco-toggle"
|
|
||||||
aria-expanded="${this.similarExpanded}"
|
|
||||||
@click=${() => { this.similarExpanded = !this.similarExpanded; }}
|
|
||||||
>
|
|
||||||
${this.similarExpanded
|
|
||||||
? 'Show less'
|
|
||||||
: `Show all ${artists.length}`}
|
|
||||||
<wa-icon name="chevron-down"></wa-icon>
|
|
||||||
</button>
|
|
||||||
`
|
|
||||||
: nothing}
|
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { avatarBackground } from '@utils/avatar-color';
|
import { avatarBackground } from '@utils/avatar-color';
|
||||||
import { albumBadgeFor, libraryStatusFor } from '@utils/library-status';
|
import { albumBadgeFor, libraryStatusFor } from '@utils/library-status';
|
||||||
import {
|
import { isOwned, ownershipLabel } from '@utils/ownership';
|
||||||
isOwned,
|
import { openMusicBrainz } from '@utils/external-link';
|
||||||
ownershipLabel,
|
|
||||||
unownedStyles,
|
|
||||||
} from '@utils/ownership';
|
|
||||||
import { completenessStore } from '@store/completeness-store';
|
import { completenessStore } from '@store/completeness-store';
|
||||||
import { downloadStore } from '@store/download-store';
|
import { downloadStore } from '@store/download-store';
|
||||||
import { LitElement, html, css, nothing } from 'lit';
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
@@ -13,6 +10,8 @@ import { classMap } from 'lit/directives/class-map.js';
|
|||||||
import '@components/page-header/page-header';
|
import '@components/page-header/page-header';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
import { srOnly } from '../../styles/sr-only.css';
|
import { srOnly } from '../../styles/sr-only.css';
|
||||||
|
import { albumCardStyles } from '../../styles/album-card.css';
|
||||||
|
import '../scroll-row/scroll-row.js';
|
||||||
import { SearchLocal, SearchLyrics, GetThumbnail, GetThumbnails, GetArtistImageURL, GetArtistImagesCachedPaths, GetExploreShelves, RecordSearchClick } from '@go/explore/service.js';
|
import { SearchLocal, SearchLyrics, GetThumbnail, GetThumbnails, GetArtistImageURL, GetArtistImagesCachedPaths, GetExploreShelves, RecordSearchClick } from '@go/explore/service.js';
|
||||||
import { GetFilePathsByAlbums, GetFilePathsByRecordingMBIDs } from '@go/library/library.js';
|
import { GetFilePathsByAlbums, GetFilePathsByRecordingMBIDs } from '@go/library/library.js';
|
||||||
import { EventsOn } from '@runtime/runtime';
|
import { EventsOn } from '@runtime/runtime';
|
||||||
@@ -253,7 +252,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
srOnly,
|
srOnly,
|
||||||
exploreLinkStyles,
|
exploreLinkStyles,
|
||||||
contextMenuStyles,
|
contextMenuStyles,
|
||||||
unownedStyles,
|
albumCardStyles,
|
||||||
css`
|
css`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -529,21 +528,9 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Horizontal scroll rows ── */
|
|
||||||
.horizontal-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
overflow-x: auto;
|
|
||||||
padding-bottom: 4px;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.horizontal-row::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Top result cards ── */
|
|
||||||
/* ── Artist cards ── */
|
/* ── Artist cards ── */
|
||||||
|
/* Fixed width, for the reason the album card is: a range
|
||||||
|
means two cards in one row are different sizes. */
|
||||||
.artist-card {
|
.artist-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -552,8 +539,8 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
min-width: 100px;
|
width: 120px;
|
||||||
max-width: 120px;
|
box-sizing: border-box;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: background 0.15s ease;
|
transition: background 0.15s ease;
|
||||||
@@ -624,115 +611,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
font-size: var(--yj-text-xs);
|
font-size: var(--yj-text-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Album cards ── */
|
|
||||||
.album-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
min-width: 130px;
|
|
||||||
max-width: 150px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-card:hover {
|
|
||||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-card:active {
|
|
||||||
transform: scale(0.97);
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-art-container {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 1;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
var(--yj-bg-overlay, #404040) 0%,
|
|
||||||
var(--yj-bg-surface, #282828) 100%
|
|
||||||
);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-art-container img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-art-fallback {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-art-fallback wa-icon {
|
|
||||||
color: var(--yj-text-tertiary, #888);
|
|
||||||
font-size: 24px;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-title {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--yj-text-primary, #fff);
|
|
||||||
font-size: var(--yj-text-sm);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-artist {
|
|
||||||
color: var(--yj-text-tertiary, #888);
|
|
||||||
font-size: var(--yj-text-xs);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 6px;
|
|
||||||
color: var(--yj-text-tertiary, #888);
|
|
||||||
font-size: var(--yj-text-xs);
|
|
||||||
min-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-meta-text {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-meta library-status-indicator {
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-badge {
|
|
||||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 10px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Track list ── */
|
/* ── Track list ── */
|
||||||
.track-list {
|
.track-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -753,7 +631,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.album-card:focus-visible,
|
|
||||||
.track-item:focus-visible {
|
.track-item:focus-visible {
|
||||||
outline: 2px solid var(--yj-accent-text, #ffd43b);
|
outline: 2px solid var(--yj-accent-text, #ffd43b);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
@@ -1371,7 +1248,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
|
|
||||||
const entity = target.kind === 'album' ? 'release-group' : 'recording';
|
const entity = target.kind === 'album' ? 'release-group' : 'recording';
|
||||||
|
|
||||||
window.open(`https://musicbrainz.org/${entity}/${target.mbid}`, '_blank', 'noopener');
|
openMusicBrainz(`/${entity}/${target.mbid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderExploreContextMenu() {
|
private renderExploreContextMenu() {
|
||||||
@@ -1662,6 +1539,8 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
} catch {
|
} catch {
|
||||||
// No image — leave empty string.
|
// No image — leave empty string.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2122,7 +2001,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
${subtitle
|
${subtitle
|
||||||
? html`<p class="section-reason">${subtitle}</p>`
|
? html`<p class="section-reason">${subtitle}</p>`
|
||||||
: nothing}
|
: nothing}
|
||||||
<div class="horizontal-row">
|
<scroll-row>
|
||||||
${artists.map((a) => {
|
${artists.map((a) => {
|
||||||
const owned = isOwned(a);
|
const owned = isOwned(a);
|
||||||
const name = a.englishName || a.name;
|
const name = a.englishName || a.name;
|
||||||
@@ -2171,7 +2050,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
</div>
|
</scroll-row>
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -2187,7 +2066,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
${subtitle
|
${subtitle
|
||||||
? html`<p class="section-reason">${subtitle}</p>`
|
? html`<p class="section-reason">${subtitle}</p>`
|
||||||
: nothing}
|
: nothing}
|
||||||
<div class="horizontal-row">
|
<scroll-row>
|
||||||
${releaseGroups.map((rg) => {
|
${releaseGroups.map((rg) => {
|
||||||
const artURL = this.thumbnailCache.get(rg.mbid) || '';
|
const artURL = this.thumbnailCache.get(rg.mbid) || '';
|
||||||
const year = extractYear(rg.firstReleaseDate);
|
const year = extractYear(rg.firstReleaseDate);
|
||||||
@@ -2249,6 +2128,18 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
>
|
>
|
||||||
<wa-icon name="compact-disc"></wa-icon>
|
<wa-icon name="compact-disc"></wa-icon>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="album-card-badge">
|
||||||
|
<library-status-indicator
|
||||||
|
status=${badge.status}
|
||||||
|
owned=${badge.owned}
|
||||||
|
expected=${badge.expected}
|
||||||
|
entity-type="album"
|
||||||
|
label=${rg.title}
|
||||||
|
request-mbid=${rg.mbid}
|
||||||
|
request-artist=${rg.artistCredit ?? ''}
|
||||||
|
size="23"
|
||||||
|
></library-status-indicator>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="album-title" title="${rg.title}">
|
<div class="album-title" title="${rg.title}">
|
||||||
${rg.title}
|
${rg.title}
|
||||||
@@ -2256,29 +2147,18 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
<div class="album-artist">${creditLink(creditStore.credits(rg.mbid), rg.artistCredit, rg.artistMbid ?? '')}</div>
|
<div class="album-artist">${creditLink(creditStore.credits(rg.mbid), rg.artistCredit, rg.artistMbid ?? '')}</div>
|
||||||
<div class="album-meta">
|
<div class="album-meta">
|
||||||
<div class="album-meta-text">
|
<div class="album-meta-text">
|
||||||
|
${year ? html`<span>${year}</span>` : nothing}
|
||||||
${rg.primaryType
|
${rg.primaryType
|
||||||
? html`<span class="type-badge"
|
? html`<span class="type-badge"
|
||||||
>${rg.primaryType}</span
|
>${rg.primaryType}</span
|
||||||
>`
|
>`
|
||||||
: nothing}
|
: nothing}
|
||||||
${year ? html`<span>${year}</span>` : nothing}
|
|
||||||
</div>
|
</div>
|
||||||
${badge.status === 'in-library'
|
|
||||||
? nothing
|
|
||||||
: html`<library-status-indicator
|
|
||||||
status=${badge.status}
|
|
||||||
owned=${badge.owned}
|
|
||||||
expected=${badge.expected}
|
|
||||||
entity-type="album"
|
|
||||||
label=${rg.title}
|
|
||||||
request-mbid=${rg.mbid}
|
|
||||||
request-artist=${rg.artistCredit ?? ''}
|
|
||||||
></library-status-indicator>`}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
</div>
|
</scroll-row>
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { libraryStore } from '@store/library-store';
|
|||||||
import { EventsOn } from '@runtime/runtime';
|
import { EventsOn } from '@runtime/runtime';
|
||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
import '@components/page-header/page-header';
|
import '@components/page-header/page-header';
|
||||||
|
import '../scroll-row/scroll-row.js';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
|
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
|
||||||
|
|
||||||
@@ -99,16 +100,6 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
|
|||||||
color: var(--yj-text-tertiary, #888);
|
color: var(--yj-text-tertiary, #888);
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
|
||||||
display: grid;
|
|
||||||
grid-auto-flow: column;
|
|
||||||
grid-auto-columns: 160px;
|
|
||||||
gap: 14px;
|
|
||||||
overflow-x: auto;
|
|
||||||
padding-bottom: 6px;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -117,6 +108,8 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
display: block;
|
display: block;
|
||||||
|
width: 160px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.art {
|
.art {
|
||||||
@@ -336,9 +329,9 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
|
|||||||
<span class="shelf-title">${shelf.title}</span>
|
<span class="shelf-title">${shelf.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="shelf-sub">${shelf.subtitle}</p>
|
<p class="shelf-sub">${shelf.subtitle}</p>
|
||||||
<div class="row">
|
<scroll-row>
|
||||||
${(shelf.albums ?? []).map((album) => this.renderCard(album))}
|
${(shelf.albums ?? []).map((album) => this.renderCard(album))}
|
||||||
</div>
|
</scroll-row>
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ import {
|
|||||||
ICON_PLAYLIST,
|
ICON_PLAYLIST,
|
||||||
ICON_QUEUE,
|
ICON_QUEUE,
|
||||||
ICON_REMOVE,
|
ICON_REMOVE,
|
||||||
|
ICON_SHUFFLE,
|
||||||
} from '@utils/icon-language';
|
} from '@utils/icon-language';
|
||||||
|
import { playAll } from '@utils/play-all';
|
||||||
|
|
||||||
/** One playlist row: the track and its position in the *playlist*,
|
/** One playlist row: the track and its position in the *playlist*,
|
||||||
* which is not its position in the filtered view. */
|
* which is not its position in the filtered view. */
|
||||||
@@ -355,14 +357,32 @@ export class PlaylistDetails
|
|||||||
// Track interactions
|
// Track interactions
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
private handlePlayAll() {
|
private playableFilePaths(): string[] {
|
||||||
const filePaths = this.tracks
|
return this.tracks
|
||||||
.filter((t) => !t.Phantom)
|
.filter((t) => !t.Phantom)
|
||||||
.map((t) => t.FilePath);
|
.map((t) => t.FilePath);
|
||||||
|
}
|
||||||
|
|
||||||
if (filePaths.length === 0) return;
|
private handlePlayAll() {
|
||||||
|
// Start at the first row, not at a random one: the old `true`
|
||||||
|
// was `shuffleStart`, which only picks a random first track
|
||||||
|
// when shuffle mode is already on — so "Play All" quietly did
|
||||||
|
// "play from the top" while leaving the mode as it was. The
|
||||||
|
// mode semantics now live in `playAll`, shared with the other
|
||||||
|
// track lists.
|
||||||
|
playAll(
|
||||||
|
this.playableFilePaths(),
|
||||||
|
{ type: 'playlist', id: this.playlistId, label: this.playlistName },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
queueStore.setQueue(filePaths, 0, true, { type: 'playlist', id: this.playlistId, label: this.playlistName });
|
private handleShuffleAll() {
|
||||||
|
playAll(
|
||||||
|
this.playableFilePaths(),
|
||||||
|
{ type: 'playlist', id: this.playlistId, label: this.playlistName },
|
||||||
|
true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleTrackClick(
|
private handleTrackClick(
|
||||||
@@ -1599,9 +1619,16 @@ export class PlaylistDetails
|
|||||||
class="play-all-button"
|
class="play-all-button"
|
||||||
@click=${() => this.handlePlayAll()}
|
@click=${() => this.handlePlayAll()}
|
||||||
>
|
>
|
||||||
<wa-icon name="play"></wa-icon>
|
<wa-icon name=${ICON_PLAY}></wa-icon>
|
||||||
Play All
|
Play All
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="play-all-button"
|
||||||
|
@click=${() => this.handleShuffleAll()}
|
||||||
|
>
|
||||||
|
<wa-icon name=${ICON_SHUFFLE}></wa-icon>
|
||||||
|
Shuffle All
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="track-header">
|
<div class="track-header">
|
||||||
<div class="header-cell col-number">#</div>
|
<div class="header-cell col-number">#</div>
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { LitElement, css, html } from 'lit';
|
||||||
|
import { customElement, query, state } from 'lit/decorators.js';
|
||||||
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
|
|
||||||
|
/** How far one press moves the row — most of a screenful, not all of
|
||||||
|
* it, so the card that was at the edge stays as an anchor. */
|
||||||
|
const SCROLL_FRACTION = 0.8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A horizontally scrolling row with arrow buttons.
|
||||||
|
*
|
||||||
|
* The shelves, the search results and (now) the artist page's
|
||||||
|
* discography and similar-artists rows are all "more than fits, scroll
|
||||||
|
* sideways". Until this existed the only way to see the rest was a
|
||||||
|
* mousewheel or a trackpad gesture, which is not an affordance — a
|
||||||
|
* mouse with no horizontal wheel simply could not reach the cards past
|
||||||
|
* the fold.
|
||||||
|
*
|
||||||
|
* It is a component rather than a rule on `.horizontal-row` for two
|
||||||
|
* reasons. The arrows are *state* — which way the row can still move —
|
||||||
|
* and that state has to be recomputed when the viewport resizes or a
|
||||||
|
* card arrives with its cover art; a stylesheet cannot do that. And
|
||||||
|
* every caller then gets the same arrows, the same reveal and the same
|
||||||
|
* keyboard labels without writing them again.
|
||||||
|
*
|
||||||
|
* **The arrows are `hidden`, not merely transparent, at the end they
|
||||||
|
* cannot move from** — a control that cannot act is worse than none,
|
||||||
|
* and an invisible one still holds a hit area and a tab stop. On a
|
||||||
|
* pointer device the pair fades in with the row's hover; where there is
|
||||||
|
* no hover they are always visible, because there is no other route to
|
||||||
|
* them there (a swipe is not an affordance a mouse-less keyboard user
|
||||||
|
* has either).
|
||||||
|
*
|
||||||
|
* The cards are light DOM children and stay in the *host's* shadow
|
||||||
|
* root, so the host's own `.album-card` / `.artist-card` styles apply
|
||||||
|
* unchanged — this component only owns the box they scroll inside.
|
||||||
|
*/
|
||||||
|
@customElement('scroll-row')
|
||||||
|
export class ScrollRow extends LitElement {
|
||||||
|
@query('.viewport') private viewport?: HTMLElement;
|
||||||
|
|
||||||
|
@state() private atStart = true;
|
||||||
|
|
||||||
|
@state() private atEnd = true;
|
||||||
|
|
||||||
|
@state() private overflowing = false;
|
||||||
|
|
||||||
|
private observer?: ResizeObserver;
|
||||||
|
|
||||||
|
static override styles = css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport {
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
/* A swipe that reaches the row's end should not drag the
|
||||||
|
whole page sideways with it. */
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--yj-border-subtle, rgba(255, 255, 255, 0.1));
|
||||||
|
background: var(--yj-bg-elevated, #343a40);
|
||||||
|
color: var(--yj-text-primary, #fff);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow.prev {
|
||||||
|
left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow.next {
|
||||||
|
right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow:hover {
|
||||||
|
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow:focus-visible {
|
||||||
|
outline: 2px solid var(--yj-accent, #ffd43b);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
:host(:hover) .arrow,
|
||||||
|
.arrow:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media not all and (hover: hover) {
|
||||||
|
.arrow {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
override firstUpdated(): void {
|
||||||
|
const viewport = this.viewport;
|
||||||
|
|
||||||
|
if (!viewport) return;
|
||||||
|
|
||||||
|
this.observer = new ResizeObserver(() => this.measure());
|
||||||
|
|
||||||
|
this.observer.observe(viewport);
|
||||||
|
|
||||||
|
// The track's own size is what changes when a card arrives with
|
||||||
|
// its cover art, and a ResizeObserver on the viewport alone
|
||||||
|
// never fires for that.
|
||||||
|
const track = viewport.firstElementChild;
|
||||||
|
|
||||||
|
if (track) this.observer.observe(track);
|
||||||
|
|
||||||
|
this.measure();
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback(): void {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this.observer?.disconnect();
|
||||||
|
this.observer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private measure(): void {
|
||||||
|
const viewport = this.viewport;
|
||||||
|
|
||||||
|
if (!viewport) return;
|
||||||
|
|
||||||
|
this.overflowing = viewport.scrollWidth > viewport.clientWidth + 1;
|
||||||
|
this.atStart = viewport.scrollLeft <= 1;
|
||||||
|
this.atEnd =
|
||||||
|
viewport.scrollLeft + viewport.clientWidth >=
|
||||||
|
viewport.scrollWidth - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onScroll = (): void => this.measure();
|
||||||
|
|
||||||
|
private scrollStep(direction: -1 | 1): void {
|
||||||
|
const viewport = this.viewport;
|
||||||
|
|
||||||
|
if (!viewport) return;
|
||||||
|
|
||||||
|
viewport.scrollBy({
|
||||||
|
left: direction * viewport.clientWidth * SCROLL_FRACTION,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
const showPrev = this.overflowing && !this.atStart;
|
||||||
|
const showNext = this.overflowing && !this.atEnd;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<button
|
||||||
|
class="arrow prev"
|
||||||
|
type="button"
|
||||||
|
aria-label="Scroll left"
|
||||||
|
?hidden=${!showPrev}
|
||||||
|
@click=${() => this.scrollStep(-1)}
|
||||||
|
>
|
||||||
|
<wa-icon name="chevron-left"></wa-icon>
|
||||||
|
</button>
|
||||||
|
<div class="viewport" @scroll=${this.onScroll}>
|
||||||
|
<div class="track"><slot></slot></div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="arrow next"
|
||||||
|
type="button"
|
||||||
|
aria-label="Scroll right"
|
||||||
|
?hidden=${!showNext}
|
||||||
|
@click=${() => this.scrollStep(1)}
|
||||||
|
>
|
||||||
|
<wa-icon name="chevron-right"></wa-icon>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'scroll-row': ScrollRow;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { EventsOn } from '@runtime/runtime';
|
import { EventsOn } from '@runtime/runtime';
|
||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
import { queueStore } from '@store/queue-store';
|
import { queueStore } from '@store/queue-store';
|
||||||
|
import { playAll } from '@utils/play-all';
|
||||||
import { creditStore } from '@store/credit-store';
|
import { creditStore } from '@store/credit-store';
|
||||||
import { PlayerController } from '@store/controllers/player-controller';
|
import { PlayerController } from '@store/controllers/player-controller';
|
||||||
import { SearchController } from '@store/controllers/search-controller';
|
import { SearchController } from '@store/controllers/search-controller';
|
||||||
@@ -771,24 +772,29 @@ export class SmartPlaylistDetails
|
|||||||
// Actions
|
// Actions
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
private handlePlay() {
|
private playableFilePaths(): string[] {
|
||||||
const filePaths = this.tracks
|
return this.tracks
|
||||||
.filter((t) => !t.Phantom)
|
.filter((t) => !t.Phantom)
|
||||||
.map((t) => t.FilePath);
|
.map((t) => t.FilePath);
|
||||||
|
}
|
||||||
|
|
||||||
if (filePaths.length === 0) return;
|
private handlePlay() {
|
||||||
|
playAll(
|
||||||
queueStore.setQueue(filePaths, 0, false, { type: 'smartPlaylist', id: this.playlistId, label: this.playlistName });
|
this.playableFilePaths(),
|
||||||
|
{ type: 'smartPlaylist', id: this.playlistId, label: this.playlistName },
|
||||||
|
false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleShuffle() {
|
private handleShuffle() {
|
||||||
const filePaths = this.tracks
|
// This used to be a no-op when shuffle mode was off: it passed
|
||||||
.filter((t) => !t.Phantom)
|
// `shuffleStart` without turning the mode on, so the queue
|
||||||
.map((t) => t.FilePath);
|
// started at track 1 in order. `playAll` sets the mode first.
|
||||||
|
playAll(
|
||||||
if (filePaths.length === 0) return;
|
this.playableFilePaths(),
|
||||||
|
{ type: 'smartPlaylist', id: this.playlistId, label: this.playlistName },
|
||||||
queueStore.setQueue(filePaths, 0, true, { type: 'smartPlaylist', id: this.playlistId, label: this.playlistName });
|
true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleRefresh() {
|
private async handleRefresh() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { LitElement, html, css, nothing } from 'lit';
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
|
import '../scroll-row/scroll-row.js';
|
||||||
import type * as explore from '@go/explore/models.js';
|
import type * as explore from '@go/explore/models.js';
|
||||||
import {
|
import {
|
||||||
GetArtistImageURL,
|
GetArtistImageURL,
|
||||||
@@ -15,7 +16,6 @@ import { albumBadgeFor, libraryStatusFor } from '../../utils/library-status';
|
|||||||
import {
|
import {
|
||||||
isOwned,
|
isOwned,
|
||||||
ownershipLabel,
|
ownershipLabel,
|
||||||
unownedStyles,
|
|
||||||
type OwnableKind,
|
type OwnableKind,
|
||||||
} from '../../utils/ownership';
|
} from '../../utils/ownership';
|
||||||
import { completenessStore } from '../../store/completeness-store';
|
import { completenessStore } from '../../store/completeness-store';
|
||||||
@@ -103,20 +103,12 @@ export class TopResultsRow extends LitElement {
|
|||||||
static override styles = [
|
static override styles = [
|
||||||
designTokens,
|
designTokens,
|
||||||
exploreLinkStyles,
|
exploreLinkStyles,
|
||||||
unownedStyles,
|
|
||||||
css`
|
css`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
overflow-x: auto;
|
|
||||||
padding-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 200px;
|
width: 200px;
|
||||||
@@ -285,9 +277,9 @@ export class TopResultsRow extends LitElement {
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="section-label">Top Results</div>
|
<div class="section-label">Top Results</div>
|
||||||
<div class="row">
|
<scroll-row>
|
||||||
${this.results.map((r) => this.renderCard(r))}
|
${this.results.map((r) => this.renderCard(r))}
|
||||||
</div>
|
</scroll-row>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller
|
|||||||
import { PlayerController } from '@store/controllers/player-controller';
|
import { PlayerController } from '@store/controllers/player-controller';
|
||||||
import { SearchController } from '@store/controllers/search-controller';
|
import { SearchController } from '@store/controllers/search-controller';
|
||||||
import '@components/page-header/page-header';
|
import '@components/page-header/page-header';
|
||||||
import type { SortOption } from '@components/page-header/page-header';
|
import type {
|
||||||
|
SortOption,
|
||||||
|
PageAction,
|
||||||
|
} from '@components/page-header/page-header';
|
||||||
import { TrackListController } from '@store/controllers/tracklist-controller';
|
import { TrackListController } from '@store/controllers/tracklist-controller';
|
||||||
import { FavoritesController } from '@store/controllers/favorites-controller';
|
import { FavoritesController } from '@store/controllers/favorites-controller';
|
||||||
import { queueStore } from '@store/queue-store';
|
import { queueStore } from '@store/queue-store';
|
||||||
@@ -87,7 +90,9 @@ import {
|
|||||||
ICON_PLAYLIST,
|
ICON_PLAYLIST,
|
||||||
ICON_PLAY_NEXT,
|
ICON_PLAY_NEXT,
|
||||||
ICON_QUEUE,
|
ICON_QUEUE,
|
||||||
|
ICON_SHUFFLE,
|
||||||
} from '@utils/icon-language';
|
} from '@utils/icon-language';
|
||||||
|
import { playAll } from '@utils/play-all';
|
||||||
|
|
||||||
const COLUMN_STORAGE_KEY = 'track-list-column-widths';
|
const COLUMN_STORAGE_KEY = 'track-list-column-widths';
|
||||||
const SORT_FIELD_KEY = 'track-list-sort-field';
|
const SORT_FIELD_KEY = 'track-list-sort-field';
|
||||||
@@ -2393,6 +2398,27 @@ export class TrackList
|
|||||||
.map((c) => ({ id: c.id, label: c.label })),
|
.map((c) => ({ id: c.id, label: c.label })),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const hasTracks = this.cachedSortedTracks.length > 0;
|
||||||
|
|
||||||
|
const actions: PageAction[] = [
|
||||||
|
{
|
||||||
|
id: 'play-all',
|
||||||
|
label: 'Play all',
|
||||||
|
icon: ICON_PLAY,
|
||||||
|
priority: 1,
|
||||||
|
disabled: !hasTracks,
|
||||||
|
onSelect: this.handlePlayAll,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'shuffle-all',
|
||||||
|
label: 'Shuffle all',
|
||||||
|
icon: ICON_SHUFFLE,
|
||||||
|
priority: 0,
|
||||||
|
disabled: !hasTracks,
|
||||||
|
onSelect: this.handleShuffleAll,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<page-header
|
<page-header
|
||||||
heading=${this.externalTracks === undefined ? 'Tracks' : ''}
|
heading=${this.externalTracks === undefined ? 'Tracks' : ''}
|
||||||
@@ -2404,11 +2430,29 @@ export class TrackList
|
|||||||
sort-field=${this.sortField ?? ''}
|
sort-field=${this.sortField ?? ''}
|
||||||
sort-direction=${this.sortDirection}
|
sort-direction=${this.sortDirection}
|
||||||
search-term=${this.searchCtrl.term}
|
search-term=${this.searchCtrl.term}
|
||||||
|
.actions=${actions}
|
||||||
@sort-change=${this.onPageHeaderSort}
|
@sort-change=${this.onPageHeaderSort}
|
||||||
></page-header>
|
></page-header>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The queue is the list as displayed, in the order the user sees. */
|
||||||
|
private handlePlayAll = (): void => {
|
||||||
|
playAll(
|
||||||
|
this.cachedSortedTracks.map((t) => t.FilePath),
|
||||||
|
this.effectiveQueueSource,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleShuffleAll = (): void => {
|
||||||
|
playAll(
|
||||||
|
this.cachedSortedTracks.map((t) => t.FilePath),
|
||||||
|
this.effectiveQueueSource,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
private onPageHeaderSort = (
|
private onPageHeaderSort = (
|
||||||
e: CustomEvent<{ field: string; direction: 'asc' | 'desc' }>,
|
e: CustomEvent<{ field: string; direction: 'asc' | 'desc' }>,
|
||||||
) => {
|
) => {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ solid/bookmark
|
|||||||
solid/box-open
|
solid/box-open
|
||||||
solid/check
|
solid/check
|
||||||
solid/chevron-down
|
solid/chevron-down
|
||||||
|
solid/chevron-left
|
||||||
solid/chevron-right
|
solid/chevron-right
|
||||||
solid/circle-check
|
solid/circle-check
|
||||||
solid/circle-exclamation
|
solid/circle-exclamation
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { css } from 'lit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Explore album card, once.
|
||||||
|
*
|
||||||
|
* Two components draw one — `explore-view`'s shelves and search
|
||||||
|
* results, and `explore-artist-details`'s discography — and they had
|
||||||
|
* grown two copies of the same rules. That is how the size came apart:
|
||||||
|
* `explore-view` clamped its cards to a 130–150px range so two cards in
|
||||||
|
* one row could be different widths, and since the artwork is square
|
||||||
|
* that made them different *heights* as well. A row of covers with
|
||||||
|
* ragged bottoms is the whole complaint.
|
||||||
|
*
|
||||||
|
* So the width is a fixed `--yj-album-card-width` and the lines below
|
||||||
|
* the art each reserve their own space, which is what makes every card
|
||||||
|
* the same size no matter what a given album happens to carry —
|
||||||
|
* `album-card-size.test.ts` measures that rather than trusting it.
|
||||||
|
*
|
||||||
|
* Three rules here are the parts that changed rather than moved.
|
||||||
|
*
|
||||||
|
* **The artwork is inset in the square, not cropped to it.** The
|
||||||
|
* container was already `aspect-ratio: 1` but the image was
|
||||||
|
* `object-fit: cover`, so a non-square cover lost its edges. It is
|
||||||
|
* `contain` now and the container's own background is transparent, so
|
||||||
|
* a tall or wide cover sits in the middle of the square with the page
|
||||||
|
* showing through beside it.
|
||||||
|
*
|
||||||
|
* **The badge lives on the artwork, top-left, and only under the
|
||||||
|
* pointer.** It used to sit in the metadata line and only for the
|
||||||
|
* unowned case. It draws for every card now — an owned album's tick is
|
||||||
|
* the answer to the same question — and it is revealed by hover on a
|
||||||
|
* pointer device. Where there is no hover it is *always* visible rather
|
||||||
|
* than never, because on those devices it is the only route to its
|
||||||
|
* action: `explore-view`'s card menu carries no request item, so a
|
||||||
|
* phone with the badge hidden could not ask for an album at all.
|
||||||
|
*
|
||||||
|
* **Nothing dims an unowned card.** `unownedStyles` was removed from
|
||||||
|
* the catalog surfaces on the rule that the badge is the mark; the
|
||||||
|
* album page's *tracklist* still dims unowned rows, which is a
|
||||||
|
* different statement about a different thing.
|
||||||
|
*/
|
||||||
|
export const albumCardStyles = css`
|
||||||
|
.album-card {
|
||||||
|
width: var(--yj-album-card-width, 150px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card:hover {
|
||||||
|
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card:focus-visible {
|
||||||
|
outline: 2px solid var(--yj-accent-text, #ffd43b);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-art-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-art-container img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The placeholder is the one case that *is* a full square, so it
|
||||||
|
carries the background the container gave up. */
|
||||||
|
.album-art-fallback {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--yj-bg-overlay, #404040) 0%,
|
||||||
|
var(--yj-bg-surface, #282828) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-art-fallback wa-icon {
|
||||||
|
color: var(--yj-text-tertiary, #888);
|
||||||
|
font-size: 24px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 6px;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.album-card:hover .album-card-badge,
|
||||||
|
.album-card:focus-within .album-card-badge {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media not all and (hover: hover) {
|
||||||
|
.album-card-badge {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--yj-text-primary, #fff);
|
||||||
|
font-size: var(--yj-text-sm);
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reserved even where a surface has no artist to draw, so a card
|
||||||
|
in a row is never shorter than its neighbour. */
|
||||||
|
.album-artist {
|
||||||
|
color: var(--yj-text-tertiary, #888);
|
||||||
|
font-size: var(--yj-text-xs);
|
||||||
|
line-height: 1.3;
|
||||||
|
min-height: 1.3em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--yj-text-tertiary, #888);
|
||||||
|
font-size: var(--yj-text-xs);
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-meta-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Opening an external page, with the destination pinned.
|
||||||
|
*
|
||||||
|
* Every external link this app opens is a MusicBrainz entity page built
|
||||||
|
* from an MBID that came from the catalog. Constructing the URL by
|
||||||
|
* string concatenation leaves the destination to whatever is in that
|
||||||
|
* string, so this parses it against the one origin the app means and
|
||||||
|
* refuses anything else — an MBID cannot change the host, and if it
|
||||||
|
* somehow did, nothing would open.
|
||||||
|
*
|
||||||
|
* It navigates through a real anchor rather than `window.open`: the
|
||||||
|
* same top-level `_blank` navigation with `noopener`, and it keeps the
|
||||||
|
* destination an ordinary link rather than an argument to a function
|
||||||
|
* whose first parameter is a URL.
|
||||||
|
*/
|
||||||
|
const MUSICBRAINZ_ORIGIN = 'https://musicbrainz.org';
|
||||||
|
|
||||||
|
export function openMusicBrainz(path: string): void {
|
||||||
|
const url = new URL(path, MUSICBRAINZ_ORIGIN);
|
||||||
|
|
||||||
|
if (url.origin !== MUSICBRAINZ_ORIGIN) return;
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
|
||||||
|
link.href = url.toString();
|
||||||
|
link.target = '_blank';
|
||||||
|
link.rel = 'noopener noreferrer';
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
@@ -10,6 +10,16 @@
|
|||||||
* badge as the only difference. This is that rule, written once, so
|
* badge as the only difference. This is that rule, written once, so
|
||||||
* eight surfaces cannot each keep their own version of it.
|
* eight surfaces cannot each keep their own version of it.
|
||||||
*
|
*
|
||||||
|
* **The catalog's *cards* no longer dim.** A grid of dimmed covers read
|
||||||
|
* as a page that had failed to load rather than as a page of things you
|
||||||
|
* could ask for, so on Explore the mark is the badge alone — over the
|
||||||
|
* artwork, on hover, drawn for owned and unowned alike. The album
|
||||||
|
* page's *tracklist* still dims unowned rows: that is a different
|
||||||
|
* statement ("this one is not here") about a different thing, and the
|
||||||
|
* `aria-disabled` row that cannot be played is what it is for. So
|
||||||
|
* `unownedStyles` survives for that one surface and the cards simply do
|
||||||
|
* not include it.
|
||||||
|
*
|
||||||
* ## Ownership is a file, and `localId` is the flag that says so
|
* ## Ownership is a file, and `localId` is the flag that says so
|
||||||
*
|
*
|
||||||
* The album page answers "do I own this row" with `filePaths`, a map
|
* The album page answers "do I own this row" with `filePaths`, a map
|
||||||
@@ -96,7 +106,8 @@ export function ownershipLabel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The dimming, shared so it cannot drift across surfaces.
|
* The dimming, shared so it cannot drift across surfaces — and now
|
||||||
|
* used by exactly one of them.
|
||||||
*
|
*
|
||||||
* Two things about it are load-bearing.
|
* Two things about it are load-bearing.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { queueStore } from '@store/queue-store';
|
||||||
|
import type { QueueSource } from '@store/queue-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a list and start it, optionally shuffled.
|
||||||
|
*
|
||||||
|
* This is the one place that owns what "shuffle this collection" means.
|
||||||
|
* `SetQueue`'s `shuffleStart` only picks a random first track when
|
||||||
|
* shuffle mode is *already* on — it does not turn it on — so the mode
|
||||||
|
* has to be set before the queue, not after. The album page used to
|
||||||
|
* carry that rule privately; the play-all/shuffle-all pair on every
|
||||||
|
* track list now shares it.
|
||||||
|
*/
|
||||||
|
export function playAll(
|
||||||
|
paths: string[],
|
||||||
|
source: QueueSource | undefined,
|
||||||
|
shuffle: boolean,
|
||||||
|
): void {
|
||||||
|
if (paths.length === 0) return;
|
||||||
|
|
||||||
|
if (shuffle && !queueStore.getState().shuffleMode) {
|
||||||
|
queueStore.toggleShuffle();
|
||||||
|
}
|
||||||
|
|
||||||
|
queueStore.setQueue(paths, 0, shuffle, source);
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* Every album card is the same size, and its artwork is a square.
|
||||||
|
*
|
||||||
|
* The size came apart because `explore-view` clamped its cards to a
|
||||||
|
* 130–150px range, so two cards in one row could be different widths —
|
||||||
|
* and since the artwork is square, different *heights* as well. A row
|
||||||
|
* of covers with ragged bottoms is what that looks like.
|
||||||
|
*
|
||||||
|
* What makes the fix hold is that the lines below the art each reserve
|
||||||
|
* their own space (`album-card.css.ts`), so an album with no year, no
|
||||||
|
* release type or a one-character title is not shorter than its
|
||||||
|
* neighbour. This measures that rather than trusting it, because the
|
||||||
|
* next component to format a card is the way it comes back.
|
||||||
|
*
|
||||||
|
* The artwork half is the other change: the container was already
|
||||||
|
* square but the image was `object-fit: cover`, so a non-square cover
|
||||||
|
* was cropped to it. It is `contain` now, and the container has no
|
||||||
|
* background of its own, so a tall cover is inset with the page
|
||||||
|
* showing through beside it.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import type { LitElement } from 'lit';
|
||||||
|
|
||||||
|
import '@components/explore-view/explore-view';
|
||||||
|
import { flush, stub, resetHarness } from '@test/support/harness';
|
||||||
|
import { fixture, shadow, shadowAll, update } from '@test/support/render';
|
||||||
|
import { completenessStore } from '@store/completeness-store';
|
||||||
|
|
||||||
|
const SEARCH = 'explore.Service.SearchLocal';
|
||||||
|
const SHELVES = 'explore.Service.GetExploreShelves';
|
||||||
|
|
||||||
|
/** A 1x1 transparent gif, so the `<img>` branch renders. */
|
||||||
|
const TINY_IMAGE =
|
||||||
|
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||||
|
|
||||||
|
/** Release groups chosen so every optional line is present on one and
|
||||||
|
* absent on another — that is what a size regression hides behind. */
|
||||||
|
const ALBUMS = [
|
||||||
|
{
|
||||||
|
mbid: 'rg-1',
|
||||||
|
title: 'A',
|
||||||
|
artistCredit: '',
|
||||||
|
artistMbid: 'ar-1',
|
||||||
|
primaryType: '',
|
||||||
|
firstReleaseDate: '',
|
||||||
|
popularity: 1,
|
||||||
|
listenerCount: 1,
|
||||||
|
secondaryTypes: [],
|
||||||
|
inLibrary: false,
|
||||||
|
localId: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mbid: 'rg-2',
|
||||||
|
title: 'A Very Long Album Name That Will Certainly Be Truncated By The Card',
|
||||||
|
artistCredit: 'An Artist With A Long Name',
|
||||||
|
artistMbid: 'ar-2',
|
||||||
|
primaryType: 'Album',
|
||||||
|
firstReleaseDate: '1994-05-01',
|
||||||
|
popularity: 1,
|
||||||
|
listenerCount: 1,
|
||||||
|
secondaryTypes: [],
|
||||||
|
inLibrary: false,
|
||||||
|
localId: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mbid: 'rg-3',
|
||||||
|
title: 'Three',
|
||||||
|
artistCredit: 'Another',
|
||||||
|
artistMbid: 'ar-3',
|
||||||
|
primaryType: 'EP',
|
||||||
|
firstReleaseDate: '2001-01-01',
|
||||||
|
popularity: 1,
|
||||||
|
listenerCount: 1,
|
||||||
|
secondaryTypes: [],
|
||||||
|
inLibrary: false,
|
||||||
|
localId: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function exploreWithAlbums(): Promise<LitElement> {
|
||||||
|
stub(SHELVES, { shelves: [], state: 'ready' });
|
||||||
|
stub(SEARCH, {
|
||||||
|
artists: [],
|
||||||
|
releaseGroups: ALBUMS,
|
||||||
|
recordings: [],
|
||||||
|
});
|
||||||
|
stub('explore.Service.GetThumbnails', Object.fromEntries(
|
||||||
|
ALBUMS.map((a) => [a.mbid, TINY_IMAGE]),
|
||||||
|
));
|
||||||
|
stub('explore.Service.GetThumbnail', TINY_IMAGE);
|
||||||
|
|
||||||
|
const el = await fixture<LitElement>('explore-view');
|
||||||
|
|
||||||
|
(el as unknown as { onViewActivate: () => void }).onViewActivate?.();
|
||||||
|
await update(el, {
|
||||||
|
results: { artists: [], releaseGroups: ALBUMS, recordings: [] },
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetHarness();
|
||||||
|
stub('library.Library.GetAlbumsCompleteness', {});
|
||||||
|
completenessStore.invalidate();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the album card size', () => {
|
||||||
|
it('is the same width and height for every card in a row', async () => {
|
||||||
|
const el = await exploreWithAlbums();
|
||||||
|
const cards = shadowAll(el, '.album-card');
|
||||||
|
|
||||||
|
expect(cards.length).toBe(ALBUMS.length);
|
||||||
|
|
||||||
|
const boxes = cards.map((c) => c.getBoundingClientRect());
|
||||||
|
|
||||||
|
// The first card is the reference; every other one must match it.
|
||||||
|
for (const box of boxes) {
|
||||||
|
expect(box.width).toBe(boxes[0]!.width);
|
||||||
|
expect(box.height).toBe(boxes[0]!.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// …and the reference is a real box, or the loop above is vacuous.
|
||||||
|
expect(boxes[0]!.width).toBeGreaterThan(0);
|
||||||
|
expect(boxes[0]!.height).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the artwork square', async () => {
|
||||||
|
const el = await exploreWithAlbums();
|
||||||
|
|
||||||
|
for (const art of shadowAll(el, '.album-art-container')) {
|
||||||
|
const box = art.getBoundingClientRect();
|
||||||
|
|
||||||
|
expect(Math.round(box.width)).toBe(Math.round(box.height));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('insets a non-square cover rather than cropping it', async () => {
|
||||||
|
const el = await exploreWithAlbums();
|
||||||
|
|
||||||
|
// Read from the parsed stylesheet rather than from a rendered
|
||||||
|
// `<img>`: the search path is what calls `loadThumbnails`, and
|
||||||
|
// setting `results` directly skips it, so there is no image to
|
||||||
|
// measure. The regression worth catching is the rule going back to
|
||||||
|
// `cover`, which is a stylesheet fact.
|
||||||
|
const rules = (el.shadowRoot?.adoptedStyleSheets ?? []).flatMap((sheet) =>
|
||||||
|
Array.from(sheet.cssRules).map((rule) => rule.cssText),
|
||||||
|
);
|
||||||
|
const art = rules.find(
|
||||||
|
(text) =>
|
||||||
|
text.startsWith('.album-art-container img') &&
|
||||||
|
text.includes('object-fit'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(art, 'no object-fit rule for the cover image').toBeDefined();
|
||||||
|
expect(art).toContain('object-fit: contain');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws the badge over the artwork, and not in the metadata line', async () => {
|
||||||
|
const el = await exploreWithAlbums();
|
||||||
|
const card = shadow(el, '.album-card')!;
|
||||||
|
|
||||||
|
const badge = card.querySelector('.album-art-container .album-card-badge');
|
||||||
|
|
||||||
|
expect(badge).not.toBeNull();
|
||||||
|
// The badge is positioned inside the art box, so its parent is the
|
||||||
|
// square rather than the row underneath it.
|
||||||
|
expect(badge?.parentElement?.classList.contains('album-art-container')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* The artist page's header and its top tracks.
|
||||||
|
*
|
||||||
|
* Two cleanups, asserted together because they are one screen:
|
||||||
|
*
|
||||||
|
* - the Play/Shuffle pair became one split button ("Play" with the
|
||||||
|
* words on its title, Shuffle behind the caret), the Follow button
|
||||||
|
* moved onto the same line, and the name and listen count went up a
|
||||||
|
* size;
|
||||||
|
* - a top track's play/request affordance moved onto its artwork,
|
||||||
|
* where a hover reveals it, instead of a badge at the end of the
|
||||||
|
* row beside a row that already plays on a double-click.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
import type { LitElement } from 'lit';
|
||||||
|
|
||||||
|
import '@components/explore-artist-details/explore-artist-details';
|
||||||
|
import { stub, flush, emit, resetHarness } from '@test/support/harness';
|
||||||
|
import { Events } from '../../src/events';
|
||||||
|
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||||
|
|
||||||
|
const ARTIST = 'artist-0001';
|
||||||
|
|
||||||
|
const track = (name: string, localId = 0) => ({
|
||||||
|
recordingMbid: `rec-${name}`,
|
||||||
|
artistName: 'Tideline',
|
||||||
|
trackName: name,
|
||||||
|
totalListenCount: 100,
|
||||||
|
caaReleaseMbid: '',
|
||||||
|
releaseName: 'Foreshore',
|
||||||
|
releaseGroupMbid: 'rg-owned',
|
||||||
|
length: 200000,
|
||||||
|
inLibrary: localId > 0,
|
||||||
|
localId,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetHarness();
|
||||||
|
|
||||||
|
stub('explore.Service.LookupArtist', {
|
||||||
|
mbid: ARTIST,
|
||||||
|
name: 'Tideline',
|
||||||
|
popularity: 1200,
|
||||||
|
type: 'Group',
|
||||||
|
country: 'GB',
|
||||||
|
});
|
||||||
|
stub('explore.Service.TopReleaseGroupsForArtist', []);
|
||||||
|
stub('explore.Service.TopRecordingsForArtist', [
|
||||||
|
track('Owned Song', 7),
|
||||||
|
track('Absent Song'),
|
||||||
|
]);
|
||||||
|
stub('explore.Service.SimilarArtists', []);
|
||||||
|
stub('explore.Service.PrefetchReleases', undefined);
|
||||||
|
stub('explore.Service.BrowseReleaseGroups', [
|
||||||
|
{
|
||||||
|
mbid: 'rg-owned',
|
||||||
|
title: 'Foreshore',
|
||||||
|
artistCredit: 'Tideline',
|
||||||
|
primaryType: 'Album',
|
||||||
|
inLibrary: true,
|
||||||
|
localId: 7,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
stub('library.Library.GetAlbumsCompleteness', {});
|
||||||
|
stub('download.Service.ListRequests', []);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mount(): Promise<LitElement> {
|
||||||
|
const el = await fixture<LitElement>('explore-artist-details', {
|
||||||
|
artistMBID: ARTIST,
|
||||||
|
artistName: 'Tideline',
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the artist header', () => {
|
||||||
|
it('offers Play, with Shuffle behind its caret', async () => {
|
||||||
|
const el = await mount();
|
||||||
|
const play = shadow<HTMLElement>(el, '[data-testid="artist-play-library"]')!;
|
||||||
|
|
||||||
|
// The words moved to the title, which is where "Play library
|
||||||
|
// tracks" can still be read without taking the width of a button.
|
||||||
|
expect(play.textContent?.trim()).toBe('Play');
|
||||||
|
expect(play.getAttribute('title')).toBe('Play library tracks');
|
||||||
|
|
||||||
|
const menuButton = shadow(el, '[data-testid="artist-play-menu"]');
|
||||||
|
|
||||||
|
expect(menuButton).not.toBeNull();
|
||||||
|
|
||||||
|
const menu = shadow(el, '#artist-play-menu');
|
||||||
|
|
||||||
|
expect(menu?.textContent).toContain('Shuffle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts Follow on the same line as Play', async () => {
|
||||||
|
const el = await mount();
|
||||||
|
const actions = shadow(el, '.artist-actions')!;
|
||||||
|
|
||||||
|
expect(actions.querySelector('[data-testid="artist-play-library"]')).not.toBeNull();
|
||||||
|
|
||||||
|
const follow = actions.querySelector('[data-testid="artist-follow"]') as HTMLElement;
|
||||||
|
|
||||||
|
expect(follow).not.toBeNull();
|
||||||
|
expect(follow.textContent?.trim()).toBe('Follow');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says Following once the artist is on the request list', async () => {
|
||||||
|
const el = await mount();
|
||||||
|
|
||||||
|
// The store is a singleton and caches its list, so the change is
|
||||||
|
// announced the way the backend announces one.
|
||||||
|
stub('download.Service.ListRequests', [
|
||||||
|
{ id: 3, mbid: ARTIST, state: 'queued' },
|
||||||
|
]);
|
||||||
|
emit(Events.RequestsChanged);
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const follow = shadow<HTMLElement>(el, '[data-testid="artist-follow"]')!;
|
||||||
|
|
||||||
|
expect(follow.textContent?.trim()).toBe('Following');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sizes the name and the listen count above the metadata line', async () => {
|
||||||
|
const el = await mount();
|
||||||
|
|
||||||
|
const title = shadow<HTMLElement>(el, '.artist-title')!;
|
||||||
|
const listens = shadow<HTMLElement>(el, '.artist-listens')!;
|
||||||
|
const meta = shadow<HTMLElement>(el, '.artist-meta')!;
|
||||||
|
|
||||||
|
expect(listens.textContent).toContain('plays on ListenBrainz');
|
||||||
|
|
||||||
|
const titleSize = parseFloat(getComputedStyle(title).fontSize);
|
||||||
|
const listensSize = parseFloat(getComputedStyle(listens).fontSize);
|
||||||
|
const metaSize = parseFloat(getComputedStyle(meta).fontSize);
|
||||||
|
|
||||||
|
expect(titleSize).toBeGreaterThan(24);
|
||||||
|
expect(listensSize).toBeGreaterThan(metaSize);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a top track’s affordance', () => {
|
||||||
|
it('plays from the artwork when it is owned', async () => {
|
||||||
|
const el = await mount();
|
||||||
|
const rows = shadowAll<HTMLElement>(el, '.track-item');
|
||||||
|
|
||||||
|
const owned = rows.find((r) => r.textContent?.includes('Owned Song'))!;
|
||||||
|
|
||||||
|
expect(owned.querySelector('.track-art-overlay .track-art-play')).not.toBeNull();
|
||||||
|
// Nothing beside the row any more.
|
||||||
|
expect(owned.querySelector(':scope > library-status-indicator')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests from the artwork when it is not', async () => {
|
||||||
|
const el = await mount();
|
||||||
|
const rows = shadowAll<HTMLElement>(el, '.track-item');
|
||||||
|
|
||||||
|
const absent = rows.find((r) => r.textContent?.includes('Absent Song'))!;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
absent.querySelector('.track-art-overlay library-status-indicator'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(absent.querySelector('.track-art-overlay .track-art-play')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,7 +25,9 @@ const ARTIST = 'artist-0001';
|
|||||||
|
|
||||||
/** The labels of the open menu's items, trimmed. */
|
/** The labels of the open menu's items, trimmed. */
|
||||||
function menuItems(el: LitElement): string[] {
|
function menuItems(el: LitElement): string[] {
|
||||||
const panel = shadow(el, '.context-menu-panel');
|
// Scoped to the context menu: the artist page also has a Play/Shuffle
|
||||||
|
// dropdown, and its panel carries the same class.
|
||||||
|
const panel = shadow(el, '#context-menu .context-menu-panel');
|
||||||
|
|
||||||
if (!panel) return [];
|
if (!panel) return [];
|
||||||
|
|
||||||
@@ -100,7 +102,7 @@ describe('the context menu on an artist page release', () => {
|
|||||||
|
|
||||||
await openMenuOnAlbum(el, 0);
|
await openMenuOnAlbum(el, 0);
|
||||||
|
|
||||||
const panel = shadow(el, '.context-menu-panel');
|
const panel = shadow(el, '#context-menu .context-menu-panel');
|
||||||
|
|
||||||
expect(panel).toBeTruthy();
|
expect(panel).toBeTruthy();
|
||||||
// The panel is shared with the track menu, so a label that does not
|
// The panel is shared with the track menu, so a label that does not
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
/**
|
||||||
|
* The play-all/shuffle-all pair on every page that lists tracks.
|
||||||
|
*
|
||||||
|
* The pair is driven by one helper (`utils/play-all`) that owns the
|
||||||
|
* one rule the album page already carried: `shuffleStart` does not turn
|
||||||
|
* shuffle on, it only picks a random first track once the mode is on —
|
||||||
|
* so the mode has to be set *before* the queue, not after. The hosts
|
||||||
|
* differ only in where their paths come from and what `Source` they
|
||||||
|
* hand over.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
import type { LitElement } from 'lit';
|
||||||
|
|
||||||
|
import '@components/track-list/track-list';
|
||||||
|
import '@components/artist-details/artist-details';
|
||||||
|
import '@components/playlist-details/playlist-details';
|
||||||
|
import '@components/smart-playlist-details/smart-playlist-details';
|
||||||
|
import {
|
||||||
|
stub,
|
||||||
|
flush,
|
||||||
|
resetHarness,
|
||||||
|
calls,
|
||||||
|
lastArgs,
|
||||||
|
emit,
|
||||||
|
} from '@test/support/harness';
|
||||||
|
import {
|
||||||
|
fixture,
|
||||||
|
shadowAll,
|
||||||
|
deepShadow,
|
||||||
|
} from '@test/support/render';
|
||||||
|
|
||||||
|
/** The action button rendered by `<page-header>`, through the nested
|
||||||
|
* shadow roots (track-list → page-header). */
|
||||||
|
function pageAction(
|
||||||
|
host: LitElement,
|
||||||
|
id: string,
|
||||||
|
): HTMLElement | null {
|
||||||
|
return deepShadow<HTMLElement>(host, `[data-testid="page-action-${id}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setShuffleMode(on: boolean): void {
|
||||||
|
emit('QueueModeChanged', { shuffleMode: on, repeatMode: 'off' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The queue's `SetQueue` args, with the shuffle flag and source. */
|
||||||
|
function queued(): {
|
||||||
|
paths: string[];
|
||||||
|
startIndex: number;
|
||||||
|
shuffleStart: boolean;
|
||||||
|
source: unknown;
|
||||||
|
} {
|
||||||
|
const args = lastArgs('queue.Queue.SetQueue');
|
||||||
|
|
||||||
|
if (!args) throw new Error('nothing was queued');
|
||||||
|
|
||||||
|
return {
|
||||||
|
paths: args[0] as string[],
|
||||||
|
startIndex: args[1] as number,
|
||||||
|
shuffleStart: args[2] as boolean,
|
||||||
|
source: args[3],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// The track list (Tracks, and every embedding detail view)
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
const PATHS = Array.from({ length: 12 }, (_, i) => `/music/track-${i}.mp3`);
|
||||||
|
|
||||||
|
const LIST = PATHS.map((FilePath, i) => ({
|
||||||
|
FilePath,
|
||||||
|
TrackName: `Track ${i}`,
|
||||||
|
ArtistName: 'An Artist',
|
||||||
|
Album: 'An Album',
|
||||||
|
Duration: 180,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const GENRE_SOURCE = { type: 'genre', id: 0, label: 'Dream Pop' };
|
||||||
|
|
||||||
|
async function embeddedTrackList(): Promise<LitElement> {
|
||||||
|
resetHarness();
|
||||||
|
localStorage.removeItem('track-list-column-widths');
|
||||||
|
|
||||||
|
const el = await fixture<LitElement>('track-list', {
|
||||||
|
externalTracks: LIST,
|
||||||
|
queueSource: GENRE_SOURCE,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Say which order is being asserted rather than inheriting a
|
||||||
|
// persisted sort. See play-in-context.test.ts for the same trap.
|
||||||
|
(el as unknown as { sortField: string | null }).sortField = null;
|
||||||
|
|
||||||
|
el.style.display = 'block';
|
||||||
|
el.style.height = '600px';
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the track-list play-all/shuffle-all pair', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetHarness();
|
||||||
|
localStorage.removeItem('track-list-column-widths');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders in the primary Tracks header and is disabled while empty', async () => {
|
||||||
|
const el = await fixture<LitElement>('track-list', {});
|
||||||
|
|
||||||
|
const play = pageAction(el, 'play-all');
|
||||||
|
const shuffle = pageAction(el, 'shuffle-all');
|
||||||
|
|
||||||
|
expect(play).not.toBeNull();
|
||||||
|
expect(shuffle).not.toBeNull();
|
||||||
|
expect(play?.hasAttribute('disabled')).toBe(true);
|
||||||
|
expect(shuffle?.hasAttribute('disabled')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders in an embedded track-list header too', async () => {
|
||||||
|
const el = await embeddedTrackList();
|
||||||
|
|
||||||
|
expect(pageAction(el, 'play-all')).not.toBeNull();
|
||||||
|
expect(pageAction(el, 'shuffle-all')).not.toBeNull();
|
||||||
|
expect(pageAction(el, 'play-all')?.hasAttribute('disabled')).toBe(false);
|
||||||
|
expect(pageAction(el, 'shuffle-all')?.hasAttribute('disabled')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Play all queues the displayed list in order, unshuffled', async () => {
|
||||||
|
const el = await embeddedTrackList();
|
||||||
|
|
||||||
|
pageAction(el, 'play-all')!.click();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(queued()).toEqual({
|
||||||
|
paths: PATHS,
|
||||||
|
startIndex: 0,
|
||||||
|
shuffleStart: false,
|
||||||
|
source: GENRE_SOURCE,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shuffle all turns shuffle on before queueing when the mode is off', async () => {
|
||||||
|
const el = await embeddedTrackList();
|
||||||
|
|
||||||
|
setShuffleMode(false);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
pageAction(el, 'shuffle-all')!.click();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const all = calls();
|
||||||
|
const toggle = all.findLastIndex(
|
||||||
|
(c) => c.path === 'queue.Queue.ToggleShuffle',
|
||||||
|
);
|
||||||
|
const setQueue = all.findLastIndex(
|
||||||
|
(c) => c.path === 'queue.Queue.SetQueue',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(toggle).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(toggle).toBeLessThan(setQueue);
|
||||||
|
|
||||||
|
expect(queued()).toEqual({
|
||||||
|
paths: PATHS,
|
||||||
|
startIndex: 0,
|
||||||
|
shuffleStart: true,
|
||||||
|
source: GENRE_SOURCE,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shuffle all does not toggle when the mode is already on', async () => {
|
||||||
|
const el = await embeddedTrackList();
|
||||||
|
|
||||||
|
setShuffleMode(true);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
pageAction(el, 'shuffle-all')!.click();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(calls('queue.Queue.ToggleShuffle')).toHaveLength(0);
|
||||||
|
|
||||||
|
expect(queued()).toEqual({
|
||||||
|
paths: PATHS,
|
||||||
|
startIndex: 0,
|
||||||
|
shuffleStart: true,
|
||||||
|
source: GENRE_SOURCE,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// The library artist page
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
const ALBUMS = [
|
||||||
|
{ ID: 3, Name: 'Third', ArtistName: 'Aurora Fields', ArtistMBID: '', MBID: '', CoverArtPath: '', CoverArtSmall: '', CoverArtMedium: '', CoverArtLarge: '', Year: 0, ReleaseYear: 0 },
|
||||||
|
{ ID: 1, Name: 'First', ArtistName: 'Aurora Fields', ArtistMBID: '', MBID: '', CoverArtPath: '', CoverArtSmall: '', CoverArtMedium: '', CoverArtLarge: '', Year: 0, ReleaseYear: 0 },
|
||||||
|
{ ID: 2, Name: 'Second', ArtistName: 'Aurora Fields', ArtistMBID: '', MBID: '', CoverArtPath: '', CoverArtSmall: '', CoverArtMedium: '', CoverArtLarge: '', Year: 0, ReleaseYear: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('the artist page play-all pair', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetHarness();
|
||||||
|
stub('explore.Service.GetArtistMBID', '');
|
||||||
|
stub('library.Library.GetAlbumsByArtist', ALBUMS);
|
||||||
|
stub('library.Library.GetFilePathsByAlbums', {
|
||||||
|
'3': ['/a3-1', '/a3-2'],
|
||||||
|
'1': ['/a1'],
|
||||||
|
'2': ['/a2-1', '/a2-2', '/a2-3'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flattens album paths in the album list order', async () => {
|
||||||
|
const el = await fixture<LitElement>('artist-details', {
|
||||||
|
artistId: 7,
|
||||||
|
artistName: 'Aurora Fields',
|
||||||
|
artistMBID: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const play = shadowAll<HTMLElement>(el, '[data-testid="artist-play-all"]')[0];
|
||||||
|
|
||||||
|
play!.click();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(queued()).toEqual({
|
||||||
|
paths: ['/a3-1', '/a3-2', '/a1', '/a2-1', '/a2-2', '/a2-3'],
|
||||||
|
startIndex: 0,
|
||||||
|
shuffleStart: false,
|
||||||
|
source: { type: 'artist', id: 7, label: 'Aurora Fields' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// A smart playlist
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
function smartPlaylistTracks(n: number) {
|
||||||
|
return Array.from({ length: n }, (_, i) => ({
|
||||||
|
ID: i + 1,
|
||||||
|
FilePath: `/music/track-${i}.mp3`,
|
||||||
|
Title: `Track ${i}`,
|
||||||
|
Artist: 'An Artist',
|
||||||
|
Album: 'An Album',
|
||||||
|
Duration: 180000,
|
||||||
|
CoverArtSmall: `/covers/${i}_sm.jpg`,
|
||||||
|
CoverArtMedium: `/covers/${i}_md.jpg`,
|
||||||
|
CoverArtPath: `/covers/${i}.jpg`,
|
||||||
|
Phantom: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the smart-playlist play-all pair', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetHarness();
|
||||||
|
stub('playlist.Service.GetSmartPlaylistTracks', smartPlaylistTracks(8));
|
||||||
|
stub('playlist.Service.GetSmartPlaylistRules', '{"rules":[]}');
|
||||||
|
stub('playlist.Service.GetAllPlaylists', []);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The details header's own action row, not a page-header action. */
|
||||||
|
function actionButton(el: LitElement, label: string): HTMLElement {
|
||||||
|
const button = shadowAll<HTMLElement>(el, '.action-button').find(
|
||||||
|
(b) => b.textContent?.trim() === label,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!button) throw new Error(`no "${label}" action button rendered`);
|
||||||
|
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('Shuffle turns the mode on before queueing, so the queue starts shuffled', async () => {
|
||||||
|
const el = await fixture<LitElement>('smart-playlist-details', {
|
||||||
|
playlistId: 1,
|
||||||
|
playlistName: 'A smart playlist',
|
||||||
|
});
|
||||||
|
|
||||||
|
el.style.display = 'block';
|
||||||
|
el.style.height = '600px';
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
|
||||||
|
setShuffleMode(false);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
actionButton(el, 'Shuffle').click();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// The issue's headline fix. `SetQueue`'s `shuffleStart` only picks
|
||||||
|
// a random first track when shuffle mode is already on — it does
|
||||||
|
// not turn it on — so reverting the mode toggle puts the queue back
|
||||||
|
// to track 1 in order while every assertion about the queue's
|
||||||
|
// contents still passes. Order of the calls is the assertion.
|
||||||
|
const all = calls();
|
||||||
|
const toggle = all.findLastIndex(
|
||||||
|
(c) => c.path === 'queue.Queue.ToggleShuffle',
|
||||||
|
);
|
||||||
|
const setQueue = all.findLastIndex(
|
||||||
|
(c) => c.path === 'queue.Queue.SetQueue',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(toggle).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(toggle).toBeLessThan(setQueue);
|
||||||
|
|
||||||
|
expect(queued()).toEqual({
|
||||||
|
paths: Array.from({ length: 8 }, (_, i) => `/music/track-${i}.mp3`),
|
||||||
|
startIndex: 0,
|
||||||
|
shuffleStart: true,
|
||||||
|
source: { type: 'smartPlaylist', id: 1, label: 'A smart playlist' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// A regular playlist
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
function playlistTracks(n: number) {
|
||||||
|
return Array.from({ length: n }, (_, i) => ({
|
||||||
|
ID: i + 1,
|
||||||
|
FilePath: `/music/track-${i}.mp3`,
|
||||||
|
Title: `Track ${i}`,
|
||||||
|
Artist: 'An Artist',
|
||||||
|
Album: 'An Album',
|
||||||
|
Duration: 180000,
|
||||||
|
Phantom: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the playlist play-all pair', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetHarness();
|
||||||
|
stub('playlist.Service.GetPlaylistTracks', playlistTracks(8));
|
||||||
|
stub('playlist.Service.GetAllPlaylists', []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers Shuffle All beside Play All, both through the helper', async () => {
|
||||||
|
const el = await fixture<LitElement>('playlist-details', {
|
||||||
|
playlistId: 1,
|
||||||
|
playlistName: 'A playlist',
|
||||||
|
});
|
||||||
|
|
||||||
|
el.style.display = 'block';
|
||||||
|
el.style.height = '600px';
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
|
||||||
|
const buttons = shadowAll<HTMLElement>(el, '.play-all-button');
|
||||||
|
|
||||||
|
expect(buttons.map((b) => b.textContent?.trim())).toEqual([
|
||||||
|
'Play All',
|
||||||
|
'Shuffle All',
|
||||||
|
]);
|
||||||
|
|
||||||
|
setShuffleMode(false);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
buttons[1]!.click();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(queued()).toEqual({
|
||||||
|
paths: Array.from({ length: 8 }, (_, i) => `/music/track-${i}.mp3`),
|
||||||
|
startIndex: 0,
|
||||||
|
shuffleStart: true,
|
||||||
|
source: { type: 'playlist', id: 1, label: 'A playlist' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user