diff --git a/cmd/autobrr/main.go b/cmd/autobrr/main.go index c08422d..0b8e838 100644 --- a/cmd/autobrr/main.go +++ b/cmd/autobrr/main.go @@ -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 diff --git a/internal/action/lidarr.go b/internal/action/lidarr.go index 0a9a3f3..16d2c2a 100644 --- a/internal/action/lidarr.go +++ b/internal/action/lidarr.go @@ -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, diff --git a/internal/action/radarr.go b/internal/action/radarr.go index eecf2c3..8d849c8 100644 --- a/internal/action/radarr.go +++ b/internal/action/radarr.go @@ -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, diff --git a/internal/action/readarr.go b/internal/action/readarr.go index dda7896..469539f 100644 --- a/internal/action/readarr.go +++ b/internal/action/readarr.go @@ -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, diff --git a/internal/action/run.go b/internal/action/run.go index dd6d0c4..bd291f9 100644 --- a/internal/action/run.go +++ b/internal/action/run.go @@ -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) } }() diff --git a/internal/action/service.go b/internal/action/service.go index 351869f..daa6155 100644 --- a/internal/action/service.go +++ b/internal/action/service.go @@ -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 { diff --git a/internal/action/sonarr.go b/internal/action/sonarr.go index 149c070..9d29543 100644 --- a/internal/action/sonarr.go +++ b/internal/action/sonarr.go @@ -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, diff --git a/internal/database/filter.go b/internal/database/filter.go index 3dcb844..c9b0731 100644 --- a/internal/database/filter.go +++ b/internal/database/filter.go @@ -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 diff --git a/internal/database/list.go b/internal/database/list.go new file mode 100644 index 0000000..08eba02 --- /dev/null +++ b/internal/database/list.go @@ -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 +} diff --git a/internal/database/postgres_migrate.go b/internal/database/postgres_migrate.go index aacae1f..8a76a1a 100644 --- a/internal/database/postgres_migrate.go +++ b/internal/database/postgres_migrate.go @@ -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) +); `, } diff --git a/internal/database/sqlite_migrate.go b/internal/database/sqlite_migrate.go index ce00e4f..67c96e4 100644 --- a/internal/database/sqlite_migrate.go +++ b/internal/database/sqlite_migrate.go @@ -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) +); `, } diff --git a/internal/domain/client.go b/internal/domain/client.go index 914aad0..2349411 100644 --- a/internal/domain/client.go +++ b/internal/domain/client.go @@ -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"` +} diff --git a/internal/domain/filter.go b/internal/domain/filter.go index f03f441..d6db986 100644 --- a/internal/domain/filter.go +++ b/internal/domain/filter.go @@ -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"` diff --git a/internal/domain/list.go b/internal/domain/list.go new file mode 100644 index 0000000..359f630 --- /dev/null +++ b/internal/domain/list.go @@ -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"` +} diff --git a/internal/download_client/connection.go b/internal/download_client/connection.go index b33a1df..d58fc5f 100644 --- a/internal/download_client/connection.go +++ b/internal/download_client/connection.go @@ -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" diff --git a/internal/download_client/service.go b/internal/download_client/service.go index b89a7a5..9d29d29 100644 --- a/internal/download_client/service.go +++ b/internal/download_client/service.go @@ -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 { diff --git a/internal/filter/service.go b/internal/filter/service.go index c144045..e0099fb 100644 --- a/internal/filter/service.go +++ b/internal/filter/service.go @@ -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) diff --git a/internal/http/download_client.go b/internal/http/download_client.go index 45b2a23..4db1f04 100644 --- a/internal/http/download_client.go +++ b/internal/http/download_client.go @@ -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 { diff --git a/internal/http/list.go b/internal/http/list.go new file mode 100644 index 0000000..8872639 --- /dev/null +++ b/internal/http/list.go @@ -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) +} diff --git a/internal/http/server.go b/internal/http/server.go index 0535f45..3d8b6f5 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -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) { diff --git a/internal/http/webhook.go b/internal/http/webhook.go new file mode 100644 index 0000000..8e97571 --- /dev/null +++ b/internal/http/webhook.go @@ -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) +} diff --git a/internal/list/process_arr_lidarr.go b/internal/list/process_arr_lidarr.go new file mode 100644 index 0000000..c9267e4 --- /dev/null +++ b/internal/list/process_arr_lidarr.go @@ -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 +} diff --git a/internal/list/process_arr_radarr.go b/internal/list/process_arr_radarr.go new file mode 100644 index 0000000..1dff6bc --- /dev/null +++ b/internal/list/process_arr_radarr.go @@ -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 = "" diff --git a/internal/list/process_arr_readarr.go b/internal/list/process_arr_readarr.go new file mode 100644 index 0000000..2bb6765 --- /dev/null +++ b/internal/list/process_arr_readarr.go @@ -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 +} diff --git a/internal/list/process_arr_sonarr.go b/internal/list/process_arr_sonarr.go new file mode 100644 index 0000000..7d3efe2 --- /dev/null +++ b/internal/list/process_arr_sonarr.go @@ -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 +} diff --git a/internal/list/process_list_mdblist.go b/internal/list/process_list_mdblist.go new file mode 100644 index 0000000..37baca1 --- /dev/null +++ b/internal/list/process_list_mdblist.go @@ -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 +} diff --git a/internal/list/process_list_metacritic.go b/internal/list/process_list_metacritic.go new file mode 100644 index 0000000..03f1f52 --- /dev/null +++ b/internal/list/process_list_metacritic.go @@ -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 +} diff --git a/internal/list/process_list_plaintext.go b/internal/list/process_list_plaintext.go new file mode 100644 index 0000000..29a9ef4 --- /dev/null +++ b/internal/list/process_list_plaintext.go @@ -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 +} diff --git a/internal/list/process_list_steam.go b/internal/list/process_list_steam.go new file mode 100644 index 0000000..c0a8574 --- /dev/null +++ b/internal/list/process_list_steam.go @@ -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 +} diff --git a/internal/list/process_list_trakt.go b/internal/list/process_list_trakt.go new file mode 100644 index 0000000..3985f21 --- /dev/null +++ b/internal/list/process_list_trakt.go @@ -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 +} diff --git a/internal/list/scheduled_jobs.go b/internal/list/scheduled_jobs.go new file mode 100644 index 0000000..538724c --- /dev/null +++ b/internal/list/scheduled_jobs.go @@ -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 +} diff --git a/internal/list/service.go b/internal/list/service.go new file mode 100644 index 0000000..5537cf0 --- /dev/null +++ b/internal/list/service.go @@ -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") + } +} diff --git a/internal/list/tags.go b/internal/list/tags.go new file mode 100644 index 0000000..68ed4cf --- /dev/null +++ b/internal/list/tags.go @@ -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 +} diff --git a/internal/list/title.go b/internal/list/title.go new file mode 100644 index 0000000..232c2a1 --- /dev/null +++ b/internal/list/title.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go index f76b556..3e2c1e3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 } diff --git a/pkg/lidarr/client.go b/pkg/arr/lidarr/client.go similarity index 70% rename from pkg/lidarr/client.go rename to pkg/arr/lidarr/client.go index e29f488..146cadf 100644 --- a/pkg/lidarr/client.go +++ b/pkg/arr/lidarr/client.go @@ -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") } diff --git a/pkg/lidarr/lidarr.go b/pkg/arr/lidarr/lidarr.go similarity index 56% rename from pkg/lidarr/lidarr.go rename to pkg/arr/lidarr/lidarr.go index 1b8d446..4dfefa6 100644 --- a/pkg/lidarr/lidarr.go +++ b/pkg/arr/lidarr/lidarr.go @@ -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 +} diff --git a/pkg/lidarr/lidarr_test.go b/pkg/arr/lidarr/lidarr_test.go similarity index 100% rename from pkg/lidarr/lidarr_test.go rename to pkg/arr/lidarr/lidarr_test.go diff --git a/pkg/lidarr/testdata/release_push_response.json b/pkg/arr/lidarr/testdata/release_push_response.json similarity index 100% rename from pkg/lidarr/testdata/release_push_response.json rename to pkg/arr/lidarr/testdata/release_push_response.json diff --git a/pkg/lidarr/testdata/system_status_response.json b/pkg/arr/lidarr/testdata/system_status_response.json similarity index 100% rename from pkg/lidarr/testdata/system_status_response.json rename to pkg/arr/lidarr/testdata/system_status_response.json diff --git a/pkg/arr/lidarr/types.go b/pkg/arr/lidarr/types.go new file mode 100644 index 0000000..1e87db5 --- /dev/null +++ b/pkg/arr/lidarr/types.go @@ -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"` +} diff --git a/pkg/radarr/client.go b/pkg/arr/radarr/client.go similarity index 77% rename from pkg/radarr/client.go rename to pkg/arr/radarr/client.go index c3acacc..cd92e22 100644 --- a/pkg/radarr/client.go +++ b/pkg/arr/radarr/client.go @@ -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") } diff --git a/pkg/radarr/radarr.go b/pkg/arr/radarr/radarr.go similarity index 62% rename from pkg/radarr/radarr.go rename to pkg/arr/radarr/radarr.go index 3ac5c46..e83b78d 100644 --- a/pkg/radarr/radarr.go +++ b/pkg/arr/radarr/radarr.go @@ -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 +} diff --git a/pkg/radarr/radarr_test.go b/pkg/arr/radarr/radarr_test.go similarity index 100% rename from pkg/radarr/radarr_test.go rename to pkg/arr/radarr/radarr_test.go diff --git a/pkg/radarr/testdata/release_push_parse_error.json b/pkg/arr/radarr/testdata/release_push_parse_error.json similarity index 100% rename from pkg/radarr/testdata/release_push_parse_error.json rename to pkg/arr/radarr/testdata/release_push_parse_error.json diff --git a/pkg/radarr/testdata/release_push_response.json b/pkg/arr/radarr/testdata/release_push_response.json similarity index 100% rename from pkg/radarr/testdata/release_push_response.json rename to pkg/arr/radarr/testdata/release_push_response.json diff --git a/pkg/radarr/testdata/system_status_response.json b/pkg/arr/radarr/testdata/system_status_response.json similarity index 100% rename from pkg/radarr/testdata/system_status_response.json rename to pkg/arr/radarr/testdata/system_status_response.json diff --git a/pkg/arr/radarr/types.go b/pkg/arr/radarr/types.go new file mode 100644 index 0000000..5f49203 --- /dev/null +++ b/pkg/arr/radarr/types.go @@ -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) +} diff --git a/pkg/readarr/client.go b/pkg/arr/readarr/client.go similarity index 77% rename from pkg/readarr/client.go rename to pkg/arr/readarr/client.go index f4a401f..174db12 100644 --- a/pkg/readarr/client.go +++ b/pkg/arr/readarr/client.go @@ -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") } diff --git a/pkg/readarr/readarr.go b/pkg/arr/readarr/readarr.go similarity index 62% rename from pkg/readarr/readarr.go rename to pkg/arr/readarr/readarr.go index e29dce8..5c6e28c 100644 --- a/pkg/readarr/readarr.go +++ b/pkg/arr/readarr/readarr.go @@ -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 +} diff --git a/pkg/readarr/readarr_test.go b/pkg/arr/readarr/readarr_test.go similarity index 100% rename from pkg/readarr/readarr_test.go rename to pkg/arr/readarr/readarr_test.go diff --git a/pkg/readarr/testdata/release_push_ok_response.json b/pkg/arr/readarr/testdata/release_push_ok_response.json similarity index 100% rename from pkg/readarr/testdata/release_push_ok_response.json rename to pkg/arr/readarr/testdata/release_push_ok_response.json diff --git a/pkg/readarr/testdata/system_status_response_ok.json b/pkg/arr/readarr/testdata/system_status_response_ok.json similarity index 100% rename from pkg/readarr/testdata/system_status_response_ok.json rename to pkg/arr/readarr/testdata/system_status_response_ok.json diff --git a/pkg/arr/readarr/types.go b/pkg/arr/readarr/types.go new file mode 100644 index 0000000..3ac9804 --- /dev/null +++ b/pkg/arr/readarr/types.go @@ -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"` +} diff --git a/pkg/arr/shared.go b/pkg/arr/shared.go new file mode 100644 index 0000000..d194523 --- /dev/null +++ b/pkg/arr/shared.go @@ -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"` +} diff --git a/pkg/sonarr/client.go b/pkg/arr/sonarr/client.go similarity index 76% rename from pkg/sonarr/client.go rename to pkg/arr/sonarr/client.go index ababdc2..1913cee 100644 --- a/pkg/sonarr/client.go +++ b/pkg/arr/sonarr/client.go @@ -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") } diff --git a/pkg/sonarr/sonarr.go b/pkg/arr/sonarr/sonarr.go similarity index 62% rename from pkg/sonarr/sonarr.go rename to pkg/arr/sonarr/sonarr.go index 81e3377..f07091c 100644 --- a/pkg/sonarr/sonarr.go +++ b/pkg/arr/sonarr/sonarr.go @@ -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 +} diff --git a/pkg/sonarr/sonarr_test.go b/pkg/arr/sonarr/sonarr_test.go similarity index 100% rename from pkg/sonarr/sonarr_test.go rename to pkg/arr/sonarr/sonarr_test.go diff --git a/pkg/sonarr/testdata/release_push_response.json b/pkg/arr/sonarr/testdata/release_push_response.json similarity index 100% rename from pkg/sonarr/testdata/release_push_response.json rename to pkg/arr/sonarr/testdata/release_push_response.json diff --git a/pkg/sonarr/testdata/system_status_response.json b/pkg/arr/sonarr/testdata/system_status_response.json similarity index 100% rename from pkg/sonarr/testdata/system_status_response.json rename to pkg/arr/sonarr/testdata/system_status_response.json diff --git a/pkg/arr/sonarr/types.go b/pkg/arr/sonarr/types.go new file mode 100644 index 0000000..6d37ab6 --- /dev/null +++ b/pkg/arr/sonarr/types.go @@ -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"` +} diff --git a/web/src/api/APIClient.ts b/web/src/api/APIClient.ts index 770ce6d..5139b17 100644 --- a/web/src/api/APIClient.ts +++ b/web/src/api/APIClient.ts @@ -296,6 +296,7 @@ export const APIClient = { }, download_clients: { getAll: () => appClient.Get("api/download_clients"), + getArrTags: (clientID: number) => appClient.Get(`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("api/lists"), + getByID: (id: number) => appClient.Get(`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("api/proxy"), getByID: (id: number) => appClient.Get(`api/proxy/${id}`), diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index 0cf21c6..37d8fc7 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -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 + }); diff --git a/web/src/api/query_keys.ts b/web/src/api/query_keys.ts index a7bd766..5614e1f 100644 --- a/web/src/api/query_keys.ts +++ b/web/src/api/query_keys.ts @@ -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 +}; diff --git a/web/src/components/inputs/input_wide.tsx b/web/src/components/inputs/input_wide.tsx index bba4af5..d56fd4d 100644 --- a/web/src/components/inputs/input_wide.tsx +++ b/web/src/components/inputs/input_wide.tsx @@ -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) => ( {description && ( - + {description} )} diff --git a/web/src/components/inputs/select_wide.tsx b/web/src/components/inputs/select_wide.tsx index 9666a34..79f504d 100644 --- a/web/src/components/inputs/select_wide.tsx +++ b/web/src/components/inputs/select_wide.tsx @@ -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 { name: string; @@ -228,3 +231,67 @@ export function SelectFieldBasic({ name, label, help, placeholder, required, ); } + +export interface MultiSelectFieldProps { + name: string; + label: string; + help?: string; + placeholder?: string; + required?: boolean; + tooltip?: JSX.Element; + options: OptionBasicTyped[]; +} + +interface ListFilterMultiSelectOption { + id: number; + name: string; +} + +export function ListFilterMultiSelectField({ name, label, help, tooltip, options }: MultiSelectFieldProps) { + return ( +
+
+ +
+
+ + {({ + field, + form: { setFieldValue } + }: FieldProps) => ( + <> + ({ + 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); + }} + /> + + )} + + {help && ( +

{help}

+ )} +
+
+ ); +} diff --git a/web/src/domain/constants.ts b/web/src/domain/constants.ts index 4adba58..03a80d9 100644 --- a/web/src/domain/constants.ts +++ b/web/src/domain/constants.ts @@ -409,6 +409,49 @@ export const PushStatusOptions: OptionBasic[] = [ } ]; +export const ListTypeOptions: OptionBasicTyped[] = [ + { + 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[] = [ { label: "Discord", @@ -607,3 +650,48 @@ export const ProxyTypeOptions: OptionBasicTyped[] = [ 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" + }, +]; diff --git a/web/src/forms/index.ts b/web/src/forms/index.ts index 937d13f..5480acd 100644 --- a/web/src/forms/index.ts +++ b/web/src/forms/index.ts @@ -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"; + diff --git a/web/src/forms/settings/ListForms.tsx b/web/src/forms/settings/ListForms.tsx new file mode 100644 index 0000000..14f04c4 --- /dev/null +++ b/web/src/forms/settings/ListForms.tsx @@ -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) => ); + toggle(); + }, + onError: () => { + toast.custom((t) => ); + } + }); + + const onSubmit = (formData: unknown) => createMutation.mutate(formData as List); + + const validate = (values: ListAddFormValues) => { + const errors = {} as FormikErrors; + if (!values.name) + errors.name = "Required"; + + return errors; + }; + + return ( + + +
+ + +
+ + {({ values }) => ( +
+
+
+
+
+ + Add List + +

+ Auto update filters from lists and arrs. +

+
+
+ +
+
+
+ +
+ + +
+
+ +
+
+ + {({ + field, + form: { setFieldValue } + }: FieldProps) => ( +