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	"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}