diff --git a/src/client/emby.go b/src/client/emby.go index 219d910..ee60e5a 100644 --- a/src/client/emby.go +++ b/src/client/emby.go @@ -150,10 +150,11 @@ func (c *Emby) SearchSongs(tracks []*models.Track) error { musicBrainzMatch := track.MusicBrainzTrackID != "" && item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID titleMatch := normalizedItemTitle == normalizedCleanTitle + albumMatch := util.ContainsFold(item.Album, track.Album) artistMatch := strings.EqualFold(item.AlbumArtist, track.MainArtist) || (len(item.Artists) > 0 && strings.EqualFold(item.Artists[0], track.MainArtist)) - pathMatch := util.ContainsFold(item.Path,track.File) + pathMatch := util.ContainsFold(item.Path, track.File) - if musicBrainzMatch || (titleMatch && artistMatch) { + if musicBrainzMatch || (titleMatch && (albumMatch || artistMatch)) { track.ID = item.ID track.Present = true break diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index 08d911d..579944a 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -168,10 +168,11 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { musicBrainzMatch := track.MusicBrainzTrackID != "" && item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID titleMatch := normalizedItemTitle == normalizedCleanTitle + albumMatch := util.ContainsFold(item.Album, track.Album) artistMatch := strings.EqualFold(item.AlbumArtist, track.MainArtist) || (len(item.Artists) > 0 && strings.EqualFold(item.Artists[0], track.MainArtist)) - pathMatch := util.ContainsFold(item.Path,track.File) + pathMatch := util.ContainsFold(item.Path, track.File) - if musicBrainzMatch || (titleMatch && artistMatch) { + if musicBrainzMatch || (titleMatch && (albumMatch || artistMatch)) { track.ID = item.ID track.Present = true break diff --git a/src/discovery/listenbrainz.go b/src/discovery/listenbrainz.go index 908195c..ddb25df 100644 --- a/src/discovery/listenbrainz.go +++ b/src/discovery/listenbrainz.go @@ -181,6 +181,11 @@ type MBRecording struct { } `json:"releases"` } +type TitleArgs struct { + CleanTitle string + FeatArtists []string +} + type ListenBrainz struct { HttpClient *util.HttpClient Headers map[string]string @@ -356,17 +361,13 @@ func (c *ListenBrainz) getTracks(mbids []string, singleArtist bool) ([]*models.T if len(recArtists) > 1 { mainArtist = recArtists[0].Name if singleArtist { - var b strings.Builder - b.WriteString(title) - b.WriteString(" feat. ") - b.WriteString(recArtists[1].Name) - - for _, a := range recArtists[2:] { - b.WriteString(", ") - b.WriteString(a.Name) - } + var titleArgs TitleArgs + titleArgs.CleanTitle = rec.Name + for _, artist := range recArtists[1:] { - title = b.String() + titleArgs.FeatArtists = append(titleArgs.FeatArtists, artist.Name) + } + title = c.buildTrackTitle(titleArgs) artist = mainArtist } } @@ -455,17 +456,12 @@ func (c *ListenBrainz) enrichTracks(tracks []*models.Track, singleArtist bool) ( if len(recArtists) > 1 { mainArtist = recArtists[0].Name if singleArtist { - var b strings.Builder - b.WriteString(title) - b.WriteString(" feat. ") - b.WriteString(recArtists[1].Name) - - for _, a := range recArtists[2:] { - b.WriteString(", ") - b.WriteString(a.Name) + var titleArgs TitleArgs + titleArgs.CleanTitle = rec.Name + for _, artist := range recArtists[1:] { + titleArgs.FeatArtists = append(titleArgs.FeatArtists, artist.Name) } - - title = b.String() + title = c.buildTrackTitle(titleArgs) artist = mainArtist } else { for _, recArtist := range recArtists { @@ -637,7 +633,6 @@ func (c *ListenBrainz) getImportPlaylist(user string) (string, error) { bestID = parts[len(parts)-1] } } - if playlists.Count+playlists.Offset >= playlists.PlaylistCount || playlists.Count == 0 { break } @@ -707,17 +702,13 @@ func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) (stri if len(trackMeta.Artists) > 1 { mainArtist = trackMeta.Artists[0].ArtistCreditName if singleArtist { - var b strings.Builder - b.WriteString(title) - b.WriteString(" feat. ") - b.WriteString(trackArtists[1].ArtistCreditName) - - for _, a := range trackArtists[2:] { - b.WriteString(", ") - b.WriteString(a.ArtistCreditName) + var titleargs TitleArgs + titleargs.CleanTitle = track.Title + for _, artist := range trackArtists[1:] { + titleargs.FeatArtists = append(titleargs.FeatArtists, artist.ArtistCreditName) } - title = b.String() - artist = trackArtists[0].ArtistCreditName + title = c.buildTrackTitle(titleargs) + artist = trackArtists[0].ArtistCreditName } } @@ -750,6 +741,27 @@ func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) (stri } +func (c *ListenBrainz) buildTrackTitle(args TitleArgs) string { + var b strings.Builder + + if len(args.FeatArtists) == 0 { + return args.CleanTitle + } + + b.WriteString(args.CleanTitle) + b.WriteString(" (feat. ") + if len(args.FeatArtists) == 1 { + b.WriteString(args.FeatArtists[0]) + } else { + b.WriteString(strings.Join(args.FeatArtists[:len(args.FeatArtists)-1], ", ")) + b.WriteString(" & ") + b.WriteString(args.FeatArtists[len(args.FeatArtists)-1]) + } + b.WriteString(")") + + return b.String() +} + // Handle ListenBrainz API requests func (c *ListenBrainz) lbRequest(path string) ([]byte, error) { diff --git a/src/downloader/slskd.go b/src/downloader/slskd.go index c6ff741..6760be8 100644 --- a/src/downloader/slskd.go +++ b/src/downloader/slskd.go @@ -275,38 +275,47 @@ func (c Slskd) CollectFiles(track models.Track, searchResults SearchResults) ([] files := slices.Collect(func(yield func(File) bool) { for _, result := range searchResults { - if result.FileCount > 0 && result.HasFreeUploadSlot { - for _, file := range result.Files { - file.Extension = strings.TrimPrefix(strings.ToLower(file.Extension), ".") - if file.Extension == "" { - extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(string(file.Name))), ".") - file.Extension = util.AlnumOnly(extension) // sanitize extension incase of bad chars + if result.FileCount == 0 || !result.HasFreeUploadSlot { + continue + } + for _, file := range result.Files { + nameExt := util.AlnumOnly(strings.TrimPrefix(strings.ToLower(filepath.Ext(string(file.Name))), ".")) + reportedExt := strings.TrimPrefix(strings.ToLower(file.Extension), ".") + if nameExt != "" { + if reportedExt != "" && reportedExt != nameExt { + slog.Debug("extension mismatch between filename and metadata, using filename ext", + "track", track.CleanTitle, "filename", file.Name, "reportedExtension", reportedExt, "actualExtension", nameExt) } - - if !slices.Contains(c.Cfg.Filters.Extensions, file.Extension) && ContainsKeyword(track, file.Name, c.Cfg.Filters.FilterList) { - continue + file.Extension = nameExt + } else { + file.Extension = reportedExt } - if track.Duration > 0 && util.Abs(track.Duration/1000-file.Length) > 10 { // skip song if track lengths have a 10s+ difference - continue - } + if !slices.Contains(c.Cfg.Filters.Extensions, file.Extension) || ContainsKeyword(track, file.Name, c.Cfg.Filters.FilterList) { + continue + } - sanitizedFilename := util.AlnumOnly(string(file.Name)) - if (containsLower(sanitizedFilename, sanitizedArtist) || containsLower(sanitizedFilename, sanitizedAlbum)) && containsLower(sanitizedFilename, sanitizedTitle) { - file.Username = result.Username - if !yield(file) { + if track.Duration > 0 && util.Abs(track.Duration/1000-file.Length) > 10 { // skip song if track lengths have a 10s+ difference + continue + } + + sanitizedFilename := util.AlnumOnly(string(file.Name)) + matchesArtist := containsLower(sanitizedFilename, sanitizedArtist) + matchesAlbum := containsLower(sanitizedFilename, sanitizedAlbum) + matchesTitle := containsLower(sanitizedFilename, sanitizedTitle) + if (matchesArtist || matchesAlbum) && matchesTitle { + file.Username = result.Username + if !yield(file) { return - } } } } } }) - if len(files) != 0 { - return files, nil - } else { + if len(files) == 0 { return nil, fmt.Errorf("no tracks passed collection for %s - %s", track.MainArtist, track.CleanTitle) - } + } + return files, nil } func (c Slskd) filterFiles(files []File) ([]File, error) { @@ -318,11 +327,11 @@ func (c Slskd) filterFiles(files []File) ([]File, error) { continue } - if file.BitRate > 0 && file.BitRate <= c.Cfg.Filters.MinBitRate { + if file.BitRate > 0 && file.BitRate < c.Cfg.Filters.MinBitRate { continue } - if file.BitDepth > 0 && file.BitDepth <= c.Cfg.Filters.MinBitDepth { + if file.BitDepth > 0 && file.BitDepth < c.Cfg.Filters.MinBitDepth { continue } diff --git a/src/web/backend/playlist/handlers.go b/src/web/backend/playlist/handlers.go index b1af52e..49ff8fa 100644 --- a/src/web/backend/playlist/handlers.go +++ b/src/web/backend/playlist/handlers.go @@ -318,62 +318,6 @@ func (p *Playlist) HandleDeleteCustomPlaylist(w http.ResponseWriter, r *http.Req w.WriteHeader(http.StatusNoContent) } -// handleSaveSchedule updates a single playlist's schedule in the .env file. -func (p *Playlist) HandleSaveSchedule(w http.ResponseWriter, r *http.Request) { - var body struct { - Name string `json:"name"` - Enabled bool `json:"enabled"` - Day int `json:"day"` // 0=Sun…6=Sat, -1=every day - Hour int `json:"hour"` - Minute int `json:"minute"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) - return - } - - var envPrefix string - var defaultFlags string - - if def, ok := defs.PlaylistDefs[body.Name]; ok { - envPrefix = def.EnvPrefix - defaultFlags = def.DefaultFlags - } else if defs.CustomIDRe.MatchString(body.Name) { - envPrefix = util.CustomEnvPrefix(body.Name) - defaultFlags = "--playlist " + body.Name - } else { - http.Error(w, "unknown playlist name", http.StatusBadRequest) - return - } - - updates := map[string]string{} - if !body.Enabled { - // Toggle off — truly disable, regardless of day value carried over from state - updates[envPrefix+"_SCHEDULE"] = "" - updates[envPrefix+"_FLAGS"] = "" - } else if body.Day == -2 { - // "Never" — keep playlist active for manual runs but remove auto-schedule - updates[envPrefix+"_SCHEDULE"] = "" - updates[envPrefix+"_FLAGS"] = defaultFlags - } else { - dom := "*" - dow := "*" - if body.Day == 100 { - dom = "1" - } else if body.Day >= 0 { - dow = fmt.Sprintf("%d", body.Day) - } - updates[envPrefix+"_SCHEDULE"] = fmt.Sprintf("%d %d %s * %s", body.Minute, body.Hour, dom, dow) - updates[envPrefix+"_FLAGS"] = defaultFlags - } - - if err := p.settings.UpdateEnvKeys(updates, web.SampleEnv); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusOK) -} - // handleGetPlaylist serves the tracklist cache written by explo during its last run. // Returns an empty track list if no cache exists yet. func (p *Playlist) HandleGetPlaylist(w http.ResponseWriter, r *http.Request) { diff --git a/src/web/backend/playlist/playlist.go b/src/web/backend/playlist/playlist.go index 1847f9d..88b2618 100644 --- a/src/web/backend/playlist/playlist.go +++ b/src/web/backend/playlist/playlist.go @@ -50,7 +50,9 @@ var validPlaylistTypes = func() map[string]bool { }() func NewPlaylist(cfg app.Config, settings *settings.Settings) *Playlist { - return &Playlist{cfg: cfg} + return &Playlist{ + cfg: cfg, + settings: settings} } // isValidPlaylistID accepts built-in playlist types and custom-* IDs (blocks path traversal). diff --git a/src/web/backend/settings/handlers.go b/src/web/backend/settings/handlers.go index f7c3627..c040077 100644 --- a/src/web/backend/settings/handlers.go +++ b/src/web/backend/settings/handlers.go @@ -94,6 +94,7 @@ func (s *Settings) HandleResetConfig(w http.ResponseWriter, r *http.Request) { // handleSaveSchedule updates a single playlist's schedule in the .env file. func (s *Settings) HandleSaveSchedule(w http.ResponseWriter, r *http.Request) { var body struct { + ID string `json:"id"` Name string `json:"name"` Enabled bool `json:"enabled"` Day int `json:"day"` // 0=Sun…6=Sat, -1=every day @@ -108,12 +109,12 @@ func (s *Settings) HandleSaveSchedule(w http.ResponseWriter, r *http.Request) { var envPrefix string var defaultFlags string - if def, ok := defs.PlaylistDefs[body.Name]; ok { + if def, ok := defs.PlaylistDefs[body.ID]; ok { envPrefix = def.EnvPrefix defaultFlags = def.DefaultFlags - } else if defs.CustomIDRe.MatchString(body.Name) { + } else if defs.CustomIDRe.MatchString(body.ID) { envPrefix = util.CustomEnvPrefix(body.Name) - defaultFlags = "--playlist " + body.Name + defaultFlags = "--playlist " + body.ID } else { http.Error(w, "unknown playlist name", http.StatusBadRequest) return @@ -211,6 +212,7 @@ func (s *Settings) HandleSaveEnrichMetadata(w http.ResponseWriter, r *http.Reque // HandleSaveReplacePlaylist injects or removes --replace-playlist=false from a playlist's FLAGS env var. func (s *Settings) HandleSaveReplacePlaylist(w http.ResponseWriter, r *http.Request) { var body struct { + ID string `json:"id"` Name string `json:"name"` Replace bool `json:"replace"` } @@ -221,12 +223,12 @@ func (s *Settings) HandleSaveReplacePlaylist(w http.ResponseWriter, r *http.Requ var envPrefix string var defaultFlags string - if def, ok := defs.PlaylistDefs[body.Name]; ok { + if def, ok := defs.PlaylistDefs[body.ID]; ok { envPrefix = def.EnvPrefix defaultFlags = def.DefaultFlags - } else if defs.CustomIDRe.MatchString(body.Name) { + } else if defs.CustomIDRe.MatchString(body.ID) { envPrefix = util.CustomEnvPrefix(body.Name) - defaultFlags = "--playlist " + body.Name + defaultFlags = "--playlist " + body.ID } else { http.Error(w, "unknown playlist name", http.StatusBadRequest) return diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx index 35b1181..e65fffc 100644 --- a/src/web/frontend/src/components/Settings.jsx +++ b/src/web/frontend/src/components/Settings.jsx @@ -243,10 +243,10 @@ function HomeSection() { } for (const cp of customList) { s[cp.id] = cp.schedule - ? { enabled: true, editing: false, ...cronToFields(cp.schedule) } + ? { name: cp.name, enabled: true, editing: false, ...cronToFields(cp.schedule) } : cp.flags - ? { enabled: true, editing: false, day: -2, hour: 4, minute: 0 } - : { enabled: false, day: -1, hour: 4, minute: 0, editing: false } + ? { name: cp.name, enabled: true, editing: false, day: -2, hour: 4, minute: 0 } + : { name: cp.name, enabled: false, day: -1, hour: 4, minute: 0, editing: false } } setSchedules(s) }) @@ -300,7 +300,7 @@ function HomeSection() { scheduleSaveStatus: scheduleSaveStatus[id] || '', onToggle: v => { setSchedules(prev => ({ ...prev, [id]: { ...prev[id], enabled: v } })) - saveSchedule(id, v, s.day, s.hour, s.minute) + saveSchedule(id, s.name, v, s.day, s.hour, s.minute) .then(() => flashStatus(id, 'Saved.')) .catch(() => flashStatus(id, 'Error saving.')) }, @@ -308,7 +308,7 @@ function HomeSection() { ...prev, [id]: { ...prev[id], editing: !prev[id].editing } })), onSave: () => { - saveSchedule(id, s.enabled, s.day, s.hour, s.minute) + saveSchedule(id, s.name, s.enabled, s.day, s.hour, s.minute) .then(() => flashStatus(id, 'Saved.')) .catch(() => flashStatus(id, 'Error saving.')) setSchedules(prev => ({ ...prev, [id]: { ...prev[id], editing: false } })) diff --git a/src/web/frontend/src/components/ui/PlaylistCard.jsx b/src/web/frontend/src/components/ui/PlaylistCard.jsx index 35e2e63..a340117 100644 --- a/src/web/frontend/src/components/ui/PlaylistCard.jsx +++ b/src/web/frontend/src/components/ui/PlaylistCard.jsx @@ -639,7 +639,7 @@ export function PlaylistCard({ boxShadow: '0 8px 24px #00000088', }} > - {sourceUrl && ( + {sourceUrl && window.isSecureContext && (