add features
This commit is contained in:
+11
-4
@@ -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 {
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, `"`, `\"`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+120
-13
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user