Compare commits
11
Commits
efedf1a6bf
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a3f1e4a24 | ||
|
|
866f243f1b | ||
|
|
789bf91eed | ||
|
|
933ed31550 | ||
|
|
238cf0d4a0 | ||
|
|
a921f9e2bc | ||
|
|
2a69f6dc33 | ||
|
|
b6acd63c2d | ||
|
|
6fb46a989e | ||
|
|
ec28053d36 | ||
|
|
e48f063f03 |
@@ -1,71 +1,57 @@
|
||||
# VibeSonic
|
||||
|
||||
VibeSonic is a lightweight, self-hosted music/media server written in Go.
|
||||
It follows the same philosophy as other small `Vibe*` tools: a single Go
|
||||
binary, SQLite storage, server-rendered Bootstrap 5 templates, dark mode
|
||||
by default, minimal JavaScript, and no external services (no Redis, no
|
||||
message broker, no microservices, nothing Kubernetes-specific).
|
||||
VibeSonic is a lightweight, self-hosted music/media server written in
|
||||
Go. Single binary, SQLite storage, server-rendered Bootstrap 5
|
||||
templates, dark mode by default, minimal JavaScript, no external
|
||||
services (no Redis, no message broker, no microservices).
|
||||
|
||||
**This is Phase 2 of 4.** Phase 1 built the foundation (config, schema,
|
||||
auth, scanner, metadata, streaming, cover art, web UI shell). Phase 2
|
||||
adds a **complete implementation of the original Subsonic REST API
|
||||
through version 1.16.1** under `/rest/*` — every endpoint, backed by
|
||||
real data, including transcoding, HLS, a real mpv-backed jukebox, and a
|
||||
real podcast subsystem. See
|
||||
It implements the **original Subsonic REST API through version
|
||||
1.16.1**, in full, plus a set of **OpenSubsonic extensions**, so it
|
||||
works with existing Subsonic/OpenSubsonic client apps, alongside a
|
||||
complete server-rendered web UI for browser use. See
|
||||
[`docs/subsonic-compatibility.md`](docs/subsonic-compatibility.md) for
|
||||
the full endpoint-by-endpoint matrix. OpenSubsonic extensions are not
|
||||
yet implemented (Phase 3).
|
||||
the endpoint-by-endpoint matrix and
|
||||
[`docs/client-compatibility.md`](docs/client-compatibility.md) for
|
||||
real-client testing status.
|
||||
|
||||
## Features
|
||||
|
||||
### Phase 1: foundation
|
||||
|
||||
- SQLite storage (WAL mode, foreign keys, versioned migrations) with a
|
||||
schema already modeling artists, albums, songs, genres, contributors,
|
||||
playlists, favorites, ratings, scrobbles, playback history, bookmarks,
|
||||
play queues, shares, podcasts, internet radio, and settings
|
||||
- Native authentication: Argon2id password hashing, HTTP-only signed
|
||||
session cookies, CSRF-protected forms, session revocation
|
||||
- Optional Navidrome-style external authentication via a trusted reverse
|
||||
proxy header, gated on the real TCP peer address (never on
|
||||
spoofable `X-Forwarded-For`/`X-Real-IP`/`Forwarded` headers)
|
||||
- A library scanner supporting multiple music folders, incremental
|
||||
rescans, new/changed/deleted/renamed file detection, and a bounded
|
||||
worker pool — never a blind full rebuild
|
||||
- Real metadata extraction via `ffprobe` (multi-artist, multi-genre,
|
||||
MusicBrainz IDs, ReplayGain, embedded artwork/lyrics, sort fields, and
|
||||
more)
|
||||
- Stable, opaque, URL-safe IDs that survive restarts and rescans
|
||||
- Direct media streaming with HTTP range request support, correct MIME
|
||||
types, and no full-file buffering
|
||||
- Cover art discovery (embedded artwork or folder images) with a
|
||||
resized-image disk cache
|
||||
- A server-rendered, dark-mode, keyboard-friendly web UI: browsing,
|
||||
search, playlists, favorites, a persistent bottom player, and an admin
|
||||
section (users, music folders, scanner status, system settings)
|
||||
- SQLite storage (WAL mode, foreign keys, versioned migrations)
|
||||
- Native authentication (Argon2id, HTTP-only session cookies, CSRF) and
|
||||
optional Navidrome-style external authentication via a trusted
|
||||
reverse-proxy header, gated on the real TCP peer address — see
|
||||
[`docs/external-auth.md`](docs/external-auth.md)
|
||||
- A library scanner: multiple music folders, incremental rescans,
|
||||
new/changed/deleted/renamed detection, bounded worker pool
|
||||
- Real metadata extraction via `ffprobe` (multi-artist/genre,
|
||||
MusicBrainz IDs, ReplayGain, embedded artwork/lyrics, sort fields)
|
||||
- Direct streaming with HTTP range support, plus real ffmpeg-backed
|
||||
transcoding (mp3/opus/aac) and HLS segmenting, bounded concurrency,
|
||||
cancelled on client disconnect
|
||||
- Structured lyrics: plain, line-synced, and (where the source
|
||||
provides it) word/syllable-level timing, exposed via the
|
||||
`songLyrics` OpenSubsonic extension and rendered in the web player
|
||||
with click-to-seek and active-line highlighting
|
||||
- Sonic similarity: real audio-feature analysis (tempo, loudness,
|
||||
spectral shape, chroma) computed during scanning, used for
|
||||
similar-track lookups and path-finding between songs
|
||||
- Optional MusicBrainz/Cover Art Archive metadata enrichment (off by
|
||||
default — VibeSonic works fully offline otherwise)
|
||||
- Podcasts (RSS subscriptions, bounded background downloads, restart
|
||||
recovery), Internet radio stations, shares (public, no-login-required
|
||||
links), bookmarks, and multi-device play queues
|
||||
- A real jukebox backed by `mpv`'s JSON IPC — server-side playback, not
|
||||
a simulation; cleanly unavailable if `mpv` isn't installed
|
||||
- API keys, with the full Subsonic per-user permission model
|
||||
(admin/settings/stream/download/upload/playlist/cover art/comment/
|
||||
podcast/share/jukebox/video-conversion roles, plus per-user music
|
||||
folder access)
|
||||
- A server-rendered, dark-mode, keyboard-friendly web UI covering
|
||||
browsing, search, playlists (including M3U import/export),
|
||||
favorites, a persistent player with shuffle/repeat and the Media
|
||||
Session API, and a full admin section
|
||||
- `/healthz` and `/readyz`
|
||||
|
||||
### Phase 2: Subsonic REST API
|
||||
|
||||
- All 76 endpoints of the original Subsonic API through 1.16.1 —
|
||||
browsing (filesystem and ID3), search, playlists, favorites/ratings/
|
||||
scrobbling, sharing, user management with the full permission model,
|
||||
bookmarks, play queue, chat, and library scan status
|
||||
- Real ffmpeg-backed transcoding (mp3/opus/aac) and HLS segmenting for
|
||||
`stream`/`hls`, bounded concurrency, killed on client disconnect
|
||||
- A real podcast subsystem: RSS feed refresh, bounded background
|
||||
downloads, restart recovery, downloaded episodes stream like any other
|
||||
song
|
||||
- A real jukebox backed by `mpv` over its JSON IPC socket — server-side
|
||||
playback, not a simulation; administratively unavailable (with a clear
|
||||
error) if `mpv` isn't installed
|
||||
- Both original Subsonic auth modes (token/salt and password, including
|
||||
`enc:` hex), verified against an AES-256-GCM-encrypted credential kept
|
||||
separate from the Argon2id hash used for native login — see
|
||||
[`docs/subsonic-compatibility.md`](docs/subsonic-compatibility.md#authentication)
|
||||
- `/rest/*` authentication is entirely independent of the browser
|
||||
session/CSRF/external-auth flow
|
||||
|
||||
## Quick start
|
||||
|
||||
```
|
||||
@@ -84,6 +70,11 @@ cover art extraction, and transcoding/HLS to work. `mpv` should be on
|
||||
available — without it, `jukeboxControl` returns a clear "unavailable"
|
||||
error rather than pretending to work.
|
||||
|
||||
Point any Subsonic/OpenSubsonic client at `http://<host>:8080` (base
|
||||
URL only, no `/rest` suffix — clients construct that themselves) with a
|
||||
VibeSonic username/password, or an API key generated from account
|
||||
settings.
|
||||
|
||||
### Docker
|
||||
|
||||
```
|
||||
@@ -92,10 +83,14 @@ docker compose up -d
|
||||
|
||||
See [`docker-compose.yml`](docker-compose.yml) — mount your music
|
||||
directory read-only at `/music` and set `VIBESONIC_SESSION_SECRET`.
|
||||
Docker is convenient but never required; see
|
||||
[`docs/deployment.md`](docs/deployment.md) for running natively.
|
||||
|
||||
### systemd
|
||||
|
||||
See [`systemd/vibesonic.service`](systemd/vibesonic.service).
|
||||
See [`systemd/vibesonic.service`](systemd/vibesonic.service) and
|
||||
[`docs/deployment.md`](docs/deployment.md) for install steps and
|
||||
reverse-proxy examples (Caddy/nginx/Traefik).
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -108,7 +103,11 @@ full list and defaults. There is no separate config file.
|
||||
- [`docs/architecture.md`](docs/architecture.md) — how the pieces fit together
|
||||
- [`docs/configuration.md`](docs/configuration.md) — every environment variable
|
||||
- [`docs/external-auth.md`](docs/external-auth.md) — trusted reverse-proxy authentication
|
||||
- [`docs/subsonic-compatibility.md`](docs/subsonic-compatibility.md) — protocol endpoint status
|
||||
- [`docs/subsonic-compatibility.md`](docs/subsonic-compatibility.md) — protocol endpoint/extension status
|
||||
- [`docs/client-compatibility.md`](docs/client-compatibility.md) — real-client testing tracker
|
||||
- [`docs/deployment.md`](docs/deployment.md) — native/Docker/systemd/reverse-proxy deployment
|
||||
- [`docs/security.md`](docs/security.md) — security-relevant design decisions
|
||||
- [`docs/backup.md`](docs/backup.md) — what to back up and how (SQLite WAL caveats included)
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
# Backup
|
||||
|
||||
VibeSonic keeps all of its state under one directory,
|
||||
`VIBESONIC_DATA_DIR` (default `/var/lib/vibesonic`, or `/data` in the
|
||||
provided Docker image). Everything that must be backed up lives there:
|
||||
|
||||
```
|
||||
<DataDir>/
|
||||
vibesonic.db SQLite database — users, library index,
|
||||
playlists, favorites, sessions, API keys,
|
||||
podcast subscriptions, shares, ...
|
||||
vibesonic.db-wal SQLite write-ahead log (see below)
|
||||
vibesonic.db-shm SQLite shared-memory index for the WAL
|
||||
cache/covers/ resized cover-art cache (safe to delete —
|
||||
regenerated on demand from source images)
|
||||
podcasts/ downloaded podcast episodes
|
||||
```
|
||||
|
||||
Your music library itself (the folders configured via
|
||||
`VIBESONIC_MUSIC_DIRS` / the admin "Music Folders" page) is not inside
|
||||
`DataDir` and has its own backup story — it's your source media, and
|
||||
VibeSonic never modifies it.
|
||||
|
||||
## What must be backed up
|
||||
|
||||
1. **`vibesonic.db`** (plus `-wal`/`-shm` if present — see below). This
|
||||
is the only thing whose loss is unrecoverable: it holds every user
|
||||
account, password/API-key hash, the entire scanned library index,
|
||||
playlists, favorites/ratings, play history, podcast subscriptions,
|
||||
share links, and configuration stored in the `settings` table.
|
||||
2. **`podcasts/`**, if you don't want to re-download subscribed
|
||||
episodes after a restore.
|
||||
3. Anything you passed via `VIBESONIC_*` environment variables
|
||||
(systemd unit, Docker Compose file, `.env`) — these aren't stored in
|
||||
the database and won't come back from a database restore alone.
|
||||
|
||||
## What does not need to be backed up
|
||||
|
||||
- **`cache/covers/`** — purely a derived cache, regenerated
|
||||
automatically the first time each cover art size is requested again.
|
||||
Safe to delete at any time, including while VibeSonic is running.
|
||||
- **Your music files** — back these up through whatever process you
|
||||
already use for your media library; VibeSonic treats them as
|
||||
read-only input, never as its own state.
|
||||
|
||||
## Correct SQLite backup: mind the WAL
|
||||
|
||||
VibeSonic runs SQLite in **WAL (write-ahead log) mode** for concurrency
|
||||
(`internal/database/database.go`: `PRAGMA journal_mode=WAL`). In WAL
|
||||
mode, recently-committed data can live in the `vibesonic.db-wal` file
|
||||
rather than in `vibesonic.db` itself, and isn't merged back
|
||||
("checkpointed") into the main file on any fixed schedule.
|
||||
|
||||
**Do not just `cp vibesonic.db` from a live server.** A plain file copy
|
||||
of `vibesonic.db` alone, taken while VibeSonic is running, can miss data
|
||||
that only exists in the `-wal` file, or copy `vibesonic.db` and
|
||||
`vibesonic.db-wal` at slightly different points in time and produce an
|
||||
inconsistent pair. Either failure mode can leave you with a backup that
|
||||
looks fine (opens without error) but is silently missing recent writes
|
||||
or is outright corrupt.
|
||||
|
||||
Use one of these instead:
|
||||
|
||||
### Option A — stop the server, then copy (simplest, requires downtime)
|
||||
|
||||
```sh
|
||||
systemctl stop vibesonic
|
||||
cp /var/lib/vibesonic/vibesonic.db* /path/to/backup/
|
||||
systemctl start vibesonic
|
||||
```
|
||||
|
||||
On a clean shutdown SQLite checkpoints and closes the WAL cleanly, so a
|
||||
plain copy of the (possibly WAL-less) database directory taken while the
|
||||
process is stopped is always consistent.
|
||||
|
||||
### Option B — the SQLite online backup API, via the `sqlite3` CLI (no downtime)
|
||||
|
||||
`sqlite3`'s `.backup` command uses SQLite's own backup API, which
|
||||
correctly handles an in-progress WAL and produces a consistent snapshot
|
||||
without stopping VibeSonic or blocking writers for more than the brief
|
||||
final copy step:
|
||||
|
||||
```sh
|
||||
sqlite3 /var/lib/vibesonic/vibesonic.db ".backup /path/to/backup/vibesonic.db"
|
||||
```
|
||||
|
||||
Do this on a schedule (cron/systemd timer) and rotate the output.
|
||||
|
||||
### Option C — filesystem snapshot (LVM, ZFS, Btrfs)
|
||||
|
||||
A crash-consistent filesystem snapshot of the whole `DataDir` (taken
|
||||
atomically, e.g. `zfs snapshot` or an LVM snapshot) is also safe — SQLite
|
||||
in WAL mode is designed to recover cleanly from a snapshot equivalent to
|
||||
an ungraceful power loss, the same way it recovers on the next open
|
||||
after a crash. Plain `rsync`/`cp` while the server is running is **not**
|
||||
equivalent to this, since it isn't atomic across the `.db`/`-wal`/`-shm`
|
||||
trio.
|
||||
|
||||
## Restoring
|
||||
|
||||
1. Stop VibeSonic.
|
||||
2. Replace `vibesonic.db` (and `-wal`/`-shm` if you backed those up too)
|
||||
with the backup copy.
|
||||
3. Restore `podcasts/` if you backed it up.
|
||||
4. Start VibeSonic. It runs pending migrations automatically on
|
||||
startup, same as any other launch.
|
||||
|
||||
If you only have a `vibesonic.db` from Option B/C without matching
|
||||
`-wal`/`-shm` files, that's fine — `.backup` and filesystem snapshots
|
||||
both produce a database file that's already fully checkpointed/consistent
|
||||
on its own.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Client compatibility
|
||||
|
||||
This is a **tracking template**, not a report of testing that has
|
||||
already happened. As of this phase, no real Subsonic/OpenSubsonic
|
||||
client apps have been run against VibeSonic in this development
|
||||
environment — every row below is unfilled and marked accordingly. Please
|
||||
do not read the table as evidence of compatibility; read
|
||||
[`docs/subsonic-compatibility.md`](subsonic-compatibility.md) for that —
|
||||
it documents endpoint-by-endpoint protocol conformance validated by
|
||||
VibeSonic's own automated HTTP-level test suite (`internal/subsonic/*_test.go`),
|
||||
which is real evidence, exercised on every change. This document exists
|
||||
to capture *real device/app* testing once someone actually does it.
|
||||
|
||||
## How to test a client and fill in a row
|
||||
|
||||
1. Point the client at your VibeSonic server URL, e.g.
|
||||
`https://music.example.com` or `http://<host>:8080` for a direct,
|
||||
un-proxied connection. Most Subsonic clients want the base URL only
|
||||
(no `/rest` suffix) plus a username/password — they construct
|
||||
`/rest/*` requests themselves.
|
||||
2. **Authentication**: try native username/password first (works whether
|
||||
or not external auth is enabled, as long as the account has a local
|
||||
password — see [`external-auth.md`](external-auth.md)). If the client
|
||||
supports OpenSubsonic API keys (the `apiKeyAuthentication` extension —
|
||||
see [`subsonic-compatibility.md`](subsonic-compatibility.md#opensubsonic-extensions)),
|
||||
generate one from the web UI's account settings and test that path
|
||||
too, noting which the client actually used.
|
||||
3. Work through: server discovery/ping, browsing (artists/albums/songs,
|
||||
both filesystem and ID3 modes if the client exposes both), search,
|
||||
cover art, direct streaming, forced transcoding (pick a bitrate/format
|
||||
the source file doesn't already match), playlists (create/edit/
|
||||
delete/reorder), favorites/star, scrobbling, lyrics (plain and
|
||||
synced, if the client supports OpenSubsonic's `songLyrics`), the play
|
||||
queue, downloads, podcasts (if the client has podcast support), and
|
||||
any OpenSubsonic-specific features the client advertises.
|
||||
4. Fill in the row below with what actually worked, partially worked, or
|
||||
didn't — and describe **Known Issues** concretely (not "broken", but
|
||||
"album art doesn't load for tracks with only embedded art, HTTP 404"
|
||||
or similar) so someone else can reproduce or fix it.
|
||||
5. If a client needs a compatibility workaround (bending strict protocol
|
||||
behavior to accommodate something the client gets wrong), that
|
||||
exception must be documented here **and** in
|
||||
[`subsonic-compatibility.md`](subsonic-compatibility.md) next to the
|
||||
affected endpoint — don't silently special-case a client in code
|
||||
without a paper trail.
|
||||
|
||||
## Tracking table
|
||||
|
||||
| Client | Version | Platform | Authentication | Browsing | Streaming | Transcoding | Playlists | Lyrics | OpenSubsonic | Known Issues |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Symfonium | — | Android | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — |
|
||||
| Ultrasonic | — | Android | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — |
|
||||
| Tempo | — | iOS | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — |
|
||||
| Amperfy | — | iOS/macOS | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — |
|
||||
| play:Sub | — | iOS | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — |
|
||||
| Subtracks | — | Android | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — |
|
||||
| DSub | — | Android | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — |
|
||||
| Supersonic | — | Desktop (Linux/macOS/Windows) | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — |
|
||||
| Feishin | — | Desktop/Web | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — |
|
||||
|
||||
Add new rows for any other client you test — this list isn't exhaustive,
|
||||
just a reasonable starting set across ecosystems (Android, iOS, desktop).
|
||||
|
||||
## Compatibility exceptions
|
||||
|
||||
None recorded yet. If a real client requires bending protocol behavior
|
||||
to work correctly, document the exception here: which client/version,
|
||||
what VibeSonic does differently and why, and a link to the affected
|
||||
endpoint in [`subsonic-compatibility.md`](subsonic-compatibility.md).
|
||||
@@ -16,7 +16,11 @@ except the session secret.
|
||||
| `VIBESONIC_MPV_PATH` | `mpv` (from `PATH`) | Path to the `mpv` binary, used as the Subsonic jukebox's playback backend. If not found, the jukebox is administratively unavailable and `jukeboxControl` returns a clear error instead of a fake success. |
|
||||
| `VIBESONIC_SCAN_INTERVAL` | `1h` | How often to rescan enabled music folders, as a Go duration (`30m`, `6h`, ...). A startup scan always runs regardless of this value. |
|
||||
| `VIBESONIC_SESSION_SECRET` | *(none)* | Reserved for future use hardening session cookies beyond their database-backed random IDs. Not required to run, but recommended to set (e.g. `openssl rand -hex 32`) — VibeSonic logs a warning at startup if it's empty. |
|
||||
| `VIBESONIC_COOKIE_SECURE` | `true` | Whether session/CSRF cookies get the `Secure` attribute. Set to `false` only for local development over plain HTTP. |
|
||||
| `VIBESONIC_COOKIE_SECURE` | `false` | Whether session/CSRF cookies get the `Secure` attribute. Set to `true` when VibeSonic is served over HTTPS (including through a TLS-terminating reverse proxy); leave it off for direct plain-HTTP access. |
|
||||
| `VIBESONIC_ENABLE_EXTERNAL_METADATA` | `false` | Enable optional MusicBrainz/Cover Art Archive lookups for songs/albums that already carry a locally-tagged MusicBrainz ID. VibeSonic works fully offline with this off; it's the only feature that makes outbound Internet requests. |
|
||||
| `VIBESONIC_ENABLE_ARTIST_PHOTOS` | `false` | Enable auto-fetching an artist photo for every artist with no manually-set one: fanart.tv (only when both `VIBESONIC_FANART_TV_API_KEY` is set and the artist has a tagged MusicBrainz ID), falling back to a direct name search against Wikidata/Wikimedia Commons — this never calls the MusicBrainz API itself. Off by default for the same offline-first reason as `VIBESONIC_ENABLE_EXTERNAL_METADATA`. An admin can always set a photo manually from an artist's edit page regardless of this setting, and can trigger a one-off bulk fetch (with per-artist results, including any errors) from Admin → System Settings regardless of this toggle. |
|
||||
| `VIBESONIC_FANART_TV_API_KEY` | *(none)* | Optional personal [fanart.tv](https://fanart.tv/) API key, tried first (for artists with a tagged MusicBrainz ID) when `VIBESONIC_ENABLE_ARTIST_PHOTOS` is on. Leave unset to go straight to the Wikidata name search. |
|
||||
| `VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS` | `false` | Allow podcast feed/enclosure URLs to resolve to loopback/LAN/link-local addresses. Leave this off unless you intentionally host podcast feeds on a private network — see [`docs/security.md`](security.md#podcast-fetching-ssrf-protections). |
|
||||
|
||||
## External authentication
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
# Deployment
|
||||
|
||||
VibeSonic is a single static binary plus `ffmpeg`/`ffprobe` on `PATH`
|
||||
(and optionally `mpv`, for the jukebox feature). Docker is convenient
|
||||
but never required for normal operation.
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is via `VIBESONIC_*` environment variables — see
|
||||
[`docs/configuration.md`](configuration.md) for the full list. There is
|
||||
no config file.
|
||||
|
||||
## Running natively
|
||||
|
||||
```sh
|
||||
go build -o vibesonic ./cmd/vibesonic
|
||||
|
||||
export VIBESONIC_DATA_DIR=/var/lib/vibesonic
|
||||
export VIBESONIC_MUSIC_DIRS=/music
|
||||
export VIBESONIC_SESSION_SECRET="$(openssl rand -hex 32)"
|
||||
./vibesonic
|
||||
```
|
||||
|
||||
For a persistent deployment, see
|
||||
[`systemd/vibesonic.service`](../systemd/vibesonic.service) — install it
|
||||
with:
|
||||
|
||||
```sh
|
||||
sudo useradd --system --home /var/lib/vibesonic --shell /usr/sbin/nologin vibesonic
|
||||
sudo install -m 0755 vibesonic /usr/local/bin/vibesonic
|
||||
sudo cp systemd/vibesonic.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now vibesonic
|
||||
```
|
||||
|
||||
The unit runs as a dedicated `vibesonic` user (not `DynamicUser`,
|
||||
because it needs a stable UID with read access to your music library —
|
||||
`DynamicUser` allocates a random UID per start, which doesn't compose
|
||||
well with a fixed set of file permissions on external media). Grant that
|
||||
user read access to your music folders:
|
||||
|
||||
```sh
|
||||
sudo usermod -aG <group-that-owns-your-music> vibesonic
|
||||
# or, more simply, make the music tree group- or world-readable
|
||||
```
|
||||
|
||||
`StateDirectory=vibesonic` in the unit creates and owns
|
||||
`/var/lib/vibesonic` (mode `0700`) for you — don't create it manually
|
||||
with different permissions first. The unit also applies standard
|
||||
hardening (`ProtectSystem=strict`, `NoNewPrivileges`, `PrivateDevices`,
|
||||
etc.) and `Restart=on-failure`.
|
||||
|
||||
Remember to set a real `VIBESONIC_SESSION_SECRET` in the unit (or in an
|
||||
`EnvironmentFile=`) — the shipped example is a placeholder.
|
||||
|
||||
## Running with Docker
|
||||
|
||||
```sh
|
||||
docker build -t vibesonic .
|
||||
```
|
||||
|
||||
Runtime image is `debian:bookworm-slim` with `ffmpeg`, `mpv`, and CA
|
||||
certificates installed, running as a non-root `vibesonic` user (uid
|
||||
10001). See [`Dockerfile`](../Dockerfile).
|
||||
|
||||
Example Compose file (also in [`docker-compose.yml`](../docker-compose.yml)):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
vibesonic:
|
||||
image: vibesonic
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/var/lib/vibesonic
|
||||
- /mnt/music:/music:ro
|
||||
environment:
|
||||
VIBESONIC_SESSION_SECRET: "change-me-to-a-random-string"
|
||||
```
|
||||
|
||||
Mount your music read-only (`:ro`) — VibeSonic never writes to it.
|
||||
`./data` (or a named volume) holds the SQLite database, cover art cache,
|
||||
and downloaded podcast episodes; back it up per
|
||||
[`docs/backup.md`](backup.md).
|
||||
|
||||
## Reverse proxy
|
||||
|
||||
Run VibeSonic behind a reverse proxy for TLS termination in any
|
||||
real deployment; the examples below forward to VibeSonic on
|
||||
`127.0.0.1:8080`.
|
||||
|
||||
VibeSonic does not use WebSockets. Streaming and transcoded responses
|
||||
are ordinary long-lived HTTP responses using standard range requests —
|
||||
make sure your proxy doesn't buffer the entire response body before
|
||||
forwarding it (buffering breaks range requests and adds needless
|
||||
latency to playback start).
|
||||
|
||||
### Caddy
|
||||
|
||||
```
|
||||
music.example.com {
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
```
|
||||
|
||||
Caddy handles TLS automatically and streams request/response bodies
|
||||
without buffering by default — no extra configuration needed for range
|
||||
requests or large responses.
|
||||
|
||||
### nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name music.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/music.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/music.example.com/privkey.pem;
|
||||
|
||||
client_max_body_size 0;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`proxy_buffering off` matters here: nginx buffers proxied responses to
|
||||
disk/memory by default, which delays the start of playback and range
|
||||
responses. `client_max_body_size 0` avoids nginx rejecting large file
|
||||
uploads if the upload feature is used.
|
||||
|
||||
### Traefik
|
||||
|
||||
```yaml
|
||||
http:
|
||||
routers:
|
||||
vibesonic:
|
||||
rule: "Host(`music.example.com`)"
|
||||
service: vibesonic
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
services:
|
||||
vibesonic:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://127.0.0.1:8080"
|
||||
```
|
||||
|
||||
Traefik streams responses without buffering by default, same as Caddy.
|
||||
|
||||
## Reverse proxy + external (trusted-proxy) authentication
|
||||
|
||||
If you're using [`docs/external-auth.md`](external-auth.md) to have the
|
||||
proxy handle SSO (OIDC, SAML, HTTP Basic Auth, ...) for the browser UI,
|
||||
two things are essential:
|
||||
|
||||
1. **The proxy must strip any client-supplied identity header before
|
||||
inserting its own.** If it doesn't, a client can set
|
||||
`Remote-User: admin` (or whatever header name you configured) on its
|
||||
own request and have the proxy forward it unchanged, impersonating
|
||||
any user. Every proxy example below includes this.
|
||||
|
||||
2. **`/rest/*` must bypass the proxy's SSO layer.** Real Subsonic/
|
||||
OpenSubsonic clients (mobile apps, desktop players) authenticate
|
||||
directly against VibeSonic's own token/API-key scheme and have no way
|
||||
to complete a browser-based SSO flow (OIDC redirect, SAML POST,
|
||||
etc.). Exclude `/rest/*` from the SSO requirement so those clients
|
||||
keep working, while still letting `/rest/*` reach VibeSonic normally
|
||||
— VibeSonic's own `/rest/*` auth is independent of external auth
|
||||
either way.
|
||||
|
||||
### Caddy example (with `forward_auth` for SSO + header stripping)
|
||||
|
||||
```
|
||||
music.example.com {
|
||||
# Strip any client-supplied identity header before forward_auth
|
||||
# potentially sets a real one, so a client can't forge it.
|
||||
header_down -Remote-User
|
||||
|
||||
@needsAuth {
|
||||
not path /rest/*
|
||||
}
|
||||
forward_auth @needsAuth auth.example.com:9091 {
|
||||
uri /verify
|
||||
copy_headers Remote-User
|
||||
}
|
||||
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
```
|
||||
|
||||
### nginx example (with `auth_request` for SSO + header stripping)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
# ... TLS config as above ...
|
||||
|
||||
location /rest/ {
|
||||
# Subsonic/OpenSubsonic clients: no SSO, straight through.
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Remote-User ""; # never forward a client-supplied value
|
||||
}
|
||||
|
||||
location / {
|
||||
auth_request /sso-verify;
|
||||
auth_request_set $remote_user $upstream_http_remote_user;
|
||||
proxy_set_header Remote-User $remote_user;
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
}
|
||||
|
||||
location = /sso-verify {
|
||||
internal;
|
||||
proxy_pass http://auth.example.com/verify;
|
||||
proxy_set_header X-Original-URI $request_uri;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Either way, also set `VIBESONIC_EXTAUTH_TRUSTED_SOURCES` to only the
|
||||
proxy's own address (e.g. `127.0.0.1/32` if it's on the same host) —
|
||||
VibeSonic independently verifies the real TCP peer, so even a
|
||||
misconfigured proxy can't be used to spoof identity from the internet
|
||||
directly. See [`docs/external-auth.md`](external-auth.md) for that
|
||||
check in detail.
|
||||
@@ -59,6 +59,10 @@ password on VibeSonic's own `/login` page even when external auth is
|
||||
enabled, unless their account has no password hash set (auto-created
|
||||
external users start this way).
|
||||
|
||||
In later phases, `/rest/*` (the native Subsonic API) will authenticate
|
||||
exclusively via Subsonic's own token/API-key scheme and must never be
|
||||
affected by external auth's browser-cookie flow.
|
||||
`/rest/*` (the Subsonic/OpenSubsonic API) never consults external auth
|
||||
at all: it authenticates exclusively via Subsonic's own token/salt,
|
||||
password, or `apiKey` scheme (see `internal/subsonic/auth.go`), so real
|
||||
Subsonic/OpenSubsonic clients keep working unmodified even when external
|
||||
auth fronts the browser UI. This is also why a reverse proxy enforcing
|
||||
SSO in front of VibeSonic should let `/rest/*` bypass that SSO layer —
|
||||
see `docs/deployment.md` for example proxy configuration.
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# Security
|
||||
|
||||
This documents VibeSonic's security-relevant design decisions and the
|
||||
configuration knobs that affect them. See also
|
||||
[`docs/external-auth.md`](external-auth.md) for the trusted-proxy
|
||||
authentication model in detail.
|
||||
|
||||
## Authentication
|
||||
|
||||
- **Passwords** are hashed with Argon2id (`internal/auth/password.go`).
|
||||
VibeSonic never stores or logs a plaintext password.
|
||||
- **Subsonic legacy token/salt auth** (`t=`/`s=` params, used by older
|
||||
Subsonic clients) requires comparing an MD5 digest against one derived
|
||||
from the stored password — but MD5 hashes aren't reversible into
|
||||
Argon2id, so VibeSonic separately keeps an AES-256-GCM-encrypted copy
|
||||
of the plaintext password (`internal/auth/subsonic_secret.go`), keyed
|
||||
by a server-held encryption key, solely to support this legacy scheme.
|
||||
This is an inherent property of the Subsonic protocol, not a VibeSonic
|
||||
design choice — every Subsonic-compatible server needs some way to
|
||||
recover the plaintext to validate `t=md5(password+salt)`. The
|
||||
comparison itself uses a constant-time compare
|
||||
(`crypto/subtle.ConstantTimeCompare`) to avoid a timing side-channel.
|
||||
- **API keys** are stored and looked up by hash, never in plaintext.
|
||||
- **Sessions** are random opaque tokens in an HTTP-only cookie. Set
|
||||
`VIBESONIC_COOKIE_SECURE=true` for HTTPS deployments so browsers only send
|
||||
session and CSRF cookies over secure connections.
|
||||
- **External (trusted-proxy) authentication** is off by default and,
|
||||
when enabled, trusts an identity-asserting header only from
|
||||
configured source IPs verified against the real TCP peer address —
|
||||
never against spoofable headers like `X-Forwarded-For`. Full detail in
|
||||
[`docs/external-auth.md`](external-auth.md).
|
||||
- `/rest/*` (the Subsonic/OpenSubsonic API) authenticates independently
|
||||
of external auth, via token/salt, password, or `apiKey`, so standard
|
||||
Subsonic clients work unmodified regardless of how the browser UI is
|
||||
fronted.
|
||||
|
||||
## CSRF
|
||||
|
||||
State-changing browser routes are protected by a double-submit CSRF
|
||||
token (`internal/auth/csrf.go`), compared in constant time. The
|
||||
`/rest/*` API is exempt by design — it's a machine API authenticated per
|
||||
request (token/password/API key), not cookie-session-based, so CSRF
|
||||
doesn't apply to it the way it does to browser form posts.
|
||||
|
||||
## Path traversal
|
||||
|
||||
Every file served — streamed audio, downloads, cover art — is resolved
|
||||
through `internal/streaming.ResolvePath`, which joins the stored
|
||||
relative path onto its music folder root, resolves symlinks, and
|
||||
verifies the result is still inside that root before anything is opened.
|
||||
A request cannot escape its folder via `../` segments or a symlink
|
||||
pointing outside the folder.
|
||||
|
||||
## Podcast fetching: SSRF protections
|
||||
|
||||
Podcast feed and episode-enclosure URLs are, by nature, externally
|
||||
supplied (an admin adds a feed URL; that feed's own content — including
|
||||
HTTP redirects — is not otherwise controlled by VibeSonic). Without
|
||||
restriction, this would let a malicious or compromised feed turn
|
||||
VibeSonic into a proxy onto its own internal network: pointing a feed
|
||||
URL, or a redirect from one, at `127.0.0.1`, an RFC1918 LAN address, or
|
||||
a cloud metadata endpoint (`169.254.169.254`).
|
||||
|
||||
`internal/netguard` closes this off for every connection the podcast
|
||||
manager's HTTP client makes, **including connections made while
|
||||
following a redirect** (so a feed can't pass an initial URL check and
|
||||
then redirect somewhere unsafe), by validating the actual IP address
|
||||
about to be dialed:
|
||||
|
||||
- Loopback, RFC1918/RFC4193 private ranges, link-local (including the
|
||||
`169.254.169.254` cloud metadata address), carrier-grade NAT
|
||||
(`100.64.0.0/10`), multicast, and unspecified addresses are all
|
||||
rejected.
|
||||
- The check runs at dial time against the specific resolved IP, not a
|
||||
hostname re-resolved later — so a DNS record that changes between the
|
||||
initial check and the connection ("DNS rebinding") can't bypass it.
|
||||
- Redirects are capped at 5 hops and restricted to `http`/`https`.
|
||||
- Episode downloads are capped at 4 GB so a misbehaving or malicious
|
||||
feed can't exhaust disk space via an unbounded response.
|
||||
|
||||
For deployments that intentionally host podcast feeds on a private
|
||||
network, set `VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS=true` to disable
|
||||
the IP restriction (the size cap and redirect limit still apply). Leave
|
||||
this off otherwise.
|
||||
|
||||
## Cover art / image handling
|
||||
|
||||
Source image files are size-capped before decoding, and — separately —
|
||||
their *decoded pixel dimensions* are checked via `image.DecodeConfig`
|
||||
before the full image is decoded into memory, rejecting anything over
|
||||
40 megapixels. This prevents a small, highly-compressed image file from
|
||||
forcing an out-of-proportion memory allocation ("decompression bomb").
|
||||
|
||||
## Artist photos and manual metadata editing
|
||||
|
||||
When `VIBESONIC_ENABLE_ARTIST_PHOTOS` is on, VibeSonic looks up an
|
||||
artist photo from fanart.tv (only for an artist with both a tagged
|
||||
MusicBrainz ID and `VIBESONIC_FANART_TV_API_KEY` set) or, failing
|
||||
that, by searching Wikidata directly by artist name and reading the
|
||||
matched entity's image from Wikimedia Commons — both
|
||||
externally-hosted services, same trust model as the existing
|
||||
MusicBrainz/Cover Art Archive lookups used elsewhere. This path
|
||||
deliberately never calls the MusicBrainz API itself (its public
|
||||
instance proved too rate-limited to depend on); a name-only match is
|
||||
necessarily less precise, so it requires an exact (case-insensitive)
|
||||
label match and only weakly prefers a music-flavored Wikidata
|
||||
description, rather than fabricating a stricter guarantee it can't
|
||||
actually make. The resulting image download, and any admin-supplied
|
||||
photo/cover-art URL entered on an artist or album's edit page, is
|
||||
fetched through the same `internal/netguard` SSRF-safe client described
|
||||
above (dial-time IP validation, capped redirects, size-limited
|
||||
response). Uploaded photo/cover files are validated by actually
|
||||
decoding them as an image before being written to disk.
|
||||
|
||||
Editing artist/album/song metadata (`/admin/artists/{id}/edit`,
|
||||
`/admin/albums/{id}/edit`, `/admin/songs/{id}/edit`) and overriding a
|
||||
photo/cover are admin-only actions, gated the same way as every other
|
||||
`/admin/*` route.
|
||||
|
||||
## Transcoding
|
||||
|
||||
`ffmpeg`/`ffprobe` are invoked with `exec.CommandContext` and an
|
||||
explicit argument-vector slice — never through a shell — so there is no
|
||||
shell-metacharacter injection surface. Output paths are passed after a
|
||||
literal `--` argument so a generated filename can never be
|
||||
misinterpreted as an additional flag. Transcode concurrency is bounded
|
||||
by a worker pool, and a client disconnect cancels the underlying
|
||||
`ffmpeg` process via the request context.
|
||||
|
||||
## HTTP server hardening
|
||||
|
||||
The server sets `ReadHeaderTimeout` to bound how long a client can take
|
||||
to send request headers, mitigating slow-header ("Slowloris"-style)
|
||||
connection exhaustion. `WriteTimeout` is deliberately left unset:
|
||||
audio/video streaming and transcoded output are long-lived writes that a
|
||||
fixed write deadline would break.
|
||||
|
||||
## Redirects
|
||||
|
||||
All same-site post-action redirects (login's `next=`, playlist actions,
|
||||
favorites, admin actions) are validated through a shared
|
||||
`safeRedirectPath` helper that only accepts a same-site relative path —
|
||||
rejecting protocol-relative (`//evil.example`) or absolute
|
||||
(`https://evil.example`) targets — before being used in an
|
||||
`http.Redirect`, so a crafted link can't be used to bounce a logged-in
|
||||
user's browser off-site.
|
||||
|
||||
## Logging
|
||||
|
||||
VibeSonic logs operational events (startup/shutdown, migrations,
|
||||
scanner activity, authentication failures, admin actions, transcode/
|
||||
podcast failures) via structured logging. It never logs passwords,
|
||||
session tokens, API keys, Subsonic auth tokens/salts, or `Authorization`
|
||||
headers.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
If you find a security issue, please report it privately rather than
|
||||
opening a public issue, so a fix can be prepared before the details are
|
||||
public.
|
||||
@@ -1,15 +1,14 @@
|
||||
# Subsonic API compatibility
|
||||
|
||||
VibeSonic implements the **complete original Subsonic REST API through
|
||||
version 1.16.1** as of Phase 2. Every endpoint below is backed by real
|
||||
data from VibeSonic's own database/scanner/streaming/transcoding/jukebox
|
||||
subsystems — none of them are stub handlers that return an empty
|
||||
successful response.
|
||||
|
||||
OpenSubsonic extensions are **not yet implemented** (planned for Phase
|
||||
3), except where the underlying architecture already needed to make a
|
||||
choice (e.g. opaque string IDs almost everywhere) that happens to also
|
||||
suit OpenSubsonic later.
|
||||
version 1.16.1** (Phase 2) plus a growing set of **OpenSubsonic
|
||||
extensions** (Phase 3). Every endpoint below is backed by real data
|
||||
from VibeSonic's own database/scanner/streaming/transcoding/jukebox/
|
||||
audio-analysis subsystems — none of them are stub handlers that return
|
||||
an empty successful response. `getOpenSubsonicExtensions` only ever
|
||||
advertises an extension once its handler and tests exist and pass; see
|
||||
[OpenSubsonic extensions](#opensubsonic-extensions) below for the full
|
||||
list and what backs each one.
|
||||
|
||||
## How to read this table
|
||||
|
||||
@@ -56,11 +55,11 @@ suit OpenSubsonic later.
|
||||
| `getSong` | 1.8.0 | ✅ | — | ✅ | ✅ | ✅ (folder restriction) | |
|
||||
| `getVideos` | 1.8.0 | ✅ | — | ✅ | — | — | Real: lists media files the scanner tagged `kind=video`. |
|
||||
| `getVideoInfo` | 1.14.0 | ⚠️ | — | — | — | — | Reports one default audio track (VibeSonic doesn't probe multi-audio-track containers yet) and real sidecar `.srt`/`.vtt`/`.ass` captions found next to the file. |
|
||||
| `getArtistInfo` | 1.11.0 | ⚠️ | — | — | — | — | Honestly returns empty biography/images/similar-artists — VibeSonic has no external metadata provider in Phase 2. Not a fake success: it's an accurate "we don't have this" response. |
|
||||
| `getArtistInfo2` | 1.11.0 | ⚠️ | — | — | — | — | Same as above, ID3 form. |
|
||||
| `getAlbumInfo` | 1.14.0 | ⚠️ | — | — | — | — | Same reasoning as `getArtistInfo`. |
|
||||
| `getAlbumInfo2` | 1.14.0 | ⚠️ | — | — | — | — | Same. |
|
||||
| `getSimilarSongs` | 1.11.0 | ✅ | — | — | — | — | Real, local genre-overlap heuristic (song/album/artist seed), not fabricated data. |
|
||||
| `getArtistInfo` | 1.11.0 | ✅ | — | ✅ | — | — | Local MusicBrainz ID always returned; biography (MusicBrainz disambiguation) filled in only when `VIBESONIC_ENABLE_EXTERNAL_METADATA=true` and the artist has a tagged MBID — see [OpenSubsonic extensions](#opensubsonic-extensions). |
|
||||
| `getArtistInfo2` | 1.11.0 | ✅ | — | ✅ | — | — | Same as above, ID3 form. |
|
||||
| `getAlbumInfo` | 1.14.0 | ✅ | — | ✅ | — | — | Local MusicBrainz ID always returned; cover-art image URLs filled in from the Cover Art Archive under the same conditions as `getArtistInfo`. |
|
||||
| `getAlbumInfo2` | 1.14.0 | ✅ | — | ✅ | — | — | Same. |
|
||||
| `getSimilarSongs` | 1.11.0 | ✅ | — | — | — | — | Genre-overlap candidate set (song/album/artist seed), re-ranked by real sonic-feature distance when the seed song has been audio-analyzed. Not the same as `getSonicSimilarTracks` (pure sonic search) — see below. |
|
||||
| `getSimilarSongs2` | 1.11.0 | ✅ | — | — | — | — | ID3 (artist-seeded) form. |
|
||||
| `getTopSongs` | 1.13.0 | ✅ | — | ✅ | — | — | Ranked by real aggregate play count across all users. |
|
||||
|
||||
@@ -103,7 +102,7 @@ suit OpenSubsonic later.
|
||||
| `hls` | 1.8.0 | ✅ | n/a | n/a | — | ✅ (stream role) | Real ffmpeg HLS segmenting, cached per (source, bitrate); segment URLs re-embed the caller's auth params. |
|
||||
| `getCaptions` | 1.14.0 | ✅ | n/a | n/a | — | — | Serves real sidecar subtitle files. |
|
||||
| `getCoverArt` | 1.0.0 | ✅ | n/a | n/a | — | ✅ (coverArt role) | Embedded or folder art, resized and cached on disk. |
|
||||
| `getLyrics` | 1.2.0 | ✅ | ✅ | — | — | — | Real embedded lyrics extracted at scan time; empty (not fabricated) if none found. |
|
||||
| `getLyrics` | 1.2.0 | ✅ | ✅ | — | — | — | Title/artist search over the same structured lyrics storage `getLyricsBySongId` reads; empty (not fabricated) if none found. |
|
||||
| `getAvatar` | 1.8.0 | ⚠️ | — | — | — | — | Honestly 404s — VibeSonic has no user-avatar feature in Phase 1/2. |
|
||||
|
||||
## Transcoding
|
||||
@@ -232,6 +231,62 @@ enforced server-side (`internal/subsonic/folders.go`).
|
||||
|
||||
---
|
||||
|
||||
## OpenSubsonic extensions
|
||||
|
||||
VibeSonic advertises these via `getOpenSubsonicExtensions`
|
||||
(`internal/subsonic/extensions.go`); `TestGetOpenSubsonicExtensionsNeverStubbed`
|
||||
in `internal/subsonic/opensubsonic_core_test.go` fails the build if an
|
||||
extension is ever registered without its handler actually existing, so
|
||||
this list can't drift ahead of what's real.
|
||||
|
||||
Of the extensions below, `apiKeyAuthentication`, `formPost`, and
|
||||
`songLyrics` come from the published OpenSubsonic extension list.
|
||||
`getPodcastEpisode`, `indexBasedQueue`, `playbackReport`,
|
||||
`topSongsByArtistId`, `transcodeOffset`, `transcoding`, and
|
||||
`sonicSimilarity` are **VibeSonic-original** extensions — real,
|
||||
implemented, and advertised the same way as any other extension, but
|
||||
not part of the OpenSubsonic project's own spec, so don't assume
|
||||
another OpenSubsonic server implements them just because it advertises
|
||||
OpenSubsonic support in general.
|
||||
|
||||
| Extension | Versions | Endpoint(s) | Tested | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `apiKeyAuthentication` | 1 | `tokenInfo`, `apiKey=` param on every endpoint | ✅ | Per-user named keys with plaintext shown once, SHA-256 hash stored, creation/last-used tracking, revocation (self or admin). `apiKey` conflicting with `u` returns error 43; an invalid/revoked key returns error 44. Managed at Settings → API Keys; admins see/revoke everyone's keys at `/admin/api-keys`. |
|
||||
| `formPost` | 1 | (framework-wide) | ✅ | `application/x-www-form-urlencoded` POST supported on every endpoint via `net/http`'s `ParseForm`; a key present in both the POST body and the query string resolves to the **body** value (verified empirically and regression-tested). |
|
||||
| `getPodcastEpisode` | 1 | `getPodcastEpisode` | ✅ | Real lookup against the Phase 2 podcast subsystem; 404s honestly for an unknown episode. |
|
||||
| `songLyrics` | 1, 2 | `getLyricsBySongId` | ✅ | Structured lyrics (multiple entries per song, multiple languages) from embedded tags and sidecar `.lrc`/Enhanced LRC files, stored with UTF-8 byte offsets computed once at index time. `enhanced=true` additionally returns `cueLine`/`cue` word-level timing — but only for entries actually parsed from a source with real word timing (Enhanced LRC); plain and line-only-synced lyrics never get fabricated cues. |
|
||||
| `indexBasedQueue` | 1 | `getPlayQueueByIndex`, `savePlayQueueByIndex` | ✅ | Same `play_queues` storage as the classic queue, but exposes `currentIndex` directly instead of resolving it to a song ID — correct even when the queue repeats the same song at multiple positions (regression-tested). |
|
||||
| `playbackReport` | 1 | `reportPlayback` | ✅ | Persists user/media/position/state/client/timestamp (`playback_reports` table); `state=stopped` also clears now-playing and (unless `ignoreScrobble`) logs playback history. Never leaks one user's playback state to another. |
|
||||
| `topSongsByArtistId` | 1 | `getTopSongs` (`id` param) | ✅ | `id` (canonical internal artist ID) takes priority over the legacy `artist` name param when both are given. |
|
||||
| `transcodeOffset` | 1 | `stream` (`timeOffset` param) | ✅ | Forces a real transcode with an actual ffmpeg `-ss` seek — never decode-from-zero — verified by asserting the response isn't a range-servable direct passthrough. |
|
||||
| `transcoding` | 1 | `getTranscodeDecision`, `getTranscodeStream` | ✅ | Real capability matching: compares the source file's actual scanned format/channels against the client's `directPlayProfiles`; falls back to searching `transcodingProfiles` for a codec VibeSonic's ffmpeg pipeline can actually encode (mp3/opus/aac) that also satisfies any `codecProfile` limitations the client marked required. `canDirectPlay`/`canTranscode` are never set true without an actual match. `getTranscodeStream` takes the opaque `transcodeParams` token verbatim and streams real transcoded audio with real seek support. |
|
||||
| `sonicSimilarity` | 1 | `getSonicSimilarTracks`, `findSonicPath` | ✅ | Backed by `internal/audiofeatures` (real ffmpeg PCM decode + a hand-written FFT for tempo/loudness/spectral-shape/chroma, computed by the scanner as part of each scan) and `internal/similarity` (an in-process, z-score-normalized nearest-neighbor index — no external vector database). A song that hasn't been analyzed yet simply doesn't appear in results; `findSonicPath` walks real feature-space geometry to a real intermediate song at each hop, never an invented one. |
|
||||
|
||||
### OpenSubsonic response/model additions
|
||||
|
||||
- `subsonic-response`: `type`, `serverVersion`, `openSubsonic` — always
|
||||
populated (`internal/subsonic/model.go`).
|
||||
- `error.helpUrl` — set on API-key-related errors (43, 44), pointing at
|
||||
the Settings API Keys page.
|
||||
- `Child`/other entity fields VibeSonic already populated correctly
|
||||
before Phase 3 (`replayGain*`, `musicBrainzId`, disc/track totals,
|
||||
etc.) are unchanged; nothing new was added here beyond what's listed
|
||||
in the extension table above (structured lyrics, sonic-match
|
||||
entries, transcode-decision/stream-details, index-based play queue).
|
||||
|
||||
### Optional external metadata (MusicBrainz / Cover Art Archive)
|
||||
|
||||
Not an OpenSubsonic extension itself, but part of Phase 3's metadata
|
||||
work: `getArtistInfo(2)`/`getAlbumInfo(2)` fetch a real MusicBrainz
|
||||
artist disambiguation or Cover Art Archive front-cover image when
|
||||
`VIBESONIC_ENABLE_EXTERNAL_METADATA=true` **and** the entity already
|
||||
carries a locally-tagged MusicBrainz ID, caching results in
|
||||
`external_metadata_cache` for 30 days. VibeSonic works completely
|
||||
without Internet access: the flag defaults off, and any lookup
|
||||
failure/timeout silently falls back to local-only fields rather than
|
||||
erroring the request. See `internal/metadataenrich` and
|
||||
`internal/subsonic/enrich.go`.
|
||||
|
||||
## Authentication
|
||||
|
||||
Both original Subsonic authentication modes are implemented in
|
||||
@@ -265,17 +320,21 @@ are accepted, since the protocol is additive.
|
||||
All defined in `internal/subsonic/errors.go`: 0 (generic), 10 (missing
|
||||
parameter), 20 (client must upgrade), 30 (server must upgrade, reserved
|
||||
— VibeSonic never needs to require a newer client), 40 (wrong
|
||||
credentials), 41 (token auth unsupported for this account), 50
|
||||
(unauthorized for the operation), 60 (license/trial — never returned),
|
||||
70 (not found).
|
||||
credentials), 41 (token auth unsupported for this account), 42 (auth
|
||||
mechanism not supported), 43 (conflicting auth mechanisms — e.g. both
|
||||
`apiKey` and `u` given), 44 (invalid/revoked API key), 50 (unauthorized
|
||||
for the operation), 60 (license/trial — never returned), 70 (not
|
||||
found).
|
||||
|
||||
## Web UI
|
||||
|
||||
Newly exposed through the Bootstrap UI in Phase 2: Podcasts (`/podcasts`,
|
||||
admin at `/admin/podcasts`), Internet Radio (`/radio`), Shares
|
||||
(`/shares`, plus the public `/share/{token}` page), Bookmarks
|
||||
(`/bookmarks`), and per-user Subsonic permission editing
|
||||
(`/admin/users/{id}/permissions`). The admin Jukebox page
|
||||
(`/bookmarks`), per-user Subsonic permission editing
|
||||
(`/admin/users/{id}/permissions`), and API key management (Settings →
|
||||
API Keys for any user; `/admin/api-keys` for admins to view/revoke
|
||||
everyone's). The admin Jukebox page
|
||||
(`/admin/jukebox`) shows live status and emergency stop/clear; building
|
||||
a jukebox playlist is done from a Subsonic client via `jukeboxControl`,
|
||||
which is the actual protocol surface for that feature.
|
||||
|
||||
+5
-3
@@ -57,6 +57,7 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) {
|
||||
|
||||
prober := &metadata.Prober{FFprobePath: cfg.FFprobePath}
|
||||
sc := scanner.New(db, prober, log)
|
||||
sc.FFmpegPath = cfg.FFmpegPath
|
||||
|
||||
coverCache := filepath.Join(cfg.DataDir, "cache", "covers")
|
||||
covers := coverart.New(coverCache, cfg.FFmpegPath)
|
||||
@@ -72,7 +73,7 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) {
|
||||
return nil, fmt.Errorf("deriving auth encryption key: %w", err)
|
||||
}
|
||||
|
||||
podcastMgr := podcasts.NewManager(db, cfg.DataDir, cfg.FFprobePath, log)
|
||||
podcastMgr := podcasts.NewManager(db, cfg.DataDir, cfg.FFprobePath, log, cfg.PodcastsAllowPrivateNetworks)
|
||||
if err := podcastMgr.RecoverInterruptedDownloads(); err != nil {
|
||||
log.Warn("podcast download recovery failed", "err", err)
|
||||
}
|
||||
@@ -164,8 +165,9 @@ func (a *App) Run(ctx context.Context) error {
|
||||
mux.Handle("/", a.Web.NewRouter())
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: a.Cfg.ListenAddr,
|
||||
Handler: mux,
|
||||
Addr: a.Cfg.ListenAddr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
// Package artistphoto looks up an artist portrait URL by name (and, if
|
||||
// available, MusicBrainz ID) from fanart.tv and Wikidata/Wikimedia
|
||||
// Commons. It never talks to anything on a hot path and, like
|
||||
// internal/metadataenrich, always degrades to "no photo found" rather
|
||||
// than an error when a source has nothing to offer — only real
|
||||
// transport failures are returned as errors.
|
||||
package artistphoto
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const userAgent = "VibeSonic/1.0 (+https://git.noroutes.net/NoRoutes/vibetunes)"
|
||||
|
||||
// commonsThumbnailWidth bounds the resolution requested from Wikimedia
|
||||
// Commons for a Wikidata-sourced photo — plenty for an artist portrait,
|
||||
// and small enough to comfortably clear internal/web's download size cap
|
||||
// (a full-resolution original can be many times this).
|
||||
const commonsThumbnailWidth = 800
|
||||
|
||||
const (
|
||||
defaultFanartTVBaseURL = "https://webservice.fanart.tv/v3/music"
|
||||
defaultWikidataSearchBaseURL = "https://www.wikidata.org/w/api.php"
|
||||
defaultWikidataEntityBaseURL = "https://www.wikidata.org/wiki/Special:EntityData"
|
||||
defaultCommonsBaseURL = "https://commons.wikimedia.org/wiki/Special:FilePath"
|
||||
)
|
||||
|
||||
// musicDescriptionKeywords is a lightweight heuristic for preferring a
|
||||
// Wikidata search hit that's actually a musician/band: a plain name
|
||||
// search can just as easily match an actor, a film, or anyone else who
|
||||
// happens to share the name. It's deliberately permissive — VibeSonic
|
||||
// would rather occasionally attach a plausible-but-wrong photo (the
|
||||
// caller asked for a best-effort name lookup, not a guarantee) than
|
||||
// return nothing for an artist a stricter check would have found.
|
||||
var musicDescriptionKeywords = []string{
|
||||
"band", "singer", "musician", "rapper", "songwriter", "duo", "group",
|
||||
"orchestra", "rock", "pop", "hip hop", "hip-hop", "dj", "composer", "vocalist",
|
||||
}
|
||||
|
||||
// Client resolves an artist photo URL from fanart.tv (when both an API
|
||||
// key and a locally-tagged MusicBrainz ID are available) or, that
|
||||
// failing, by searching Wikidata directly by artist name. It
|
||||
// deliberately never calls the MusicBrainz API: MusicBrainz's public
|
||||
// instance is shared, heavily used, and prone to returning HTTP 503
|
||||
// under its own load regardless of how considerately a single client
|
||||
// paces its requests, which made it an unreliable dependency for a
|
||||
// feature that's supposed to degrade gracefully. The zero value talks
|
||||
// to the real public services; the BaseURL fields exist so tests can
|
||||
// point it at httptest servers instead.
|
||||
type Client struct {
|
||||
HTTPClient *http.Client
|
||||
FanartTVBaseURL string
|
||||
WikidataSearchBaseURL string
|
||||
WikidataEntityBaseURL string
|
||||
CommonsBaseURL string
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
}
|
||||
return &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
|
||||
func (c *Client) fanartTVBaseURL() string {
|
||||
if c.FanartTVBaseURL != "" {
|
||||
return c.FanartTVBaseURL
|
||||
}
|
||||
return defaultFanartTVBaseURL
|
||||
}
|
||||
|
||||
func (c *Client) wikidataSearchBaseURL() string {
|
||||
if c.WikidataSearchBaseURL != "" {
|
||||
return c.WikidataSearchBaseURL
|
||||
}
|
||||
return defaultWikidataSearchBaseURL
|
||||
}
|
||||
|
||||
func (c *Client) wikidataEntityBaseURL() string {
|
||||
if c.WikidataEntityBaseURL != "" {
|
||||
return c.WikidataEntityBaseURL
|
||||
}
|
||||
return defaultWikidataEntityBaseURL
|
||||
}
|
||||
|
||||
func (c *Client) commonsBaseURL() string {
|
||||
if c.CommonsBaseURL != "" {
|
||||
return c.CommonsBaseURL
|
||||
}
|
||||
return defaultCommonsBaseURL
|
||||
}
|
||||
|
||||
// getWithRetry performs a GET and decodes a JSON response, retrying
|
||||
// once on a transient transport error (a dropped connection or a slow
|
||||
// response past the client timeout) rather than giving up on the first
|
||||
// hiccup — free public services occasionally have one.
|
||||
func (c *Client) getWithRetry(ctx context.Context, rawURL string, out any) (found bool, err error) {
|
||||
for attempt := 0; ; attempt++ {
|
||||
found, err = c.get(ctx, rawURL, out)
|
||||
if err == nil || attempt >= 1 || ctx.Err() != nil {
|
||||
return found, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, rawURL string, out any) (found bool, err error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return false, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("%s: HTTP %d", rawURL, resp.StatusCode)
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// FetchPhotoURL resolves an artist photo URL. When mbid is a
|
||||
// locally-tagged MusicBrainz ID and fanartAPIKey is configured,
|
||||
// fanart.tv (keyed directly by mbid — no MusicBrainz call involved) is
|
||||
// tried first; otherwise, and as a fallback, name is searched directly
|
||||
// against Wikidata. Returns ("", nil) — not an error — when no photo
|
||||
// can be found through either source, since that's a normal, common
|
||||
// outcome.
|
||||
func (c *Client) FetchPhotoURL(ctx context.Context, mbid, name, fanartAPIKey string) (string, error) {
|
||||
if mbid != "" && fanartAPIKey != "" {
|
||||
u, err := c.fetchFanartTVPhoto(ctx, mbid, fanartAPIKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if u != "" {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
return c.fetchWikidataPhotoByName(ctx, name)
|
||||
}
|
||||
|
||||
type fanartResponse struct {
|
||||
ArtistThumb []struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"artistthumb"`
|
||||
}
|
||||
|
||||
func (c *Client) fetchFanartTVPhoto(ctx context.Context, mbid, apiKey string) (string, error) {
|
||||
u := fmt.Sprintf("%s/%s?api_key=%s", c.fanartTVBaseURL(), url.PathEscape(mbid), url.QueryEscape(apiKey))
|
||||
var raw fanartResponse
|
||||
found, err := c.getWithRetry(ctx, u, &raw)
|
||||
if err != nil || !found || len(raw.ArtistThumb) == 0 {
|
||||
return "", err
|
||||
}
|
||||
return raw.ArtistThumb[0].URL, nil
|
||||
}
|
||||
|
||||
type wikidataSearchResponse struct {
|
||||
Search []struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
} `json:"search"`
|
||||
}
|
||||
|
||||
// SearchArtistWikidataID looks up a Wikidata item ID (a "QID") by
|
||||
// artist name, entirely independent of MusicBrainz. Only an exact
|
||||
// (case-insensitive) label match is considered; among those, one whose
|
||||
// description reads like a musician/band (see musicDescriptionKeywords)
|
||||
// is preferred, falling back to the first exact match otherwise. No
|
||||
// match at all — including a same-named but clearly unrelated entity —
|
||||
// returns ("", nil).
|
||||
func (c *Client) SearchArtistWikidataID(ctx context.Context, name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", nil
|
||||
}
|
||||
u := fmt.Sprintf("%s?action=wbsearchentities&search=%s&language=en&format=json&type=item&limit=8",
|
||||
c.wikidataSearchBaseURL(), url.QueryEscape(name))
|
||||
var raw wikidataSearchResponse
|
||||
found, err := c.getWithRetry(ctx, u, &raw)
|
||||
if err != nil || !found {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var exact []struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
for _, r := range raw.Search {
|
||||
if strings.EqualFold(r.Label, name) {
|
||||
exact = append(exact, r)
|
||||
}
|
||||
}
|
||||
if len(exact) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
for _, r := range exact {
|
||||
desc := strings.ToLower(r.Description)
|
||||
for _, kw := range musicDescriptionKeywords {
|
||||
if strings.Contains(desc, kw) {
|
||||
return r.ID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return exact[0].ID, nil
|
||||
}
|
||||
|
||||
// wikidataEntityResponse only decodes each claim's raw JSON, not its
|
||||
// value: a Wikidata entity carries many properties whose datavalue.value
|
||||
// is an object (e.g. P31 "instance of" is an entity reference), not a
|
||||
// string, so unmarshaling the whole claims map into one string-valued
|
||||
// struct fails on the first non-string property it hits — long before
|
||||
// ever reaching P18. Only the property actually needed (P18, the image)
|
||||
// gets decoded into a concrete shape, on demand.
|
||||
type wikidataEntityResponse struct {
|
||||
Entities map[string]struct {
|
||||
Claims map[string]json.RawMessage `json:"claims"`
|
||||
} `json:"entities"`
|
||||
}
|
||||
|
||||
type wikidataImageClaim struct {
|
||||
MainSnak struct {
|
||||
DataValue struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"datavalue"`
|
||||
} `json:"mainsnak"`
|
||||
}
|
||||
|
||||
// fetchWikidataPhotoByName resolves a photo by searching Wikidata for
|
||||
// name, then reading the matched entity's P18 (image) claim, then
|
||||
// building Commons' canonical file-path URL for that filename.
|
||||
func (c *Client) fetchWikidataPhotoByName(ctx context.Context, name string) (string, error) {
|
||||
qid, err := c.SearchArtistWikidataID(ctx, name)
|
||||
if err != nil || qid == "" {
|
||||
return "", err
|
||||
}
|
||||
|
||||
entityURL := fmt.Sprintf("%s/%s.json", c.wikidataEntityBaseURL(), url.PathEscape(qid))
|
||||
var entity wikidataEntityResponse
|
||||
found, err := c.getWithRetry(ctx, entityURL, &entity)
|
||||
if err != nil || !found {
|
||||
return "", err
|
||||
}
|
||||
|
||||
claims, ok := entity.Entities[qid]
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
p18Raw, ok := claims.Claims["P18"]
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
var images []wikidataImageClaim
|
||||
if err := json.Unmarshal(p18Raw, &images); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(images) == 0 || images[0].MainSnak.DataValue.Value == "" {
|
||||
return "", nil
|
||||
}
|
||||
filename := images[0].MainSnak.DataValue.Value
|
||||
|
||||
// Special:FilePath serves the original file by default, which for a
|
||||
// press photo can be a many-megapixel, many-megabyte original — ask
|
||||
// for a reasonably sized rendition instead (Commons redirects this
|
||||
// to the matching pre-generated thumbnail rather than resizing on
|
||||
// the fly).
|
||||
return fmt.Sprintf("%s/%s?width=%d", c.commonsBaseURL(), url.PathEscape(filename), commonsThumbnailWidth), nil
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package artistphoto
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchPhotoURLUsesFanartTVWhenMBIDAndKeyProvided(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/mbid-1" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
if r.URL.Query().Get("api_key") != "secret" {
|
||||
t.Errorf("api_key = %q", r.URL.Query().Get("api_key"))
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"artistthumb": []map[string]string{{"url": "https://fanart.example/photo.jpg"}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{FanartTVBaseURL: srv.URL}
|
||||
u, err := c.FetchPhotoURL(context.Background(), "mbid-1", "Some Artist", "secret")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchPhotoURL: %v", err)
|
||||
}
|
||||
if u != "https://fanart.example/photo.jpg" {
|
||||
t.Errorf("url = %q", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchPhotoURLSkipsFanartTVWithoutMBID(t *testing.T) {
|
||||
fanartCalled := false
|
||||
fanart := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fanartCalled = true
|
||||
}))
|
||||
defer fanart.Close()
|
||||
|
||||
wds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{"search": []map[string]any{}})
|
||||
}))
|
||||
defer wds.Close()
|
||||
|
||||
c := &Client{FanartTVBaseURL: fanart.URL, WikidataSearchBaseURL: wds.URL}
|
||||
if _, err := c.FetchPhotoURL(context.Background(), "", "Some Artist", "secret"); err != nil {
|
||||
t.Fatalf("FetchPhotoURL: %v", err)
|
||||
}
|
||||
if fanartCalled {
|
||||
t.Error("fanart.tv should never be called without a MusicBrainz ID (it has no name-search endpoint)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchPhotoURLSearchesWikidataByName(t *testing.T) {
|
||||
wds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("search"); got != "The Fray" {
|
||||
t.Errorf("search query = %q", got)
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"search": []map[string]any{
|
||||
{"id": "Q191571", "label": "The Fray", "description": "American rock band"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer wds.Close()
|
||||
|
||||
wde := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/Q191571.json" {
|
||||
t.Errorf("unexpected entity path %q", r.URL.Path)
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"entities": map[string]any{
|
||||
"Q191571": map[string]any{
|
||||
"claims": map[string]any{
|
||||
// A real Wikidata entity carries many other
|
||||
// properties whose datavalue.value is an
|
||||
// object, not a string (P31 "instance of" is
|
||||
// an entity reference, for example) — this
|
||||
// must not prevent P18 from being read.
|
||||
"P31": []map[string]any{
|
||||
{"mainsnak": map[string]any{"datavalue": map[string]any{"value": map[string]any{"entity-type": "item", "id": "Q215380"}}}},
|
||||
},
|
||||
"P18": []map[string]any{
|
||||
{"mainsnak": map[string]any{"datavalue": map[string]any{"value": "The Fray.jpg"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer wde.Close()
|
||||
|
||||
commons := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer commons.Close()
|
||||
|
||||
c := &Client{WikidataSearchBaseURL: wds.URL, WikidataEntityBaseURL: wde.URL, CommonsBaseURL: commons.URL}
|
||||
u, err := c.FetchPhotoURL(context.Background(), "", "The Fray", "")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchPhotoURL: %v", err)
|
||||
}
|
||||
want := commons.URL + "/The%20Fray.jpg?width=800"
|
||||
if u != want {
|
||||
t.Errorf("url = %q, want %q", u, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchPhotoURLFallsBackToWikidataWhenFanartHasNoPhoto(t *testing.T) {
|
||||
fanart := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer fanart.Close()
|
||||
|
||||
wds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"search": []map[string]any{
|
||||
{"id": "Q1", "label": "Some Artist", "description": "American singer"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer wds.Close()
|
||||
|
||||
wde := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{"entities": map[string]any{"Q1": map[string]any{"claims": map[string]any{}}}})
|
||||
}))
|
||||
defer wde.Close()
|
||||
|
||||
c := &Client{FanartTVBaseURL: fanart.URL, WikidataSearchBaseURL: wds.URL, WikidataEntityBaseURL: wde.URL}
|
||||
u, err := c.FetchPhotoURL(context.Background(), "mbid-2", "Some Artist", "secret")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchPhotoURL: %v", err)
|
||||
}
|
||||
if u != "" {
|
||||
t.Errorf("expected empty URL (no P18 claim), got %q", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchArtistWikidataIDPrefersMusicDescription(t *testing.T) {
|
||||
wds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"search": []map[string]any{
|
||||
{"id": "Q-actor", "label": "Gigi Perez", "description": "Filipino actor"},
|
||||
{"id": "Q-singer", "label": "Gigi Perez", "description": "American singer-songwriter"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer wds.Close()
|
||||
|
||||
c := &Client{WikidataSearchBaseURL: wds.URL}
|
||||
qid, err := c.SearchArtistWikidataID(context.Background(), "Gigi Perez")
|
||||
if err != nil {
|
||||
t.Fatalf("SearchArtistWikidataID: %v", err)
|
||||
}
|
||||
if qid != "Q-singer" {
|
||||
t.Errorf("qid = %q, want the music-flavored match Q-singer", qid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchArtistWikidataIDRequiresExactLabelMatch(t *testing.T) {
|
||||
wds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"search": []map[string]any{
|
||||
{"id": "Q-tribute", "label": "Not Related! with Luke Smith Tribute", "description": "cover band"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer wds.Close()
|
||||
|
||||
c := &Client{WikidataSearchBaseURL: wds.URL}
|
||||
qid, err := c.SearchArtistWikidataID(context.Background(), "Not Related! with Luke Smith")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
if qid != "" {
|
||||
t.Errorf("expected no match for a non-exact label, got %q", qid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchWikidataPhotoByNameRetriesOnTransientError(t *testing.T) {
|
||||
var calls int
|
||||
wds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
// Simulate a transient network hiccup by closing the
|
||||
// connection without responding at all.
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
t.Fatal("ResponseWriter does not support hijacking")
|
||||
}
|
||||
conn, _, err := hj.Hijack()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"search": []map[string]any{}})
|
||||
}))
|
||||
defer wds.Close()
|
||||
|
||||
c := &Client{WikidataSearchBaseURL: wds.URL}
|
||||
u, err := c.FetchPhotoURL(context.Background(), "", "Retry Artist", "")
|
||||
if err != nil {
|
||||
t.Fatalf("expected the retry to recover from the first request's transient failure, got %v", err)
|
||||
}
|
||||
if u != "" {
|
||||
t.Errorf("expected empty URL (no search results), got %q", u)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Errorf("expected exactly one retry (2 total calls), got %d calls", calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
// Package audiofeatures extracts a small set of real, numerically
|
||||
// computed audio characteristics from a media file — tempo, loudness,
|
||||
// spectral shape, and a chroma (pitch-class) profile used both for a
|
||||
// musical key guess and as the basis of a sonic-similarity feature
|
||||
// vector. Everything here is derived from decoding the actual audio
|
||||
// with ffmpeg; nothing is inferred from tags, filenames, or genre.
|
||||
package audiofeatures
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"os/exec"
|
||||
"sort"
|
||||
)
|
||||
|
||||
const (
|
||||
sampleRate = 22050
|
||||
frameSize = 2048
|
||||
hopSize = 1024
|
||||
)
|
||||
|
||||
// Features is everything VibeSonic actually managed to measure about a
|
||||
// track's audio. Fields the analysis couldn't determine with enough
|
||||
// confidence (e.g. tempo on a clip too short to contain a beat) are
|
||||
// left at their zero value with the matching Valid flag false, rather
|
||||
// than filled with a guess.
|
||||
type Features struct {
|
||||
TempoBPM float64
|
||||
TempoValid bool
|
||||
Key string // e.g. "C major", "" if undetermined
|
||||
LoudnessLUFS float64 // approximate: mean-square level in dB, not full ITU-R BS.1770
|
||||
SpectralCentroidHz float64
|
||||
SpectralRolloffHz float64
|
||||
ZeroCrossingRate float64
|
||||
Chroma [12]float64 // normalized pitch-class energy distribution
|
||||
}
|
||||
|
||||
// Vector returns the fixed-length numeric feature vector used for
|
||||
// sonic-similarity distance calculations: [tempo, loudness, centroid,
|
||||
// rolloff, zcr, chroma(12)]. An invalid tempo contributes 0, same as a
|
||||
// track's other quiet dimensions would if it were literally silent in
|
||||
// that regard — the similarity index normalizes each dimension across
|
||||
// the library before comparing, so a shared 0 among untempoed tracks
|
||||
// doesn't bias results toward or away from any real tempo.
|
||||
func (f Features) Vector() []float64 {
|
||||
tempo := 0.0
|
||||
if f.TempoValid {
|
||||
tempo = f.TempoBPM
|
||||
}
|
||||
v := make([]float64, 0, 17)
|
||||
v = append(v, tempo, f.LoudnessLUFS, f.SpectralCentroidHz, f.SpectralRolloffHz, f.ZeroCrossingRate)
|
||||
v = append(v, f.Chroma[:]...)
|
||||
return v
|
||||
}
|
||||
|
||||
// Analyze decodes sourcePath to mono PCM via ffmpeg and computes
|
||||
// Features from it. It requires at least a few frames of audio
|
||||
// (roughly 150ms); shorter input still returns loudness/spectral
|
||||
// values from what's available but leaves tempo unset.
|
||||
func Analyze(ctx context.Context, ffmpegPath, sourcePath string) (*Features, error) {
|
||||
if ffmpegPath == "" {
|
||||
ffmpegPath = "ffmpeg"
|
||||
}
|
||||
pcm, err := decodePCM(ctx, ffmpegPath, sourcePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(pcm) == 0 {
|
||||
return nil, fmt.Errorf("audiofeatures: no decodable audio in %s", sourcePath)
|
||||
}
|
||||
return analyzeSamples(pcm), nil
|
||||
}
|
||||
|
||||
func decodePCM(ctx context.Context, ffmpegPath, sourcePath string) ([]float64, error) {
|
||||
cmd := exec.CommandContext(ctx, ffmpegPath,
|
||||
"-v", "error",
|
||||
"-i", sourcePath,
|
||||
"-f", "f32le", "-ac", "1", "-ar", fmt.Sprintf("%d", sampleRate),
|
||||
"-",
|
||||
)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("ffmpeg decode failed: %w: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
raw := stdout.Bytes()
|
||||
n := len(raw) / 4
|
||||
samples := make([]float64, n)
|
||||
for i := 0; i < n; i++ {
|
||||
bits := binary.LittleEndian.Uint32(raw[i*4 : i*4+4])
|
||||
samples[i] = float64(math.Float32frombits(bits))
|
||||
}
|
||||
return samples, nil
|
||||
}
|
||||
|
||||
func analyzeSamples(samples []float64) *Features {
|
||||
f := &Features{}
|
||||
|
||||
// Overall RMS loudness, in dB relative to full scale — a real
|
||||
// (if simplified) measurement of the decoded signal, not a stand-in.
|
||||
var sumSq float64
|
||||
for _, s := range samples {
|
||||
sumSq += s * s
|
||||
}
|
||||
rms := math.Sqrt(sumSq / float64(len(samples)))
|
||||
if rms > 0 {
|
||||
f.LoudnessLUFS = 20 * math.Log10(rms)
|
||||
} else {
|
||||
f.LoudnessLUFS = -120
|
||||
}
|
||||
|
||||
var (
|
||||
centroidSum, rolloffSum, zcrSum float64
|
||||
frameCount int
|
||||
energyEnvelope []float64
|
||||
chroma [12]float64
|
||||
)
|
||||
|
||||
hann := hannWindow(frameSize)
|
||||
for start := 0; start+frameSize <= len(samples); start += hopSize {
|
||||
frame := make([]float64, frameSize)
|
||||
var frameEnergy float64
|
||||
var crossings int
|
||||
for i := 0; i < frameSize; i++ {
|
||||
s := samples[start+i]
|
||||
frame[i] = s * hann[i]
|
||||
frameEnergy += s * s
|
||||
if i > 0 && ((samples[start+i-1] < 0) != (s < 0)) {
|
||||
crossings++
|
||||
}
|
||||
}
|
||||
energyEnvelope = append(energyEnvelope, math.Sqrt(frameEnergy/float64(frameSize)))
|
||||
zcrSum += float64(crossings) / float64(frameSize)
|
||||
|
||||
mags := magnitudeSpectrum(frame)
|
||||
centroid, rolloff := spectralShape(mags, sampleRate, frameSize)
|
||||
centroidSum += centroid
|
||||
rolloffSum += rolloff
|
||||
accumulateChroma(&chroma, mags, sampleRate, frameSize)
|
||||
|
||||
frameCount++
|
||||
}
|
||||
|
||||
if frameCount > 0 {
|
||||
f.SpectralCentroidHz = centroidSum / float64(frameCount)
|
||||
f.SpectralRolloffHz = rolloffSum / float64(frameCount)
|
||||
f.ZeroCrossingRate = zcrSum / float64(frameCount)
|
||||
normalizeChroma(&chroma)
|
||||
f.Chroma = chroma
|
||||
f.Key = estimateKey(chroma)
|
||||
}
|
||||
|
||||
if bpm, ok := estimateTempo(energyEnvelope, float64(hopSize)/float64(sampleRate)); ok {
|
||||
f.TempoBPM = bpm
|
||||
f.TempoValid = true
|
||||
}
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
func hannWindow(n int) []float64 {
|
||||
w := make([]float64, n)
|
||||
for i := range w {
|
||||
w[i] = 0.5 * (1 - math.Cos(2*math.Pi*float64(i)/float64(n-1)))
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// spectralShape returns the spectral centroid (the "center of mass" of
|
||||
// the spectrum, in Hz — a brightness measure) and the 85% rolloff
|
||||
// frequency (below which 85% of the frame's spectral energy lies).
|
||||
func spectralShape(mags []float64, sampleRate, frameSize int) (centroid, rolloff float64) {
|
||||
var weighted, total float64
|
||||
binHz := float64(sampleRate) / float64(frameSize)
|
||||
for i, m := range mags {
|
||||
freq := float64(i) * binHz
|
||||
weighted += freq * m
|
||||
total += m
|
||||
}
|
||||
if total == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
centroid = weighted / total
|
||||
|
||||
target := 0.85 * total
|
||||
var cum float64
|
||||
for i, m := range mags {
|
||||
cum += m
|
||||
if cum >= target {
|
||||
rolloff = float64(i) * binHz
|
||||
break
|
||||
}
|
||||
}
|
||||
return centroid, rolloff
|
||||
}
|
||||
|
||||
// accumulateChroma folds a frame's spectral magnitude into 12
|
||||
// pitch-class bins (assuming standard A440 tuning), skipping the
|
||||
// sub-audible and very-high-frequency bins where pitch content is
|
||||
// unreliable.
|
||||
func accumulateChroma(chroma *[12]float64, mags []float64, sampleRate, frameSize int) {
|
||||
binHz := float64(sampleRate) / float64(frameSize)
|
||||
for i, m := range mags {
|
||||
freq := float64(i) * binHz
|
||||
if freq < 27.5 || freq > 5000 || m == 0 {
|
||||
continue
|
||||
}
|
||||
midi := 69 + 12*math.Log2(freq/440)
|
||||
pc := int(math.Round(midi)) % 12
|
||||
if pc < 0 {
|
||||
pc += 12
|
||||
}
|
||||
chroma[pc] += m
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeChroma(chroma *[12]float64) {
|
||||
var total float64
|
||||
for _, v := range chroma {
|
||||
total += v
|
||||
}
|
||||
if total == 0 {
|
||||
return
|
||||
}
|
||||
for i := range chroma {
|
||||
chroma[i] /= total
|
||||
}
|
||||
}
|
||||
|
||||
// krumhanslMajor/Minor are the classic Krumhansl-Kessler key profiles:
|
||||
// the relative perceptual stability of each pitch class within a major
|
||||
// or minor key, starting from the tonic. Correlating a track's chroma
|
||||
// vector against all 24 rotations picks the best-fitting key.
|
||||
var krumhanslMajor = [12]float64{6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88}
|
||||
var krumhanslMinor = [12]float64{6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17}
|
||||
|
||||
var pitchClassNames = [12]string{"C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B"}
|
||||
|
||||
func estimateKey(chroma [12]float64) string {
|
||||
type candidate struct {
|
||||
name string
|
||||
score float64
|
||||
}
|
||||
var candidates []candidate
|
||||
for tonic := 0; tonic < 12; tonic++ {
|
||||
candidates = append(candidates,
|
||||
candidate{pitchClassNames[tonic] + " major", correlate(chroma, rotate(krumhanslMajor, tonic))},
|
||||
candidate{pitchClassNames[tonic] + " minor", correlate(chroma, rotate(krumhanslMinor, tonic))},
|
||||
)
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool { return candidates[i].score > candidates[j].score })
|
||||
if len(candidates) == 0 || candidates[0].score <= 0 {
|
||||
return ""
|
||||
}
|
||||
return candidates[0].name
|
||||
}
|
||||
|
||||
func rotate(profile [12]float64, by int) [12]float64 {
|
||||
var out [12]float64
|
||||
for i := range out {
|
||||
out[i] = profile[(i-by+12)%12]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func correlate(a, b [12]float64) float64 {
|
||||
var meanA, meanB float64
|
||||
for i := 0; i < 12; i++ {
|
||||
meanA += a[i]
|
||||
meanB += b[i]
|
||||
}
|
||||
meanA /= 12
|
||||
meanB /= 12
|
||||
var num, denomA, denomB float64
|
||||
for i := 0; i < 12; i++ {
|
||||
da, db := a[i]-meanA, b[i]-meanB
|
||||
num += da * db
|
||||
denomA += da * da
|
||||
denomB += db * db
|
||||
}
|
||||
if denomA == 0 || denomB == 0 {
|
||||
return 0
|
||||
}
|
||||
return num / math.Sqrt(denomA*denomB)
|
||||
}
|
||||
|
||||
// estimateTempo autocorrelates the frame-wise energy envelope over the
|
||||
// lag range corresponding to 50-220 BPM and returns the lag with the
|
||||
// strongest periodicity. It reports ok=false when there isn't enough
|
||||
// envelope to cover even the slowest candidate tempo, rather than
|
||||
// returning a meaningless number for a clip too short to have a beat.
|
||||
func estimateTempo(envelope []float64, hopSeconds float64) (bpm float64, ok bool) {
|
||||
minLagSec := 60.0 / 220.0
|
||||
maxLagSec := 60.0 / 50.0
|
||||
minLag := int(minLagSec / hopSeconds)
|
||||
maxLag := int(maxLagSec / hopSeconds)
|
||||
if minLag < 1 {
|
||||
minLag = 1
|
||||
}
|
||||
if len(envelope) < maxLag+8 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var mean float64
|
||||
for _, v := range envelope {
|
||||
mean += v
|
||||
}
|
||||
mean /= float64(len(envelope))
|
||||
centered := make([]float64, len(envelope))
|
||||
for i, v := range envelope {
|
||||
centered[i] = v - mean
|
||||
}
|
||||
|
||||
bestLag := -1
|
||||
bestScore := 0.0
|
||||
for lag := minLag; lag <= maxLag && lag < len(centered); lag++ {
|
||||
var score float64
|
||||
for i := 0; i+lag < len(centered); i++ {
|
||||
score += centered[i] * centered[i+lag]
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestLag = lag
|
||||
}
|
||||
}
|
||||
if bestLag <= 0 || bestScore <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return 60.0 / (float64(bestLag) * hopSeconds), true
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package audiofeatures
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func genTone(t *testing.T, dir, name string, freq float64, duration string) string {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
t.Skip("ffmpeg not installed, skipping audiofeatures integration test")
|
||||
}
|
||||
out := filepath.Join(dir, name)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg", "-y",
|
||||
"-f", "lavfi", "-i", "sine=frequency="+strconv.FormatFloat(freq, 'f', -1, 64)+":duration="+duration,
|
||||
"-codec:a", "pcm_s16le", out,
|
||||
)
|
||||
if combined, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("ffmpeg failed: %v\n%s", err, combined)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAnalyzeLowVsHighToneHaveDifferentChromaPeak(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
lowPath := genTone(t, dir, "low.wav", 130.81, "1") // C3
|
||||
highPath := genTone(t, dir, "high.wav", 1046.50, "1") // C6 — same pitch class, different octave
|
||||
|
||||
low, err := Analyze(context.Background(), "", lowPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Analyze(low): %v", err)
|
||||
}
|
||||
high, err := Analyze(context.Background(), "", highPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Analyze(high): %v", err)
|
||||
}
|
||||
|
||||
if high.SpectralCentroidHz <= low.SpectralCentroidHz {
|
||||
t.Errorf("expected the higher tone to have a higher spectral centroid: low=%.1f high=%.1f",
|
||||
low.SpectralCentroidHz, high.SpectralCentroidHz)
|
||||
}
|
||||
|
||||
// Both tones are a C, just in different octaves: pitch-class energy
|
||||
// should peak at chroma bin 0 for both — a real, verifiable property
|
||||
// of the chroma computation, not a fuzzy assertion.
|
||||
if argmax(low.Chroma) != 0 {
|
||||
t.Errorf("low tone (C3) chroma peak = bin %d, want 0 (C)", argmax(low.Chroma))
|
||||
}
|
||||
if argmax(high.Chroma) != 0 {
|
||||
t.Errorf("high tone (C6) chroma peak = bin %d, want 0 (C)", argmax(high.Chroma))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDifferentPitchClassesHaveDifferentChromaPeak(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cPath := genTone(t, dir, "c.wav", 261.63, "1")
|
||||
gPath := genTone(t, dir, "g.wav", 392.00, "1")
|
||||
|
||||
c, err := Analyze(context.Background(), "", cPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Analyze(C): %v", err)
|
||||
}
|
||||
g, err := Analyze(context.Background(), "", gPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Analyze(G): %v", err)
|
||||
}
|
||||
|
||||
if argmax(c.Chroma) != 0 {
|
||||
t.Errorf("C4 chroma peak = bin %d, want 0", argmax(c.Chroma))
|
||||
}
|
||||
if argmax(g.Chroma) != 7 {
|
||||
t.Errorf("G4 chroma peak = bin %d, want 7", argmax(g.Chroma))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeLoudVsQuietHaveDifferentLoudness(t *testing.T) {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
t.Skip("ffmpeg not installed, skipping audiofeatures integration test")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
loud := filepath.Join(dir, "loud.wav")
|
||||
if out, err := exec.CommandContext(ctx, "ffmpeg", "-y", "-f", "lavfi",
|
||||
"-i", "sine=frequency=440:duration=1", "-af", "volume=1.0", "-codec:a", "pcm_s16le", loud).CombinedOutput(); err != nil {
|
||||
t.Fatalf("ffmpeg: %v\n%s", err, out)
|
||||
}
|
||||
quiet := filepath.Join(dir, "quiet.wav")
|
||||
if out, err := exec.CommandContext(ctx, "ffmpeg", "-y", "-f", "lavfi",
|
||||
"-i", "sine=frequency=440:duration=1", "-af", "volume=0.05", "-codec:a", "pcm_s16le", quiet).CombinedOutput(); err != nil {
|
||||
t.Fatalf("ffmpeg: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
loudF, err := Analyze(context.Background(), "", loud)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quietF, err := Analyze(context.Background(), "", quiet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loudF.LoudnessLUFS <= quietF.LoudnessLUFS {
|
||||
t.Errorf("loud=%.1f quiet=%.1f, want loud clip to measure louder", loudF.LoudnessLUFS, quietF.LoudnessLUFS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeShortClipLeavesTempoInvalid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// A single sine tone has no rhythmic/onset content at all — VibeSonic
|
||||
// must not fabricate a BPM for it.
|
||||
path := genTone(t, dir, "tone.wav", 440, "0.5")
|
||||
|
||||
f, err := Analyze(context.Background(), "", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.TempoValid {
|
||||
t.Errorf("expected TempoValid=false for a featureless 0.5s tone, got %.1f BPM", f.TempoBPM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVectorLengthAndTempoZeroWhenInvalid(t *testing.T) {
|
||||
f := Features{TempoValid: false, TempoBPM: 999}
|
||||
v := f.Vector()
|
||||
if len(v) != 17 {
|
||||
t.Fatalf("Vector length = %d, want 17", len(v))
|
||||
}
|
||||
if v[0] != 0 {
|
||||
t.Errorf("Vector()[0] (tempo) = %v, want 0 when TempoValid=false", v[0])
|
||||
}
|
||||
}
|
||||
|
||||
func argmax(v [12]float64) int {
|
||||
best := 0
|
||||
for i := 1; i < 12; i++ {
|
||||
if v[i] > v[best] {
|
||||
best = i
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package audiofeatures
|
||||
|
||||
import "math"
|
||||
|
||||
// fft computes the in-place iterative radix-2 Cooley-Tukey FFT of a,
|
||||
// whose length must be a power of two. It's the only DSP primitive this
|
||||
// package needs (spectral centroid/rolloff and the chroma vector all
|
||||
// derive from the resulting magnitude spectrum), so a small
|
||||
// hand-written implementation avoids pulling in an external numerics
|
||||
// dependency for one function.
|
||||
func fft(a []complex128) {
|
||||
n := len(a)
|
||||
if n <= 1 {
|
||||
return
|
||||
}
|
||||
|
||||
// Bit-reversal permutation.
|
||||
for i, j := 1, 0; i < n; i++ {
|
||||
bit := n >> 1
|
||||
for ; j&bit != 0; bit >>= 1 {
|
||||
j ^= bit
|
||||
}
|
||||
j ^= bit
|
||||
if i < j {
|
||||
a[i], a[j] = a[j], a[i]
|
||||
}
|
||||
}
|
||||
|
||||
for length := 2; length <= n; length <<= 1 {
|
||||
ang := -2 * math.Pi / float64(length)
|
||||
wlen := complex(math.Cos(ang), math.Sin(ang))
|
||||
for i := 0; i < n; i += length {
|
||||
w := complex(1, 0)
|
||||
half := length / 2
|
||||
for k := 0; k < half; k++ {
|
||||
u := a[i+k]
|
||||
v := a[i+k+half] * w
|
||||
a[i+k] = u + v
|
||||
a[i+k+half] = u - v
|
||||
w *= wlen
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// magnitudeSpectrum returns |FFT(frame)| for bins [0, n/2], given a
|
||||
// real-valued frame whose length is a power of two.
|
||||
func magnitudeSpectrum(frame []float64) []float64 {
|
||||
n := len(frame)
|
||||
buf := make([]complex128, n)
|
||||
for i, v := range frame {
|
||||
buf[i] = complex(v, 0)
|
||||
}
|
||||
fft(buf)
|
||||
mags := make([]float64, n/2+1)
|
||||
for i := range mags {
|
||||
mags[i] = cmplxAbs(buf[i])
|
||||
}
|
||||
return mags
|
||||
}
|
||||
|
||||
func cmplxAbs(c complex128) float64 {
|
||||
re, im := real(c), imag(c)
|
||||
return math.Sqrt(re*re + im*im)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
// FuzzParseCIDROrIP guards against a crash in the
|
||||
// VIBESONIC_EXTAUTH_TRUSTED_SOURCES parser, which is security-sensitive:
|
||||
// it decides which peers are trusted to assert an authenticated
|
||||
// identity via external auth.
|
||||
func FuzzParseCIDROrIP(f *testing.F) {
|
||||
f.Add("127.0.0.1/32")
|
||||
f.Add("10.0.0.0/8")
|
||||
f.Add("::1")
|
||||
f.Add("fd00::/8")
|
||||
f.Add("")
|
||||
f.Add("not-an-ip")
|
||||
f.Add("127.0.0.1/99")
|
||||
f.Add("999.999.999.999")
|
||||
f.Add("::/0")
|
||||
|
||||
f.Fuzz(func(t *testing.T, s string) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("parseCIDROrIP panicked on %q: %v", s, r)
|
||||
}
|
||||
}()
|
||||
_, _ = parseCIDROrIP(s)
|
||||
})
|
||||
}
|
||||
@@ -26,6 +26,33 @@ type Config struct {
|
||||
SessionSecret string
|
||||
CookieSecure bool
|
||||
|
||||
// EnableExternalMetadata opts into looking up MusicBrainz/Cover Art
|
||||
// Archive data for songs/albums that already carry a locally-tagged
|
||||
// MusicBrainz ID. Off by default: VibeSonic must work completely
|
||||
// without Internet access, and this is the only feature that talks
|
||||
// to the outside world.
|
||||
EnableExternalMetadata bool
|
||||
|
||||
// EnableArtistPhotos opts into looking up an artist photo (fanart.tv,
|
||||
// falling back to Wikidata/Wikimedia Commons) for artists that carry
|
||||
// a locally-tagged MusicBrainz ID. Off by default for the same
|
||||
// offline-first reason as EnableExternalMetadata.
|
||||
EnableArtistPhotos bool
|
||||
|
||||
// FanartTVAPIKey is an optional personal API key for fanart.tv,
|
||||
// tried before the free Wikidata fallback when EnableArtistPhotos is
|
||||
// on. Empty means "skip fanart.tv".
|
||||
FanartTVAPIKey string
|
||||
|
||||
// PodcastsAllowPrivateNetworks disables SSRF protection on podcast
|
||||
// feed/enclosure fetching, allowing feeds hosted on loopback/LAN/
|
||||
// link-local addresses. Off by default: podcast URLs are supplied
|
||||
// by an admin (or, in principle, a compromised feed's redirects)
|
||||
// and must not be usable to probe the server's own internal
|
||||
// network. Only enable this for deployments that intentionally
|
||||
// host podcast feeds on a private network.
|
||||
PodcastsAllowPrivateNetworks bool
|
||||
|
||||
ExtAuth ExtAuthConfig
|
||||
}
|
||||
|
||||
@@ -52,7 +79,17 @@ func Load() (Config, error) {
|
||||
FFprobePath: envOr("VIBESONIC_FFPROBE_PATH", "ffprobe"),
|
||||
MPVPath: envOr("VIBESONIC_MPV_PATH", "mpv"),
|
||||
SessionSecret: os.Getenv("VIBESONIC_SESSION_SECRET"),
|
||||
CookieSecure: envBool("VIBESONIC_COOKIE_SECURE", true),
|
||||
// The built-in server speaks plain HTTP, so Secure cookies cannot be
|
||||
// the default: browsers would discard them during first-run setup.
|
||||
// Deployments terminating TLS at a reverse proxy should opt in.
|
||||
CookieSecure: envBool("VIBESONIC_COOKIE_SECURE", false),
|
||||
|
||||
EnableExternalMetadata: envBool("VIBESONIC_ENABLE_EXTERNAL_METADATA", false),
|
||||
|
||||
EnableArtistPhotos: envBool("VIBESONIC_ENABLE_ARTIST_PHOTOS", false),
|
||||
FanartTVAPIKey: os.Getenv("VIBESONIC_FANART_TV_API_KEY"),
|
||||
|
||||
PodcastsAllowPrivateNetworks: envBool("VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS", false),
|
||||
}
|
||||
cfg.DBPath = envOr("VIBESONIC_DB", filepath.Join(cfg.DataDir, "vibesonic.db"))
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ func TestLoadDefaults(t *testing.T) {
|
||||
if cfg.DBPath != "/var/lib/vibesonic/vibesonic.db" {
|
||||
t.Errorf("DBPath = %q, want default under DataDir", cfg.DBPath)
|
||||
}
|
||||
if cfg.CookieSecure {
|
||||
t.Error("CookieSecure = true, want false for the default plain-HTTP server")
|
||||
}
|
||||
if cfg.ExtAuth.Enabled {
|
||||
t.Errorf("ExtAuth.Enabled = true, want false by default")
|
||||
}
|
||||
@@ -33,6 +36,17 @@ func TestLoadDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSecureCookiesOptIn(t *testing.T) {
|
||||
t.Setenv("VIBESONIC_COOKIE_SECURE", "true")
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if !cfg.CookieSecure {
|
||||
t.Error("CookieSecure = false, want true when explicitly enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMusicDirs(t *testing.T) {
|
||||
withEnv(t, map[string]string{"VIBESONIC_MUSIC_DIRS": "/music:/music-lossless: /audiobooks "})
|
||||
cfg, err := Load()
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -19,6 +20,12 @@ import (
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
// maxDecodePixels bounds the decoded pixel count of a source image so a
|
||||
// small, highly-compressed file (a "decompression bomb") can't force an
|
||||
// enormous in-memory pixel buffer allocation. 40 megapixels comfortably
|
||||
// covers any legitimate cover art.
|
||||
const maxDecodePixels = 40_000_000
|
||||
|
||||
// commonFolderImageNames are checked, in priority order, for a
|
||||
// folder-level cover image when a file has no embedded artwork.
|
||||
var commonFolderImageNames = []string{
|
||||
@@ -70,7 +77,11 @@ func (s *Store) ExtractEmbedded(ctx context.Context, mediaPath string, streamInd
|
||||
"-map", fmt.Sprintf("0:%d", streamIndex),
|
||||
"-frames:v", "1",
|
||||
"-c:v", "copy",
|
||||
"--", tmp.Name(),
|
||||
// Temporary files deliberately have a neutral .img suffix. Tell
|
||||
// ffmpeg which muxer to use instead of asking it to infer one from
|
||||
// that suffix. ffmpeg does not accept `--` as an end-of-options
|
||||
// marker, so including it here made every embedded-art request fail.
|
||||
"-f", "image2", tmp.Name(),
|
||||
)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
os.Remove(tmp.Name())
|
||||
@@ -112,6 +123,15 @@ func (s *Store) GetOrCreate(sourcePath, contentHash string, size int) (string, e
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
if cfg, _, err := image.DecodeConfig(src); err == nil {
|
||||
if cfg.Width*cfg.Height > maxDecodePixels {
|
||||
return "", fmt.Errorf("source image %s exceeds %d pixel limit (%dx%d)", sourcePath, maxDecodePixels, cfg.Width, cfg.Height)
|
||||
}
|
||||
}
|
||||
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
img, _, err := image.Decode(src)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decoding image: %w", err)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package coverart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
@@ -9,6 +10,33 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractEmbeddedSuppliesExplicitOutputFormat(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
argsPath := filepath.Join(dir, "args")
|
||||
fakeFFmpeg := filepath.Join(dir, "ffmpeg")
|
||||
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$ARGS_PATH\"\n"
|
||||
if err := os.WriteFile(fakeFFmpeg, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ARGS_PATH", argsPath)
|
||||
|
||||
store := New(dir, fakeFFmpeg)
|
||||
path, err := store.ExtractEmbedded(context.Background(), filepath.Join(dir, "song.mp3"), 2)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractEmbedded: %v", err)
|
||||
}
|
||||
defer os.Remove(path)
|
||||
|
||||
args, err := os.ReadFile(argsPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(args)
|
||||
if got != "-y\n-v\nerror\n-i\n"+filepath.Join(dir, "song.mp3")+"\n-map\n0:2\n-frames:v\n1\n-c:v\ncopy\n-f\nimage2\n"+path+"\n" {
|
||||
t.Fatalf("unexpected ffmpeg arguments:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestJPEG(t *testing.T, path string, w, h int) {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/audiofeatures"
|
||||
)
|
||||
|
||||
// AudioFeaturesRow is one song's stored sonic-analysis result.
|
||||
type AudioFeaturesRow struct {
|
||||
SongID string
|
||||
TempoBPM float64
|
||||
TempoValid bool
|
||||
MusicalKey string
|
||||
LoudnessLUFS float64
|
||||
Extra audioFeaturesExtra
|
||||
}
|
||||
|
||||
// audioFeaturesExtra is the analyzer output that doesn't have its own
|
||||
// dedicated column — kept in extra_json so the schema doesn't need a
|
||||
// migration every time the analyzer grows a new measurement.
|
||||
type audioFeaturesExtra struct {
|
||||
SpectralCentroidHz float64 `json:"spectralCentroidHz"`
|
||||
SpectralRolloffHz float64 `json:"spectralRolloffHz"`
|
||||
ZeroCrossingRate float64 `json:"zeroCrossingRate"`
|
||||
Chroma [12]float64 `json:"chroma"`
|
||||
}
|
||||
|
||||
// Vector reconstructs the same fixed-length feature vector
|
||||
// audiofeatures.Features.Vector produces, from stored values.
|
||||
func (r AudioFeaturesRow) Vector() []float64 {
|
||||
tempo := 0.0
|
||||
if r.TempoValid {
|
||||
tempo = r.TempoBPM
|
||||
}
|
||||
v := make([]float64, 0, 17)
|
||||
v = append(v, tempo, r.LoudnessLUFS, r.Extra.SpectralCentroidHz, r.Extra.SpectralRolloffHz, r.Extra.ZeroCrossingRate)
|
||||
v = append(v, r.Extra.Chroma[:]...)
|
||||
return v
|
||||
}
|
||||
|
||||
// UpsertAudioFeatures stores (or replaces) the analyzed features for a
|
||||
// song.
|
||||
func UpsertAudioFeatures(db *sql.DB, songID string, f *audiofeatures.Features) error {
|
||||
extra := audioFeaturesExtra{
|
||||
SpectralCentroidHz: f.SpectralCentroidHz,
|
||||
SpectralRolloffHz: f.SpectralRolloffHz,
|
||||
ZeroCrossingRate: f.ZeroCrossingRate,
|
||||
Chroma: f.Chroma,
|
||||
}
|
||||
extraJSON, err := json.Marshal(extra)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var tempo sql.NullFloat64
|
||||
if f.TempoValid {
|
||||
tempo = sql.NullFloat64{Float64: f.TempoBPM, Valid: true}
|
||||
}
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO audio_features (song_id, tempo_bpm, musical_key, loudness_lufs, extra_json, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
ON CONFLICT(song_id) DO UPDATE SET
|
||||
tempo_bpm = excluded.tempo_bpm,
|
||||
musical_key = excluded.musical_key,
|
||||
loudness_lufs = excluded.loudness_lufs,
|
||||
extra_json = excluded.extra_json,
|
||||
updated_at = excluded.updated_at`,
|
||||
songID, tempo, f.Key, f.LoudnessLUFS, string(extraJSON))
|
||||
return err
|
||||
}
|
||||
|
||||
const audioFeaturesSelect = `SELECT song_id, tempo_bpm, musical_key, loudness_lufs, extra_json FROM audio_features`
|
||||
|
||||
func scanAudioFeaturesRow(scan func(dest ...any) error) (*AudioFeaturesRow, error) {
|
||||
var r AudioFeaturesRow
|
||||
var tempo sql.NullFloat64
|
||||
var loudness sql.NullFloat64
|
||||
var extraJSON string
|
||||
if err := scan(&r.SongID, &tempo, &r.MusicalKey, &loudness, &extraJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.TempoValid = tempo.Valid
|
||||
r.TempoBPM = tempo.Float64
|
||||
r.LoudnessLUFS = loudness.Float64
|
||||
_ = json.Unmarshal([]byte(extraJSON), &r.Extra)
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// GetAudioFeatures returns a song's stored analysis, or nil if it
|
||||
// hasn't been analyzed yet.
|
||||
func GetAudioFeatures(db *sql.DB, songID string) (*AudioFeaturesRow, error) {
|
||||
row := db.QueryRow(audioFeaturesSelect+` WHERE song_id = ?`, songID)
|
||||
r, err := scanAudioFeaturesRow(row.Scan)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
// ListAllAudioFeatures returns every analyzed song's features, the
|
||||
// input the in-process sonic-similarity index is built from.
|
||||
func ListAllAudioFeatures(db *sql.DB) ([]AudioFeaturesRow, error) {
|
||||
rows, err := db.Query(audioFeaturesSelect)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AudioFeaturesRow
|
||||
for rows.Next() {
|
||||
r, err := scanAudioFeaturesRow(rows.Scan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/audiofeatures"
|
||||
)
|
||||
|
||||
func openAudioFeaturesTestDB(t *testing.T) (*sql.DB, string) {
|
||||
t.Helper()
|
||||
db, err := Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
folder, err := CreateMusicFolder(db, "Music", "/music", "music")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mf, err := CreateMediaFile(db, folder.ID, "song.mp3", 100, 0, "h", "mp3", "song", 10, 128, 44100, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
song, err := UpsertSong(db, UpsertSongInput{MediaFileID: mf.ID, Title: "Song"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db, song.ID
|
||||
}
|
||||
|
||||
func TestUpsertAndGetAudioFeatures(t *testing.T) {
|
||||
db, songID := openAudioFeaturesTestDB(t)
|
||||
|
||||
f := &audiofeatures.Features{
|
||||
TempoBPM: 120, TempoValid: true, Key: "C major", LoudnessLUFS: -14,
|
||||
SpectralCentroidHz: 1500, SpectralRolloffHz: 4000, ZeroCrossingRate: 0.05,
|
||||
Chroma: [12]float64{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
|
||||
}
|
||||
if err := UpsertAudioFeatures(db, songID, f); err != nil {
|
||||
t.Fatalf("UpsertAudioFeatures: %v", err)
|
||||
}
|
||||
|
||||
got, err := GetAudioFeatures(db, songID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAudioFeatures: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected a row, got nil")
|
||||
}
|
||||
if !got.TempoValid || got.TempoBPM != 120 {
|
||||
t.Errorf("tempo = %v/%v, want 120/valid", got.TempoBPM, got.TempoValid)
|
||||
}
|
||||
if got.MusicalKey != "C major" {
|
||||
t.Errorf("key = %q", got.MusicalKey)
|
||||
}
|
||||
if got.LoudnessLUFS != -14 {
|
||||
t.Errorf("loudness = %v", got.LoudnessLUFS)
|
||||
}
|
||||
if got.Extra.SpectralCentroidHz != 1500 || got.Extra.Chroma[0] != 1 {
|
||||
t.Errorf("extra = %+v", got.Extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertAudioFeaturesOverwrites(t *testing.T) {
|
||||
db, songID := openAudioFeaturesTestDB(t)
|
||||
if err := UpsertAudioFeatures(db, songID, &audiofeatures.Features{Key: "C major"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := UpsertAudioFeatures(db, songID, &audiofeatures.Features{Key: "D minor"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := GetAudioFeatures(db, songID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.MusicalKey != "D minor" {
|
||||
t.Errorf("key = %q, want overwrite to D minor", got.MusicalKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAudioFeaturesNotAnalyzed(t *testing.T) {
|
||||
db, songID := openAudioFeaturesTestDB(t)
|
||||
got, err := GetAudioFeatures(db, songID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("expected nil for an unanalyzed song, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAllAudioFeatures(t *testing.T) {
|
||||
db, songID := openAudioFeaturesTestDB(t)
|
||||
if err := UpsertAudioFeatures(db, songID, &audiofeatures.Features{TempoValid: true, TempoBPM: 90}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all, err := ListAllAudioFeatures(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != 1 || all[0].SongID != songID {
|
||||
t.Fatalf("all = %+v", all)
|
||||
}
|
||||
if len(all[0].Vector()) != 17 {
|
||||
t.Fatalf("Vector length = %d, want 17", len(all[0].Vector()))
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ type CoverArt struct {
|
||||
ID string
|
||||
AlbumID sql.NullString
|
||||
MediaFileID sql.NullString
|
||||
ArtistID sql.NullString
|
||||
Source string
|
||||
SourcePath string
|
||||
MimeType string
|
||||
@@ -19,7 +20,7 @@ type CoverArt struct {
|
||||
Height int
|
||||
}
|
||||
|
||||
const coverArtSelect = `SELECT id, album_id, media_file_id, source, source_path, mime_type, content_hash, width, height FROM cover_art`
|
||||
const coverArtSelect = `SELECT id, album_id, media_file_id, artist_id, source, source_path, mime_type, content_hash, width, height FROM cover_art`
|
||||
|
||||
func GetCoverArt(db *sql.DB, id string) (*CoverArt, error) {
|
||||
return scanCoverArt(db.QueryRow(coverArtSelect+` WHERE id = ?`, id))
|
||||
@@ -31,7 +32,7 @@ func GetCoverArtForAlbum(db *sql.DB, albumID string) (*CoverArt, error) {
|
||||
|
||||
func scanCoverArt(row *sql.Row) (*CoverArt, error) {
|
||||
var c CoverArt
|
||||
err := row.Scan(&c.ID, &c.AlbumID, &c.MediaFileID, &c.Source, &c.SourcePath, &c.MimeType, &c.ContentHash, &c.Width, &c.Height)
|
||||
err := row.Scan(&c.ID, &c.AlbumID, &c.MediaFileID, &c.ArtistID, &c.Source, &c.SourcePath, &c.MimeType, &c.ContentHash, &c.Width, &c.Height)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -71,6 +72,36 @@ func UpsertAlbumCoverArt(db *sql.DB, albumID, source, sourcePath, mediaFileID, m
|
||||
return GetCoverArt(db, id)
|
||||
}
|
||||
|
||||
// UpsertArtistCoverArt records (or replaces) an artist's photo and links
|
||||
// it as the artist's cover art. manual=true marks it as an admin
|
||||
// override (see database.SetArtistCoverArt); manual=false is the
|
||||
// auto-fetch path, which never overwrites an existing manual override.
|
||||
func UpsertArtistCoverArt(db *sql.DB, artistID, source, sourcePath, mimeType, contentHash string, width, height int, manual bool) (*CoverArt, error) {
|
||||
var id string
|
||||
err := db.QueryRow(`SELECT id FROM cover_art WHERE artist_id = ?`, artistID).Scan(&id)
|
||||
switch {
|
||||
case err == nil:
|
||||
_, err = db.Exec(`UPDATE cover_art SET source=?, source_path=?, mime_type=?, content_hash=?, width=?, height=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`,
|
||||
source, sourcePath, mimeType, contentHash, width, height, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
id = idgen.New("ca")
|
||||
_, err = db.Exec(`INSERT INTO cover_art (id, artist_id, source, source_path, mime_type, content_hash, width, height) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, artistID, source, sourcePath, mimeType, contentHash, width, height)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
if err := SetArtistCoverArt(db, artistID, sql.NullString{String: id, Valid: true}, manual); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return GetCoverArt(db, id)
|
||||
}
|
||||
|
||||
func nullEmptyStr(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
|
||||
)
|
||||
|
||||
// GetCachedExternalMetadata returns a previously fetched MusicBrainz/
|
||||
// Cover Art Archive response for (entityType, mbid, source), if any.
|
||||
// found is false when nothing has been cached yet — callers decide
|
||||
// whether/how to refresh, this layer never guesses an age-based expiry.
|
||||
func GetCachedExternalMetadata(db *sql.DB, entityType, mbid, source string) (dataJSON string, fetchedAt time.Time, found bool, err error) {
|
||||
var fetchedAtStr string
|
||||
err = db.QueryRow(`SELECT data_json, fetched_at FROM external_metadata_cache WHERE entity_type = ? AND mbid = ? AND source = ?`,
|
||||
entityType, mbid, source).Scan(&dataJSON, &fetchedAtStr)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", time.Time{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", time.Time{}, false, err
|
||||
}
|
||||
fetchedAt, _ = time.Parse("2006-01-02T15:04:05.000Z", fetchedAtStr)
|
||||
return dataJSON, fetchedAt, true, nil
|
||||
}
|
||||
|
||||
// PutCachedExternalMetadata stores (or replaces) the cached response
|
||||
// for (entityType, mbid, source).
|
||||
func PutCachedExternalMetadata(db *sql.DB, entityType, mbid, source, dataJSON string) error {
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO external_metadata_cache (id, entity_type, mbid, source, data_json, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
ON CONFLICT(entity_type, mbid, source) DO UPDATE SET
|
||||
data_json = excluded.data_json,
|
||||
fetched_at = excluded.fetched_at`,
|
||||
idgen.New("emc"), entityType, mbid, source, dataJSON)
|
||||
return err
|
||||
}
|
||||
@@ -15,6 +15,23 @@ type Artist struct {
|
||||
Name string
|
||||
SortName string
|
||||
MusicBrainzID string
|
||||
Bio string
|
||||
CoverArtID sql.NullString
|
||||
PhotoManual bool
|
||||
}
|
||||
|
||||
const artistSelect = `SELECT id, name, sort_name, musicbrainz_id, bio, cover_art_id, photo_manual FROM artists`
|
||||
|
||||
func scanArtist(row *sql.Row) (*Artist, error) {
|
||||
var a Artist
|
||||
err := row.Scan(&a.ID, &a.Name, &a.SortName, &a.MusicBrainzID, &a.Bio, &a.CoverArtID, &a.PhotoManual)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// FindOrCreateArtist looks up an artist by (name, musicbrainz_id) and
|
||||
@@ -43,19 +60,11 @@ func FindOrCreateArtist(db *sql.DB, name, sortName, musicBrainzID string) (*Arti
|
||||
}
|
||||
|
||||
func GetArtist(db *sql.DB, id string) (*Artist, error) {
|
||||
var a Artist
|
||||
err := db.QueryRow(`SELECT id, name, sort_name, musicbrainz_id FROM artists WHERE id = ?`, id).Scan(&a.ID, &a.Name, &a.SortName, &a.MusicBrainzID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
return scanArtist(db.QueryRow(artistSelect+` WHERE id = ?`, id))
|
||||
}
|
||||
|
||||
func ListArtists(db *sql.DB) ([]*Artist, error) {
|
||||
rows, err := db.Query(`SELECT DISTINCT a.id, a.name, a.sort_name, a.musicbrainz_id FROM artists a
|
||||
rows, err := db.Query(`SELECT DISTINCT a.id, a.name, a.sort_name, a.musicbrainz_id, a.bio, a.cover_art_id, a.photo_manual FROM artists a
|
||||
JOIN album_artists aa ON aa.artist_id = a.id
|
||||
ORDER BY COALESCE(NULLIF(a.sort_name, ''), a.name) COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
@@ -65,7 +74,7 @@ func ListArtists(db *sql.DB) ([]*Artist, error) {
|
||||
var out []*Artist
|
||||
for rows.Next() {
|
||||
var a Artist
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.SortName, &a.MusicBrainzID); err != nil {
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.SortName, &a.MusicBrainzID, &a.Bio, &a.CoverArtID, &a.PhotoManual); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &a)
|
||||
@@ -73,6 +82,25 @@ func ListArtists(db *sql.DB) ([]*Artist, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateArtist overwrites an artist's name, sort name, and bio. It never
|
||||
// touches the artist's photo (see SetArtistCoverArt).
|
||||
func UpdateArtist(db *sql.DB, id, name, sortName, bio string) error {
|
||||
_, err := db.Exec(`UPDATE artists SET name = ?, sort_name = ?, bio = ? WHERE id = ?`, name, sortName, bio, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetArtistCoverArt links (or unlinks) an artist's cover_art row.
|
||||
// manual=true records an admin override; manual=false is the auto-fetch
|
||||
// path, which is guarded to never overwrite an existing manual override.
|
||||
func SetArtistCoverArt(db *sql.DB, artistID string, coverArtID sql.NullString, manual bool) error {
|
||||
if manual {
|
||||
_, err := db.Exec(`UPDATE artists SET cover_art_id = ?, photo_manual = 1 WHERE id = ?`, nullStr(coverArtID), artistID)
|
||||
return err
|
||||
}
|
||||
_, err := db.Exec(`UPDATE artists SET cover_art_id = ?, photo_manual = 0 WHERE id = ? AND photo_manual = 0`, nullStr(coverArtID), artistID)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Genres ---
|
||||
|
||||
type Genre struct {
|
||||
@@ -223,6 +251,14 @@ func scanAlbums(rows *sql.Rows) ([]*Album, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateAlbum overwrites an album's editable metadata fields. It never
|
||||
// touches the album's cover art (see SetAlbumCoverArt).
|
||||
func UpdateAlbum(db *sql.DB, id, title, sortTitle string, year, originalYear sql.NullInt64, label string) error {
|
||||
_, err := db.Exec(`UPDATE albums SET title = ?, sort_title = ?, year = ?, original_year = ?, record_label = ? WHERE id = ?`,
|
||||
title, sortTitle, nullInt(year), nullInt(originalYear), label, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func SetAlbumCoverArt(db *sql.DB, albumID, coverArtID string) error {
|
||||
_, err := db.Exec(`UPDATE albums SET cover_art_id = ? WHERE id = ?`, coverArtID, albumID)
|
||||
return err
|
||||
@@ -260,11 +296,12 @@ type Song struct {
|
||||
ReplayGainTrackPeak sql.NullFloat64
|
||||
ReplayGainAlbumGainDB sql.NullFloat64
|
||||
ReplayGainAlbumPeak sql.NullFloat64
|
||||
MetadataLocked bool
|
||||
}
|
||||
|
||||
const songSelect = `SELECT id, media_file_id, album_id, title, sort_title, track_number, track_total, disc_number, disc_total,
|
||||
composer, conductor, isrc, bpm, musicbrainz_track_id, musicbrainz_recording_id,
|
||||
replaygain_track_gain_db, replaygain_track_peak, replaygain_album_gain_db, replaygain_album_peak FROM songs`
|
||||
replaygain_track_gain_db, replaygain_track_peak, replaygain_album_gain_db, replaygain_album_peak, metadata_locked FROM songs`
|
||||
|
||||
func GetSong(db *sql.DB, id string) (*Song, error) {
|
||||
return scanSong(db.QueryRow(songSelect+` WHERE id = ?`, id))
|
||||
@@ -278,7 +315,7 @@ func scanSong(row *sql.Row) (*Song, error) {
|
||||
var s Song
|
||||
err := row.Scan(&s.ID, &s.MediaFileID, &s.AlbumID, &s.Title, &s.SortTitle, &s.TrackNumber, &s.TrackTotal, &s.DiscNumber, &s.DiscTotal,
|
||||
&s.Composer, &s.Conductor, &s.ISRC, &s.BPM, &s.MusicBrainzTrackID, &s.MusicBrainzRecordingID,
|
||||
&s.ReplayGainTrackGainDB, &s.ReplayGainTrackPeak, &s.ReplayGainAlbumGainDB, &s.ReplayGainAlbumPeak)
|
||||
&s.ReplayGainTrackGainDB, &s.ReplayGainTrackPeak, &s.ReplayGainAlbumGainDB, &s.ReplayGainAlbumPeak, &s.MetadataLocked)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -312,7 +349,7 @@ func scanSongs(rows *sql.Rows) ([]*Song, error) {
|
||||
var s Song
|
||||
if err := rows.Scan(&s.ID, &s.MediaFileID, &s.AlbumID, &s.Title, &s.SortTitle, &s.TrackNumber, &s.TrackTotal, &s.DiscNumber, &s.DiscTotal,
|
||||
&s.Composer, &s.Conductor, &s.ISRC, &s.BPM, &s.MusicBrainzTrackID, &s.MusicBrainzRecordingID,
|
||||
&s.ReplayGainTrackGainDB, &s.ReplayGainTrackPeak, &s.ReplayGainAlbumGainDB, &s.ReplayGainAlbumPeak); err != nil {
|
||||
&s.ReplayGainTrackGainDB, &s.ReplayGainTrackPeak, &s.ReplayGainAlbumGainDB, &s.ReplayGainAlbumPeak, &s.MetadataLocked); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &s)
|
||||
@@ -354,14 +391,29 @@ func UpsertSong(db *sql.DB, in UpsertSongInput) (*Song, error) {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
_, err = db.Exec(`UPDATE songs SET album_id=?, title=?, sort_title=?, track_number=?, track_total=?, disc_number=?, disc_total=?, disc_subtitle=?,
|
||||
composer=?, conductor=?, comment=?, isrc=?, bpm=?, musicbrainz_track_id=?, musicbrainz_recording_id=?,
|
||||
replaygain_track_gain_db=?, replaygain_track_peak=?, replaygain_album_gain_db=?, replaygain_album_peak=?,
|
||||
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`,
|
||||
nullStr(in.AlbumID), in.Title, in.SortTitle, nullInt(in.TrackNumber), nullInt(in.TrackTotal), nullInt(in.DiscNumber), nullInt(in.DiscTotal), in.DiscSubtitle,
|
||||
in.Composer, in.Conductor, in.Comment, in.ISRC, nullInt(in.BPM), in.MusicBrainzTrackID, in.MusicBrainzRecordingID,
|
||||
nullFloat(in.ReplayGainTrackGainDB), nullFloat(in.ReplayGainTrackPeak), nullFloat(in.ReplayGainAlbumGainDB), nullFloat(in.ReplayGainAlbumPeak),
|
||||
existing.ID)
|
||||
if existing.MetadataLocked {
|
||||
// An admin has hand-edited title/sort_title/track_number/
|
||||
// disc_number/composer/conductor for this song; leave those
|
||||
// alone and only refresh the fields that come straight off
|
||||
// the file itself and were never exposed for editing.
|
||||
_, err = db.Exec(`UPDATE songs SET album_id=?, track_total=?, disc_total=?, disc_subtitle=?,
|
||||
comment=?, isrc=?, bpm=?, musicbrainz_track_id=?, musicbrainz_recording_id=?,
|
||||
replaygain_track_gain_db=?, replaygain_track_peak=?, replaygain_album_gain_db=?, replaygain_album_peak=?,
|
||||
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`,
|
||||
nullStr(in.AlbumID), nullInt(in.TrackTotal), nullInt(in.DiscTotal), in.DiscSubtitle,
|
||||
in.Comment, in.ISRC, nullInt(in.BPM), in.MusicBrainzTrackID, in.MusicBrainzRecordingID,
|
||||
nullFloat(in.ReplayGainTrackGainDB), nullFloat(in.ReplayGainTrackPeak), nullFloat(in.ReplayGainAlbumGainDB), nullFloat(in.ReplayGainAlbumPeak),
|
||||
existing.ID)
|
||||
} else {
|
||||
_, err = db.Exec(`UPDATE songs SET album_id=?, title=?, sort_title=?, track_number=?, track_total=?, disc_number=?, disc_total=?, disc_subtitle=?,
|
||||
composer=?, conductor=?, comment=?, isrc=?, bpm=?, musicbrainz_track_id=?, musicbrainz_recording_id=?,
|
||||
replaygain_track_gain_db=?, replaygain_track_peak=?, replaygain_album_gain_db=?, replaygain_album_peak=?,
|
||||
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`,
|
||||
nullStr(in.AlbumID), in.Title, in.SortTitle, nullInt(in.TrackNumber), nullInt(in.TrackTotal), nullInt(in.DiscNumber), nullInt(in.DiscTotal), in.DiscSubtitle,
|
||||
in.Composer, in.Conductor, in.Comment, in.ISRC, nullInt(in.BPM), in.MusicBrainzTrackID, in.MusicBrainzRecordingID,
|
||||
nullFloat(in.ReplayGainTrackGainDB), nullFloat(in.ReplayGainTrackPeak), nullFloat(in.ReplayGainAlbumGainDB), nullFloat(in.ReplayGainAlbumPeak),
|
||||
existing.ID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -382,6 +434,15 @@ func UpsertSong(db *sql.DB, in UpsertSongInput) (*Song, error) {
|
||||
return GetSong(db, id)
|
||||
}
|
||||
|
||||
// UpdateSongMetadata overwrites a song's editable fields. locked=true
|
||||
// sets metadata_locked so a later library rescan (UpsertSong) leaves
|
||||
// these fields alone instead of overwriting them from the file's tags.
|
||||
func UpdateSongMetadata(db *sql.DB, id, title, sortTitle string, trackNumber, discNumber sql.NullInt64, composer, conductor string, locked bool) error {
|
||||
_, err := db.Exec(`UPDATE songs SET title=?, sort_title=?, track_number=?, disc_number=?, composer=?, conductor=?, metadata_locked=? WHERE id=?`,
|
||||
title, sortTitle, nullInt(trackNumber), nullInt(discNumber), composer, conductor, locked, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func ClearSongArtists(db *sql.DB, songID string) error {
|
||||
_, err := db.Exec(`DELETE FROM song_artists WHERE song_id = ?`, songID)
|
||||
return err
|
||||
@@ -431,6 +492,21 @@ func ListSongArtistNames(db *sql.DB, songID string) ([]string, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PrimarySongArtistID returns the ID of a song's first-credited artist
|
||||
// (position 0), or "" if the song has no linked artist — used to link
|
||||
// the now-playing bar's artist name to that artist's page.
|
||||
func PrimarySongArtistID(db *sql.DB, songID string) (string, error) {
|
||||
var id string
|
||||
err := db.QueryRow(`SELECT a.id FROM song_artists sa JOIN artists a ON a.id = sa.artist_id WHERE sa.song_id = ? ORDER BY sa.position LIMIT 1`, songID).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func nullInt(n sql.NullInt64) any {
|
||||
if !n.Valid {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
|
||||
)
|
||||
|
||||
// This file replaces the minimal plain-text-only lyrics helpers from
|
||||
// Phase 2 (SetSongLyrics/SongLyricsPlainText in subsonic_helpers2.go,
|
||||
// still kept for the legacy getLyrics endpoint's simple text need) with
|
||||
// a structured form that preserves line timing and, when the source
|
||||
// provides it, word-level cues with precomputed UTF-8 byte offsets —
|
||||
// everything the OpenSubsonic songLyrics v2 extension needs, stored
|
||||
// once at index time rather than re-derived per request.
|
||||
|
||||
// LyricsWordInput is one word/syllable-level cue to store, already
|
||||
// carrying its UTF-8 byte offsets into the parent line's text (computed
|
||||
// by the caller, which is the only place that still has the exact
|
||||
// string the offsets are relative to).
|
||||
type LyricsWordInput struct {
|
||||
StartMs int
|
||||
EndMs *int
|
||||
ByteStart int
|
||||
ByteEnd int
|
||||
Text string
|
||||
}
|
||||
|
||||
// LyricsLineInput is one line to store. StartMs is nil for unsynced
|
||||
// lyrics.
|
||||
type LyricsLineInput struct {
|
||||
StartMs *int
|
||||
Text string
|
||||
Words []LyricsWordInput
|
||||
}
|
||||
|
||||
// ClearSongLyrics removes every stored lyrics entry (all sources) for a
|
||||
// song; lines and cues cascade-delete with it.
|
||||
func ClearSongLyrics(db *sql.DB, songID string) error {
|
||||
_, err := db.Exec(`DELETE FROM lyrics WHERE song_id = ?`, songID)
|
||||
return err
|
||||
}
|
||||
|
||||
// InsertLyrics stores one lyrics entry (one "structuredLyrics" in
|
||||
// OpenSubsonic terms) for a song. Multiple entries per song are
|
||||
// expected — e.g. an embedded plain-text tag alongside a synced sidecar
|
||||
// .lrc file — and are not merged or deduplicated.
|
||||
func InsertLyrics(db *sql.DB, songID, source, language string, synced bool, lines []LyricsLineInput) (string, error) {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
lyricsID := idgen.New("ly")
|
||||
if _, err := tx.Exec(`INSERT INTO lyrics (id, song_id, language, source, synced) VALUES (?, ?, ?, ?, ?)`,
|
||||
lyricsID, songID, language, source, boolInt(synced)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i, line := range lines {
|
||||
lineID := idgen.New("ll")
|
||||
var startMs any
|
||||
if line.StartMs != nil {
|
||||
startMs = *line.StartMs
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO lyrics_lines (id, lyrics_id, line_index, text, start_ms) VALUES (?, ?, ?, ?, ?)`,
|
||||
lineID, lyricsID, i, line.Text, startMs); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, w := range line.Words {
|
||||
var endMs any
|
||||
if w.EndMs != nil {
|
||||
endMs = *w.EndMs
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO lyrics_cues (id, lyrics_id, line_index, start_ms, end_ms, byte_start, byte_end, text) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
idgen.New("lc"), lyricsID, i, w.StartMs, endMs, w.ByteStart, w.ByteEnd, w.Text); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
return lyricsID, tx.Commit()
|
||||
}
|
||||
|
||||
// StructuredLyricsRow is everything stored for one lyrics entry.
|
||||
type StructuredLyricsRow struct {
|
||||
ID string
|
||||
Language string
|
||||
Source string
|
||||
Synced bool
|
||||
Lines []LyricsLineRow
|
||||
}
|
||||
|
||||
type LyricsLineRow struct {
|
||||
Text string
|
||||
StartMs *int
|
||||
Words []LyricsWordRow
|
||||
}
|
||||
|
||||
type LyricsWordRow struct {
|
||||
StartMs int
|
||||
EndMs *int
|
||||
ByteStart int
|
||||
ByteEnd int
|
||||
Text string
|
||||
}
|
||||
|
||||
// GetSongStructuredLyrics returns every stored lyrics entry for a song,
|
||||
// each with its lines and (if present) word-level cues.
|
||||
func GetSongStructuredLyrics(db *sql.DB, songID string) ([]StructuredLyricsRow, error) {
|
||||
rows, err := db.Query(`SELECT id, language, source, synced FROM lyrics WHERE song_id = ? ORDER BY created_at`, songID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var entries []StructuredLyricsRow
|
||||
for rows.Next() {
|
||||
var e StructuredLyricsRow
|
||||
var synced int
|
||||
if err := rows.Scan(&e.ID, &e.Language, &e.Source, &synced); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
e.Synced = synced != 0
|
||||
entries = append(entries, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for i := range entries {
|
||||
lines, err := lyricsLinesFor(db, entries[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries[i].Lines = lines
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func lyricsLinesFor(db *sql.DB, lyricsID string) ([]LyricsLineRow, error) {
|
||||
rows, err := db.Query(`SELECT line_index, text, start_ms FROM lyrics_lines WHERE lyrics_id = ? ORDER BY line_index`, lyricsID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []LyricsLineRow
|
||||
byIndex := map[int]*LyricsLineRow{}
|
||||
for rows.Next() {
|
||||
var idx int
|
||||
var text string
|
||||
var startMs sql.NullInt64
|
||||
if err := rows.Scan(&idx, &text, &startMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line := LyricsLineRow{Text: text}
|
||||
if startMs.Valid {
|
||||
v := int(startMs.Int64)
|
||||
line.StartMs = &v
|
||||
}
|
||||
out = append(out, line)
|
||||
byIndex[idx] = &out[len(out)-1]
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cueRows, err := db.Query(`SELECT line_index, start_ms, end_ms, byte_start, byte_end, text FROM lyrics_cues WHERE lyrics_id = ? ORDER BY line_index, start_ms`, lyricsID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cueRows.Close()
|
||||
for cueRows.Next() {
|
||||
var idx int
|
||||
var startMs int
|
||||
var endMs sql.NullInt64
|
||||
var byteStart, byteEnd int
|
||||
var text string
|
||||
if err := cueRows.Scan(&idx, &startMs, &endMs, &byteStart, &byteEnd, &text); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line, ok := byIndex[idx]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
w := LyricsWordRow{StartMs: startMs, ByteStart: byteStart, ByteEnd: byteEnd, Text: text}
|
||||
if endMs.Valid {
|
||||
v := int(endMs.Int64)
|
||||
w.EndMs = &v
|
||||
}
|
||||
line.Words = append(line.Words, w)
|
||||
}
|
||||
return out, cueRows.Err()
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func openLyricsTestDB(t *testing.T) (*sql.DB, string) {
|
||||
t.Helper()
|
||||
db, err := Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
folder, err := CreateMusicFolder(db, "Music", "/music", "music")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mf, err := CreateMediaFile(db, folder.ID, "song.mp3", 100, 0, "h", "mp3", "song", 10, 128, 44100, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
song, err := UpsertSong(db, UpsertSongInput{MediaFileID: mf.ID, Title: "Song"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db, song.ID
|
||||
}
|
||||
|
||||
func TestInsertAndGetPlainLyrics(t *testing.T) {
|
||||
db, songID := openLyricsTestDB(t)
|
||||
|
||||
lines := []LyricsLineInput{{Text: "Line one"}, {Text: "Line two"}}
|
||||
if _, err := InsertLyrics(db, songID, "embedded", "xxx", false, lines); err != nil {
|
||||
t.Fatalf("InsertLyrics: %v", err)
|
||||
}
|
||||
|
||||
entries, err := GetSongStructuredLyrics(db, songID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSongStructuredLyrics: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("entries = %v, want 1", entries)
|
||||
}
|
||||
e := entries[0]
|
||||
if e.Synced || e.Source != "embedded" || e.Language != "xxx" {
|
||||
t.Errorf("entry = %+v", e)
|
||||
}
|
||||
if len(e.Lines) != 2 || e.Lines[0].Text != "Line one" {
|
||||
t.Fatalf("lines = %+v", e.Lines)
|
||||
}
|
||||
if e.Lines[0].StartMs != nil {
|
||||
t.Error("plain lyrics line should have nil StartMs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertAndGetSyncedLyricsWithWordCues(t *testing.T) {
|
||||
db, songID := openLyricsTestDB(t)
|
||||
|
||||
start0 := 1000
|
||||
end0 := 1500
|
||||
lines := []LyricsLineInput{
|
||||
{
|
||||
StartMs: &start0,
|
||||
Text: "Hello world",
|
||||
Words: []LyricsWordInput{
|
||||
{StartMs: 1000, EndMs: &end0, ByteStart: 0, ByteEnd: 5, Text: "Hello"},
|
||||
{StartMs: 1500, ByteStart: 6, ByteEnd: 11, Text: "world"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if _, err := InsertLyrics(db, songID, "external", "en", true, lines); err != nil {
|
||||
t.Fatalf("InsertLyrics: %v", err)
|
||||
}
|
||||
|
||||
entries, err := GetSongStructuredLyrics(db, songID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSongStructuredLyrics: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || !entries[0].Synced {
|
||||
t.Fatalf("entries = %+v", entries)
|
||||
}
|
||||
line := entries[0].Lines[0]
|
||||
if line.StartMs == nil || *line.StartMs != 1000 {
|
||||
t.Fatalf("line.StartMs = %v, want 1000", line.StartMs)
|
||||
}
|
||||
if len(line.Words) != 2 {
|
||||
t.Fatalf("words = %+v, want 2", line.Words)
|
||||
}
|
||||
if line.Words[0].EndMs == nil || *line.Words[0].EndMs != 1500 {
|
||||
t.Errorf("Words[0].EndMs = %v, want 1500", line.Words[0].EndMs)
|
||||
}
|
||||
if line.Words[1].EndMs != nil {
|
||||
t.Errorf("Words[1].EndMs = %v, want nil (not provided)", line.Words[1].EndMs)
|
||||
}
|
||||
if line.Words[0].ByteStart != 0 || line.Words[0].ByteEnd != 5 {
|
||||
t.Errorf("Words[0] byte offsets = %d,%d, want 0,5", line.Words[0].ByteStart, line.Words[0].ByteEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleLyricsEntriesPerSong(t *testing.T) {
|
||||
db, songID := openLyricsTestDB(t)
|
||||
|
||||
if _, err := InsertLyrics(db, songID, "embedded", "xxx", false, []LyricsLineInput{{Text: "Plain"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := 0
|
||||
if _, err := InsertLyrics(db, songID, "external", "en", true, []LyricsLineInput{{StartMs: &start, Text: "Synced"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entries, err := GetSongStructuredLyrics(db, songID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("entries = %v, want 2 (multiple lyric sets preserved)", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearSongLyricsCascades(t *testing.T) {
|
||||
db, songID := openLyricsTestDB(t)
|
||||
if _, err := InsertLyrics(db, songID, "embedded", "xxx", false, []LyricsLineInput{{Text: "Plain"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ClearSongLyrics(db, songID); err != nil {
|
||||
t.Fatalf("ClearSongLyrics: %v", err)
|
||||
}
|
||||
entries, err := GetSongStructuredLyrics(db, songID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("entries = %v, want 0 after clear", entries)
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func ListGenreStats(db *sql.DB) ([]GenreStat, error) {
|
||||
func SongsByGenre(db *sql.DB, genreName string, limit, offset int) ([]*Song, error) {
|
||||
rows, err := db.Query(`SELECT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total,
|
||||
s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id,
|
||||
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak
|
||||
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak, s.metadata_locked
|
||||
FROM songs s
|
||||
JOIN song_genres sg ON sg.song_id = s.id
|
||||
JOIN genres g ON g.id = sg.genre_id
|
||||
|
||||
@@ -37,7 +37,7 @@ func FindArtistByName(db *sql.DB, name string) (*Artist, error) {
|
||||
func TopSongsByArtist(db *sql.DB, artistID string, limit int) ([]*Song, error) {
|
||||
rows, err := db.Query(`SELECT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total,
|
||||
s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id,
|
||||
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak
|
||||
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak, s.metadata_locked
|
||||
FROM songs s
|
||||
JOIN song_artists sa ON sa.song_id = s.id
|
||||
LEFT JOIN (SELECT song_id, COUNT(*) AS plays FROM playback_history GROUP BY song_id) ph ON ph.song_id = s.id
|
||||
@@ -58,7 +58,7 @@ func TopSongsByArtist(db *sql.DB, artistID string, limit int) ([]*Song, error) {
|
||||
func SimilarSongsByGenre(db *sql.DB, seedSongID string, limit int) ([]*Song, error) {
|
||||
rows, err := db.Query(`SELECT DISTINCT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total,
|
||||
s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id,
|
||||
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak
|
||||
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak, s.metadata_locked
|
||||
FROM songs s
|
||||
JOIN song_genres sg ON sg.song_id = s.id
|
||||
WHERE sg.genre_id IN (SELECT genre_id FROM song_genres WHERE song_id = ?)
|
||||
@@ -78,7 +78,7 @@ func SimilarSongsByGenre(db *sql.DB, seedSongID string, limit int) ([]*Song, err
|
||||
func SimilarSongsByArtist(db *sql.DB, artistID string, limit int) ([]*Song, error) {
|
||||
rows, err := db.Query(`SELECT DISTINCT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total,
|
||||
s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id,
|
||||
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak
|
||||
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak, s.metadata_locked
|
||||
FROM songs s
|
||||
JOIN song_genres sg ON sg.song_id = s.id
|
||||
WHERE sg.genre_id IN (
|
||||
@@ -143,58 +143,6 @@ func AlbumsByRecentlyPlayed(db *sql.DB, userID string, limit, offset int) ([]*Al
|
||||
return scanAlbums(rows)
|
||||
}
|
||||
|
||||
// SetSongLyrics replaces a song's stored (unsynced) lyrics with
|
||||
// plainText, split into lines. Called by the scanner when a file has
|
||||
// embedded lyrics.
|
||||
func SetSongLyrics(db *sql.DB, songID, language, plainText string) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM lyrics WHERE song_id = ?`, songID); err != nil {
|
||||
return err
|
||||
}
|
||||
if plainText == "" {
|
||||
return tx.Commit()
|
||||
}
|
||||
lyricsID := idgen.New("ly")
|
||||
if _, err := tx.Exec(`INSERT INTO lyrics (id, song_id, language, source, synced) VALUES (?, ?, ?, 'embedded', 0)`,
|
||||
lyricsID, songID, language); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, line := range splitLines(plainText) {
|
||||
if _, err := tx.Exec(`INSERT INTO lyrics_lines (id, lyrics_id, line_index, text) VALUES (?, ?, ?, ?)`,
|
||||
idgen.New("ll"), lyricsID, i, line); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var lines []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
line := s[start:i]
|
||||
line = trimCR(line)
|
||||
lines = append(lines, line)
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
lines = append(lines, trimCR(s[start:]))
|
||||
return lines
|
||||
}
|
||||
|
||||
func trimCR(s string) string {
|
||||
if len(s) > 0 && s[len(s)-1] == '\r' {
|
||||
return s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SongLyricsPlainText returns a song's stored lyrics as plain text
|
||||
// (lines joined with "\n"), and whether any were found.
|
||||
func SongLyricsPlainText(db *sql.DB, songID string) (string, bool, error) {
|
||||
|
||||
@@ -144,6 +144,14 @@ func ListUsers(db *sql.DB) ([]*User, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountAdmins returns the number of enabled admin accounts, so callers
|
||||
// can refuse to demote or disable the last one.
|
||||
func CountAdmins(db *sql.DB) (int, error) {
|
||||
var n int
|
||||
err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE is_admin = 1 AND disabled = 0`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func scanUserRow(rows *sql.Rows) (*User, error) {
|
||||
var u User
|
||||
var isAdmin, disabled, external int
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// idempotent: rerunning it for the same file converges to the same rows,
|
||||
// reusing existing artist/album/genre IDs via their natural keys so IDs
|
||||
// stay stable across rescans.
|
||||
func IndexFile(db *sql.DB, mediaFile *database.MediaFile, tags *metadata.Tags) (*database.Song, error) {
|
||||
func IndexFile(db *sql.DB, mediaFile *database.MediaFile, absPath string, tags *metadata.Tags) (*database.Song, error) {
|
||||
title := tags.Title
|
||||
if title == "" {
|
||||
title = mediaFile.RelativePath
|
||||
@@ -114,7 +114,7 @@ func IndexFile(db *sql.DB, mediaFile *database.MediaFile, tags *metadata.Tags) (
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.SetSongLyrics(db, song.ID, "xxx", tags.EmbeddedLyrics); err != nil {
|
||||
if err := IndexLyrics(db, song.ID, absPath, tags.EmbeddedLyrics); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestIndexFileCreatesArtistAlbumSong(t *testing.T) {
|
||||
Year: 2020,
|
||||
}
|
||||
|
||||
song, err := IndexFile(db, mf, tags)
|
||||
song, err := IndexFile(db, mf, "/music/"+mf.RelativePath, tags)
|
||||
if err != nil {
|
||||
t.Fatalf("IndexFile: %v", err)
|
||||
}
|
||||
@@ -87,11 +87,11 @@ func TestIndexFileIsIdempotent(t *testing.T) {
|
||||
mf := makeMediaFile(t, db, "Artist/Album/01 - Song.flac")
|
||||
tags := &metadata.Tags{Title: "Song", Album: "Album", AlbumArtist: "Artist", Artists: []string{"Artist"}}
|
||||
|
||||
song1, err := IndexFile(db, mf, tags)
|
||||
song1, err := IndexFile(db, mf, "/music/"+mf.RelativePath, tags)
|
||||
if err != nil {
|
||||
t.Fatalf("IndexFile (1st): %v", err)
|
||||
}
|
||||
song2, err := IndexFile(db, mf, tags)
|
||||
song2, err := IndexFile(db, mf, "/music/"+mf.RelativePath, tags)
|
||||
if err != nil {
|
||||
t.Fatalf("IndexFile (2nd): %v", err)
|
||||
}
|
||||
@@ -114,7 +114,7 @@ func TestIndexFileIsIdempotent(t *testing.T) {
|
||||
func TestIndexFileUsesFilenameWhenNoTitle(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
mf := makeMediaFile(t, db, "Unknown/Track.flac")
|
||||
song, err := IndexFile(db, mf, &metadata.Tags{})
|
||||
song, err := IndexFile(db, mf, "/music/"+mf.RelativePath, &metadata.Tags{})
|
||||
if err != nil {
|
||||
t.Fatalf("IndexFile: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/lyrics"
|
||||
)
|
||||
|
||||
// IndexLyrics discovers and stores every lyrics source available for a
|
||||
// scanned file: embedded plain-text lyrics from tags (already extracted
|
||||
// into metadata.Tags by the scanner), and a sidecar .lrc file next to
|
||||
// the media file if one exists — synced, and word-level cued if the
|
||||
// .lrc is an Enhanced LRC file. Both can be present at once and are
|
||||
// stored as separate lyrics entries; nothing here invents timing that
|
||||
// wasn't actually in the source.
|
||||
func IndexLyrics(db *sql.DB, songID, mediaAbsPath, embeddedPlainText string) error {
|
||||
if err := database.ClearSongLyrics(db, songID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if embeddedPlainText != "" {
|
||||
lines := make([]database.LyricsLineInput, 0)
|
||||
for _, l := range strings.Split(strings.ReplaceAll(embeddedPlainText, "\r\n", "\n"), "\n") {
|
||||
lines = append(lines, database.LyricsLineInput{Text: l})
|
||||
}
|
||||
if _, err := database.InsertLyrics(db, songID, "embedded", "xxx", false, lines); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if lrcPath := findSidecarLRC(mediaAbsPath); lrcPath != "" {
|
||||
content, err := os.ReadFile(lrcPath)
|
||||
if err == nil {
|
||||
if err := indexParsedLRC(db, songID, content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func indexParsedLRC(db *sql.DB, songID string, content []byte) error {
|
||||
parsed := lyrics.Parse(string(content))
|
||||
if len(parsed.Lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
lang := parsed.Lang
|
||||
if lang == "" {
|
||||
lang = "xxx"
|
||||
}
|
||||
|
||||
lines := make([]database.LyricsLineInput, 0, len(parsed.Lines))
|
||||
for _, l := range parsed.Lines {
|
||||
start := l.StartMs
|
||||
line := database.LyricsLineInput{StartMs: &start, Text: l.Text}
|
||||
if len(l.Words) > 0 {
|
||||
line.Words = wordsWithByteOffsets(l.Words)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
_, err := database.InsertLyrics(db, songID, "external", lang, true, lines)
|
||||
return err
|
||||
}
|
||||
|
||||
// wordsWithByteOffsets computes each word's zero-based inclusive UTF-8
|
||||
// byte offsets into lineText, matching how internal/lyrics reconstructs
|
||||
// lineText (words joined by single spaces) so the offsets are exact.
|
||||
func wordsWithByteOffsets(words []lyrics.Word) []database.LyricsWordInput {
|
||||
out := make([]database.LyricsWordInput, 0, len(words))
|
||||
pos := 0
|
||||
for _, w := range words {
|
||||
start := pos
|
||||
end := start + len(w.Text) - 1 // inclusive, per the OpenSubsonic Cue schema
|
||||
if end < start {
|
||||
end = start
|
||||
}
|
||||
out = append(out, database.LyricsWordInput{
|
||||
StartMs: w.StartMs, ByteStart: start, ByteEnd: end, Text: w.Text,
|
||||
})
|
||||
pos = end + 2 // one past the inclusive end, plus the joining space
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// findSidecarLRC looks for a same-basename .lrc file next to a media
|
||||
// file — the standard convention lyric-fetching tools and taggers use.
|
||||
func findSidecarLRC(mediaAbsPath string) string {
|
||||
dir := filepath.Dir(mediaAbsPath)
|
||||
base := strings.TrimSuffix(filepath.Base(mediaAbsPath), filepath.Ext(mediaAbsPath))
|
||||
candidate := filepath.Join(dir, base+".lrc")
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
return candidate
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package lyrics
|
||||
|
||||
import "testing"
|
||||
|
||||
// FuzzParse guards against a crash/panic in the LRC parser when fed
|
||||
// arbitrary bytes — lyrics come from sidecar files and embedded tags
|
||||
// found while scanning a music library, so the content is effectively
|
||||
// untrusted input.
|
||||
func FuzzParse(f *testing.F) {
|
||||
f.Add("[00:12.34]First line\n[00:15.00]Second line")
|
||||
f.Add("[la:en]\n[00:12.34]<00:12.34>Hello <00:12.90>world")
|
||||
f.Add("")
|
||||
f.Add("[not a tag")
|
||||
f.Add("[99:99.999999999999]overflowing timestamp")
|
||||
f.Add("[00:12.34][00:15.00]shared timestamp line")
|
||||
f.Add("no timestamps at all, just plain text")
|
||||
|
||||
f.Fuzz(func(t *testing.T, content string) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("Parse panicked on %q: %v", content, r)
|
||||
}
|
||||
}()
|
||||
Parse(content)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Package metadataenrich looks up optional supplementary metadata for
|
||||
// artists and albums that already carry a MusicBrainz ID from local
|
||||
// tags — from MusicBrainz itself and the Cover Art Archive. It is never
|
||||
// on any hot path and never required: VibeSonic must work completely
|
||||
// offline, so every function here degrades to "no data" rather than an
|
||||
// error when the network is unavailable, and nothing here ever
|
||||
// fabricates a value the API didn't actually return.
|
||||
package metadataenrich
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const userAgent = "VibeSonic/1.0 (+https://git.noroutes.net/NoRoutes/vibetunes)"
|
||||
|
||||
const (
|
||||
defaultMusicBrainzBaseURL = "https://musicbrainz.org/ws/2"
|
||||
defaultCoverArtArchiveBaseURL = "https://coverartarchive.org"
|
||||
)
|
||||
|
||||
// Client talks to the MusicBrainz and Cover Art Archive HTTP APIs. The
|
||||
// zero value is usable and talks to the real public services;
|
||||
// MusicBrainzBaseURL/CoverArtArchiveBaseURL exist so tests can point it
|
||||
// at an httptest server instead.
|
||||
type Client struct {
|
||||
HTTPClient *http.Client
|
||||
MusicBrainzBaseURL string
|
||||
CoverArtArchiveBaseURL string
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
}
|
||||
return &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
|
||||
func (c *Client) musicBrainzBaseURL() string {
|
||||
if c.MusicBrainzBaseURL != "" {
|
||||
return c.MusicBrainzBaseURL
|
||||
}
|
||||
return defaultMusicBrainzBaseURL
|
||||
}
|
||||
|
||||
func (c *Client) coverArtArchiveBaseURL() string {
|
||||
if c.CoverArtArchiveBaseURL != "" {
|
||||
return c.CoverArtArchiveBaseURL
|
||||
}
|
||||
return defaultCoverArtArchiveBaseURL
|
||||
}
|
||||
|
||||
// ArtistData is the (small) subset of a MusicBrainz artist entity
|
||||
// VibeSonic actually surfaces. Fields MusicBrainz didn't return are
|
||||
// left as their zero value.
|
||||
type ArtistData struct {
|
||||
Disambiguation string `json:"disambiguation"`
|
||||
Genres []string `json:"genres"`
|
||||
}
|
||||
|
||||
type mbArtistResponse struct {
|
||||
Disambiguation string `json:"disambiguation"`
|
||||
Tags []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"tags"`
|
||||
}
|
||||
|
||||
// FetchArtist looks up a MusicBrainz artist entity by MBID. A network
|
||||
// or lookup failure returns an error; there is no fallback value.
|
||||
func (c *Client) FetchArtist(ctx context.Context, mbid string) (*ArtistData, error) {
|
||||
url := fmt.Sprintf("%s/artist/%s?fmt=json&inc=tags", c.musicBrainzBaseURL(), mbid)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("musicbrainz: artist %s: HTTP %d", mbid, resp.StatusCode)
|
||||
}
|
||||
|
||||
var raw mbArtistResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := &ArtistData{Disambiguation: raw.Disambiguation}
|
||||
for _, t := range raw.Tags {
|
||||
data.Genres = append(data.Genres, t.Name)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// CoverArt is the image URLs the Cover Art Archive returned for a
|
||||
// release's front cover, at three sizes. A blank field means the
|
||||
// archive simply didn't provide that size, not that VibeSonic
|
||||
// substituted anything.
|
||||
type CoverArt struct {
|
||||
SmallURL string
|
||||
MediumURL string
|
||||
LargeURL string
|
||||
}
|
||||
|
||||
type caaResponse struct {
|
||||
Images []struct {
|
||||
Front bool `json:"front"`
|
||||
Image string `json:"image"`
|
||||
Thumbnails map[string]string `json:"thumbnails"`
|
||||
} `json:"images"`
|
||||
}
|
||||
|
||||
// FetchReleaseCoverArt looks up the Cover Art Archive entry for a
|
||||
// release MBID and returns its front-cover image URLs. Returns
|
||||
// (nil, nil) — not an error — when the release has no cover art
|
||||
// registered, since that's a normal, common outcome, not a failure.
|
||||
func (c *Client) FetchReleaseCoverArt(ctx context.Context, mbid string) (*CoverArt, error) {
|
||||
url := fmt.Sprintf("%s/release/%s", c.coverArtArchiveBaseURL(), mbid)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("coverartarchive: release %s: HTTP %d", mbid, resp.StatusCode)
|
||||
}
|
||||
|
||||
var raw caaResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, img := range raw.Images {
|
||||
if !img.Front {
|
||||
continue
|
||||
}
|
||||
return &CoverArt{
|
||||
SmallURL: img.Thumbnails["small"],
|
||||
MediumURL: img.Thumbnails["large"],
|
||||
LargeURL: img.Image,
|
||||
}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package metadataenrich
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchArtistParsesRealFields(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/artist/abc-123" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("User-Agent") == "" {
|
||||
t.Error("MusicBrainz requires a non-empty User-Agent")
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"disambiguation": "English rock band",
|
||||
"tags": []map[string]string{{"name": "rock"}, {"name": "alternative"}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{MusicBrainzBaseURL: srv.URL}
|
||||
data, err := c.FetchArtist(context.Background(), "abc-123")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchArtist: %v", err)
|
||||
}
|
||||
if data.Disambiguation != "English rock band" {
|
||||
t.Errorf("disambiguation = %q", data.Disambiguation)
|
||||
}
|
||||
if len(data.Genres) != 2 || data.Genres[0] != "rock" {
|
||||
t.Errorf("genres = %v", data.Genres)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchArtistHTTPError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{MusicBrainzBaseURL: srv.URL}
|
||||
if _, err := c.FetchArtist(context.Background(), "does-not-exist"); err == nil {
|
||||
t.Fatal("expected an error for a 404 from MusicBrainz, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReleaseCoverArtParsesFrontImage(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"images": []map[string]any{
|
||||
{"front": false, "image": "http://example.com/back.jpg"},
|
||||
{
|
||||
"front": true,
|
||||
"image": "http://example.com/full.jpg",
|
||||
"thumbnails": map[string]string{
|
||||
"small": "http://example.com/small.jpg",
|
||||
"large": "http://example.com/large.jpg",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{CoverArtArchiveBaseURL: srv.URL}
|
||||
art, err := c.FetchReleaseCoverArt(context.Background(), "release-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchReleaseCoverArt: %v", err)
|
||||
}
|
||||
if art == nil {
|
||||
t.Fatal("expected cover art, got nil")
|
||||
}
|
||||
if art.LargeURL != "http://example.com/full.jpg" {
|
||||
t.Errorf("LargeURL = %q", art.LargeURL)
|
||||
}
|
||||
if art.SmallURL != "http://example.com/small.jpg" || art.MediumURL != "http://example.com/large.jpg" {
|
||||
t.Errorf("art = %+v", art)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReleaseCoverArtNotFoundReturnsNilNotError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{CoverArtArchiveBaseURL: srv.URL}
|
||||
art, err := c.FetchReleaseCoverArt(context.Background(), "no-art")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for a release with no registered cover art, got %v", err)
|
||||
}
|
||||
if art != nil {
|
||||
t.Errorf("expected nil CoverArt, got %+v", art)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReleaseCoverArtNoFrontImage(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"images": []map[string]any{
|
||||
{"front": false, "image": "http://example.com/back.jpg"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{CoverArtArchiveBaseURL: srv.URL}
|
||||
art, err := c.FetchReleaseCoverArt(context.Background(), "back-only")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if art != nil {
|
||||
t.Errorf("expected nil when no image is marked front, got %+v (must never substitute a back cover)", art)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Package netguard builds HTTP clients that refuse to connect to
|
||||
// non-public IP addresses. It exists so features that fetch
|
||||
// externally-controlled URLs (podcast feeds and enclosures, most
|
||||
// notably) cannot be turned into a proxy onto the server's own
|
||||
// loopback interface, its LAN, or cloud-metadata endpoints
|
||||
// (169.254.169.254 and friends) via a malicious or compromised feed
|
||||
// URL, a redirect, or DNS rebinding.
|
||||
//
|
||||
// The check is enforced at dial time, on the specific IP address about
|
||||
// to be connected to, for every connection the client makes — including
|
||||
// ones made while following a redirect. Because the resolved IP is
|
||||
// validated immediately before dialing that exact address (rather than
|
||||
// re-resolving the hostname later), a DNS record that changes between
|
||||
// requests cannot rebind a validated hostname onto a blocked address.
|
||||
package netguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AllowedSchemes are the only URL schemes a guarded client will ever
|
||||
// dial; anything else (file://, ftp://, gopher://, ...) is rejected
|
||||
// before a connection is attempted.
|
||||
var AllowedSchemes = map[string]bool{"http": true, "https": true}
|
||||
|
||||
// ValidateURL rejects an obviously-bad externally-supplied URL (wrong
|
||||
// scheme, missing host) before it's stored or fetched. This is a
|
||||
// fast, user-facing sanity check; the actual SSRF protection is
|
||||
// enforced at connection time by the client NewSafeHTTPClient returns,
|
||||
// regardless of what passes this check.
|
||||
func ValidateURL(rawURL string) error {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
if !AllowedSchemes[u.Scheme] {
|
||||
return fmt.Errorf("URL must be http or https")
|
||||
}
|
||||
if u.Host == "" {
|
||||
return fmt.Errorf("URL must include a host")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewSafeHTTPClient returns an *http.Client that only connects to
|
||||
// public IP addresses, unless allowPrivateNetworks is true (for
|
||||
// deployments that intentionally point podcast feeds at a LAN/self-
|
||||
// hosted server). It also refuses to follow a redirect more than 5
|
||||
// times or onto a non-http(s) scheme.
|
||||
func NewSafeHTTPClient(timeout time.Duration, allowPrivateNetworks bool) *http.Client {
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
transport := &http.Transport{
|
||||
DialContext: safeDialContext(dialer, allowPrivateNetworks),
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: 20 * time.Second,
|
||||
ForceAttemptHTTP2: true,
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: transport,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return fmt.Errorf("stopped after 5 redirects")
|
||||
}
|
||||
if !AllowedSchemes[req.URL.Scheme] {
|
||||
return fmt.Errorf("refusing to follow redirect to unsupported scheme %q", req.URL.Scheme)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func safeDialContext(base *net.Dialer, allowPrivateNetworks bool) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If the host is already a literal IP, LookupIP still handles
|
||||
// it (returning it unchanged) without a real DNS round trip.
|
||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, ip := range ips {
|
||||
if !allowPrivateNetworks && !IsPublicIP(ip) {
|
||||
lastErr = fmt.Errorf("refusing to connect to non-public address %s", ip)
|
||||
continue
|
||||
}
|
||||
conn, dialErr := base.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||
if dialErr == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = dialErr
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no addresses found for %s", host)
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
}
|
||||
|
||||
// IsPublicIP reports whether ip is a globally routable unicast address
|
||||
// that a server-side fetcher should be allowed to connect to. It
|
||||
// rejects loopback, link-local (including the 169.254.169.254 cloud
|
||||
// metadata address), RFC1918/RFC4193 private ranges, carrier-grade NAT
|
||||
// (100.64.0.0/10), multicast, and unspecified addresses.
|
||||
func IsPublicIP(ip net.IP) bool {
|
||||
ip = ip.To16()
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
if cgnat.Contains(v4) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case ip.IsLoopback(),
|
||||
ip.IsPrivate(),
|
||||
ip.IsLinkLocalUnicast(),
|
||||
ip.IsLinkLocalMulticast(),
|
||||
ip.IsInterfaceLocalMulticast(),
|
||||
ip.IsMulticast(),
|
||||
ip.IsUnspecified():
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var cgnat = mustParseCIDR("100.64.0.0/10")
|
||||
|
||||
func mustParseCIDR(s string) *net.IPNet {
|
||||
_, n, err := net.ParseCIDR(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package netguard
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// FuzzValidateURL guards against a crash in the podcast feed/enclosure
|
||||
// URL sanity check — the URLs it validates are admin-supplied but the
|
||||
// check must never panic regardless of input.
|
||||
func FuzzValidateURL(f *testing.F) {
|
||||
f.Add("https://example.com/feed.xml")
|
||||
f.Add("http://127.0.0.1/feed")
|
||||
f.Add("ftp://example.com")
|
||||
f.Add("not a url at all")
|
||||
f.Add("")
|
||||
f.Add("http://")
|
||||
f.Add("javascript:alert(1)")
|
||||
f.Add("http://[::1]:8080/feed")
|
||||
|
||||
f.Fuzz(func(t *testing.T, raw string) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("ValidateURL panicked on %q: %v", raw, r)
|
||||
}
|
||||
}()
|
||||
_ = ValidateURL(raw)
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzIsPublicIP guards the address classifier that decides whether a
|
||||
// resolved IP is safe to connect to.
|
||||
func FuzzIsPublicIP(f *testing.F) {
|
||||
f.Add("8.8.8.8")
|
||||
f.Add("127.0.0.1")
|
||||
f.Add("::1")
|
||||
f.Add("169.254.169.254")
|
||||
f.Add("100.64.0.1")
|
||||
f.Add("::ffff:127.0.0.1")
|
||||
|
||||
f.Fuzz(func(t *testing.T, s string) {
|
||||
ip := net.ParseIP(s)
|
||||
if ip == nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("IsPublicIP panicked on %q: %v", s, r)
|
||||
}
|
||||
}()
|
||||
IsPublicIP(ip)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package netguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIsPublicIP(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"8.8.8.8": true,
|
||||
"1.1.1.1": true,
|
||||
"127.0.0.1": false,
|
||||
"10.0.0.1": false,
|
||||
"172.16.0.1": false,
|
||||
"192.168.1.1": false,
|
||||
"169.254.169.254": false, // cloud metadata
|
||||
"100.64.0.1": false, // CGNAT
|
||||
"0.0.0.0": false,
|
||||
"::1": false,
|
||||
"fe80::1": false,
|
||||
"fc00::1": false,
|
||||
"2606:4700::1111": true,
|
||||
}
|
||||
for s, want := range cases {
|
||||
ip := net.ParseIP(s)
|
||||
if ip == nil {
|
||||
t.Fatalf("bad test IP %q", s)
|
||||
}
|
||||
if got := IsPublicIP(ip); got != want {
|
||||
t.Errorf("IsPublicIP(%q) = %v, want %v", s, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeHTTPClientBlocksLoopback(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewSafeHTTPClient(5*time.Second, false)
|
||||
_, err := client.Get(srv.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected request to loopback server to be blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeHTTPClientAllowsLoopbackWhenPermitted(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewSafeHTTPClient(5*time.Second, true)
|
||||
resp, err := client.Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("expected request to loopback server to succeed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestSafeHTTPClientBlocksRedirectToPrivate(t *testing.T) {
|
||||
blocked := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("should never be reached"))
|
||||
}))
|
||||
defer blocked.Close()
|
||||
|
||||
// A "public-looking" server (still loopback in this test, but with
|
||||
// AllowPrivateNetworks=false the initial dial to it would itself be
|
||||
// blocked — so instead verify redirect scheme enforcement directly
|
||||
// via CheckRedirect using a context that never dials at all).
|
||||
client := NewSafeHTTPClient(5*time.Second, false)
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, blocked.URL, nil)
|
||||
if err := client.CheckRedirect(req, nil); err != nil {
|
||||
t.Fatalf("unexpected error for allowed scheme: %v", err)
|
||||
}
|
||||
req.URL.Scheme = "file"
|
||||
if err := client.CheckRedirect(req, nil); err == nil {
|
||||
t.Fatal("expected file:// redirect to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,16 @@ import (
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/netguard"
|
||||
)
|
||||
|
||||
const maxConcurrentDownloads = 2
|
||||
const (
|
||||
maxConcurrentDownloads = 2
|
||||
// maxEnclosureSize bounds a single episode download so a malicious
|
||||
// or misbehaving feed can't exhaust disk space; legitimate podcast
|
||||
// episodes are essentially never larger than this.
|
||||
maxEnclosureSize = 4 << 30 // 4GB
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
DB *sql.DB
|
||||
@@ -37,12 +44,18 @@ type Manager struct {
|
||||
sem chan struct{}
|
||||
}
|
||||
|
||||
func NewManager(db *sql.DB, dataDir, ffprobePath string, log *slog.Logger) *Manager {
|
||||
// NewManager builds a podcast manager whose HTTP client refuses to
|
||||
// connect to non-public (loopback/LAN/link-local/cloud-metadata)
|
||||
// addresses — feed URLs and episode enclosure URLs are attacker- or
|
||||
// admin-supplied and must never turn VibeSonic into an internal
|
||||
// network proxy. Set allowPrivateNetworks only for deployments that
|
||||
// intentionally host feeds on a private network.
|
||||
func NewManager(db *sql.DB, dataDir, ffprobePath string, log *slog.Logger, allowPrivateNetworks bool) *Manager {
|
||||
return &Manager{
|
||||
DB: db,
|
||||
DataDir: dataDir,
|
||||
FFprobePath: ffprobePath,
|
||||
HTTPClient: &http.Client{Timeout: 60 * time.Second},
|
||||
HTTPClient: netguard.NewSafeHTTPClient(60*time.Second, allowPrivateNetworks),
|
||||
Log: log,
|
||||
sem: make(chan struct{}, maxConcurrentDownloads),
|
||||
}
|
||||
@@ -78,6 +91,11 @@ func (m *Manager) RefreshChannel(ctx context.Context, channelID string) error {
|
||||
}
|
||||
_ = database.SetPodcastChannelStatus(m.DB, channelID, "downloading", "")
|
||||
|
||||
if err := netguard.ValidateURL(channel.URL); err != nil {
|
||||
_ = database.SetPodcastChannelStatus(m.DB, channelID, "error", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, channel.URL, nil)
|
||||
if err != nil {
|
||||
_ = database.SetPodcastChannelStatus(m.DB, channelID, "error", err.Error())
|
||||
@@ -234,7 +252,34 @@ func (m *Manager) DownloadEpisode(ctx context.Context, episodeID string) error {
|
||||
return database.SetPodcastEpisodeMediaFile(m.DB, episodeID, mf.ID)
|
||||
}
|
||||
|
||||
// DownloadChannel downloads every episode in a channel that is not already
|
||||
// complete or in progress. Downloads are run in feed order and still pass
|
||||
// through the manager's concurrency limiter.
|
||||
func (m *Manager) DownloadChannel(ctx context.Context, channelID string) error {
|
||||
episodes, err := database.ListPodcastEpisodes(m.DB, channelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, episode := range episodes {
|
||||
if episode.Status == "completed" || episode.Status == "downloading" {
|
||||
continue
|
||||
}
|
||||
if err := m.DownloadEpisode(ctx, episode.ID); err != nil {
|
||||
if m.Log != nil {
|
||||
m.Log.Warn("podcast episode download failed", "episode", episode.ID, "err", err)
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) downloadToFile(ctx context.Context, sourceURL, destPath string) error {
|
||||
if err := netguard.ValidateURL(sourceURL); err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -253,11 +298,17 @@ func (m *Manager) downloadToFile(ctx context.Context, sourceURL, destPath string
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, resp.Body); err != nil {
|
||||
n, err := io.Copy(out, io.LimitReader(resp.Body, maxEnclosureSize+1))
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if n > maxEnclosureSize {
|
||||
out.Close()
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("enclosure exceeds maximum allowed size of %d bytes", maxEnclosureSize)
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
|
||||
@@ -79,7 +79,10 @@ func newTestManager(t *testing.T) (*Manager, string) {
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return NewManager(db, dir, "", log), dir
|
||||
// Tests talk to httptest.Server, which listens on loopback, so
|
||||
// private networks must be allowed here even though production
|
||||
// defaults to blocking them (see internal/netguard).
|
||||
return NewManager(db, dir, "", log, true), dir
|
||||
}
|
||||
|
||||
func TestRefreshChannel(t *testing.T) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/audiofeatures"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/coverart"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/library"
|
||||
@@ -35,9 +36,10 @@ const maxWorkers = 4
|
||||
|
||||
// Scanner indexes one or more music folders into the database.
|
||||
type Scanner struct {
|
||||
DB *sql.DB
|
||||
Prober *metadata.Prober
|
||||
Log *slog.Logger
|
||||
DB *sql.DB
|
||||
Prober *metadata.Prober
|
||||
Log *slog.Logger
|
||||
FFmpegPath string // for sonic-analysis audio decoding; "" defaults to "ffmpeg" on PATH
|
||||
|
||||
mu sync.Mutex
|
||||
scanning map[string]bool // music_folder_id -> in progress
|
||||
@@ -170,11 +172,12 @@ func (s *Scanner) indexOne(ctx context.Context, folderID, absPath, relPath strin
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
song, err := library.IndexFile(s.DB, mf, tags)
|
||||
song, err := library.IndexFile(s.DB, mf, absPath, tags)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
s.indexCoverArt(absPath, song, tags)
|
||||
s.analyzeAudio(ctx, absPath, song, tags)
|
||||
return false, true, nil
|
||||
}
|
||||
if err != database.ErrNotFound {
|
||||
@@ -199,14 +202,36 @@ func (s *Scanner) indexOne(ctx context.Context, folderID, absPath, relPath strin
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
song, err := library.IndexFile(s.DB, mf, tags)
|
||||
song, err := library.IndexFile(s.DB, mf, absPath, tags)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
s.indexCoverArt(absPath, song, tags)
|
||||
s.analyzeAudio(ctx, absPath, song, tags)
|
||||
return true, false, nil
|
||||
}
|
||||
|
||||
// analyzeAudio runs the sonic-feature analyzer over a scanned file and
|
||||
// stores the result, powering the sonicSimilarity extension. This runs
|
||||
// as part of the (already background, admin-triggered) scan itself
|
||||
// rather than on any request path. A decode/analysis failure is logged
|
||||
// and otherwise ignored: the song is still indexed and playable, it
|
||||
// just won't appear in sonic-similarity results until a future rescan
|
||||
// succeeds — never a reason to fail the whole scan.
|
||||
func (s *Scanner) analyzeAudio(ctx context.Context, absPath string, song *database.Song, tags *metadata.Tags) {
|
||||
if tags.Kind != "song" {
|
||||
return
|
||||
}
|
||||
features, err := audiofeatures.Analyze(ctx, s.FFmpegPath, absPath)
|
||||
if err != nil {
|
||||
s.Log.Warn("audio feature analysis failed", "err", err, "path", absPath)
|
||||
return
|
||||
}
|
||||
if err := database.UpsertAudioFeatures(s.DB, song.ID, features); err != nil {
|
||||
s.Log.Warn("storing audio features failed", "err", err, "path", absPath)
|
||||
}
|
||||
}
|
||||
|
||||
// indexCoverArt records where an album's cover art can be found: a
|
||||
// folder image next to the file if one exists, otherwise the file's own
|
||||
// embedded artwork (extracted lazily by the web layer on request, not
|
||||
|
||||
@@ -237,3 +237,82 @@ func TestScanFolderRejectsConcurrentScan(t *testing.T) {
|
||||
t.Fatal("expected error scanning a folder that is already being scanned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFolderIndexesSidecarLRC(t *testing.T) {
|
||||
s, folder, dir := newTestScanner(t)
|
||||
genFile(t, dir, "song1.mp3", "Song One", "Artist One")
|
||||
|
||||
lrc := "[ar:Artist One]\n[00:01.00]Hello world\n[00:02.50]<00:02.50>Second <00:02.90>line\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "song1.lrc"), []byte(lrc), 0o644); err != nil {
|
||||
t.Fatalf("write sidecar lrc: %v", err)
|
||||
}
|
||||
|
||||
if err := s.ScanFolder(context.Background(), folder.ID); err != nil {
|
||||
t.Fatalf("ScanFolder: %v", err)
|
||||
}
|
||||
|
||||
songs, err := database.ListSongs(s.DB, 10, 0)
|
||||
if err != nil || len(songs) != 1 {
|
||||
t.Fatalf("ListSongs: %v %v", songs, err)
|
||||
}
|
||||
|
||||
entries, err := database.GetSongStructuredLyrics(s.DB, songs[0].ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSongStructuredLyrics: %v", err)
|
||||
}
|
||||
var synced *database.StructuredLyricsRow
|
||||
for i := range entries {
|
||||
if entries[i].Synced {
|
||||
synced = &entries[i]
|
||||
}
|
||||
}
|
||||
if synced == nil {
|
||||
t.Fatalf("expected a synced structuredLyrics entry from the sidecar .lrc, got %+v", entries)
|
||||
}
|
||||
if len(synced.Lines) != 2 || synced.Lines[0].Text != "Hello world" {
|
||||
t.Fatalf("lines = %+v", synced.Lines)
|
||||
}
|
||||
if synced.Lines[0].StartMs == nil || *synced.Lines[0].StartMs != 1000 {
|
||||
t.Errorf("line[0].StartMs = %v, want 1000", synced.Lines[0].StartMs)
|
||||
}
|
||||
if len(synced.Lines[1].Words) != 2 {
|
||||
t.Fatalf("expected word-level cues from Enhanced LRC line, got %+v", synced.Lines[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFolderAnalyzesAudioFeatures(t *testing.T) {
|
||||
s, folder, dir := newTestScanner(t)
|
||||
genFile(t, dir, "song1.mp3", "Song One", "Artist One")
|
||||
|
||||
if err := s.ScanFolder(context.Background(), folder.ID); err != nil {
|
||||
t.Fatalf("ScanFolder: %v", err)
|
||||
}
|
||||
|
||||
songs, err := database.ListSongs(s.DB, 10, 0)
|
||||
if err != nil || len(songs) != 1 {
|
||||
t.Fatalf("ListSongs: %v %v", songs, err)
|
||||
}
|
||||
|
||||
features, err := database.GetAudioFeatures(s.DB, songs[0].ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAudioFeatures: %v", err)
|
||||
}
|
||||
if features == nil {
|
||||
t.Fatal("expected audio features to be stored after scanning, got none")
|
||||
}
|
||||
// The fixture is a pure 440Hz sine tone (A4): its chroma should
|
||||
// actually peak at pitch class A (bin 9), a real measured property,
|
||||
// not a placeholder.
|
||||
if features.Extra.Chroma == ([12]float64{}) {
|
||||
t.Error("expected a non-zero chroma vector from real analysis")
|
||||
}
|
||||
best := 0
|
||||
for i := 1; i < 12; i++ {
|
||||
if features.Extra.Chroma[i] > features.Extra.Chroma[best] {
|
||||
best = i
|
||||
}
|
||||
}
|
||||
if best != 9 { // A
|
||||
t.Errorf("chroma peak = bin %d, want 9 (A) for a 440Hz fixture", best)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// Package similarity builds a small in-process nearest-neighbor index
|
||||
// over numeric audio-feature vectors and uses it for two things:
|
||||
// ranking tracks by actual sonic closeness, and walking a musically
|
||||
// reasonable path between two tracks through feature space. There is
|
||||
// no external vector database — the whole index is just normalized
|
||||
// vectors held in memory, rebuilt from internal/database's
|
||||
// audio_features table whenever a caller needs it.
|
||||
package similarity
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Item is one track's raw (un-normalized) feature vector, keyed by
|
||||
// song ID.
|
||||
type Item struct {
|
||||
ID string
|
||||
Vector []float64
|
||||
}
|
||||
|
||||
// Index holds z-score-normalized vectors for a set of tracks. Each
|
||||
// dimension is normalized independently using the mean/stddev of that
|
||||
// dimension across exactly the items the index was built from, so
|
||||
// dimensions with a wide natural range (like tempo, in BPM) don't
|
||||
// dominate Euclidean distance over dimensions with a narrow one (like a
|
||||
// normalized chroma bin).
|
||||
type Index struct {
|
||||
ids []string
|
||||
norm [][]float64
|
||||
}
|
||||
|
||||
// Build normalizes and indexes items. An item whose Vector length
|
||||
// disagrees with the first item's is dropped rather than causing a
|
||||
// panic or silently misaligning dimensions — a defensive check, not
|
||||
// something expected to trigger with correctly-produced feature rows.
|
||||
func Build(items []Item) *Index {
|
||||
idx := &Index{}
|
||||
if len(items) == 0 {
|
||||
return idx
|
||||
}
|
||||
dims := len(items[0].Vector)
|
||||
|
||||
var kept []Item
|
||||
for _, it := range items {
|
||||
if len(it.Vector) == dims {
|
||||
kept = append(kept, it)
|
||||
}
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
return idx
|
||||
}
|
||||
|
||||
means := make([]float64, dims)
|
||||
for _, it := range kept {
|
||||
for d, v := range it.Vector {
|
||||
means[d] += v
|
||||
}
|
||||
}
|
||||
for d := range means {
|
||||
means[d] /= float64(len(kept))
|
||||
}
|
||||
|
||||
stds := make([]float64, dims)
|
||||
for _, it := range kept {
|
||||
for d, v := range it.Vector {
|
||||
diff := v - means[d]
|
||||
stds[d] += diff * diff
|
||||
}
|
||||
}
|
||||
for d := range stds {
|
||||
stds[d] = math.Sqrt(stds[d] / float64(len(kept)))
|
||||
}
|
||||
|
||||
idx.ids = make([]string, len(kept))
|
||||
idx.norm = make([][]float64, len(kept))
|
||||
for i, it := range kept {
|
||||
nv := make([]float64, dims)
|
||||
for d, v := range it.Vector {
|
||||
if stds[d] > 1e-9 {
|
||||
nv[d] = (v - means[d]) / stds[d]
|
||||
}
|
||||
}
|
||||
idx.ids[i] = it.ID
|
||||
idx.norm[i] = nv
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
// Match is one similarity result: another track and its distance from
|
||||
// the query track in normalized feature space (0 = identical, larger =
|
||||
// less similar).
|
||||
type Match struct {
|
||||
ID string
|
||||
Distance float64
|
||||
}
|
||||
|
||||
// Nearest returns up to k tracks closest to id, nearest first,
|
||||
// excluding id itself. Returns nil if id isn't in the index.
|
||||
func (idx *Index) Nearest(id string, k int) []Match {
|
||||
pos := idx.indexOf(id)
|
||||
if pos < 0 {
|
||||
return nil
|
||||
}
|
||||
matches := make([]Match, 0, len(idx.ids)-1)
|
||||
for i, other := range idx.ids {
|
||||
if i == pos {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, Match{ID: other, Distance: euclidean(idx.norm[pos], idx.norm[i])})
|
||||
}
|
||||
sort.Slice(matches, func(a, b int) bool { return matches[a].Distance < matches[b].Distance })
|
||||
if k > 0 && len(matches) > k {
|
||||
matches = matches[:k]
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
// Path walks from fromID to toID in `steps` hops (including both
|
||||
// endpoints), picking at each intermediate step the not-yet-used track
|
||||
// closest to the corresponding point on the straight line between
|
||||
// fromID's and toID's feature vectors. That keeps the sequence
|
||||
// perceptually gradual — each hop is a small, real step in sonic
|
||||
// character rather than a random jump — without ever inventing a track
|
||||
// that isn't actually in the library. Returns nil if either endpoint
|
||||
// isn't indexed.
|
||||
func (idx *Index) Path(fromID, toID string, steps int) []string {
|
||||
fromPos := idx.indexOf(fromID)
|
||||
toPos := idx.indexOf(toID)
|
||||
if fromPos < 0 || toPos < 0 {
|
||||
return nil
|
||||
}
|
||||
if steps < 2 {
|
||||
steps = 2
|
||||
}
|
||||
|
||||
path := []string{fromID}
|
||||
used := map[string]bool{fromID: true, toID: true}
|
||||
fromVec, toVec := idx.norm[fromPos], idx.norm[toPos]
|
||||
|
||||
for step := 1; step < steps-1; step++ {
|
||||
t := float64(step) / float64(steps-1)
|
||||
target := lerp(fromVec, toVec, t)
|
||||
|
||||
best := -1
|
||||
bestDist := math.Inf(1)
|
||||
for i, id := range idx.ids {
|
||||
if used[id] {
|
||||
continue
|
||||
}
|
||||
d := euclidean(idx.norm[i], target)
|
||||
if d < bestDist {
|
||||
bestDist = d
|
||||
best = i
|
||||
}
|
||||
}
|
||||
if best < 0 {
|
||||
break // library exhausted; fall through with what we have
|
||||
}
|
||||
path = append(path, idx.ids[best])
|
||||
used[idx.ids[best]] = true
|
||||
}
|
||||
|
||||
path = append(path, toID)
|
||||
return path
|
||||
}
|
||||
|
||||
// DistanceBetween returns the normalized-feature-space Euclidean
|
||||
// distance between two indexed tracks, or +Inf if either isn't
|
||||
// indexed.
|
||||
func (idx *Index) DistanceBetween(idA, idB string) float64 {
|
||||
a, b := idx.indexOf(idA), idx.indexOf(idB)
|
||||
if a < 0 || b < 0 {
|
||||
return math.Inf(1)
|
||||
}
|
||||
return euclidean(idx.norm[a], idx.norm[b])
|
||||
}
|
||||
|
||||
func (idx *Index) indexOf(id string) int {
|
||||
for i, v := range idx.ids {
|
||||
if v == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func euclidean(a, b []float64) float64 {
|
||||
var sum float64
|
||||
for i := range a {
|
||||
d := a[i] - b[i]
|
||||
sum += d * d
|
||||
}
|
||||
return math.Sqrt(sum)
|
||||
}
|
||||
|
||||
func lerp(a, b []float64, t float64) []float64 {
|
||||
out := make([]float64, len(a))
|
||||
for i := range a {
|
||||
out[i] = a[i] + (b[i]-a[i])*t
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package similarity
|
||||
|
||||
import "testing"
|
||||
|
||||
func tempoLibrary() []Item {
|
||||
// A synthetic library where the only varying dimension is "tempo":
|
||||
// songs at 60, 70, 80, ..., 150 BPM. Deterministic and needs no
|
||||
// audio decoding, so it exercises the index math directly.
|
||||
var items []Item
|
||||
for i, bpm := range []float64{60, 70, 80, 90, 100, 110, 120, 130, 140, 150} {
|
||||
items = append(items, Item{ID: idFor(i), Vector: []float64{bpm, 0, 0}})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func idFor(i int) string {
|
||||
return string(rune('a' + i))
|
||||
}
|
||||
|
||||
func TestNearestOrdersByActualDistance(t *testing.T) {
|
||||
idx := Build(tempoLibrary())
|
||||
// "d" is 90 BPM; its closest neighbors by tempo should be "c" (80)
|
||||
// and "e" (100), in that order (tie broken by index order is fine
|
||||
// since both are exactly 10 BPM away — check the pair, not order).
|
||||
matches := idx.Nearest("d", 2)
|
||||
if len(matches) != 2 {
|
||||
t.Fatalf("matches = %v, want 2", matches)
|
||||
}
|
||||
got := map[string]bool{matches[0].ID: true, matches[1].ID: true}
|
||||
if !got["c"] || !got["e"] {
|
||||
t.Errorf("nearest to d(90bpm) = %v, want {c(80bpm), e(100bpm)}", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearestExcludesSelf(t *testing.T) {
|
||||
idx := Build(tempoLibrary())
|
||||
matches := idx.Nearest("a", 20)
|
||||
for _, m := range matches {
|
||||
if m.ID == "a" {
|
||||
t.Fatal("Nearest must never include the query track itself")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearestUnknownID(t *testing.T) {
|
||||
idx := Build(tempoLibrary())
|
||||
if matches := idx.Nearest("does-not-exist", 5); matches != nil {
|
||||
t.Errorf("expected nil for unknown id, got %v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearestFarthestIsOppositeEnd(t *testing.T) {
|
||||
idx := Build(tempoLibrary())
|
||||
matches := idx.Nearest("a", 20) // a = 60bpm, all 9 others
|
||||
if len(matches) != 9 {
|
||||
t.Fatalf("matches = %d, want 9", len(matches))
|
||||
}
|
||||
farthest := matches[len(matches)-1]
|
||||
if farthest.ID != "j" { // j = 150bpm, the farthest from 60bpm
|
||||
t.Errorf("farthest from a(60bpm) = %v, want j(150bpm)", farthest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathIsMonotonicAlongTheOnlyVaryingDimension(t *testing.T) {
|
||||
idx := Build(tempoLibrary())
|
||||
path := idx.Path("a", "j", 5) // 60bpm -> 150bpm in 5 hops
|
||||
if len(path) != 5 {
|
||||
t.Fatalf("path = %v, want length 5", path)
|
||||
}
|
||||
if path[0] != "a" || path[len(path)-1] != "j" {
|
||||
t.Fatalf("path endpoints = %q..%q, want a..j", path[0], path[len(path)-1])
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, id := range path {
|
||||
if seen[id] {
|
||||
t.Fatalf("path repeats %q: %v", id, path)
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
// With tempo as the only varying dimension, a straight-line
|
||||
// interpolation from 60 to 150 BPM should visit strictly increasing
|
||||
// tempos.
|
||||
bpmOf := map[string]float64{"a": 60, "b": 70, "c": 80, "d": 90, "e": 100, "f": 110, "g": 120, "h": 130, "i": 140, "j": 150}
|
||||
for i := 1; i < len(path); i++ {
|
||||
if bpmOf[path[i]] <= bpmOf[path[i-1]] {
|
||||
t.Errorf("path not monotonically increasing in tempo: %v", path)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathUnknownEndpoint(t *testing.T) {
|
||||
idx := Build(tempoLibrary())
|
||||
if path := idx.Path("a", "nope", 5); path != nil {
|
||||
t.Errorf("expected nil path for unknown endpoint, got %v", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEmpty(t *testing.T) {
|
||||
idx := Build(nil)
|
||||
if matches := idx.Nearest("a", 5); matches != nil {
|
||||
t.Errorf("expected nil from an empty index, got %v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConstantDimensionDoesNotDivideByZero(t *testing.T) {
|
||||
items := []Item{
|
||||
{ID: "x", Vector: []float64{5, 1}},
|
||||
{ID: "y", Vector: []float64{5, 2}},
|
||||
}
|
||||
idx := Build(items)
|
||||
matches := idx.Nearest("x", 5)
|
||||
if len(matches) != 1 || matches[0].ID != "y" {
|
||||
t.Fatalf("matches = %v", matches)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package subsonic
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
@@ -60,7 +61,7 @@ func authenticate(db *sql.DB, encryptionKey []byte, p params) (*database.User, *
|
||||
}
|
||||
sum := md5.Sum([]byte(plaintext + salt))
|
||||
expected := hex.EncodeToString(sum[:])
|
||||
if !strings.EqualFold(expected, token) {
|
||||
if subtle.ConstantTimeCompare([]byte(expected), []byte(strings.ToLower(token))) != 1 {
|
||||
return nil, errWrongCredentials
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/similarity"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/streaming"
|
||||
)
|
||||
|
||||
@@ -86,44 +88,50 @@ func handleGetVideoInfo(rc *Context) *apiError {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Artist/album info: VibeSonic has no external metadata provider
|
||||
// (last.fm etc.) in Phase 2, so these honestly return only what's known
|
||||
// locally — which for biography/images/similar-artists is nothing —
|
||||
// rather than fabricating placeholder content. ---
|
||||
// --- Artist/album info: local data only (title/name, and a
|
||||
// MusicBrainz ID if one was tagged), optionally enriched from
|
||||
// MusicBrainz/Cover Art Archive when VIBESONIC_ENABLE_EXTERNAL_METADATA
|
||||
// is set and the entity has a tagged MusicBrainz ID — see enrich.go.
|
||||
// VibeSonic must work completely offline; enrichment is always
|
||||
// best-effort and never blocks or fails the response. ---
|
||||
|
||||
func handleGetArtistInfo(rc *Context) *apiError {
|
||||
if _, aerr := rc.Params.Required("id"); aerr != nil {
|
||||
id, aerr := rc.Params.Required("id")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
rc.Response.ArtistInfo = &ArtistInfo{}
|
||||
rc.Response.ArtistInfo = &ArtistInfo{ArtistInfoBase: artistInfoBase(rc, id)}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleGetArtistInfo2(rc *Context) *apiError {
|
||||
if _, aerr := rc.Params.Required("id"); aerr != nil {
|
||||
id, aerr := rc.Params.Required("id")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
rc.Response.ArtistInfo2 = &ArtistInfo2{}
|
||||
rc.Response.ArtistInfo2 = &ArtistInfo2{ArtistInfoBase: artistInfoBase(rc, id)}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleGetAlbumInfo(rc *Context) *apiError {
|
||||
if _, aerr := rc.Params.Required("id"); aerr != nil {
|
||||
id, aerr := rc.Params.Required("id")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
rc.Response.AlbumInfo = &AlbumInfo{}
|
||||
rc.Response.AlbumInfo = albumInfo(rc, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleGetAlbumInfo2(rc *Context) *apiError {
|
||||
if _, aerr := rc.Params.Required("id"); aerr != nil {
|
||||
id, aerr := rc.Params.Required("id")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
rc.Response.AlbumInfo = &AlbumInfo{}
|
||||
rc.Response.AlbumInfo = albumInfo(rc, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -182,18 +190,32 @@ func handleGetSimilarSongs2(rc *Context) *apiError {
|
||||
}
|
||||
|
||||
// similarSongsFor resolves id as a song, album, or artist (getSimilarSongs's
|
||||
// "id" can be any of the three in real Subsonic usage) and finds
|
||||
// genre-similar songs for it.
|
||||
// "id" can be any of the three in real Subsonic usage), finds
|
||||
// genre-similar songs for it, and — when the seed song has been sonic-
|
||||
// analyzed — re-ranks that genre-matched candidate set by actual sonic
|
||||
// distance rather than leaving it in the database's random order. This
|
||||
// never replaces the genre filter with pure sonic search (that's what
|
||||
// getSonicSimilarTracks is for); it just makes "similar" among
|
||||
// genre-mates mean something more concrete when the data exists to
|
||||
// back it up.
|
||||
func similarSongsFor(db *sql.DB, id string, count int) ([]*database.Song, error) {
|
||||
if song, err := database.GetSong(db, id); err == nil {
|
||||
return database.SimilarSongsByGenre(db, song.ID, count)
|
||||
songs, err := database.SimilarSongsByGenre(db, song.ID, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reorderBySonicSimilarity(db, song.ID, songs), nil
|
||||
}
|
||||
if _, err := database.GetAlbum(db, id); err == nil {
|
||||
songs, err := database.ListSongsByAlbum(db, id)
|
||||
if err != nil || len(songs) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return database.SimilarSongsByGenre(db, songs[0].ID, count)
|
||||
similar, err := database.SimilarSongsByGenre(db, songs[0].ID, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reorderBySonicSimilarity(db, songs[0].ID, similar), nil
|
||||
}
|
||||
if _, err := database.GetArtist(db, id); err == nil {
|
||||
return database.SimilarSongsByArtist(db, id, count)
|
||||
@@ -201,6 +223,50 @@ func similarSongsFor(db *sql.DB, id string, count int) ([]*database.Song, error)
|
||||
return nil, database.ErrNotFound
|
||||
}
|
||||
|
||||
// reorderBySonicSimilarity sorts songs by real sonic distance from
|
||||
// seedSongID when both the seed and (some of) the candidates have
|
||||
// stored audio_features; candidates without analysis data are left in
|
||||
// their existing relative order at the end, never dropped and never
|
||||
// assigned a fabricated distance.
|
||||
func reorderBySonicSimilarity(db *sql.DB, seedSongID string, songs []*database.Song) []*database.Song {
|
||||
if len(songs) < 2 {
|
||||
return songs
|
||||
}
|
||||
seed, err := database.GetAudioFeatures(db, seedSongID)
|
||||
if err != nil || seed == nil {
|
||||
return songs
|
||||
}
|
||||
|
||||
items := []similarity.Item{{ID: seedSongID, Vector: seed.Vector()}}
|
||||
analyzed := map[string]bool{}
|
||||
for _, s := range songs {
|
||||
f, err := database.GetAudioFeatures(db, s.ID)
|
||||
if err != nil || f == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, similarity.Item{ID: s.ID, Vector: f.Vector()})
|
||||
analyzed[s.ID] = true
|
||||
}
|
||||
if len(items) < 2 {
|
||||
return songs
|
||||
}
|
||||
idx := similarity.Build(items)
|
||||
|
||||
sorted := make([]*database.Song, len(songs))
|
||||
copy(sorted, songs)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
ai, aj := analyzed[sorted[i].ID], analyzed[sorted[j].ID]
|
||||
if ai != aj {
|
||||
return ai // analyzed songs sort before unanalyzed ones
|
||||
}
|
||||
if !ai {
|
||||
return false // preserve relative order among unanalyzed songs
|
||||
}
|
||||
return idx.DistanceBetween(seedSongID, sorted[i].ID) < idx.DistanceBetween(seedSongID, sorted[j].ID)
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
func handleGetTopSongs(rc *Context) *apiError {
|
||||
count := rc.Params.Int("count", 50)
|
||||
db := rc.Server.DB
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/metadataenrich"
|
||||
)
|
||||
|
||||
// enrichmentCacheTTL is how long a cached MusicBrainz/Cover Art Archive
|
||||
// response is trusted before VibeSonic tries fetching again. This data
|
||||
// changes rarely, so a long TTL avoids hammering the (rate-limited,
|
||||
// shared, free) MusicBrainz service on every request.
|
||||
const enrichmentCacheTTL = 30 * 24 * time.Hour
|
||||
|
||||
var enrichClient = &metadataenrich.Client{}
|
||||
|
||||
// artistInfoBase builds the local ArtistInfoBase for an artist ID,
|
||||
// filling in the MusicBrainz-derived fields only when external
|
||||
// metadata is enabled, the artist has a tagged MusicBrainz ID, and a
|
||||
// (possibly cached) lookup actually succeeds. Any failure along the
|
||||
// way — unknown id, no mbid, network error, disabled feature — leaves
|
||||
// those fields blank rather than erroring the whole request or
|
||||
// inventing a value.
|
||||
func artistInfoBase(rc *Context, id string) ArtistInfoBase {
|
||||
db := rc.Server.DB
|
||||
artist, err := database.GetArtist(db, id)
|
||||
if err != nil {
|
||||
return ArtistInfoBase{}
|
||||
}
|
||||
base := ArtistInfoBase{MusicBrainzID: artist.MusicBrainzID}
|
||||
if !rc.Server.Config.EnableExternalMetadata || artist.MusicBrainzID == "" {
|
||||
return base
|
||||
}
|
||||
|
||||
data, err := fetchArtistCached(rc.R.Context(), db, artist.MusicBrainzID)
|
||||
if err != nil || data == nil {
|
||||
return base
|
||||
}
|
||||
base.Biography = data.Disambiguation
|
||||
return base
|
||||
}
|
||||
|
||||
// albumInfo builds the local AlbumInfo for an album ID, enriched with
|
||||
// Cover Art Archive image URLs under the same conditions as
|
||||
// artistInfoBase.
|
||||
func albumInfo(rc *Context, id string) *AlbumInfo {
|
||||
db := rc.Server.DB
|
||||
album, err := database.GetAlbum(db, id)
|
||||
if err != nil {
|
||||
return &AlbumInfo{}
|
||||
}
|
||||
info := &AlbumInfo{MusicBrainzID: album.MusicBrainzID}
|
||||
if !rc.Server.Config.EnableExternalMetadata || album.MusicBrainzID == "" {
|
||||
return info
|
||||
}
|
||||
|
||||
art, err := fetchCoverArtCached(rc.R.Context(), db, album.MusicBrainzID)
|
||||
if err != nil || art == nil {
|
||||
return info
|
||||
}
|
||||
info.SmallImageURL = art.SmallURL
|
||||
info.MediumImageURL = art.MediumURL
|
||||
info.LargeImageURL = art.LargeURL
|
||||
return info
|
||||
}
|
||||
|
||||
func fetchArtistCached(ctx context.Context, db *sql.DB, mbid string) (*metadataenrich.ArtistData, error) {
|
||||
if cached, ok := readEnrichmentCache(db, "artist", mbid, "musicbrainz"); ok {
|
||||
var data metadataenrich.ArtistData
|
||||
if err := json.Unmarshal([]byte(cached), &data); err == nil {
|
||||
return &data, nil
|
||||
}
|
||||
}
|
||||
|
||||
data, err := enrichClient.FetchArtist(ctx, mbid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if raw, err := json.Marshal(data); err == nil {
|
||||
_ = database.PutCachedExternalMetadata(db, "artist", mbid, "musicbrainz", string(raw))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func fetchCoverArtCached(ctx context.Context, db *sql.DB, mbid string) (*metadataenrich.CoverArt, error) {
|
||||
if cached, ok := readEnrichmentCache(db, "release", mbid, "coverartarchive"); ok {
|
||||
var art metadataenrich.CoverArt
|
||||
if err := json.Unmarshal([]byte(cached), &art); err == nil {
|
||||
return &art, nil
|
||||
}
|
||||
}
|
||||
|
||||
art, err := enrichClient.FetchReleaseCoverArt(ctx, mbid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if art != nil {
|
||||
if raw, err := json.Marshal(art); err == nil {
|
||||
_ = database.PutCachedExternalMetadata(db, "release", mbid, "coverartarchive", string(raw))
|
||||
}
|
||||
}
|
||||
return art, nil
|
||||
}
|
||||
|
||||
// readEnrichmentCache returns a cached response's JSON body if one
|
||||
// exists and is still within enrichmentCacheTTL.
|
||||
func readEnrichmentCache(db *sql.DB, entityType, mbid, source string) (string, bool) {
|
||||
dataJSON, fetchedAt, found, err := database.GetCachedExternalMetadata(db, entityType, mbid, source)
|
||||
if err != nil || !found {
|
||||
return "", false
|
||||
}
|
||||
if time.Since(fetchedAt) > enrichmentCacheTTL {
|
||||
return "", false
|
||||
}
|
||||
return dataJSON, true
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/metadataenrich"
|
||||
)
|
||||
|
||||
// withEnrichClient temporarily swaps the package-level enrichClient for
|
||||
// a test double and restores it afterward, since tests run
|
||||
// sequentially in this package.
|
||||
func withEnrichClient(t *testing.T, c *metadataenrich.Client) {
|
||||
t.Helper()
|
||||
orig := enrichClient
|
||||
enrichClient = c
|
||||
t.Cleanup(func() { enrichClient = orig })
|
||||
}
|
||||
|
||||
func TestArtistInfo2EnrichedWhenEnabled(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
artist, err := database.FindOrCreateArtist(db, "Test Artist", "", "mbid-artist-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var calls int32
|
||||
mb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
json.NewEncoder(w).Encode(map[string]any{"disambiguation": "a real band"})
|
||||
}))
|
||||
defer mb.Close()
|
||||
withEnrichClient(t, &metadataenrich.Client{MusicBrainzBaseURL: mb.URL})
|
||||
|
||||
s.Config.EnableExternalMetadata = true
|
||||
srv := newSubsonicTestServer(t, s)
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", artist.ID)
|
||||
q.Set("f", "json")
|
||||
|
||||
resp := getPath(t, srv, "/rest/getArtistInfo2.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
info, ok := j["artistInfo2"].(map[string]any)
|
||||
if !ok || info["biography"] != "a real band" {
|
||||
t.Fatalf("artistInfo2 = %v, want biography from the mocked MusicBrainz response", j)
|
||||
}
|
||||
if info["musicBrainzId"] != "mbid-artist-1" {
|
||||
t.Errorf("musicBrainzId = %v", info["musicBrainzId"])
|
||||
}
|
||||
|
||||
// A second lookup must hit the cache, not MusicBrainz again.
|
||||
getPath(t, srv, "/rest/getArtistInfo2.view", q)
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Errorf("MusicBrainz was called %d times, want 1 (second lookup should be served from cache)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtistInfo2NotEnrichedWhenDisabled(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
artist, err := database.FindOrCreateArtist(db, "Test Artist", "", "mbid-artist-2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
called := false
|
||||
mb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
json.NewEncoder(w).Encode(map[string]any{"disambiguation": "should never be fetched"})
|
||||
}))
|
||||
defer mb.Close()
|
||||
withEnrichClient(t, &metadataenrich.Client{MusicBrainzBaseURL: mb.URL})
|
||||
|
||||
// EnableExternalMetadata left false (the default): must never
|
||||
// call out to the network, matching "must work completely offline".
|
||||
srv := newSubsonicTestServer(t, s)
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", artist.ID)
|
||||
q.Set("f", "json")
|
||||
resp := getPath(t, srv, "/rest/getArtistInfo2.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
info := j["artistInfo2"].(map[string]any)
|
||||
if _, has := info["biography"]; has {
|
||||
t.Errorf("biography should be absent when external metadata is disabled, got %v", info)
|
||||
}
|
||||
if called {
|
||||
t.Error("MusicBrainz must never be called when external metadata is disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtistInfo2NoMusicBrainzIDNeverCallsNetwork(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
artist, err := database.FindOrCreateArtist(db, "Untagged Artist", "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
called := false
|
||||
mb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
}))
|
||||
defer mb.Close()
|
||||
withEnrichClient(t, &metadataenrich.Client{MusicBrainzBaseURL: mb.URL})
|
||||
s.Config.EnableExternalMetadata = true
|
||||
|
||||
srv := newSubsonicTestServer(t, s)
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", artist.ID)
|
||||
q.Set("f", "json")
|
||||
getPath(t, srv, "/rest/getArtistInfo2.view", q)
|
||||
if called {
|
||||
t.Error("must never look up an artist with no locally-tagged MusicBrainz ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtistInfo2NetworkFailureLeavesFieldsBlank(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
artist, err := database.FindOrCreateArtist(db, "Test Artist", "", "mbid-artist-3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer mb.Close()
|
||||
withEnrichClient(t, &metadataenrich.Client{MusicBrainzBaseURL: mb.URL})
|
||||
s.Config.EnableExternalMetadata = true
|
||||
|
||||
srv := newSubsonicTestServer(t, s)
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", artist.ID)
|
||||
q.Set("f", "json")
|
||||
resp := getPath(t, srv, "/rest/getArtistInfo2.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
if j["status"] != nil && j["status"] != "ok" {
|
||||
t.Fatalf("a MusicBrainz failure must not fail the whole response: %v", j)
|
||||
}
|
||||
info := j["artistInfo2"].(map[string]any)
|
||||
if _, has := info["biography"]; has {
|
||||
t.Errorf("biography should be absent after a fetch failure, got %v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlbumInfo2EnrichedCoverArt(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
album, err := database.FindOrCreateAlbum(db, "Test Album", sql.NullString{}, "", sql.NullInt64{}, sql.NullInt64{}, "", "mbid-release-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
caa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"images": []map[string]any{
|
||||
{"front": true, "image": "http://example.com/full.jpg", "thumbnails": map[string]string{
|
||||
"small": "http://example.com/small.jpg", "large": "http://example.com/large.jpg",
|
||||
}},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer caa.Close()
|
||||
withEnrichClient(t, &metadataenrich.Client{CoverArtArchiveBaseURL: caa.URL})
|
||||
s.Config.EnableExternalMetadata = true
|
||||
|
||||
srv := newSubsonicTestServer(t, s)
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", album.ID)
|
||||
q.Set("f", "json")
|
||||
resp := getPath(t, srv, "/rest/getAlbumInfo2.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
info, ok := j["albumInfo"].(map[string]any)
|
||||
if !ok || info["largeImageUrl"] != "http://example.com/full.jpg" {
|
||||
t.Fatalf("albumInfo = %v, want cover art from the mocked Cover Art Archive response", j)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerExtension("songLyrics", 1, 2)
|
||||
register("getLyricsBySongId", handleGetLyricsBySongID)
|
||||
}
|
||||
|
||||
// handleGetLyricsBySongID implements the songLyrics extension. It never
|
||||
// fakes cue-level timing: cueLine data is only emitted for entries that
|
||||
// were actually parsed from an Enhanced-LRC-style source with word/
|
||||
// syllable cues, and only when the client asked for enhanced=true.
|
||||
func handleGetLyricsBySongID(rc *Context) *apiError {
|
||||
id, aerr := rc.Params.Required("id")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
enhanced := rc.Params.Bool("enhanced", false)
|
||||
|
||||
db := rc.Server.DB
|
||||
if _, err := database.GetSong(db, id); err != nil {
|
||||
return errNotFound("Song")
|
||||
}
|
||||
|
||||
rows, err := database.GetSongStructuredLyrics(db, id)
|
||||
if err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
|
||||
list := &LyricsList{}
|
||||
for _, row := range rows {
|
||||
kind := "main" // every entry VibeSonic currently stores/ingests is a primary lyric layer, never a translation or pronunciation.
|
||||
if !enhanced && kind != "main" {
|
||||
continue
|
||||
}
|
||||
|
||||
sl := StructuredLyrics{
|
||||
Lang: row.Language,
|
||||
Synced: row.Synced,
|
||||
Line: make([]Line, 0, len(row.Lines)),
|
||||
}
|
||||
if enhanced {
|
||||
sl.Kind = kind
|
||||
}
|
||||
|
||||
hasCues := false
|
||||
for _, l := range row.Lines {
|
||||
line := Line{Value: l.Text}
|
||||
if row.Synced && l.StartMs != nil {
|
||||
start := *l.StartMs
|
||||
line.Start = &start
|
||||
}
|
||||
sl.Line = append(sl.Line, line)
|
||||
if len(l.Words) > 0 {
|
||||
hasCues = true
|
||||
}
|
||||
}
|
||||
|
||||
if enhanced && row.Synced && hasCues {
|
||||
sl.CueLine = make([]CueLine, 0, len(row.Lines))
|
||||
for i, l := range row.Lines {
|
||||
if len(l.Words) == 0 {
|
||||
continue
|
||||
}
|
||||
cl := CueLine{Index: i, Value: l.Text}
|
||||
if l.StartMs != nil {
|
||||
cl.Start = *l.StartMs
|
||||
}
|
||||
cl.Cue = make([]Cue, 0, len(l.Words))
|
||||
for _, w := range l.Words {
|
||||
cue := Cue{Start: w.StartMs, ByteStart: w.ByteStart, ByteEnd: w.ByteEnd, Value: w.Text}
|
||||
if w.EndMs != nil {
|
||||
end := *w.EndMs
|
||||
cue.End = &end
|
||||
}
|
||||
cl.Cue = append(cl.Cue, cue)
|
||||
}
|
||||
if last := l.Words[len(l.Words)-1]; last.EndMs != nil {
|
||||
cl.End = *last.EndMs
|
||||
}
|
||||
sl.CueLine = append(sl.CueLine, cl)
|
||||
}
|
||||
}
|
||||
|
||||
list.StructuredLyrics = append(list.StructuredLyrics, sl)
|
||||
}
|
||||
|
||||
rc.Response = newResponse()
|
||||
rc.Response.LyricsList = list
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
func TestGetLyricsBySongIDPlain(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
seedLibrary(t, s)
|
||||
song := firstSong(t, s)
|
||||
|
||||
if _, err := database.InsertLyrics(db, song.ID, "embedded", "xxx", false, []database.LyricsLineInput{
|
||||
{Text: "Héllo wörld"},
|
||||
{Text: "Second line"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
srv := newSubsonicTestServer(t, s)
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", song.ID)
|
||||
q.Set("f", "json")
|
||||
resp := getPath(t, srv, "/rest/getLyricsBySongId.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
list, ok := j["lyricsList"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("lyricsList missing: %v", j)
|
||||
}
|
||||
entries, ok := list["structuredLyrics"].([]any)
|
||||
if !ok || len(entries) != 1 {
|
||||
t.Fatalf("structuredLyrics = %v, want 1 entry", list)
|
||||
}
|
||||
e := entries[0].(map[string]any)
|
||||
if e["synced"] != false {
|
||||
t.Errorf("synced = %v, want false", e["synced"])
|
||||
}
|
||||
lines, ok := e["line"].([]any)
|
||||
if !ok || len(lines) != 2 {
|
||||
t.Fatalf("lines = %v, want 2", e["line"])
|
||||
}
|
||||
first := lines[0].(map[string]any)
|
||||
if first["value"] != "Héllo wörld" {
|
||||
t.Errorf("line[0].value = %v", first["value"])
|
||||
}
|
||||
if _, hasStart := first["start"]; hasStart {
|
||||
t.Error("plain (unsynced) line should not have a start time")
|
||||
}
|
||||
if _, hasCue := e["cueLine"]; hasCue {
|
||||
t.Error("plain lyrics must never have cueLine data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLyricsBySongIDSyncedNotEnhanced(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
seedLibrary(t, s)
|
||||
song := firstSong(t, s)
|
||||
|
||||
start0 := 1000
|
||||
end0 := 1500
|
||||
if _, err := database.InsertLyrics(db, song.ID, "external", "en", true, []database.LyricsLineInput{
|
||||
{
|
||||
StartMs: &start0,
|
||||
Text: "Hello world",
|
||||
Words: []database.LyricsWordInput{
|
||||
{StartMs: 1000, EndMs: &end0, ByteStart: 0, ByteEnd: 4, Text: "Hello"},
|
||||
{StartMs: 1500, ByteStart: 6, ByteEnd: 10, Text: "world"},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
srv := newSubsonicTestServer(t, s)
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", song.ID)
|
||||
q.Set("f", "json")
|
||||
resp := getPath(t, srv, "/rest/getLyricsBySongId.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
list := j["lyricsList"].(map[string]any)
|
||||
entries := list["structuredLyrics"].([]any)
|
||||
e := entries[0].(map[string]any)
|
||||
if e["synced"] != true {
|
||||
t.Errorf("synced = %v, want true", e["synced"])
|
||||
}
|
||||
lines := e["line"].([]any)
|
||||
first := lines[0].(map[string]any)
|
||||
if first["start"] != float64(1000) {
|
||||
t.Errorf("line[0].start = %v, want 1000", first["start"])
|
||||
}
|
||||
// enhanced=false (default): cueLine data must be withheld even though
|
||||
// word-level cues actually exist in storage.
|
||||
if _, hasCue := e["cueLine"]; hasCue {
|
||||
t.Error("cueLine must be omitted when enhanced=false")
|
||||
}
|
||||
if _, hasKind := e["kind"]; hasKind {
|
||||
t.Error("kind must be omitted when enhanced=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLyricsBySongIDEnhancedWordCues(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
seedLibrary(t, s)
|
||||
song := firstSong(t, s)
|
||||
|
||||
start0 := 1000
|
||||
end0 := 1500
|
||||
end1 := 2200
|
||||
if _, err := database.InsertLyrics(db, song.ID, "external", "en", true, []database.LyricsLineInput{
|
||||
{
|
||||
StartMs: &start0,
|
||||
Text: "Hello wörld",
|
||||
Words: []database.LyricsWordInput{
|
||||
{StartMs: 1000, EndMs: &end0, ByteStart: 0, ByteEnd: 4, Text: "Hello"},
|
||||
{StartMs: 1500, EndMs: &end1, ByteStart: 6, ByteEnd: 12, Text: "wörld"},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
srv := newSubsonicTestServer(t, s)
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", song.ID)
|
||||
q.Set("enhanced", "true")
|
||||
q.Set("f", "json")
|
||||
resp := getPath(t, srv, "/rest/getLyricsBySongId.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
list := j["lyricsList"].(map[string]any)
|
||||
entries := list["structuredLyrics"].([]any)
|
||||
e := entries[0].(map[string]any)
|
||||
if e["kind"] != "main" {
|
||||
t.Errorf("kind = %v, want main", e["kind"])
|
||||
}
|
||||
cueLines, ok := e["cueLine"].([]any)
|
||||
if !ok || len(cueLines) != 1 {
|
||||
t.Fatalf("cueLine = %v, want 1 entry", e["cueLine"])
|
||||
}
|
||||
cl := cueLines[0].(map[string]any)
|
||||
if cl["value"] != "Hello wörld" {
|
||||
t.Errorf("cueLine.value = %v", cl["value"])
|
||||
}
|
||||
if cl["end"] != float64(2200) {
|
||||
t.Errorf("cueLine.end = %v, want 2200 (last cue's end)", cl["end"])
|
||||
}
|
||||
cues, ok := cl["cue"].([]any)
|
||||
if !ok || len(cues) != 2 {
|
||||
t.Fatalf("cue = %v, want 2", cl["cue"])
|
||||
}
|
||||
c0 := cues[0].(map[string]any)
|
||||
if c0["byteStart"] != float64(0) || c0["byteEnd"] != float64(4) || c0["value"] != "Hello" {
|
||||
t.Errorf("cue[0] = %v", c0)
|
||||
}
|
||||
c1 := cues[1].(map[string]any)
|
||||
// "wörld" starts at byte 6 in the UTF-8 encoded "Hello wörld" and its
|
||||
// byte offsets were computed against that exact string, not a naive
|
||||
// rune count, so this exercises the UTF-8-safe path end to end.
|
||||
if c1["byteStart"] != float64(6) || c1["byteEnd"] != float64(12) || c1["value"] != "wörld" {
|
||||
t.Errorf("cue[1] = %v", c1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLyricsBySongIDMultipleEntriesPreserved(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
seedLibrary(t, s)
|
||||
song := firstSong(t, s)
|
||||
|
||||
if _, err := database.InsertLyrics(db, song.ID, "embedded", "xxx", false, []database.LyricsLineInput{{Text: "Plain"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := 0
|
||||
if _, err := database.InsertLyrics(db, song.ID, "external", "en", true, []database.LyricsLineInput{{StartMs: &start, Text: "Synced"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
srv := newSubsonicTestServer(t, s)
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", song.ID)
|
||||
q.Set("f", "json")
|
||||
resp := getPath(t, srv, "/rest/getLyricsBySongId.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
list := j["lyricsList"].(map[string]any)
|
||||
entries := list["structuredLyrics"].([]any)
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("entries = %v, want 2 (embedded + synced both preserved)", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLyricsBySongIDNoLyrics(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
seedLibrary(t, s)
|
||||
song := firstSong(t, s)
|
||||
|
||||
srv := newSubsonicTestServer(t, s)
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", song.ID)
|
||||
q.Set("f", "json")
|
||||
resp := getPath(t, srv, "/rest/getLyricsBySongId.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
list, ok := j["lyricsList"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("lyricsList missing: %v", j)
|
||||
}
|
||||
if _, has := list["structuredLyrics"]; has {
|
||||
t.Errorf("expected no structuredLyrics entries, got %v", list["structuredLyrics"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLyricsBySongIDUnknownSong(t *testing.T) {
|
||||
s, srv := newTestRecorderServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", "sg_doesnotexist")
|
||||
resp := getPath(t, srv.URL, "/rest/getLyricsBySongId.view", q)
|
||||
x := decodeXML(t, resp)
|
||||
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound {
|
||||
t.Fatalf("expected error 70, got %+v", x)
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ func TestGetOpenSubsonicExtensions(t *testing.T) {
|
||||
// set once Phase 3 is done.
|
||||
for _, want := range []string{
|
||||
"apiKeyAuthentication", "formPost", "getPodcastEpisode", "topSongsByArtistId", "transcodeOffset",
|
||||
"indexBasedQueue", "playbackReport",
|
||||
"indexBasedQueue", "playbackReport", "songLyrics", "transcoding", "sonicSimilarity",
|
||||
} {
|
||||
if !names[want] {
|
||||
t.Errorf("extension %q not advertised: %v", want, names)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/netguard"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -176,6 +177,9 @@ func handleCreatePodcastChannel(rc *Context) *apiError {
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
if err := netguard.ValidateURL(feedURL); err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
channel, err := database.CreatePodcastChannel(rc.Server.DB, feedURL)
|
||||
if err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/audiofeatures"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
func makeGenreSong(t *testing.T, s *Server, folderID, genreID, title string) *database.Song {
|
||||
t.Helper()
|
||||
mf, err := database.CreateMediaFile(s.DB, folderID, title+".mp3", 100, 0, "hash-"+title, "mp3", "song", 10, 128, 44100, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
song, err := database.UpsertSong(s.DB, database.UpsertSongInput{MediaFileID: mf.ID, Title: title})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.LinkSongGenre(s.DB, song.ID, genreID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return song
|
||||
}
|
||||
|
||||
func TestSimilarSongsForReordersGenreCandidatesBySonicDistance(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
folder, err := database.CreateMusicFolder(db, "Music", "/music", "music")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
genre, err := database.FindOrCreateGenre(db, "Rock")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
seed := makeGenreSong(t, s, folder.ID, genre.ID, "Seed")
|
||||
if err := database.UpsertAudioFeatures(db, seed.ID, &audiofeatures.Features{TempoBPM: 100, TempoValid: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
near := makeGenreSong(t, s, folder.ID, genre.ID, "Near")
|
||||
if err := database.UpsertAudioFeatures(db, near.ID, &audiofeatures.Features{TempoBPM: 105, TempoValid: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
far := makeGenreSong(t, s, folder.ID, genre.ID, "Far")
|
||||
if err := database.UpsertAudioFeatures(db, far.ID, &audiofeatures.Features{TempoBPM: 200, TempoValid: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = makeGenreSong(t, s, folder.ID, genre.ID, "Unanalyzed")
|
||||
|
||||
songs, err := similarSongsFor(db, seed.ID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("similarSongsFor: %v", err)
|
||||
}
|
||||
if len(songs) != 3 {
|
||||
t.Fatalf("songs = %v, want 3 (near, far, unanalyzed)", songs)
|
||||
}
|
||||
|
||||
pos := map[string]int{}
|
||||
for i, sg := range songs {
|
||||
pos[sg.Title] = i
|
||||
}
|
||||
if pos["Near"] >= pos["Far"] {
|
||||
t.Errorf("expected Near (105bpm) to rank before Far (200bpm), got order %v", songs)
|
||||
}
|
||||
if pos["Unanalyzed"] != len(songs)-1 {
|
||||
t.Errorf("expected the unanalyzed candidate last, got order %v", songs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimilarSongsForNoAudioFeaturesLeavesOrderAlone(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
folder, err := database.CreateMusicFolder(db, "Music", "/music", "music")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
genre, err := database.FindOrCreateGenre(db, "Rock")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed := makeGenreSong(t, s, folder.ID, genre.ID, "Seed")
|
||||
makeGenreSong(t, s, folder.ID, genre.ID, "Other")
|
||||
|
||||
songs, err := similarSongsFor(db, seed.ID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("similarSongsFor: %v", err)
|
||||
}
|
||||
if len(songs) != 1 || songs[0].Title != "Other" {
|
||||
t.Fatalf("songs = %v, want [Other] (no analysis on either side, no reorder crash)", songs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/similarity"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerExtension("sonicSimilarity", 1)
|
||||
register("getSonicSimilarTracks", handleGetSonicSimilarTracks)
|
||||
register("findSonicPath", handleFindSonicPath)
|
||||
}
|
||||
|
||||
// buildSimilarityIndex loads every song VibeSonic has actually analyzed
|
||||
// (audio_features rows populated by the scanner's background analysis)
|
||||
// and builds an in-process nearest-neighbor index from their real
|
||||
// feature vectors. It's rebuilt per request rather than cached: the
|
||||
// library-wide mean/stddev normalization it depends on shifts as more
|
||||
// songs get analyzed, and a from-scratch build over even a large
|
||||
// library is a few hundred microseconds of arithmetic over small
|
||||
// vectors, not a bottleneck worth caching.
|
||||
func buildSimilarityIndex(db *sql.DB) (*similarity.Index, error) {
|
||||
rows, err := database.ListAllAudioFeatures(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]similarity.Item, len(rows))
|
||||
for i, r := range rows {
|
||||
items[i] = similarity.Item{ID: r.SongID, Vector: r.Vector()}
|
||||
}
|
||||
return similarity.Build(items), nil
|
||||
}
|
||||
|
||||
// similarityScore converts a Euclidean distance in normalized feature
|
||||
// space into the "normalized similarity score" the spec asks for: 1.0
|
||||
// for an identical vector, approaching 0 as distance grows, never
|
||||
// negative.
|
||||
func similarityScore(distance float64) float64 {
|
||||
return 1 / (1 + distance)
|
||||
}
|
||||
|
||||
func childForSongID(db *sql.DB, songID, userID string) (*Child, error) {
|
||||
song, err := database.GetSong(db, songID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mf, err := database.GetMediaFile(db, song.MediaFileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := childFromSong(db, song, mf, albumOfSong(db, song), userID)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func handleGetSonicSimilarTracks(rc *Context) *apiError {
|
||||
id, aerr := rc.Params.Required("id")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
count := rc.Params.Int("count", 10)
|
||||
db := rc.Server.DB
|
||||
|
||||
if _, err := database.GetSong(db, id); err != nil {
|
||||
return errNotFound("Song")
|
||||
}
|
||||
|
||||
idx, err := buildSimilarityIndex(db)
|
||||
if err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
matches := idx.Nearest(id, count)
|
||||
if matches == nil {
|
||||
// Either the song hasn't been analyzed yet, or nothing else in
|
||||
// the library has — an honest empty result, not an error.
|
||||
rc.Response = newResponse()
|
||||
return nil
|
||||
}
|
||||
|
||||
var out []SonicMatch
|
||||
for _, m := range matches {
|
||||
child, err := childForSongID(db, m.ID, rc.User.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, SonicMatch{Entry: *child, Similarity: similarityScore(m.Distance)})
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
rc.Response.SonicMatch = out
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleFindSonicPath(rc *Context) *apiError {
|
||||
startID, aerr := rc.Params.Required("startSongId")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
endID, aerr := rc.Params.Required("endSongId")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
count := rc.Params.Int("count", 25)
|
||||
db := rc.Server.DB
|
||||
|
||||
if _, err := database.GetSong(db, startID); err != nil {
|
||||
return errNotFound("Start song")
|
||||
}
|
||||
if _, err := database.GetSong(db, endID); err != nil {
|
||||
return errNotFound("End song")
|
||||
}
|
||||
|
||||
idx, err := buildSimilarityIndex(db)
|
||||
if err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
path := idx.Path(startID, endID, count)
|
||||
if path == nil {
|
||||
// Analysis hasn't run for one or both endpoints yet: an honest
|
||||
// empty result rather than a fabricated single-hop path.
|
||||
rc.Response = newResponse()
|
||||
return nil
|
||||
}
|
||||
|
||||
var out []SonicMatch
|
||||
for _, songID := range path {
|
||||
child, err := childForSongID(db, songID, rc.User.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
distFromStart := idx.DistanceBetween(startID, songID)
|
||||
out = append(out, SonicMatch{Entry: *child, Similarity: similarityScore(distFromStart)})
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
rc.Response.SonicMatch = out
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/audiofeatures"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
// makeAnalyzedSong creates a minimal song fixture (no real audio file
|
||||
// needed — sonic-similarity handlers only ever read the songs table and
|
||||
// audio_features, never touch disk) and stores a hand-crafted feature
|
||||
// vector for it, isolating these tests from ffmpeg/analysis timing.
|
||||
func makeAnalyzedSong(t *testing.T, s *Server, folderID, title string, tempo float64) *database.Song {
|
||||
t.Helper()
|
||||
mf, err := database.CreateMediaFile(s.DB, folderID, title+".mp3", 100, 0, "hash-"+title, "mp3", "song", 10, 128, 44100, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
song, err := database.UpsertSong(s.DB, database.UpsertSongInput{MediaFileID: mf.ID, Title: title})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &audiofeatures.Features{TempoBPM: tempo, TempoValid: true, Key: "C major"}
|
||||
if err := database.UpsertAudioFeatures(s.DB, song.ID, f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return song
|
||||
}
|
||||
|
||||
func sonicTestLibrary(t *testing.T, s *Server) (slow, mid, fast, unanalyzed *database.Song) {
|
||||
t.Helper()
|
||||
folder, err := database.CreateMusicFolder(s.DB, "Music", "/music", "music")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slow = makeAnalyzedSong(t, s, folder.ID, "Slow", 70)
|
||||
mid = makeAnalyzedSong(t, s, folder.ID, "Mid", 100)
|
||||
fast = makeAnalyzedSong(t, s, folder.ID, "Fast", 160)
|
||||
|
||||
mf, err := database.CreateMediaFile(s.DB, folder.ID, "Unanalyzed.mp3", 100, 0, "hash-unanalyzed", "mp3", "song", 10, 128, 44100, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unanalyzed, err = database.UpsertSong(s.DB, database.UpsertSongInput{MediaFileID: mf.ID, Title: "Unanalyzed"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func TestGetSonicSimilarTracksOrdersByRealDistance(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
_, mid, fast, _ := sonicTestLibrary(t, s)
|
||||
|
||||
srv := newSubsonicTestServer(t, s)
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", mid.ID) // 100bpm: 30bpm from Slow(70), 60bpm from Fast(160) — Slow should rank first
|
||||
q.Set("f", "json")
|
||||
resp := getPath(t, srv, "/rest/getSonicSimilarTracks.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
matches, ok := j["sonicMatch"].([]any)
|
||||
if !ok || len(matches) != 2 {
|
||||
t.Fatalf("sonicMatch = %v, want 2 entries", j)
|
||||
}
|
||||
first := matches[0].(map[string]any)
|
||||
entry := first["entry"].(map[string]any)
|
||||
if entry["title"] != "Slow" {
|
||||
t.Errorf("nearest to Mid(100bpm) = %v, want Slow(70bpm) (closer than Fast(160bpm))", entry["title"])
|
||||
}
|
||||
sim := first["similarity"].(float64)
|
||||
if sim <= 0 || sim > 1 {
|
||||
t.Errorf("similarity = %v, want in (0,1]", sim)
|
||||
}
|
||||
_ = fast
|
||||
}
|
||||
|
||||
func TestGetSonicSimilarTracksExcludesUnanalyzedSongs(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
slow, _, _, unanalyzed := sonicTestLibrary(t, s)
|
||||
|
||||
srv := newSubsonicTestServer(t, s)
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", slow.ID)
|
||||
q.Set("f", "json")
|
||||
resp := getPath(t, srv, "/rest/getSonicSimilarTracks.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
matches := j["sonicMatch"].([]any)
|
||||
for _, m := range matches {
|
||||
entry := m.(map[string]any)["entry"].(map[string]any)
|
||||
if entry["title"] == "Unanalyzed" {
|
||||
t.Error("a song with no stored audio_features must never appear in sonic-similarity results")
|
||||
}
|
||||
}
|
||||
_ = unanalyzed
|
||||
}
|
||||
|
||||
func TestGetSonicSimilarTracksUnknownSong(t *testing.T) {
|
||||
s, srv := newTestRecorderServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", "sg_doesnotexist")
|
||||
resp := getPath(t, srv.URL, "/rest/getSonicSimilarTracks.view", q)
|
||||
x := decodeXML(t, resp)
|
||||
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound {
|
||||
t.Fatalf("expected error 70, got %+v", x)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSonicSimilarTracksNeverAnalyzedReturnsEmpty(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
folder, err := database.CreateMusicFolder(s.DB, "Music", "/music", "music")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mf, err := database.CreateMediaFile(s.DB, folder.ID, "solo.mp3", 100, 0, "h", "mp3", "song", 10, 128, 44100, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
song, err := database.UpsertSong(s.DB, database.UpsertSongInput{MediaFileID: mf.ID, Title: "Solo"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
srv := newSubsonicTestServer(t, s)
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", song.ID)
|
||||
q.Set("f", "json")
|
||||
resp := getPath(t, srv, "/rest/getSonicSimilarTracks.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
// No song in the library has been analyzed at all — VibeSonic must
|
||||
// return an honest empty result, never a fabricated match.
|
||||
if _, has := j["sonicMatch"]; has {
|
||||
t.Errorf("expected no sonicMatch key when nothing is analyzed, got %v", j["sonicMatch"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindSonicPathReachesBothEndpointsAndOrdersMonotonically(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
slow, _, fast, _ := sonicTestLibrary(t, s)
|
||||
|
||||
srv := newSubsonicTestServer(t, s)
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("startSongId", slow.ID)
|
||||
q.Set("endSongId", fast.ID)
|
||||
q.Set("count", "3")
|
||||
q.Set("f", "json")
|
||||
resp := getPath(t, srv, "/rest/findSonicPath.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
matches, ok := j["sonicMatch"].([]any)
|
||||
if !ok || len(matches) != 3 {
|
||||
t.Fatalf("sonicMatch = %v, want 3 (start, mid, end)", j)
|
||||
}
|
||||
first := matches[0].(map[string]any)["entry"].(map[string]any)
|
||||
last := matches[2].(map[string]any)["entry"].(map[string]any)
|
||||
middle := matches[1].(map[string]any)["entry"].(map[string]any)
|
||||
if first["title"] != "Slow" || last["title"] != "Fast" {
|
||||
t.Fatalf("path endpoints = %v .. %v, want Slow .. Fast", first["title"], last["title"])
|
||||
}
|
||||
if middle["title"] != "Mid" {
|
||||
t.Errorf("path middle hop = %v, want Mid (the only other analyzed song, between Slow and Fast)", middle["title"])
|
||||
}
|
||||
startSim := matches[0].(map[string]any)["similarity"].(float64)
|
||||
if startSim != 1 {
|
||||
t.Errorf("similarity of start song to itself = %v, want 1", startSim)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindSonicPathUnknownEndpoint(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
slow, _, _, _ := sonicTestLibrary(t, s)
|
||||
|
||||
srv := newSubsonicTestServer(t, s)
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("startSongId", slow.ID)
|
||||
q.Set("endSongId", "sg_doesnotexist")
|
||||
resp := getPath(t, srv, "/rest/findSonicPath.view", q)
|
||||
x := decodeXML(t, resp)
|
||||
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound {
|
||||
t.Fatalf("expected error 70, got %+v", x)
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func newTestServer(t *testing.T) (*Server, *sql.DB) {
|
||||
Scanner: scanner.New(db, &metadata.Prober{}, log),
|
||||
CoverArt: coverart.New(t.TempDir(), ""),
|
||||
Transcoder: transcoding.NewManager(""),
|
||||
Podcasts: podcasts.NewManager(db, t.TempDir(), "", log),
|
||||
Podcasts: podcasts.NewManager(db, t.TempDir(), "", log, true),
|
||||
Jukebox: jukebox.New(""),
|
||||
Log: log,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/streaming"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/transcoding"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerExtension("transcoding", 1)
|
||||
register("getTranscodeDecision", handleGetTranscodeDecision)
|
||||
register("getTranscodeStream", handleGetTranscodeStream)
|
||||
}
|
||||
|
||||
// codecFormats are the transcode targets VibeSonic's ffmpeg pipeline can
|
||||
// actually produce. A client's transcodingProfile.audioCodec must name
|
||||
// one of these for VibeSonic to be able to honor it — canTranscode is
|
||||
// never set true for a codec we can't actually encode.
|
||||
var codecFormats = map[string]transcoding.Format{
|
||||
"mp3": transcoding.FormatMP3,
|
||||
"opus": transcoding.FormatOpus,
|
||||
"aac": transcoding.FormatAAC,
|
||||
}
|
||||
|
||||
func containsFold(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
if strings.EqualFold(s, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesDirectPlay reports whether a source file, as actually scanned
|
||||
// (its real container/format, channel count), satisfies one of the
|
||||
// client's declared direct-play profiles.
|
||||
func matchesDirectPlay(mf *database.MediaFile, profiles []DirectPlayProfile) bool {
|
||||
for _, p := range profiles {
|
||||
if len(p.Protocols) > 0 && !containsFold(p.Protocols, "http") {
|
||||
continue
|
||||
}
|
||||
if !containsFold(p.Containers, mf.Format) {
|
||||
continue
|
||||
}
|
||||
if len(p.AudioCodecs) > 0 && !containsFold(p.AudioCodecs, mf.Format) {
|
||||
continue
|
||||
}
|
||||
if p.MaxAudioChannels > 0 && mf.Channels > p.MaxAudioChannels {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// limitationOK evaluates one CodecProfile limitation against a candidate
|
||||
// value VibeSonic would actually produce for that transcode.
|
||||
func limitationOK(lim Limitation, value int) bool {
|
||||
if len(lim.Values) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, raw := range lim.Values {
|
||||
want, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
switch lim.Comparison {
|
||||
case "Equals":
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
case "NotEquals":
|
||||
if value != want {
|
||||
return true
|
||||
}
|
||||
case "LessThanEqual":
|
||||
if value <= want {
|
||||
return true
|
||||
}
|
||||
case "GreaterThanEqual":
|
||||
if value >= want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// codecProfileFor finds the CodecProfile (if any) the client declared
|
||||
// for a given audio codec name.
|
||||
func codecProfileFor(profiles []CodecProfile, codec string) *CodecProfile {
|
||||
for i := range profiles {
|
||||
if strings.EqualFold(profiles[i].Type, "audio") && strings.EqualFold(profiles[i].Name, codec) {
|
||||
return &profiles[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// transcodePlan is what VibeSonic decided to actually produce for a
|
||||
// chosen transcoding profile: a real ffmpeg format, bitrate, and channel
|
||||
// count that satisfy every limitation the client marked required.
|
||||
type transcodePlan struct {
|
||||
format transcoding.Format
|
||||
container string
|
||||
codec string
|
||||
bitrate int
|
||||
channels int
|
||||
}
|
||||
|
||||
// planTranscode picks the first client-declared transcoding profile
|
||||
// VibeSonic can actually fulfill: a codec its ffmpeg pipeline supports,
|
||||
// with a bitrate/channel count that satisfies every "required" codec
|
||||
// limitation the client listed. It never claims to satisfy a required
|
||||
// limitation it doesn't actually meet.
|
||||
func planTranscode(mf *database.MediaFile, client ClientInfo) *transcodePlan {
|
||||
for _, tp := range client.TranscodingProfiles {
|
||||
if tp.Protocol != "" && !strings.EqualFold(tp.Protocol, "http") {
|
||||
continue
|
||||
}
|
||||
format, ok := codecFormats[strings.ToLower(tp.AudioCodec)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
bitrate := client.MaxTranscodingAudioBitrate
|
||||
if bitrate <= 0 {
|
||||
bitrate = 192
|
||||
}
|
||||
channels := mf.Channels
|
||||
if tp.MaxAudioChannels > 0 && channels > tp.MaxAudioChannels {
|
||||
channels = tp.MaxAudioChannels
|
||||
}
|
||||
|
||||
if cp := codecProfileFor(client.CodecProfiles, tp.AudioCodec); cp != nil {
|
||||
ok := true
|
||||
for _, lim := range cp.Limitations {
|
||||
switch lim.Name {
|
||||
case "audioBitrate":
|
||||
if !limitationOK(lim, bitrate) {
|
||||
if !lim.Required {
|
||||
continue
|
||||
}
|
||||
ok = false
|
||||
}
|
||||
case "audioChannels":
|
||||
if !limitationOK(lim, channels) {
|
||||
if !lim.Required {
|
||||
continue
|
||||
}
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return &transcodePlan{
|
||||
format: format,
|
||||
container: tp.Container,
|
||||
codec: tp.AudioCodec,
|
||||
bitrate: bitrate,
|
||||
channels: channels,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleGetTranscodeDecision(rc *Context) *apiError {
|
||||
if !rc.User.Allows(rc.User.Permissions.Stream) {
|
||||
return errUnauthorized("streaming")
|
||||
}
|
||||
mediaID, aerr := rc.Params.Required("mediaId")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
mediaType, aerr := rc.Params.Required("mediaType")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
|
||||
var client ClientInfo
|
||||
if rc.R.Body != nil {
|
||||
defer rc.R.Body.Close()
|
||||
if err := json.NewDecoder(rc.R.Body).Decode(&client); err != nil {
|
||||
return newError(ErrGeneric, "invalid ClientInfo request body: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
db := rc.Server.DB
|
||||
song, err := resolveMediaID(db, mediaID, mediaType)
|
||||
if err != nil {
|
||||
return errNotFound("Media")
|
||||
}
|
||||
mf, err := database.GetMediaFile(db, song.MediaFileID)
|
||||
if err != nil {
|
||||
return errNotFound("Media")
|
||||
}
|
||||
|
||||
source := &StreamDetails{
|
||||
Protocol: "http",
|
||||
Container: mf.Format,
|
||||
Codec: mf.Format,
|
||||
AudioChannels: mf.Channels,
|
||||
AudioBitrate: mf.BitrateKbps,
|
||||
AudioSamplerate: mf.SampleRateHz,
|
||||
}
|
||||
|
||||
decision := TranscodeDecision{SourceStream: source}
|
||||
|
||||
if matchesDirectPlay(mf, client.DirectPlayProfiles) {
|
||||
decision.CanDirectPlay = true
|
||||
rc.Response = newResponse()
|
||||
rc.Response.TranscodeDecision = &decision
|
||||
return nil
|
||||
}
|
||||
decision.TranscodeReason = []string{"source format not in client's direct-play profiles"}
|
||||
|
||||
plan := planTranscode(mf, client)
|
||||
if plan == nil {
|
||||
decision.CanTranscode = false
|
||||
decision.ErrorReason = "no client transcoding profile is compatible with what this server can produce"
|
||||
rc.Response = newResponse()
|
||||
rc.Response.TranscodeDecision = &decision
|
||||
return nil
|
||||
}
|
||||
|
||||
decision.CanTranscode = true
|
||||
decision.TranscodeStream = &StreamDetails{
|
||||
Protocol: "http",
|
||||
Container: plan.container,
|
||||
Codec: plan.codec,
|
||||
AudioChannels: plan.channels,
|
||||
AudioBitrate: plan.bitrate,
|
||||
}
|
||||
decision.TranscodeParams = encodeTranscodeParams(plan)
|
||||
|
||||
rc.Response = newResponse()
|
||||
rc.Response.TranscodeDecision = &decision
|
||||
return nil
|
||||
}
|
||||
|
||||
// encodeTranscodeParams and decodeTranscodeParams round-trip a
|
||||
// transcodePlan through an opaque server-specific token, per the spec's
|
||||
// instruction that clients must pass back exactly what
|
||||
// getTranscodeDecision gave them rather than reconstructing it.
|
||||
func encodeTranscodeParams(plan *transcodePlan) string {
|
||||
v := url.Values{}
|
||||
v.Set("format", string(plan.format))
|
||||
v.Set("bitrate", strconv.Itoa(plan.bitrate))
|
||||
v.Set("channels", strconv.Itoa(plan.channels))
|
||||
return v.Encode()
|
||||
}
|
||||
|
||||
func decodeTranscodeParams(s string) (transcoding.Options, error) {
|
||||
v, err := url.ParseQuery(s)
|
||||
if err != nil {
|
||||
return transcoding.Options{}, err
|
||||
}
|
||||
format, ok := codecFormats[v.Get("format")]
|
||||
if !ok {
|
||||
return transcoding.Options{}, fmt.Errorf("unsupported transcodeParams format %q", v.Get("format"))
|
||||
}
|
||||
bitrate, _ := strconv.Atoi(v.Get("bitrate"))
|
||||
return transcoding.Options{Format: format, MaxBitRateKbps: bitrate}, nil
|
||||
}
|
||||
|
||||
func handleGetTranscodeStream(rc *Context) *apiError {
|
||||
if !rc.User.Allows(rc.User.Permissions.Stream) {
|
||||
return errUnauthorized("streaming")
|
||||
}
|
||||
mediaID, aerr := rc.Params.Required("mediaId")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
mediaType, aerr := rc.Params.Required("mediaType")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
transcodeParams, aerr := rc.Params.Required("transcodeParams")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
offset := rc.Params.Int("offset", 0)
|
||||
|
||||
db := rc.Server.DB
|
||||
song, err := resolveMediaID(db, mediaID, mediaType)
|
||||
if err != nil {
|
||||
return errNotFound("Media")
|
||||
}
|
||||
mf, folder, aerr := resolveSongFile(rc, song.ID)
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
absPath, perr := streaming.ResolvePath(folder.Path, mf.RelativePath)
|
||||
if perr != nil {
|
||||
return newError(ErrGeneric, perr.Error())
|
||||
}
|
||||
|
||||
opts, err := decodeTranscodeParams(transcodeParams)
|
||||
if err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
opts.TimeOffset = float64(offset)
|
||||
|
||||
rc.Handled = true
|
||||
rc.W.Header().Set("Content-Type", opts.Format.MimeType())
|
||||
ctx := rc.R.Context()
|
||||
if err := rc.Server.Transcoder.Stream(ctx, rc.W, absPath, opts); err != nil && ctx.Err() == nil {
|
||||
rc.Server.Log.Warn("transcode failed", "err", err, "path", absPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
func postTranscodeDecision(t *testing.T, srv string, q map[string]string, client ClientInfo) *http.Response {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(client)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
format := q["f"]
|
||||
if format == "" {
|
||||
format = "xml"
|
||||
}
|
||||
url := srv + "/rest/getTranscodeDecision.view?u=" + q["u"] + "&p=" + q["p"] + "&v=1.16.1&c=vibesonic-test&f=" + format + "&mediaId=" + q["mediaId"] + "&mediaType=" + q["mediaType"]
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestTranscodeDecisionDirectPlay(t *testing.T) {
|
||||
s, srv := newTestRecorderServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
seedLibrary(t, s)
|
||||
song := firstSong(t, s)
|
||||
|
||||
client := ClientInfo{
|
||||
Name: "TestClient",
|
||||
Platform: "linux",
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{"http"}},
|
||||
},
|
||||
}
|
||||
resp := postTranscodeDecision(t, srv.URL, map[string]string{"u": "alice", "p": "password123", "mediaId": song.ID, "mediaType": "song", "f": "json"}, client)
|
||||
j := decodeJSON(t, resp)
|
||||
d, ok := j["transcodeDecision"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("transcodeDecision missing: %v", j)
|
||||
}
|
||||
if d["canDirectPlay"] != true {
|
||||
t.Fatalf("canDirectPlay = %v, want true (client declared mp3 direct play, fixture songs are mp3)", d)
|
||||
}
|
||||
if _, has := d["transcodeStream"]; has {
|
||||
t.Error("transcodeStream should be absent when direct play is possible")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscodeDecisionFallsBackToTranscode(t *testing.T) {
|
||||
s, srv := newTestRecorderServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
seedLibrary(t, s)
|
||||
song := firstSong(t, s)
|
||||
|
||||
client := ClientInfo{
|
||||
Name: "TestClient",
|
||||
Platform: "linux",
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}, Protocols: []string{"http"}},
|
||||
},
|
||||
TranscodingProfiles: []TranscodingProfile{
|
||||
{Container: "ogg", AudioCodec: "opus", Protocol: "http", MaxAudioChannels: 2},
|
||||
},
|
||||
MaxTranscodingAudioBitrate: 96,
|
||||
}
|
||||
resp := postTranscodeDecision(t, srv.URL, map[string]string{"u": "alice", "p": "password123", "mediaId": song.ID, "mediaType": "song", "f": "json"}, client)
|
||||
j := decodeJSON(t, resp)
|
||||
d := j["transcodeDecision"].(map[string]any)
|
||||
if d["canDirectPlay"] != nil && d["canDirectPlay"] != false {
|
||||
t.Errorf("canDirectPlay = %v, want false (source is mp3, client only direct-plays flac)", d["canDirectPlay"])
|
||||
}
|
||||
if d["canTranscode"] != true {
|
||||
t.Fatalf("canTranscode = %v, want true", d)
|
||||
}
|
||||
ts, ok := d["transcodeStream"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("transcodeStream missing: %v", d)
|
||||
}
|
||||
if ts["codec"] != "opus" || ts["audioBitrate"] != float64(96) {
|
||||
t.Errorf("transcodeStream = %v, want opus @ 96kbps", ts)
|
||||
}
|
||||
params, ok := d["transcodeParams"].(string)
|
||||
if !ok || params == "" {
|
||||
t.Fatalf("transcodeParams missing: %v", d)
|
||||
}
|
||||
|
||||
// getTranscodeStream must accept exactly the transcodeParams token
|
||||
// getTranscodeDecision returned and actually produce transcoded
|
||||
// audio bytes.
|
||||
streamResp, err := http.Get(srv.URL + "/rest/getTranscodeStream.view?u=alice&p=password123&v=1.16.1&c=vibesonic-test&mediaId=" + song.ID + "&mediaType=song&transcodeParams=" + url.QueryEscape(params))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer streamResp.Body.Close()
|
||||
if streamResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("getTranscodeStream status = %d", streamResp.StatusCode)
|
||||
}
|
||||
data, _ := io.ReadAll(streamResp.Body)
|
||||
if len(data) == 0 {
|
||||
t.Fatal("getTranscodeStream returned no bytes")
|
||||
}
|
||||
if ct := streamResp.Header.Get("Content-Type"); ct != "audio/ogg" {
|
||||
t.Errorf("Content-Type = %q, want audio/ogg for an opus transcode", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscodeDecisionNoCompatibleProfile(t *testing.T) {
|
||||
s, srv := newTestRecorderServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
seedLibrary(t, s)
|
||||
song := firstSong(t, s)
|
||||
|
||||
client := ClientInfo{
|
||||
Name: "PickyClient",
|
||||
Platform: "linux",
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}, Protocols: []string{"http"}},
|
||||
},
|
||||
TranscodingProfiles: []TranscodingProfile{
|
||||
{Container: "wma", AudioCodec: "wmav2", Protocol: "http"},
|
||||
},
|
||||
}
|
||||
resp := postTranscodeDecision(t, srv.URL, map[string]string{"u": "alice", "p": "password123", "mediaId": song.ID, "mediaType": "song", "f": "json"}, client)
|
||||
j := decodeJSON(t, resp)
|
||||
d := j["transcodeDecision"].(map[string]any)
|
||||
if d["canTranscode"] != nil && d["canTranscode"] != false {
|
||||
t.Errorf("canTranscode = %v, want false (server has no wmav2 encoder)", d["canTranscode"])
|
||||
}
|
||||
if d["errorReason"] == nil || d["errorReason"] == "" {
|
||||
t.Error("expected a non-empty errorReason when no profile is compatible")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscodeDecisionRequiresStreamPermission(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
u := createTestUser(t, s, "alice", "password123", false)
|
||||
perms := u.Permissions
|
||||
perms.Stream = false
|
||||
if err := database.SetUserPermissions(db, u.ID, perms); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := newSubsonicTestServer(t, s)
|
||||
seedLibrary(t, s)
|
||||
song := firstSong(t, s)
|
||||
|
||||
resp := postTranscodeDecision(t, srv, map[string]string{"u": "alice", "p": "password123", "mediaId": song.ID, "mediaType": "song"}, ClientInfo{})
|
||||
x := decodeXML(t, resp)
|
||||
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrUnauthorized {
|
||||
t.Fatalf("expected error 50, got %+v", x)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscodeDecisionUnknownMedia(t *testing.T) {
|
||||
s, srv := newTestRecorderServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
|
||||
resp := postTranscodeDecision(t, srv.URL, map[string]string{"u": "alice", "p": "password123", "mediaId": "sg_doesnotexist", "mediaType": "song"}, ClientInfo{})
|
||||
x := decodeXML(t, resp)
|
||||
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound {
|
||||
t.Fatalf("expected error 70, got %+v", x)
|
||||
}
|
||||
}
|
||||
+116
-10
@@ -2,7 +2,9 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
|
||||
@@ -48,6 +50,16 @@ func (h *Web) handleAdminUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/users", http.StatusFound)
|
||||
}
|
||||
|
||||
// userPermissionsPageData bundles the target user with the data the
|
||||
// permissions form needs beyond the plain user row: every music folder
|
||||
// (so the form can render a checkbox per folder) and which of those the
|
||||
// user is currently restricted to.
|
||||
type userPermissionsPageData struct {
|
||||
*database.User
|
||||
Folders []*database.MusicFolder
|
||||
AllowedFolders map[string]bool
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminUserPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
target, err := database.GetUserByID(h.DB, id)
|
||||
@@ -55,12 +67,31 @@ func (h *Web) handleAdminUserPermissions(w http.ResponseWriter, r *http.Request)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.page(w, r, "admin_user_permissions", "admin_users", "Permissions: "+target.Username, target)
|
||||
folders, err := database.ListMusicFolders(h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
allowedIDs, err := database.AllowedMusicFolderIDs(h.DB, id)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
allowed := make(map[string]bool, len(allowedIDs))
|
||||
for _, fid := range allowedIDs {
|
||||
allowed[fid] = true
|
||||
}
|
||||
h.page(w, r, "admin_user_permissions", "admin_users", "Permissions: "+target.Username, userPermissionsPageData{
|
||||
User: target,
|
||||
Folders: folders,
|
||||
AllowedFolders: allowed,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminUserPermissionsUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := database.GetUserByID(h.DB, id); err != nil {
|
||||
target, err := database.GetUserByID(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -86,6 +117,32 @@ func (h *Web) handleAdminUserPermissionsUpdate(w http.ResponseWriter, r *http.Re
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
wantAdmin := get("admin")
|
||||
if target.IsAdmin && !wantAdmin {
|
||||
// Refuse to demote the last remaining admin — otherwise the
|
||||
// instance could end up with no one able to reach /admin/*.
|
||||
n, err := database.CountAdmins(h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if n <= 1 {
|
||||
http.Error(w, "cannot remove admin role from the last remaining admin", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := database.SetUserAdmin(h.DB, id, wantAdmin); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
folderIDs := r.Form["music_folders"]
|
||||
if err := database.SetAllowedMusicFolders(h.DB, id, folderIDs); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/admin/users", http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -189,11 +246,15 @@ func (h *Web) handleAdminFolderScan(w http.ResponseWriter, r *http.Request) {
|
||||
if h.Scanner != nil {
|
||||
go func() { _ = h.Scanner.ScanFolder(context.Background(), id) }()
|
||||
}
|
||||
referer := r.Header.Get("Referer")
|
||||
if referer == "" {
|
||||
referer = "/admin/scanner"
|
||||
dest := "/admin/scanner"
|
||||
if referer := r.Header.Get("Referer"); referer != "" {
|
||||
if u, err := url.Parse(referer); err == nil {
|
||||
if safe := safeRedirectPath(u.Path); safe != "/" {
|
||||
dest = safe
|
||||
}
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, referer, http.StatusFound)
|
||||
http.Redirect(w, r, dest, http.StatusFound)
|
||||
}
|
||||
|
||||
// --- Scanner status ---
|
||||
@@ -223,10 +284,10 @@ func (h *Web) handleAdminScanner(w http.ResponseWriter, r *http.Request) {
|
||||
// --- System settings ---
|
||||
|
||||
func (h *Web) handleAdminSettings(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderAdminSettings(w, r, "", "")
|
||||
h.renderAdminSettings(w, r, "", "", nil)
|
||||
}
|
||||
|
||||
func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash, kind string) {
|
||||
func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash, kind string, photoResults []ArtistPhotoOutcome) {
|
||||
trusted := ""
|
||||
for i, p := range h.Config.ExtAuth.TrustedSources {
|
||||
if i > 0 {
|
||||
@@ -241,6 +302,8 @@ func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash,
|
||||
ExtAuthEnabled bool
|
||||
ExtAuthTrustedSources string
|
||||
ExtAuthHeader string
|
||||
ArtistPhotosEnabled bool
|
||||
ArtistPhotoResults []ArtistPhotoOutcome
|
||||
}{
|
||||
ScanInterval: database.GetSetting(h.DB, "scan_interval", h.Config.ScanInterval.String()),
|
||||
FFmpegPath: database.GetSetting(h.DB, "ffmpeg_path", h.Config.FFmpegPath),
|
||||
@@ -248,6 +311,8 @@ func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash,
|
||||
ExtAuthEnabled: h.Config.ExtAuth.Enabled,
|
||||
ExtAuthTrustedSources: trusted,
|
||||
ExtAuthHeader: h.Config.ExtAuth.UserHeader,
|
||||
ArtistPhotosEnabled: h.Config.EnableArtistPhotos,
|
||||
ArtistPhotoResults: photoResults,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -258,11 +323,52 @@ func (h *Web) handleAdminSettingsUpdate(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
interval := r.FormValue("scan_interval")
|
||||
if _, err := time.ParseDuration(interval); err != nil {
|
||||
h.renderAdminSettings(w, r, "Invalid scan interval: "+err.Error(), "danger")
|
||||
h.renderAdminSettings(w, r, "Invalid scan interval: "+err.Error(), "danger", nil)
|
||||
return
|
||||
}
|
||||
_ = database.SetSetting(h.DB, "scan_interval", interval)
|
||||
_ = database.SetSetting(h.DB, "ffmpeg_path", r.FormValue("ffmpeg_path"))
|
||||
_ = database.SetSetting(h.DB, "ffprobe_path", r.FormValue("ffprobe_path"))
|
||||
h.renderAdminSettings(w, r, "Settings saved. Some changes take effect after a restart.", "success")
|
||||
h.renderAdminSettings(w, r, "Settings saved. Some changes take effect after a restart.", "success", nil)
|
||||
}
|
||||
|
||||
// handleAdminArtistPhotosRefresh runs a synchronous, admin-triggered
|
||||
// fetch of every eligible artist's photo and reports the outcome for
|
||||
// each one — including failures — rather than the silent best-effort
|
||||
// background fetch normally used when an artist's page is viewed.
|
||||
func (h *Web) handleAdminArtistPhotosRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
// Unlike the best-effort background fetch on an artist's page (gated
|
||||
// by EnableArtistPhotos, since that one runs unattended on every
|
||||
// visit), this is an explicit, one-off admin action — it runs
|
||||
// regardless of the config toggle so it doubles as a way to check
|
||||
// what's actually happening (bad network, no confident name match,
|
||||
// an API error) without editing the environment first.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
results, err := h.RefreshAllArtistPhotos(ctx)
|
||||
if err != nil {
|
||||
h.renderAdminSettings(w, r, "Artist photo refresh failed: "+err.Error(), "danger", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var fetched, notFound, skipped, failed int
|
||||
for _, res := range results {
|
||||
switch {
|
||||
case res.Error != "":
|
||||
failed++
|
||||
case res.Status == "fetched":
|
||||
fetched++
|
||||
case res.Status == "no photo found":
|
||||
notFound++
|
||||
default:
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
kind := "success"
|
||||
if failed > 0 {
|
||||
kind = "warning"
|
||||
}
|
||||
flash := fmt.Sprintf("Artist photo refresh complete: %d fetched, %d no photo found, %d skipped, %d failed (see below).",
|
||||
fetched, notFound, skipped, failed)
|
||||
h.renderAdminSettings(w, r, flash, kind, results)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/netguard"
|
||||
)
|
||||
|
||||
// --- Artists ---
|
||||
|
||||
func (h *Web) handleAdminArtistEdit(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
artist, err := database.GetArtist(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.page(w, r, "admin_artist_edit", "artists", "Edit artist: "+artist.Name, artist)
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminArtistUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := database.GetArtist(h.DB, id); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(maxArtistPhotoBytes); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.UpdateArtist(h.DB, id, r.FormValue("name"), r.FormValue("sort_name"), r.FormValue("bio")); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case r.FormValue("remove_photo") == "1":
|
||||
if err := database.SetArtistCoverArt(h.DB, id, sql.NullString{}, false); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
default:
|
||||
if !h.applyPhotoOverride(w, r, "photo_file", "photo_url", func(localPath, contentHash, mimeType string) error {
|
||||
_, err := database.UpsertArtistCoverArt(h.DB, id, "external", localPath, mimeType, contentHash, 0, 0, true)
|
||||
return err
|
||||
}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/artists/"+id, http.StatusFound)
|
||||
}
|
||||
|
||||
// --- Albums ---
|
||||
|
||||
func (h *Web) handleAdminAlbumEdit(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
album, err := database.GetAlbum(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.page(w, r, "admin_album_edit", "albums", "Edit album: "+album.Title, album)
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminAlbumUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := database.GetAlbum(h.DB, id); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(maxArtistPhotoBytes); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.UpdateAlbum(h.DB, id, r.FormValue("title"), r.FormValue("sort_title"),
|
||||
formNullInt(r, "year"), formNullInt(r, "original_year"), r.FormValue("record_label")); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.applyPhotoOverride(w, r, "cover_file", "cover_url", func(localPath, contentHash, mimeType string) error {
|
||||
_, err := database.UpsertAlbumCoverArt(h.DB, id, "external", localPath, "", mimeType, contentHash, 0, 0)
|
||||
return err
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/albums/"+id, http.StatusFound)
|
||||
}
|
||||
|
||||
// --- Songs ---
|
||||
|
||||
func (h *Web) handleAdminSongEdit(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
song, err := database.GetSong(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.page(w, r, "admin_song_edit", "songs", "Edit song: "+song.Title, song)
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminSongUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := database.GetSong(h.DB, id); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
locked := r.FormValue("metadata_locked") == "1"
|
||||
if err := database.UpdateSongMetadata(h.DB, id, r.FormValue("title"), r.FormValue("sort_title"),
|
||||
formNullInt(r, "track_number"), formNullInt(r, "disc_number"), r.FormValue("composer"), r.FormValue("conductor"), locked); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/songs", http.StatusFound)
|
||||
}
|
||||
|
||||
// --- shared helpers ---
|
||||
|
||||
// formNullInt parses a form field as an integer, returning an invalid
|
||||
// sql.NullInt64 when the field is blank or unparsable rather than
|
||||
// erroring the request — the same tolerant behavior as the rest of
|
||||
// VibeSonic's admin forms.
|
||||
func formNullInt(r *http.Request, name string) sql.NullInt64 {
|
||||
raw := strings.TrimSpace(r.FormValue(name))
|
||||
if raw == "" {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
n, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
return sql.NullInt64{Int64: n, Valid: true}
|
||||
}
|
||||
|
||||
// applyPhotoOverride implements the common "upload a file, or give a
|
||||
// URL, or leave the existing photo/cover alone" logic shared by the
|
||||
// artist and album edit forms. store is called with the resulting local
|
||||
// file's path/hash/mime once one is available. Returns false (having
|
||||
// already written an HTTP error response) on failure.
|
||||
func (h *Web) applyPhotoOverride(w http.ResponseWriter, r *http.Request, fileField, urlField string, store func(localPath, contentHash, mimeType string) error) bool {
|
||||
if file, _, err := r.FormFile(fileField); err == nil {
|
||||
defer file.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(file, maxArtistPhotoBytes+1))
|
||||
if err != nil {
|
||||
http.Error(w, "could not read uploaded image", http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
if len(body) > maxArtistPhotoBytes {
|
||||
http.Error(w, "uploaded image exceeds maximum allowed size", http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
localPath, contentHash, mimeType, err := h.storeImageBytes(body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
if err := store(localPath, contentHash, mimeType); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
photoURL := strings.TrimSpace(r.FormValue(urlField))
|
||||
if photoURL == "" {
|
||||
return true
|
||||
}
|
||||
if err := netguard.ValidateURL(photoURL); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
localPath, contentHash, mimeType, err := h.downloadImage(r.Context(), photoURL)
|
||||
if err != nil {
|
||||
http.Error(w, "could not download image: "+err.Error(), http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
if err := store(localPath, contentHash, mimeType); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/netguard"
|
||||
)
|
||||
|
||||
func (h *Web) handleAdminPodcasts(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -35,6 +36,10 @@ func (h *Web) handleAdminPodcastCreate(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/podcasts", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := netguard.ValidateURL(feedURL); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
channel, err := database.CreatePodcastChannel(h.DB, feedURL)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/artistphoto"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/netguard"
|
||||
)
|
||||
|
||||
// maxArtistPhotoBytes bounds a single downloaded artist photo, the same
|
||||
// way internal/podcasts bounds enclosure downloads.
|
||||
const maxArtistPhotoBytes = 8 << 20
|
||||
|
||||
// artistPhotoCacheTTL mirrors internal/subsonic's enrichmentCacheTTL:
|
||||
// artist photos change essentially never, so a long TTL avoids
|
||||
// repeatedly hitting fanart.tv/Wikidata for the same artist.
|
||||
const artistPhotoCacheTTL = 30 * 24 * time.Hour
|
||||
|
||||
var artistPhotoClient = &artistphoto.Client{}
|
||||
|
||||
func (h *Web) artistPhotoDir() string {
|
||||
return filepath.Join(h.Config.DataDir, "artist-photos")
|
||||
}
|
||||
|
||||
// maybeFetchArtistPhoto kicks off a best-effort background fetch of an
|
||||
// artist's photo the first time their page is viewed. It never blocks
|
||||
// the caller and is a no-op unless artist photos are enabled and no
|
||||
// photo (auto-fetched or manually set) is already on file — every
|
||||
// artist is attempted regardless of whether it has a tagged
|
||||
// MusicBrainz ID, since artistphoto.Client can resolve a photo by name
|
||||
// alone via Wikidata.
|
||||
func (h *Web) maybeFetchArtistPhoto(artist *database.Artist) {
|
||||
if !h.Config.EnableArtistPhotos || artist.CoverArtID.Valid || artist.PhotoManual {
|
||||
return
|
||||
}
|
||||
go h.fetchAndStoreArtistPhoto(artist)
|
||||
}
|
||||
|
||||
func (h *Web) fetchAndStoreArtistPhoto(artist *database.Artist) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
if status, err := h.fetchAndStoreArtistPhotoResult(ctx, artist, false); err != nil {
|
||||
h.Log.Warn("artist photo fetch failed", "artist_id", artist.ID, "status", status, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// artistPhotoCacheKey is what a lookup result is cached/looked-up under:
|
||||
// the artist's MusicBrainz ID when tagged, or else a name-derived key
|
||||
// (real MBIDs are UUIDs, so this can never collide with one) — an
|
||||
// artist resolved by name still benefits from the same negative/
|
||||
// positive caching as a tagged one.
|
||||
func artistPhotoCacheKey(artist *database.Artist) string {
|
||||
if artist.MusicBrainzID != "" {
|
||||
return artist.MusicBrainzID
|
||||
}
|
||||
return "name:" + artist.Name
|
||||
}
|
||||
|
||||
// resolveArtistPhotoURL resolves a photo URL for artist, using the
|
||||
// cached result unless bypassCache is set (the manual "refresh all"
|
||||
// admin action bypasses it, so a previous failed/empty lookup doesn't
|
||||
// hide a photo that might exist now).
|
||||
func (h *Web) resolveArtistPhotoURL(ctx context.Context, artist *database.Artist, bypassCache bool) (string, error) {
|
||||
cacheKey := artistPhotoCacheKey(artist)
|
||||
if !bypassCache {
|
||||
if cached, ok := readArtistPhotoCache(h.DB, cacheKey); ok {
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
u, err := artistPhotoClient.FetchPhotoURL(ctx, artist.MusicBrainzID, artist.Name, h.Config.FanartTVAPIKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if raw, err := json.Marshal(struct {
|
||||
URL string `json:"url"`
|
||||
}{u}); err == nil {
|
||||
_ = database.PutCachedExternalMetadata(h.DB, "artist_photo", cacheKey, "combined", string(raw))
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// fetchAndStoreArtistPhotoResult resolves and stores a photo for one
|
||||
// artist, returning a short human-readable status plus the underlying
|
||||
// error (if any) so callers that report to a person (RefreshAllArtistPhotos)
|
||||
// can show exactly what went wrong, not just log-and-forget.
|
||||
func (h *Web) fetchAndStoreArtistPhotoResult(ctx context.Context, artist *database.Artist, bypassCache bool) (status string, err error) {
|
||||
photoURL, err := h.resolveArtistPhotoURL(ctx, artist, bypassCache)
|
||||
if err != nil {
|
||||
return "lookup failed", err
|
||||
}
|
||||
if photoURL == "" {
|
||||
return "no photo found", nil
|
||||
}
|
||||
|
||||
localPath, contentHash, mimeType, err := h.downloadImage(ctx, photoURL)
|
||||
if err != nil {
|
||||
return "download failed", err
|
||||
}
|
||||
if _, err := database.UpsertArtistCoverArt(h.DB, artist.ID, "external", localPath, mimeType, contentHash, 0, 0, false); err != nil {
|
||||
return "store failed", err
|
||||
}
|
||||
return "fetched", nil
|
||||
}
|
||||
|
||||
// ArtistPhotoOutcome is the per-artist result of a bulk photo refresh
|
||||
// (see RefreshAllArtistPhotos), surfaced to an admin so failures are
|
||||
// visible instead of just falling back to the default placeholder with
|
||||
// no explanation.
|
||||
type ArtistPhotoOutcome struct {
|
||||
ArtistID string
|
||||
ArtistName string
|
||||
Status string
|
||||
Error string
|
||||
}
|
||||
|
||||
// RefreshAllArtistPhotos attempts to (re-)fetch a photo for every
|
||||
// artist with no manual photo override, bypassing the cached "nothing
|
||||
// found" result so a previous failure doesn't silently persist
|
||||
// forever. A small worker pool keeps a large library from serializing
|
||||
// one slow network call after another.
|
||||
func (h *Web) RefreshAllArtistPhotos(ctx context.Context) ([]ArtistPhotoOutcome, error) {
|
||||
artists, err := database.ListArtists(h.DB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make([]ArtistPhotoOutcome, len(artists))
|
||||
type job struct {
|
||||
idx int
|
||||
artist *database.Artist
|
||||
}
|
||||
jobs := make(chan job)
|
||||
|
||||
const workers = 4
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := range jobs {
|
||||
results[j.idx] = h.refreshOneArtistPhoto(ctx, j.artist)
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i, a := range artists {
|
||||
jobs <- job{i, a}
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
|
||||
sort.Slice(results, func(i, k int) bool { return results[i].ArtistName < results[k].ArtistName })
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (h *Web) refreshOneArtistPhoto(ctx context.Context, artist *database.Artist) ArtistPhotoOutcome {
|
||||
out := ArtistPhotoOutcome{ArtistID: artist.ID, ArtistName: artist.Name}
|
||||
if artist.PhotoManual {
|
||||
out.Status = "skipped (manual photo set)"
|
||||
return out
|
||||
}
|
||||
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, 120*time.Second)
|
||||
defer cancel()
|
||||
status, err := h.fetchAndStoreArtistPhotoResult(fetchCtx, artist, true)
|
||||
out.Status = status
|
||||
if err != nil {
|
||||
out.Error = err.Error()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readArtistPhotoCache(db *sql.DB, cacheKey string) (string, bool) {
|
||||
dataJSON, fetchedAt, found, err := database.GetCachedExternalMetadata(db, "artist_photo", cacheKey, "combined")
|
||||
if err != nil || !found || time.Since(fetchedAt) > artistPhotoCacheTTL {
|
||||
return "", false
|
||||
}
|
||||
var payload struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(dataJSON), &payload); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return payload.URL, true
|
||||
}
|
||||
|
||||
// downloadImage fetches sourceURL through an SSRF-safe client
|
||||
// (internal/netguard, the same protection used for podcast feeds and
|
||||
// enclosures) and hands the body to storeImageBytes. Used both for
|
||||
// auto-fetched artist photos and admin-supplied photo/cover URLs.
|
||||
func (h *Web) downloadImage(ctx context.Context, sourceURL string) (localPath, contentHash, mimeType string, err error) {
|
||||
if err := netguard.ValidateURL(sourceURL); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
client := netguard.NewSafeHTTPClient(15*time.Second, false)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "VibeSonic/1.0 (+https://git.noroutes.net/NoRoutes/vibetunes)")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", "", fmt.Errorf("artist photo download: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxArtistPhotoBytes+1))
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if len(body) > maxArtistPhotoBytes {
|
||||
return "", "", "", fmt.Errorf("artist photo exceeds maximum allowed size of %d bytes", maxArtistPhotoBytes)
|
||||
}
|
||||
return h.storeImageBytes(body)
|
||||
}
|
||||
|
||||
// storeImageBytes validates that body actually decodes as an image,
|
||||
// then saves it under the artist-photos cache directory keyed by its
|
||||
// content hash (so re-fetching or re-uploading the same bytes reuses
|
||||
// the same file). Used both for artist photos and album cover
|
||||
// overrides — the directory name is a historical artifact, not a
|
||||
// restriction on what's stored there.
|
||||
func (h *Web) storeImageBytes(body []byte) (localPath, contentHash, mimeType string, err error) {
|
||||
if _, _, err := image.Decode(bytes.NewReader(body)); err != nil {
|
||||
return "", "", "", fmt.Errorf("not a valid image: %w", err)
|
||||
}
|
||||
mimeType = http.DetectContentType(body)
|
||||
sum := sha256.Sum256(body)
|
||||
contentHash = hex.EncodeToString(sum[:])[:16]
|
||||
|
||||
dir := h.artistPhotoDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
localPath = filepath.Join(dir, contentHash+extensionForMIME(mimeType))
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
return localPath, contentHash, mimeType, nil
|
||||
}
|
||||
tmp := localPath + ".tmp"
|
||||
if err := os.WriteFile(tmp, body, 0o644); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if err := os.Rename(tmp, localPath); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return localPath, contentHash, mimeType, nil
|
||||
}
|
||||
|
||||
func extensionForMIME(mimeType string) string {
|
||||
switch mimeType {
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
default:
|
||||
return ".jpg"
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func (h *Web) handleBookmarks(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
views = append(views, bookmarkView{Bookmark: b, Song: toSongRow(h.DB, song, albumOf(h.DB, song), user.ID)})
|
||||
views = append(views, bookmarkView{Bookmark: b, Song: toSongRow(h.DB, song, albumOf(h.DB, song), user.ID, user.IsAdmin)})
|
||||
}
|
||||
h.page(w, r, "bookmarks", "bookmarks", "Bookmarks", views)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,10 @@ func (h *Web) handleCoverArt(w http.ResponseWriter, r *http.Request) {
|
||||
// row, extracting embedded artwork to a temp file on demand. isTemp
|
||||
// tells the caller whether it must remove the returned path afterward.
|
||||
func (h *Web) resolveCoverArtSource(ctx context.Context, art *database.CoverArt) (path, contentHash string, isTemp bool, err error) {
|
||||
if art.Source == "folder" {
|
||||
if art.Source == "folder" || art.Source == "external" {
|
||||
// "external" is a locally-downloaded artist photo or an
|
||||
// admin-uploaded/URL-sourced cover override — already a plain
|
||||
// file on disk, same as a folder image.
|
||||
return art.SourcePath, art.ContentHash, false, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
type lyricsLineJSON struct {
|
||||
Text string `json:"text"`
|
||||
StartMs *int `json:"startMs,omitempty"`
|
||||
}
|
||||
|
||||
type lyricsResponseJSON struct {
|
||||
Synced bool `json:"synced"`
|
||||
Lines []lyricsLineJSON `json:"lines"`
|
||||
}
|
||||
|
||||
// handleLyrics serves the web player's lyrics panel: the best available
|
||||
// stored structuredLyrics entry for a song, as line text plus a start
|
||||
// time when the source actually has one. Prefers a synced entry over
|
||||
// an unsynced one so the panel can highlight/click-to-seek whenever
|
||||
// that data exists, but never fabricates timing for an unsynced entry.
|
||||
func (h *Web) handleLyrics(w http.ResponseWriter, r *http.Request) {
|
||||
songID := r.PathValue("id")
|
||||
entries, err := database.GetSongStructuredLyrics(h.DB, songID)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var best *database.StructuredLyricsRow
|
||||
for i := range entries {
|
||||
if best == nil || (entries[i].Synced && !best.Synced) {
|
||||
best = &entries[i]
|
||||
}
|
||||
}
|
||||
|
||||
out := lyricsResponseJSON{Lines: []lyricsLineJSON{}}
|
||||
if best != nil {
|
||||
out.Synced = best.Synced
|
||||
for _, l := range best.Lines {
|
||||
out.Lines = append(out.Lines, lyricsLineJSON{Text: l.Text, StartMs: l.StartMs})
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
func TestLyricsHandlerPrefersSyncedEntry(t *testing.T) {
|
||||
h, db := newTestWeb(t)
|
||||
srv := httptest.NewServer(h.NewRouter())
|
||||
defer srv.Close()
|
||||
jar := map[string]*http.Cookie{}
|
||||
doSetup(t, srv, jar, t.TempDir())
|
||||
|
||||
folder, err := database.CreateMusicFolder(db, "Music", "/music", "music")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mf, err := database.CreateMediaFile(db, folder.ID, filepath.Join("a.mp3"), 100, 0, "h", "mp3", "song", 10, 128, 44100, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
song, err := database.UpsertSong(db, database.UpsertSongInput{MediaFileID: mf.ID, Title: "Song"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.InsertLyrics(db, song.ID, "embedded", "xxx", false, []database.LyricsLineInput{{Text: "Plain line"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := 1500
|
||||
if _, err := database.InsertLyrics(db, song.ID, "external", "en", true, []database.LyricsLineInput{{StartMs: &start, Text: "Synced line"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp := doRequest(t, srv, jar, http.MethodGet, "/player/lyrics/"+song.ID, nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var out lyricsResponseJSON
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, body)
|
||||
}
|
||||
if !out.Synced {
|
||||
t.Errorf("expected the synced entry to be preferred, got synced=false: %+v", out)
|
||||
}
|
||||
if len(out.Lines) != 1 || out.Lines[0].Text != "Synced line" {
|
||||
t.Fatalf("lines = %+v", out.Lines)
|
||||
}
|
||||
if out.Lines[0].StartMs == nil || *out.Lines[0].StartMs != 1500 {
|
||||
t.Errorf("StartMs = %v, want 1500", out.Lines[0].StartMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLyricsHandlerNoLyricsReturnsEmpty(t *testing.T) {
|
||||
h, db := newTestWeb(t)
|
||||
srv := httptest.NewServer(h.NewRouter())
|
||||
defer srv.Close()
|
||||
jar := map[string]*http.Cookie{}
|
||||
doSetup(t, srv, jar, t.TempDir())
|
||||
|
||||
folder, err := database.CreateMusicFolder(db, "Music", "/music", "music")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mf, err := database.CreateMediaFile(db, folder.ID, "b.mp3", 100, 0, "h2", "mp3", "song", 10, 128, 44100, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
song, err := database.UpsertSong(db, database.UpsertSongInput{MediaFileID: mf.ID, Title: "No Lyrics"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp := doRequest(t, srv, jar, http.MethodGet, "/player/lyrics/"+song.ID, nil)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var out lyricsResponseJSON
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, body)
|
||||
}
|
||||
if len(out.Lines) != 0 {
|
||||
t.Errorf("expected no lines, got %+v", out.Lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLyricsHandlerRequiresAuth(t *testing.T) {
|
||||
h, _ := newTestWeb(t)
|
||||
srv := httptest.NewServer(h.NewRouter())
|
||||
defer srv.Close()
|
||||
jar := map[string]*http.Cookie{}
|
||||
resp := doRequest(t, srv, jar, http.MethodGet, "/player/lyrics/sg_whatever", nil)
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatal("expected unauthenticated request to be rejected/redirected, got 200")
|
||||
}
|
||||
}
|
||||
+12
-5
@@ -75,6 +75,7 @@ func (h *Web) handleArtist(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.maybeFetchArtistPhoto(artist)
|
||||
albums, err := database.ListAlbumsByArtist(h.DB, id)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
@@ -85,9 +86,9 @@ func (h *Web) handleArtist(w http.ResponseWriter, r *http.Request) {
|
||||
views = append(views, toAlbumView(h.DB, a))
|
||||
}
|
||||
h.page(w, r, "artist", "artists", artist.Name, struct {
|
||||
Artist *database.Artist
|
||||
Artist ArtistView
|
||||
Albums []AlbumView
|
||||
}{artist, views})
|
||||
}{toArtistView(artist), views})
|
||||
}
|
||||
|
||||
func (h *Web) handleAlbums(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -126,12 +127,14 @@ func (h *Web) handleAlbum(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
userID := ""
|
||||
isAdmin := false
|
||||
if user != nil {
|
||||
userID = user.ID
|
||||
isAdmin = user.IsAdmin
|
||||
}
|
||||
var rows []SongRow
|
||||
for _, s := range songs {
|
||||
rows = append(rows, toSongRow(h.DB, s, album, userID))
|
||||
rows = append(rows, toSongRow(h.DB, s, album, userID, isAdmin))
|
||||
}
|
||||
starred := false
|
||||
if user != nil {
|
||||
@@ -157,12 +160,14 @@ func (h *Web) handleSongs(w http.ResponseWriter, r *http.Request) {
|
||||
songs = songs[:pageSize]
|
||||
}
|
||||
userID := ""
|
||||
isAdmin := false
|
||||
if user != nil {
|
||||
userID = user.ID
|
||||
isAdmin = user.IsAdmin
|
||||
}
|
||||
var rows []SongRow
|
||||
for _, s := range songs {
|
||||
rows = append(rows, toSongRow(h.DB, s, albumOf(h.DB, s), userID))
|
||||
rows = append(rows, toSongRow(h.DB, s, albumOf(h.DB, s), userID, isAdmin))
|
||||
}
|
||||
h.page(w, r, "songs", "songs", "Songs", struct {
|
||||
Songs []SongRow
|
||||
@@ -219,14 +224,16 @@ func (h *Web) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
if songIDRows != nil {
|
||||
defer songIDRows.Close()
|
||||
userID := ""
|
||||
isAdmin := false
|
||||
if user != nil {
|
||||
userID = user.ID
|
||||
isAdmin = user.IsAdmin
|
||||
}
|
||||
for songIDRows.Next() {
|
||||
var id string
|
||||
if songIDRows.Scan(&id) == nil {
|
||||
if song, err := database.GetSong(h.DB, id); err == nil {
|
||||
songRows = append(songRows, toSongRow(h.DB, song, albumOf(h.DB, song), userID))
|
||||
songRows = append(songRows, toSongRow(h.DB, song, albumOf(h.DB, song), userID, isAdmin))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
// handlePlaylistExport streams a playlist out as a standard M3U8 file:
|
||||
// an #EXTM3U header, an #EXTINF duration/title line per track, and the
|
||||
// track's absolute on-disk path (folder root + relative path) so a
|
||||
// re-import against the same library's folders resolves cleanly.
|
||||
func (h *Web) handlePlaylistExport(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id := r.PathValue("id")
|
||||
playlist, err := database.GetPlaylist(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if playlist.OwnerID != user.ID && !playlist.IsPublic {
|
||||
http.Error(w, "403 Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
entries, err := database.ListPlaylistEntries(h.DB, id)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
folders, err := database.ListMusicFolders(h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
folderByID := make(map[string]*database.MusicFolder, len(folders))
|
||||
for _, f := range folders {
|
||||
folderByID[f.ID] = f
|
||||
}
|
||||
|
||||
filename := sanitizeFilename(playlist.Name) + ".m3u8"
|
||||
w.Header().Set("Content-Type", "audio/x-mpegurl; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
|
||||
buf := bufio.NewWriter(w)
|
||||
defer buf.Flush()
|
||||
buf.WriteString("#EXTM3U\n")
|
||||
for _, e := range entries {
|
||||
song, err := database.GetSong(h.DB, e.SongID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mf, err := database.GetMediaFile(h.DB, song.MediaFileID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
folder, ok := folderByID[mf.MusicFolderID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
artist := ""
|
||||
if a := albumOf(h.DB, song); a != nil && a.PrimaryAlbumArtistID.Valid {
|
||||
if art, err := database.GetArtist(h.DB, a.PrimaryAlbumArtistID.String); err == nil {
|
||||
artist = art.Name
|
||||
}
|
||||
}
|
||||
title := song.Title
|
||||
if artist != "" {
|
||||
title = artist + " - " + title
|
||||
}
|
||||
fmt.Fprintf(buf, "#EXTINF:%d,%s\n", int(mf.DurationSeconds), title)
|
||||
buf.WriteString(filepath.Join(folder.Path, mf.RelativePath))
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// handlePlaylistImportForm renders the upload form.
|
||||
func (h *Web) handlePlaylistImportForm(w http.ResponseWriter, r *http.Request) {
|
||||
h.page(w, r, "playlist_import", "playlists", "Import Playlist", nil)
|
||||
}
|
||||
|
||||
// playlistImportResult is the summary shown after an import: how many
|
||||
// M3U entries matched a track in the library versus were skipped.
|
||||
type playlistImportResult struct {
|
||||
Playlist *database.Playlist
|
||||
Matched int
|
||||
Skipped int
|
||||
}
|
||||
|
||||
// handlePlaylistImport accepts an uploaded .m3u/.m3u8 file, resolves each
|
||||
// entry to a song already known to the library (matching by absolute
|
||||
// path against a scanned music folder's root, falling back to a
|
||||
// filename-only match within that folder), and creates a new playlist
|
||||
// from whatever matched. Unmatched lines are reported, not treated as an
|
||||
// error — a partial match is still useful to the user.
|
||||
func (h *Web) handlePlaylistImport(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if err := r.ParseMultipartForm(4 << 20); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, "no file uploaded", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
folders, err := database.ListMusicFolders(h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
|
||||
if name == "" {
|
||||
name = "Imported Playlist"
|
||||
}
|
||||
playlist, err := database.CreatePlaylist(h.DB, user.ID, name, "", false)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
matched, skipped := 0, 0
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
song := resolvePlaylistImportLine(h.DB, folders, line)
|
||||
if song == nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if err := database.AppendPlaylistEntry(h.DB, playlist.ID, song.ID); err != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
matched++
|
||||
}
|
||||
|
||||
h.page(w, r, "playlist_import_result", "playlists", "Import Result", playlistImportResult{
|
||||
Playlist: playlist,
|
||||
Matched: matched,
|
||||
Skipped: skipped,
|
||||
})
|
||||
}
|
||||
|
||||
// resolvePlaylistImportLine matches one M3U entry (an absolute path, or a
|
||||
// path relative to a music folder root) to a known song.
|
||||
func resolvePlaylistImportLine(db *sql.DB, folders []*database.MusicFolder, line string) *database.Song {
|
||||
for _, folder := range folders {
|
||||
rel := line
|
||||
if strings.HasPrefix(line, folder.Path) {
|
||||
rel = strings.TrimPrefix(line, folder.Path)
|
||||
rel = strings.TrimPrefix(rel, string(filepath.Separator))
|
||||
} else if filepath.IsAbs(line) {
|
||||
continue // absolute path under a different folder root: not a match here
|
||||
}
|
||||
mf, err := database.FindMediaFileByPath(db, folder.ID, rel)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
song, err := database.GetSongByMediaFileID(db, mf.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return song
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
name = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '/', '\\', ':', '*', '?', '"', '<', '>', '|':
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "playlist"
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
// seedPlaylistFixture creates a logged-in user with one song in a music
|
||||
// folder, matching the shape seedShareFixture uses elsewhere in this
|
||||
// package.
|
||||
func seedPlaylistFixture(t *testing.T, db *sql.DB) (folderPath string, mediaFile *database.MediaFile, song *database.Song) {
|
||||
t.Helper()
|
||||
folderPath = t.TempDir()
|
||||
folder, err := database.CreateMusicFolder(db, "Music", folderPath, "music")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mf, err := database.CreateMediaFile(db, folder.ID, "artist/album/track.mp3", 100, 0, "hash1", "mp3", "song", 123, 128, 44100, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := database.UpsertSong(db, database.UpsertSongInput{MediaFileID: mf.ID, Title: "Test Track"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return folderPath, mf, s
|
||||
}
|
||||
|
||||
func TestPlaylistExportProducesValidM3U(t *testing.T) {
|
||||
h, db := newTestWeb(t)
|
||||
srv := httptest.NewServer(h.NewRouter())
|
||||
defer srv.Close()
|
||||
|
||||
jar := map[string]*http.Cookie{}
|
||||
doSetup(t, srv, jar, t.TempDir())
|
||||
|
||||
folderPath, mf, song := seedPlaylistFixture(t, db)
|
||||
|
||||
user, err := database.GetUserByUsername(db, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
playlist, err := database.CreatePlaylist(db, user.ID, "My Mix", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.AppendPlaylistEntry(db, playlist.ID, song.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp := doRequest(t, srv, jar, http.MethodGet, "/playlists/"+playlist.ID+"/export", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET export = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
text := string(body)
|
||||
if !strings.HasPrefix(text, "#EXTM3U\n") {
|
||||
t.Fatalf("export missing #EXTM3U header: %q", text)
|
||||
}
|
||||
wantPath := filepath.Join(folderPath, mf.RelativePath)
|
||||
if !strings.Contains(text, wantPath) {
|
||||
t.Fatalf("export = %q, want it to contain track path %q", text, wantPath)
|
||||
}
|
||||
if !strings.Contains(text, "Test Track") {
|
||||
t.Fatalf("export = %q, want it to contain track title", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistExportRequiresOwnershipOrPublic(t *testing.T) {
|
||||
h, db := newTestWeb(t)
|
||||
srv := httptest.NewServer(h.NewRouter())
|
||||
defer srv.Close()
|
||||
|
||||
adminJar := map[string]*http.Cookie{}
|
||||
doSetup(t, srv, adminJar, t.TempDir())
|
||||
seedPlaylistFixture(t, db)
|
||||
|
||||
owner, err := database.CreateUser(db, "owner", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
playlist, err := database.CreatePlaylist(db, owner.ID, "Private", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp := doRequest(t, srv, adminJar, http.MethodGet, "/playlists/"+playlist.ID+"/export", nil)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("GET export of another user's private playlist = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistImportMatchesTracksByPath(t *testing.T) {
|
||||
h, db := newTestWeb(t)
|
||||
srv := httptest.NewServer(h.NewRouter())
|
||||
defer srv.Close()
|
||||
|
||||
jar := map[string]*http.Cookie{}
|
||||
doSetup(t, srv, jar, t.TempDir())
|
||||
|
||||
folderPath, mf, _ := seedPlaylistFixture(t, db)
|
||||
trackPath := filepath.Join(folderPath, mf.RelativePath)
|
||||
|
||||
m3u := "#EXTM3U\n#EXTINF:123,Some Artist - Test Track\n" + trackPath + "\nunmatched/nonexistent.mp3\n"
|
||||
|
||||
token := csrfToken(t, srv, jar, "/playlists/import")
|
||||
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
if err := mw.WriteField("csrf_token", token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fw, err := mw.CreateFormFile("file", "My Import.m3u8")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fw.Write([]byte(m3u)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mw.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, srv.URL+"/playlists/import", &body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
for _, c := range jar {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("POST import = %d, want 200; body: %s", resp.StatusCode, respBody)
|
||||
}
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
text := string(respBody)
|
||||
if !strings.Contains(text, "1") {
|
||||
t.Fatalf("import result page = %q, want it to mention 1 matched track", text)
|
||||
}
|
||||
|
||||
user, err := database.GetUserByUsername(db, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
playlists, err := database.ListPlaylistsByOwner(db, user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(playlists) != 1 {
|
||||
t.Fatalf("ListPlaylistsByOwner = %d playlists, want 1", len(playlists))
|
||||
}
|
||||
entries, err := database.ListPlaylistEntries(db, playlists[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("imported playlist has %d entries, want 1 (one unmatched line should be skipped)", len(entries))
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func (h *Web) handlePlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID))
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID, user.IsAdmin))
|
||||
}
|
||||
h.page(w, r, "playlist", "playlists", playlist.Name, struct {
|
||||
Playlist *database.Playlist
|
||||
@@ -109,7 +109,7 @@ func (h *Web) handleFavorites(w http.ResponseWriter, r *http.Request) {
|
||||
var songs []SongRow
|
||||
for _, id := range songIDs {
|
||||
if s, err := database.GetSong(h.DB, id); err == nil {
|
||||
songs = append(songs, toSongRow(h.DB, s, albumOf(h.DB, s), user.ID))
|
||||
songs = append(songs, toSongRow(h.DB, s, albumOf(h.DB, s), user.ID, user.IsAdmin))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ func (h *Web) handleRecent(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID))
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID, user.IsAdmin))
|
||||
}
|
||||
h.page(w, r, "recent", "recent", "Recently Played", rows)
|
||||
}
|
||||
|
||||
@@ -34,3 +34,25 @@ func (h *Web) handlePodcastDownload(w http.ResponseWriter, r *http.Request) {
|
||||
go func() { _ = h.Podcasts.DownloadEpisode(context.Background(), id) }()
|
||||
http.Redirect(w, r, "/podcasts", http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Web) handlePodcastDownloadAll(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := database.GetPodcastChannel(h.DB, id); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if err := h.Podcasts.DownloadChannel(context.Background(), id); err != nil && h.Log != nil {
|
||||
h.Log.Warn("podcast channel download failed", "channel", id, "err", err)
|
||||
}
|
||||
}()
|
||||
destination := "/podcasts"
|
||||
if next := r.FormValue("next"); next != "" {
|
||||
destination = safeRedirectPath(next)
|
||||
}
|
||||
http.Redirect(w, r, destination, http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -40,8 +40,11 @@ func (h *Web) NewRouter() http.Handler {
|
||||
|
||||
mux.HandleFunc("GET /playlists", h.requireUser(h.handlePlaylists))
|
||||
mux.HandleFunc("POST /playlists", h.requireUser(h.handlePlaylistCreate))
|
||||
mux.HandleFunc("GET /playlists/import", h.requireUser(h.handlePlaylistImportForm))
|
||||
mux.HandleFunc("POST /playlists/import", h.requireUser(h.handlePlaylistImport))
|
||||
mux.HandleFunc("GET /playlists/{id}", h.requireUser(h.handlePlaylist))
|
||||
mux.HandleFunc("POST /playlists/{id}/delete", h.requireUser(h.handlePlaylistDelete))
|
||||
mux.HandleFunc("GET /playlists/{id}/export", h.requireUser(h.handlePlaylistExport))
|
||||
|
||||
mux.HandleFunc("GET /favorites", h.requireUser(h.handleFavorites))
|
||||
mux.HandleFunc("POST /favorites/star", h.requireUser(h.handleFavoriteStar))
|
||||
@@ -50,6 +53,7 @@ func (h *Web) NewRouter() http.Handler {
|
||||
|
||||
mux.HandleFunc("GET /podcasts", h.requireUser(h.handlePodcasts))
|
||||
mux.HandleFunc("POST /podcasts/{id}/download", h.requireUser(h.handlePodcastDownload))
|
||||
mux.HandleFunc("POST /podcasts/{id}/download-all", h.requireUser(h.handlePodcastDownloadAll))
|
||||
|
||||
mux.HandleFunc("GET /radio", h.requireUser(h.handleRadio))
|
||||
mux.HandleFunc("POST /radio", h.requireAdmin(h.handleRadioCreate))
|
||||
@@ -69,6 +73,7 @@ func (h *Web) NewRouter() http.Handler {
|
||||
mux.HandleFunc("POST /settings/sessions/{id}/revoke", h.requireUser(h.handleSessionRevoke))
|
||||
|
||||
mux.HandleFunc("GET /stream/{id}", h.requireUser(h.handleStream))
|
||||
mux.HandleFunc("GET /player/lyrics/{id}", h.requireUser(h.handleLyrics))
|
||||
mux.HandleFunc("GET /cover-art/{id}", h.requireUser(h.handleCoverArt))
|
||||
|
||||
mux.HandleFunc("GET /admin", h.requireAdmin(h.handleAdminRoot))
|
||||
@@ -88,6 +93,7 @@ func (h *Web) NewRouter() http.Handler {
|
||||
mux.HandleFunc("GET /admin/scanner", h.requireAdmin(h.handleAdminScanner))
|
||||
mux.HandleFunc("GET /admin/settings", h.requireAdmin(h.handleAdminSettings))
|
||||
mux.HandleFunc("POST /admin/settings", h.requireAdmin(h.handleAdminSettingsUpdate))
|
||||
mux.HandleFunc("POST /admin/settings/artist-photos/refresh", h.requireAdmin(h.handleAdminArtistPhotosRefresh))
|
||||
|
||||
mux.HandleFunc("GET /admin/podcasts", h.requireAdmin(h.handleAdminPodcasts))
|
||||
mux.HandleFunc("POST /admin/podcasts", h.requireAdmin(h.handleAdminPodcastCreate))
|
||||
@@ -96,6 +102,13 @@ func (h *Web) NewRouter() http.Handler {
|
||||
mux.HandleFunc("POST /admin/podcasts/episodes/{id}/download", h.requireAdmin(h.handleAdminPodcastEpisodeDownload))
|
||||
mux.HandleFunc("POST /admin/podcasts/episodes/{id}/delete", h.requireAdmin(h.handleAdminPodcastEpisodeDelete))
|
||||
|
||||
mux.HandleFunc("GET /admin/artists/{id}/edit", h.requireAdmin(h.handleAdminArtistEdit))
|
||||
mux.HandleFunc("POST /admin/artists/{id}/edit", h.requireAdmin(h.handleAdminArtistUpdate))
|
||||
mux.HandleFunc("GET /admin/albums/{id}/edit", h.requireAdmin(h.handleAdminAlbumEdit))
|
||||
mux.HandleFunc("POST /admin/albums/{id}/edit", h.requireAdmin(h.handleAdminAlbumUpdate))
|
||||
mux.HandleFunc("GET /admin/songs/{id}/edit", h.requireAdmin(h.handleAdminSongEdit))
|
||||
mux.HandleFunc("POST /admin/songs/{id}/edit", h.requireAdmin(h.handleAdminSongUpdate))
|
||||
|
||||
mux.HandleFunc("GET /admin/api-keys", h.requireAdmin(h.handleAdminAPIKeys))
|
||||
mux.HandleFunc("POST /admin/api-keys/{id}/revoke", h.requireAdmin(h.handleAdminAPIKeyRevoke))
|
||||
|
||||
|
||||
@@ -270,3 +270,71 @@ func TestLoginRejectsWrongPassword(t *testing.T) {
|
||||
t.Error("expected login failure message in response body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPermissionsCannotDemoteLastAdmin(t *testing.T) {
|
||||
h, db := newTestWeb(t)
|
||||
srv := httptest.NewServer(h.NewRouter())
|
||||
defer srv.Close()
|
||||
|
||||
adminJar := map[string]*http.Cookie{}
|
||||
doSetup(t, srv, adminJar, t.TempDir())
|
||||
|
||||
admin, err := database.GetUserByUsername(db, "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByUsername: %v", err)
|
||||
}
|
||||
|
||||
token := csrfToken(t, srv, adminJar, "/admin/users/"+admin.ID+"/permissions")
|
||||
resp := doRequest(t, srv, adminJar, http.MethodPost, "/admin/users/"+admin.ID+"/permissions", url.Values{
|
||||
"csrf_token": {token},
|
||||
// "admin" intentionally omitted: attempting to demote the only admin.
|
||||
"stream": {"1"},
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("demote last admin = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
|
||||
got, err := database.GetUserByID(db, admin.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByID: %v", err)
|
||||
}
|
||||
if !got.IsAdmin {
|
||||
t.Fatal("last admin was demoted despite the guard")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPermissionsSetsMusicFolderRestriction(t *testing.T) {
|
||||
h, db := newTestWeb(t)
|
||||
srv := httptest.NewServer(h.NewRouter())
|
||||
defer srv.Close()
|
||||
|
||||
adminJar := map[string]*http.Cookie{}
|
||||
doSetup(t, srv, adminJar, t.TempDir())
|
||||
|
||||
user, err := database.CreateUser(db, "regular", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
folder, err := database.CreateMusicFolder(db, "Music", t.TempDir(), "music")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMusicFolder: %v", err)
|
||||
}
|
||||
|
||||
token := csrfToken(t, srv, adminJar, "/admin/users/"+user.ID+"/permissions")
|
||||
resp := doRequest(t, srv, adminJar, http.MethodPost, "/admin/users/"+user.ID+"/permissions", url.Values{
|
||||
"csrf_token": {token},
|
||||
"music_folders": {folder.ID},
|
||||
"stream": {"1"},
|
||||
})
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("save permissions = %d, want 302", resp.StatusCode)
|
||||
}
|
||||
|
||||
allowed, err := database.AllowedMusicFolderIDs(db, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("AllowedMusicFolderIDs: %v", err)
|
||||
}
|
||||
if len(allowed) != 1 || allowed[0] != folder.ID {
|
||||
t.Fatalf("AllowedMusicFolderIDs = %v, want [%s]", allowed, folder.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func (h *Web) handleSharePage(w http.ResponseWriter, r *http.Request) {
|
||||
if serr != nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), ""))
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), "", false))
|
||||
}
|
||||
|
||||
owner, _ := database.GetUserByID(h.DB, share.OwnerID)
|
||||
|
||||
+18
-5
@@ -10,8 +10,10 @@ import (
|
||||
// scanning details (sql.Null*) out of the .html files.
|
||||
|
||||
type ArtistView struct {
|
||||
ID string
|
||||
Name string
|
||||
ID string
|
||||
Name string
|
||||
Bio string
|
||||
CoverArtID string
|
||||
}
|
||||
|
||||
type AlbumView struct {
|
||||
@@ -26,16 +28,22 @@ type SongRow struct {
|
||||
ID string
|
||||
Title string
|
||||
ArtistName string
|
||||
ArtistID string
|
||||
AlbumTitle string
|
||||
AlbumID string
|
||||
CoverArtID string
|
||||
TrackNumber int64
|
||||
DurationSeconds float64
|
||||
Starred bool
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
func toArtistView(a *database.Artist) ArtistView {
|
||||
return ArtistView{ID: a.ID, Name: a.Name}
|
||||
v := ArtistView{ID: a.ID, Name: a.Name, Bio: a.Bio}
|
||||
if a.CoverArtID.Valid {
|
||||
v.CoverArtID = a.CoverArtID.String
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func toAlbumView(db *sql.DB, a *database.Album) AlbumView {
|
||||
@@ -56,12 +64,14 @@ func toAlbumView(db *sql.DB, a *database.Album) AlbumView {
|
||||
|
||||
// toSongRow builds a display row for a song, given its owning album (may
|
||||
// be nil for albumless tracks) and the current user (for the starred
|
||||
// flag; pass "" userID to skip that lookup).
|
||||
func toSongRow(db *sql.DB, s *database.Song, album *database.Album, userID string) SongRow {
|
||||
// flag; pass "" userID to skip that lookup, and isAdmin=false to hide
|
||||
// the row's "Edit" link).
|
||||
func toSongRow(db *sql.DB, s *database.Song, album *database.Album, userID string, isAdmin bool) SongRow {
|
||||
row := SongRow{
|
||||
ID: s.ID,
|
||||
Title: s.Title,
|
||||
DurationSeconds: 0,
|
||||
IsAdmin: isAdmin,
|
||||
}
|
||||
if s.TrackNumber.Valid {
|
||||
row.TrackNumber = s.TrackNumber.Int64
|
||||
@@ -72,6 +82,9 @@ func toSongRow(db *sql.DB, s *database.Song, album *database.Album, userID strin
|
||||
row.ArtistName += ", " + n
|
||||
}
|
||||
}
|
||||
if id, err := database.PrimarySongArtistID(db, s.ID); err == nil {
|
||||
row.ArtistID = id
|
||||
}
|
||||
if album != nil {
|
||||
row.AlbumTitle = album.Title
|
||||
row.AlbumID = album.ID
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- lyrics_lines needs its own synced start time: a line can be synced
|
||||
-- (has a start time) without necessarily having word-level cues (plain
|
||||
-- LRC vs Enhanced LRC), so the line's timing can't always be derived
|
||||
-- from its cues.
|
||||
ALTER TABLE lyrics_lines ADD COLUMN start_ms INTEGER;
|
||||
@@ -0,0 +1,61 @@
|
||||
-- Artist photos (auto-fetched or manually overridden) reuse the
|
||||
-- existing cover_art table rather than a parallel one, so photo
|
||||
-- resizing/caching/serving (internal/coverart, /cover-art/{id}) works
|
||||
-- unchanged for artists. That requires adding an artist_id column and
|
||||
-- widening the "must belong to something" CHECK, which SQLite can only
|
||||
-- do by rebuilding the table.
|
||||
--
|
||||
-- albums.cover_art_id and podcast_channels.cover_art_id are declared
|
||||
-- "ON DELETE SET NULL" against cover_art(id), and SQLite's DROP TABLE
|
||||
-- performs an implicit DELETE FROM first when foreign_keys=ON — which
|
||||
-- would fire that SET NULL and silently wipe every album's/channel's
|
||||
-- chosen cover on upgrade. Back those columns up and restore them
|
||||
-- after the rebuild.
|
||||
CREATE TEMP TABLE _cover_art_migration_backup_albums AS
|
||||
SELECT id, cover_art_id FROM albums WHERE cover_art_id IS NOT NULL;
|
||||
CREATE TEMP TABLE _cover_art_migration_backup_podcasts AS
|
||||
SELECT id, cover_art_id FROM podcast_channels WHERE cover_art_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE cover_art_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
album_id TEXT REFERENCES albums(id) ON DELETE CASCADE,
|
||||
media_file_id TEXT REFERENCES media_files(id) ON DELETE CASCADE,
|
||||
artist_id TEXT REFERENCES artists(id) ON DELETE CASCADE,
|
||||
source TEXT NOT NULL CHECK (source IN ('embedded','folder','external')),
|
||||
source_path TEXT NOT NULL DEFAULT '',
|
||||
mime_type TEXT NOT NULL DEFAULT '',
|
||||
content_hash TEXT NOT NULL DEFAULT '',
|
||||
width INTEGER NOT NULL DEFAULT 0,
|
||||
height INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
CHECK ((album_id IS NOT NULL) OR (media_file_id IS NOT NULL) OR (artist_id IS NOT NULL))
|
||||
);
|
||||
|
||||
INSERT INTO cover_art_new (id, album_id, media_file_id, artist_id, source, source_path, mime_type, content_hash, width, height, updated_at)
|
||||
SELECT id, album_id, media_file_id, NULL, source, source_path, mime_type, content_hash, width, height, updated_at FROM cover_art;
|
||||
|
||||
DROP TABLE cover_art;
|
||||
ALTER TABLE cover_art_new RENAME TO cover_art;
|
||||
|
||||
CREATE INDEX idx_cover_art_album ON cover_art(album_id);
|
||||
CREATE INDEX idx_cover_art_media_file ON cover_art(media_file_id);
|
||||
CREATE INDEX idx_cover_art_artist ON cover_art(artist_id);
|
||||
|
||||
UPDATE albums SET cover_art_id = (
|
||||
SELECT cover_art_id FROM _cover_art_migration_backup_albums WHERE _cover_art_migration_backup_albums.id = albums.id
|
||||
) WHERE id IN (SELECT id FROM _cover_art_migration_backup_albums);
|
||||
UPDATE podcast_channels SET cover_art_id = (
|
||||
SELECT cover_art_id FROM _cover_art_migration_backup_podcasts WHERE _cover_art_migration_backup_podcasts.id = podcast_channels.id
|
||||
) WHERE id IN (SELECT id FROM _cover_art_migration_backup_podcasts);
|
||||
|
||||
DROP TABLE _cover_art_migration_backup_albums;
|
||||
DROP TABLE _cover_art_migration_backup_podcasts;
|
||||
|
||||
-- Artist bio + photo (auto-fetched or admin-overridden).
|
||||
ALTER TABLE artists ADD COLUMN bio TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE artists ADD COLUMN cover_art_id TEXT REFERENCES cover_art(id);
|
||||
ALTER TABLE artists ADD COLUMN photo_manual INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Lets an admin edit a song's title/track/disc/composer/conductor
|
||||
-- without a later library rescan silently overwriting the edit.
|
||||
ALTER TABLE songs ADD COLUMN metadata_locked INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -178,6 +178,21 @@ body {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.vs-song-art-cell {
|
||||
width: 48px;
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.vs-song-art {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.25rem;
|
||||
object-fit: cover;
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
/* --- Player bar --- */
|
||||
|
||||
.vs-player {
|
||||
@@ -211,19 +226,33 @@ body {
|
||||
}
|
||||
|
||||
.vs-player-title {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.vs-player-artist {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
color: var(--bs-secondary-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.vs-player-title:hover,
|
||||
.vs-player-artist:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.vs-player-art[role="button"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vs-player-controls {
|
||||
@@ -262,6 +291,10 @@ body {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.vs-player-btn.active {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.vs-player-timeline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -286,6 +319,44 @@ body {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.vs-lyrics-panel {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: var(--vs-player-height);
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
background: var(--bs-body-bg);
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
padding: 1rem;
|
||||
z-index: 1029;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vs-lyrics-line {
|
||||
padding: 0.15rem 0;
|
||||
color: var(--bs-secondary-color);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.vs-lyrics-line.vs-lyrics-synced {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vs-lyrics-line.vs-lyrics-synced:hover {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.vs-lyrics-line.vs-lyrics-active {
|
||||
color: var(--bs-body-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vs-lyrics-empty {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* --- Misc --- */
|
||||
|
||||
.vs-star-btn {
|
||||
@@ -310,3 +381,9 @@ body {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
/* Inline page transitions keep the persistent player mounted. */
|
||||
.vs-main[aria-busy="true"] {
|
||||
opacity: .65;
|
||||
transition: opacity 120ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
+342
-8
@@ -26,9 +26,20 @@
|
||||
this.ui = ui;
|
||||
this.queue = [];
|
||||
this.index = -1;
|
||||
this.shuffle = false;
|
||||
this.repeat = "off"; // "off" | "all" | "one"
|
||||
this.shuffleOrder = []; // permutation of queue indices; shufflePos points into it
|
||||
this.shufflePos = -1;
|
||||
try {
|
||||
this.shuffle = localStorage.getItem("vs-shuffle") === "1";
|
||||
var storedRepeat = localStorage.getItem("vs-repeat");
|
||||
if (storedRepeat === "all" || storedRepeat === "one") this.repeat = storedRepeat;
|
||||
} catch (e) {
|
||||
/* storage may be unavailable (private browsing); state just won't persist */
|
||||
}
|
||||
this.audio.addEventListener("timeupdate", this._onTimeUpdate.bind(this));
|
||||
this.audio.addEventListener("loadedmetadata", this._onTimeUpdate.bind(this));
|
||||
this.audio.addEventListener("ended", this.next.bind(this));
|
||||
this.audio.addEventListener("ended", this._onEnded.bind(this));
|
||||
this.audio.addEventListener("play", this._syncPlayIcon.bind(this));
|
||||
this.audio.addEventListener("pause", this._syncPlayIcon.bind(this));
|
||||
}
|
||||
@@ -36,21 +47,82 @@
|
||||
VibeSonicPlayer.prototype.playQueue = function (songs, startIndex) {
|
||||
this.queue = songs;
|
||||
this.index = startIndex || 0;
|
||||
this._reshuffle();
|
||||
this._loadCurrent();
|
||||
this.audio.play().catch(function () {});
|
||||
};
|
||||
|
||||
// _reshuffle rebuilds the shuffle order (a Fisher-Yates permutation of
|
||||
// every queue index except the currently-playing one, which is pinned
|
||||
// to the front) whenever the queue changes or shuffle is toggled on,
|
||||
// so "next" has a fresh random order to draw from without immediately
|
||||
// repeating the track that's already playing.
|
||||
VibeSonicPlayer.prototype._reshuffle = function () {
|
||||
var order = [];
|
||||
for (var i = 0; i < this.queue.length; i++) {
|
||||
if (i !== this.index) order.push(i);
|
||||
}
|
||||
for (var j = order.length - 1; j > 0; j--) {
|
||||
var k = Math.floor(Math.random() * (j + 1));
|
||||
var tmp = order[j];
|
||||
order[j] = order[k];
|
||||
order[k] = tmp;
|
||||
}
|
||||
order.unshift(this.index);
|
||||
this.shuffleOrder = order;
|
||||
this.shufflePos = 0;
|
||||
};
|
||||
|
||||
VibeSonicPlayer.prototype.setShuffle = function (on) {
|
||||
this.shuffle = on;
|
||||
try {
|
||||
localStorage.setItem("vs-shuffle", on ? "1" : "0");
|
||||
} catch (e) {}
|
||||
if (on) this._reshuffle();
|
||||
if (this.ui.shuffleBtn) this.ui.shuffleBtn.classList.toggle("active", on);
|
||||
};
|
||||
|
||||
VibeSonicPlayer.prototype.cycleRepeat = function () {
|
||||
this.repeat = this.repeat === "off" ? "all" : this.repeat === "all" ? "one" : "off";
|
||||
try {
|
||||
localStorage.setItem("vs-repeat", this.repeat);
|
||||
} catch (e) {}
|
||||
this._syncRepeatButton();
|
||||
};
|
||||
|
||||
VibeSonicPlayer.prototype._syncRepeatButton = function () {
|
||||
var btn = this.ui.repeatBtn;
|
||||
if (!btn) return;
|
||||
btn.dataset.repeat = this.repeat;
|
||||
btn.classList.toggle("active", this.repeat !== "off");
|
||||
var icon = btn.querySelector("i");
|
||||
if (icon) icon.className = this.repeat === "one" ? "bi bi-repeat-1" : "bi bi-repeat";
|
||||
btn.title = this.repeat === "off" ? "Repeat: off" : this.repeat === "all" ? "Repeat: all" : "Repeat: one";
|
||||
};
|
||||
|
||||
VibeSonicPlayer.prototype._onEnded = function () {
|
||||
if (this.repeat === "one") {
|
||||
this.audio.currentTime = 0;
|
||||
this.audio.play().catch(function () {});
|
||||
return;
|
||||
}
|
||||
this.next();
|
||||
};
|
||||
|
||||
VibeSonicPlayer.prototype._loadCurrent = function () {
|
||||
var song = this.queue[this.index];
|
||||
if (!song) return;
|
||||
this.audio.src = "/stream/" + encodeURIComponent(song.id);
|
||||
this.ui.title.textContent = song.title || "";
|
||||
this.ui.title.href = song.albumId ? "/albums/" + encodeURIComponent(song.albumId) : "javascript:void(0)";
|
||||
this.ui.artist.textContent = song.artist || "";
|
||||
this.ui.artist.href = song.artistId ? "/artists/" + encodeURIComponent(song.artistId) : "javascript:void(0)";
|
||||
this.ui.art.src = song.cover ? "/cover-art/" + encodeURIComponent(song.cover) + "?size=100" : "/static/img/default-art.svg";
|
||||
this.ui.starBtn.dataset.itemType = "song";
|
||||
this.ui.starBtn.dataset.itemId = song.id;
|
||||
this.ui.starBtn.classList.toggle("active", !!song.starred);
|
||||
this.ui.starBtn.innerHTML = song.starred ? '<i class="bi bi-star-fill"></i>' : '<i class="bi bi-star"></i>';
|
||||
this._loadLyrics(song.id);
|
||||
|
||||
if ("mediaSession" in navigator) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
@@ -61,6 +133,79 @@
|
||||
}
|
||||
};
|
||||
|
||||
// --- Lyrics panel: fetches the real structured lyrics stored for the
|
||||
// current song (internal/database.GetSongStructuredLyrics via
|
||||
// /player/lyrics/{id}), renders them, and — only for lines that
|
||||
// actually carry a start time — supports active-line highlighting and
|
||||
// click-to-seek. Plain lyrics render as static text with no seek
|
||||
// affordance, since there's no timing to seek to. ---
|
||||
|
||||
VibeSonicPlayer.prototype._loadLyrics = function (songId) {
|
||||
var self = this;
|
||||
this.lyricsLines = [];
|
||||
if (!this.ui.lyricsPanel) return;
|
||||
fetch("/player/lyrics/" + encodeURIComponent(songId))
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (data) {
|
||||
// Ignore a response that arrived after the user already skipped
|
||||
// to a different song.
|
||||
if (!data || (self.queue[self.index] || {}).id !== songId) return;
|
||||
self.lyricsLines = data.lines || [];
|
||||
self._renderLyrics();
|
||||
})
|
||||
.catch(function () {
|
||||
self.lyricsLines = [];
|
||||
self._renderLyrics();
|
||||
});
|
||||
};
|
||||
|
||||
VibeSonicPlayer.prototype._renderLyrics = function () {
|
||||
var panel = this.ui.lyricsPanel;
|
||||
if (!panel) return;
|
||||
panel.textContent = "";
|
||||
if (!this.lyricsLines.length) {
|
||||
var empty = document.createElement("div");
|
||||
empty.className = "vs-lyrics-empty";
|
||||
empty.textContent = "No lyrics available for this song.";
|
||||
panel.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
var self = this;
|
||||
this.lyricsLines.forEach(function (line, i) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "vs-lyrics-line";
|
||||
el.textContent = line.text;
|
||||
if (line.startMs != null) {
|
||||
el.classList.add("vs-lyrics-synced");
|
||||
el.dataset.startMs = String(line.startMs);
|
||||
el.addEventListener("click", function () {
|
||||
self.audio.currentTime = line.startMs / 1000;
|
||||
self.audio.play().catch(function () {});
|
||||
});
|
||||
}
|
||||
panel.appendChild(el);
|
||||
});
|
||||
};
|
||||
|
||||
VibeSonicPlayer.prototype._updateActiveLyricLine = function (curMs) {
|
||||
var panel = this.ui.lyricsPanel;
|
||||
if (!panel || !this.lyricsLines.length) return;
|
||||
var activeIndex = -1;
|
||||
for (var i = 0; i < this.lyricsLines.length; i++) {
|
||||
var startMs = this.lyricsLines[i].startMs;
|
||||
if (startMs != null && startMs <= curMs) activeIndex = i;
|
||||
}
|
||||
if (activeIndex === this._activeLyricIndex) return;
|
||||
this._activeLyricIndex = activeIndex;
|
||||
var nodes = panel.querySelectorAll(".vs-lyrics-line");
|
||||
nodes.forEach(function (node, i) {
|
||||
node.classList.toggle("vs-lyrics-active", i === activeIndex);
|
||||
});
|
||||
if (activeIndex >= 0 && !panel.classList.contains("d-none")) {
|
||||
nodes[activeIndex].scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
VibeSonicPlayer.prototype.playPause = function () {
|
||||
if (!this.queue.length) return;
|
||||
if (this.audio.paused) {
|
||||
@@ -71,11 +216,25 @@
|
||||
};
|
||||
|
||||
VibeSonicPlayer.prototype.next = function () {
|
||||
if (this.index + 1 < this.queue.length) {
|
||||
if (!this.queue.length) return;
|
||||
if (this.shuffle) {
|
||||
if (this.shufflePos + 1 < this.shuffleOrder.length) {
|
||||
this.shufflePos++;
|
||||
} else if (this.repeat === "all") {
|
||||
this._reshuffle();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
this.index = this.shuffleOrder[this.shufflePos];
|
||||
} else if (this.index + 1 < this.queue.length) {
|
||||
this.index++;
|
||||
this._loadCurrent();
|
||||
this.audio.play().catch(function () {});
|
||||
} else if (this.repeat === "all") {
|
||||
this.index = 0;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
this._loadCurrent();
|
||||
this.audio.play().catch(function () {});
|
||||
};
|
||||
|
||||
VibeSonicPlayer.prototype.prev = function () {
|
||||
@@ -83,11 +242,17 @@
|
||||
this.audio.currentTime = 0;
|
||||
return;
|
||||
}
|
||||
if (this.index > 0) {
|
||||
if (this.shuffle) {
|
||||
if (this.shufflePos <= 0) return;
|
||||
this.shufflePos--;
|
||||
this.index = this.shuffleOrder[this.shufflePos];
|
||||
} else if (this.index > 0) {
|
||||
this.index--;
|
||||
this._loadCurrent();
|
||||
this.audio.play().catch(function () {});
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
this._loadCurrent();
|
||||
this.audio.play().catch(function () {});
|
||||
};
|
||||
|
||||
VibeSonicPlayer.prototype.seekTo = function (fraction) {
|
||||
@@ -123,6 +288,7 @@
|
||||
if (dur > 0) {
|
||||
this.ui.progress.value = String((cur / dur) * 100);
|
||||
}
|
||||
this._updateActiveLyricLine(cur * 1000);
|
||||
};
|
||||
|
||||
function formatTime(seconds) {
|
||||
@@ -143,7 +309,9 @@
|
||||
id: node.dataset.songId,
|
||||
title: node.dataset.title,
|
||||
artist: node.dataset.artist,
|
||||
artistId: node.dataset.artistId,
|
||||
album: node.dataset.album,
|
||||
albumId: node.dataset.albumId,
|
||||
cover: node.dataset.cover,
|
||||
starred: node.dataset.starred === "1",
|
||||
});
|
||||
@@ -165,8 +333,46 @@
|
||||
progress: document.getElementById("vs-progress"),
|
||||
volumeSlider: document.getElementById("vs-volume"),
|
||||
starBtn: document.getElementById("vs-now-star"),
|
||||
lyricsPanel: document.getElementById("vs-lyrics-panel"),
|
||||
shuffleBtn: document.getElementById("vs-shuffle-btn"),
|
||||
repeatBtn: document.getElementById("vs-repeat-btn"),
|
||||
};
|
||||
var player = new VibeSonicPlayer(audio, ui);
|
||||
player.setShuffle(player.shuffle);
|
||||
player._syncRepeatButton();
|
||||
|
||||
if (ui.shuffleBtn) {
|
||||
ui.shuffleBtn.addEventListener("click", function () {
|
||||
player.setShuffle(!player.shuffle);
|
||||
});
|
||||
}
|
||||
if (ui.repeatBtn) {
|
||||
ui.repeatBtn.addEventListener("click", function () {
|
||||
player.cycleRepeat();
|
||||
});
|
||||
}
|
||||
|
||||
var artModalEl = document.getElementById("vs-player-art-modal");
|
||||
if (ui.art && artModalEl && window.bootstrap) {
|
||||
var artModalImg = document.getElementById("vs-player-art-modal-img");
|
||||
var artModal = new bootstrap.Modal(artModalEl);
|
||||
ui.art.addEventListener("click", function () {
|
||||
var song = player.queue[player.index];
|
||||
artModalImg.src = song && song.cover
|
||||
? "/cover-art/" + encodeURIComponent(song.cover) + "?size=1200"
|
||||
: "/static/img/default-art.svg";
|
||||
artModalImg.alt = song ? (song.title || "") : "";
|
||||
artModal.show();
|
||||
});
|
||||
}
|
||||
|
||||
var lyricsBtn = document.getElementById("vs-lyrics-btn");
|
||||
if (lyricsBtn && ui.lyricsPanel) {
|
||||
lyricsBtn.addEventListener("click", function () {
|
||||
ui.lyricsPanel.classList.toggle("d-none");
|
||||
lyricsBtn.classList.toggle("active");
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("vs-play-btn").addEventListener("click", function () {
|
||||
player.playPause();
|
||||
@@ -286,7 +492,134 @@
|
||||
form.appendChild(input);
|
||||
});
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
form.requestSubmit();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Inline navigation ---
|
||||
// Fetch server-rendered pages and replace only the main content area. The
|
||||
// audio element and player live outside that area, so playback continues
|
||||
// uninterrupted while the URL, title, and active sidebar item still track
|
||||
// the current page normally.
|
||||
function initInlineNavigation() {
|
||||
var pending = null;
|
||||
var currentURL = location.href;
|
||||
history.replaceState({ vsInline: true, scrollY: window.scrollY }, "", currentURL);
|
||||
|
||||
function isLocalHTTP(url) {
|
||||
return (url.protocol === "http:" || url.protocol === "https:") && url.origin === location.origin;
|
||||
}
|
||||
|
||||
function shouldHandleLink(link, ev) {
|
||||
if (!link || ev.defaultPrevented || ev.button !== 0) return false;
|
||||
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return false;
|
||||
if (link.target || link.hasAttribute("download") || link.dataset.noInline != null) return false;
|
||||
var url = new URL(link.href, location.href);
|
||||
if (!isLocalHTTP(url)) return false;
|
||||
if (url.pathname.indexOf("/static/") === 0 || url.pathname.indexOf("/stream/") === 0 ||
|
||||
url.pathname.indexOf("/cover-art/") === 0 || url.pathname.indexOf("/share/") === 0 ||
|
||||
/\/export$/.test(url.pathname)) return false;
|
||||
// Let the browser handle an in-page fragment without a network request.
|
||||
if (url.pathname === location.pathname && url.search === location.search && url.hash) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function setBusy(busy) {
|
||||
var main = document.querySelector(".vs-main");
|
||||
if (!main) return;
|
||||
if (busy) main.setAttribute("aria-busy", "true");
|
||||
else main.removeAttribute("aria-busy");
|
||||
}
|
||||
|
||||
function applyPage(html, responseURL, push, restoreScroll) {
|
||||
var parsed = new DOMParser().parseFromString(html, "text/html");
|
||||
var nextMain = parsed.querySelector(".vs-main");
|
||||
var oldMain = document.querySelector(".vs-main");
|
||||
// Login, setup, shared, and error documents are intentionally full-page.
|
||||
if (!nextMain || !oldMain) return false;
|
||||
|
||||
oldMain.replaceWith(nextMain);
|
||||
document.title = parsed.title || document.title;
|
||||
var tokenScript = parsed.querySelector("script[data-vs-csrf]");
|
||||
if (tokenScript) window.vsCSRFToken = tokenScript.dataset.vsCsrf;
|
||||
|
||||
var activeHref = parsed.querySelector(".vs-sidebar .nav-link.active");
|
||||
document.querySelectorAll(".vs-sidebar .nav-link").forEach(function (link) {
|
||||
link.classList.toggle("active", !!activeHref && link.getAttribute("href") === activeHref.getAttribute("href"));
|
||||
});
|
||||
|
||||
currentURL = responseURL;
|
||||
if (push) history.pushState({ vsInline: true, scrollY: 0 }, "", responseURL);
|
||||
var top = typeof restoreScroll === "number" ? restoreScroll : 0;
|
||||
window.scrollTo(0, top);
|
||||
var autofocus = nextMain.querySelector("[autofocus]");
|
||||
if (autofocus) autofocus.focus({ preventScroll: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
function navigate(url, options) {
|
||||
options = options || {};
|
||||
if (pending) pending.abort();
|
||||
var controller = new AbortController();
|
||||
pending = controller;
|
||||
setBusy(true);
|
||||
var request = {
|
||||
method: options.method || "GET",
|
||||
body: options.body || null,
|
||||
signal: controller.signal,
|
||||
headers: { "X-VibeSonic-Inline": "1" },
|
||||
};
|
||||
return fetch(url, request).then(function (response) {
|
||||
if (!response.ok || !(response.headers.get("Content-Type") || "").includes("text/html")) {
|
||||
throw new Error("not an inline page");
|
||||
}
|
||||
return response.text().then(function (html) {
|
||||
if (!applyPage(html, response.url, options.push !== false, options.restoreScroll)) {
|
||||
location.assign(response.url);
|
||||
}
|
||||
});
|
||||
}).catch(function (err) {
|
||||
if (err.name !== "AbortError") {
|
||||
if ((options.method || "GET") === "GET") location.assign(url);
|
||||
else location.reload();
|
||||
}
|
||||
}).finally(function () {
|
||||
if (pending === controller) {
|
||||
pending = null;
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (ev) {
|
||||
var link = ev.target.closest("a[href]");
|
||||
if (!shouldHandleLink(link, ev)) return;
|
||||
ev.preventDefault();
|
||||
history.replaceState({ vsInline: true, scrollY: window.scrollY }, "", currentURL);
|
||||
navigate(link.href);
|
||||
});
|
||||
|
||||
document.addEventListener("submit", function (ev) {
|
||||
var form = ev.target;
|
||||
if (ev.defaultPrevented || !(form instanceof HTMLFormElement) || form.dataset.noInline != null) return;
|
||||
var url = new URL(form.action || location.href, location.href);
|
||||
if (!isLocalHTTP(url) || url.pathname === "/logout") return;
|
||||
ev.preventDefault();
|
||||
var data = new FormData(form);
|
||||
var method = (form.method || "GET").toUpperCase();
|
||||
if (method === "GET") {
|
||||
url.search = new URLSearchParams(data).toString();
|
||||
navigate(url.href);
|
||||
} else {
|
||||
navigate(url.href, { method: method, body: data });
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("popstate", function (ev) {
|
||||
navigate(location.href, {
|
||||
push: false,
|
||||
restoreScroll: ev.state && typeof ev.state.scrollY === "number" ? ev.state.scrollY : 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -295,5 +628,6 @@
|
||||
var player = initPlayer();
|
||||
initKeyboardShortcuts(player);
|
||||
initStarButtons();
|
||||
initInlineNavigation();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
{{define "admin_album_edit"}}{{template "header" .}}
|
||||
<div class="card" style="max-width: 500px;">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Edit album</h2>
|
||||
<form method="post" action="/admin/albums/{{.Data.ID}}/edit" enctype="multipart/form-data">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Title</label>
|
||||
<input type="text" name="title" class="form-control form-control-sm" value="{{.Data.Title}}" required>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Sort title</label>
|
||||
<input type="text" name="sort_title" class="form-control form-control-sm" value="{{.Data.SortTitle}}">
|
||||
</div>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Year</label>
|
||||
<input type="number" name="year" class="form-control form-control-sm" value="{{if .Data.Year.Valid}}{{.Data.Year.Int64}}{{end}}">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Original year</label>
|
||||
<input type="number" name="original_year" class="form-control form-control-sm" value="{{if .Data.OriginalYear.Valid}}{{.Data.OriginalYear.Int64}}{{end}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Record label</label>
|
||||
<input type="text" name="record_label" class="form-control form-control-sm" value="{{.Data.RecordLabel}}">
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h3 class="h6">Cover art</h3>
|
||||
{{if .Data.CoverArtID.Valid}}
|
||||
<img src="/cover-art/{{.Data.CoverArtID.String}}?size=150" style="width:120px;" class="mb-2" alt="">
|
||||
{{end}}
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Upload a cover</label>
|
||||
<input type="file" name="cover_file" class="form-control form-control-sm" accept="image/*">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">...or set a cover URL</label>
|
||||
<input type="url" name="cover_url" class="form-control form-control-sm" placeholder="https://example.com/cover.jpg">
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
<a href="/albums/{{.Data.ID}}" class="btn btn-sm btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,50 @@
|
||||
{{define "admin_artist_edit"}}{{template "header" .}}
|
||||
<div class="card" style="max-width: 500px;">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Edit artist</h2>
|
||||
<form method="post" action="/admin/artists/{{.Data.ID}}/edit" enctype="multipart/form-data">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" name="name" class="form-control form-control-sm" value="{{.Data.Name}}" required>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Sort name</label>
|
||||
<input type="text" name="sort_name" class="form-control form-control-sm" value="{{.Data.SortName}}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Biography</label>
|
||||
<textarea name="bio" class="form-control form-control-sm" rows="4">{{.Data.Bio}}</textarea>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h3 class="h6">Photo</h3>
|
||||
{{if .Data.CoverArtID.Valid}}
|
||||
<img src="/cover-art/{{.Data.CoverArtID.String}}?size=150" style="width:100px;height:100px;border-radius:50%;object-fit:cover;" class="mb-2" alt="">
|
||||
{{end}}
|
||||
{{if .Data.PhotoManual}}
|
||||
<p class="small text-body-secondary">This photo was set manually and will not be replaced by auto-fetch.</p>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" name="remove_photo" value="1" id="remove_photo">
|
||||
<label class="form-check-label" for="remove_photo">Remove custom photo (revert to auto-fetch)</label>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="small text-body-secondary">No custom photo set{{if not .Data.MusicBrainzID}} (this artist has no MusicBrainz ID, so auto-fetch cannot run){{end}}.</p>
|
||||
{{end}}
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Upload a photo</label>
|
||||
<input type="file" name="photo_file" class="form-control form-control-sm" accept="image/*">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">...or set a photo URL</label>
|
||||
<input type="url" name="photo_url" class="form-control form-control-sm" placeholder="https://example.com/photo.jpg">
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
<a href="/artists/{{.Data.ID}}" class="btn btn-sm btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -22,6 +22,13 @@
|
||||
{{if .Channel.ErrorMessage}}<div class="text-danger small">{{.Channel.ErrorMessage}}</div>{{end}}
|
||||
</div>
|
||||
<div>
|
||||
{{if .Episodes}}
|
||||
<form method="post" action="/podcasts/{{.Channel.ID}}/download-all" class="d-inline">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<input type="hidden" name="next" value="/admin/podcasts">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary"><i class="bi bi-download"></i> Download all</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="post" action="/admin/podcasts/{{.Channel.ID}}/refresh" class="d-inline">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Refresh</button>
|
||||
|
||||
@@ -21,6 +21,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3" style="max-width: 600px;">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Artist photos</h2>
|
||||
{{if .Data.ArtistPhotosEnabled}}
|
||||
<p class="text-secondary small mb-2">
|
||||
Fetch a photo (fanart.tv when an artist has a tagged MusicBrainz ID and a key is configured, otherwise a direct name search against Wikidata/Wikimedia Commons) for every artist with no manually-set photo. This never calls MusicBrainz itself, so it isn't affected by its rate limits — matching by name alone is less precise, though. This bypasses any previously-cached "not found" result, so it's a good way to retry after fixing a config or network issue.
|
||||
</p>
|
||||
{{else}}
|
||||
<p class="text-secondary small mb-2">
|
||||
Automatic background fetching is disabled (<code>VIBESONIC_ENABLE_ARTIST_PHOTOS=false</code>), but this button still runs a one-off fetch — useful for checking why photos aren't showing up.
|
||||
</p>
|
||||
{{end}}
|
||||
<form method="post" action="/admin/settings/artist-photos/refresh">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Fetch all artist photos now</button>
|
||||
</form>
|
||||
{{if .Data.ArtistPhotoResults}}
|
||||
<table class="table table-sm vs-table mt-3 mb-0">
|
||||
<thead><tr><th>Artist</th><th>Status</th><th>Error</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data.ArtistPhotoResults}}
|
||||
<tr>
|
||||
<td>{{.ArtistName}}</td>
|
||||
<td>
|
||||
{{if .Error}}<span class="text-danger">{{.Status}}</span>
|
||||
{{else if eq .Status "fetched"}}<span class="text-success">{{.Status}}</span>
|
||||
{{else}}<span class="text-secondary">{{.Status}}</span>{{end}}
|
||||
</td>
|
||||
<td class="text-danger small">{{.Error}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3" style="max-width: 600px;">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">External authentication</h2>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
{{define "admin_song_edit"}}{{template "header" .}}
|
||||
<div class="card" style="max-width: 500px;">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Edit song</h2>
|
||||
<form method="post" action="/admin/songs/{{.Data.ID}}/edit">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Title</label>
|
||||
<input type="text" name="title" class="form-control form-control-sm" value="{{.Data.Title}}" required>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Sort title</label>
|
||||
<input type="text" name="sort_title" class="form-control form-control-sm" value="{{.Data.SortTitle}}">
|
||||
</div>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Track #</label>
|
||||
<input type="number" name="track_number" class="form-control form-control-sm" value="{{if .Data.TrackNumber.Valid}}{{.Data.TrackNumber.Int64}}{{end}}">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Disc #</label>
|
||||
<input type="number" name="disc_number" class="form-control form-control-sm" value="{{if .Data.DiscNumber.Valid}}{{.Data.DiscNumber.Int64}}{{end}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Composer</label>
|
||||
<input type="text" name="composer" class="form-control form-control-sm" value="{{.Data.Composer}}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Conductor</label>
|
||||
<input type="text" name="conductor" class="form-control form-control-sm" value="{{.Data.Conductor}}">
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="metadata_locked" value="1" id="metadata_locked" {{if .Data.MetadataLocked}}checked{{end}}>
|
||||
<label class="form-check-label" for="metadata_locked">Lock metadata (prevent library rescans from overwriting these fields)</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
<a href="/songs" class="btn btn-sm btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -4,6 +4,10 @@
|
||||
<h2 class="h6">{{.Data.Username}}</h2>
|
||||
<form method="post" action="/admin/users/{{.Data.ID}}/permissions">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="admin" value="1" id="p_admin" {{if .Data.IsAdmin}}checked{{end}}>
|
||||
<label class="form-check-label" for="p_admin"><strong>Administrator</strong></label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="settings" value="1" id="p_settings" {{if .Data.Permissions.Settings}}checked{{end}}>
|
||||
<label class="form-check-label" for="p_settings">Settings</label>
|
||||
@@ -48,8 +52,21 @@
|
||||
<input class="form-check-input" type="checkbox" name="video_conversion" value="1" id="p_video" {{if .Data.Permissions.VideoConversion}}checked{{end}}>
|
||||
<label class="form-check-label" for="p_video">Video Conversion</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
<a href="/admin/users" class="btn btn-sm btn-outline-secondary">Cancel</a>
|
||||
<hr>
|
||||
<h3 class="h6">Music Folder Access</h3>
|
||||
<p class="small text-body-secondary">Leave all unchecked for unrestricted access to every folder.</p>
|
||||
{{range .Data.Folders}}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="music_folders" value="{{.ID}}" id="f_{{.ID}}" {{if index $.Data.AllowedFolders .ID}}checked{{end}}>
|
||||
<label class="form-check-label" for="f_{{.ID}}">{{.Name}}</label>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="small text-body-secondary">No music folders configured.</p>
|
||||
{{end}}
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
<a href="/admin/users" class="btn btn-sm btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary"><i class="bi bi-share"></i> Share</button>
|
||||
</form>
|
||||
{{if and .User .User.IsAdmin}}
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/admin/albums/{{.Data.Album.ID}}/edit"><i class="bi bi-pencil"></i> Edit album</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{template "song_rows" .Data.Songs}}
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{{define "artist"}}{{template "header" .}}
|
||||
<div class="d-flex gap-3 mb-4">
|
||||
<img class="vs-card-art" style="width:180px;border-radius:50%;" src="{{if .Data.Artist.CoverArtID}}/cover-art/{{.Data.Artist.CoverArtID}}?size=400{{else}}/static/img/default-art.svg{{end}}" alt="">
|
||||
<div>
|
||||
<h2 class="h4 mb-1">{{.Data.Artist.Name}}</h2>
|
||||
{{if .Data.Artist.Bio}}<p class="text-secondary mb-2">{{.Data.Artist.Bio}}</p>{{end}}
|
||||
{{if and .User .User.IsAdmin}}
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/admin/artists/{{.Data.Artist.ID}}/edit"><i class="bi bi-pencil"></i> Edit artist</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="vs-grid mb-3">
|
||||
{{range .Data.Albums}}{{template "album_card" .}}{{end}}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
{{define "artist_card"}}
|
||||
<a href="/artists/{{.ID}}" class="vs-card">
|
||||
<img class="vs-card-art" style="border-radius:50%;" src="/static/img/default-art.svg" alt="" loading="lazy">
|
||||
<img class="vs-card-art" style="border-radius:50%;" src="{{if .CoverArtID}}/cover-art/{{.CoverArtID}}?size=300{{else}}/static/img/default-art.svg{{end}}" alt="" loading="lazy">
|
||||
<div class="vs-card-title">{{.Name}}</div>
|
||||
</a>
|
||||
{{end}}
|
||||
@@ -22,15 +22,23 @@
|
||||
data-song-id="{{$s.ID}}"
|
||||
data-title="{{$s.Title}}"
|
||||
data-artist="{{$s.ArtistName}}"
|
||||
data-artist-id="{{$s.ArtistID}}"
|
||||
data-album="{{$s.AlbumTitle}}"
|
||||
data-album-id="{{$s.AlbumID}}"
|
||||
data-cover="{{$s.CoverArtID}}"
|
||||
data-starred="{{if $s.Starred}}1{{else}}0{{end}}">
|
||||
<td class="vs-track-num">{{if $s.TrackNumber}}{{$s.TrackNumber}}{{else}}{{add $i 1}}{{end}}</td>
|
||||
<td class="vs-song-art-cell">
|
||||
<img class="vs-song-art" src="{{if $s.CoverArtID}}/cover-art/{{$s.CoverArtID}}?size=80{{else}}/static/img/default-art.svg{{end}}" alt="" loading="lazy">
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold">{{$s.Title}}</div>
|
||||
{{if $s.AlbumTitle}}<div class="text-secondary small">{{$s.ArtistName}} — {{$s.AlbumTitle}}</div>{{else}}<div class="text-secondary small">{{$s.ArtistName}}</div>{{end}}
|
||||
</td>
|
||||
<td class="text-end text-secondary small">{{fmtDuration $s.DurationSeconds}}</td>
|
||||
<td class="text-end text-secondary small">
|
||||
{{fmtDuration $s.DurationSeconds}}
|
||||
{{if $s.IsAdmin}}<a href="/admin/songs/{{$s.ID}}/edit" title="Edit song" class="text-secondary ms-2"><i class="bi bi-pencil"></i></a>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{{if .User}}{{template "player"}}{{end}}
|
||||
{{if .User}}{{template "player"}}{{template "player_art_modal"}}{{end}}
|
||||
{{template "shortcuts_modal"}}
|
||||
{{template "confirm_delete_modal"}}
|
||||
|
||||
@@ -15,23 +15,27 @@
|
||||
|
||||
{{define "player"}}
|
||||
<audio id="vs-audio" preload="metadata"></audio>
|
||||
<div id="vs-lyrics-panel" class="vs-lyrics-panel d-none"></div>
|
||||
<div class="vs-player">
|
||||
<img id="vs-player-art" class="vs-player-art" src="/static/img/default-art.svg" alt="">
|
||||
<img id="vs-player-art" class="vs-player-art" src="/static/img/default-art.svg" alt="" role="button" title="View larger photo">
|
||||
<div class="vs-player-meta">
|
||||
<div id="vs-player-title" class="vs-player-title">Nothing playing</div>
|
||||
<div id="vs-player-artist" class="vs-player-artist"></div>
|
||||
<a id="vs-player-title" class="vs-player-title" href="javascript:void(0)">Nothing playing</a>
|
||||
<a id="vs-player-artist" class="vs-player-artist" href="javascript:void(0)"></a>
|
||||
</div>
|
||||
<div class="vs-player-controls">
|
||||
<div class="vs-player-buttons">
|
||||
<button id="vs-shuffle-btn" type="button" class="vs-player-btn" title="Shuffle"><i class="bi bi-shuffle"></i></button>
|
||||
<button id="vs-prev-btn" type="button" class="vs-player-btn" title="Previous (J)"><i class="bi bi-skip-start-fill"></i></button>
|
||||
<button id="vs-play-btn" type="button" class="vs-player-btn vs-play" title="Play/Pause (Space)"><i class="bi bi-play-fill"></i></button>
|
||||
<button id="vs-next-btn" type="button" class="vs-player-btn" title="Next (L)"><i class="bi bi-skip-end-fill"></i></button>
|
||||
<button id="vs-repeat-btn" type="button" class="vs-player-btn" title="Repeat: off" data-repeat="off"><i class="bi bi-repeat"></i></button>
|
||||
</div>
|
||||
<div class="vs-player-timeline">
|
||||
<span id="vs-current-time" class="vs-player-time">0:00</span>
|
||||
<input id="vs-progress" type="range" class="form-range" min="0" max="100" value="0">
|
||||
<span id="vs-duration" class="vs-player-time">0:00</span>
|
||||
</div>
|
||||
<button id="vs-lyrics-btn" type="button" class="vs-player-btn" title="Lyrics"><i class="bi bi-quote"></i></button>
|
||||
<button id="vs-now-star" type="button" class="vs-star-btn" data-star-toggle data-item-type="song" data-item-id="" title="Favorite">
|
||||
<i class="bi bi-star"></i>
|
||||
</button>
|
||||
@@ -43,6 +47,17 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "player_art_modal"}}
|
||||
<div class="modal fade" id="vs-player-art-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content bg-transparent border-0">
|
||||
<button type="button" class="btn-close btn-close-white ms-auto me-2 mt-2" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
<img id="vs-player-art-modal-img" src="" alt="" class="w-100 rounded">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "shortcuts_modal"}}
|
||||
<div class="modal fade" id="vs-shortcuts-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<script>window.vsCSRFToken = {{.CSRFToken}};</script>
|
||||
<script data-vs-csrf="{{.CSRFToken}}">window.vsCSRFToken = {{.CSRFToken}};</script>
|
||||
<header class="vs-topbar d-flex align-items-center justify-content-between">
|
||||
<a href="/" class="vs-brand d-flex align-items-center gap-2">
|
||||
<i class="bi bi-vinyl-fill"></i>
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
<h2 class="h5 mb-0">{{.Data.Playlist.Name}}</h2>
|
||||
{{if .Data.Playlist.Description}}<div class="text-secondary small">{{.Data.Playlist.Description}}</div>{{end}}
|
||||
</div>
|
||||
{{if .Data.Owned}}
|
||||
<form method="post" action="/playlists/{{.Data.Playlist.ID}}/delete" onsubmit="return confirm('Delete this playlist?');">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm"><i class="bi bi-trash"></i> Delete</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/playlists/{{.Data.Playlist.ID}}/export" class="btn btn-outline-secondary btn-sm"><i class="bi bi-download"></i> Export M3U</a>
|
||||
{{if .Data.Owned}}
|
||||
<form method="post" action="/playlists/{{.Data.Playlist.ID}}/delete" onsubmit="return confirm('Delete this playlist?');">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm"><i class="bi bi-trash"></i> Delete</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{if .Data.Songs}}{{template "song_rows" .Data.Songs}}{{else}}<p class="text-secondary">This playlist is empty.</p>{{end}}
|
||||
{{template "footer" .}}{{end}}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{{define "playlist_import"}}{{template "header" .}}
|
||||
<h2 class="h5 mb-3">Import Playlist</h2>
|
||||
<form method="post" action="/playlists/import" enctype="multipart/form-data" class="mb-3" style="max-width: 480px;">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<div class="mb-2">
|
||||
<input type="file" name="file" class="form-control" accept=".m3u,.m3u8,audio/x-mpegurl" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-upload"></i> Import</button>
|
||||
<a href="/playlists" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</form>
|
||||
<p class="text-secondary small">Accepts an M3U/M3U8 file. Tracks are matched by file path against your scanned music folders; a new playlist is created from the tracks that match.</p>
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{define "playlist_import_result"}}{{template "header" .}}
|
||||
<h2 class="h5 mb-3">Import Complete</h2>
|
||||
<p>
|
||||
Created playlist <a href="/playlists/{{.Data.Playlist.ID}}">{{.Data.Playlist.Name}}</a>
|
||||
with <strong>{{.Data.Matched}}</strong> matched track{{if ne .Data.Matched 1}}s{{end}}
|
||||
{{if .Data.Skipped}}({{.Data.Skipped}} entr{{if eq .Data.Skipped 1}}y{{else}}ies{{end}} could not be matched to a track in your library){{end}}.
|
||||
</p>
|
||||
<a href="/playlists/{{.Data.Playlist.ID}}" class="btn btn-primary btn-sm">View Playlist</a>
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -1,5 +1,5 @@
|
||||
{{define "playlists"}}{{template "header" .}}
|
||||
<form method="post" action="/playlists" class="row g-2 mb-4" style="max-width: 480px;">
|
||||
<form method="post" action="/playlists" class="row g-2 mb-2" style="max-width: 480px;">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<div class="col-8">
|
||||
<input type="text" name="name" class="form-control" placeholder="New playlist name" required>
|
||||
@@ -8,6 +8,9 @@
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="bi bi-plus-lg"></i> Create</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mb-4">
|
||||
<a href="/playlists/import" class="btn btn-outline-secondary btn-sm"><i class="bi bi-upload"></i> Import M3U</a>
|
||||
</div>
|
||||
|
||||
{{if .Data}}
|
||||
<div class="list-group">
|
||||
|
||||
@@ -3,7 +3,15 @@
|
||||
{{range .Data}}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">{{.Channel.Title}}</h2>
|
||||
<div class="d-flex align-items-center justify-content-between gap-2">
|
||||
<h2 class="h6 mb-0">{{.Channel.Title}}</h2>
|
||||
{{if .Episodes}}
|
||||
<form method="post" action="/podcasts/{{.Channel.ID}}/download-all">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary"><i class="bi bi-download"></i> Download all episodes</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
<p class="text-secondary small">{{.Channel.Description}}</p>
|
||||
<table class="table table-sm vs-table mb-0">
|
||||
<tbody>
|
||||
|
||||
Reference in New Issue
Block a user