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

feat: add media-requests widget #345

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
35 changes: 34 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
9 changes: 9 additions & 0 deletions internal/glance/static/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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; }
Expand Down
37 changes: 37 additions & 0 deletions internal/glance/templates/media-requests.html
Original file line number Diff line number Diff line change
@@ -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 changes: 228 additions & 0 deletions internal/glance/widget-media.requests.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions internal/glance/widget.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down