This commit is contained in:
lanlinux141 2026-10-06 11:07:40 -05:00 • committed by GitHub
commit 3311e79b2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 244 additions and 8 deletions

View file

@ -33,6 +33,10 @@ please file a [GitHub issue](https://github.com/navidrome/navidrome/issues) or j
See instructions on the [project's website](https://www.navidrome.org/docs/installation/)
Set `InstanceName = "My Music"` (or `ND_INSTANCENAME="My Music"`) to name your
server on the login screen, page header and browser tab. The default is
`Navidrome`, including for blank values. Restart and reload after changing it.
## Cloud Hosting
[PikaPods](https://www.pikapods.com) has partnered with us to offer you an

View file

@ -51,6 +51,7 @@ type configOptions struct {
TLSKey string
UILoginBackgroundURL string
UIWelcomeMessage string
InstanceName string
MaxSidebarPlaylists int
EnableTranscodingConfig bool
EnableDownloads bool
@ -382,6 +383,10 @@ func Load(noConfigDump bool) {
if err != nil {
logFatal("Error parsing config:", err)
}
Server.InstanceName = strings.TrimSpace(Server.InstanceName)
if Server.InstanceName == "" {
Server.InstanceName = "Navidrome"
}
// Validate non-root user early, before any filesystem operations
if err := validateEnforceNonRootUser(); err != nil {
@ -1027,6 +1032,7 @@ func setViperDefaults() {
viper.SetDefault("enablestarrating", true)
viper.SetDefault("enableuserediting", true)
viper.SetDefault("defaulttheme", "Dark")
viper.SetDefault("instancename", "Navidrome")
viper.SetDefault("defaultlanguage", "")
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)

View file

@ -38,6 +38,33 @@ var _ = Describe("Configuration", func() {
}))
})
Describe("InstanceName", func() {
It("defaults to Navidrome", func() {
conf.Load(true)
Expect(conf.Server.InstanceName).To(Equal("Navidrome"))
})
DescribeTable("normalizes configured names", func(value, expected string) {
viper.Set("instancename", value)
conf.Load(true)
Expect(conf.Server.InstanceName).To(Equal(expected))
},
Entry("custom name", "mp3-player", "mp3-player"),
Entry("surrounding whitespace", " 家の音楽 ", "家の音楽"),
Entry("empty", "", "Navidrome"),
Entry("whitespace", " \t ", "Navidrome"),
)
It("loads ND_INSTANCENAME over the config file", func() {
filename := filepath.Join(GinkgoT().TempDir(), "navidrome.toml")
Expect(os.WriteFile(filename, []byte(`InstanceName = "File name"`), 0600)).To(Succeed())
GinkgoT().Setenv("ND_INSTANCENAME", "mp3-player")
conf.InitConfig(filename, true)
conf.Load(true)
Expect(conf.Server.InstanceName).To(Equal("mp3-player"))
})
})
Describe("ParseLanguages", func() {
It("parses single language", func() {
Expect(conf.ParseLanguages("en")).To(Equal([]string{"en"}))

View file

@ -47,6 +47,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
"baseURL": str.SanitizeText(strings.TrimSuffix(conf.Server.BasePath, "/")),
"loginBackgroundURL": str.SanitizeText(conf.Server.UILoginBackgroundURL),
"welcomeMessage": str.SanitizeHTML(conf.Server.UIWelcomeMessage),
"instanceName": conf.Server.InstanceName,
"maxSidebarPlaylists": conf.Server.MaxSidebarPlaylists,
"enableTranscodingConfig": conf.Server.EnableTranscodingConfig,
"enableDownloads": conf.Server.EnableDownloads,
@ -105,8 +106,9 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
version = "v" + version
}
data := map[string]any{
"AppConfig": string(appConfigJson),
"Version": version,
"InstanceName": conf.Server.InstanceName,
"AppConfig": string(appConfigJson),
"Version": version,
}
addShareData(r, data, shareInfo)

View file

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"html"
"net/http"
"net/http/httptest"
"os"
@ -43,6 +44,24 @@ var _ = Describe("serveIndex", func() {
Expect(config).To(BeAssignableToTypeOf(map[string]any{}))
})
DescribeTable("renders the instance name as text in the actual UI template", func(name string) {
conf.Server.InstanceName = name
r := httptest.NewRequest("GET", "/app/", nil)
w := httptest.NewRecorder()
serveIndex(ds, os.DirFS("ui"), nil)(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Body.String()).To(ContainSubstring("<title>" + html.EscapeString(name) + "</title>"))
// The JSON configuration also preserves the name as plain text.
w = httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
Expect(extractAppConfig(w.Body.String())).To(HaveKeyWithValue("instanceName", name))
},
Entry("default name", "Navidrome"),
Entry("custom name", "mp3-player"),
Entry("Unicode and punctuation", `家の音楽 & "Friends"`),
Entry("HTML-like name", `</title><script>alert("name")</script>`),
)
It("sets firstTime = true when User table is empty", func() {
mockUser.empty = true
r := httptest.NewRequest("GET", "/index.html", nil)
@ -77,6 +96,7 @@ var _ = Describe("serveIndex", func() {
Expect(config).To(HaveKeyWithValue(configKey, expectedValue))
},
Entry("baseURL", func() { conf.Server.BasePath = "base_url_test" }, "baseURL", "base_url_test"),
Entry("instanceName", func() { conf.Server.InstanceName = "mp3-player" }, "instanceName", "mp3-player"),
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),

View file

@ -26,7 +26,7 @@
<meta property="og:image" content="{{ .ShareImageURL }}">
<meta property="og:image:width" content="300">
<meta property="og:image:height" content="300">
<title>Navidrome</title>
<title>{{ .InstanceName }}</title>
<script>
// Shim for libraries that check for Node.js process object
window.process = { env: {} };

View file

@ -265,7 +265,7 @@ const Player = () => {
const onAudioProgress = useCallback((info) => {
if (info.ended) {
document.title = 'Navidrome'
document.title = config.instanceName
}
if (!info.isRadio && info.currentTime != null) {
lastPositionMsRef.current = Math.floor(info.currentTime * 1000)
@ -287,7 +287,7 @@ const Player = () => {
dispatch(currentPlaying(info))
if (info.duration) {
const song = info.song
document.title = `${song.title} - ${song.artist} - Navidrome`
document.title = `${song.title} - ${song.artist} - ${config.instanceName}`
if (!info.isRadio) {
const posMs = Math.floor(info.currentTime * 1000)
lastPositionMsRef.current = posMs
@ -410,7 +410,7 @@ const Player = () => {
}, [dispatch, currentTrackId])
if (!visible) {
document.title = 'Navidrome'
document.title = config.instanceName
}
const handlers = useMemo(

View file

@ -0,0 +1,78 @@
import { act, render } from '@testing-library/react'
import { Player } from './Player'
import config from '../config'
let playerProps
let state
const dispatch = vi.fn()
vi.mock('navidrome-music-player', () => ({
default: (props) => {
playerProps = props
return null
},
}))
vi.mock('react-redux', () => ({
useDispatch: () => dispatch,
useSelector: (select) => select(state),
}))
vi.mock('react-admin', async () => ({
...(await vi.importActual('react-admin')),
useAuthState: () => ({ authenticated: true }),
useDataProvider: () => ({}),
useTranslate: () => (text) => text,
}))
vi.mock('../themes/useCurrentTheme', () => ({ default: () => ({}) }))
vi.mock('../common', () => ({ useInterval: () => {} }))
vi.mock('./styles', () => ({ default: () => ({}) }))
vi.mock('./PlayerToolbar', () => ({ default: () => null }))
vi.mock('./AudioTitle', () => ({ default: () => null }))
vi.mock('react-hotkeys', () => ({ GlobalHotKeys: () => null }))
vi.mock('../subsonic', () => ({
default: { reportPlayback: vi.fn().mockResolvedValue({}) },
}))
vi.mock('../transcode', () => ({
detectBrowserProfile: () => ({}),
decisionService: {
setProfile: vi.fn(),
resolveStreamUrl: vi.fn().mockResolvedValue('/stream'),
prefetchDecisions: vi.fn(),
},
}))
beforeEach(() => {
state = {
player: { queue: [], current: {}, volume: 1 },
settings: {},
replayGain: {},
}
})
afterEach(() => {
config.instanceName = 'Navidrome'
document.title = ''
})
it.each(['Navidrome', 'mp3-player'])(
'uses %s in idle, playing and finished tab titles',
async (name) => {
config.instanceName = name
const { rerender } = render(<Player />)
expect(document.title).toBe(name)
state.player.queue = [{ trackId: 'track' }]
await act(async () => {
rerender(<Player />)
})
await act(async () => {
playerProps.onAudioPlay({
trackId: 'track',
duration: 60,
currentTime: 0,
song: { title: 'Moon', artist: 'Artist' },
})
})
expect(document.title).toBe(`Moon - Artist - ${name}`)
act(() => {
playerProps.onAudioProgress({ ended: true })
})
expect(document.title).toBe(name)
},
)

View file

@ -1,6 +1,7 @@
import React from 'react'
import { useMediaQuery } from '@material-ui/core'
import { useTranslate } from 'react-admin'
import config from '../config'
export const Title = ({ subTitle, args }) => {
const translate = useTranslate()
@ -8,7 +9,12 @@ export const Title = ({ subTitle, args }) => {
const text = translate(subTitle, { ...args, _: subTitle })
if (isDesktop) {
return <span>Navidrome {text ? ` - ${text}` : ''}</span>
return (
<span>
{config.instanceName}
{text ? ` - ${text}` : ''}
</span>
)
}
return <span>{text ? text : 'Navidrome'}</span>
return <span>{text ? text : config.instanceName}</span>
}

View file

@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react'
import { useMediaQuery } from '@material-ui/core'
import { Title } from './Title'
import config from '../config'
vi.mock('@material-ui/core', async () => ({
...(await vi.importActual('@material-ui/core')),
useMediaQuery: vi.fn(),
}))
vi.mock('react-admin', () => ({ useTranslate: () => (text) => text || '' }))
afterEach(() => {
config.instanceName = 'Navidrome'
})
it.each(['Navidrome', 'mp3-player', '家の音楽 & Friends'])(
'shows %s with the desktop page title',
(name) => {
config.instanceName = name
useMediaQuery.mockReturnValue(true)
render(<Title subTitle="Albums" />)
expect(screen.getByText(`${name} - Albums`)).toBeInTheDocument()
},
)
it('preserves the compact mobile subtitle and uses the name when it is empty', () => {
config.instanceName = 'mp3-player'
useMediaQuery.mockReturnValue(false)
const { rerender } = render(<Title subTitle="Albums" />)
expect(screen.getByText('Albums')).toBeInTheDocument()
rerender(<Title />)
expect(screen.getByText('mp3-player')).toBeInTheDocument()
})
it('renders an HTML-like name as text', () => {
config.instanceName = '<img src=x onerror=alert(1)>'
useMediaQuery.mockReturnValue(true)
const { container } = render(<Title />)
expect(container).toHaveTextContent(config.instanceName)
expect(container.querySelector('img')).toBeNull()
})

View file

@ -3,6 +3,7 @@
// in the /server/app/serve_index.go
const defaultConfig = {
version: 'dev',
instanceName: 'Navidrome',
firstTime: false,
baseURL: '',
variousArtistsId: '63sqASlAfjbGMuLP4JhnZU', // See consts.VariousArtistsID in consts.go

View file

@ -56,9 +56,20 @@ const useStyles = makeStyles(
systemName: {
marginTop: '1em',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: '#3f51b5', //theme.palette.grey[500]
},
instanceName: {
color: theme.palette.text.primary,
fontSize: '1.25rem',
fontWeight: 500,
padding: '0 1em',
maxWidth: 300,
overflowWrap: 'anywhere',
textAlign: 'center',
},
welcome: {
marginTop: '1em',
padding: '0 1em 1em 1em',
@ -126,6 +137,11 @@ const FormLogin = ({ loading, handleSubmit, validate }) => {
<img src={Logo} className={classes.icon} alt={'logo'} />
</div>
<div className={classes.systemName}>
{config.instanceName !== 'Navidrome' && (
<div className={classes.instanceName}>
{config.instanceName}
</div>
)}
<a
href="https://www.navidrome.org"
target="_blank"

View file

@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/react'
import Login from './Login'
import config from '../config'
const dispatch = vi.fn()
vi.mock('react-redux', () => ({ useDispatch: () => dispatch }))
vi.mock('react-admin', async () => ({
...(await vi.importActual('react-admin')),
useLogin: () => vi.fn(),
useNotify: () => vi.fn(),
useTranslate: () => (text) => text,
useVersion: () => 1,
}))
vi.mock('../themes/useCurrentTheme', () => ({ default: () => ({}) }))
vi.mock('./Notification', () => ({ default: () => null }))
afterEach(() => {
config.instanceName = 'Navidrome'
})
it('keeps the default project name and link', () => {
render(<Login />)
expect(screen.getAllByText('Navidrome')).toHaveLength(1)
expect(screen.getByRole('link', { name: 'Navidrome' })).toHaveAttribute(
'href',
'https://www.navidrome.org',
)
})
it('shows a custom name while retaining project attribution', () => {
config.instanceName = 'mp3-player'
render(<Login />)
expect(screen.getByText('mp3-player')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Navidrome' })).toBeInTheDocument()
})