From 0bca1b30411a8ad9328cbf9fc430f8c4e5dadc12 Mon Sep 17 00:00:00 2001 From: iwa Date: Sat, 25 May 2024 17:32:08 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20basic=20support?= =?UTF-8?q?=20for=20sonarr=20today's=20releases=20feed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/assets/templates.go | 1 + .../templates/arr-stack-today-releases.html | 33 +++++ internal/feed/arr-stack.go | 129 ++++++++++++++++++ internal/widget/arr-stack-today-releases.go | 52 +++++++ internal/widget/widget.go | 2 + 5 files changed, 217 insertions(+) create mode 100644 internal/assets/templates/arr-stack-today-releases.html create mode 100644 internal/feed/arr-stack.go create mode 100644 internal/widget/arr-stack-today-releases.go diff --git a/internal/assets/templates.go b/internal/assets/templates.go index b8aa6aed..cc76da11 100644 --- a/internal/assets/templates.go +++ b/internal/assets/templates.go @@ -32,6 +32,7 @@ var ( TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html") TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html") RepositoryTemplate = compileTemplate("repository.html", "widget-base.html") + ArrReleasesTemplate = compileTemplate("arr-stack-today-releases.html", "widget-base.html") ) var globalTemplateFunctions = template.FuncMap{ diff --git a/internal/assets/templates/arr-stack-today-releases.html b/internal/assets/templates/arr-stack-today-releases.html new file mode 100644 index 00000000..b52a3741 --- /dev/null +++ b/internal/assets/templates/arr-stack-today-releases.html @@ -0,0 +1,33 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content" }} + +{{ if gt (len .Releases) $.CollapseAfter }} + +{{ end }} +{{ end }} diff --git a/internal/feed/arr-stack.go b/internal/feed/arr-stack.go new file mode 100644 index 00000000..b210f12a --- /dev/null +++ b/internal/feed/arr-stack.go @@ -0,0 +1,129 @@ +package feed + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" +) + +type SonarrConfig struct { + Enable bool `yaml:"enable"` + Endpoint string `yaml:"endpoint"` + ApiKey string `yaml:"apikey"` +} + +type ArrRelease struct { + Title string + ImageCoverUrl string + AirDateUtc string + SeasonNumber *int + EpisodeNumber *int + Grabbed bool +} + +type ArrReleases []ArrRelease + +type SonarrReleaseResponse struct { + HasFile bool `json:"hasFile"` + SeasonNumber int `json:"seasonNumber"` + EpisodeNumber int `json:"episodeNumber"` + Series struct { + Title string `json:"title"` + Images []struct { + CoverType string `json:"coverType"` + RemoteUrl string `json:"remoteUrl"` + } `json:"images"` + } `json:"series"` + AirDateUtc string `json:"airDateUtc"` +} + +func extractHostFromURL(apiEndpoint string) string { + u, err := url.Parse(apiEndpoint) + if err != nil { + return "127.0.0.1" + } + return u.Host +} + +func FetchReleasesFromSonarr(SonarrEndpoint string, SonarrApiKey string) (ArrReleases, error) { + if SonarrEndpoint == "" { + return nil, fmt.Errorf("missing sonarr-endpoint config") + } + + if SonarrApiKey == "" { + return nil, fmt.Errorf("missing sonarr-apikey config") + } + + client := &http.Client{} + url := fmt.Sprintf("%s/api/v3/calendar?includeSeries=true", strings.TrimSuffix(SonarrEndpoint, "/")) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + + req.Header.Set("X-Api-Key", SonarrApiKey) + req.Header.Set("Host", extractHostFromURL(SonarrEndpoint)) + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %v", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + + var sonarrReleases []SonarrReleaseResponse + err = json.Unmarshal(body, &sonarrReleases) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON: %v", err) + } + + var releases ArrReleases + for _, release := range sonarrReleases { + var imageCover string + for _, image := range release.Series.Images { + if image.CoverType == "poster" { + imageCover = image.RemoteUrl + break + } + } + + releases = append(releases, ArrRelease{ + Title: release.Series.Title, + ImageCoverUrl: imageCover, + AirDateUtc: release.AirDateUtc, + SeasonNumber: &release.SeasonNumber, + EpisodeNumber: &release.EpisodeNumber, + Grabbed: release.HasFile, + }) + } + + return releases, nil +} + +func FetchReleasesFromArrStack(Sonarr SonarrConfig) (ArrReleases, error) { + result := ArrReleases{} + + // Call FetchReleasesFromSonarr and handle the result + if Sonarr.Enable { + sonarrReleases, err := FetchReleasesFromSonarr(Sonarr.Endpoint, Sonarr.ApiKey) + if err != nil { + slog.Warn("failed to fetch release", "error", err) + return nil, err + } + + result = sonarrReleases + } + + return result, nil +} diff --git a/internal/widget/arr-stack-today-releases.go b/internal/widget/arr-stack-today-releases.go new file mode 100644 index 00000000..4aaea5ac --- /dev/null +++ b/internal/widget/arr-stack-today-releases.go @@ -0,0 +1,52 @@ +package widget + +import ( + "context" + "html/template" + "time" + + "github.com/glanceapp/glance/internal/assets" + "github.com/glanceapp/glance/internal/feed" +) + +type ArrReleases struct { + widgetBase `yaml:",inline"` + Releases feed.ArrReleases `yaml:"-"` + Sonarr struct { + Enable bool `yaml:"enable"` + Endpoint string `yaml:"endpoint"` + ApiKey string `yaml:"apikey"` + } + CollapseAfter int `yaml:"collapse-after"` + CacheDuration time.Duration `yaml:"cache-duration"` +} + +func (widget *ArrReleases) Initialize() error { + widget.withTitle("Releasing Today") + + // Set cache duration + if widget.CacheDuration == 0 { + widget.CacheDuration = time.Minute * 5 + } + widget.withCacheDuration(widget.CacheDuration) + + // Set collapse after default value + if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { + widget.CollapseAfter = 5 + } + + return nil +} + +func (widget *ArrReleases) Update(ctx context.Context) { + releases, err := feed.FetchReleasesFromArrStack(widget.Sonarr) + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + widget.Releases = releases +} + +func (widget *ArrReleases) Render() template.HTML { + return widget.render(widget, assets.ArrReleasesTemplate) +} diff --git a/internal/widget/widget.go b/internal/widget/widget.go index 3707b7ea..a54feddf 100644 --- a/internal/widget/widget.go +++ b/internal/widget/widget.go @@ -45,6 +45,8 @@ func New(widgetType string) (Widget, error) { return &TwitchChannels{}, nil case "repository": return &Repository{}, nil + case "arr-stack-releases": + return &ArrReleases{}, nil default: return nil, fmt.Errorf("unknown widget type: %s", widgetType) } From fde9190254322983d27067d0a13362622bc9d8a0 Mon Sep 17 00:00:00 2001 From: iwa Date: Sat, 25 May 2024 17:55:11 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=F0=9F=92=84=20style=20of=20releasin?= =?UTF-8?q?g=20today=20widget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/assets/static/main.css | 17 ++++++++++++++ .../templates/arr-stack-today-releases.html | 12 ++++------ internal/feed/arr-stack.go | 23 +++++++++++++++---- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/internal/assets/static/main.css b/internal/assets/static/main.css index 1e64def1..3e24987f 100644 --- a/internal/assets/static/main.css +++ b/internal/assets/static/main.css @@ -980,6 +980,23 @@ body { background: linear-gradient(0deg, var(--color-widget-background) 10%, transparent); } +.arr-release-cover { + border-radius: 0.4em; + max-width: 6em; + box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); +} + +.arr-grabbed-label { + color: green; /* or any other color you prefer */ + font-weight: bold; + padding: 2px 5px; + border: 1px solid green; + border-radius: 3px; + display: inline-block; + margin-top: 5px; +} + + @media (max-width: 1190px) { .header-container { display: none; diff --git a/internal/assets/templates/arr-stack-today-releases.html b/internal/assets/templates/arr-stack-today-releases.html index b52a3741..4afcdd03 100644 --- a/internal/assets/templates/arr-stack-today-releases.html +++ b/internal/assets/templates/arr-stack-today-releases.html @@ -7,20 +7,18 @@
{{ if $release.ImageCoverUrl }} - Cover for {{ $release.Title }} + Cover for {{ $release.Title }} {{ else }} - + {{ end }}
- {{ $release.Title }} -
Air Date: {{ $release.AirDateUtc }}
- {{ if $release.SeasonNumber }}
Season: {{ $release.SeasonNumber }}
{{ end }} - {{ if $release.EpisodeNumber }}
Episode: {{ $release.EpisodeNumber }}
{{ end }} + {{ $release.Title }} - S{{ $release.SeasonNumber }}E{{ $release.EpisodeNumber }} +
Airing on {{ $release.AirDateUtc }} (UTC)
{{ if $release.Grabbed }} -
Grabbed
+
Grabbed
{{ end }}
diff --git a/internal/feed/arr-stack.go b/internal/feed/arr-stack.go index b210f12a..366b851e 100644 --- a/internal/feed/arr-stack.go +++ b/internal/feed/arr-stack.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "strings" + "time" ) type SonarrConfig struct { @@ -20,8 +21,8 @@ type ArrRelease struct { Title string ImageCoverUrl string AirDateUtc string - SeasonNumber *int - EpisodeNumber *int + SeasonNumber *string + EpisodeNumber *string Grabbed bool } @@ -98,12 +99,24 @@ func FetchReleasesFromSonarr(SonarrEndpoint string, SonarrApiKey string) (ArrRel } } + airDate, err := time.Parse(time.RFC3339, release.AirDateUtc) + if err != nil { + return nil, fmt.Errorf("failed to parse air date: %v", err) + } + + // Format the date as YYYY-MM-DD HH:MM:SS + formattedDate := airDate.Format("2006-01-02 15:04:05") + + // Format SeasonNumber and EpisodeNumber with at least two digits + seasonNumber := fmt.Sprintf("%02d", release.SeasonNumber) + episodeNumber := fmt.Sprintf("%02d", release.EpisodeNumber) + releases = append(releases, ArrRelease{ Title: release.Series.Title, ImageCoverUrl: imageCover, - AirDateUtc: release.AirDateUtc, - SeasonNumber: &release.SeasonNumber, - EpisodeNumber: &release.EpisodeNumber, + AirDateUtc: formattedDate, + SeasonNumber: &seasonNumber, + EpisodeNumber: &episodeNumber, Grabbed: release.HasFile, }) } From cbf25cd12978bf74788c4ce1b84da15fe6334d0c Mon Sep 17 00:00:00 2001 From: iwa Date: Sat, 25 May 2024 18:11:29 +0200 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20radarr=20releases?= =?UTF-8?q?=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/feed/arr-stack.go | 114 +++++++++++++++++++- internal/widget/arr-stack-today-releases.go | 7 +- 2 files changed, 117 insertions(+), 4 deletions(-) diff --git a/internal/feed/arr-stack.go b/internal/feed/arr-stack.go index 366b851e..b44f274e 100644 --- a/internal/feed/arr-stack.go +++ b/internal/feed/arr-stack.go @@ -17,6 +17,12 @@ type SonarrConfig struct { ApiKey string `yaml:"apikey"` } +type RadarrConfig struct { + Enable bool `yaml:"enable"` + Endpoint string `yaml:"endpoint"` + ApiKey string `yaml:"apikey"` +} + type ArrRelease struct { Title string ImageCoverUrl string @@ -42,6 +48,18 @@ type SonarrReleaseResponse struct { AirDateUtc string `json:"airDateUtc"` } +type RadarrReleaseResponse struct { + HasFile bool `json:"hasFile"` + Title string `json:"title"` + Images []struct { + CoverType string `json:"coverType"` + RemoteUrl string `json:"remoteUrl"` + } `json:"images"` + InCinemasDate string `json:"inCinemas"` + PhysicalReleaseDate string `json:"physicalRelease"` + DigitalReleaseDate string `json:"digitalRelease"` +} + func extractHostFromURL(apiEndpoint string) string { u, err := url.Parse(apiEndpoint) if err != nil { @@ -124,18 +142,108 @@ func FetchReleasesFromSonarr(SonarrEndpoint string, SonarrApiKey string) (ArrRel return releases, nil } -func FetchReleasesFromArrStack(Sonarr SonarrConfig) (ArrReleases, error) { +func FetchReleasesFromRadarr(RadarrEndpoint string, RadarrApiKey string) (ArrReleases, error) { + if RadarrEndpoint == "" { + return nil, fmt.Errorf("missing radarr-endpoint config") + } + + if RadarrApiKey == "" { + return nil, fmt.Errorf("missing radarr-apikey config") + } + + client := &http.Client{} + url := fmt.Sprintf("%s/api/v3/calendar", strings.TrimSuffix(RadarrEndpoint, "/")) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + + req.Header.Set("X-Api-Key", RadarrApiKey) + req.Header.Set("Host", extractHostFromURL(RadarrEndpoint)) + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %v", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + + var radarrReleases []RadarrReleaseResponse + err = json.Unmarshal(body, &radarrReleases) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON: %v", err) + } + + var releases ArrReleases + for _, release := range radarrReleases { + var imageCover string + for _, image := range release.Images { + if image.CoverType == "poster" { + imageCover = image.RemoteUrl + break + } + } + + // Choose the appropriate release date from Radarr's response + releaseDate := release.InCinemasDate + formattedDate := "In Cinemas: " + if release.PhysicalReleaseDate != "" { + releaseDate = release.PhysicalReleaseDate + formattedDate = "Physical Release: " + } else if release.DigitalReleaseDate != "" { + releaseDate = release.DigitalReleaseDate + formattedDate = "Digital Release: " + } + + airDate, err := time.Parse("2006-01-02", releaseDate) + if err != nil { + return nil, fmt.Errorf("failed to parse release date: %v", err) + } + + // Format the date as YYYY-MM-DD HH:MM:SS + formattedDate = formattedDate + airDate.Format("2006-01-02 15:04:05") + + releases = append(releases, ArrRelease{ + Title: release.Title, + ImageCoverUrl: imageCover, + AirDateUtc: formattedDate, + Grabbed: release.HasFile, + }) + } + + return releases, nil +} + +func FetchReleasesFromArrStack(Sonarr SonarrConfig, Radarr RadarrConfig) (ArrReleases, error) { result := ArrReleases{} // Call FetchReleasesFromSonarr and handle the result if Sonarr.Enable { sonarrReleases, err := FetchReleasesFromSonarr(Sonarr.Endpoint, Sonarr.ApiKey) if err != nil { - slog.Warn("failed to fetch release", "error", err) + slog.Warn("failed to fetch release from sonarr", "error", err) + return nil, err + } + + result = append(result, sonarrReleases...) + } + + // Call FetchReleasesFromRadarr and handle the result + if Radarr.Enable { + radarrReleases, err := FetchReleasesFromRadarr(Radarr.Endpoint, Radarr.ApiKey) + if err != nil { + slog.Warn("failed to fetch release from radarr", "error", err) return nil, err } - result = sonarrReleases + result = append(result, radarrReleases...) } return result, nil diff --git a/internal/widget/arr-stack-today-releases.go b/internal/widget/arr-stack-today-releases.go index 4aaea5ac..0f1faa07 100644 --- a/internal/widget/arr-stack-today-releases.go +++ b/internal/widget/arr-stack-today-releases.go @@ -17,6 +17,11 @@ type ArrReleases struct { Endpoint string `yaml:"endpoint"` ApiKey string `yaml:"apikey"` } + Radarr struct { + Enable bool `yaml:"enable"` + Endpoint string `yaml:"endpoint"` + ApiKey string `yaml:"apikey"` + } CollapseAfter int `yaml:"collapse-after"` CacheDuration time.Duration `yaml:"cache-duration"` } @@ -39,7 +44,7 @@ func (widget *ArrReleases) Initialize() error { } func (widget *ArrReleases) Update(ctx context.Context) { - releases, err := feed.FetchReleasesFromArrStack(widget.Sonarr) + releases, err := feed.FetchReleasesFromArrStack(widget.Sonarr, widget.Radarr) if !widget.canContinueUpdateAfterHandlingErr(err) { return }