jellyfin-tools/jellyfin-news/main.go
jellyfin-news/main.goRaw
1package main
2
3import (
4 "context"
5 _ "embed"
6 "errors"
7 "flag"
8 "fmt"
9 "html"
10 "log"
11 "net/http"
12 "os"
13 "strconv"
14 "strings"
15 "time"
16)
17
18//go:embed index.html
19var indexHTML []byte
20
21type itemSource interface {
22 AddedBetween(context.Context, time.Time, time.Time) ([]Item, error)
23}
24
25type watchedSource interface {
26 WatchedBetween(context.Context, time.Time, time.Time) ([]WatchedTitle, error)
27}
28
29type app struct {
30 weekStart weekStart
31 jellystat watchedSource
32 jellyfin itemSource
33 location *time.Location
34 labels Labels
35 now func() time.Time
36}
37
38func main() {
39 listen := flag.String("listen", env("LISTEN_ADDR", ":8080"), "HTTP listen address")
40 flag.Parse()
41
42 location, err := time.LoadLocation(env("TZ", "Local"))
43 if err != nil {
44 log.Fatalf("invalid TZ: %v", err)
45 }
46 week, err := loadWeekStart()
47 if err != nil {
48 log.Fatal(err)
49 }
50 client, err := New(os.Getenv("JELLYFIN_URL"), os.Getenv("JELLYFIN_API_KEY"), os.Getenv("JELLYFIN_USER_ID"), nil)
51 if err != nil {
52 log.Fatalf("configuration error: %v", err)
53 }
54
55 application := &app{weekStart: week, jellyfin: client, location: location, labels: loadLabels(), now: time.Now}
56 if baseURL := os.Getenv("JELLYSTAT_URL"); strings.TrimSpace(baseURL) != "" {
57 application.jellystat, err = NewJellystat(baseURL, os.Getenv("JELLYSTAT_API_KEY"))
58 if err != nil {
59 log.Fatal(err)
60 }
61 }
62 server := &http.Server{
63 Addr: *listen,
64 Handler: application.routes(),
65 ReadHeaderTimeout: 5 * time.Second,
66 ReadTimeout: 15 * time.Second,
67 WriteTimeout: 60 * time.Second,
68 IdleTimeout: 60 * time.Second,
69 }
70 log.Printf("Jellyfin updates available at http://%s", displayAddress(*listen))
71 if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
72 log.Fatal(err)
73 }
74}
75
76func (a *app) routes() http.Handler {
77 mux := http.NewServeMux()
78 mux.HandleFunc("GET /{$}", a.index)
79 mux.HandleFunc("GET /api/current-week", a.currentWeek)
80 mux.HandleFunc("GET /api/week", a.week)
81 mux.HandleFunc("GET /api/month", a.month)
82 mux.HandleFunc("GET /api/watched", a.watched)
83 mux.HandleFunc("GET /api/posters", a.posters)
84 mux.HandleFunc("POST /api/posters", a.posters)
85 mux.HandleFunc("GET /api/poster/{id}", a.poster)
86 return mux
87}
88
89func (a *app) index(w http.ResponseWriter, r *http.Request) {
90 w.Header().Set("Content-Type", "text/html; charset=utf-8")
91 w.Write([]byte(strings.ReplaceAll(string(indexHTML), "{{WATCHED_BUTTON}}", html.EscapeString(a.labels.WatchedButton))))
92}
93
94func (a *app) currentWeek(w http.ResponseWriter, r *http.Request) {
95 now := a.now().In(a.location)
96 start := a.weekStart.start(now)
97 a.writeWeek(w, r, start, now)
98}
99
100func (a *app) week(w http.ResponseWriter, r *http.Request) {
101 offset, ok := readOffset(w, r)
102 if !ok {
103 return
104 }
105 currentWeek := a.weekStart.start(a.now().In(a.location))
106 start := currentWeek.AddDate(0, 0, offset*7)
107 a.writeWeek(w, r, start, start.AddDate(0, 0, 7))
108}
109
110func (a *app) month(w http.ResponseWriter, r *http.Request) {
111 offset, ok := readOffset(w, r)
112 if !ok {
113 return
114 }
115 a.writeMonth(w, r, offset)
116}
117
118func (a *app) writeWeek(w http.ResponseWriter, r *http.Request, start, end time.Time) {
119 items, err := a.jellyfin.AddedBetween(r.Context(), start, end)
120 if err != nil {
121 log.Printf("Jellyfin weekly query failed: %v", err)
122 http.Error(w, "Could not query Jellyfin. Check the server logs.", http.StatusBadGateway)
123 return
124 }
125 writeText(w, FormatWeek(start, end, items, a.labels))
126}
127
128func (a *app) writeMonth(w http.ResponseWriter, r *http.Request, offset int) {
129 now := a.now().In(a.location)
130 currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, a.location)
131 start := currentMonth.AddDate(0, offset, 0)
132 end := start.AddDate(0, 1, 0)
133 items, err := a.jellyfin.AddedBetween(r.Context(), start, end)
134 if err != nil {
135 log.Printf("Jellyfin monthly query failed: %v", err)
136 http.Error(w, "Could not query Jellyfin. Check the server logs.", http.StatusBadGateway)
137 return
138 }
139 writeText(w, FormatMonth(start, items, a.labels))
140}
141
142func writeText(w http.ResponseWriter, text string) {
143 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
144 w.Header().Set("Cache-Control", "no-store")
145 w.Write([]byte(text))
146}
147
148func readOffset(w http.ResponseWriter, r *http.Request) (int, bool) {
149 offset, err := strconv.Atoi(r.URL.Query().Get("offset"))
150 if err != nil || offset < -1200 || offset > -1 {
151 http.Error(w, "offset must be an integer between -1200 and -1", http.StatusBadRequest)
152 return 0, false
153 }
154 return offset, true
155}
156
157// weekStart defines a weekly boundary in the report's local timezone.
158type weekStart struct {
159 day time.Weekday
160 hour, minute int
161}
162
163// loadWeekStart rejects invalid settings rather than silently changing report periods.
164func loadWeekStart() (weekStart, error) {
165 day := env("WEEK_START_DAY", "Monday")
166 clock := env("WEEK_START_TIME", "00:00")
167 var result weekStart
168 found := false
169 for d := time.Sunday; d <= time.Saturday; d++ {
170 if strings.EqualFold(day, d.String()) {
171 result.day = d
172 found = true
173 break
174 }
175 }
176 if !found {
177 return result, fmt.Errorf("WEEK_START_DAY must be a full weekday name (Monday through Sunday)")
178 }
179 parsed, err := time.Parse("15:04", clock)
180 if err != nil || parsed.Format("15:04") != clock {
181 return result, fmt.Errorf("WEEK_START_TIME must be HH:MM in 24-hour format")
182 }
183 result.hour, result.minute = parsed.Hour(), parsed.Minute()
184 return result, nil
185}
186
187// start finds the latest weekly boundary, including before today's start time.
188func (w weekStart) start(now time.Time) time.Time {
189 days := (int(now.Weekday()) - int(w.day) + 7) % 7
190 boundary := time.Date(now.Year(), now.Month(), now.Day()-days, w.hour, w.minute, 0, 0, now.Location())
191 if boundary.After(now) {
192 boundary = boundary.AddDate(0, 0, -7)
193 }
194 return boundary
195}
196
197func env(name, fallback string) string {
198 if value := strings.TrimSpace(os.Getenv(name)); value != "" {
199 return value
200 }
201 return fallback
202}
203
204func loadLabels() Labels {
205 defaults := DefaultLabels()
206 return Labels{
207 WatchedUpdate: env("TEXT_WATCHED_UPDATE", defaults.WatchedUpdate),
208 NoWatchedItems: env("TEXT_NO_WATCHED_ITEMS", defaults.NoWatchedItems),
209 Users: env("TEXT_USERS", defaults.Users),
210 WatchedButton: env("TEXT_WATCHED_BUTTON", defaults.WatchedButton),
211 WeeklyUpdate: env("TEXT_WEEKLY_UPDATE", defaults.WeeklyUpdate),
212 MonthlyUpdate: env("TEXT_MONTHLY_UPDATE", defaults.MonthlyUpdate),
213 Movies: env("TEXT_MOVIES", defaults.Movies),
214 Shows: env("TEXT_SHOWS", defaults.Shows),
215 Season: env("TEXT_SEASON", defaults.Season),
216 Specials: env("TEXT_SPECIALS", defaults.Specials),
217 Episode: env("TEXT_EPISODE", defaults.Episode),
218 Episodes: env("TEXT_EPISODES", defaults.Episodes),
219 UnknownShow: env("TEXT_UNKNOWN_SHOW", defaults.UnknownShow),
220 NoWeeklyItems: env("TEXT_NO_WEEKLY_ITEMS", defaults.NoWeeklyItems),
221 NoMonthlyItems: env("TEXT_NO_MONTHLY_ITEMS", defaults.NoMonthlyItems),
222 }
223}
224
225func displayAddress(address string) string {
226 if strings.HasPrefix(address, ":") {
227 return "localhost" + address
228 }
229 return address
230}
231
232// watched uses the same calendar boundaries as the added-content reports.
233func (a *app) watched(w http.ResponseWriter, r *http.Request) {
234 now := a.now().In(a.location)
235 start, end := a.weekStart.start(now), now
236 period := r.URL.Query().Get("period")
237 switch period {
238 case "current-week":
239 case "week", "month":
240 offset, ok := readOffset(w, r)
241 if !ok {
242 return
243 }
244 if period == "week" {
245 start = start.AddDate(0, 0, offset*7)
246 end = start.AddDate(0, 0, 7)
247 } else {
248 start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, a.location).AddDate(0, offset, 0)
249 end = start.AddDate(0, 1, 0)
250 }
251 default:
252 http.Error(w, "Invalid report period.", http.StatusBadRequest)
253 return
254 }
255 if a.jellystat == nil {
256 http.Error(w, "Configure JELLYSTAT_URL and JELLYSTAT_API_KEY to use watched reports.", http.StatusServiceUnavailable)
257 return
258 }
259 ctx, cancel := context.WithTimeout(r.Context(), 50*time.Second)
260 defer cancel()
261 titles, err := a.jellystat.WatchedBetween(ctx, start, end)
262 if err != nil {
263 log.Printf("Jellystat query failed: %v", err)
264 http.Error(w, "Could not query Jellystat. Check the server logs.", http.StatusBadGateway)
265 return
266 }
267 writeText(w, FormatWatched(start, end, period == "month", titles, a.labels))
268}