Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/client/emby.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ func (c *Emby) CheckRefreshState() bool {

func (c *Emby) SearchSongs(tracks []*models.Track) error {
for _, track := range tracks {
reqParam := fmt.Sprintf("/emby/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path", url.QueryEscape(track.CleanTitle))
reqParam := fmt.Sprintf("/emby/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path", url.QueryEscape(util.CleanSearchTitle(track.CleanTitle)))

body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers)
if err != nil {
Expand All @@ -139,7 +139,7 @@ func (c *Emby) SearchSongs(tracks []*models.Track) error {
}

for _, item := range results.Items {
if strings.EqualFold(track.MainArtist, item.AlbumArtist) && (strings.EqualFold(item.Name, track.CleanTitle) || (track.File != "" && strings.Contains(strings.ToLower(item.Path), strings.ToLower(track.File)))) {
if strings.EqualFold(track.MainArtist, item.AlbumArtist) && (util.NormalizeTitle(item.Name) == util.NormalizeTitle(track.Title) || (track.File != "" && strings.Contains(strings.ToLower(item.Path), strings.ToLower(track.File)))) {
track.ID = item.ID
track.Present = true
break
Expand Down
4 changes: 2 additions & 2 deletions src/client/jellyfin.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ func (c *Jellyfin) CheckRefreshState() bool {

func (c *Jellyfin) SearchSongs(tracks []*models.Track) error {
for _, track := range tracks {
reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path", url.QueryEscape(track.CleanTitle))
reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path", url.QueryEscape(util.CleanSearchTitle(track.CleanTitle)))

body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers)
if err != nil {
Expand All @@ -154,7 +154,7 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error {
}

for _, item := range results.Items {
if strings.EqualFold(track.MainArtist, item.AlbumArtist) && (strings.EqualFold(item.Name, track.CleanTitle) || (track.File != "" && strings.Contains(strings.ToLower(item.Path), strings.ToLower(track.File)))) {
if strings.EqualFold(track.MainArtist, item.AlbumArtist) && (util.NormalizeTitle(item.Name) == util.NormalizeTitle(track.Title) || (track.File != "" && strings.Contains(strings.ToLower(item.Path), strings.ToLower(track.File)))) {
track.ID = item.ID
track.Present = true
break
Expand Down
18 changes: 9 additions & 9 deletions src/client/plex.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ func (c *Plex) SearchSongs(tracks []*models.Track) error {
for _, track := range tracks {
params := fmt.Sprintf(
"/hubs/search?query=%s&limit=10",
url.QueryEscape(track.CleanTitle),
url.QueryEscape(util.CleanSearchTitle(track.CleanTitle)),
)

var body []byte
Expand All @@ -390,13 +390,13 @@ func (c *Plex) SearchSongs(tracks []*models.Track) error {
}

if err != nil {
slog.Warn("search request failed for '%s': %s", track.Title, err.Error())
slog.Warn("search request failed", "title", track.Title, "err", err)
continue
}

var hubResults PlexHubSearch
if err := util.ParseResp(body, &hubResults); err != nil {
slog.Warn("failed to parse hub response for '%s': %s", track.Title, err.Error())
slog.Warn("failed to parse hub response", "title", track.Title, "err", err)
continue
}

Expand All @@ -412,7 +412,7 @@ func (c *Plex) SearchSongs(tracks []*models.Track) error {

key, err := getPlexSong(track, all)
if err != nil {
slog.Warn("failed to find match for '%s': %s", track.Title, err.Error())
slog.Warn("failed to find match", "title", track.Title, "err", err)
continue
}
if key != "" {
Expand Down Expand Up @@ -550,16 +550,16 @@ func (c *Plex) getServer() error {
}

func getPlexSong(track *models.Track, metadata []SongMetadata) (string, error) {
loweredArtist := strings.ToLower(track.MainArtist)
normArtist := util.AlnumOnly(strings.ToLower(track.MainArtist))

for _, md := range metadata {
if md.Type != "track" {
continue
}

titleMatch := strings.EqualFold(md.Title, track.Title) || strings.EqualFold(md.Title, track.CleanTitle)
albumMatch := strings.EqualFold(md.ParentTitle, track.Album)
artistMatch := strings.Contains(strings.ToLower(md.OriginalTitle), loweredArtist) || strings.Contains(strings.ToLower(md.GrandparentTitle), loweredArtist)
titleMatch := util.NormalizeTitle(md.Title) == util.NormalizeTitle(track.Title)
albumMatch := util.AlnumOnly(strings.ToLower(md.ParentTitle)) == util.AlnumOnly(strings.ToLower(track.Album))
artistMatch := strings.Contains(util.AlnumOnly(strings.ToLower(md.OriginalTitle)), normArtist) || strings.Contains(util.AlnumOnly(strings.ToLower(md.GrandparentTitle)), normArtist)

if titleMatch && (albumMatch || artistMatch) {
slog.Debug(fmt.Sprintf("matched track via metadata: %s by %s", track.Title, track.Artist))
Expand Down Expand Up @@ -590,7 +590,7 @@ func (c *Plex) addtoPlaylist(tracks []*models.Track) {
params := fmt.Sprintf("/playlists/%s/items?uri=server://%s/com.plexapp.plugins.library%s", c.Cfg.PlaylistID, c.machineID, track.ID)

if _, err := c.HttpClient.MakeRequest("PUT", c.Cfg.URL+params, nil, c.Cfg.Creds.Headers); err != nil {
slog.Warn("failed to add %s to playlist: %s", track.Title, err.Error())
slog.Warn("failed to add to playlist", "title", track.Title, "err", err)
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/client/subsonic.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ func (c *Subsonic) AddLibrary() error {

func (c *Subsonic) SearchSongs(tracks []*models.Track) error {
for _, track := range tracks {
searchQuery := fmt.Sprintf("%s %s", track.CleanTitle, track.MainArtist)
searchQuery := fmt.Sprintf("%s %s", util.CleanSearchTitle(track.CleanTitle), track.MainArtist)
reqParam := fmt.Sprintf("search3?query=%s&f=json", url.QueryEscape(searchQuery))

body, err := c.subsonicRequest(reqParam)
Expand All @@ -146,7 +146,7 @@ func (c *Subsonic) SearchSongs(tracks []*models.Track) error {

for _, song := range songs {
artistMatch := strings.Contains(strings.ToLower(song.Artist), strings.ToLower(track.MainArtist))
titleMatch := strings.EqualFold(song.Title, track.Title) || strings.EqualFold(song.Title, track.CleanTitle)
titleMatch := util.NormalizeTitle(song.Title) == util.NormalizeTitle(track.Title)
durationMatch := util.Abs(song.Duration - (track.Duration / 1000)) < 10
pathMatch := strings.Contains(strings.ToLower(song.Path), strings.ToLower(track.File))

Expand Down
2 changes: 1 addition & 1 deletion src/downloader/monitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ func tracksProcessed(tracks []*models.Track, progressMap map[string]*DownloadMon
key := fmt.Sprintf("%s|%s", track.ID, track.File)
tracker, exists := progressMap[key]
if !track.Present && exists && !tracker.Skipped {
slog.Info("file still present", "file", track.File)
slog.Info("[monitor] track download still in progress", "title", track.CleanTitle, "artist", track.MainArtist, "file", track.File)
return false
}
}
Expand Down
15 changes: 10 additions & 5 deletions src/main/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ type Song struct {
// models.Track slices, bypassing the LB discovery step entirely.
func loadCustomTracks(dataDir, playlistID string) ([]*models.Track, string, error) {
type cachedTrack struct {
Title string `json:"title"`
Artist string `json:"artist"`
Release string `json:"release"`
CoverURL string `json:"coverUrl"`
Title string `json:"title"`
Artist string `json:"artist"`
MainArtist string `json:"mainArtist"`
Release string `json:"release"`
CoverURL string `json:"coverUrl"`
}
type cacheFile struct {
Tracks []cachedTrack `json:"tracks"`
Expand Down Expand Up @@ -67,11 +68,15 @@ func loadCustomTracks(dataDir, playlistID string) ([]*models.Track, string, erro

tracks := make([]*models.Track, len(c.Tracks))
for i, t := range c.Tracks {
mainArtist := t.MainArtist
if mainArtist == "" {
mainArtist = t.Artist
}
tracks[i] = &models.Track{
CleanTitle: t.Title,
Title: t.Title,
Artist: t.Artist,
MainArtist: t.Artist,
MainArtist: mainArtist,
Album: t.Release,
CoverURL: t.CoverURL,
}
Expand Down
17 changes: 12 additions & 5 deletions src/util/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,22 @@ func DownloadFile(url, destPath string) (string, error) {
return destPath, nil
}

// DownloadCover downloads coverURL into coversDir and returns "/api/covers/<mbid>.jpg".
// Returns "" if url is empty.
// DownloadCover downloads coverURL into coversDir and returns "/api/covers/<id>.jpg".
// For CoverArtArchive URLs the id is the MusicBrainz release MBID (second-to-last
// path segment). For Spotify CDN URLs (i.scdn.co) the id is the image hash (last
// path segment). Returns "" if url is empty.
func DownloadCover(url, coversDir string) string {
if url == "" {
return ""
}
parts := strings.Split(strings.TrimRight(url, "/"), "/")
mbid := parts[len(parts)-2]
destPath := filepath.Join(coversDir, mbid+".jpg")
// Spotify CDN: https://i.scdn.co/image/<hash> → use last segment
// CAA: https://coverartarchive.org/release/<mbid>/front-250 → use second-to-last
id := parts[len(parts)-2]
if strings.Contains(url, "scdn.co") || strings.Contains(url, "spotifycdn.com") {
id = parts[len(parts)-1]
}
destPath := filepath.Join(coversDir, id+".jpg")
if _, err := os.Stat(destPath); os.IsNotExist(err) {
resp, err := http.Get(url) //nolint:noctx
if err == nil {
Expand All @@ -140,5 +147,5 @@ func DownloadCover(url, coversDir string) string {
}()
}
}
return "/api/covers/" + mbid + ".jpg"
return "/api/covers/" + id + ".jpg"
}
27 changes: 24 additions & 3 deletions src/util/sanitize.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,33 @@
package util

import "regexp"
import (
"regexp"
"strings"
)

var (
filenameRe = regexp.MustCompile(`[^\p{L}\d._,\-]+`)
alnumRe = regexp.MustCompile(`[^\p{L}\d]+`)
filenameRe = regexp.MustCompile(`[^\p{L}\d._,\-]+`)
alnumRe = regexp.MustCompile(`[^\p{L}\d]+`)
featTailRe = regexp.MustCompile(`(?i)\s*[\(\[\{]\s*(feat\.?|featuring|ft\.?|with)\s[^\)\]\}]*[\)\]\}]\s*$`)
remasterTailRe = regexp.MustCompile(`(?i)\s*[-–—]\s*\d{4}\s*remaster(ed)?\s*$`)
)

// CleanSearchTitle strips trailing (feat. …) and "- 2011 Remaster" suffixes
// but keeps the title human-readable for use in search API queries.
func CleanSearchTitle(s string) string {
s = featTailRe.ReplaceAllString(s, "")
s = remasterTailRe.ReplaceAllString(s, "")
return strings.TrimSpace(s)
}

// NormalizeTitle strips trailing (feat. …) annotations, lowercases,
// and reduces to alphanumeric-only for fuzzy title comparison.
func NormalizeTitle(s string) string {
s = featTailRe.ReplaceAllString(s, "")
s = remasterTailRe.ReplaceAllString(s, "")
return AlnumOnly(strings.ToLower(s))
}

// FilenameSafe replaces characters unsafe for filenames with '_'
func FilenameSafe(s string) string {
return filenameRe.ReplaceAllString(s, "_")
Expand Down
16 changes: 11 additions & 5 deletions src/web/backend/apple_music.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func resolveArtworkURL(tpl string) string {
// fetchAppleMusicPlaylist scrapes a public Apple Music playlist page and extracts
// track info from the embedded server data.
// Returns (playlistName, artworkURL, tracks, error) where tracks are [title, artist, album, coverURL].
func fetchAppleMusicPlaylist(pageURL string) (string, string, [][4]string, error) {
func fetchAppleMusicPlaylist(pageURL string) (string, string, []PlaylistTrack, error) {
req, err := http.NewRequest("GET", pageURL, nil)
if err != nil {
return "", "", nil, fmt.Errorf("apple music: invalid URL: %w", err)
Expand Down Expand Up @@ -104,7 +104,7 @@ func fetchAppleMusicPlaylist(pageURL string) (string, string, [][4]string, error

// extractServerData parses the <script id="serialized-server-data"> blob for
// the playlist name, playlist artwork URL (from the header section), and tracks with artwork.
func extractServerData(htmlStr string) (string, string, [][4]string, error) {
func extractServerData(htmlStr string) (string, string, []PlaylistTrack, error) {
scripts := extractScriptByID(htmlStr, "serialized-server-data")
if len(scripts) == 0 {
return "", "", nil, fmt.Errorf("apple music: no serialized-server-data found in page")
Expand All @@ -117,7 +117,7 @@ func extractServerData(htmlStr string) (string, string, [][4]string, error) {

var playlistName string
var artworkURL string
var tracks [][4]string
var tracks []PlaylistTrack

for _, outer := range ssd.Data {
for _, sec := range outer.Data.Sections {
Expand All @@ -144,7 +144,7 @@ func extractServerData(htmlStr string) (string, string, [][4]string, error) {
}

// Track section.
tracks = make([][4]string, 0, len(sec.Items))
tracks = make([]PlaylistTrack, 0, len(sec.Items))
for _, item := range sec.Items {
album := ""
if len(item.TertiaryLinks) > 0 {
Expand All @@ -154,7 +154,13 @@ func extractServerData(htmlStr string) (string, string, [][4]string, error) {
if item.Artwork != nil {
coverURL = resolveArtworkURL(item.Artwork.Dictionary.URL)
}
tracks = append(tracks, [4]string{item.Title, item.ArtistName, album, coverURL})
tracks = append(tracks, PlaylistTrack{
Title: item.Title,
Artist: item.ArtistName,
MainArtist: item.ArtistName,
Album: album,
CoverURL: coverURL,
})
}
}
}
Expand Down
28 changes: 15 additions & 13 deletions src/web/backend/custom_playlists.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ import (
type CustomPlaylist struct {
ID string `json:"id"`
Name string `json:"name"`
Source string `json:"source"` // "listenbrainz" | "apple_music"
SourceURL string `json:"source_url,omitempty"` // original URL for dedup + refresh
LBMBID string `json:"lb_mbid,omitempty"` // ListenBrainz MBID (backward compat)
ArtworkURL string `json:"artwork_url,omitempty"` // playlist cover image (Apple Music)
Source string `json:"source"` // "listenbrainz" | "apple_music" | "spotify"
SourceURL string `json:"source_url,omitempty"` // original URL for dedup + refresh
LBMBID string `json:"lb_mbid,omitempty"` // ListenBrainz MBID (backward compat)
ArtworkURL string `json:"artwork_url,omitempty"` // playlist cover image (Apple Music)
ArtworkUploaded bool `json:"artwork_uploaded,omitempty"` // true after artwork has been pushed to the music app
RefreshDays int `json:"refresh_days"`
ColorIndex int `json:"color_index"`
Expand Down Expand Up @@ -127,7 +127,7 @@ func saveCustomPlaylists(cfgDir string, playlists []CustomPlaylist) error {
type FetchResult struct {
Name string
ArtworkURL string
Tracks [][4]string
Tracks []PlaylistTrack
}

// fetchCustomPlaylistTracks dispatches to the appropriate source fetcher.
Expand All @@ -137,6 +137,9 @@ func fetchCustomPlaylistTracks(p CustomPlaylist) (FetchResult, error) {
case "apple_music":
name, art, tracks, err := fetchAppleMusicPlaylist(p.SourceURL)
return FetchResult{name, art, tracks}, err
case "spotify":
name, art, tracks, err := fetchSpotifyPlaylist(p.SourceURL)
return FetchResult{name, art, tracks}, err
default: // "listenbrainz" or legacy empty
mbid := p.LBMBID
if mbid == "" && p.SourceURL != "" {
Expand All @@ -154,10 +157,7 @@ func fetchCustomPlaylistTracks(p CustomPlaylist) (FetchResult, error) {
if err != nil {
return FetchResult{}, err
}
tracks := make([][4]string, len(modelTracks))
for i, t := range modelTracks {
tracks[i] = [4]string{t.CleanTitle, t.Artist, t.Album, t.CoverURL}
}
tracks := modelTracksToPlaylistTracks(modelTracks)
return FetchResult{Name: name, Tracks: tracks}, nil
}
}
Expand All @@ -167,6 +167,8 @@ func extractSourceID(source, url string) (string, error) {
switch source {
case "apple_music":
return extractAppleMusicID(url)
case "spotify":
return extractSpotifyID(url)
default:
return extractLBMBID(url)
}
Expand Down Expand Up @@ -306,7 +308,7 @@ func (s *Server) handleImportCustomPlaylist(w http.ResponseWriter, r *http.Reque
// Save metadata
// Derive LBMBID for backward compatibility (LB playlists only)
var lbMBID string
if body.Source != "apple_music" {
if body.Source != "apple_music" && body.Source != "spotify" {
lbMBID = sourceID
}

Expand Down Expand Up @@ -347,9 +349,9 @@ func (s *Server) handleImportCustomPlaylist(w http.ResponseWriter, r *http.Reque
seen := make(map[string]bool)
covers := make([]string, 0, 6)
for _, t := range tracks {
if t[3] != "" && !seen[t[3]] {
seen[t[3]] = true
covers = append(covers, t[3])
if t.CoverURL != "" && !seen[t.CoverURL] {
seen[t.CoverURL] = true
covers = append(covers, t.CoverURL)
}
if len(covers) >= 6 {
break
Expand Down
Loading
Loading