Kaynağa Gözat

feat: add media-requests widget

frahz 4 ay önce
ebeveyn
işleme
60a6c182e2

+ 34 - 1
docs/configuration.md

@@ -39,7 +39,7 @@
   - [Twitch Top Games](#twitch-top-games)
   - [Twitch Top Games](#twitch-top-games)
   - [iframe](#iframe)
   - [iframe](#iframe)
   - [HTML](#html)
   - [HTML](#html)
-
+  - [Media Requests](#media-requests)
 
 
 ## Preconfigured page
 ## 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:
 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.
 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 - 0
internal/glance/static/main.css

@@ -1471,6 +1471,12 @@ details[open] .summary::after {
     flex-shrink: 0;
     flex-shrink: 0;
 }
 }
 
 
+.media-requests-thumbnail {
+    width: 5rem;
+    aspect-ratio: 3 / 4;
+    border-radius: var(--border-radius);
+}
+
 .docker-container-icon {
 .docker-container-icon {
     display: block;
     display: block;
     filter: grayscale(0.4);
     filter: grayscale(0.4);
@@ -2018,6 +2024,9 @@ details[open] .summary::after {
 .color-negative     { color: var(--color-negative); }
 .color-negative     { color: var(--color-negative); }
 .color-positive     { color: var(--color-positive); }
 .color-positive     { color: var(--color-positive); }
 .color-primary      { color: var(--color-primary); }
 .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; }
 .cursor-help        { cursor: help; }
 .break-all          { word-break: break-all; }
 .break-all          { word-break: break-all; }

+ 37 - 0
internal/glance/templates/media-requests.html

@@ -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 - 0
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
+}

+ 2 - 0
internal/glance/widget.go

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