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