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