diff --git a/examples/docker-compose.yml b/examples/docker-compose.yml index 89a4a01..c800871 100644 --- a/examples/docker-compose.yml +++ b/examples/docker-compose.yml @@ -100,6 +100,15 @@ services: - UN_WHISPARR_0_DELETE_DELAY=5m - UN_WHISPARR_0_DELETE_ORIG=false - UN_WHISPARR_0_SYNCTHING=false + ## Sportarr Settings + - UN_SPORTARR_0_URL=http://sportarr:1867 + - UN_SPORTARR_0_API_KEY=0123456789abcdef0123456789abcdef + - UN_SPORTARR_0_PATHS_0=/downloads + - UN_SPORTARR_0_PROTOCOLS=torrent,TorrentDownloadProtocol + - UN_SPORTARR_0_TIMEOUT=10s + - UN_SPORTARR_0_DELETE_DELAY=5m + - UN_SPORTARR_0_DELETE_ORIG=false + - UN_SPORTARR_0_SYNCTHING=false ## Watch Folders - UN_FOLDER_0_PATH=/downloads/auto_extract - UN_FOLDER_0_EXTRACT_PATH= @@ -138,4 +147,4 @@ services: - UN_CMDHOOK_0_EXCLUDE_1=lidarr - UN_CMDHOOK_0_TIMEOUT=10s -## => Content Auto Generated, 08 MAR 2026 18:55 UTC +## => Content Auto Generated, 04 AUG 2026 02:53 UTC diff --git a/examples/unpackerr.conf.example b/examples/unpackerr.conf.example index 94f1e62..9c330ac 100644 --- a/examples/unpackerr.conf.example +++ b/examples/unpackerr.conf.example @@ -248,6 +248,30 @@ passwords = [] ## If you use Syncthing, setting this to true will make unpackerr wait for syncs to finish. # syncthing = false +#[[sportarr]] +# url = "http://127.0.0.1:1867" +# api_key = "0123456789abcdef0123456789abcdef" +## List of paths where content is downloaded for this app. +## Used as fallback if the path the Starr app reports does not exist or is not accessible. +# paths = ['/downloads'] +## Protocols to process from the download queue. Starr apps historically +## used "torrent" and "usenet" as the protocol strings, but newer versions +## emit the full class names "TorrentDownloadProtocol" and +## "UsenetDownloadProtocol" instead. List every string your app may return, +## separated by commas. The default covers both the legacy and new torrent +## strings. Add "usenet,UsenetDownloadProtocol" if you use a Usenet client. +# protocols = "torrent,TorrentDownloadProtocol" +## How long to wait for a reply from the backend. +# timeout = "10s" +## How long to wait after import before deleting the extracted items. +# delete_delay = "5m" +## If you use this app with NZB you may wish to delete archives after extraction. +## General recommendation is: do not enable this for torrent use. +## Setting this to true deletes the entire original download folder after import. +# delete_orig = false +## If you use Syncthing, setting this to true will make unpackerr wait for syncs to finish. +# syncthing = false + ################################################################################## ### ### STOP HERE ### STOP HERE ### STOP HERE ### STOP HERE #### STOP HERE ### # ### Only using Starr apps? The things above. The below configs are OPTIONAL. ### # @@ -343,4 +367,4 @@ passwords = [] ## You can adjust how long to wait for the command to run. # timeout = "10s" -## => Content Auto Generated, 08 MAR 2026 18:55 UTC +## => Content Auto Generated, 04 AUG 2026 02:53 UTC diff --git a/init/config/definitions.yml b/init/config/definitions.yml index 27ce515..699c535 100644 --- a/init/config/definitions.yml +++ b/init/config/definitions.yml @@ -17,6 +17,7 @@ def_order: - lidarr - readarr - whisparr + - sportarr recommendations: apps: &APPS - name: Sonarr @@ -29,6 +30,8 @@ recommendations: value: readarr - name: Whisparr value: whisparr + - name: Sportarr + value: sportarr - name: Folder value: folder on_off: &BOOLEAN @@ -194,6 +197,14 @@ defs: url: http://whisparr:6969 examples: url: http://127.0.0.1:6969 + sportarr: + title: Sportarr Settings + prefix: SPORTARR_ + comment: true + docker_example: + url: http://sportarr:1867 + examples: + url: http://127.0.0.1:1867 sections: global: diff --git a/pkg/unpackerr/apps.go b/pkg/unpackerr/apps.go index 572cc51..c8b985b 100644 --- a/pkg/unpackerr/apps.go +++ b/pkg/unpackerr/apps.go @@ -73,6 +73,7 @@ type Config struct { Whisparr []*RadarrConfig `json:"whisparr,omitempty" toml:"whisparr" xml:"whisparr" yaml:"whisparr,omitempty"` Readarr []*ReadarrConfig `json:"readarr,omitempty" toml:"readarr" xml:"readarr" yaml:"readarr,omitempty"` Sonarr []*SonarrConfig `json:"sonarr,omitempty" toml:"sonarr" xml:"sonarr" yaml:"sonarr,omitempty"` + Sportarr []*SonarrConfig `json:"sportarr,omitempty" toml:"sportarr" xml:"sportarr" yaml:"sportarr,omitempty"` Folders []*FolderConfig `json:"folder,omitempty" toml:"folder" xml:"folder" yaml:"folder,omitempty"` Webhook []*WebhookConfig `json:"webhook,omitempty" toml:"webhook" xml:"webhook" yaml:"webhook,omitempty"` Cmdhook []*WebhookConfig `json:"cmdhook,omitempty" toml:"cmdhook" xml:"cmdhook" yaml:"cmdhook,omitempty"` @@ -86,7 +87,7 @@ type FoldersConfig struct { func (u *Unpackerr) watchWorkThread() { // 1 worker for each app, so they poll quickly. - for range len(u.Lidarr) + len(u.Radarr) + len(u.Readarr) + len(u.Sonarr) + len(u.Whisparr) { + for range len(u.Lidarr) + len(u.Radarr) + len(u.Readarr) + len(u.Sonarr) + len(u.Sportarr) + len(u.Whisparr) { go func() { for funcs := range u.workChan { for _, fn := range funcs { @@ -101,7 +102,7 @@ func (u *Unpackerr) watchWorkThread() { // Then calls the check methods to scan their queue contents for changes. func (u *Unpackerr) retrieveAppQueues(now time.Time) { wait := sync.WaitGroup{} - wait.Add(len(u.Lidarr) + len(u.Radarr) + len(u.Readarr) + len(u.Sonarr) + len(u.Whisparr)) + wait.Add(len(u.Lidarr) + len(u.Radarr) + len(u.Readarr) + len(u.Sonarr) + len(u.Sportarr) + len(u.Whisparr)) // Run each app's getQueue method in a go routine as a waitgroup. for _, server := range u.Lidarr { u.workChan <- []func(){func() { u.getLidarrQueue(server, now) }, wait.Done} @@ -119,6 +120,10 @@ func (u *Unpackerr) retrieveAppQueues(now time.Time) { u.workChan <- []func(){func() { u.getSonarrQueue(server, now) }, wait.Done} } + for _, server := range u.Sportarr { + u.workChan <- []func(){func() { u.getSportarrQueue(server, now) }, wait.Done} + } + for _, server := range u.Whisparr { u.workChan <- []func(){func() { u.getWhisparrQueue(server, now) }, wait.Done} } @@ -129,6 +134,7 @@ func (u *Unpackerr) retrieveAppQueues(now time.Time) { u.checkRadarrQueue(now) u.checkReadarrQueue(now) u.checkSonarrQueue(now) + u.checkSportarrQueue(now) u.checkWhisparrQueue(now) } @@ -139,6 +145,7 @@ func (u *Unpackerr) validateApps() error { u.validateRadarr, u.validateReadarr, u.validateSonarr, + u.validateSportarr, u.validateWhisparr, u.validateFolders, } { @@ -169,6 +176,8 @@ func (u *Unpackerr) haveQitem(name string, app starr.App) bool { return u.haveReadarrQitem(name) case starr.Sonarr: return u.haveSonarrQitem(name) + case Sportarr: + return u.haveSportarrQitem(name) case starr.Whisparr: return u.haveWhisparrQitem(name) default: diff --git a/pkg/unpackerr/logs.go b/pkg/unpackerr/logs.go index c7b97a1..aa2f970 100644 --- a/pkg/unpackerr/logs.go +++ b/pkg/unpackerr/logs.go @@ -253,6 +253,7 @@ func (u *Unpackerr) logStartupInfo(msg string, externalFiles map[string]string) } u.logSonarr() + u.logSportarr() u.logRadarr() u.logLidarr() u.logReadarr() diff --git a/pkg/unpackerr/sportarr.go b/pkg/unpackerr/sportarr.go new file mode 100644 index 0000000..71060b5 --- /dev/null +++ b/pkg/unpackerr/sportarr.go @@ -0,0 +1,135 @@ +package unpackerr + +import ( + "errors" + "time" + + "golift.io/starr" + "golift.io/starr/sonarr" +) + +// Sportarr is not part of the starr library. It exposes a Sonarr-v3-compatible +// API, so the starr Sonarr client drives it; this constant names it in logs, +// metrics and webhooks. +const Sportarr starr.App = "Sportarr" + +// SportarrConfig just uses sonarr. + +func (u *Unpackerr) validateSportarr() error { + tmp := u.Sportarr[:0] + + for idx := range u.Sportarr { + if err := u.validateApp(&u.Sportarr[idx].StarrConfig, Sportarr); err != nil { + if errors.Is(err, ErrInvalidURL) { + continue // We ignore these errors, just remove the instance from the list. + } + + return err + } + + // shoehorned into Sonarr! + u.Sportarr[idx].Sonarr = sonarr.New(&u.Sportarr[idx].Config) + tmp = append(tmp, u.Sportarr[idx]) + } + + u.Sportarr = tmp + + return nil +} + +func (u *Unpackerr) logSportarr() { + if count := len(u.Sportarr); count == 1 { + u.Printf(" => Sportarr Config: 1 server: "+starrLogLine, + u.Sportarr[0].URL, u.Sportarr[0].APIKey != "", u.Sportarr[0].Timeout, + u.Sportarr[0].ValidSSL, u.Sportarr[0].Protocols, u.Sportarr[0].Syncthing, + u.Sportarr[0].DeleteOrig, u.Sportarr[0].DeleteDelay.Duration, u.Sportarr[0].Paths) + } else if count != 0 { + u.Printf(" => Sportarr Config: %d servers", count) + + for _, f := range u.Sportarr { + u.Printf(starrLogPfx+starrLogLine, + f.URL, f.APIKey != "", f.Timeout, f.ValidSSL, f.Protocols, + f.Syncthing, f.DeleteOrig, f.DeleteDelay.Duration, f.Paths) + } + } +} + +// getSportarrQueue saves the Sportarr Queue(s). +func (u *Unpackerr) getSportarrQueue(server *SonarrConfig, start time.Time) { + if server.APIKey == "" { + u.Debugf("Sportarr (%s): skipped, no API key", server.URL) + return + } + + queue, err := server.GetQueue(DefaultQueuePageSize, 1) + if err != nil { + u.saveQueueMetrics(0, start, Sportarr, server.URL, err) + return + } + + // Only update if there was not an error fetching. + server.Queue = queue + u.saveQueueMetrics(server.Queue.TotalRecords, start, Sportarr, server.URL, nil) + + if !u.Activity || queue.TotalRecords > 0 { + u.Printf("[Sportarr] Updated (%s): %d Items Queued, %d Retrieved", server.URL, queue.TotalRecords, len(queue.Records)) + } +} + +// checkSportarrQueue saves completed Sportarr-queued downloads to u.Map. +func (u *Unpackerr) checkSportarrQueue(now time.Time) { + for _, server := range u.Sportarr { + if server.Queue == nil { + continue + } + + for _, record := range server.Queue.Records { + switch x, ok := u.Map[record.Title]; { + case ok && x.Status == EXTRACTED && u.isComplete(record.Status, record.Protocol, server.Protocols): + u.Debugf("%s (%s): Item Waiting for Import: %v", Sportarr, server.URL, record.Title) + case !ok && u.isComplete(record.Status, record.Protocol, server.Protocols): + u.Map[record.Title] = &Extract{ + App: Sportarr, + URL: server.URL, + Updated: now, + Status: WAITING, + DeleteOrig: server.DeleteOrig, + DeleteDelay: server.DeleteDelay.Duration, + Syncthing: server.Syncthing, + Path: u.getDownloadPath(record.OutputPath, Sportarr, record.Title, server.Paths), + IDs: map[string]any{ + "title": record.Title, + "downloadId": record.DownloadID, + "seriesId": record.SeriesID, + "episodeId": record.EpisodeID, + "reason": buildStatusReason(record.Status, record.StatusMessages), + }, + } + u.Map[record.Title].XProg = &ExtractProgress{Extract: u.Map[record.Title]} + + fallthrough + default: + u.Debugf("%s (%s): %s (%s:%d%%): %v (Ep: %v)", + Sportarr, server.URL, record.Status, record.Protocol, + percent(record.Sizeleft, record.Size), record.Title, record.EpisodeID) + } + } + } +} + +// checks if the application currently has an item in its queue. +func (u *Unpackerr) haveSportarrQitem(name string) bool { + for _, server := range u.Sportarr { + if server.Queue == nil { + continue + } + + for _, record := range server.Queue.Records { + if record.Title == name { + return true + } + } + } + + return false +} diff --git a/pkg/unpackerr/start.go b/pkg/unpackerr/start.go index 5a42eed..e8124b8 100644 --- a/pkg/unpackerr/start.go +++ b/pkg/unpackerr/start.go @@ -349,7 +349,7 @@ func (u *Unpackerr) Run() { // Only start the queue/totals log timer when at least one app or folder is configured. var logger <-chan time.Time - if len(u.Lidarr)+len(u.Radarr)+len(u.Readarr)+len(u.Sonarr)+len(u.Whisparr)+len(u.Folders) > 0 { + if len(u.Lidarr)+len(u.Radarr)+len(u.Readarr)+len(u.Sonarr)+len(u.Sportarr)+len(u.Whisparr)+len(u.Folders) > 0 { logger = time.NewTicker(u.Config.LogQueues.Duration).C } else { u.Printf("No Starr apps or folders configured. Shut down and add some apps or folders to your config file.")