From 19ac042285edc760e0065f619a0d9ed0d40e7464 Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Tue, 29 Apr 2025 20:16:28 -0400 Subject: [PATCH 01/10] Implement initial support for Lidarr downloader Add helper funcs; implement hour cooldown for search Check for rejected releases Add cleanup func and worker Add check to artist adding Mark track as present --- .gitignore | 1 + go.mod | 5 +- go.sum | 10 +- src/config/config.go | 146 +++++++++-------- src/downloader/downloader.go | 3 +- src/downloader/lidarr.go | 298 +++++++++++++++++++++++++++++++++++ src/main/main.go | 9 +- src/models/types.go | 23 +-- 8 files changed, 405 insertions(+), 90 deletions(-) create mode 100644 .gitignore create mode 100644 src/downloader/lidarr.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/go.mod b/go.mod index 7fd25df..c0f8d87 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,10 @@ module explo go 1.24.0 -toolchain go1.24.3 +toolchain go1.24.2 require ( + github.com/devopsarr/lidarr-go v1.2.0 github.com/ilyakaznacheev/cleanenv v1.5.0 github.com/spf13/pflag v1.0.10 github.com/u2takey/ffmpeg-go v0.5.0 @@ -18,7 +19,9 @@ require ( github.com/aws/aws-sdk-go v1.38.20 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/joho/godotenv v1.5.1 // indirect + github.com/stretchr/testify v1.10.0 // indirect github.com/u2takey/go-utils v0.3.1 // indirect + golang.org/x/net v0.39.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect ) diff --git a/go.sum b/go.sum index 37c9420..2491071 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2z github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/devopsarr/lidarr-go v1.2.0 h1:umU91TYu+Eh54dJMWSvwT1KBAzT/9+gckMDVu7e6oRk= +github.com/devopsarr/lidarr-go v1.2.0/go.mod h1:l6uoYzFsExwp0zGNQGmMw2UueqXwL3Vaux+i2YspEOg= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= @@ -40,8 +42,9 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/u2takey/ffmpeg-go v0.5.0 h1:r7d86XuL7uLWJ5mzSeQ03uvjfIhiJYvsRAJFCW4uklU= github.com/u2takey/ffmpeg-go v0.5.0/go.mod h1:ruZWkvC1FEiUNjmROowOAps3ZcWxEiOpFoHCvk97kGc= github.com/u2takey/go-utils v0.3.1 h1:TaQTgmEZZeDHQFYfd+AdUT1cT4QJgJn/XVPELhHw4ys= @@ -55,10 +58,9 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/src/config/config.go b/src/config/config.go index 791283a..ca2f634 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -3,10 +3,10 @@ package config import ( "errors" "fmt" + "log/slog" "os" "strings" "time" - "log/slog" "github.com/ilyakaznacheev/cleanenv" "golang.org/x/text/cases" @@ -14,101 +14,109 @@ import ( ) type Config struct { - DownloadCfg DownloadConfig + DownloadCfg DownloadConfig DiscoveryCfg DiscoveryConfig - ClientCfg ClientConfig - Flags Flags - PersistENV bool `env:"PERSIST" env-default:"true"` - Persist bool - System string `env:"EXPLO_SYSTEM"` - Debug bool `env:"DEBUG" env-default:"false"` - LogLevel string `env:"LOG_LEVEL" env-default:"INFO"` + ClientCfg ClientConfig + Flags Flags + PersistENV bool `env:"PERSIST" env-default:"true"` + Persist bool + System string `env:"EXPLO_SYSTEM"` + Debug bool `env:"DEBUG" env-default:"false"` + LogLevel string `env:"LOG_LEVEL" env-default:"INFO"` } type Flags struct { - CfgPath string - Playlist string + CfgPath string + Playlist string DownloadMode string ExcludeLocal bool - Persist bool - PersistSet bool + Persist bool + PersistSet bool } type ClientConfig struct { - ClientID string `env:"CLIENT_ID" env-default:"explo"` - LibraryName string `env:"LIBRARY_NAME" env-default:"Explo"` - URL string `env:"SYSTEM_URL"` - DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"` - PlaylistDir string `env:"PLAYLIST_DIR"` - PlaylistName string + ClientID string `env:"CLIENT_ID" env-default:"explo"` + LibraryName string `env:"LIBRARY_NAME" env-default:"Explo"` + URL string `env:"SYSTEM_URL"` + DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"` + PlaylistDir string `env:"PLAYLIST_DIR"` + PlaylistName string PlaylistDescr string - PlaylistID string - Sleep int `env:"SLEEP" env-default:"2"` - HTTPTimeout int `env:"CLIENT_HTTP_TIMEOUT" env-default:"10"` - Creds Credentials - Subsonic SubsonicConfig + PlaylistID string + Sleep int `env:"SLEEP" env-default:"2"` + HTTPTimeout int `env:"CLIENT_HTTP_TIMEOUT" env-default:"10"` + Creds Credentials + Subsonic SubsonicConfig } type Credentials struct { - APIKey string `env:"API_KEY"` - User string `env:"SYSTEM_USERNAME"` + APIKey string `env:"API_KEY"` + User string `env:"SYSTEM_USERNAME"` Password string `env:"SYSTEM_PASSWORD"` - Headers map[string]string - Token string - Salt string + Headers map[string]string + Token string + Salt string } - type SubsonicConfig struct { - Version string `env:"SUBSONIC_VERSION" env-default:"1.16.1"` - ID string `env:"CLIENT" env-default:"explo"` - PublicPlaylist bool `env:"PUBLIC_PLAYLIST" env-default:"false"` + Version string `env:"SUBSONIC_VERSION" env-default:"1.16.1"` + ID string `env:"CLIENT" env-default:"explo"` + PublicPlaylist bool `env:"PUBLIC_PLAYLIST" env-default:"false"` } type DownloadConfig struct { - DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"` - 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/"` + Youtube Youtube + YoutubeMusic YoutubeMusic + Slskd Slskd + Lidarr Lidarr + 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"` } type Filters struct { - Extensions []string `env:"EXTENSIONS" env-default:"flac,mp3"` - MinBitDepth int `env:"MIN_BIT_DEPTH" env-default:"8"` - MinBitRate int `env:"MIN_BITRATE" env-default:"256"` - FilterList []string `env:"FILTER_LIST" env-default:"live,remix,instrumental,extended,clean,acapella"` + Extensions []string `env:"EXTENSIONS" env-default:"flac,mp3"` + MinBitDepth int `env:"MIN_BIT_DEPTH" env-default:"8"` + MinBitRate int `env:"MIN_BITRATE" env-default:"256"` + FilterList []string `env:"FILTER_LIST" env-default:"live,remix,instrumental,extended,clean,acapella"` } type Youtube struct { - APIKey string `env:"YOUTUBE_API_KEY"` - FfmpegPath string `env:"FFMPEG_PATH"` - YtdlpPath string `env:"YTDLP_PATH"` + APIKey string `env:"YOUTUBE_API_KEY"` + FfmpegPath string `env:"FFMPEG_PATH"` + YtdlpPath string `env:"YTDLP_PATH"` CookiesPath string `env:"COOKIES_PATH" env-default:"./cookies.txt"` - Filters Filters + Filters Filters } type YoutubeMusic struct { FfmpegPath string `env:"FFMPEG_PATH"` - YtdlpPath string `env:"YTDLP_PATH"` - Filters Filters + YtdlpPath string `env:"YTDLP_PATH"` + Filters Filters +} + +type Lidarr struct { + APIKey string `env:"LIDARR_API_KEY"` + Separator string `env:"FILENAME_SEPARATOR" env-default:" "` + FilterList []string `env:"FILTER_LIST" env-default:"live,remix,instrumental,extended"` + Scheme string `env:"LIDARR_SCHEME" env-default:"http"` + URL string `env:"LIDARR_URL"` } type Slskd struct { - APIKey string `env:"SLSKD_API_KEY"` - URL string `env:"SLSKD_URL"` - Retry int `env:"SLSKD_RETRY" env-default:"5"` // Number of times to check search status before skipping the track - DownloadAttempts int `env:"SLSKD_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track - SlskdDir string `env:"SLSKD_DIR" env-default:"/slskd/"` - MigrateDL bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from SlskdDir to DownloadDir - Timeout int `env:"SLSKD_TIMEOUT" env-default:"20"` - Filters Filters - MonitorConfig SlskdMon + APIKey string `env:"SLSKD_API_KEY"` + URL string `env:"SLSKD_URL"` + Retry int `env:"SLSKD_RETRY" env-default:"5"` // Number of times to check search status before skipping the track + DownloadAttempts int `env:"SLSKD_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track + SlskdDir string `env:"SLSKD_DIR" env-default:"/slskd/"` + MigrateDL bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from SlskdDir to DownloadDir + Timeout int `env:"SLSKD_TIMEOUT" env-default:"20"` + Filters Filters + MonitorConfig SlskdMon } type SlskdMon struct { @@ -117,14 +125,14 @@ type SlskdMon struct { } type DiscoveryConfig struct { - Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"` + Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"` Listenbrainz Listenbrainz } type Listenbrainz struct { - Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"` - User string `env:"LISTENBRAINZ_USER"` + Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"` + User string `env:"LISTENBRAINZ_USER"` ImportPlaylist string - SingleArtist bool `env:"SINGLE_ARTIST" env-default:"true"` + SingleArtist bool `env:"SINGLE_ARTIST" env-default:"true"` } func (cfg *Config) ReadEnv() { @@ -162,7 +170,7 @@ func fixDir(dir string) string { return dir } -func (cfg *Config) HandleDeprecation() { // +func (cfg *Config) HandleDeprecation() { // if cfg.Debug { slog.Warn("'DEBUG' variable is deprecated, please use LOG_LEVEL=DEBUG instead") cfg.LogLevel = "DEBUG" @@ -179,7 +187,7 @@ func (cfg *Config) HandleDeprecation() { // func (cfg *Config) GetPlaylistName() { // Generate playlist name and description toTitle := cases.Title(language.Und) - + playlistName := toTitle.String(cfg.Flags.Playlist) if cfg.Persist { year, week := time.Now().ISOWeek() @@ -194,7 +202,7 @@ func (cfg *Config) GetPlaylistName() { // Generate playlist name and description cfg.ClientCfg.PlaylistName = playlistName if cfg.DownloadCfg.UseSubDir { - // add playlist name to downloadDir so all songs get downloaded to a single sub directory. + // add playlist name to downloadDir so all songs get downloaded to a single sub directory. cfg.DownloadCfg.DownloadDir = fmt.Sprintf("%s%s/", cfg.DownloadCfg.DownloadDir, playlistName) } } diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 01ed797..23c2c4c 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -38,11 +38,12 @@ func NewDownloader(cfg *cfg.DownloadConfig, httpClient *util.HttpClient, filterL slskdClient := NewSlskd(cfg.Slskd, cfg.DownloadDir) slskdClient.AddHeader() downloader = append(downloader, slskdClient) + case "lidarr": + downloader = append(downloader, NewLidarr(cfg.Lidarr, cfg.Discovery, cfg.DownloadDir, httpClient)) default: return nil, fmt.Errorf("downloader '%s' not supported", service) } } - return &DownloadClient{ Cfg: cfg, Downloaders: downloader}, nil diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go new file mode 100644 index 0000000..31196ff --- /dev/null +++ b/src/downloader/lidarr.go @@ -0,0 +1,298 @@ +package downloader + +import ( + "context" + "encoding/json" + "fmt" + "log" + "time" + + cfg "explo/src/config" + "explo/src/models" + "explo/src/util" + + "github.com/devopsarr/lidarr-go/lidarr" +) + +type Lidarr struct { + DownloadDir string + HttpClient *util.HttpClient + Client *lidarr.APIClient +} + +func NewLidarr(cfg cfg.Lidarr, discovery, downloadDir string, httpClient *util.HttpClient) *Lidarr { + // Create Lidarr SDK config + apiCfg := lidarr.NewConfiguration() + apiCfg.Host = cfg.URL + apiCfg.Scheme = cfg.Scheme + apiCfg.DefaultHeader["X-Api-Key"] = cfg.APIKey + apiCfg.HTTPClient = httpClient.Client + + client := lidarr.NewAPIClient(apiCfg) + + l := &Lidarr{ + DownloadDir: downloadDir, + HttpClient: httpClient, + Client: client, + } + ctx := context.Background() + l.startCleanupWorker(ctx) + + return l +} + +func (c *Lidarr) QueryTrack(track *models.Track) error { + ctx := context.Background() + + query := fmt.Sprintf("%s %s", track.Artist, track.Album) + albums, _ := c.albumLookup(ctx, query) + + if len(albums) == 0 || len(albums[0].Releases) == 0 { + return fmt.Errorf("could not find album for track: %s - %s", track.Title, track.Artist) + } + + var err error + if albums[0].Id == nil || albums[0].ArtistId == nil { + return fmt.Errorf("album or artist ID was nil for track: %s - %s", track.Title, track.Artist) + } + track.Present, err = c.checkExistingTrack(ctx, *albums[0].Id, *albums[0].ArtistId, track) + if err != nil { + return fmt.Errorf("failed to check existing tracks: %w", err) + } + + return nil +} + +func (c *Lidarr) GetTrack(track *models.Track) error { + ctx := context.Background() + + if track.Present { + return nil + } + // Get the defaults from the root dir + rootFolders, _, err := c.Client.RootFolderAPI.ListRootFolder(ctx).Execute() + if err != nil || len(rootFolders) == 0 { + return fmt.Errorf("failed to get root folders: %w", err) + } + root := rootFolders[0] + + artist, err := c.findArtist(ctx, track.Artist) + if err != nil { + return err + } + if err := c.addArtistIfNeeded(ctx, artist, root); err != nil { + return err + } + + album, err := c.findAlbum(ctx, track.Album) + if err != nil { + return err + } + + chosen, err := c.findReleases(ctx, *album.Id, *album.ArtistId) + if err != nil { + return err + } + + // Start download + release, err := c.createRelease(ctx, chosen) + if err != nil { + return err + } + + track.Present, err = c.checkExistingTrack(ctx, *release.AlbumId.Get(), *release.ArtistId.Get(), track) + if err != nil { + return err + } + + return nil +} + +func (c *Lidarr) checkExistingTrack(ctx context.Context, albumID, artistID int32, track *models.Track) (bool, error) { + log.Print("checking for existing tracks") + tracks, _, err := c.Client.TrackAPI.ListTrack(ctx). + AlbumId(albumID). + ArtistId(artistID). + Execute() + if err != nil { + return false, fmt.Errorf("failed to get album tracks: %w", err) + } + + for _, t := range tracks { + if t.Title.IsSet() && t.Title.Get() != nil && *t.Title.Get() == track.Title { + log.Printf("Track already downloaded: %s", *t.Title.Get()) + return true, nil + } + } + + return false, nil +} + +func (c *Lidarr) findArtist(ctx context.Context, name string) (*lidarr.ArtistResource, error) { + // Lookup Artist + log.Printf("Finding artist: %s", name) + resp, err := c.Client.ArtistLookupAPI.GetArtistLookup(ctx). + Term(name). + Execute() + if err != nil { + return nil, fmt.Errorf("Lidarr artist ID lookup failed with error: %w", err) + } + var artists []lidarr.ArtistResource + if err := json.NewDecoder(resp.Body).Decode(&artists); err != nil { + return nil, fmt.Errorf("failed to decode Lidarr response: %w", err) + } + if len(artists) == 0 { + return nil, fmt.Errorf("no artist found for: %s", name) + } + + return &artists[0], nil +} + +func (c *Lidarr) addArtistIfNeeded(ctx context.Context, artist *lidarr.ArtistResource, root lidarr.RootFolderResource) error { + // Ensure we aren't adding an artist that already exists + if artist.Path.IsSet() && artist.Added != nil && !artist.Added.IsZero() { + log.Printf("Skipping adding already added artist: %v", *artist.ArtistName.Get()) + return nil + } + + a := lidarr.NewArtistResourceWithDefaults() + a.ArtistName = artist.ArtistName + a.ForeignArtistId = artist.ForeignArtistId + a.RootFolderPath = root.Path + a.MetadataProfileId = root.DefaultMetadataProfileId + a.QualityProfileId = root.DefaultQualityProfileId + + _, httpResp, err := c.Client.ArtistAPI.CreateArtist(ctx). + ArtistResource(*a). + Execute() + if err != nil && (httpResp == nil || httpResp.StatusCode != 400) { + return fmt.Errorf("failed to create artist: %w", err) + } + return nil +} + +func (c *Lidarr) albumLookup(ctx context.Context, query string) ([]lidarr.AlbumResource, error) { + resp, err := c.Client.AlbumLookupAPI.GetAlbumLookup(ctx). + Term(query). + Execute() + if err != nil { + return nil, fmt.Errorf("Lidarr album lookup error: %w", err) + } + + var albums []lidarr.AlbumResource + if err := json.NewDecoder(resp.Body).Decode(&albums); err != nil { + return nil, fmt.Errorf("failed to decode Lidarr response: %w", err) + } + return albums, nil +} + +func (c *Lidarr) findAlbum(ctx context.Context, albumName string) (*lidarr.AlbumResource, error) { + log.Print("Finding album: ", albumName) + albums, _ := c.albumLookup(ctx, albumName) + + for _, album := range albums { + // Skip if the album has been searched recently + if album.LastSearchTime.IsSet() { + if time.Since(*album.LastSearchTime.Get()) < time.Hour { + log.Printf("Skipping recently searched album: %s", *album.Title.Get()) + continue + } + } + + // Return the first album that's not recently searched + if album.Id != nil && album.ArtistId != nil { + return &album, nil + } + } + + return nil, fmt.Errorf("no new album found for: %s", albumName) +} + +func (c *Lidarr) findReleases(ctx context.Context, albumID, artistID int32) (*lidarr.ReleaseResource, error) { + log.Print("Finding release") + releases, _, _ := c.Client.ReleaseAPI.ListRelease(ctx). + AlbumId(albumID). + ArtistId(artistID). + Execute() + if len(releases) == 0 { + return nil, fmt.Errorf("no releases found for album ID %d", albumID) + } + + var chosen lidarr.ReleaseResource + found := false + + // Ensure release isn't rejected + for i := range releases { + if releases[i].Rejected != nil && *releases[i].Rejected { + continue + } + chosen = releases[i] + found = true + break + } + + if !found { + return nil, fmt.Errorf("no valid releases found") + } + + chosen.Protocol = nil + + return &chosen, nil +} + +func (c *Lidarr) createRelease(ctx context.Context, chosen *lidarr.ReleaseResource) (*lidarr.ReleaseResource, error) { + log.Print("Starting download") + release, _, err := c.Client.ReleaseAPI.CreateRelease(ctx). + ReleaseResource(*chosen). + Execute() + if err != nil { + return nil, fmt.Errorf("failed to create release: %w", err) + } + body, _ := json.MarshalIndent(release, "", " ") + log.Println("Release created", string(body)) + return release, nil +} + +func (c *Lidarr) startCleanupWorker(ctx context.Context) { + go func() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Println("Cleanup worker stopped") + return + case <-ticker.C: + if err := c.cleanStaleDownloads(ctx); err != nil { + log.Printf("Cleanup worker error: %v", err) + } + } + } + }() +} + +func (c *Lidarr) cleanStaleDownloads(ctx context.Context) error { + queue, _, err := c.Client.QueueAPI.GetQueue(ctx).Execute() + if err != nil { + return fmt.Errorf("failed to get queue: %w", err) + } + records := queue.Records + for _, record := range records { + // skip invalid or incomplete entries + if record.Size == nil || record.Sizeleft == nil { + continue + } + + // Check if download is older than 15 minutes and has not progressed + age := time.Since(*record.Added.Get()) + if age > 15*time.Minute && *record.Size == *record.Sizeleft { + log.Printf("Removing stale download: %s (no progress in %v)", *record.Title.Get(), age) + _, err := c.Client.QueueAPI.DeleteQueue(ctx, *record.Id).Execute() + if err != nil { + log.Printf("Failed to delete record %d from queue: %v", *record.Id, err) + } + } + } + return nil +} diff --git a/src/main/main.go b/src/main/main.go index 08e18e1..4f8a86a 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -3,8 +3,9 @@ package main import ( "explo/src/debug" "log" - "os" "log/slog" + "os" + "time" "explo/src/client" "explo/src/config" @@ -19,9 +20,9 @@ type Song struct { Album string } -func initHttpClient() *util.HttpClient { +func initHttpClient(cfg *config.Config) *util.HttpClient { return util.NewHttp(util.HttpClientConfig{ - Timeout: 10, + Timeout: time.Duration(cfg.Timeout) * time.Second, }) } @@ -87,4 +88,4 @@ func main() { } else { slog.Info("playlist created successfully", "system", cfg.System, "name", cfg.ClientCfg.PlaylistName) } -} \ No newline at end of file +} diff --git a/src/models/types.go b/src/models/types.go index 91cf956..daba231 100644 --- a/src/models/types.go +++ b/src/models/types.go @@ -3,15 +3,16 @@ package models // for structs used across the project type Track struct { - Album string - ID string - Artist string // All artists as returned by LB - MainArtist string + Album string + AlbumID string + ID string + Artist string // All artists as returned by LB + MainArtist string MainArtistID string - CleanTitle string // Title as returned by LB - Title string // Title as built in listenbrainz.go - File string // File name - Size int // File size - Present bool // is track present in the system or not - Duration int // Track duration in milliseconds (not available for every track) -} \ No newline at end of file + CleanTitle string // Title as returned by LB + Title string // Title as built in listenbrainz.go + File string // File name + Size int // File size + Present bool // is track present in the system or not + Duration int // Track duration in milliseconds (not available for every track) +} From 92fb31bd5a41d65744b977cf68332ff6bb63dfb4 Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Sun, 4 May 2025 12:29:46 -0400 Subject: [PATCH 02/10] Use main artist in search --- src/downloader/lidarr.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 31196ff..29fa416 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -44,16 +44,16 @@ func NewLidarr(cfg cfg.Lidarr, discovery, downloadDir string, httpClient *util.H func (c *Lidarr) QueryTrack(track *models.Track) error { ctx := context.Background() - query := fmt.Sprintf("%s %s", track.Artist, track.Album) + query := fmt.Sprintf("%s %s", track.MainArtist, track.Album) albums, _ := c.albumLookup(ctx, query) if len(albums) == 0 || len(albums[0].Releases) == 0 { - return fmt.Errorf("could not find album for track: %s - %s", track.Title, track.Artist) + return fmt.Errorf("could not find album for track: %s - %s", track.Title, track.MainArtist) } var err error if albums[0].Id == nil || albums[0].ArtistId == nil { - return fmt.Errorf("album or artist ID was nil for track: %s - %s", track.Title, track.Artist) + return fmt.Errorf("album or artist ID was nil for track: %s - %s", track.Title, track.MainArtist) } track.Present, err = c.checkExistingTrack(ctx, *albums[0].Id, *albums[0].ArtistId, track) if err != nil { @@ -76,7 +76,7 @@ func (c *Lidarr) GetTrack(track *models.Track) error { } root := rootFolders[0] - artist, err := c.findArtist(ctx, track.Artist) + artist, err := c.findArtist(ctx, track.MainArtist) if err != nil { return err } From 94ffc812abc574cd3001fe33d6cdf50d7296dd90 Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Mon, 5 May 2025 17:20:09 -0400 Subject: [PATCH 03/10] Use raw api calls Add download monitor --- go.mod | 1 - go.sum | 2 - src/downloader/lidarr.go | 443 ++++++++++++++++++++++----------------- src/models/types.go | 3 +- 4 files changed, 248 insertions(+), 201 deletions(-) diff --git a/go.mod b/go.mod index c0f8d87..6448a1e 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.24.0 toolchain go1.24.2 require ( - github.com/devopsarr/lidarr-go v1.2.0 github.com/ilyakaznacheev/cleanenv v1.5.0 github.com/spf13/pflag v1.0.10 github.com/u2takey/ffmpeg-go v0.5.0 diff --git a/go.sum b/go.sum index 2491071..233e44b 100644 --- a/go.sum +++ b/go.sum @@ -5,8 +5,6 @@ github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2z github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/devopsarr/lidarr-go v1.2.0 h1:umU91TYu+Eh54dJMWSvwT1KBAzT/9+gckMDVu7e6oRk= -github.com/devopsarr/lidarr-go v1.2.0/go.mod h1:l6uoYzFsExwp0zGNQGmMw2UueqXwL3Vaux+i2YspEOg= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 29fa416..4b1b54d 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -1,259 +1,288 @@ package downloader import ( + "bytes" "context" "encoding/json" "fmt" "log" + "net/url" + "strings" "time" cfg "explo/src/config" "explo/src/models" "explo/src/util" - - "github.com/devopsarr/lidarr-go/lidarr" ) type Lidarr struct { DownloadDir string HttpClient *util.HttpClient - Client *lidarr.APIClient + Cfg cfg.Lidarr } -func NewLidarr(cfg cfg.Lidarr, discovery, downloadDir string, httpClient *util.HttpClient) *Lidarr { - // Create Lidarr SDK config - apiCfg := lidarr.NewConfiguration() - apiCfg.Host = cfg.URL - apiCfg.Scheme = cfg.Scheme - apiCfg.DefaultHeader["X-Api-Key"] = cfg.APIKey - apiCfg.HTTPClient = httpClient.Client +type Album struct { + ID int `json:"id"` + Title string `json:"title"` + Disambiguation string `json:"disambiguation"` + Overview string `json:"overview"` + ArtistID int `json:"artistId"` + ForeignAlbumID string `json:"foreignAlbumId"` + Monitored bool `json:"monitored"` + AnyReleaseOK bool `json:"anyReleaseOk"` + ProfileID int `json:"profileId"` + Duration int `json:"duration"` + AlbumType string `json:"albumType"` + SecondaryTypes []string `json:"secondaryTypes"` + MediumCount int `json:"mediumCount"` + Ratings Ratings `json:"ratings"` + ReleaseDate string `json:"releaseDate"` + Releases []Release `json:"releases"` + Genres []string `json:"genres"` + Media []Media `json:"media"` + Artist Artist `json:"artist"` +} - client := lidarr.NewAPIClient(apiCfg) +type Ratings struct { + Votes int `json:"votes"` + Value float64 `json:"value"` +} - l := &Lidarr{ - DownloadDir: downloadDir, - HttpClient: httpClient, - Client: client, - } - ctx := context.Background() - l.startCleanupWorker(ctx) +type Release struct { + ID int `json:"id"` + AlbumID int `json:"albumId"` + ForeignReleaseID string `json:"foreignReleaseId"` + Title string `json:"title"` + Status string `json:"status"` + Duration int `json:"duration"` + TrackCount int `json:"trackCount"` + Media []Media `json:"media"` + MediumCount int `json:"mediumCount"` + Disambiguation string `json:"disambiguation"` + Country []string `json:"country"` + Label []string `json:"label"` + Format string `json:"format"` + Monitored bool `json:"monitored"` +} - return l +type Media struct { + MediumNumber int `json:"mediumNumber"` + MediumName string `json:"mediumName"` + MediumFormat string `json:"mediumFormat"` } -func (c *Lidarr) QueryTrack(track *models.Track) error { - ctx := context.Background() +type Artist struct { + Status string `json:"status"` + Ended bool `json:"ended"` + ArtistName string `json:"artistName"` + ForeignArtistID string `json:"foreignArtistId"` + ArtistType string `json:"artistType"` + Disambiguation string `json:"disambiguation"` + QualityProfileID int + MetadataProfileID int + RootFolderPath string +} - query := fmt.Sprintf("%s %s", track.MainArtist, track.Album) - albums, _ := c.albumLookup(ctx, query) +type LidarrTrack struct { + ArtistID int `json:"artistId"` + ForeignTrackID string `json:"foreignTrackId"` + ForeignRecordingID string `json:"foreignRecordingId"` + TrackFileID int `json:"trackFileId"` + AlbumID int `json:"albumId"` + Explicit bool `json:"explicit"` + AbsoluteTrackNumber int `json:"absoluteTrackNumber"` + TrackNumber string `json:"trackNumber"` + Title string `json:"title"` + Duration int `json:"duration"` // In milliseconds + MediumNumber int `json:"mediumNumber"` + HasFile bool `json:"hasFile"` + Ratings struct { + Votes int `json:"votes"` + Value float64 `json:"value"` + } `json:"ratings"` + ID int `json:"id"` +} - if len(albums) == 0 || len(albums[0].Releases) == 0 { - return fmt.Errorf("could not find album for track: %s - %s", track.Title, track.MainArtist) - } +type LidarrQueue struct { + TotalRecords int `json:"totalRecords"` + Records []LidarrQueueItem `json:"records"` +} - var err error - if albums[0].Id == nil || albums[0].ArtistId == nil { - return fmt.Errorf("album or artist ID was nil for track: %s - %s", track.Title, track.MainArtist) - } - track.Present, err = c.checkExistingTrack(ctx, *albums[0].Id, *albums[0].ArtistId, track) - if err != nil { - return fmt.Errorf("failed to check existing tracks: %w", err) - } +type LidarrQueueArtist struct { + ForeignArtistID string `json:"foreignArtistId"` + Album LidarrQueueAlbum `json:"album"` +} - return nil +type LidarrQueueAlbum struct { + ForeignAlbumID string `json:"foreignAlbumId"` } -func (c *Lidarr) GetTrack(track *models.Track) error { - ctx := context.Background() +type LidarrQueueItem struct { + ArtistID int `json:"artistId"` + AlbumID int `json:"albumId"` + Size int64 `json:"size"` + Title string `json:"title"` + SizeLeft int64 `json:"sizeleft"` + TimeLeft string `json:"timeleft"` // duration string like "00:00:00" + EstimatedCompletionTime time.Time `json:"estimatedCompletionTime"` + Added time.Time `json:"added"` + Status string `json:"status"` + TrackedDownloadStatus string `json:"trackedDownloadStatus"` + TrackedDownloadState string `json:"trackedDownloadState"` + StatusMessages []string `json:"statusMessages"` + DownloadID string `json:"downloadId"` + Protocol string `json:"protocol"` + DownloadClient string `json:"downloadClient"` + DownloadClientHasPostImportCategory bool `json:"downloadClientHasPostImportCategory"` + Indexer string `json:"indexer"` + TrackFileCount int `json:"trackFileCount"` + TrackHasFileCount int `json:"trackHasFileCount"` + DownloadForced bool `json:"downloadForced"` + ID int64 `json:"id"` + Artist []LidarrQueueArtist `json:"artist"` +} - if track.Present { - return nil - } - // Get the defaults from the root dir - rootFolders, _, err := c.Client.RootFolderAPI.ListRootFolder(ctx).Execute() - if err != nil || len(rootFolders) == 0 { - return fmt.Errorf("failed to get root folders: %w", err) - } - root := rootFolders[0] +type Image struct { + // can leave empty for now +} - artist, err := c.findArtist(ctx, track.MainArtist) - if err != nil { - return err - } - if err := c.addArtistIfNeeded(ctx, artist, root); err != nil { - return err - } +type AddOptions struct { + SearchForNewAlbum bool `json:"searchForNewAlbum"` +} - album, err := c.findAlbum(ctx, track.Album) - if err != nil { - return err - } +type MinimalArtist struct { + ForeignArtistID string `json:"foreignArtistId"` + QualityProfileID int `json:"qualityProfileId"` + MetadataProfileID int `json:"metadataProfileId"` + RootFolderPath string `json:"rootFolderPath"` +} - chosen, err := c.findReleases(ctx, *album.Id, *album.ArtistId) - if err != nil { - return err - } +type AddAlbumRequest struct { + ForeignAlbumID string `json:"foreignAlbumId"` + Images []Image `json:"images"` + Artist MinimalArtist `json:"artist"` + AddOptions AddOptions `json:"addOptions"` +} - // Start download - release, err := c.createRelease(ctx, chosen) - if err != nil { - return err - } +type RootFolder struct { + Path string `json:"path"` + DefaultMetadataProfileId int `json:"defaultMetadataProfileId"` + DefaultQualityProfileId int `json:"defaultQualityProfileId"` +} - track.Present, err = c.checkExistingTrack(ctx, *release.AlbumId.Get(), *release.ArtistId.Get(), track) - if err != nil { - return err +func NewLidarr(cfg cfg.Lidarr, discovery, downloadDir string, httpClient *util.HttpClient) *Lidarr { // init downloader cfg for lidarr + return &Lidarr{ + DownloadDir: downloadDir, + HttpClient: httpClient, + Cfg: cfg, } - - return nil } -func (c *Lidarr) checkExistingTrack(ctx context.Context, albumID, artistID int32, track *models.Track) (bool, error) { - log.Print("checking for existing tracks") - tracks, _, err := c.Client.TrackAPI.ListTrack(ctx). - AlbumId(albumID). - ArtistId(artistID). - Execute() +func (c *Lidarr) QueryTrack(track *models.Track) error { + escQuery := url.PathEscape(fmt.Sprintf("%s - %s", track.Album, track.MainArtist)) + queryURL := fmt.Sprintf("%s://%s/api/v1/album/lookup?apiKey=%s&term=%s", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey, escQuery) + + body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) if err != nil { - return false, fmt.Errorf("failed to get album tracks: %w", err) + return fmt.Errorf("failed to lookup tracks: %s", err.Error()) } - for _, t := range tracks { - if t.Title.IsSet() && t.Title.Get() != nil && *t.Title.Get() == track.Title { - log.Printf("Track already downloaded: %s", *t.Title.Get()) - return true, nil - } + var albums []Album + if err = util.ParseResp(body, &albums); err != nil { + return fmt.Errorf("failed to unmarshal query lidarr body: %s", err.Error()) } - return false, nil -} - -func (c *Lidarr) findArtist(ctx context.Context, name string) (*lidarr.ArtistResource, error) { - // Lookup Artist - log.Printf("Finding artist: %s", name) - resp, err := c.Client.ArtistLookupAPI.GetArtistLookup(ctx). - Term(name). - Execute() - if err != nil { - return nil, fmt.Errorf("Lidarr artist ID lookup failed with error: %w", err) - } - var artists []lidarr.ArtistResource - if err := json.NewDecoder(resp.Body).Decode(&artists); err != nil { - return nil, fmt.Errorf("failed to decode Lidarr response: %w", err) + if len(albums) == 0 { + return fmt.Errorf("could not find album for track: %s - %s", track.Title, track.MainArtist) } - if len(artists) == 0 { - return nil, fmt.Errorf("no artist found for: %s", name) + topMatch := albums[0] + if len(topMatch.Releases) == 0 { + return fmt.Errorf("could not find album releases for track: %s - %s", track.Title, track.MainArtist) } - return &artists[0], nil -} - -func (c *Lidarr) addArtistIfNeeded(ctx context.Context, artist *lidarr.ArtistResource, root lidarr.RootFolderResource) error { - // Ensure we aren't adding an artist that already exists - if artist.Path.IsSet() && artist.Added != nil && !artist.Added.IsZero() { - log.Printf("Skipping adding already added artist: %v", *artist.ArtistName.Get()) + if topMatch.Releases[0].ID == 0 || topMatch.ArtistID == 0 { + track.AlbumMBID = topMatch.ForeignAlbumID + track.ArtistMBID = topMatch.Artist.ForeignArtistID + track.Present = false return nil } - a := lidarr.NewArtistResourceWithDefaults() - a.ArtistName = artist.ArtistName - a.ForeignArtistId = artist.ForeignArtistId - a.RootFolderPath = root.Path - a.MetadataProfileId = root.DefaultMetadataProfileId - a.QualityProfileId = root.DefaultQualityProfileId - - _, httpResp, err := c.Client.ArtistAPI.CreateArtist(ctx). - ArtistResource(*a). - Execute() - if err != nil && (httpResp == nil || httpResp.StatusCode != 400) { - return fmt.Errorf("failed to create artist: %w", err) - } - return nil -} - -func (c *Lidarr) albumLookup(ctx context.Context, query string) ([]lidarr.AlbumResource, error) { - resp, err := c.Client.AlbumLookupAPI.GetAlbumLookup(ctx). - Term(query). - Execute() + queryURL = fmt.Sprintf("%s://%s/api/v1/track?apiKey=%s&artistId=%v&albumId=%v", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey, albums[0].ArtistID, albums[0].Releases[0].AlbumID) + body, err = c.HttpClient.MakeRequest("GET", queryURL, nil, nil) if err != nil { - return nil, fmt.Errorf("Lidarr album lookup error: %w", err) + return fmt.Errorf("failed to check existing tracks: %w", err) } - var albums []lidarr.AlbumResource - if err := json.NewDecoder(resp.Body).Decode(&albums); err != nil { - return nil, fmt.Errorf("failed to decode Lidarr response: %w", err) + var lidarrTracks []LidarrTrack + if err = util.ParseResp(body, &lidarrTracks); err != nil { + return fmt.Errorf("failed to unmarshal query lidarr tracks body: %s", err.Error()) } - return albums, nil -} -func (c *Lidarr) findAlbum(ctx context.Context, albumName string) (*lidarr.AlbumResource, error) { - log.Print("Finding album: ", albumName) - albums, _ := c.albumLookup(ctx, albumName) - - for _, album := range albums { - // Skip if the album has been searched recently - if album.LastSearchTime.IsSet() { - if time.Since(*album.LastSearchTime.Get()) < time.Hour { - log.Printf("Skipping recently searched album: %s", *album.Title.Get()) - continue + for _, t := range lidarrTracks { + if strings.Contains(t.Title, track.Title) { + if t.HasFile { + track.Present = true } } - - // Return the first album that's not recently searched - if album.Id != nil && album.ArtistId != nil { - return &album, nil - } } - return nil, fmt.Errorf("no new album found for: %s", albumName) + return nil } -func (c *Lidarr) findReleases(ctx context.Context, albumID, artistID int32) (*lidarr.ReleaseResource, error) { - log.Print("Finding release") - releases, _, _ := c.Client.ReleaseAPI.ListRelease(ctx). - AlbumId(albumID). - ArtistId(artistID). - Execute() - if len(releases) == 0 { - return nil, fmt.Errorf("no releases found for album ID %d", albumID) +func (c *Lidarr) GetTrack(track *models.Track) error { + ctx := context.Background() + + if track.Present { + return nil } - var chosen lidarr.ReleaseResource - found := false + c.startQueueWorker(ctx, track) - // Ensure release isn't rejected - for i := range releases { - if releases[i].Rejected != nil && *releases[i].Rejected { - continue - } - chosen = releases[i] - found = true - break - } + // Get the defaults from the root dir + queryURL := fmt.Sprintf("%s://%s/api/v1/rootfolder?apiKey=%s", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey) - if !found { - return nil, fmt.Errorf("no valid releases found") + body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) + if err != nil { + return fmt.Errorf("failed to lookup root folder: %s", err.Error()) } - chosen.Protocol = nil + var rootFolders []RootFolder + if err = util.ParseResp(body, &rootFolders); err != nil { + return fmt.Errorf("failed to unmarshal query lidarr body: %s", err.Error()) + } - return &chosen, nil -} + if len(rootFolders) == 0 { + return fmt.Errorf("no root folders found in Lidarr") + } + rootFolder := rootFolders[0] + + payload := AddAlbumRequest{ + ForeignAlbumID: track.AlbumMBID, + Images: []Image{}, + Artist: MinimalArtist{ + QualityProfileID: rootFolder.DefaultQualityProfileId, + MetadataProfileID: rootFolder.DefaultMetadataProfileId, + ForeignArtistID: track.ArtistMBID, + RootFolderPath: rootFolder.Path, + }, + AddOptions: AddOptions{ + SearchForNewAlbum: true, + }, + } -func (c *Lidarr) createRelease(ctx context.Context, chosen *lidarr.ReleaseResource) (*lidarr.ReleaseResource, error) { - log.Print("Starting download") - release, _, err := c.Client.ReleaseAPI.CreateRelease(ctx). - ReleaseResource(*chosen). - Execute() + body, err = json.Marshal(payload) if err != nil { - return nil, fmt.Errorf("failed to create release: %w", err) + return fmt.Errorf("marshal error: %w", err) } - body, _ := json.MarshalIndent(release, "", " ") - log.Println("Release created", string(body)) - return release, nil + queryURL = fmt.Sprintf("%s://%s/api/v1/album?apiKey=%s", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey) + _, err = c.HttpClient.MakeRequest("POST", queryURL, bytes.NewReader(body), nil) + if err != nil { + return fmt.Errorf("failed to add album: %w", err) + } + return nil } -func (c *Lidarr) startCleanupWorker(ctx context.Context) { +func (c *Lidarr) startQueueWorker(ctx context.Context, track *models.Track) { go func() { ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() @@ -261,36 +290,56 @@ func (c *Lidarr) startCleanupWorker(ctx context.Context) { for { select { case <-ctx.Done(): - log.Println("Cleanup worker stopped") + log.Println("Queue worker stopped") return case <-ticker.C: - if err := c.cleanStaleDownloads(ctx); err != nil { - log.Printf("Cleanup worker error: %v", err) + if err := c.monitorQueue(track); err != nil { + log.Printf("Queue worker error: %v", err) } } } }() } -func (c *Lidarr) cleanStaleDownloads(ctx context.Context) error { - queue, _, err := c.Client.QueueAPI.GetQueue(ctx).Execute() +func (c *Lidarr) monitorQueue(track *models.Track) error { + queryURL := fmt.Sprintf("%s://%s/api/v1/queue?apiKey=%s", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey) + + body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) if err != nil { - return fmt.Errorf("failed to get queue: %w", err) + return fmt.Errorf("failed to lookup tracks: %s", err.Error()) + } + + var queue LidarrQueue + if err = util.ParseResp(body, &queue); err != nil { + return fmt.Errorf("failed to unmarshal query lidarr body: %s", err.Error()) } - records := queue.Records - for _, record := range records { + + for _, record := range queue.Records { // skip invalid or incomplete entries - if record.Size == nil || record.Sizeleft == nil { + if record.Size == 0 || record.SizeLeft == 0 { continue } // Check if download is older than 15 minutes and has not progressed - age := time.Since(*record.Added.Get()) - if age > 15*time.Minute && *record.Size == *record.Sizeleft { - log.Printf("Removing stale download: %s (no progress in %v)", *record.Title.Get(), age) - _, err := c.Client.QueueAPI.DeleteQueue(ctx, *record.Id).Execute() + age := time.Since(record.Added) + + if age > 15*time.Minute && record.Size == record.SizeLeft { + log.Printf("Removing stale download: %s (no progress in %v)", record.Title, age) + + deleteURL := fmt.Sprintf("%s://%s/api/v1/queue/%v?apiKey=%s", c.Cfg.Scheme, c.Cfg.URL, record.ID, c.Cfg.APIKey) + + _, err = c.HttpClient.MakeRequest("DELETE", deleteURL, nil, nil) if err != nil { - log.Printf("Failed to delete record %d from queue: %v", *record.Id, err) + return fmt.Errorf("failed to delete record %d from queue: %v", record.ID, err) + } + continue + } + + if record.SizeLeft == 0 && record.TrackHasFileCount > 0 { + log.Printf("Marking downloaded tracks from album %d as present", record.AlbumID) + + if track.Album == record.Artist[0].Album.ForeignAlbumID { + track.Present = true } } } diff --git a/src/models/types.go b/src/models/types.go index daba231..d9ad005 100644 --- a/src/models/types.go +++ b/src/models/types.go @@ -4,9 +4,10 @@ package models type Track struct { Album string - AlbumID string + AlbumMBID string ID string Artist string // All artists as returned by LB + ArtistMBID string MainArtist string MainArtistID string CleanTitle string // Title as returned by LB From b7f5220ec2fbc9b275a1bf7595c83315ccbb6c5b Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Wed, 14 May 2025 15:16:24 -0400 Subject: [PATCH 04/10] Add album release to request More updates to lidarr --- src/downloader/lidarr.go | 83 +++++++++++++++++++++++++--------------- 1 file changed, 52 insertions(+), 31 deletions(-) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 4b1b54d..64a3f62 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -160,8 +160,11 @@ type MinimalArtist struct { type AddAlbumRequest struct { ForeignAlbumID string `json:"foreignAlbumId"` Images []Image `json:"images"` + Monitored bool `json:"monitored"` + AnyReleaseOk bool `json:"anyReleaseOk"` Artist MinimalArtist `json:"artist"` AddOptions AddOptions `json:"addOptions"` + Releases []Release `json:"releases"` } type RootFolder struct { @@ -179,43 +182,21 @@ func NewLidarr(cfg cfg.Lidarr, discovery, downloadDir string, httpClient *util.H } func (c *Lidarr) QueryTrack(track *models.Track) error { - escQuery := url.PathEscape(fmt.Sprintf("%s - %s", track.Album, track.MainArtist)) - queryURL := fmt.Sprintf("%s://%s/api/v1/album/lookup?apiKey=%s&term=%s", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey, escQuery) - body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) + album, err := c.findBestAlbumMatch(track) if err != nil { - return fmt.Errorf("failed to lookup tracks: %s", err.Error()) - } - - var albums []Album - if err = util.ParseResp(body, &albums); err != nil { - return fmt.Errorf("failed to unmarshal query lidarr body: %s", err.Error()) - } - - if len(albums) == 0 { - return fmt.Errorf("could not find album for track: %s - %s", track.Title, track.MainArtist) - } - topMatch := albums[0] - if len(topMatch.Releases) == 0 { - return fmt.Errorf("could not find album releases for track: %s - %s", track.Title, track.MainArtist) - } - - if topMatch.Releases[0].ID == 0 || topMatch.ArtistID == 0 { - track.AlbumMBID = topMatch.ForeignAlbumID - track.ArtistMBID = topMatch.Artist.ForeignArtistID - track.Present = false - return nil + return err } - queryURL = fmt.Sprintf("%s://%s/api/v1/track?apiKey=%s&artistId=%v&albumId=%v", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey, albums[0].ArtistID, albums[0].Releases[0].AlbumID) - body, err = c.HttpClient.MakeRequest("GET", queryURL, nil, nil) + queryURL := fmt.Sprintf("%s://%s/api/v1/track?apiKey=%s&artistId=%v&albumId=%v", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey, album.ArtistID, album.Releases[0].AlbumID) + body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) if err != nil { return fmt.Errorf("failed to check existing tracks: %w", err) } var lidarrTracks []LidarrTrack if err = util.ParseResp(body, &lidarrTracks); err != nil { - return fmt.Errorf("failed to unmarshal query lidarr tracks body: %s", err.Error()) + return fmt.Errorf("failed to unmarshal query lidarr tracks body: %w", err) } for _, t := range lidarrTracks { @@ -243,12 +224,12 @@ func (c *Lidarr) GetTrack(track *models.Track) error { body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) if err != nil { - return fmt.Errorf("failed to lookup root folder: %s", err.Error()) + return fmt.Errorf("failed to lookup root folder: %w", err) } var rootFolders []RootFolder if err = util.ParseResp(body, &rootFolders); err != nil { - return fmt.Errorf("failed to unmarshal query lidarr body: %s", err.Error()) + return fmt.Errorf("failed to unmarshal query lidarr body: %w", err) } if len(rootFolders) == 0 { @@ -256,9 +237,16 @@ func (c *Lidarr) GetTrack(track *models.Track) error { } rootFolder := rootFolders[0] + album, err := c.findBestAlbumMatch(track) + if err != nil { + return err + } + payload := AddAlbumRequest{ ForeignAlbumID: track.AlbumMBID, Images: []Image{}, + Monitored: true, + AnyReleaseOk: true, Artist: MinimalArtist{ QualityProfileID: rootFolder.DefaultQualityProfileId, MetadataProfileID: rootFolder.DefaultMetadataProfileId, @@ -268,6 +256,7 @@ func (c *Lidarr) GetTrack(track *models.Track) error { AddOptions: AddOptions{ SearchForNewAlbum: true, }, + Releases: []Release{album.Releases[0]}, } body, err = json.Marshal(payload) @@ -282,6 +271,38 @@ func (c *Lidarr) GetTrack(track *models.Track) error { return nil } +func (c *Lidarr) findBestAlbumMatch(track *models.Track) (*Album, error) { + escQuery := url.PathEscape(fmt.Sprintf("%s - %s", track.Album, track.MainArtist)) + queryURL := fmt.Sprintf("%s://%s/api/v1/album/lookup?apiKey=%s&term=%s", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey, escQuery) + + body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to lookup tracks: %w", err) + } + + var albums []Album + if err = util.ParseResp(body, &albums); err != nil { + return nil, fmt.Errorf("failed to unmarshal query lidarr body: %w", err) + } + + if len(albums) == 0 { + return nil, fmt.Errorf("could not find album for track: %s - %s", track.Title, track.MainArtist) + } + topMatch := albums[0] + if len(topMatch.Releases) == 0 { + return nil, fmt.Errorf("could not find album releases for track: %s - %s", track.Title, track.MainArtist) + } + + track.AlbumMBID = topMatch.ForeignAlbumID + track.ArtistMBID = topMatch.Artist.ForeignArtistID + + if topMatch.Releases[0].ID == 0 || topMatch.ArtistID == 0 { + return nil, fmt.Errorf("invalid album or artist ID for track: %s - %s", track.Title, track.MainArtist) + } + + return &topMatch, nil +} + func (c *Lidarr) startQueueWorker(ctx context.Context, track *models.Track) { go func() { ticker := time.NewTicker(5 * time.Minute) @@ -306,12 +327,12 @@ func (c *Lidarr) monitorQueue(track *models.Track) error { body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) if err != nil { - return fmt.Errorf("failed to lookup tracks: %s", err.Error()) + return fmt.Errorf("failed to lookup tracks: %w", err) } var queue LidarrQueue if err = util.ParseResp(body, &queue); err != nil { - return fmt.Errorf("failed to unmarshal query lidarr body: %s", err.Error()) + return fmt.Errorf("failed to unmarshal query lidarr body: %w", err) } for _, record := range queue.Records { From 8b84aada6f4ac6af7c5b69a9f01bee82015bf175 Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Thu, 15 May 2025 23:17:30 -0400 Subject: [PATCH 05/10] Don't monitor the whole artist Fix rebase More rebase fixes --- go.mod | 4 +--- go.sum | 9 +++++---- src/config/config.go | 28 ++++++++++++++++------------ src/downloader/lidarr.go | 2 ++ 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/go.mod b/go.mod index 6448a1e..7fd25df 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module explo go 1.24.0 -toolchain go1.24.2 +toolchain go1.24.3 require ( github.com/ilyakaznacheev/cleanenv v1.5.0 @@ -18,9 +18,7 @@ require ( github.com/aws/aws-sdk-go v1.38.20 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/joho/godotenv v1.5.1 // indirect - github.com/stretchr/testify v1.10.0 // indirect github.com/u2takey/go-utils v0.3.1 // indirect - golang.org/x/net v0.39.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect ) diff --git a/go.sum b/go.sum index 233e44b..5878280 100644 --- a/go.sum +++ b/go.sum @@ -40,9 +40,8 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/u2takey/ffmpeg-go v0.5.0 h1:r7d86XuL7uLWJ5mzSeQ03uvjfIhiJYvsRAJFCW4uklU= github.com/u2takey/ffmpeg-go v0.5.0/go.mod h1:ruZWkvC1FEiUNjmROowOAps3ZcWxEiOpFoHCvk97kGc= github.com/u2takey/go-utils v0.3.1 h1:TaQTgmEZZeDHQFYfd+AdUT1cT4QJgJn/XVPELhHw4ys= @@ -56,15 +55,17 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= diff --git a/src/config/config.go b/src/config/config.go index ca2f634..864227f 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -58,6 +58,22 @@ type Credentials struct { Salt string } +type DiscoveryConfig struct { + Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"` + Separator string `env:"FILENAME_SEPARATOR" env-default:" "` + Listenbrainz Listenbrainz +} + +type Lidarr struct { + APIKey string `env:"LIDARR_API_KEY"` + Retry int `env:"LIDARR_RETRY" env-default:"5"` // Number of times to check search status before skipping the track + DownloadAttempts int `env:"LIDARR_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track + Timeout time.Duration `env:"LIDARR_TIMEOUT" env-default:"20s"` + Scheme string `env:"LIDARR_SCHEME" env-default:"http"` + URL string `env:"LIDARR_URL"` + Filters Filters +} + type SubsonicConfig struct { Version string `env:"SUBSONIC_VERSION" env-default:"1.16.1"` ID string `env:"CLIENT" env-default:"explo"` @@ -99,14 +115,6 @@ type YoutubeMusic struct { Filters Filters } -type Lidarr struct { - APIKey string `env:"LIDARR_API_KEY"` - Separator string `env:"FILENAME_SEPARATOR" env-default:" "` - FilterList []string `env:"FILTER_LIST" env-default:"live,remix,instrumental,extended"` - Scheme string `env:"LIDARR_SCHEME" env-default:"http"` - URL string `env:"LIDARR_URL"` -} - type Slskd struct { APIKey string `env:"SLSKD_API_KEY"` URL string `env:"SLSKD_URL"` @@ -124,10 +132,6 @@ type SlskdMon struct { Duration time.Duration `env:"SLSKD_MONITOR_DURATION" env-default:"15m"` } -type DiscoveryConfig struct { - Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"` - Listenbrainz Listenbrainz -} type Listenbrainz struct { Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"` User string `env:"LISTENBRAINZ_USER"` diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 64a3f62..eaa614c 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -154,6 +154,7 @@ type MinimalArtist struct { ForeignArtistID string `json:"foreignArtistId"` QualityProfileID int `json:"qualityProfileId"` MetadataProfileID int `json:"metadataProfileId"` + Monitored bool `json:"monitored"` RootFolderPath string `json:"rootFolderPath"` } @@ -250,6 +251,7 @@ func (c *Lidarr) GetTrack(track *models.Track) error { Artist: MinimalArtist{ QualityProfileID: rootFolder.DefaultQualityProfileId, MetadataProfileID: rootFolder.DefaultMetadataProfileId, + Monitored: false, ForeignArtistID: track.ArtistMBID, RootFolderPath: rootFolder.Path, }, From 29b1b6026663e54d918b5c77c2799f6b1e6212ef Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Wed, 2 Jul 2025 17:18:50 -0400 Subject: [PATCH 06/10] Remove timeout from main.go Fix rebase more Fix rebase more --- src/config/config.go | 3 +++ src/main/main.go | 5 ++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/config/config.go b/src/config/config.go index 864227f..82165b0 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -22,7 +22,10 @@ type Config struct { Persist bool System string `env:"EXPLO_SYSTEM"` Debug bool `env:"DEBUG" env-default:"false"` +<<<<<<< HEAD LogLevel string `env:"LOG_LEVEL" env-default:"INFO"` +======= +>>>>>>> 2065c16 (Remove timeout from main.go) } type Flags struct { diff --git a/src/main/main.go b/src/main/main.go index 4f8a86a..47bddd8 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -5,7 +5,6 @@ import ( "log" "log/slog" "os" - "time" "explo/src/client" "explo/src/config" @@ -20,9 +19,9 @@ type Song struct { Album string } -func initHttpClient(cfg *config.Config) *util.HttpClient { +func initHttpClient() *util.HttpClient { return util.NewHttp(util.HttpClientConfig{ - Timeout: time.Duration(cfg.Timeout) * time.Second, + Timeout: 10, }) } From 5e3793aede8148606d3d3474b2f4eb32093635bf Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Tue, 22 Jul 2025 02:57:15 -0400 Subject: [PATCH 07/10] DRY downloader checker Fix post rebase Remove redeclarations --- src/config/config.go | 9 ++- src/downloader/lidarr.go | 119 ++++++++++++++++++++------------------- 2 files changed, 67 insertions(+), 61 deletions(-) diff --git a/src/config/config.go b/src/config/config.go index 82165b0..fb72d96 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -22,10 +22,7 @@ type Config struct { Persist bool System string `env:"EXPLO_SYSTEM"` Debug bool `env:"DEBUG" env-default:"false"` -<<<<<<< HEAD LogLevel string `env:"LOG_LEVEL" env-default:"INFO"` -======= ->>>>>>> 2065c16 (Remove timeout from main.go) } type Flags struct { @@ -71,6 +68,8 @@ type Lidarr struct { APIKey string `env:"LIDARR_API_KEY"` Retry int `env:"LIDARR_RETRY" env-default:"5"` // Number of times to check search status before skipping the track DownloadAttempts int `env:"LIDARR_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track + LidarrDir string `env:"LIDARR_DIR" env-default:"/lidarr/"` + MigrateDL bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from SlskdDir to DownloadDir Timeout time.Duration `env:"LIDARR_TIMEOUT" env-default:"20s"` Scheme string `env:"LIDARR_SCHEME" env-default:"http"` URL string `env:"LIDARR_URL"` @@ -143,6 +142,10 @@ type Listenbrainz struct { } func (cfg *Config) ReadEnv() { +} + +func ReadEnv() Config { + var cfg Config // Try to read from .env file first err := cleanenv.ReadConfig(cfg.Flags.CfgPath, cfg) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index eaa614c..3fb3ac3 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -2,15 +2,14 @@ package downloader import ( "bytes" - "context" "encoding/json" "fmt" - "log" "net/url" "strings" "time" cfg "explo/src/config" + "explo/src/debug" "explo/src/models" "explo/src/util" ) @@ -174,15 +173,15 @@ type RootFolder struct { DefaultQualityProfileId int `json:"defaultQualityProfileId"` } -func NewLidarr(cfg cfg.Lidarr, discovery, downloadDir string, httpClient *util.HttpClient) *Lidarr { // init downloader cfg for lidarr - return &Lidarr{ +func NewLidarr(cfg cfg.Lidarr, discovery, downloadDir string, httpClient *util.HttpClient) Lidarr { // init downloader cfg for lidarr + return Lidarr{ DownloadDir: downloadDir, HttpClient: httpClient, Cfg: cfg, } } -func (c *Lidarr) QueryTrack(track *models.Track) error { +func (c Lidarr) QueryTrack(track *models.Track) error { album, err := c.findBestAlbumMatch(track) if err != nil { @@ -211,15 +210,12 @@ func (c *Lidarr) QueryTrack(track *models.Track) error { return nil } -func (c *Lidarr) GetTrack(track *models.Track) error { - ctx := context.Background() +func (c Lidarr) GetTrack(track *models.Track) error { if track.Present { return nil } - c.startQueueWorker(ctx, track) - // Get the defaults from the root dir queryURL := fmt.Sprintf("%s://%s/api/v1/rootfolder?apiKey=%s", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey) @@ -273,7 +269,7 @@ func (c *Lidarr) GetTrack(track *models.Track) error { return nil } -func (c *Lidarr) findBestAlbumMatch(track *models.Track) (*Album, error) { +func (c Lidarr) findBestAlbumMatch(track *models.Track) (*Album, error) { escQuery := url.PathEscape(fmt.Sprintf("%s - %s", track.Album, track.MainArtist)) queryURL := fmt.Sprintf("%s://%s/api/v1/album/lookup?apiKey=%s&term=%s", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey, escQuery) @@ -305,66 +301,73 @@ func (c *Lidarr) findBestAlbumMatch(track *models.Track) (*Album, error) { return &topMatch, nil } -func (c *Lidarr) startQueueWorker(ctx context.Context, track *models.Track) { - go func() { - ticker := time.NewTicker(5 * time.Minute) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - log.Println("Queue worker stopped") - return - case <-ticker.C: - if err := c.monitorQueue(track); err != nil { - log.Printf("Queue worker error: %v", err) - } - } - } - }() +func (c Lidarr) MonitorDownloads(tracks []*models.Track) error { + monitorCfg := MonitorConfig{ + CheckInterval: 1 * time.Minute, + MonitorDuration: 15 * time.Minute, + MigrateDownload: c.Cfg.MigrateDL, + FromDir: c.Cfg.LidarrDir, + ToDir: c.DownloadDir, + } + err := Monitor( + tracks, + c.getDownloadStatus, + func(t *models.Track, id string) { c.cleanupTrack(t, id) }, + moveDownload, + monitorCfg, + ) + if err != nil { + return err + } + return nil } -func (c *Lidarr) monitorQueue(track *models.Track) error { - queryURL := fmt.Sprintf("%s://%s/api/v1/queue?apiKey=%s", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey) +func (c Lidarr) getDownloadStatus() (DownloadStatus, error) { + reqParams := "/api/v0/transfers/downloads" - body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) + body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParams, nil, nil) if err != nil { - return fmt.Errorf("failed to lookup tracks: %w", err) + return nil, err } - var queue LidarrQueue - if err = util.ParseResp(body, &queue); err != nil { - return fmt.Errorf("failed to unmarshal query lidarr body: %w", err) + var status DownloadStatus + if err := util.ParseResp(body, &status); err != nil { + return nil, err } + return status, nil +} - for _, record := range queue.Records { - // skip invalid or incomplete entries - if record.Size == 0 || record.SizeLeft == 0 { - continue - } - - // Check if download is older than 15 minutes and has not progressed - age := time.Since(record.Added) - - if age > 15*time.Minute && record.Size == record.SizeLeft { - log.Printf("Removing stale download: %s (no progress in %v)", record.Title, age) +func (c Lidarr) cleanupTrack(track *models.Track, fileID string) { + if err := c.deleteSearch(track.ID); err != nil { + debug.Debug(fmt.Sprintf("[slskd] failed to delete search request: %v", err)) + } + if err := c.deleteDownload(track.MainArtistID, fileID); err != nil { + debug.Debug(fmt.Sprintf("[slskd] failed to delete download: %v", err)) + } +} - deleteURL := fmt.Sprintf("%s://%s/api/v1/queue/%v?apiKey=%s", c.Cfg.Scheme, c.Cfg.URL, record.ID, c.Cfg.APIKey) +func (c Lidarr) deleteSearch(ID string) error { + reqParams := fmt.Sprintf("/api/v0/searches/%s", ID) - _, err = c.HttpClient.MakeRequest("DELETE", deleteURL, nil, nil) - if err != nil { - return fmt.Errorf("failed to delete record %d from queue: %v", record.ID, err) - } - continue - } + _, err := c.HttpClient.MakeRequest("DELETE", c.Cfg.URL+reqParams, nil, nil) + if err != nil { + return err + } + return nil +} - if record.SizeLeft == 0 && record.TrackHasFileCount > 0 { - log.Printf("Marking downloaded tracks from album %d as present", record.AlbumID) +func (c Lidarr) deleteDownload(user, ID string) error { + reqParams := fmt.Sprintf("/api/v0/transfers/downloads/%s/%s", user, ID) - if track.Album == record.Artist[0].Album.ForeignAlbumID { - track.Present = true - } - } + // cancel download + if _, err := c.HttpClient.MakeRequest("DELETE", c.Cfg.URL+reqParams+"?remove=false", nil, nil); err != nil { + return fmt.Errorf("soft delete failed: %s", err.Error()) + } + time.Sleep(1 * time.Second) // Small buffer between soft and hard delete + // delete download + if _, err := c.HttpClient.MakeRequest("DELETE", c.Cfg.URL+reqParams+"?remove=true", nil, nil); err != nil { + return fmt.Errorf("hard delete failed: %s", err.Error()) } + return nil } From 966810a7dec454b15eca2fe27ff62f2f52967c6d Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Wed, 13 Aug 2025 01:30:29 -0400 Subject: [PATCH 08/10] Update sample env; reorganize Add PUID/GID support to Docker container Fixup rebase --- Dockerfile | 4 ++++ docker-compose.yaml | 2 ++ docker/start.sh | 34 ++++++++++++++++++++++++++++++---- go.sum | 5 ++--- sample.env | 11 +++++++++++ src/config/config.go | 16 ++++++---------- src/downloader/lidarr.go | 30 +++++++++++++++--------------- 7 files changed, 70 insertions(+), 32 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4f7feaf..b0e06e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,4 +38,8 @@ RUN chmod +x /start.sh ./explo # Can be defined from compose as well ENV WEEKLY_EXPLORATION_SCHEDULE="15 0 * * 2" +# Default PUID and PGID +ENV PUID=1000 +ENV PGID=1000 + CMD ["/start.sh"] \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 5e33e5c..c419f08 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -12,6 +12,8 @@ services: # - /path/to/cookies.txt:/opt/explo/cookies.txt # Path to optional cookies file (for yt-dlp) environment: - TZ=UTC # Change this to the timezone set in ListenBrainz (default is UTC) + - PUID=1000 # User ID for file permissions (optional, defaults to 1000) + - PGID=1000 # Group ID for file permissions (optional, defaults to 1000) - WEEKLY_EXPLORATION_SCHEDULE=15 00 * * 2 # Runs weekly, every Tuesday 15 minutes past midnight - WEEKLY_EXPLORATION_FLAGS= # Run weekly exploration with default settings diff --git a/docker/start.sh b/docker/start.sh index a3b27d8..f2dbdc6 100644 --- a/docker/start.sh +++ b/docker/start.sh @@ -1,11 +1,37 @@ #!/bin/sh + +# Handle PUID/PGID +if [ "$PUID" != "0" ] && [ "$PGID" != "0" ]; then + echo "[setup] Setting up user with PUID=$PUID and PGID=$PGID" + + # Create group if it doesn't exist + if ! getent group explo > /dev/null 2>&1; then + groupadd -g "$PGID" explo + fi + + # Create user if it doesn't exist + if ! getent passwd explo > /dev/null 2>&1; then + useradd -u "$PUID" -g "$PGID" -d /opt/explo -s /bin/sh explo + fi + + # Ensure explo user owns the working directory and data directory + chown -R explo:explo /opt/explo + [ -d /data ] && chown -R explo:explo /data + + # If running as non-root, exec as the explo user + if [ "$(id -u)" = "0" ]; then + exec su-exec explo "$0" "$@" + fi +fi + echo "[setup] Initializing cron jobs..." # $CRON_SHCEDULE was deprecated in v0.11.0, keeping this block for backwards compatibility if [ -n "$CRON_SCHEDULE" ]; then - echo "$CRON_SCHEDULE apk add --upgrade yt-dlp && cd /opt/explo && ./explo >> /proc/1/fd/1 2>&1" > /etc/crontabs/root - chmod 600 /etc/crontabs/root + cmd="apk add --upgrade yt-dlp && cd /opt/explo && ./explo >> /proc/1/fd/1 2>&1" + echo "$CRON_SCHEDULE $cmd" > "/var/spool/cron/crontabs/$CRON_USER" + chmod 600 "/var/spool/cron/crontabs/$CRON_USER" echo "[setup] Registered single CRON_SCHEDULE job: $CRON_SCHEDULE" crond -f -l 2 fi @@ -25,13 +51,13 @@ for var in $(env | grep "_SCHEDULE=" | cut -d= -f1); do # Default: just run explo if flags are empty cmd="apk add --upgrade yt-dlp && cd /opt/explo && ./explo $flags >> /proc/1/fd/1 2>&1" - echo "$schedule $cmd" >> /etc/crontabs/root + echo "$schedule $cmd" >> "/var/spool/cron/crontabs/$CRON_USER" echo "[setup] Registered job: $job" echo " Schedule: $schedule" echo " Command : ./explo $flags" done -chmod 600 /etc/crontabs/root +chmod 600 "/var/spool/cron/crontabs/$CRON_USER" echo "[setup] Starting cron..." diff --git a/go.sum b/go.sum index 5878280..37c9420 100644 --- a/go.sum +++ b/go.sum @@ -57,15 +57,14 @@ golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+o golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= diff --git a/sample.env b/sample.env index 61c7a70..cc60bc7 100644 --- a/sample.env +++ b/sample.env @@ -81,6 +81,17 @@ YOUTUBE_API_KEY= # Comma-separated (without spaces) keywords to avoid, when filtering slskd results (default: live,remix,instrumental,extended,clean,acapella) # FILTER_LIST=live,remix,instrumental,extended,clean,acapella +# === Lidarr Configuration === + +# LIDARR_API_KEY= +# LIDARR_RETRY= +# LIDARR_DL_ATTEMPTS= +# LIDARR_DIR= +# MIGRATE_DOWNLOADS= +# LIDARR_TIMEOUT= +# LIDARR_SCHEME= +# LIDARR_URL= + # === Metadata / Formatting === # Set to true to merge featured artists into title (recommended), false appends them to artist field (default: true) diff --git a/src/config/config.go b/src/config/config.go index fb72d96..fe27700 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -58,12 +58,6 @@ type Credentials struct { Salt string } -type DiscoveryConfig struct { - Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"` - Separator string `env:"FILENAME_SEPARATOR" env-default:" "` - Listenbrainz Listenbrainz -} - type Lidarr struct { APIKey string `env:"LIDARR_API_KEY"` Retry int `env:"LIDARR_RETRY" env-default:"5"` // Number of times to check search status before skipping the track @@ -134,6 +128,11 @@ type SlskdMon struct { Duration time.Duration `env:"SLSKD_MONITOR_DURATION" env-default:"15m"` } +type DiscoveryConfig struct { + Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"` + Listenbrainz Listenbrainz +} + type Listenbrainz struct { Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"` User string `env:"LISTENBRAINZ_USER"` @@ -141,10 +140,7 @@ type Listenbrainz struct { SingleArtist bool `env:"SINGLE_ARTIST" env-default:"true"` } -func (cfg *Config) ReadEnv() { -} - -func ReadEnv() Config { +func ReadEnv() { var cfg Config // Try to read from .env file first diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 3fb3ac3..be93c84 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -301,6 +301,21 @@ func (c Lidarr) findBestAlbumMatch(track *models.Track) (*Album, error) { return &topMatch, nil } +func (c Lidarr) getDownloadStatus() (DownloadStatus, error) { + reqParams := "/api/v0/transfers/downloads" + + body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParams, nil, nil) + if err != nil { + return nil, err + } + + var status DownloadStatus + if err := util.ParseResp(body, &status); err != nil { + return nil, err + } + return status, nil +} + func (c Lidarr) MonitorDownloads(tracks []*models.Track) error { monitorCfg := MonitorConfig{ CheckInterval: 1 * time.Minute, @@ -322,21 +337,6 @@ func (c Lidarr) MonitorDownloads(tracks []*models.Track) error { return nil } -func (c Lidarr) getDownloadStatus() (DownloadStatus, error) { - reqParams := "/api/v0/transfers/downloads" - - body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParams, nil, nil) - if err != nil { - return nil, err - } - - var status DownloadStatus - if err := util.ParseResp(body, &status); err != nil { - return nil, err - } - return status, nil -} - func (c Lidarr) cleanupTrack(track *models.Track, fileID string) { if err := c.deleteSearch(track.ID); err != nil { debug.Debug(fmt.Sprintf("[slskd] failed to delete search request: %v", err)) From 9014077241a77cfe69c6b1fae258bfa24d2c2888 Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Mon, 22 Sep 2025 22:22:48 -0400 Subject: [PATCH 09/10] Update lidarr monitoring Fix config Fix ReadEnv rebase Fixup rebase Restore a couple files Unformat config Ignore vscode Restore downloader Add fields to type Make lidarr timeout an int Fix env reading Remove scheme Fix cleanup tasks --- .gitignore | 1 + Dockerfile | 4 - docker-compose.yaml | 2 - docker/start.sh | 34 +------- src/config/config.go | 157 ++++++++++++++++++----------------- src/downloader/downloader.go | 5 +- src/downloader/lidarr.go | 128 ++++++++++++++++------------ src/main/main.go | 4 +- src/models/types.go | 26 +++--- 9 files changed, 178 insertions(+), 183 deletions(-) diff --git a/.gitignore b/.gitignore index 4c49bd7..2af1eb9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .env +**/.vscode/** diff --git a/Dockerfile b/Dockerfile index b0e06e3..4f7feaf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,8 +38,4 @@ RUN chmod +x /start.sh ./explo # Can be defined from compose as well ENV WEEKLY_EXPLORATION_SCHEDULE="15 0 * * 2" -# Default PUID and PGID -ENV PUID=1000 -ENV PGID=1000 - CMD ["/start.sh"] \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index c419f08..5e33e5c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -12,8 +12,6 @@ services: # - /path/to/cookies.txt:/opt/explo/cookies.txt # Path to optional cookies file (for yt-dlp) environment: - TZ=UTC # Change this to the timezone set in ListenBrainz (default is UTC) - - PUID=1000 # User ID for file permissions (optional, defaults to 1000) - - PGID=1000 # Group ID for file permissions (optional, defaults to 1000) - WEEKLY_EXPLORATION_SCHEDULE=15 00 * * 2 # Runs weekly, every Tuesday 15 minutes past midnight - WEEKLY_EXPLORATION_FLAGS= # Run weekly exploration with default settings diff --git a/docker/start.sh b/docker/start.sh index f2dbdc6..a3b27d8 100644 --- a/docker/start.sh +++ b/docker/start.sh @@ -1,37 +1,11 @@ #!/bin/sh - -# Handle PUID/PGID -if [ "$PUID" != "0" ] && [ "$PGID" != "0" ]; then - echo "[setup] Setting up user with PUID=$PUID and PGID=$PGID" - - # Create group if it doesn't exist - if ! getent group explo > /dev/null 2>&1; then - groupadd -g "$PGID" explo - fi - - # Create user if it doesn't exist - if ! getent passwd explo > /dev/null 2>&1; then - useradd -u "$PUID" -g "$PGID" -d /opt/explo -s /bin/sh explo - fi - - # Ensure explo user owns the working directory and data directory - chown -R explo:explo /opt/explo - [ -d /data ] && chown -R explo:explo /data - - # If running as non-root, exec as the explo user - if [ "$(id -u)" = "0" ]; then - exec su-exec explo "$0" "$@" - fi -fi - echo "[setup] Initializing cron jobs..." # $CRON_SHCEDULE was deprecated in v0.11.0, keeping this block for backwards compatibility if [ -n "$CRON_SCHEDULE" ]; then - cmd="apk add --upgrade yt-dlp && cd /opt/explo && ./explo >> /proc/1/fd/1 2>&1" - echo "$CRON_SCHEDULE $cmd" > "/var/spool/cron/crontabs/$CRON_USER" - chmod 600 "/var/spool/cron/crontabs/$CRON_USER" + echo "$CRON_SCHEDULE apk add --upgrade yt-dlp && cd /opt/explo && ./explo >> /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 fi @@ -51,13 +25,13 @@ for var in $(env | grep "_SCHEDULE=" | cut -d= -f1); do # Default: just run explo if flags are empty cmd="apk add --upgrade yt-dlp && cd /opt/explo && ./explo $flags >> /proc/1/fd/1 2>&1" - echo "$schedule $cmd" >> "/var/spool/cron/crontabs/$CRON_USER" + echo "$schedule $cmd" >> /etc/crontabs/root echo "[setup] Registered job: $job" echo " Schedule: $schedule" echo " Command : ./explo $flags" done -chmod 600 "/var/spool/cron/crontabs/$CRON_USER" +chmod 600 /etc/crontabs/root echo "[setup] Starting cron..." diff --git a/src/config/config.go b/src/config/config.go index fe27700..21dfca2 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -3,10 +3,10 @@ package config import ( "errors" "fmt" - "log/slog" "os" "strings" "time" + "log/slog" "github.com/ilyakaznacheev/cleanenv" "golang.org/x/text/cases" @@ -14,113 +14,120 @@ import ( ) type Config struct { - DownloadCfg DownloadConfig + DownloadCfg DownloadConfig DiscoveryCfg DiscoveryConfig - ClientCfg ClientConfig - Flags Flags - PersistENV bool `env:"PERSIST" env-default:"true"` - Persist bool - System string `env:"EXPLO_SYSTEM"` - Debug bool `env:"DEBUG" env-default:"false"` - LogLevel string `env:"LOG_LEVEL" env-default:"INFO"` + ClientCfg ClientConfig + Flags Flags + PersistENV bool `env:"PERSIST" env-default:"true"` + Persist bool + System string `env:"EXPLO_SYSTEM"` + Debug bool `env:"DEBUG" env-default:"false"` + LogLevel string `env:"LOG_LEVEL" env-default:"INFO"` } type Flags struct { - CfgPath string - Playlist string + CfgPath string + Playlist string DownloadMode string ExcludeLocal bool - Persist bool - PersistSet bool + Persist bool + PersistSet bool } type ClientConfig struct { - ClientID string `env:"CLIENT_ID" env-default:"explo"` - LibraryName string `env:"LIBRARY_NAME" env-default:"Explo"` - URL string `env:"SYSTEM_URL"` - DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"` - PlaylistDir string `env:"PLAYLIST_DIR"` - PlaylistName string + ClientID string `env:"CLIENT_ID" env-default:"explo"` + LibraryName string `env:"LIBRARY_NAME" env-default:"Explo"` + URL string `env:"SYSTEM_URL"` + DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"` + PlaylistDir string `env:"PLAYLIST_DIR"` + PlaylistName string PlaylistDescr string - PlaylistID string - Sleep int `env:"SLEEP" env-default:"2"` - HTTPTimeout int `env:"CLIENT_HTTP_TIMEOUT" env-default:"10"` - Creds Credentials - Subsonic SubsonicConfig + PlaylistID string + Sleep int `env:"SLEEP" env-default:"2"` + HTTPTimeout int `env:"CLIENT_HTTP_TIMEOUT" env-default:"10"` + Creds Credentials + Subsonic SubsonicConfig } type Credentials struct { - APIKey string `env:"API_KEY"` - User string `env:"SYSTEM_USERNAME"` + APIKey string `env:"API_KEY"` + User string `env:"SYSTEM_USERNAME"` Password string `env:"SYSTEM_PASSWORD"` - Headers map[string]string - Token string - Salt string + Headers map[string]string + Token string + Salt string } + + type Lidarr struct { APIKey string `env:"LIDARR_API_KEY"` Retry int `env:"LIDARR_RETRY" env-default:"5"` // Number of times to check search status before skipping the track DownloadAttempts int `env:"LIDARR_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track LidarrDir string `env:"LIDARR_DIR" env-default:"/lidarr/"` - MigrateDL bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from SlskdDir to DownloadDir - Timeout time.Duration `env:"LIDARR_TIMEOUT" env-default:"20s"` - Scheme string `env:"LIDARR_SCHEME" env-default:"http"` + MigrateDL bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from LidarrDir to DownloadDir + Timeout int `env:"LIDARR_TIMEOUT" env-default:"20"` URL string `env:"LIDARR_URL"` Filters Filters + MonitorConfig LidarrMon +} + +type LidarrMon struct { + Interval time.Duration `env:"SLSKD_MONITOR_INTERVAL" env-default:"1m"` + Duration time.Duration `env:"SLSKD_MONITOR_DURATION" env-default:"15m"` } type SubsonicConfig struct { - Version string `env:"SUBSONIC_VERSION" env-default:"1.16.1"` - ID string `env:"CLIENT" env-default:"explo"` - PublicPlaylist bool `env:"PUBLIC_PLAYLIST" env-default:"false"` + Version string `env:"SUBSONIC_VERSION" env-default:"1.16.1"` + ID string `env:"CLIENT" env-default:"explo"` + PublicPlaylist bool `env:"PUBLIC_PLAYLIST" env-default:"false"` } type DownloadConfig struct { - DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"` - Youtube Youtube - YoutubeMusic YoutubeMusic - Slskd Slskd - Lidarr Lidarr - 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/"` + Youtube Youtube + YoutubeMusic YoutubeMusic + Slskd Slskd + Lidarr Lidarr + 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"` } type Filters struct { - Extensions []string `env:"EXTENSIONS" env-default:"flac,mp3"` - MinBitDepth int `env:"MIN_BIT_DEPTH" env-default:"8"` - MinBitRate int `env:"MIN_BITRATE" env-default:"256"` - FilterList []string `env:"FILTER_LIST" env-default:"live,remix,instrumental,extended,clean,acapella"` + Extensions []string `env:"EXTENSIONS" env-default:"flac,mp3"` + MinBitDepth int `env:"MIN_BIT_DEPTH" env-default:"8"` + MinBitRate int `env:"MIN_BITRATE" env-default:"256"` + FilterList []string `env:"FILTER_LIST" env-default:"live,remix,instrumental,extended,clean,acapella"` } type Youtube struct { - APIKey string `env:"YOUTUBE_API_KEY"` - FfmpegPath string `env:"FFMPEG_PATH"` - YtdlpPath string `env:"YTDLP_PATH"` + APIKey string `env:"YOUTUBE_API_KEY"` + FfmpegPath string `env:"FFMPEG_PATH"` + YtdlpPath string `env:"YTDLP_PATH"` CookiesPath string `env:"COOKIES_PATH" env-default:"./cookies.txt"` - Filters Filters + Filters Filters } type YoutubeMusic struct { FfmpegPath string `env:"FFMPEG_PATH"` - YtdlpPath string `env:"YTDLP_PATH"` - Filters Filters + YtdlpPath string `env:"YTDLP_PATH"` + Filters Filters } type Slskd struct { - APIKey string `env:"SLSKD_API_KEY"` - URL string `env:"SLSKD_URL"` - Retry int `env:"SLSKD_RETRY" env-default:"5"` // Number of times to check search status before skipping the track - DownloadAttempts int `env:"SLSKD_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track - SlskdDir string `env:"SLSKD_DIR" env-default:"/slskd/"` - MigrateDL bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from SlskdDir to DownloadDir - Timeout int `env:"SLSKD_TIMEOUT" env-default:"20"` - Filters Filters - MonitorConfig SlskdMon + APIKey string `env:"SLSKD_API_KEY"` + URL string `env:"SLSKD_URL"` + Retry int `env:"SLSKD_RETRY" env-default:"5"` // Number of times to check search status before skipping the track + DownloadAttempts int `env:"SLSKD_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track + SlskdDir string `env:"SLSKD_DIR" env-default:"/slskd/"` + MigrateDL bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from SlskdDir to DownloadDir + Timeout int `env:"SLSKD_TIMEOUT" env-default:"20"` + Filters Filters + MonitorConfig SlskdMon } type SlskdMon struct { @@ -129,26 +136,24 @@ type SlskdMon struct { } type DiscoveryConfig struct { - Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"` + Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"` Listenbrainz Listenbrainz } - type Listenbrainz struct { - Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"` - User string `env:"LISTENBRAINZ_USER"` + Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"` + User string `env:"LISTENBRAINZ_USER"` ImportPlaylist string - SingleArtist bool `env:"SINGLE_ARTIST" env-default:"true"` + SingleArtist bool `env:"SINGLE_ARTIST" env-default:"true"` } -func ReadEnv() { - var cfg Config +func (cfg *Config) ReadEnv() { // Try to read from .env file first err := cleanenv.ReadConfig(cfg.Flags.CfgPath, cfg) if err != nil { // If the error is because the file doesn't exist, fallback to env vars if errors.Is(err, os.ErrNotExist) { - if err := cleanenv.ReadEnv(&cfg); err != nil { + if err := cleanenv.ReadEnv(cfg); err != nil { slog.Error("failed to load config from env vars", "context", err.Error()) os.Exit(1) } @@ -176,7 +181,7 @@ func fixDir(dir string) string { return dir } -func (cfg *Config) HandleDeprecation() { // +func (cfg *Config) HandleDeprecation() { // if cfg.Debug { slog.Warn("'DEBUG' variable is deprecated, please use LOG_LEVEL=DEBUG instead") cfg.LogLevel = "DEBUG" @@ -193,7 +198,7 @@ func (cfg *Config) HandleDeprecation() { // func (cfg *Config) GetPlaylistName() { // Generate playlist name and description toTitle := cases.Title(language.Und) - + playlistName := toTitle.String(cfg.Flags.Playlist) if cfg.Persist { year, week := time.Now().ISOWeek() @@ -208,7 +213,7 @@ func (cfg *Config) GetPlaylistName() { // Generate playlist name and description cfg.ClientCfg.PlaylistName = playlistName if cfg.DownloadCfg.UseSubDir { - // add playlist name to downloadDir so all songs get downloaded to a single sub directory. + // add playlist name to downloadDir so all songs get downloaded to a single sub directory. cfg.DownloadCfg.DownloadDir = fmt.Sprintf("%s%s/", cfg.DownloadCfg.DownloadDir, playlistName) } } diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 23c2c4c..925cc7d 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -39,11 +39,14 @@ func NewDownloader(cfg *cfg.DownloadConfig, httpClient *util.HttpClient, filterL slskdClient.AddHeader() downloader = append(downloader, slskdClient) case "lidarr": - downloader = append(downloader, NewLidarr(cfg.Lidarr, cfg.Discovery, cfg.DownloadDir, httpClient)) + lidarrClient := NewLidarr(cfg.Lidarr, cfg.DownloadDir) + lidarrClient.AddHeader() + downloader = append(downloader, lidarrClient) default: return nil, fmt.Errorf("downloader '%s' not supported", service) } } + return &DownloadClient{ Cfg: cfg, Downloaders: downloader}, nil diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index be93c84..cc21c36 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -4,17 +4,19 @@ import ( "bytes" "encoding/json" "fmt" + "log/slog" "net/url" "strings" "time" + "strconv" cfg "explo/src/config" - "explo/src/debug" "explo/src/models" "explo/src/util" ) type Lidarr struct { + Headers map[string]string DownloadDir string HttpClient *util.HttpClient Cfg cfg.Lidarr @@ -173,22 +175,43 @@ type RootFolder struct { DefaultQualityProfileId int `json:"defaultQualityProfileId"` } -func NewLidarr(cfg cfg.Lidarr, discovery, downloadDir string, httpClient *util.HttpClient) Lidarr { // init downloader cfg for lidarr - return Lidarr{ - DownloadDir: downloadDir, - HttpClient: httpClient, +func NewLidarr(cfg cfg.Lidarr, downloadDir string) *Lidarr { // init downloader cfg for lidarr + return &Lidarr{ Cfg: cfg, + HttpClient: util.NewHttp(util.HttpClientConfig{Timeout: cfg.Timeout}), + DownloadDir: downloadDir, + } +} + +func (c *Lidarr) AddHeader() { + if c.Headers == nil { + c.Headers = make(map[string]string) } + c.Headers["X-API-Key"] = c.Cfg.APIKey } -func (c Lidarr) QueryTrack(track *models.Track) error { +func (c *Lidarr) GetConf() (MonitorConfig, error) { + return MonitorConfig{ + CheckInterval: c.Cfg.MonitorConfig.Interval, + MonitorDuration: c.Cfg.MonitorConfig.Duration, + MigrateDownload: c.Cfg.MigrateDL, + ToDir: c.DownloadDir, + FromDir: c.Cfg.LidarrDir, + Service: "Lidarr", + }, nil +} + +func (c *Lidarr) QueryTrack(track *models.Track) error { + + slog.Debug("querying track", track) + slog.Debug(fmt.Sprintf("looking for track %s by %s on album %s", track.Title, track.Artist, track.Album)) album, err := c.findBestAlbumMatch(track) if err != nil { return err } - queryURL := fmt.Sprintf("%s://%s/api/v1/track?apiKey=%s&artistId=%v&albumId=%v", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey, album.ArtistID, album.Releases[0].AlbumID) + queryURL := fmt.Sprintf("%s/api/v1/track?apiKey=%s&artistId=%v&albumId=%v", c.Cfg.URL, c.Cfg.APIKey, album.ArtistID, album.Releases[0].AlbumID) body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) if err != nil { return fmt.Errorf("failed to check existing tracks: %w", err) @@ -212,12 +235,14 @@ func (c Lidarr) QueryTrack(track *models.Track) error { func (c Lidarr) GetTrack(track *models.Track) error { + slog.Debug(fmt.Sprintf("downloading track %s", track.Title)) + if track.Present { return nil } // Get the defaults from the root dir - queryURL := fmt.Sprintf("%s://%s/api/v1/rootfolder?apiKey=%s", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey) + queryURL := fmt.Sprintf("%s/api/v1/rootfolder?apiKey=%s", c.Cfg.URL, c.Cfg.APIKey) body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) if err != nil { @@ -261,7 +286,7 @@ func (c Lidarr) GetTrack(track *models.Track) error { if err != nil { return fmt.Errorf("marshal error: %w", err) } - queryURL = fmt.Sprintf("%s://%s/api/v1/album?apiKey=%s", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey) + queryURL = fmt.Sprintf("%s/api/v1/album?apiKey=%s", c.Cfg.URL, c.Cfg.APIKey) _, err = c.HttpClient.MakeRequest("POST", queryURL, bytes.NewReader(body), nil) if err != nil { return fmt.Errorf("failed to add album: %w", err) @@ -271,7 +296,7 @@ func (c Lidarr) GetTrack(track *models.Track) error { func (c Lidarr) findBestAlbumMatch(track *models.Track) (*Album, error) { escQuery := url.PathEscape(fmt.Sprintf("%s - %s", track.Album, track.MainArtist)) - queryURL := fmt.Sprintf("%s://%s/api/v1/album/lookup?apiKey=%s&term=%s", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey, escQuery) + queryURL := fmt.Sprintf("%s/api/v1/album/lookup?apiKey=%s&term=%s", c.Cfg.URL, c.Cfg.APIKey, escQuery) body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) if err != nil { @@ -301,73 +326,66 @@ func (c Lidarr) findBestAlbumMatch(track *models.Track) (*Album, error) { return &topMatch, nil } -func (c Lidarr) getDownloadStatus() (DownloadStatus, error) { - reqParams := "/api/v0/transfers/downloads" - body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParams, nil, nil) +func (c *Lidarr) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatus, error) { + req := fmt.Sprintf("/api/v1/queue?apiKey=%s", c.Cfg.APIKey) + + body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+req, nil, nil) if err != nil { return nil, err } - var status DownloadStatus - if err := util.ParseResp(body, &status); err != nil { + var queue LidarrQueue + if err := util.ParseResp(body, &queue); err != nil { return nil, err } - return status, nil -} -func (c Lidarr) MonitorDownloads(tracks []*models.Track) error { - monitorCfg := MonitorConfig{ - CheckInterval: 1 * time.Minute, - MonitorDuration: 15 * time.Minute, - MigrateDownload: c.Cfg.MigrateDL, - FromDir: c.Cfg.LidarrDir, - ToDir: c.DownloadDir, - } - err := Monitor( - tracks, - c.getDownloadStatus, - func(t *models.Track, id string) { c.cleanupTrack(t, id) }, - moveDownload, - monitorCfg, - ) - if err != nil { - return err - } - return nil -} + statuses := make(map[string]FileStatus) -func (c Lidarr) cleanupTrack(track *models.Track, fileID string) { - if err := c.deleteSearch(track.ID); err != nil { - debug.Debug(fmt.Sprintf("[slskd] failed to delete search request: %v", err)) + for _, record := range queue.Records { + // MVP assumption: record.Title matches track.File closely enough + statuses[record.Title] = FileStatus{ + ID: strconv.FormatInt(record.ID, 10), + State: record.Status, + BytesRemaining: int(record.SizeLeft), + BytesTransferred: int(record.Size - record.SizeLeft), + PercentComplete: percent(record.Size, record.SizeLeft), + } } - if err := c.deleteDownload(track.MainArtistID, fileID); err != nil { - debug.Debug(fmt.Sprintf("[slskd] failed to delete download: %v", err)) + + if len(statuses) == 0 { + return nil, fmt.Errorf("no queue items found") } -} -func (c Lidarr) deleteSearch(ID string) error { - reqParams := fmt.Sprintf("/api/v0/searches/%s", ID) + return statuses, nil +} - _, err := c.HttpClient.MakeRequest("DELETE", c.Cfg.URL+reqParams, nil, nil) - if err != nil { - return err +func percent(total, remaining int64) float64 { + if total == 0 { + return 0 } - return nil + return float64(total-remaining) / float64(total) * 100 } -func (c Lidarr) deleteDownload(user, ID string) error { - reqParams := fmt.Sprintf("/api/v0/transfers/downloads/%s/%s", user, ID) +func (c Lidarr) deleteDownload(ID string) error { + reqParams := fmt.Sprintf("/api/v1/queue/%s?apiKey=%s", ID, c.Cfg.APIKey) // cancel download - if _, err := c.HttpClient.MakeRequest("DELETE", c.Cfg.URL+reqParams+"?remove=false", nil, nil); err != nil { - return fmt.Errorf("soft delete failed: %s", err.Error()) + if _, err := c.HttpClient.MakeRequest("DELETE", c.Cfg.URL+reqParams+"/removeFromClient=false", nil, nil); err != nil { + return fmt.Errorf("soft delete failed: %w", err) } time.Sleep(1 * time.Second) // Small buffer between soft and hard delete // delete download - if _, err := c.HttpClient.MakeRequest("DELETE", c.Cfg.URL+reqParams+"?remove=true", nil, nil); err != nil { - return fmt.Errorf("hard delete failed: %s", err.Error()) + if _, err := c.HttpClient.MakeRequest("DELETE", c.Cfg.URL+reqParams+"/removeFromClient=true", nil, nil); err != nil { + return fmt.Errorf("hard delete failed: %w", err) } return nil } + +func (c *Lidarr) Cleanup(track models.Track, fileID string) error { + if err := c.deleteDownload(fileID); err != nil { + slog.Debug(fmt.Sprintf("[lidarr] failed to delete download: %v", err)) + } + return nil +} diff --git a/src/main/main.go b/src/main/main.go index 47bddd8..08e18e1 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -3,8 +3,8 @@ package main import ( "explo/src/debug" "log" - "log/slog" "os" + "log/slog" "explo/src/client" "explo/src/config" @@ -87,4 +87,4 @@ func main() { } else { slog.Info("playlist created successfully", "system", cfg.System, "name", cfg.ClientCfg.PlaylistName) } -} +} \ No newline at end of file diff --git a/src/models/types.go b/src/models/types.go index d9ad005..a992de6 100644 --- a/src/models/types.go +++ b/src/models/types.go @@ -3,17 +3,17 @@ package models // for structs used across the project type Track struct { - Album string - AlbumMBID string - ID string - Artist string // All artists as returned by LB - ArtistMBID string - MainArtist string + Album string + AlbumMBID string + ID string + Artist string // All artists as returned by LB + ArtistMBID string + MainArtist string MainArtistID string - CleanTitle string // Title as returned by LB - Title string // Title as built in listenbrainz.go - File string // File name - Size int // File size - Present bool // is track present in the system or not - Duration int // Track duration in milliseconds (not available for every track) -} + CleanTitle string // Title as returned by LB + Title string // Title as built in listenbrainz.go + File string // File name + Size int // File size + Present bool // is track present in the system or not + Duration int // Track duration in milliseconds (not available for every track) +} \ No newline at end of file From 6300895d65a0445abe6dbbea734f53ea3b8a5005 Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Wed, 24 Dec 2025 00:13:22 -0500 Subject: [PATCH 10/10] Use track title Structure logging --- src/downloader/lidarr.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index cc21c36..78d32d0 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -203,7 +203,11 @@ func (c *Lidarr) GetConf() (MonitorConfig, error) { func (c *Lidarr) QueryTrack(track *models.Track) error { - slog.Debug("querying track", track) + slog.Debug("querying track", + "title", track.Title, + "artist", track.Artist, + "album", track.Album, + ) slog.Debug(fmt.Sprintf("looking for track %s by %s on album %s", track.Title, track.Artist, track.Album)) album, err := c.findBestAlbumMatch(track) @@ -235,8 +239,11 @@ func (c *Lidarr) QueryTrack(track *models.Track) error { func (c Lidarr) GetTrack(track *models.Track) error { - slog.Debug(fmt.Sprintf("downloading track %s", track.Title)) - + slog.Debug("downloading track", + "title", track.Title, + "artist", track.Artist, + "album", track.Album, + ) if track.Present { return nil }