perf: lazy player source build + Cache-Control on byte endpoints

Two unrelated wins as a single batch.

Flutter — lazy source building in setQueueFromTracks:
Today: Future.wait builds all N AudioSource objects (drift queries +
LockCaching ctor) before the player can call setAudioSources →
play(). Measured at 83ms for a 25-track playlist, on top of the
~285ms initial-source preload.

New flow: build only the initial source, hand it to setAudioSources
([initial], initialIndex: 0) so play() can start, then background-
fill the rest. Forward direction (skipNext targets) added via
addAudioSource. Backward direction (skipPrev) inserted at index 0..
initialIndex-1 with _suppressIndexUpdates true so the unavoidable
currentIndex shifts don't push the wrong MediaItem onto the stream.

Saves the up-front source-build wait — tap-to-audio for long queues
should drop by ~80-100ms even on cache hits.

Server — Cache-Control on the three byte-serving endpoints:
- /api/albums/{id}/cover: max-age=86400, must-revalidate. Covers
  change rarely (re-scan, MBID enrichment); a day of cache is safe
  and skips conditional GETs for the bulk of a session.
- /api/playlists/{id}/cover: max-age=300, must-revalidate. Collages
  recompute when contents change; short enough for edits to feel
  fresh, long enough to skip repeat fetches during a session.
- /api/tracks/{id}/stream: max-age=31536000, immutable. Track bytes
  are immutable for a given id (scanner re-indexes by file_path; new
  files get new ids). LockCachingAudioSource on the Flutter side
  already disk-caches, but proper headers let it skip even the
  conditional 304 on repeat plays.
This commit is contained in:
2026-05-11 22:18:58 -04:00
parent 261b44522d
commit 1ddde12959
3 changed files with 84 additions and 13 deletions
+12
View File
@@ -112,6 +112,12 @@ func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", imageContentType(path))
// Cover bytes change rarely (cover-source enrichment, manual rescan,
// rare MBID-driven re-fetch). One-day max-age + must-revalidate means
// clients skip the conditional GET for the bulk of a session, but
// stale art clears within 24h after a re-scan. ServeContent below
// still emits Last-Modified for the conditional path when needed.
w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate")
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
}
@@ -140,5 +146,11 @@ func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", audioContentType(track.FileFormat))
w.Header().Set("Accept-Ranges", "bytes")
// Track bytes are immutable for a given track id (the scanner
// indexes by file_path; re-encoded files take new ids). One-year
// max-age + immutable lets the client cache (LockCachingAudioSource
// on the Flutter side, browser cache on web) skip even the
// conditional GET on repeat plays.
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
}
+5
View File
@@ -386,6 +386,11 @@ func (h *handlers) handleGetPlaylistCover(w http.ResponseWriter, r *http.Request
return
}
full := filepath.Join(h.dataDir, *detail.CoverPath)
// Playlist collages recompute when the playlist's tracks change
// (system playlists re-rendered on rebuild, user playlists when
// modified). 5 minutes is short enough for normal edits to feel
// fresh, long enough to skip repeat fetches during a session.
w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
http.ServeFile(w, r, full)
}