Merge cbf25cd129
into f8816ff227
This commit is contained in:
commit
e81c0a1731
6 changed files with 358 additions and 0 deletions
|
@ -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;
|
||||
|
|
|
@ -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{
|
||||
|
|
31
internal/assets/templates/arr-stack-today-releases.html
Normal file
31
internal/assets/templates/arr-stack-today-releases.html
Normal file
|
@ -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
internal/feed/arr-stack.go
Normal file
250
internal/feed/arr-stack.go
Normal file
|
@ -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
internal/widget/arr-stack-today-releases.go
Normal file
57
internal/widget/arr-stack-today-releases.go
Normal file
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue