configs/server-dockers/jellyfin-news/main.go
server-dockers/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 return mux
84}
85
86func (a *app) index(w http.ResponseWriter, r *http.Request) {
87 w.Header().Set("Content-Type", "text/html; charset=utf-8")
88 w.Write([]byte(strings.ReplaceAll(string(indexHTML), "{{WATCHED_BUTTON}}", html.EscapeString(a.labels.WatchedButton))))
89}
90
91func (a *app) currentWeek(w http.ResponseWriter, r *http.Request) {
92 now := a.now().In(a.location)
93 start := a.weekStart.start(now)
94 a.writeWeek(w, r, start, now)
95}
96
97func (a *app) week(w http.ResponseWriter, r *http.Request) {
98 offset, ok := readOffset(w, r)
99 if !ok {
100 return
101 }
102 currentWeek := a.weekStart.start(a.now().In(a.location))
103 start := currentWeek.AddDate(0, 0, offset*7)
104 a.writeWeek(w, r, start, start.AddDate(0, 0, 7))
105}
106
107func (a *app) month(w http.ResponseWriter, r *http.Request) {
108 offset, ok := readOffset(w, r)
109 if !ok {
110 return
111 }
112 a.writeMonth(w, r, offset)
113}
114
115func (a *app) writeWeek(w http.ResponseWriter, r *http.Request, start, end time.Time) {
116 items, err := a.jellyfin.AddedBetween(r.Context(), start, end)
117 if err != nil {
118 log.Printf("Jellyfin weekly query failed: %v", err)
119 http.Error(w, "Could not query Jellyfin. Check the server logs.", http.StatusBadGateway)
120 return
121 }
122 writeText(w, FormatWeek(start, end, items, a.labels))
123}
124
125func (a *app) writeMonth(w http.ResponseWriter, r *http.Request, offset int) {
126 now := a.now().In(a.location)
127 currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, a.location)
128 start := currentMonth.AddDate(0, offset, 0)
129 end := start.AddDate(0, 1, 0)
130 items, err := a.jellyfin.AddedBetween(r.Context(), start, end)
131 if err != nil {
132 log.Printf("Jellyfin monthly query failed: %v", err)
133 http.Error(w, "Could not query Jellyfin. Check the server logs.", http.StatusBadGateway)
134 return
135 }
136 writeText(w, FormatMonth(start, items, a.labels))
137}
138
139func writeText(w http.ResponseWriter, text string) {
140 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
141 w.Header().Set("Cache-Control", "no-store")
142 w.Write([]byte(text))
143}
144
145func readOffset(w http.ResponseWriter, r *http.Request) (int, bool) {
146 offset, err := strconv.Atoi(r.URL.Query().Get("offset"))
147 if err != nil || offset < -1200 || offset > -1 {
148 http.Error(w, "offset must be an integer between -1200 and -1", http.StatusBadRequest)
149 return 0, false
150 }
151 return offset, true
152}
153
154// weekStart defines a weekly boundary in the report's local timezone.
155type weekStart struct {
156 day time.Weekday
157 hour, minute int
158}
159
160// loadWeekStart rejects invalid settings rather than silently changing report periods.
161func loadWeekStart() (weekStart, error) {
162 day := env("WEEK_START_DAY", "Monday")
163 clock := env("WEEK_START_TIME", "00:00")
164 var result weekStart
165 found := false
166 for d := time.Sunday; d <= time.Saturday; d++ {
167 if strings.EqualFold(day, d.String()) {
168 result.day = d
169 found = true
170 break
171 }
172 }
173 if !found {
174 return result, fmt.Errorf("WEEK_START_DAY must be a full weekday name (Monday through Sunday)")
175 }
176 parsed, err := time.Parse("15:04", clock)
177 if err != nil || parsed.Format("15:04") != clock {
178 return result, fmt.Errorf("WEEK_START_TIME must be HH:MM in 24-hour format")
179 }
180 result.hour, result.minute = parsed.Hour(), parsed.Minute()
181 return result, nil
182}
183
184// start finds the latest weekly boundary, including before today's start time.
185func (w weekStart) start(now time.Time) time.Time {
186 days := (int(now.Weekday()) - int(w.day) + 7) % 7
187 boundary := time.Date(now.Year(), now.Month(), now.Day()-days, w.hour, w.minute, 0, 0, now.Location())
188 if boundary.After(now) {
189 boundary = boundary.AddDate(0, 0, -7)
190 }
191 return boundary
192}
193
194func env(name, fallback string) string {
195 if value := strings.TrimSpace(os.Getenv(name)); value != "" {
196 return value
197 }
198 return fallback
199}
200
201func loadLabels() Labels {
202 defaults := DefaultLabels()
203 return Labels{
204 WatchedUpdate: env("TEXT_WATCHED_UPDATE", defaults.WatchedUpdate),
205 NoWatchedItems: env("TEXT_NO_WATCHED_ITEMS", defaults.NoWatchedItems),
206 Users: env("TEXT_USERS", defaults.Users),
207 WatchedButton: env("TEXT_WATCHED_BUTTON", defaults.WatchedButton),
208 WeeklyUpdate: env("TEXT_WEEKLY_UPDATE", defaults.WeeklyUpdate),
209 MonthlyUpdate: env("TEXT_MONTHLY_UPDATE", defaults.MonthlyUpdate),
210 Movies: env("TEXT_MOVIES", defaults.Movies),
211 Shows: env("TEXT_SHOWS", defaults.Shows),
212 Season: env("TEXT_SEASON", defaults.Season),
213 Specials: env("TEXT_SPECIALS", defaults.Specials),
214 Episode: env("TEXT_EPISODE", defaults.Episode),
215 Episodes: env("TEXT_EPISODES", defaults.Episodes),
216 UnknownShow: env("TEXT_UNKNOWN_SHOW", defaults.UnknownShow),
217 NoWeeklyItems: env("TEXT_NO_WEEKLY_ITEMS", defaults.NoWeeklyItems),
218 NoMonthlyItems: env("TEXT_NO_MONTHLY_ITEMS", defaults.NoMonthlyItems),
219 }
220}
221
222func displayAddress(address string) string {
223 if strings.HasPrefix(address, ":") {
224 return "localhost" + address
225 }
226 return address
227}
228
229// watched uses the same calendar boundaries as the added-content reports.
230func (a *app) watched(w http.ResponseWriter, r *http.Request) {
231 now := a.now().In(a.location)
232 start, end := a.weekStart.start(now), now
233 period := r.URL.Query().Get("period")
234 switch period {
235 case "current-week":
236 case "week", "month":
237 offset, ok := readOffset(w, r)
238 if !ok {
239 return
240 }
241 if period == "week" {
242 start = start.AddDate(0, 0, offset*7)
243 end = start.AddDate(0, 0, 7)
244 } else {
245 start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, a.location).AddDate(0, offset, 0)
246 end = start.AddDate(0, 1, 0)
247 }
248 default:
249 http.Error(w, "Invalid report period.", http.StatusBadRequest)
250 return
251 }
252 if a.jellystat == nil {
253 http.Error(w, "Configure JELLYSTAT_URL and JELLYSTAT_API_KEY to use watched reports.", http.StatusServiceUnavailable)
254 return
255 }
256 ctx, cancel := context.WithTimeout(r.Context(), 50*time.Second)
257 defer cancel()
258 titles, err := a.jellystat.WatchedBetween(ctx, start, end)
259 if err != nil {
260 log.Printf("Jellystat query failed: %v", err)
261 http.Error(w, "Could not query Jellystat. Check the server logs.", http.StatusBadGateway)
262 return
263 }
264 writeText(w, FormatWatched(start, end, period == "month", titles, a.labels))
265}