Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add *arr Feed to check releases #112

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions internal/assets/static/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions internal/assets/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
31 changes: 31 additions & 0 deletions internal/assets/templates/arr-stack-today-releases.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{{ template "widget-base.html" . }}

{{ define "widget-content" }}
<ul class="list list-gap-14 list-collapsible">
{{ range $i, $release := .Releases }}
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
<div class="flex gap-10 items-start thumbnail-container">
<div class="anime-release-cover-container">
{{ if $release.ImageCoverUrl }}
<img class="arr-release-cover thumbnail" src="{{ $release.ImageCoverUrl }}" alt="Cover for {{ $release.Title }}" loading="lazy">
{{ else }}
<svg class="arr-release-cover thumbnail" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
{{ end }}
</div>
<div class="shrink min-width-0">
<strong class="size-h3 block text-truncate">{{ $release.Title }} - S{{ $release.SeasonNumber }}E{{ $release.EpisodeNumber }}</strong>
<div>Airing on {{ $release.AirDateUtc }} (UTC)</div>
{{ if $release.Grabbed }}
<div class="arr-grabbed-label">Grabbed</div>
{{ end }}
</div>
</div>
</li>
{{ end }}
</ul>
{{ if gt (len .Releases) $.CollapseAfter }}
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
{{ end }}
{{ end }}
250 changes: 250 additions & 0 deletions internal/feed/arr-stack.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
package feed

import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
)

type SonarrConfig struct {
Enable bool `yaml:"enable"`
Endpoint string `yaml:"endpoint"`
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
AirDateUtc string
SeasonNumber *string
EpisodeNumber *string
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"`
}

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 {
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
}
}

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: formattedDate,
SeasonNumber: &seasonNumber,
EpisodeNumber: &episodeNumber,
Grabbed: release.HasFile,
})
}

return releases, nil
}

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 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 = append(result, radarrReleases...)
}

return result, nil
}
57 changes: 57 additions & 0 deletions internal/widget/arr-stack-today-releases.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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"`
}
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"`
}

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, widget.Radarr)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}

widget.Releases = releases
}

func (widget *ArrReleases) Render() template.HTML {
return widget.render(widget, assets.ArrReleasesTemplate)
}
2 changes: 2 additions & 0 deletions internal/widget/widget.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down