From 60a6c182e2a24b5d0dabae3c0d25dac3ba665cdd Mon Sep 17 00:00:00 2001 From: frahz Date: Thu, 6 Feb 2025 23:43:20 -0800 Subject: [PATCH] feat: add media-requests widget --- docs/configuration.md | 35 ++- internal/glance/static/main.css | 9 + internal/glance/templates/media-requests.html | 37 +++ internal/glance/widget-media.requests.go | 228 ++++++++++++++++++ internal/glance/widget.go | 2 + 5 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 internal/glance/templates/media-requests.html create mode 100644 internal/glance/widget-media.requests.go diff --git a/docs/configuration.md b/docs/configuration.md index 74d10c2..ab5df86 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. diff --git a/internal/glance/static/main.css b/internal/glance/static/main.css index a271d4a..c4eb8cd 100644 --- a/internal/glance/static/main.css +++ b/internal/glance/static/main.css @@ -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; } diff --git a/internal/glance/templates/media-requests.html b/internal/glance/templates/media-requests.html new file mode 100644 index 0000000..33d6e4b --- /dev/null +++ b/internal/glance/templates/media-requests.html @@ -0,0 +1,37 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content" }} + +{{ end }} diff --git a/internal/glance/widget-media.requests.go b/internal/glance/widget-media.requests.go new file mode 100644 index 0000000..2ed4cc2 --- /dev/null +++ b/internal/glance/widget-media.requests.go @@ -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 +} diff --git a/internal/glance/widget.go b/internal/glance/widget.go index c15368a..9a63763 100644 --- a/internal/glance/widget.go +++ b/internal/glance/widget.go @@ -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) }