2021-06-13 08:46:36 -08:00
|
|
|
package server
|
2020-04-06 11:37:15 -08:00
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
2023-03-27 16:36:23 -08:00
|
|
|
"net/http"
|
2020-04-06 11:37:15 -08:00
|
|
|
"net/http/httptest"
|
2021-03-12 07:06:51 -09:00
|
|
|
"os"
|
2020-04-06 11:37:15 -08:00
|
|
|
"regexp"
|
|
|
|
|
"strconv"
|
2021-04-06 18:18:48 -08:00
|
|
|
"strings"
|
2023-03-27 16:36:23 -08:00
|
|
|
"time"
|
2020-04-06 11:37:15 -08:00
|
|
|
|
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
2022-11-29 10:40:44 -09:00
|
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
2024-04-28 08:18:24 -08:00
|
|
|
"github.com/navidrome/navidrome/conf/mime"
|
2020-04-08 07:00:30 -08:00
|
|
|
"github.com/navidrome/navidrome/consts"
|
2020-04-06 11:37:15 -08:00
|
|
|
"github.com/navidrome/navidrome/model"
|
2020-10-27 07:01:40 -08:00
|
|
|
"github.com/navidrome/navidrome/tests"
|
2022-07-26 12:47:16 -08:00
|
|
|
. "github.com/onsi/ginkgo/v2"
|
2020-04-06 11:37:15 -08:00
|
|
|
. "github.com/onsi/gomega"
|
|
|
|
|
)
|
|
|
|
|
|
2020-10-01 05:56:09 -08:00
|
|
|
var _ = Describe("serveIndex", func() {
|
2020-04-06 11:37:15 -08:00
|
|
|
var ds model.DataStore
|
|
|
|
|
mockUser := &mockedUserRepo{}
|
2021-03-12 07:06:51 -09:00
|
|
|
fs := os.DirFS("tests/fixtures")
|
2020-04-06 11:37:15 -08:00
|
|
|
|
|
|
|
|
BeforeEach(func() {
|
2020-10-27 07:01:40 -08:00
|
|
|
ds = &tests.MockDataStore{MockedUser: mockUser}
|
2022-11-29 10:40:44 -09:00
|
|
|
DeferCleanup(configtest.SetupConfig())
|
2020-04-06 11:37:15 -08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("adds app_config to index.html", func() {
|
|
|
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
2023-01-19 18:52:55 -09:00
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
2020-04-06 11:37:15 -08:00
|
|
|
|
|
|
|
|
Expect(w.Code).To(Equal(200))
|
|
|
|
|
config := extractAppConfig(w.Body.String())
|
2025-06-28 16:01:47 -08:00
|
|
|
Expect(config).To(BeAssignableToTypeOf(map[string]any{}))
|
2020-04-06 11:37:15 -08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("sets firstTime = true when User table is empty", func() {
|
|
|
|
|
mockUser.empty = true
|
|
|
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
2023-01-19 18:52:55 -09:00
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
2020-04-06 11:37:15 -08:00
|
|
|
|
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
|
|
|
Expect(config).To(HaveKeyWithValue("firstTime", true))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("sets firstTime = false when User table is not empty", func() {
|
|
|
|
|
mockUser.empty = false
|
|
|
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
2023-01-19 18:52:55 -09:00
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
2020-04-06 11:37:15 -08:00
|
|
|
|
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
|
|
|
Expect(config).To(HaveKeyWithValue("firstTime", false))
|
|
|
|
|
})
|
|
|
|
|
|
2025-06-28 16:01:47 -08:00
|
|
|
DescribeTable("sets configuration values",
|
|
|
|
|
func(configSetter func(), configKey string, expectedValue any) {
|
|
|
|
|
configSetter()
|
|
|
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
|
|
|
|
|
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
|
|
|
Expect(config).To(HaveKeyWithValue(configKey, expectedValue))
|
|
|
|
|
},
|
|
|
|
|
Entry("baseURL", func() { conf.Server.BasePath = "base_url_test" }, "baseURL", "base_url_test"),
|
|
|
|
|
Entry("welcomeMessage", func() { conf.Server.UIWelcomeMessage = "Hello" }, "welcomeMessage", "Hello"),
|
|
|
|
|
Entry("maxSidebarPlaylists", func() { conf.Server.MaxSidebarPlaylists = 42 }, "maxSidebarPlaylists", float64(42)),
|
|
|
|
|
Entry("enableTranscodingConfig", func() { conf.Server.EnableTranscodingConfig = true }, "enableTranscodingConfig", true),
|
|
|
|
|
Entry("enableDownloads", func() { conf.Server.EnableDownloads = true }, "enableDownloads", true),
|
|
|
|
|
Entry("enableFavourites", func() { conf.Server.EnableFavourites = true }, "enableFavourites", true),
|
|
|
|
|
Entry("enableStarRating", func() { conf.Server.EnableStarRating = true }, "enableStarRating", true),
|
|
|
|
|
Entry("defaultTheme", func() { conf.Server.DefaultTheme = "Light" }, "defaultTheme", "Light"),
|
|
|
|
|
Entry("defaultLanguage", func() { conf.Server.DefaultLanguage = "pt" }, "defaultLanguage", "pt"),
|
|
|
|
|
Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)),
|
feat(server): implement FTS5-based full-text search (#5079)
* build: add sqlite_fts5 build tag to enable FTS5 support
* feat: add SearchBackend config option (default: fts)
* feat: add buildFTS5Query for safe FTS5 query preprocessing
* feat: add FTS5 search backend with config toggle, refactor legacy search
- Add searchExprFunc type and getSearchExpr() for backend selection
- Rename fullTextExpr to legacySearchExpr
- Add ftsSearchExpr using FTS5 MATCH subquery
- Update fullTextFilter in sql_restful.go to use configured backend
* feat: add FTS5 migration with virtual tables, triggers, and search_participants
Creates FTS5 virtual tables for media_file, album, and artist with
unicode61 tokenizer and diacritic folding. Adds search_participants
column, populates from JSON, and sets up INSERT/UPDATE/DELETE triggers.
* feat: populate search_participants in PostMapArgs for FTS5 indexing
* test: add FTS5 search integration tests
* fix: exclude FTS5 virtual tables from e2e DB restore
The restoreDB function iterates all tables in sqlite_master and
runs DELETE + INSERT to reset state. FTS5 contentless virtual tables
cannot be directly deleted from. Since triggers handle FTS5 sync
automatically, simply skip tables matching *_fts and *_fts_* patterns.
* build: add compile-time guard for sqlite_fts5 build tag
Same pattern as netgo: compilation fails with a clear error if
the sqlite_fts5 build tag is missing.
* build: add sqlite_fts5 tag to reflex dev server config
* build: extract GO_BUILD_TAGS variable in Makefile to avoid duplication
* fix: strip leading * from FTS5 queries to prevent "unknown special query" error
* feat: auto-append prefix wildcard to FTS5 search tokens for broader matching
Every plain search token now gets a trailing * appended (e.g., "love" becomes
"love*"), so searching for "love" also matches "lovelace", "lovely", etc.
Quoted phrases are preserved as exact matches without wildcards. Results are
ordered alphabetically by name/title, so shorter exact matches naturally
appear first.
* fix: clarify comments about FTS5 operator neutralization
The comments said "strip" but the code lowercases operators to
neutralize them (FTS5 operators are case-sensitive). Updated comments
to accurately describe the behavior.
* fix: use fmt.Sprintf for FTS5 phrase placeholders
The previous encoding used rune('0'+index) which silently breaks with
10+ quoted phrases. Use fmt.Sprintf for arbitrary index support.
* fix: validate and normalize SearchBackend config option
Normalize the value to lowercase and fall back to "fts" with a log
warning for unrecognized values. This prevents silent misconfiguration
from typos like "FTS", "Legacy", or "fts5".
* refactor: improve documentation for build tags and FTS5 requirements
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: convert FTS5 query and search backend normalization tests to DescribeTable format
Signed-off-by: Deluan <deluan@navidrome.org>
* fix: add sqlite_fts5 build tag to golangci configuration
Signed-off-by: Deluan <deluan@navidrome.org>
* feat: add UISearchDebounceMs configuration option and update related components
Signed-off-by: Deluan <deluan@navidrome.org>
* fix: fall back to legacy search when SearchFullString is enabled
FTS5 is token-based and cannot match substrings within words, so
getSearchExpr now returns legacySearchExpr when SearchFullString
is true, regardless of SearchBackend setting.
* fix: add sqlite_fts5 build tag to CI pipeline and Dockerfile
* fix: add WHEN clauses to FTS5 AFTER UPDATE triggers
Added WHEN clauses to the media_file_fts_au, album_fts_au, and
artist_fts_au triggers so they only fire when FTS-indexed columns
actually change. Previously, every row update (e.g., play count, rating,
starred status) triggered an unnecessary delete+insert cycle in the FTS
shadow tables. The WHEN clauses use IS NOT for NULL-safe comparison of
each indexed column, avoiding FTS index churn for non-indexed updates.
* feat: add SearchBackend configuration option to data and insights components
Signed-off-by: Deluan <deluan@navidrome.org>
* fix: enhance input sanitization for FTS5 by stripping additional punctuation and special characters
Signed-off-by: Deluan <deluan@navidrome.org>
* feat: add search_normalized column for punctuated name search (R.E.M., AC/DC)
Add index-time normalization and query-time single-letter collapsing to
fix FTS5 search for punctuated names. A new search_normalized column
stores concatenated forms of punctuated words (e.g., "R.E.M." → "REM",
"AC/DC" → "ACDC") and is indexed in FTS5 tables. At query time, runs of
consecutive single letters (from dot-stripping) are collapsed into OR
expressions like ("R E M" OR REM*) to match both the original tokens and
the normalized form. This enables searching by "R.E.M.", "REM", "AC/DC",
"ACDC", "A-ha", or "Aha" and finding the correct results.
* refactor: simplify isSingleUnicodeLetter to avoid []rune allocation
Use utf8.DecodeRuneInString to check for a single Unicode letter
instead of converting the entire string to a []rune slice.
* feat: define ftsSearchColumns for flexible FTS5 search column inclusion
Signed-off-by: Deluan <deluan@navidrome.org>
* feat: update collapseSingleLetterRuns to return quoted phrases for abbreviations
Signed-off-by: Deluan <deluan@navidrome.org>
* feat: implement extractPunctuatedWords to handle artist/album names with embedded punctuation
Signed-off-by: Deluan <deluan@navidrome.org>
* feat: implement extractPunctuatedWords to handle artist/album names with embedded punctuation
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: punctuated word handling to improve processing of artist/album names
Signed-off-by: Deluan <deluan@navidrome.org>
* feat: add CJK support for search queries with LIKE filters
Signed-off-by: Deluan <deluan@navidrome.org>
* feat: enhance FTS5 search by adding album version support and CJK handling
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: search configuration to use structured options
Signed-off-by: Deluan <deluan@navidrome.org>
* feat: enhance search functionality to support punctuation-only queries and update related tests
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-21 13:52:42 -09:00
|
|
|
Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)),
|
2026-04-04 11:17:01 -08:00
|
|
|
Entry("uiCoverArtSize", func() { conf.Server.UICoverArtSize = 300 }, "uiCoverArtSize", float64(300)),
|
2025-06-28 16:01:47 -08:00
|
|
|
Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true),
|
|
|
|
|
Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true),
|
|
|
|
|
Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"),
|
|
|
|
|
Entry("defaultDownloadableShare", func() { conf.Server.DefaultDownloadableShare = true }, "defaultDownloadableShare", true),
|
|
|
|
|
Entry("devSidebarPlaylists", func() { conf.Server.DevSidebarPlaylists = true }, "devSidebarPlaylists", true),
|
|
|
|
|
Entry("lastFMEnabled", func() { conf.Server.LastFM.Enabled = true }, "lastFMEnabled", true),
|
|
|
|
|
Entry("devShowArtistPage", func() { conf.Server.DevShowArtistPage = true }, "devShowArtistPage", true),
|
|
|
|
|
Entry("devUIShowConfig", func() { conf.Server.DevUIShowConfig = true }, "devUIShowConfig", true),
|
|
|
|
|
Entry("listenBrainzEnabled", func() { conf.Server.ListenBrainz.Enabled = true }, "listenBrainzEnabled", true),
|
|
|
|
|
Entry("enableReplayGain", func() { conf.Server.EnableReplayGain = true }, "enableReplayGain", true),
|
|
|
|
|
Entry("enableExternalServices", func() { conf.Server.EnableExternalServices = true }, "enableExternalServices", true),
|
|
|
|
|
Entry("devActivityPanel", func() { conf.Server.DevActivityPanel = true }, "devActivityPanel", true),
|
|
|
|
|
Entry("shareURL", func() { conf.Server.ShareURL = "https://share.example.com" }, "shareURL", "https://share.example.com"),
|
|
|
|
|
Entry("enableInspect", func() { conf.Server.Inspect.Enabled = true }, "enableInspect", true),
|
|
|
|
|
Entry("defaultDownsamplingFormat", func() { conf.Server.DefaultDownsamplingFormat = "mp3" }, "defaultDownsamplingFormat", "mp3"),
|
|
|
|
|
Entry("enableUserEditing", func() { conf.Server.EnableUserEditing = false }, "enableUserEditing", false),
|
|
|
|
|
Entry("enableSharing", func() { conf.Server.EnableSharing = true }, "enableSharing", true),
|
2025-06-29 06:18:05 -08:00
|
|
|
Entry("devNewEventStream", func() { conf.Server.DevNewEventStream = true }, "devNewEventStream", true),
|
2026-02-23 16:28:38 -09:00
|
|
|
Entry("extAuthLogoutURL", func() { conf.Server.ExtAuth.LogoutURL = "https://auth.example.com/logout" }, "extAuthLogoutURL", "https://auth.example.com/logout"),
|
feat(ui): replace UI scrobble with reportPlayback and redesign NowPlaying panel (#5448)
* feat(config): add UIPlaybackReportInterval setting
* feat(server): expose playbackReportIntervalMs to UI config
* feat(ui): add playbackReportIntervalMs config default
* feat(ui): replace scrobble/nowPlaying with reportPlayback in subsonic API layer
* feat(ui): replace scrobble logic with reportPlayback state machine in Player
* refactor(ui): simplify Player heartbeat using useInterval hook
- Replace manual setInterval/clearInterval with existing useInterval hook
- Extract shared reportPlaybackUrl helper to deduplicate URL construction
- Use ref for currentTrackId in beforeunload to stabilize effect deps
- Have heartbeat read lastPositionMsRef instead of audioInstance.currentTime
* feat(ui): redesign NowPlaying panel with Discord-style layout
Show album art with play/pause overlay icon, track title, artist,
album name, progress bar with position/duration, and username.
* fix(ui): adjust NowPlaying panel height to fit 3 entries
* fix(ui): send stopped on player close and tab close while paused
- onBeforeDestroy now sends reportPlayback stopped before clearing queue
- beforeunload sends stopped beacon regardless of pause state
* feat(ui): animate NowPlaying progress bar with 1s client-side tick
* fix(ui): account for playbackRate in NowPlaying progress interpolation
* fix(ui): use timestamp-based interpolation for smooth NowPlaying progress
Replace tick counter with fetchedAt timestamp so the progress bar
advances smoothly without resetting on each server poll.
* fix(ui): fix NowPlaying progress bar not animating
Pass `now` (Date.now()) as a prop that changes every tick, so
React.memo'd components actually re-render each second.
* fix(ui): prevent progress bar reset on NowPlaying poll
Set fetchedAt and now atomically on fetch so the elapsed offset
starts at zero and the server's already-estimated positionMs
is used as the base without a visible jump.
* fix(ui): stamp entries with fetch time to prevent progress bar reset
Embed _fetchedAt timestamp directly into each entry object so the
position and its reference timestamp are always in the same state
update, eliminating the React 17 multi-setState batching race.
* fix(server): estimate position for starting state in GetNowPlaying
GetNowPlaying was only estimating elapsed position for the "playing"
state, returning raw positionMs=0 for "starting". Since the UI
player sends "starting" once and then doesn't update until the
60s heartbeat, NowPlaying polls returned 0 for up to a minute,
causing the progress bar to reset on every poll.
* fix(ui): send playing immediately after starting to enable position estimation
The server only estimates elapsed position for "playing" state in
GetNowPlaying. The Player was sending "starting" once and then not
updating until the 60s heartbeat, leaving the server state as
"starting" with positionMs=0 for up to a minute.
Now the Player follows up "starting" with an immediate "playing"
call, transitioning the server state so position estimation works
from the first poll.
* fix(subsonic): fix getNowPlaying returning same playerId for all entries
PlayerId was never incremented in the map callback, so every entry
got playerId=1. This caused the UI to use duplicate React keys,
mixing up rendered entries between players. Also use a stable
composite key in the UI instead of the sequential playerId.
* fix(ui): only send stopped beacon when tab is actually closing
Move the reportPlaybackBeacon call from beforeunload to pagehide.
beforeunload fires before the confirmation dialog, so cancelling
the close would still send stopped. pagehide only fires when the
page is actually being unloaded.
* fix(ui): revert to beforeunload for stopped beacon
pagehide does not fire reliably in Chrome when closing tabs.
Use beforeunload instead — if the user cancels the close dialog,
the heartbeat will re-register the NowPlaying entry on its next tick.
* fix(ui): use synchronous XHR for stopped report on tab close
Replace sendBeacon with synchronous XMLHttpRequest in beforeunload.
This blocks the page from closing until the server acknowledges
the stopped state, ensuring the NowPlaying entry is always removed.
* fix(ui): fix confirmation dialog and use fetch keepalive for tab close
- Move e.preventDefault() before the stopped report so the dialog
always shows regardless of XHR errors
- Use fetch with keepalive:true instead of sync XHR (more reliable,
non-blocking, survives page teardown)
- Fall back to sendBeacon if fetch throws
* fix(ui): prevent heartbeat from re-adding entry after stopped on tab close
Set a stoppedRef flag in beforeunload so the heartbeat interval
skips sending playing reports after stopped has been sent.
Without this, the heartbeat could re-register the NowPlaying
entry after the stopped event removed it.
* fix(ui): include client unique ID header in stopped report on tab close
Root cause: reportPlaybackSync (fetch keepalive) did not include the
X-ND-Client-Unique-Id header. Regular reportPlayback calls via
httpClient include this header, and the server uses it as the playMap
key. Without the header, the stopped call fell back to player.ID
as the key, which didn't match the entry added with the UUID key.
The playMap.Remove targeted the wrong key, so the entry persisted.
Fix: export clientUniqueId from httpClient and include it as a header
in the fetch keepalive request.
* fix(ui): use pagehide for stopped report to avoid premature send
beforeunload fires before the confirmation dialog, so the stopped
event was sent even when the user cancelled closing. Move the
stopped report to pagehide, which only fires when the page is
actually being unloaded (after confirmation).
* feat(server): broadcast NowPlaying SSE on every state change
Previously, the SSE broadcast only fired when the NowPlaying count
changed. Now it fires on every reportPlayback call (starting,
playing, paused, stopped), so the NowPlaying panel gets instant
updates for state transitions and position changes.
The UI reducer stores a nowPlayingLastUpdate timestamp alongside
the count, ensuring every SSE event triggers a re-fetch even when
the count is unchanged (e.g., pause/resume).
* fix(ui): clamp NowPlaying position to prevent negative time display
* fix(ui): debounce NowPlaying fetches to prevent progress bar trembling
During track changes, rapid SSE events (stopped, starting, playing)
triggered multiple refetches within milliseconds, each resetting the
interpolation base and causing the progress bar to oscillate. Skip
fetches within 1 second of the previous fetch.
* feat(ui): report playback position on seek
Send a reportPlayback(playing) call when the user seeks/scrubs in
the player, so the NowPlaying panel and server position stay in
sync immediately instead of waiting for the next 60s heartbeat.
* refactor: code review cleanup
- Export clientUniqueIdHeader from httpClient, use in subsonic layer
- Fix variable shadowing (now → fetchNow) in NowPlayingPanel fetchList
- Fix onBeforeDestroy nested dep (read isRadio from ref instead)
- Only broadcast SSE on state transitions, not heartbeat position updates
- Only enqueue NowPlaying to external scrobblers on state transitions
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ui): use trailing-edge debounce for NowPlaying fetch
Replace the leading-edge throttle (which fetched on the first event
and blocked subsequent ones) with a trailing-edge debounce (300ms).
During track transitions, the burst of events (stopped → starting →
playing) now collapses into a single fetch after the burst settles,
showing the new track immediately instead of briefly showing empty.
* fix(ui): only show overlay on NowPlaying artwork when paused
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(ui): remove unnecessary sendBeacon fallback from reportPlaybackSync
* refactor(ui): rename reportPlaybackSync to reportPlaybackKeepalive
The function was never synchronous — it uses fetch with keepalive:true,
which is fire-and-forget. The name now reflects the actual behavior.
* style: format code with prettier
* test: add tests for reportPlayback SSE broadcast and UI changes
- play_tracker: verify SSE broadcast on every state transition and
that broadcasts are skipped when EnableNowPlaying is false
- activityReducer: verify nowPlayingLastUpdate timestamp is set
- subsonic/index: verify reportPlayback URL construction
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ui): prevent NowPlaying from fetching every second when panel is open
fetchList had unstable identity because it depended on doFetch
(which depended on notify/dispatch). Each 1s setNow re-render
recreated the callback chain, re-triggering the useEffect that
calls fetchList. Use a ref for the fetch logic so fetchList has
a stable identity with empty deps.
* fix(ui): break fetch→dispatch→effect→fetch loop in NowPlaying panel
The fetch dispatched nowPlayingCountUpdate on every result, which
updated nowPlayingLastUpdate in Redux, which triggered the SSE
effect to call fetchList again — creating a fetch loop.
Fix: remove dispatch from fetch results. The badge count uses
entries.length (from local state) when entries are loaded, falling
back to Redux count (from SSE) when they aren't. SSE events remain
the only trigger for nowPlayingLastUpdate, breaking the loop.
* fix(ui): clear NowPlaying entries on panel close so badge uses SSE count
* style: format code with prettier
* fix: address code review feedback
- Fix currentTime truthiness check to handle position 0 correctly
- Report actual player state (playing/paused) on seek instead of
always sending 'playing'
- Remove idx from React key to avoid reorder issues
- Add debounce timer cleanup on unmount
- Keep entries on panel close so badge stays accurate from polling
- Fix test description to match actual assertion
* fix(ui): keep NowPlaying badge count accurate from polling
Add a separate nowPlayingCountSync action that updates the Redux
count without setting nowPlayingLastUpdate (which would trigger
the SSE effect and cause a fetch loop). Polling results now sync
the badge count via this action, so the badge stays accurate even
when SSE is unavailable.
* style: format code with prettier
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-01 11:27:32 -08:00
|
|
|
Entry("playbackReportIntervalMs", func() { conf.Server.UIPlaybackReportInterval = 30 * time.Second }, "playbackReportIntervalMs", float64(30000)),
|
2025-06-28 16:01:47 -08:00
|
|
|
)
|
2023-01-25 06:28:03 -09:00
|
|
|
|
2026-04-23 13:53:28 -08:00
|
|
|
It("sanitizes entity-encoded welcomeMessage as html", func() {
|
|
|
|
|
conf.Server.UIWelcomeMessage = `<img src=x onerror=alert(1)><b>Hello</b>`
|
|
|
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
|
|
|
|
|
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
|
|
|
Expect(config).To(HaveKey("welcomeMessage"))
|
|
|
|
|
Expect(config["welcomeMessage"]).To(Equal(`<img src="x"><b>Hello</b>`))
|
|
|
|
|
})
|
|
|
|
|
|
2025-06-28 16:01:47 -08:00
|
|
|
DescribeTable("sets other UI configuration values",
|
|
|
|
|
func(configKey string, expectedValueFunc func() any) {
|
|
|
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
|
|
|
|
|
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
|
|
|
Expect(config).To(HaveKeyWithValue(configKey, expectedValueFunc()))
|
|
|
|
|
},
|
|
|
|
|
Entry("version", "version", func() any { return consts.Version }),
|
|
|
|
|
Entry("variousArtistsId", "variousArtistsId", func() any { return consts.VariousArtistsID }),
|
|
|
|
|
Entry("losslessFormats", "losslessFormats", func() any {
|
|
|
|
|
return strings.ToUpper(strings.Join(mime.LosslessFormats, ","))
|
|
|
|
|
}),
|
|
|
|
|
Entry("separator", "separator", func() any { return string(os.PathSeparator) }),
|
|
|
|
|
)
|
2023-01-25 06:28:03 -09:00
|
|
|
|
2022-11-29 10:40:44 -09:00
|
|
|
Describe("loginBackgroundURL", func() {
|
|
|
|
|
Context("empty BaseURL", func() {
|
|
|
|
|
BeforeEach(func() {
|
2023-02-15 17:13:38 -09:00
|
|
|
conf.Server.BasePath = "/"
|
2022-11-29 10:40:44 -09:00
|
|
|
})
|
|
|
|
|
When("it is the default URL", func() {
|
|
|
|
|
It("points to the default URL", func() {
|
|
|
|
|
conf.Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURL
|
|
|
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
2023-01-19 18:52:55 -09:00
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
2022-11-29 10:40:44 -09:00
|
|
|
|
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
|
|
|
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", consts.DefaultUILoginBackgroundURL))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
When("it is the default offline URL", func() {
|
|
|
|
|
It("points to the offline URL", func() {
|
|
|
|
|
conf.Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURLOffline
|
|
|
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
2023-01-19 18:52:55 -09:00
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
2022-11-29 10:40:44 -09:00
|
|
|
|
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
|
|
|
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", consts.DefaultUILoginBackgroundURLOffline))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
When("it is a custom URL", func() {
|
|
|
|
|
It("points to the offline URL", func() {
|
|
|
|
|
conf.Server.UILoginBackgroundURL = "https://example.com/images/1.jpg"
|
|
|
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
2023-01-19 18:52:55 -09:00
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
2022-11-29 10:40:44 -09:00
|
|
|
|
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
|
|
|
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", "https://example.com/images/1.jpg"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
Context("with a BaseURL", func() {
|
|
|
|
|
BeforeEach(func() {
|
2023-02-15 17:13:38 -09:00
|
|
|
conf.Server.BasePath = "/music"
|
2022-11-29 10:40:44 -09:00
|
|
|
})
|
|
|
|
|
When("it is the default URL", func() {
|
|
|
|
|
It("points to the default URL with BaseURL prefix", func() {
|
|
|
|
|
conf.Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURL
|
|
|
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
2023-01-19 18:52:55 -09:00
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
2022-11-29 10:40:44 -09:00
|
|
|
|
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
|
|
|
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", "/music"+consts.DefaultUILoginBackgroundURL))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
When("it is the default offline URL", func() {
|
|
|
|
|
It("points to the offline URL", func() {
|
|
|
|
|
conf.Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURLOffline
|
|
|
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
2023-01-19 18:52:55 -09:00
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
2022-11-29 10:40:44 -09:00
|
|
|
|
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
|
|
|
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", consts.DefaultUILoginBackgroundURLOffline))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
When("it is a custom URL", func() {
|
|
|
|
|
It("points to the offline URL", func() {
|
|
|
|
|
conf.Server.UILoginBackgroundURL = "https://example.com/images/1.jpg"
|
|
|
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
2023-01-19 18:52:55 -09:00
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
2022-11-29 10:40:44 -09:00
|
|
|
|
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
|
|
|
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", "https://example.com/images/1.jpg"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
2020-04-06 11:37:15 -08:00
|
|
|
})
|
|
|
|
|
|
2023-03-27 16:36:23 -08:00
|
|
|
var _ = Describe("addShareData", func() {
|
|
|
|
|
var (
|
|
|
|
|
r *http.Request
|
2025-06-28 16:01:47 -08:00
|
|
|
data map[string]any
|
2023-03-27 16:36:23 -08:00
|
|
|
shareInfo *model.Share
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
BeforeEach(func() {
|
2025-06-28 16:01:47 -08:00
|
|
|
data = make(map[string]any)
|
2023-03-27 16:36:23 -08:00
|
|
|
r = httptest.NewRequest("GET", "/", nil)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("when shareInfo is nil or has an empty ID", func() {
|
|
|
|
|
It("should not modify data", func() {
|
|
|
|
|
addShareData(r, data, nil)
|
|
|
|
|
Expect(data).To(BeEmpty())
|
|
|
|
|
|
|
|
|
|
shareInfo = &model.Share{}
|
|
|
|
|
addShareData(r, data, shareInfo)
|
|
|
|
|
Expect(data).To(BeEmpty())
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("when shareInfo is not nil and has a non-empty ID", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
shareInfo = &model.Share{
|
|
|
|
|
ID: "testID",
|
|
|
|
|
Description: "Test description",
|
|
|
|
|
Downloadable: true,
|
|
|
|
|
Tracks: []model.MediaFile{
|
|
|
|
|
{
|
|
|
|
|
ID: "track1",
|
|
|
|
|
Title: "Track 1",
|
|
|
|
|
Artist: "Artist 1",
|
|
|
|
|
Album: "Album 1",
|
|
|
|
|
Duration: 100,
|
|
|
|
|
UpdatedAt: time.Date(2023, time.Month(3), 27, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
ID: "track2",
|
|
|
|
|
Title: "Track 2",
|
|
|
|
|
Artist: "Artist 2",
|
|
|
|
|
Album: "Album 2",
|
|
|
|
|
Duration: 200,
|
|
|
|
|
UpdatedAt: time.Date(2023, time.Month(3), 26, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
Contents: "Test contents",
|
|
|
|
|
URL: "https://example.com/share/testID",
|
|
|
|
|
ImageURL: "https://example.com/share/testID/image",
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("should populate data with shareInfo data", func() {
|
|
|
|
|
addShareData(r, data, shareInfo)
|
|
|
|
|
|
|
|
|
|
Expect(data["ShareDescription"]).To(Equal(shareInfo.Description))
|
|
|
|
|
Expect(data["ShareURL"]).To(Equal(shareInfo.URL))
|
|
|
|
|
Expect(data["ShareImageURL"]).To(Equal(shareInfo.ImageURL))
|
|
|
|
|
|
|
|
|
|
var shareData shareData
|
|
|
|
|
err := json.Unmarshal([]byte(data["ShareInfo"].(string)), &shareData)
|
|
|
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
|
Expect(shareData.ID).To(Equal(shareInfo.ID))
|
|
|
|
|
Expect(shareData.Description).To(Equal(shareInfo.Description))
|
|
|
|
|
Expect(shareData.Downloadable).To(Equal(shareInfo.Downloadable))
|
|
|
|
|
|
|
|
|
|
Expect(shareData.Tracks).To(HaveLen(len(shareInfo.Tracks)))
|
|
|
|
|
for i, track := range shareData.Tracks {
|
|
|
|
|
Expect(track.ID).To(Equal(shareInfo.Tracks[i].ID))
|
|
|
|
|
Expect(track.Title).To(Equal(shareInfo.Tracks[i].Title))
|
|
|
|
|
Expect(track.Artist).To(Equal(shareInfo.Tracks[i].Artist))
|
|
|
|
|
Expect(track.Album).To(Equal(shareInfo.Tracks[i].Album))
|
|
|
|
|
Expect(track.Duration).To(Equal(shareInfo.Tracks[i].Duration))
|
|
|
|
|
Expect(track.UpdatedAt).To(Equal(shareInfo.Tracks[i].UpdatedAt))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("when shareInfo has an empty description", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
shareInfo.Description = ""
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("should use shareInfo.Contents as ShareDescription", func() {
|
|
|
|
|
addShareData(r, data, shareInfo)
|
|
|
|
|
Expect(data["ShareDescription"]).To(Equal(shareInfo.Contents))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2022-11-29 10:40:44 -09:00
|
|
|
var appConfigRegex = regexp.MustCompile(`(?m)window.__APP_CONFIG__=(.*);</script>`)
|
2020-04-06 11:37:15 -08:00
|
|
|
|
2025-06-28 16:01:47 -08:00
|
|
|
func extractAppConfig(body string) map[string]any {
|
|
|
|
|
config := make(map[string]any)
|
2020-04-06 11:37:15 -08:00
|
|
|
match := appConfigRegex.FindStringSubmatch(body)
|
|
|
|
|
if match == nil {
|
|
|
|
|
return config
|
|
|
|
|
}
|
2022-11-29 10:40:44 -09:00
|
|
|
str, err := strconv.Unquote(match[1])
|
2020-04-06 11:37:15 -08:00
|
|
|
if err != nil {
|
|
|
|
|
panic(fmt.Sprintf("%s: %s", match[1], err))
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal([]byte(str), &config); err != nil {
|
|
|
|
|
panic(err)
|
|
|
|
|
}
|
|
|
|
|
return config
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type mockedUserRepo struct {
|
|
|
|
|
model.UserRepository
|
|
|
|
|
empty bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (u *mockedUserRepo) CountAll(...model.QueryOptions) (int64, error) {
|
|
|
|
|
if u.empty {
|
|
|
|
|
return 0, nil
|
|
|
|
|
}
|
|
|
|
|
return 1, nil
|
|
|
|
|
}
|