feat(lists): integrate Omegabrr (#1885)

* feat(lists): integrate Omegabrr

* feat(lists): add missing lists index

* feat(lists): add db repo

* feat(lists): add db migrations

* feat(lists): labels

* feat(lists): url lists and more arrs

* fix(lists): db migrations client_id wrong type

* fix(lists): db fields

* feat(lists): create list form wip

* feat(lists): show in list and create

* feat(lists): update and delete

* feat(lists): trigger via webhook

* feat(lists): add webhook handler

* fix(arr): encode json to pointer

* feat(lists): rename endpoint to lists

* feat(lists): fetch tags from arr

* feat(lists): process plaintext lists

* feat(lists): add background refresh job

* run every 6th hour with a random start delay between 1-35 seconds

* feat(lists): refresh on save and improve logging

* feat(lists): cast arr client to pointer

* feat(lists): improve error handling

* feat(lists): reset shows field with match release

* feat(lists): filter opts all lists

* feat(lists): trigger on update if enabled

* feat(lists): update option for lists

* feat(lists): show connected filters in list

* feat(lists): missing listSvc dep

* feat(lists): cleanup

* feat(lists): typo arr list

* feat(lists): radarr include original

* feat(lists): rename ExcludeAlternateTitle to IncludeAlternateTitle

* fix(lists): arr client type conversion to pointer

* fix(actions): only log panic recover if err not nil

* feat(lists): show spinner on save

* feat(lists): show icon in filters list

* feat(lists): change icon color in filters list

* feat(lists): delete relations on filter delete
This commit is contained in:
ze0s 2024-12-25 13:23:37 +01:00 committed by GitHub
parent b68ae334ca
commit 221bc35371
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 5025 additions and 254 deletions

View file

@ -8,8 +8,8 @@ import (
"time"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/pkg/arr/lidarr"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/autobrr/autobrr/pkg/lidarr"
)
func (s *service) lidarr(ctx context.Context, action *domain.Action, release domain.Release) ([]string, error) {
@ -27,7 +27,7 @@ func (s *service) lidarr(ctx context.Context, action *domain.Action, release dom
return nil, errors.New("client %s %s not enabled", client.Type, client.Name)
}
arr := client.Client.(lidarr.Client)
arr := client.Client.(*lidarr.Client)
r := lidarr.Release{
Title: release.TorrentName,

View file

@ -8,8 +8,8 @@ import (
"time"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/pkg/arr/radarr"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/autobrr/autobrr/pkg/radarr"
)
func (s *service) radarr(ctx context.Context, action *domain.Action, release domain.Release) ([]string, error) {
@ -27,7 +27,7 @@ func (s *service) radarr(ctx context.Context, action *domain.Action, release dom
return nil, errors.New("client %s %s not enabled", client.Type, client.Name)
}
arr := client.Client.(radarr.Client)
arr := client.Client.(*radarr.Client)
r := radarr.Release{
Title: release.TorrentName,

View file

@ -8,8 +8,8 @@ import (
"time"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/pkg/arr/readarr"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/autobrr/autobrr/pkg/readarr"
)
func (s *service) readarr(ctx context.Context, action *domain.Action, release domain.Release) ([]string, error) {
@ -27,7 +27,7 @@ func (s *service) readarr(ctx context.Context, action *domain.Action, release do
return nil, errors.New("client %s %s not enabled", client.Type, client.Name)
}
arr := client.Client.(readarr.Client)
arr := client.Client.(*readarr.Client)
r := readarr.Release{
Title: release.TorrentName,

View file

@ -18,17 +18,11 @@ import (
"github.com/autobrr/autobrr/pkg/errors"
)
func (s *service) RunAction(ctx context.Context, action *domain.Action, release *domain.Release) ([]string, error) {
var (
err error
rejections []string
)
func (s *service) RunAction(ctx context.Context, action *domain.Action, release *domain.Release) (rejections []string, err error) {
defer func() {
if r := recover(); r != nil {
s.log.Error().Msgf("recovering from panic in run action %s error: %v", action.Name, r)
err = errors.New("panic in action: %s", action.Name)
return
errors.RecoverPanic(recover(), &err)
if err != nil {
s.log.Error().Err(err).Msgf("recovering from panic in run action %s", action.Name)
}
}()

View file

@ -30,7 +30,7 @@ type Service interface {
DeleteByFilterID(ctx context.Context, filterID int) error
ToggleEnabled(actionID int) error
RunAction(ctx context.Context, action *domain.Action, release *domain.Release) ([]string, error)
RunAction(ctx context.Context, action *domain.Action, release *domain.Release) (rejections []string, err error)
}
type service struct {

View file

@ -8,8 +8,8 @@ import (
"time"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/pkg/arr/sonarr"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/autobrr/autobrr/pkg/sonarr"
)
func (s *service) sonarr(ctx context.Context, action *domain.Action, release domain.Release) ([]string, error) {
@ -27,7 +27,7 @@ func (s *service) sonarr(ctx context.Context, action *domain.Action, release dom
return nil, errors.New("client %s %s not enabled", client.Type, client.Name)
}
arr := client.Client.(sonarr.Client)
arr := client.Client.(*sonarr.Client)
r := sonarr.Release{
Title: release.TorrentName,

View file

@ -52,6 +52,10 @@ func (r *FilterRepo) find(ctx context.Context, params domain.FilterQueryParams)
Where("a.filter_id = f.id").
Where("a.enabled = '1'")
isAutoUpdated := r.db.squirrel.Select("CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END").
From("list_filter lf").
Where("lf.filter_id = f.id")
queryBuilder := r.db.squirrel.
Select(
"f.id",
@ -64,6 +68,7 @@ func (r *FilterRepo) find(ctx context.Context, params domain.FilterQueryParams)
Distinct().
Column(sq.Alias(actionCountQuery, "action_count")).
Column(sq.Alias(actionEnabledCountQuery, "actions_enabled_count")).
Column(sq.Alias(isAutoUpdated, "is_auto_updated")).
LeftJoin("filter_indexer fi ON f.id = fi.filter_id").
LeftJoin("indexer i ON i.id = fi.indexer_id").
From("filter f")
@ -103,7 +108,7 @@ func (r *FilterRepo) find(ctx context.Context, params domain.FilterQueryParams)
for rows.Next() {
var f domain.Filter
if err := rows.Scan(&f.ID, &f.Enabled, &f.Name, &f.Priority, &f.CreatedAt, &f.UpdatedAt, &f.ActionsCount, &f.ActionsEnabledCount); err != nil {
if err := rows.Scan(&f.ID, &f.Enabled, &f.Name, &f.Priority, &f.CreatedAt, &f.UpdatedAt, &f.ActionsCount, &f.ActionsEnabledCount, &f.IsAutoUpdated); err != nil {
return nil, errors.Wrap(err, "error scanning row")
}
@ -1288,6 +1293,13 @@ func (r *FilterRepo) DeleteFilterExternal(ctx context.Context, filterID int) err
}
func (r *FilterRepo) Delete(ctx context.Context, filterID int) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return errors.Wrap(err, "error begin transaction")
}
defer tx.Rollback()
queryBuilder := r.db.squirrel.
Delete("filter").
Where(sq.Eq{"id": filterID})
@ -1297,7 +1309,7 @@ func (r *FilterRepo) Delete(ctx context.Context, filterID int) error {
return errors.Wrap(err, "error building query")
}
result, err := r.db.handler.ExecContext(ctx, query, args...)
result, err := tx.ExecContext(ctx, query, args...)
if err != nil {
return errors.Wrap(err, "error executing query")
}
@ -1308,6 +1320,22 @@ func (r *FilterRepo) Delete(ctx context.Context, filterID int) error {
return domain.ErrRecordNotFound
}
listFilterQueryBuilder := r.db.squirrel.Delete("list_filter").Where(sq.Eq{"filter_id": filterID})
deleteListFilterQuery, deleteListFilterArgs, err := listFilterQueryBuilder.ToSql()
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, deleteListFilterQuery, deleteListFilterArgs...)
if err != nil {
return err
}
if err := tx.Commit(); err != nil {
return errors.Wrap(err, "error storing list and filters")
}
r.log.Debug().Msgf("filter.delete: successfully deleted: %v", filterID)
return nil

456
internal/database/list.go Normal file
View file

@ -0,0 +1,456 @@
package database
import (
"context"
"database/sql"
"time"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/internal/logger"
"github.com/autobrr/autobrr/pkg/errors"
sq "github.com/Masterminds/squirrel"
"github.com/lib/pq"
"github.com/rs/zerolog"
)
type ListRepo struct {
log zerolog.Logger
db *DB
}
func NewListRepo(log logger.Logger, db *DB) domain.ListRepo {
return &ListRepo{
log: log.With().Str("repo", "list").Logger(),
db: db,
}
}
func (r *ListRepo) List(ctx context.Context) ([]*domain.List, error) {
qb := r.db.squirrel.Select(
"id",
"name",
"enabled",
"type",
"client_id",
"url",
"headers",
"api_key",
"match_release",
"tags_included",
"tags_excluded",
"include_unmonitored",
"include_alternate_titles",
"last_refresh_time",
"last_refresh_status",
"last_refresh_data",
"created_at",
"updated_at",
).
From("list").
OrderBy("name ASC")
query, args, err := qb.ToSql()
if err != nil {
return nil, err
}
rows, err := r.db.handler.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
lists := make([]*domain.List, 0)
for rows.Next() {
var list domain.List
var url, apiKey, lastRefreshStatus, lastRefreshData sql.Null[string]
var lastRefreshTime sql.Null[time.Time]
err = rows.Scan(&list.ID, &list.Name, &list.Enabled, &list.Type, &list.ClientID, &url, pq.Array(&list.Headers), &list.APIKey, &list.MatchRelease, pq.Array(&list.TagsInclude), pq.Array(&list.TagsExclude), &list.IncludeUnmonitored, &list.IncludeAlternateTitles, &lastRefreshTime, &lastRefreshStatus, &lastRefreshData, &list.CreatedAt, &list.UpdatedAt)
if err != nil {
return nil, err
}
list.URL = url.V
list.APIKey = apiKey.V
list.LastRefreshTime = lastRefreshTime.V
list.LastRefreshData = lastRefreshData.V
list.LastRefreshStatus = domain.ListRefreshStatus(lastRefreshStatus.V)
list.Filters = make([]domain.ListFilter, 0)
lists = append(lists, &list)
}
return lists, nil
}
func (r *ListRepo) FindByID(ctx context.Context, listID int64) (*domain.List, error) {
qb := r.db.squirrel.Select(
"id",
"name",
"enabled",
"type",
"client_id",
"url",
"headers",
"api_key",
"match_release",
"tags_included",
"tags_excluded",
"include_unmonitored",
"include_alternate_titles",
"last_refresh_time",
"last_refresh_status",
"last_refresh_data",
"created_at",
"updated_at",
).
From("list").
Where(sq.Eq{"id": listID})
query, args, err := qb.ToSql()
if err != nil {
return nil, err
}
row := r.db.handler.QueryRowContext(ctx, query, args...)
if err := row.Err(); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrRecordNotFound
}
return nil, err
}
var list domain.List
var url, apiKey sql.Null[string]
err = row.Scan(&list.ID, &list.Name, &list.Enabled, &list.Type, &list.ClientID, &url, pq.Array(&list.Headers), &list.APIKey, &list.MatchRelease, pq.Array(&list.TagsInclude), pq.Array(&list.TagsExclude), &list.IncludeUnmonitored, &list.IncludeAlternateTitles, &list.LastRefreshTime, &list.LastRefreshStatus, &list.LastRefreshData, &list.CreatedAt, &list.UpdatedAt)
if err != nil {
return nil, err
}
list.URL = url.V
list.APIKey = apiKey.V
return &list, nil
}
func (r *ListRepo) Store(ctx context.Context, list *domain.List) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return errors.Wrap(err, "error begin transaction")
}
defer tx.Rollback()
qb := r.db.squirrel.Insert("list").
Columns(
"name",
"enabled",
"type",
"client_id",
"url",
"headers",
"api_key",
"match_release",
"tags_included",
"tags_excluded",
"include_unmonitored",
"include_alternate_titles",
).
Values(
list.Name,
list.Enabled,
list.Type,
list.ClientID,
list.URL,
pq.Array(list.Headers),
list.APIKey,
list.MatchRelease,
pq.Array(list.TagsInclude),
pq.Array(list.TagsExclude),
list.IncludeUnmonitored,
list.IncludeAlternateTitles,
).Suffix("RETURNING id").RunWith(tx)
//query, args, err := qb.ToSql()
//if err != nil {
// return err
//}
if err := qb.QueryRowContext(ctx).Scan(&list.ID); err != nil {
return err
}
if err := r.StoreListFilterConnection(ctx, tx, list); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return errors.Wrap(err, "error storing list and filters")
}
return nil
}
func (r *ListRepo) Update(ctx context.Context, list *domain.List) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return errors.Wrap(err, "error begin transaction")
}
defer tx.Rollback()
qb := r.db.squirrel.Update("list").
Set("name", list.Name).
Set("enabled", list.Enabled).
Set("type", list.Type).
Set("client_id", list.ClientID).
Set("url", list.URL).
Set("headers", pq.Array(list.Headers)).
Set("api_key", list.APIKey).
Set("match_release", list.MatchRelease).
Set("tags_included", pq.Array(list.TagsInclude)).
Set("tags_excluded", pq.Array(list.TagsExclude)).
Set("include_unmonitored", list.IncludeUnmonitored).
Set("include_alternate_titles", list.IncludeAlternateTitles).
Set("updated_at", sq.Expr("CURRENT_TIMESTAMP")).
Where(sq.Eq{"id": list.ID})
query, args, err := qb.ToSql()
if err != nil {
return err
}
results, err := tx.ExecContext(ctx, query, args...)
if err != nil {
return err
}
if err := r.StoreListFilterConnection(ctx, tx, list); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return errors.Wrap(err, "error updating filter actions")
}
rowsAffected, err := results.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return domain.ErrUpdateFailed
}
return nil
}
func (r *ListRepo) UpdateLastRefresh(ctx context.Context, list *domain.List) error {
qb := r.db.squirrel.Update("list").
Set("last_refresh_time", list.LastRefreshTime).
Set("last_refresh_status", list.LastRefreshStatus).
Set("last_refresh_data", list.LastRefreshData).
Set("updated_at", sq.Expr("CURRENT_TIMESTAMP")).
Where(sq.Eq{"id": list.ID})
query, args, err := qb.ToSql()
if err != nil {
return err
}
results, err := r.db.handler.ExecContext(ctx, query, args...)
if err != nil {
return err
}
rowsAffected, err := results.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return domain.ErrUpdateFailed
}
return nil
}
func (r *ListRepo) Delete(ctx context.Context, listID int64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return errors.Wrap(err, "error begin transaction")
}
defer tx.Rollback()
qb := r.db.squirrel.Delete("list").From("list").Where(sq.Eq{"id": listID})
query, args, err := qb.ToSql()
if err != nil {
return err
}
results, err := tx.ExecContext(ctx, query, args...)
if err != nil {
return err
}
rowsAffected, err := results.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return domain.ErrDeleteFailed
}
listFilterQueryBuilder := r.db.squirrel.Delete("list_filter").Where(sq.Eq{"list_id": listID})
deleteListFilterQuery, deleteListFilterArgs, err := listFilterQueryBuilder.ToSql()
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, deleteListFilterQuery, deleteListFilterArgs...)
if err != nil {
return err
}
if err := tx.Commit(); err != nil {
return errors.Wrap(err, "error storing list and filters")
}
return nil
}
func (r *ListRepo) ToggleEnabled(ctx context.Context, listID int64, enabled bool) error {
qb := r.db.squirrel.Update("list").
Set("enabled", enabled).
Set("updated_at", sq.Expr("CURRENT_TIMESTAMP")).
Where(sq.Eq{"id": listID})
query, args, err := qb.ToSql()
if err != nil {
return err
}
results, err := r.db.handler.ExecContext(ctx, query, args...)
if err != nil {
return err
}
rowsAffected, err := results.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return domain.ErrUpdateFailed
}
return nil
}
func (r *ListRepo) StoreListFilterConnection(ctx context.Context, tx *Tx, list *domain.List) error {
qb := r.db.squirrel.Delete("list_filter").Where(sq.Eq{"list_id": list.ID})
query, args, err := qb.ToSql()
if err != nil {
return err
}
results, err := tx.ExecContext(ctx, query, args...)
if err != nil {
return err
}
rowsAffected, err := results.RowsAffected()
if err != nil {
return err
}
r.log.Trace().Int64("rows_affected", rowsAffected).Msg("deleted list filters")
//if rowsAffected == 0 {
// return domain.ErrUpdateFailed
//}
for _, filter := range list.Filters {
qb := r.db.squirrel.Insert("list_filter").
Columns(
"list_id",
"filter_id",
).
Values(
list.ID,
filter.ID,
)
query, args, err := qb.ToSql()
if err != nil {
return err
}
results, err := tx.ExecContext(ctx, query, args...)
if err != nil {
return err
}
rowsAffected, err := results.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return domain.ErrUpdateFailed
}
}
return nil
}
func (r *ListRepo) GetListFilters(ctx context.Context, listID int64) ([]domain.ListFilter, error) {
qb := r.db.squirrel.Select(
"f.id",
"f.name",
).
From("list_filter lf").
Join(
"filter f ON f.id = lf.filter_id",
).
OrderBy(
"f.name ASC",
).
Where(sq.Eq{"lf.list_id": listID})
query, args, err := qb.ToSql()
if err != nil {
return nil, err
}
rows, err := r.db.handler.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
filters := make([]domain.ListFilter, 0)
for rows.Next() {
var filter domain.ListFilter
err = rows.Scan(&filter.ID, &filter.Name)
if err != nil {
return nil, err
}
filters = append(filters, filter)
}
return filters, nil
}

View file

@ -415,6 +415,38 @@ CREATE TABLE api_key
scopes TEXT [] DEFAULT '{}' NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE list
(
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
enabled BOOLEAN,
type TEXT NOT NULL,
client_id INTEGER,
url TEXT,
headers TEXT [] DEFAULT '{}' NOT NULL,
api_key TEXT,
match_release BOOLEAN,
tags_included TEXT [] DEFAULT '{}' NOT NULL,
tags_excluded TEXT [] DEFAULT '{}' NOT NULL,
include_unmonitored BOOLEAN,
include_alternate_titles BOOLEAN,
last_refresh_time TIMESTAMP,
last_refresh_status TEXT,
last_refresh_data TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE SET NULL
);
CREATE TABLE list_filter
(
list_id INTEGER,
filter_id INTEGER,
FOREIGN KEY (list_id) REFERENCES list(id) ON DELETE CASCADE,
FOREIGN KEY (filter_id) REFERENCES filter(id) ON DELETE CASCADE,
PRIMARY KEY (list_id, filter_id)
);
`
var postgresMigrations = []string{
@ -1002,5 +1034,37 @@ UPDATE irc_network
ALTER TABLE filter
ADD COLUMN announce_types TEXT [] DEFAULT '{}';
`,
`CREATE TABLE list
(
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
enabled BOOLEAN,
type TEXT NOT NULL,
client_id INTEGER,
url TEXT,
headers TEXT [] DEFAULT '{}' NOT NULL,
api_key TEXT,
match_release BOOLEAN,
tags_included TEXT [] DEFAULT '{}' NOT NULL,
tags_excluded TEXT [] DEFAULT '{}' NOT NULL,
include_unmonitored BOOLEAN,
include_alternate_titles BOOLEAN,
last_refresh_time TIMESTAMP,
last_refresh_status TEXT,
last_refresh_data TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE SET NULL
);
CREATE TABLE list_filter
(
list_id INTEGER,
filter_id INTEGER,
FOREIGN KEY (list_id) REFERENCES list(id) ON DELETE CASCADE,
FOREIGN KEY (filter_id) REFERENCES filter(id) ON DELETE CASCADE,
PRIMARY KEY (list_id, filter_id)
);
`,
}

View file

@ -411,6 +411,38 @@ CREATE TABLE api_key
scopes TEXT [] DEFAULT '{}' NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE list
(
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
enabled BOOLEAN,
type TEXT NOT NULL,
client_id INTEGER,
url TEXT,
headers TEXT [] DEFAULT '{}' NOT NULL,
api_key TEXT,
match_release BOOLEAN,
tags_included TEXT [] DEFAULT '{}' NOT NULL,
tags_excluded TEXT [] DEFAULT '{}' NOT NULL,
include_unmonitored BOOLEAN,
include_alternate_titles BOOLEAN,
last_refresh_time TIMESTAMP,
last_refresh_status TEXT,
last_refresh_data TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE SET NULL
);
CREATE TABLE list_filter
(
list_id INTEGER,
filter_id INTEGER,
FOREIGN KEY (list_id) REFERENCES list(id) ON DELETE CASCADE,
FOREIGN KEY (filter_id) REFERENCES filter(id) ON DELETE CASCADE,
PRIMARY KEY (list_id, filter_id)
);
`
var sqliteMigrations = []string{
@ -1644,5 +1676,37 @@ UPDATE irc_network
ALTER TABLE filter
ADD COLUMN announce_types TEXT [] DEFAULT '{}';
`,
`CREATE TABLE list
(
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
enabled BOOLEAN,
type TEXT NOT NULL,
client_id INTEGER,
url TEXT,
headers TEXT [] DEFAULT '{}' NOT NULL,
api_key TEXT,
match_release BOOLEAN,
tags_included TEXT [] DEFAULT '{}' NOT NULL,
tags_excluded TEXT [] DEFAULT '{}' NOT NULL,
include_unmonitored BOOLEAN,
include_alternate_titles BOOLEAN,
last_refresh_time TIMESTAMP,
last_refresh_status TEXT,
last_refresh_data TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE SET NULL
);
CREATE TABLE list_filter
(
list_id INTEGER,
filter_id INTEGER,
FOREIGN KEY (list_id) REFERENCES list(id) ON DELETE CASCADE,
FOREIGN KEY (filter_id) REFERENCES filter(id) ON DELETE CASCADE,
PRIMARY KEY (list_id, filter_id)
);
`,
}

View file

@ -205,3 +205,8 @@ func (c DownloadClient) qbitBuildLegacyHost() (string, error) {
// make into new string and return
return u.String(), nil
}
type ArrTag struct {
ID int `json:"id"`
Label string `json:"label"`
}

View file

@ -164,6 +164,7 @@ type Filter struct {
MaxLeechers int `json:"max_leechers,omitempty"`
ActionsCount int `json:"actions_count"`
ActionsEnabledCount int `json:"actions_enabled_count"`
IsAutoUpdated bool `json:"is_auto_updated"`
Actions []*Action `json:"actions,omitempty"`
External []FilterExternal `json:"external,omitempty"`
Indexers []Indexer `json:"indexers"`

133
internal/domain/list.go Normal file
View file

@ -0,0 +1,133 @@
package domain
import (
"context"
"net/http"
"net/url"
"strings"
"time"
"github.com/autobrr/autobrr/pkg/errors"
)
type ListRepo interface {
List(ctx context.Context) ([]*List, error)
FindByID(ctx context.Context, listID int64) (*List, error)
Store(ctx context.Context, listID *List) error
Update(ctx context.Context, listID *List) error
UpdateLastRefresh(ctx context.Context, list *List) error
ToggleEnabled(ctx context.Context, listID int64, enabled bool) error
Delete(ctx context.Context, listID int64) error
GetListFilters(ctx context.Context, listID int64) ([]ListFilter, error)
}
type ListType string
const (
ListTypeRadarr ListType = "RADARR"
ListTypeSonarr ListType = "SONARR"
ListTypeLidarr ListType = "LIDARR"
ListTypeReadarr ListType = "READARR"
ListTypeWhisparr ListType = "WHISPARR"
ListTypeMDBList ListType = "MDBLIST"
ListTypeMetacritic ListType = "METACRITIC"
ListTypePlaintext ListType = "PLAINTEXT"
ListTypeTrakt ListType = "TRAKT"
ListTypeSteam ListType = "STEAM"
)
type ListRefreshStatus string
const (
ListRefreshStatusSuccess ListRefreshStatus = "SUCCESS"
ListRefreshStatusError ListRefreshStatus = "ERROR"
)
type List struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type ListType `json:"type"`
Enabled bool `json:"enabled"`
ClientID int `json:"client_id"`
URL string `json:"url"`
Headers []string `json:"headers"`
APIKey string `json:"api_key"`
Filters []ListFilter `json:"filters"`
MatchRelease bool `json:"match_release"`
TagsInclude []string `json:"tags_included"`
TagsExclude []string `json:"tags_excluded"`
IncludeUnmonitored bool `json:"include_unmonitored"`
IncludeAlternateTitles bool `json:"include_alternate_titles"`
LastRefreshTime time.Time `json:"last_refresh_time"`
LastRefreshData string `json:"last_refresh_error"`
LastRefreshStatus ListRefreshStatus `json:"last_refresh_status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (l *List) Validate() error {
if l.Name == "" {
return errors.New("name is required")
}
if l.Type == "" {
return errors.New("type is required")
}
if !l.ListTypeArr() && !l.ListTypeList() {
return errors.New("invalid list type: %s", l.Type)
}
if l.ListTypeArr() && l.ClientID == 0 {
return errors.New("arr client id is required")
}
if l.ListTypeList() {
if l.URL == "" {
return errors.New("list url is required")
}
_, err := url.Parse(l.URL)
if err != nil {
return errors.Wrap(err, "could not parse list url: %s", l.URL)
}
}
if len(l.Filters) == 0 {
return errors.New("at least one filter is required")
}
return nil
}
func (l *List) ListTypeArr() bool {
return l.Type == ListTypeRadarr || l.Type == ListTypeSonarr || l.Type == ListTypeLidarr || l.Type == ListTypeReadarr || l.Type == ListTypeWhisparr
}
func (l *List) ListTypeList() bool {
return l.Type == ListTypeMDBList || l.Type == ListTypeMetacritic || l.Type == ListTypePlaintext || l.Type == ListTypeTrakt || l.Type == ListTypeSteam
}
func (l *List) ShouldProcessItem(monitored bool) bool {
if l.IncludeUnmonitored {
return true
}
return monitored
}
// SetRequestHeaders set headers from list on the request
func (l *List) SetRequestHeaders(req *http.Request) {
for _, header := range l.Headers {
parts := strings.Split(header, "=")
if len(parts) != 2 {
continue
}
req.Header.Set(parts[0], parts[1])
}
}
type ListFilter struct {
ID int `json:"id"`
Name string `json:"name"`
}

View file

@ -12,13 +12,13 @@ import (
"time"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/pkg/arr/lidarr"
"github.com/autobrr/autobrr/pkg/arr/radarr"
"github.com/autobrr/autobrr/pkg/arr/readarr"
"github.com/autobrr/autobrr/pkg/arr/sonarr"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/autobrr/autobrr/pkg/lidarr"
"github.com/autobrr/autobrr/pkg/porla"
"github.com/autobrr/autobrr/pkg/radarr"
"github.com/autobrr/autobrr/pkg/readarr"
"github.com/autobrr/autobrr/pkg/sabnzbd"
"github.com/autobrr/autobrr/pkg/sonarr"
"github.com/autobrr/autobrr/pkg/transmission"
"github.com/autobrr/autobrr/pkg/whisparr"

View file

@ -15,13 +15,13 @@ import (
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/internal/logger"
"github.com/autobrr/autobrr/pkg/arr/lidarr"
"github.com/autobrr/autobrr/pkg/arr/radarr"
"github.com/autobrr/autobrr/pkg/arr/readarr"
"github.com/autobrr/autobrr/pkg/arr/sonarr"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/autobrr/autobrr/pkg/lidarr"
"github.com/autobrr/autobrr/pkg/porla"
"github.com/autobrr/autobrr/pkg/radarr"
"github.com/autobrr/autobrr/pkg/readarr"
"github.com/autobrr/autobrr/pkg/sabnzbd"
"github.com/autobrr/autobrr/pkg/sonarr"
"github.com/autobrr/autobrr/pkg/transmission"
"github.com/autobrr/autobrr/pkg/whisparr"
@ -41,6 +41,7 @@ type Service interface {
Delete(ctx context.Context, clientID int32) error
Test(ctx context.Context, client domain.DownloadClient) error
GetArrTags(ctx context.Context, id int32) ([]*domain.ArrTag, error)
GetClient(ctx context.Context, clientId int32) (*domain.DownloadClient, error)
}
@ -94,6 +95,57 @@ func (s *service) FindByID(ctx context.Context, id int32) (*domain.DownloadClien
return client, nil
}
func (s *service) GetArrTags(ctx context.Context, id int32) ([]*domain.ArrTag, error) {
data := make([]*domain.ArrTag, 0)
client, err := s.GetClient(ctx, id)
if err != nil {
s.log.Error().Err(err).Msgf("could not find download client by id: %v", id)
return data, nil
}
switch client.Type {
case "RADARR":
arrClient := client.Client.(*radarr.Client)
tags, err := arrClient.GetTags(ctx)
if err != nil {
s.log.Error().Err(err).Msgf("could not get tags from radarr: %v", id)
return data, nil
}
for _, tag := range tags {
emt := &domain.ArrTag{
ID: tag.ID,
Label: tag.Label,
}
data = append(data, emt)
}
return data, nil
case "SONARR":
arrClient := client.Client.(*sonarr.Client)
tags, err := arrClient.GetTags(ctx)
if err != nil {
s.log.Error().Err(err).Msgf("could not get tags from sonarr: %v", id)
return data, nil
}
for _, tag := range tags {
emt := &domain.ArrTag{
ID: tag.ID,
Label: tag.Label,
}
data = append(data, emt)
}
return data, nil
default:
return data, nil
}
}
func (s *service) Store(ctx context.Context, client *domain.DownloadClient) error {
// basic validation of client
if err := client.Validate(); err != nil {

View file

@ -490,8 +490,7 @@ func (s *service) CheckFilter(ctx context.Context, f *domain.Filter, release *do
// it is necessary to download the torrent file and parse it to make the size
// check. We use the API where available to minimize the number of torrents we
// need to download.
func (s *service) AdditionalSizeCheck(ctx context.Context, f *domain.Filter, release *domain.Release) (bool, error) {
var err error
func (s *service) AdditionalSizeCheck(ctx context.Context, f *domain.Filter, release *domain.Release) (ok bool, err error) {
defer func() {
// try recover panic if anything went wrong with API or size checks
errors.RecoverPanic(recover(), &err)
@ -554,8 +553,7 @@ func (s *service) AdditionalSizeCheck(ctx context.Context, f *domain.Filter, rel
return true, nil
}
func (s *service) AdditionalUploaderCheck(ctx context.Context, f *domain.Filter, release *domain.Release) (bool, error) {
var err error
func (s *service) AdditionalUploaderCheck(ctx context.Context, f *domain.Filter, release *domain.Release) (ok bool, err error) {
defer func() {
// try recover panic if anything went wrong with API or size checks
errors.RecoverPanic(recover(), &err)
@ -634,9 +632,7 @@ func (s *service) CheckSmartEpisodeCanDownload(ctx context.Context, params *doma
return s.releaseRepo.CheckSmartEpisodeCanDownload(ctx, params)
}
func (s *service) RunExternalFilters(ctx context.Context, f *domain.Filter, externalFilters []domain.FilterExternal, release *domain.Release) (bool, error) {
var err error
func (s *service) RunExternalFilters(ctx context.Context, f *domain.Filter, externalFilters []domain.FilterExternal, release *domain.Release) (ok bool, err error) {
defer func() {
// try recover panic if anything went wrong with the external filter checks
errors.RecoverPanic(recover(), &err)

View file

@ -22,6 +22,7 @@ type downloadClientService interface {
Update(ctx context.Context, client *domain.DownloadClient) error
Delete(ctx context.Context, clientID int32) error
Test(ctx context.Context, client domain.DownloadClient) error
GetArrTags(ctx context.Context, id int32) ([]*domain.ArrTag, error)
}
type downloadClientHandler struct {
@ -45,6 +46,8 @@ func (h downloadClientHandler) Routes(r chi.Router) {
r.Route("/{clientID}", func(r chi.Router) {
r.Get("/", h.findByID)
r.Delete("/", h.delete)
r.Get("/arr/tags", h.findArrTagsByID)
})
}
@ -78,6 +81,26 @@ func (h downloadClientHandler) findByID(w http.ResponseWriter, r *http.Request)
h.encoder.StatusResponse(w, http.StatusOK, client)
}
func (h downloadClientHandler) findArrTagsByID(w http.ResponseWriter, r *http.Request) {
clientID, err := strconv.ParseInt(chi.URLParam(r, "clientID"), 10, 32)
if err != nil {
h.encoder.Error(w, err)
return
}
client, err := h.service.GetArrTags(r.Context(), int32(clientID))
if err != nil {
if errors.Is(err, domain.ErrRecordNotFound) {
h.encoder.NotFoundErr(w, errors.New("download client with id %d not found", clientID))
}
h.encoder.Error(w, err)
return
}
h.encoder.StatusResponse(w, http.StatusOK, client)
}
func (h downloadClientHandler) store(w http.ResponseWriter, r *http.Request) {
var data *domain.DownloadClient
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {

124
internal/http/list.go Normal file
View file

@ -0,0 +1,124 @@
package http
import (
"context"
"encoding/json"
"net/http"
"strconv"
"github.com/autobrr/autobrr/internal/domain"
"github.com/go-chi/chi/v5"
)
type listService interface {
Store(ctx context.Context, list *domain.List) error
Update(ctx context.Context, list *domain.List) error
Delete(ctx context.Context, id int64) error
FindByID(ctx context.Context, id int64) (*domain.List, error)
List(ctx context.Context) ([]*domain.List, error)
RefreshList(ctx context.Context, id int64) error
RefreshAll(ctx context.Context) error
RefreshArrLists(ctx context.Context) error
RefreshOtherLists(ctx context.Context) error
}
type listHandler struct {
encoder encoder
listSvc listService
}
func newListHandler(encoder encoder, service listService) *listHandler {
return &listHandler{encoder: encoder, listSvc: service}
}
func (h listHandler) Routes(r chi.Router) {
r.Get("/", h.list)
r.Post("/", h.store)
r.Post("/refresh", h.refreshAll)
r.Route("/{listID}", func(r chi.Router) {
r.Post("/refresh", h.refreshList)
r.Put("/", h.update)
r.Delete("/", h.delete)
})
}
func (h listHandler) list(w http.ResponseWriter, r *http.Request) {
data, err := h.listSvc.List(r.Context())
if err != nil {
h.encoder.Error(w, err)
return
}
h.encoder.StatusResponse(w, http.StatusOK, data)
}
func (h listHandler) store(w http.ResponseWriter, r *http.Request) {
var data *domain.List
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
h.encoder.Error(w, err)
return
}
if err := h.listSvc.Store(r.Context(), data); err != nil {
h.encoder.Error(w, err)
return
}
h.encoder.StatusCreatedData(w, data)
}
func (h listHandler) update(w http.ResponseWriter, r *http.Request) {
var data *domain.List
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
h.encoder.Error(w, err)
return
}
if err := h.listSvc.Update(r.Context(), data); err != nil {
h.encoder.Error(w, err)
return
}
h.encoder.NoContent(w)
}
func (h listHandler) delete(w http.ResponseWriter, r *http.Request) {
listID, err := strconv.Atoi(chi.URLParam(r, "listID"))
if err != nil {
h.encoder.Error(w, err)
return
}
if err := h.listSvc.Delete(r.Context(), int64(listID)); err != nil {
h.encoder.Error(w, err)
return
}
h.encoder.NoContent(w)
}
func (h listHandler) refreshAll(w http.ResponseWriter, r *http.Request) {
if err := h.listSvc.RefreshAll(r.Context()); err != nil {
h.encoder.Error(w, err)
return
}
h.encoder.NoContent(w)
}
func (h listHandler) refreshList(w http.ResponseWriter, r *http.Request) {
listID, err := strconv.Atoi(chi.URLParam(r, "listID"))
if err != nil {
h.encoder.Error(w, err)
return
}
if err := h.listSvc.RefreshList(r.Context(), int64(listID)); err != nil {
h.encoder.Error(w, err)
return
}
h.encoder.NoContent(w)
}

View file

@ -42,13 +42,14 @@ type Server struct {
feedService feedService
indexerService indexerService
ircService ircService
listService listService
notificationService notificationService
proxyService proxyService
releaseService releaseService
updateService updateService
}
func NewServer(log logger.Logger, config *config.AppConfig, sse *sse.Server, db *database.DB, version string, commit string, date string, actionService actionService, apiService apikeyService, authService authService, downloadClientSvc downloadClientService, filterSvc filterService, feedSvc feedService, indexerSvc indexerService, ircSvc ircService, notificationSvc notificationService, proxySvc proxyService, releaseSvc releaseService, updateSvc updateService) Server {
func NewServer(log logger.Logger, config *config.AppConfig, sse *sse.Server, db *database.DB, version string, commit string, date string, actionService actionService, apiService apikeyService, authService authService, downloadClientSvc downloadClientService, filterSvc filterService, feedSvc feedService, indexerSvc indexerService, ircSvc ircService, listSvc listService, notificationSvc notificationService, proxySvc proxyService, releaseSvc releaseService, updateSvc updateService) Server {
return Server{
log: log.With().Str("module", "http").Logger(),
config: config,
@ -68,6 +69,7 @@ func NewServer(log logger.Logger, config *config.AppConfig, sse *sse.Server, db
feedService: feedSvc,
indexerService: indexerSvc,
ircService: ircSvc,
listService: listSvc,
notificationService: notificationSvc,
proxyService: proxySvc,
releaseService: releaseSvc,
@ -142,12 +144,14 @@ func (s Server) Handler() http.Handler {
r.Route("/feeds", newFeedHandler(encoder, s.feedService).Routes)
r.Route("/irc", newIrcHandler(encoder, s.sse, s.ircService).Routes)
r.Route("/indexer", newIndexerHandler(encoder, s.indexerService, s.ircService).Routes)
r.Route("/lists", newListHandler(encoder, s.listService).Routes)
r.Route("/keys", newAPIKeyHandler(encoder, s.apiService).Routes)
r.Route("/logs", newLogsHandler(s.config).Routes)
r.Route("/notification", newNotificationHandler(encoder, s.notificationService).Routes)
r.Route("/proxy", newProxyHandler(encoder, s.proxyService).Routes)
r.Route("/release", newReleaseHandler(encoder, s.releaseService).Routes)
r.Route("/updates", newUpdateHandler(encoder, s.updateService).Routes)
r.Route("/webhook", newWebhookHandler(encoder, s.listService).Routes)
r.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {

71
internal/http/webhook.go Normal file
View file

@ -0,0 +1,71 @@
package http
import (
"context"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
type webhookHandler struct {
encoder encoder
listSvc listService
}
func newWebhookHandler(encoder encoder, listSvc listService) *webhookHandler {
return &webhookHandler{encoder: encoder, listSvc: listSvc}
}
func (h *webhookHandler) Routes(r chi.Router) {
r.Route("/lists", func(r chi.Router) {
r.Post("/trigger", h.refreshAll)
r.Post("/trigger/arr", h.refreshArr)
r.Post("/trigger/lists", h.refreshLists)
r.Get("/trigger", h.refreshAll)
r.Get("/trigger/arr", h.refreshArr)
r.Get("/trigger/lists", h.refreshLists)
r.Post("/trigger/{listID}", h.refreshByID)
})
}
func (h *webhookHandler) refreshAll(w http.ResponseWriter, r *http.Request) {
go h.listSvc.RefreshAll(context.Background())
h.encoder.NoContent(w)
}
func (h *webhookHandler) refreshByID(w http.ResponseWriter, r *http.Request) {
listID, err := strconv.Atoi(chi.URLParam(r, "listID"))
if err != nil {
h.encoder.Error(w, err)
return
}
if err := h.listSvc.RefreshList(context.Background(), int64(listID)); err != nil {
h.encoder.Error(w, err)
return
}
h.encoder.NoContent(w)
}
func (h *webhookHandler) refreshArr(w http.ResponseWriter, r *http.Request) {
if err := h.listSvc.RefreshArrLists(r.Context()); err != nil {
h.encoder.Error(w, err)
return
}
h.encoder.NoContent(w)
}
func (h *webhookHandler) refreshLists(w http.ResponseWriter, r *http.Request) {
if err := h.listSvc.RefreshOtherLists(r.Context()); err != nil {
h.encoder.Error(w, err)
return
}
h.encoder.NoContent(w)
}

View file

@ -0,0 +1,172 @@
package list
import (
"context"
"strings"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/pkg/arr/lidarr"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/rs/zerolog"
)
func (s *service) lidarr(ctx context.Context, list *domain.List) error {
l := s.log.With().Str("list", list.Name).Str("type", "lidarr").Int("client", list.ClientID).Logger()
l.Debug().Msgf("gathering titles...")
titles, artists, err := s.processLidarr(ctx, list, &l)
if err != nil {
return err
}
l.Debug().Msgf("got %d filter titles", len(titles))
// Process titles
var processedTitles []string
for _, title := range titles {
processedTitles = append(processedTitles, processTitle(title, list.MatchRelease)...)
}
if len(processedTitles) == 0 {
l.Debug().Msgf("no titles found to update for list: %v", list.Name)
return nil
}
// Update filter based on MatchRelease
var f domain.FilterUpdate
if list.MatchRelease {
joinedTitles := strings.Join(processedTitles, ",")
if len(joinedTitles) == 0 {
return nil
}
l.Trace().Str("titles", joinedTitles).Msgf("found %d titles", len(joinedTitles))
f.MatchReleases = &joinedTitles
} else {
// Process artists only if MatchRelease is false
var processedArtists []string
for _, artist := range artists {
processedArtists = append(processedArtists, processTitle(artist, list.MatchRelease)...)
}
joinedTitles := strings.Join(processedTitles, ",")
l.Trace().Str("albums", joinedTitles).Msgf("found %d titles", len(joinedTitles))
joinedArtists := strings.Join(processedArtists, ",")
if len(joinedTitles) == 0 && len(joinedArtists) == 0 {
return nil
}
l.Trace().Str("artists", joinedArtists).Msgf("found %d titles", len(joinedArtists))
f.Albums = &joinedTitles
f.Artists = &joinedArtists
}
//joinedTitles := strings.Join(titles, ",")
//
//l.Trace().Msgf("%v", joinedTitles)
//
//if len(joinedTitles) == 0 {
// return nil
//}
for _, filter := range list.Filters {
l.Debug().Msgf("updating filter: %v", filter.ID)
f.ID = filter.ID
if err := s.filterSvc.UpdatePartial(ctx, f); err != nil {
return errors.Wrap(err, "error updating filter: %v", filter.ID)
}
l.Debug().Msgf("successfully updated filter: %v", filter.ID)
}
return nil
}
func (s *service) processLidarr(ctx context.Context, list *domain.List, logger *zerolog.Logger) ([]string, []string, error) {
downloadClient, err := s.downloadClientSvc.GetClient(ctx, int32(list.ClientID))
if err != nil {
return nil, nil, errors.Wrap(err, "could not get client with id %d", list.ClientID)
}
if !downloadClient.Enabled {
return nil, nil, errors.New("client %s %s not enabled", downloadClient.Type, downloadClient.Name)
}
client := downloadClient.Client.(*lidarr.Client)
//var tags []*arr.Tag
//if len(list.TagsExclude) > 0 || len(list.TagsInclude) > 0 {
// t, err := client.GetTags(ctx)
// if err != nil {
// logger.Debug().Msg("could not get tags")
// }
// tags = t
//}
albums, err := client.GetAlbums(ctx, 0)
if err != nil {
return nil, nil, err
}
logger.Debug().Msgf("found %d albums to process", len(albums))
var titles []string
var artists []string
seenArtists := make(map[string]struct{})
for _, album := range albums {
if !list.ShouldProcessItem(album.Monitored) {
continue
}
//if len(list.TagsInclude) > 0 {
// if len(album.Tags) == 0 {
// continue
// }
// if !containsTag(tags, album.Tags, list.TagsInclude) {
// continue
// }
//}
//
//if len(list.TagsExclude) > 0 {
// if containsTag(tags, album.Tags, list.TagsExclude) {
// continue
// }
//}
// Fetch the artist details
artist, err := client.GetArtistByID(ctx, album.ArtistID)
if err != nil {
logger.Error().Err(err).Msgf("Error fetching artist details for album: %v", album.Title)
continue // Skip this album if there's an error fetching the artist
}
if artist.Monitored {
processedTitles := processTitle(album.Title, list.MatchRelease)
titles = append(titles, processedTitles...)
// Debug logging
logger.Debug().Msgf("Processing artist: %s", artist.ArtistName)
if _, exists := seenArtists[artist.ArtistName]; !exists {
artists = append(artists, artist.ArtistName)
seenArtists[artist.ArtistName] = struct{}{}
logger.Debug().Msgf("Added artist: %s", artist.ArtistName) // Log when an artist is added
}
}
}
//sort.Strings(titles)
logger.Debug().Msgf("Processed %d monitored albums with monitored artists, created %d titles, found %d unique artists", len(titles), len(titles), len(artists))
return titles, artists, nil
}

View file

@ -0,0 +1,146 @@
package list
import (
"context"
"sort"
"strings"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/pkg/arr"
"github.com/autobrr/autobrr/pkg/arr/radarr"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/rs/zerolog"
)
func (s *service) radarr(ctx context.Context, list *domain.List) error {
l := s.log.With().Str("list", list.Name).Str("type", "radarr").Int("client", list.ClientID).Logger()
l.Debug().Msgf("gathering titles...")
titles, err := s.processRadarr(ctx, list, &l)
if err != nil {
return err
}
l.Debug().Msgf("got %d filter titles", len(titles))
if len(titles) == 0 {
l.Debug().Msgf("no titles found to update for list: %v", list.Name)
return nil
}
joinedTitles := strings.Join(titles, ",")
l.Trace().Str("titles", joinedTitles).Msgf("found %d titles", len(joinedTitles))
filterUpdate := domain.FilterUpdate{Shows: &joinedTitles}
if list.MatchRelease {
filterUpdate.Shows = &nullString
filterUpdate.MatchReleases = &joinedTitles
}
for _, filter := range list.Filters {
l.Debug().Msgf("updating filter: %v", filter.ID)
filterUpdate.ID = filter.ID
if err := s.filterSvc.UpdatePartial(ctx, filterUpdate); err != nil {
return errors.Wrap(err, "error updating filter: %v", filter.ID)
}
l.Debug().Msgf("successfully updated filter: %v", filter.ID)
}
return nil
}
func (s *service) processRadarr(ctx context.Context, list *domain.List, logger *zerolog.Logger) ([]string, error) {
downloadClient, err := s.downloadClientSvc.GetClient(ctx, int32(list.ClientID))
if err != nil {
return nil, errors.Wrap(err, "could not get client with id %d", list.ClientID)
}
if !downloadClient.Enabled {
return nil, errors.New("client %s %s not enabled", downloadClient.Type, downloadClient.Name)
}
client := downloadClient.Client.(*radarr.Client)
var tags []*arr.Tag
if len(list.TagsExclude) > 0 || len(list.TagsInclude) > 0 {
t, err := client.GetTags(ctx)
if err != nil {
logger.Debug().Msg("could not get tags")
}
tags = t
}
movies, err := client.GetMovies(ctx, 0)
if err != nil {
return nil, err
}
logger.Debug().Msgf("found %d movies to process", len(movies))
titleSet := make(map[string]struct{})
var processedTitles int
for _, movie := range movies {
if !list.ShouldProcessItem(movie.Monitored) {
continue
}
//if !s.shouldProcessItem(movie.Monitored, list) {
// continue
//}
if len(list.TagsInclude) > 0 {
if len(movie.Tags) == 0 {
continue
}
if !containsTag(tags, movie.Tags, list.TagsInclude) {
continue
}
}
if len(list.TagsExclude) > 0 {
if containsTag(tags, movie.Tags, list.TagsExclude) {
continue
}
}
processedTitles++
// Taking the international title and the original title and appending them to the titles array.
for _, title := range []string{movie.Title, movie.OriginalTitle} {
if title != "" {
for _, t := range processTitle(title, list.MatchRelease) {
titleSet[t] = struct{}{}
}
}
}
if list.IncludeAlternateTitles {
for _, title := range movie.AlternateTitles {
altTitles := processTitle(title.Title, list.MatchRelease)
for _, altTitle := range altTitles {
titleSet[altTitle] = struct{}{}
}
}
}
}
uniqueTitles := make([]string, 0, len(titleSet))
for title := range titleSet {
uniqueTitles = append(uniqueTitles, title)
}
sort.Strings(uniqueTitles)
logger.Debug().Msgf("from a total of %d movies we found %d titles and created %d release titles", len(movies), processedTitles, len(uniqueTitles))
return uniqueTitles, nil
}
var nullString = ""

View file

@ -0,0 +1,113 @@
package list
import (
"context"
"sort"
"strings"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/pkg/arr/readarr"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/rs/zerolog"
)
func (s *service) readarr(ctx context.Context, list *domain.List) error {
l := s.log.With().Str("list", list.Name).Str("type", "readarr").Int("client", list.ClientID).Logger()
l.Debug().Msgf("gathering titles...")
titles, err := s.processReadarr(ctx, list, &l)
if err != nil {
return err
}
l.Debug().Msgf("got %d filter titles", len(titles))
if len(titles) == 0 {
l.Debug().Msgf("no titles found to update for list: %v", list.Name)
return nil
}
joinedTitles := strings.Join(titles, ",")
l.Trace().Str("titles", joinedTitles).Msgf("found %d titles", len(joinedTitles))
filterUpdate := domain.FilterUpdate{MatchReleases: &joinedTitles}
for _, filter := range list.Filters {
l.Debug().Msgf("updating filter: %v", filter.ID)
filterUpdate.ID = filter.ID
if err := s.filterSvc.UpdatePartial(ctx, filterUpdate); err != nil {
return errors.Wrap(err, "error updating filter: %v", filter.ID)
}
l.Debug().Msgf("successfully updated filter: %v", filter.ID)
}
return nil
}
func (s *service) processReadarr(ctx context.Context, list *domain.List, logger *zerolog.Logger) ([]string, error) {
downloadClient, err := s.downloadClientSvc.GetClient(ctx, int32(list.ClientID))
if err != nil {
return nil, errors.Wrap(err, "could not get client with id %d", list.ClientID)
}
if !downloadClient.Enabled {
return nil, errors.New("client %s %s not enabled", downloadClient.Type, downloadClient.Name)
}
client := downloadClient.Client.(*readarr.Client)
//var tags []*arr.Tag
//if len(list.TagsExclude) > 0 || len(list.TagsInclude) > 0 {
// t, err := client.GetTags(ctx)
// if err != nil {
// logger.Debug().Msg("could not get tags")
// }
// tags = t
//}
books, err := client.GetBooks(ctx, "")
if err != nil {
return nil, err
}
logger.Debug().Msgf("found %d books to process", len(books))
var titles []string
var processedTitles int
for _, book := range books {
if !list.ShouldProcessItem(book.Monitored) {
continue
}
//if len(list.TagsInclude) > 0 {
// if len(book.Tags) == 0 {
// continue
// }
// if !containsTag(tags, book.Tags, list.TagsInclude) {
// continue
// }
//}
//
//if len(list.TagsExclude) > 0 {
// if containsTag(tags, book.Tags, list.TagsExclude) {
// continue
// }
//}
processedTitles++
titles = append(titles, processTitle(book.Title, list.MatchRelease)...)
}
sort.Strings(titles)
logger.Debug().Msgf("from a total of %d books we found %d titles and created %d release titles", len(books), processedTitles, len(titles))
return titles, nil
}

View file

@ -0,0 +1,147 @@
package list
import (
"context"
"sort"
"strings"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/pkg/arr"
"github.com/autobrr/autobrr/pkg/arr/sonarr"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/rs/zerolog"
)
func (s *service) sonarr(ctx context.Context, list *domain.List) error {
var arrType string
if list.Type == domain.ListTypeWhisparr {
arrType = "whisparr"
} else {
arrType = "sonarr"
}
l := s.log.With().Str("list", list.Name).Str("type", arrType).Int("client", list.ClientID).Logger()
l.Debug().Msgf("gathering titles...")
titles, err := s.processSonarr(ctx, list, &l)
if err != nil {
return err
}
l.Debug().Msgf("got %d filter titles", len(titles))
if len(titles) == 0 {
l.Debug().Msgf("no titles found to update for list: %v", list.Name)
return nil
}
joinedTitles := strings.Join(titles, ",")
l.Trace().Str("titles", joinedTitles).Msgf("found %d titles", len(joinedTitles))
filterUpdate := domain.FilterUpdate{Shows: &joinedTitles}
if list.MatchRelease {
filterUpdate.Shows = &nullString
filterUpdate.MatchReleases = &joinedTitles
}
for _, filter := range list.Filters {
l.Debug().Msgf("updating filter: %v", filter.ID)
filterUpdate.ID = filter.ID
if err := s.filterSvc.UpdatePartial(ctx, filterUpdate); err != nil {
return errors.Wrap(err, "error updating filter: %v", filter.ID)
}
l.Debug().Msgf("successfully updated filter: %v", filter.ID)
}
return nil
}
func (s *service) processSonarr(ctx context.Context, list *domain.List, logger *zerolog.Logger) ([]string, error) {
downloadClient, err := s.downloadClientSvc.GetClient(ctx, int32(list.ClientID))
if err != nil {
return nil, errors.Wrap(err, "could not get client with id %d", list.ClientID)
}
if !downloadClient.Enabled {
return nil, errors.New("client %s %s not enabled", downloadClient.Type, downloadClient.Name)
}
client := downloadClient.Client.(*sonarr.Client)
var tags []*arr.Tag
if len(list.TagsExclude) > 0 || len(list.TagsInclude) > 0 {
t, err := client.GetTags(ctx)
if err != nil {
logger.Debug().Msg("could not get tags")
}
tags = t
}
shows, err := client.GetAllSeries(ctx)
if err != nil {
return nil, err
}
logger.Debug().Msgf("found %d shows to process", len(shows))
titleSet := make(map[string]struct{})
var processedTitles int
for _, show := range shows {
if !list.ShouldProcessItem(show.Monitored) {
continue
}
//if !s.shouldProcessItem(show.Monitored, list) {
// continue
//}
if len(list.TagsInclude) > 0 {
if len(show.Tags) == 0 {
continue
}
if !containsTag(tags, show.Tags, list.TagsInclude) {
continue
}
}
if len(list.TagsExclude) > 0 {
if containsTag(tags, show.Tags, list.TagsExclude) {
continue
}
}
processedTitles++
titles := processTitle(show.Title, list.MatchRelease)
for _, title := range titles {
titleSet[title] = struct{}{}
}
if list.IncludeAlternateTitles {
for _, title := range show.AlternateTitles {
altTitles := processTitle(title.Title, list.MatchRelease)
for _, altTitle := range altTitles {
titleSet[altTitle] = struct{}{}
}
}
}
}
uniqueTitles := make([]string, 0, len(titleSet))
for title := range titleSet {
uniqueTitles = append(uniqueTitles, title)
}
sort.Strings(uniqueTitles)
logger.Debug().Msgf("from a total of %d shows we found %d titles and created %d release titles", len(shows), processedTitles, len(uniqueTitles))
return uniqueTitles, nil
}

View file

@ -0,0 +1,87 @@
package list
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/autobrr/autobrr/internal/domain"
"github.com/pkg/errors"
)
func (s *service) mdblist(ctx context.Context, list *domain.List) error {
l := s.log.With().Str("type", "mdblist").Str("list", list.Name).Logger()
if list.URL == "" {
return errors.New("no URL provided for Mdblist")
}
//var titles []string
//green := color.New(color.FgGreen).SprintFunc()
l.Debug().Msgf("fetching titles from %s", list.URL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, list.URL, nil)
if err != nil {
return errors.Wrapf(err, "could not make new request for URL: %s", list.URL)
}
list.SetRequestHeaders(req)
//setUserAgent(req)
resp, err := s.httpClient.Do(req)
if err != nil {
return errors.Wrapf(err, "failed to fetch titles from URL: %s", list.URL)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.Errorf("failed to fetch titles from URL: %s", list.URL)
}
var data []struct {
Title string `json:"title"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return errors.Wrapf(err, "failed to decode JSON data from URL: %s", list.URL)
}
filterTitles := []string{}
for _, item := range data {
filterTitles = append(filterTitles, processTitle(item.Title, list.MatchRelease)...)
}
if len(filterTitles) == 0 {
l.Debug().Msgf("no titles found to update for list: %v", list.Name)
return nil
}
joinedTitles := strings.Join(filterTitles, ",")
l.Trace().Str("titles", joinedTitles).Msgf("found %d titles", len(joinedTitles))
filterUpdate := domain.FilterUpdate{Shows: &joinedTitles}
if list.MatchRelease {
filterUpdate.Shows = &nullString
filterUpdate.MatchReleases = &joinedTitles
}
for _, filter := range list.Filters {
l.Debug().Msgf("updating filter: %v", filter.ID)
filterUpdate.ID = filter.ID
if err := s.filterSvc.UpdatePartial(ctx, filterUpdate); err != nil {
return errors.Wrapf(err, "error updating filter: %v", filter.ID)
}
l.Debug().Msgf("successfully updated filter: %v", filter.ID)
}
return nil
}

View file

@ -0,0 +1,123 @@
package list
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/autobrr/autobrr/internal/domain"
"github.com/pkg/errors"
)
func (s *service) metacritic(ctx context.Context, list *domain.List) error {
l := s.log.With().Str("type", "metacritic").Str("list", list.Name).Logger()
if list.URL == "" {
return errors.New("no URL provided for metacritic")
}
l.Debug().Msgf("fetching titles from %s", list.URL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, list.URL, nil)
if err != nil {
return errors.Wrapf(err, "could not make new request for URL: %s", list.URL)
}
list.SetRequestHeaders(req)
//setUserAgent(req)
resp, err := s.httpClient.Do(req)
if err != nil {
return errors.Wrapf(err, "failed to fetch titles from URL: %s", list.URL)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return errors.Errorf("No endpoint found at %v. (404 Not Found)", list.URL)
}
if resp.StatusCode != http.StatusOK {
return errors.Wrapf(err, "failed to fetch titles from URL: %s", list.URL)
}
contentType := resp.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "application/json") {
return errors.Wrapf(err, "unexpected content type for URL: %s expected application/json got %s", list.URL, contentType)
}
var data struct {
Title string `json:"title"`
Albums []struct {
Artist string `json:"artist"`
Title string `json:"title"`
} `json:"albums"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return errors.Wrapf(err, "failed to decode JSON data from URL: %s", list.URL)
}
var titles []string
var artists []string
for _, album := range data.Albums {
titles = append(titles, album.Title)
artists = append(artists, album.Artist)
}
// Deduplicate artists
uniqueArtists := []string{}
seenArtists := map[string]struct{}{}
for _, artist := range artists {
if _, ok := seenArtists[artist]; !ok {
uniqueArtists = append(uniqueArtists, artist)
seenArtists[artist] = struct{}{}
}
}
filterTitles := []string{}
for _, title := range titles {
filterTitles = append(filterTitles, processTitle(title, list.MatchRelease)...)
}
filterArtists := []string{}
for _, artist := range uniqueArtists {
filterArtists = append(filterArtists, processTitle(artist, list.MatchRelease)...)
}
if len(filterTitles) == 0 && len(filterArtists) == 0 {
l.Debug().Msgf("no titles found to update filter: %v", list.Name)
return nil
}
joinedArtists := strings.Join(filterArtists, ",")
joinedTitles := strings.Join(filterTitles, ",")
l.Trace().Str("albums", joinedTitles).Msgf("found %d album titles", len(joinedTitles))
l.Trace().Str("artists", joinedTitles).Msgf("found %d artit titles", len(joinedArtists))
filterUpdate := domain.FilterUpdate{Albums: &joinedTitles, Artists: &joinedArtists}
if list.MatchRelease {
filterUpdate.Albums = &nullString
filterUpdate.Artists = &nullString
filterUpdate.MatchReleases = &joinedTitles
}
for _, filter := range list.Filters {
l.Debug().Msgf("updating filter: %v", filter.ID)
filterUpdate.ID = filter.ID
if err := s.filterSvc.UpdatePartial(ctx, filterUpdate); err != nil {
return errors.Wrapf(err, "error updating filter: %v", filter.ID)
}
l.Debug().Msgf("successfully updated filter: %v", filter.ID)
}
return nil
}

View file

@ -0,0 +1,96 @@
package list
import (
"context"
"io"
"net/http"
"strings"
"github.com/autobrr/autobrr/internal/domain"
"github.com/pkg/errors"
)
func (s *service) plaintext(ctx context.Context, list *domain.List) error {
l := s.log.With().Str("type", "plaintext").Str("list", list.Name).Logger()
if list.URL == "" {
return errors.New("no URL provided for plaintext")
}
l.Debug().Msgf("fetching titles from %s", list.URL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, list.URL, nil)
if err != nil {
return errors.Wrapf(err, "could not make new request for URL: %s", list.URL)
}
list.SetRequestHeaders(req)
//setUserAgent(req)
resp, err := s.httpClient.Do(req)
if err != nil {
return errors.Wrapf(err, "failed to fetch titles from URL: %s", list.URL)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.Wrapf(err, "failed to fetch titles from URL: %s", list.URL)
}
contentType := resp.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "text/plain") {
return errors.Wrapf(err, "unexpected content type for URL: %s expected text/plain got %s", list.URL, contentType)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Wrapf(err, "failed to read response body from URL: %s", list.URL)
}
var titles []string
titleLines := strings.Split(string(body), "\n")
for _, titleLine := range titleLines {
title := strings.TrimSpace(titleLine)
if title == "" {
continue
}
titles = append(titles, title)
}
filterTitles := []string{}
for _, title := range titles {
filterTitles = append(filterTitles, processTitle(title, list.MatchRelease)...)
}
if len(filterTitles) == 0 {
l.Debug().Msgf("no titles found to update for list: %v", list.Name)
return nil
}
joinedTitles := strings.Join(filterTitles, ",")
l.Trace().Str("titles", joinedTitles).Msgf("found %d titles", len(joinedTitles))
filterUpdate := domain.FilterUpdate{Shows: &joinedTitles}
if list.MatchRelease {
filterUpdate.Shows = &nullString
filterUpdate.MatchReleases = &joinedTitles
}
for _, filter := range list.Filters {
l.Debug().Msgf("updating filter: %v", filter.ID)
filterUpdate.ID = filter.ID
if err := s.filterSvc.UpdatePartial(ctx, filterUpdate); err != nil {
return errors.Wrapf(err, "error updating filter: %v", filter.ID)
}
l.Debug().Msgf("successfully updated filter: %v", filter.ID)
}
return nil
}

View file

@ -0,0 +1,82 @@
package list
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/autobrr/autobrr/internal/domain"
"github.com/pkg/errors"
)
func (s *service) steam(ctx context.Context, list *domain.List) error {
l := s.log.With().Str("type", "steam").Str("list", list.Name).Logger()
if list.URL == "" {
return errors.New("no URL provided for steam")
}
l.Debug().Msgf("fetching titles from %s", list.URL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, list.URL, nil)
if err != nil {
return errors.Wrapf(err, "could not make new request for URL: %s", list.URL)
}
list.SetRequestHeaders(req)
//setUserAgent(req)
resp, err := s.httpClient.Do(req)
if err != nil {
return errors.Wrapf(err, "failed to fetch titles from URL: %s", list.URL)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.Errorf("failed to fetch titles, non-OK status recieved: %d", resp.StatusCode)
}
var data map[string]struct {
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return errors.Wrapf(err, "failed to decode JSON data from URL: %s", list.URL)
}
var titles []string
for _, item := range data {
titles = append(titles, item.Name)
}
filterTitles := []string{}
for _, title := range titles {
filterTitles = append(filterTitles, processTitle(title, list.MatchRelease)...)
}
if len(filterTitles) == 0 {
l.Debug().Msgf("no titles found for list to update: %v", list.Name)
return nil
}
joinedTitles := strings.Join(filterTitles, ",")
l.Trace().Str("titles", joinedTitles).Msgf("found %d titles", len(joinedTitles))
filterUpdate := domain.FilterUpdate{MatchReleases: &joinedTitles}
for _, filter := range list.Filters {
filterUpdate.ID = filter.ID
if err := s.filterSvc.UpdatePartial(ctx, filterUpdate); err != nil {
return errors.Wrapf(err, "error updating filter: %v", filter.ID)
}
l.Debug().Msgf("successfully updated filter: %v", filter.ID)
}
return nil
}

View file

@ -0,0 +1,118 @@
package list
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/autobrr/autobrr/internal/domain"
"github.com/pkg/errors"
)
func (s *service) trakt(ctx context.Context, list *domain.List) error {
l := s.log.With().Str("type", "trakt").Str("list", list.Name).Logger()
if list.URL == "" {
errMsg := "no URL provided for steam"
l.Error().Msg(errMsg)
return fmt.Errorf(errMsg)
}
l.Debug().Msgf("fetching titles from %s", list.URL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, list.URL, nil)
if err != nil {
l.Error().Err(err).Msg("could not make new request")
return err
}
req.Header.Set("trakt-api-version", "2")
if list.APIKey != "" {
req.Header.Set("trakt-api-key", list.APIKey)
}
list.SetRequestHeaders(req)
//setUserAgent(req)
resp, err := s.httpClient.Do(req)
if err != nil {
l.Error().Err(err).Msgf("failed to fetch titles from URL: %s", list.URL)
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
l.Error().Msgf("failed to fetch titles from URL: %s", list.URL)
return fmt.Errorf("failed to fetch titles from URL: %s", list.URL)
}
contentType := resp.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "application/json") {
errMsg := fmt.Sprintf("invalid content type for URL: %s, content type should be application/json", list.URL)
return fmt.Errorf(errMsg)
}
var data []struct {
Title string `json:"title"`
Movie struct {
Title string `json:"title"`
} `json:"movie"`
Show struct {
Title string `json:"title"`
} `json:"show"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
l.Error().Err(err).Msgf("failed to decode JSON data from URL: %s", list.URL)
return err
}
var titles []string
for _, item := range data {
titles = append(titles, item.Title)
if item.Movie.Title != "" {
titles = append(titles, item.Movie.Title)
}
if item.Show.Title != "" {
titles = append(titles, item.Show.Title)
}
}
filterTitles := []string{}
for _, title := range titles {
filterTitles = append(filterTitles, processTitle(title, list.MatchRelease)...)
}
if len(filterTitles) == 0 {
l.Debug().Msgf("no titles found to update for list: %v", list.Name)
return nil
}
joinedTitles := strings.Join(filterTitles, ",")
l.Trace().Str("titles", joinedTitles).Msgf("found %d titles", len(joinedTitles))
filterUpdate := domain.FilterUpdate{Shows: &joinedTitles}
if list.MatchRelease {
filterUpdate.Shows = &nullString
filterUpdate.MatchReleases = &joinedTitles
}
for _, filter := range list.Filters {
filterUpdate.ID = filter.ID
if err := s.filterSvc.UpdatePartial(ctx, filterUpdate); err != nil {
return errors.Wrapf(err, "error updating filter: %v", filter.ID)
}
l.Debug().Msgf("successfully updated filter: %v", filter.ID)
}
return nil
}

View file

@ -0,0 +1,68 @@
package list
import (
"context"
"math/rand"
"time"
"github.com/robfig/cron/v3"
"github.com/rs/zerolog"
)
type Job interface {
cron.Job
RunE(ctx context.Context) error
}
type RefreshListSvc interface {
RefreshAll(ctx context.Context) error
}
type RefreshListsJob struct {
log zerolog.Logger
listSvc RefreshListSvc
}
func NewRefreshListsJob(log zerolog.Logger, listSvc RefreshListSvc) Job {
return &RefreshListsJob{log: log, listSvc: listSvc}
}
func (job *RefreshListsJob) Run() {
ctx := context.Background()
if err := job.RunE(ctx); err != nil {
job.log.Error().Err(err).Msg("error refreshing lists")
}
}
func (job *RefreshListsJob) RunE(ctx context.Context) error {
if err := job.run(ctx); err != nil {
job.log.Error().Err(err).Msg("error refreshing lists")
return err
}
return nil
}
func (job *RefreshListsJob) run(ctx context.Context) error {
job.log.Debug().Msg("running refresh lists job")
// Seed the random number generator
rand.New(rand.NewSource(time.Now().UnixNano()))
// Generate a random duration between 1 and 35 seconds
delay := time.Duration(rand.Intn(35-1+1)+1) * time.Second // (35-1+1)+1 => range: 1 to 35
job.log.Debug().Msgf("delaying for %v...", delay)
// Sleep for the calculated duration
time.Sleep(delay)
if err := job.listSvc.RefreshAll(ctx); err != nil {
job.log.Error().Err(err).Msg("error refreshing lists")
return err
}
job.log.Debug().Msg("finished refresh lists job")
return nil
}

337
internal/list/service.go Normal file
View file

@ -0,0 +1,337 @@
package list
import (
"context"
stdErr "errors"
"net/http"
"time"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/internal/download_client"
"github.com/autobrr/autobrr/internal/filter"
"github.com/autobrr/autobrr/internal/logger"
"github.com/autobrr/autobrr/internal/scheduler"
"github.com/pkg/errors"
"github.com/rs/zerolog"
)
type Service interface {
List(ctx context.Context) ([]*domain.List, error)
FindByID(ctx context.Context, id int64) (*domain.List, error)
Store(ctx context.Context, list *domain.List) error
Update(ctx context.Context, list *domain.List) error
Delete(ctx context.Context, id int64) error
RefreshAll(ctx context.Context) error
RefreshList(ctx context.Context, listID int64) error
RefreshArrLists(ctx context.Context) error
RefreshOtherLists(ctx context.Context) error
Start()
}
type service struct {
log zerolog.Logger
repo domain.ListRepo
httpClient *http.Client
scheduler scheduler.Service
downloadClientSvc download_client.Service
filterSvc filter.Service
}
func NewService(log logger.Logger, repo domain.ListRepo, downloadClientSvc download_client.Service, filterSvc filter.Service, schedulerSvc scheduler.Service) Service {
return &service{
log: log.With().Str("module", "list").Logger(),
repo: repo,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
downloadClientSvc: downloadClientSvc,
filterSvc: filterSvc,
scheduler: schedulerSvc,
}
}
func (s *service) List(ctx context.Context) ([]*domain.List, error) {
data, err := s.repo.List(ctx)
if err != nil {
return nil, err
}
// attach filters
for _, list := range data {
filters, err := s.repo.GetListFilters(ctx, list.ID)
if err != nil {
return nil, err
}
list.Filters = filters
}
return data, nil
}
func (s *service) FindByID(ctx context.Context, id int64) (*domain.List, error) {
list, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, err
}
// attach filters
filters, err := s.repo.GetListFilters(ctx, list.ID)
if err != nil {
return nil, err
}
list.Filters = filters
return list, nil
}
func (s *service) Store(ctx context.Context, list *domain.List) error {
if err := list.Validate(); err != nil {
s.log.Error().Err(err).Msgf("could not validate list %s", list.Name)
return err
}
if err := s.repo.Store(ctx, list); err != nil {
s.log.Error().Err(err).Msgf("could not store list %s", list.Name)
return err
}
s.log.Debug().Msgf("successfully created list %s", list.Name)
if list.Enabled {
if err := s.refreshList(ctx, list); err != nil {
s.log.Error().Err(err).Msgf("could not refresh list %s", list.Name)
return err
}
}
return nil
}
func (s *service) Update(ctx context.Context, list *domain.List) error {
if err := list.Validate(); err != nil {
s.log.Error().Err(err).Msgf("could not validate list %s", list.Name)
return err
}
if err := s.repo.Update(ctx, list); err != nil {
s.log.Error().Err(err).Msgf("could not update list %s", list.Name)
return err
}
s.log.Debug().Msgf("successfully updated list %s", list.Name)
if list.Enabled {
if err := s.refreshList(ctx, list); err != nil {
s.log.Error().Err(err).Msgf("could not refresh list %s", list.Name)
return err
}
}
return nil
}
func (s *service) Delete(ctx context.Context, id int64) error {
err := s.repo.Delete(ctx, id)
if err != nil {
s.log.Error().Err(err).Msgf("could not delete list by id %d", id)
return err
}
s.log.Debug().Msgf("successfully deleted list %d", id)
return nil
}
func (s *service) RefreshAll(ctx context.Context) error {
lists, err := s.List(ctx)
if err != nil {
return err
}
s.log.Debug().Msgf("found %d lists to refresh", len(lists))
if err := s.refreshAll(ctx, lists); err != nil {
return err
}
s.log.Debug().Msgf("successfully refreshed all lists")
return nil
}
func (s *service) refreshAll(ctx context.Context, lists []*domain.List) error {
var processingErrors []error
for _, listItem := range lists {
if !listItem.Enabled {
s.log.Debug().Msgf("list %s is disabled, skipping...", listItem.Name)
continue
}
if err := s.refreshList(ctx, listItem); err != nil {
s.log.Error().Err(err).Str("type", string(listItem.Type)).Str("list", listItem.Name).Msgf("error while refreshing %s, continuing with other lists", listItem.Type)
processingErrors = append(processingErrors, errors.Wrapf(err, "error while refreshing %s", listItem.Name))
}
}
if len(processingErrors) > 0 {
err := stdErr.Join(processingErrors...)
s.log.Error().Err(err).Msg("Errors encountered during processing Arrs:")
return err
}
return nil
}
func (s *service) refreshList(ctx context.Context, listItem *domain.List) error {
s.log.Debug().Msgf("refresh list %s - %s", listItem.Type, listItem.Name)
var err error
switch listItem.Type {
case domain.ListTypeRadarr:
err = s.radarr(ctx, listItem)
case domain.ListTypeSonarr:
err = s.sonarr(ctx, listItem)
case domain.ListTypeWhisparr:
err = s.sonarr(ctx, listItem)
case domain.ListTypeReadarr:
err = s.readarr(ctx, listItem)
case domain.ListTypeLidarr:
err = s.lidarr(ctx, listItem)
case domain.ListTypeMDBList:
err = s.mdblist(ctx, listItem)
case domain.ListTypeMetacritic:
err = s.metacritic(ctx, listItem)
case domain.ListTypeSteam:
err = s.steam(ctx, listItem)
case domain.ListTypeTrakt:
err = s.trakt(ctx, listItem)
case domain.ListTypePlaintext:
err = s.plaintext(ctx, listItem)
default:
err = errors.Errorf("unsupported list type: %s", listItem.Type)
}
if err != nil {
s.log.Error().Err(err).Str("type", string(listItem.Type)).Str("list", listItem.Name).Msgf("error refreshing %s list", listItem.Name)
// update last run for list and set errs and status
listItem.LastRefreshStatus = domain.ListRefreshStatusError
listItem.LastRefreshData = err.Error()
listItem.LastRefreshTime = time.Now()
if updateErr := s.repo.UpdateLastRefresh(ctx, listItem); updateErr != nil {
s.log.Error().Err(updateErr).Str("type", string(listItem.Type)).Str("list", listItem.Name).Msgf("error updating last refresh for %s list", listItem.Name)
return updateErr
}
return err
}
listItem.LastRefreshStatus = domain.ListRefreshStatusSuccess
//listItem.LastRefreshData = err.Error()
listItem.LastRefreshTime = time.Now()
if updateErr := s.repo.UpdateLastRefresh(ctx, listItem); updateErr != nil {
s.log.Error().Err(updateErr).Str("type", string(listItem.Type)).Str("list", listItem.Name).Msgf("error updating last refresh for %s list", listItem.Name)
return updateErr
}
s.log.Debug().Msgf("successfully refreshed list %s", listItem.Name)
return nil
}
func (s *service) RefreshList(ctx context.Context, listID int64) error {
list, err := s.FindByID(ctx, listID)
if err != nil {
return err
}
if err := s.refreshList(ctx, list); err != nil {
return err
}
return nil
}
func (s *service) RefreshArrLists(ctx context.Context) error {
lists, err := s.List(ctx)
if err != nil {
return err
}
var selectedLists []*domain.List
for _, list := range lists {
if list.ListTypeArr() && list.Enabled {
selectedLists = append(selectedLists, list)
}
}
if err := s.refreshAll(ctx, selectedLists); err != nil {
return err
}
return nil
}
func (s *service) RefreshOtherLists(ctx context.Context) error {
lists, err := s.List(ctx)
if err != nil {
return err
}
var selectedLists []*domain.List
for _, list := range lists {
if list.ListTypeList() && list.Enabled {
selectedLists = append(selectedLists, list)
}
}
if err := s.refreshAll(ctx, selectedLists); err != nil {
return err
}
return nil
}
// scheduleJob start list updater in the background
func (s *service) scheduleJob() error {
identifierKey := "lists-updater"
job := NewRefreshListsJob(s.log.With().Str("job", identifierKey).Logger(), s)
// schedule job to run every 6th hour
id, err := s.scheduler.AddJob(job, "0 */6 * * *", identifierKey)
if err != nil {
return err
}
s.log.Debug().Msgf("scheduled job with id %d", id)
return nil
}
func (s *service) Start() {
if err := s.scheduleJob(); err != nil {
s.log.Error().Err(err).Msg("error while scheduling job")
}
}

28
internal/list/tags.go Normal file
View file

@ -0,0 +1,28 @@
package list
import "github.com/autobrr/autobrr/pkg/arr"
func containsTag(tags []*arr.Tag, titleTags []int, checkTags []string) bool {
tagLabels := []string{}
// match tag id's with labels
for _, movieTag := range titleTags {
for _, tag := range tags {
tag := tag
if movieTag == tag.ID {
tagLabels = append(tagLabels, tag.Label)
}
}
}
// check included tags and set ret to true if we have a match
for _, includeTag := range checkTags {
for _, label := range tagLabels {
if includeTag == label {
return true
}
}
}
return false
}

103
internal/list/title.go Normal file
View file

@ -0,0 +1,103 @@
package list
import (
"fmt"
"regexp"
"strings"
)
// Regex patterns
// https://www.regular-expressions.info/unicode.html#category
// https://www.ncbi.nlm.nih.gov/staff/beck/charents/hex.html
var (
replaceRegexp = regexp.MustCompile(`[\p{P}\p{Z}\x{00C0}-\x{017E}\x{00AE}]`)
questionmarkRegexp = regexp.MustCompile(`[?]{2,}`)
regionCodeRegexp = regexp.MustCompile(`\(.+\)$`)
parenthesesEndRegexp = regexp.MustCompile(`\)$`)
)
func processTitle(title string, matchRelease bool) []string {
// Checking if the title is empty.
if strings.TrimSpace(title) == "" {
return nil
}
// cleans year like (2020) from arr title
//var re = regexp.MustCompile(`(?m)\s(\(\d+\))`)
//title = re.ReplaceAllString(title, "")
t := NewTitleSlice()
if replaceRegexp.ReplaceAllString(title, "") == "" {
t.Add(title, matchRelease)
} else {
// title with all non-alphanumeric characters replaced by "?"
apostropheTitle := parenthesesEndRegexp.ReplaceAllString(title, "?")
apostropheTitle = replaceRegexp.ReplaceAllString(apostropheTitle, "?")
apostropheTitle = questionmarkRegexp.ReplaceAllString(apostropheTitle, "*")
t.Add(apostropheTitle, matchRelease)
t.Add(strings.TrimRight(apostropheTitle, "?* "), matchRelease)
// title with apostrophes removed and all non-alphanumeric characters replaced by "?"
noApostropheTitle := parenthesesEndRegexp.ReplaceAllString(title, "?")
noApostropheTitle = strings.ReplaceAll(noApostropheTitle, "'", "")
noApostropheTitle = replaceRegexp.ReplaceAllString(noApostropheTitle, "?")
noApostropheTitle = questionmarkRegexp.ReplaceAllString(noApostropheTitle, "*")
t.Add(noApostropheTitle, matchRelease)
t.Add(strings.TrimRight(noApostropheTitle, "?* "), matchRelease)
// title with regions in parentheses removed and all non-alphanumeric characters replaced by "?"
removedRegionCodeApostrophe := regionCodeRegexp.ReplaceAllString(title, "")
removedRegionCodeApostrophe = strings.TrimRight(removedRegionCodeApostrophe, " ")
removedRegionCodeApostrophe = replaceRegexp.ReplaceAllString(removedRegionCodeApostrophe, "?")
removedRegionCodeApostrophe = questionmarkRegexp.ReplaceAllString(removedRegionCodeApostrophe, "*")
t.Add(removedRegionCodeApostrophe, matchRelease)
t.Add(strings.TrimRight(removedRegionCodeApostrophe, "?* "), matchRelease)
// title with regions in parentheses and apostrophes removed and all non-alphanumeric characters replaced by "?"
removedRegionCodeNoApostrophe := regionCodeRegexp.ReplaceAllString(title, "")
removedRegionCodeNoApostrophe = strings.TrimRight(removedRegionCodeNoApostrophe, " ")
removedRegionCodeNoApostrophe = strings.ReplaceAll(removedRegionCodeNoApostrophe, "'", "")
removedRegionCodeNoApostrophe = replaceRegexp.ReplaceAllString(removedRegionCodeNoApostrophe, "?")
removedRegionCodeNoApostrophe = questionmarkRegexp.ReplaceAllString(removedRegionCodeNoApostrophe, "*")
t.Add(removedRegionCodeNoApostrophe, matchRelease)
t.Add(strings.TrimRight(removedRegionCodeNoApostrophe, "?* "), matchRelease)
}
return t.Titles()
}
type Titles struct {
tm map[string]struct{}
}
func NewTitleSlice() *Titles {
ts := Titles{
tm: map[string]struct{}{},
}
return &ts
}
func (ts *Titles) Add(title string, matchRelease bool) {
if matchRelease {
title = strings.Trim(title, "?")
title = fmt.Sprintf("*%v*", title)
}
_, ok := ts.tm[title]
if !ok {
ts.tm[title] = struct{}{}
}
}
func (ts *Titles) Titles() []string {
titles := []string{}
for key := range ts.tm {
titles = append(titles, key)
}
return titles
}

View file

@ -12,6 +12,7 @@ import (
"github.com/autobrr/autobrr/internal/feed"
"github.com/autobrr/autobrr/internal/indexer"
"github.com/autobrr/autobrr/internal/irc"
"github.com/autobrr/autobrr/internal/list"
"github.com/autobrr/autobrr/internal/logger"
"github.com/autobrr/autobrr/internal/scheduler"
"github.com/autobrr/autobrr/internal/update"
@ -27,19 +28,21 @@ type Server struct {
ircService irc.Service
feedService feed.Service
scheduler scheduler.Service
listService list.Service
updateService *update.Service
stopWG sync.WaitGroup
lock sync.Mutex
}
func NewServer(log logger.Logger, config *domain.Config, ircSvc irc.Service, indexerSvc indexer.Service, feedSvc feed.Service, scheduler scheduler.Service, updateSvc *update.Service) *Server {
func NewServer(log logger.Logger, config *domain.Config, ircSvc irc.Service, indexerSvc indexer.Service, feedSvc feed.Service, listSvc list.Service, scheduler scheduler.Service, updateSvc *update.Service) *Server {
return &Server{
log: log.With().Str("module", "server").Logger(),
config: config,
indexerService: indexerSvc,
ircService: ircSvc,
feedService: feedSvc,
listService: listSvc,
scheduler: scheduler,
updateService: updateSvc,
}
@ -65,6 +68,9 @@ func (s *Server) Start() error {
s.log.Error().Err(err).Msg("Could not start feed service")
}
// start lists background updater
go s.listService.Start()
return nil
}