Velvet UI: fix critical API mismatches, add /velvet side-by-side, enable radio + podcasts#566
Open
IrosTheBeggar wants to merge 12 commits intomasterfrom
Open
Velvet UI: fix critical API mismatches, add /velvet side-by-side, enable radio + podcasts#566IrosTheBeggar wants to merge 12 commits intomasterfrom
IrosTheBeggar wants to merge 12 commits intomasterfrom
Conversation
Four small additive corrections discovered during the Velvet ↔ backend
audit. None of them change behaviour for non-Velvet clients.
- `/api/v1/files/art`: emit both `file` and `aaFile` in the response.
The Velvet UI reads `aaFile` at webapp/velvet/app.js:876, 1273, 6350,
6895 and was getting undefined — on-demand art extraction for
unscanned/uploaded files silently fell back to a placeholder. Keeping
`file` alongside is zero-cost (two bytes) and avoids coordinating a
docs/client update.
- `/api/v1/db/decade/albums` and `/api/v1/db/genre/albums`: the row
mapping now also emits `aaFile: album_art_file`. The Velvet grid
renderer in `_albArtUrl()` reads `aaFile`; decade and genre album
grids were showing names but no art. Additive — existing shape is
preserved.
- New `GET /api/v1/albums/art-file?p=…`: serves an image file located
inside a library root, addressed by its vpath-qualified relative path
(e.g. `Music/Artist/Album/cover.jpg`). Access-gated on the caller
having the vpath; traversal guarded via the same path.resolve +
startsWith(root + sep) pattern used by src/dlna/time-seek.js;
extension whitelisted to image types to block exfiltration through
the art URL. Velvet's `_albArtUrl()` falls back to this when an album
row has `artFile` but no `aaFile` — today most albums will have
`aaFile` (our scanner routes art to image-cache), so this is mostly
a safety net for folder-scanned art.
- Removed the duplicate `/api/v1/admin/directories` mount. src/api/admin.js
already implements this route with the keyed-object shape the Velvet
admin panel reads; the stub here returned an array-of-objects and
would have broken the admin panel if it ever won the route-resolution
race. The admin-gate middleware in admin.js (`mstream.all('/api/v1/admin/…')`)
is stricter than the stub's inline check too.
Tests: 410/410 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the flat stub (which returned {id, name, artist, year,
album_art_file, track_count, displayName} and left every field the UI
read as undefined) with a proper nested tree:
albums: [{ id, displayName, artist, year, aaFile, artFile, seriesId,
discs: [{ label, discIndex,
tracks: [{ filepath, title, artist, number,
duration }] }] }]
series: []
Lives in its own module (src/api/albums-browse.js) so the SQL and
in-JS grouping logic aren't buried inside velvet-stubs.js. Single-pass
query pulls every (album, track) row the caller can see; rowsToAlbums
walks the rowset once, groups tracks by disc_number (NULL coalesced to
1 so mixed-NULL single-disc albums don't split), and materializes the
discs Map as a sorted array.
Track filepaths are emitted pre-joined to the library name ("<vpath>/
<relative>") so Player.setQueue() can feed them straight to /media/…
without a second round-trip. aaFile is our existing album_art_file
column; artFile stays null until we implement folder-scan art
detection; seriesId stays null until we add box-set grouping.
The old stub in velvet-stubs.js is removed and replaced with a
comment pointer. server.js's velvet-only loader now dynamic-imports
the new module alongside the other velvet-gated modules.
Tests: 410/410 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five endpoints silently ignored the scoping fields the Velvet UI has
been passing — behaviour looked plausible (everything returned data)
but audiobook-exclusion and "Albums Only" modes were cosmetic and
Auto-DJ ignored its own settings. libraryFilter() grows a third
optional `options` arg that callers opt into; the existing (user,
ignoreVPaths) signature continues to work unchanged for every caller
that doesn't pass it.
includeFilepathPrefixes: Array<{vpath, prefix}>
Per-vpath whitelist. For each listed vpath, rows only pass if
their filepath starts with one of that vpath's prefixes. Rows in
other vpaths are unaffected. Used by "Albums Only" to restrict a
parent vpath to just the prefix its albumsOnly child targets.
excludeFilepathPrefixes: Array<{vpath, prefix}>
Same shape, blacklist. Used by audio-book exclusions to strip the
audiobook slice from music-view queries when books live under a
parent music vpath.
filepathPrefix: string
Single unconditional prefix, applied to all rows. Auto-DJ uses
this after narrowing to a single parent vpath via ignoreVPaths.
Threaded through /api/v1/db/{album-songs, search, rated, recent/added,
stats/recently-played, stats/most-played, random-songs}. Each Joi
schema is extended to allow the new fields (without, sends would
have been 403'd); the handlers pick the options out of req.body via a
small helper.
SQL `LIKE ... ESCAPE '\'` with % and _ escaped in the prefix string
so a user-supplied "50%" doesn't match anything whose path contains
a five followed by anything.
random-songs also gets `artists` (whitelist, case-insensitive) and
`ignoreArtists` (blacklist). Together they let Auto-DJ bias picks
toward Last.fm similar-artists suggestions and enforce the 15-song
artist-repeat cooldown without client-side filtering.
/db/search responses now include a `folders` array (distinct parent
directories whose path contains the search term, returned as
`{browse_path, folder_name}` objects Velvet's folder-search strip
consumes) and `artists[].variants` — a placeholder single-element
array until we track artist aliases. The UI handles single-element
variants gracefully.
Tests: 410/410 pass. Lint count unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the 501 stub. Velvet calls this from the recording-view
delete button and from the playlist context menu for arbitrary tracks.
Safeguards mirror the tag-writer and album-art-embed paths:
- unauthenticated requests rejected with 401;
- server-wide noFileModify or per-user allow_file_modify=0 gates the
operation with 403;
- filepath is resolved through getVPathInfo so callers can only
address files in libraries they already have access to;
- path.resolve + startsWith(root + sep) guards against traversal
(same pattern as src/dlna/time-seek.js).
After unlink(), the tracks row is dropped so the track vanishes from
browse/search immediately instead of waiting for the next scan.
Orphan user_metadata rows become unreachable and playlist_tracks
entries fail to resolve at playback time — both are self-healing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Users can now have a plaintext Subsonic-specific password separate
from their mStream password. The POST /api/v1/admin/users/subsonic-password
endpoint (previously a 501 stub) accepts {username, password} and
writes it to the new users.subsonic_password column; empty/null
clears it. Admin-gated via the /api/v1/admin/* prefix middleware.
Subsonic auth (src/api/subsonic/auth.js:userForPassword) checks the
new column first, falls through to the existing PBKDF2 comparison
on users.password so current clients keep working. When a Subsonic-
specific password is set, clients can use either one.
Plaintext storage is necessary — Subsonic's u/p flow has no
protocol-level hash handshake; the client sends plaintext and the
server compares plaintext. Token auth (t/s) is even worse (requires
server-side plaintext). Document the tradeoff in the admin UI so
operators know what they're opting into.
Schema V24 is a simple ALTER TABLE ADD COLUMN (nullable, default
NULL). V25 and V26 land in the same migration bundle because they're
coming next — radio_stations and podcast_feeds/episodes tables to
back the Subsonic getInternetRadioStations/getPodcasts endpoints
(currently empty stubs) alongside Velvet's radio/podcast UI.
Tests: 410/410 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two new modules — src/api/radio.js and src/api/podcasts.js —
each backing its feature in both the Velvet UI and the Subsonic REST
API. Same per-user rows, different projections.
Internet radio (src/api/radio.js)
• radio_stations table (V25) keyed on (user_id, id) with order_idx
for stable drag-reorder.
• Velvet endpoints: GET/POST/PUT/DELETE /api/v1/radio/stations,
PUT /reorder, plus the art + stream proxies the Velvet player
uses because <audio> can't send auth headers or handle
CORS-restrictive upstreams. SSRF-guarded against loopback /
RFC1918 / link-local / ULA / mapped-v4 — resolves hostname
once and uses the resolved IP as the connect target so DNS
rebinding can't redirect to a private address mid-stream.
• Subsonic handlers: getInternetRadioStations (upgraded from
empty stub), createInternetRadioStation, updateInternetRadioStation,
deleteInternetRadioStation. Subsonic ids are `rad-<N>`.
• /api/v1/radio/enabled now returns {enabled: true} so the
Velvet sidebar surfaces the radio section.
Podcasts (src/api/podcasts.js)
• podcast_feeds + podcast_episodes tables (V26). Episodes keyed
on (feed_id, guid) with a pub_date index so getNewestPodcasts's
cross-feed ordering is index-backed.
• RSS 2.0 parser via fast-xml-parser. iTunes namespace
extensions (itunes:duration, itunes:image, itunes:author)
populate fields vanilla RSS doesn't have. Atom isn't handled
explicitly but most podcast hosts wrap Atom in an RSS shell.
• Velvet endpoints: GET / POST / PATCH / DELETE
/api/v1/podcast/feeds (+ /:id/refresh, /reorder), GET
/api/v1/podcast/episodes/:feedId, POST
/api/v1/podcast/episode/save. save downloads the enclosure
into <vpath-root>/Podcasts/<feed-title>/<episode-title>.<ext>,
respecting noFileModify + allow_file_modify permissions and
the usual traversal guard; the next scan picks up the file.
• Subsonic handlers: getPodcasts (upgraded from empty stub),
getNewestPodcasts, createPodcastChannel, deletePodcastChannel,
deletePodcastEpisode, downloadPodcastEpisode (ack-only today;
the Velvet save path is the real disk-fetch entrypoint),
refreshPodcasts. Subsonic ids are `pc-<N>` for channels and
`pe-<N>` for episodes.
• Same SSRF guard as radio. Bounded size (10 MB RSS, 500 MB
enclosure) + 30 s fetch timeout so a hostile or malformed
feed can't burn resources.
Both modules removed from velvet-stubs.js and mounted in
server.js's velvet-only dynamic-import block before the stubs
module so real handlers win route resolution. Subsonic method
status table in src/api/subsonic/index.js flips the relevant
entries from 'stub' to 'full'.
Recording + schedules stay deferred — need an ffmpeg child-
process manager + per-user cron and aren't blocking the main UX.
Tests: 410/410 pass. Schema migrations V25+V26 run on fresh DBs
from SCHEMA_VERSION = 26 already bumped in the V24 commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously /api/v1/lastfm/connect wrote the caller's Last.fm password to users.lastfm_password (plaintext) and let the Scribble class do the auth.getMobileSession exchange lazily on the first scrobble. The password was unnecessary after that first exchange — every subsequent scrobble / now-playing / love call signs with the session key, not the password. Storing a credential we don't need is an avoidable risk. V27 adds users.lastfm_session_key. /lastfm/connect now does the handshake itself (the fetchLastfmSessionKey helper factors that logic out of the test-login handler so both callers can reuse it), stores only the session key, and actively nulls lastfm_password so an upgrade-then-reconnect cycle scrubs the old value. Backward compatibility: - Existing rows with lastfm_password and null session_key keep working; Scribble's MakeSession flow exchanges on first use as before. - The scrobbler.js boot loop prefers the session key, falls back to the password when the session key is absent. - Scribble.addUser gains an optional sessionKey arg; MakeSession short-circuits when one's cached and fails quietly when neither is set (rather than trying to hash a null password). Handshake now uses https://ws.audioscrobbler.com/2.0/ instead of the http variant the old test-login handler used. Last.fm supports TLS and there's no reason to send authentication over plaintext. Tests: 409/410. The lrclib full-suite flake is a pre-existing timing issue — passes in isolation (17/17), reproduces on master without these changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an explanatory comment inside the try/catch that wraps upstream.destroy() so no-empty lint is happy; the catch is intentionally silent because the upstream socket may have already ended by the time cleanup fires. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Found during a pass over the radio + podcast + subsonic-password
additions.
- IPv6 link-local guard was too narrow: `startsWith('fe80')` only
caught fe80::/16, missing fe9x/feax/febx (the actual fe80::/10
range is encoded as first byte 0xfe + top 2 bits of the second
byte = 10, i.e. second hex digit in {8,9,a,b}). Fixed in both
radio.js and podcasts.js with `/^fe[89ab]/`.
- _fetchBytes / _downloadToFile in podcasts.js recursed on HTTP
redirects without a depth budget — a loop could blow the call
stack. Added MAX_REDIRECTS = 5; each hop re-runs the SSRF
validation on the new URL so a redirect still can't tunnel into
a private address.
- Subsonic auth's subsonic_password compare used `===`, vulnerable
to timing analysis. Switched to crypto.timingSafeEqual via an
_constantTimeEqual helper that handles the equal-length-buffer
requirement. Consolidated with the module's existing crypto import
(subsonic/auth.js had ended up with two `import crypto` lines —
the second was dead weight since the first is hoisted to the top
of the module graph).
- refreshFeed's UPDATE of podcast_feeds title/description/image_url
used unconditional assignment, so a feed subscribed under a user-
supplied friendly name would get blanked on refresh if the RSS
didn't declare a channel title. Switched to COALESCE so parsed
values only overwrite when non-null. User-supplied titles survive
refreshes now.
- downloadPodcastEpisode (Subsonic) used to run `UPDATE ... SET
downloaded = 0` which is a no-op (0 is the default) and misleading
about whether a download was queued. Removed. Handler still verifies
ownership + acks with ok; the Velvet /api/v1/podcast/episode/save
route remains the actual disk-fetch entrypoint.
Tests: 410/410 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Velvet is now always reachable at /velvet/ regardless of the
config.program.ui setting, so default-UI installs can demo or
switch to Velvet without touching config.json. Previously Velvet
was only available when ui='velvet' mounted it at root.
Server-side changes (src/server.js):
- Added a static mount `app.use('/velvet', express.static(velvetDir))`
positioned before the root static so /velvet/foo.js resolves
inside webapp/velvet/ cleanly. Skipped when ui='velvet' already
since the root mount covers everything and a duplicate would be
wasteful.
- Added `velvet` to the Subsonic SPA catch-all skip list so the
Refix fallback doesn't swallow /velvet/* requests when
ui='subsonic'.
- Removed the `if (ui==='velvet')` gate around the velvet-feature
API modules (listenbrainz, smart-playlists, wrapped, user-settings,
discogs, cuepoints, albums-browse, radio, podcasts, velvet-stubs).
The UI at /velvet/ needs them regardless of which primary UI is
selected, and they're all additive — none override routes the
default or subsonic UIs depend on.
Velvet HTML path fixes:
- webapp/velvet/index.html — absolute `/app.js`, `/style.css`, and
`/assets/fav/*` references changed to relative so they resolve
correctly whether the page is served from `/` (standalone velvet
mode) or `/velvet/` (side-by-side). PWA manifest `start_url` +
icon paths are now derived from window.location so an installed
PWA from /velvet/ lands back there, not at /.
- webapp/velvet/remote/index.html — "Try Another Code" fallback
link was hard-coded to `/remote/`; changed to `../remote/` so it
stays inside the /velvet/ mount under side-by-side.
- webapp/velvet/qr/index.html — same favicon path cleanup as
index.html.
Both modes verified:
- Standalone velvet (ui='velvet'): page at /, relative `app.js`
resolves to /app.js which maps to webapp/velvet/app.js via the
root static (unchanged behaviour).
- Side-by-side (ui='default' or 'subsonic'): page at /velvet/,
relative `app.js` resolves to /velvet/app.js which the /velvet
static mount serves from webapp/velvet/app.js.
- API / /media / /album-art / /rest / /dlna paths stay absolute
in the JS, which is correct — those endpoints are at root
regardless of where the UI is served from.
Tests: 410/410 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit turned up a three-way mismatch in the YouTube download flow:
- Default UI (alpha) calls POST /api/v1/ytdl/, GET
/api/v1/ytdl/metadata?url=, GET /api/v1/ytdl/downloads. All three
exist on the backend with matching body shapes — default works.
- Velvet UI calls GET /api/v1/ytdl/info?url= (doesn't exist) and
POST /api/v1/ytdl/download (wrong path — backend is at
/api/v1/ytdl/). Body fields also differ: Velvet sends
{url, title, artist, album, format}; backend expects
{url, outputCodec, directory, metadata}. Every button in
Velvet's YouTube section would 404.
Velvet gates the YouTube nav button, detail view, settings section,
and the Listen-nav tile on `ping.allowYoutubeDownload === true`. The
field was emitted from /api/v1/ping with the exact comment
"VELVET ONLY: redundant with noUpload — update Velvet UI to use
noUpload instead, then remove this" — the TODO has now come due.
Dropping the field makes Velvet's `d.allowYoutubeDownload === true`
evaluate to false across all five usage sites; the nav button stays
hidden, viewYouTubeDownload's first-line `if (!S.allowYoutubeDownload)`
renders the "not enabled" info panel if anyone back-button-navigates
into it, and the settings section / Listen tile disappear. No Velvet
UI edits needed — all paths already handle the unset case.
Default UI is untouched — it never referenced allowYoutubeDownload
(grep-verified across webapp/alpha/ and webapp/assets/).
Tests: 410/410 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ained asset loads
Deep audit of the Velvet UI vs our backend caught three issues the
earlier rounds missed.
1. Jukebox update-playlist body mismatch (critical).
webapp/velvet/app.js:7679 POSTs {code, tracks, idx}. The backend
Joi schema in src/api/remote.js:131 declared a strict {code,
playlist} contract — `tracks` and `idx` were rejected with 403,
and the jukebox silently stopped mirroring Velvet's queue to
remote clients. Default UI (webapp/assets/js/mstream.jukebox.js)
sends {code, playlist} which still worked.
Fix: relax the schema to accept `playlist` OR `tracks`, treat
`tracks` as an alias, and silently accept the optional `idx`
for forward compat. GET /get-playlist continues to emit only
`playlist` so no consumer has to change.
2. Velvet's copy of /remote/:id read the wrong response shape
(critical but dead-code).
webapp/velvet/remote/index.html:283 treated the whole get-now-
playing response as the song (`np.filepath`, `np.title` etc.),
and get-playlist's 313 line read `pl.tracks` / `pl.idx`. The
backend actually emits `{nowPlaying: ...}` and `{playlist: [...]}`.
Velvet's copy is currently unreachable (src/api/remote.js:209
always serves webapp/remote/index.html regardless of ui=velvet),
so in practice remote control works because the default UI's
remote page — which reads the correct shape — is the one served.
The fix is still worth applying so the file isn't a landmine
for anyone who later switches the backend to ui-aware template
selection.
3. Side-by-side mount 404s audiomotion-analyzer.js (critical for
/velvet/ under ui=default).
webapp/velvet/index.html used `../assets/js/lib/...` for library
loads. That escapes the /velvet mount and tries to fetch from
the default UI's webapp/assets/js/lib/, which has butterchurn
and qr but not audiomotion-analyzer.js (it only lives in
webapp/velvet/assets/). Under ui=velvet everything worked
because the root static already maps to webapp/velvet/. Under
ui=default + /velvet/ side-by-side, the VU-meter script 404s
and the visualizer panel is broken.
Fix: rewrite `../assets/` → `assets/` so all five lib scripts,
four favicons, and the two PWA manifest icons load from Velvet's
own assets dir regardless of which mount serves the page. The
manifest's start_url computation from window.location stays
intact so an installed PWA from /velvet/ relaunches there.
Tests: 410/410 pass. Subagent also confirmed the other audit
categories (cuepoints, waveform, playlist load, shares, search
shape, smart-playlists, admin panel, auth, websockets) are working
end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Makes the Velvet UI actually work end-to-end against our backend, plus ports the Velvet-shaped radio/podcast features into both Velvet and our Subsonic API. Adds a
/velvet/side-by-side serving path so a default-UI install can try Velvet without changing config.Twelve commits; each isolated and independently revertable. Backend code was rewritten against our normalized SQLite schema rather than copied from the velvet fork (whose server code targets a denormalized
filestable we do not want).What shipped
Velvet UI shape-match fixes
src/api/albums-browse.js): the old stub returned a flat row the UI could not read. Now emits the nested tree{albums: [{id, displayName, artist, year, aaFile, artFile, seriesId, discs:[{label, discIndex, tracks:[{filepath, title, artist, number, duration}]}]}]}. Single-pass query, in-JS disc grouping.src/api/db.js):filepathPrefix,includeFilepathPrefixes,excludeFilepathPrefixesthreaded throughlibraryFilter()and used by/db/{album-songs, search, rated, recent/added, stats/*, random-songs}. Audio-book exclusion and Albums-Only scoping were cosmetic before.src/api/db.js):random-songsnow supportsartistswhitelist andignoreArtistsblacklist. Last.fm similar-artists bias and 15-song artist cooldown actually work server-side.folders[]andartists[].variantsso Velvet's folder-search strip and merged-artist drill-down wire up./api/v1/files/artemits bothfileandaaFile; decade/genre album grids emitaaFilealongsidealbum_art_fileso art renders./api/v1/albums/art-file?p=for folder-scanned art with SSRF, traversal, and extension-whitelist guards.DELETE /api/v1/files/recordingreplaces the 501 stub with a real implementation gated onallow_file_modify.update-playlistbody accepts{code, playlist}OR{code, tracks, idx}. Velvet uses the latter./remote/:idpoll fixed to readres.nowPlayingandres.playlistinstead of treating the envelope as the song itself.allowYoutubeDownloadfrom the ping response; Velvet gracefully hides the now-404'ing YouTube UI. The default UI's ytdl flow (which does work) is untouched.New features with both Velvet and Subsonic parity
src/api/radio.js+ SubsonicgetInternetRadioStations/createInternetRadioStation/update.../delete...): per-user stations, drag-reorder, art and stream proxies with SSRF guard (resolves hostname once and connects by IP so DNS rebinding cannot redirect mid-stream).src/api/podcasts.js+ SubsonicgetPodcasts/getNewestPodcasts/createPodcastChannel/delete.../refreshPodcasts): feeds + episodes, RSS 2.0 parser viafast-xml-parser, save-to-library downloads, bounded size (10 MB RSS / 500 MB enclosure), fetch timeout, and redirect-depth cap.users.subsonic_password) withPOST /api/v1/admin/users/subsonic-passwordto set it. Subsonic auth checks viacrypto.timingSafeEqual, falls back to PBKDF2 on the mStream password for backward compat./lastfm/connectnow runs theauth.getMobileSessionhandshake itself and stores only the session key inusers.lastfm_session_key, clearing the plaintext password. Existing rows with password keep working via the Scribble class's lazy exchange./velvet/side-by-side serving/velvet/always serves Velvet regardless ofconfig.ui(skipped whenui=velvetto avoid double-mount). Subsonic SPA catch-all skip list updated so it does not swallow/velvet/*./app.js,/style.css,/assets/fav/*, the<link>tags in login/admin) converted to relative so they work under either mount. PWA manifest derivesstart_urland icon paths fromwindow.locationso installing from/velvet/relaunches there.ui=velvet; always loaded so/velvet/has a working backend in any primary-UI mode.Security and robustness pass
startsWith('fe80')missed fe9x/feax/febx). Fixed to match the correct fe80::/10 boundary.MAX_REDIRECTS = 5) added to podcast fetch helpers; each hop re-runs SSRF validation.subsonic_password.refreshFeedusesCOALESCEso user-set feed titles are not blanked when the RSS does not declare one.xmlEscapestrips lone surrogate halves and U+FFFE/U+FFFF for XML 1.0 compliance (from the earlier DLNA round, included here).Schema migrations
V24 through V27 are all additive and nullable /
IF NOT EXISTS-safe. No rescan required.users.subsonic_password TEXTradio_stationstable +(user_id, order_idx)indexpodcast_feeds+podcast_episodestables, unique(feed_id, guid),pub_dateindexusers.lastfm_session_key TEXTAll new tables have FK cascade on user delete so user cleanup does not leave orphans.
Compatibility
webapp/alpha/andwebapp/assets/js/*.jsdo not reference any of the fields or endpoints I added or renamed.Validation
no-emptyerrors I had introduced during development; they are all now annotated or closed).ui=default+/velvet/side-by-side AND standaloneui=velvet): all critical paths return 200 —/velvet/,/velvet/app.js,/velvet/style.css,/velvet/assets/js/lib/audiomotion-analyzer.js,/velvet/assets/fav/favicon.ico,/api/v1/radio/enabled,/api/v1/albums/browse, ping minusallowYoutubeDownload.Known deferred
ping.allowRadioRecordingflag we do not emit, so the UI hides cleanly.seriesIdgrouping for box-set albums (UI handles null seriesId; no broken state).albums-browse.artFile(low-impact with our image-cache scanner)./remote/:idand/shared/:idalways serve the default UI's templates regardless ofui=velvet. Pre-existing; not a regression.Test plan
ui='default'. Hit/velvet/in a browser — should load the Velvet player with all assets (check devtools for 404s; the audiomotion-analyzer.js fix is the canary).ui='default', hit/— default UI still works.ui='velvet'./serves Velvet as before;/velvet/returns 404 (expected — no double-mount).POST /api/v1/radio/stations, verify it appears via bothGET /api/v1/radio/stations(Velvet) and/rest/getInternetRadioStations(Subsonic).POST /api/v1/podcast/feeds, confirm episodes populate after the initial RSS fetch; save an episode and verify the file lands under<vpath>/Podcasts/<feed>/<episode>.<ext>.POST /api/v1/admin/users/subsonic-passwordthen confirm a Subsonic client can log in with the new password./lastfm/connectwith valid creds saveslastfm_session_keyand clearslastfm_password;/lastfm/disconnectnulls both.🤖 Generated with Claude Code