arnaucube
arnaucube/configsPublic Self-hosted repository
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}