Compare commits

...

12 Commits

Author SHA1 Message Date
bvandeusen a251dce7e3 Merge pull request 'feat: scan on startup by default + README first-run walkthrough & screenshots' (#102) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-go / test (push) Successful in 37s
release / Build + push container image (push) Successful in 1m44s
test-go / integration (push) Successful in 4m35s
2026-06-20 11:40:56 -04:00
bvandeusen 97e0e88483 feat(server): scan library on startup by default + README first-run walkthrough
test-go / test (push) Successful in 37s
test-go / integration (push) Successful in 4m38s
Make a fresh install usable out of the box and document the first-run flow
for the public-facing repo.

- Default scan_on_startup to true (Default() + config.example.yaml, which is
  the live config baked into the image). Previously false, so a fresh stack
  came up with an empty library and no hint to scan. Scans are incremental
  (mtime skip), so the per-restart cost is just a directory walk. Re-point
  the env-override test to exercise the override against the new default.
- README: add a "First run" walkthrough (register -> scan -> integrations ->
  install Android app -> invite users), each grounded in a real route.
- Add docs/screenshots/ with six captures, referenced via width-constrained
  <img> wrapped in a link (shrink inline + click to open full size).
  API token and invite token were cropped/redacted out of the captures
  before commit so no live credential lands in the public history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 11:35:22 -04:00
bvandeusen 938dae7163 Merge pull request 'docs: correct README setup/OOBE + fix data-volume mount' (#101) from dev into main 2026-06-20 10:58:07 -04:00
bvandeusen ad37937949 docs: correct README setup/OOBE + fix data-volume mount
Bring the README and client/README in line with the current product and
release pipeline ahead of making the repo public-facing:

- Highlights: replace the retired "Flutter client in flight" bullet with
  the native-Android-APK-bundled-in-the-image story.
- Quickstart: document the purpose of each compose volume mount, and fix
  the minstrel-data mount (/data -> /app/data) so it matches the image's
  MINSTREL_STORAGE_DATA_DIR and generated artefacts actually persist.
- Configuration: tie MINSTREL_STORAGE_DATA_DIR to the /app/data mount.
- Updating: replace the bogus :v1.0.x scheme with the real tag model
  (:latest, immutable :vYYYY.MM.DD, :main) and note the bundled APK.
- client/README: rewrite the Production section to match release.yml
  (android-release + needs: ordering, non-tag :latest bundle path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:57:28 -04:00
bvandeusen 93365cb555 Merge pull request 'ci(release): bundle the latest release APK into non-tag :latest builds' (#100) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 17s
2026-06-14 22:55:23 -04:00
bvandeusen 2c5c477a0d ci(release): bundle the latest release APK into non-tag :latest builds
Every main push moves :latest, but main builds don't build an APK — so the
in-app update channel silently vanished from :latest until the next tag.

Now the image-release job, on non-tag builds, pulls the most-recent
release's signed APK from the gitea API and reconstructs its exact
versionName (${TAG#v}.$(git rev-list --count TAG) — the same formula
android-release bakes in) for the version sidecar. No rebuild, just
rebundle; tag builds still bundle their own freshly-built APK. Checkout
gains fetch-depth:0 + fetch-tags so the commit count resolves. Degrades to
an empty client/ (404 update channel) — never a wrong version — if no
release / APK asset / tag count can be resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:54:54 -04:00
bvandeusen eeabdf1f2c Merge pull request 'Web UI: Most Played hover fix, narrower seek bar, Android-parity track kebab' (#99) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-web / test (push) Successful in 35s
release / Build + push container image (push) Successful in 1m50s
2026-06-14 22:26:52 -04:00
bvandeusen f7278f2417 feat(web): drop "Remove from library" from the track kebab
test-web / test (push) Successful in 32s
No single-click destructive action belongs in the kebab. Removing the item
orphaned its whole path (RemoveTrackPopover was its only caller, and the
admin/tracks API client was the popover's only caller), so per the repo's
no-dead-code convention the chain is fully removed: the menu item + its
admin/isAdmin plumbing in TrackMenu, RemoveTrackPopover(.svelte/.test),
src/lib/api/admin/tracks(.ts/.test), and the now-needless transitive mocks
in the CompactTrackCard / PlaylistTrackRow / playlist specs.

The kebab is now an 8-item, admin-agnostic menu. The DELETE /api/admin/tracks
server endpoint is untouched — a future safer admin surface can rebind it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:17:35 -04:00
bvandeusen 9a166f9032 test(web): update TrackMenu spec for Android-parity kebab
test-web / test (push) Successful in 32s
The kebab gained "Start radio" and dropped the duplicate "Flag this track…"
(its action now lives solely under "Hide", which opens the same FlagPopover).
Net item count is unchanged (9 admin / 8 non-admin), but the named-item and
flag-entry assertions needed updating:
- mock playRadio in the store mock; assert Start radio dispatches playRadio.
- swap the flag-item presence check for start-radio.
- replace the "click Flag" test with "click Hide opens the popover".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:48:57 -04:00
bvandeusen b3d6785543 feat(web): match track kebab to Android + add it to full-screen Now Playing
test-web / test (push) Failing after 32s
Bring the web TrackMenu to parity with Android's canonical TrackActionsSheet
so the kebab reads the same on both clients:
- Reorder to Android's groups: queue → like/add-to-playlist/start-radio →
  go-to-album/artist → hide.
- Drop the duplicate "Flag this track…" item — it opened the very same
  FlagPopover as "Hide" (Android folds flag into a single Hide).
- Align icons (ListVideo / ListMusic / ListPlus / Disc3 / User).
- Admin-only "Remove from library" stays as a web superset (Android has no
  surface for it), past its own divider.

Mount the kebab on the full-screen /now-playing route with hideQueueActions,
mirroring Android's NowPlayingScreen — Start radio / Add to playlist / Hide
were previously unreachable there (only like + volume + queue existed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:46:54 -04:00
bvandeusen 773275e916 feat(web): surface "Start radio" in the track kebab menu
test-web / test (push) Failing after 31s
Radio was fully wired (playRadio → /api/radio + 80% auto-refresh) but its
only entry point was TrackRow's inline 📻 button, so it was unreachable from
the kebab — i.e. missing on the Most Played compact cards and the mini-player.
Add a "Start radio" item to TrackMenu, shown even under hideQueueActions since
reseeding a station from the current track is meaningful there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:40:16 -04:00
bvandeusen 066616e196 fix(web): de-overlap Most Played hover controls + narrow desktop seek bar
test-web / test (push) Successful in 40s
CompactTrackCard is a short one-line row but reused CardActionCluster,
which corner-splits Like+Add (top) and the kebab menu (bottom). That split
is right for the tall square Album/Artist cards but makes the two groups
collide on the compact row's hover state. Give the compact card a single
inline, vertically-centred right cluster (Like + Add + menu in one group)
and widen its right padding reserve to match.

In the desktop PlayerBar, the left info column was a fixed w-72 (title kept
truncating) while the seek column was flex-1 (the scrubber hogged the slack
on wide screens). Let the left column grow up to max-w-md while holding its
288px floor at md, and cap the seek/transport column at max-w-xl centred, so
freed width flows to the title instead of stretching the bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:36:26 -04:00
25 changed files with 239 additions and 415 deletions
+54 -5
View File
@@ -2,8 +2,8 @@ name: release
# Builds and pushes the minstrel container image to the Gitea registry. # Builds and pushes the minstrel container image to the Gitea registry.
# #
# push to main → :main and :latest (no APK bundled) # push to main → :main and :latest (latest-release APK bundled)
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (APK bundled) # push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (freshly-built APK bundled)
# workflow_dispatch → manual trigger (same rules based on the ref) # workflow_dispatch → manual trigger (same rules based on the ref)
# #
# Release model: per-day CalVer tags (no trailing patch digit). The day's # Release model: per-day CalVer tags (no trailing patch digit). The day's
@@ -20,6 +20,14 @@ name: release
# happens in the same android-release job, so the Release-page download # happens in the same android-release job, so the Release-page download
# link and the in-image bundled APK are both populated atomically. # link and the in-image bundled APK are both populated atomically.
# #
# :latest always carries an APK. Because every main push also moves
# :latest (not just tags), a main build with no APK would silently strip
# the in-app update channel off :latest until the next release. So on
# non-tag builds image-release pulls the MOST RECENT release's signed APK
# and reconstructs its exact versionName (tag + commit-count, the same
# formula android-release bakes in) for the version sidecar — no rebuild,
# just rebundle. Tag builds keep bundling their own freshly-built APK.
#
# Android testing (lint + detekt + unit tests, debug APK upload on main) # Android testing (lint + detekt + unit tests, debug APK upload on main)
# lives in android.yml and runs independently on every push. # lives in android.yml and runs independently on every push.
@@ -181,6 +189,12 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
# Full history + tags so non-tag :latest builds can resolve the
# latest release tag's commit count and reconstruct the bundled
# APK's exact versionName (see "Bundle latest release APK" below).
fetch-depth: 0
fetch-tags: true
- name: Detect buildable project - name: Detect buildable project
id: guard id: guard
@@ -221,9 +235,8 @@ jobs:
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin | docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
- name: Download signed APK artifact - name: Download signed APK artifact
# Tag pushes only — android-release just produced this. Main # Tag pushes only — android-release just produced this. Non-tag
# pushes skip and the image ships with empty client/ (the # builds take the "Bundle latest release APK" path below instead.
# /api/client/version endpoint then returns 404 by design).
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v') if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:
@@ -247,6 +260,42 @@ jobs:
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
ls -lh client/ ls -lh client/
- name: Bundle latest release APK (non-tag :latest builds)
# Main pushes don't build an APK, but they DO move :latest — so
# without this the in-app update channel would vanish from :latest
# until the next tag. Pull the most-recent release's signed APK and
# reconstruct its exact versionName (${TAG#v}.$(git rev-list --count
# TAG) — identical to android-release's formula) so the version
# sidecar the server hands clients matches the installed build.
# Degrades to an empty client/ (404 update channel) — never a wrong
# version — if no release / APK asset / tag-count can be resolved.
if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v')
shell: bash
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
run: |
set -eu
REPO="${GITHUB_REPOSITORY}"
REL_JSON="$(curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/latest" || true)"
if [ -z "${REL_JSON}" ]; then
echo "::notice::no published release — image ships without bundled APK"; exit 0
fi
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)"
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)"
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
fi
COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)"
if [ -z "${COUNT}" ]; then
echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0
fi
VERSION_NAME="${TAG#v}.${COUNT}"
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
echo "${VERSION_NAME}" > client/minstrel.apk.version
echo "::notice::bundled release APK ${TAG} as version ${VERSION_NAME}"
ls -lh client/
- name: Build and push - name: Build and push
if: steps.guard.outputs.ready == 'true' if: steps.guard.outputs.ready == 'true'
run: | run: |
+44 -7
View File
@@ -4,7 +4,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
> State and intelligence belong on the server, not the client. > State and intelligence belong on the server, not the client.
<!-- TODO: screenshot of the home page --> <a href="docs/screenshots/home.png"><img src="docs/screenshots/home.png" width="820" alt="Minstrel home — your library at a glance"></a>
## Highlights ## Highlights
@@ -13,7 +13,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag. - **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit. - **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit.
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy. - **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
- **Flutter mobile client in flight.** Tracking issue [#356](https://git.fabledsword.com/bvandeusen/minstrel/issues/356). - **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
## Quickstart ## Quickstart
@@ -24,10 +24,18 @@ services:
image: git.fabledsword.com/bvandeusen/minstrel:latest image: git.fabledsword.com/bvandeusen/minstrel:latest
ports: ['4533:4533'] ports: ['4533:4533']
volumes: volumes:
# Your music library. Point ./music at wherever your audio files
# live. Mounted read-only — Minstrel never writes to your library.
- ./music:/music:ro - ./music:/music:ro
- minstrel-data:/data # Generated data: playlist cover collages, artist art, caches.
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image
# sets to /app/data — keep this mount on /app/data or your cache
# won't survive a container recreate.
- minstrel-data:/app/data
environment: environment:
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
# Colon-separated library roots to scan; must match the container
# path of the read-only music mount above (/music here).
MINSTREL_LIBRARY_SCAN_PATHS: /music MINSTREL_LIBRARY_SCAN_PATHS: /music
depends_on: [db] depends_on: [db]
@@ -37,6 +45,8 @@ services:
POSTGRES_USER: minstrel POSTGRES_USER: minstrel
POSTGRES_PASSWORD: minstrel POSTGRES_PASSWORD: minstrel
POSTGRES_DB: minstrel POSTGRES_DB: minstrel
# Postgres data dir — users, likes, play history, sessions, settings.
# The one volume you must never lose; back it up with pg_dump.
volumes: [pgdata:/var/lib/postgresql/data] volumes: [pgdata:/var/lib/postgresql/data]
volumes: volumes:
@@ -48,7 +58,29 @@ volumes:
docker compose up -d docker compose up -d
``` ```
After the stack is up, visit `http://localhost:4533/register` and create your admin account. The first user to register on a fresh instance is automatically marked as the administrator; subsequent users can register through the same form (or via invite tokens generated from the admin Users panel, depending on how you configure registration). ## First run
With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of `localhost` if you're reaching the server over a LAN/VPN address (plain `http://` is fine — no TLS required).
**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance is automatically the administrator; later users join through the same form or an invite token (step 5).
<a href="docs/screenshots/register.png"><img src="docs/screenshots/register.png" width="320" alt="Creating the first (admin) account on a fresh instance"></a>
**2. Let the first library scan finish.** `scan_on_startup` is on by default, so Minstrel walks your mounted library on boot and imports artists, albums, and tracks — no button to press. Watch progress (and re-scan any time) on the **Admin** page (`/admin`); the scan runs in stages and is incremental, so later restarts only pick up what changed.
<a href="docs/screenshots/library-scan.png"><img src="docs/screenshots/library-scan.png" width="820" alt="The Admin page, where the library scan runs and reports progress"></a>
**3. (Optional) Name the instance and wire up integrations.** In admin **Settings → Integrations** (`/admin/integrations`), add a ListenBrainz token (scrobbling + similarity radio) and/or a Lidarr URL + API key (the request flow). These live in the UI and apply without a restart; the display name can also be set via `MINSTREL_BRANDING_APP_NAME`.
<a href="docs/screenshots/integrations.png"><img src="docs/screenshots/integrations.png" width="820" alt="ListenBrainz and Lidarr integration cards in admin Settings"></a>
**4. Install the Android app.** Open **Settings** (`/settings`) and use the *Install the Android app* card to download the APK that ships inside this server image, then sign in with the same account. From then on the app self-updates straight from your server.
<a href="docs/screenshots/android-download.png"><img src="docs/screenshots/android-download.png" width="820" alt="The &quot;Install the Android app&quot; download card in Settings"></a>
**5. Invite the rest of the household.** From admin **Users** (`/admin/users`), generate an invite token (or enable open registration). Each person gets their own account, so likes, play history, and recommendations stay per-user.
<a href="docs/screenshots/invite-users.png"><img src="docs/screenshots/invite-users.png" width="820" alt="Generating an invite token in admin Users"></a>
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml). For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
@@ -57,7 +89,7 @@ For the full configuration surface, see [`config.example.yaml`](./config.example
Most operators only need the env vars in the quickstart above. A few extras worth knowing: Most operators only need the env vars in the quickstart above. A few extras worth knowing:
- `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews. - `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.
- `MINSTREL_STORAGE_DATA_DIR`defaults to `./data`. Holds playlist cover collages and other generated artefacts. - `MINSTREL_STORAGE_DATA_DIR`where generated artefacts (playlist cover collages, artist art, caches) are written. The container image sets this to `/app/data`, which is why the quickstart mounts the `minstrel-data` volume there.
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`). - `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting. ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
@@ -66,8 +98,13 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
## Updating ## Updating
- `:main` — rolling, follows the dev branch's tested tip. Recommended only for the operator who's running an upstream-watching deployment. Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
- `:v1.0.x` — pinned releases. Recommended default. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
- `:vYYYY.MM.DD` — immutable per-day release tags. Pin one of these for a deployment you don't want moving under you. (Per-day CalVer — no trailing patch digit; a same-day re-cut moves the tag forward.)
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
Every `:latest` and every `:vYYYY.MM.DD` bundles the current signed Android APK, so the in-app update channel is always live. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
## Specs ## Specs
+18 -8
View File
@@ -6,17 +6,27 @@ in-app update flow (#397).
## Production ## Production
CI populates this directory on tag releases: CI populates this directory in `release.yml` (no Flutter — the client is
1. `flutter.yml` builds `app-release.apk` and attaches it to the native Android, built by the `android-release` job):
Gitea release as `minstrel-<TAG>.apk`.
2. `release.yml` waits for that asset to appear, downloads it into - **Tag release (`v*`):** the `android-release` job builds + signs the
this directory as `minstrel.apk`, writes the tag string to APK, uploads it as a workflow artifact, and attaches it to the Gitea
`minstrel.apk.version`, and `docker buildx build` includes both release as `minstrel-<TAG>.apk`. The `image-release` job
via `COPY client/ /app/client/`. (`needs: android-release`) downloads that artifact into this directory
as `minstrel.apk` and writes the computed versionName to
`minstrel.apk.version`.
- **Main push (non-tag `:latest` / `:main`):** no APK is built, but the
image still moves `:latest`, so `image-release` pulls the most-recent
release's signed APK from the Gitea API and reconstructs its exact
versionName — keeping the in-app update channel alive on `:latest`
instead of stripping it until the next tag.
Either way, `docker buildx build` bakes both files into the image via
`COPY client/ /app/client/`.
## Development ## Development
Empty directory works fine — the endpoints return 404 and the Flutter Empty directory works fine — the endpoints return 404 and the Android
update banner stays hidden ("no update channel" graceful degradation). update banner stays hidden ("no update channel" graceful degradation).
To smoke-test the update flow locally, drop a real APK + a version To smoke-test the update flow locally, drop a real APK + a version
+7 -2
View File
@@ -26,8 +26,13 @@ library:
# Filesystem roots to scan for music. Each path is walked recursively. # Filesystem roots to scan for music. Each path is walked recursively.
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style) # Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
scan_paths: [] scan_paths: []
# Kick off a scan on server startup. Env: MINSTREL_LIBRARY_SCAN_ON_STARTUP # Kick off a scan on server startup. On by default so a fresh install
scan_on_startup: false # populates its library on first boot with no manual step. Scans are
# incremental (unchanged files are skipped by mtime), so the cost on
# every later restart is just a directory walk, not a full re-index.
# Set false if you'd rather scan only on a schedule / via the admin UI.
# Env: MINSTREL_LIBRARY_SCAN_ON_STARTUP
scan_on_startup: true
# Per-instance branding. Operators can rename their instance ("Family # Per-instance branding. Operators can rename their instance ("Family
# Jukebox", "Office Music", etc.) and override the OG share-preview # Jukebox", "Office Music", etc.) and override the OG share-preview
+19
View File
@@ -0,0 +1,19 @@
# README screenshots
Drop the files below into this directory with these **exact names** — the
root `README.md` already references them, so no Markdown editing is needed.
Export as PNG. Capture while logged in as admin and *after* the first
library scan so nothing looks empty; keep the same browser width and theme
across all shots for a consistent look.
| Filename | Route | What to capture |
|---|---|---|
| `home.png` | `/` | The home page with a populated library (system playlists / recently added) |
| `register.png` | `/register` | The account-creation form on a fresh instance |
| `library-scan.png` | `/admin` | The library scan running in stages (grab it mid-scan so stage tallies show) |
| `integrations.png` | `/admin/integrations` | The ListenBrainz + Lidarr integration cards |
| `android-download.png` | `/settings` | The "Install the Android app" download card |
| `invite-users.png` | `/admin/users` | The invite-token generation / users list |
Tip: to control display width in the README, you can swap the Markdown
`![alt](path)` for an HTML tag, e.g. `<img src="docs/screenshots/home.png" width="800" alt="...">`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+5
View File
@@ -135,6 +135,11 @@ func Default() Config {
RadioSizeMax: 200, RadioSizeMax: 200,
}, },
Library: LibraryConfig{ Library: LibraryConfig{
// On by default so a fresh install populates its library on
// first boot without the operator hunting for a "scan now"
// button. Scans are incremental (unchanged files skipped by
// mtime), so every later restart is just a cheap directory walk.
ScanOnStartup: true,
CoverArtFromMBCAA: true, CoverArtFromMBCAA: true,
}, },
} }
+4 -3
View File
@@ -97,7 +97,8 @@ func TestEnvOverrides(t *testing.T) {
func TestLibraryEnvOverrides(t *testing.T) { func TestLibraryEnvOverrides(t *testing.T) {
stubStreamSecret(t) stubStreamSecret(t)
t.Setenv("MINSTREL_LIBRARY_SCAN_PATHS", "/music:/other::/third") t.Setenv("MINSTREL_LIBRARY_SCAN_PATHS", "/music:/other::/third")
t.Setenv("MINSTREL_LIBRARY_SCAN_ON_STARTUP", "true") // Default is true; set false so the override path is actually exercised.
t.Setenv("MINSTREL_LIBRARY_SCAN_ON_STARTUP", "false")
cfg, err := Load("") cfg, err := Load("")
if err != nil { if err != nil {
@@ -112,8 +113,8 @@ func TestLibraryEnvOverrides(t *testing.T) {
t.Errorf("scan_paths[%d] = %q, want %q", i, cfg.Library.ScanPaths[i], p) t.Errorf("scan_paths[%d] = %q, want %q", i, cfg.Library.ScanPaths[i], p)
} }
} }
if !cfg.Library.ScanOnStartup { if cfg.Library.ScanOnStartup {
t.Error("scan_on_startup = false, want true") t.Error("scan_on_startup = true, want false (env override of the true default)")
} }
} }
-64
View File
@@ -1,64 +0,0 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { removeTrack } from './tracks';
afterEach(() => {
vi.unstubAllGlobals();
});
function stubFetch(status: number, body: unknown, init: Partial<Response> = {}) {
const res = new Response(
body === null ? null : JSON.stringify(body),
{ status, headers: { 'Content-Type': 'application/json' }, ...init }
);
const spy = vi.fn().mockResolvedValue(res);
vi.stubGlobal('fetch', spy);
return spy;
}
describe('removeTrack', () => {
test('DELETEs /api/admin/tracks/:id with no query string when unmonitor unset', async () => {
const spy = stubFetch(200, {
deleted_track_id: 't-1',
deleted_album_id: 'al-1'
});
const r = await removeTrack('t-1');
expect(r.deleted_track_id).toBe('t-1');
expect(r.deleted_album_id).toBe('al-1');
expect(r.deleted_artist_id).toBeUndefined();
expect(r.lidarr_unmonitor_failed).toBeUndefined();
const call = spy.mock.calls[0];
expect(call[0]).toBe('/api/admin/tracks/t-1');
expect((call[1] as RequestInit).method).toBe('DELETE');
});
test('appends ?unmonitor=true when option set', async () => {
const spy = stubFetch(200, {
deleted_track_id: 't-1',
lidarr_unmonitor_failed: true
});
const r = await removeTrack('t-1', { unmonitor: true });
expect(r.lidarr_unmonitor_failed).toBe(true);
const call = spy.mock.calls[0];
expect(call[0]).toBe('/api/admin/tracks/t-1?unmonitor=true');
});
test('omits the query string when unmonitor: false', async () => {
const spy = stubFetch(200, { deleted_track_id: 't-1' });
await removeTrack('t-1', { unmonitor: false });
const call = spy.mock.calls[0];
expect(call[0]).toBe('/api/admin/tracks/t-1');
});
test('surfaces not_found as ApiError', async () => {
stubFetch(404, { error: 'not_found' });
await expect(removeTrack('t-1')).rejects.toMatchObject({
code: 'not_found'
});
});
});
-42
View File
@@ -1,42 +0,0 @@
import { apiFetch } from '$lib/api/client';
export type RemoveTrackResult = {
deleted_track_id: string;
deleted_album_id?: string;
deleted_artist_id?: string;
/** Only present (always `true` when set) when the operator requested
* unmonitor=true AND the Lidarr unmonitor call failed. The destructive
* file + DB delete already succeeded — surface a follow-up toast so
* the operator knows to manually unmonitor in Lidarr. */
lidarr_unmonitor_failed?: true;
};
export type RemoveTrackOptions = {
/** When true, after the file + DB delete the server calls
* Lidarr.UnmonitorTrack so Lidarr won't search for a replacement.
* When false / omitted, no Lidarr call — Lidarr's monitoring will
* re-import on next scan, which is the operator's "find a replacement"
* path. */
unmonitor?: boolean;
};
/**
* DELETE /api/admin/tracks/{id} — admin-only. Deletes the file from
* disk and the DB row, cascading album/artist tidy-up. Optionally
* unmonitors the track in Lidarr.
*
* On success returns the deleted track id plus optional album/artist
* ids when their parent row was tidied up by the server-side cascade.
* Caller is responsible for invalidating any TanStack Query keys the
* deleted entities backed.
*/
export async function removeTrack(
id: string,
options: RemoveTrackOptions = {}
): Promise<RemoveTrackResult> {
const params = new URLSearchParams();
if (options.unmonitor) params.set('unmonitor', 'true');
const qs = params.toString();
const url = `/api/admin/tracks/${encodeURIComponent(id)}${qs ? '?' + qs : ''}`;
return (await apiFetch(url, { method: 'DELETE' })) as RemoveTrackResult;
}
+27 -11
View File
@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { Plus } from 'lucide-svelte';
import type { TrackRef } from '$lib/api/types'; import type { TrackRef } from '$lib/api/types';
import { playQueue, enqueueTrack } from '$lib/player/store.svelte'; import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers'; import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
import TrackMenu from './TrackMenu.svelte'; import TrackMenu from './TrackMenu.svelte';
import CardActionCluster from './CardActionCluster.svelte'; import LikeButton from './LikeButton.svelte';
// Horizontal compact track row — cover thumb on the left, title + // Horizontal compact track row — cover thumb on the left, title +
// artist on the right. Mirrors Android's CompactTrackTile so the // artist on the right. Mirrors Android's CompactTrackTile so the
@@ -42,7 +43,7 @@
type="button" type="button"
aria-label={`Play ${track.title}`} aria-label={`Play ${track.title}`}
onclick={onClick} onclick={onClick}
class="flex w-full items-center gap-2 rounded-md p-1 pr-12 text-left class="flex w-full items-center gap-2 rounded-md p-1 pr-24 text-left
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent" hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
> >
<div class="h-12 w-12 flex-shrink-0 overflow-hidden rounded bg-surface-hover"> <div class="h-12 w-12 flex-shrink-0 overflow-hidden rounded bg-surface-hover">
@@ -59,14 +60,29 @@
<div class="truncate text-xs text-text-secondary">{track.artist_name}</div> <div class="truncate text-xs text-text-secondary">{track.artist_name}</div>
</div> </div>
</button> </button>
<CardActionCluster <!-- Single inline cluster, vertically centred on the right. The shared
likeEntityType="track" CardActionCluster splits Like+Add (top) and the menu (bottom) into
likeEntityId={track.id} opposite corners — correct for the tall square Album/Artist cards but
onAdd={onAddToQueue} it makes the two groups collide on this short one-line row. A compact
addLabel={`Add ${track.title} to queue`} row keeps all three controls in one horizontal group instead. -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute right-2 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5
opacity-0 transition-opacity duration-150
group-hover:opacity-100 focus-within:opacity-100"
onclick={(e) => e.stopPropagation()}
> >
{#snippet menu()} <LikeButton entityType="track" entityId={track.id} size="sm" />
<TrackMenu {track} /> <button
{/snippet} type="button"
</CardActionCluster> aria-label={`Add ${track.title} to queue`}
onclick={onAddToQueue}
class="rounded-full bg-surface p-1 text-text-secondary hover:text-text-primary
focus-visible:ring-2 focus-visible:ring-accent"
>
<Plus size={16} strokeWidth={1} />
</button>
<TrackMenu {track} />
</div>
</div> </div>
@@ -10,10 +10,6 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock()); vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
vi.mock('$lib/api/admin/tracks', () => ({
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' })
}));
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock()); vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
vi.mock('$lib/auth/store.svelte', () => ({ vi.mock('$lib/auth/store.svelte', () => ({
+10 -3
View File
@@ -272,8 +272,12 @@
data-testid="player-bar-desktop" data-testid="player-bar-desktop"
class="hidden md:flex md:flex-row md:min-h-[108px] md:items-center md:gap-4 border-t border-border bg-surface md:px-4 md:py-1.5" class="hidden md:flex md:flex-row md:min-h-[108px] md:items-center md:gap-4 border-t border-border bg-surface md:px-4 md:py-1.5"
> >
<!-- Left: cover + title + artist + like + menu --> <!-- Left: cover + title + artist + like + menu.
<div class="flex w-72 min-w-0 items-center gap-3"> basis-72 grow max-w-md: holds its 288px floor at the md breakpoint
(where the seek column is the flex-1 that absorbs the remainder), but
on wider screens it grows up to 448px instead of leaving the title
truncated while the seek bar hogs the slack. -->
<div class="flex basis-72 grow max-w-md min-w-0 items-center gap-3">
<a <a
href="/now-playing" href="/now-playing"
aria-label="Open now playing" aria-label="Open now playing"
@@ -318,7 +322,10 @@
</button> </button>
</div> </div>
{:else} {:else}
<div class="flex flex-1 flex-col items-stretch gap-2"> <!-- max-w-xl + mx-auto caps the seek bar (and the transport row) at
576px and centres it, so on wide screens the extra width flows to
the title column on the left rather than stretching the scrubber. -->
<div class="flex flex-1 flex-col items-stretch gap-2 mx-auto w-full max-w-xl">
<!-- Transport row --> <!-- Transport row -->
<div class="flex items-center justify-center gap-3"> <div class="flex items-center justify-center gap-3">
<button <button
@@ -21,10 +21,6 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock()); vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
vi.mock('$lib/api/admin/tracks', () => ({
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't-1' })
}));
vi.mock('$lib/player/store.svelte', () => ({ vi.mock('$lib/player/store.svelte', () => ({
playNext: vi.fn(), playNext: vi.fn(),
enqueueTrack: vi.fn(), enqueueTrack: vi.fn(),
@@ -1,103 +0,0 @@
<script lang="ts">
import { Trash2 } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import { removeTrack } from '$lib/api/admin/tracks';
import { errMessage } from '$lib/api/errors';
import { qk } from '$lib/api/queries';
import type { TrackRef } from '$lib/api/types';
let {
track,
onClose
}: {
track: TrackRef;
onClose: () => void;
} = $props();
const queryClient = useQueryClient();
let unmonitor = $state(false);
let busy = $state(false);
let error = $state<string | null>(null);
async function confirm() {
busy = true;
error = null;
try {
const result = await removeTrack(track.id, { unmonitor });
// Invalidate caches for any vanished entity. The album/artist that
// contained the track is always touched; if the parent rows were
// tidied up by the server cascade, evict those too. Home payload
// surfaces albums/artists on the landing page so it always
// refreshes.
await queryClient.invalidateQueries({ queryKey: qk.album(track.album_id) });
await queryClient.invalidateQueries({ queryKey: qk.artist(track.artist_id) });
await queryClient.invalidateQueries({ queryKey: qk.home() });
if (result.deleted_album_id) {
await queryClient.invalidateQueries({ queryKey: qk.album(result.deleted_album_id) });
}
if (result.deleted_artist_id) {
await queryClient.invalidateQueries({ queryKey: qk.artist(result.deleted_artist_id) });
}
// Note: result.lidarr_unmonitor_failed is true when unmonitor was
// requested AND Lidarr couldn't be reached. The destructive part
// already succeeded; we still close the popover. A future toast
// surface (if any) can pick up the flag.
onClose();
} catch (e: unknown) {
error = errMessage(e);
} finally {
busy = false;
}
}
</script>
<div
role="dialog"
tabindex="-1"
aria-modal="false"
aria-labelledby="remove-track-popover-title"
class="absolute right-0 z-30 mt-1 w-72 rounded-lg border border-border bg-surface p-3 shadow-xl"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Escape') onClose(); }}
>
<h4 id="remove-track-popover-title" class="text-sm font-medium text-text-primary">
Remove "{track.title}" from your library?
</h4>
<p class="mt-1 text-xs text-text-muted">
This deletes the file from disk.
</p>
<label class="mt-3 flex items-start gap-2 text-sm text-text-primary">
<input
type="checkbox"
bind:checked={unmonitor}
class="mt-0.5 rounded border-border"
/>
<span>Also stop Lidarr from finding a replacement</span>
</label>
{#if error}
<p class="mt-2 text-xs text-action-destructive">{error}</p>
{/if}
<div class="mt-3 flex justify-end gap-2">
<button
type="button"
class="rounded-md border border-border px-2.5 py-1 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
onclick={onClose}
disabled={busy}
>
Cancel
</button>
<button
type="button"
onclick={confirm}
disabled={busy}
class="inline-flex items-center gap-1 rounded-md border border-border bg-surface px-2.5 py-1 text-sm text-action-destructive hover:bg-surface-hover disabled:opacity-50"
>
<Trash2 size={14} strokeWidth={1} />
{busy ? 'Removing…' : 'Remove'}
</button>
</div>
</div>
@@ -1,97 +0,0 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { makeTrack } from '$test-utils/fixtures/track';
const invalidateMock = vi.fn();
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return {
...actual,
useQueryClient: () => ({ invalidateQueries: invalidateMock })
};
});
vi.mock('$lib/api/admin/tracks', () => ({
removeTrack: vi.fn()
}));
import RemoveTrackPopover from './RemoveTrackPopover.svelte';
import { removeTrack } from '$lib/api/admin/tracks';
const track = makeTrack({ title: 'Roygbiv' });
afterEach(() => vi.clearAllMocks());
describe('RemoveTrackPopover', () => {
test('renders the title and subtext', () => {
render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } });
expect(screen.getByText(/remove "roygbiv" from your library/i)).toBeInTheDocument();
expect(screen.getByText(/this deletes the file from disk/i)).toBeInTheDocument();
});
test('renders the Lidarr unmonitor checkbox', () => {
render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } });
const cb = screen.getByRole('checkbox', { name: /also stop lidarr/i });
expect(cb).toBeInTheDocument();
expect((cb as HTMLInputElement).checked).toBe(false);
});
test('Cancel button calls onClose without firing removeTrack', async () => {
const onClose = vi.fn();
render(RemoveTrackPopover, { props: { track, onClose } });
await fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(removeTrack).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
test('Remove without checkbox calls removeTrack with unmonitor=false', async () => {
(removeTrack as ReturnType<typeof vi.fn>).mockResolvedValue({ deleted_track_id: 't1' });
const onClose = vi.fn();
render(RemoveTrackPopover, { props: { track, onClose } });
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
// confirm() chains 3-5 awaited invalidateQueries calls before onClose,
// each a separate microtask. waitFor polls until the side effects land.
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(removeTrack).toHaveBeenCalledWith('t1', { unmonitor: false });
});
test('Remove with checkbox checked calls removeTrack with unmonitor=true', async () => {
(removeTrack as ReturnType<typeof vi.fn>).mockResolvedValue({ deleted_track_id: 't1' });
const onClose = vi.fn();
render(RemoveTrackPopover, { props: { track, onClose } });
await fireEvent.click(screen.getByRole('checkbox', { name: /also stop lidarr/i }));
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(removeTrack).toHaveBeenCalledWith('t1', { unmonitor: true });
});
test('failed remove surfaces error via copyForCode and does not close', async () => {
(removeTrack as ReturnType<typeof vi.fn>).mockRejectedValue({ code: 'not_found' });
const onClose = vi.fn();
render(RemoveTrackPopover, { props: { track, onClose } });
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
await waitFor(() => expect(removeTrack).toHaveBeenCalled());
// The catch path runs synchronously after the rejected await; one more
// microtask boundary is enough.
await Promise.resolve();
expect(onClose).not.toHaveBeenCalled();
});
test('success invalidates album, artist and home queries', async () => {
(removeTrack as ReturnType<typeof vi.fn>).mockResolvedValue({
deleted_track_id: 't1',
deleted_album_id: 'a1',
deleted_artist_id: 'ar1'
});
render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } });
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
// Wait for all three baseline invalidations (album, artist, home) plus
// the two cascade ones (deleted_album_id, deleted_artist_id) to land.
await waitFor(() => expect(invalidateMock.mock.calls.length).toBeGreaterThanOrEqual(5));
const keys = invalidateMock.mock.calls.map((c) => c[0].queryKey);
const flat = keys.map((k) => Array.isArray(k) ? k.join('|') : String(k));
expect(flat.some((k) => k.startsWith('album|a1'))).toBe(true);
expect(flat.some((k) => k.startsWith('artist|ar1'))).toBe(true);
expect(flat.some((k) => k === 'home')).toBe(true);
});
});
+26 -43
View File
@@ -1,30 +1,27 @@
<script lang="ts"> <script lang="ts">
import { import {
MoreVertical, MoreVertical,
ListPlus, ListVideo,
Plus, ListMusic,
Heart, Heart,
HeartOff, HeartOff,
ListMusic, ListPlus,
Album, Radio,
Disc3, Disc3,
Flag, User,
EyeOff, EyeOff,
Eye, Eye
Trash2
} from 'lucide-svelte'; } from 'lucide-svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
import FlagPopover from './FlagPopover.svelte'; import FlagPopover from './FlagPopover.svelte';
import RemoveTrackPopover from './RemoveTrackPopover.svelte';
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte'; import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
import TrackMenuItem from './TrackMenuItem.svelte'; import TrackMenuItem from './TrackMenuItem.svelte';
import TrackMenuDivider from './TrackMenuDivider.svelte'; import TrackMenuDivider from './TrackMenuDivider.svelte';
import { user } from '$lib/auth/store.svelte';
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes'; import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes';
import { createMyQuarantineQuery, unflagTrack } from '$lib/api/quarantine'; import { createMyQuarantineQuery, unflagTrack } from '$lib/api/quarantine';
import { qk } from '$lib/api/queries'; import { qk } from '$lib/api/queries';
import { playNext, enqueueTrack } from '$lib/player/store.svelte'; import { playNext, enqueueTrack, playRadio } from '$lib/player/store.svelte';
import type { TrackRef } from '$lib/api/types'; import type { TrackRef } from '$lib/api/types';
let { let {
@@ -44,7 +41,6 @@
let menuOpen = $state(false); let menuOpen = $state(false);
let flagOpen = $state(false); let flagOpen = $state(false);
let removeOpen = $state(false);
let addOpen = $state(false); let addOpen = $state(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -63,8 +59,6 @@
return rows.some((r) => r.track_id === track.id); return rows.some((r) => r.track_id === track.id);
}); });
const isAdmin = $derived(user.value?.is_admin === true);
function toggleMenu(e: MouseEvent) { function toggleMenu(e: MouseEvent) {
e.stopPropagation(); e.stopPropagation();
menuOpen = !menuOpen; menuOpen = !menuOpen;
@@ -73,7 +67,6 @@
function closeAll() { function closeAll() {
menuOpen = false; menuOpen = false;
flagOpen = false; flagOpen = false;
removeOpen = false;
addOpen = false; addOpen = false;
} }
@@ -87,6 +80,11 @@
closeAll(); closeAll();
} }
function onStartRadio() {
playRadio(track.id);
closeAll();
}
async function onToggleLike() { async function onToggleLike() {
closeAll(); closeAll();
if (liked) { if (liked) {
@@ -119,18 +117,8 @@
goto(`/artists/${track.artist_id}`); goto(`/artists/${track.artist_id}`);
} }
function onFlag() {
menuOpen = false;
flagOpen = true;
}
function onRemoveOpen() {
menuOpen = false;
removeOpen = true;
}
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
if (!menuOpen && !flagOpen && !removeOpen && !addOpen) return; if (!menuOpen && !flagOpen && !addOpen) return;
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
closeAll(); closeAll();
@@ -161,9 +149,16 @@
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Escape') menuOpen = false; }} onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Escape') menuOpen = false; }}
> >
<!-- Item set + order mirror Android's canonical TrackActionsSheet so
the kebab reads the same on both clients: queue group → like /
add-to-playlist / start-radio group → navigation group → hide.
"Start radio" is shown even under hideQueueActions (reseeding a
station from the current track is meaningful where play-next /
add-to-queue are not). No single-click destructive action lives
here — track removal is intentionally kept out of the kebab. -->
{#if !hideQueueActions} {#if !hideQueueActions}
<TrackMenuItem icon={ListPlus} label="Play next" onclick={onPlayNext} /> <TrackMenuItem icon={ListVideo} label="Play next" onclick={onPlayNext} />
<TrackMenuItem icon={Plus} label="Add to queue" onclick={onAddToQueue} /> <TrackMenuItem icon={ListMusic} label="Add to queue" onclick={onAddToQueue} />
<TrackMenuDivider /> <TrackMenuDivider />
{/if} {/if}
@@ -174,32 +169,24 @@
onclick={onToggleLike} onclick={onToggleLike}
/> />
<TrackMenuItem <TrackMenuItem
icon={ListMusic} icon={ListPlus}
label="Add to playlist…" label="Add to playlist…"
onclick={() => { addOpen = true; menuOpen = false; }} onclick={() => { addOpen = true; menuOpen = false; }}
/> />
<TrackMenuItem icon={Radio} label="Start radio" onclick={onStartRadio} />
<TrackMenuDivider /> <TrackMenuDivider />
<TrackMenuItem icon={Album} label="Go to album" onclick={onGoToAlbum} /> <TrackMenuItem icon={Disc3} label="Go to album" onclick={onGoToAlbum} />
<TrackMenuItem icon={Disc3} label="Go to artist" onclick={onGoToArtist} /> <TrackMenuItem icon={User} label="Go to artist" onclick={onGoToArtist} />
<TrackMenuDivider /> <TrackMenuDivider />
<TrackMenuItem icon={Flag} label="Flag this track…" onclick={onFlag} />
<TrackMenuItem <TrackMenuItem
icon={hidden ? Eye : EyeOff} icon={hidden ? Eye : EyeOff}
label={hidden ? 'Unhide' : 'Hide'} label={hidden ? 'Unhide' : 'Hide'}
onclick={onToggleHide} onclick={onToggleHide}
/> />
{#if isAdmin}
<TrackMenuItem
icon={Trash2}
label="Remove from library"
onclick={onRemoveOpen}
danger
/>
{/if}
</div> </div>
{/if} {/if}
@@ -207,10 +194,6 @@
<FlagPopover {track} onClose={closeAll} /> <FlagPopover {track} onClose={closeAll} />
{/if} {/if}
{#if removeOpen}
<RemoveTrackPopover {track} onClose={closeAll} />
{/if}
{#if addOpen} {#if addOpen}
<AddToPlaylistMenu tracks={[track]} onClose={() => (addOpen = false)} /> <AddToPlaylistMenu tracks={[track]} onClose={() => (addOpen = false)} />
{/if} {/if}
+15 -18
View File
@@ -25,19 +25,16 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock()); vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
vi.mock('$lib/api/admin/tracks', () => ({
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' })
}));
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock()); vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
vi.mock('$lib/player/store.svelte', () => ({ vi.mock('$lib/player/store.svelte', () => ({
playNext: vi.fn(), playNext: vi.fn(),
enqueueTrack: vi.fn() enqueueTrack: vi.fn(),
playRadio: vi.fn()
})); }));
import TrackMenu from './TrackMenu.svelte'; import TrackMenu from './TrackMenu.svelte';
import { playNext, enqueueTrack } from '$lib/player/store.svelte'; import { playNext, enqueueTrack, playRadio } from '$lib/player/store.svelte';
const track = makeTrack({ title: 'Roygbiv' }); const track = makeTrack({ title: 'Roygbiv' });
@@ -55,7 +52,7 @@ describe('TrackMenu', () => {
expect(kebab.getAttribute('aria-expanded')).toBe('true'); expect(kebab.getAttribute('aria-expanded')).toBe('true');
}); });
test('opening menu shows all 9 entries for an admin user', async () => { test('opening menu shows all 8 entries', async () => {
render(TrackMenu, { props: { track } }); render(TrackMenu, { props: { track } });
await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
@@ -63,19 +60,12 @@ describe('TrackMenu', () => {
expect(screen.getByRole('menuitem', { name: /add to queue/i })).toBeInTheDocument(); expect(screen.getByRole('menuitem', { name: /add to queue/i })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /^like$/i })).toBeInTheDocument(); expect(screen.getByRole('menuitem', { name: /^like$/i })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /add to playlist/i })).toBeInTheDocument(); expect(screen.getByRole('menuitem', { name: /add to playlist/i })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /start radio/i })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /go to album/i })).toBeInTheDocument(); expect(screen.getByRole('menuitem', { name: /go to album/i })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /go to artist/i })).toBeInTheDocument(); expect(screen.getByRole('menuitem', { name: /go to artist/i })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /flag this track/i })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /^hide$/i })).toBeInTheDocument(); expect(screen.getByRole('menuitem', { name: /^hide$/i })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /remove from library/i })).toBeInTheDocument();
expect(screen.getAllByRole('menuitem')).toHaveLength(9); // No "Remove from library" — destructive removal is intentionally absent.
});
test('"Remove from library" hidden for non-admin', async () => {
userState.current = { id: 'u2', username: 'normal', is_admin: false };
render(TrackMenu, { props: { track } });
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
expect(screen.queryByRole('menuitem', { name: /remove from library/i })).not.toBeInTheDocument(); expect(screen.queryByRole('menuitem', { name: /remove from library/i })).not.toBeInTheDocument();
expect(screen.getAllByRole('menuitem')).toHaveLength(8); expect(screen.getAllByRole('menuitem')).toHaveLength(8);
}); });
@@ -113,10 +103,17 @@ describe('TrackMenu', () => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument(); expect(screen.queryByRole('menu')).not.toBeInTheDocument();
}); });
test('clicking "Flag this track…" opens FlagPopover and closes menu', async () => { test('"Start radio" dispatches playRadio(track.id)', async () => {
render(TrackMenu, { props: { track } }); render(TrackMenu, { props: { track } });
await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
await fireEvent.click(screen.getByRole('menuitem', { name: /flag this track/i })); await fireEvent.click(screen.getByRole('menuitem', { name: /start radio/i }));
expect(playRadio).toHaveBeenCalledWith(track.id);
});
test('clicking "Hide" opens the flag/quarantine popover and closes menu', async () => {
render(TrackMenu, { props: { track } });
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
await fireEvent.click(screen.getByRole('menuitem', { name: /^hide$/i }));
expect(screen.queryByRole('menu')).not.toBeInTheDocument(); expect(screen.queryByRole('menu')).not.toBeInTheDocument();
expect( expect(
screen.getByRole('dialog', { name: /flag this track as broken/i }) screen.getByRole('dialog', { name: /flag this track as broken/i })
+10
View File
@@ -16,6 +16,7 @@
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers'; import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte'; import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
import LikeButton from '$lib/components/LikeButton.svelte'; import LikeButton from '$lib/components/LikeButton.svelte';
import TrackMenu from '$lib/components/TrackMenu.svelte';
import QueueList from '$lib/components/QueueList.svelte'; import QueueList from '$lib/components/QueueList.svelte';
import { pageTitle } from '$lib/branding'; import { pageTitle } from '$lib/branding';
@@ -78,6 +79,15 @@
<ArrowLeft size={22} strokeWidth={1.5} /> <ArrowLeft size={22} strokeWidth={1.5} />
</button> </button>
<div class="ml-2 text-xs uppercase tracking-wide text-text-secondary">Now playing</div> <div class="ml-2 text-xs uppercase tracking-wide text-text-secondary">Now playing</div>
<!-- Full-screen Now Playing carries the same kebab as Android's
NowPlayingScreen (hideQueueActions — the menu's track IS the one
playing), so Start radio / Add to playlist / Hide etc. are reachable
here, not just from the mini-player bar. -->
{#if current}
<div class="ml-auto">
<TrackMenu track={current} hideQueueActions />
</div>
{/if}
</header> </header>
{#if !current} {#if !current}
@@ -41,7 +41,6 @@ vi.mock('$lib/api/likes', () => ({
unlikeEntity: vi.fn() unlikeEntity: vi.fn()
})); }));
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock()); vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn() }));
import PlaylistDetailPage from './+page.svelte'; import PlaylistDetailPage from './+page.svelte';
import { createPlaylistQuery, deletePlaylist, refreshSystem } from '$lib/api/playlists'; import { createPlaylistQuery, deletePlaylist, refreshSystem } from '$lib/api/playlists';