mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
Merge 37d3cf0e24 into 52135913d4
This commit is contained in:
commit
3311e79b2a
13 changed files with 244 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"}))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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: {} };
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
78
ui/src/audioplayer/Player.test.jsx
Normal file
78
ui/src/audioplayer/Player.test.jsx
Normal 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)
|
||||
},
|
||||
)
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
41
ui/src/common/Title.test.jsx
Normal file
41
ui/src/common/Title.test.jsx
Normal 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()
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
35
ui/src/layout/Login.test.jsx
Normal file
35
ui/src/layout/Login.test.jsx
Normal 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()
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue