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
|
||||||
|
|
||||||
VibeSonic is a lightweight, self-hosted music/media server written in Go.
|
VibeSonic is a lightweight, self-hosted music/media server written in
|
||||||
It follows the same philosophy as other small `Vibe*` tools: a single Go
|
Go. Single binary, SQLite storage, server-rendered Bootstrap 5
|
||||||
binary, SQLite storage, server-rendered Bootstrap 5 templates, dark mode
|
templates, dark mode by default, minimal JavaScript, no external
|
||||||
by default, minimal JavaScript, and no external services (no Redis, no
|
services (no Redis, no message broker, no microservices).
|
||||||
message broker, no microservices, nothing Kubernetes-specific).
|
|
||||||
|
|
||||||
**This is Phase 2 of 4.** Phase 1 built the foundation (config, schema,
|
It implements the **original Subsonic REST API through version
|
||||||
auth, scanner, metadata, streaming, cover art, web UI shell). Phase 2
|
1.16.1**, in full, plus a set of **OpenSubsonic extensions**, so it
|
||||||
adds a **complete implementation of the original Subsonic REST API
|
works with existing Subsonic/OpenSubsonic client apps, alongside a
|
||||||
through version 1.16.1** under `/rest/*` — every endpoint, backed by
|
complete server-rendered web UI for browser use. See
|
||||||
real data, including transcoding, HLS, a real mpv-backed jukebox, and a
|
|
||||||
real podcast subsystem. See
|
|
||||||
[`docs/subsonic-compatibility.md`](docs/subsonic-compatibility.md) for
|
[`docs/subsonic-compatibility.md`](docs/subsonic-compatibility.md) for
|
||||||
the full endpoint-by-endpoint matrix. OpenSubsonic extensions are not
|
the endpoint-by-endpoint matrix and
|
||||||
yet implemented (Phase 3).
|
[`docs/client-compatibility.md`](docs/client-compatibility.md) for
|
||||||
|
real-client testing status.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Phase 1: foundation
|
- SQLite storage (WAL mode, foreign keys, versioned migrations)
|
||||||
|
- Native authentication (Argon2id, HTTP-only session cookies, CSRF) and
|
||||||
- SQLite storage (WAL mode, foreign keys, versioned migrations) with a
|
optional Navidrome-style external authentication via a trusted
|
||||||
schema already modeling artists, albums, songs, genres, contributors,
|
reverse-proxy header, gated on the real TCP peer address — see
|
||||||
playlists, favorites, ratings, scrobbles, playback history, bookmarks,
|
[`docs/external-auth.md`](docs/external-auth.md)
|
||||||
play queues, shares, podcasts, internet radio, and settings
|
- A library scanner: multiple music folders, incremental rescans,
|
||||||
- Native authentication: Argon2id password hashing, HTTP-only signed
|
new/changed/deleted/renamed detection, bounded worker pool
|
||||||
session cookies, CSRF-protected forms, session revocation
|
- Real metadata extraction via `ffprobe` (multi-artist/genre,
|
||||||
- Optional Navidrome-style external authentication via a trusted reverse
|
MusicBrainz IDs, ReplayGain, embedded artwork/lyrics, sort fields)
|
||||||
proxy header, gated on the real TCP peer address (never on
|
- Direct streaming with HTTP range support, plus real ffmpeg-backed
|
||||||
spoofable `X-Forwarded-For`/`X-Real-IP`/`Forwarded` headers)
|
transcoding (mp3/opus/aac) and HLS segmenting, bounded concurrency,
|
||||||
- A library scanner supporting multiple music folders, incremental
|
cancelled on client disconnect
|
||||||
rescans, new/changed/deleted/renamed file detection, and a bounded
|
- Structured lyrics: plain, line-synced, and (where the source
|
||||||
worker pool — never a blind full rebuild
|
provides it) word/syllable-level timing, exposed via the
|
||||||
- Real metadata extraction via `ffprobe` (multi-artist, multi-genre,
|
`songLyrics` OpenSubsonic extension and rendered in the web player
|
||||||
MusicBrainz IDs, ReplayGain, embedded artwork/lyrics, sort fields, and
|
with click-to-seek and active-line highlighting
|
||||||
more)
|
- Sonic similarity: real audio-feature analysis (tempo, loudness,
|
||||||
- Stable, opaque, URL-safe IDs that survive restarts and rescans
|
spectral shape, chroma) computed during scanning, used for
|
||||||
- Direct media streaming with HTTP range request support, correct MIME
|
similar-track lookups and path-finding between songs
|
||||||
types, and no full-file buffering
|
- Optional MusicBrainz/Cover Art Archive metadata enrichment (off by
|
||||||
- Cover art discovery (embedded artwork or folder images) with a
|
default — VibeSonic works fully offline otherwise)
|
||||||
resized-image disk cache
|
- Podcasts (RSS subscriptions, bounded background downloads, restart
|
||||||
- A server-rendered, dark-mode, keyboard-friendly web UI: browsing,
|
recovery), Internet radio stations, shares (public, no-login-required
|
||||||
search, playlists, favorites, a persistent bottom player, and an admin
|
links), bookmarks, and multi-device play queues
|
||||||
section (users, music folders, scanner status, system settings)
|
- 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`
|
- `/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
|
## 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"
|
available — without it, `jukeboxControl` returns a clear "unavailable"
|
||||||
error rather than pretending to work.
|
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
|
### Docker
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -92,10 +83,14 @@ docker compose up -d
|
|||||||
|
|
||||||
See [`docker-compose.yml`](docker-compose.yml) — mount your music
|
See [`docker-compose.yml`](docker-compose.yml) — mount your music
|
||||||
directory read-only at `/music` and set `VIBESONIC_SESSION_SECRET`.
|
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
|
### 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
|
## 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/architecture.md`](docs/architecture.md) — how the pieces fit together
|
||||||
- [`docs/configuration.md`](docs/configuration.md) — every environment variable
|
- [`docs/configuration.md`](docs/configuration.md) — every environment variable
|
||||||
- [`docs/external-auth.md`](docs/external-auth.md) — trusted reverse-proxy authentication
|
- [`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
|
## 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_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_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_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
|
## 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
|
enabled, unless their account has no password hash set (auto-created
|
||||||
external users start this way).
|
external users start this way).
|
||||||
|
|
||||||
In later phases, `/rest/*` (the native Subsonic API) will authenticate
|
`/rest/*` (the Subsonic/OpenSubsonic API) never consults external auth
|
||||||
exclusively via Subsonic's own token/API-key scheme and must never be
|
at all: it authenticates exclusively via Subsonic's own token/salt,
|
||||||
affected by external auth's browser-cookie flow.
|
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
|
# Subsonic API compatibility
|
||||||
|
|
||||||
VibeSonic implements the **complete original Subsonic REST API through
|
VibeSonic implements the **complete original Subsonic REST API through
|
||||||
version 1.16.1** as of Phase 2. Every endpoint below is backed by real
|
version 1.16.1** (Phase 2) plus a growing set of **OpenSubsonic
|
||||||
data from VibeSonic's own database/scanner/streaming/transcoding/jukebox
|
extensions** (Phase 3). Every endpoint below is backed by real data
|
||||||
subsystems — none of them are stub handlers that return an empty
|
from VibeSonic's own database/scanner/streaming/transcoding/jukebox/
|
||||||
successful response.
|
audio-analysis subsystems — none of them are stub handlers that return
|
||||||
|
an empty successful response. `getOpenSubsonicExtensions` only ever
|
||||||
OpenSubsonic extensions are **not yet implemented** (planned for Phase
|
advertises an extension once its handler and tests exist and pass; see
|
||||||
3), except where the underlying architecture already needed to make a
|
[OpenSubsonic extensions](#opensubsonic-extensions) below for the full
|
||||||
choice (e.g. opaque string IDs almost everywhere) that happens to also
|
list and what backs each one.
|
||||||
suit OpenSubsonic later.
|
|
||||||
|
|
||||||
## How to read this table
|
## How to read this table
|
||||||
|
|
||||||
@@ -56,11 +55,11 @@ suit OpenSubsonic later.
|
|||||||
| `getSong` | 1.8.0 | ✅ | — | ✅ | ✅ | ✅ (folder restriction) | |
|
| `getSong` | 1.8.0 | ✅ | — | ✅ | ✅ | ✅ (folder restriction) | |
|
||||||
| `getVideos` | 1.8.0 | ✅ | — | ✅ | — | — | Real: lists media files the scanner tagged `kind=video`. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `getArtistInfo2` | 1.11.0 | ✅ | — | ✅ | — | — | Same as above, ID3 form. |
|
||||||
| `getAlbumInfo` | 1.14.0 | ⚠️ | — | — | — | — | Same reasoning as `getArtistInfo`. |
|
| `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. |
|
| `getAlbumInfo2` | 1.14.0 | ✅ | — | ✅ | — | — | Same. |
|
||||||
| `getSimilarSongs` | 1.11.0 | ✅ | — | — | — | — | Real, local genre-overlap heuristic (song/album/artist seed), not fabricated data. |
|
| `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. |
|
| `getSimilarSongs2` | 1.11.0 | ✅ | — | — | — | — | ID3 (artist-seeded) form. |
|
||||||
| `getTopSongs` | 1.13.0 | ✅ | — | ✅ | — | — | Ranked by real aggregate play count across all users. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `getAvatar` | 1.8.0 | ⚠️ | — | — | — | — | Honestly 404s — VibeSonic has no user-avatar feature in Phase 1/2. |
|
||||||
|
|
||||||
## Transcoding
|
## 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
|
## Authentication
|
||||||
|
|
||||||
Both original Subsonic authentication modes are implemented in
|
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
|
All defined in `internal/subsonic/errors.go`: 0 (generic), 10 (missing
|
||||||
parameter), 20 (client must upgrade), 30 (server must upgrade, reserved
|
parameter), 20 (client must upgrade), 30 (server must upgrade, reserved
|
||||||
— VibeSonic never needs to require a newer client), 40 (wrong
|
— VibeSonic never needs to require a newer client), 40 (wrong
|
||||||
credentials), 41 (token auth unsupported for this account), 50
|
credentials), 41 (token auth unsupported for this account), 42 (auth
|
||||||
(unauthorized for the operation), 60 (license/trial — never returned),
|
mechanism not supported), 43 (conflicting auth mechanisms — e.g. both
|
||||||
70 (not found).
|
`apiKey` and `u` given), 44 (invalid/revoked API key), 50 (unauthorized
|
||||||
|
for the operation), 60 (license/trial — never returned), 70 (not
|
||||||
|
found).
|
||||||
|
|
||||||
## Web UI
|
## Web UI
|
||||||
|
|
||||||
Newly exposed through the Bootstrap UI in Phase 2: Podcasts (`/podcasts`,
|
Newly exposed through the Bootstrap UI in Phase 2: Podcasts (`/podcasts`,
|
||||||
admin at `/admin/podcasts`), Internet Radio (`/radio`), Shares
|
admin at `/admin/podcasts`), Internet Radio (`/radio`), Shares
|
||||||
(`/shares`, plus the public `/share/{token}` page), Bookmarks
|
(`/shares`, plus the public `/share/{token}` page), Bookmarks
|
||||||
(`/bookmarks`), and per-user Subsonic permission editing
|
(`/bookmarks`), per-user Subsonic permission editing
|
||||||
(`/admin/users/{id}/permissions`). The admin Jukebox page
|
(`/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
|
(`/admin/jukebox`) shows live status and emergency stop/clear; building
|
||||||
a jukebox playlist is done from a Subsonic client via `jukeboxControl`,
|
a jukebox playlist is done from a Subsonic client via `jukeboxControl`,
|
||||||
which is the actual protocol surface for that feature.
|
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}
|
prober := &metadata.Prober{FFprobePath: cfg.FFprobePath}
|
||||||
sc := scanner.New(db, prober, log)
|
sc := scanner.New(db, prober, log)
|
||||||
|
sc.FFmpegPath = cfg.FFmpegPath
|
||||||
|
|
||||||
coverCache := filepath.Join(cfg.DataDir, "cache", "covers")
|
coverCache := filepath.Join(cfg.DataDir, "cache", "covers")
|
||||||
covers := coverart.New(coverCache, cfg.FFmpegPath)
|
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)
|
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 {
|
if err := podcastMgr.RecoverInterruptedDownloads(); err != nil {
|
||||||
log.Warn("podcast download recovery failed", "err", err)
|
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())
|
mux.Handle("/", a.Web.NewRouter())
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: a.Cfg.ListenAddr,
|
Addr: a.Cfg.ListenAddr,
|
||||||
Handler: mux,
|
Handler: mux,
|
||||||
|
ReadHeaderTimeout: 15 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
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
|
SessionSecret string
|
||||||
CookieSecure bool
|
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
|
ExtAuth ExtAuthConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +79,17 @@ func Load() (Config, error) {
|
|||||||
FFprobePath: envOr("VIBESONIC_FFPROBE_PATH", "ffprobe"),
|
FFprobePath: envOr("VIBESONIC_FFPROBE_PATH", "ffprobe"),
|
||||||
MPVPath: envOr("VIBESONIC_MPV_PATH", "mpv"),
|
MPVPath: envOr("VIBESONIC_MPV_PATH", "mpv"),
|
||||||
SessionSecret: os.Getenv("VIBESONIC_SESSION_SECRET"),
|
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"))
|
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" {
|
if cfg.DBPath != "/var/lib/vibesonic/vibesonic.db" {
|
||||||
t.Errorf("DBPath = %q, want default under DataDir", cfg.DBPath)
|
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 {
|
if cfg.ExtAuth.Enabled {
|
||||||
t.Errorf("ExtAuth.Enabled = true, want false by default")
|
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) {
|
func TestLoadMusicDirs(t *testing.T) {
|
||||||
withEnv(t, map[string]string{"VIBESONIC_MUSIC_DIRS": "/music:/music-lossless: /audiobooks "})
|
withEnv(t, map[string]string{"VIBESONIC_MUSIC_DIRS": "/music:/music-lossless: /audiobooks "})
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
_ "image/gif"
|
_ "image/gif"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -19,6 +20,12 @@ import (
|
|||||||
"golang.org/x/image/draw"
|
"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
|
// commonFolderImageNames are checked, in priority order, for a
|
||||||
// folder-level cover image when a file has no embedded artwork.
|
// folder-level cover image when a file has no embedded artwork.
|
||||||
var commonFolderImageNames = []string{
|
var commonFolderImageNames = []string{
|
||||||
@@ -70,7 +77,11 @@ func (s *Store) ExtractEmbedded(ctx context.Context, mediaPath string, streamInd
|
|||||||
"-map", fmt.Sprintf("0:%d", streamIndex),
|
"-map", fmt.Sprintf("0:%d", streamIndex),
|
||||||
"-frames:v", "1",
|
"-frames:v", "1",
|
||||||
"-c:v", "copy",
|
"-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 {
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
os.Remove(tmp.Name())
|
os.Remove(tmp.Name())
|
||||||
@@ -112,6 +123,15 @@ func (s *Store) GetOrCreate(sourcePath, contentHash string, size int) (string, e
|
|||||||
}
|
}
|
||||||
defer src.Close()
|
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)
|
img, _, err := image.Decode(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("decoding image: %w", err)
|
return "", fmt.Errorf("decoding image: %w", err)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package coverart
|
package coverart
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
@@ -9,6 +10,33 @@ import (
|
|||||||
"testing"
|
"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) {
|
func writeTestJPEG(t *testing.T, path string, w, h int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
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
|
ID string
|
||||||
AlbumID sql.NullString
|
AlbumID sql.NullString
|
||||||
MediaFileID sql.NullString
|
MediaFileID sql.NullString
|
||||||
|
ArtistID sql.NullString
|
||||||
Source string
|
Source string
|
||||||
SourcePath string
|
SourcePath string
|
||||||
MimeType string
|
MimeType string
|
||||||
@@ -19,7 +20,7 @@ type CoverArt struct {
|
|||||||
Height int
|
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) {
|
func GetCoverArt(db *sql.DB, id string) (*CoverArt, error) {
|
||||||
return scanCoverArt(db.QueryRow(coverArtSelect+` WHERE id = ?`, id))
|
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) {
|
func scanCoverArt(row *sql.Row) (*CoverArt, error) {
|
||||||
var c CoverArt
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -71,6 +72,36 @@ func UpsertAlbumCoverArt(db *sql.DB, albumID, source, sourcePath, mediaFileID, m
|
|||||||
return GetCoverArt(db, id)
|
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 {
|
func nullEmptyStr(s string) any {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return nil
|
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
|
Name string
|
||||||
SortName string
|
SortName string
|
||||||
MusicBrainzID 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
|
// 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) {
|
func GetArtist(db *sql.DB, id string) (*Artist, error) {
|
||||||
var a Artist
|
return scanArtist(db.QueryRow(artistSelect+` WHERE id = ?`, id))
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListArtists(db *sql.DB) ([]*Artist, error) {
|
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
|
JOIN album_artists aa ON aa.artist_id = a.id
|
||||||
ORDER BY COALESCE(NULLIF(a.sort_name, ''), a.name) COLLATE NOCASE`)
|
ORDER BY COALESCE(NULLIF(a.sort_name, ''), a.name) COLLATE NOCASE`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -65,7 +74,7 @@ func ListArtists(db *sql.DB) ([]*Artist, error) {
|
|||||||
var out []*Artist
|
var out []*Artist
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a Artist
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, &a)
|
out = append(out, &a)
|
||||||
@@ -73,6 +82,25 @@ func ListArtists(db *sql.DB) ([]*Artist, error) {
|
|||||||
return out, rows.Err()
|
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 ---
|
// --- Genres ---
|
||||||
|
|
||||||
type Genre struct {
|
type Genre struct {
|
||||||
@@ -223,6 +251,14 @@ func scanAlbums(rows *sql.Rows) ([]*Album, error) {
|
|||||||
return out, rows.Err()
|
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 {
|
func SetAlbumCoverArt(db *sql.DB, albumID, coverArtID string) error {
|
||||||
_, err := db.Exec(`UPDATE albums SET cover_art_id = ? WHERE id = ?`, coverArtID, albumID)
|
_, err := db.Exec(`UPDATE albums SET cover_art_id = ? WHERE id = ?`, coverArtID, albumID)
|
||||||
return err
|
return err
|
||||||
@@ -260,11 +296,12 @@ type Song struct {
|
|||||||
ReplayGainTrackPeak sql.NullFloat64
|
ReplayGainTrackPeak sql.NullFloat64
|
||||||
ReplayGainAlbumGainDB sql.NullFloat64
|
ReplayGainAlbumGainDB sql.NullFloat64
|
||||||
ReplayGainAlbumPeak 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,
|
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,
|
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) {
|
func GetSong(db *sql.DB, id string) (*Song, error) {
|
||||||
return scanSong(db.QueryRow(songSelect+` WHERE id = ?`, id))
|
return scanSong(db.QueryRow(songSelect+` WHERE id = ?`, id))
|
||||||
@@ -278,7 +315,7 @@ func scanSong(row *sql.Row) (*Song, error) {
|
|||||||
var s Song
|
var s Song
|
||||||
err := row.Scan(&s.ID, &s.MediaFileID, &s.AlbumID, &s.Title, &s.SortTitle, &s.TrackNumber, &s.TrackTotal, &s.DiscNumber, &s.DiscTotal,
|
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.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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -312,7 +349,7 @@ func scanSongs(rows *sql.Rows) ([]*Song, error) {
|
|||||||
var s Song
|
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,
|
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.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
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, &s)
|
out = append(out, &s)
|
||||||
@@ -354,14 +391,29 @@ func UpsertSong(db *sql.DB, in UpsertSongInput) (*Song, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
_, err = db.Exec(`UPDATE songs SET album_id=?, title=?, sort_title=?, track_number=?, track_total=?, disc_number=?, disc_total=?, disc_subtitle=?,
|
if existing.MetadataLocked {
|
||||||
composer=?, conductor=?, comment=?, isrc=?, bpm=?, musicbrainz_track_id=?, musicbrainz_recording_id=?,
|
// An admin has hand-edited title/sort_title/track_number/
|
||||||
replaygain_track_gain_db=?, replaygain_track_peak=?, replaygain_album_gain_db=?, replaygain_album_peak=?,
|
// disc_number/composer/conductor for this song; leave those
|
||||||
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`,
|
// alone and only refresh the fields that come straight off
|
||||||
nullStr(in.AlbumID), in.Title, in.SortTitle, nullInt(in.TrackNumber), nullInt(in.TrackTotal), nullInt(in.DiscNumber), nullInt(in.DiscTotal), in.DiscSubtitle,
|
// the file itself and were never exposed for editing.
|
||||||
in.Composer, in.Conductor, in.Comment, in.ISRC, nullInt(in.BPM), in.MusicBrainzTrackID, in.MusicBrainzRecordingID,
|
_, err = db.Exec(`UPDATE songs SET album_id=?, track_total=?, disc_total=?, disc_subtitle=?,
|
||||||
nullFloat(in.ReplayGainTrackGainDB), nullFloat(in.ReplayGainTrackPeak), nullFloat(in.ReplayGainAlbumGainDB), nullFloat(in.ReplayGainAlbumPeak),
|
comment=?, isrc=?, bpm=?, musicbrainz_track_id=?, musicbrainz_recording_id=?,
|
||||||
existing.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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -382,6 +434,15 @@ func UpsertSong(db *sql.DB, in UpsertSongInput) (*Song, error) {
|
|||||||
return GetSong(db, id)
|
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 {
|
func ClearSongArtists(db *sql.DB, songID string) error {
|
||||||
_, err := db.Exec(`DELETE FROM song_artists WHERE song_id = ?`, songID)
|
_, err := db.Exec(`DELETE FROM song_artists WHERE song_id = ?`, songID)
|
||||||
return err
|
return err
|
||||||
@@ -431,6 +492,21 @@ func ListSongArtistNames(db *sql.DB, songID string) ([]string, error) {
|
|||||||
return out, rows.Err()
|
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 {
|
func nullInt(n sql.NullInt64) any {
|
||||||
if !n.Valid {
|
if !n.Valid {
|
||||||
return nil
|
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) {
|
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,
|
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.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
|
FROM songs s
|
||||||
JOIN song_genres sg ON sg.song_id = s.id
|
JOIN song_genres sg ON sg.song_id = s.id
|
||||||
JOIN genres g ON g.id = sg.genre_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) {
|
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,
|
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.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
|
FROM songs s
|
||||||
JOIN song_artists sa ON sa.song_id = s.id
|
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
|
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) {
|
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,
|
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.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
|
FROM songs s
|
||||||
JOIN song_genres sg ON sg.song_id = s.id
|
JOIN song_genres sg ON sg.song_id = s.id
|
||||||
WHERE sg.genre_id IN (SELECT genre_id FROM song_genres WHERE song_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) {
|
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,
|
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.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
|
FROM songs s
|
||||||
JOIN song_genres sg ON sg.song_id = s.id
|
JOIN song_genres sg ON sg.song_id = s.id
|
||||||
WHERE sg.genre_id IN (
|
WHERE sg.genre_id IN (
|
||||||
@@ -143,58 +143,6 @@ func AlbumsByRecentlyPlayed(db *sql.DB, userID string, limit, offset int) ([]*Al
|
|||||||
return scanAlbums(rows)
|
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
|
// SongLyricsPlainText returns a song's stored lyrics as plain text
|
||||||
// (lines joined with "\n"), and whether any were found.
|
// (lines joined with "\n"), and whether any were found.
|
||||||
func SongLyricsPlainText(db *sql.DB, songID string) (string, bool, error) {
|
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()
|
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) {
|
func scanUserRow(rows *sql.Rows) (*User, error) {
|
||||||
var u User
|
var u User
|
||||||
var isAdmin, disabled, external int
|
var isAdmin, disabled, external int
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
// idempotent: rerunning it for the same file converges to the same rows,
|
// idempotent: rerunning it for the same file converges to the same rows,
|
||||||
// reusing existing artist/album/genre IDs via their natural keys so IDs
|
// reusing existing artist/album/genre IDs via their natural keys so IDs
|
||||||
// stay stable across rescans.
|
// 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
|
title := tags.Title
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = mediaFile.RelativePath
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func TestIndexFileCreatesArtistAlbumSong(t *testing.T) {
|
|||||||
Year: 2020,
|
Year: 2020,
|
||||||
}
|
}
|
||||||
|
|
||||||
song, err := IndexFile(db, mf, tags)
|
song, err := IndexFile(db, mf, "/music/"+mf.RelativePath, tags)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("IndexFile: %v", err)
|
t.Fatalf("IndexFile: %v", err)
|
||||||
}
|
}
|
||||||
@@ -87,11 +87,11 @@ func TestIndexFileIsIdempotent(t *testing.T) {
|
|||||||
mf := makeMediaFile(t, db, "Artist/Album/01 - Song.flac")
|
mf := makeMediaFile(t, db, "Artist/Album/01 - Song.flac")
|
||||||
tags := &metadata.Tags{Title: "Song", Album: "Album", AlbumArtist: "Artist", Artists: []string{"Artist"}}
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("IndexFile (1st): %v", err)
|
t.Fatalf("IndexFile (1st): %v", err)
|
||||||
}
|
}
|
||||||
song2, err := IndexFile(db, mf, tags)
|
song2, err := IndexFile(db, mf, "/music/"+mf.RelativePath, tags)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("IndexFile (2nd): %v", err)
|
t.Fatalf("IndexFile (2nd): %v", err)
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ func TestIndexFileIsIdempotent(t *testing.T) {
|
|||||||
func TestIndexFileUsesFilenameWhenNoTitle(t *testing.T) {
|
func TestIndexFileUsesFilenameWhenNoTitle(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
mf := makeMediaFile(t, db, "Unknown/Track.flac")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("IndexFile: %v", err)
|
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/database"
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
|
"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 {
|
type Manager struct {
|
||||||
DB *sql.DB
|
DB *sql.DB
|
||||||
@@ -37,12 +44,18 @@ type Manager struct {
|
|||||||
sem chan 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{
|
return &Manager{
|
||||||
DB: db,
|
DB: db,
|
||||||
DataDir: dataDir,
|
DataDir: dataDir,
|
||||||
FFprobePath: ffprobePath,
|
FFprobePath: ffprobePath,
|
||||||
HTTPClient: &http.Client{Timeout: 60 * time.Second},
|
HTTPClient: netguard.NewSafeHTTPClient(60*time.Second, allowPrivateNetworks),
|
||||||
Log: log,
|
Log: log,
|
||||||
sem: make(chan struct{}, maxConcurrentDownloads),
|
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", "")
|
_ = 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)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, channel.URL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = database.SetPodcastChannelStatus(m.DB, channelID, "error", err.Error())
|
_ = 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)
|
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 {
|
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)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -253,11 +298,17 @@ func (m *Manager) downloadToFile(ctx context.Context, sourceURL, destPath string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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()
|
out.Close()
|
||||||
os.Remove(tmp)
|
os.Remove(tmp)
|
||||||
return err
|
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 {
|
if err := out.Close(); err != nil {
|
||||||
os.Remove(tmp)
|
os.Remove(tmp)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -79,7 +79,10 @@ func newTestManager(t *testing.T) (*Manager, string) {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { db.Close() })
|
t.Cleanup(func() { db.Close() })
|
||||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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) {
|
func TestRefreshChannel(t *testing.T) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.noroutes.net/NoRoutes/vibetunes/internal/audiofeatures"
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/coverart"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/coverart"
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/library"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/library"
|
||||||
@@ -35,9 +36,10 @@ const maxWorkers = 4
|
|||||||
|
|
||||||
// Scanner indexes one or more music folders into the database.
|
// Scanner indexes one or more music folders into the database.
|
||||||
type Scanner struct {
|
type Scanner struct {
|
||||||
DB *sql.DB
|
DB *sql.DB
|
||||||
Prober *metadata.Prober
|
Prober *metadata.Prober
|
||||||
Log *slog.Logger
|
Log *slog.Logger
|
||||||
|
FFmpegPath string // for sonic-analysis audio decoding; "" defaults to "ffmpeg" on PATH
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
scanning map[string]bool // music_folder_id -> in progress
|
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 {
|
if err != nil {
|
||||||
return false, false, err
|
return false, false, err
|
||||||
}
|
}
|
||||||
song, err := library.IndexFile(s.DB, mf, tags)
|
song, err := library.IndexFile(s.DB, mf, absPath, tags)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, err
|
return false, false, err
|
||||||
}
|
}
|
||||||
s.indexCoverArt(absPath, song, tags)
|
s.indexCoverArt(absPath, song, tags)
|
||||||
|
s.analyzeAudio(ctx, absPath, song, tags)
|
||||||
return false, true, nil
|
return false, true, nil
|
||||||
}
|
}
|
||||||
if err != database.ErrNotFound {
|
if err != database.ErrNotFound {
|
||||||
@@ -199,14 +202,36 @@ func (s *Scanner) indexOne(ctx context.Context, folderID, absPath, relPath strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, err
|
return false, false, err
|
||||||
}
|
}
|
||||||
song, err := library.IndexFile(s.DB, mf, tags)
|
song, err := library.IndexFile(s.DB, mf, absPath, tags)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, err
|
return false, false, err
|
||||||
}
|
}
|
||||||
s.indexCoverArt(absPath, song, tags)
|
s.indexCoverArt(absPath, song, tags)
|
||||||
|
s.analyzeAudio(ctx, absPath, song, tags)
|
||||||
return true, false, nil
|
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
|
// 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
|
// 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
|
// 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")
|
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 (
|
import (
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
|
"crypto/subtle"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -60,7 +61,7 @@ func authenticate(db *sql.DB, encryptionKey []byte, p params) (*database.User, *
|
|||||||
}
|
}
|
||||||
sum := md5.Sum([]byte(plaintext + salt))
|
sum := md5.Sum([]byte(plaintext + salt))
|
||||||
expected := hex.EncodeToString(sum[:])
|
expected := hex.EncodeToString(sum[:])
|
||||||
if !strings.EqualFold(expected, token) {
|
if subtle.ConstantTimeCompare([]byte(expected), []byte(strings.ToLower(token))) != 1 {
|
||||||
return nil, errWrongCredentials
|
return nil, errWrongCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||||
|
"git.noroutes.net/NoRoutes/vibetunes/internal/similarity"
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/streaming"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/streaming"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,44 +88,50 @@ func handleGetVideoInfo(rc *Context) *apiError {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Artist/album info: VibeSonic has no external metadata provider
|
// --- Artist/album info: local data only (title/name, and a
|
||||||
// (last.fm etc.) in Phase 2, so these honestly return only what's known
|
// MusicBrainz ID if one was tagged), optionally enriched from
|
||||||
// locally — which for biography/images/similar-artists is nothing —
|
// MusicBrainz/Cover Art Archive when VIBESONIC_ENABLE_EXTERNAL_METADATA
|
||||||
// rather than fabricating placeholder content. ---
|
// 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 {
|
func handleGetArtistInfo(rc *Context) *apiError {
|
||||||
if _, aerr := rc.Params.Required("id"); aerr != nil {
|
id, aerr := rc.Params.Required("id")
|
||||||
|
if aerr != nil {
|
||||||
return aerr
|
return aerr
|
||||||
}
|
}
|
||||||
rc.Response = newResponse()
|
rc.Response = newResponse()
|
||||||
rc.Response.ArtistInfo = &ArtistInfo{}
|
rc.Response.ArtistInfo = &ArtistInfo{ArtistInfoBase: artistInfoBase(rc, id)}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleGetArtistInfo2(rc *Context) *apiError {
|
func handleGetArtistInfo2(rc *Context) *apiError {
|
||||||
if _, aerr := rc.Params.Required("id"); aerr != nil {
|
id, aerr := rc.Params.Required("id")
|
||||||
|
if aerr != nil {
|
||||||
return aerr
|
return aerr
|
||||||
}
|
}
|
||||||
rc.Response = newResponse()
|
rc.Response = newResponse()
|
||||||
rc.Response.ArtistInfo2 = &ArtistInfo2{}
|
rc.Response.ArtistInfo2 = &ArtistInfo2{ArtistInfoBase: artistInfoBase(rc, id)}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleGetAlbumInfo(rc *Context) *apiError {
|
func handleGetAlbumInfo(rc *Context) *apiError {
|
||||||
if _, aerr := rc.Params.Required("id"); aerr != nil {
|
id, aerr := rc.Params.Required("id")
|
||||||
|
if aerr != nil {
|
||||||
return aerr
|
return aerr
|
||||||
}
|
}
|
||||||
rc.Response = newResponse()
|
rc.Response = newResponse()
|
||||||
rc.Response.AlbumInfo = &AlbumInfo{}
|
rc.Response.AlbumInfo = albumInfo(rc, id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleGetAlbumInfo2(rc *Context) *apiError {
|
func handleGetAlbumInfo2(rc *Context) *apiError {
|
||||||
if _, aerr := rc.Params.Required("id"); aerr != nil {
|
id, aerr := rc.Params.Required("id")
|
||||||
|
if aerr != nil {
|
||||||
return aerr
|
return aerr
|
||||||
}
|
}
|
||||||
rc.Response = newResponse()
|
rc.Response = newResponse()
|
||||||
rc.Response.AlbumInfo = &AlbumInfo{}
|
rc.Response.AlbumInfo = albumInfo(rc, id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,18 +190,32 @@ func handleGetSimilarSongs2(rc *Context) *apiError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// similarSongsFor resolves id as a song, album, or artist (getSimilarSongs's
|
// similarSongsFor resolves id as a song, album, or artist (getSimilarSongs's
|
||||||
// "id" can be any of the three in real Subsonic usage) and finds
|
// "id" can be any of the three in real Subsonic usage), finds
|
||||||
// genre-similar songs for it.
|
// 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) {
|
func similarSongsFor(db *sql.DB, id string, count int) ([]*database.Song, error) {
|
||||||
if song, err := database.GetSong(db, id); err == nil {
|
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 {
|
if _, err := database.GetAlbum(db, id); err == nil {
|
||||||
songs, err := database.ListSongsByAlbum(db, id)
|
songs, err := database.ListSongsByAlbum(db, id)
|
||||||
if err != nil || len(songs) == 0 {
|
if err != nil || len(songs) == 0 {
|
||||||
return nil, err
|
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 {
|
if _, err := database.GetArtist(db, id); err == nil {
|
||||||
return database.SimilarSongsByArtist(db, id, count)
|
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
|
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 {
|
func handleGetTopSongs(rc *Context) *apiError {
|
||||||
count := rc.Params.Int("count", 50)
|
count := rc.Params.Int("count", 50)
|
||||||
db := rc.Server.DB
|
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.
|
// set once Phase 3 is done.
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"apiKeyAuthentication", "formPost", "getPodcastEpisode", "topSongsByArtistId", "transcodeOffset",
|
"apiKeyAuthentication", "formPost", "getPodcastEpisode", "topSongsByArtistId", "transcodeOffset",
|
||||||
"indexBasedQueue", "playbackReport",
|
"indexBasedQueue", "playbackReport", "songLyrics", "transcoding", "sonicSimilarity",
|
||||||
} {
|
} {
|
||||||
if !names[want] {
|
if !names[want] {
|
||||||
t.Errorf("extension %q not advertised: %v", want, names)
|
t.Errorf("extension %q not advertised: %v", want, names)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||||
|
"git.noroutes.net/NoRoutes/vibetunes/internal/netguard"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -176,6 +177,9 @@ func handleCreatePodcastChannel(rc *Context) *apiError {
|
|||||||
if aerr != nil {
|
if aerr != nil {
|
||||||
return aerr
|
return aerr
|
||||||
}
|
}
|
||||||
|
if err := netguard.ValidateURL(feedURL); err != nil {
|
||||||
|
return newError(ErrGeneric, err.Error())
|
||||||
|
}
|
||||||
channel, err := database.CreatePodcastChannel(rc.Server.DB, feedURL)
|
channel, err := database.CreatePodcastChannel(rc.Server.DB, feedURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return newError(ErrGeneric, err.Error())
|
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),
|
Scanner: scanner.New(db, &metadata.Prober{}, log),
|
||||||
CoverArt: coverart.New(t.TempDir(), ""),
|
CoverArt: coverart.New(t.TempDir(), ""),
|
||||||
Transcoder: transcoding.NewManager(""),
|
Transcoder: transcoding.NewManager(""),
|
||||||
Podcasts: podcasts.NewManager(db, t.TempDir(), "", log),
|
Podcasts: podcasts.NewManager(db, t.TempDir(), "", log, true),
|
||||||
Jukebox: jukebox.New(""),
|
Jukebox: jukebox.New(""),
|
||||||
Log: log,
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
|
"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)
|
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) {
|
func (h *Web) handleAdminUserPermissions(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
target, err := database.GetUserByID(h.DB, 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)
|
http.NotFound(w, r)
|
||||||
return
|
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) {
|
func (h *Web) handleAdminUserPermissionsUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.PathValue("id")
|
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)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -86,6 +117,32 @@ func (h *Web) handleAdminUserPermissionsUpdate(w http.ResponseWriter, r *http.Re
|
|||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
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)
|
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 {
|
if h.Scanner != nil {
|
||||||
go func() { _ = h.Scanner.ScanFolder(context.Background(), id) }()
|
go func() { _ = h.Scanner.ScanFolder(context.Background(), id) }()
|
||||||
}
|
}
|
||||||
referer := r.Header.Get("Referer")
|
dest := "/admin/scanner"
|
||||||
if referer == "" {
|
if referer := r.Header.Get("Referer"); referer != "" {
|
||||||
referer = "/admin/scanner"
|
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 ---
|
// --- Scanner status ---
|
||||||
@@ -223,10 +284,10 @@ func (h *Web) handleAdminScanner(w http.ResponseWriter, r *http.Request) {
|
|||||||
// --- System settings ---
|
// --- System settings ---
|
||||||
|
|
||||||
func (h *Web) handleAdminSettings(w http.ResponseWriter, r *http.Request) {
|
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 := ""
|
trusted := ""
|
||||||
for i, p := range h.Config.ExtAuth.TrustedSources {
|
for i, p := range h.Config.ExtAuth.TrustedSources {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
@@ -241,6 +302,8 @@ func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash,
|
|||||||
ExtAuthEnabled bool
|
ExtAuthEnabled bool
|
||||||
ExtAuthTrustedSources string
|
ExtAuthTrustedSources string
|
||||||
ExtAuthHeader string
|
ExtAuthHeader string
|
||||||
|
ArtistPhotosEnabled bool
|
||||||
|
ArtistPhotoResults []ArtistPhotoOutcome
|
||||||
}{
|
}{
|
||||||
ScanInterval: database.GetSetting(h.DB, "scan_interval", h.Config.ScanInterval.String()),
|
ScanInterval: database.GetSetting(h.DB, "scan_interval", h.Config.ScanInterval.String()),
|
||||||
FFmpegPath: database.GetSetting(h.DB, "ffmpeg_path", h.Config.FFmpegPath),
|
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,
|
ExtAuthEnabled: h.Config.ExtAuth.Enabled,
|
||||||
ExtAuthTrustedSources: trusted,
|
ExtAuthTrustedSources: trusted,
|
||||||
ExtAuthHeader: h.Config.ExtAuth.UserHeader,
|
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")
|
interval := r.FormValue("scan_interval")
|
||||||
if _, err := time.ParseDuration(interval); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
_ = database.SetSetting(h.DB, "scan_interval", interval)
|
_ = database.SetSetting(h.DB, "scan_interval", interval)
|
||||||
_ = database.SetSetting(h.DB, "ffmpeg_path", r.FormValue("ffmpeg_path"))
|
_ = database.SetSetting(h.DB, "ffmpeg_path", r.FormValue("ffmpeg_path"))
|
||||||
_ = database.SetSetting(h.DB, "ffprobe_path", r.FormValue("ffprobe_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"
|
"net/http"
|
||||||
|
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||||
|
"git.noroutes.net/NoRoutes/vibetunes/internal/netguard"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h *Web) handleAdminPodcasts(w http.ResponseWriter, r *http.Request) {
|
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)
|
http.Redirect(w, r, "/admin/podcasts", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := netguard.ValidateURL(feedURL); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
channel, err := database.CreatePodcastChannel(h.DB, feedURL)
|
channel, err := database.CreatePodcastChannel(h.DB, feedURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
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 {
|
if err != nil {
|
||||||
continue
|
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)
|
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
|
// row, extracting embedded artwork to a temp file on demand. isTemp
|
||||||
// tells the caller whether it must remove the returned path afterward.
|
// 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) {
|
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
|
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)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.maybeFetchArtistPhoto(artist)
|
||||||
albums, err := database.ListAlbumsByArtist(h.DB, id)
|
albums, err := database.ListAlbumsByArtist(h.DB, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
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))
|
views = append(views, toAlbumView(h.DB, a))
|
||||||
}
|
}
|
||||||
h.page(w, r, "artist", "artists", artist.Name, struct {
|
h.page(w, r, "artist", "artists", artist.Name, struct {
|
||||||
Artist *database.Artist
|
Artist ArtistView
|
||||||
Albums []AlbumView
|
Albums []AlbumView
|
||||||
}{artist, views})
|
}{toArtistView(artist), views})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Web) handleAlbums(w http.ResponseWriter, r *http.Request) {
|
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
|
return
|
||||||
}
|
}
|
||||||
userID := ""
|
userID := ""
|
||||||
|
isAdmin := false
|
||||||
if user != nil {
|
if user != nil {
|
||||||
userID = user.ID
|
userID = user.ID
|
||||||
|
isAdmin = user.IsAdmin
|
||||||
}
|
}
|
||||||
var rows []SongRow
|
var rows []SongRow
|
||||||
for _, s := range songs {
|
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
|
starred := false
|
||||||
if user != nil {
|
if user != nil {
|
||||||
@@ -157,12 +160,14 @@ func (h *Web) handleSongs(w http.ResponseWriter, r *http.Request) {
|
|||||||
songs = songs[:pageSize]
|
songs = songs[:pageSize]
|
||||||
}
|
}
|
||||||
userID := ""
|
userID := ""
|
||||||
|
isAdmin := false
|
||||||
if user != nil {
|
if user != nil {
|
||||||
userID = user.ID
|
userID = user.ID
|
||||||
|
isAdmin = user.IsAdmin
|
||||||
}
|
}
|
||||||
var rows []SongRow
|
var rows []SongRow
|
||||||
for _, s := range songs {
|
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 {
|
h.page(w, r, "songs", "songs", "Songs", struct {
|
||||||
Songs []SongRow
|
Songs []SongRow
|
||||||
@@ -219,14 +224,16 @@ func (h *Web) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
if songIDRows != nil {
|
if songIDRows != nil {
|
||||||
defer songIDRows.Close()
|
defer songIDRows.Close()
|
||||||
userID := ""
|
userID := ""
|
||||||
|
isAdmin := false
|
||||||
if user != nil {
|
if user != nil {
|
||||||
userID = user.ID
|
userID = user.ID
|
||||||
|
isAdmin = user.IsAdmin
|
||||||
}
|
}
|
||||||
for songIDRows.Next() {
|
for songIDRows.Next() {
|
||||||
var id string
|
var id string
|
||||||
if songIDRows.Scan(&id) == nil {
|
if songIDRows.Scan(&id) == nil {
|
||||||
if song, err := database.GetSong(h.DB, id); err == 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 {
|
if err != nil {
|
||||||
continue
|
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 {
|
h.page(w, r, "playlist", "playlists", playlist.Name, struct {
|
||||||
Playlist *database.Playlist
|
Playlist *database.Playlist
|
||||||
@@ -109,7 +109,7 @@ func (h *Web) handleFavorites(w http.ResponseWriter, r *http.Request) {
|
|||||||
var songs []SongRow
|
var songs []SongRow
|
||||||
for _, id := range songIDs {
|
for _, id := range songIDs {
|
||||||
if s, err := database.GetSong(h.DB, id); err == nil {
|
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 {
|
if err != nil {
|
||||||
continue
|
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)
|
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) }()
|
go func() { _ = h.Podcasts.DownloadEpisode(context.Background(), id) }()
|
||||||
http.Redirect(w, r, "/podcasts", http.StatusFound)
|
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("GET /playlists", h.requireUser(h.handlePlaylists))
|
||||||
mux.HandleFunc("POST /playlists", h.requireUser(h.handlePlaylistCreate))
|
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("GET /playlists/{id}", h.requireUser(h.handlePlaylist))
|
||||||
mux.HandleFunc("POST /playlists/{id}/delete", h.requireUser(h.handlePlaylistDelete))
|
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("GET /favorites", h.requireUser(h.handleFavorites))
|
||||||
mux.HandleFunc("POST /favorites/star", h.requireUser(h.handleFavoriteStar))
|
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("GET /podcasts", h.requireUser(h.handlePodcasts))
|
||||||
mux.HandleFunc("POST /podcasts/{id}/download", h.requireUser(h.handlePodcastDownload))
|
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("GET /radio", h.requireUser(h.handleRadio))
|
||||||
mux.HandleFunc("POST /radio", h.requireAdmin(h.handleRadioCreate))
|
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("POST /settings/sessions/{id}/revoke", h.requireUser(h.handleSessionRevoke))
|
||||||
|
|
||||||
mux.HandleFunc("GET /stream/{id}", h.requireUser(h.handleStream))
|
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 /cover-art/{id}", h.requireUser(h.handleCoverArt))
|
||||||
|
|
||||||
mux.HandleFunc("GET /admin", h.requireAdmin(h.handleAdminRoot))
|
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/scanner", h.requireAdmin(h.handleAdminScanner))
|
||||||
mux.HandleFunc("GET /admin/settings", h.requireAdmin(h.handleAdminSettings))
|
mux.HandleFunc("GET /admin/settings", h.requireAdmin(h.handleAdminSettings))
|
||||||
mux.HandleFunc("POST /admin/settings", h.requireAdmin(h.handleAdminSettingsUpdate))
|
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("GET /admin/podcasts", h.requireAdmin(h.handleAdminPodcasts))
|
||||||
mux.HandleFunc("POST /admin/podcasts", h.requireAdmin(h.handleAdminPodcastCreate))
|
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}/download", h.requireAdmin(h.handleAdminPodcastEpisodeDownload))
|
||||||
mux.HandleFunc("POST /admin/podcasts/episodes/{id}/delete", h.requireAdmin(h.handleAdminPodcastEpisodeDelete))
|
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("GET /admin/api-keys", h.requireAdmin(h.handleAdminAPIKeys))
|
||||||
mux.HandleFunc("POST /admin/api-keys/{id}/revoke", h.requireAdmin(h.handleAdminAPIKeyRevoke))
|
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")
|
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 {
|
if serr != nil {
|
||||||
continue
|
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)
|
owner, _ := database.GetUserByID(h.DB, share.OwnerID)
|
||||||
|
|||||||
+18
-5
@@ -10,8 +10,10 @@ import (
|
|||||||
// scanning details (sql.Null*) out of the .html files.
|
// scanning details (sql.Null*) out of the .html files.
|
||||||
|
|
||||||
type ArtistView struct {
|
type ArtistView struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
|
Bio string
|
||||||
|
CoverArtID string
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlbumView struct {
|
type AlbumView struct {
|
||||||
@@ -26,16 +28,22 @@ type SongRow struct {
|
|||||||
ID string
|
ID string
|
||||||
Title string
|
Title string
|
||||||
ArtistName string
|
ArtistName string
|
||||||
|
ArtistID string
|
||||||
AlbumTitle string
|
AlbumTitle string
|
||||||
AlbumID string
|
AlbumID string
|
||||||
CoverArtID string
|
CoverArtID string
|
||||||
TrackNumber int64
|
TrackNumber int64
|
||||||
DurationSeconds float64
|
DurationSeconds float64
|
||||||
Starred bool
|
Starred bool
|
||||||
|
IsAdmin bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func toArtistView(a *database.Artist) ArtistView {
|
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 {
|
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
|
// 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
|
// be nil for albumless tracks) and the current user (for the starred
|
||||||
// flag; pass "" userID to skip that lookup).
|
// flag; pass "" userID to skip that lookup, and isAdmin=false to hide
|
||||||
func toSongRow(db *sql.DB, s *database.Song, album *database.Album, userID string) SongRow {
|
// the row's "Edit" link).
|
||||||
|
func toSongRow(db *sql.DB, s *database.Song, album *database.Album, userID string, isAdmin bool) SongRow {
|
||||||
row := SongRow{
|
row := SongRow{
|
||||||
ID: s.ID,
|
ID: s.ID,
|
||||||
Title: s.Title,
|
Title: s.Title,
|
||||||
DurationSeconds: 0,
|
DurationSeconds: 0,
|
||||||
|
IsAdmin: isAdmin,
|
||||||
}
|
}
|
||||||
if s.TrackNumber.Valid {
|
if s.TrackNumber.Valid {
|
||||||
row.TrackNumber = s.TrackNumber.Int64
|
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
|
row.ArtistName += ", " + n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if id, err := database.PrimarySongArtistID(db, s.ID); err == nil {
|
||||||
|
row.ArtistID = id
|
||||||
|
}
|
||||||
if album != nil {
|
if album != nil {
|
||||||
row.AlbumTitle = album.Title
|
row.AlbumTitle = album.Title
|
||||||
row.AlbumID = album.ID
|
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;
|
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 --- */
|
/* --- Player bar --- */
|
||||||
|
|
||||||
.vs-player {
|
.vs-player {
|
||||||
@@ -211,19 +226,33 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vs-player-title {
|
.vs-player-title {
|
||||||
|
display: block;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vs-player-artist {
|
.vs-player-artist {
|
||||||
|
display: block;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--bs-secondary-color);
|
color: var(--bs-secondary-color);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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 {
|
.vs-player-controls {
|
||||||
@@ -262,6 +291,10 @@ body {
|
|||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vs-player-btn.active {
|
||||||
|
color: var(--bs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.vs-player-timeline {
|
.vs-player-timeline {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -286,6 +319,44 @@ body {
|
|||||||
flex: 0 0 auto;
|
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 --- */
|
/* --- Misc --- */
|
||||||
|
|
||||||
.vs-star-btn {
|
.vs-star-btn {
|
||||||
@@ -310,3 +381,9 @@ body {
|
|||||||
display: none;
|
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.ui = ui;
|
||||||
this.queue = [];
|
this.queue = [];
|
||||||
this.index = -1;
|
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("timeupdate", this._onTimeUpdate.bind(this));
|
||||||
this.audio.addEventListener("loadedmetadata", 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("play", this._syncPlayIcon.bind(this));
|
||||||
this.audio.addEventListener("pause", this._syncPlayIcon.bind(this));
|
this.audio.addEventListener("pause", this._syncPlayIcon.bind(this));
|
||||||
}
|
}
|
||||||
@@ -36,21 +47,82 @@
|
|||||||
VibeSonicPlayer.prototype.playQueue = function (songs, startIndex) {
|
VibeSonicPlayer.prototype.playQueue = function (songs, startIndex) {
|
||||||
this.queue = songs;
|
this.queue = songs;
|
||||||
this.index = startIndex || 0;
|
this.index = startIndex || 0;
|
||||||
|
this._reshuffle();
|
||||||
this._loadCurrent();
|
this._loadCurrent();
|
||||||
this.audio.play().catch(function () {});
|
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 () {
|
VibeSonicPlayer.prototype._loadCurrent = function () {
|
||||||
var song = this.queue[this.index];
|
var song = this.queue[this.index];
|
||||||
if (!song) return;
|
if (!song) return;
|
||||||
this.audio.src = "/stream/" + encodeURIComponent(song.id);
|
this.audio.src = "/stream/" + encodeURIComponent(song.id);
|
||||||
this.ui.title.textContent = song.title || "";
|
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.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.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.itemType = "song";
|
||||||
this.ui.starBtn.dataset.itemId = song.id;
|
this.ui.starBtn.dataset.itemId = song.id;
|
||||||
this.ui.starBtn.classList.toggle("active", !!song.starred);
|
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.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) {
|
if ("mediaSession" in navigator) {
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({
|
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 () {
|
VibeSonicPlayer.prototype.playPause = function () {
|
||||||
if (!this.queue.length) return;
|
if (!this.queue.length) return;
|
||||||
if (this.audio.paused) {
|
if (this.audio.paused) {
|
||||||
@@ -71,11 +216,25 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
VibeSonicPlayer.prototype.next = function () {
|
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.index++;
|
||||||
this._loadCurrent();
|
} else if (this.repeat === "all") {
|
||||||
this.audio.play().catch(function () {});
|
this.index = 0;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
this._loadCurrent();
|
||||||
|
this.audio.play().catch(function () {});
|
||||||
};
|
};
|
||||||
|
|
||||||
VibeSonicPlayer.prototype.prev = function () {
|
VibeSonicPlayer.prototype.prev = function () {
|
||||||
@@ -83,11 +242,17 @@
|
|||||||
this.audio.currentTime = 0;
|
this.audio.currentTime = 0;
|
||||||
return;
|
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.index--;
|
||||||
this._loadCurrent();
|
} else {
|
||||||
this.audio.play().catch(function () {});
|
return;
|
||||||
}
|
}
|
||||||
|
this._loadCurrent();
|
||||||
|
this.audio.play().catch(function () {});
|
||||||
};
|
};
|
||||||
|
|
||||||
VibeSonicPlayer.prototype.seekTo = function (fraction) {
|
VibeSonicPlayer.prototype.seekTo = function (fraction) {
|
||||||
@@ -123,6 +288,7 @@
|
|||||||
if (dur > 0) {
|
if (dur > 0) {
|
||||||
this.ui.progress.value = String((cur / dur) * 100);
|
this.ui.progress.value = String((cur / dur) * 100);
|
||||||
}
|
}
|
||||||
|
this._updateActiveLyricLine(cur * 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatTime(seconds) {
|
function formatTime(seconds) {
|
||||||
@@ -143,7 +309,9 @@
|
|||||||
id: node.dataset.songId,
|
id: node.dataset.songId,
|
||||||
title: node.dataset.title,
|
title: node.dataset.title,
|
||||||
artist: node.dataset.artist,
|
artist: node.dataset.artist,
|
||||||
|
artistId: node.dataset.artistId,
|
||||||
album: node.dataset.album,
|
album: node.dataset.album,
|
||||||
|
albumId: node.dataset.albumId,
|
||||||
cover: node.dataset.cover,
|
cover: node.dataset.cover,
|
||||||
starred: node.dataset.starred === "1",
|
starred: node.dataset.starred === "1",
|
||||||
});
|
});
|
||||||
@@ -165,8 +333,46 @@
|
|||||||
progress: document.getElementById("vs-progress"),
|
progress: document.getElementById("vs-progress"),
|
||||||
volumeSlider: document.getElementById("vs-volume"),
|
volumeSlider: document.getElementById("vs-volume"),
|
||||||
starBtn: document.getElementById("vs-now-star"),
|
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);
|
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 () {
|
document.getElementById("vs-play-btn").addEventListener("click", function () {
|
||||||
player.playPause();
|
player.playPause();
|
||||||
@@ -286,7 +492,134 @@
|
|||||||
form.appendChild(input);
|
form.appendChild(input);
|
||||||
});
|
});
|
||||||
document.body.appendChild(form);
|
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();
|
var player = initPlayer();
|
||||||
initKeyboardShortcuts(player);
|
initKeyboardShortcuts(player);
|
||||||
initStarButtons();
|
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}}
|
{{if .Channel.ErrorMessage}}<div class="text-danger small">{{.Channel.ErrorMessage}}</div>{{end}}
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<form method="post" action="/admin/podcasts/{{.Channel.ID}}/refresh" class="d-inline">
|
||||||
{{template "csrf_field" $.CSRFToken}}
|
{{template "csrf_field" $.CSRFToken}}
|
||||||
<button type="submit" class="btn btn-sm btn-outline-primary">Refresh</button>
|
<button type="submit" class="btn btn-sm btn-outline-primary">Refresh</button>
|
||||||
|
|||||||
@@ -21,6 +21,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 mt-3" style="max-width: 600px;">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="h6">External authentication</h2>
|
<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>
|
<h2 class="h6">{{.Data.Username}}</h2>
|
||||||
<form method="post" action="/admin/users/{{.Data.ID}}/permissions">
|
<form method="post" action="/admin/users/{{.Data.ID}}/permissions">
|
||||||
{{template "csrf_field" .CSRFToken}}
|
{{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">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" name="settings" value="1" id="p_settings" {{if .Data.Permissions.Settings}}checked{{end}}>
|
<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>
|
<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}}>
|
<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>
|
<label class="form-check-label" for="p_video">Video Conversion</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
<hr>
|
||||||
<a href="/admin/users" class="btn btn-sm btn-outline-secondary">Cancel</a>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
{{template "csrf_field" .CSRFToken}}
|
{{template "csrf_field" .CSRFToken}}
|
||||||
<button type="submit" class="btn btn-sm btn-outline-secondary"><i class="bi bi-share"></i> Share</button>
|
<button type="submit" class="btn btn-sm btn-outline-secondary"><i class="bi bi-share"></i> Share</button>
|
||||||
</form>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{{template "song_rows" .Data.Songs}}
|
{{template "song_rows" .Data.Songs}}
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
{{define "artist"}}{{template "header" .}}
|
{{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">
|
<div class="vs-grid mb-3">
|
||||||
{{range .Data.Albums}}{{template "album_card" .}}{{end}}
|
{{range .Data.Albums}}{{template "album_card" .}}{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
{{define "artist_card"}}
|
{{define "artist_card"}}
|
||||||
<a href="/artists/{{.ID}}" class="vs-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>
|
<div class="vs-card-title">{{.Name}}</div>
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -22,15 +22,23 @@
|
|||||||
data-song-id="{{$s.ID}}"
|
data-song-id="{{$s.ID}}"
|
||||||
data-title="{{$s.Title}}"
|
data-title="{{$s.Title}}"
|
||||||
data-artist="{{$s.ArtistName}}"
|
data-artist="{{$s.ArtistName}}"
|
||||||
|
data-artist-id="{{$s.ArtistID}}"
|
||||||
data-album="{{$s.AlbumTitle}}"
|
data-album="{{$s.AlbumTitle}}"
|
||||||
|
data-album-id="{{$s.AlbumID}}"
|
||||||
data-cover="{{$s.CoverArtID}}"
|
data-cover="{{$s.CoverArtID}}"
|
||||||
data-starred="{{if $s.Starred}}1{{else}}0{{end}}">
|
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-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>
|
<td>
|
||||||
<div class="fw-semibold">{{$s.Title}}</div>
|
<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}}
|
{{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>
|
||||||
<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>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .User}}{{template "player"}}{{end}}
|
{{if .User}}{{template "player"}}{{template "player_art_modal"}}{{end}}
|
||||||
{{template "shortcuts_modal"}}
|
{{template "shortcuts_modal"}}
|
||||||
{{template "confirm_delete_modal"}}
|
{{template "confirm_delete_modal"}}
|
||||||
|
|
||||||
@@ -15,23 +15,27 @@
|
|||||||
|
|
||||||
{{define "player"}}
|
{{define "player"}}
|
||||||
<audio id="vs-audio" preload="metadata"></audio>
|
<audio id="vs-audio" preload="metadata"></audio>
|
||||||
|
<div id="vs-lyrics-panel" class="vs-lyrics-panel d-none"></div>
|
||||||
<div class="vs-player">
|
<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 class="vs-player-meta">
|
||||||
<div id="vs-player-title" class="vs-player-title">Nothing playing</div>
|
<a id="vs-player-title" class="vs-player-title" href="javascript:void(0)">Nothing playing</a>
|
||||||
<div id="vs-player-artist" class="vs-player-artist"></div>
|
<a id="vs-player-artist" class="vs-player-artist" href="javascript:void(0)"></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="vs-player-controls">
|
<div class="vs-player-controls">
|
||||||
<div class="vs-player-buttons">
|
<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-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-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-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>
|
||||||
<div class="vs-player-timeline">
|
<div class="vs-player-timeline">
|
||||||
<span id="vs-current-time" class="vs-player-time">0:00</span>
|
<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">
|
<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>
|
<span id="vs-duration" class="vs-player-time">0:00</span>
|
||||||
</div>
|
</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">
|
<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>
|
<i class="bi bi-star"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -43,6 +47,17 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{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"}}
|
{{define "shortcuts_modal"}}
|
||||||
<div class="modal fade" id="vs-shortcuts-modal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="vs-shortcuts-modal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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">
|
<header class="vs-topbar d-flex align-items-center justify-content-between">
|
||||||
<a href="/" class="vs-brand d-flex align-items-center gap-2">
|
<a href="/" class="vs-brand d-flex align-items-center gap-2">
|
||||||
<i class="bi bi-vinyl-fill"></i>
|
<i class="bi bi-vinyl-fill"></i>
|
||||||
|
|||||||
@@ -4,12 +4,15 @@
|
|||||||
<h2 class="h5 mb-0">{{.Data.Playlist.Name}}</h2>
|
<h2 class="h5 mb-0">{{.Data.Playlist.Name}}</h2>
|
||||||
{{if .Data.Playlist.Description}}<div class="text-secondary small">{{.Data.Playlist.Description}}</div>{{end}}
|
{{if .Data.Playlist.Description}}<div class="text-secondary small">{{.Data.Playlist.Description}}</div>{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{if .Data.Owned}}
|
<div class="d-flex gap-2">
|
||||||
<form method="post" action="/playlists/{{.Data.Playlist.ID}}/delete" onsubmit="return confirm('Delete this playlist?');">
|
<a href="/playlists/{{.Data.Playlist.ID}}/export" class="btn btn-outline-secondary btn-sm"><i class="bi bi-download"></i> Export M3U</a>
|
||||||
{{template "csrf_field" .CSRFToken}}
|
{{if .Data.Owned}}
|
||||||
<button type="submit" class="btn btn-outline-danger btn-sm"><i class="bi bi-trash"></i> Delete</button>
|
<form method="post" action="/playlists/{{.Data.Playlist.ID}}/delete" onsubmit="return confirm('Delete this playlist?');">
|
||||||
</form>
|
{{template "csrf_field" .CSRFToken}}
|
||||||
{{end}}
|
<button type="submit" class="btn btn-outline-danger btn-sm"><i class="bi bi-trash"></i> Delete</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{if .Data.Songs}}{{template "song_rows" .Data.Songs}}{{else}}<p class="text-secondary">This playlist is empty.</p>{{end}}
|
{{if .Data.Songs}}{{template "song_rows" .Data.Songs}}{{else}}<p class="text-secondary">This playlist is empty.</p>{{end}}
|
||||||
{{template "footer" .}}{{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" .}}
|
{{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}}
|
{{template "csrf_field" .CSRFToken}}
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<input type="text" name="name" class="form-control" placeholder="New playlist name" required>
|
<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>
|
<button type="submit" class="btn btn-primary w-100"><i class="bi bi-plus-lg"></i> Create</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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}}
|
{{if .Data}}
|
||||||
<div class="list-group">
|
<div class="list-group">
|
||||||
|
|||||||
@@ -3,7 +3,15 @@
|
|||||||
{{range .Data}}
|
{{range .Data}}
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-body">
|
<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>
|
<p class="text-secondary small">{{.Channel.Description}}</p>
|
||||||
<table class="table table-sm vs-table mb-0">
|
<table class="table table-sm vs-table mb-0">
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user