diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..a304d2fa --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,84 @@ +# Contributing to Explo + +Thanks for your interest in contributing to Explo. + +Explo is maintained by a small number of active contributors with limited time, so every change is considered not only for its usefulness, but also for its maintenance cost. + +## Design Philosophy + +Explo started as a simple personal Go script in 2023: fetch recommendations from ListenBrainz, find the tracks on YouTube, and create playlists in Navidrome (Subsonic). It was originally intended to run as a single binary on a server. + +While Explo has grown significantly since then, its core goal has remained the same: **automated music discovery for self-hosted music systems.** + +Explo is designed to be a **set-and-forget** solution. Once configured, it should run with minimal user intervention, continuously requesting music and creating playlists. Optional interaction is acceptable, but automation remains the default. Features that cannot reasonably fit this automation-first approach may be rejected. + +This philosophy also applies to Explo's development. The codebase should be as close to set-and-forget as the application itself. Changes should avoid introducing unnecessary maintenance, manual intervention, or complexity for maintainers. + +The project prioritizes: + +- Reliability over feature count +- Automation over manual workflows +- Compatibility across supported platforms +- Long-term maintainability + +Not every useful feature belongs in Explo. Features may be declined when they do not align with the project's goals, even if they solve a legitimate use case. + +## Backward Compatibility + +Compatibility is a priority. + +Changes should avoid breaking existing configurations, Docker deployments or supported integrations whenever possible. If a breaking change is necessary, discuss it first and provide a clear migration path. + +## Pull Requests + +Please discuss changes before beginning work on them. + +- Check existing issues first +- If an issue already exists, comment there before starting work +- If the idea involves a specific music system, please try to check if it can be implemented on other supported systems as well + +Opening a pull request without prior discussion may result in it being rejected. + +Wait for maintainer feedback before investing significant work in a new feature. + +### Keep changes focused + +Pull requests are easier to review when they solve one problem at a time. + +Good examples include: + +- Bug fixes +- Small, well-scoped features +- Refactors that solve a specific maintenance issue + +If a pull request contains multiple related fixes or features, use a title that reflects the overall change rather than a single problem. + +## Coding Expectations + +You do not need to know every part of the codebase, but you should understand the code you work with and systems you modify. + +Before submitting: + +- Test the implementation on every supported system affected by your changes +- Make sure existing functionality continues to work unless the change intentionally modifies it + +## AI Usage + +AI-driven development is discouraged. + +Examples include: + +- Most or all code generated by LLM agents +- Submitting generated code without understanding how it works or accounting for platform limitations + +AI-assisted development is allowed when used as a supporting tool. + +Examples include: + +- Rubber ducking +- Explaining unfamiliar parts of the codebase +- Generating small helper functions + +Contributors are responsible for understanding and testing all submitted code. + +Large AI-generated implementations or refactors that the author cannot explain, verify, or maintain will be **rejected**. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 87d110ec..2fa010bb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,13 +28,12 @@ FROM python:3.12-alpine RUN apk add --no-cache \ libc6-compat \ ffmpeg \ - yt-dlp \ tzdata \ shadow \ su-exec # Install ytmusicapi in the container -RUN pip install --no-cache-dir ytmusicapi +RUN pip install --no-cache-dir ytmusicapi yt-dlp # Create user to set PUID/PGID values RUN useradd -ms /bin/sh explo diff --git a/README.md b/README.md index bf988419..9dd8ca07 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Explo uses the [ListenBrainz](https://listenbrainz.org/) recommendation engine t - Apple Music - ListenBrainz - Spotify -- Request tracks from YouTube, Soulseek, or both +- Automatically request playlist tracks from YouTube and Soulseek, or albums through Lidarr - Add metadata to downloaded tracks - Create playlists in your music system - Optionally keep previous playlists for later listening diff --git a/docker/start.sh b/docker/start.sh index 205f1bb5..9561d63d 100644 --- a/docker/start.sh +++ b/docker/start.sh @@ -3,6 +3,18 @@ PUID="${PUID:-0}" PGID="${PGID:-0}" + +# Update yt-dlp on startup +pip install --disable-pip-version-check --root-user-action=ignore --no-cache-dir --upgrade yt-dlp +# Clear crontabs +crontab -r + +# Update yt-dlp periodically +YTDLP_UPDATE="${YTDLP_UPDATE:-59 23 * * *}" +echo "$YTDLP_UPDATE echo 'Updating yt-dlp...' && pip install --disable-pip-version-check --root-user-action=ignore --no-cache-dir --upgrade yt-dlp >> /proc/1/fd/1 2>&1" >> /etc/crontabs/root +echo "[setup] Registered yt-dlp update job" +echo " Schedule: $YTDLP_UPDATE" + if [ "$PUID" != "0" ] || [ "$PGID" != "0" ]; then groupmod -o -g "$PGID" explo usermod -o -u "$PUID" explo @@ -12,7 +24,6 @@ else echo "[setup] WARN: running as root. Consider defining PUID & PGID in docker-compose to run as a non-root user" RUN_USER="root" RUNNER="" - fi mkdir -p /opt/explo @@ -52,7 +63,7 @@ fi # $CRON_SHCEDULE was deprecated in v0.11.0, keeping this block for backwards compatibility if [ -n "$CRON_SCHEDULE" ]; then - echo "$CRON_SCHEDULE apk add --no-cache --upgrade yt-dlp && cd /opt/explo && $RUNNER ./explo --config \"$_cfg\" >> /proc/1/fd/1 2>&1" > /etc/crontabs/root + echo "$CRON_SCHEDULE cd /opt/explo && $RUNNER ./explo --config \"$_cfg\" >> /proc/1/fd/1 2>&1" >> /etc/crontabs/root chmod 600 /etc/crontabs/root echo "[setup] Registered single CRON_SCHEDULE job: $CRON_SCHEDULE" crond -f -l 2 @@ -71,7 +82,7 @@ for var in $(env | grep "_SCHEDULE=" | cut -d= -f1); do fi # Default: just run explo if flags are empty - cmd="apk add --no-cache --upgrade yt-dlp && cd /opt/explo && $RUNNER ./explo --config \"$_cfg\" $flags >> /proc/1/fd/1 2>&1" + cmd="cd /opt/explo && $RUNNER ./explo --config \"$_cfg\" $flags >> /proc/1/fd/1 2>&1" echo "$schedule $cmd" >> /etc/crontabs/root echo "[setup] Registered job: $job" @@ -85,7 +96,7 @@ echo "[setup] Starting cron..." if [ "$EXECUTE_ON_START" = "true" ]; then echo "[setup] Executing startup task..." - apk add --no-cache --upgrade yt-dlp && cd /opt/explo && $RUNNER ./explo --config "$_cfg" $START_FLAGS + cd /opt/explo && $RUNNER ./explo --config "$_cfg" $START_FLAGS fi crond -f -l 2 diff --git a/src/client/client.go b/src/client/client.go index 518b4276..c666e90a 100644 --- a/src/client/client.go +++ b/src/client/client.go @@ -7,13 +7,59 @@ import ( "log/slog" "net/http" "os" + "path/filepath" "time" + "strings" "explo/src/config" "explo/src/models" "explo/src/util" ) +type SearchResult struct { + ID string + Title string + Album string + Artist string + Artists []string + Path string + Duration int // seconds + MBID string + Score int +} + +// normalized track data for matching +type NormalisedTrack struct { + CleanTitle string + MainArtist string + Album string + File string + Duration int + MBTrackID string + MBReleaseTrackID string +} + +// Client manages interactions with the selected music system +type Client struct { + System string + Cfg *config.ClientConfig + API APIClient +} + +type APIClient interface { + GetLibrary() error + GetAuth() error + AddHeader() error + AddLibrary() error + SearchSongs([]*models.Track) error + RefreshLibrary() error + CheckRefreshState() bool + CreatePlaylist([]*models.Track) error + SearchPlaylist() error + UpdatePlaylist() error + DeletePlaylist() error +} + // uploadPlaylistArtwork POSTs raw image bytes to a music app's artwork endpoint. // Plex, Jellyfin, and Emby all accept the same format — POST + Content-Type: image/jpeg + raw body. // The only per-client difference is the URL path, which each caller builds before invoking. @@ -47,27 +93,6 @@ func uploadPlaylistArtwork(hc *util.HttpClient, endpoint, localPath string, head return nil } -// Client manages interactions with the selected music system -type Client struct { - System string - Cfg *config.ClientConfig - API APIClient -} - -type APIClient interface { - GetLibrary() error - GetAuth() error - AddHeader() error - AddLibrary() error - SearchSongs([]*models.Track) error - RefreshLibrary() error - CheckRefreshState() bool - CreatePlaylist([]*models.Track) error - SearchPlaylist() error - UpdatePlaylist() error - DeletePlaylist() error -} - // ArtworkUploader is an optional capability for clients that support setting // playlist artwork. Use a type assertion: if u, ok := c.API.(client.ArtworkUploader); ok {...}. type ArtworkUploader interface { @@ -227,3 +252,77 @@ func (c *Client) DeletePlaylist() error { } return nil } + +func BestMatch(track *models.Track, results []SearchResult, minScore int) (SearchResult, bool) { + bestScore := -1 + var best SearchResult + nmTrack := NormalisedTrack{ + CleanTitle: util.NormalizeTitle(track.CleanTitle), + MainArtist: track.MainArtist, + Album: track.Album, + File: filepath.Base(track.File), + Duration: track.Duration, + MBTrackID: track.MusicBrainzTrackID, + MBReleaseTrackID: track.MusicBrainzReleaseTrackID} + + for _, r := range results { + score := rankResult(nmTrack, r) + if score > bestScore { + bestScore = score + best = r + } + if bestScore == definitiveMatchScore { + break + } + } + if bestScore < minScore { + return SearchResult{}, false + } + best.Score = bestScore + return best, true +} + +const definitiveMatchScore = 1000 +func rankResult(track NormalisedTrack, r SearchResult) int { + score := 0 + + resultTitle := util.NormalizeTitle(r.Title) + + if (track.MBTrackID != "" && r.MBID == track.MBTrackID) || (track.MBReleaseTrackID != "" && r.MBID == track.MBReleaseTrackID) { + return definitiveMatchScore // definitive match + } + if track.CleanTitle == resultTitle { + score += 45 + } else if len(track.CleanTitle) > 3 && (strings.Contains(resultTitle, track.CleanTitle) || strings.Contains(track.CleanTitle, resultTitle)) { + score += 25 + } + if strings.EqualFold(track.Album, r.Album) { + score += 20 + } else if util.ContainsFold(track.Album, r.Album) || util.ContainsFold(r.Album, track.Album) { + score += 15 + } + if strings.EqualFold(track.MainArtist, r.Artist) || (len(r.Artists) > 0 && strings.EqualFold(r.Artists[0], track.MainArtist)) { + score += 30 + } else if (util.ContainsFold(track.MainArtist, r.Artist) || util.ContainsFold(r.Artist, track.MainArtist)) || (len(r.Artists) > 0 && util.ContainsFold(r.Artists[0], track.MainArtist)) { + score += 15 + } + + resultFile := filepath.Base(r.Path) + + if track.File != "" && strings.EqualFold(track.File, resultFile) { + score += 50 + } + durationSet := track.Duration != 0 && r.Duration != 0 + if durationSet { + durationDiff := util.Abs(r.Duration-track.Duration/1000) + switch { + case durationDiff < 3: + score += 10 + case durationDiff < 10: + score += 5 + case durationDiff > 30: + score -= 10 + } +} + return score +} \ No newline at end of file diff --git a/src/client/emby.go b/src/client/emby.go index ee60e5a9..fa2b0b45 100644 --- a/src/client/emby.go +++ b/src/client/emby.go @@ -36,6 +36,7 @@ type EmbyItems struct { ID string `json:"Id"` ProviderIds EmbyProviderIds `json:"ProviderIds"` Path string `json:"Path"` + RunTimeTicks int `json:"RunTimeTicks"` Album string `json:"Album,omitempty"` AlbumArtist string `json:"AlbumArtist,omitempty"` Artists []string `json:"Artists"` @@ -131,7 +132,8 @@ 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,ProviderIDs", url.QueryEscape(util.CleanSearchTitle(track.CleanTitle))) + searchQuery := util.CleanSearchTitle(track.CleanTitle) + reqParam := fmt.Sprintf("/emby/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path,ProviderIDs", url.QueryEscape(searchQuery)) body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers) if err != nil { @@ -143,33 +145,33 @@ func (c *Emby) SearchSongs(tracks []*models.Track) error { return err } - normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle) + if len(results.Items) == 0 { + slog.Debug("no results returned for query", "query", searchQuery, "trackArtist", track.MainArtist, "trackAlbum", track.Album) + continue + } + + searchData := make([]SearchResult, 0, len(results.Items)) for _, item := range results.Items { - normalizedItemTitle := util.NormalizeTitle(item.Name) - - 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) - - if musicBrainzMatch || (titleMatch && (albumMatch || artistMatch)) { - track.ID = item.ID - track.Present = true - break - } - - if track.File != "" && artistMatch && pathMatch { - track.ID = item.ID - track.Present = true - break - } + searchData = append(searchData, SearchResult{ + ID: item.ID, + Title: item.Name, + Album: item.Album, + Artist: item.AlbumArtist, + Artists: item.Artists, + Path: item.Path, + Duration: (item.RunTimeTicks / 10000000), + MBID: item.ProviderIds.MusicBrainzTrack, + }) } - - if !track.Present { - slog.Debug(fmt.Sprintf("[emby] failed to find '%s' by '%s' in album '%s'", track.Title, track.Artist, track.Album)) + trackMatch, ok := BestMatch(track, searchData, c.Cfg.MatchScore) + if !ok { + slog.Debug("failed to find match, no results returned above threshold score", "threshold", c.Cfg.MatchScore, "searchQuery", searchQuery) + continue } + track.ID = trackMatch.ID + track.Present = true + slog.Debug("matched track", "searchQuery", searchQuery, "matchScore", trackMatch.Score, "trackArtist", trackMatch.Artist, "trackAlbum", trackMatch.Album) } return nil } @@ -187,12 +189,13 @@ func (c *Emby) SearchPlaylist() error { return err } - if len(results.Items) != 0 { - c.Cfg.PlaylistID = results.Items[0].ID - return nil - } else { - return fmt.Errorf("no results found for %s", c.Cfg.PlaylistName) + for _, playlist := range results.Items { + if playlist.Name == c.Cfg.PlaylistName { + c.Cfg.PlaylistID = playlist.ID + return nil + } } + return fmt.Errorf("no results found for playlist: %s", c.Cfg.PlaylistName) } func (c *Emby) CreatePlaylist(tracks []*models.Track) error { diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index 579944a6..8a3822c1 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -45,14 +45,15 @@ type ProviderIds struct { } type Items struct { - 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"` + Name string `json:"Name"` + ServerID string `json:"ServerId"` + ID string `json:"Id"` + ProviderIds ProviderIds `json:"ProviderIds"` + Path string `json:"Path"` + RunTimeTicks int `json:"RunTimeTicks"` + Album string `json:"Album,omitempty"` + AlbumArtist string `json:"AlbumArtist,omitempty"` + Artists []string `json:"Artists"` } @@ -150,7 +151,8 @@ 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,ProviderIDs", url.QueryEscape(util.CleanSearchTitle(track.CleanTitle))) + searchQuery := util.CleanSearchTitle(track.CleanTitle) + reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path,ProviderIDs", url.QueryEscape(searchQuery)) body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers) if err != nil { @@ -161,33 +163,32 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { if err = util.ParseResp(body, &results); err != nil { return err } - normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle) - for _, item := range results.Items { - - normalizedItemTitle := util.NormalizeTitle(item.Name) - - 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) - - if musicBrainzMatch || (titleMatch && (albumMatch || artistMatch)) { - track.ID = item.ID - track.Present = true - break - } - - if track.File != "" && artistMatch && pathMatch { - track.ID = item.ID - track.Present = true - break - } + if len(results.Items) == 0 { + slog.Debug("no results returned for query", "query", searchQuery, "trackArtist", track.MainArtist, "trackAlbum", track.Album) + continue } - if !track.Present { - slog.Debug(fmt.Sprintf("[jellyfin] failed to find '%s' by '%s' in album '%s'", track.Title, track.Artist, track.Album)) + searchData := make([]SearchResult, 0, len(results.Items)) + for _, item := range results.Items { + searchData = append(searchData, SearchResult{ + ID: item.ID, + Title: item.Name, + Album: item.Album, + Artist: item.AlbumArtist, + Artists: item.Artists, + Path: item.Path, + Duration: (item.RunTimeTicks / 10000000), + MBID: item.ProviderIds.MusicBrainzTrack, + }) + } + trackMatch, ok := BestMatch(track, searchData, c.Cfg.MatchScore) + if !ok { + slog.Debug("failed to find match, no results returned above threshold score", "threshold", c.Cfg.MatchScore, "searchQuery", searchQuery) + continue } + track.ID = trackMatch.ID + track.Present = true + slog.Debug("matched track", "searchQuery", searchQuery, "matchScore", trackMatch.Score, "trackArtist", trackMatch.Artist, "trackAlbum", trackMatch.Album) } return nil } @@ -204,12 +205,13 @@ func (c *Jellyfin) SearchPlaylist() error { return err } - if len(results.SearchHints) != 0 { - c.Cfg.PlaylistID = results.SearchHints[0].ID - return nil - } else { - return fmt.Errorf("no results found for playlist: %s", c.Cfg.PlaylistName) + for _, playlist := range results.SearchHints { + if playlist.Name == c.Cfg.PlaylistName { + c.Cfg.PlaylistID = playlist.ID + return nil + } } + return fmt.Errorf("no results found for playlist: %s", c.Cfg.PlaylistName) } func (c *Jellyfin) CreatePlaylist(tracks []*models.Track) error { diff --git a/src/client/plex.go b/src/client/plex.go index a4779659..af1740a7 100644 --- a/src/client/plex.go +++ b/src/client/plex.go @@ -86,16 +86,18 @@ type SongSearch struct { type Media struct { ID int `json:"id"` Duration int `json:"duration"` - Part []struct { + Part []Part `json:"Part"` + AudioChannels int `json:"audioChannels"` + AudioCodec string `json:"audioCodec"` + Container string `json:"container"` +} + +type Part struct { ID int `json:"id"` Key string `json:"key"` Duration int `json:"duration"` File string `json:"file"` Size int `json:"size"` - } `json:"Part"` - AudioChannels int `json:"audioChannels"` - AudioCodec string `json:"audioCodec"` - Container string `json:"container"` } type PlexSearch struct { @@ -134,11 +136,15 @@ type PlexPlaylist struct { } `json:"MediaContainer"` } -type GUID struct { - ID string `json:"id"` -} -type Metadata struct { - GUID []GUID `json:"Guid"` +type MetadataResponse struct { + MediaContainer struct { + Metadata []struct { + PlexGUID string `json:"guid"` + GUID []struct { + ID string `json:"id"` + } `json:"Guid"` + } `json:"Metadata"` + } `json:"MediaContainer"` } type Plex struct { @@ -389,9 +395,9 @@ func (c *Plex) CheckRefreshState() bool { } func (c *Plex) SearchSongs(tracks []*models.Track) error { for _, track := range tracks { + searchQuery := util.CleanSearchTitle(track.CleanTitle) params := fmt.Sprintf( - "/hubs/search?query=%s&limit=10", - url.QueryEscape(util.CleanSearchTitle(track.CleanTitle)), + "/hubs/search?query=%s&limit=20", url.QueryEscape(searchQuery), ) var body []byte @@ -414,18 +420,16 @@ func (c *Plex) SearchSongs(tracks []*models.Track) error { } if err != nil { - slog.Warn("search request failed", "title", track.Title, "err", err) + slog.Warn("search request failed", "searchQuery", searchQuery, "err", err) continue } var hubResults PlexHubSearch if err := util.ParseResp(body, &hubResults); err != nil { - slog.Warn("failed to parse hub response", "title", track.Title, "err", err) + slog.Warn("failed to parse hub response", "searchQuery", searchQuery, "err", err) continue } - var matched bool - var all []SongMetadata for _, hub := range hubResults.MediaContainer.Hub { @@ -433,20 +437,19 @@ func (c *Plex) SearchSongs(tracks []*models.Track) error { all = append(all, hub.Metadata...) } } + if len(all) == 0 { + slog.Debug("no results returned for query", "query", searchQuery, "trackArtist", track.MainArtist, "trackAlbum", track.Album) + continue + } - key, err := c.getPlexSong(track, all) + trackMatch, err := c.getPlexSong(track, all) if err != nil { - slog.Warn("failed to find match", "title", track.Title, "err", err) + slog.Debug("failed to find match, no results returned above threshold score", "threshold", c.Cfg.MatchScore, "searchQuery", searchQuery) continue } - if key != "" { - track.ID = key - track.Present = true - matched = true - } - if !matched { - slog.Debug("no match found for track", "title", track.Title) - } + track.ID = trackMatch.ID + track.Present = true + slog.Debug("matched track", "searchQuery", searchQuery, "matchScore", trackMatch.Score, "trackArtist", trackMatch.Artist, "trackAlbum", trackMatch.Album) } return nil @@ -573,50 +576,45 @@ func (c *Plex) getServer() error { return nil } -func (c *Plex) getPlexSong(track *models.Track, metadata []SongMetadata) (string, error) { - normArtist := util.AlnumOnly(track.MainArtist) - normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle) - normalizedAlbum := util.AlnumOnly(strings.ToLower(track.Album)) - +func (c *Plex) getPlexSong(track *models.Track, metadata []SongMetadata) (SearchResult, error) { + searchResults := make([]SearchResult, 0, len(metadata)) for _, md := range metadata { if md.Type != "track" { continue } - - 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 == 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 + var mbid string + if c.AdminClient != nil { + mbid = c.AdminClient.getPlexMBID(md.RatingKey) + } else { + mbid = c.getPlexMBID(md.RatingKey) + } + var media Media + if len(md.Media) > 0 { + media = md.Media[0] } - - if track.File == "" || len(md.Media) == 0 || len(md.Media[0].Part) == 0 { - continue + if len(media.Part) == 0 { + media.Part = []Part{{File: "", Duration: 0}} } + searchResults = append(searchResults, SearchResult{ + ID: md.Key, + Title: md.Title, + Album: md.ParentTitle, + Artist: md.OriginalTitle, + Artists: []string{md.GrandparentTitle}, + Path: media.Part[0].File, + Duration: (media.Part[0].Duration / 1000), + MBID: mbid, + }) + } - media := md.Media[0] - pathMatch := util.ContainsFold(media.Part[0].File, track.File) - durationMatch := util.Abs(media.Duration-track.Duration) < 10000 // duration within 10s + trackMatch, ok := BestMatch(track, searchResults, c.Cfg.MatchScore) - if durationMatch && pathMatch { - slog.Debug("matched track via path", "title", track.Title, "artist", track.Artist) - return md.Key, nil - } + if !ok { + slog.Debug("full search result", "results", metadata) + slog.Debug("failed to find match, no results returned above threshold score", "threshold", c.Cfg.MatchScore) + return SearchResult{}, fmt.Errorf("failed to find match, no results returned above threshold score, threshold %d", c.Cfg.MatchScore) } - - slog.Debug(fmt.Sprintf("full search result: %v", metadata)) - return "", fmt.Errorf("failed to find '%s' by '%s' in '%s'", track.Title, track.Artist, track.Album) + return trackMatch, nil } func (c *Plex) getPlexMBID(ratingKey string) string { @@ -627,17 +625,20 @@ func (c *Plex) getPlexMBID(ratingKey string) string { if err != nil { return "" } - - var metadata Metadata - if err = util.ParseResp(body, &metadata); err != nil { + var meta MetadataResponse + err = json.Unmarshal(body, &meta) + if err != nil { return "" } prefix := "mbid://" - for _, guid := range metadata.GUID { - if strings.HasPrefix(guid.ID, prefix) { - return strings.TrimPrefix(guid.ID, prefix) - } + for _, metadata := range meta.MediaContainer.Metadata { + for _, guid := range metadata.GUID { + if strings.HasPrefix(guid.ID, prefix) { + mbid := strings.TrimPrefix(guid.ID, prefix) + return mbid + } } +} return "" } diff --git a/src/client/subsonic.go b/src/client/subsonic.go index 8f2685ba..04073a1f 100644 --- a/src/client/subsonic.go +++ b/src/client/subsonic.go @@ -127,7 +127,7 @@ func (c *Subsonic) AddLibrary() error { func (c *Subsonic) SearchSongs(tracks []*models.Track) error { for _, track := range tracks { - searchQuery := fmt.Sprintf("%s %s", util.CleanSearchTitle(track.CleanTitle), track.MainArtist) + searchQuery := util.CleanSearchTitle(track.CleanTitle) reqParam := fmt.Sprintf("search3?query=%s&f=json", url.QueryEscape(searchQuery)) body, err := c.subsonicRequest(reqParam) @@ -145,7 +145,7 @@ func (c *Subsonic) SearchSongs(tracks []*models.Track) error { if len(songs) == 0 { if track.MusicBrainzTrackID != "" { slog.Debug("[subsonic] using fallback MB TrackID search", "mbid", track.MusicBrainzTrackID) - reqParam := fmt.Sprintf("search3?query=%s&f=json", url.QueryEscape(track.MusicBrainzTrackID)) + reqParam = fmt.Sprintf("search3?query=%s&f=json", url.QueryEscape(track.MusicBrainzTrackID)) body, err = c.subsonicRequest(reqParam) if err != nil { return err @@ -159,37 +159,31 @@ func (c *Subsonic) SearchSongs(tracks []*models.Track) error { } if len(songs) == 0 { - slog.Debug(fmt.Sprintf("[subsonic] no results found for %s", searchQuery)) + slog.Debug("no results returned for query", "query", searchQuery, "trackArtist", track.MainArtist, "trackAlbum", track.Album) continue } } - normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle) + + searchData := make([]SearchResult, 0, len(songs)) for _, song := range songs { - 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 == normalizedCleanTitle - durationMatch := util.Abs(song.Duration - (track.Duration / 1000)) < 10 - pathMatch := util.ContainsFold(song.Path, track.File) - - if musicBrainzMatch || (titleMatch && (albumMatch || artistMatch)) { - track.ID = song.ID - track.Present = true - break - } - - if track.File != "" && durationMatch && pathMatch { - track.ID = song.ID - track.Present = true - break - } + searchData = append(searchData, SearchResult{ + ID: song.ID, + Title: song.Title, + Album: song.Album, + Artist: song.Artist, + Path: song.Path, + Duration: song.Duration, + MBID: song.MusicBrainzID, + }) } - - if !track.Present { - slog.Debug(fmt.Sprintf("[subsonic] no matching tracks for %s", searchQuery)) + trackMatch, ok := BestMatch(track, searchData, c.Cfg.MatchScore) + if !ok { + slog.Debug("failed to find match, no results returned above threshold score", "threshold", c.Cfg.MatchScore, "searchQuery", searchQuery) + continue } + track.ID = trackMatch.ID + track.Present = true + slog.Debug("matched track", "searchQuery", searchQuery, "matchScore", trackMatch.Score, "trackArtist", trackMatch.Artist, "trackAlbum", trackMatch.Album) } return nil } @@ -218,24 +212,25 @@ func (c *Subsonic) startScan() error { } func (c *Subsonic) CheckRefreshState() bool { + time.Sleep(500 * time.Millisecond) // small sleep to avoid calling scan status too fast var state ScanState reqParam := "getScanStatus?f=json" for { body, err := c.subsonicRequest(reqParam) if err != nil { - slog.Warn("could not check scan status", "err", err.Error()) + slog.Warn("could not check scan status", "err", err) return false } if err = json.Unmarshal(body, &state); err != nil { - slog.Warn("failed to unmarshal scan status response", "err", err.Error()) + slog.Warn("failed to unmarshal scan status response", "err", err) return false } if !state.SubsonicResponse.ScanStatus.Scanning { return true } slog.Debug("Library scan still ongoing") - time.Sleep(30 * time.Second) + time.Sleep(15 * time.Second) } } diff --git a/src/config/config.go b/src/config/config.go index 4f0a1f19..2205cdc1 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -62,6 +62,7 @@ type ClientConfig struct { PlaylistDir string `env:"PLAYLIST_DIR"` PlaylistName string PlaylistNFormat string `env:"PLAYLISTNAME_FORMAT" env-default:"week"` + MatchScore int `env:"TRACK_MATCH_SCORE" env-default:"60"` PlaylistDescr string PlaylistID string PublicPlaylist bool `env:"PUBLIC_PLAYLIST" env-default:"false"` @@ -152,8 +153,9 @@ type Slskd struct { } type SlskdMon struct { + OldMonDuration int `env:"MONITOR_DURATION" env-default:"15"` // replaced with SLSKD_STALL_DURATION Interval int `env:"SLSKD_MONITOR_INTERVAL" env-default:"1"` // in minutes - Duration int `env:"SLSKD_MONITOR_DURATION" env-default:"15"` // in minutes + StallDuration int `env:"SLSKD_STALL_DURATION" env-default:"15"` // in minutes MaxDuration int `env:"SLSKD_MONITOR_MAX_DURATION" env-default:"120"` // in minutes } @@ -173,7 +175,7 @@ type Lidarr struct { type LidarrMon struct { Interval int `env:"LIDARR_MONITOR_INTERVAL" env-default:"1"` // in minutes - Duration int `env:"LIDARR_MONITOR_DURATION" env-default:"20"` // in minutes + StallDuration int `env:"LIDARR_STALL_DURATION" env-default:"20"` // in minutes MaxDuration int `env:"LIDARR_MONITOR_MAX_DURATION" env-default:"120"` // in minutes } @@ -295,16 +297,27 @@ func (cfg *Config) HandleDeprecation() { slog.Warn("Deleting tracks requires 'USE_SUBDIRECTORY' to be true") } + if cfg.DownloadCfg.Slskd.MonitorConfig.OldMonDuration != 15 { + cfg.DownloadCfg.Slskd.MonitorConfig.StallDuration = cfg.DownloadCfg.Slskd.MonitorConfig.OldMonDuration + slog.Warn("MONITOR_DURATION is deprecated as of v1.2; using SLSKD_STALL_DURATION instead. Consider renaming the variable in your env file") + } + if cfg.DownloadCfg.OverwriteMetadata { cfg.DownloadCfg.Slskd.OverwriteMetadata = cfg.DownloadCfg.OverwriteMetadata + slog.Warn("OVERWRITE_METADATA is deprecated as of v1.2; using SLSKD_OVERWRITE_METADATA instead. Consider renaming the variable in your env file") } if !cfg.DownloadCfg.KeepPermissions { cfg.DownloadCfg.Slskd.KeepPermissions = cfg.DownloadCfg.KeepPermissions + slog.Warn("KEEP_PERMISSIONS is deprecated as of v1.2; using SLSKD_KEEP_PERMISSIONS instead. Consider renaming the variable in your env file") } if cfg.DownloadCfg.Slskd.MigrateDLOld { cfg.DownloadCfg.Slskd.MigrateDL = cfg.DownloadCfg.Slskd.MigrateDLOld + slog.Warn("MIGRATE_DOWNLOADS is deprecated as of v1.2; using SLSKD_MIGRATE_DOWNLOADS instead. Consider renaming the variable in your env file") + } + if cfg.DownloadCfg.RenameTrack { + slog.Warn("RENAME_TRACK has been superseded by path templating. Check the wiki or UI Settings page to configure path templates") } } @@ -312,9 +325,18 @@ func (cfg *Config) HandleDeprecation() { func (cfg *Config) GenPlaylistDetails() { cfg.ClientCfg.PlaylistName = getPlaylistName(cfg.Flags.Playlist, cfg.ClientCfg.PlaylistNFormat, cfg.ReplacePlaylist) - cfg.ClientCfg.PlaylistDescr = fmt.Sprintf( - "Created for %s by Explo, using ListenBrainz recommendations.", - cfg.DiscoveryCfg.Listenbrainz.User) + + desc := fmt.Sprintf( + "Created for %s by Explo, using ListenBrainz recommendations.", + cfg.DiscoveryCfg.Listenbrainz.User) + + if strings.HasPrefix(cfg.Flags.Playlist, "custom-") { + desc = fmt.Sprintf( + "Imported by Explo for %s", + cfg.DiscoveryCfg.Listenbrainz.User) + } + + cfg.ClientCfg.PlaylistDescr = desc if cfg.DownloadCfg.UseSubDir { // add playlist name to downloadDir so all songs get downloaded to a single sub directory. diff --git a/src/config/flags.go b/src/config/flags.go index 6e5d6b3a..5d7d6f5c 100644 --- a/src/config/flags.go +++ b/src/config/flags.go @@ -38,7 +38,7 @@ func (cfg *Config) GetFlags() error { flag.BoolVar(&refreshOnly, "refresh-only", false, "Trigger alibrary rescan and exit; skips discovery and downloads") flag.BoolVar(&cleanDownloads, "clean-downloads", false, "Delete previously downloaded tracks before downloading new ones (requires USE_SUBDIRECTORY)") - flag.Parse() + flag.Parse() if showVersion { fmt.Println(Version) diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 47921656..c9eaefb6 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -252,8 +252,16 @@ func buildTrackPath(template string, track *models.Track) string { value, ) } + cleanPath := filepath.Clean(result) + file := filepath.Base(cleanPath) + if ext := filepath.Ext(file); ext == "" { + slog.Warn("path template does not have file extension ( {{ext}} ) appended, adding it automatically") + file +=filepath.Ext(track.File) + cleanPath = filepath.Join(filepath.Dir(cleanPath), file) + } - return filepath.Clean(result) + track.File = file + return cleanPath } func overwriteMetadata(metadata []string, srcFile string) error { @@ -288,8 +296,6 @@ func moveTrack(srcFile, destDir string, track *models.Track, pathTemplate string var dstFile string if pathTemplate != "" { relativePath := buildTrackPath(pathTemplate, track) - track.File = filepath.Base(relativePath) - if track.File == "." || track.File == string(filepath.Separator) { track.File = getFilename(track.CleanTitle, track.MainArtist) + filepath.Ext(srcFile) relativePath = filepath.Join(filepath.Dir(relativePath), track.File) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 0e5640b4..bc2ac774 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -219,7 +219,7 @@ func (c *Lidarr) AddHeader() { func (c *Lidarr) GetConf() (MonitorConfig, error) { return MonitorConfig{ CheckInterval: time.Duration(c.Cfg.MonitorConfig.Interval) * time.Minute, - MonitorDuration: time.Duration(c.Cfg.MonitorConfig.Duration) * time.Minute, + StallDuration: time.Duration(c.Cfg.MonitorConfig.StallDuration) * time.Minute, MaxDuration: time.Duration(c.Cfg.MonitorConfig.MaxDuration) * time.Minute, MigrateDownload: c.Cfg.MigrateDL, ToDir: c.DownloadDir, diff --git a/src/downloader/monitor.go b/src/downloader/monitor.go index b5177d4b..00ac2d28 100644 --- a/src/downloader/monitor.go +++ b/src/downloader/monitor.go @@ -18,7 +18,7 @@ type Monitor interface { type MonitorConfig struct { CheckInterval time.Duration - MonitorDuration time.Duration + StallDuration time.Duration MaxDuration time.Duration MigrateDownload bool FromDir string @@ -118,7 +118,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err continue } else if fileStatus.State == "Errored" || - stallTime > monCfg.MonitorDuration || + stallTime > monCfg.StallDuration || monitoredTime > monCfg.MaxDuration { switch { @@ -127,7 +127,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err "service", monCfg.Service, "title", track.CleanTitle, ) - case stallTime > monCfg.MonitorDuration: + case stallTime > monCfg.StallDuration: slog.Info("[monitor] download stalled", "service", monCfg.Service, "title", track.CleanTitle, diff --git a/src/downloader/slskd.go b/src/downloader/slskd.go index 38e6e1e2..6ca3d8b7 100644 --- a/src/downloader/slskd.go +++ b/src/downloader/slskd.go @@ -123,7 +123,7 @@ func (c *Slskd) AddHeader() { func (c *Slskd) GetConf() (MonitorConfig, error) { return MonitorConfig{ CheckInterval: time.Duration(c.Cfg.MonitorConfig.Interval) * time.Minute, - MonitorDuration: time.Duration(c.Cfg.MonitorConfig.Duration) * time.Minute, + StallDuration: time.Duration(c.Cfg.MonitorConfig.StallDuration) * time.Minute, MaxDuration: time.Duration(c.Cfg.MonitorConfig.MaxDuration) * time.Minute, MigrateDownload: c.Cfg.MigrateDL, ToDir: c.DownloadDir, diff --git a/src/downloader/youtube.go b/src/downloader/youtube.go index ff4df008..6a91d823 100644 --- a/src/downloader/youtube.go +++ b/src/downloader/youtube.go @@ -40,8 +40,8 @@ type Item struct { } type YTMusicSearchResult struct { - VideoID string `json:"videoId"` - Title string `json:"title"` + VideoID string `json:"videoId"` + Title string `json:"title"` } type Youtube struct { @@ -78,7 +78,7 @@ func (c *Youtube) QueryTrack(track *models.Track) error { // Queries youtube for query := fmt.Sprintf("%s - %s", track.Title, track.Artist) if c.Cfg.APIKey == "" { // if no API key set, use Python YT Music module - err := queryYTMusic(track, query) + err := c.queryYTMusic(track, query) return err } @@ -91,7 +91,7 @@ func (c *Youtube) QueryTrack(track *models.Track) error { // Queries youtube for } var videos Videos if err = util.ParseResp(body, &videos); err != nil { - return fmt.Errorf("failed to unmarshal queryYT body: %s", err.Error()) + return fmt.Errorf("failed to unmarshal queryYT body: %w", err) } id := c.gatherVideo(c.Cfg, videos, *track) @@ -103,11 +103,11 @@ func (c *Youtube) QueryTrack(track *models.Track) error { // Queries youtube for return nil } -func queryYTMusic(track *models.Track, query string) error { +func (c *Youtube) queryYTMusic(track *models.Track, query string) error { slog.Debug(fmt.Sprintf("Querying YTMusic for track %s", query)) - cmd := exec.Command("python3", "search_ytmusic.py", query, "1") + cmd := exec.Command("python3", "search_ytmusic.py", query, "10") out, err := cmd.Output() if err != nil { @@ -123,17 +123,37 @@ func queryYTMusic(track *models.Track, query string) error { return fmt.Errorf("no YouTube Music track found for: %s", query) } - track.ID = results[0].VideoID + id, err := c.filterYTMusic(*track, results) + if err != nil { + return err + } + + track.ID = id //log.Printf("Matched track %s => videoId %s", query, track.ID) keeping this until I improve logging (good trace) return nil } +func (c *Youtube) filterYTMusic(track models.Track, results []YTMusicSearchResult) (string, error) { + for _, video := range results { + if video.VideoID == "" { + continue + } + if ContainsKeyword(track, video.Title, c.Cfg.Filters.FilterList) { + continue + } + + return video.VideoID, nil + } + + return "", fmt.Errorf("no suitable YouTube Music result found") +} + func (c *Youtube) GetTrack(track *models.Track) error { ctx := context.Background() // ctx for yt-dlp track.File = fmt.Sprintf("%s.%s", getFilename(track.Title, track.Artist), c.Cfg.FileExtension) - track.Present = fetchAndSaveVideo(ctx, *c, *track) + track.Present = fetchAndSaveVideo(ctx, *c, track) if track.Present { slog.Info("download finished", "service", "youtube", "track", track.File) @@ -163,36 +183,36 @@ func getVideo(ctx context.Context, c Youtube, videoID string) (*goutubedl.Downlo result, err := goutubedl.New(ctx, videoID, c.gouTubeOpts) if err != nil { - return nil, fmt.Errorf("could not create URL for video download (ID: %s): %s", videoID, err.Error()) + return nil, fmt.Errorf("could not create URL for video download (ID: %s): %w", videoID, err) } downloadResult, err := result.Download(ctx, "bestaudio") if err != nil { - return nil, fmt.Errorf("could not download video: %s", err.Error()) + return nil, fmt.Errorf("could not download video: %w", err) } return downloadResult, nil } -func saveVideo(c Youtube, track models.Track, stream *goutubedl.DownloadResult) bool { +func saveVideo(c Youtube, track *models.Track, stream *goutubedl.DownloadResult) bool { defer func() { if err := stream.Close(); err != nil { - slog.Warn("closing stream failed", "context", err.Error()) + slog.Warn("closing stream failed", "context", err) } }() input := filepath.Join(c.DownloadDir, track.File+".tmp") file, err := os.Create(input) if err != nil { - slog.Error("failed to create song file", "context", err.Error()) + slog.Error("failed to create song file", "err", err) return false } defer func() { if err := file.Close(); err != nil { - slog.Warn("file close failed", "context", err.Error()) + slog.Warn("file close failed", "context", err) } }() @@ -206,23 +226,23 @@ func saveVideo(c Youtube, track models.Track, stream *goutubedl.DownloadResult) }() if _, err = io.Copy(file, stream); err != nil { - slog.Error("failed to copy stream to file", "context", err.Error()) + slog.Error("failed to copy stream to file", "err", err) return false } - metadata := util.BuildffmpegMetadata(track) + metadata := util.BuildffmpegMetadata(*track) outputPath := filepath.Join(c.DownloadDir, track.File) if c.Cfg.PathTemplate != "" { outputPath = filepath.Join( c.DownloadDir, - buildTrackPath(c.Cfg.PathTemplate, &track), + buildTrackPath(c.Cfg.PathTemplate, track), ) } if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { - slog.Error("failed to create output directory", "context", err.Error()) + slog.Error("failed to create output directory", "err", err) return false } @@ -231,10 +251,9 @@ func saveVideo(c Youtube, track models.Track, stream *goutubedl.DownloadResult) 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)) - } + _, track.CoverPath = util.DownloadCover(track.CoverURL, c.Cfg.CoversDir) } + streams = append(streams, ffmpeg.Input(track.CoverPath)) opts = ffmpeg.KwArgs{ "c:v": "mjpeg", "disposition:v:0": "attached_pic", @@ -251,6 +270,7 @@ func saveVideo(c Youtube, track models.Track, stream *goutubedl.DownloadResult) } if err := util.WriteMetadata(streams, c.Cfg.FfmpegPath, outputPath, opts); err != nil { + slog.Error("failed saving file", "err", err) return false } @@ -275,10 +295,10 @@ func (c *Youtube) gatherVideo(cfg cfg.Youtube, videos Videos, track models.Track return "" } -func fetchAndSaveVideo(ctx context.Context, cfg Youtube, track models.Track) bool { +func fetchAndSaveVideo(ctx context.Context, cfg Youtube, track *models.Track) bool { stream, err := getVideo(ctx, cfg, track.ID) if err != nil { - slog.Error("failed getting stream for video", "trackID", track.ID, "context", err.Error()) + slog.Error("failed getting stream for video", "trackID", track.ID, "err", err) return false } diff --git a/src/util/http.go b/src/util/http.go index 3d7a3d51..588e3b04 100644 --- a/src/util/http.go +++ b/src/util/http.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "explo/src/config" "explo/src/logging" ) @@ -28,7 +29,7 @@ func NewHttp(cfg HttpClientConfig) *HttpClient { Client: &http.Client{ Timeout: time.Duration(cfg.Timeout) * time.Second, }, - UserAgent: "Explo (+https://github.com/LumePart/explo))", + UserAgent: fmt.Sprintf("Explo/%s ( https://github.com/LumePart/explo )", config.Version), } } diff --git a/src/util/metadata.go b/src/util/metadata.go index 039ede87..e5286ae0 100644 --- a/src/util/metadata.go +++ b/src/util/metadata.go @@ -3,11 +3,37 @@ package util import ( "explo/src/models" "fmt" + "log/slog" + "path/filepath" "strings" ffmpeg "github.com/u2takey/ffmpeg-go" ) + +type metadataTags struct { + Title string + Album string + Artist string + AlbumArtist string + ArtistSort string + Date string + Media string + ReleaseType string + ReleaseStatus string + ReleaseGroupID string + AlbumArtistID string + TrackID string + AlbumID string + ReleaseTrackID string + ArtistID string + OriginalYear string + TrackNumber string + TrackTotal string + DiscNumber string + DiscTotal string +} + // Return absolute difference between tracks func Abs(x int) int { if x < 0 { @@ -30,36 +56,37 @@ func addIntTag(metadata []string, key string, value int) []string { return metadata } -func BuildffmpegMetadata(track models.Track) []string { +func buildMetadata(track models.Track, tags metadataTags) []string { metadata := []string{} + artist := track.Artist if len(track.Artists) > 0 { - metadata = append(metadata, "artist="+strings.Join(track.Artists, "; ")) - } else { - metadata = addStringTag(metadata, "artist", track.Artist) + artist = strings.Join(track.Artists, "; ") } - metadata = addStringTag(metadata, "title", track.Title) - metadata = addStringTag(metadata, "album", track.Album) - metadata = addStringTag(metadata, "albumartist", track.AlbumArtist) - metadata = addStringTag(metadata, "artistsort", track.ArtistSort) - metadata = addStringTag(metadata, "date", track.OriginalDate) + metadata = addStringTag(metadata, tags.Artist, artist) + metadata = addStringTag(metadata, tags.Title, track.Title) + metadata = addStringTag(metadata, tags.Album, track.Album) + metadata = addStringTag(metadata, tags.AlbumArtist, track.AlbumArtist) + metadata = addStringTag(metadata, tags.ArtistSort, track.ArtistSort) + metadata = addStringTag(metadata, tags.Date, track.OriginalDate) metadata = addStringTag(metadata, "genre", track.Genres) - metadata = addStringTag(metadata, "TMED", track.Media) - metadata = addStringTag(metadata, "MusicBrainz_AlbumType", track.ReleaseType) - metadata = addStringTag(metadata, "MusicBrainz_AlbumStatus", track.ReleaseStatus) - metadata = addStringTag(metadata, "MusicBrainz_ReleaseGroupId", track.MusicBrainzReleaseGroupID) - metadata = addStringTag(metadata, "MusicBrainz_AlbumArtistId", track.MusicBrainzAlbumArtistID) - metadata = addStringTag(metadata, "MusicBrainz_TrackId", track.MusicBrainzTrackID) - metadata = addStringTag(metadata, "MusicBrainz_AlbumId", track.MusicBrainzAlbumID) - metadata = addStringTag(metadata, "MusicBrainz_ReleaseTrackId", track.MusicBrainzReleaseTrackID) - metadata = addStringTag(metadata, "MusicBrainz_ArtistId", track.MusicBrainzArtistID) - - metadata = addIntTag(metadata, "originalyear", track.OriginalYear) - metadata = addIntTag(metadata, "track", track.TrackNumber) - metadata = addIntTag(metadata, "Tracktotal", track.TrackTotal) - metadata = addIntTag(metadata, "disc", track.DiscNumber) - metadata = addIntTag(metadata, "Disctotal", track.DiscTotal) + metadata = addStringTag(metadata, tags.Media, track.Media) + + metadata = addStringTag(metadata, tags.ReleaseType, track.ReleaseType) + metadata = addStringTag(metadata, tags.ReleaseStatus, track.ReleaseStatus) + metadata = addStringTag(metadata, tags.ReleaseGroupID, track.MusicBrainzReleaseGroupID) + metadata = addStringTag(metadata, tags.AlbumArtistID, track.MusicBrainzAlbumArtistID) + metadata = addStringTag(metadata, tags.TrackID, track.MusicBrainzTrackID) + metadata = addStringTag(metadata, tags.AlbumID, track.MusicBrainzAlbumID) + metadata = addStringTag(metadata, tags.ReleaseTrackID, track.MusicBrainzReleaseTrackID) + metadata = addStringTag(metadata, tags.ArtistID, track.MusicBrainzArtistID) + + metadata = addIntTag(metadata, tags.OriginalYear, track.OriginalYear) + metadata = addIntTag(metadata, tags.TrackNumber, track.TrackNumber) + metadata = addIntTag(metadata, tags.TrackTotal, track.TrackTotal) + metadata = addIntTag(metadata, tags.DiscNumber, track.DiscNumber) + metadata = addIntTag(metadata, tags.DiscTotal, track.DiscTotal) for _, isrc := range track.ISRCs { metadata = addStringTag(metadata, "ISRC", isrc) @@ -68,6 +95,113 @@ func BuildffmpegMetadata(track models.Track) []string { return metadata } + +func basicTags() metadataTags { + return metadataTags{ + Title: "title", + Album: "album", + Artist: "artist", + AlbumArtist: "album_artist", + ArtistSort: "artist-sort", + Date: "date", + TrackNumber: "track", + DiscNumber: "disc", + } +} + +func id3Tags() metadataTags { + return metadataTags{ + Title: "title", + Album: "album", + Artist: "artist", + AlbumArtist: "album_artist", + ArtistSort: "artist-sort", + Date: "date", + Media: "TMED", + ReleaseType: "MusicBrainz Album Type", + ReleaseStatus: "MusicBrainz Album Status", + ReleaseGroupID: "MusicBrainz Release Group Id", + AlbumArtistID: "MusicBrainz Album Artist Id", + TrackID: "MusicBrainz Track Id", + AlbumID: "MusicBrainz Album Id", + ReleaseTrackID: "MusicBrainz Release Track Id", + ArtistID: "MusicBrainz Artist Id", + OriginalYear: "originalyear", + TrackNumber: "track", + TrackTotal: "Tracktotal", + DiscNumber: "disc", + DiscTotal: "Disctotal", + } +} + +func vorbisTags() metadataTags { + return metadataTags{ + Title: "title", + Album: "album", + Artist: "artist", + AlbumArtist: "albumartist", + ArtistSort: "artistsort", + Date: "date", + Media: "Media", + ReleaseType: "ReleaseType", + ReleaseStatus: "ReleaseStatus", + ReleaseGroupID: "MusicBrainz_ReleaseGroupId", + AlbumArtistID: "MusicBrainz_AlbumArtistId", + TrackID: "MusicBrainz_TrackId", + AlbumID: "MusicBrainz_AlbumId", + ReleaseTrackID: "MusicBrainz_ReleaseTrackId", + ArtistID: "MusicBrainz_ArtistId", + OriginalYear: "originalyear", + TrackNumber: "track", + TrackTotal: "Tracktotal", + DiscNumber: "discnumber", + DiscTotal: "Disctotal", + } +} + +func apeTags() metadataTags { + return metadataTags{ + Title: "title", + Album: "album", + Artist: "artist", + AlbumArtist: "albumartist", + ArtistSort: "artistsort", + Date: "Year", + Media: "Media", + ReleaseType: "MusicBrainz_AlbumType", + ReleaseStatus: "MusicBrainz_AlbumStatus", + ReleaseGroupID: "MusicBrainz_ReleaseGroupId", + AlbumArtistID: "MusicBrainz_AlbumArtistId", + TrackID: "MusicBrainz_TrackId", + AlbumID: "MusicBrainz_AlbumId", + ReleaseTrackID: "MusicBrainz_ReleaseTrackId", + ArtistID: "MusicBrainz_ArtistId", + OriginalYear: "originalyear", + TrackNumber: "track", + DiscNumber: "disc", + } +} + + +func BuildffmpegMetadata(track models.Track) []string { + extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(track.File)), ".") + switch strings.ToLower(extension) { + case "mp3": + return buildMetadata(track, id3Tags()) + + case "flac", "opus": + return buildMetadata(track, vorbisTags()) + + case "ape", "wv", "mpc": + return buildMetadata(track, apeTags()) + + default: + slog.Debug("using basic metadata mapping", "ext", extension) + return buildMetadata(track, basicTags()) + + } +} + func WriteMetadata(streams []*ffmpeg.Stream, ffmpegPath, filePath string, opts ffmpeg.KwArgs) error { cmd := ffmpeg.Output(streams, filePath, opts).OverWriteOutput().ErrorToStdOut() @@ -80,4 +214,4 @@ func WriteMetadata(streams []*ffmpeg.Stream, ffmpegPath, filePath string, opts f return fmt.Errorf("failed to write metadata: %w", err) } return nil -} +} \ No newline at end of file diff --git a/src/util/sanitize.go b/src/util/sanitize.go index 35c73ec2..07eeb663 100644 --- a/src/util/sanitize.go +++ b/src/util/sanitize.go @@ -9,7 +9,7 @@ var ( 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*$`) + remasterTailRe = regexp.MustCompile(`(?i)\s*(?:[-–—]|\(|\[)\s*(?:\d{4}\s+|digital\s+)?remaster(ed)?\)?\]?\s*$`) ) // CleanSearchTitle strips trailing (feat. …) and "- 2011 Remaster" suffixes diff --git a/src/web/backend/playlist/playlist.go b/src/web/backend/playlist/playlist.go index d1cd494d..d37b285a 100644 --- a/src/web/backend/playlist/playlist.go +++ b/src/web/backend/playlist/playlist.go @@ -117,7 +117,9 @@ func WritePlaylistCache(cfgPath, playlist string, tracks []*models.Track, added ct := make([]cachedTrack, len(tracks)) for i, t := range tracks { - apiPath, coverPath := util.DownloadCover(t.CoverURL, coversDir) + if t.CoverPath == "" { + t.CoverURL, t.CoverPath = util.DownloadCover(t.CoverURL, coversDir) + } var inLibrary *bool if added != nil { v := added[t.CleanTitle+"|"+t.Artist] @@ -128,8 +130,8 @@ func WritePlaylistCache(cfgPath, playlist string, tracks []*models.Track, added Title: t.CleanTitle, Artist: t.Artist, Release: t.Album, - CoverURL: apiPath, - CoverPath: coverPath, + CoverURL: t.CoverURL, + CoverPath: t.CoverPath, InLibrary: inLibrary, } } diff --git a/src/web/backend/settings/handlers.go b/src/web/backend/settings/handlers.go index 43ceb36d..ff02eead 100644 --- a/src/web/backend/settings/handlers.go +++ b/src/web/backend/settings/handlers.go @@ -431,7 +431,7 @@ func (s *Settings) HandleWizardStep3(w http.ResponseWriter, r *http.Request) { var body struct { DownloadDir string `json:"download_dir"` UseSubdirectory bool `json:"use_subdirectory"` - MigrateDownloads bool `json:"migrate_downloads"` + SlskdMigrateDL bool `json:"slskd_migrate_downloads"` DownloadServices []string `json:"download_services"` YoutubeAPIKey string `json:"youtube_api_key"` TrackExtension string `json:"track_extension"` // yt-dlp @@ -440,6 +440,7 @@ func (s *Settings) HandleWizardStep3(w http.ResponseWriter, r *http.Request) { SlskdAPIKey string `json:"slskd_api_key"` LidarrURL string `json:"lidarr_url"` LidarrAPIKey string `json:"lidarr_api_key"` + LidarrMigrateDL bool `json:"lidarr_migrate_downloads"` Extensions string `json:"extensions"` // slskd } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { @@ -456,24 +457,29 @@ func (s *Settings) HandleWizardStep3(w http.ResponseWriter, r *http.Request) { if body.UseSubdirectory { useSubdir = "true" } - migrateDL := "false" - if body.MigrateDownloads { - migrateDL = "true" + slskdMigrateDL := "false" + if body.SlskdMigrateDL { + slskdMigrateDL = "true" + } + lidarrMigrateDL := "false" + if body.LidarrMigrateDL { + lidarrMigrateDL = "true" } updates := map[string]string{ - "DOWNLOAD_DIR": body.DownloadDir, - "USE_SUBDIRECTORY": useSubdir, - "MIGRATE_DOWNLOADS": migrateDL, - "DOWNLOAD_SERVICES": joined, - "YOUTUBE_API_KEY": body.YoutubeAPIKey, - "TRACK_EXTENSION": body.TrackExtension, // yt-dlp - "FILTER_LIST": body.FilterList, - "SLSKD_URL": body.SlskdURL, - "SLSKD_API_KEY": body.SlskdAPIKey, - "LIDARR_URL": body.LidarrURL, - "LIDARR_API_KEY": body.LidarrAPIKey, - "EXTENSIONS": body.Extensions, // slskd - "WIZARD_COMPLETE": "true", + "DOWNLOAD_DIR": body.DownloadDir, + "USE_SUBDIRECTORY": useSubdir, + "SLSKD_MIGRATE_DOWNLOADS": slskdMigrateDL, + "DOWNLOAD_SERVICES": joined, + "YOUTUBE_API_KEY": body.YoutubeAPIKey, + "TRACK_EXTENSION": body.TrackExtension, // yt-dlp + "FILTER_LIST": body.FilterList, + "SLSKD_URL": body.SlskdURL, + "SLSKD_API_KEY": body.SlskdAPIKey, + "LIDARR_URL": body.LidarrURL, + "LIDARR_API_KEY": body.LidarrAPIKey, + "LIDARR_MIGRATE_DOWNLOADS": lidarrMigrateDL, + "EXTENSIONS": body.Extensions, // slskd + "WIZARD_COMPLETE": "true", } if err := s.UpdateEnvKeys(updates, web.SampleEnv); err != nil { diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx index 97c69513..2908642f 100644 --- a/src/web/frontend/src/components/Settings.jsx +++ b/src/web/frontend/src/components/Settings.jsx @@ -320,7 +320,7 @@ function HomeSection() { onReplaceToggle: () => { const next = !(replacePlaylists[id] ?? true) setReplacePlaylists(prev => ({ ...prev, [id]: next })) - saveReplacePlaylist(id, next).catch(() => + saveReplacePlaylist(id, s.name, next).catch(() => setReplacePlaylists(prev => ({ ...prev, [id]: !next })) ) }, @@ -384,7 +384,7 @@ function HomeSection() { playlist={openTracklist} refreshTick={refreshTick} onRun={async () => { - await startRun(openTracklist, 'normal') + await startRun(openTracklist, 'normal', replacePlaylists[playlist] ?? true) setRunning(true) setStatus('running…') setLogEntries([]) diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index 5f0b61c8..56421f23 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -468,6 +468,23 @@ function Collapse({ open, children }) { // Collects download service selection (YouTube, Slskd, Lidarr) and their respective // credentials, download directory, and file format preferences. +const DL_PRIORITIES = { + youtube: 0, + slskd: 1, + lidarr: 2, +}; + +const DL_NAMES = { + youtube: "YouTube", + slskd: "Slskd (Soulseek)", + lidarr: "Lidarr", +}; + +const rankDownloaders = (services) => + [...services].sort( + (a, b) => (DL_PRIORITIES[b] ?? -1) - (DL_PRIORITIES[a] ?? -1) + ); + function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { const { downloadDir, @@ -488,32 +505,56 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { const isLocked = (key) => envSources[key] === "env"; const valid = () => { - if (!Object.values(dlServices).some(Boolean)) return false; - if (dlServices.slskd && (!slskdUrl.trim() || !slskdApiKey.trim())) - return false; - if (dlServices.lidarr && (!lidarrUrl.trim() || !lidarrApiKey.trim())) + if (dlServices.length === 0) return false; + if (dlServices.includes("slskd") && (!slskdUrl.trim() || !slskdApiKey.trim())) return false; return true; }; + const isEnabled = (service) => dlServices.includes(service) + const toggleService = (service, enabled) => { + if (enabled) { + setField("dlServices", [...dlServices, service]); + } else { + setField( + "dlServices", + dlServices.filter(s => s !== service) + ); + } + }; + const rankedServices = rankDownloaders(dlServices); + return (
- Explo downloads tracks using one or both services. Enable what you have - access to — if both are enabled, YouTube is tried first. + Services Explo can use to request tracks. Enable what you have + access to.
+This can be changed in settings later
+
Alternative: Create a dedicated Root Folder for Explo in Lidarr and define it above. Lidarr will handle organizing downloads itself, allowing you to leave "Move completed downloads" disabled.
@@ -694,7 +735,6 @@ export default function Wizard({
const [saving, setSaving] = useState(false);
const [fields, setFields] = useState(() => {
- const s = (config.DOWNLOAD_SERVICES || "").split(",");
return {
// Step 1
user: config.LISTENBRAINZ_USER || "",
@@ -718,11 +758,7 @@ export default function Wizard({
// Step 3
downloadDir: config.DOWNLOAD_DIR || "",
useSubdirectory: config.USE_SUBDIRECTORY !== "false",
- dlServices: {
- youtube: s.includes("youtube"),
- slskd: s.includes("slskd"),
- lidarr: s.includes('lidarr')
- },
+ dlServices: (config.DOWNLOAD_SERVICES || "").split(",").filter(Boolean),
youtubeApiKey: config.YOUTUBE_API_KEY || "",
trackExtension: config.TRACK_EXTENSION || "",
filterList: config.FILTER_LIST || "",
@@ -806,14 +842,11 @@ export default function Wizard({
async function handleStep3() {
setSaving(true);
try {
- const services = Object.entries(fields.dlServices)
- .filter(([, v]) => v)
- .map(([k]) => k);
await wizardStep3({
download_dir: fields.downloadDir,
use_subdirectory: fields.useSubdirectory,
slskd_migrate_downloads: fields.slskdMigrateDownloads,
- download_services: services,
+ download_services: rankDownloaders(fields.dlServices),
youtube_api_key: fields.youtubeApiKey,
track_extension: fields.trackExtension,
filter_list: fields.filterList,
diff --git a/src/web/frontend/src/components/ui/PlaylistCard.jsx b/src/web/frontend/src/components/ui/PlaylistCard.jsx
index a3401179..69b84868 100644
--- a/src/web/frontend/src/components/ui/PlaylistCard.jsx
+++ b/src/web/frontend/src/components/ui/PlaylistCard.jsx
@@ -701,7 +701,7 @@ export function PlaylistCard({