jellyfin-tools/jellyfin-news/posters.go
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}