Compare commits

...
10 Commits
Author SHA1 Message Date
owen 866f243f1b weee 2026-08-23 14:13:18 -05:00
owenandClaude Sonnet 5 789bf91eed Complete Phase 4: security hardening, player/playlist/admin UI, docs
Security:
- New internal/netguard package: podcast feed/enclosure fetching now
  refuses to dial non-public IPs (loopback/RFC1918/link-local/cloud
  metadata/CGNAT), validated per-connection including across redirects,
  closing an SSRF gap where a malicious or compromised feed URL could
  probe the server's internal network. Configurable via
  VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS for intentional LAN feeds.
- Podcast episode downloads capped at 4GB (was unbounded, disk-exhaustion risk)
- Cover art decoding now rejects images above a 40-megapixel budget
  before full decode (decompression-bomb guard)
- HTTP server sets ReadHeaderTimeout (slowloris hardening)
- Admin folder-scan redirect now validated as same-site instead of
  trusting the raw Referer header (open-redirect fix)
- Legacy Subsonic token/salt auth now uses a constant-time comparison

Web UI completion:
- Persistent player: shuffle and repeat (off/all/one) controls, wired
  into queue advance/prev logic and persisted like other player state
- Admin user-permissions form: adminRole toggle (with a guard against
  demoting the last remaining admin) and per-user music-folder access
  checkboxes, backed by existing schema/enforcement that was previously
  unreachable from the UI
- Playlist M3U8 import/export

Docs: added docs/client-compatibility.md (honest tracking template —
no client testing has been performed), docs/deployment.md,
docs/security.md, docs/backup.md; refreshed README and cross-links;
added a footnote in docs/subsonic-compatibility.md distinguishing
spec-standard from VibeSonic-original OpenSubsonic extensions.

Also added Go fuzz tests for the external-auth CIDR/IP parser and the
LRC lyrics parser.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
2026-08-23 09:18:17 -05:00
owenandClaude Sonnet 5 933ed31550 Add a web player lyrics panel with active-line highlighting and click-to-seek
New GET /player/lyrics/{id} (auth-required) returns the best available
stored structuredLyrics entry for a song — preferring a synced one —
as plain JSON lines with a start time only where the source actually
has one.

The player bar gains a lyrics toggle; the panel fetches lyrics on
every song change, renders each line, and — only for lines that
actually carry a startMs — makes them clickable (seeks + plays) and
highlights the currently active line as playback progresses. Plain
(unsynced) lyrics render as static text with no fabricated timing or
highlighting.

Note: verified via Go template parsing/rendering and the new handler
tests, plus a JS syntax check; not manually exercised in a browser in
this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
2026-08-23 08:39:22 -05:00
owenandClaude Sonnet 5 238cf0d4a0 Update docs/subsonic-compatibility.md with the OpenSubsonic extension matrix
Documents every currently-registered OpenSubsonic extension
(apiKeyAuthentication, formPost, getPodcastEpisode, songLyrics,
indexBasedQueue, playbackReport, topSongsByArtistId, transcodeOffset,
transcoding, sonicSimilarity), what backs each one, and what it's
tested against — cross-referencing the extensions-registry regression
guard that fails the build if an advertised extension's handler ever
goes missing. Also updates the getArtistInfo/getAlbumInfo/
getSimilarSongs rows and error-code list for Phase 3 changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
2026-08-23 08:36:48 -05:00
owenandClaude Sonnet 5 a921f9e2bc Add optional MusicBrainz/Cover Art Archive metadata enrichment
getArtistInfo/2 and getAlbumInfo/2 now return a real MusicBrainz
disambiguation/cover-art lookup for entities that carry a locally
tagged MusicBrainz ID, gated behind the new
VIBESONIC_ENABLE_EXTERNAL_METADATA flag (default off). Responses are
cached in the existing external_metadata_cache table for 30 days to
avoid hammering the free MusicBrainz/CAA services.

VibeSonic still works completely offline: with the flag off, or when
an entity has no tagged MusicBrainz ID, or on any network/lookup
failure, the handlers fall back to local-only fields exactly as
before — nothing is ever fabricated, and a failed external lookup
never fails the request.

internal/metadataenrich is a small, independently-testable HTTP client
for the two real APIs (base URLs overridable for tests, exercised
against httptest servers rather than the live network).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
2026-08-23 08:35:40 -05:00
owenandClaude Sonnet 5 2a69f6dc33 Blend real sonic distance into classic getSimilarSongs ranking
similarSongsFor still filters by genre (unchanged, and getSonicSimilarTracks
remains the pure-sonic endpoint), but when the seed song has been
analyzed, genre-matched candidates that also have stored audio_features
are now ordered by actual feature-space distance from the seed instead
of SQL's random order; candidates without analysis data are left at
the end in their existing relative order rather than dropped or given
a fabricated distance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
2026-08-23 08:31:54 -05:00
owenandClaude Sonnet 5 b6acd63c2d Implement sonicSimilarity extension with real audio feature analysis
Adds internal/audiofeatures: decodes each scanned song to PCM via
ffmpeg and computes actual measured characteristics — tempo (via
autocorrelation of the frame energy envelope, left unset rather than
guessed when a clip is too short to contain a beat), an approximate
loudness figure, spectral centroid/rolloff and zero-crossing rate (via
a hand-written FFT), and a 12-bin chroma vector used both for a
Krumhansl-Kessler key estimate and as similarity-index input. No
external DSP/ML dependency.

The scanner now runs this analysis after indexing each song (as part
of the already-background scan, not on any request path) and stores
results in the existing audio_features table via the new
database.UpsertAudioFeatures/GetAudioFeatures/ListAllAudioFeatures.

internal/similarity is a small in-process nearest-neighbor index:
z-score normalizes each feature dimension across the analyzed library
and answers Nearest (ranked real distances) and Path (a feature-space
straight-line walk from one track to another, hopping to the closest
not-yet-used real song at each step — never a fabricated intermediate
track).

getSonicSimilarTracks and findSonicPath expose these through the
standard sonicMatch response, converting distance to a normalized
(0,1] similarity score. A song that hasn't been analyzed yet is simply
absent from results rather than faked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
2026-08-23 08:30:20 -05:00
owenandClaude Sonnet 5 6fb46a989e Implement the OpenSubsonic transcoding extension (getTranscodeDecision/getTranscodeStream)
Adds a real capability-matching decision engine: getTranscodeDecision
compares the source file's actual scanned format/channels against the
client's declared directPlayProfiles first, and only when nothing
matches does it search transcodingProfiles for a codec VibeSonic's
ffmpeg pipeline can actually encode (mp3/opus/aac), applying any
codecProfile limitations (bitrate/channel required constraints) the
client listed. canTranscode is never set true for a codec the server
has no encoder for, and canDirectPlay is never set true without a
matching client profile.

getTranscodeStream takes the opaque transcodeParams token the
decision returned (never re-derived) plus a real time offset and
streams actual transcoded audio via the existing seek-capable
transcoding.Manager.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
2026-08-23 08:23:08 -05:00
owenandClaude Sonnet 5 ec28053d36 Implement songLyrics extension: getLyricsBySongId with real word/syllable cues
Adds getLyricsBySongId (versions 1 and 2 of the songLyrics extension,
gated by the enhanced param): returns every stored structuredLyrics
entry for a song, including line-level start times for synced lyrics
and, only when a source actually provided word/syllable timing (an
Enhanced LRC sidecar) and enhanced=true was requested, cueLine/cue
data with the precomputed UTF-8 byte offsets. Plain and non-enhanced
synced lyrics never get fabricated cue data.

Removes the now-dead SetSongLyrics writer (superseded by
database.InsertLyrics); SongLyricsPlainText keeps serving the legacy
getLyrics endpoint unchanged.

Adds a scanner integration test proving a sidecar .lrc (with an
Enhanced LRC line) is discovered and indexed during a real scan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
2026-08-23 08:19:41 -05:00
owenandClaude Sonnet 5 e48f063f03 Add structured lyrics storage and indexing (line/word timing, byte offsets)
Adds database.InsertLyrics/GetSongStructuredLyrics storing multiple
lyrics entries per song with per-line start times and, when the
source provides it, word-level cues carrying precomputed UTF-8 byte
offsets. library.IndexLyrics wires this into the scanner: embedded
plain-text tags are stored unsynced, and a same-basename sidecar .lrc
(plain or Enhanced) is parsed and stored synced, with word cues when
present. IndexFile now takes the media file's absolute path so lyrics
indexing can locate sidecar files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
2026-08-23 08:16:42 -05:00
95 changed files with 7425 additions and 252 deletions
+60 -61
View File
@@ -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
View File
@@ -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.
+69
View File
@@ -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).
+5 -1
View File
@@ -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
+230
View File
@@ -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.
+7 -3
View File
@@ -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.
+160
View File
@@ -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.
+79 -20
View File
@@ -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
View File
@@ -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)
+285
View File
@@ -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
}
+213
View File
@@ -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)
}
}
+335
View File
@@ -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
}
+148
View File
@@ -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
}
+65
View File
@@ -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)
}
+28
View File
@@ -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)
})
}
+38 -1
View File
@@ -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"))
+14
View File
@@ -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()
+21 -1
View File
@@ -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)
+28
View File
@@ -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))
+118
View File
@@ -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()
}
+109
View File
@@ -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()))
}
}
+33 -2
View File
@@ -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
}
+83 -22
View File
@@ -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
+194
View File
@@ -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()
}
+138
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
+3 -55
View File
@@ -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) {
+8
View File
@@ -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
+2 -2
View File
@@ -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
} }
+4 -4
View File
@@ -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)
} }
+101
View File
@@ -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 ""
}
+26
View File
@@ -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)
})
}
+160
View File
@@ -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
}
+119
View File
@@ -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)
}
}
+147
View File
@@ -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
}
+53
View File
@@ -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)
})
}
+85
View File
@@ -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")
}
}
+55 -4
View File
@@ -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
+4 -1
View File
@@ -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) {
+30 -5
View File
@@ -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
+79
View File
@@ -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)
}
}
+203
View File
@@ -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
}
+116
View File
@@ -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 -1
View File
@@ -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
} }
+82 -16
View File
@@ -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
+120
View File
@@ -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
}
+184
View File
@@ -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)
}
}
+94
View File
@@ -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
}
+226
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
+4
View File
@@ -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)
}
}
+137
View File
@@ -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
}
+188
View File
@@ -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)
}
}
+1 -1
View File
@@ -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,
} }
+324
View File
@@ -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
View File
@@ -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)
} }
+195
View File
@@ -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
View File
@@ -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)
+278
View File
@@ -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"
}
}
+1 -1
View File
@@ -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)
} }
+4 -1
View File
@@ -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
} }
+50
View File
@@ -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)
}
+101
View File
@@ -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
View File
@@ -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))
} }
} }
} }
+194
View File
@@ -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
}
+174
View File
@@ -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))
}
}
+3 -3
View File
@@ -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)
} }
+22
View File
@@ -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)
}
+13
View File
@@ -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))
+68
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
+14 -5
View File
@@ -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 {
@@ -32,10 +34,15 @@ type SongRow struct {
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 +63,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
+5
View File
@@ -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;
+63
View File
@@ -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 {
@@ -262,6 +277,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 +305,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 +367,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;
}
+324 -8
View File
@@ -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,10 +47,68 @@
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;
@@ -51,6 +120,7 @@
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 +131,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 +214,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 +240,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 +286,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) {
@@ -165,8 +329,32 @@
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 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 +474,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 +610,6 @@
var player = initPlayer(); var player = initPlayer();
initKeyboardShortcuts(player); initKeyboardShortcuts(player);
initStarButtons(); initStarButtons();
initInlineNavigation();
}); });
})(); })();
+51
View File
@@ -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}}
+50
View File
@@ -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}}
+7
View File
@@ -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>
+37
View File
@@ -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>
+46
View File
@@ -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}}
+19 -2
View File
@@ -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>
+3
View File
@@ -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}}
+10
View File
@@ -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 -2
View File
@@ -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}}
@@ -26,11 +26,17 @@
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>
+4
View File
@@ -15,6 +15,7 @@
{{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="">
<div class="vs-player-meta"> <div class="vs-player-meta">
@@ -23,15 +24,18 @@
</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>
+1 -1
View File
@@ -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>
+9 -6
View File
@@ -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}}
+12
View File
@@ -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}}
+4 -1
View File
@@ -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">
+9 -1
View File
@@ -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>