This commit is contained in:
frahz 2025-03-15 05:48:47 -05:00 committed by GitHub
commit e8c4c7f787
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 310 additions and 1 deletions

View file

@ -39,7 +39,7 @@
- [Twitch Top Games](#twitch-top-games)
- [iframe](#iframe)
- [HTML](#html)
- [Media Requests](#media-requests)
## Preconfigured page
If you don't want to spend time reading through all the available configuration options and just want something to get you going quickly you can use [this `glance.yml` file](glance.yml) and make changes to it as you see fit. It will give you a page that looks like the following:
@ -2395,3 +2395,36 @@ Example:
```
Note the use of `|` after `source:`, this allows you to insert a multi-line string.
### Media Requests
The Media Requests widget displays a list of media requests done through Jellyseerr/Overseerr and their availability status.
Example:
```yaml
- type: media-requests
url: https://jellyseerr.domain.com
api-key: ${JELLYSEERR_API_KEY}
service: jellyseerr
```
#### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| url | string | yes | |
| api-key | string | yes | |
| service | string | no | jellyseerr |
| limit | integer | no | 20 |
| collapse-after | integer | no | 5 |
##### `api-key`
Required for both `jellyseerr` and `overseerr`. The API token which can be found in `Settings -> General -> API Key`. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`.
##### `service`
Either `jellyseerr` or `overseerr`.
##### `limit`
The maximum number of articles to show.
##### `collapse-after`
How many articles are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.

View file

@ -1484,6 +1484,12 @@ details[open] .summary::after {
flex-shrink: 0;
}
.media-requests-thumbnail {
width: 5rem;
aspect-ratio: 3 / 4;
border-radius: var(--border-radius);
}
.docker-container-icon {
display: block;
filter: grayscale(0.4);
@ -2031,6 +2037,9 @@ details[open] .summary::after {
.color-negative { color: var(--color-negative); }
.color-positive { color: var(--color-positive); }
.color-primary { color: var(--color-primary); }
.color-purple { color: hsl(267deg, 84%, 81%); }
.color-yellow { color: hsl(41deg, 86%, 83%); }
.color-blue { color: hsl(217deg, 92%, 76%); }
.cursor-help { cursor: help; }
.break-all { word-break: break-all; }

View file

@ -0,0 +1,37 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .MediaRequests }}
<li class="media-requests thumbnail-parent">
<div class="flex gap-10 items-start">
<img class="media-requests-thumbnail thumbnail" loading="lazy" src="{{ .PosterImageUrl }}" alt="">
<div class="min-width-0">
<a class="title size-h3 color-highlight text-truncate block" href="{{.Href}}" target="_blank" rel="noreferrer" title="{{ .Name }}">
{{ .Name }}
</a>
<ul class="list-horizontal-text">
{{ if eq .Availability 5}}
<li class="color-positive">Available</li>
{{ else if eq .Availability 4}}
<li class="color-yellow">Partial</li>
{{ else if eq .Availability 3}}
<li class="color-blue">Processing</li>
{{ else if eq .Availability 2}}
<li class="color-purple">Pending Approval</li>
{{ else}}
<li class="color-negative">Unknown</li>
{{ end }}
</ul>
<ul class="list-horizontal-text flex-nowrap">
<li>{{ .AirDate }}</li>
<li class="min-width-0">
<a href="{{.RequestedBy.Link}}" target="_blank" rel="noreferrer">{{.RequestedBy.DisplayName}}</a>
</li>
</ul>
</div>
</div>
</li>
{{ end }}
</ul>
{{ end }}

View file

@ -0,0 +1,228 @@
package glance
import (
"context"
"errors"
"fmt"
"html/template"
// "log"
"net/http"
"strings"
"time"
)
var mediaRequestsWidgetTemplate = mustParseTemplate("media-requests.html", "widget-base.html")
type mediaRequestsWidget struct {
widgetBase `yaml:",inline"`
MediaRequests []MediaRequest `yaml:"-"`
Service string `yaml:"service"`
URL string `yaml:"url"`
ApiKey string `yaml:"api-key"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
}
func (widget *mediaRequestsWidget) initialize() error {
widget.
withTitle("Media Requests").
withTitleURL(string(widget.URL)).
withCacheDuration(10 * time.Minute)
if widget.Service != "jellyseerr" && widget.Service != "overseerr" {
return errors.New("service must be either 'jellyseerr' or 'overseerr'")
}
if widget.Limit <= 0 {
widget.Limit = 20
}
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
widget.CollapseAfter = 5
}
return nil
}
func (widget *mediaRequestsWidget) update(ctx context.Context) {
mediaReqs, err := fetchMediaRequests(widget.URL, widget.ApiKey, widget.Limit)
if err != nil {
return
}
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
widget.MediaRequests = mediaReqs
}
func (widget *mediaRequestsWidget) Render() template.HTML {
return widget.renderTemplate(widget, mediaRequestsWidgetTemplate)
}
type MediaRequest struct {
Id int
Name string
Status int
Availability int
BackdropImageUrl string
PosterImageUrl string
Href string
Type string
CreatedAt time.Time
AirDate string // TODO: change to time.Time
RequestedBy User
}
type mediaRequestsResponse struct {
Results []MediaRequestData `json:"results"`
}
type MediaRequestData struct {
Id int `json:"id"`
Status int `json:"status"`
CreatedAt time.Time `json:"createdAt"`
Type string `json:"type"`
Media struct {
Id int `json:"id"`
MediaType string `json:"mediaType"`
TmdbID int `json:"tmdbId"`
Status int `json:"status"`
CreatedAt time.Time `json:"createdAt"`
} `json:"media"`
RequestedBy User `json:"requestedBy"`
}
type User struct {
Id int `json:"id"`
DisplayName string `json:"displayName"`
Avatar string `json:"avatar"`
Link string `json:"-"`
}
func fetchMediaRequests(instanceURL string, apiKey string, limit int) ([]MediaRequest, error) {
if apiKey == "" {
return nil, errors.New("missing API key")
}
requestURL := fmt.Sprintf("%s/api/v1/request?take=%d&sort=added&sortDirection=desc", strings.TrimRight(instanceURL, "/"), limit)
request, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
request.Header.Set("X-Api-Key", apiKey)
request.Header.Set("accept", "application/json")
client := defaultHTTPClient
responseJson, err := decodeJsonFromRequest[mediaRequestsResponse](client, request)
if err != nil {
return nil, err
}
mediaRequests := make([]MediaRequest, len(responseJson.Results))
for i, res := range responseJson.Results {
info, err := fetchItemInformation(instanceURL, apiKey, res.Media.TmdbID, res.Media.MediaType)
if err != nil {
return nil, err
}
mediaReq := MediaRequest{
Id: res.Id,
Name: info.Name,
Status: res.Status,
Availability: res.Media.Status,
BackdropImageUrl: "https://image.tmdb.org/t/p/original/" + info.BackdropPath,
PosterImageUrl: "https://image.tmdb.org/t/p/w600_and_h900_bestv2/" + info.PosterPath,
Href: fmt.Sprintf("%s/%s/%d", strings.TrimRight(instanceURL, "/"), res.Type, res.Media.TmdbID),
Type: res.Type,
CreatedAt: res.CreatedAt,
AirDate: info.AirDate,
RequestedBy: User{
Id: res.RequestedBy.Id,
DisplayName: res.RequestedBy.DisplayName,
Avatar: constructAvatarUrl(instanceURL, res.RequestedBy.Avatar),
Link: fmt.Sprintf("%s/users/%d", strings.TrimRight(instanceURL, "/"), res.RequestedBy.Id),
},
}
mediaRequests[i] = mediaReq
}
return mediaRequests, nil
}
type MediaInfo struct {
Name string
PosterPath string
BackdropPath string
AirDate string
}
type TvInfo struct {
Name string `json:"name"`
PosterPath string `json:"posterPath"`
BackdropPath string `json:"backdropPath"`
AirDate string `json:"firstAirDate"`
}
type MovieInfo struct {
Name string `json:"name"`
PosterPath string `json:"posterPath"`
BackdropPath string `json:"backdropPath"`
AirDate string `json:"releaseDate"`
}
func fetchItemInformation(instanceURL string, apiKey string, id int, mediaType string) (*MediaInfo, error) {
requestURL := fmt.Sprintf("%s/api/v1/%s/%d", strings.TrimRight(instanceURL, "/"), mediaType, id)
request, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
request.Header.Set("X-Api-Key", apiKey)
request.Header.Set("accept", "application/json")
client := defaultHTTPClient
if mediaType == "tv" {
series, err := decodeJsonFromRequest[TvInfo](client, request)
if err != nil {
return nil, err
}
media := MediaInfo{
Name: series.Name,
PosterPath: series.PosterPath,
BackdropPath: series.BackdropPath,
AirDate: series.AirDate,
}
return &media, nil
}
movie, err := decodeJsonFromRequest[MovieInfo](client, request)
if err != nil {
return nil, err
}
media := MediaInfo{
Name: movie.Name,
PosterPath: movie.PosterPath,
BackdropPath: movie.BackdropPath,
AirDate: movie.AirDate,
}
return &media, nil
}
func constructAvatarUrl(instanceURL string, avatar string) string {
isAbsolute := strings.HasPrefix(avatar, "http://") || strings.HasPrefix(avatar, "https://")
if isAbsolute {
return avatar
}
return instanceURL + avatar
}

View file

@ -75,6 +75,8 @@ func newWidget(widgetType string) (widget, error) {
w = &dockerContainersWidget{}
case "server-stats":
w = &serverStatsWidget{}
case "media-requests":
w = &mediaRequestsWidget{}
default:
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
}