Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion examples/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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
26 changes: 25 additions & 1 deletion examples/unpackerr.conf.example
Original file line number Diff line number Diff line change
Expand Up @@ -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. ### #
Expand Down Expand Up @@ -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
11 changes: 11 additions & 0 deletions init/config/definitions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def_order:
- lidarr
- readarr
- whisparr
- sportarr
recommendations:
apps: &APPS
- name: Sonarr
Expand All @@ -29,6 +30,8 @@ recommendations:
value: readarr
- name: Whisparr
value: whisparr
- name: Sportarr
value: sportarr
- name: Folder
value: folder
on_off: &BOOLEAN
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 11 additions & 2 deletions pkg/unpackerr/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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 {
Expand All @@ -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}
Expand All @@ -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}
}
Expand All @@ -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)
}

Expand All @@ -139,6 +145,7 @@ func (u *Unpackerr) validateApps() error {
u.validateRadarr,
u.validateReadarr,
u.validateSonarr,
u.validateSportarr,
u.validateWhisparr,
u.validateFolders,
} {
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions pkg/unpackerr/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ func (u *Unpackerr) logStartupInfo(msg string, externalFiles map[string]string)
}

u.logSonarr()
u.logSportarr()
u.logRadarr()
u.logLidarr()
u.logReadarr()
Expand Down
135 changes: 135 additions & 0 deletions pkg/unpackerr/sportarr.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion pkg/unpackerr/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
Loading