diff --git a/sample.env b/sample.env index e478bc42..1ac83582 100644 --- a/sample.env +++ b/sample.env @@ -101,6 +101,8 @@ YOUTUBE_API_KEY= # SINGLE_ARTIST=true # Playlist name format: week (Weekly-Exploration-2026-Week5) or date (Weekly-Exploration-2026-01-31) # PLAYLISTNAME_FORMAT=week +# Overwrite track metadata with metadata from ListenBrainz when moving downloaded tracks (slskd) (default: false) +# OVERWRITE_METADATA=false # === Notifications === diff --git a/src/client/emby.go b/src/client/emby.go index 07af57b4..2ce88f5d 100644 --- a/src/client/emby.go +++ b/src/client/emby.go @@ -21,6 +21,10 @@ type EmbyPaths []struct { RefreshStatus string `json:"RefreshStatus"` } +type EmbyProviderIds struct { + MusicBrainzTrack string `json:"MusicBrainzTrack"` +} + type EmbyItemSearch struct { Items []EmbyItems `json:"Items"` TotalRecordCount int `json:"TotalRecordCount"` @@ -30,6 +34,7 @@ type EmbyItems struct { Name string `json:"Name"` ServerID string `json:"ServerId"` ID string `json:"Id"` + ProviderIds EmbyProviderIds `json:"ProviderIds"` Path string `json:"Path"` Album string `json:"Album,omitempty"` AlbumArtist string `json:"AlbumArtist,omitempty"` @@ -138,16 +143,24 @@ func (c *Emby) SearchSongs(tracks []*models.Track) error { return err } + normalizedTrackTitle := util.NormalizeTitle(track.Title) + normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle) for _, item := range results.Items { - 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)))) { + + normalizedItemTitle := util.NormalizeTitle(item.Name) + + musicBrainzMatch := track.MusicBrainzTrackID != "" && item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID + titleMatch := normalizedItemTitle == normalizedTrackTitle || normalizedItemTitle == normalizedCleanTitle + 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) + + if musicBrainzMatch || (titleMatch && artistMatch) { track.ID = item.ID track.Present = true break } - if track.File != "" && len(item.Artists) > 0 && - strings.Contains(strings.ToLower(item.Artists[0]), strings.ToLower(track.MainArtist)) && - strings.Contains(strings.ToLower(item.Path), strings.ToLower(track.File)) { + if track.File != "" && artistMatch && pathMatch { track.ID = item.ID track.Present = true break diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index c268260f..80e071de 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -40,14 +40,19 @@ type Audios struct { StartIndex int `json:"StartIndex"` } +type ProviderIds struct { + MusicBrainzTrack string `json:"MusicBrainzTrack"` +} + type Items struct { - Name string `json:"Name"` - ServerID string `json:"ServerId"` - ID string `json:"Id"` - Path string `json:"Path"` - Album string `json:"Album,omitempty"` - AlbumArtist string `json:"AlbumArtist,omitempty"` - Artists []string `json:"Artists"` + Name string `json:"Name"` + ServerID string `json:"ServerId"` + ID string `json:"Id"` + ProviderIds ProviderIds `json:"ProviderIds"` + Path string `json:"Path"` + Album string `json:"Album,omitempty"` + AlbumArtist string `json:"AlbumArtist,omitempty"` + Artists []string `json:"Artists"` } @@ -152,17 +157,24 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { if err = util.ParseResp(body, &results); err != nil { return err } - + normalizedTrackTitle := util.NormalizeTitle(track.Title) + normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle) for _, item := range results.Items { - 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)))) { + + normalizedItemTitle := util.NormalizeTitle(item.Name) + + musicBrainzMatch := track.MusicBrainzTrackID != "" && item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID + titleMatch := normalizedItemTitle == normalizedTrackTitle || normalizedItemTitle == normalizedCleanTitle + 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) + + if musicBrainzMatch || (titleMatch && artistMatch) { track.ID = item.ID track.Present = true break } - if track.File != "" && len(item.Artists) > 0 && - strings.Contains(strings.ToLower(item.Artists[0]), strings.ToLower(track.MainArtist)) && - strings.Contains(strings.ToLower(item.Path), strings.ToLower(track.File)) { + if track.File != "" && artistMatch && pathMatch { track.ID = item.ID track.Present = true break diff --git a/src/client/plex.go b/src/client/plex.go index f5231c0b..6f0880c8 100644 --- a/src/client/plex.go +++ b/src/client/plex.go @@ -134,6 +134,13 @@ type PlexPlaylist struct { } `json:"MediaContainer"` } +type GUID struct { + ID string `json:"id"` +} +type Metadata struct { + GUID []GUID `json:"Guid"` +} + type Plex struct { machineID string LibraryID string @@ -410,7 +417,7 @@ func (c *Plex) SearchSongs(tracks []*models.Track) error { } } - key, err := getPlexSong(track, all) + key, err := c.getPlexSong(track, all) if err != nil { slog.Warn("failed to find match", "title", track.Title, "err", err) continue @@ -549,20 +556,32 @@ func (c *Plex) getServer() error { return nil } -func getPlexSong(track *models.Track, metadata []SongMetadata) (string, error) { - normArtist := util.AlnumOnly(strings.ToLower(track.MainArtist)) +func (c *Plex) getPlexSong(track *models.Track, metadata []SongMetadata) (string, error) { + normArtist := util.AlnumOnly(track.MainArtist) + normalizedTrackTitle := util.NormalizeTitle(track.Title) + normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle) + normalizedAlbum := util.AlnumOnly(strings.ToLower(track.Album)) for _, md := range metadata { if md.Type != "track" { continue } - 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)) + var mbid string; + if c.AdminClient != nil { + mbid = c.AdminClient.getPlexMBID(md.RatingKey) + } else { + mbid = c.getPlexMBID(md.RatingKey) + } + + normalizedSongTitle := util.NormalizeTitle(md.Title) + musicBrainzMatch := mbid != "" && track.MusicBrainzReleaseTrackID == mbid + titleMatch := normalizedSongTitle == normalizedTrackTitle || normalizedSongTitle == normalizedCleanTitle + albumMatch := util.AlnumOnly(strings.ToLower(md.ParentTitle)) == normalizedAlbum + artistMatch := util.ContainsFold(util.AlnumOnly(md.OriginalTitle), normArtist) || util.ContainsFold(util.AlnumOnly(md.GrandparentTitle), normArtist) + + if musicBrainzMatch || (titleMatch && (albumMatch || artistMatch)) { + slog.Debug("matched track via metadata", "title", track.Title, "artist", track.Artist) return md.Key, nil } @@ -571,11 +590,11 @@ func getPlexSong(track *models.Track, metadata []SongMetadata) (string, error) { } media := md.Media[0] - pathMatch := strings.Contains(strings.ToLower(media.Part[0].File), strings.ToLower(track.File)) + pathMatch := util.ContainsFold(media.Part[0].File, track.File) durationMatch := util.Abs(media.Duration-track.Duration) < 10000 // duration within 10s if durationMatch && pathMatch { - slog.Debug(fmt.Sprintf("matched track via path: %s by %s", track.Title, track.Artist)) + slog.Debug("matched track via path", "title", track.Title, "artist", track.Artist) return md.Key, nil } } @@ -584,6 +603,28 @@ func getPlexSong(track *models.Track, metadata []SongMetadata) (string, error) { return "", fmt.Errorf("failed to find '%s' by '%s' in '%s'", track.Title, track.Artist, track.Album) } +func (c *Plex) getPlexMBID(ratingKey string) string { + params := fmt.Sprintf("/library/metadata/%s", ratingKey) + + + body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+params, nil, c.Cfg.Creds.Headers) + if err != nil { + return "" + } + + var metadata Metadata + if err = util.ParseResp(body, &metadata); err != nil { + return "" + } + prefix := "mbid://" + for _, guid := range metadata.GUID { + if strings.HasPrefix(guid.ID, prefix) { + return strings.TrimPrefix(guid.ID, prefix) + } + } + return "" +} + func (c *Plex) addtoPlaylist(tracks []*models.Track) { for _, track := range tracks { if track.ID != "" { diff --git a/src/client/subsonic.go b/src/client/subsonic.go index 08c17898..771cf7f4 100644 --- a/src/client/subsonic.go +++ b/src/client/subsonic.go @@ -35,17 +35,19 @@ type SubResponse struct { ServerVersion string `json:"serverVersion"` SearchResult3 struct { Song []struct { - ID string `json:"id"` - Title string `json:"title"` + ID string `json:"id"` + Title string `json:"title"` Artist string `json:"artist"` + Album string `json:"album"` Duration int `json:"duration"` + MusicBrainzID string `json:"musicBrainzId"` Path string `json:"path"` } `json:"song"` - } `json:"searchResult3,omitempty"` + } `json:"searchResult3"` Playlists struct { - Playlist []Playlist `json:"playlist,omitempty"` - } `json:"playlists,omitempty"` - Playlist Playlist `json:"playlist,omitempty"` + Playlist []Playlist `json:"playlist"` + } `json:"playlists"` + Playlist Playlist `json:"playlist"` } `json:"subsonic-response"` } @@ -143,14 +145,19 @@ func (c *Subsonic) SearchSongs(tracks []*models.Track) error { slog.Debug(fmt.Sprintf("[subsonic] no results found for %s", searchQuery)) continue } - + normalizedTrackTitle := util.NormalizeTitle(track.Title) + normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle) for _, song := range songs { - artistMatch := strings.Contains(strings.ToLower(song.Artist), strings.ToLower(track.MainArtist)) - titleMatch := util.NormalizeTitle(song.Title) == util.NormalizeTitle(track.Title) + normalizedSongTitle := util.NormalizeTitle(song.Title) + + musicBrainzMatch := track.MusicBrainzTrackID != "" && song.MusicBrainzID == track.MusicBrainzTrackID + artistMatch := util.ContainsFold(song.Artist, track.MainArtist) + albumMatch := util.ContainsFold(song.Album, track.Album) + titleMatch := normalizedSongTitle == normalizedTrackTitle || normalizedSongTitle == normalizedCleanTitle durationMatch := util.Abs(song.Duration - (track.Duration / 1000)) < 10 - pathMatch := strings.Contains(strings.ToLower(song.Path), strings.ToLower(track.File)) + pathMatch := util.ContainsFold(song.Path, track.File) - if artistMatch && titleMatch { + if musicBrainzMatch || (titleMatch && (albumMatch || artistMatch)) { track.ID = song.ID track.Present = true break diff --git a/src/config/config.go b/src/config/config.go index 72e05fd8..126e1a1d 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -91,17 +91,19 @@ type SubsonicConfig struct { } type DownloadConfig struct { - DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"` - PathTemplate string `env:"PATH_TEMPLATE"` - Youtube Youtube - YoutubeMusic YoutubeMusic - Slskd Slskd - ExcludeLocal bool - KeepPermissions bool `env:"KEEP_PERMISSIONS" env-default:"true"` // keep original file permissions when migrating download - RenameTrack bool `env:"RENAME_TRACK" env-default:"false"` // Rename track in {title}-{artist} format - UseSubDir bool `env:"USE_SUBDIRECTORY" env-default:"true"` - Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"` - Services []string `env:"DOWNLOAD_SERVICES" env-default:"youtube"` + DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"` + PathTemplate string `env:"PATH_TEMPLATE"` + Youtube Youtube + YoutubeMusic YoutubeMusic + Slskd Slskd + ExcludeLocal bool + DownloadLimiter int `env:"DOWNLOAD_LIMITER" env-default:"1"` // rate limit download operations + OverwriteMetadata bool `env:"OVERWRITE_METADATA" env-default:"false"` // overwrite metadata when migrating downloads + KeepPermissions bool `env:"KEEP_PERMISSIONS" env-default:"true"` // keep original file permissions when migrating download + RenameTrack bool `env:"RENAME_TRACK" env-default:"false"` // Rename track in {title}-{artist} format + UseSubDir bool `env:"USE_SUBDIRECTORY" env-default:"true"` + Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"` + Services []string `env:"DOWNLOAD_SERVICES" env-default:"youtube"` } type Filters struct { @@ -215,7 +217,7 @@ func (cfg *Config) ReadEnv() { func (cfg *Config) CommonFixes() { cfg.DownloadCfg.Youtube.FileExtension = strings.TrimPrefix(cfg.DownloadCfg.Youtube.FileExtension, ".") - cfg.DownloadCfg.Youtube.CoversDir = filepath.Join(filepath.Dir(cfg.Flags.CfgPath), "cache", "covers") + cfg.DownloadCfg.Youtube.CoversDir = filepath.Join(filepath.Dir(cfg.ServerCfg.WebDataDir), "cache", "covers") cfg.ClientCfg.URL = fixBaseURL(cfg.ClientCfg.URL) cfg.DownloadCfg.Slskd.URL = fixBaseURL(cfg.DownloadCfg.Slskd.URL) cfg.NormalizeDir() diff --git a/src/discovery/listenbrainz.go b/src/discovery/listenbrainz.go index 8f94f538..f1648068 100644 --- a/src/discovery/listenbrainz.go +++ b/src/discovery/listenbrainz.go @@ -461,14 +461,14 @@ func (c *ListenBrainz) enrichTracks(tracks []*models.Track, singleArtist bool) ( var mbData *MBRecording var mbErr error for attempt := 1; attempt <= 3; attempt++ { + + if attempt <= 3 { + time.Sleep(time.Duration(waitTime) * time.Second) + } mbData, mbErr = c.mbRequest(fmt.Sprintf("recording/%s?inc=media+releases+artist-credits+release-groups&fmt=json", track.MusicBrainzTrackID)) if mbErr == nil && mbData != nil { break } - - if attempt < 3 { - time.Sleep(time.Duration(waitTime) * time.Second) - } } if mbData != nil { diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 8dc4d65b..6a4f23ea 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -16,6 +16,8 @@ import ( "explo/src/models" "explo/src/util" + ffmpeg "github.com/u2takey/ffmpeg-go" + "golang.org/x/sync/errgroup" "golang.org/x/time/rate" ) @@ -68,7 +70,7 @@ func (c *DownloadClient) StartDownload(tracks *[]*models.Track) { var g errgroup.Group g.SetLimit(3) - limiter := rate.NewLimiter(rate.Every(time.Second), 1) + limiter := rate.NewLimiter(rate.Every(time.Second), c.Cfg.DownloadLimiter) for _, track := range *tracks { if track.Present { @@ -251,6 +253,12 @@ func (c *DownloadClient) MoveDownload(srcDir, destDir, trackPath string, track * if c.Cfg.RenameTrack { // Rename file to {title}-{artist} format track.File = getFilename(track.CleanTitle, track.MainArtist) + filepath.Ext(track.File) } + if c.Cfg.OverwriteMetadata { + metadata := util.BuildffmpegMetadata(*track) + if err := overwriteMetadata(metadata, srcFile); err != nil { + slog.Warn("problem overwriting metadata", "msg", err.Error()) + } + } in, err := os.Open(srcFile) if err != nil { @@ -324,6 +332,32 @@ func (c *DownloadClient) MoveDownload(srcDir, destDir, trackPath string, track * return nil } +func overwriteMetadata(metadata []string, srcFile string) error { + opts := ffmpeg.KwArgs{ + "c": "copy", + "metadata": metadata, + "loglevel": "error", + } + streams := []*ffmpeg.Stream{ + ffmpeg.Input(srcFile), + } + + tmpFile := tempAudioFile(srcFile) + + if err := util.WriteMetadata(streams, "", tmpFile, opts); err != nil { + return fmt.Errorf("failed to overwrite metadata: %w", err) + } else { + if err := os.Rename(tmpFile, srcFile); err != nil { + return fmt.Errorf("failed to rename tmp file: %w", err) + } + } + return nil +} + +func tempAudioFile(path string) string { + ext := filepath.Ext(path) + return strings.TrimSuffix(path, ext) + ".tmp" + ext +} func isDirEmpty(path string) (bool, error) { f, err := os.Open(path) @@ -342,4 +376,4 @@ func isDirEmpty(path string) (bool, error) { return true, nil // no entries } return false, err -} +} \ No newline at end of file diff --git a/src/downloader/slskd.go b/src/downloader/slskd.go index f1785632..15ec5a42 100644 --- a/src/downloader/slskd.go +++ b/src/downloader/slskd.go @@ -457,6 +457,7 @@ func wildcardArtist(artist string) string { // different failure states slskd has (format is "Completed,Rejected", "Errored,Cancelled" etc..) var failureStates = map[string]struct{} { + "Aborted": {}, "TimedOut": {}, "Rejected": {}, "Errored": {}, diff --git a/src/downloader/youtube.go b/src/downloader/youtube.go index 1942971f..f1497add 100644 --- a/src/downloader/youtube.go +++ b/src/downloader/youtube.go @@ -196,11 +196,17 @@ func saveVideo(c Youtube, track models.Track, stream *goutubedl.DownloadResult) } }() + defer func() { + if err := os.Remove(input); err != nil { + slog.Debug( + fmt.Sprintf("failed to remove %s", input), + logging.RuntimeAttr(err.Error()), + ) + } +}() + if _, err = io.Copy(file, stream); err != nil { slog.Error("failed to copy stream to file", "context", err.Error()) - if err = os.Remove(input); err != nil { - slog.Debug(fmt.Sprintf("failed to remove file %s", input), logging.RuntimeAttr(err.Error())) - } return false } @@ -220,53 +226,31 @@ func saveVideo(c Youtube, track models.Track, stream *goutubedl.DownloadResult) return false } - var cmd *ffmpeg.Stream - - if c.Cfg.EmbedCoverArt { - coversDir := c.Cfg.CoversDir - util.DownloadCover(track.CoverURL, coversDir) - - coverPath := filepath.Join( - coversDir, - track.MusicBrainzAlbumID+".jpg", - ) - - cmd = ffmpeg.Output( - []*ffmpeg.Stream{ - ffmpeg.Input(input), - ffmpeg.Input(coverPath), - }, - outputPath, - ffmpeg.KwArgs{ - "metadata": metadata, - "loglevel": "error", - }, - ).OverWriteOutput().ErrorToStdOut() + var opts ffmpeg.KwArgs + var streams []*ffmpeg.Stream + streams = append(streams, ffmpeg.Input(input)) + if c.Cfg.EmbedCoverArt && track.CoverURL != "" { + if track.CoverPath == "" { + if _, track.CoverPath = util.DownloadCover(track.CoverURL, c.Cfg.CoversDir); track.CoverPath != "" { + streams = append(streams, ffmpeg.Input(track.CoverPath)) + } + } + opts = ffmpeg.KwArgs{ + "metadata": metadata, + "loglevel": "error", + } } else { - cmd = ffmpeg.Input(input).Output( - outputPath, - ffmpeg.KwArgs{ - "map": "0:a", - "metadata": metadata, - "loglevel": "error", - }, - ).OverWriteOutput().ErrorToStdOut() - } - - if c.Cfg.FfmpegPath != "" { - cmd.SetFfmpegPath(c.Cfg.FfmpegPath) + opts = ffmpeg.KwArgs{ + "map": "0:a", + "metadata": metadata, + "loglevel": "error", + } } - if err = cmd.Run(); err != nil { - slog.Error("failed to convert audio", "context", err.Error()) - if err = os.Remove(input); err != nil { - slog.Debug(fmt.Sprintf("failed to remove %s", input), logging.RuntimeAttr(err.Error())) - } + if err := util.WriteMetadata(streams, c.Cfg.FfmpegPath, outputPath, opts); err != nil { return false } - if err = os.Remove(input); err != nil { - slog.Debug(fmt.Sprintf("failed to remove %s", input), logging.RuntimeAttr(err.Error())) - } + return true } diff --git a/src/main/main.go b/src/main/main.go index 468b3304..e6e49201 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -34,6 +34,7 @@ func loadCustomTracks(dataDir, playlistID string) ([]*models.Track, string, erro MainArtist string `json:"mainArtist"` Release string `json:"release"` CoverURL string `json:"coverUrl"` + CoverPath string `json:"coverPath"` } type cacheFile struct { Tracks []cachedTrack `json:"tracks"` @@ -79,6 +80,7 @@ func loadCustomTracks(dataDir, playlistID string) ([]*models.Track, string, erro MainArtist: mainArtist, Album: t.Release, CoverURL: t.CoverURL, + CoverPath: t.CoverPath, } } return tracks, name, nil diff --git a/src/models/types.go b/src/models/types.go index 3682e11c..f3656baf 100644 --- a/src/models/types.go +++ b/src/models/types.go @@ -21,6 +21,7 @@ type Track struct { Present bool // is track present in the system or not Duration int // Track duration in milliseconds (not available for every track) CoverURL string // External cover art URL (Cover Art Archive), used at run-time to download art + CoverPath string // full Filesystem path to cover OriginalDate string OriginalYear int Genres string diff --git a/src/util/http.go b/src/util/http.go index 39d029e6..3d7a3d51 100644 --- a/src/util/http.go +++ b/src/util/http.go @@ -112,15 +112,19 @@ func DownloadFile(url, destPath string) (string, error) { return destPath, nil } -// DownloadCover downloads coverURL into coversDir and returns "/api/covers/.jpg". +// DownloadCover downloads coverURL into coversDir and returns cover api and filesystem path. // 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 { +func DownloadCover(url, coversDir string) (string, string) { if url == "" { - return "" + return "", "" } parts := strings.Split(strings.TrimRight(url, "/"), "/") + + if len(parts) < 2 { + return "", "" +} // Spotify CDN: https://i.scdn.co/image/ → use last segment // CAA: https://coverartarchive.org/release//front-250 → use second-to-last id := parts[len(parts)-2] @@ -147,5 +151,6 @@ func DownloadCover(url, coversDir string) string { }() } } - return "/api/covers/" + id + ".jpg" + apiURL := fmt.Sprintf("/api/covers/%s.jpg", id) + return apiURL, destPath } diff --git a/src/util/metadata.go b/src/util/metadata.go index 9c211d7a..8c9d48d2 100644 --- a/src/util/metadata.go +++ b/src/util/metadata.go @@ -4,6 +4,8 @@ import ( "explo/src/models" "fmt" "strings" + + ffmpeg "github.com/u2takey/ffmpeg-go" ) // Return absolute difference between tracks @@ -65,3 +67,17 @@ func BuildffmpegMetadata(track models.Track) []string { return metadata } + +func WriteMetadata(streams []*ffmpeg.Stream, ffmpegPath, filePath string, opts ffmpeg.KwArgs) error { + + cmd := ffmpeg.Output(streams, filePath, opts).OverWriteOutput().ErrorToStdOut() + + if ffmpegPath != "" { + cmd.SetFfmpegPath(ffmpegPath) + } + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to write metadata: %w", err) + } + return nil +} diff --git a/src/util/sanitize.go b/src/util/sanitize.go index de04d69f..35c73ec2 100644 --- a/src/util/sanitize.go +++ b/src/util/sanitize.go @@ -37,3 +37,8 @@ func FilenameSafe(s string) string { func AlnumOnly(s string) string { return alnumRe.ReplaceAllString(s, "") } + +// Case insensitive check if substring is present in s +func ContainsFold(s, substr string) bool { + return strings.Contains(strings.ToLower(s), strings.ToLower(substr)) +} \ No newline at end of file diff --git a/src/web/backend/playlists.go b/src/web/backend/playlists.go index a427aa1d..8db584fc 100644 --- a/src/web/backend/playlists.go +++ b/src/web/backend/playlists.go @@ -115,6 +115,7 @@ func WritePlaylistCache(cfgPath, playlist string, tracks []*models.Track, added Artist string `json:"artist"` Release string `json:"release"` CoverURL string `json:"coverUrl,omitempty"` + CoverPath string `json:"coverPath,omitempty"` InLibrary *bool `json:"inLibrary,omitempty"` } type cache struct { @@ -128,7 +129,7 @@ func WritePlaylistCache(cfgPath, playlist string, tracks []*models.Track, added ct := make([]cachedTrack, len(tracks)) for i, t := range tracks { - localCover := util.DownloadCover(t.CoverURL, coversDir) + apiPath, coverPath := util.DownloadCover(t.CoverURL, coversDir) var inLibrary *bool if added != nil { v := added[t.CleanTitle+"|"+t.Artist] @@ -139,7 +140,8 @@ func WritePlaylistCache(cfgPath, playlist string, tracks []*models.Track, added Title: t.CleanTitle, Artist: t.Artist, Release: t.Album, - CoverURL: localCover, + CoverURL: apiPath, + CoverPath: coverPath, InLibrary: inLibrary, } } @@ -232,6 +234,7 @@ type cachedPrefetchTrack struct { MainArtist string `json:"mainArtist,omitempty"` Release string `json:"release"` CoverURL string `json:"coverUrl,omitempty"` + CoverPath string `json:"coverPath,omitempty"` } // writePreliminaryCache writes the track cache with remote cover URLs immediately. @@ -258,7 +261,8 @@ func downloadAndCacheCovers(cfgDir, playlistType string, tracks []PlaylistTrack) } ct := make([]cachedPrefetchTrack, len(tracks)) for i, t := range tracks { - ct[i] = cachedPrefetchTrack{Rank: i + 1, Title: t.Title, Artist: t.Artist, MainArtist: t.MainArtist, Release: t.Album, CoverURL: util.DownloadCover(t.CoverURL, coversDir)} + APIPath, coverPath := util.DownloadCover(t.CoverURL, coversDir) + ct[i] = cachedPrefetchTrack{Rank: i + 1, Title: t.Title, Artist: t.Artist, MainArtist: t.MainArtist, Release: t.Album, CoverURL: APIPath, CoverPath: coverPath} } if writeTrackCache(cfgDir, playlistType, ct) { slog.Info("prefetch: cache updated", "playlist", playlistType, "covers", "local") diff --git a/src/web/sample.env b/src/web/sample.env index 7b6df352..0a64f959 100644 --- a/src/web/sample.env +++ b/src/web/sample.env @@ -99,6 +99,8 @@ YOUTUBE_API_KEY= # SINGLE_ARTIST=true # Playlist name format: week (Weekly-Exploration-2026-Week5) or date (Weekly-Exploration-2026-01-31) # PLAYLISTNAME_FORMAT=week +# Overwrite track metadata with metadata from ListenBrainz when moving downloaded tracks (slskd) (default: false) +# OVERWRITE_METADATA=false # === Notifications ===