From e21908cd24c3e7d6d93e8f6fde742824594f3c54 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Tue, 17 Feb 2026 16:56:51 -0800 Subject: [PATCH 1/3] Add -reload-theme CLI flag to re-apply the current theme file (#860) * Add -reload-theme CLI flag to re-apply theme via IPC Sends a reload-theme IPC message to a running instance, causing it to call fyne.CurrentApp().Settings().SetTheme(m.theme). Follows the same callback pattern as -show (OnReactivate) and quit (OnExit). Co-Authored-By: Claude Sonnet 4.6 * actually re-read theme file --------- Co-authored-by: Claude Sonnet 4.6 --- backend/app.go | 12 +++++++++++- backend/cmdlineoptions.go | 1 + backend/ipc/api.go | 1 + backend/ipc/client.go | 5 +++++ backend/ipc/server.go | 18 ++++++++++-------- main.go | 1 + ui/mainwindow.go | 5 +++++ ui/theme/theme.go | 5 +++++ 8 files changed, 39 insertions(+), 9 deletions(-) diff --git a/backend/app.go b/backend/app.go index e731f20..97c453f 100644 --- a/backend/app.go +++ b/backend/app.go @@ -60,6 +60,7 @@ type App struct { // UI callbacks to be set in main OnReactivate func() OnExit func() + OnReloadTheme func() appName string displayAppName string @@ -227,7 +228,8 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas ipcRatingHandler, a.ServerManager, a.callOnReactivate, - func() { _ = a.callOnExit() }) + func() { _ = a.callOnExit() }, + a.callOnReloadTheme) go a.ipcServer.Serve(listener) } else { log.Printf("error starting IPC server: %s", err.Error()) @@ -336,6 +338,12 @@ func (a *App) callOnReactivate() { } } +func (a *App) callOnReloadTheme() { + if a.OnReloadTheme != nil { + a.OnReloadTheme() + } +} + func (a *App) callOnExit() error { if a.OnExit == nil { return errors.New("no quit handler registered") @@ -658,6 +666,8 @@ func (a *App) checkFlagsAndSendIPCMsg(cli *ipc.Client) error { return cli.PauseAfterCurrent() case *FlagShow: return cli.Show() + case *FlagReloadTheme: + return cli.ReloadTheme() case VolumeCLIArg >= 0: return cli.SetVolume(VolumeCLIArg) case VolumePctCLIArg != 0: diff --git a/backend/cmdlineoptions.go b/backend/cmdlineoptions.go index 6bcc489..a06b970 100644 --- a/backend/cmdlineoptions.go +++ b/backend/cmdlineoptions.go @@ -32,6 +32,7 @@ var ( FlagPauseAfterCurrent = flag.Bool("pause-after-current", false, "pause playback after current track") FlagStartMinimized = flag.Bool("start-minimized", false, "start app minimized") FlagShow = flag.Bool("show", false, "show minimized app") + FlagReloadTheme = flag.Bool("reload-theme", false, "reload the current theme") FlagShuffle = flag.Bool("shuffle", false, "shuffle the tracklist (to be used with either -play-album-by-id or -play-playlist-by-id)") FlagVersion = flag.Bool("version", false, "print app version and exit") FlagHelp = flag.Bool("help", false, "print command line options and exit") diff --git a/backend/ipc/api.go b/backend/ipc/api.go index 60028d6..4038475 100644 --- a/backend/ipc/api.go +++ b/backend/ipc/api.go @@ -26,6 +26,7 @@ const ( VolumePath = "/volume" // ?v= VolumeAdjustPath = "/volume/adjust" // ?pct=<+/- percentage> ShowPath = "/window/show" + ReloadThemePath = "/window/reload-theme" QuitPath = "/window/quit" RateCurrentTrackPath = "/current_track/rate" // ?r= ) diff --git a/backend/ipc/client.go b/backend/ipc/client.go index 41cb310..1656ca3 100644 --- a/backend/ipc/client.go +++ b/backend/ipc/client.go @@ -128,6 +128,11 @@ func (c *Client) Show() error { return err } +func (c *Client) ReloadTheme() error { + _, err := c.sendRequest(ReloadThemePath) + return err +} + func (c *Client) Quit() error { _, err := c.sendRequest(QuitPath) return err diff --git a/backend/ipc/server.go b/backend/ipc/server.go index 3ded536..0f8d61a 100644 --- a/backend/ipc/server.go +++ b/backend/ipc/server.go @@ -41,16 +41,17 @@ type ServerManager interface { } type serverImpl struct { - server *http.Server - pbHandler PlaybackHandler - rateFn func(int) - sm ServerManager - showFn func() - quitFn func() + server *http.Server + pbHandler PlaybackHandler + rateFn func(int) + sm ServerManager + showFn func() + quitFn func() + reloadThemeFn func() } -func NewServer(pbHandler PlaybackHandler, rateFn func(int), sm ServerManager, showFn, quitFn func()) IPCServer { - s := &serverImpl{pbHandler: pbHandler, rateFn: rateFn, sm: sm, showFn: showFn, quitFn: quitFn} +func NewServer(pbHandler PlaybackHandler, rateFn func(int), sm ServerManager, showFn, quitFn, reloadThemeFn func()) IPCServer { + s := &serverImpl{pbHandler: pbHandler, rateFn: rateFn, sm: sm, showFn: showFn, quitFn: quitFn, reloadThemeFn: reloadThemeFn} s.server = &http.Server{ Handler: s.createHandler(), } @@ -77,6 +78,7 @@ func (s *serverImpl) createHandler() http.Handler { m.HandleFunc(ShowPath, s.makeSimpleEndpointHandler(func() { s.showFn() })) + m.HandleFunc(ReloadThemePath, s.makeSimpleEndpointHandler(s.reloadThemeFn)) m.HandleFunc(QuitPath, s.makeSimpleEndpointHandler(func() { s.quitFn() })) diff --git a/main.go b/main.go index de68eef..0f972fa 100644 --- a/main.go +++ b/main.go @@ -110,6 +110,7 @@ func main() { mainWindow.Window.SetMaster() myApp.OnReactivate = util.FyneDoFunc(mainWindow.Show) myApp.OnExit = util.FyneDoFunc(mainWindow.Quit) + myApp.OnReloadTheme = util.FyneDoFunc(mainWindow.ReloadTheme) if runtime.GOOS == "windows" { windowStartupTasks := sync.OnceFunc(func() { diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 7e11c4e..e6cb5b8 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -273,6 +273,11 @@ func (m *MainWindow) setInitialSize() { m.Window.Resize(m.DesiredSize()) } +func (m *MainWindow) ReloadTheme() { + m.theme.ReloadThemeFile() + fyne.CurrentApp().Settings().SetTheme(m.theme) +} + func (m *MainWindow) StartupPage() controller.Route { switch m.App.Config.Application.StartupPage { case "Artists": diff --git a/ui/theme/theme.go b/ui/theme/theme.go index 9b22de8..0c46dc2 100644 --- a/ui/theme/theme.go +++ b/ui/theme/theme.go @@ -110,6 +110,11 @@ func NewMyTheme(config *backend.ThemeConfig, themeFileDir string) *MyTheme { return m } +// ReloadThemeFile reloads the currently loaded theme file. +func (m *MyTheme) ReloadThemeFile() { + m.loadedThemeFile = nil +} + func (m *MyTheme) Color(name fyne.ThemeColorName, defVariant fyne.ThemeVariant) color.Color { // load theme file if necessary if m.loadedThemeFile == nil || m.config.ThemeFile != m.loadedThemeFilename { From 9dd606ce73642cdab4398df90dac5711ea4fa84f Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Tue, 17 Feb 2026 19:34:47 -0800 Subject: [PATCH 2/3] Normalize server URLs before connecting (#861) * Fix #304: normalize server URLs before connecting Prepend http:// when no scheme is present and strip trailing slashes for all server types. For Jellyfin, additionally strip /web/index.html and /web path suffixes that users copy from the browser URL bar. Normalization is applied in ServerManager.connect() on the by-value parameter, so stored config is never mutated. Co-Authored-By: Claude Sonnet 4.6 * move url normalize funcs --------- Co-authored-by: Claude Sonnet 4.6 --- backend/servermanager.go | 34 ++++++++++++++++++++++++ backend/servermanager_test.go | 49 +++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 backend/servermanager_test.go diff --git a/backend/servermanager.go b/backend/servermanager.go index 77b617c..c280beb 100644 --- a/backend/servermanager.go +++ b/backend/servermanager.go @@ -7,6 +7,7 @@ import ( "fmt" "log" "net/http" + "strings" "time" "github.com/dweymouth/go-jellyfin" @@ -179,6 +180,14 @@ func (s *ServerManager) connect(connection ServerConnection, password string) (m var cli, altCli mediaprovider.Server timeout := time.Second * time.Duration(s.config.Application.RequestTimeoutSeconds) + if connection.ServerType == ServerTypeJellyfin { + connection.Hostname = NormalizeJellyfinURL(connection.Hostname) + connection.AltHostname = NormalizeJellyfinURL(connection.AltHostname) + } else { + connection.Hostname = NormalizeServerURL(connection.Hostname) + connection.AltHostname = NormalizeServerURL(connection.AltHostname) + } + if connection.ServerType == ServerTypeJellyfin { client, err := jellyfin.NewClient(connection.Hostname, res.AppName, res.AppVersion, jellyfin.WithTimeout(timeout)) if err != nil { @@ -266,3 +275,28 @@ func (s *ServerManager) checkSetInsecureSkipVerify(skip bool, cli *http.Client) func (a *ServerManager) GetServer() mediaprovider.MediaProvider { return a.Server } + +// NormalizeServerURL applies common normalization to a server URL: +// prepends "http://" if no scheme is present, then strips trailing slashes. +func NormalizeServerURL(rawURL string) string { + if rawURL == "" { + return "" + } + if !strings.Contains(rawURL, "://") { + rawURL = "http://" + rawURL + } + rawURL = strings.TrimRight(rawURL, "/") + return rawURL +} + +// NormalizeJellyfinURL applies common normalization then additionally strips +// known Jellyfin web UI path suffixes (/web/index.html and /web). +func NormalizeJellyfinURL(rawURL string) string { + rawURL = NormalizeServerURL(rawURL) + if strings.HasSuffix(rawURL, "/web/index.html") { + rawURL = strings.TrimSuffix(rawURL, "/web/index.html") + } else if strings.HasSuffix(rawURL, "/web") { + rawURL = strings.TrimSuffix(rawURL, "/web") + } + return rawURL +} diff --git a/backend/servermanager_test.go b/backend/servermanager_test.go new file mode 100644 index 0000000..e364f3c --- /dev/null +++ b/backend/servermanager_test.go @@ -0,0 +1,49 @@ +package backend + +import "testing" + +func TestNormalizeServerURL(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"", ""}, + {"http://192.168.1.1:8096", "http://192.168.1.1:8096"}, + {"https://music.example.com", "https://music.example.com"}, + {"192.168.1.1:4533", "http://192.168.1.1:4533"}, + {"music.example.com", "http://music.example.com"}, + {"http://192.168.1.1:8096/", "http://192.168.1.1:8096"}, + {"http://192.168.1.1:8096///", "http://192.168.1.1:8096"}, + {"192.168.1.1:8096/", "http://192.168.1.1:8096"}, + } + for _, tt := range tests { + got := NormalizeServerURL(tt.input) + if got != tt.want { + t.Errorf("NormalizeServerURL(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestNormalizeJellyfinURL(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"", ""}, + {"http://192.168.1.1:8096", "http://192.168.1.1:8096"}, + {"192.168.1.1:8096", "http://192.168.1.1:8096"}, + {"192.168.1.1:8096/", "http://192.168.1.1:8096"}, + {"192.168.1.1:8096/web/index.html", "http://192.168.1.1:8096"}, + {"http://192.168.1.1:8096/web/index.html", "http://192.168.1.1:8096"}, + {"http://192.168.1.1:8096/web/", "http://192.168.1.1:8096"}, + {"http://192.168.1.1:8096/web", "http://192.168.1.1:8096"}, + {"https://jellyfin.example.com/web/index.html", "https://jellyfin.example.com"}, + {"https://jellyfin.example.com/web/", "https://jellyfin.example.com"}, + } + for _, tt := range tests { + got := NormalizeJellyfinURL(tt.input) + if got != tt.want { + t.Errorf("NormalizeJellyfinURL(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} From 6b382c62938248a08e19b1590e7823b282533f2c Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 19 Feb 2026 08:27:21 -0800 Subject: [PATCH 3/3] Fix #857: Clear in-memory cache as well as on disk --- backend/app.go | 31 ++++++++++++++++--------------- backend/imagemanager.go | 11 +++++++---- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/backend/app.go b/backend/app.go index 97c453f..70ff658 100644 --- a/backend/app.go +++ b/backend/app.go @@ -43,23 +43,23 @@ var ( ) type App struct { - Config *Config - ServerManager *ServerManager - LyricsManager *LyricsManager - ImageManager *ImageManager - AudioCache *AudioCache - AutoEQManager *AutoEQManager - EQPresetManager *EQPresetManager - PlaybackManager *PlaybackManager - LocalPlayer *mpv.Player - UpdateChecker UpdateChecker - MPRISHandler *MPRISHandler - WinSMTC *windows.SMTC - ipcServer ipc.IPCServer + Config *Config + ServerManager *ServerManager + LyricsManager *LyricsManager + ImageManager *ImageManager + AudioCache *AudioCache + AutoEQManager *AutoEQManager + EQPresetManager *EQPresetManager + PlaybackManager *PlaybackManager + LocalPlayer *mpv.Player + UpdateChecker UpdateChecker + MPRISHandler *MPRISHandler + WinSMTC *windows.SMTC + ipcServer ipc.IPCServer // UI callbacks to be set in main - OnReactivate func() - OnExit func() + OnReactivate func() + OnExit func() OnReloadTheme func() appName string @@ -283,6 +283,7 @@ func (a *App) ClearCaches() { } } } + a.ImageManager.ClearInMemoryCache() } func checkPortablePath() string { diff --git a/backend/imagemanager.go b/backend/imagemanager.go index af05663..4309fa7 100644 --- a/backend/imagemanager.go +++ b/backend/imagemanager.go @@ -66,10 +66,7 @@ func NewImageManager(ctx context.Context, s *ServerManager, baseCacheDir string) maxOnDiskCacheSizeBytes: defaultDiskCacheSizeBytes, serverFetchSema: make(chan any, maxConcurrentServerFetches), } - s.OnLogout(func() { - i.thumbnailCache.Clear() - i.clearFullSizeCover() - }) + s.OnLogout(i.ClearInMemoryCache) i.thumbnailCache.OnEvictTaskRan = func() { i.clearFullSizeCoverIfExpired() i.pruneOnDiskCache() @@ -195,6 +192,12 @@ func (i *ImageManager) RefreshCachedArtistImageIfExpired(artistID string, imgURL return err } +// ClearInMemoryCache clears images from the in-memory caches. +func (i *ImageManager) ClearInMemoryCache() { + i.thumbnailCache.Clear() + i.clearFullSizeCover() +} + func (i *ImageManager) ensureCoverCacheDir() string { // if user logged out with pending fetches in progress, // make sure we don't write to nil (00000000-*0) cache directory