diff --git a/cmd/autobrr/main.go b/cmd/autobrr/main.go index 4f0b0e5..930735e 100644 --- a/cmd/autobrr/main.go +++ b/cmd/autobrr/main.go @@ -88,7 +88,8 @@ func main() { // setup services var ( - schedulingService = scheduler.NewService(log) + notificationService = notification.NewService(log, notificationRepo) + schedulingService = scheduler.NewService(log, version, notificationService) apiService = indexer.NewAPIService(log) userService = user.NewService(userRepo) authService = auth.NewService(log, userService) @@ -97,7 +98,6 @@ func main() { indexerService = indexer.NewService(log, cfg.Config, indexerRepo, apiService, schedulingService) filterService = filter.NewService(log, filterRepo, actionRepo, apiService, indexerService) releaseService = release.NewService(log, releaseRepo, actionService, filterService) - notificationService = notification.NewService(log, notificationRepo) ircService = irc.NewService(log, ircRepo, releaseService, indexerService, notificationService) feedService = feed.NewService(log, feedRepo, feedCacheRepo, releaseService, schedulingService) ) diff --git a/go.mod b/go.mod index c1952ca..5cef53e 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/go-chi/chi v1.5.4 github.com/gorilla/sessions v1.2.1 github.com/gosimple/slug v1.12.0 + github.com/hashicorp/go-version v1.6.0 github.com/hekmon/transmissionrpc/v2 v2.0.1 github.com/lib/pq v1.10.4 github.com/mattn/go-shellwords v1.0.12 diff --git a/go.sum b/go.sum index a70af24..cca8971 100644 --- a/go.sum +++ b/go.sum @@ -277,6 +277,8 @@ github.com/gosuri/uiprogress v0.0.0-20170224063937-d0567a9d84a1/go.mod h1:C1RTYn github.com/gosuri/uiprogress v0.0.1/go.mod h1:C1RTYn4Sc7iEyf6j8ft5dyoZ4212h8G1ol9QQluh5+0= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= +github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= diff --git a/internal/domain/notification.go b/internal/domain/notification.go index 476d257..0cfcfbf 100644 --- a/internal/domain/notification.go +++ b/internal/domain/notification.go @@ -78,14 +78,13 @@ const ( type NotificationEvent string const ( - //NotificationEventAppUpdateAvailable NotificationEvent = "APP_UPDATE_AVAILABLE" - - NotificationEventPushApproved NotificationEvent = "PUSH_APPROVED" - NotificationEventPushRejected NotificationEvent = "PUSH_REJECTED" - NotificationEventPushError NotificationEvent = "PUSH_ERROR" - NotificationEventIRCDisconnected NotificationEvent = "IRC_DISCONNECTED" - NotificationEventIRCReconnected NotificationEvent = "IRC_RECONNECTED" - NotificationEventTest NotificationEvent = "TEST" + NotificationEventAppUpdateAvailable NotificationEvent = "APP_UPDATE_AVAILABLE" + NotificationEventPushApproved NotificationEvent = "PUSH_APPROVED" + NotificationEventPushRejected NotificationEvent = "PUSH_REJECTED" + NotificationEventPushError NotificationEvent = "PUSH_ERROR" + NotificationEventIRCDisconnected NotificationEvent = "IRC_DISCONNECTED" + NotificationEventIRCReconnected NotificationEvent = "IRC_RECONNECTED" + NotificationEventTest NotificationEvent = "TEST" ) type NotificationEventArr []NotificationEvent diff --git a/internal/notification/service.go b/internal/notification/service.go index 8f2a58d..4024896 100644 --- a/internal/notification/service.go +++ b/internal/notification/service.go @@ -128,7 +128,9 @@ func (s *service) registerSenders() { // Send notifications func (s *service) Send(event domain.NotificationEvent, payload domain.NotificationPayload) { - s.log.Debug().Msgf("sending notification for %v", string(event)) + if len(s.senders) > 0 { + s.log.Debug().Msgf("sending notification for %v", string(event)) + } go func() { for _, sender := range s.senders { diff --git a/internal/scheduler/jobs.go b/internal/scheduler/jobs.go new file mode 100644 index 0000000..db363d6 --- /dev/null +++ b/internal/scheduler/jobs.go @@ -0,0 +1,51 @@ +package scheduler + +import ( + "context" + "time" + + "github.com/autobrr/autobrr/internal/domain" + "github.com/autobrr/autobrr/internal/notification" + "github.com/autobrr/autobrr/pkg/version" + + "github.com/rs/zerolog" +) + +type CheckUpdatesJob struct { + Name string + Log zerolog.Logger + Version string + NotifSvc notification.Service + + lastCheckVersion string +} + +func (j *CheckUpdatesJob) Run() { + v := version.Checker{ + Owner: "autobrr", + Repo: "autobrr", + } + + newAvailable, newVersion, err := v.CheckNewVersion(context.TODO(), j.Version) + if err != nil { + j.Log.Error().Err(err).Msg("could not check for new release") + return + } + + if newAvailable { + j.Log.Info().Msgf("a new release has been found: %v Consider updating.", newVersion) + + // this is not persisted so this can trigger more than once + // lets check if we have different versions between runs + if newVersion != j.lastCheckVersion { + j.NotifSvc.Send(domain.NotificationEventAppUpdateAvailable, domain.NotificationPayload{ + Subject: "New update available!", + Message: newVersion, + Event: domain.NotificationEventAppUpdateAvailable, + Timestamp: time.Now(), + }) + } + + j.lastCheckVersion = newVersion + } +} diff --git a/internal/scheduler/service.go b/internal/scheduler/service.go index 917cce8..c933b48 100644 --- a/internal/scheduler/service.go +++ b/internal/scheduler/service.go @@ -1,7 +1,10 @@ package scheduler import ( + "time" + "github.com/autobrr/autobrr/internal/logger" + "github.com/autobrr/autobrr/internal/notification" "github.com/autobrr/autobrr/pkg/errors" "github.com/robfig/cron/v3" @@ -17,15 +20,19 @@ type Service interface { } type service struct { - log zerolog.Logger - cron *cron.Cron + log zerolog.Logger + version string + notificationSvc notification.Service + cron *cron.Cron jobs map[string]cron.EntryID } -func NewService(log logger.Logger) Service { +func NewService(log logger.Logger, version string, notificationSvc notification.Service) Service { return &service{ - log: log.With().Str("module", "scheduler").Logger(), + log: log.With().Str("module", "scheduler").Logger(), + version: version, + notificationSvc: notificationSvc, cron: cron.New(cron.WithChain( cron.Recover(cron.DefaultLogger), )), @@ -36,15 +43,35 @@ func NewService(log logger.Logger) Service { func (s *service) Start() { s.log.Debug().Msg("scheduler.Start") + // start scheduler s.cron.Start() + + // init jobs + go s.addAppJobs() + return } +func (s *service) addAppJobs() { + time.Sleep(5 * time.Second) + + checkUpdates := &CheckUpdatesJob{ + Name: "app-check-updates", + Log: s.log.With().Str("job", "app-check-updates").Logger(), + Version: s.version, + NotifSvc: s.notificationSvc, + lastCheckVersion: "", + } + + s.AddJob(checkUpdates, "2 */6 * * *", "app-check-updates") +} + func (s *service) Stop() { s.log.Debug().Msg("scheduler.Stop") s.cron.Stop() return } + func (s *service) AddJob(job cron.Job, interval string, identifier string) (int, error) { id, err := s.cron.AddJob(interval, cron.NewChain( @@ -88,3 +115,14 @@ func (s *service) RemoveJobByIdentifier(id string) error { return nil } + +type GenericJob struct { + Name string + Log zerolog.Logger + + callback func() +} + +func (j *GenericJob) Run() { + j.callback() +} diff --git a/pkg/version/version.go b/pkg/version/version.go new file mode 100644 index 0000000..1ac1a8e --- /dev/null +++ b/pkg/version/version.go @@ -0,0 +1,101 @@ +package version + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/autobrr/autobrr/pkg/errors" + + goversion "github.com/hashicorp/go-version" +) + +// Release is a GitHub release +type Release struct { + TagName string `json:"tag_name,omitempty"` + TargetCommitish *string `json:"target_commitish,omitempty"` + Name *string `json:"name,omitempty"` + Body *string `json:"body,omitempty"` + Draft *bool `json:"draft,omitempty"` + Prerelease *bool `json:"prerelease,omitempty"` +} + +func (r *Release) IsPreOrDraft() bool { + if *r.Draft || *r.Prerelease { + return true + } + return false +} + +type Checker struct { + // user/repo-name or org/repo-name + Owner string + Repo string +} + +func (c *Checker) get(ctx context.Context) (*Release, error) { + url := fmt.Sprintf("https://api.github.com/repos/%v/%v/releases/latest", c.Owner, c.Repo) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/vnd.github.v3+json") + client := http.DefaultClient + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("error getting releases for %v: %s", c.Repo, resp.Status) + } + + var release Release + dec := json.NewDecoder(resp.Body) + if err := dec.Decode(&release); err != nil { + return nil, err + } + + return &release, nil +} + +func (c *Checker) CheckNewVersion(ctx context.Context, version string) (bool, string, error) { + if version == "dev" { + return false, "", nil + } + + release, err := c.get(ctx) + if err != nil { + return false, "", err + } + + return c.checkNewVersion(version, release) +} + +func (c *Checker) checkNewVersion(version string, release *Release) (bool, string, error) { + currentVersion, err := goversion.NewVersion(version) + if err != nil { + return false, "", errors.Wrap(err, "error parsing current version") + } + + releaseVersion, err := goversion.NewVersion(release.TagName) + if err != nil { + return false, "", errors.Wrap(err, "error parsing release version") + } + + if len(currentVersion.Prerelease()) == 0 && len(releaseVersion.Prerelease()) > 0 { + return false, "", nil + } + + if releaseVersion.GreaterThan(currentVersion) { + // new update available + return true, releaseVersion.String(), nil + } + + return false, "", nil +} diff --git a/pkg/version/version_test.go b/pkg/version/version_test.go new file mode 100644 index 0000000..7460b17 --- /dev/null +++ b/pkg/version/version_test.go @@ -0,0 +1,109 @@ +package version + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGitHubReleaseChecker_checkNewVersion(t *testing.T) { + type fields struct { + Repo string + } + type args struct { + version string + release *Release + } + tests := []struct { + name string + fields fields + args args + wantNew bool + wantVersion string + wantErr bool + }{ + { + name: "outdated new available", + fields: fields{}, + args: args{ + version: "v0.2.0", + release: &Release{ + TagName: "v0.3.0", + TargetCommitish: nil, + }, + }, + wantNew: true, + wantVersion: "0.3.0", + wantErr: false, + }, + { + name: "same version", + fields: fields{}, + args: args{ + version: "v0.2.0", + release: &Release{ + TagName: "v0.2.0", + TargetCommitish: nil, + }, + }, + wantNew: false, + wantVersion: "", + wantErr: false, + }, + { + name: "no new version", + fields: fields{}, + args: args{ + version: "v0.3.0", + release: &Release{ + TagName: "v0.2.0", + TargetCommitish: nil, + }, + }, + wantNew: false, + wantVersion: "", + wantErr: false, + }, + { + name: "new rc available", + fields: fields{}, + args: args{ + version: "v0.3.0", + release: &Release{ + TagName: "v0.3.0-rc1", + TargetCommitish: nil, + }, + }, + wantNew: false, + wantVersion: "", + wantErr: false, + }, + { + name: "new rc available", + fields: fields{}, + args: args{ + version: "v0.3.0-RC1", + release: &Release{ + TagName: "v0.3.0-RC2", + TargetCommitish: nil, + }, + }, + wantNew: true, + wantVersion: "0.3.0-RC2", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := &Checker{ + Repo: tt.fields.Repo, + } + got, gotVersion, err := g.checkNewVersion(tt.args.version, tt.args.release) + if tt.wantErr && assert.Error(t, err) { + assert.Equal(t, tt.wantErr, err) + } + assert.Equal(t, tt.wantNew, got) + assert.Equal(t, tt.wantVersion, gotVersion) + }) + } +} diff --git a/web/src/domain/constants.ts b/web/src/domain/constants.ts index 8680675..4f6c188 100644 --- a/web/src/domain/constants.ts +++ b/web/src/domain/constants.ts @@ -305,7 +305,7 @@ export const downloadsPerUnitOptions: OptionBasic[] = [ export interface SelectOption { label: string; description: string; - value: string; + value: NotificationEvent; } export const EventOptions: SelectOption[] = [ @@ -333,5 +333,10 @@ export const EventOptions: SelectOption[] = [ label: "IRC Reconnected", value: "IRC_RECONNECTED", description: "Reconnected to irc network after error" - } + }, + { + label: "New update", + value: "APP_UPDATE_AVAILABLE", + description: "Get notified on updates" + }, ]; diff --git a/web/src/types/Notification.d.ts b/web/src/types/Notification.d.ts index e2d1d37..10aa36b 100644 --- a/web/src/types/Notification.d.ts +++ b/web/src/types/Notification.d.ts @@ -1,5 +1,5 @@ type NotificationType = "DISCORD" | "TELEGRAM"; -type NotificationEvent = "PUSH_APPROVED" | "PUSH_REJECTED" | "PUSH_ERROR" | "IRC_DISCONNECTED" | "IRC_RECONNECTED"; +type NotificationEvent = "PUSH_APPROVED" | "PUSH_REJECTED" | "PUSH_ERROR" | "IRC_DISCONNECTED" | "IRC_RECONNECTED" | "APP_UPDATE_AVAILABLE"; interface Notification { id: number;