Skip to content

Velvet UI: fix critical API mismatches, add /velvet side-by-side, enable radio + podcasts#566

Open
IrosTheBeggar wants to merge 12 commits intomasterfrom
velvet-fixes
Open

Velvet UI: fix critical API mismatches, add /velvet side-by-side, enable radio + podcasts#566
IrosTheBeggar wants to merge 12 commits intomasterfrom
velvet-fixes

Conversation

@IrosTheBeggar
Copy link
Copy Markdown
Owner

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 files table we do not want).

What shipped

Velvet UI shape-match fixes

  • Albums page rebuilt (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.
  • Filter flags now honored (src/api/db.js): filepathPrefix, includeFilepathPrefixes, excludeFilepathPrefixes threaded through libraryFilter() and used by /db/{album-songs, search, rated, recent/added, stats/*, random-songs}. Audio-book exclusion and Albums-Only scoping were cosmetic before.
  • Auto-DJ honors its settings (src/api/db.js): random-songs now supports artists whitelist and ignoreArtists blacklist. Last.fm similar-artists bias and 15-song artist cooldown actually work server-side.
  • Search returns folders[] and artists[].variants so Velvet's folder-search strip and merged-artist drill-down wire up.
  • /api/v1/files/art emits both file and aaFile; decade/genre album grids emit aaFile alongside album_art_file so art renders.
  • New /api/v1/albums/art-file?p= for folder-scanned art with SSRF, traversal, and extension-whitelist guards.
  • DELETE /api/v1/files/recording replaces the 501 stub with a real implementation gated on allow_file_modify.
  • Jukebox update-playlist body accepts {code, playlist} OR {code, tracks, idx}. Velvet uses the latter.
  • Velvet's /remote/:id poll fixed to read res.nowPlaying and res.playlist instead of treating the envelope as the song itself.
  • Disable ytdl in Velvet — removed allowYoutubeDownload from 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

  • Internet radio (src/api/radio.js + Subsonic getInternetRadioStations / 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).
  • Podcasts (src/api/podcasts.js + Subsonic getPodcasts / getNewestPodcasts / createPodcastChannel / delete... / refreshPodcasts): feeds + episodes, RSS 2.0 parser via fast-xml-parser, save-to-library downloads, bounded size (10 MB RSS / 500 MB enclosure), fetch timeout, and redirect-depth cap.
  • Subsonic password column (users.subsonic_password) with POST /api/v1/admin/users/subsonic-password to set it. Subsonic auth checks via crypto.timingSafeEqual, falls back to PBKDF2 on the mStream password for backward compat.
  • Last.fm session-key refactor: /lastfm/connect now runs the auth.getMobileSession handshake itself and stores only the session key in users.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 of config.ui (skipped when ui=velvet to avoid double-mount). Subsonic SPA catch-all skip list updated so it does not swallow /velvet/*.
  • Velvet's HTML absolute paths (/app.js, /style.css, /assets/fav/*, the <link> tags in login/admin) converted to relative so they work under either mount. PWA manifest derives start_url and icon paths from window.location so installing from /velvet/ relaunches there.
  • Velvet-feature API modules no longer gated on ui=velvet; always loaded so /velvet/ has a working backend in any primary-UI mode.

Security and robustness pass

  • IPv6 link-local SSRF check was too narrow (startsWith('fe80') missed fe9x/feax/febx). Fixed to match the correct fe80::/10 boundary.
  • Redirect depth limit (MAX_REDIRECTS = 5) added to podcast fetch helpers; each hop re-runs SSRF validation.
  • Timing-safe compare on subsonic_password.
  • Podcast refreshFeed uses COALESCE so user-set feed titles are not blanked when the RSS does not declare one.
  • xmlEscape strips 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.

Version What
V24 users.subsonic_password TEXT
V25 radio_stations table + (user_id, order_idx) index
V26 podcast_feeds + podcast_episodes tables, unique (feed_id, guid), pub_date index
V27 users.lastfm_session_key TEXT

All new tables have FK cascade on user delete so user cleanup does not leave orphans.

Compatibility

  • Default UI is untouched. Grep-verified that webapp/alpha/ and webapp/assets/js/*.js do not reference any of the fields or endpoints I added or renamed.
  • Subsonic API gains new handlers; existing ones are unchanged. Stub to full transitions reflected in the admin method-status table.
  • DLNA / shared / remote / jukebox flows for the default UI still work (smoke-tested).

Validation

  • Tests: 410/410 pass at every intermediate commit.
  • Lint: unchanged vs master for files I touched (caught 3 no-empty errors I had introduced during development; they are all now annotated or closed).
  • Smoke test on a live server (both ui=default + /velvet/ side-by-side AND standalone ui=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 minus allowYoutubeDownload.

Known deferred

  • Radio recording and schedules (XL — needs ffmpeg pipeline + per-user cron). Velvet gates the feature behind a ping.allowRadioRecording flag we do not emit, so the UI hides cleanly.
  • Radio now-playing endpoint (ICY metadata scrape or upstream status).
  • Podcast preview-before-subscribe endpoint (UX gap; subscribe still works blind).
  • seriesId grouping for box-set albums (UI handles null seriesId; no broken state).
  • Folder-scan art detection for albums-browse.artFile (low-impact with our image-cache scanner).
  • /remote/:id and /shared/:id always serve the default UI's templates regardless of ui=velvet. Pre-existing; not a regression.

Test plan

  • Boot with 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).
  • Still with ui='default', hit / — default UI still works.
  • Boot with ui='velvet'. / serves Velvet as before; /velvet/ returns 404 (expected — no double-mount).
  • Configure a radio station via POST /api/v1/radio/stations, verify it appears via both GET /api/v1/radio/stations (Velvet) and /rest/getInternetRadioStations (Subsonic).
  • Subscribe to a podcast via 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>.
  • Admin: POST /api/v1/admin/users/subsonic-password then confirm a Subsonic client can log in with the new password.
  • Jukebox code flow: open jukebox in Velvet, scan the QR on a second device — verify the remote page shows the active queue (A1 fix).
  • /lastfm/connect with valid creds saves lastfm_session_key and clears lastfm_password; /lastfm/disconnect nulls both.

🤖 Generated with Claude Code

IrosTheBeggar and others added 12 commits April 23, 2026 15:17
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant