From 6898ad83158ef24afab7fd3f56db2adef1deedd2 Mon Sep 17 00:00:00 2001 From: ze0s <43699394+zze0s@users.noreply.github.com> Date: Mon, 15 May 2023 21:30:04 +0200 Subject: [PATCH] feat(releases): replay actions (#932) * feat(releases): replay actions * feat(releases): replay actions component * fix: update filter actions * fix: select filter_id from ras --- internal/action/run.go | 2 +- internal/action/service.go | 34 ++- internal/database/action.go | 361 +++++++++++++++++------- internal/database/postgres_migrate.go | 8 + internal/database/release.go | 113 +++++++- internal/database/sqlite_migrate.go | 57 ++++ internal/domain/action.go | 13 +- internal/domain/release.go | 32 ++- internal/http/action.go | 39 ++- internal/http/release.go | 49 ++++ internal/release/service.go | 82 +++++- web/src/api/APIClient.ts | 3 +- web/src/components/data-table/Cells.tsx | 120 ++++++-- web/src/components/tooltips/Tooltip.tsx | 2 +- web/src/screens/filters/action.tsx | 23 +- web/src/types/Release.d.ts | 3 + 16 files changed, 752 insertions(+), 189 deletions(-) diff --git a/internal/action/run.go b/internal/action/run.go index c5df557..eadde96 100644 --- a/internal/action/run.go +++ b/internal/action/run.go @@ -99,7 +99,7 @@ func (s *service) RunAction(ctx context.Context, action *domain.Action, release payload := &domain.NotificationPayload{ Event: domain.NotificationEventPushApproved, ReleaseName: release.TorrentName, - Filter: release.Filter.Name, + Filter: release.FilterName, Indexer: release.Indexer, InfoHash: release.TorrentHash, Size: release.Size, diff --git a/internal/action/service.go b/internal/action/service.go index 1326596..867b27d 100644 --- a/internal/action/service.go +++ b/internal/action/service.go @@ -19,7 +19,8 @@ import ( type Service interface { Store(ctx context.Context, action domain.Action) (*domain.Action, error) List(ctx context.Context) ([]domain.Action, error) - Delete(actionID int) error + Get(ctx context.Context, req *domain.GetActionRequest) (*domain.Action, error) + Delete(ctx context.Context, req *domain.DeleteActionRequest) error DeleteByFilterID(ctx context.Context, filterID int) error ToggleEnabled(actionID int) error @@ -51,18 +52,37 @@ func (s *service) Store(ctx context.Context, action domain.Action) (*domain.Acti return s.repo.Store(ctx, action) } -func (s *service) Delete(actionID int) error { - return s.repo.Delete(actionID) +func (s *service) List(ctx context.Context) ([]domain.Action, error) { + return s.repo.List(ctx) +} + +func (s *service) Get(ctx context.Context, req *domain.GetActionRequest) (*domain.Action, error) { + a, err := s.repo.Get(ctx, req) + if err != nil { + return nil, err + } + + // optionally attach download client to action + if a.ClientID > 0 { + client, err := s.clientSvc.FindByID(ctx, a.ClientID) + if err != nil { + return nil, err + } + + a.Client = client + } + + return a, nil +} + +func (s *service) Delete(ctx context.Context, req *domain.DeleteActionRequest) error { + return s.repo.Delete(ctx, req) } func (s *service) DeleteByFilterID(ctx context.Context, filterID int) error { return s.repo.DeleteByFilterID(ctx, filterID) } -func (s *service) List(ctx context.Context) ([]domain.Action, error) { - return s.repo.List(ctx) -} - func (s *service) ToggleEnabled(actionID int) error { return s.repo.ToggleEnabled(actionID) } diff --git a/internal/database/action.go b/internal/database/action.go index 21c0085..c1590c8 100644 --- a/internal/database/action.go +++ b/internal/database/action.go @@ -280,30 +280,117 @@ func (r *ActionRepo) List(ctx context.Context) ([]domain.Action, error) { a.ClientID = clientID.Int32 actions = append(actions, a) - } - if err := rows.Err(); err != nil { - return nil, errors.Wrap(err, "rows error") + + if err := rows.Err(); err != nil { + return nil, errors.Wrap(err, "rows error") + } } return actions, nil } -func (r *ActionRepo) Delete(actionID int) error { +func (r *ActionRepo) Get(ctx context.Context, req *domain.GetActionRequest) (*domain.Action, error) { + queryBuilder := r.db.squirrel. + Select( + "id", + "name", + "type", + "enabled", + "exec_cmd", + "exec_args", + "watch_folder", + "category", + "tags", + "label", + "save_path", + "paused", + "ignore_rules", + "limit_download_speed", + "limit_upload_speed", + "limit_ratio", + "limit_seed_time", + "reannounce_skip", + "reannounce_delete", + "reannounce_interval", + "reannounce_max_attempts", + "webhook_host", + "webhook_type", + "webhook_method", + "webhook_data", + "client_id", + "filter_id", + ). + From("action"). + Where(sq.Eq{"id": req.Id}) + + query, args, err := queryBuilder.ToSql() + if err != nil { + return nil, errors.Wrap(err, "error building query") + } + + row := r.db.handler.QueryRowContext(ctx, query, args...) + if err != nil { + return nil, errors.Wrap(err, "error executing query") + } + + if err := row.Err(); err != nil { + return nil, errors.Wrap(err, "rows error") + } + + var a domain.Action + + var execCmd, execArgs, watchFolder, category, tags, label, savePath, webhookHost, webhookType, webhookMethod, webhookData sql.NullString + var limitUl, limitDl, limitSeedTime sql.NullInt64 + var limitRatio sql.NullFloat64 + var clientID, filterID sql.NullInt32 + var paused, ignoreRules sql.NullBool + + if err := row.Scan(&a.ID, &a.Name, &a.Type, &a.Enabled, &execCmd, &execArgs, &watchFolder, &category, &tags, &label, &savePath, &paused, &ignoreRules, &limitDl, &limitUl, &limitRatio, &limitSeedTime, &a.ReAnnounceSkip, &a.ReAnnounceDelete, &a.ReAnnounceInterval, &a.ReAnnounceMaxAttempts, &webhookHost, &webhookType, &webhookMethod, &webhookData, &clientID, &filterID); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + + return nil, errors.Wrap(err, "error scanning row") + } + + a.Category = category.String + a.Tags = tags.String + a.Label = label.String + a.SavePath = savePath.String + a.Paused = paused.Bool + a.IgnoreRules = ignoreRules.Bool + + a.LimitDownloadSpeed = limitDl.Int64 + a.LimitUploadSpeed = limitUl.Int64 + a.LimitRatio = limitRatio.Float64 + a.LimitSeedTime = limitSeedTime.Int64 + + a.WebhookHost = webhookHost.String + a.WebhookType = webhookType.String + a.WebhookMethod = webhookMethod.String + a.WebhookData = webhookData.String + + a.ClientID = clientID.Int32 + a.FilterID = int(filterID.Int32) + + return &a, nil +} + +func (r *ActionRepo) Delete(ctx context.Context, req *domain.DeleteActionRequest) error { queryBuilder := r.db.squirrel. Delete("action"). - Where(sq.Eq{"id": actionID}) + Where(sq.Eq{"id": req.ActionId}) query, args, err := queryBuilder.ToSql() if err != nil { return errors.Wrap(err, "error building query") } - _, err = r.db.handler.Exec(query, args...) - if err != nil { + if _, err = r.db.handler.ExecContext(ctx, query, args...); err != nil { return errors.Wrap(err, "error executing query") } - r.log.Debug().Msgf("action.delete: %v", actionID) + r.log.Debug().Msgf("action.delete: %v", req.ActionId) return nil } @@ -506,113 +593,171 @@ func (r *ActionRepo) StoreFilterActions(ctx context.Context, actions []*domain.A defer tx.Rollback() - deleteQueryBuilder := r.db.squirrel. - Delete("action"). - Where(sq.Eq{"filter_id": filterID}) - - deleteQuery, deleteArgs, err := deleteQueryBuilder.ToSql() - if err != nil { - return nil, errors.Wrap(err, "error building query") - } - _, err = tx.ExecContext(ctx, deleteQuery, deleteArgs...) - if err != nil { - return nil, errors.Wrap(err, "error executing query") - } - for _, action := range actions { - execCmd := toNullString(action.ExecCmd) - execArgs := toNullString(action.ExecArgs) - watchFolder := toNullString(action.WatchFolder) - category := toNullString(action.Category) - tags := toNullString(action.Tags) - label := toNullString(action.Label) - savePath := toNullString(action.SavePath) - contentLayout := toNullString(string(action.ContentLayout)) - webhookHost := toNullString(action.WebhookHost) - webhookType := toNullString(action.WebhookType) - webhookMethod := toNullString(action.WebhookMethod) - webhookData := toNullString(action.WebhookData) + action := action - limitDL := toNullInt64(action.LimitDownloadSpeed) - limitUL := toNullInt64(action.LimitUploadSpeed) - limitRatio := toNullFloat64(action.LimitRatio) - limitSeedTime := toNullInt64(action.LimitSeedTime) - clientID := toNullInt32(action.ClientID) + if action.ID > 0 { + execCmd := toNullString(action.ExecCmd) + execArgs := toNullString(action.ExecArgs) + watchFolder := toNullString(action.WatchFolder) + category := toNullString(action.Category) + tags := toNullString(action.Tags) + label := toNullString(action.Label) + savePath := toNullString(action.SavePath) + contentLayout := toNullString(string(action.ContentLayout)) + webhookHost := toNullString(action.WebhookHost) + webhookType := toNullString(action.WebhookType) + webhookMethod := toNullString(action.WebhookMethod) + webhookData := toNullString(action.WebhookData) - queryBuilder := r.db.squirrel. - Insert("action"). - Columns( - "name", - "type", - "enabled", - "exec_cmd", - "exec_args", - "watch_folder", - "category", - "tags", - "label", - "save_path", - "paused", - "ignore_rules", - "skip_hash_check", - "content_layout", - "limit_upload_speed", - "limit_download_speed", - "limit_ratio", - "limit_seed_time", - "reannounce_skip", - "reannounce_delete", - "reannounce_interval", - "reannounce_max_attempts", - "webhook_host", - "webhook_type", - "webhook_method", - "webhook_data", - "client_id", - "filter_id", - ). - Values( - action.Name, - action.Type, - action.Enabled, - execCmd, - execArgs, - watchFolder, - category, - tags, - label, - savePath, - action.Paused, - action.IgnoreRules, - action.SkipHashCheck, - contentLayout, - limitUL, - limitDL, - limitRatio, - limitSeedTime, - action.ReAnnounceSkip, - action.ReAnnounceDelete, - action.ReAnnounceInterval, - action.ReAnnounceMaxAttempts, - webhookHost, - webhookType, - webhookMethod, - webhookData, - clientID, - filterID, - ). - Suffix("RETURNING id").RunWith(tx) + limitDL := toNullInt64(action.LimitDownloadSpeed) + limitUL := toNullInt64(action.LimitUploadSpeed) + limitRatio := toNullFloat64(action.LimitRatio) + limitSeedTime := toNullInt64(action.LimitSeedTime) - // return values - var retID int + clientID := toNullInt32(action.ClientID) - err = queryBuilder.QueryRowContext(ctx).Scan(&retID) - if err != nil { - return nil, errors.Wrap(err, "error executing query") + var err error + + queryBuilder := r.db.squirrel. + Update("action"). + Set("name", action.Name). + Set("type", action.Type). + Set("enabled", action.Enabled). + Set("exec_cmd", execCmd). + Set("exec_args", execArgs). + Set("watch_folder", watchFolder). + Set("category", category). + Set("tags", tags). + Set("label", label). + Set("save_path", savePath). + Set("paused", action.Paused). + Set("ignore_rules", action.IgnoreRules). + Set("skip_hash_check", action.SkipHashCheck). + Set("content_layout", contentLayout). + Set("limit_upload_speed", limitUL). + Set("limit_download_speed", limitDL). + Set("limit_ratio", limitRatio). + Set("limit_seed_time", limitSeedTime). + Set("reannounce_skip", action.ReAnnounceSkip). + Set("reannounce_delete", action.ReAnnounceDelete). + Set("reannounce_interval", action.ReAnnounceInterval). + Set("reannounce_max_attempts", action.ReAnnounceMaxAttempts). + Set("webhook_host", webhookHost). + Set("webhook_type", webhookType). + Set("webhook_method", webhookMethod). + Set("webhook_data", webhookData). + Set("client_id", clientID). + Set("filter_id", filterID). + Where(sq.Eq{"id": action.ID}) + + query, args, err := queryBuilder.ToSql() + if err != nil { + return nil, errors.Wrap(err, "error building query") + } + + if _, err = tx.ExecContext(ctx, query, args...); err != nil { + return nil, errors.Wrap(err, "error executing query") + } + + r.log.Trace().Msgf("action.StoreFilterActions: update %d", action.ID) + + } else { + execCmd := toNullString(action.ExecCmd) + execArgs := toNullString(action.ExecArgs) + watchFolder := toNullString(action.WatchFolder) + category := toNullString(action.Category) + tags := toNullString(action.Tags) + label := toNullString(action.Label) + savePath := toNullString(action.SavePath) + contentLayout := toNullString(string(action.ContentLayout)) + webhookHost := toNullString(action.WebhookHost) + webhookType := toNullString(action.WebhookType) + webhookMethod := toNullString(action.WebhookMethod) + webhookData := toNullString(action.WebhookData) + + limitDL := toNullInt64(action.LimitDownloadSpeed) + limitUL := toNullInt64(action.LimitUploadSpeed) + limitRatio := toNullFloat64(action.LimitRatio) + limitSeedTime := toNullInt64(action.LimitSeedTime) + clientID := toNullInt32(action.ClientID) + + queryBuilder := r.db.squirrel. + Insert("action"). + Columns( + "name", + "type", + "enabled", + "exec_cmd", + "exec_args", + "watch_folder", + "category", + "tags", + "label", + "save_path", + "paused", + "ignore_rules", + "skip_hash_check", + "content_layout", + "limit_upload_speed", + "limit_download_speed", + "limit_ratio", + "limit_seed_time", + "reannounce_skip", + "reannounce_delete", + "reannounce_interval", + "reannounce_max_attempts", + "webhook_host", + "webhook_type", + "webhook_method", + "webhook_data", + "client_id", + "filter_id", + ). + Values( + action.Name, + action.Type, + action.Enabled, + execCmd, + execArgs, + watchFolder, + category, + tags, + label, + savePath, + action.Paused, + action.IgnoreRules, + action.SkipHashCheck, + contentLayout, + limitUL, + limitDL, + limitRatio, + limitSeedTime, + action.ReAnnounceSkip, + action.ReAnnounceDelete, + action.ReAnnounceInterval, + action.ReAnnounceMaxAttempts, + webhookHost, + webhookType, + webhookMethod, + webhookData, + clientID, + filterID, + ). + Suffix("RETURNING id").RunWith(tx) + + // return values + var retID int + + if err = queryBuilder.QueryRowContext(ctx).Scan(&retID); err != nil { + return nil, errors.Wrap(err, "error executing query") + } + + action.ID = retID + + r.log.Trace().Msgf("action.StoreFilterActions: store %d", action.ID) } - action.ID = retID - r.log.Debug().Msgf("action.StoreFilterActions: store '%v' type: '%v' on filter: %v", action.Name, action.Type, filterID) } diff --git a/internal/database/postgres_migrate.go b/internal/database/postgres_migrate.go index 633dbb9..be225f8 100644 --- a/internal/database/postgres_migrate.go +++ b/internal/database/postgres_migrate.go @@ -271,6 +271,7 @@ CREATE TABLE release_action_status id SERIAL PRIMARY KEY, status TEXT, action TEXT NOT NULL, + action_id INTEGER, type TEXT NOT NULL, client TEXT, filter TEXT, @@ -280,6 +281,7 @@ CREATE TABLE release_action_status raw TEXT, log TEXT, release_id INTEGER NOT NULL, + FOREIGN KEY (action_id) REFERENCES "action"(id), FOREIGN KEY (release_id) REFERENCES "release"(id) ON DELETE CASCADE, FOREIGN KEY (filter_id) REFERENCES "filter"(id) ON DELETE SET NULL ); @@ -691,4 +693,10 @@ ADD COLUMN topic text;`, ALTER TABLE filter ADD COLUMN use_regex_description BOOLEAN DEFAULT FALSE;`, + `ALTER TABLE release_action_status + ADD action_id INTEGER; + +ALTER TABLE release_action_status + ADD CONSTRAINT release_action_status_action_id_fk + FOREIGN KEY (action_id) REFERENCES action;`, } diff --git a/internal/database/release.go b/internal/database/release.go index 2c15807..6b17e82 100644 --- a/internal/database/release.go +++ b/internal/database/release.go @@ -79,8 +79,8 @@ func (repo *ReleaseRepo) StoreReleaseActionStatus(ctx context.Context, status *d } else { queryBuilder := repo.db.squirrel. Insert("release_action_status"). - Columns("status", "action", "type", "client", "filter", "filter_id", "rejections", "timestamp", "release_id"). - Values(status.Status, status.Action, status.Type, status.Client, status.Filter, status.FilterID, pq.Array(status.Rejections), status.Timestamp.Format(time.RFC3339), status.ReleaseID). + Columns("status", "action", "action_id", "type", "client", "filter", "filter_id", "rejections", "timestamp", "release_id"). + Values(status.Status, status.Action, status.ActionID, status.Type, status.Client, status.Filter, status.FilterID, pq.Array(status.Rejections), status.Timestamp.Format(time.RFC3339), status.ReleaseID). Suffix("RETURNING id").RunWith(repo.db.handler) // return values @@ -207,7 +207,7 @@ func (repo *ReleaseRepo) findReleases(ctx context.Context, tx *Tx, params domain queryBuilder := repo.db.squirrel. Select("r.id", "r.filter_status", "r.rejections", "r.indexer", "r.filter", "r.protocol", "r.info_url", "r.download_url", "r.title", "r.torrent_name", "r.size", "r.timestamp", - "ras.id", "ras.status", "ras.action", "ras.type", "ras.client", "ras.filter", "ras.rejections", "ras.timestamp"). + "ras.id", "ras.status", "ras.action", "ras.action_id", "ras.type", "ras.client", "ras.filter", "ras.filter_id", "ras.release_id", "ras.rejections", "ras.timestamp"). Column(sq.Alias(countQuery, "page_total")). From("release r"). OrderBy("r.id DESC"). @@ -242,22 +242,25 @@ func (repo *ReleaseRepo) findReleases(ctx context.Context, tx *Tx, params domain var rlsindexer, rlsfilter, infoUrl, downloadUrl sql.NullString - var rasId sql.NullInt64 + var rasId, rasFilterId, rasReleaseId, rasActionId sql.NullInt64 var rasStatus, rasAction, rasType, rasClient, rasFilter sql.NullString var rasRejections []sql.NullString var rasTimestamp sql.NullTime - if err := rows.Scan(&rls.ID, &rls.FilterStatus, pq.Array(&rls.Rejections), &rlsindexer, &rlsfilter, &rls.Protocol, &infoUrl, &downloadUrl, &rls.Title, &rls.TorrentName, &rls.Size, &rls.Timestamp, &rasId, &rasStatus, &rasAction, &rasType, &rasClient, &rasFilter, pq.Array(&rasRejections), &rasTimestamp, &countItems); err != nil { + if err := rows.Scan(&rls.ID, &rls.FilterStatus, pq.Array(&rls.Rejections), &rlsindexer, &rlsfilter, &rls.Protocol, &infoUrl, &downloadUrl, &rls.Title, &rls.TorrentName, &rls.Size, &rls.Timestamp, &rasId, &rasStatus, &rasAction, &rasActionId, &rasType, &rasClient, &rasFilter, &rasFilterId, &rasReleaseId, pq.Array(&rasRejections), &rasTimestamp, &countItems); err != nil { return res, 0, 0, errors.Wrap(err, "error scanning row") } ras.ID = rasId.Int64 ras.Status = domain.ReleasePushStatus(rasStatus.String) ras.Action = rasAction.String + ras.ActionID = rasActionId.Int64 ras.Type = domain.ActionType(rasType.String) ras.Client = rasClient.String ras.Filter = rasFilter.String + ras.FilterID = rasFilterId.Int64 ras.Timestamp = rasTimestamp.Time + ras.ReleaseID = rasReleaseId.Int64 ras.Rejections = []string{} for _, rejection := range rasRejections { @@ -351,7 +354,7 @@ func (repo *ReleaseRepo) GetIndexerOptions(ctx context.Context) ([]string, error func (repo *ReleaseRepo) GetActionStatusByReleaseID(ctx context.Context, releaseID int64) ([]domain.ReleaseActionStatus, error) { queryBuilder := repo.db.squirrel. - Select("id", "status", "action", "type", "client", "filter", "rejections", "timestamp"). + Select("id", "status", "action", "action_id", "type", "client", "filter", "release_id", "rejections", "timestamp"). From("release_action_status"). Where(sq.Eq{"release_id": releaseID}) @@ -378,11 +381,13 @@ func (repo *ReleaseRepo) GetActionStatusByReleaseID(ctx context.Context, release var rls domain.ReleaseActionStatus var client, filter sql.NullString + var actionId sql.NullInt64 - if err := rows.Scan(&rls.ID, &rls.Status, &rls.Action, &rls.Type, &client, &filter, pq.Array(&rls.Rejections), &rls.Timestamp); err != nil { + if err := rows.Scan(&rls.ID, &rls.Status, &rls.Action, &actionId, &rls.Type, &client, &filter, &rls.ReleaseID, pq.Array(&rls.Rejections), &rls.Timestamp); err != nil { return res, errors.Wrap(err, "error scanning row") } + rls.ActionID = actionId.Int64 rls.Client = client.String rls.Filter = filter.String @@ -392,9 +397,96 @@ func (repo *ReleaseRepo) GetActionStatusByReleaseID(ctx context.Context, release return res, nil } +func (repo *ReleaseRepo) Get(ctx context.Context, req *domain.GetReleaseRequest) (*domain.Release, error) { + queryBuilder := repo.db.squirrel. + Select("r.id", "r.filter_status", "r.rejections", "r.indexer", "r.filter", "r.filter_id", "r.protocol", "r.info_url", "r.download_url", "r.title", "r.torrent_name", "r.size", "r.timestamp"). + From("release r"). + OrderBy("r.id DESC"). + Where(sq.Eq{"r.id": req.Id}) + + query, args, err := queryBuilder.ToSql() + if err != nil { + return nil, errors.Wrap(err, "error building query") + } + + repo.log.Trace().Str("database", "release.find").Msgf("query: '%v', args: '%v'", query, args) + + row := repo.db.handler.QueryRowContext(ctx, query, args...) + if err != nil { + return nil, errors.Wrap(err, "error executing query") + } + + if err := row.Err(); err != nil { + return nil, errors.Wrap(err, "error rows find release") + } + + var rls domain.Release + + var indexerName, filterName, infoUrl, downloadUrl sql.NullString + var filterId sql.NullInt64 + + if err := row.Scan(&rls.ID, &rls.FilterStatus, pq.Array(&rls.Rejections), &indexerName, &filterName, &filterId, &rls.Protocol, &infoUrl, &downloadUrl, &rls.Title, &rls.TorrentName, &rls.Size, &rls.Timestamp); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, errors.Wrap(err, "error scanning row") + } + + rls.Indexer = indexerName.String + rls.FilterName = filterName.String + rls.FilterID = int(filterId.Int64) + rls.ActionStatus = make([]domain.ReleaseActionStatus, 0) + rls.InfoURL = infoUrl.String + rls.TorrentURL = downloadUrl.String + + return &rls, nil +} + +func (repo *ReleaseRepo) GetActionStatus(ctx context.Context, req *domain.GetReleaseActionStatusRequest) (*domain.ReleaseActionStatus, error) { + queryBuilder := repo.db.squirrel. + Select("id", "status", "action", "action_id", "type", "client", "filter", "filter_id", "release_id", "rejections", "timestamp"). + From("release_action_status"). + Where(sq.Eq{"id": req.Id}) + + query, args, err := queryBuilder.ToSql() + if err != nil { + return nil, errors.Wrap(err, "error building query") + } + + row := repo.db.handler.QueryRowContext(ctx, query, args...) + if err != nil { + return nil, errors.Wrap(err, "error executing query") + } + + if err := row.Err(); err != nil { + repo.log.Error().Stack().Err(err) + return nil, err + } + + var rls domain.ReleaseActionStatus + + var client, filter sql.NullString + var actionId, filterId sql.NullInt64 + + if err := row.Scan(&rls.ID, &rls.Status, &rls.Action, &actionId, &rls.Type, &client, &filter, &filterId, &rls.ReleaseID, pq.Array(&rls.Rejections), &rls.Timestamp); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + + return nil, errors.Wrap(err, "error scanning row") + } + + rls.ActionID = actionId.Int64 + rls.Client = client.String + rls.Filter = filter.String + rls.FilterID = filterId.Int64 + + return &rls, nil +} + func (repo *ReleaseRepo) attachActionStatus(ctx context.Context, tx *Tx, releaseID int64) ([]domain.ReleaseActionStatus, error) { queryBuilder := repo.db.squirrel. - Select("id", "status", "action", "type", "client", "filter", "filter_id", "rejections", "timestamp"). + Select("id", "status", "action", "action_id", "type", "client", "filter", "filter_id", "release_id", "rejections", "timestamp"). From("release_action_status"). Where(sq.Eq{"release_id": releaseID}) @@ -420,12 +512,13 @@ func (repo *ReleaseRepo) attachActionStatus(ctx context.Context, tx *Tx, release var rls domain.ReleaseActionStatus var client, filter sql.NullString - var filterID sql.NullInt64 + var actionId, filterID sql.NullInt64 - if err := rows.Scan(&rls.ID, &rls.Status, &rls.Action, &rls.Type, &client, &filter, &filterID, pq.Array(&rls.Rejections), &rls.Timestamp); err != nil { + if err := rows.Scan(&rls.ID, &rls.Status, &rls.Action, &actionId, &rls.Type, &client, &filter, &filterID, &rls.ReleaseID, pq.Array(&rls.Rejections), &rls.Timestamp); err != nil { return res, errors.Wrap(err, "error scanning row") } + rls.ActionID = actionId.Int64 rls.Client = client.String rls.Filter = filter.String rls.FilterID = filterID.Int64 diff --git a/internal/database/sqlite_migrate.go b/internal/database/sqlite_migrate.go index 1fee991..4dc79ea 100644 --- a/internal/database/sqlite_migrate.go +++ b/internal/database/sqlite_migrate.go @@ -254,6 +254,9 @@ CREATE TABLE release_action_status id INTEGER PRIMARY KEY, status TEXT, action TEXT NOT NULL, + action_id INTEGER + CONSTRAINT release_action_status_action_id_fk + REFERENCES action, type TEXT NOT NULL, client TEXT, filter TEXT, @@ -1084,4 +1087,58 @@ ADD COLUMN topic text;`, ALTER TABLE filter ADD COLUMN use_regex_description BOOLEAN DEFAULT FALSE;`, + `create table release_action_status_dg_tmp +( + id INTEGER + primary key, + status TEXT, + action TEXT not null, + action_id INTEGER + constraint release_action_status_action_id_fk + references action, + type TEXT not null, + rejections TEXT default '{}' not null, + timestamp TIMESTAMP default CURRENT_TIMESTAMP, + raw TEXT, + log TEXT, + release_id INTEGER not null + constraint release_action_status_release_id_fkey + references "release" + on delete cascade, + client TEXT, + filter TEXT, + filter_id INTEGER + constraint release_action_status_filter_id_fk + references filter +); + +insert into release_action_status_dg_tmp(id, status, action, type, rejections, timestamp, raw, log, release_id, client, + filter, filter_id) +select id, + status, + action, + type, + rejections, + timestamp, + raw, + log, + release_id, + client, + filter, + filter_id +from release_action_status; + +drop table release_action_status; + +alter table release_action_status_dg_tmp + rename to release_action_status; + +create index release_action_status_filter_id_index + on release_action_status (filter_id); + +create index release_action_status_release_id_index + on release_action_status (release_id); + +create index release_action_status_status_index + on release_action_status (status);`, } diff --git a/internal/domain/action.go b/internal/domain/action.go index 4d42d3d..1e8c0a0 100644 --- a/internal/domain/action.go +++ b/internal/domain/action.go @@ -14,10 +14,11 @@ import ( type ActionRepo interface { Store(ctx context.Context, action Action) (*Action, error) StoreFilterActions(ctx context.Context, actions []*Action, filterID int64) ([]*Action, error) - DeleteByFilterID(ctx context.Context, filterID int) error FindByFilterID(ctx context.Context, filterID int) ([]*Action, error) List(ctx context.Context) ([]Action, error) - Delete(actionID int) error + Get(ctx context.Context, req *GetActionRequest) (*Action, error) + Delete(ctx context.Context, req *DeleteActionRequest) error + DeleteByFilterID(ctx context.Context, filterID int) error ToggleEnabled(actionID int) error } @@ -125,3 +126,11 @@ const ( ActionContentLayoutSubfolderNone ActionContentLayout = "SUBFOLDER_NONE" ActionContentLayoutSubfolderCreate ActionContentLayout = "SUBFOLDER_CREATE" ) + +type GetActionRequest struct { + Id int +} + +type DeleteActionRequest struct { + ActionId int +} diff --git a/internal/domain/release.go b/internal/domain/release.go index 912d141..0ce9558 100644 --- a/internal/domain/release.go +++ b/internal/domain/release.go @@ -30,12 +30,14 @@ type ReleaseRepo interface { Store(ctx context.Context, release *Release) (*Release, error) Find(ctx context.Context, params ReleaseQueryParams) (res []*Release, nextCursor int64, count int64, err error) FindRecent(ctx context.Context) ([]*Release, error) + Get(ctx context.Context, req *GetReleaseRequest) (*Release, error) GetIndexerOptions(ctx context.Context) ([]string, error) - GetActionStatusByReleaseID(ctx context.Context, releaseID int64) ([]ReleaseActionStatus, error) Stats(ctx context.Context) (*ReleaseStats, error) - StoreReleaseActionStatus(ctx context.Context, status *ReleaseActionStatus) error Delete(ctx context.Context) error CanDownloadShow(ctx context.Context, title string, season int, episode int) (bool, error) + + GetActionStatus(ctx context.Context, req *GetReleaseActionStatusRequest) (*ReleaseActionStatus, error) + StoreReleaseActionStatus(ctx context.Context, status *ReleaseActionStatus) error } type Release struct { @@ -100,13 +102,14 @@ type ReleaseActionStatus struct { ID int64 `json:"id"` Status ReleasePushStatus `json:"status"` Action string `json:"action"` + ActionID int64 `json:"action_id"` Type ActionType `json:"type"` Client string `json:"client"` Filter string `json:"filter"` - FilterID int64 `json:"-"` + FilterID int64 `json:"filter_id"` Rejections []string `json:"rejections"` + ReleaseID int64 `json:"release_id"` Timestamp time.Time `json:"timestamp"` - ReleaseID int64 `json:"-"` } func NewReleaseActionStatus(action *Action, release *Release) *ReleaseActionStatus { @@ -114,9 +117,10 @@ func NewReleaseActionStatus(action *Action, release *Release) *ReleaseActionStat ID: 0, Status: ReleasePushStatusPending, Action: action.Name, + ActionID: int64(action.ID), Type: action.Type, - Filter: release.Filter.Name, - FilterID: int64(release.Filter.ID), + Filter: release.FilterName, + FilterID: int64(release.FilterID), Rejections: []string{}, Timestamp: time.Now(), ReleaseID: release.ID, @@ -153,6 +157,8 @@ const ( func (r ReleasePushStatus) String() string { switch r { + case ReleasePushStatusPending: + return "Pending" case ReleasePushStatusApproved: return "Approved" case ReleasePushStatusRejected: @@ -227,6 +233,20 @@ type ReleaseQueryParams struct { Search string } +type ReleaseActionRetryReq struct { + ReleaseId int + ActionStatusId int + ActionId int +} + +type GetReleaseRequest struct { + Id int +} + +type GetReleaseActionStatusRequest struct { + Id int +} + func NewRelease(indexer string) *Release { r := &Release{ Indexer: indexer, diff --git a/internal/http/action.go b/internal/http/action.go index 1820e57..19d66ce 100644 --- a/internal/http/action.go +++ b/internal/http/action.go @@ -11,13 +11,14 @@ import ( "strconv" "github.com/autobrr/autobrr/internal/domain" + "github.com/go-chi/chi/v5" ) type actionService interface { List(ctx context.Context) ([]domain.Action, error) Store(ctx context.Context, action domain.Action) (*domain.Action, error) - Delete(actionID int) error + Delete(ctx context.Context, req *domain.DeleteActionRequest) error ToggleEnabled(actionID int) error } @@ -36,15 +37,19 @@ func newActionHandler(encoder encoder, service actionService) *actionHandler { func (h actionHandler) Routes(r chi.Router) { r.Get("/", h.getActions) r.Post("/", h.storeAction) - r.Delete("/{id}", h.deleteAction) - r.Put("/{id}", h.updateAction) - r.Patch("/{id}/toggleEnabled", h.toggleActionEnabled) + + r.Route("/{id}", func(r chi.Router) { + r.Delete("/", h.deleteAction) + r.Put("/", h.updateAction) + r.Patch("/toggleEnabled", h.toggleActionEnabled) + }) } func (h actionHandler) getActions(w http.ResponseWriter, r *http.Request) { actions, err := h.service.List(r.Context()) if err != nil { - // encode error + h.encoder.Error(w, err) + return } h.encoder.StatusResponse(w, http.StatusOK, actions) @@ -57,13 +62,14 @@ func (h actionHandler) storeAction(w http.ResponseWriter, r *http.Request) { ) if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - // encode error + h.encoder.Error(w, err) return } action, err := h.service.Store(ctx, data) if err != nil { - // encode error + h.encoder.Error(w, err) + return } h.encoder.StatusResponse(w, http.StatusCreated, action) @@ -76,13 +82,14 @@ func (h actionHandler) updateAction(w http.ResponseWriter, r *http.Request) { ) if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - // encode error + h.encoder.Error(w, err) return } action, err := h.service.Store(ctx, data) if err != nil { - // encode error + h.encoder.Error(w, err) + return } h.encoder.StatusResponse(w, http.StatusCreated, action) @@ -91,11 +98,13 @@ func (h actionHandler) updateAction(w http.ResponseWriter, r *http.Request) { func (h actionHandler) deleteAction(w http.ResponseWriter, r *http.Request) { actionID, err := parseInt(chi.URLParam(r, "id")) if err != nil { - h.encoder.StatusResponse(w, http.StatusBadRequest, errors.New("bad param id")) + h.encoder.StatusError(w, http.StatusBadRequest, errors.New("bad param id")) + return } - if err := h.service.Delete(actionID); err != nil { - // encode error + if err := h.service.Delete(r.Context(), &domain.DeleteActionRequest{ActionId: actionID}); err != nil { + h.encoder.Error(w, err) + return } h.encoder.StatusResponse(w, http.StatusNoContent, nil) @@ -104,11 +113,13 @@ func (h actionHandler) deleteAction(w http.ResponseWriter, r *http.Request) { func (h actionHandler) toggleActionEnabled(w http.ResponseWriter, r *http.Request) { actionID, err := parseInt(chi.URLParam(r, "id")) if err != nil { - h.encoder.StatusResponse(w, http.StatusBadRequest, errors.New("bad param id")) + h.encoder.StatusError(w, http.StatusBadRequest, errors.New("bad param id")) + return } if err := h.service.ToggleEnabled(actionID); err != nil { - // encode error + h.encoder.Error(w, err) + return } h.encoder.StatusResponse(w, http.StatusCreated, nil) diff --git a/internal/http/release.go b/internal/http/release.go index f91129a..e45df80 100644 --- a/internal/http/release.go +++ b/internal/http/release.go @@ -10,6 +10,7 @@ import ( "strconv" "github.com/autobrr/autobrr/internal/domain" + "github.com/go-chi/chi/v5" ) @@ -19,6 +20,7 @@ type releaseService interface { GetIndexerOptions(ctx context.Context) ([]string, error) Stats(ctx context.Context) (*domain.ReleaseStats, error) Delete(ctx context.Context) error + Retry(ctx context.Context, req *domain.ReleaseActionRetryReq) error } type releaseHandler struct { @@ -39,6 +41,10 @@ func (h releaseHandler) Routes(r chi.Router) { r.Get("/stats", h.getStats) r.Get("/indexers", h.getIndexerOptions) r.Delete("/all", h.deleteReleases) + + r.Route("/{releaseId}", func(r chi.Router) { + r.Post("/actions/{actionStatusId}/retry", h.retryAction) + }) } func (h releaseHandler) findReleases(w http.ResponseWriter, r *http.Request) { @@ -186,3 +192,46 @@ func (h releaseHandler) deleteReleases(w http.ResponseWriter, r *http.Request) { h.encoder.NoContent(w) } + +func (h releaseHandler) retryAction(w http.ResponseWriter, r *http.Request) { + var ( + req *domain.ReleaseActionRetryReq + err error + ) + + releaseIdParam := chi.URLParam(r, "releaseId") + if releaseIdParam == "" { + h.encoder.StatusError(w, http.StatusBadRequest, err) + return + } + + releaseId, err := strconv.Atoi(releaseIdParam) + if err != nil { + h.encoder.StatusError(w, http.StatusBadRequest, err) + return + } + + actionStatusIdParam := chi.URLParam(r, "actionStatusId") + if actionStatusIdParam == "" { + h.encoder.StatusError(w, http.StatusBadRequest, err) + return + } + + actionStatusId, err := strconv.Atoi(actionStatusIdParam) + if err != nil { + h.encoder.StatusError(w, http.StatusBadRequest, err) + return + } + + req = &domain.ReleaseActionRetryReq{ + ReleaseId: releaseId, + ActionStatusId: actionStatusId, + } + + if err := h.service.Retry(r.Context(), req); err != nil { + h.encoder.Error(w, err) + return + } + + h.encoder.NoContent(w) +} diff --git a/internal/release/service.go b/internal/release/service.go index 4899af1..ad6fb01 100644 --- a/internal/release/service.go +++ b/internal/release/service.go @@ -19,6 +19,8 @@ import ( type Service interface { Find(ctx context.Context, query domain.ReleaseQueryParams) (res []*domain.Release, nextCursor int64, count int64, err error) FindRecent(ctx context.Context) ([]*domain.Release, error) + Get(ctx context.Context, req *domain.GetReleaseRequest) (*domain.Release, error) + GetActionStatus(ctx context.Context, req *domain.GetReleaseActionStatusRequest) (*domain.ReleaseActionStatus, error) GetIndexerOptions(ctx context.Context) ([]string, error) Stats(ctx context.Context) (*domain.ReleaseStats, error) Store(ctx context.Context, release *domain.Release) error @@ -27,6 +29,7 @@ type Service interface { Process(release *domain.Release) ProcessMultiple(releases []*domain.Release) + Retry(ctx context.Context, req *domain.ReleaseActionRetryReq) error } type actionClientTypeKey struct { @@ -59,6 +62,14 @@ func (s *service) FindRecent(ctx context.Context) (res []*domain.Release, err er return s.repo.FindRecent(ctx) } +func (s *service) Get(ctx context.Context, req *domain.GetReleaseRequest) (*domain.Release, error) { + return s.repo.Get(ctx, req) +} + +func (s *service) GetActionStatus(ctx context.Context, req *domain.GetReleaseActionStatusRequest) (*domain.ReleaseActionStatus, error) { + return s.repo.GetActionStatus(ctx, req) +} + func (s *service) GetIndexerOptions(ctx context.Context) ([]string, error) { return s.repo.GetIndexerOptions(ctx) } @@ -138,13 +149,13 @@ func (s *service) Process(release *domain.Release) { } if !match { - l.Trace().Msgf("release.Process: indexer: %s, filter: %s release: %s, no match. rejections: %s", release.Indexer, release.Filter.Name, release.TorrentName, release.RejectionsString()) + l.Trace().Msgf("release.Process: indexer: %s, filter: %s release: %s, no match. rejections: %s", release.Indexer, release.FilterName, release.TorrentName, release.RejectionsString()) l.Debug().Msgf("release rejected: %s", release.RejectionsString()) continue } - l.Info().Msgf("Matched '%s' (%s) for %s", release.TorrentName, release.Filter.Name, release.Indexer) + l.Info().Msgf("Matched '%s' (%s) for %s", release.TorrentName, release.FilterName, release.Indexer) // save release here to only save those with rejections from actions instead of all releases if release.ID == 0 { @@ -158,7 +169,7 @@ func (s *service) Process(release *domain.Release) { // sleep for the delay period specified in the filter before running actions delay := release.Filter.Delay if delay > 0 { - l.Debug().Msgf("Delaying processing of '%s' (%s) for %s by %d seconds as specified in the filter", release.TorrentName, release.Filter.Name, release.Indexer, delay) + l.Debug().Msgf("Delaying processing of '%s' (%s) for %s by %d seconds as specified in the filter", release.TorrentName, release.FilterName, release.Indexer, delay) time.Sleep(time.Duration(delay) * time.Second) } @@ -170,30 +181,30 @@ func (s *service) Process(release *domain.Release) { // only run enabled actions if !act.Enabled { - l.Trace().Msgf("release.Process: indexer: %s, filter: %s release: %s action '%s' not enabled, skip", release.Indexer, release.Filter.Name, release.TorrentName, act.Name) + l.Trace().Msgf("release.Process: indexer: %s, filter: %s release: %s action '%s' not enabled, skip", release.Indexer, release.FilterName, release.TorrentName, act.Name) continue } - l.Trace().Msgf("release.Process: indexer: %s, filter: %s release: %s , run action: %s", release.Indexer, release.Filter.Name, release.TorrentName, act.Name) + l.Trace().Msgf("release.Process: indexer: %s, filter: %s release: %s , run action: %s", release.Indexer, release.FilterName, release.TorrentName, act.Name) // keep track of actiom clients to avoid sending the same thing all over again _, tried := triedActionClients[actionClientTypeKey{Type: act.Type, ClientID: act.ClientID}] if tried { - l.Trace().Msgf("release.Process: indexer: %s, filter: %s release: %s action client already tried, skip", release.Indexer, release.Filter.Name, release.TorrentName) + l.Trace().Msgf("release.Process: indexer: %s, filter: %s release: %s action client already tried, skip", release.Indexer, release.FilterName, release.TorrentName) continue } // run action status, err := s.runAction(ctx, act, release) if err != nil { - l.Error().Stack().Err(err).Msgf("release.Process: error running actions for filter: %s", release.Filter.Name) + l.Error().Stack().Err(err).Msgf("release.Process: error running actions for filter: %s", release.FilterName) //continue } rejections = status.Rejections if err := s.StoreReleaseActionStatus(ctx, status); err != nil { - s.log.Error().Err(err).Msgf("release.Process: error storing action status for filter: %s", release.Filter.Name) + s.log.Error().Err(err).Msgf("release.Process: error storing action status for filter: %s", release.FilterName) } if len(rejections) > 0 { @@ -237,12 +248,12 @@ func (s *service) runAction(ctx context.Context, action *domain.Action, release status := domain.NewReleaseActionStatus(action, release) if err := s.StoreReleaseActionStatus(ctx, status); err != nil { - s.log.Error().Err(err).Msgf("release.runAction: error storing action for filter: %s", release.Filter.Name) + s.log.Error().Err(err).Msgf("release.runAction: error storing action for filter: %s", release.FilterName) } rejections, err := s.actionSvc.RunAction(ctx, action, release) if err != nil { - s.log.Error().Stack().Err(err).Msgf("release.runAction: error running actions for filter: %s", release.Filter.Name) + s.log.Error().Stack().Err(err).Msgf("release.runAction: error running actions for filter: %s", release.FilterName) status.Status = domain.ReleasePushStatusErr status.Rejections = []string{err.Error()} @@ -261,3 +272,54 @@ func (s *service) runAction(ctx context.Context, action *domain.Action, release return status, nil } + +func (s *service) retryAction(ctx context.Context, action *domain.Action, release *domain.Release) error { + actionStatus, err := s.runAction(ctx, action, release) + if err != nil { + s.log.Error().Err(err).Msgf("release.retryAction: error running actions for filter: %s", release.FilterName) + + if err := s.StoreReleaseActionStatus(ctx, actionStatus); err != nil { + s.log.Error().Err(err).Msgf("release.retryAction: error storing filterAction status for filter: %s", release.FilterName) + return err + } + + return err + } + + if err := s.StoreReleaseActionStatus(ctx, actionStatus); err != nil { + s.log.Error().Err(err).Msgf("release.retryAction: error storing filterAction status for filter: %s", release.FilterName) + return err + } + + return nil +} + +func (s *service) Retry(ctx context.Context, req *domain.ReleaseActionRetryReq) error { + // get release + release, err := s.Get(ctx, &domain.GetReleaseRequest{Id: req.ReleaseId}) + if err != nil { + return err + } + + // get release filter action status + status, err := s.GetActionStatus(ctx, &domain.GetReleaseActionStatusRequest{Id: req.ActionStatusId}) + if err != nil { + return err + } + + // get filter action with action id from status + filterAction, err := s.actionSvc.Get(ctx, &domain.GetActionRequest{Id: int(status.ActionID)}) + if err != nil { + return err + } + + // run filterAction + if err := s.retryAction(ctx, filterAction, release); err != nil { + s.log.Error().Err(err).Msgf("release.Retry: error re-running action: %s", filterAction.Name) + return err + } + + s.log.Info().Msgf("successfully replayed action %s for release %s", filterAction.Name, release.TorrentName) + + return nil +} diff --git a/web/src/api/APIClient.ts b/web/src/api/APIClient.ts index cd05929..23b3a3e 100644 --- a/web/src/api/APIClient.ts +++ b/web/src/api/APIClient.ts @@ -193,7 +193,8 @@ export const APIClient = { }, indexerOptions: () => appClient.Get("api/release/indexers"), stats: () => appClient.Get("api/release/stats"), - delete: () => appClient.Delete("api/release/all") + delete: () => appClient.Delete("api/release/all"), + replayAction: (releaseId: number, actionId: number) => appClient.Post(`api/release/${releaseId}/actions/${actionId}/retry`) }, updates: { check: () => appClient.Get("api/updates/check"), diff --git a/web/src/components/data-table/Cells.tsx b/web/src/components/data-table/Cells.tsx index 7d840dc..aa36075 100644 --- a/web/src/components/data-table/Cells.tsx +++ b/web/src/components/data-table/Cells.tsx @@ -5,11 +5,17 @@ import * as React from "react"; import { formatDistanceToNowStrict } from "date-fns"; -import { CheckIcon } from "@heroicons/react/24/solid"; +import { ArrowPathIcon, CheckIcon } from "@heroicons/react/24/solid"; import { ClockIcon, ExclamationCircleIcon, NoSymbolIcon } from "@heroicons/react/24/outline"; import { classNames, simplifyDate } from "@utils"; import { Tooltip } from "../tooltips/Tooltip"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { APIClient } from "@api/APIClient"; +import { filterKeys } from "@screens/filters/list"; +import { toast } from "react-hot-toast"; +import Toast from "@components/notifications/Toast"; +import { RingResizeSpinner } from "@components/Icons"; interface CellProps { value: string; @@ -57,6 +63,46 @@ export const TitleCell = ({ value }: CellProps) => ( ); +interface RetryActionButtonProps { + status: ReleaseActionStatus; +} + +interface RetryAction { + releaseId: number; + actionId: number; +} + +const RetryActionButton = ({ status }: RetryActionButtonProps) => { + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: (vars: RetryAction) => APIClient.release.replayAction(vars.releaseId, vars.actionId), + onSuccess: () => { + // Invalidate filters just in case, most likely not necessary but can't hurt. + queryClient.invalidateQueries({ queryKey: filterKeys.lists() }); + + toast.custom((t) => ( + + )); + } + }); + + const replayAction = () => { + console.log("replay action"); + mutation.mutate({ releaseId: status.release_id,actionId: status.id }); + }; + + return ( + + ); +}; + interface ReleaseStatusCellProps { value: ReleaseActionStatus[]; } @@ -64,69 +110,89 @@ interface ReleaseStatusCellProps { interface StatusCellMapEntry { colors: string; icon: React.ReactElement; - textFormatter: (text: string) => React.ReactElement; + textFormatter: (status: ReleaseActionStatus) => React.ReactElement; } const StatusCellMap: Record = { "PUSH_ERROR": { colors: "bg-pink-100 text-pink-800 hover:bg-pink-300", icon: