Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a251dce7e3 | |||
| 97e0e88483 | |||
| 938dae7163 | |||
| ad37937949 | |||
| 93365cb555 | |||
| 2c5c477a0d | |||
| eeabdf1f2c | |||
| f7278f2417 | |||
| 9a166f9032 | |||
| b3d6785543 | |||
| 773275e916 | |||
| 066616e196 |
@@ -2,8 +2,8 @@ name: release
|
||||
|
||||
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||
#
|
||||
# push to main → :main and :latest (no APK bundled)
|
||||
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (APK bundled)
|
||||
# push to main → :main and :latest (latest-release 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)
|
||||
#
|
||||
# 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
|
||||
# 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)
|
||||
# lives in android.yml and runs independently on every push.
|
||||
|
||||
@@ -181,6 +189,12 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
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
|
||||
id: guard
|
||||
@@ -221,9 +235,8 @@ jobs:
|
||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Download signed APK artifact
|
||||
# Tag pushes only — android-release just produced this. Main
|
||||
# pushes skip and the image ships with empty client/ (the
|
||||
# /api/client/version endpoint then returns 404 by design).
|
||||
# Tag pushes only — android-release just produced this. Non-tag
|
||||
# builds take the "Bundle latest release APK" path below instead.
|
||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
@@ -247,6 +260,42 @@ jobs:
|
||||
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
|
||||
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
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
run: |
|
||||
|
||||
@@ -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.
|
||||
|
||||
<!-- 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
|
||||
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
@@ -24,10 +24,18 @@ services:
|
||||
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
||||
ports: ['4533:4533']
|
||||
volumes:
|
||||
# Your music library. Point ./music at wherever your audio files
|
||||
# live. Mounted read-only — Minstrel never writes to your library.
|
||||
- ./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:
|
||||
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
|
||||
depends_on: [db]
|
||||
|
||||
@@ -37,6 +45,8 @@ services:
|
||||
POSTGRES_USER: minstrel
|
||||
POSTGRES_PASSWORD: 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:
|
||||
@@ -48,7 +58,29 @@ volumes:
|
||||
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 "Install the Android app" 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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
- `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`).
|
||||
|
||||
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
|
||||
|
||||
- `:main` — rolling, follows the dev branch's tested tip. Recommended only for the operator who's running an upstream-watching deployment.
|
||||
- `:v1.0.x` — pinned releases. Recommended default. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
||||
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
|
||||
|
||||
- `: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
|
||||
|
||||
|
||||
+18
-8
@@ -6,17 +6,27 @@ in-app update flow (#397).
|
||||
|
||||
## Production
|
||||
|
||||
CI populates this directory on tag releases:
|
||||
1. `flutter.yml` builds `app-release.apk` and attaches it to the
|
||||
Gitea release as `minstrel-<TAG>.apk`.
|
||||
2. `release.yml` waits for that asset to appear, downloads it into
|
||||
this directory as `minstrel.apk`, writes the tag string to
|
||||
`minstrel.apk.version`, and `docker buildx build` includes both
|
||||
via `COPY client/ /app/client/`.
|
||||
CI populates this directory in `release.yml` (no Flutter — the client is
|
||||
native Android, built by the `android-release` job):
|
||||
|
||||
- **Tag release (`v*`):** the `android-release` job builds + signs the
|
||||
APK, uploads it as a workflow artifact, and attaches it to the Gitea
|
||||
release as `minstrel-<TAG>.apk`. The `image-release` job
|
||||
(`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
|
||||
|
||||
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).
|
||||
|
||||
To smoke-test the update flow locally, drop a real APK + a version
|
||||
|
||||
+7
-2
@@ -26,8 +26,13 @@ library:
|
||||
# Filesystem roots to scan for music. Each path is walked recursively.
|
||||
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
|
||||
scan_paths: []
|
||||
# Kick off a scan on server startup. Env: MINSTREL_LIBRARY_SCAN_ON_STARTUP
|
||||
scan_on_startup: false
|
||||
# Kick off a scan on server startup. On by default so a fresh install
|
||||
# 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
|
||||
# Jukebox", "Office Music", etc.) and override the OG share-preview
|
||||
|
||||
@@ -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
|
||||
`` 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 |
@@ -135,6 +135,11 @@ func Default() Config {
|
||||
RadioSizeMax: 200,
|
||||
},
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -97,7 +97,8 @@ func TestEnvOverrides(t *testing.T) {
|
||||
func TestLibraryEnvOverrides(t *testing.T) {
|
||||
stubStreamSecret(t)
|
||||
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("")
|
||||
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)
|
||||
}
|
||||
}
|
||||
if !cfg.Library.ScanOnStartup {
|
||||
t.Error("scan_on_startup = false, want true")
|
||||
if cfg.Library.ScanOnStartup {
|
||||
t.Error("scan_on_startup = true, want false (env override of the true default)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Plus } from 'lucide-svelte';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
||||
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
||||
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 +
|
||||
// artist on the right. Mirrors Android's CompactTrackTile so the
|
||||
@@ -42,7 +43,7 @@
|
||||
type="button"
|
||||
aria-label={`Play ${track.title}`}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
<CardActionCluster
|
||||
likeEntityType="track"
|
||||
likeEntityId={track.id}
|
||||
onAdd={onAddToQueue}
|
||||
addLabel={`Add ${track.title} to queue`}
|
||||
<!-- Single inline cluster, vertically centred on the right. The shared
|
||||
CardActionCluster splits Like+Add (top) and the menu (bottom) into
|
||||
opposite corners — correct for the tall square Album/Artist cards but
|
||||
it makes the two groups collide on this short one-line row. A compact
|
||||
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()}
|
||||
<TrackMenu {track} />
|
||||
{/snippet}
|
||||
</CardActionCluster>
|
||||
<LikeButton entityType="track" entityId={track.id} size="sm" />
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
|
||||
@@ -10,10 +10,6 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
|
||||
|
||||
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/auth/store.svelte', () => ({
|
||||
|
||||
@@ -272,8 +272,12 @@
|
||||
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"
|
||||
>
|
||||
<!-- Left: cover + title + artist + like + menu -->
|
||||
<div class="flex w-72 min-w-0 items-center gap-3">
|
||||
<!-- Left: cover + title + artist + like + menu.
|
||||
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
|
||||
href="/now-playing"
|
||||
aria-label="Open now playing"
|
||||
@@ -318,7 +322,10 @@
|
||||
</button>
|
||||
</div>
|
||||
{: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 -->
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<button
|
||||
|
||||
@@ -21,10 +21,6 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
|
||||
|
||||
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', () => ({
|
||||
playNext: 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);
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,27 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
MoreVertical,
|
||||
ListPlus,
|
||||
Plus,
|
||||
ListVideo,
|
||||
ListMusic,
|
||||
Heart,
|
||||
HeartOff,
|
||||
ListMusic,
|
||||
Album,
|
||||
ListPlus,
|
||||
Radio,
|
||||
Disc3,
|
||||
Flag,
|
||||
User,
|
||||
EyeOff,
|
||||
Eye,
|
||||
Trash2
|
||||
Eye
|
||||
} from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import FlagPopover from './FlagPopover.svelte';
|
||||
import RemoveTrackPopover from './RemoveTrackPopover.svelte';
|
||||
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
||||
import TrackMenuItem from './TrackMenuItem.svelte';
|
||||
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
||||
import { user } from '$lib/auth/store.svelte';
|
||||
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes';
|
||||
import { createMyQuarantineQuery, unflagTrack } from '$lib/api/quarantine';
|
||||
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';
|
||||
|
||||
let {
|
||||
@@ -44,7 +41,6 @@
|
||||
|
||||
let menuOpen = $state(false);
|
||||
let flagOpen = $state(false);
|
||||
let removeOpen = $state(false);
|
||||
let addOpen = $state(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
@@ -63,8 +59,6 @@
|
||||
return rows.some((r) => r.track_id === track.id);
|
||||
});
|
||||
|
||||
const isAdmin = $derived(user.value?.is_admin === true);
|
||||
|
||||
function toggleMenu(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
menuOpen = !menuOpen;
|
||||
@@ -73,7 +67,6 @@
|
||||
function closeAll() {
|
||||
menuOpen = false;
|
||||
flagOpen = false;
|
||||
removeOpen = false;
|
||||
addOpen = false;
|
||||
}
|
||||
|
||||
@@ -87,6 +80,11 @@
|
||||
closeAll();
|
||||
}
|
||||
|
||||
function onStartRadio() {
|
||||
playRadio(track.id);
|
||||
closeAll();
|
||||
}
|
||||
|
||||
async function onToggleLike() {
|
||||
closeAll();
|
||||
if (liked) {
|
||||
@@ -119,18 +117,8 @@
|
||||
goto(`/artists/${track.artist_id}`);
|
||||
}
|
||||
|
||||
function onFlag() {
|
||||
menuOpen = false;
|
||||
flagOpen = true;
|
||||
}
|
||||
|
||||
function onRemoveOpen() {
|
||||
menuOpen = false;
|
||||
removeOpen = true;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (!menuOpen && !flagOpen && !removeOpen && !addOpen) return;
|
||||
if (!menuOpen && !flagOpen && !addOpen) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAll();
|
||||
@@ -161,9 +149,16 @@
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
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}
|
||||
<TrackMenuItem icon={ListPlus} label="Play next" onclick={onPlayNext} />
|
||||
<TrackMenuItem icon={Plus} label="Add to queue" onclick={onAddToQueue} />
|
||||
<TrackMenuItem icon={ListVideo} label="Play next" onclick={onPlayNext} />
|
||||
<TrackMenuItem icon={ListMusic} label="Add to queue" onclick={onAddToQueue} />
|
||||
|
||||
<TrackMenuDivider />
|
||||
{/if}
|
||||
@@ -174,32 +169,24 @@
|
||||
onclick={onToggleLike}
|
||||
/>
|
||||
<TrackMenuItem
|
||||
icon={ListMusic}
|
||||
icon={ListPlus}
|
||||
label="Add to playlist…"
|
||||
onclick={() => { addOpen = true; menuOpen = false; }}
|
||||
/>
|
||||
<TrackMenuItem icon={Radio} label="Start radio" onclick={onStartRadio} />
|
||||
|
||||
<TrackMenuDivider />
|
||||
|
||||
<TrackMenuItem icon={Album} label="Go to album" onclick={onGoToAlbum} />
|
||||
<TrackMenuItem icon={Disc3} label="Go to artist" onclick={onGoToArtist} />
|
||||
<TrackMenuItem icon={Disc3} label="Go to album" onclick={onGoToAlbum} />
|
||||
<TrackMenuItem icon={User} label="Go to artist" onclick={onGoToArtist} />
|
||||
|
||||
<TrackMenuDivider />
|
||||
|
||||
<TrackMenuItem icon={Flag} label="Flag this track…" onclick={onFlag} />
|
||||
<TrackMenuItem
|
||||
icon={hidden ? Eye : EyeOff}
|
||||
label={hidden ? 'Unhide' : 'Hide'}
|
||||
onclick={onToggleHide}
|
||||
/>
|
||||
{#if isAdmin}
|
||||
<TrackMenuItem
|
||||
icon={Trash2}
|
||||
label="Remove from library"
|
||||
onclick={onRemoveOpen}
|
||||
danger
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -207,10 +194,6 @@
|
||||
<FlagPopover {track} onClose={closeAll} />
|
||||
{/if}
|
||||
|
||||
{#if removeOpen}
|
||||
<RemoveTrackPopover {track} onClose={closeAll} />
|
||||
{/if}
|
||||
|
||||
{#if addOpen}
|
||||
<AddToPlaylistMenu tracks={[track]} onClose={() => (addOpen = false)} />
|
||||
{/if}
|
||||
|
||||
@@ -25,19 +25,16 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
|
||||
|
||||
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/player/store.svelte', () => ({
|
||||
playNext: vi.fn(),
|
||||
enqueueTrack: vi.fn()
|
||||
enqueueTrack: vi.fn(),
|
||||
playRadio: vi.fn()
|
||||
}));
|
||||
|
||||
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' });
|
||||
|
||||
@@ -55,7 +52,7 @@ describe('TrackMenu', () => {
|
||||
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 } });
|
||||
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: /^like$/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 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: /remove from library/i })).toBeInTheDocument();
|
||||
|
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(9);
|
||||
});
|
||||
|
||||
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 }));
|
||||
// No "Remove from library" — destructive removal is intentionally absent.
|
||||
expect(screen.queryByRole('menuitem', { name: /remove from library/i })).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(8);
|
||||
});
|
||||
@@ -113,10 +103,17 @@ describe('TrackMenu', () => {
|
||||
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 } });
|
||||
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.getByRole('dialog', { name: /flag this track as broken/i })
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
||||
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
|
||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||
import TrackMenu from '$lib/components/TrackMenu.svelte';
|
||||
import QueueList from '$lib/components/QueueList.svelte';
|
||||
import { pageTitle } from '$lib/branding';
|
||||
|
||||
@@ -78,6 +79,15 @@
|
||||
<ArrowLeft size={22} strokeWidth={1.5} />
|
||||
</button>
|
||||
<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>
|
||||
|
||||
{#if !current}
|
||||
|
||||
@@ -41,7 +41,6 @@ vi.mock('$lib/api/likes', () => ({
|
||||
unlikeEntity: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
||||
vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn() }));
|
||||
|
||||
import PlaylistDetailPage from './+page.svelte';
|
||||
import { createPlaylistQuery, deletePlaylist, refreshSystem } from '$lib/api/playlists';
|
||||
|
||||
Reference in New Issue
Block a user