feat: add media-requests widget
This commit is contained in:
parent
232cab01f8
commit
60a6c182e2
5 changed files with 310 additions and 1 deletions
|
@ -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:
|
||||
|
@ -2377,3 +2377,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.
|
||||
|
|
|
@ -1471,6 +1471,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);
|
||||
|
@ -2018,6 +2024,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; }
|
||||
|
|
37
internal/glance/templates/media-requests.html
Normal file
37
internal/glance/templates/media-requests.html
Normal 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 }}
|
228
internal/glance/widget-media.requests.go
Normal file
228
internal/glance/widget-media.requests.go
Normal 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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue