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

@ -25,6 +25,7 @@ import (
"github.com/autobrr/autobrr/internal/http"
"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/notification"
"github.com/autobrr/autobrr/internal/proxy"
@ -116,6 +117,7 @@ func main() {
feedCacheRepo = database.NewFeedCacheRepo(log, db)
indexerRepo = database.NewIndexerRepo(log, db)
ircRepo = database.NewIrcRepo(log, db)
listRepo = database.NewListRepo(log, db)
notificationRepo = database.NewNotificationRepo(log, db)
releaseRepo = database.NewReleaseRepo(log, db)
userRepo = database.NewUserRepo(log, db)
@ -140,6 +142,7 @@ func main() {
releaseService = release.NewService(log, releaseRepo, actionService, filterService, indexerService)
ircService = irc.NewService(log, serverEvents, ircRepo, releaseService, indexerService, notificationService, proxyService)
feedService = feed.NewService(log, feedRepo, feedCacheRepo, releaseService, proxyService, schedulingService)
listService = list.NewService(log, listRepo, downloadClientService, filterService, schedulingService)
)
// register event subscribers
@ -164,6 +167,7 @@ func main() {
feedService,
indexerService,
ircService,
listService,
notificationService,
proxyService,
releaseService,
@ -175,7 +179,7 @@ func main() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM)
srv := server.NewServer(log, cfg.Config, ircService, indexerService, feedService, schedulingService, updateService)
srv := server.NewServer(log, cfg.Config, ircService, indexerService, feedService, listService, schedulingService, updateService)
if err := srv.Start(); err != nil {
log.Fatal().Stack().Err(err).Msg("could not start server")
return

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
}

View file

@ -15,7 +15,7 @@ import (
"github.com/autobrr/autobrr/pkg/errors"
)
func (c *client) get(ctx context.Context, endpoint string) (int, []byte, error) {
func (c *Client) get(ctx context.Context, endpoint string) (int, []byte, error) {
u, err := url.Parse(c.config.Hostname)
if err != nil {
return 0, nil, errors.Wrap(err, "could not parse url: %s", c.config.Hostname)
@ -26,7 +26,7 @@ func (c *client) get(ctx context.Context, endpoint string) (int, []byte, error)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqUrl, http.NoBody)
if err != nil {
return 0, nil, errors.Wrap(err, "lidarr client request error : %v", reqUrl)
return 0, nil, errors.Wrap(err, "lidarr Client request error : %v", reqUrl)
}
if c.config.BasicAuth {
@ -54,7 +54,47 @@ func (c *client) get(ctx context.Context, endpoint string) (int, []byte, error)
return resp.StatusCode, buf.Bytes(), nil
}
func (c *client) post(ctx context.Context, endpoint string, data interface{}) (*http.Response, error) {
func (c *Client) getJSON(ctx context.Context, endpoint string, params url.Values, data any) error {
u, err := url.Parse(c.config.Hostname)
if err != nil {
return errors.Wrap(err, "could not parse url: %s", c.config.Hostname)
}
u.Path = path.Join(u.Path, "/api/v1/", endpoint)
reqUrl := u.String()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqUrl, http.NoBody)
if err != nil {
return errors.Wrap(err, "could not build request")
}
if c.config.BasicAuth {
req.SetBasicAuth(c.config.Username, c.config.Password)
}
c.setHeaders(req)
req.URL.RawQuery = params.Encode()
resp, err := c.http.Do(req)
if err != nil {
return errors.Wrap(err, "lidarr.http.Do(req): %+v", req)
}
defer resp.Body.Close()
if resp.Body == nil {
return errors.New("response body is nil")
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return errors.Wrap(err, "could not unmarshal data")
}
return nil
}
func (c *Client) post(ctx context.Context, endpoint string, data interface{}) (*http.Response, error) {
u, err := url.Parse(c.config.Hostname)
if err != nil {
return nil, errors.Wrap(err, "could not parse url: %s", c.config.Hostname)
@ -65,12 +105,12 @@ func (c *client) post(ctx context.Context, endpoint string, data interface{}) (*
jsonData, err := json.Marshal(data)
if err != nil {
return nil, errors.Wrap(err, "lidarr client could not marshal data: %v", reqUrl)
return nil, errors.Wrap(err, "lidarr Client could not marshal data: %v", reqUrl)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqUrl, bytes.NewBuffer(jsonData))
if err != nil {
return nil, errors.Wrap(err, "lidarr client request error: %v", reqUrl)
return nil, errors.Wrap(err, "lidarr Client request error: %v", reqUrl)
}
if c.config.BasicAuth {
@ -83,7 +123,7 @@ func (c *client) post(ctx context.Context, endpoint string, data interface{}) (*
res, err := c.http.Do(req)
if err != nil {
return res, errors.Wrap(err, "lidarr client request error: %v", reqUrl)
return res, errors.Wrap(err, "lidarr Client request error: %v", reqUrl)
}
// validate response
@ -97,7 +137,7 @@ func (c *client) post(ctx context.Context, endpoint string, data interface{}) (*
return res, nil
}
func (c *client) postBody(ctx context.Context, endpoint string, data interface{}) (int, []byte, error) {
func (c *Client) postBody(ctx context.Context, endpoint string, data interface{}) (int, []byte, error) {
u, err := url.Parse(c.config.Hostname)
if err != nil {
return 0, nil, errors.Wrap(err, "could not parse url: %s", c.config.Hostname)
@ -108,12 +148,12 @@ func (c *client) postBody(ctx context.Context, endpoint string, data interface{}
jsonData, err := json.Marshal(data)
if err != nil {
return 0, nil, errors.Wrap(err, "lidarr client could not marshal data: %v", reqUrl)
return 0, nil, errors.Wrap(err, "lidarr Client could not marshal data: %v", reqUrl)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqUrl, bytes.NewBuffer(jsonData))
if err != nil {
return 0, nil, errors.Wrap(err, "lidarr client request error: %v", reqUrl)
return 0, nil, errors.Wrap(err, "lidarr Client request error: %v", reqUrl)
}
if c.config.BasicAuth {
@ -147,7 +187,7 @@ func (c *client) postBody(ctx context.Context, endpoint string, data interface{}
return resp.StatusCode, buf.Bytes(), nil
}
func (c *client) setHeaders(req *http.Request) {
func (c *Client) setHeaders(req *http.Request) {
if req.Body != nil {
req.Header.Set("Content-Type", "application/json")
}

View file

@ -6,10 +6,11 @@ package lidarr
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@ -29,26 +30,26 @@ type Config struct {
Log *log.Logger
}
type Client interface {
type ClientInterface interface {
Test(ctx context.Context) (*SystemStatusResponse, error)
Push(ctx context.Context, release Release) ([]string, error)
}
type client struct {
type Client struct {
config Config
http *http.Client
Log *log.Logger
}
// New create new lidarr client
func New(config Config) Client {
// New create new lidarr Client
func New(config Config) *Client {
httpClient := &http.Client{
Timeout: time.Second * 120,
Transport: sharedhttp.Transport,
}
c := &client{
c := &Client{
config: config,
http: httpClient,
Log: log.New(io.Discard, "", log.LstdFlags),
@ -61,47 +62,10 @@ func New(config Config) Client {
return c
}
type Release struct {
Title string `json:"title"`
InfoUrl string `json:"infoUrl,omitempty"`
DownloadUrl string `json:"downloadUrl,omitempty"`
MagnetUrl string `json:"magnetUrl,omitempty"`
Size uint64 `json:"size"`
Indexer string `json:"indexer"`
DownloadProtocol string `json:"downloadProtocol"`
Protocol string `json:"protocol"`
PublishDate string `json:"publishDate"`
DownloadClientId int `json:"downloadClientId,omitempty"`
DownloadClient string `json:"downloadClient,omitempty"`
}
type PushResponse struct {
Approved bool `json:"approved"`
Rejected bool `json:"rejected"`
TempRejected bool `json:"temporarilyRejected"`
Rejections []string `json:"rejections"`
}
type BadRequestResponse struct {
PropertyName string `json:"propertyName"`
ErrorMessage string `json:"errorMessage"`
ErrorCode string `json:"errorCode"`
AttemptedValue string `json:"attemptedValue"`
Severity string `json:"severity"`
}
func (r BadRequestResponse) String() string {
return fmt.Sprintf("[%s: %s] %s: %s - got value: %s", r.Severity, r.ErrorCode, r.PropertyName, r.ErrorMessage, r.AttemptedValue)
}
type SystemStatusResponse struct {
Version string `json:"version"`
}
func (c *client) Test(ctx context.Context) (*SystemStatusResponse, error) {
func (c *Client) Test(ctx context.Context) (*SystemStatusResponse, error) {
status, res, err := c.get(ctx, "system/status")
if err != nil {
return nil, errors.Wrap(err, "lidarr client get error")
return nil, errors.Wrap(err, "lidarr Client get error")
}
if status == http.StatusUnauthorized {
@ -113,16 +77,16 @@ func (c *client) Test(ctx context.Context) (*SystemStatusResponse, error) {
response := SystemStatusResponse{}
err = json.Unmarshal(res, &response)
if err != nil {
return nil, errors.Wrap(err, "lidarr client error json unmarshal")
return nil, errors.Wrap(err, "lidarr Client error json unmarshal")
}
return &response, nil
}
func (c *client) Push(ctx context.Context, release Release) ([]string, error) {
func (c *Client) Push(ctx context.Context, release Release) ([]string, error) {
status, res, err := c.postBody(ctx, "release/push", release)
if err != nil {
return nil, errors.Wrap(err, "lidarr client post error")
return nil, errors.Wrap(err, "lidarr Client post error")
}
c.Log.Printf("lidarr release/push response status: %v body: %v", status, string(res))
@ -143,7 +107,7 @@ func (c *client) Push(ctx context.Context, release Release) ([]string, error) {
pushResponse := PushResponse{}
if err = json.Unmarshal(res, &pushResponse); err != nil {
return nil, errors.Wrap(err, "lidarr client error json unmarshal")
return nil, errors.Wrap(err, "lidarr Client error json unmarshal")
}
// log and return if rejected
@ -156,3 +120,28 @@ func (c *client) Push(ctx context.Context, release Release) ([]string, error) {
return nil, nil
}
func (c *Client) GetAlbums(ctx context.Context, mbID int64) ([]Album, error) {
params := make(url.Values)
if mbID != 0 {
params.Set("ForeignAlbumId", strconv.FormatInt(mbID, 10))
}
data := make([]Album, 0)
err := c.getJSON(ctx, "album", params, &data)
if err != nil {
return nil, errors.Wrap(err, "could not get tags")
}
return data, nil
}
func (c *Client) GetArtistByID(ctx context.Context, artistID int64) (*Artist, error) {
var data Artist
err := c.getJSON(ctx, "artist/"+strconv.FormatInt(artistID, 10), nil, &data)
if err != nil {
return nil, errors.Wrap(err, "could not get tags")
}
return &data, nil
}

150
pkg/arr/lidarr/types.go Normal file
View file

@ -0,0 +1,150 @@
package lidarr
import (
"fmt"
"time"
"github.com/autobrr/autobrr/pkg/arr"
)
type Release struct {
Title string `json:"title"`
InfoUrl string `json:"infoUrl,omitempty"`
DownloadUrl string `json:"downloadUrl,omitempty"`
MagnetUrl string `json:"magnetUrl,omitempty"`
Size uint64 `json:"size"`
Indexer string `json:"indexer"`
DownloadProtocol string `json:"downloadProtocol"`
Protocol string `json:"protocol"`
PublishDate string `json:"publishDate"`
DownloadClientId int `json:"downloadClientId,omitempty"`
DownloadClient string `json:"downloadClient,omitempty"`
}
type PushResponse struct {
Approved bool `json:"approved"`
Rejected bool `json:"rejected"`
TempRejected bool `json:"temporarilyRejected"`
Rejections []string `json:"rejections"`
}
type BadRequestResponse struct {
PropertyName string `json:"propertyName"`
ErrorMessage string `json:"errorMessage"`
ErrorCode string `json:"errorCode"`
AttemptedValue string `json:"attemptedValue"`
Severity string `json:"severity"`
}
func (r BadRequestResponse) String() string {
return fmt.Sprintf("[%s: %s] %s: %s - got value: %s", r.Severity, r.ErrorCode, r.PropertyName, r.ErrorMessage, r.AttemptedValue)
}
type SystemStatusResponse struct {
Version string `json:"version"`
}
type Statistics struct {
AlbumCount int `json:"albumCount,omitempty"`
TrackFileCount int `json:"trackFileCount"`
TrackCount int `json:"trackCount"`
TotalTrackCount int `json:"totalTrackCount"`
SizeOnDisk int `json:"sizeOnDisk"`
PercentOfTracks float64 `json:"percentOfTracks"`
}
type Artist struct {
ID int64 `json:"id"`
Status string `json:"status,omitempty"`
LastInfoSync time.Time `json:"lastInfoSync,omitempty"`
ArtistName string `json:"artistName,omitempty"`
ForeignArtistID string `json:"foreignArtistId,omitempty"`
TadbID int64 `json:"tadbId,omitempty"`
DiscogsID int64 `json:"discogsId,omitempty"`
QualityProfileID int64 `json:"qualityProfileId,omitempty"`
MetadataProfileID int64 `json:"metadataProfileId,omitempty"`
Overview string `json:"overview,omitempty"`
ArtistType string `json:"artistType,omitempty"`
Disambiguation string `json:"disambiguation,omitempty"`
RootFolderPath string `json:"rootFolderPath,omitempty"`
Path string `json:"path,omitempty"`
CleanName string `json:"cleanName,omitempty"`
SortName string `json:"sortName,omitempty"`
Links []*arr.Link `json:"links,omitempty"`
Images []*arr.Image `json:"images,omitempty"`
Genres []string `json:"genres,omitempty"`
Tags []int `json:"tags,omitempty"`
Added time.Time `json:"added,omitempty"`
Ratings *arr.Ratings `json:"ratings,omitempty"`
Statistics *Statistics `json:"statistics,omitempty"`
LastAlbum *Album `json:"lastAlbum,omitempty"`
NextAlbum *Album `json:"nextAlbum,omitempty"`
AddOptions *ArtistAddOptions `json:"addOptions,omitempty"`
AlbumFolder bool `json:"albumFolder,omitempty"`
Monitored bool `json:"monitored"`
Ended bool `json:"ended,omitempty"`
}
type Album struct {
ID int64 `json:"id,omitempty"`
Title string `json:"title"`
Disambiguation string `json:"disambiguation"`
Overview string `json:"overview"`
ArtistID int64 `json:"artistId"`
ForeignAlbumID string `json:"foreignAlbumId"`
ProfileID int64 `json:"profileId"`
Duration int `json:"duration"`
AlbumType string `json:"albumType"`
SecondaryTypes []interface{} `json:"secondaryTypes"`
MediumCount int `json:"mediumCount"`
Ratings *arr.Ratings `json:"ratings"`
ReleaseDate time.Time `json:"releaseDate"`
Releases []*AlbumRelease `json:"releases"`
Genres []interface{} `json:"genres"`
Media []*Media `json:"media"`
Artist *Artist `json:"artist"`
Links []*arr.Link `json:"links"`
Images []*arr.Image `json:"images"`
Statistics *Statistics `json:"statistics"`
RemoteCover string `json:"remoteCover,omitempty"`
AddOptions *AlbumAddOptions `json:"addOptions,omitempty"`
Monitored bool `json:"monitored"`
AnyReleaseOk bool `json:"anyReleaseOk"`
Grabbed bool `json:"grabbed"`
}
// Release is part of an Album.
type AlbumRelease struct {
ID int64 `json:"id"`
AlbumID int64 `json:"albumId"`
ForeignReleaseID string `json:"foreignReleaseId"`
Title string `json:"title"`
Status string `json:"status"`
Duration int `json:"duration"`
TrackCount int `json:"trackCount"`
Media []*Media `json:"media"`
MediumCount int `json:"mediumCount"`
Disambiguation string `json:"disambiguation"`
Country []string `json:"country"`
Label []string `json:"label"`
Format string `json:"format"`
Monitored bool `json:"monitored"`
}
// Media is part of an Album.
type Media struct {
MediumNumber int64 `json:"mediumNumber"`
MediumName string `json:"mediumName"`
MediumFormat string `json:"mediumFormat"`
}
// ArtistAddOptions is part of an artist and an album.
type ArtistAddOptions struct {
Monitor string `json:"monitor,omitempty"`
Monitored bool `json:"monitored,omitempty"`
SearchForMissingAlbums bool `json:"searchForMissingAlbums,omitempty"`
}
type AlbumAddOptions struct {
SearchForNewAlbum bool `json:"searchForNewAlbum,omitempty"`
}

View file

@ -15,7 +15,7 @@ import (
"github.com/autobrr/autobrr/pkg/errors"
)
func (c *client) get(ctx context.Context, endpoint string) (int, []byte, error) {
func (c *Client) get(ctx context.Context, endpoint string) (int, []byte, error) {
u, err := url.Parse(c.config.Hostname)
if err != nil {
return 0, nil, errors.Wrap(err, "could not parse url: %s", c.config.Hostname)
@ -54,7 +54,47 @@ func (c *client) get(ctx context.Context, endpoint string) (int, []byte, error)
return resp.StatusCode, buf.Bytes(), nil
}
func (c *client) post(ctx context.Context, endpoint string, data interface{}) (*http.Response, error) {
func (c *Client) getJSON(ctx context.Context, endpoint string, params url.Values, data any) error {
u, err := url.Parse(c.config.Hostname)
if err != nil {
return errors.Wrap(err, "could not parse url: %s", c.config.Hostname)
}
u.Path = path.Join(u.Path, "/api/v3/", endpoint)
reqUrl := u.String()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqUrl, http.NoBody)
if err != nil {
return errors.Wrap(err, "could not build request")
}
if c.config.BasicAuth {
req.SetBasicAuth(c.config.Username, c.config.Password)
}
c.setHeaders(req)
req.URL.RawQuery = params.Encode()
resp, err := c.http.Do(req)
if err != nil {
return errors.Wrap(err, "radarr.http.Do(req): %+v", req)
}
defer resp.Body.Close()
if resp.Body == nil {
return errors.New("response body is nil")
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return errors.Wrap(err, "could not unmarshal data")
}
return nil
}
func (c *Client) post(ctx context.Context, endpoint string, data interface{}) (*http.Response, error) {
u, err := url.Parse(c.config.Hostname)
if err != nil {
return nil, errors.Wrap(err, "could not parse url: %s", c.config.Hostname)
@ -99,7 +139,7 @@ func (c *client) post(ctx context.Context, endpoint string, data interface{}) (*
return res, nil
}
func (c *client) postBody(ctx context.Context, endpoint string, data interface{}) (int, []byte, error) {
func (c *Client) postBody(ctx context.Context, endpoint string, data interface{}) (int, []byte, error) {
u, err := url.Parse(c.config.Hostname)
if err != nil {
return 0, nil, errors.Wrap(err, "could not parse url: %s", c.config.Hostname)
@ -149,7 +189,7 @@ func (c *client) postBody(ctx context.Context, endpoint string, data interface{}
return resp.StatusCode, buf.Bytes(), nil
}
func (c *client) setHeaders(req *http.Request) {
func (c *Client) setHeaders(req *http.Request) {
if req.Body != nil {
req.Header.Set("Content-Type", "application/json")
}

View file

@ -6,13 +6,15 @@ package radarr
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/autobrr/autobrr/pkg/arr"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/autobrr/autobrr/pkg/sharedhttp"
)
@ -29,25 +31,25 @@ type Config struct {
Log *log.Logger
}
type Client interface {
type ClientInterface interface {
Test(ctx context.Context) (*SystemStatusResponse, error)
Push(ctx context.Context, release Release) ([]string, error)
}
type client struct {
type Client struct {
config Config
http *http.Client
Log *log.Logger
}
func New(config Config) Client {
func New(config Config) *Client {
httpClient := &http.Client{
Timeout: time.Second * 120,
Transport: sharedhttp.Transport,
}
c := &client{
c := &Client{
config: config,
http: httpClient,
Log: log.New(io.Discard, "", log.LstdFlags),
@ -60,44 +62,7 @@ func New(config Config) Client {
return c
}
type Release struct {
Title string `json:"title"`
InfoUrl string `json:"infoUrl,omitempty"`
DownloadUrl string `json:"downloadUrl,omitempty"`
MagnetUrl string `json:"magnetUrl,omitempty"`
Size uint64 `json:"size"`
Indexer string `json:"indexer"`
DownloadProtocol string `json:"downloadProtocol"`
Protocol string `json:"protocol"`
PublishDate string `json:"publishDate"`
DownloadClientId int `json:"downloadClientId,omitempty"`
DownloadClient string `json:"downloadClient,omitempty"`
}
type PushResponse struct {
Approved bool `json:"approved"`
Rejected bool `json:"rejected"`
TempRejected bool `json:"temporarilyRejected"`
Rejections []string `json:"rejections"`
}
type SystemStatusResponse struct {
Version string `json:"version"`
}
type BadRequestResponse struct {
Severity string `json:"severity"`
ErrorCode string `json:"errorCode"`
ErrorMessage string `json:"errorMessage"`
PropertyName string `json:"propertyName"`
AttemptedValue string `json:"attemptedValue"`
}
func (r *BadRequestResponse) String() string {
return fmt.Sprintf("[%s: %s] %s: %s - got value: %s", r.Severity, r.ErrorCode, r.PropertyName, r.ErrorMessage, r.AttemptedValue)
}
func (c *client) Test(ctx context.Context) (*SystemStatusResponse, error) {
func (c *Client) Test(ctx context.Context) (*SystemStatusResponse, error) {
status, res, err := c.get(ctx, "system/status")
if err != nil {
return nil, errors.Wrap(err, "radarr error running test")
@ -117,7 +82,7 @@ func (c *client) Test(ctx context.Context) (*SystemStatusResponse, error) {
return &response, nil
}
func (c *client) Push(ctx context.Context, release Release) ([]string, error) {
func (c *Client) Push(ctx context.Context, release Release) ([]string, error) {
status, res, err := c.postBody(ctx, "release/push", release)
if err != nil {
return nil, errors.Wrap(err, "error push release")
@ -155,3 +120,28 @@ func (c *client) Push(ctx context.Context, release Release) ([]string, error) {
// success true
return nil, nil
}
func (c *Client) GetMovies(ctx context.Context, tmdbID int64) ([]Movie, error) {
params := make(url.Values)
if tmdbID != 0 {
params.Set("tmdbId", strconv.FormatInt(tmdbID, 10))
}
data := make([]Movie, 0)
err := c.getJSON(ctx, "movie", params, &data)
if err != nil {
return nil, errors.Wrap(err, "could not get tags")
}
return data, nil
}
func (c *Client) GetTags(ctx context.Context) ([]*arr.Tag, error) {
data := make([]*arr.Tag, 0)
err := c.getJSON(ctx, "tag", nil, &data)
if err != nil {
return nil, errors.Wrap(err, "could not get tags")
}
return data, nil
}

135
pkg/arr/radarr/types.go Normal file
View file

@ -0,0 +1,135 @@
package radarr
import (
"fmt"
"time"
"github.com/autobrr/autobrr/pkg/arr"
)
type Movie struct {
ID int64 `json:"id"`
Title string `json:"title,omitempty"`
Path string `json:"path,omitempty"`
MinimumAvailability string `json:"minimumAvailability,omitempty"`
QualityProfileID int64 `json:"qualityProfileId,omitempty"`
TmdbID int64 `json:"tmdbId,omitempty"`
OriginalTitle string `json:"originalTitle,omitempty"`
AlternateTitles []*AlternativeTitle `json:"alternateTitles,omitempty"`
SecondaryYearSourceID int `json:"secondaryYearSourceId,omitempty"`
SortTitle string `json:"sortTitle,omitempty"`
SizeOnDisk int64 `json:"sizeOnDisk,omitempty"`
Status string `json:"status,omitempty"`
Overview string `json:"overview,omitempty"`
InCinemas time.Time `json:"inCinemas,omitempty"`
PhysicalRelease time.Time `json:"physicalRelease,omitempty"`
DigitalRelease time.Time `json:"digitalRelease,omitempty"`
Images []*arr.Image `json:"images,omitempty"`
Website string `json:"website,omitempty"`
Year int `json:"year,omitempty"`
YouTubeTrailerID string `json:"youTubeTrailerId,omitempty"`
Studio string `json:"studio,omitempty"`
FolderName string `json:"folderName,omitempty"`
Runtime int `json:"runtime,omitempty"`
CleanTitle string `json:"cleanTitle,omitempty"`
ImdbID string `json:"imdbId,omitempty"`
TitleSlug string `json:"titleSlug,omitempty"`
Certification string `json:"certification,omitempty"`
Genres []string `json:"genres,omitempty"`
Tags []int `json:"tags,omitempty"`
Added time.Time `json:"added,omitempty"`
Ratings *arr.Ratings `json:"ratings,omitempty"`
MovieFile *MovieFile `json:"movieFile,omitempty"`
Collection *Collection `json:"collection,omitempty"`
HasFile bool `json:"hasFile,omitempty"`
IsAvailable bool `json:"isAvailable,omitempty"`
Monitored bool `json:"monitored"`
}
type AlternativeTitle struct {
MovieID int `json:"movieId"`
Title string `json:"title"`
SourceType string `json:"sourceType"`
SourceID int `json:"sourceId"`
Votes int `json:"votes"`
VoteCount int `json:"voteCount"`
Language *arr.Value `json:"language"`
ID int `json:"id"`
}
type MovieFile struct {
ID int64 `json:"id"`
MovieID int64 `json:"movieId"`
RelativePath string `json:"relativePath"`
Path string `json:"path"`
Size int64 `json:"size"`
DateAdded time.Time `json:"dateAdded"`
SceneName string `json:"sceneName"`
IndexerFlags int64 `json:"indexerFlags"`
Quality *arr.Quality `json:"quality"`
MediaInfo *MediaInfo `json:"mediaInfo"`
QualityCutoffNotMet bool `json:"qualityCutoffNotMet"`
Languages []*arr.Value `json:"languages"`
ReleaseGroup string `json:"releaseGroup"`
Edition string `json:"edition"`
}
type MediaInfo struct {
AudioAdditionalFeatures string `json:"audioAdditionalFeatures"`
AudioBitrate int `json:"audioBitrate"`
AudioChannels float64 `json:"audioChannels"`
AudioCodec string `json:"audioCodec"`
AudioLanguages string `json:"audioLanguages"`
AudioStreamCount int `json:"audioStreamCount"`
VideoBitDepth int `json:"videoBitDepth"`
VideoBitrate int `json:"videoBitrate"`
VideoCodec string `json:"videoCodec"`
VideoFps float64 `json:"videoFps"`
Resolution string `json:"resolution"`
RunTime string `json:"runTime"`
ScanType string `json:"scanType"`
Subtitles string `json:"subtitles"`
}
type Collection struct {
Name string `json:"name"`
TmdbID int64 `json:"tmdbId"`
Images []*arr.Image `json:"images"`
}
type Release struct {
Title string `json:"title"`
InfoUrl string `json:"infoUrl,omitempty"`
DownloadUrl string `json:"downloadUrl,omitempty"`
MagnetUrl string `json:"magnetUrl,omitempty"`
Size uint64 `json:"size"`
Indexer string `json:"indexer"`
DownloadProtocol string `json:"downloadProtocol"`
Protocol string `json:"protocol"`
PublishDate string `json:"publishDate"`
DownloadClientId int `json:"downloadClientId,omitempty"`
DownloadClient string `json:"downloadClient,omitempty"`
}
type PushResponse struct {
Approved bool `json:"approved"`
Rejected bool `json:"rejected"`
TempRejected bool `json:"temporarilyRejected"`
Rejections []string `json:"rejections"`
}
type SystemStatusResponse struct {
Version string `json:"version"`
}
type BadRequestResponse struct {
Severity string `json:"severity"`
ErrorCode string `json:"errorCode"`
ErrorMessage string `json:"errorMessage"`
PropertyName string `json:"propertyName"`
AttemptedValue string `json:"attemptedValue"`
}
func (r *BadRequestResponse) String() string {
return fmt.Sprintf("[%s: %s] %s: %s - got value: %s", r.Severity, r.ErrorCode, r.PropertyName, r.ErrorMessage, r.AttemptedValue)
}

View file

@ -15,7 +15,7 @@ import (
"github.com/autobrr/autobrr/pkg/errors"
)
func (c *client) get(ctx context.Context, endpoint string) (int, []byte, error) {
func (c *Client) get(ctx context.Context, endpoint string) (int, []byte, error) {
u, err := url.Parse(c.config.Hostname)
if err != nil {
return 0, nil, errors.Wrap(err, "could not parse url: %s", c.config.Hostname)
@ -54,7 +54,47 @@ func (c *client) get(ctx context.Context, endpoint string) (int, []byte, error)
return resp.StatusCode, buf.Bytes(), nil
}
func (c *client) post(ctx context.Context, endpoint string, data interface{}) (*http.Response, error) {
func (c *Client) getJSON(ctx context.Context, endpoint string, params url.Values, data any) error {
u, err := url.Parse(c.config.Hostname)
if err != nil {
return errors.Wrap(err, "could not parse url: %s", c.config.Hostname)
}
u.Path = path.Join(u.Path, "/api/v1/", endpoint)
reqUrl := u.String()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqUrl, http.NoBody)
if err != nil {
return errors.Wrap(err, "could not build request")
}
if c.config.BasicAuth {
req.SetBasicAuth(c.config.Username, c.config.Password)
}
c.setHeaders(req)
req.URL.RawQuery = params.Encode()
resp, err := c.http.Do(req)
if err != nil {
return errors.Wrap(err, "readarr.http.Do(req): %+v", req)
}
defer resp.Body.Close()
if resp.Body == nil {
return errors.New("response body is nil")
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return errors.Wrap(err, "could not unmarshal data")
}
return nil
}
func (c *Client) post(ctx context.Context, endpoint string, data interface{}) (*http.Response, error) {
u, err := url.Parse(c.config.Hostname)
if err != nil {
return nil, errors.Wrap(err, "could not parse url: %s", c.config.Hostname)
@ -97,7 +137,7 @@ func (c *client) post(ctx context.Context, endpoint string, data interface{}) (*
return res, nil
}
func (c *client) postBody(ctx context.Context, endpoint string, data interface{}) (int, []byte, error) {
func (c *Client) postBody(ctx context.Context, endpoint string, data interface{}) (int, []byte, error) {
u, err := url.Parse(c.config.Hostname)
if err != nil {
return 0, nil, errors.Wrap(err, "could not parse url: %s", c.config.Hostname)
@ -149,7 +189,7 @@ func (c *client) postBody(ctx context.Context, endpoint string, data interface{}
return resp.StatusCode, buf.Bytes(), nil
}
func (c *client) setHeaders(req *http.Request) {
func (c *Client) setHeaders(req *http.Request) {
if req.Body != nil {
req.Header.Set("Content-Type", "application/json")
}

View file

@ -6,14 +6,15 @@ package readarr
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"log"
"github.com/autobrr/autobrr/pkg/arr"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/autobrr/autobrr/pkg/sharedhttp"
)
@ -30,26 +31,26 @@ type Config struct {
Log *log.Logger
}
type Client interface {
type ClientInterface interface {
Test(ctx context.Context) (*SystemStatusResponse, error)
Push(ctx context.Context, release Release) ([]string, error)
}
type client struct {
type Client struct {
config Config
http *http.Client
Log *log.Logger
}
// New create new readarr client
func New(config Config) Client {
// New create new readarr Client
func New(config Config) *Client {
httpClient := &http.Client{
Timeout: time.Second * 120,
Transport: sharedhttp.Transport,
}
c := &client{
c := &Client{
config: config,
http: httpClient,
Log: log.New(io.Discard, "", log.LstdFlags),
@ -62,45 +63,7 @@ func New(config Config) Client {
return c
}
type Release struct {
Title string `json:"title"`
InfoUrl string `json:"infoUrl,omitempty"`
DownloadUrl string `json:"downloadUrl,omitempty"`
MagnetUrl string `json:"magnetUrl,omitempty"`
Size uint64 `json:"size"`
Indexer string `json:"indexer"`
DownloadProtocol string `json:"downloadProtocol"`
Protocol string `json:"protocol"`
PublishDate string `json:"publishDate"`
DownloadClientId int `json:"downloadClientId,omitempty"`
DownloadClient string `json:"downloadClient,omitempty"`
}
type PushResponse struct {
Approved bool `json:"approved"`
Rejected bool `json:"rejected"`
TempRejected bool `json:"temporarilyRejected"`
Rejections []string `json:"rejections"`
}
type BadRequestResponse struct {
PropertyName string `json:"propertyName"`
ErrorMessage string `json:"errorMessage"`
ErrorCode string `json:"errorCode"`
AttemptedValue string `json:"attemptedValue"`
Severity string `json:"severity"`
}
func (r *BadRequestResponse) String() string {
return fmt.Sprintf("[%s: %s] %s: %s - got value: %s", r.Severity, r.ErrorCode, r.PropertyName, r.ErrorMessage, r.AttemptedValue)
}
type SystemStatusResponse struct {
AppName string `json:"appName"`
Version string `json:"version"`
}
func (c *client) Test(ctx context.Context) (*SystemStatusResponse, error) {
func (c *Client) Test(ctx context.Context) (*SystemStatusResponse, error) {
status, res, err := c.get(ctx, "system/status")
if err != nil {
return nil, errors.Wrap(err, "could not make Test")
@ -120,7 +83,7 @@ func (c *client) Test(ctx context.Context) (*SystemStatusResponse, error) {
return &response, nil
}
func (c *client) Push(ctx context.Context, release Release) ([]string, error) {
func (c *Client) Push(ctx context.Context, release Release) ([]string, error) {
status, res, err := c.postBody(ctx, "release/push", release)
if err != nil {
return nil, errors.Wrap(err, "could not push release to readarr")
@ -160,3 +123,28 @@ func (c *client) Push(ctx context.Context, release Release) ([]string, error) {
// successful push
return nil, nil
}
func (c *Client) GetBooks(ctx context.Context, gridID string) ([]Book, error) {
params := make(url.Values)
if gridID != "" {
params.Set("titleSlug", gridID)
}
data := make([]Book, 0)
err := c.getJSON(ctx, "book", params, &data)
if err != nil {
return nil, errors.Wrap(err, "could not get tags")
}
return data, nil
}
func (c *Client) GetTags(ctx context.Context) ([]*arr.Tag, error) {
data := make([]*arr.Tag, 0)
err := c.getJSON(ctx, "tag", nil, &data)
if err != nil {
return nil, errors.Wrap(err, "could not get tags")
}
return data, nil
}

122
pkg/arr/readarr/types.go Normal file
View file

@ -0,0 +1,122 @@
package readarr
import (
"fmt"
"github.com/autobrr/autobrr/pkg/arr"
"time"
)
type Release struct {
Title string `json:"title"`
InfoUrl string `json:"infoUrl,omitempty"`
DownloadUrl string `json:"downloadUrl,omitempty"`
MagnetUrl string `json:"magnetUrl,omitempty"`
Size uint64 `json:"size"`
Indexer string `json:"indexer"`
DownloadProtocol string `json:"downloadProtocol"`
Protocol string `json:"protocol"`
PublishDate string `json:"publishDate"`
DownloadClientId int `json:"downloadClientId,omitempty"`
DownloadClient string `json:"downloadClient,omitempty"`
}
type PushResponse struct {
Approved bool `json:"approved"`
Rejected bool `json:"rejected"`
TempRejected bool `json:"temporarilyRejected"`
Rejections []string `json:"rejections"`
}
type BadRequestResponse struct {
PropertyName string `json:"propertyName"`
ErrorMessage string `json:"errorMessage"`
ErrorCode string `json:"errorCode"`
AttemptedValue string `json:"attemptedValue"`
Severity string `json:"severity"`
}
func (r *BadRequestResponse) String() string {
return fmt.Sprintf("[%s: %s] %s: %s - got value: %s", r.Severity, r.ErrorCode, r.PropertyName, r.ErrorMessage, r.AttemptedValue)
}
type SystemStatusResponse struct {
AppName string `json:"appName"`
Version string `json:"version"`
}
type Book struct {
Title string `json:"title"`
SeriesTitle string `json:"seriesTitle"`
Overview string `json:"overview"`
AuthorID int64 `json:"authorId"`
ForeignBookID string `json:"foreignBookId"`
TitleSlug string `json:"titleSlug"`
Monitored bool `json:"monitored"`
AnyEditionOk bool `json:"anyEditionOk"`
Ratings *arr.Ratings `json:"ratings"`
ReleaseDate time.Time `json:"releaseDate"`
PageCount int `json:"pageCount"`
Genres []interface{} `json:"genres"`
Author *BookAuthor `json:"author,omitempty"`
Images []*arr.Image `json:"images"`
Links []*arr.Link `json:"links"`
Statistics *Statistics `json:"statistics,omitempty"`
Editions []*Edition `json:"editions"`
ID int64 `json:"id"`
Disambiguation string `json:"disambiguation,omitempty"`
}
// Statistics for a Book, or maybe an author.
type Statistics struct {
BookCount int `json:"bookCount"`
BookFileCount int `json:"bookFileCount"`
TotalBookCount int `json:"totalBookCount"`
SizeOnDisk int `json:"sizeOnDisk"`
PercentOfBooks float64 `json:"percentOfBooks"`
}
// BookAuthor of a Book.
type BookAuthor struct {
ID int64 `json:"id"`
Status string `json:"status"`
AuthorName string `json:"authorName"`
ForeignAuthorID string `json:"foreignAuthorId"`
TitleSlug string `json:"titleSlug"`
Overview string `json:"overview"`
Links []*arr.Link `json:"links"`
Images []*arr.Image `json:"images"`
Path string `json:"path"`
QualityProfileID int64 `json:"qualityProfileId"`
MetadataProfileID int64 `json:"metadataProfileId"`
Genres []interface{} `json:"genres"`
CleanName string `json:"cleanName"`
SortName string `json:"sortName"`
Tags []int `json:"tags"`
Added time.Time `json:"added"`
Ratings *arr.Ratings `json:"ratings"`
Statistics *Statistics `json:"statistics"`
Monitored bool `json:"monitored"`
Ended bool `json:"ended"`
}
// Edition is more Book meta data.
type Edition struct {
ID int64 `json:"id"`
BookID int64 `json:"bookId"`
ForeignEditionID string `json:"foreignEditionId"`
TitleSlug string `json:"titleSlug"`
Isbn13 string `json:"isbn13"`
Asin string `json:"asin"`
Title string `json:"title"`
Overview string `json:"overview"`
Format string `json:"format"`
Publisher string `json:"publisher"`
PageCount int `json:"pageCount"`
ReleaseDate time.Time `json:"releaseDate"`
Images []*arr.Image `json:"images"`
Links []*arr.Link `json:"links"`
Ratings *arr.Ratings `json:"ratings"`
Monitored bool `json:"monitored"`
ManualAdd bool `json:"manualAdd"`
IsEbook bool `json:"isEbook"`
}

57
pkg/arr/shared.go Normal file
View file

@ -0,0 +1,57 @@
package arr
type Tag struct {
ID int
Label string
}
type Link struct {
URL string `json:"url"`
Name string `json:"name"`
}
type Image struct {
CoverType string `json:"coverType"`
URL string `json:"url"`
RemoteURL string `json:"remoteUrl,omitempty"`
Extension string `json:"extension,omitempty"`
}
type Ratings struct {
Votes int64 `json:"votes"`
Value float64 `json:"value"`
Popularity float64 `json:"popularity,omitempty"`
}
type Value struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
// BaseQuality is a base quality profile.
type BaseQuality struct {
ID int64 `json:"id"`
Name string `json:"name"`
Source string `json:"source,omitempty"`
Resolution int `json:"resolution,omitempty"`
Modifier string `json:"modifier,omitempty"`
}
// Quality is a download quality profile attached to a movie, book, track or series.
// It may contain 1 or more profiles.
// Sonarr nor Readarr use Name or ID in this struct.
type Quality struct {
Name string `json:"name,omitempty"`
ID int `json:"id,omitempty"`
Quality *BaseQuality `json:"quality,omitempty"`
Items []*Quality `json:"items,omitempty"`
Allowed bool `json:"allowed"`
Revision *QualityRevision `json:"revision,omitempty"` // Not sure which app had this....
}
// QualityRevision is probably used in Sonarr.
type QualityRevision struct {
Version int64 `json:"version"`
Real int64 `json:"real"`
IsRepack bool `json:"isRepack,omitempty"`
}

View file

@ -15,7 +15,7 @@ import (
"github.com/autobrr/autobrr/pkg/errors"
)
func (c *client) get(ctx context.Context, endpoint string) (int, []byte, error) {
func (c *Client) get(ctx context.Context, endpoint string) (int, []byte, error) {
u, err := url.Parse(c.config.Hostname)
if err != nil {
return 0, nil, errors.Wrap(err, "could not parse url: %s", c.config.Hostname)
@ -54,7 +54,47 @@ func (c *client) get(ctx context.Context, endpoint string) (int, []byte, error)
return resp.StatusCode, buf.Bytes(), nil
}
func (c *client) post(ctx context.Context, endpoint string, data interface{}) (*http.Response, error) {
func (c *Client) getJSON(ctx context.Context, endpoint string, params url.Values, data any) error {
u, err := url.Parse(c.config.Hostname)
if err != nil {
return errors.Wrap(err, "could not parse url: %s", c.config.Hostname)
}
u.Path = path.Join(u.Path, "/api/v3/", endpoint)
reqUrl := u.String()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqUrl, http.NoBody)
if err != nil {
return errors.Wrap(err, "could not build request")
}
if c.config.BasicAuth {
req.SetBasicAuth(c.config.Username, c.config.Password)
}
c.setHeaders(req)
req.URL.RawQuery = params.Encode()
resp, err := c.http.Do(req)
if err != nil {
return errors.Wrap(err, "sonarr.http.Do(req): %+v", req)
}
defer resp.Body.Close()
if resp.Body == nil {
return errors.New("response body is nil")
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return errors.Wrap(err, "could not unmarshal data")
}
return nil
}
func (c *Client) post(ctx context.Context, endpoint string, data interface{}) (*http.Response, error) {
u, err := url.Parse(c.config.Hostname)
if err != nil {
return nil, errors.Wrap(err, "could not parse url: %s", c.config.Hostname)
@ -97,7 +137,7 @@ func (c *client) post(ctx context.Context, endpoint string, data interface{}) (*
return res, nil
}
func (c *client) postBody(ctx context.Context, endpoint string, data interface{}) (int, []byte, error) {
func (c *Client) postBody(ctx context.Context, endpoint string, data interface{}) (int, []byte, error) {
u, err := url.Parse(c.config.Hostname)
if err != nil {
return 0, nil, errors.Wrap(err, "could not parse url: %s", c.config.Hostname)
@ -147,7 +187,7 @@ func (c *client) postBody(ctx context.Context, endpoint string, data interface{}
return resp.StatusCode, buf.Bytes(), nil
}
func (c *client) setHeaders(req *http.Request) {
func (c *Client) setHeaders(req *http.Request) {
if req.Body != nil {
req.Header.Set("Content-Type", "application/json")
}

View file

@ -6,14 +6,16 @@ package sonarr
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"log"
"github.com/autobrr/autobrr/pkg/arr"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/autobrr/autobrr/pkg/sharedhttp"
)
@ -30,26 +32,26 @@ type Config struct {
Log *log.Logger
}
type Client interface {
type ClientInterface interface {
Test(ctx context.Context) (*SystemStatusResponse, error)
Push(ctx context.Context, release Release) ([]string, error)
}
type client struct {
type Client struct {
config Config
http *http.Client
Log *log.Logger
}
// New create new sonarr client
func New(config Config) Client {
// New create new sonarr Client
func New(config Config) *Client {
httpClient := &http.Client{
Timeout: time.Second * 120,
Transport: sharedhttp.Transport,
}
c := &client{
c := &Client{
config: config,
http: httpClient,
Log: log.New(io.Discard, "", log.LstdFlags),
@ -62,44 +64,7 @@ func New(config Config) Client {
return c
}
type Release struct {
Title string `json:"title"`
InfoUrl string `json:"infoUrl,omitempty"`
DownloadUrl string `json:"downloadUrl,omitempty"`
MagnetUrl string `json:"magnetUrl,omitempty"`
Size uint64 `json:"size"`
Indexer string `json:"indexer"`
DownloadProtocol string `json:"downloadProtocol"`
Protocol string `json:"protocol"`
PublishDate string `json:"publishDate"`
DownloadClientId int `json:"downloadClientId,omitempty"`
DownloadClient string `json:"downloadClient,omitempty"`
}
type PushResponse struct {
Approved bool `json:"approved"`
Rejected bool `json:"rejected"`
TempRejected bool `json:"temporarilyRejected"`
Rejections []string `json:"rejections"`
}
type BadRequestResponse struct {
PropertyName string `json:"propertyName"`
ErrorMessage string `json:"errorMessage"`
ErrorCode string `json:"errorCode"`
AttemptedValue string `json:"attemptedValue"`
Severity string `json:"severity"`
}
func (r *BadRequestResponse) String() string {
return fmt.Sprintf("[%s: %s] %s: %s - got value: %s", r.Severity, r.ErrorCode, r.PropertyName, r.ErrorMessage, r.AttemptedValue)
}
type SystemStatusResponse struct {
Version string `json:"version"`
}
func (c *client) Test(ctx context.Context) (*SystemStatusResponse, error) {
func (c *Client) Test(ctx context.Context) (*SystemStatusResponse, error) {
status, res, err := c.get(ctx, "system/status")
if err != nil {
return nil, errors.Wrap(err, "could not make Test")
@ -119,7 +84,7 @@ func (c *client) Test(ctx context.Context) (*SystemStatusResponse, error) {
return &response, nil
}
func (c *client) Push(ctx context.Context, release Release) ([]string, error) {
func (c *Client) Push(ctx context.Context, release Release) ([]string, error) {
status, res, err := c.postBody(ctx, "release/push", release)
if err != nil {
return nil, errors.Wrap(err, "could not push release to sonarr")
@ -158,3 +123,32 @@ func (c *client) Push(ctx context.Context, release Release) ([]string, error) {
// successful push
return nil, nil
}
func (c *Client) GetAllSeries(ctx context.Context) ([]Series, error) {
return c.GetSeries(ctx, 0)
}
func (c *Client) GetSeries(ctx context.Context, tvdbID int64) ([]Series, error) {
params := make(url.Values)
if tvdbID != 0 {
params.Set("tvdbId", strconv.FormatInt(tvdbID, 10))
}
data := make([]Series, 0)
err := c.getJSON(ctx, "series", params, &data)
if err != nil {
return nil, errors.Wrap(err, "could not get tags")
}
return data, nil
}
func (c *Client) GetTags(ctx context.Context) ([]*arr.Tag, error) {
data := make([]*arr.Tag, 0)
err := c.getJSON(ctx, "tag", nil, &data)
if err != nil {
return nil, errors.Wrap(err, "could not get tags")
}
return data, nil
}

105
pkg/arr/sonarr/types.go Normal file
View file

@ -0,0 +1,105 @@
package sonarr
import (
"fmt"
"time"
"github.com/autobrr/autobrr/pkg/arr"
)
type Release struct {
Title string `json:"title"`
InfoUrl string `json:"infoUrl,omitempty"`
DownloadUrl string `json:"downloadUrl,omitempty"`
MagnetUrl string `json:"magnetUrl,omitempty"`
Size uint64 `json:"size"`
Indexer string `json:"indexer"`
DownloadProtocol string `json:"downloadProtocol"`
Protocol string `json:"protocol"`
PublishDate string `json:"publishDate"`
DownloadClientId int `json:"downloadClientId,omitempty"`
DownloadClient string `json:"downloadClient,omitempty"`
}
type PushResponse struct {
Approved bool `json:"approved"`
Rejected bool `json:"rejected"`
TempRejected bool `json:"temporarilyRejected"`
Rejections []string `json:"rejections"`
}
type BadRequestResponse struct {
PropertyName string `json:"propertyName"`
ErrorMessage string `json:"errorMessage"`
ErrorCode string `json:"errorCode"`
AttemptedValue string `json:"attemptedValue"`
Severity string `json:"severity"`
}
func (r *BadRequestResponse) String() string {
return fmt.Sprintf("[%s: %s] %s: %s - got value: %s", r.Severity, r.ErrorCode, r.PropertyName, r.ErrorMessage, r.AttemptedValue)
}
type SystemStatusResponse struct {
Version string `json:"version"`
}
type AlternateTitle struct {
Title string `json:"title"`
SeasonNumber int `json:"seasonNumber"`
}
type Season struct {
SeasonNumber int `json:"seasonNumber"`
Monitored bool `json:"monitored"`
Statistics *Statistics `json:"statistics,omitempty"`
}
type Statistics struct {
SeasonCount int `json:"seasonCount"`
PreviousAiring time.Time `json:"previousAiring"`
EpisodeFileCount int `json:"episodeFileCount"`
EpisodeCount int `json:"episodeCount"`
TotalEpisodeCount int `json:"totalEpisodeCount"`
SizeOnDisk int64 `json:"sizeOnDisk"`
PercentOfEpisodes float64 `json:"percentOfEpisodes"`
}
type Series struct {
ID int64 `json:"id"`
Title string `json:"title,omitempty"`
AlternateTitles []*AlternateTitle `json:"alternateTitles,omitempty"`
SortTitle string `json:"sortTitle,omitempty"`
Status string `json:"status,omitempty"`
Overview string `json:"overview,omitempty"`
PreviousAiring time.Time `json:"previousAiring,omitempty"`
Network string `json:"network,omitempty"`
Images []*arr.Image `json:"images,omitempty"`
Seasons []*Season `json:"seasons,omitempty"`
Year int `json:"year,omitempty"`
Path string `json:"path,omitempty"`
QualityProfileID int64 `json:"qualityProfileId,omitempty"`
LanguageProfileID int64 `json:"languageProfileId,omitempty"`
Runtime int `json:"runtime,omitempty"`
TvdbID int64 `json:"tvdbId,omitempty"`
TvRageID int64 `json:"tvRageId,omitempty"`
TvMazeID int64 `json:"tvMazeId,omitempty"`
FirstAired time.Time `json:"firstAired,omitempty"`
SeriesType string `json:"seriesType,omitempty"`
CleanTitle string `json:"cleanTitle,omitempty"`
ImdbID string `json:"imdbId,omitempty"`
TitleSlug string `json:"titleSlug,omitempty"`
RootFolderPath string `json:"rootFolderPath,omitempty"`
Certification string `json:"certification,omitempty"`
Genres []string `json:"genres,omitempty"`
Tags []int `json:"tags,omitempty"`
Added time.Time `json:"added,omitempty"`
Ratings *arr.Ratings `json:"ratings,omitempty"`
Statistics *Statistics `json:"statistics,omitempty"`
NextAiring time.Time `json:"nextAiring,omitempty"`
AirTime string `json:"airTime,omitempty"`
Ended bool `json:"ended,omitempty"`
SeasonFolder bool `json:"seasonFolder,omitempty"`
Monitored bool `json:"monitored"`
UseSceneNumbering bool `json:"useSceneNumbering,omitempty"`
}

View file

@ -296,6 +296,7 @@ export const APIClient = {
},
download_clients: {
getAll: () => appClient.Get<DownloadClient[]>("api/download_clients"),
getArrTags: (clientID: number) => appClient.Get<ArrTag[]>(`api/download_clients/${clientID}/arr/tags`),
create: (dc: DownloadClient) => appClient.Post("api/download_clients", {
body: dc
}),
@ -409,6 +410,22 @@ export const APIClient = {
body: notification
})
},
lists: {
list: () => appClient.Get<List[]>("api/lists"),
getByID: (id: number) => appClient.Get<List>(`api/lists/${id}`),
store: (list: List) => appClient.Post("api/lists", {
body: list
}),
update: (list: List) => appClient.Put(`api/lists/${list.id}`, {
body: list
}),
delete: (id: number) => appClient.Delete(`api/lists/${id}`),
refreshList: (id: number) => appClient.Post(`api/lists/${id}/refresh`),
refreshAll: () => appClient.Post(`api/lists/refresh`),
test: (list: List) => appClient.Post("api/lists/test", {
body: list
})
},
proxy: {
list: () => appClient.Get<Proxy[]>("api/proxy"),
getByID: (id: number) => appClient.Get<Proxy>(`api/proxy/${id}`),

View file

@ -11,12 +11,19 @@ import {
FeedKeys,
FilterKeys,
IndexerKeys,
IrcKeys, NotificationKeys, ProxyKeys,
IrcKeys, ListKeys, NotificationKeys, ProxyKeys,
ReleaseKeys,
SettingsKeys
} from "@api/query_keys";
import { ColumnFilter } from "@tanstack/react-table";
export const FiltersGetAllQueryOptions = () =>
queryOptions({
queryKey: FilterKeys.lists(),
queryFn: () => APIClient.filters.getAll(),
refetchOnWindowFocus: false
});
export const FiltersQueryOptions = (indexers: string[], sortOrder: string) =>
queryOptions({
queryKey: FilterKeys.list(indexers, sortOrder),
@ -92,6 +99,14 @@ export const DownloadClientsQueryOptions = () =>
queryFn: () => APIClient.download_clients.getAll(),
});
export const DownloadClientsArrTagsQueryOptions = (clientID: number) =>
queryOptions({
queryKey: DownloadClientKeys.arrTags(clientID),
queryFn: () => APIClient.download_clients.getArrTags(clientID),
retry: false,
enabled: clientID > 0,
});
export const NotificationsQueryOptions = () =>
queryOptions({
queryKey: NotificationKeys.lists(),
@ -163,3 +178,10 @@ export const ProxyByIdQueryOptions = (proxyId: number) =>
queryFn: async ({queryKey}) => await APIClient.proxy.getByID(queryKey[2]),
retry: false,
});
export const ListsQueryOptions = () =>
queryOptions({
queryKey: ListKeys.lists(),
queryFn: () => APIClient.lists.list(),
refetchOnWindowFocus: false
});

View file

@ -47,7 +47,8 @@ export const DownloadClientKeys = {
lists: () => [...DownloadClientKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...clientKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...DownloadClientKeys.all, "detail"] as const,
detail: (id: number) => [...DownloadClientKeys.details(), id] as const
detail: (id: number) => [...DownloadClientKeys.details(), id] as const,
arrTags: (id: number) => [...DownloadClientKeys.details(), id, "arr-tags"] as const
};
export const FeedKeys = {
@ -89,3 +90,10 @@ export const ProxyKeys = {
details: () => [...ProxyKeys.all, "detail"] as const,
detail: (id: number) => [...ProxyKeys.details(), id] as const
};
export const ListKeys = {
all: ["list"] as const,
lists: () => [...ListKeys.all, "list"] as const,
details: () => [...ListKeys.all, "detail"] as const,
detail: (id: number) => [...ListKeys.details(), id] as const
};

View file

@ -3,6 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { JSX } from "react";
import { Field as FormikField } from "formik";
import Select from "react-select";
import { Field, Label, Description } from "@headlessui/react";
@ -33,6 +34,7 @@ interface TextFieldWideProps {
placeholder?: string;
defaultValue?: string;
required?: boolean;
disabled?: boolean;
autoComplete?: string;
hidden?: boolean;
tooltip?: JSX.Element;
@ -46,6 +48,7 @@ export const TextFieldWide = ({
placeholder,
defaultValue,
required,
disabled,
autoComplete,
tooltip,
hidden,
@ -68,6 +71,7 @@ export const TextFieldWide = ({
value={defaultValue}
required={required}
validate={validate}
disabled={disabled}
>
{({ field, meta }: FieldProps) => (
<input
@ -76,11 +80,13 @@ export const TextFieldWide = ({
type="text"
value={field.value ? field.value : defaultValue ?? ""}
onChange={field.onChange}
disabled={disabled}
className={classNames(
meta.touched && meta.error
? "border-red-500 focus:ring-red-500 focus:border-red-500"
: "border-gray-300 dark:border-gray-700 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500",
"block w-full shadow-sm sm:text-sm rounded-md border py-2.5 bg-gray-100 dark:bg-gray-850 dark:text-gray-100"
"block w-full shadow-sm sm:text-sm rounded-md border py-2.5 dark:text-gray-100",
disabled ? "bg-gray-200 dark:bg-gray-700" : "bg-gray-100 dark:bg-gray-850 "
)}
placeholder={placeholder}
hidden={hidden}
@ -273,7 +279,7 @@ export const SwitchGroupWide = ({
</div>
</Label>
{description && (
<Description className="text-sm text-gray-500 dark:text-gray-700">
<Description className="text-sm text-gray-500 dark:text-gray-500">
{description}
</Description>
)}

View file

@ -3,6 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { JSX } from "react";
import { Field } from "formik";
import Select from "react-select";
import CreatableSelect from "react-select/creatable";
@ -11,6 +12,8 @@ import type { FieldProps } from "formik";
import { OptionBasicTyped } from "@domain/constants";
import * as common from "@components/inputs/common";
import { DocsTooltip } from "@components/tooltips/DocsTooltip";
import { MultiSelect as RMSC } from "react-multi-select-component";
import { MultiSelectOption } from "@components/inputs/select.tsx";
interface SelectFieldProps<T> {
name: string;
@ -228,3 +231,67 @@ export function SelectFieldBasic<T>({ name, label, help, placeholder, required,
</div>
);
}
export interface MultiSelectFieldProps {
name: string;
label: string;
help?: string;
placeholder?: string;
required?: boolean;
tooltip?: JSX.Element;
options: OptionBasicTyped<number>[];
}
interface ListFilterMultiSelectOption {
id: number;
name: string;
}
export function ListFilterMultiSelectField({ name, label, help, tooltip, options }: MultiSelectFieldProps) {
return (
<div className="flex items-center space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
<div>
<label
htmlFor={name}
className="block ml-px text-sm font-medium text-gray-900 dark:text-white"
>
<div className="flex">
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
</div>
</label>
</div>
<div className="sm:col-span-2">
<Field name={name} type="select">
{({
field,
form: { setFieldValue }
}: FieldProps) => (
<>
<RMSC
{...field}
options={options}
// disabled={disabled}
labelledBy={name}
// isCreatable={creatable}
// onCreateOption={handleNewField}
value={field.value && field.value.map((item: ListFilterMultiSelectOption) => ({
value: item.id,
label: item.name
}))}
onChange={(values: MultiSelectOption[]) => {
const item = values && values.map((i) => ({ id: i.value, name: i.label }));
setFieldValue(field.name, item);
}}
/>
</>
)}
</Field>
{help && (
<p className="mt-2 text-sm text-gray-500" id={`${name}-description`}>{help}</p>
)}
</div>
</div>
);
}

View file

@ -409,6 +409,49 @@ export const PushStatusOptions: OptionBasic[] = [
}
];
export const ListTypeOptions: OptionBasicTyped<ListType>[] = [
{
label: "Sonarr",
value: "SONARR"
},
{
label: "Radarr",
value: "RADARR"
},
{
label: "Lidarr",
value: "LIDARR"
},
{
label: "Readarr",
value: "READARR"
},
{
label: "Whisparr",
value: "WHISPARR"
},
{
label: "MDBList",
value: "MDBLIST"
},
{
label: "Trakt",
value: "TRAKT"
},
{
label: "Plaintext",
value: "PLAINTEXT"
},
{
label: "Steam",
value: "STEAM"
},
{
label: "Metacritic",
value: "METACRITIC"
},
];
export const NotificationTypeOptions: OptionBasicTyped<NotificationType>[] = [
{
label: "Discord",
@ -607,3 +650,48 @@ export const ProxyTypeOptions: OptionBasicTyped<ProxyType>[] = [
value: "SOCKS5"
},
];
export const ListsTraktOptions: OptionBasic[] = [
{
label: "Anticipated TV",
value: "https://api.autobrr.com/lists/trakt/anticipated-tv"
},
{
label: "Popular TV",
value: "https://api.autobrr.com/lists/trakt/popular-tv"
},
{
label: "Upcoming Movies",
value: "https://api.autobrr.com/lists/trakt/upcoming-movies"
},
{
label: "Upcoming BluRay",
value: "https://api.autobrr.com/lists/trakt/upcoming-bluray"
},
{
label: "Popular TV",
value: "https://api.autobrr.com/lists/trakt/popular-tv"
},
{
label: "Steven Lu",
value: "https://api.autobrr.com/lists/stevenlu"
},
];
export const ListsMetacriticOptions: OptionBasic[] = [
{
label: "Upcoming Albums",
value: "https://api.autobrr.com/lists/metacritic/upcoming-albums"
},
{
label: "New Albums",
value: "https://api.autobrr.com/lists/metacritic/new-albums"
}
];
export const ListsMDBListOptions: OptionBasic[] = [
{
label: "Latest TV Shows",
value: "https://mdblist.com/lists/garycrawfordgc/latest-tv-shows/json"
},
];

View file

@ -8,3 +8,5 @@ export { FilterAddForm } from "./filters/FilterAddForm";
export { DownloadClientAddForm, DownloadClientUpdateForm } from "./settings/DownloadClientForms";
export { IndexerAddForm, IndexerUpdateForm } from "./settings/IndexerForms";
export { IrcNetworkAddForm, IrcNetworkUpdateForm } from "./settings/IrcForms";
export { ListAddForm, ListUpdateForm } from "./settings/ListForms";

View file

@ -0,0 +1,969 @@
/*
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Fragment, JSX, useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Select from "react-select";
import {
Field,
FieldProps,
Form,
Formik,
FormikErrors,
FormikValues,
useFormikContext
} from "formik";
import {
Dialog,
DialogPanel,
DialogTitle,
Listbox,
ListboxButton, ListboxOption, ListboxOptions,
Transition,
TransitionChild
} from "@headlessui/react";
import { CheckIcon, ChevronUpDownIcon, XMarkIcon } from "@heroicons/react/24/solid";
import { APIClient } from "@api/APIClient";
import { ListKeys } from "@api/query_keys";
import { toast } from "@components/hot-toast";
import Toast from "@components/notifications/Toast";
import * as common from "@components/inputs/common";
import {
MultiSelectOption,
PasswordFieldWide,
SwitchGroupWide,
TextFieldWide
} from "@components/inputs";
import {
ListsMDBListOptions,
ListsMetacriticOptions,
ListsTraktOptions,
ListTypeOptions, OptionBasicTyped,
SelectOption
} from "@domain/constants";
import { DEBUG } from "@components/debug";
import {
DownloadClientsArrTagsQueryOptions,
DownloadClientsQueryOptions,
FiltersGetAllQueryOptions
} from "@api/queries";
import { classNames, sleep } from "@utils";
import {
ListFilterMultiSelectField,
SelectFieldCreatable
} from "@components/inputs/select_wide";
import { DocsTooltip } from "@components/tooltips/DocsTooltip";
import { MultiSelect as RMSC } from "react-multi-select-component";
import { useToggle } from "@hooks/hooks.ts";
import { DeleteModal } from "@components/modals";
interface ListAddFormValues {
name: string;
enabled: boolean;
}
interface AddFormProps {
isOpen: boolean;
toggle: () => void;
}
export function ListAddForm({ isOpen, toggle }: AddFormProps) {
const queryClient = useQueryClient();
const { data: clients } = useQuery(DownloadClientsQueryOptions());
const filterQuery = useQuery(FiltersGetAllQueryOptions());
const createMutation = useMutation({
mutationFn: (list: List) => APIClient.lists.store(list),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ListKeys.lists() });
toast.custom((t) => <Toast type="success" body="List added!" t={t}/>);
toggle();
},
onError: () => {
toast.custom((t) => <Toast type="error" body="List could not be added" t={t}/>);
}
});
const onSubmit = (formData: unknown) => createMutation.mutate(formData as List);
const validate = (values: ListAddFormValues) => {
const errors = {} as FormikErrors<FormikValues>;
if (!values.name)
errors.name = "Required";
return errors;
};
return (
<Transition show={isOpen} as={Fragment}>
<Dialog
as="div"
static
className="fixed inset-0 overflow-hidden"
open={isOpen}
onClose={toggle}
>
<div className="absolute inset-0 overflow-hidden">
<DialogPanel className="absolute inset-y-0 right-0 max-w-full flex">
<TransitionChild
as={Fragment}
enter="transform transition ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-500 sm:duration-700"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<div className="w-screen max-w-2xl dark:border-gray-700 border-l">
<Formik
enableReinitialize={true}
initialValues={{
enabled: true,
type: "",
name: "",
client_id: 0,
url: "",
headers: [],
api_key: "",
filters: [],
match_release: false,
tags_included: [],
tags_excluded: [],
include_unmonitored: false,
include_alternate_titles: false,
}}
onSubmit={onSubmit}
validate={validate}
>
{({ values }) => (
<Form className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-auto">
<div className="flex-1">
<div className="px-4 py-6 bg-gray-50 dark:bg-gray-900 sm:px-6">
<div className="flex items-start justify-between space-x-3">
<div className="space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Add List
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-200">
Auto update filters from lists and arrs.
</p>
</div>
<div className="h-7 flex items-center">
<button
type="button"
className="bg-white dark:bg-gray-700 rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
onClick={toggle}
>
<span className="sr-only">Close panel</span>
<XMarkIcon className="h-6 w-6" aria-hidden="true"/>
</button>
</div>
</div>
</div>
<div className="flex flex-col space-y-4 py-6 sm:py-0 sm:space-y-0">
<TextFieldWide
name="name"
label="Name"
required={true}
/>
<div className="flex items-center justify-between space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
<div>
<label htmlFor="type" className="block text-sm font-medium text-gray-900 dark:text-white"
>
Type
</label>
</div>
<div className="sm:col-span-2">
<Field name="type" type="select">
{({
field,
form: { setFieldValue }
}: FieldProps) => (
<Select
{...field}
isClearable={true}
isSearchable={true}
components={{
Input: common.SelectInput,
Control: common.SelectControl,
Menu: common.SelectMenu,
Option: common.SelectOption,
IndicatorSeparator: common.IndicatorSeparator,
DropdownIndicator: common.DropdownIndicator
}}
placeholder="Choose a type"
styles={{
singleValue: (base) => ({
...base,
color: "unset"
})
}}
theme={(theme) => ({
...theme,
spacing: {
...theme.spacing,
controlHeight: 30,
baseUnit: 2
}
})}
value={field?.value && field.value.value}
onChange={(option: unknown) => {
// resetForm();
const opt = option as SelectOption;
// setFieldValue("name", option?.label ?? "")
setFieldValue(
field.name,
opt.value ?? ""
);
}}
options={ListTypeOptions}
/>
)}
</Field>
</div>
</div>
<SwitchGroupWide name="enabled" label="Enabled"/>
</div>
<ListTypeForm listType={values.type} clients={clients ?? []}/>
<div className="flex flex-col space-y-4 py-6 sm:py-0 sm:space-y-0">
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
<div className="px-4 space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Filters
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-400">
Select filters to update for this list.
</p>
</div>
<ListFilterMultiSelectField name="filters" label="Filters" options={filterQuery.data?.map(f => ({ value: f.id, label: f.name })) ?? []} />
</div>
</div>
</div>
<div className="flex-shrink-0 px-4 border-t border-gray-200 dark:border-gray-700 py-4 sm:px-6">
<div className="space-x-3 flex justify-end">
<button
type="button"
className="bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
onClick={toggle}
>
Cancel
</button>
<SubmitButton isPending={createMutation.isPending} isError={createMutation.isError} isSuccess={createMutation.isSuccess} />
</div>
</div>
<DEBUG values={values}/>
</Form>
)}
</Formik>
</div>
</TransitionChild>
</DialogPanel>
</div>
</Dialog>
</Transition>
);
}
interface UpdateFormProps<T> {
isOpen: boolean;
toggle: () => void;
data: T;
}
export function ListUpdateForm({ isOpen, toggle, data }: UpdateFormProps<List>) {
const cancelModalButtonRef = useRef<HTMLInputElement | null>(null);
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
const queryClient = useQueryClient();
const clientsQuery = useQuery(DownloadClientsQueryOptions());
const filterQuery = useQuery(FiltersGetAllQueryOptions());
const mutation = useMutation({
mutationFn: (list: List) => APIClient.lists.update(list),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ListKeys.lists() });
toast.custom((t) => <Toast type="success" body={`${data.name} was updated successfully`} t={t}/>);
sleep(1500);
toggle();
}
});
const onSubmit = (formData: unknown) => mutation.mutate(formData as List);
const deleteMutation = useMutation({
mutationFn: (listID: number) => APIClient.lists.delete(listID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ListKeys.lists() });
toast.custom((t) => <Toast type="success" body={`${data.name} was deleted.`} t={t}/>);
}
});
const deleteAction = () => deleteMutation.mutate(data.id);
return (
<Transition show={isOpen} as={Fragment}>
<Dialog
as="div"
static
className="fixed inset-0 overflow-hidden"
open={isOpen}
onClose={toggle}
>
{deleteAction && (
<DeleteModal
isOpen={deleteModalIsOpen}
isLoading={false}
toggle={toggleDeleteModal}
buttonRef={cancelModalButtonRef}
deleteAction={deleteAction}
title={`Remove ${data.name}`}
text={`Are you sure you want to remove this ${data.name}? This action cannot be undone.`}
/>
)}
<div className="absolute inset-0 overflow-hidden">
<DialogPanel className="absolute inset-y-0 right-0 max-w-full flex">
<TransitionChild
as={Fragment}
enter="transform transition ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-500 sm:duration-700"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<div className="w-screen max-w-2xl dark:border-gray-700 border-l">
<Formik
enableReinitialize={true}
initialValues={{
id: data.id,
enabled: data.enabled,
type: data.type,
name: data.name,
client_id: data.client_id,
url: data.url,
headers: data.headers || [],
api_key: data.api_key,
filters: data.filters,
match_release: data.match_release,
tags_included: data.tags_included,
tags_excluded: data.tags_excluded,
include_unmonitored: data.include_unmonitored,
include_alternate_titles: data.include_alternate_titles,
}}
onSubmit={onSubmit}
// validate={validate}
>
{({ values }) => (
<Form className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-auto">
<div className="flex-1">
<div className="px-4 py-6 bg-gray-50 dark:bg-gray-900 sm:px-6">
<div className="flex items-start justify-between space-x-3">
<div className="space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Update List
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-200">
Auto update filters from lists and arrs.
</p>
</div>
<div className="h-7 flex items-center">
<button
type="button"
className="bg-white dark:bg-gray-700 rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
onClick={toggle}
>
<span className="sr-only">Close panel</span>
<XMarkIcon className="h-6 w-6" aria-hidden="true"/>
</button>
</div>
</div>
</div>
<div className="flex flex-col space-y-4 py-6 sm:py-0 sm:space-y-0">
<TextFieldWide name="name" label="Name" required={true}/>
<TextFieldWide name="type" label="Type" required={true} disabled={true} />
<SwitchGroupWide name="enabled" label="Enabled"/>
<div className="space-y-2 divide-y divide-gray-200 dark:divide-gray-700">
<ListTypeForm listType={values.type} clients={clientsQuery.data ?? []}/>
</div>
<div className="flex flex-col space-y-4 py-6 sm:py-0 sm:space-y-0">
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
<div className="px-4 space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Filters
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-400">
Select filters to update for this list.
</p>
</div>
<ListFilterMultiSelectField name="filters" label="Filters" options={filterQuery.data?.map(f => ({
value: f.id,
label: f.name
})) ?? []}/>
</div>
</div>
</div>
</div>
<div className="flex-shrink-0 px-4 border-t border-gray-200 dark:border-gray-700 py-4">
<div className="space-x-3 flex justify-between">
<button
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 dark:text-white bg-red-100 dark:bg-red-700 hover:bg-red-200 dark:hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm"
onClick={toggleDeleteModal}
>
Remove
</button>
<div className="flex space-x-3">
<button
type="button"
className="bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
onClick={toggle}
>
Cancel
</button>
<SubmitButton isPending={mutation.isPending} isError={mutation.isError} isSuccess={mutation.isSuccess} />
</div>
</div>
</div>
<DEBUG values={values}/>
</Form>
)}
</Formik>
</div>
</TransitionChild>
</DialogPanel>
</div>
</Dialog>
</Transition>
);
}
interface SubmitButtonProps {
isPending?: boolean;
isError?: boolean;
isSuccess?: boolean;
}
const SubmitButton = (props: SubmitButtonProps) => {
return (
<button
type="submit"
className={classNames(
// isTestSuccessful
// ? "text-green-500 border-green-500 bg-green-50"
// : isError
// ? "text-red-500 border-red-500 bg-red-50"
// : "border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:border-rose-700 active:bg-rose-700",
props.isPending ? "cursor-not-allowed" : "",
"mr-2 inline-flex items-center px-4 py-2 border font-medium rounded-md shadow-sm text-sm transition ease-in-out duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:border-blue-700 active:bg-blue-700"
)}
>
{props.isPending ? (
<>
<svg
className="animate-spin h-5 w-5 text-green-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span className="pl-2">Saving..</span>
</>
) : (
<span>Save</span>
)}
</button>
);
}
interface ListTypeFormProps {
listID?: number;
listType: string;
clients: DownloadClient[];
}
const ListTypeForm = (props: ListTypeFormProps) => {
const { setFieldValue } = useFormikContext();
const [prevActionType, setPrevActionType] = useState<string | null>(null);
const { listType } = props;
useEffect(() => {
// if (prevActionType !== null && prevActionType !== list.type && ListTypeOptions.map(l => l.value).includes(list.type)) {
if (prevActionType !== null && prevActionType !== listType && ListTypeOptions.map(l => l.value).includes(listType as ListType)) {
// Reset the client_id field value
setFieldValue(`client_id`, 0);
}
setPrevActionType(listType);
}, [listType, prevActionType, setFieldValue]);
switch (props.listType) {
case "RADARR":
return <ListTypeArr {...props} />;
case "SONARR":
return <ListTypeArr {...props} />;
case "LIDARR":
return <ListTypeArr {...props} />;
case "READARR":
return <ListTypeArr {...props} />;
case "WHISPARR":
return <ListTypeArr {...props} />;
case "TRAKT":
return <ListTypeTrakt />;
case "STEAM":
return <ListTypeSteam />;
case "METACRITIC":
return <ListTypeMetacritic />;
case "MDBLIST":
return <ListTypeMDBList />;
case "PLAINTEXT":
return <ListTypePlainText />;
default:
return null;
}
}
const FilterOptionCheckBoxes = (props: ListTypeFormProps) => {
switch (props.listType) {
case "RADARR":
case "SONARR":
return (
<fieldset>
<legend className="sr-only">Settings</legend>
<SwitchGroupWide name="match_release" label="Match Release" description="Use Match Releases field. Uses Movies/Shows field by default." />
<SwitchGroupWide name="include_unmonitored" label="Include Unmonitored" description="By default only monitored titles are filtered." />
<SwitchGroupWide name="include_alternate_titles" label="Include Alternate Titles" description="Include alternate titles in the filter." />
</fieldset>
);
case "LIDARR":
case "WHISPARR":
case "READARR":
return (
<fieldset>
<legend className="sr-only">Settings</legend>
<SwitchGroupWide name="include_unmonitored" label="Include Unmonitored" description="By default only monitored titles are filtered." />
</fieldset>
);
}
}
function ListTypeArr({ listType, clients }: ListTypeFormProps) {
const { values } = useFormikContext<List>();
useEffect(() => {
}, [values.client_id]);
const arrTagsQuery = useQuery(DownloadClientsArrTagsQueryOptions(values.client_id));
return (
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
<div className="px-4 space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Source
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-400">
Update filters from titles in Radarr, Sonarr, Lidarr, Readarr, or Whisparr.
</p>
</div>
<DownloadClientSelectCustom
name={`client_id`}
clients={clients}
clientType={listType}
/>
{values.client_id > 0 && (values.type === "RADARR" || values.type == "SONARR") && (
<>
<ListArrTagsMultiSelectField name="tags_included" label="Tags Included" options={arrTagsQuery.data?.map(f => ({
value: f.label,
label: f.label
})) ?? []}/>
<ListArrTagsMultiSelectField name="tags_excluded" label="Tags Excluded" options={arrTagsQuery.data?.map(f => ({
value: f.label,
label: f.label
})) ?? []}/>
</>
)}
<div className="space-y-1">
<FilterOptionCheckBoxes listType={listType} clients={[]}/>
</div>
</div>
)
}
function ListTypeTrakt() {
const { values } = useFormikContext<List>();
return (
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
<div className="px-4 space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Source list
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-400">
Use a Trakt list or one of the default autobrr hosted lists.
</p>
</div>
<SelectFieldCreatable
name="url"
label="List URL"
help="Default Trakt lists. Override with your own."
options={ListsTraktOptions.map(u => ({ value: u.value, label: u.label, key: u.label }))}
/>
{!values.url.startsWith("https://api.autobrr.com/") && (
<PasswordFieldWide
name="api_key"
label="API Key"
help="Trakt API Key. Required for private lists."
/>
)}
<div className="space-y-1">
<fieldset>
<legend className="sr-only">Settings</legend>
<SwitchGroupWide name="match_release" label="Match Release" description="Use Match Releases field. Uses Movies/Shows field by default." />
</fieldset>
</div>
</div>
)
}
function ListTypePlainText() {
const { values } = useFormikContext<List>();
return (
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
<div className="px-4 space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Source list
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-400">
Use a Trakt list or one of the default autobrr hosted lists.
</p>
</div>
<SelectFieldCreatable
name="url"
label="List URL"
help="Default Trakt lists. Override with your own."
options={ListsTraktOptions.map(u => ({ value: u.value, label: u.label, key: u.label }))}
/>
{!values.url.startsWith("https://api.autobrr.com/") && (
<PasswordFieldWide
name="api_key"
label="API Key"
help="Trakt API Key. Required for private lists."
/>
)}
<div className="space-y-1">
<fieldset>
<legend className="sr-only">Settings</legend>
<SwitchGroupWide name="match_release" label="Match Release" description="Use Match Releases field. Uses Movies/Shows field by default." />
</fieldset>
</div>
</div>
)
}
function ListTypeSteam() {
return (
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
<div className="px-4 space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Source list
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-400">
Follow Steam wishlists.
</p>
</div>
<TextFieldWide name="url" label="URL" help={"Steam Wishlist URL"} placeholder="https://store.steampowered.com/wishlist/id/USERNAME/wishlistdata"/>
</div>
)
}
function ListTypeMetacritic() {
return (
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
<div className="px-4 space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Source list
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-400">
Use a Metacritic list or one of the default autobrr hosted lists.
</p>
</div>
<SelectFieldCreatable
name="url"
label="List URL"
help="Metacritic lists. Override with your own."
options={ListsMetacriticOptions.map(u => ({ value: u.value, label: u.label, key: u.label }))}
/>
<div className="space-y-1">
<fieldset>
<legend className="sr-only">Settings</legend>
<SwitchGroupWide name="match_release" label="Match Release" description="Use Match Releases field. Uses Movies/Shows field by default." />
</fieldset>
</div>
</div>
)
}
function ListTypeMDBList() {
return (
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
<div className="px-4 space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Source list
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-400">
Use a MDBList list or one of the default autobrr hosted lists.
</p>
</div>
<SelectFieldCreatable
name="url"
label="List URL"
help="MDBLists.com lists. Override with your own."
options={ListsMDBListOptions.map(u => ({ value: u.value, label: u.label, key: u.label }))}
/>
<div className="space-y-1">
<fieldset>
<legend className="sr-only">Settings</legend>
<SwitchGroupWide name="match_release" label="Match Release" description="Use Match Releases field. Uses Movies/Shows field by default." />
</fieldset>
</div>
</div>
)
}
interface DownloadClientSelectProps {
name: string;
clientType: string;
clients: DownloadClient[];
}
function DownloadClientSelectCustom({ name, clientType, clients }: DownloadClientSelectProps) {
return (
<div className="flex items-center space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
<div>
<label
htmlFor={name}
className="block ml-px text-sm font-medium text-gray-900 dark:text-white"
>
<div className="flex">
Select Client
</div>
</label>
</div>
<div className="sm:col-span-2">
<Field name={name} type="select">
{({
field,
meta,
form: { setFieldValue }
}: FieldProps) => (
<Listbox
value={field.value}
onChange={(value) => setFieldValue(field?.name, value)}
>
{({ open }) => (
<>
{/*<Label className="block text-xs font-bold text-gray-800 dark:text-gray-100 uppercase tracking-wide">*/}
{/* Client*/}
{/*</Label>*/}
<div className="relative">
<ListboxButton
className="block w-full shadow-sm sm:text-sm rounded-md border py-2 pl-3 pr-10 text-left focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-815 dark:text-gray-100">
<span className="block truncate">
{field.value
? clients.find((c) => c.id === field.value)?.name
: "Choose a client"}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<ChevronUpDownIcon
className="h-5 w-5 text-gray-400 dark:text-gray-300"
aria-hidden="true"/>
</span>
</ListboxButton>
<Transition
show={open}
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions
static
className="absolute z-10 mt-1 w-full border border-gray-400 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-lg max-h-60 rounded-md py-1 text-base overflow-auto focus:outline-none sm:text-sm"
>
{clients
.filter((c) => c.type === clientType)
.map((client) => (
<ListboxOption
key={client.id}
className={({ focus }) => classNames(
focus
? "text-white dark:text-gray-100 bg-blue-600 dark:bg-gray-950"
: "text-gray-900 dark:text-gray-300",
"cursor-default select-none relative py-2 pl-3 pr-9"
)}
value={client.id}
>
{({ selected, focus }) => (
<>
<span
className={classNames(
selected ? "font-semibold" : "font-normal",
"block truncate"
)}
>
{client.name}
</span>
{selected ? (
<span
className={classNames(
focus ? "text-white dark:text-gray-100" : "text-blue-600 dark:text-blue-500",
"absolute inset-y-0 right-0 flex items-center pr-4"
)}
>
<CheckIcon
className="h-5 w-5"
aria-hidden="true"/>
</span>
) : null}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
{meta.touched && meta.error && (
<p className="error text-sm text-red-600 mt-1">* {meta.error}</p>
)}
</div>
</>
)}
</Listbox>
)}
</Field>
</div>
</div>
);
}
export interface ListMultiSelectFieldProps {
name: string;
label: string;
help?: string;
placeholder?: string;
required?: boolean;
tooltip?: JSX.Element;
options: OptionBasicTyped<number | string>[];
}
export function ListArrTagsMultiSelectField({ name, label, help, tooltip, options }: ListMultiSelectFieldProps) {
return (
<div className="flex items-center space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
<div>
<label
htmlFor={name}
className="block ml-px text-sm font-medium text-gray-900 dark:text-white"
>
<div className="flex">
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
</div>
</label>
</div>
<div className="sm:col-span-2">
<Field name={name} type="select">
{({
field,
form: { setFieldValue }
}: FieldProps) => (
<>
<RMSC
{...field}
options={options}
// disabled={disabled}
labelledBy={name}
// isCreatable={creatable}
// onCreateOption={handleNewField}
value={field.value && field.value.map((item: MultiSelectOption) => ({
value: item.value ? item.value : item,
label: item.label ? item.label : item
}))}
onChange={(values: Array<MultiSelectOption>) => {
const am = values && values.map((i) => i.value);
setFieldValue(field.name, am);
}}
/>
</>
)}
</Field>
{help && (
<p className="mt-2 text-sm text-gray-500" id={`${name}-description`}>{help}</p>
)}
</div>
</div>
);
}

View file

@ -29,7 +29,7 @@ import {
FeedsQueryOptions,
FilterByIdQueryOptions,
IndexersQueryOptions,
IrcQueryOptions,
IrcQueryOptions, ListsQueryOptions,
NotificationsQueryOptions,
ProxiesQueryOptions
} from "@api/queries";
@ -52,6 +52,7 @@ import { TanStackRouterDevtools } from "@tanstack/router-devtools";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { queryClient } from "@api/QueryClient";
import ProxySettings from "@screens/settings/Proxy";
import ListsSettings from "@screens/settings/Lists";
import { ErrorPage } from "@components/alerts";
@ -186,6 +187,13 @@ export const SettingsIrcRoute = createRoute({
component: IrcSettings
});
export const SettingsListsRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'lists',
loader: (opts) => opts.context.queryClient.ensureQueryData(ListsQueryOptions()),
component: ListsSettings
});
export const SettingsFeedsRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'feeds',
@ -364,7 +372,7 @@ export const RootRoute = createRootRouteWithContext<{
});
const filterRouteTree = FiltersRoute.addChildren([FilterIndexRoute, FilterGetByIdRoute.addChildren([FilterGeneralRoute, FilterMoviesTvRoute, FilterMusicRoute, FilterAdvancedRoute, FilterExternalRoute, FilterActionsRoute])])
const settingsRouteTree = SettingsRoute.addChildren([SettingsIndexRoute, SettingsLogRoute, SettingsIndexersRoute, SettingsIrcRoute, SettingsFeedsRoute, SettingsClientsRoute, SettingsNotificationsRoute, SettingsApiRoute, SettingsProxiesRoute, SettingsReleasesRoute, SettingsAccountRoute])
const settingsRouteTree = SettingsRoute.addChildren([SettingsIndexRoute, SettingsLogRoute, SettingsIndexersRoute, SettingsIrcRoute, SettingsListsRoute, SettingsFeedsRoute, SettingsClientsRoute, SettingsNotificationsRoute, SettingsApiRoute, SettingsProxiesRoute, SettingsReleasesRoute, SettingsAccountRoute])
const authenticatedTree = AuthRoute.addChildren([AuthIndexRoute.addChildren([DashboardRoute, filterRouteTree, ReleasesRoute, settingsRouteTree, LogsRoute])])
const routeTree = RootRoute.addChildren([
authenticatedTree,

View file

@ -4,6 +4,7 @@
*/
import {
BarsArrowDownIcon,
BellIcon,
ChatBubbleLeftRightIcon,
CogIcon,
@ -32,6 +33,7 @@ const subNavigation: NavTabType[] = [
{ name: "Indexers", href: "/settings/indexers", icon: KeyIcon },
{ name: "IRC", href: "/settings/irc", icon: ChatBubbleLeftRightIcon },
{ name: "Feeds", href: "/settings/feeds", icon: RssIcon },
{ name: "Lists", href: "/settings/lists", icon: BarsArrowDownIcon },
{ name: "Clients", href: "/settings/clients", icon: FolderArrowDownIcon },
{ name: "Notifications", href: "/settings/notifications", icon: BellIcon },
{ name: "API keys", href: "/settings/api", icon: KeyIcon },

View file

@ -25,7 +25,7 @@ import {
DocumentDuplicateIcon,
EllipsisHorizontalIcon,
PencilSquareIcon,
PlusIcon,
PlusIcon, SparklesIcon,
TrashIcon
} from "@heroicons/react/24/outline";
import { ArrowDownTrayIcon } from "@heroicons/react/24/solid";
@ -589,9 +589,9 @@ function FilterListItem({ filter, idx }: FilterListItemProps) {
params={{
filterId: filter.id
}}
className="transition w-full break-words whitespace-wrap text-sm font-bold text-gray-800 dark:text-gray-100 hover:text-black dark:hover:text-gray-350"
className="transition flex items-center w-full break-words whitespace-wrap text-sm font-bold text-gray-800 dark:text-gray-100 hover:text-black dark:hover:text-gray-350"
>
{filter.name}
{filter.name} {filter.is_auto_updated && <SparklesIcon title="This filter is automatically updated by a list" className="ml-1 w-4 h-4 text-amber-500 dark:text-amber-400" aria-hidden="true"/>}
</Link>
<div className="flex items-center flex-wrap">
<span className="mr-2 break-words whitespace-nowrap text-xs font-medium text-gray-600 dark:text-gray-400">

View file

@ -0,0 +1,196 @@
/*
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { PlusIcon } from "@heroicons/react/24/solid";
import { useToggle } from "@hooks/hooks";
import { APIClient } from "@api/APIClient";
import { ListKeys } from "@api/query_keys";
import { toast } from "@components/hot-toast";
import Toast from "@components/notifications/Toast";
import { Checkbox } from "@components/Checkbox";
import { ListsQueryOptions } from "@api/queries";
import { Section } from "@screens/settings/_components";
import { EmptySimple } from "@components/emptystates";
import { ListAddForm, ListUpdateForm } from "@forms";
import { FC } from "react";
import { Link } from "@tanstack/react-router";
function ListsSettings() {
const [addFormIsOpen, toggleAddList] = useToggle(false);
const listsQuery = useSuspenseQuery(ListsQueryOptions())
const lists = listsQuery.data
return (
<Section
title="Lists"
description={
<>
Lists can automatically update your filters from arrs or other sources.<br/>
</>
}
rightSide={
<button
type="button"
onClick={toggleAddList}
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
>
<PlusIcon className="h-5 w-5 mr-1"/>
Add new
</button>
}
>
<ListAddForm isOpen={addFormIsOpen} toggle={toggleAddList} />
<div className="flex flex-col">
{lists.length ? (
<ul className="min-w-full relative">
<li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700">
<div
className="flex col-span-2 sm:col-span-1 pl-0 sm:pl-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 hover:dark:text-gray-250 transition-colors uppercase tracking-wider cursor-pointer"
>
Enabled
</div>
<div
className="col-span-5 sm:col-span-4 pl-12 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 hover:dark:text-gray-250 transition-colors uppercase tracking-wider cursor-pointer"
>
Name
</div>
<div
className="hidden md:flex col-span-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 hover:dark:text-gray-250 transition-colors uppercase tracking-wider cursor-pointer"
>
Filters
</div>
<div
className="hidden md:flex col-span-1 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 hover:dark:text-gray-250 transition-colors uppercase tracking-wider cursor-pointer"
>
Type
</div>
</li>
{lists.map((list) => (
<ListItem list={list} key={list.id}/>
))}
</ul>
) : (
<EmptySimple
title="No lists"
subtitle=""
buttonText="Add new list"
buttonAction={toggleAddList}
/>
)}
</div>
</Section>
);
}
interface FilterPillProps {
filter: ListFilter;
}
const FilterPill: FC<FilterPillProps> = ({ filter }) => (
<Link
className="hidden sm:inline-flex items-center px-2 py-0.5 rounded-md text-sm font-medium bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-400 hover:dark:bg-gray-750 hover:bg-gray-700"
to={`/filters/$filterId`}
params={{ filterId: filter.id }}
>
{filter.name}
</Link>
);
export default ListsSettings;
interface ListItemProps {
list: List;
}
function ListItem({ list }: ListItemProps) {
const [isOpen, toggleUpdate] = useToggle(false);
const queryClient = useQueryClient();
const updateMutation = useMutation({
mutationFn: (req: List) => APIClient.lists.update(req),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ListKeys.lists() });
toast.custom(t => <Toast type="success" body={`List ${list.name} was ${list.enabled ? "enabled" : "disabled"} successfully.`} t={t} />);
},
onError: () => {
toast.custom((t) => <Toast type="error" body="List state could not be updated" t={t} />);
}
});
const onToggleMutation = (newState: boolean) => {
updateMutation.mutate({
...list,
enabled: newState
});
};
return (
<li>
<ListUpdateForm isOpen={isOpen} toggle={toggleUpdate} data={list} />
<div className="grid grid-cols-12 items-center py-1.5">
<div className="col-span-2 sm:col-span-1 flex pl-1 sm:pl-5 items-center">
<Checkbox value={list.enabled ?? false} setValue={onToggleMutation}/>
</div>
<div
className="col-span-5 sm:col-span-4 pl-12 sm:pr-6 py-3 block flex-col text-sm font-medium text-gray-900 dark:text-white truncate">
{list.name}
</div>
<div
className="hidden md:block col-span-4 pr-6 py-3 space-x-1 text-left items-center whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 truncate">
{/*{list.filters.map(filter => <FilterPill filter={filter} key={filter.id} />)}*/}
<ListItemFilters filters={list.filters} />
</div>
<div
className="hidden md:block col-span-2 pr-6 py-3 text-left items-center whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 truncate">
{list.type}
</div>
<div className="col-span-1 flex first-letter:px-6 py-3 whitespace-nowrap text-right text-sm font-medium">
<span
className="col-span-1 px-6 text-blue-600 dark:text-gray-300 hover:text-blue-900 dark:hover:text-blue-500 cursor-pointer"
onClick={toggleUpdate}
>
Edit
</span>
</div>
</div>
</li>
);
}
interface ListItemFiltersProps {
filters: ListFilter[];
}
const ListItemFilters = ({ filters }: ListItemFiltersProps) => {
if (!filters.length) {
return null;
}
const res = filters.slice(2);
return (
<div className="flex flex-row gap-1">
<FilterPill filter={filters[0]} />
{filters.length > 1 ? (
<FilterPill filter={filters[1]} />
) : null}
{filters.length > 2 ? (
<span
className="mr-2 inline-flex items-center px-2 py-0.5 rounded-md text-sm font-medium bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-400"
title={res.map(v => v.name).toString()}
>
+{filters.length - 2}
</span>
) : null}
</div>
);
}

View file

@ -9,6 +9,7 @@ export { default as DownloadClient } from "./DownloadClient";
export { default as Feed } from "./Feed";
export { default as Indexer } from "./Indexer";
export { default as Irc } from "./Irc";
export { default as Lists } from "./Lists";
export { default as Logs } from "./Logs";
export { default as Notification } from "./Notifications";
export { default as Proxy } from "./Proxy";

View file

@ -65,3 +65,8 @@ interface DownloadClient {
password: string;
settings?: DownloadClientSettings;
}
interface ArrTag {
id: number;
label: string;
}

View file

@ -74,6 +74,7 @@ interface Filter {
max_seeders: number;
min_leechers: number;
max_leechers: number;
is_auto_updated: boolean;
actions_count: number;
actions_enabled_count: number;
actions: Action[];

44
web/src/types/List.d.ts vendored Normal file
View file

@ -0,0 +1,44 @@
/*
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
interface List {
id: number;
name: string;
enabled: boolean;
type: ListType;
client_id: number;
url: string;
headers: string[];
api_key: string;
filters: ListFilter[];
match_release: boolean;
tags_included: string[];
tags_excluded: string[];
include_unmonitored: boolean;
include_alternate_titles: boolean;
}
interface ListFilter {
id: number;
name: string;
}
interface ListCreate {
name: string;
enabled: boolean;
type: ListType;
client_id: number;
url: string;
headers: string[];
api_key: string;
filters: number[];
match_release: boolean;
tags_include: string[];
tags_exclude: string[];
include_unmonitored: boolean;
include_alternate_titles: boolean;
}
type ListType = "SONARR" | "RADARR" | "LIDARR" | "READARR" | "WHISPARR" | "MDBLIST" | "TRAKT" | "METACRITIC" | "STEAM" | "PLAINTEXT";