Merge pull request 'feat: M2 likes — full track/album/artist starring (closes M2)' (#20) from dev into main
This commit was merged in pull request #20.
This commit is contained in:
Executable
BIN
Binary file not shown.
@@ -0,0 +1,954 @@
|
||||
# Web UI Scaffold Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Scaffold a SvelteKit SPA at `web/`, embed its build output into the Go binary, and update the Dockerfile + CI so a single container image serves the SPA at `/` alongside the existing `/api/*` and `/rest/*` surfaces.
|
||||
|
||||
**Architecture:** SvelteKit with `@sveltejs/adapter-static` (SPA fallback to `index.html`) builds to `web/build/`. A thin `web` Go package `//go:embed`s that directory and returns an `http.Handler` that serves static assets and falls back to `index.html` for unknown paths. The handler is wired as the server's `NotFound` route so explicit routes (`/api`, `/rest`, `/healthz`) are unaffected; the `NotFound` wrapper also returns a JSON 404 for unmatched `/api/*` and `/rest/*` paths so the SPA fallback never produces HTML bodies for API consumers. A hand-crafted placeholder `web/build/index.html` is committed (via negated gitignore) so `go build` works in clean checkouts before anyone runs `npm run build`.
|
||||
|
||||
**Tech Stack:** SvelteKit 2.x, Svelte 5, TypeScript 5, Vite 5, Tailwind CSS 3, Vitest, Go 1.23, chi/v5, Docker multi-stage builds, Forgejo Actions.
|
||||
|
||||
**Reference spec:** `docs/superpowers/specs/2026-04-20-web-ui-scaffold-design.md`. This plan implements only the Stack / Packaging / Dev-workflow / Build sections. Feature UI (login, library views, player) is out of scope and lands in follow-up plans.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Action | Purpose |
|
||||
|---|---|---|
|
||||
| `web/package.json` | Create | npm config + scripts |
|
||||
| `web/tsconfig.json` | Create | TypeScript config extending SvelteKit's generated one |
|
||||
| `web/svelte.config.js` | Create | `adapter-static` with `fallback: 'index.html'` |
|
||||
| `web/vite.config.ts` | Create | Vite config + dev proxy for `/api` and `/rest` |
|
||||
| `web/vitest.config.ts` | Create | Vitest + jsdom config |
|
||||
| `web/tailwind.config.js` | Create | Tailwind content globs + dark palette tokens |
|
||||
| `web/postcss.config.cjs` | Create | PostCSS pipeline (tailwindcss + autoprefixer) |
|
||||
| `web/src/app.html` | Create | HTML template |
|
||||
| `web/src/app.css` | Create | Tailwind directives + CSS custom properties |
|
||||
| `web/src/routes/+layout.svelte` | Create | Imports `app.css`, renders outlet |
|
||||
| `web/src/routes/+page.svelte` | Create | Placeholder landing page |
|
||||
| `web/src/lib/example.test.ts` | Create | Vitest smoke test |
|
||||
| `web/static/favicon.png` | Create | 1×1 transparent PNG placeholder |
|
||||
| `web/build/index.html` | Create | Hand-crafted placeholder committed so `go:embed` works pre-build |
|
||||
| `web/.gitignore` | Create | Ignore `node_modules/`, `.svelte-kit/`, `build/*` with `!build/index.html` |
|
||||
| `web/embed.go` | Create | `//go:embed` build tree + `Handler()` with SPA fallback (must live next to `web/build/` — `go:embed` cannot reach parent directories) |
|
||||
| `web/embed_test.go` | Create | Unit tests for the handler |
|
||||
| `internal/server/server.go` | Modify | Wire `web.Handler()` into `NotFound`; JSON-404 for `/api/*` and `/rest/*` |
|
||||
| `internal/server/server_test.go` | Modify | Assert `/` returns HTML; `/api/anything` does not return HTML |
|
||||
| `Dockerfile` | Rewrite | Three-stage build: `node` → `golang` → `debian:slim` |
|
||||
| `.forgejo/workflows/test.yml` | Modify | Install Node, build web, run `npm test` before Go steps |
|
||||
| `README.md` | Modify | Document two-process dev workflow |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: SvelteKit project skeleton
|
||||
|
||||
**Files:**
|
||||
- Create: `web/package.json`, `web/tsconfig.json`, `web/svelte.config.js`, `web/vite.config.ts`, `web/tailwind.config.js`, `web/postcss.config.cjs`
|
||||
- Create: `web/src/app.html`, `web/src/app.css`, `web/src/routes/+layout.svelte`, `web/src/routes/+page.svelte`
|
||||
- Create: `web/.gitignore`, `web/static/favicon.png`, `web/build/index.html`
|
||||
|
||||
- [ ] **Step 1: Create `web/package.json`**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "minstrel-web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "svelte-kit sync && vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.8.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.4.49",
|
||||
"svelte": "^5.1.9",
|
||||
"svelte-check": "^4.0.5",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"tslib": "^2.8.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vitest": "^2.1.4"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `web/tsconfig.json`**
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create `web/svelte.config.js`**
|
||||
|
||||
```js
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: 'index.html',
|
||||
precompress: false,
|
||||
strict: false
|
||||
}),
|
||||
files: {
|
||||
assets: 'static'
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create `web/vite.config.ts`**
|
||||
|
||||
```ts
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:4533',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/rest': {
|
||||
target: 'http://localhost:4533',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Create `web/tailwind.config.js`**
|
||||
|
||||
```js
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,ts,svelte}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
surface: {
|
||||
900: '#14161a',
|
||||
800: '#1a1d22',
|
||||
700: '#2a2f36'
|
||||
},
|
||||
text: {
|
||||
primary: '#e8ecf2',
|
||||
secondary: '#cdd3db'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Create `web/postcss.config.cjs`**
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Create `web/src/app.html`**
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Minstrel</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body class="bg-surface-900 text-text-primary">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Create `web/src/app.css`**
|
||||
|
||||
```css
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Create `web/src/routes/+layout.svelte`**
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Create `web/src/routes/+page.svelte`**
|
||||
|
||||
```svelte
|
||||
<main class="flex h-screen items-center justify-center">
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl font-semibold">Minstrel</h1>
|
||||
<p class="mt-2 text-text-secondary">
|
||||
Scaffold — UI features land in subsequent plans.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
```
|
||||
|
||||
- [ ] **Step 11: Create `web/.gitignore`**
|
||||
|
||||
```
|
||||
node_modules/
|
||||
.svelte-kit/
|
||||
build/*
|
||||
!build/index.html
|
||||
```
|
||||
|
||||
- [ ] **Step 12: Create placeholder `web/static/favicon.png`**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
mkdir -p web/static
|
||||
python3 -c "import base64,sys; sys.stdout.buffer.write(base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='))" > web/static/favicon.png
|
||||
```
|
||||
|
||||
Expected: `web/static/favicon.png` is a valid 1×1 PNG (67 bytes).
|
||||
|
||||
- [ ] **Step 13: Create placeholder `web/build/index.html`**
|
||||
|
||||
This placeholder lets `//go:embed build` compile before anyone runs `npm run build`. Real `npm run build` overwrites it locally; only this hand-written version is committed.
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Minstrel</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>
|
||||
Minstrel SPA has not been built. Run
|
||||
<code>cd web && npm install && npm run build</code> or
|
||||
build the container image, which runs the web build during
|
||||
<code>docker build</code>.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
- [ ] **Step 14: Install dependencies**
|
||||
|
||||
Run: `cd web && npm install`
|
||||
Expected: `node_modules/` populated, `package-lock.json` written, exit 0.
|
||||
|
||||
- [ ] **Step 15: Verify SvelteKit sync + build**
|
||||
|
||||
Run: `cd web && npm run build`
|
||||
Expected: exit 0; `web/build/` now contains `index.html`, `_app/`, `favicon.png`. (Local `index.html` is overwritten but not committed — `.gitignore` keeps only the committed placeholder under version control.)
|
||||
|
||||
Undo the local overwrite so the committed placeholder remains:
|
||||
|
||||
Run: `git checkout -- web/build/index.html`
|
||||
Expected: placeholder restored.
|
||||
|
||||
- [ ] **Step 16: Commit**
|
||||
|
||||
```bash
|
||||
git add web/.gitignore web/package.json web/package-lock.json web/tsconfig.json \
|
||||
web/svelte.config.js web/vite.config.ts web/tailwind.config.js web/postcss.config.cjs \
|
||||
web/src/ web/static/ web/build/index.html
|
||||
git commit -m "feat(web): scaffold SvelteKit SPA with Tailwind + adapter-static
|
||||
|
||||
- SvelteKit 2.x + Svelte 5 + TypeScript + Vite 5
|
||||
- adapter-static with fallback:'index.html' for SPA deep-link routing
|
||||
- Tailwind configured with dark-only palette matching spec
|
||||
- Placeholder landing page at /
|
||||
- Committed web/build/index.html placeholder (via .gitignore negation) so
|
||||
go:embed works before the SvelteKit build has been run"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Vitest smoke test
|
||||
|
||||
**Files:**
|
||||
- Create: `web/vitest.config.ts`
|
||||
- Create: `web/src/lib/example.test.ts`
|
||||
|
||||
- [ ] **Step 1: Create `web/vitest.config.ts`**
|
||||
|
||||
```ts
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.test.ts']
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write failing smoke test**
|
||||
|
||||
Create `web/src/lib/example.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('scaffold smoke', () => {
|
||||
it('runs vitest', () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests**
|
||||
|
||||
Run: `cd web && npm test`
|
||||
Expected: 1 test passes. Exit 0.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web/vitest.config.ts web/src/lib/example.test.ts
|
||||
git commit -m "test(web): add Vitest smoke test and jsdom config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Go embed package + SPA fallback handler
|
||||
|
||||
**Files:**
|
||||
- Create: `web/embed.go`
|
||||
- Create: `web/embed_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `web/embed_test.go`:
|
||||
|
||||
```go
|
||||
package web
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandler_ServesIndexAtRoot(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q, want text/html*", ct)
|
||||
}
|
||||
body, _ := io.ReadAll(rec.Body)
|
||||
if !strings.Contains(strings.ToLower(string(body)), "<html") {
|
||||
t.Errorf("body missing <html tag; got: %q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_UnknownPathFallsBackToIndex(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/artists/some-uuid", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (SPA fallback)", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q, want text/html* for SPA fallback", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_MissingAssetFallsBackToIndex(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/_app/immutable/chunks/does-not-exist.js", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (fallback to index.html)", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q, want text/html* for missing asset", ct)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `go test ./web/ -v`
|
||||
Expected: FAIL — `Handler` undefined / package missing.
|
||||
|
||||
- [ ] **Step 3: Implement `web/embed.go`**
|
||||
|
||||
```go
|
||||
// Package web embeds the SvelteKit SPA build output and serves it over HTTP.
|
||||
// Requests for real files under build/ are served verbatim; anything else —
|
||||
// deep links like /artists/abc, missing asset paths — returns build/index.html
|
||||
// so the SPA can resolve the route client-side.
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed all:build
|
||||
var buildFS embed.FS
|
||||
|
||||
// Handler returns an http.Handler that serves the embedded SPA.
|
||||
//
|
||||
// Behavior:
|
||||
// - GET / -> build/index.html
|
||||
// - GET /<path that exists in build/> -> that file, via http.FileServer
|
||||
// - GET /<anything else> -> build/index.html (SPA fallback)
|
||||
//
|
||||
// Callers are expected to register this as the router's NotFound/catch-all so
|
||||
// explicitly-routed paths (like /api/* and /rest/*) take precedence.
|
||||
func Handler() http.Handler {
|
||||
sub, err := fs.Sub(buildFS, "build")
|
||||
if err != nil {
|
||||
// fs.Sub on a valid embed path can only fail if the embed directive
|
||||
// is malformed, which the compile would have caught.
|
||||
panic("web: fs.Sub(build) failed: " + err.Error())
|
||||
}
|
||||
|
||||
index, err := fs.ReadFile(sub, "index.html")
|
||||
if err != nil {
|
||||
panic("web: embedded build/index.html missing: " + err.Error())
|
||||
}
|
||||
|
||||
fileServer := http.FileServer(http.FS(sub))
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
clean := path.Clean(r.URL.Path)
|
||||
if clean == "/" || clean == "." {
|
||||
serveIndex(w, index)
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(clean, "/")
|
||||
if info, err := fs.Stat(sub, name); err == nil && !info.IsDir() {
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
serveIndex(w, index)
|
||||
})
|
||||
}
|
||||
|
||||
func serveIndex(w http.ResponseWriter, index []byte) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
_, _ = w.Write(index)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests — expect PASS**
|
||||
|
||||
Run: `go test ./web/ -v`
|
||||
Expected: `TestHandler_ServesIndexAtRoot`, `TestHandler_UnknownPathFallsBackToIndex`, `TestHandler_MissingAssetFallsBackToIndex` all PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web/
|
||||
git commit -m "feat(web): embed SvelteKit build output with SPA fallback handler"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Wire web handler into server router
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/server/server.go`
|
||||
- Modify: `internal/server/server_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Add to `internal/server/server_test.go` (also add `"strings"` to the imports):
|
||||
|
||||
```go
|
||||
func TestRouter_ServesSPAAtRoot(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL + "/")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q, want text/html*", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL + "/artists/00000000-0000-0000-0000-000000000001")
|
||||
if err != nil {
|
||||
t.Fatalf("GET deep link: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q, want text/html*", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL + "/api/does-not-exist")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /api/does-not-exist: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q; /api/* must never return text/html", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL + "/rest/does-not-exist")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /rest/does-not-exist: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q; /rest/* must never return text/html", ct)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `go test ./internal/server/ -v`
|
||||
Expected: FAIL on all four new tests (chi returns its own 404 with empty/plain body for unmatched routes).
|
||||
|
||||
- [ ] **Step 3: Wire `web.Handler()` into `Router()`**
|
||||
|
||||
Modify `internal/server/server.go`:
|
||||
|
||||
a) Add imports (preserving alphabetical order within the group):
|
||||
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/api"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/web"
|
||||
)
|
||||
```
|
||||
|
||||
b) Replace the `Router()` method with:
|
||||
|
||||
```go
|
||||
func (s *Server) Router() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
r.Get("/healthz", s.handleHealthz)
|
||||
|
||||
if s.Pool != nil {
|
||||
api.Mount(r, s.Pool, s.Logger)
|
||||
r.Route("/api/admin", func(admin chi.Router) {
|
||||
admin.Use(auth.RequireAdmin(s.Pool))
|
||||
if s.Scanner != nil {
|
||||
admin.Post("/scan", s.handleAdminScan)
|
||||
}
|
||||
})
|
||||
subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg)
|
||||
}
|
||||
|
||||
spa := web.Handler()
|
||||
r.NotFound(func(w http.ResponseWriter, req *http.Request) {
|
||||
p := req.URL.Path
|
||||
if strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/rest/") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"not found"}}`))
|
||||
return
|
||||
}
|
||||
spa.ServeHTTP(w, req)
|
||||
})
|
||||
return r
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests — expect PASS**
|
||||
|
||||
Run: `go test ./internal/server/ -v`
|
||||
Expected: all tests PASS, including the existing `TestHealthz` and the four new ones.
|
||||
|
||||
- [ ] **Step 5: Run full test suite**
|
||||
|
||||
Run: `go test -short -race ./...`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Run linter**
|
||||
|
||||
Run: `golangci-lint run ./...`
|
||||
Expected: no issues.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/server/
|
||||
git commit -m "feat(server): serve embedded SPA at root with /api,/rest carve-out
|
||||
|
||||
- NotFound wrapper routes unknown /api/* and /rest/* to a JSON 404
|
||||
- Everything else falls through to the embedded web handler, which
|
||||
resolves static assets or returns index.html for SPA deep links"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Multi-stage Dockerfile
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `Dockerfile`
|
||||
|
||||
- [ ] **Step 1: Replace `Dockerfile` contents**
|
||||
|
||||
```dockerfile
|
||||
# syntax=docker/dockerfile:1.6
|
||||
|
||||
FROM node:22-bookworm-slim AS web
|
||||
WORKDIR /web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM golang:1.23-bookworm AS builder
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
# Overwrite the committed placeholder with the freshly-built SPA assets.
|
||||
COPY --from=web /web/build ./web/build
|
||||
ENV CGO_ENABLED=0
|
||||
RUN go build -trimpath -ldflags="-s -w" -o /out/minstrel ./cmd/minstrel
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd --system --gid 1000 minstrel \
|
||||
&& useradd --system --uid 1000 --gid minstrel --shell /usr/sbin/nologin minstrel
|
||||
|
||||
COPY --from=builder /out/minstrel /usr/local/bin/minstrel
|
||||
COPY config.example.yaml /etc/smartmusic/config.yaml
|
||||
|
||||
USER minstrel
|
||||
EXPOSE 4533
|
||||
ENTRYPOINT ["/usr/local/bin/minstrel"]
|
||||
CMD ["--config", "/etc/smartmusic/config.yaml"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build image locally**
|
||||
|
||||
Run: `docker build -t minstrel:scaffold-smoke .`
|
||||
Expected: all three stages succeed. The final layer is `FROM debian:bookworm-slim`, the binary exists at `/usr/local/bin/minstrel`.
|
||||
|
||||
- [ ] **Step 3: Inspect image for SPA assets**
|
||||
|
||||
Run: `docker run --rm --entrypoint /bin/sh minstrel:scaffold-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'`
|
||||
Expected: output `ok` (verifies the binary was copied in and is executable).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add Dockerfile
|
||||
git commit -m "build: multi-stage Dockerfile with node->go->slim for embedded SPA
|
||||
|
||||
Web assets are built in the node stage, copied into the go stage before
|
||||
go build so //go:embed picks them up, then the minimal slim runtime
|
||||
carries only the final binary and ffmpeg."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: CI — install Node and build web before Go
|
||||
|
||||
**Files:**
|
||||
- Modify: `.forgejo/workflows/test.yml`
|
||||
|
||||
- [ ] **Step 1: Replace `.forgejo/workflows/test.yml` contents**
|
||||
|
||||
```yaml
|
||||
name: test
|
||||
|
||||
# Lint + short unit tests. Integration tests needing Postgres/ffmpeg run
|
||||
# locally via docker-compose; they should guard with testing.Short().
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: go-ci
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Install web deps
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Web build (populates web/build/ for go:embed)
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Web test (vitest)
|
||||
working-directory: web
|
||||
run: npm test
|
||||
|
||||
- name: Detect Go project
|
||||
id: gomod
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f go.mod ]; then
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::No go.mod yet — Go steps will be skipped until the skeleton lands"
|
||||
fi
|
||||
|
||||
- name: Toolchain versions
|
||||
if: steps.gomod.outputs.present == 'true'
|
||||
run: |
|
||||
go version
|
||||
golangci-lint --version
|
||||
|
||||
- name: go vet
|
||||
if: steps.gomod.outputs.present == 'true'
|
||||
run: go vet ./...
|
||||
|
||||
- name: golangci-lint
|
||||
if: steps.gomod.outputs.present == 'true'
|
||||
run: golangci-lint run ./...
|
||||
|
||||
- name: go test (short, race)
|
||||
if: steps.gomod.outputs.present == 'true'
|
||||
run: go test -short -race ./...
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add .forgejo/workflows/test.yml
|
||||
git commit -m "ci: install Node + build web before Go steps
|
||||
|
||||
Go's //go:embed needs web/build/ to exist at compile time. CI now
|
||||
runs 'npm ci && npm run build && npm test' ahead of the Go steps
|
||||
so the embed directive sees real, freshly-built assets."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: README dev-workflow section
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] **Step 1: Read current README**
|
||||
|
||||
Run: `cat README.md`
|
||||
Note the existing structure (headings, sections).
|
||||
|
||||
- [ ] **Step 2: Append or replace a "Development" section**
|
||||
|
||||
Use the Edit tool to insert the following section. Place it after any existing "Getting started" or "Quickstart" section; if none exists, append to the end of the file.
|
||||
|
||||
```markdown
|
||||
## Development
|
||||
|
||||
Minstrel has two concurrent dev processes:
|
||||
|
||||
1. **Backend:** `docker compose up` — starts Postgres and Minstrel on `:4533`.
|
||||
2. **Frontend:** `cd web && npm install && npm run dev` — Vite dev server on `:5173` with HMR.
|
||||
|
||||
The Vite dev server proxies `/api/*` and `/rest/*` to the backend on `:4533`,
|
||||
so session cookies work correctly when the SPA is loaded from the Vite origin.
|
||||
|
||||
### Production build
|
||||
|
||||
`docker build -t minstrel .` runs the SvelteKit build inside a `node` stage,
|
||||
copies the output into the `golang` stage, and `//go:embed`s it into the
|
||||
final binary. The resulting container serves the SPA from `/` alongside the
|
||||
API surfaces. No separate static-file server is required.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "docs: document two-process dev workflow with Vite proxy"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Final verification
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
- [ ] **Step 1: Full Go suite**
|
||||
|
||||
Run: `go test -short -race ./...`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Go linter**
|
||||
|
||||
Run: `golangci-lint run ./...`
|
||||
Expected: no issues.
|
||||
|
||||
- [ ] **Step 3: Web tests + build**
|
||||
|
||||
Run: `cd web && npm test && npm run build`
|
||||
Expected: PASS. Verify `web/build/index.html` was regenerated; then:
|
||||
|
||||
Run: `git checkout -- web/build/index.html`
|
||||
Expected: committed placeholder restored (the fresh build output stays out of version control).
|
||||
|
||||
- [ ] **Step 4: Docker build smoke**
|
||||
|
||||
Run: `docker build -t minstrel:scaffold-final . && docker image inspect minstrel:scaffold-final --format '{{.Size}}'`
|
||||
Expected: image builds; size printed (bytes).
|
||||
|
||||
- [ ] **Step 5: Local end-to-end**
|
||||
|
||||
Bring up the stack:
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
Then in another terminal:
|
||||
```bash
|
||||
curl -sI http://localhost:4533/
|
||||
curl -sI http://localhost:4533/artists/00000000-0000-0000-0000-000000000001
|
||||
curl -sI http://localhost:4533/healthz
|
||||
curl -sI http://localhost:4533/api/nonexistent
|
||||
```
|
||||
|
||||
Expected responses:
|
||||
- `GET /` → `200 OK`, `Content-Type: text/html; charset=utf-8`.
|
||||
- `GET /artists/…` → `200 OK`, `Content-Type: text/html; charset=utf-8` (SPA fallback).
|
||||
- `GET /healthz` → `200 OK`, `Content-Type: application/json`.
|
||||
- `GET /api/nonexistent` → `401 Unauthorized` (RequireUser kicks in before the NotFound wrapper for mounted `/api/*` routes) OR `404 Not Found` with `Content-Type: application/json` (not `text/html`). Either is acceptable — the key requirement is **no `text/html` body**.
|
||||
|
||||
Tear down:
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Finish the branch**
|
||||
|
||||
Follow `superpowers:finishing-a-development-branch`: present the four-option menu and proceed per the user's choice. The expected path is Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
||||
# M2 Likes Sub-Plan — Design Spec
|
||||
|
||||
**Status:** approved 2026-04-26
|
||||
**Slice:** M2 (Events, sessions, general likes), spec §13 step 6 + scope extension. The original spec only covers track-level `general_likes`; this slice extends to album and artist starring for full Subsonic compatibility.
|
||||
**Fable tasks:** #326 (server + Subsonic) and #327 (web UI heart + Liked view); closes M2 along with #328 (milestone gate verification).
|
||||
|
||||
## Goal
|
||||
|
||||
Ship liking end-to-end across all three entity types (track / album / artist), wired through both the native `/api/*` surface and the Subsonic `/rest/*` compatibility surface, with web UI heart buttons everywhere a track/album/artist is rendered, plus a dedicated `/library/liked` page.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Per-context likes, session vector capture on like (`contextual_likes`) — that's M3 territory; the table already exists from migration `0005_events`.
|
||||
- Aggregate `artist_preferences` (computed weighted signal) — M3.
|
||||
- Liked-state propagation via WebSocket / SSE. Subsonic-driven changes only show up after the next refetch (manual reload or TanStack's window-focus refetch).
|
||||
- Bulk operations (like-all-from-album, etc.). Single-entity like/unlike only.
|
||||
- Sorting controls on `/library/liked`. Default `liked_at DESC` is the only order in v1.
|
||||
- Importing likes from Last.fm / ListenBrainz / Spotify. v1 only persists what the user does in this server.
|
||||
|
||||
## Architecture
|
||||
|
||||
Three new tables — one per entity type — keyed on `(user_id, entity_id)` with `liked_at timestamptz`. Three native `/api/likes/{tracks,albums,artists}/:id` endpoint families (POST/DELETE/GET) plus a single `/api/likes/ids` endpoint that returns flat id sets for client-side heart-rendering. Subsonic `/rest/star`, `/rest/unstar`, `/rest/getStarred`, `/rest/getStarred2` handlers route through the same per-table writes.
|
||||
|
||||
The web client exposes one `LikeButton.svelte` component that takes `(entityType, entityId)` and reads from a single `createLikedIdsQuery()` TanStack Query whose data shape is `{ track_ids: string[], album_ids: string[], artist_ids: string[] }`. Mutations do optimistic `setQueryData` updates with rollback on error. Heart placements: `TrackRow`, `AlbumCard`, `ArtistRow`, `PlayerBar`. New `/library/liked` page with three sections backed by infinite queries.
|
||||
|
||||
**No `liked` flag on entity refs.** Server doesn't add a join to every track/album/artist response. Liked state is computed on the client via O(1) Set lookups from the cached `likedIdsQuery`. Single source of truth; cache is shared across all hearts in the app; mutations invalidate it once and every component re-renders.
|
||||
|
||||
## Schema (migration `0006_likes.up.sql`)
|
||||
|
||||
```sql
|
||||
CREATE TABLE general_likes (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
liked_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, track_id)
|
||||
);
|
||||
CREATE INDEX general_likes_user_liked_at_idx ON general_likes (user_id, liked_at DESC);
|
||||
|
||||
CREATE TABLE general_likes_albums (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
album_id uuid NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
|
||||
liked_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, album_id)
|
||||
);
|
||||
CREATE INDEX general_likes_albums_user_liked_at_idx ON general_likes_albums (user_id, liked_at DESC);
|
||||
|
||||
CREATE TABLE general_likes_artists (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
|
||||
liked_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, artist_id)
|
||||
);
|
||||
CREATE INDEX general_likes_artists_user_liked_at_idx ON general_likes_artists (user_id, liked_at DESC);
|
||||
```
|
||||
|
||||
Down migration drops all three tables.
|
||||
|
||||
## API contracts
|
||||
|
||||
### Native `/api/likes/...`
|
||||
|
||||
```ts
|
||||
// Idempotent upsert. 204 on success, 404 if entity doesn't exist, 400 on bad UUID.
|
||||
POST /api/likes/tracks/:track_id → 204
|
||||
POST /api/likes/albums/:album_id → 204
|
||||
POST /api/likes/artists/:artist_id → 204
|
||||
|
||||
// Idempotent delete. 204 regardless of prior state.
|
||||
DELETE /api/likes/tracks/:track_id → 204
|
||||
DELETE /api/likes/albums/:album_id → 204
|
||||
DELETE /api/likes/artists/:artist_id → 204
|
||||
|
||||
// Paginated detail listings, sorted by liked_at DESC.
|
||||
GET /api/likes/tracks?limit=&offset= → Page<TrackRef>
|
||||
GET /api/likes/albums?limit=&offset= → Page<AlbumRef>
|
||||
GET /api/likes/artists?limit=&offset= → Page<ArtistRef>
|
||||
|
||||
// Flat id set for heart-button rendering.
|
||||
GET /api/likes/ids → { track_ids: string[], album_ids: string[], artist_ids: string[] }
|
||||
```
|
||||
|
||||
All gated by the existing `RequireUser` middleware. `user_id` comes from request context; nothing in the URL or body identifies the user. Cross-user isolation is enforced by the queries (`WHERE user_id = $1`).
|
||||
|
||||
### Subsonic `/rest/...`
|
||||
|
||||
```
|
||||
GET /rest/star?id=<track>&albumId=<album>&artistId=<artist> → standard `ok` envelope
|
||||
GET /rest/unstar?id=<track>&albumId=<album>&artistId=<artist> → standard `ok` envelope
|
||||
GET /rest/getStarred → <starred> with song/album/artist children
|
||||
GET /rest/getStarred2 → <starred2> (OpenSubsonic JSON variant)
|
||||
```
|
||||
|
||||
`star`/`unstar` accept any combination of `id`, `albumId`, `artistId` (zero or more of each). For each present param, upsert (or delete) the matching row in the matching table. Missing-entity → standard Subsonic `<error code="70">` (data not found). Repeated params (e.g. `id=A&id=B`) — accept the first occurrence only for v1; document as known limitation.
|
||||
|
||||
`getStarred` and `getStarred2` return the user's stars under a `<starred>` (or `starred2` JSON) wrapper containing arrays of `song`, `album`, `artist` children, each sorted `liked_at DESC`. The existing `TrackRef`/`AlbumRef`/`ArtistRef` Subsonic serialization (used by search3, getAlbum, getArtist) is reused here.
|
||||
|
||||
## Components & files
|
||||
|
||||
### New server files
|
||||
|
||||
| Path | Responsibility |
|
||||
|---|---|
|
||||
| `internal/db/migrations/0006_likes.up.sql` + `.down.sql` | Three tables + indexes. |
|
||||
| `internal/db/queries/likes.sql` | sqlc queries: `Insert*Like`, `Delete*Like`, `List*Likes` (paginated), `Count*Likes`, `List*LikedIds` for each of the three tables. |
|
||||
| `internal/db/dbq/likes.sql.go` | Generated bindings. |
|
||||
| `internal/api/likes.go` | Handlers for all seven native routes. |
|
||||
| `internal/api/likes_test.go` | HTTP-level coverage. |
|
||||
| `internal/subsonic/star.go` | `handleStar`, `handleUnstar`, `handleGetStarred`, `handleGetStarred2`. |
|
||||
| `internal/subsonic/star_test.go` | Subsonic coverage. |
|
||||
|
||||
### Modified server files
|
||||
|
||||
| Path | Change |
|
||||
|---|---|
|
||||
| `internal/api/api.go` | Register `/api/likes/{tracks,albums,artists}/:id` (POST + DELETE), `/api/likes/{tracks,albums,artists}` (GET, paginated), `/api/likes/ids` (GET). Inside the authed group. |
|
||||
| `internal/subsonic/subsonic.go` | Register `/star`, `/unstar`, `/getStarred`, `/getStarred2` in the Mount table. |
|
||||
|
||||
### New web files
|
||||
|
||||
| Path | Responsibility |
|
||||
|---|---|
|
||||
| `web/src/lib/api/likes.ts` | TanStack Query helpers: `createLikedIdsQuery()` and per-entity infinite-query factories. Mutation factories: `likeTrack(id)`, `unlikeTrack(id)`, plus album and artist variants. Each mutation does optimistic update via `setQueryData` and rolls back on error. |
|
||||
| `web/src/lib/api/likes.test.ts` | Cache-update unit tests. |
|
||||
| `web/src/lib/components/LikeButton.svelte` | Heart icon. Props `entityType: 'track' \| 'album' \| 'artist'`, `entityId: string`. Reads `createLikedIdsQuery()`; click toggles via the right mutation; `event.stopPropagation()` so it doesn't bubble to `TrackRow`'s row-click. |
|
||||
| `web/src/lib/components/LikeButton.test.ts` | Renders correct fill state; click toggles correctly per entity type; click stops propagation. |
|
||||
| `web/src/routes/library/liked/+page.svelte` | Three sections (Artists / Albums / Tracks) backed by infinite queries. |
|
||||
| `web/src/routes/library/liked/liked.test.ts` | Page integration. |
|
||||
|
||||
### Modified web files
|
||||
|
||||
| Path | Change |
|
||||
|---|---|
|
||||
| `web/src/lib/api/types.ts` | Add `LikedIdsResponse = { track_ids: string[]; album_ids: string[]; artist_ids: string[] }`. |
|
||||
| `web/src/lib/api/queries.ts` | Add `qk.likedIds`, `qk.likedTracks(q)`, `qk.likedAlbums(q)`, `qk.likedArtists(q)` keys. Re-export from `likes.ts` (or leave them in their own module — match prior convention). |
|
||||
| `web/src/lib/components/TrackRow.svelte` | Insert `<LikeButton entityType="track" entityId={track.id} />` next to the existing `+ queue` button. Grid template grows to `[32px_1fr_auto_auto_auto]`. |
|
||||
| `web/src/lib/components/AlbumCard.svelte` | Add a second overlay button row beneath the existing `+ queue` overlay (or stack them vertically) with `<LikeButton entityType="album" entityId={album.id} />`. |
|
||||
| `web/src/lib/components/ArtistRow.svelte` | Add `<LikeButton entityType="artist" entityId={artist.id} />` to the row's right side, before the chevron. |
|
||||
| `web/src/lib/components/PlayerBar.svelte` | Add `<LikeButton entityType="track" entityId={current.id} />` next to the title in the left cluster (cover + title + artist link). |
|
||||
| `web/src/lib/components/Shell.svelte` | Add a "Liked" entry to `navItems` pointing at `/library/liked`. |
|
||||
|
||||
## Data flow
|
||||
|
||||
**Like a track from the album page:**
|
||||
|
||||
1. User clicks heart on a `TrackRow`.
|
||||
2. `LikeButton` reads `createLikedIdsQuery()` data; sees `track_ids` doesn't include this id; fires `likeTrack(id)` mutation.
|
||||
3. Mutation does optimistic `setQueryData(qk.likedIds, prev => ({ ...prev, track_ids: [...prev.track_ids, id] }))` so heart fills instantly. Then `POST /api/likes/tracks/:id`.
|
||||
4. Server inserts into `general_likes` with `ON CONFLICT DO NOTHING`. Returns 204.
|
||||
5. Mutation `onSuccess` invalidates `qk.likedIds` and `qk.likedTracks` (so `/library/liked` refetches if visible). `onError` rolls back the optimistic update from a `previousIds` snapshot captured in `onMutate`.
|
||||
|
||||
**Star from a Subsonic client:**
|
||||
|
||||
1. Client POSTs `/rest/star?id=<track_uuid>&u=admin&t=...`.
|
||||
2. `internal/subsonic/star.go::handleStar` resolves auth, parses any combination of `id`/`albumId`/`artistId`, upserts each non-empty into the matching table, returns `ok` envelope.
|
||||
3. Web SPA's `createLikedIdsQuery()` doesn't see it until next refetch (TanStack default refetch-on-window-focus or manual invalidation). Acceptable — Subsonic-driven changes flow through eventually.
|
||||
|
||||
**View liked items:**
|
||||
|
||||
1. User clicks "Liked" → `/library/liked`.
|
||||
2. Page mounts three `createInfiniteQuery` instances. Each fires `GET /api/likes/{type}?limit=50&offset=0`.
|
||||
3. Server SQL: e.g. `SELECT t.* FROM tracks t JOIN general_likes l ON l.track_id = t.id WHERE l.user_id = $1 ORDER BY l.liked_at DESC LIMIT $2 OFFSET $3`. Plus `SELECT count(*) FROM general_likes WHERE user_id = $1` for the `total`.
|
||||
4. Returns `Page<TrackRef>` (and analogous for albums/artists).
|
||||
5. Page renders three sections; each has its own "Load more" per the existing infinite-query convention. Empty section shows "No liked X yet".
|
||||
|
||||
**Heart on PlayerBar:**
|
||||
|
||||
`<LikeButton entityType="track" entityId={current.id} />` — same component as TrackRow's heart. The fill state is reactive: when `player.current` changes, the derived id-membership check re-runs against the cached id set.
|
||||
|
||||
**Liked-state propagation across the app:** every heart subscribes to the same `qk.likedIds` query. A mutation invalidation triggers all of them to re-render. No prop drilling or pub-sub needed.
|
||||
|
||||
## States
|
||||
|
||||
| Condition | Render |
|
||||
|---|---|
|
||||
| `likedIdsQuery.isPending` | Heart is disabled and shows a neutral state (outline). Action defers; quick to load. |
|
||||
| `likedIdsQuery.isError` | Heart shows outline; clicks are no-ops; no error toast (likes are non-critical). |
|
||||
| Set contains entity id | Filled heart, `aria-pressed="true"`. |
|
||||
| Set does NOT contain entity id | Outline heart, `aria-pressed="false"`. |
|
||||
|
||||
`/library/liked` page states (per section, reusing patterns from search):
|
||||
- `q.isPending && items.length === 0` → `LibrarySkeleton` for that section
|
||||
- `q.isError` → `ApiErrorBanner`
|
||||
- `total === 0` → "No liked X yet" hint
|
||||
- otherwise → list/grid + "Load more" if `hasNextPage`
|
||||
|
||||
## Testing
|
||||
|
||||
### Server (`go test`)
|
||||
|
||||
- **Migration smoke** — applying `0006_likes` round-trips the three tables; insert+select for each.
|
||||
- **Native handlers** (`internal/api/likes_test.go`):
|
||||
- Like-track happy path (204; row exists; idempotent — 204 on repeat).
|
||||
- Like with malformed UUID → 400; nonexistent UUID → 404.
|
||||
- Unlike happy path (204; row gone; idempotent).
|
||||
- List endpoint returns paginated `Page<TrackRef>` sorted `liked_at DESC`; `total` reflects user's full liked set; `limit`/`offset` work.
|
||||
- `GET /api/likes/ids` returns three string arrays containing exactly the ids of the user's rows.
|
||||
- Cross-user isolation — Alice likes track X, Bob's `/api/likes/ids` does not contain X.
|
||||
- One coverage case per entity type (album, artist) on the like+unlike+list+ids paths.
|
||||
- **Subsonic handlers** (`internal/subsonic/star_test.go`):
|
||||
- `star?id=<track>` writes to `general_likes`, returns `ok` envelope.
|
||||
- `star?albumId=<album>&artistId=<artist>` writes to both album and artist tables in one call.
|
||||
- `star?id=<unknown>` returns Subsonic error 70 (data not found).
|
||||
- `unstar` removes the row, returns `ok`. Idempotent.
|
||||
- `getStarred2` returns artist/album/song arrays correctly populated, sorted `liked_at DESC`. Cross-user isolation verified.
|
||||
|
||||
### Web (`vitest`)
|
||||
|
||||
- **`likes.test.ts`** — TanStack Query factory + mutations:
|
||||
- `createLikedIdsQuery` shape (`{ track_ids, album_ids, artist_ids }`).
|
||||
- `likeTrack(id)` optimistic update inserts id into `track_ids`; mocked failure rolls back.
|
||||
- `unlikeTrack(id)` removes id; rollback restores.
|
||||
- Same coverage for album/artist mutations.
|
||||
- **`LikeButton.test.ts`**:
|
||||
- Renders empty heart when id is NOT in the matching set; filled heart when it IS.
|
||||
- Click on empty heart fires the like mutation (mocked).
|
||||
- Click on filled heart fires the unlike mutation.
|
||||
- Click stops propagation (doesn't bubble to parent `<div role="button">` on TrackRow).
|
||||
- **`liked.test.ts`** (`/library/liked` page):
|
||||
- Three sections render with mocked infinite queries.
|
||||
- Empty section shows "No liked X yet" hint (gate on `total === 0` per the search-page pattern).
|
||||
- "Load more" calls `fetchNextPage` per section.
|
||||
- **`TrackRow.test.ts` / `AlbumCard.test.ts` / `ArtistRow.test.ts` / `PlayerBar.test.ts`** updates: each gets one new test asserting `<LikeButton>` is rendered with the right `entityType` + `entityId`. The button's behavior is covered in `LikeButton.test.ts`; we just verify wiring.
|
||||
|
||||
### End-to-end manual
|
||||
|
||||
Final task in the implementation plan:
|
||||
|
||||
1. Sign in. Click heart on a track → fills instantly. Refresh page → still filled. `psql` shows the row in `general_likes`.
|
||||
2. Click heart again → empties. Row gone.
|
||||
3. Click heart on an album card. Click heart on an artist row. Verify `general_likes_albums` and `general_likes_artists` rows.
|
||||
4. Open Feishin/Symfonium. Star a track. Refresh web SPA (or focus tab). Heart on that track is filled.
|
||||
5. Unstar from Subsonic. Web SPA's heart empties on next refetch.
|
||||
6. Visit `/library/liked`. Three sections render with the items above. "Load more" works once you have > 50 of any type.
|
||||
7. Currently-playing track shows filled heart in `PlayerBar` if liked; toggling from `PlayerBar` updates state everywhere the same track is rendered.
|
||||
|
||||
## Risks & mitigations
|
||||
|
||||
- **Likes diverge from Subsonic on partial failures.** If a Subsonic client sends `star?id=A&albumId=B` and the album doesn't exist, the track gets starred but album fails. Mitigation: validate all entity ids first; if any is missing, return error 70 BEFORE writing any. Atomicity matters less than predictability for clients.
|
||||
- **`createLikedIdsQuery` payload grows unbounded for power users.** For 10k liked tracks, the array is ~370 KB — acceptable. For 100k+ users it becomes a concern. Mitigation: not a v1 problem; document and revisit if telemetry shows large libraries. A per-entity-type "is liked" check endpoint would be a clean upgrade path.
|
||||
- **Optimistic update / refetch race.** If a mutation is in-flight when a refetch lands, the rollback might overwrite the actual server state with stale "previous" data. TanStack's mutation flow handles this — `onError` only fires on actual error, and the `setQueryData` we do in `onMutate` is the optimistic value, not stale.
|
||||
- **Cross-table star with mixed presence.** `star?id=&albumId=` where one exists and the other doesn't — option A: 404 the whole call; option B: succeed for the existing one, ignore the missing one. We pick A (atomicity) per the first risk above. Validation pass before any insert.
|
||||
- **`liked_at` clock skew across replicas.** Single-instance deployment for v1 — defer.
|
||||
@@ -36,6 +36,16 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Get("/search", h.handleSearch)
|
||||
authed.Get("/radio", h.handleRadio)
|
||||
authed.Post("/events", h.handleEvents)
|
||||
authed.Post("/likes/tracks/{id}", h.handleLikeTrack)
|
||||
authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack)
|
||||
authed.Post("/likes/albums/{id}", h.handleLikeAlbum)
|
||||
authed.Delete("/likes/albums/{id}", h.handleUnlikeAlbum)
|
||||
authed.Post("/likes/artists/{id}", h.handleLikeArtist)
|
||||
authed.Delete("/likes/artists/{id}", h.handleUnlikeArtist)
|
||||
authed.Get("/likes/tracks", h.handleListLikedTracks)
|
||||
authed.Get("/likes/albums", h.handleListLikedAlbums)
|
||||
authed.Get("/likes/artists", h.handleListLikedArtists)
|
||||
authed.Get("/likes/ids", h.handleGetLikedIDs)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
type likedIDsResponse struct {
|
||||
TrackIDs []string `json:"track_ids"`
|
||||
AlbumIDs []string `json:"album_ids"`
|
||||
ArtistIDs []string `json:"artist_ids"`
|
||||
}
|
||||
|
||||
func (h *handlers) handleLikeTrack(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||||
return
|
||||
}
|
||||
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id")
|
||||
return
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
if _, err := q.GetTrackByID(r.Context(), id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "track not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("api: like track lookup", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||
return
|
||||
}
|
||||
if err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id}); err != nil {
|
||||
h.logger.Error("api: like track insert", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "insert failed")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||||
return
|
||||
}
|
||||
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id")
|
||||
return
|
||||
}
|
||||
if err := dbq.New(h.pool).UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: id}); err != nil {
|
||||
h.logger.Error("api: unlike track", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "delete failed")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *handlers) handleLikeAlbum(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||||
return
|
||||
}
|
||||
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid album id")
|
||||
return
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
if _, err := q.GetAlbumByID(r.Context(), id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "album not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("api: like album lookup", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||
return
|
||||
}
|
||||
if err := q.LikeAlbum(r.Context(), dbq.LikeAlbumParams{UserID: user.ID, AlbumID: id}); err != nil {
|
||||
h.logger.Error("api: like album insert", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "insert failed")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *handlers) handleUnlikeAlbum(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||||
return
|
||||
}
|
||||
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid album id")
|
||||
return
|
||||
}
|
||||
if err := dbq.New(h.pool).UnlikeAlbum(r.Context(), dbq.UnlikeAlbumParams{UserID: user.ID, AlbumID: id}); err != nil {
|
||||
h.logger.Error("api: unlike album", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "delete failed")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *handlers) handleLikeArtist(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||||
return
|
||||
}
|
||||
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid artist id")
|
||||
return
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
if _, err := q.GetArtistByID(r.Context(), id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "artist not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("api: like artist lookup", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||
return
|
||||
}
|
||||
if err := q.LikeArtist(r.Context(), dbq.LikeArtistParams{UserID: user.ID, ArtistID: id}); err != nil {
|
||||
h.logger.Error("api: like artist insert", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "insert failed")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *handlers) handleUnlikeArtist(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||||
return
|
||||
}
|
||||
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid artist id")
|
||||
return
|
||||
}
|
||||
if err := dbq.New(h.pool).UnlikeArtist(r.Context(), dbq.UnlikeArtistParams{UserID: user.ID, ArtistID: id}); err != nil {
|
||||
h.logger.Error("api: unlike artist", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "delete failed")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *handlers) handleListLikedTracks(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||||
return
|
||||
}
|
||||
limit, offset, err := parsePaging(r.URL.Query())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
rows, err := q.ListLikedTrackRows(r.Context(), dbq.ListLikedTrackRowsParams{
|
||||
UserID: user.ID, Limit: int32(limit), Offset: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("api: list liked tracks", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||||
return
|
||||
}
|
||||
total, err := q.CountLikedTracks(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("api: count liked tracks", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "count failed")
|
||||
return
|
||||
}
|
||||
items, err := h.resolveTrackRefs(r.Context(), q, rows)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, Page[TrackRef]{Items: items, Total: int(total), Limit: limit, Offset: offset})
|
||||
}
|
||||
|
||||
func (h *handlers) handleListLikedAlbums(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||||
return
|
||||
}
|
||||
limit, offset, err := parsePaging(r.URL.Query())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
rows, err := q.ListLikedAlbumRows(r.Context(), dbq.ListLikedAlbumRowsParams{
|
||||
UserID: user.ID, Limit: int32(limit), Offset: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("api: list liked albums", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||||
return
|
||||
}
|
||||
total, err := q.CountLikedAlbums(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("api: count liked albums", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "count failed")
|
||||
return
|
||||
}
|
||||
items, err := h.resolveAlbumRefs(r.Context(), q, rows)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, Page[AlbumRef]{Items: items, Total: int(total), Limit: limit, Offset: offset})
|
||||
}
|
||||
|
||||
func (h *handlers) handleListLikedArtists(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||||
return
|
||||
}
|
||||
limit, offset, err := parsePaging(r.URL.Query())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
rows, err := q.ListLikedArtistRows(r.Context(), dbq.ListLikedArtistRowsParams{
|
||||
UserID: user.ID, Limit: int32(limit), Offset: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("api: list liked artists", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||||
return
|
||||
}
|
||||
total, err := q.CountLikedArtists(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("api: count liked artists", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "count failed")
|
||||
return
|
||||
}
|
||||
items := make([]ArtistRef, 0, len(rows))
|
||||
for _, a := range rows {
|
||||
albums, aerr := q.ListAlbumsByArtist(r.Context(), a.ID)
|
||||
if aerr != nil {
|
||||
h.logger.Error("api: list albums for artist", "err", aerr)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||||
return
|
||||
}
|
||||
items = append(items, artistRefFrom(a, len(albums)))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, Page[ArtistRef]{Items: items, Total: int(total), Limit: limit, Offset: offset})
|
||||
}
|
||||
|
||||
func (h *handlers) handleGetLikedIDs(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||||
return
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
trackIDs, err := q.ListLikedTrackIDs(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("api: list liked track ids", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||||
return
|
||||
}
|
||||
albumIDs, err := q.ListLikedAlbumIDs(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("api: list liked album ids", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||||
return
|
||||
}
|
||||
artistIDs, err := q.ListLikedArtistIDs(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("api: list liked artist ids", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||||
return
|
||||
}
|
||||
resp := likedIDsResponse{
|
||||
TrackIDs: uuidsToStrings(trackIDs),
|
||||
AlbumIDs: uuidsToStrings(albumIDs),
|
||||
ArtistIDs: uuidsToStrings(artistIDs),
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func uuidsToStrings(ids []pgtype.UUID) []string {
|
||||
out := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, uuidToString(id))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Compile-time references so the json import is always live even when
|
||||
// no handler in this file decodes a body.
|
||||
var _ = json.Marshal
|
||||
@@ -0,0 +1,201 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
func callLike(h *handlers, user dbq.User, method, path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
|
||||
w := httptest.NewRecorder()
|
||||
likesRouter(h).ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// likesRouter matches the routes registered in Mount but without RequireUser
|
||||
// so tests can inject the user via context directly.
|
||||
func likesRouter(h *handlers) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/likes/tracks/{id}", h.handleLikeTrack)
|
||||
r.Delete("/api/likes/tracks/{id}", h.handleUnlikeTrack)
|
||||
r.Post("/api/likes/albums/{id}", h.handleLikeAlbum)
|
||||
r.Delete("/api/likes/albums/{id}", h.handleUnlikeAlbum)
|
||||
r.Post("/api/likes/artists/{id}", h.handleLikeArtist)
|
||||
r.Delete("/api/likes/artists/{id}", h.handleUnlikeArtist)
|
||||
r.Get("/api/likes/tracks", h.handleListLikedTracks)
|
||||
r.Get("/api/likes/albums", h.handleListLikedAlbums)
|
||||
r.Get("/api/likes/artists", h.handleListLikedArtists)
|
||||
r.Get("/api/likes/ids", h.handleGetLikedIDs)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestLikeTrack_Idempotent(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
user := seedUser(t, pool, "alice", "x", false)
|
||||
artist := seedArtist(t, pool, "Beatles")
|
||||
album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969)
|
||||
track := seedTrack(t, pool, album.ID, artist.ID, "Something", 3, 183_000)
|
||||
|
||||
w := callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(track.ID))
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("first like: status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
w = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(track.ID))
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("second like (idempotent): status = %d", w.Code)
|
||||
}
|
||||
var count int
|
||||
_ = pool.QueryRow(context.Background(),
|
||||
"SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("rows = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLikeTrack_BadUUIDIs400(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "alice", "x", false)
|
||||
w := callLike(h, user, http.MethodPost, "/api/likes/tracks/not-a-uuid")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLikeTrack_UnknownTrackIs404(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
user := seedUser(t, pool, "alice", "x", false)
|
||||
w := callLike(h, user, http.MethodPost, "/api/likes/tracks/00000000-0000-0000-0000-000000000000")
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnlikeTrack_IdempotentEvenWhenAbsent(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
user := seedUser(t, pool, "alice", "x", false)
|
||||
artist := seedArtist(t, pool, "Beatles")
|
||||
album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969)
|
||||
track := seedTrack(t, pool, album.ID, artist.ID, "Something", 3, 183_000)
|
||||
|
||||
w := callLike(h, user, http.MethodDelete, "/api/likes/tracks/"+uuidToString(track.ID))
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLikedTracks_PaginatedAndSortedDescByLikedAt(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
user := seedUser(t, pool, "alice", "x", false)
|
||||
artist := seedArtist(t, pool, "X")
|
||||
album := seedAlbum(t, pool, artist.ID, "X", 1990)
|
||||
t1 := seedTrack(t, pool, album.ID, artist.ID, "First", 1, 100_000)
|
||||
t2 := seedTrack(t, pool, album.ID, artist.ID, "Second", 2, 100_000)
|
||||
|
||||
// Like t1 then t2 — newest should come first.
|
||||
_ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(t1.ID))
|
||||
_ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(t2.ID))
|
||||
|
||||
w := callLike(h, user, http.MethodGet, "/api/likes/tracks?limit=10&offset=0")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Items []TrackRef `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp.Total != 2 || len(resp.Items) != 2 {
|
||||
t.Fatalf("shape: %+v", resp)
|
||||
}
|
||||
if resp.Items[0].Title != "Second" {
|
||||
t.Errorf("first item = %q, want %q (most recent)", resp.Items[0].Title, "Second")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLikedIDs_ReturnsAllThreeArrays(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
user := seedUser(t, pool, "alice", "x", false)
|
||||
artist := seedArtist(t, pool, "X")
|
||||
album := seedAlbum(t, pool, artist.ID, "X", 1990)
|
||||
track := seedTrack(t, pool, album.ID, artist.ID, "T", 1, 100_000)
|
||||
|
||||
_ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(track.ID))
|
||||
_ = callLike(h, user, http.MethodPost, "/api/likes/albums/"+uuidToString(album.ID))
|
||||
_ = callLike(h, user, http.MethodPost, "/api/likes/artists/"+uuidToString(artist.ID))
|
||||
|
||||
w := callLike(h, user, http.MethodGet, "/api/likes/ids")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", w.Code)
|
||||
}
|
||||
var resp struct {
|
||||
TrackIDs []string `json:"track_ids"`
|
||||
AlbumIDs []string `json:"album_ids"`
|
||||
ArtistIDs []string `json:"artist_ids"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if len(resp.TrackIDs) != 1 || len(resp.AlbumIDs) != 1 || len(resp.ArtistIDs) != 1 {
|
||||
t.Errorf("ids = %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLikedIDs_CrossUserIsolation(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
alice := seedUser(t, pool, "alice", "x", false)
|
||||
bob := seedUser(t, pool, "bob", "x", false)
|
||||
artist := seedArtist(t, pool, "X")
|
||||
album := seedAlbum(t, pool, artist.ID, "X", 1990)
|
||||
track := seedTrack(t, pool, album.ID, artist.ID, "T", 1, 100_000)
|
||||
|
||||
// Alice likes the track.
|
||||
_ = callLike(h, alice, http.MethodPost, "/api/likes/tracks/"+uuidToString(track.ID))
|
||||
// Bob queries his ids — should be empty.
|
||||
w := callLike(h, bob, http.MethodGet, "/api/likes/ids")
|
||||
var resp struct {
|
||||
TrackIDs []string `json:"track_ids"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if len(resp.TrackIDs) != 0 {
|
||||
t.Errorf("bob's track_ids should be empty, got %v", resp.TrackIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLikeAlbumAndArtist_HappyPath(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
user := seedUser(t, pool, "alice", "x", false)
|
||||
artist := seedArtist(t, pool, "X")
|
||||
album := seedAlbum(t, pool, artist.ID, "X", 1990)
|
||||
|
||||
w := callLike(h, user, http.MethodPost, "/api/likes/albums/"+uuidToString(album.ID))
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("album: status = %d", w.Code)
|
||||
}
|
||||
w = callLike(h, user, http.MethodPost, "/api/likes/artists/"+uuidToString(artist.ID))
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("artist: status = %d", w.Code)
|
||||
}
|
||||
w = callLike(h, user, http.MethodGet, "/api/likes/albums?limit=10")
|
||||
var resp struct {
|
||||
Total int `json:"total"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp.Total != 1 {
|
||||
t.Errorf("album total = %d", resp.Total)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: likes.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const countLikedAlbums = `-- name: CountLikedAlbums :one
|
||||
SELECT count(*) FROM general_likes_albums WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) CountLikedAlbums(ctx context.Context, userID pgtype.UUID) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countLikedAlbums, userID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const countLikedArtists = `-- name: CountLikedArtists :one
|
||||
SELECT count(*) FROM general_likes_artists WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) CountLikedArtists(ctx context.Context, userID pgtype.UUID) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countLikedArtists, userID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const countLikedTracks = `-- name: CountLikedTracks :one
|
||||
SELECT count(*) FROM general_likes WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) CountLikedTracks(ctx context.Context, userID pgtype.UUID) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countLikedTracks, userID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const likeAlbum = `-- name: LikeAlbum :exec
|
||||
INSERT INTO general_likes_albums (user_id, album_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id, album_id) DO NOTHING
|
||||
`
|
||||
|
||||
type LikeAlbumParams struct {
|
||||
UserID pgtype.UUID
|
||||
AlbumID pgtype.UUID
|
||||
}
|
||||
|
||||
func (q *Queries) LikeAlbum(ctx context.Context, arg LikeAlbumParams) error {
|
||||
_, err := q.db.Exec(ctx, likeAlbum, arg.UserID, arg.AlbumID)
|
||||
return err
|
||||
}
|
||||
|
||||
const likeArtist = `-- name: LikeArtist :exec
|
||||
INSERT INTO general_likes_artists (user_id, artist_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id, artist_id) DO NOTHING
|
||||
`
|
||||
|
||||
type LikeArtistParams struct {
|
||||
UserID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
}
|
||||
|
||||
func (q *Queries) LikeArtist(ctx context.Context, arg LikeArtistParams) error {
|
||||
_, err := q.db.Exec(ctx, likeArtist, arg.UserID, arg.ArtistID)
|
||||
return err
|
||||
}
|
||||
|
||||
const likeTrack = `-- name: LikeTrack :exec
|
||||
INSERT INTO general_likes (user_id, track_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id, track_id) DO NOTHING
|
||||
`
|
||||
|
||||
type LikeTrackParams struct {
|
||||
UserID pgtype.UUID
|
||||
TrackID pgtype.UUID
|
||||
}
|
||||
|
||||
func (q *Queries) LikeTrack(ctx context.Context, arg LikeTrackParams) error {
|
||||
_, err := q.db.Exec(ctx, likeTrack, arg.UserID, arg.TrackID)
|
||||
return err
|
||||
}
|
||||
|
||||
const listLikedAlbumIDs = `-- name: ListLikedAlbumIDs :many
|
||||
SELECT album_id FROM general_likes_albums WHERE user_id = $1 ORDER BY liked_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListLikedAlbumIDs(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, listLikedAlbumIDs, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []pgtype.UUID
|
||||
for rows.Next() {
|
||||
var album_id pgtype.UUID
|
||||
if err := rows.Scan(&album_id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, album_id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listLikedAlbumRows = `-- name: ListLikedAlbumRows :many
|
||||
SELECT a.id, a.title, a.sort_title, a.artist_id, a.release_date, a.mbid, a.cover_art_path, a.created_at, a.updated_at FROM albums a
|
||||
JOIN general_likes_albums l ON l.album_id = a.id
|
||||
WHERE l.user_id = $1
|
||||
ORDER BY l.liked_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
type ListLikedAlbumRowsParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
Offset int32
|
||||
}
|
||||
|
||||
func (q *Queries) ListLikedAlbumRows(ctx context.Context, arg ListLikedAlbumRowsParams) ([]Album, error) {
|
||||
rows, err := q.db.Query(ctx, listLikedAlbumRows, arg.UserID, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Album
|
||||
for rows.Next() {
|
||||
var i Album
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.SortTitle,
|
||||
&i.ArtistID,
|
||||
&i.ReleaseDate,
|
||||
&i.Mbid,
|
||||
&i.CoverArtPath,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listLikedArtistIDs = `-- name: ListLikedArtistIDs :many
|
||||
SELECT artist_id FROM general_likes_artists WHERE user_id = $1 ORDER BY liked_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListLikedArtistIDs(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, listLikedArtistIDs, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []pgtype.UUID
|
||||
for rows.Next() {
|
||||
var artist_id pgtype.UUID
|
||||
if err := rows.Scan(&artist_id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, artist_id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listLikedArtistRows = `-- name: ListLikedArtistRows :many
|
||||
SELECT a.id, a.name, a.sort_name, a.mbid, a.created_at, a.updated_at FROM artists a
|
||||
JOIN general_likes_artists l ON l.artist_id = a.id
|
||||
WHERE l.user_id = $1
|
||||
ORDER BY l.liked_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
type ListLikedArtistRowsParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
Offset int32
|
||||
}
|
||||
|
||||
func (q *Queries) ListLikedArtistRows(ctx context.Context, arg ListLikedArtistRowsParams) ([]Artist, error) {
|
||||
rows, err := q.db.Query(ctx, listLikedArtistRows, arg.UserID, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Artist
|
||||
for rows.Next() {
|
||||
var i Artist
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.SortName,
|
||||
&i.Mbid,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listLikedTrackIDs = `-- name: ListLikedTrackIDs :many
|
||||
SELECT track_id FROM general_likes WHERE user_id = $1 ORDER BY liked_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListLikedTrackIDs(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, listLikedTrackIDs, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []pgtype.UUID
|
||||
for rows.Next() {
|
||||
var track_id pgtype.UUID
|
||||
if err := rows.Scan(&track_id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, track_id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listLikedTrackRows = `-- name: ListLikedTrackRows :many
|
||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at FROM tracks t
|
||||
JOIN general_likes l ON l.track_id = t.id
|
||||
WHERE l.user_id = $1
|
||||
ORDER BY l.liked_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
type ListLikedTrackRowsParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
Offset int32
|
||||
}
|
||||
|
||||
func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRowsParams) ([]Track, error) {
|
||||
rows, err := q.db.Query(ctx, listLikedTrackRows, arg.UserID, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Track
|
||||
for rows.Next() {
|
||||
var i Track
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.AlbumID,
|
||||
&i.ArtistID,
|
||||
&i.TrackNumber,
|
||||
&i.DiscNumber,
|
||||
&i.DurationMs,
|
||||
&i.FilePath,
|
||||
&i.FileSize,
|
||||
&i.FileFormat,
|
||||
&i.Bitrate,
|
||||
&i.Mbid,
|
||||
&i.Genre,
|
||||
&i.AddedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const unlikeAlbum = `-- name: UnlikeAlbum :exec
|
||||
DELETE FROM general_likes_albums WHERE user_id = $1 AND album_id = $2
|
||||
`
|
||||
|
||||
type UnlikeAlbumParams struct {
|
||||
UserID pgtype.UUID
|
||||
AlbumID pgtype.UUID
|
||||
}
|
||||
|
||||
func (q *Queries) UnlikeAlbum(ctx context.Context, arg UnlikeAlbumParams) error {
|
||||
_, err := q.db.Exec(ctx, unlikeAlbum, arg.UserID, arg.AlbumID)
|
||||
return err
|
||||
}
|
||||
|
||||
const unlikeArtist = `-- name: UnlikeArtist :exec
|
||||
DELETE FROM general_likes_artists WHERE user_id = $1 AND artist_id = $2
|
||||
`
|
||||
|
||||
type UnlikeArtistParams struct {
|
||||
UserID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
}
|
||||
|
||||
func (q *Queries) UnlikeArtist(ctx context.Context, arg UnlikeArtistParams) error {
|
||||
_, err := q.db.Exec(ctx, unlikeArtist, arg.UserID, arg.ArtistID)
|
||||
return err
|
||||
}
|
||||
|
||||
const unlikeTrack = `-- name: UnlikeTrack :exec
|
||||
DELETE FROM general_likes WHERE user_id = $1 AND track_id = $2
|
||||
`
|
||||
|
||||
type UnlikeTrackParams struct {
|
||||
UserID pgtype.UUID
|
||||
TrackID pgtype.UUID
|
||||
}
|
||||
|
||||
func (q *Queries) UnlikeTrack(ctx context.Context, arg UnlikeTrackParams) error {
|
||||
_, err := q.db.Exec(ctx, unlikeTrack, arg.UserID, arg.TrackID)
|
||||
return err
|
||||
}
|
||||
@@ -29,6 +29,24 @@ type Artist struct {
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type GeneralLike struct {
|
||||
UserID pgtype.UUID
|
||||
TrackID pgtype.UUID
|
||||
LikedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type GeneralLikesAlbum struct {
|
||||
UserID pgtype.UUID
|
||||
AlbumID pgtype.UUID
|
||||
LikedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type GeneralLikesArtist struct {
|
||||
UserID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
LikedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type PlayEvent struct {
|
||||
ID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS general_likes_artists;
|
||||
DROP TABLE IF EXISTS general_likes_albums;
|
||||
DROP TABLE IF EXISTS general_likes;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- M2 likes: three tables, one per entity type. Schema follows the spec §5
|
||||
-- pattern (general_likes was originally track-only); this slice extends it
|
||||
-- to albums and artists for full Subsonic star/unstar support.
|
||||
|
||||
CREATE TABLE general_likes (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
liked_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, track_id)
|
||||
);
|
||||
CREATE INDEX general_likes_user_liked_at_idx ON general_likes (user_id, liked_at DESC);
|
||||
|
||||
CREATE TABLE general_likes_albums (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
album_id uuid NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
|
||||
liked_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, album_id)
|
||||
);
|
||||
CREATE INDEX general_likes_albums_user_liked_at_idx ON general_likes_albums (user_id, liked_at DESC);
|
||||
|
||||
CREATE TABLE general_likes_artists (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
|
||||
liked_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, artist_id)
|
||||
);
|
||||
CREATE INDEX general_likes_artists_user_liked_at_idx ON general_likes_artists (user_id, liked_at DESC);
|
||||
@@ -0,0 +1,62 @@
|
||||
-- name: LikeTrack :exec
|
||||
INSERT INTO general_likes (user_id, track_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id, track_id) DO NOTHING;
|
||||
|
||||
-- name: UnlikeTrack :exec
|
||||
DELETE FROM general_likes WHERE user_id = $1 AND track_id = $2;
|
||||
|
||||
-- name: ListLikedTrackRows :many
|
||||
SELECT t.* FROM tracks t
|
||||
JOIN general_likes l ON l.track_id = t.id
|
||||
WHERE l.user_id = $1
|
||||
ORDER BY l.liked_at DESC
|
||||
LIMIT $2 OFFSET $3;
|
||||
|
||||
-- name: CountLikedTracks :one
|
||||
SELECT count(*) FROM general_likes WHERE user_id = $1;
|
||||
|
||||
-- name: ListLikedTrackIDs :many
|
||||
SELECT track_id FROM general_likes WHERE user_id = $1 ORDER BY liked_at DESC;
|
||||
|
||||
-- name: LikeAlbum :exec
|
||||
INSERT INTO general_likes_albums (user_id, album_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id, album_id) DO NOTHING;
|
||||
|
||||
-- name: UnlikeAlbum :exec
|
||||
DELETE FROM general_likes_albums WHERE user_id = $1 AND album_id = $2;
|
||||
|
||||
-- name: ListLikedAlbumRows :many
|
||||
SELECT a.* FROM albums a
|
||||
JOIN general_likes_albums l ON l.album_id = a.id
|
||||
WHERE l.user_id = $1
|
||||
ORDER BY l.liked_at DESC
|
||||
LIMIT $2 OFFSET $3;
|
||||
|
||||
-- name: CountLikedAlbums :one
|
||||
SELECT count(*) FROM general_likes_albums WHERE user_id = $1;
|
||||
|
||||
-- name: ListLikedAlbumIDs :many
|
||||
SELECT album_id FROM general_likes_albums WHERE user_id = $1 ORDER BY liked_at DESC;
|
||||
|
||||
-- name: LikeArtist :exec
|
||||
INSERT INTO general_likes_artists (user_id, artist_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id, artist_id) DO NOTHING;
|
||||
|
||||
-- name: UnlikeArtist :exec
|
||||
DELETE FROM general_likes_artists WHERE user_id = $1 AND artist_id = $2;
|
||||
|
||||
-- name: ListLikedArtistRows :many
|
||||
SELECT a.* FROM artists a
|
||||
JOIN general_likes_artists l ON l.artist_id = a.id
|
||||
WHERE l.user_id = $1
|
||||
ORDER BY l.liked_at DESC
|
||||
LIMIT $2 OFFSET $3;
|
||||
|
||||
-- name: CountLikedArtists :one
|
||||
SELECT count(*) FROM general_likes_artists WHERE user_id = $1;
|
||||
|
||||
-- name: ListLikedArtistIDs :many
|
||||
SELECT artist_id FROM general_likes_artists WHERE user_id = $1 ORDER BY liked_at DESC;
|
||||
@@ -0,0 +1,233 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// handleStar implements /rest/star. Accepts any combination of id (track),
|
||||
// albumId, artistId. All entities are validated before any insert; a single
|
||||
// missing entity fails the whole call with Subsonic error 70.
|
||||
func (m *mediaHandlers) handleStar(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := UserFromContext(r.Context())
|
||||
if !ok {
|
||||
WriteFail(w, r, ErrGeneric, "Unauthenticated")
|
||||
return
|
||||
}
|
||||
params := r.URL.Query()
|
||||
q := dbq.New(m.pool)
|
||||
trackID, err := resolveStarID(r.Context(), q, params.Get("id"), entityKindTrack)
|
||||
if err != nil {
|
||||
WriteFail(w, r, ErrDataNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
albumID, err := resolveStarID(r.Context(), q, params.Get("albumId"), entityKindAlbum)
|
||||
if err != nil {
|
||||
WriteFail(w, r, ErrDataNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
artistID, err := resolveStarID(r.Context(), q, params.Get("artistId"), entityKindArtist)
|
||||
if err != nil {
|
||||
WriteFail(w, r, ErrDataNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if trackID.Valid {
|
||||
if err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}); err != nil {
|
||||
WriteFail(w, r, ErrGeneric, "Could not star track")
|
||||
return
|
||||
}
|
||||
}
|
||||
if albumID.Valid {
|
||||
if err := q.LikeAlbum(r.Context(), dbq.LikeAlbumParams{UserID: user.ID, AlbumID: albumID}); err != nil {
|
||||
WriteFail(w, r, ErrGeneric, "Could not star album")
|
||||
return
|
||||
}
|
||||
}
|
||||
if artistID.Valid {
|
||||
if err := q.LikeArtist(r.Context(), dbq.LikeArtistParams{UserID: user.ID, ArtistID: artistID}); err != nil {
|
||||
WriteFail(w, r, ErrGeneric, "Could not star artist")
|
||||
return
|
||||
}
|
||||
}
|
||||
Write(w, r, struct{ Envelope }{Envelope: NewEnvelope("ok")})
|
||||
}
|
||||
|
||||
// handleUnstar mirrors handleStar but DELETEs. Missing entities are treated
|
||||
// as a no-op rather than an error — Subsonic clients commonly call unstar
|
||||
// on entities the server has never seen (cross-server library moves).
|
||||
func (m *mediaHandlers) handleUnstar(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := UserFromContext(r.Context())
|
||||
if !ok {
|
||||
WriteFail(w, r, ErrGeneric, "Unauthenticated")
|
||||
return
|
||||
}
|
||||
params := r.URL.Query()
|
||||
q := dbq.New(m.pool)
|
||||
if t, ok := parseUUID(params.Get("id")); ok {
|
||||
_ = q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: t})
|
||||
}
|
||||
if a, ok := parseUUID(params.Get("albumId")); ok {
|
||||
_ = q.UnlikeAlbum(r.Context(), dbq.UnlikeAlbumParams{UserID: user.ID, AlbumID: a})
|
||||
}
|
||||
if a, ok := parseUUID(params.Get("artistId")); ok {
|
||||
_ = q.UnlikeArtist(r.Context(), dbq.UnlikeArtistParams{UserID: user.ID, ArtistID: a})
|
||||
}
|
||||
Write(w, r, struct{ Envelope }{Envelope: NewEnvelope("ok")})
|
||||
}
|
||||
|
||||
type entityKind int
|
||||
|
||||
const (
|
||||
entityKindTrack entityKind = iota
|
||||
entityKindAlbum
|
||||
entityKindArtist
|
||||
)
|
||||
|
||||
// resolveStarID returns (uuid, nil) if the param is empty (no-op), or
|
||||
// (uuid, nil) if the entity exists, or (zero, error) if the entity is
|
||||
// missing/malformed. Non-empty malformed UUID is treated as a 'not found'
|
||||
// per Subsonic semantics.
|
||||
func resolveStarID(ctx context.Context, q *dbq.Queries, raw string, kind entityKind) (pgtype.UUID, error) {
|
||||
if raw == "" {
|
||||
return pgtype.UUID{}, nil
|
||||
}
|
||||
id, ok := parseUUID(raw)
|
||||
if !ok {
|
||||
return pgtype.UUID{}, errors.New("not found")
|
||||
}
|
||||
switch kind {
|
||||
case entityKindTrack:
|
||||
if _, err := q.GetTrackByID(ctx, id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return pgtype.UUID{}, errors.New("track not found")
|
||||
}
|
||||
return pgtype.UUID{}, err
|
||||
}
|
||||
case entityKindAlbum:
|
||||
if _, err := q.GetAlbumByID(ctx, id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return pgtype.UUID{}, errors.New("album not found")
|
||||
}
|
||||
return pgtype.UUID{}, err
|
||||
}
|
||||
case entityKindArtist:
|
||||
if _, err := q.GetArtistByID(ctx, id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return pgtype.UUID{}, errors.New("artist not found")
|
||||
}
|
||||
return pgtype.UUID{}, err
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (m *mediaHandlers) handleGetStarred(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := UserFromContext(r.Context())
|
||||
if !ok {
|
||||
WriteFail(w, r, ErrGeneric, "Unauthenticated")
|
||||
return
|
||||
}
|
||||
q := dbq.New(m.pool)
|
||||
songs, albums, artists, err := loadStarred(r.Context(), q, user.ID)
|
||||
if err != nil {
|
||||
WriteFail(w, r, ErrGeneric, "Could not load starred")
|
||||
return
|
||||
}
|
||||
Write(w, r, GetStarredResponse{
|
||||
Envelope: NewEnvelope("ok"),
|
||||
Starred: GetStarredContainer{
|
||||
Artists: artists,
|
||||
Albums: albums,
|
||||
Songs: songs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (m *mediaHandlers) handleGetStarred2(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := UserFromContext(r.Context())
|
||||
if !ok {
|
||||
WriteFail(w, r, ErrGeneric, "Unauthenticated")
|
||||
return
|
||||
}
|
||||
q := dbq.New(m.pool)
|
||||
songs, albums, artists, err := loadStarred(r.Context(), q, user.ID)
|
||||
if err != nil {
|
||||
WriteFail(w, r, ErrGeneric, "Could not load starred")
|
||||
return
|
||||
}
|
||||
Write(w, r, GetStarred2Response{
|
||||
Envelope: NewEnvelope("ok"),
|
||||
Starred2: StarredContainer{
|
||||
Artists: artists,
|
||||
Albums: albums,
|
||||
Songs: songs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// loadStarred fetches the user's full starred set in liked_at DESC order,
|
||||
// projects to Subsonic refs (Song / Album / Artist).
|
||||
func loadStarred(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]SongRef, []AlbumRef, []ArtistRef, error) {
|
||||
const cap = 500 // bounded for safety; M2 doesn't paginate getStarred
|
||||
songs := []SongRef{}
|
||||
albums := []AlbumRef{}
|
||||
artists := []ArtistRef{}
|
||||
|
||||
trackRows, err := q.ListLikedTrackRows(ctx, dbq.ListLikedTrackRowsParams{
|
||||
UserID: userID, Limit: cap, Offset: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
for _, t := range trackRows {
|
||||
album, err := q.GetAlbumByID(ctx, t.AlbumID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
artist, err := q.GetArtistByID(ctx, t.ArtistID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
songs = append(songs, songRef(t, album.Title, artist.Name))
|
||||
}
|
||||
|
||||
albumRows, err := q.ListLikedAlbumRows(ctx, dbq.ListLikedAlbumRowsParams{
|
||||
UserID: userID, Limit: cap, Offset: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
for _, a := range albumRows {
|
||||
artist, err := q.GetArtistByID(ctx, a.ArtistID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
count, err := q.CountTracksByAlbum(ctx, a.ID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
albums = append(albums, albumRef(a, artist.Name, int(count), 0))
|
||||
}
|
||||
|
||||
artistRows, err := q.ListLikedArtistRows(ctx, dbq.ListLikedArtistRowsParams{
|
||||
UserID: userID, Limit: cap, Offset: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
for _, a := range artistRows {
|
||||
albums, err := q.ListAlbumsByArtist(ctx, a.ID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
artists = append(artists, artistRef(a, len(albums)))
|
||||
}
|
||||
|
||||
return songs, albums, artists, nil
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
)
|
||||
|
||||
func testStarPool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track, dbq.Album, dbq.Artist) {
|
||||
t.Helper()
|
||||
if testing.Short() {
|
||||
t.Skip("skipping in -short mode")
|
||||
}
|
||||
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
"TRUNCATE general_likes, general_likes_albums, general_likes_artists, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
q := dbq.New(pool)
|
||||
u, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{
|
||||
Username: "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
|
||||
})
|
||||
a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "X", SortName: "X"})
|
||||
al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "X", SortTitle: "X", ArtistID: a.ID})
|
||||
tr, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||||
Title: "Y", AlbumID: al.ID, ArtistID: a.ID, FilePath: "/tmp/star.flac", DurationMs: 100_000,
|
||||
})
|
||||
return pool, u, tr, al, a
|
||||
}
|
||||
|
||||
func newStarHandlers(pool *pgxpool.Pool) *mediaHandlers {
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
|
||||
return newMediaHandlers(pool, w)
|
||||
}
|
||||
|
||||
func TestHandleStar_TrackIDInsertsRow(t *testing.T) {
|
||||
pool, user, track, _, _ := testStarPool(t)
|
||||
m := newStarHandlers(pool)
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("id", uuidToID(track.ID))
|
||||
req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil)
|
||||
ctx := context.WithValue(req.Context(), userCtxKey, user)
|
||||
w := httptest.NewRecorder()
|
||||
m.handleStar(w, req.WithContext(ctx))
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", w.Code)
|
||||
}
|
||||
var count int
|
||||
_ = pool.QueryRow(context.Background(),
|
||||
"SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("count = %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStar_AllThreeIDsAtOnce(t *testing.T) {
|
||||
pool, user, track, album, artist := testStarPool(t)
|
||||
m := newStarHandlers(pool)
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("id", uuidToID(track.ID))
|
||||
q.Set("albumId", uuidToID(album.ID))
|
||||
q.Set("artistId", uuidToID(artist.ID))
|
||||
req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil)
|
||||
ctx := context.WithValue(req.Context(), userCtxKey, user)
|
||||
w := httptest.NewRecorder()
|
||||
m.handleStar(w, req.WithContext(ctx))
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", w.Code)
|
||||
}
|
||||
var tCount, alCount, arCount int
|
||||
_ = pool.QueryRow(context.Background(), "SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&tCount)
|
||||
_ = pool.QueryRow(context.Background(), "SELECT count(*) FROM general_likes_albums WHERE user_id=$1", user.ID).Scan(&alCount)
|
||||
_ = pool.QueryRow(context.Background(), "SELECT count(*) FROM general_likes_artists WHERE user_id=$1", user.ID).Scan(&arCount)
|
||||
if tCount != 1 || alCount != 1 || arCount != 1 {
|
||||
t.Errorf("counts: tracks=%d albums=%d artists=%d", tCount, alCount, arCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStar_UnknownTrackReturnsError70(t *testing.T) {
|
||||
pool, user, _, _, _ := testStarPool(t)
|
||||
m := newStarHandlers(pool)
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("id", "00000000-0000-0000-0000-000000000000")
|
||||
req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil)
|
||||
ctx := context.WithValue(req.Context(), userCtxKey, user)
|
||||
w := httptest.NewRecorder()
|
||||
m.handleStar(w, req.WithContext(ctx))
|
||||
|
||||
body := w.Body.String()
|
||||
// Subsonic error envelope returns 200 OK with status="failed" inside the body.
|
||||
// The exact code is 70 (data not found).
|
||||
if w.Code != http.StatusOK || !contains(body, `"code":70`) && !contains(body, `code="70"`) {
|
||||
t.Errorf("expected error 70 envelope, got status=%d body=%s", w.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStar_PartialMissingAtomic(t *testing.T) {
|
||||
pool, user, track, _, _ := testStarPool(t)
|
||||
m := newStarHandlers(pool)
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("id", uuidToID(track.ID))
|
||||
q.Set("albumId", "00000000-0000-0000-0000-000000000000")
|
||||
req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil)
|
||||
ctx := context.WithValue(req.Context(), userCtxKey, user)
|
||||
w := httptest.NewRecorder()
|
||||
m.handleStar(w, req.WithContext(ctx))
|
||||
|
||||
// Album missing → atomic refusal: track NOT starred.
|
||||
var count int
|
||||
_ = pool.QueryRow(context.Background(),
|
||||
"SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("track was starred despite album miss; count = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUnstar_RemovesAndIsIdempotent(t *testing.T) {
|
||||
pool, user, track, _, _ := testStarPool(t)
|
||||
m := newStarHandlers(pool)
|
||||
|
||||
// First star.
|
||||
q := url.Values{}
|
||||
q.Set("id", uuidToID(track.ID))
|
||||
star := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil)
|
||||
starCtx := context.WithValue(star.Context(), userCtxKey, user)
|
||||
starResp := httptest.NewRecorder()
|
||||
m.handleStar(starResp, star.WithContext(starCtx))
|
||||
|
||||
// Unstar.
|
||||
un := httptest.NewRequest(http.MethodGet, "/rest/unstar?"+q.Encode(), nil)
|
||||
unCtx := context.WithValue(un.Context(), userCtxKey, user)
|
||||
unResp := httptest.NewRecorder()
|
||||
m.handleUnstar(unResp, un.WithContext(unCtx))
|
||||
|
||||
if unResp.Code != http.StatusOK {
|
||||
t.Fatalf("unstar status = %d", unResp.Code)
|
||||
}
|
||||
var count int
|
||||
_ = pool.QueryRow(context.Background(),
|
||||
"SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("count after unstar = %d, want 0", count)
|
||||
}
|
||||
|
||||
// Idempotent — unstar again.
|
||||
un2 := httptest.NewRequest(http.MethodGet, "/rest/unstar?"+q.Encode(), nil)
|
||||
un2 = un2.WithContext(context.WithValue(un2.Context(), userCtxKey, user))
|
||||
un2Resp := httptest.NewRecorder()
|
||||
m.handleUnstar(un2Resp, un2)
|
||||
if un2Resp.Code != http.StatusOK {
|
||||
t.Errorf("second unstar status = %d", un2Resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
for i := 0; i+len(substr) <= len(s); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestHandleGetStarred2_ReturnsAllThreeArrays(t *testing.T) {
|
||||
pool, user, track, album, artist := testStarPool(t)
|
||||
m := newStarHandlers(pool)
|
||||
q := dbq.New(pool)
|
||||
|
||||
// Star one of each.
|
||||
_ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: user.ID, TrackID: track.ID})
|
||||
_ = q.LikeAlbum(context.Background(), dbq.LikeAlbumParams{UserID: user.ID, AlbumID: album.ID})
|
||||
_ = q.LikeArtist(context.Background(), dbq.LikeArtistParams{UserID: user.ID, ArtistID: artist.ID})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/rest/getStarred2?f=json", nil)
|
||||
ctx := context.WithValue(req.Context(), userCtxKey, user)
|
||||
w := httptest.NewRecorder()
|
||||
m.handleGetStarred2(w, req.WithContext(ctx))
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
// Subsonic JSON envelope: {"subsonic-response":{...,"starred2":{...}}}
|
||||
var raw map[string]any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &raw)
|
||||
resp, _ := raw["subsonic-response"].(map[string]any)
|
||||
starred, _ := resp["starred2"].(map[string]any)
|
||||
if starred == nil {
|
||||
t.Fatalf("missing starred2 envelope: %v", raw)
|
||||
}
|
||||
songs, _ := starred["song"].([]any)
|
||||
albums, _ := starred["album"].([]any)
|
||||
artists, _ := starred["artist"].([]any)
|
||||
if len(songs) != 1 || len(albums) != 1 || len(artists) != 1 {
|
||||
t.Errorf("counts: songs=%d albums=%d artists=%d", len(songs), len(albums), len(artists))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetStarred2_CrossUserIsolation(t *testing.T) {
|
||||
pool, alice, track, _, _ := testStarPool(t)
|
||||
q := dbq.New(pool)
|
||||
bob, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{
|
||||
Username: "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false,
|
||||
})
|
||||
_ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: alice.ID, TrackID: track.ID})
|
||||
|
||||
m := newStarHandlers(pool)
|
||||
req := httptest.NewRequest(http.MethodGet, "/rest/getStarred2?f=json", nil)
|
||||
ctx := context.WithValue(req.Context(), userCtxKey, bob)
|
||||
w := httptest.NewRecorder()
|
||||
m.handleGetStarred2(w, req.WithContext(ctx))
|
||||
|
||||
var raw map[string]any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &raw)
|
||||
resp, _ := raw["subsonic-response"].(map[string]any)
|
||||
starred, _ := resp["starred2"].(map[string]any)
|
||||
songs, _ := starred["song"].([]any)
|
||||
if len(songs) != 0 {
|
||||
t.Errorf("bob's starred songs should be empty, got %d", len(songs))
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,10 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config, ev
|
||||
register(sub, "/download", m.handleDownload)
|
||||
register(sub, "/getCoverArt", m.handleGetCoverArt)
|
||||
register(sub, "/scrobble", m.handleScrobble)
|
||||
register(sub, "/star", m.handleStar)
|
||||
register(sub, "/unstar", m.handleUnstar)
|
||||
register(sub, "/getStarred", m.handleGetStarred)
|
||||
register(sub, "/getStarred2", m.handleGetStarred2)
|
||||
register(sub, "/getUser", handleGetUser)
|
||||
|
||||
// Subsonic clients expect every /rest/* miss to come back as a
|
||||
|
||||
@@ -211,6 +211,34 @@ type SearchResult3Response struct {
|
||||
SearchResult3 SearchResult3Container `json:"searchResult3" xml:"searchResult3"`
|
||||
}
|
||||
|
||||
// StarredContainer is the body of getStarred / getStarred2.
|
||||
type StarredContainer struct {
|
||||
XMLName xml.Name `json:"-" xml:"starred2"`
|
||||
Artists []ArtistRef `json:"artist" xml:"artist"`
|
||||
Albums []AlbumRef `json:"album" xml:"album"`
|
||||
Songs []SongRef `json:"song" xml:"song"`
|
||||
}
|
||||
|
||||
type GetStarred2Response struct {
|
||||
Envelope
|
||||
Starred2 StarredContainer `json:"starred2" xml:"starred2"`
|
||||
}
|
||||
|
||||
// GetStarredContainer mirrors getStarred (id3-less variant). Response uses
|
||||
// the same shape; the only material difference from getStarred2 is the
|
||||
// outer key name.
|
||||
type GetStarredContainer struct {
|
||||
XMLName xml.Name `json:"-" xml:"starred"`
|
||||
Artists []ArtistRef `json:"artist" xml:"artist"`
|
||||
Albums []AlbumRef `json:"album" xml:"album"`
|
||||
Songs []SongRef `json:"song" xml:"song"`
|
||||
}
|
||||
|
||||
type GetStarredResponse struct {
|
||||
Envelope
|
||||
Starred GetStarredContainer `json:"starred" xml:"starred"`
|
||||
}
|
||||
|
||||
// ---- conversion helpers ----
|
||||
|
||||
func artistRef(a dbq.Artist, albumCount int) ArtistRef {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { QueryClient } from '@tanstack/svelte-query';
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
api: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn().mockResolvedValue(null),
|
||||
del: vi.fn().mockResolvedValue(null)
|
||||
}
|
||||
}));
|
||||
|
||||
import { likeEntity, unlikeEntity } from './likes';
|
||||
import { qk } from './queries';
|
||||
import { api } from './client';
|
||||
|
||||
let client: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
client = new QueryClient();
|
||||
client.setQueryData(qk.likedIds(), {
|
||||
track_ids: ['t1'],
|
||||
album_ids: [],
|
||||
artist_ids: []
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => client.clear());
|
||||
|
||||
describe('likes mutations', () => {
|
||||
test('likeEntity track adds id to track_ids in cache (optimistic)', async () => {
|
||||
await likeEntity(client, 'track', 't2');
|
||||
const cached = client.getQueryData(qk.likedIds()) as
|
||||
| { track_ids: string[] } | undefined;
|
||||
expect(cached?.track_ids).toContain('t2');
|
||||
expect(api.post).toHaveBeenCalledWith('/api/likes/tracks/t2', undefined);
|
||||
});
|
||||
|
||||
test('likeEntity album hits albums endpoint and adds to album_ids', async () => {
|
||||
await likeEntity(client, 'album', 'al1');
|
||||
const cached = client.getQueryData(qk.likedIds()) as
|
||||
| { album_ids: string[] } | undefined;
|
||||
expect(cached?.album_ids).toContain('al1');
|
||||
expect(api.post).toHaveBeenCalledWith('/api/likes/albums/al1', undefined);
|
||||
});
|
||||
|
||||
test('likeEntity artist adds to artist_ids', async () => {
|
||||
await likeEntity(client, 'artist', 'ar1');
|
||||
const cached = client.getQueryData(qk.likedIds()) as
|
||||
| { artist_ids: string[] } | undefined;
|
||||
expect(cached?.artist_ids).toContain('ar1');
|
||||
expect(api.post).toHaveBeenCalledWith('/api/likes/artists/ar1', undefined);
|
||||
});
|
||||
|
||||
test('unlikeEntity removes id from cache', async () => {
|
||||
await unlikeEntity(client, 'track', 't1');
|
||||
const cached = client.getQueryData(qk.likedIds()) as
|
||||
| { track_ids: string[] } | undefined;
|
||||
expect(cached?.track_ids).not.toContain('t1');
|
||||
expect(api.del).toHaveBeenCalledWith('/api/likes/tracks/t1');
|
||||
});
|
||||
|
||||
test('likeEntity rolls back on error', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('boom'));
|
||||
try {
|
||||
await likeEntity(client, 'track', 't2');
|
||||
} catch { /* expected */ }
|
||||
const cached = client.getQueryData(qk.likedIds()) as
|
||||
| { track_ids: string[] } | undefined;
|
||||
expect(cached?.track_ids).not.toContain('t2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { QueryClient } from '@tanstack/svelte-query';
|
||||
import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query';
|
||||
import { api } from './client';
|
||||
import { qk, ARTIST_PAGE_SIZE } from './queries';
|
||||
import type {
|
||||
ArtistRef,
|
||||
AlbumRef,
|
||||
TrackRef,
|
||||
Page,
|
||||
LikedIdsResponse
|
||||
} from './types';
|
||||
|
||||
export type EntityKind = 'track' | 'album' | 'artist';
|
||||
|
||||
const PATH_BY_KIND: Record<EntityKind, 'tracks' | 'albums' | 'artists'> = {
|
||||
track: 'tracks',
|
||||
album: 'albums',
|
||||
artist: 'artists'
|
||||
};
|
||||
|
||||
const SET_KEY_BY_KIND: Record<EntityKind, keyof LikedIdsResponse> = {
|
||||
track: 'track_ids',
|
||||
album: 'album_ids',
|
||||
artist: 'artist_ids'
|
||||
};
|
||||
|
||||
export function createLikedIdsQuery() {
|
||||
return createQuery({
|
||||
queryKey: qk.likedIds(),
|
||||
queryFn: () => api.get<LikedIdsResponse>('/api/likes/ids'),
|
||||
staleTime: 60_000
|
||||
});
|
||||
}
|
||||
|
||||
export function createLikedTracksInfiniteQuery() {
|
||||
return createInfiniteQuery({
|
||||
queryKey: qk.likedTracks(),
|
||||
queryFn: ({ pageParam = 0 }) =>
|
||||
api.get<Page<TrackRef>>(`/api/likes/tracks?limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}`),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (last) => {
|
||||
const loaded = last.offset + last.items.length;
|
||||
return loaded >= last.total ? undefined : loaded;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createLikedAlbumsInfiniteQuery() {
|
||||
return createInfiniteQuery({
|
||||
queryKey: qk.likedAlbums(),
|
||||
queryFn: ({ pageParam = 0 }) =>
|
||||
api.get<Page<AlbumRef>>(`/api/likes/albums?limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}`),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (last) => {
|
||||
const loaded = last.offset + last.items.length;
|
||||
return loaded >= last.total ? undefined : loaded;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createLikedArtistsInfiniteQuery() {
|
||||
return createInfiniteQuery({
|
||||
queryKey: qk.likedArtists(),
|
||||
queryFn: ({ pageParam = 0 }) =>
|
||||
api.get<Page<ArtistRef>>(`/api/likes/artists?limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}`),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (last) => {
|
||||
const loaded = last.offset + last.items.length;
|
||||
return loaded >= last.total ? undefined : loaded;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// likeEntity does an optimistic insert into the likedIds cache, fires the
|
||||
// network call, and rolls back on error. Same shape for unlikeEntity.
|
||||
export async function likeEntity(
|
||||
client: QueryClient,
|
||||
kind: EntityKind,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const setKey = SET_KEY_BY_KIND[kind];
|
||||
const previous = client.getQueryData<LikedIdsResponse>(qk.likedIds());
|
||||
if (previous) {
|
||||
client.setQueryData<LikedIdsResponse>(qk.likedIds(), {
|
||||
...previous,
|
||||
[setKey]: previous[setKey].includes(id) ? previous[setKey] : [...previous[setKey], id]
|
||||
});
|
||||
}
|
||||
try {
|
||||
await api.post(`/api/likes/${PATH_BY_KIND[kind]}/${id}`, undefined);
|
||||
} catch (err) {
|
||||
if (previous) client.setQueryData(qk.likedIds(), previous);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unlikeEntity(
|
||||
client: QueryClient,
|
||||
kind: EntityKind,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const setKey = SET_KEY_BY_KIND[kind];
|
||||
const previous = client.getQueryData<LikedIdsResponse>(qk.likedIds());
|
||||
if (previous) {
|
||||
client.setQueryData<LikedIdsResponse>(qk.likedIds(), {
|
||||
...previous,
|
||||
[setKey]: previous[setKey].filter((x) => x !== id)
|
||||
});
|
||||
}
|
||||
try {
|
||||
await api.del(`/api/likes/${PATH_BY_KIND[kind]}/${id}`);
|
||||
} catch (err) {
|
||||
if (previous) client.setQueryData(qk.likedIds(), previous);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@ export const qk = {
|
||||
searchArtists: (q: string) => ['searchArtists', { q }] as const,
|
||||
searchAlbums: (q: string) => ['searchAlbums', { q }] as const,
|
||||
searchTracks: (q: string) => ['searchTracks', { q }] as const,
|
||||
likedIds: () => ['likedIds'] as const,
|
||||
likedTracks: () => ['likedTracks'] as const,
|
||||
likedAlbums: () => ['likedAlbums'] as const,
|
||||
likedArtists: () => ['likedArtists'] as const,
|
||||
};
|
||||
|
||||
export function createArtistsQuery(sort: ArtistSort) {
|
||||
|
||||
@@ -57,6 +57,12 @@ export type RadioResponse = {
|
||||
tracks: TrackRef[];
|
||||
};
|
||||
|
||||
export type LikedIdsResponse = {
|
||||
track_ids: string[];
|
||||
album_ids: string[];
|
||||
artist_ids: string[];
|
||||
};
|
||||
|
||||
export type EventRequest =
|
||||
| { type: 'play_started'; track_id: string; client_id?: string }
|
||||
| { type: 'play_ended'; play_event_id: string; duration_played_ms: number }
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { FALLBACK_COVER } from '$lib/media/covers';
|
||||
import { api } from '$lib/api/client';
|
||||
import { enqueueTracks } from '$lib/player/store.svelte';
|
||||
import LikeButton from './LikeButton.svelte';
|
||||
|
||||
let { album }: { album: AlbumRef } = $props();
|
||||
|
||||
@@ -35,10 +36,13 @@
|
||||
<div class="text-xs text-text-secondary">{album.year}</div>
|
||||
{/if}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add to queue"
|
||||
onclick={onAddClick}
|
||||
class="absolute right-2 top-2 rounded-full bg-surface/80 px-2 py-1 text-sm text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>+</button>
|
||||
<div class="absolute right-2 top-2 flex gap-1">
|
||||
<LikeButton entityType="album" entityId={album.id} />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add to queue"
|
||||
onclick={onAddClick}
|
||||
class="rounded-full bg-surface/80 px-2 py-1 text-sm text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import type { AlbumRef, AlbumDetail, TrackRef } from '$lib/api/types';
|
||||
import { FALLBACK_COVER } from '$lib/media/covers';
|
||||
import { readable } from 'svelte/store';
|
||||
|
||||
vi.mock('$lib/api/client', () => ({
|
||||
api: { get: vi.fn() }
|
||||
@@ -10,6 +11,21 @@ vi.mock('$lib/player/store.svelte', () => ({
|
||||
enqueueTracks: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/likes', () => ({
|
||||
createLikedIdsQuery: () => readable({
|
||||
data: { track_ids: [], album_ids: [], artist_ids: [] },
|
||||
isPending: false,
|
||||
isError: false
|
||||
}),
|
||||
likeEntity: vi.fn(),
|
||||
unlikeEntity: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({}) };
|
||||
});
|
||||
|
||||
import AlbumCard from './AlbumCard.svelte';
|
||||
import { api } from '$lib/api/client';
|
||||
import { enqueueTracks } from '$lib/player/store.svelte';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { ArtistRef } from '$lib/api/types';
|
||||
import LikeButton from './LikeButton.svelte';
|
||||
|
||||
let { artist }: { artist: ArtistRef } = $props();
|
||||
|
||||
@@ -7,17 +8,22 @@
|
||||
const countLabel = $derived(artist.album_count === 1 ? '1 album' : `${artist.album_count} albums`);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={`/artists/${artist.id}`}
|
||||
class="flex items-center gap-3 border-b border-border px-3 py-3 hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<div class="relative flex items-center gap-3 border-b border-border px-3 py-3 hover:bg-surface-hover">
|
||||
<a
|
||||
href={`/artists/${artist.id}`}
|
||||
class="absolute inset-0 focus-visible:ring-2 focus-visible:ring-accent"
|
||||
aria-label={artist.name}
|
||||
></a>
|
||||
<span
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface font-semibold"
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface font-semibold relative"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
<span class="flex-1 truncate">{artist.name}</span>
|
||||
<span class="text-sm text-text-secondary">{countLabel}</span>
|
||||
<span class="text-text-secondary" aria-hidden="true">›</span>
|
||||
</a>
|
||||
<span class="flex-1 truncate relative">{artist.name}</span>
|
||||
<span class="text-sm text-text-secondary relative">{countLabel}</span>
|
||||
<span class="relative">
|
||||
<LikeButton entityType="artist" entityId={artist.id} />
|
||||
</span>
|
||||
<span class="text-text-secondary relative" aria-hidden="true">›</span>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { readable } from 'svelte/store';
|
||||
import ArtistRow from './ArtistRow.svelte';
|
||||
import type { ArtistRef } from '$lib/api/types';
|
||||
|
||||
vi.mock('$lib/api/likes', () => ({
|
||||
createLikedIdsQuery: () => readable({
|
||||
data: { track_ids: [], album_ids: [], artist_ids: [] },
|
||||
isPending: false,
|
||||
isError: false
|
||||
}),
|
||||
likeEntity: vi.fn(),
|
||||
unlikeEntity: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({}) };
|
||||
});
|
||||
|
||||
const artist: ArtistRef = { id: 'abc', name: 'alice', album_count: 12 };
|
||||
|
||||
describe('ArtistRow', () => {
|
||||
@@ -11,14 +27,14 @@ describe('ArtistRow', () => {
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/artists/abc');
|
||||
expect(link).toHaveTextContent('alice');
|
||||
expect(link).toHaveTextContent('12 albums');
|
||||
expect(link).toHaveTextContent('A'); // initial letter
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('12 albums')).toBeInTheDocument();
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('singular "1 album" when album_count is 1', () => {
|
||||
render(ArtistRow, { props: { artist: { ...artist, album_count: 1 } } });
|
||||
expect(screen.getByRole('link')).toHaveTextContent('1 album');
|
||||
expect(screen.getByText('1 album')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('empty name still renders a safe initial', () => {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { createLikedIdsQuery, likeEntity, unlikeEntity, type EntityKind } from '$lib/api/likes';
|
||||
|
||||
let {
|
||||
entityType,
|
||||
entityId
|
||||
}: { entityType: EntityKind; entityId: string } = $props();
|
||||
|
||||
const SET_KEY = {
|
||||
track: 'track_ids',
|
||||
album: 'album_ids',
|
||||
artist: 'artist_ids'
|
||||
} as const;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const queryStore = $derived(createLikedIdsQuery());
|
||||
const query = $derived($queryStore);
|
||||
|
||||
const liked = $derived.by(() => {
|
||||
const data = query?.data;
|
||||
if (!data) return false;
|
||||
return data[SET_KEY[entityType]].includes(entityId);
|
||||
});
|
||||
|
||||
async function onClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (liked) {
|
||||
await unlikeEntity(queryClient, entityType, entityId);
|
||||
} else {
|
||||
await likeEntity(queryClient, entityType, entityId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={liked ? 'Unlike' : 'Like'}
|
||||
aria-pressed={liked}
|
||||
onclick={onClick}
|
||||
class="rounded p-1 {liked ? 'text-accent' : 'text-text-secondary hover:text-text-primary'}"
|
||||
>{liked ? '♥' : '♡'}</button>
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import { readable } from 'svelte/store';
|
||||
|
||||
const cacheState = vi.hoisted(() => ({
|
||||
data: { track_ids: ['t1'] as string[], album_ids: [] as string[], artist_ids: [] as string[] }
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/likes', () => ({
|
||||
createLikedIdsQuery: () => readable({ data: cacheState.data, isPending: false, isError: false }),
|
||||
likeEntity: vi.fn().mockResolvedValue(undefined),
|
||||
unlikeEntity: vi.fn().mockResolvedValue(undefined)
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
useQueryClient: () => ({})
|
||||
};
|
||||
});
|
||||
|
||||
import LikeButton from './LikeButton.svelte';
|
||||
import { likeEntity, unlikeEntity } from '$lib/api/likes';
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cacheState.data = { track_ids: ['t1'], album_ids: [], artist_ids: [] };
|
||||
});
|
||||
|
||||
describe('LikeButton', () => {
|
||||
test('renders filled heart when track id IS in the set', () => {
|
||||
render(LikeButton, { props: { entityType: 'track', entityId: 't1' } });
|
||||
const btn = screen.getByRole('button', { name: /unlike/i });
|
||||
expect(btn.getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
test('renders empty heart when track id is NOT in the set', () => {
|
||||
render(LikeButton, { props: { entityType: 'track', entityId: 't2' } });
|
||||
const btn = screen.getByRole('button', { name: /like/i });
|
||||
expect(btn.getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
test('click on empty heart calls likeEntity', async () => {
|
||||
render(LikeButton, { props: { entityType: 'track', entityId: 't2' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /like/i }));
|
||||
expect(likeEntity).toHaveBeenCalledWith(expect.anything(), 'track', 't2');
|
||||
});
|
||||
|
||||
test('click on filled heart calls unlikeEntity', async () => {
|
||||
render(LikeButton, { props: { entityType: 'track', entityId: 't1' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /unlike/i }));
|
||||
expect(unlikeEntity).toHaveBeenCalledWith(expect.anything(), 'track', 't1');
|
||||
});
|
||||
|
||||
test('click stops propagation', async () => {
|
||||
const parentClick = vi.fn();
|
||||
const { container } = render(LikeButton, { props: { entityType: 'track', entityId: 't2' } });
|
||||
const wrapper = container.parentElement!;
|
||||
wrapper.addEventListener('click', parentClick);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /like/i }));
|
||||
expect(parentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('album entityType reads from album_ids set', () => {
|
||||
cacheState.data = { track_ids: [], album_ids: ['al1'], artist_ids: [] };
|
||||
render(LikeButton, { props: { entityType: 'album', entityId: 'al1' } });
|
||||
expect(screen.getByRole('button', { name: /unlike/i }).getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
} from '$lib/player/store.svelte';
|
||||
import { formatDuration } from '$lib/media/duration';
|
||||
import { FALLBACK_COVER } from '$lib/media/covers';
|
||||
import LikeButton from './LikeButton.svelte';
|
||||
|
||||
const current = $derived(player.current);
|
||||
|
||||
@@ -48,7 +49,7 @@
|
||||
onerror={onCoverError}
|
||||
/>
|
||||
</a>
|
||||
<div class="min-w-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium">{current.title}</div>
|
||||
<a
|
||||
href={`/artists/${current.artist_id}`}
|
||||
@@ -57,6 +58,7 @@
|
||||
{current.artist_name}
|
||||
</a>
|
||||
</div>
|
||||
<LikeButton entityType="track" entityId={current.id} />
|
||||
</div>
|
||||
|
||||
<!-- Center: seek row + transport row -->
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
import { readable } from 'svelte/store';
|
||||
|
||||
// Mutable state the mocked store reads from.
|
||||
const state = vi.hoisted(() => ({
|
||||
@@ -40,6 +41,21 @@ vi.mock('$lib/player/store.svelte', () => ({
|
||||
playQueue: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/likes', () => ({
|
||||
createLikedIdsQuery: () => readable({
|
||||
data: { track_ids: [], album_ids: [], artist_ids: [] },
|
||||
isPending: false,
|
||||
isError: false
|
||||
}),
|
||||
likeEntity: vi.fn(),
|
||||
unlikeEntity: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({}) };
|
||||
});
|
||||
|
||||
import PlayerBar from './PlayerBar.svelte';
|
||||
import {
|
||||
togglePlay, skipNext, skipPrev, seekTo, setVolume,
|
||||
|
||||
@@ -22,9 +22,10 @@
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Library' },
|
||||
{ href: '/search', label: 'Search' },
|
||||
{ href: '/playlists', label: 'Playlists' }
|
||||
{ href: '/', label: 'Library' },
|
||||
{ href: '/library/liked', label: 'Liked' },
|
||||
{ href: '/search', label: 'Search' },
|
||||
{ href: '/playlists', label: 'Playlists' }
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
import { formatDuration } from '$lib/media/duration';
|
||||
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
||||
import LikeButton from './LikeButton.svelte';
|
||||
|
||||
type PlayHandler = (tracks: TrackRef[], index: number) => void;
|
||||
|
||||
@@ -41,12 +42,13 @@
|
||||
aria-label={track.title}
|
||||
onclick={onRowClick}
|
||||
onkeydown={onRowKey}
|
||||
class="grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
class="grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<span class="text-right tabular-nums text-text-secondary">
|
||||
{track.track_number ?? '—'}
|
||||
</span>
|
||||
<span class="truncate">{track.title}</span>
|
||||
<LikeButton entityType="track" entityId={track.id} />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add to queue"
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
import { readable } from 'svelte/store';
|
||||
|
||||
vi.mock('$lib/player/store.svelte', () => ({
|
||||
playQueue: vi.fn(),
|
||||
enqueueTrack: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/likes', () => ({
|
||||
createLikedIdsQuery: () => readable({
|
||||
data: { track_ids: [], album_ids: [], artist_ids: [] },
|
||||
isPending: false,
|
||||
isError: false
|
||||
}),
|
||||
likeEntity: vi.fn(),
|
||||
unlikeEntity: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({}) };
|
||||
});
|
||||
|
||||
import TrackRow from './TrackRow.svelte';
|
||||
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { flushSync } from 'svelte';
|
||||
import { readable } from 'svelte/store';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import type { AlbumDetail, TrackRef } from '$lib/api/types';
|
||||
|
||||
@@ -18,6 +19,21 @@ vi.mock('$lib/api/queries', () => ({
|
||||
createAlbumQuery: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/likes', () => ({
|
||||
createLikedIdsQuery: () => readable({
|
||||
data: { track_ids: [], album_ids: [], artist_ids: [] },
|
||||
isPending: false,
|
||||
isError: false
|
||||
}),
|
||||
likeEntity: vi.fn(),
|
||||
unlikeEntity: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({}) };
|
||||
});
|
||||
|
||||
import AlbumPage from './+page.svelte';
|
||||
import { createAlbumQuery } from '$lib/api/queries';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import { flushSync } from 'svelte';
|
||||
import { readable } from 'svelte/store';
|
||||
import { mockInfiniteQuery } from '../test-utils/query';
|
||||
import type { ArtistRef, Page } from '$lib/api/types';
|
||||
|
||||
@@ -19,6 +20,21 @@ vi.mock('$lib/api/queries', () => ({
|
||||
createArtistsQuery: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/likes', () => ({
|
||||
createLikedIdsQuery: () => readable({
|
||||
data: { track_ids: [], album_ids: [], artist_ids: [] },
|
||||
isPending: false,
|
||||
isError: false
|
||||
}),
|
||||
likeEntity: vi.fn(),
|
||||
unlikeEntity: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({}) };
|
||||
});
|
||||
|
||||
import ArtistsPage from './+page.svelte';
|
||||
import { createArtistsQuery } from '$lib/api/queries';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { flushSync } from 'svelte';
|
||||
import { readable } from 'svelte/store';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import type { ArtistDetail, AlbumRef } from '$lib/api/types';
|
||||
|
||||
@@ -18,6 +19,21 @@ vi.mock('$lib/api/queries', () => ({
|
||||
createArtistQuery: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/likes', () => ({
|
||||
createLikedIdsQuery: () => readable({
|
||||
data: { track_ids: [], album_ids: [], artist_ids: [] },
|
||||
isPending: false,
|
||||
isError: false
|
||||
}),
|
||||
likeEntity: vi.fn(),
|
||||
unlikeEntity: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({}) };
|
||||
});
|
||||
|
||||
import ArtistPage from './+page.svelte';
|
||||
import { createArtistQuery } from '$lib/api/queries';
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
createLikedTracksInfiniteQuery,
|
||||
createLikedAlbumsInfiniteQuery,
|
||||
createLikedArtistsInfiniteQuery
|
||||
} from '$lib/api/likes';
|
||||
import ArtistRow from '$lib/components/ArtistRow.svelte';
|
||||
import AlbumCard from '$lib/components/AlbumCard.svelte';
|
||||
import TrackRow from '$lib/components/TrackRow.svelte';
|
||||
|
||||
const artistsStore = $derived(createLikedArtistsInfiniteQuery());
|
||||
const albumsStore = $derived(createLikedAlbumsInfiniteQuery());
|
||||
const tracksStore = $derived(createLikedTracksInfiniteQuery());
|
||||
|
||||
const artistsQuery = $derived($artistsStore);
|
||||
const albumsQuery = $derived($albumsStore);
|
||||
const tracksQuery = $derived($tracksStore);
|
||||
|
||||
const artists = $derived(artistsQuery?.data?.pages?.flatMap((p) => p.items) ?? []);
|
||||
const albums = $derived(albumsQuery?.data?.pages?.flatMap((p) => p.items) ?? []);
|
||||
const tracks = $derived(tracksQuery?.data?.pages?.flatMap((p) => p.items) ?? []);
|
||||
|
||||
const artistsTotal = $derived(artistsQuery?.data?.pages?.[0]?.total ?? 0);
|
||||
const albumsTotal = $derived(albumsQuery?.data?.pages?.[0]?.total ?? 0);
|
||||
const tracksTotal = $derived(tracksQuery?.data?.pages?.[0]?.total ?? 0);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-semibold">Liked</h1>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-2 text-lg font-semibold">Artists</h2>
|
||||
{#if artistsTotal === 0}
|
||||
<p class="text-text-secondary">No liked artists yet.</p>
|
||||
{:else}
|
||||
<div>
|
||||
{#each artists as a (a.id)}
|
||||
<ArtistRow artist={a} />
|
||||
{/each}
|
||||
</div>
|
||||
{#if artistsQuery?.hasNextPage}
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
|
||||
disabled={artistsQuery.isFetchingNextPage}
|
||||
onclick={() => artistsQuery.fetchNextPage()}
|
||||
>
|
||||
{artistsQuery.isFetchingNextPage ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-2 text-lg font-semibold">Albums</h2>
|
||||
{#if albumsTotal === 0}
|
||||
<p class="text-text-secondary">No liked albums yet.</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{#each albums as al (al.id)}
|
||||
<AlbumCard album={al} />
|
||||
{/each}
|
||||
</div>
|
||||
{#if albumsQuery?.hasNextPage}
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
|
||||
disabled={albumsQuery.isFetchingNextPage}
|
||||
onclick={() => albumsQuery.fetchNextPage()}
|
||||
>
|
||||
{albumsQuery.isFetchingNextPage ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-2 text-lg font-semibold">Tracks</h2>
|
||||
{#if tracksTotal === 0}
|
||||
<p class="text-text-secondary">No liked tracks yet.</p>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded border border-border">
|
||||
{#each tracks as t, i (t.id)}
|
||||
<TrackRow tracks={tracks} index={i} />
|
||||
{/each}
|
||||
</div>
|
||||
{#if tracksQuery?.hasNextPage}
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
|
||||
disabled={tracksQuery.isFetchingNextPage}
|
||||
onclick={() => tracksQuery.fetchNextPage()}
|
||||
>
|
||||
{tracksQuery.isFetchingNextPage ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import { mockInfiniteQuery } from '../../../test-utils/query';
|
||||
import type { ArtistRef, AlbumRef, TrackRef, Page } from '$lib/api/types';
|
||||
import { readable } from 'svelte/store';
|
||||
|
||||
vi.mock('$lib/api/likes', () => ({
|
||||
createLikedIdsQuery: () => readable({
|
||||
data: { track_ids: [], album_ids: [], artist_ids: [] },
|
||||
isPending: false,
|
||||
isError: false
|
||||
}),
|
||||
createLikedTracksInfiniteQuery: vi.fn(),
|
||||
createLikedAlbumsInfiniteQuery: vi.fn(),
|
||||
createLikedArtistsInfiniteQuery: vi.fn(),
|
||||
likeEntity: vi.fn(),
|
||||
unlikeEntity: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({}) };
|
||||
});
|
||||
|
||||
vi.mock('$lib/api/client', () => ({ api: { get: vi.fn() } }));
|
||||
vi.mock('$lib/player/store.svelte', () => ({
|
||||
playQueue: vi.fn(), enqueueTrack: vi.fn(), enqueueTracks: vi.fn()
|
||||
}));
|
||||
|
||||
import LikedPage from './+page.svelte';
|
||||
import {
|
||||
createLikedTracksInfiniteQuery,
|
||||
createLikedAlbumsInfiniteQuery,
|
||||
createLikedArtistsInfiniteQuery
|
||||
} from '$lib/api/likes';
|
||||
|
||||
function page<T>(items: T[], total: number, offset = 0, limit = 50): Page<T> {
|
||||
return { items, total, offset, limit };
|
||||
}
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('liked library page', () => {
|
||||
test('renders three sections when each has items', () => {
|
||||
const ar: ArtistRef = { id: 'a1', name: 'X', album_count: 1 };
|
||||
const al: AlbumRef = {
|
||||
id: 'al1', title: 'Y', artist_id: 'a1', artist_name: 'X',
|
||||
year: 2020, track_count: 1, duration_sec: 100, cover_url: '/x'
|
||||
};
|
||||
const tr: TrackRef = {
|
||||
id: 't1', title: 'Z', album_id: 'al1', album_title: 'Y',
|
||||
artist_id: 'a1', artist_name: 'X',
|
||||
track_number: 1, disc_number: 1, duration_sec: 100,
|
||||
stream_url: '/api/tracks/t1/stream'
|
||||
};
|
||||
(createLikedArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page([ar], 1)] })
|
||||
);
|
||||
(createLikedAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page([al], 1)] })
|
||||
);
|
||||
(createLikedTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page([tr], 1)] })
|
||||
);
|
||||
render(LikedPage);
|
||||
expect(screen.getByRole('heading', { name: /artists/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /albums/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /tracks/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('empty section renders "No liked X yet" hint', () => {
|
||||
(createLikedArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page<ArtistRef>([], 0)] })
|
||||
);
|
||||
(createLikedAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page<AlbumRef>([], 0)] })
|
||||
);
|
||||
(createLikedTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page<TrackRef>([], 0)] })
|
||||
);
|
||||
render(LikedPage);
|
||||
expect(screen.getAllByText(/no liked/i)).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('Load more calls fetchNextPage on each section that has more', async () => {
|
||||
const fetchTracks = vi.fn();
|
||||
(createLikedTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page<TrackRef>([], 100)], hasNextPage: true, fetchNextPage: fetchTracks })
|
||||
);
|
||||
(createLikedAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page<AlbumRef>([], 0)] })
|
||||
);
|
||||
(createLikedArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page<ArtistRef>([], 0)] })
|
||||
);
|
||||
render(LikedPage);
|
||||
const buttons = screen.getAllByRole('button', { name: /load more/i });
|
||||
expect(buttons).toHaveLength(1);
|
||||
await fireEvent.click(buttons[0]);
|
||||
expect(fetchTracks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import { readable } from 'svelte/store';
|
||||
import { mockInfiniteQuery } from '../../../test-utils/query';
|
||||
import type { AlbumRef, Page } from '$lib/api/types';
|
||||
|
||||
@@ -20,6 +21,21 @@ vi.mock('$lib/player/store.svelte', () => ({
|
||||
enqueueTracks: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/likes', () => ({
|
||||
createLikedIdsQuery: () => readable({
|
||||
data: { track_ids: [], album_ids: [], artist_ids: [] },
|
||||
isPending: false,
|
||||
isError: false
|
||||
}),
|
||||
likeEntity: vi.fn(),
|
||||
unlikeEntity: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({}) };
|
||||
});
|
||||
|
||||
import AlbumsOverflow from './+page.svelte';
|
||||
import { createSearchAlbumsInfiniteQuery } from '$lib/api/queries';
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import { readable } from 'svelte/store';
|
||||
import { mockInfiniteQuery } from '../../../test-utils/query';
|
||||
import type { ArtistRef, Page } from '$lib/api/types';
|
||||
|
||||
@@ -13,6 +14,21 @@ vi.mock('$lib/api/queries', () => ({
|
||||
createSearchArtistsInfiniteQuery: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/likes', () => ({
|
||||
createLikedIdsQuery: () => readable({
|
||||
data: { track_ids: [], album_ids: [], artist_ids: [] },
|
||||
isPending: false,
|
||||
isError: false
|
||||
}),
|
||||
likeEntity: vi.fn(),
|
||||
unlikeEntity: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({}) };
|
||||
});
|
||||
|
||||
import ArtistsOverflow from './+page.svelte';
|
||||
import { createSearchArtistsInfiniteQuery } from '$lib/api/queries';
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { readable } from 'svelte/store';
|
||||
import { mockQuery } from '../../test-utils/query';
|
||||
import type { ArtistRef, AlbumRef, TrackRef, SearchResponse } from '$lib/api/types';
|
||||
|
||||
@@ -26,6 +27,21 @@ vi.mock('$lib/api/client', () => ({
|
||||
api: { get: vi.fn() }
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/likes', () => ({
|
||||
createLikedIdsQuery: () => readable({
|
||||
data: { track_ids: [], album_ids: [], artist_ids: [] },
|
||||
isPending: false,
|
||||
isError: false
|
||||
}),
|
||||
likeEntity: vi.fn(),
|
||||
unlikeEntity: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({}) };
|
||||
});
|
||||
|
||||
import SearchPage from './+page.svelte';
|
||||
import { createSearchQuery } from '$lib/api/queries';
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import { readable } from 'svelte/store';
|
||||
import { mockInfiniteQuery } from '../../../test-utils/query';
|
||||
import type { TrackRef, Page } from '$lib/api/types';
|
||||
|
||||
@@ -19,6 +20,21 @@ vi.mock('$lib/player/store.svelte', () => ({
|
||||
enqueueTrack: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/likes', () => ({
|
||||
createLikedIdsQuery: () => readable({
|
||||
data: { track_ids: [], album_ids: [], artist_ids: [] },
|
||||
isPending: false,
|
||||
isError: false
|
||||
}),
|
||||
likeEntity: vi.fn(),
|
||||
unlikeEntity: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({}) };
|
||||
});
|
||||
|
||||
import TracksOverflow from './+page.svelte';
|
||||
import { createSearchTracksInfiniteQuery } from '$lib/api/queries';
|
||||
import { playRadio } from '$lib/player/store.svelte';
|
||||
|
||||
Reference in New Issue
Block a user