diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2af1eb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +**/.vscode/** 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 791283a..21dfca2 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -59,6 +59,24 @@ type Credentials struct { } + +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 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"` @@ -70,6 +88,7 @@ type DownloadConfig struct { 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 @@ -134,7 +153,7 @@ func (cfg *Config) ReadEnv() { 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) } diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 01ed797..925cc7d 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -38,6 +38,10 @@ func NewDownloader(cfg *cfg.DownloadConfig, httpClient *util.HttpClient, filterL slskdClient := NewSlskd(cfg.Slskd, cfg.DownloadDir) slskdClient.AddHeader() downloader = append(downloader, slskdClient) + case "lidarr": + lidarrClient := NewLidarr(cfg.Lidarr, cfg.DownloadDir) + lidarrClient.AddHeader() + downloader = append(downloader, lidarrClient) default: return nil, fmt.Errorf("downloader '%s' not supported", service) } diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go new file mode 100644 index 0000000..78d32d0 --- /dev/null +++ b/src/downloader/lidarr.go @@ -0,0 +1,398 @@ +package downloader + +import ( + "bytes" + "encoding/json" + "fmt" + "log/slog" + "net/url" + "strings" + "time" + "strconv" + + cfg "explo/src/config" + "explo/src/models" + "explo/src/util" +) + +type Lidarr struct { + Headers map[string]string + DownloadDir string + HttpClient *util.HttpClient + Cfg cfg.Lidarr +} + +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"` +} + +type Ratings struct { + Votes int `json:"votes"` + Value float64 `json:"value"` +} + +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"` +} + +type Media struct { + MediumNumber int `json:"mediumNumber"` + MediumName string `json:"mediumName"` + MediumFormat string `json:"mediumFormat"` +} + +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 +} + +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"` +} + +type LidarrQueue struct { + TotalRecords int `json:"totalRecords"` + Records []LidarrQueueItem `json:"records"` +} + +type LidarrQueueArtist struct { + ForeignArtistID string `json:"foreignArtistId"` + Album LidarrQueueAlbum `json:"album"` +} + +type LidarrQueueAlbum struct { + ForeignAlbumID string `json:"foreignAlbumId"` +} + +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"` +} + +type Image struct { + // can leave empty for now +} + +type AddOptions struct { + SearchForNewAlbum bool `json:"searchForNewAlbum"` +} + +type MinimalArtist struct { + ForeignArtistID string `json:"foreignArtistId"` + QualityProfileID int `json:"qualityProfileId"` + MetadataProfileID int `json:"metadataProfileId"` + Monitored bool `json:"monitored"` + RootFolderPath string `json:"rootFolderPath"` +} + +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 { + Path string `json:"path"` + DefaultMetadataProfileId int `json:"defaultMetadataProfileId"` + DefaultQualityProfileId int `json:"defaultQualityProfileId"` +} + +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) 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", + "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) + if err != nil { + return err + } + + 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) + } + + var lidarrTracks []LidarrTrack + if err = util.ParseResp(body, &lidarrTracks); err != nil { + return fmt.Errorf("failed to unmarshal query lidarr tracks body: %w", err) + } + + for _, t := range lidarrTracks { + if strings.Contains(t.Title, track.Title) { + if t.HasFile { + track.Present = true + } + } + } + + return nil +} + +func (c Lidarr) GetTrack(track *models.Track) error { + + slog.Debug("downloading track", + "title", track.Title, + "artist", track.Artist, + "album", track.Album, + ) + if track.Present { + return nil + } + + // Get the defaults from the root dir + 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 { + 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: %w", err) + } + + if len(rootFolders) == 0 { + return fmt.Errorf("no root folders found in Lidarr") + } + 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, + Monitored: false, + ForeignArtistID: track.ArtistMBID, + RootFolderPath: rootFolder.Path, + }, + AddOptions: AddOptions{ + SearchForNewAlbum: true, + }, + Releases: []Release{album.Releases[0]}, + } + + body, err = json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal error: %w", err) + } + 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) + } + 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/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 { + 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) 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 queue LidarrQueue + if err := util.ParseResp(body, &queue); err != nil { + return nil, err + } + + statuses := make(map[string]FileStatus) + + 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 len(statuses) == 0 { + return nil, fmt.Errorf("no queue items found") + } + + return statuses, nil +} + +func percent(total, remaining int64) float64 { + if total == 0 { + return 0 + } + return float64(total-remaining) / float64(total) * 100 +} + +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+"/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+"/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/models/types.go b/src/models/types.go index 91cf956..a992de6 100644 --- a/src/models/types.go +++ b/src/models/types.go @@ -4,8 +4,10 @@ package models type Track struct { 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