diff --git a/backend/app.go b/backend/app.go index 9898beb..7644804 100644 --- a/backend/app.go +++ b/backend/app.go @@ -25,7 +25,6 @@ import ( "github.com/google/uuid" "github.com/20after4/configdir" - "github.com/zalando/go-keyring" ) const ( @@ -57,6 +56,7 @@ type App struct { LocalPlayer *mpv.Player UpdateChecker UpdateChecker MPRISHandler *MPRISHandler + DiscordPresence *DiscordPresence WinSMTC *windows.SMTC ipcServer ipc.IPCServer @@ -150,7 +150,8 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas return nil, err } - a.ServerManager = NewServerManager(appName, appVersion, a.Config, !portableMode && a.Config.Application.EnablePasswordStorage) + a.ServerManager = NewServerManager(appName, appVersion, a.Config, confDir, + a.Config.Application.EnablePasswordStorage, !portableMode) a.ImageManager = NewImageManager(a.bgrndCtx, a.ServerManager, cacheDir) if a.Config.Playback.UseWaveformSeekbar { ac, err := NewAudioCache(a.bgrndCtx, a.ServerManager, filepath.Join(cacheDir, audioCacheSubdir)) @@ -215,6 +216,9 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas a.PlaybackManager.OnSongChange(func(_ mediaprovider.MediaItem, _ *mediaprovider.Track) { go a.SavePlayQueueIfEnabled() }) + a.DiscordPresence = NewDiscordPresence(a.PlaybackManager, func() mediaprovider.MediaProvider { + return a.ServerManager.GetServer() + }, newMusicBrainzArtworkResolver(a.bgrndCtx, fmt.Sprintf("Supersonic/%s (%s)", appVersion, latestReleaseURL))) // Start IPC server if another not already running in a different instance if cli == nil { @@ -559,9 +563,9 @@ func (a *App) LoginToDefaultServer() error { if serverCfg == nil { return ErrNoServers } - pass, err := keyring.Get(a.appName, serverCfg.ID.String()) + pass, err := a.ServerManager.GetServerPassword(serverCfg.ID) if err != nil { - return fmt.Errorf("error reading keyring credentials: %v", err) + return fmt.Errorf("error reading saved credentials: %v", err) } return a.ServerManager.ConnectToServer(serverCfg, pass) } @@ -604,6 +608,9 @@ func (a *App) Shutdown() { if a.WinSMTC != nil { a.WinSMTC.Shutdown() } + if a.DiscordPresence != nil { + a.DiscordPresence.Shutdown() + } a.PlaybackManager.DisableCallbacks() a.PlaybackManager.Shutdown() // will trigger scrobble check if a.AudioCache != nil { diff --git a/backend/config.go b/backend/config.go index 7a44eae..5bcd042 100644 --- a/backend/config.go +++ b/backend/config.go @@ -30,6 +30,7 @@ type ServerConfig struct { Nickname string Default bool SelectedLibrary string + EnablePodcasts bool } type AppConfig struct { @@ -136,7 +137,7 @@ type LocalPlaybackConfig struct { InMemoryCacheSizeMB int Volume int EqualizerEnabled bool - EqualizerType string // "ISO10Band" or "ISO15Band" + EqualizerType string // "ISO10Band" or "ISO15Band" EqualizerPreamp float64 GraphicEqualizerBands []float64 ActiveEQPresetName string // Name of currently selected EQ preset diff --git a/backend/discordpresence.go b/backend/discordpresence.go new file mode 100644 index 0000000..d6352e9 --- /dev/null +++ b/backend/discordpresence.go @@ -0,0 +1,266 @@ +package backend + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "log" + "os" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/backend/player" + "github.com/hugolgst/rich-go/ipc" +) + +const discordApplicationID = "1531906238919475352" + +// DiscordPresence publishes playback information to the local Discord desktop +// client. All RPC failures are intentionally non-fatal: Discord may not be +// installed or running, and that should never affect playback. +type DiscordPresence struct { + pm *PlaybackManager + server func() mediaprovider.MediaProvider + mu sync.Mutex + loggedIn bool + shareURL map[string]string + artURL map[string]string + artwork *musicBrainzArtworkResolver +} + +type discordRPCActivity struct { + Type int `json:"type"` + StatusDisplayType int `json:"status_display_type"` + Details string `json:"details,omitempty"` + State string `json:"state,omitempty"` + Timestamps *discordRPCTimestamps `json:"timestamps,omitempty"` + Assets *discordRPCAssets `json:"assets,omitempty"` + Buttons []discordRPCButton `json:"buttons,omitempty"` +} + +type discordRPCTimestamps struct { + Start int64 `json:"start,omitempty"` + End int64 `json:"end,omitempty"` +} + +type discordRPCAssets struct { + LargeImage string `json:"large_image,omitempty"` + LargeText string `json:"large_text,omitempty"` +} + +type discordRPCButton struct { + Label string `json:"label"` + URL string `json:"url"` +} + +func NewDiscordPresence(pm *PlaybackManager, server func() mediaprovider.MediaProvider, artwork *musicBrainzArtworkResolver) *DiscordPresence { + d := &DiscordPresence{pm: pm, server: server, shareURL: make(map[string]string), artURL: make(map[string]string), artwork: artwork} + pm.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) { + d.update() + d.createShareURL(item) + d.resolveArtwork(item) + }) + pm.OnRadioMetadataChange(func(_, _, _ string) { d.update() }) + pm.OnPlaying(d.update) + pm.OnPaused(d.update) + pm.OnSeek(d.update) + pm.OnStopped(d.clear) + return d +} + +func (d *DiscordPresence) loginLocked() bool { + if d.loggedIn { + return true + } + if err := ipc.OpenSocket(); err != nil { + return false + } + payload, err := json.Marshal(map[string]string{"v": "1", "client_id": discordApplicationID}) + if err != nil { + _ = ipc.CloseSocket() + return false + } + ipc.Send(0, string(payload)) + d.loggedIn = true + return true +} + +func (d *DiscordPresence) update() { + item := d.pm.NowPlaying() + if item == nil || d.pm.PlaybackStatus().State == player.Stopped { + d.clear() + return + } + + metadata := item.Metadata() + status := d.pm.PlaybackStatus() + activity := discordActivity(metadata, status) + + d.mu.Lock() + defer d.mu.Unlock() + if shareURL := d.shareURL[metadata.ID]; shareURL != "" && metadata.Type == mediaprovider.MediaItemTypeTrack { + log.Printf("Discord share URL for %q: %s", metadata.Name, shareURL) + activity.Buttons = []discordRPCButton{{Label: "View Song", URL: shareURL}} + } + if url := d.artURL[metadata.ID]; url != "" { + activity.Assets.LargeImage = url + } + if d.loginLocked() { + if err := setDiscordActivity(activity); err != nil { + log.Printf("failed to update Discord Rich Presence: %v", err) + } + } +} + +func (d *DiscordPresence) resolveArtwork(item mediaprovider.MediaItem) { + if item == nil || d.artwork == nil { + return + } + metadata := item.Metadata() + if metadata.Album == "" || len(metadata.Artists) == 0 { + return + } + id := metadata.ID + d.mu.Lock() + _, exists := d.artURL[id] + d.mu.Unlock() + if exists { + return + } + go func() { + artURL := d.artwork.Resolve(metadata.Artists[0], metadata.Album) + d.mu.Lock() + d.artURL[id] = artURL + d.mu.Unlock() + if artURL != "" { + if current := d.pm.NowPlaying(); current != nil && current.Metadata().ID == id { + d.update() + } + } + }() +} + +func discordActivity(metadata mediaprovider.MediaItemMetadata, status PlaybackStatus) discordRPCActivity { + artist := strings.Join(metadata.Artists, ", ") + state := artist + if metadata.Album != "" { + if state != "" { + state += " — " + } + state += metadata.Album + } + if status.State == player.Paused { + if state != "" { + state += " • Paused" + } else { + state = "Paused" + } + } + + activity := discordRPCActivity{ + Type: 2, // Listening + StatusDisplayType: 2, // Use Details (the song title) as the profile headline. + Details: discordText(metadata.Name), + State: discordText(state), + Assets: &discordRPCAssets{LargeText: "Listening with Supersonic"}, + } + if status.State == player.Playing { + started := time.Now().Add(-time.Duration(status.TimePos * float64(time.Second))) + activity.Timestamps = &discordRPCTimestamps{Start: started.UnixMilli()} + if metadata.Duration > 0 { + activity.Timestamps.End = started.Add(metadata.Duration).UnixMilli() + } + } + return activity +} + +func setDiscordActivity(activity discordRPCActivity) error { + nonceBytes := make([]byte, 16) + if _, err := rand.Read(nonceBytes); err != nil { + return err + } + payload, err := json.Marshal(struct { + Command string `json:"cmd"` + Args struct { + PID int `json:"pid"` + Activity discordRPCActivity `json:"activity"` + } `json:"args"` + Nonce string `json:"nonce"` + }{ + Command: "SET_ACTIVITY", + Args: struct { + PID int `json:"pid"` + Activity discordRPCActivity `json:"activity"` + }{PID: os.Getpid(), Activity: activity}, + Nonce: hex.EncodeToString(nonceBytes), + }) + if err != nil { + return err + } + ipc.Send(1, string(payload)) + return nil +} + +// Discord limits details and state to 128 characters. It also rejects a +// non-empty field shorter than two characters. +func discordText(value string) string { + value = strings.TrimSpace(value) + if utf8.RuneCountInString(value) < 2 { + return "" + } + runes := []rune(value) + if len(runes) > 128 { + value = string(runes[:127]) + "…" + } + return value +} + +func (d *DiscordPresence) createShareURL(item mediaprovider.MediaItem) { + if item == nil || item.Metadata().Type != mediaprovider.MediaItemTypeTrack { + return + } + id := item.Metadata().ID + d.mu.Lock() + _, exists := d.shareURL[id] + d.mu.Unlock() + if exists { + return + } + + go func() { + server := d.server() + sharing, ok := server.(mediaprovider.SupportsSharing) + if !ok { + return + } + url, err := sharing.CreateShareURL(id) + if err != nil { + log.Printf("failed to create Discord download share for track: %v", err) + return + } + d.mu.Lock() + d.shareURL[id] = url.String() + d.mu.Unlock() + log.Printf("Created Discord share URL for %q: %s", item.Metadata().Name, url.String()) + if current := d.pm.NowPlaying(); current != nil && current.Metadata().ID == id { + d.update() + } + }() +} + +func (d *DiscordPresence) clear() { + d.mu.Lock() + defer d.mu.Unlock() + if d.loggedIn { + // Closing RPC clears this application's activity in Discord. + _ = ipc.CloseSocket() + d.loggedIn = false + } +} + +func (d *DiscordPresence) Shutdown() { + d.clear() +} diff --git a/backend/lyricsmanager.go b/backend/lyricsmanager.go index f991580..5d13c09 100644 --- a/backend/lyricsmanager.go +++ b/backend/lyricsmanager.go @@ -28,6 +28,13 @@ func NewLyricsManager(sm *ServerManager, lrclib *LrcLibFetcher) *LyricsManager { } func (lm *LyricsManager) FetchLyricsAsync(song *mediaprovider.Track, cb func(string, *mediaprovider.Lyrics)) { + if song == nil { + if cb != nil { + cb("", nil) + } + return + } + lm.lock.Lock() defer lm.lock.Unlock() diff --git a/backend/lyricsmanager_test.go b/backend/lyricsmanager_test.go new file mode 100644 index 0000000..3d1cec3 --- /dev/null +++ b/backend/lyricsmanager_test.go @@ -0,0 +1,22 @@ +package backend + +import ( + "testing" + + "github.com/dweymouth/supersonic/backend/mediaprovider" +) + +func TestFetchLyricsAsyncWithNilTrack(t *testing.T) { + lm := &LyricsManager{} + called := false + + lm.FetchLyricsAsync(nil, func(id string, lyrics *mediaprovider.Lyrics) { + called = true + if id != "" || lyrics != nil { + t.Fatalf("got callback values (%q, %v), want empty ID and nil lyrics", id, lyrics) + } + }) + if !called { + t.Fatal("callback was not called") + } +} diff --git a/backend/mediaprovider/mediaprovider.go b/backend/mediaprovider/mediaprovider.go index b93a64d..6b5120e 100644 --- a/backend/mediaprovider/mediaprovider.go +++ b/backend/mediaprovider/mediaprovider.go @@ -196,6 +196,15 @@ type Server interface { MediaProvider() MediaProvider } +// PodcastProvider is implemented by Subsonic servers with the standard podcast API. +// Podcast IDs are opaque and must never be converted to numeric IDs. +type PodcastProvider interface { + GetPodcastChannels() ([]*PodcastChannel, error) + GetPodcastChannel(id string) (*PodcastChannel, error) + GetNewestPodcastEpisodes(count int) ([]*PodcastEpisode, error) + GetPodcastEpisode(id string) (*PodcastEpisode, error) +} + type MediaProvider interface { SetPrefetchCoverCallback(cb func(coverArtID string)) diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index a6aaeb6..e567f1d 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -221,8 +221,44 @@ type MediaItemType int const ( MediaItemTypeTrack MediaItemType = iota MediaItemTypeRadioStation + MediaItemTypePodcastEpisode ) +type PodcastChannel struct { + ID, URL, Title, Description, CoverArtID, OriginalImageURL, Status, ErrorMessage string + Episodes []*PodcastEpisode +} + +type PodcastEpisode struct { + ID, StreamID, ChannelID, Title, Description, Status, CoverArtID, OriginalImageURL, ChannelTitle string + PublishDate time.Time + Duration time.Duration + Size int64 + BitRate int + ContentType string +} + +func (p *PodcastEpisode) Playable() bool { + return p != nil && p.Status == "completed" && p.StreamID != "" +} + +func (p *PodcastEpisode) Metadata() MediaItemMetadata { + if p == nil { + return MediaItemMetadata{} + } + return MediaItemMetadata{Type: MediaItemTypePodcastEpisode, MIMEType: p.ContentType, + ID: p.ID, Name: p.Title, Artists: []string{p.ChannelTitle}, Album: p.ChannelTitle, + CoverArtID: p.CoverArtID, Duration: p.Duration, Size: p.Size, BitRate: p.BitRate} +} + +func (p *PodcastEpisode) Copy() MediaItem { + if p == nil { + return nil + } + c := *p + return &c +} + type MediaItemMetadata struct { Type MediaItemType MIMEType string diff --git a/backend/mediaprovider/subsonic/podcasts.go b/backend/mediaprovider/subsonic/podcasts.go new file mode 100644 index 0000000..fb0a004 --- /dev/null +++ b/backend/mediaprovider/subsonic/podcasts.go @@ -0,0 +1,162 @@ +package subsonic + +import ( + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/dweymouth/supersonic/backend/mediaprovider" +) + +type podcastChannelDTO struct { + ID string `xml:"id,attr" json:"id"` + URL string `xml:"url,attr" json:"url"` + Title string `xml:"title,attr" json:"title"` + Description string `xml:"description,attr" json:"description"` + CoverArt string `xml:"coverArt,attr" json:"coverArt"` + OriginalImageURL string `xml:"originalImageUrl,attr" json:"originalImageUrl"` + Status string `xml:"status,attr" json:"status"` + ErrorMessage string `xml:"errorMessage,attr" json:"errorMessage"` + Episodes []podcastEpisodeDTO `xml:"episode" json:"episode"` +} + +type podcastEpisodeDTO struct { + ID string `xml:"id,attr" json:"id"` + StreamID string `xml:"streamId,attr" json:"streamId"` + ChannelID string `xml:"channelId,attr" json:"channelId"` + Title string `xml:"title,attr" json:"title"` + Description string `xml:"description,attr" json:"description"` + Status string `xml:"status,attr" json:"status"` + CoverArt string `xml:"coverArt,attr" json:"coverArt"` + PublishDate time.Time `xml:"publishDate,attr" json:"publishDate"` + Duration int `xml:"duration,attr" json:"duration"` + Size int64 `xml:"size,attr" json:"size"` + BitRate int `xml:"bitRate,attr" json:"bitRate"` + ContentType string `xml:"contentType,attr" json:"contentType"` +} + +type podcastAPIResponse struct { + XMLName xml.Name `xml:"subsonic-response" json:"-"` + Status string `xml:"status,attr" json:"status"` + Error *struct { + Code int `xml:"code,attr" json:"code"` + Message string `xml:"message,attr" json:"message"` + } `xml:"error" json:"error"` + Podcasts struct { + Channels []podcastChannelDTO `xml:"channel" json:"channel"` + } `xml:"podcasts" json:"podcasts"` + Newest struct { + Episodes []podcastEpisodeDTO `xml:"episode" json:"episode"` + } `xml:"newestPodcasts" json:"newestPodcasts"` + Episode *podcastEpisodeDTO `xml:"podcastEpisode" json:"podcastEpisode"` +} + +func (s *subsonicMediaProvider) podcastRequest(endpoint string, params url.Values) (*podcastAPIResponse, error) { + endpoint += ".view" + resp, err := s.client.Request(http.MethodGet, endpoint, params) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("podcast API: HTTP %s", resp.Status) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var out podcastAPIResponse + if s.client.UseJSON { + var wrapper struct { + Response podcastAPIResponse `json:"subsonic-response"` + } + if err = json.Unmarshal(body, &wrapper); err != nil { + return nil, err + } + out = wrapper.Response + } else if err = xml.Unmarshal(body, &out); err != nil { + return nil, err + } + if out.Status != "ok" { + if out.Error != nil { + return nil, fmt.Errorf("podcast API error %d: %s", out.Error.Code, out.Error.Message) + } + return nil, fmt.Errorf("podcast API returned status %q", out.Status) + } + return &out, nil +} + +func episodeFromDTO(e podcastEpisodeDTO, ch *mediaprovider.PodcastChannel) *mediaprovider.PodcastEpisode { + p := &mediaprovider.PodcastEpisode{ID: e.ID, StreamID: e.StreamID, ChannelID: e.ChannelID, Title: e.Title, + Description: e.Description, Status: e.Status, CoverArtID: e.CoverArt, PublishDate: e.PublishDate, + Duration: time.Duration(e.Duration) * time.Second, Size: e.Size, BitRate: e.BitRate, ContentType: e.ContentType} + if ch != nil { + p.ChannelTitle = ch.Title + if p.ChannelID == "" { + p.ChannelID = ch.ID + } + if p.CoverArtID == "" { + p.CoverArtID = ch.CoverArtID + } + p.OriginalImageURL = ch.OriginalImageURL + } + return p +} + +func channelFromDTO(c podcastChannelDTO) *mediaprovider.PodcastChannel { + ch := &mediaprovider.PodcastChannel{ID: c.ID, URL: c.URL, Title: c.Title, Description: c.Description, + CoverArtID: c.CoverArt, OriginalImageURL: c.OriginalImageURL, Status: c.Status, ErrorMessage: c.ErrorMessage} + for _, e := range c.Episodes { + ch.Episodes = append(ch.Episodes, episodeFromDTO(e, ch)) + } + return ch +} + +func (s *subsonicMediaProvider) GetPodcastChannels() ([]*mediaprovider.PodcastChannel, error) { + r, err := s.podcastRequest("getPodcasts", url.Values{"includeEpisodes": {"false"}}) + if err != nil { + return nil, err + } + result := make([]*mediaprovider.PodcastChannel, 0, len(r.Podcasts.Channels)) + for _, c := range r.Podcasts.Channels { + result = append(result, channelFromDTO(c)) + } + return result, nil +} +func (s *subsonicMediaProvider) GetPodcastChannel(id string) (*mediaprovider.PodcastChannel, error) { + r, err := s.podcastRequest("getPodcasts", url.Values{"id": {id}, "includeEpisodes": {"true"}}) + if err != nil { + return nil, err + } + if len(r.Podcasts.Channels) == 0 { + return nil, errors.New("podcast channel not found") + } + return channelFromDTO(r.Podcasts.Channels[0]), nil +} +func (s *subsonicMediaProvider) GetNewestPodcastEpisodes(count int) ([]*mediaprovider.PodcastEpisode, error) { + r, err := s.podcastRequest("getNewestPodcasts", url.Values{"count": {strconv.Itoa(count)}}) + if err != nil { + return nil, err + } + result := make([]*mediaprovider.PodcastEpisode, 0, len(r.Newest.Episodes)) + for _, e := range r.Newest.Episodes { + result = append(result, episodeFromDTO(e, nil)) + } + return result, nil +} +func (s *subsonicMediaProvider) GetPodcastEpisode(id string) (*mediaprovider.PodcastEpisode, error) { + r, err := s.podcastRequest("getPodcastEpisode", url.Values{"id": {id}}) + if err != nil { + return nil, err + } + if r.Episode == nil { + return nil, errors.New("podcast episode not found") + } + return episodeFromDTO(*r.Episode, nil), nil +} diff --git a/backend/musicbrainzartwork.go b/backend/musicbrainzartwork.go new file mode 100644 index 0000000..1cd9261 --- /dev/null +++ b/backend/musicbrainzartwork.go @@ -0,0 +1,147 @@ +package backend + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +const ( + musicBrainzAPIBase = "https://musicbrainz.org/ws/2" + coverArtAPIBase = "https://coverartarchive.org" +) + +// musicBrainzArtworkResolver resolves public album artwork URLs while obeying +// MusicBrainz's one-request-per-second client limit. +type musicBrainzArtworkResolver struct { + ctx context.Context + client *http.Client + userAgent string + mu sync.Mutex + lastCall time.Time + cache map[string]string + inFlight map[string][]chan string +} + +func newMusicBrainzArtworkResolver(ctx context.Context, userAgent string) *musicBrainzArtworkResolver { + return &musicBrainzArtworkResolver{ + ctx: ctx, client: &http.Client{Timeout: 10 * time.Second}, userAgent: userAgent, + cache: make(map[string]string), inFlight: make(map[string][]chan string), + } +} + +func (r *musicBrainzArtworkResolver) Resolve(artist, album string) string { + artist, album = strings.TrimSpace(artist), strings.TrimSpace(album) + if artist == "" || album == "" { + return "" + } + key := strings.ToLower(artist + "\x00" + album) + r.mu.Lock() + if result, ok := r.cache[key]; ok { + r.mu.Unlock() + return result + } + if waiters, ok := r.inFlight[key]; ok { + wait := make(chan string, 1) + r.inFlight[key] = append(waiters, wait) + r.mu.Unlock() + select { + case result := <-wait: + return result + case <-r.ctx.Done(): + return "" + } + } + r.inFlight[key] = nil + r.mu.Unlock() + + result := r.lookup(artist, album) + r.mu.Lock() + r.cache[key] = result + for _, waiter := range r.inFlight[key] { + waiter <- result + close(waiter) + } + delete(r.inFlight, key) + r.mu.Unlock() + return result +} + +func (r *musicBrainzArtworkResolver) lookup(artist, album string) string { + if !r.waitForRateLimit() { + return "" + } + query := fmt.Sprintf(`releasegroup:"%s" AND artist:"%s"`, luceneEscape(album), luceneEscape(artist)) + endpoint := musicBrainzAPIBase + "/release-group/?fmt=json&limit=1&query=" + url.QueryEscape(query) + req, err := http.NewRequestWithContext(r.ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "" + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", r.userAgent) + resp, err := r.client.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "" + } + var result struct { + ReleaseGroups []struct { + ID string `json:"id"` + } `json:"release-groups"` + } + if json.NewDecoder(resp.Body).Decode(&result) != nil || len(result.ReleaseGroups) == 0 { + return "" + } + + artURL := coverArtAPIBase + "/release-group/" + result.ReleaseGroups[0].ID + "/front-500" + artReq, err := http.NewRequestWithContext(r.ctx, http.MethodHead, artURL, nil) + if err != nil { + return "" + } + // Validate the Cover Art Archive endpoint itself without following the + // redirect to an archive.org image host. Some archive mirrors reject HEAD + // even though Discord can fetch the redirected image normally. + artClient := *r.client + artClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } + artResp, err := artClient.Do(artReq) + if err != nil { + return "" + } + artResp.Body.Close() + validRedirect := artResp.StatusCode >= 300 && artResp.StatusCode < 400 && artResp.Header.Get("Location") != "" + if (artResp.StatusCode < 200 || artResp.StatusCode >= 300) && !validRedirect { + return "" + } + return artURL +} + +func (r *musicBrainzArtworkResolver) waitForRateLimit() bool { + r.mu.Lock() + wait := time.Until(r.lastCall.Add(time.Second)) + if wait < 0 { + wait = 0 + } + r.lastCall = time.Now().Add(wait) + r.mu.Unlock() + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case <-timer.C: + return true + case <-r.ctx.Done(): + return false + } +} + +func luceneEscape(value string) string { + value = strings.ReplaceAll(value, `\`, `\\`) + return strings.ReplaceAll(value, `"`, `\"`) +} diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 0a40f6a..7f04151 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -1043,6 +1043,10 @@ func (p *playbackEngine) getMediaURLForIdx(idx int) string { } } url, _ = p.sm.Server.GetStreamURL(tr.ID, ts, p.transcodeCfg.ForceRawFile) + } else if ep, ok := item.(*mediaprovider.PodcastEpisode); ok { + if ep.Playable() { + url, _ = p.sm.Server.GetStreamURL(ep.StreamID, nil, true) + } } else { url = item.(*mediaprovider.RadioStation).StreamURL } diff --git a/backend/servermanager.go b/backend/servermanager.go index f147eaa..ce94342 100644 --- a/backend/servermanager.go +++ b/backend/servermanager.go @@ -3,11 +3,15 @@ package backend import ( "context" "crypto/tls" + "encoding/json" "errors" "fmt" "log" "net/http" + "os" + "path/filepath" "strings" + "sync" "time" "github.com/dweymouth/go-jellyfin" @@ -26,6 +30,9 @@ type ServerManager struct { Server mediaprovider.MediaProvider useKeyring bool + storePasswords bool + credentialFile string + credentialFileMu sync.Mutex prefetchCoverCB func(string) appName string appVersion string @@ -36,12 +43,14 @@ type ServerManager struct { var ErrUnreachable = errors.New("server is unreachable") -func NewServerManager(appName, appVersion string, config *Config, useKeyring bool) *ServerManager { +func NewServerManager(appName, appVersion string, config *Config, configDir string, storePasswords, useKeyring bool) *ServerManager { return &ServerManager{ - appName: appName, - appVersion: appVersion, - config: config, - useKeyring: useKeyring, + appName: appName, + appVersion: appVersion, + config: config, + useKeyring: useKeyring, + storePasswords: storePasswords, + credentialFile: filepath.Join(configDir, "credentials.json"), } } @@ -147,9 +156,13 @@ func (s *ServerManager) Logout(deletePassword bool) { } func (s *ServerManager) deleteServerPassword(serverID uuid.UUID) { - if s.useKeyring { - keyring.Delete(s.appName, s.ServerID.String()) + if !s.storePasswords { + return } + if s.useKeyring { + _ = keyring.Delete(s.appName, serverID.String()) + } + _ = s.deleteFilePassword(serverID) } // Sets a callback that is invoked when a server is connected to. @@ -163,17 +176,111 @@ func (s *ServerManager) OnLogout(cb func()) { } func (s *ServerManager) GetServerPassword(serverID uuid.UUID) (string, error) { - if s.useKeyring { - return keyring.Get(s.appName, serverID.String()) + if !s.storePasswords { + return "", errors.New("password storage not enabled") } - return "", errors.New("keyring not enabled") + if s.useKeyring { + if password, err := keyring.Get(s.appName, serverID.String()); err == nil { + return password, nil + } else { + log.Printf("OS keyring unavailable, using local credential store: %v", err) + } + } + return s.getFilePassword(serverID) } func (s *ServerManager) SetServerPassword(server *ServerConfig, password string) error { - if s.useKeyring { - return keyring.Set(s.appName, server.ID.String(), password) + if !s.storePasswords { + return errors.New("password storage not enabled") } - return errors.New("keyring not available") + if s.useKeyring { + if err := keyring.Set(s.appName, server.ID.String(), password); err == nil { + _ = s.deleteFilePassword(server.ID) + return nil + } else { + log.Printf("OS keyring unavailable, using local credential store: %v", err) + } + } + return s.setFilePassword(server.ID, password) +} + +func (s *ServerManager) readFilePasswords() (map[string]string, error) { + passwords := make(map[string]string) + data, err := os.ReadFile(s.credentialFile) + if errors.Is(err, os.ErrNotExist) { + return passwords, nil + } + if err != nil { + return nil, err + } + if err := json.Unmarshal(data, &passwords); err != nil { + return nil, fmt.Errorf("read saved credentials: %w", err) + } + return passwords, nil +} + +func (s *ServerManager) writeFilePasswords(passwords map[string]string) error { + tmp, err := os.CreateTemp(filepath.Dir(s.credentialFile), ".credentials-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if err := json.NewEncoder(tmp).Encode(passwords); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, s.credentialFile) +} + +func (s *ServerManager) getFilePassword(serverID uuid.UUID) (string, error) { + s.credentialFileMu.Lock() + defer s.credentialFileMu.Unlock() + passwords, err := s.readFilePasswords() + if err != nil { + return "", err + } + password, ok := passwords[serverID.String()] + if !ok { + return "", errors.New("no saved password") + } + return password, nil +} + +func (s *ServerManager) setFilePassword(serverID uuid.UUID, password string) error { + s.credentialFileMu.Lock() + defer s.credentialFileMu.Unlock() + passwords, err := s.readFilePasswords() + if err != nil { + return err + } + passwords[serverID.String()] = password + return s.writeFilePasswords(passwords) +} + +func (s *ServerManager) deleteFilePassword(serverID uuid.UUID) error { + s.credentialFileMu.Lock() + defer s.credentialFileMu.Unlock() + passwords, err := s.readFilePasswords() + if err != nil { + return err + } + delete(passwords, serverID.String()) + if len(passwords) == 0 { + err := os.Remove(s.credentialFile) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + return s.writeFilePasswords(passwords) } func (s *ServerManager) connect(connection ServerConnection, password string) (mediaprovider.Server, error) { diff --git a/backend/servermanager_test.go b/backend/servermanager_test.go index e364f3c..3f8d83d 100644 --- a/backend/servermanager_test.go +++ b/backend/servermanager_test.go @@ -1,6 +1,33 @@ package backend -import "testing" +import ( + "os" + "path/filepath" + "testing" +) + +func TestServerManagerFilePasswordFallback(t *testing.T) { + configDir := t.TempDir() + manager := NewServerManager("supersonic-test", "test", DefaultConfig("test"), configDir, true, false) + server := manager.AddServer("Test", ServerConnection{}) + + if err := manager.SetServerPassword(server, "secret"); err != nil { + t.Fatalf("SetServerPassword: %v", err) + } + if got, err := manager.GetServerPassword(server.ID); err != nil || got != "secret" { + t.Fatalf("GetServerPassword = %q, %v; want secret, nil", got, err) + } + if info, err := os.Stat(filepath.Join(configDir, "credentials.json")); err != nil { + t.Fatalf("stat credential file: %v", err) + } else if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("credential file permissions = %o; want 600", got) + } + + manager.DeleteServer(server.ID) + if _, err := manager.GetServerPassword(server.ID); err == nil { + t.Fatal("GetServerPassword succeeded after server deletion") + } +} func TestNormalizeServerURL(t *testing.T) { tests := []struct { diff --git a/go.mod b/go.mod index acb5872..9222e3d 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/godbus/dbus/v5 v5.2.2 github.com/google/uuid v1.6.0 github.com/hashicorp/go-retryablehttp v0.7.8 + github.com/hugolgst/rich-go v0.0.0-20240715122152-74618cc1ace2 github.com/pelletier/go-toml/v2 v2.4.2 github.com/quarckster/go-mpris-server v1.0.3 github.com/supersonic-app/fyne-lyrics v0.0.0-20250614151306-b1880a70a410 @@ -67,6 +68,7 @@ require ( github.com/stretchr/testify v1.11.1 // indirect github.com/yuin/goldmark v1.8.2 // indirect golang.org/x/image v0.36.0 // indirect + gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index be1e28c..818786e 100644 --- a/go.sum +++ b/go.sum @@ -86,6 +86,8 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hugolgst/rich-go v0.0.0-20240715122152-74618cc1ace2 h1:9qOViOQGFIP5ar+2NorfAIsfuADEKXtklySC0zNnYf4= +github.com/hugolgst/rich-go v0.0.0-20240715122152-74618cc1ace2/go.mod h1:nGaW7CGfNZnhtiFxMpc4OZdqIexGXjUlBnlmpZmjEKA= github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= @@ -152,5 +154,7 @@ golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= +gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index c598507..b9fa367 100644 --- a/main.go +++ b/main.go @@ -105,7 +105,7 @@ func main() { } } - fyneApp := app.New() + fyneApp := app.NewWithID(res.AppName) fyneApp.SetIcon(res.ResAppicon256Png) mainWindow := ui.NewMainWindow(fyneApp, res.AppName, res.DisplayName, res.AppVersion, myApp) diff --git a/ui/bottompanel.go b/ui/bottompanel.go index 234cb14..f49aacb 100644 --- a/ui/bottompanel.go +++ b/ui/bottompanel.go @@ -53,6 +53,8 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr })) bp.NowPlaying = widgets.NewNowPlayingCard() + _, canShare := contr.App.ServerManager.Server.(mediaprovider.SupportsSharing) + bp.NowPlaying.DisableSharing = !canShare bp.NowPlaying.ShowAlbumYear = cfg.AlbumsPage.ShowYears bp.NowPlaying.OnCoverTapped = func() { contr.NavigateTo(controller.NowPlayingRoute()) @@ -87,8 +89,13 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr } } bp.NowPlaying.OnShare = func() { - if tr, ok := pm.NowPlaying().(*mediaprovider.Track); ok { - contr.ShowShareDialog(tr.ID) + switch item := pm.NowPlaying().(type) { + case *mediaprovider.Track: + contr.ShowShareDialog(item.ID) + case *mediaprovider.PodcastEpisode: + if item.StreamID != "" { + contr.ShowShareDialog(item.StreamID) + } } } bp.Controls = widgets.NewPlayerControls(cfg.Playback.UseWaveformSeekbar, pm.GetLoopMode(), pm.IsShuffle()) diff --git a/ui/browsing/artistpage.go b/ui/browsing/artistpage.go index 96af4e4..94bf9a0 100644 --- a/ui/browsing/artistpage.go +++ b/ui/browsing/artistpage.go @@ -686,7 +686,7 @@ func (a *ArtistPageHeader) Clear() { a.fullSizeCoverFetching = false } -func (a *ArtistPageHeader) Update(artist *mediaprovider.ArtistWithAlbums, im *backend.ImageManager) { +func (a *ArtistPageHeader) Update(artist *mediaprovider.ArtistWithAlbums, _ *backend.ImageManager) { if artist == nil { return } @@ -695,17 +695,6 @@ func (a *ArtistPageHeader) Update(artist *mediaprovider.ArtistWithAlbums, im *ba a.artistID = artist.ID a.titleDisp.Segments[0].(*widget.TextSegment).Text = artist.Name a.titleDisp.Refresh() - if artist.CoverArtID == "" { - return - } - a.artistImageID = artist.CoverArtID - go func() { - if cover, err := im.GetCoverThumbnail(artist.CoverArtID); err == nil { - fyne.Do(func() { a.artistImage.SetImage(cover, true) }) - } else { - log.Printf("error fetching cover: %v", err) - } - }() } func (a *ArtistPageHeader) UpdateInfo(info *mediaprovider.ArtistInfo) { diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index 16510d4..e8efa52 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -402,7 +402,10 @@ func (a *NowPlayingPage) updateLyrics() { return } } - if a.nowPlaying == nil || a.nowPlaying.Metadata().Type == mediaprovider.MediaItemTypeRadioStation { + tr, isTrack := a.nowPlaying.(*mediaprovider.Track) + if !isTrack { + a.lyricsLoading.Stop() + a.lyricsViewer.EnableTapToSeek() a.lyricsViewer.SetLyrics(nil) a.curLyrics = nil a.curLyricsID = "" @@ -417,8 +420,6 @@ func (a *NowPlayingPage) updateLyrics() { Synced: true, Lines: []mediaprovider.LyricLine{{Text: ""}}, }) - tr, _ := a.nowPlaying.(*mediaprovider.Track) - a.lm.FetchLyricsAsync(tr, func(id string, lyrics *mediaprovider.Lyrics) { if id != a.nowPlayingID { return diff --git a/ui/browsing/podcastspage.go b/ui/browsing/podcastspage.go new file mode 100644 index 0000000..ea48923 --- /dev/null +++ b/ui/browsing/podcastspage.go @@ -0,0 +1,248 @@ +package browsing + +import ( + "fmt" + "html" + "log" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/lang" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/deluan/sanitize" + "github.com/dweymouth/supersonic/backend" + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/ui/controller" + myTheme "github.com/dweymouth/supersonic/ui/theme" + "github.com/dweymouth/supersonic/ui/util" + appWidgets "github.com/dweymouth/supersonic/ui/widgets" +) + +type PodcastsPage struct { + widget.BaseWidget + contr *controller.Controller + provider mediaprovider.PodcastProvider + pm *backend.PlaybackManager + channels []*mediaprovider.PodcastChannel + episodes []*mediaprovider.PodcastEpisode + channelList, episodeList *widget.List + channelTitle *widget.Label + content *fyne.Container +} + +func NewPodcastsPage(c *controller.Controller, p mediaprovider.PodcastProvider, pm *backend.PlaybackManager) *PodcastsPage { + x := &PodcastsPage{contr: c, provider: p, pm: pm, channelTitle: widget.NewLabel(lang.L("Newest episodes"))} + x.ExtendBaseWidget(x) + x.channelTitle.TextStyle.Bold = true + x.channelList = widget.NewList(func() int { return len(x.channels) }, func() fyne.CanvasObject { + title := widget.NewButton("", nil) + more := appWidgets.NewIconButton(theme.MoreVerticalIcon(), nil) + more.IconSize = appWidgets.IconButtonSizeSmaller + more.SetToolTip(lang.L("More")) + return container.NewBorder(nil, nil, nil, more, title) + }, func(id widget.ListItemID, o fyne.CanvasObject) { + ch := x.channels[id] + row := o.(*fyne.Container) + b := row.Objects[0].(*widget.Button) + more := row.Objects[1].(*appWidgets.IconButton) + b.SetText(ch.Title) + b.OnTapped = func() { go x.loadChannel(ch.ID) } + more.OnTapped = func() { + pos := fyne.CurrentApp().Driver().AbsolutePositionForObject(more) + x.showChannelMenu(ch, pos) + } + }) + x.episodeList = widget.NewList(func() int { return len(x.episodes) }, x.newEpisodeRow, x.updateEpisodeRow) + header := container.NewHBox(widget.NewLabelWithStyle(lang.L("Podcasts"), fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), layout.NewSpacer()) + left := container.NewBorder(widget.NewLabel(lang.L("Channels")), nil, nil, nil, x.channelList) + right := container.NewBorder(x.channelTitle, nil, nil, nil, x.episodeList) + split := container.NewHSplit(left, right) + split.Offset = .27 + x.content = container.NewBorder(header, nil, nil, nil, split) + go x.load() + return x +} + +func (p *PodcastsPage) showChannelMenu(ch *mediaprovider.PodcastChannel, pos fyne.Position) { + share := fyne.NewMenuItem(lang.L("Share")+"...", func() { + p.contr.ShowShareDialog(ch.ID) + }) + share.Icon = myTheme.ShareIcon + _, canShare := p.provider.(mediaprovider.SupportsSharing) + share.Disabled = ch.ID == "" || !canShare + menu := fyne.NewMenu("", share) + widget.ShowPopUpMenuAtPosition(menu, fyne.CurrentApp().Driver().CanvasForObject(p), pos) +} + +func (p *PodcastsPage) newEpisodeRow() fyne.CanvasObject { + title := widget.NewLabel("") + title.TextStyle.Bold = true + desc := widget.NewLabel("") + // List rows are virtualized and have a fixed template height. Letting an + // arbitrary feed description wrap makes it paint over following rows. + desc.Wrapping = fyne.TextWrapOff + desc.Truncation = fyne.TextTruncateEllipsis + play := appWidgets.NewIconButton(theme.MediaPlayIcon(), nil) + play.IconSize = appWidgets.IconButtonSizeSlightlyBigger + play.SetToolTip(lang.L("Play")) + playArea := container.NewPadded(container.NewCenter(play)) + more := appWidgets.NewIconButton(theme.MoreVerticalIcon(), nil) + more.IconSize = appWidgets.IconButtonSizeSmaller + more.SetToolTip(lang.L("More")) + moreArea := container.NewPadded(container.NewCenter(more)) + texts := container.New(layout.NewCustomPaddedVBoxLayout(0), title, desc) + return container.NewBorder(nil, nil, playArea, moreArea, texts) +} + +func (p *PodcastsPage) updateEpisodeRow(id widget.ListItemID, obj fyne.CanvasObject) { + e := p.episodes[id] + border := obj.(*fyne.Container) + texts := border.Objects[0].(*fyne.Container) + playArea := border.Objects[1].(*fyne.Container) + playCenter := playArea.Objects[0].(*fyne.Container) + play := playCenter.Objects[0].(*appWidgets.IconButton) + moreArea := border.Objects[2].(*fyne.Container) + moreCenter := moreArea.Objects[0].(*fyne.Container) + more := moreCenter.Objects[0].(*appWidgets.IconButton) + date := "" + if !e.PublishDate.IsZero() { + date = e.PublishDate.Local().Format("Jan 2, 2006") + } + title := e.Title + if date != "" { + title = fmt.Sprintf("%s (%s)", title, date) + } + texts.Objects[0].(*widget.Label).SetText(title) + texts.Objects[1].(*widget.Label).SetText(podcastDescriptionText(e.Description)) + play.OnTapped = func() { p.pm.LoadItems([]mediaprovider.MediaItem{e}, backend.Replace, false); p.pm.PlayTrackAt(0) } + more.OnTapped = func() { + pos := fyne.CurrentApp().Driver().AbsolutePositionForObject(more) + p.showEpisodeMenu(e, pos) + } + if e.Playable() { + play.Enable() + } else { + play.Disable() + } +} + +func (p *PodcastsPage) showEpisodeMenu(e *mediaprovider.PodcastEpisode, pos fyne.Position) { + play := fyne.NewMenuItem(lang.L("Play"), func() { + p.pm.LoadItems([]mediaprovider.MediaItem{e}, backend.Replace, false) + p.pm.PlayTrackAt(0) + }) + play.Icon = theme.MediaPlayIcon() + playNext := fyne.NewMenuItem(lang.L("Play next"), func() { + p.pm.LoadItems([]mediaprovider.MediaItem{e}, backend.InsertNext, false) + }) + playNext.Icon = myTheme.PlayNextIcon + addToQueue := fyne.NewMenuItem(lang.L("Add to queue"), func() { + p.pm.LoadItems([]mediaprovider.MediaItem{e}, backend.Append, false) + }) + addToQueue.Icon = theme.ContentAddIcon() + addToPlaylist := fyne.NewMenuItem(lang.L("Add to playlist")+"...", func() { + p.contr.DoAddTracksToPlaylistWorkflow([]string{e.StreamID}) + }) + addToPlaylist.Icon = myTheme.PlaylistIcon + share := fyne.NewMenuItem(lang.L("Share")+"...", func() { + p.contr.ShowShareDialog(e.StreamID) + }) + share.Icon = myTheme.ShareIcon + favorite := fyne.NewMenuItem(lang.L("Set favorite"), func() { + p.contr.SetTrackFavorites([]string{e.StreamID}, true) + }) + favorite.Icon = myTheme.FavoriteIcon + unfavorite := fyne.NewMenuItem(lang.L("Unset favorite"), func() { + p.contr.SetTrackFavorites([]string{e.StreamID}, false) + }) + unfavorite.Icon = myTheme.NotFavoriteIcon + rating := util.NewRatingSubmenu(func(value int) { + p.contr.SetTrackRatings([]string{e.StreamID}, value) + }) + + play.Disabled = !e.Playable() + playNext.Disabled = !e.Playable() + addToQueue.Disabled = !e.Playable() + mediaActionsDisabled := e.StreamID == "" + addToPlaylist.Disabled = mediaActionsDisabled + favorite.Disabled = mediaActionsDisabled + unfavorite.Disabled = mediaActionsDisabled + _, canRate := p.provider.(mediaprovider.SupportsRating) + rating.Disabled = mediaActionsDisabled || !canRate + _, canShare := p.provider.(mediaprovider.SupportsSharing) + share.Disabled = mediaActionsDisabled || !canShare + + menu := fyne.NewMenu("", play, playNext, addToQueue, fyne.NewMenuItemSeparator(), + addToPlaylist, share, fyne.NewMenuItemSeparator(), favorite, unfavorite, rating) + widget.ShowPopUpMenuAtPosition(menu, fyne.CurrentApp().Driver().CanvasForObject(p), pos) +} + +func podcastDescriptionText(description string) string { + // Some servers return RSS descriptions still wrapped in CDATA and HTML. + // Labels display plain text, so normalize it before rendering the preview. + description = strings.TrimSpace(description) + description = strings.TrimPrefix(description, "") + description = html.UnescapeString(sanitize.HTML(description)) + return strings.Join(strings.Fields(description), " ") +} + +func (p *PodcastsPage) load() { + if p.provider == nil { + return + } + channels, err := p.provider.GetPodcastChannels() + if err != nil { + log.Printf("load podcast channels: %v", err) + return + } + episodes, err := p.provider.GetNewestPodcastEpisodes(20) + if err != nil { + log.Printf("load newest podcasts: %v", err) + } + byID := make(map[string]*mediaprovider.PodcastChannel, len(channels)) + for _, ch := range channels { + byID[ch.ID] = ch + } + for _, ep := range episodes { + if ch := byID[ep.ChannelID]; ch != nil { + ep.ChannelTitle = ch.Title + if ep.CoverArtID == "" { + ep.CoverArtID = ch.CoverArtID + } + ep.OriginalImageURL = ch.OriginalImageURL + } + } + fyne.Do(func() { + p.channels = channels + p.episodes = episodes + p.channelTitle.SetText(lang.L("Newest episodes")) + p.channelList.Refresh() + p.episodeList.Refresh() + }) +} +func (p *PodcastsPage) loadChannel(id string) { + ch, err := p.provider.GetPodcastChannel(id) + if err != nil { + log.Printf("load podcast channel: %v", err) + return + } + fyne.Do(func() { p.episodes = ch.Episodes; p.channelTitle.SetText(ch.Title); p.episodeList.Refresh() }) +} +func (p *PodcastsPage) Reload() { go p.load() } +func (p *PodcastsPage) Route() controller.Route { return controller.PodcastsRoute() } +func (p *PodcastsPage) Save() SavedPage { return &savedPodcastsPage{p.contr, p.provider, p.pm} } +func (p *PodcastsPage) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(p.content) +} + +type savedPodcastsPage struct { + c *controller.Controller + p mediaprovider.PodcastProvider + pm *backend.PlaybackManager +} + +func (s *savedPodcastsPage) Restore() Page { return NewPodcastsPage(s.c, s.p, s.pm) } diff --git a/ui/browsing/podcastspage_test.go b/ui/browsing/podcastspage_test.go new file mode 100644 index 0000000..8099e17 --- /dev/null +++ b/ui/browsing/podcastspage_test.go @@ -0,0 +1,10 @@ +package browsing + +import "testing" + +func TestPodcastDescriptionText(t *testing.T) { + got := podcastDescriptionText(`Hello podcast & listeners.

]]>`) + if want := "Hello podcast & listeners."; got != want { + t.Fatalf("got %q, want %q", got, want) + } +} diff --git a/ui/browsing/router.go b/ui/browsing/router.go index 7d16c38..312eea9 100644 --- a/ui/browsing/router.go +++ b/ui/browsing/router.go @@ -61,6 +61,9 @@ func (r Router) CreatePage(rte controller.Route) Page { var rp mediaprovider.RadioProvider rp, _ = r.App.ServerManager.Server.(mediaprovider.RadioProvider) return NewRadiosPage(r.Controller, rp, r.App.PlaybackManager) + case controller.Podcasts: + pp, _ := r.App.ServerManager.Server.(mediaprovider.PodcastProvider) + return NewPodcastsPage(r.Controller, pp, r.App.PlaybackManager) } return nil } diff --git a/ui/controller/routes.go b/ui/controller/routes.go index 6e5cdc2..3b0104c 100644 --- a/ui/controller/routes.go +++ b/ui/controller/routes.go @@ -16,6 +16,7 @@ const ( Playlists Tracks Radios + Podcasts ) func (p PageName) String() string { @@ -44,6 +45,8 @@ func (p PageName) String() string { return "All Tracks" case Radios: return "Internet Radio Stations" + case Podcasts: + return "Podcasts" default: return "" } @@ -102,6 +105,8 @@ func RadiosRoute() Route { return Route{Page: Radios} } +func PodcastsRoute() Route { return Route{Page: Podcasts} } + func NowPlayingRoute() Route { return Route{Page: NowPlaying} } diff --git a/ui/controller/serverconnection.go b/ui/controller/serverconnection.go index eca2e6f..c8bf4ca 100644 --- a/ui/controller/serverconnection.go +++ b/ui/controller/serverconnection.go @@ -35,6 +35,7 @@ func (m *Controller) PromptForFirstServer() { SkipSSLVerify: d.SkipSSLVerify, } server := m.App.ServerManager.AddServer(d.Nickname, conn) + server.EnablePodcasts = d.EnablePodcasts if err := m.trySetPasswordAndConnectToServer(server, d.Password); err != nil { log.Printf("error connecting to server: %s", err.Error()) } @@ -67,41 +68,15 @@ func (c *Controller) DoConnectToServerWorkflow(server *backend.ServerConfig) { } func (c *Controller) doConnectWithPassword(server *backend.ServerConfig, pass string) { - canceled := false - ctx, cancel := context.WithCancel(context.Background()) - dlg := dialog.NewCustom(lang.L("Connecting"), lang.L("Cancel"), - widget.NewLabel(fmt.Sprintf(lang.L("Connecting to")+" %s", server.Nickname)), c.MainWindow) - dlg.SetOnClosed(func() { - canceled = true - cancel() - }) - c.haveModal = true - dlg.Show() - - // try to connect go func() { - defer cancel() // make sure to free up ctx resources if user does not cancel - - if err := c.tryConnectToServer(ctx, server, pass); err != nil { + if err := c.tryConnectToServer(context.Background(), server, pass); err != nil { fyne.Do(func() { - dlg.Hide() - c.haveModal = false - if canceled { + dlg := dialog.NewError(err, c.MainWindow) + dlg.SetOnClosed(func() { c.PromptForLoginAndConnect() - } else { - // connection failure - dlg := dialog.NewError(err, c.MainWindow) - dlg.SetOnClosed(func() { - c.PromptForLoginAndConnect() - }) - c.haveModal = true - dlg.Show() - } - }) - } else { - fyne.Do(func() { - dlg.Hide() - c.haveModal = false + }) + c.haveModal = true + dlg.Show() }) } }() @@ -150,6 +125,7 @@ func (m *Controller) PromptForLoginAndConnect() { server.Username = editD.Username server.LegacyAuth = editD.LegacyAuth server.SkipSSLVerify = editD.SkipSSLVerify + server.EnablePodcasts = editD.EnablePodcasts m.trySetPasswordAndConnectToServer(server, editD.Password) m.doModalClosed() } @@ -184,6 +160,7 @@ func (m *Controller) PromptForLoginAndConnect() { SkipSSLVerify: newD.SkipSSLVerify, } server := m.App.ServerManager.AddServer(newD.Nickname, conn) + server.EnablePodcasts = newD.EnablePodcasts m.trySetPasswordAndConnectToServer(server, newD.Password) m.doModalClosed() } diff --git a/ui/dialogs/addeditserverdialog.go b/ui/dialogs/addeditserverdialog.go index 0574c55..36d30f2 100644 --- a/ui/dialogs/addeditserverdialog.go +++ b/ui/dialogs/addeditserverdialog.go @@ -17,16 +17,17 @@ import ( type AddEditServerDialog struct { widget.BaseWidget - ServerType backend.ServerType - Nickname string - Host string - AltHost string - Username string - Password string - LegacyAuth bool - SkipSSLVerify bool - OnSubmit func() - OnCancel func() + ServerType backend.ServerType + Nickname string + Host string + AltHost string + Username string + Password string + LegacyAuth bool + SkipSSLVerify bool + EnablePodcasts bool + OnSubmit func() + OnCancel func() passField *widget.Entry submitBtn *widget.Button @@ -47,17 +48,21 @@ func NewAddEditServerDialog(title string, cancelable bool, prefillServer *backen a.Username = prefillServer.Username a.LegacyAuth = prefillServer.LegacyAuth a.SkipSSLVerify = prefillServer.SkipSSLVerify + a.EnablePodcasts = prefillServer.EnablePodcasts } titleLabel := widget.NewLabel(title) titleLabel.TextStyle.Bold = true legacyAuthCheck := widget.NewCheckWithData(lang.L("Use legacy authentication"), binding.BindBool(&a.LegacyAuth)) + podcastsCheck := widget.NewCheckWithData(lang.L("Enable podcasts"), binding.BindBool(&a.EnablePodcasts)) serverTypeChoice := widget.NewRadioGroup([]string{"Subsonic", "Jellyfin"}, func(s string) { a.ServerType = backend.ServerType(s) if s == string(backend.ServerTypeSubsonic) { legacyAuthCheck.Show() + podcastsCheck.Show() } else { legacyAuthCheck.Hide() + podcastsCheck.Hide() } }) skipSSLCheck := widget.NewCheckWithData(lang.L("Skip SSL certificate verification"), binding.BindBool(&a.SkipSSLVerify)) @@ -116,7 +121,7 @@ func NewAddEditServerDialog(title string, cancelable bool, prefillServer *backen widget.NewLabel(lang.L("Password")), a.passField, ), - container.NewHBox(layout.NewSpacer(), legacyAuthCheck, skipSSLCheck), + container.NewVBox(podcastsCheck, legacyAuthCheck, skipSSLCheck), widget.NewSeparator(), bottomRow, ) diff --git a/ui/dialogs/settingsdialog.go b/ui/dialogs/settingsdialog.go index 458292b..49a5a4d 100644 --- a/ui/dialogs/settingsdialog.go +++ b/ui/dialogs/settingsdialog.go @@ -801,6 +801,11 @@ func (s *SettingsDialog) createAdvancedTab() *container.TabItem { multi := widget.NewCheckWithData(lang.L("Allow multiple app instances"), binding.BindBool(&s.config.Application.AllowMultiInstance)) update := widget.NewCheckWithData(lang.L("Automatically check for updates"), binding.BindBool(&s.config.Application.EnableAutoUpdateChecker)) lrclib := widget.NewCheckWithData(lang.L("Enable LrcLib lyrics fetcher"), binding.BindBool(&s.config.Application.EnableLrcLib)) + savePasswords := widget.NewCheck(lang.L("Save passwords on this device"), func(enabled bool) { + s.config.Application.EnablePasswordStorage = enabled + s.setRestartRequired() + }) + savePasswords.Checked = s.config.Application.EnablePasswordStorage threeDigitValidator := func(text, selText string, r rune) bool { return unicode.IsDigit(r) && len(text)-len(selText) < 3 @@ -842,6 +847,7 @@ func (s *SettingsDialog) createAdvancedTab() *container.TabItem { multi, update, lrclib, + savePasswords, osMediaAPIs, preventScreensaver, imgCacheCfg, diff --git a/ui/mainwindow.go b/ui/mainwindow.go index b160495..2792a35 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -369,6 +369,8 @@ func (m *MainWindow) RunOnServerConnectedTasks(serverConf *backend.ServerConfig, _, supportsRadio := m.App.ServerManager.Server.(mediaprovider.RadioProvider) m.Toolbar.SetRadioButtonVisible(supportsRadio) + _, supportsPodcasts := m.App.ServerManager.Server.(mediaprovider.PodcastProvider) + m.Toolbar.SetPodcastButtonVisible(serverConf.EnablePodcasts && supportsPodcasts) }) m.App.SaveConfigFile() diff --git a/ui/sidebar.go b/ui/sidebar.go index d049749..2dc7c9b 100644 --- a/ui/sidebar.go +++ b/ui/sidebar.go @@ -106,7 +106,10 @@ func (s *Sidebar) updateLyrics() { return } } - if s.nowPlaying == nil || s.nowPlaying.Metadata().Type == mediaprovider.MediaItemTypeRadioStation { + tr, isTrack := s.nowPlaying.(*mediaprovider.Track) + if !isTrack { + s.lyricsLoading.Stop() + s.lyricsViewer.EnableTapToSeek() s.lyricsViewer.SetLyrics(nil) s.curLyrics = nil s.curLyricsID = "" @@ -121,8 +124,6 @@ func (s *Sidebar) updateLyrics() { Synced: true, Lines: []mediaprovider.LyricLine{{Text: ""}}, }) - tr, _ := s.nowPlaying.(*mediaprovider.Track) - s.lm.FetchLyricsAsync(tr, func(id string, lyrics *mediaprovider.Lyrics) { if id != s.nowPlayingID { return diff --git a/ui/toolbar.go b/ui/toolbar.go index eedcb79..937b93d 100644 --- a/ui/toolbar.go +++ b/ui/toolbar.go @@ -28,6 +28,7 @@ type Toolbar struct { navBtnsContainer *fyne.Container navBtnsPageMap map[controller.PageName]fyne.Resource radioBtn fyne.CanvasObject + podcastBtn fyne.CanvasObject quickSearchBtn *ttwidget.Button sidebarBtn *ttwidget.Button @@ -82,6 +83,14 @@ func (t *Toolbar) SetRadioButtonVisible(vis bool) { } } +func (t *Toolbar) SetPodcastButtonVisible(vis bool) { + if vis { + t.podcastBtn.Show() + } else { + t.podcastBtn.Hide() + } +} + // AddSettingsMenuItem adds an item to the Settings menu func (t *Toolbar) AddSettingsMenuItem(label string, icon fyne.Resource, action func()) { item := fyne.NewMenuItem(label, action) @@ -170,6 +179,10 @@ func (t *Toolbar) setupNavigationButtons(navigateFn func(controller.Route)) { t.radioBtn = t.addNavigationButton(myTheme.RadioIcon, controller.Radios, func() { navigateFn(controller.RadiosRoute()) }) + t.podcastBtn = t.addNavigationButton(myTheme.HeadphonesIcon, controller.Podcasts, func() { + navigateFn(controller.PodcastsRoute()) + }) + t.podcastBtn.Hide() } func (t *Toolbar) addNavigationButton(icon fyne.Resource, pageName controller.PageName, action func()) *ttwidget.Button { diff --git a/ui/widgets/nowplayingcard.go b/ui/widgets/nowplayingcard.go index 3bc0689..89f7d8a 100644 --- a/ui/widgets/nowplayingcard.go +++ b/ui/widgets/nowplayingcard.go @@ -23,8 +23,9 @@ import ( type NowPlayingCard struct { widget.BaseWidget - DisableRating bool - ShowAlbumYear bool + DisableRating bool + DisableSharing bool + ShowAlbumYear bool trackName *OptionHyperlink artistName *MultiHyperlink @@ -33,7 +34,9 @@ type NowPlayingCard struct { menu *widget.PopUpMenu ratingMenu *fyne.MenuItem - albumYear string + albumYear string + isPodcast bool + podcastSharingAvailable bool OnTrackNameTapped func() OnArtistNameTapped func(artistID string) @@ -144,6 +147,8 @@ func (n *NowPlayingCard) Update(track mediaprovider.MediaItem) { n.artistName.BuildSegments([]string{}, []string{}) n.albumName.BuildSegments([]string{}, []string{}) n.albumYear = "" + n.isPodcast = false + n.podcastSharingAvailable = false n.cover.Hidden = true } else { n.cover.Hidden = false @@ -153,15 +158,25 @@ func (n *NowPlayingCard) Update(track mediaprovider.MediaItem) { n.albumName.BuildSegments([]string{tr.Album}, []string{tr.AlbumID}) n.albumYear = strconv.Itoa(tr.Year) n.cover.PlaceholderIcon = myTheme.TracksIcon + } else if episode, ok := track.(*mediaprovider.PodcastEpisode); ok { + n.artistName.BuildSegments([]string{episode.ChannelTitle}, nil) + n.albumName.BuildSegments([]string{}, []string{}) + n.albumYear = "" + n.cover.PlaceholderIcon = myTheme.RadioIcon + n.isPodcast = true + n.podcastSharingAvailable = episode.StreamID != "" } else { n.artistName.BuildSegments([]string{}, []string{}) n.albumName.BuildSegments([]string{}, []string{}) n.albumName.Suffix = "" n.cover.PlaceholderIcon = myTheme.RadioIcon + n.isPodcast = false + n.podcastSharingAvailable = false } } n.trackName.Hidden = n.trackName.Text() == "" - n.trackName.SetMenuBtnEnabled(n.cover.PlaceholderIcon != myTheme.RadioIcon) + n.trackName.SetMenuBtnEnabled(n.cover.PlaceholderIcon != myTheme.RadioIcon || + (n.isPodcast && n.podcastSharingAvailable && !n.DisableSharing)) n.artistName.Hidden = len(n.artistName.Segments) == 0 n.albumName.Hidden = len(n.albumName.Segments) == 0 n.Refresh() @@ -181,7 +196,11 @@ func (n *NowPlayingCard) Refresh() { } func (n *NowPlayingCard) showMenu(btnPos fyne.Position) { - if n.menu == nil { + if n.isPodcast { + share := fyne.NewMenuItem(lang.L("Share")+"...", func() { n.onShare() }) + share.Icon = myTheme.ShareIcon + n.menu = widget.NewPopUpMenu(fyne.NewMenu("", share), fyne.CurrentApp().Driver().CanvasForObject(n)) + } else { n.ratingMenu = util.NewRatingSubmenu(n.onSetRating) favorite := fyne.NewMenuItem(lang.L("Set favorite"), func() { n.onSetFavorite(true) }) favorite.Icon = myTheme.FavoriteIcon @@ -193,12 +212,15 @@ func (n *NowPlayingCard) showMenu(btnPos fyne.Position) { info.Icon = theme.InfoIcon() share := fyne.NewMenuItem(lang.L("Share")+"...", func() { n.onShare() }) share.Icon = myTheme.ShareIcon + share.Disabled = n.DisableSharing m := fyne.NewMenu("", favorite, unfavorite, n.ratingMenu, playlist, info, share) n.menu = widget.NewPopUpMenu(m, fyne.CurrentApp().Driver().CanvasForObject(n)) } menuSize := n.menu.MinSize() - n.ratingMenu.Disabled = n.DisableRating + if !n.isPodcast { + n.ratingMenu.Disabled = n.DisableRating + } btnPos.Y -= (menuSize.Height + theme.Padding()*3) btnPos.X -= menuSize.Width / 2 n.menu.ShowAtPosition(btnPos)