arnaucube
arnaucube/configsPublic Self-hosted repository
configs/server-dockers/jellyfin-news/posters.go
server-dockers/jellyfin-news/posters.goRaw
  1package main
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"errors"
  7	"fmt"
  8	"io"
  9	"net/http"
 10	"net/url"
 11	"regexp"
 12	"sort"
 13	"strconv"
 14	"strings"
 15	"time"
 16)
 17
 18type posterTitle struct {
 19	Name string `json:"name"`
 20	Kind string `json:"kind"`
 21	URL  string `json:"url"`
 22}
 23
 24type extraTitle struct {
 25	name string
 26	kind string
 27	year int
 28}
 29
 30var movieYear = regexp.MustCompile(`^(.*) \(([0-9]{4})\)$`)
 31
 32// parseExtraTitles accepts the headings and bullet entries used in added recaps.
 33func parseExtraTitles(text string, labels Labels) ([]extraTitle, error) {
 34	var titles []extraTitle
 35	kind := ""
 36	for _, raw := range strings.Split(text, "\n") {
 37		line := strings.TrimSpace(strings.TrimSuffix(raw, "\r"))
 38		switch line {
 39		case "":
 40			continue
 41		case labels.Movies + ":":
 42			kind = "Movie"
 43			continue
 44		case labels.Shows + ":":
 45			kind = "Series"
 46			continue
 47		}
 48		if !strings.HasPrefix(line, "• ") {
 49			// A pasted recap can include its date and title above the list.
 50			if kind == "" && !strings.HasPrefix(line, "•") {
 51				continue
 52			}
 53			return nil, fmt.Errorf("Invalid extra title: %s", line)
 54		}
 55		if kind == "" {
 56			return nil, errors.New("Add a Movies: or Shows: heading before extra titles.")
 57		}
 58		name := strings.TrimSpace(strings.TrimPrefix(line, "• "))
 59		if kind == "Series" {
 60			name, _, _ = strings.Cut(name, " — ")
 61		}
 62		year := 0
 63		if kind == "Movie" {
 64			if match := movieYear.FindStringSubmatch(name); match != nil {
 65				name = strings.TrimSpace(match[1])
 66				year, _ = strconv.Atoi(match[2])
 67			}
 68		}
 69		if name == "" || len(titles) >= 50 {
 70			return nil, errors.New("Enter 1 to 50 named movies or shows.")
 71		}
 72		titles = append(titles, extraTitle{name: name, kind: kind, year: year})
 73	}
 74	return titles, nil
 75}
 76
 77// posters uses the same added-date boundaries as the selected text recap.
 78func (a *app) posters(w http.ResponseWriter, r *http.Request) {
 79	var extras []extraTitle
 80	if r.Method == http.MethodPost {
 81		body, err := io.ReadAll(io.LimitReader(r.Body, 32*1024+1))
 82		if err != nil || len(body) > 32*1024 {
 83			http.Error(w, "Extra titles are too long.", http.StatusBadRequest)
 84			return
 85		}
 86		extras, err = parseExtraTitles(string(body), a.labels)
 87		if err != nil || len(extras) == 0 {
 88			http.Error(w, "Use Movies: and Shows: headings with bullet entries, up to 50 titles.", http.StatusBadRequest)
 89			return
 90		}
 91	}
 92	now := a.now().In(a.location)
 93	start, end := a.weekStart.start(now), now
 94	switch r.URL.Query().Get("period") {
 95	case "current-week":
 96	case "week", "month":
 97		offset, ok := readOffset(w, r)
 98		if !ok {
 99			return
100		}
101		if r.URL.Query().Get("period") == "month" {
102			start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, a.location).AddDate(0, offset, 0)
103			end = start.AddDate(0, 1, 0)
104		} else {
105			start = start.AddDate(0, 0, offset*7)
106			end = start.AddDate(0, 0, 7)
107		}
108	default:
109		http.Error(w, "Invalid report period.", http.StatusBadRequest)
110		return
111	}
112	items, err := a.jellyfin.AddedBetween(r.Context(), start, end)
113	if err != nil {
114		http.Error(w, "Could not query Jellyfin for posters.", http.StatusBadGateway)
115		return
116	}
117	titles := make([]posterTitle, 0)
118	seen := make(map[string]bool)
119	for _, item := range items {
120		id, name, kind := item.ID, item.Name, a.labels.Movies
121		switch item.Type {
122		case "Movie":
123			if item.ProductionYear != nil {
124				name = fmt.Sprintf("%s (%d)", name, *item.ProductionYear)
125			}
126		case "Episode":
127			id, name, kind = item.SeriesID, item.SeriesName, a.labels.Shows
128			if strings.TrimSpace(name) == "" {
129				name = a.labels.UnknownShow
130			}
131		default:
132			continue
133		}
134		key := item.Type + ":" + id
135		if id == "" {
136			key += name
137		}
138		if seen[key] {
139			continue
140		}
141		seen[key] = true
142		imageURL := ""
143		if validPosterID.MatchString(id) {
144			imageURL = "/api/poster/" + url.PathEscape(id)
145		}
146		titles = append(titles, posterTitle{Name: name, Kind: kind, URL: imageURL})
147	}
148	if len(extras) > 0 {
149		searcher, ok := a.jellyfin.(interface {
150			FindPosterTitle(context.Context, string, string, int) (string, error)
151		})
152		for _, extra := range extras {
153			kind := a.labels.Movies
154			if extra.kind == "Series" {
155				kind = a.labels.Shows
156			}
157			name := extra.name
158			if extra.year != 0 {
159				name = fmt.Sprintf("%s (%d)", name, extra.year)
160			}
161			key := strings.ToLower(kind + ":" + name)
162			duplicate := false
163			for _, title := range titles {
164				if strings.ToLower(title.Kind+":"+title.Name) == key ||
165					(extra.kind == "Movie" && extra.year == 0 && title.Kind == kind &&
166						movieYear.MatchString(title.Name) && strings.EqualFold(movieYear.FindStringSubmatch(title.Name)[1], name)) {
167					duplicate = true
168					break
169				}
170			}
171			if duplicate {
172				continue
173			}
174			imageURL := ""
175			if ok {
176				id, err := searcher.FindPosterTitle(r.Context(), extra.kind, extra.name, extra.year)
177				if err != nil {
178					http.Error(w, "Could not query Jellyfin for extra posters.", http.StatusBadGateway)
179					return
180				}
181				if validPosterID.MatchString(id) {
182					imageURL = "/api/poster/" + url.PathEscape(id)
183				}
184			}
185			titles = append(titles, posterTitle{Name: name, Kind: kind, URL: imageURL})
186		}
187	}
188	sort.Slice(titles, func(i, j int) bool {
189		if titles[i].Kind != titles[j].Kind {
190			return titles[i].Kind < titles[j].Kind
191		}
192		return strings.ToLower(titles[i].Name) < strings.ToLower(titles[j].Name)
193	})
194	w.Header().Set("Content-Type", "application/json")
195	w.Header().Set("Cache-Control", "no-store")
196	title := a.labels.WeeklyUpdate
197	if r.URL.Query().Get("period") == "month" {
198		title = a.labels.MonthlyUpdate
199	}
200	json.NewEncoder(w).Encode(struct {
201		Start  string        `json:"start"`
202		End    string        `json:"end"`
203		Title  string        `json:"title"`
204		Titles []posterTitle `json:"titles"`
205	}{start.Format("2006-01-02"), end.Format("2006-01-02"), title, titles})
206}
207
208var validPosterID = regexp.MustCompile(`^[a-zA-Z0-9-]{1,64}$`)
209var errNoPoster = errors.New("poster unavailable")
210
211// Poster fetches bounded, resized artwork while keeping Jellyfin credentials on the server.
212func (c *Client) Poster(ctx context.Context, id string) ([]byte, error) {
213	if !validPosterID.MatchString(id) {
214		return nil, errNoPoster
215	}
216	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/Items/"+id+"/Images/Primary?maxWidth=480&maxHeight=720&format=Jpg", nil)
217	if err != nil {
218		return nil, err
219	}
220	req.Header.Set("Authorization", `MediaBrowser Token="`+c.apiKey+`"`)
221	resp, err := c.http.Do(req)
222	if err != nil {
223		return nil, err
224	}
225	defer resp.Body.Close()
226	if resp.StatusCode == http.StatusNotFound {
227		return nil, errNoPoster
228	}
229	if resp.StatusCode != http.StatusOK {
230		return nil, fmt.Errorf("Jellyfin poster returned HTTP %d", resp.StatusCode)
231	}
232	const maxSize = 5 << 20
233	data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1))
234	if err != nil {
235		return nil, err
236	}
237	if len(data) > maxSize {
238		return nil, errors.New("poster exceeds size limit")
239	}
240	switch http.DetectContentType(data) {
241	case "image/jpeg", "image/png", "image/webp":
242		return data, nil
243	default:
244		return nil, errors.New("invalid poster image")
245	}
246}
247
248// poster serves same-origin artwork so the browser can export a canvas without exposing the API key.
249func (a *app) poster(w http.ResponseWriter, r *http.Request) {
250	id := r.PathValue("id")
251	if !validPosterID.MatchString(id) {
252		http.Error(w, "Invalid poster ID.", http.StatusBadRequest)
253		return
254	}
255	source, ok := a.jellyfin.(interface {
256		Poster(context.Context, string) ([]byte, error)
257	})
258	if !ok {
259		http.Error(w, "Posters unavailable.", http.StatusServiceUnavailable)
260		return
261	}
262	data, err := source.Poster(r.Context(), id)
263	if errors.Is(err, errNoPoster) {
264		http.NotFound(w, r)
265		return
266	}
267	if err != nil {
268		http.Error(w, "Could not load poster.", http.StatusBadGateway)
269		return
270	}
271	w.Header().Set("Content-Type", http.DetectContentType(data))
272	w.Header().Set("Cache-Control", "private, max-age=3600")
273	w.Header().Set("X-Content-Type-Options", "nosniff")
274	w.Write(data)
275}