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	"log"
  9	"net/http"
 10	"os"
 11	"strconv"
 12	"strings"
 13	"time"
 14)
 15
 16//go:embed index.html
 17var indexHTML []byte
 18
 19type itemSource interface {
 20	AddedBetween(context.Context, time.Time, time.Time) ([]Item, error)
 21}
 22
 23type app struct {
 24	jellyfin itemSource
 25	location *time.Location
 26	labels   Labels
 27	now      func() time.Time
 28}
 29
 30func main() {
 31	listen := flag.String("listen", env("LISTEN_ADDR", ":8080"), "HTTP listen address")
 32	flag.Parse()
 33
 34	location, err := time.LoadLocation(env("TZ", "Local"))
 35	if err != nil {
 36		log.Fatalf("invalid TZ: %v", err)
 37	}
 38	client, err := New(os.Getenv("JELLYFIN_URL"), os.Getenv("JELLYFIN_API_KEY"), os.Getenv("JELLYFIN_USER_ID"), nil)
 39	if err != nil {
 40		log.Fatalf("configuration error: %v", err)
 41	}
 42
 43	application := &app{jellyfin: client, location: location, labels: loadLabels(), now: time.Now}
 44	server := &http.Server{
 45		Addr:              *listen,
 46		Handler:           application.routes(),
 47		ReadHeaderTimeout: 5 * time.Second,
 48		ReadTimeout:       15 * time.Second,
 49		WriteTimeout:      60 * time.Second,
 50		IdleTimeout:       60 * time.Second,
 51	}
 52	log.Printf("Jellyfin updates available at http://%s", displayAddress(*listen))
 53	if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
 54		log.Fatal(err)
 55	}
 56}
 57
 58func (a *app) routes() http.Handler {
 59	mux := http.NewServeMux()
 60	mux.HandleFunc("GET /{$}", a.index)
 61	mux.HandleFunc("GET /api/current-week", a.currentWeek)
 62	mux.HandleFunc("GET /api/week", a.week)
 63	mux.HandleFunc("GET /api/month", a.month)
 64	return mux
 65}
 66
 67func (a *app) index(w http.ResponseWriter, r *http.Request) {
 68	w.Header().Set("Content-Type", "text/html; charset=utf-8")
 69	w.Write(indexHTML)
 70}
 71
 72func (a *app) currentWeek(w http.ResponseWriter, r *http.Request) {
 73	now := a.now().In(a.location)
 74	start := startOfWeek(now)
 75	a.writeWeek(w, r, start, now)
 76}
 77
 78func (a *app) week(w http.ResponseWriter, r *http.Request) {
 79	offset, ok := readOffset(w, r)
 80	if !ok {
 81		return
 82	}
 83	currentWeek := startOfWeek(a.now().In(a.location))
 84	start := currentWeek.AddDate(0, 0, offset*7)
 85	a.writeWeek(w, r, start, start.AddDate(0, 0, 7))
 86}
 87
 88func (a *app) month(w http.ResponseWriter, r *http.Request) {
 89	offset, ok := readOffset(w, r)
 90	if !ok {
 91		return
 92	}
 93	a.writeMonth(w, r, offset)
 94}
 95
 96func (a *app) writeWeek(w http.ResponseWriter, r *http.Request, start, end time.Time) {
 97	items, err := a.jellyfin.AddedBetween(r.Context(), start, end)
 98	if err != nil {
 99		log.Printf("Jellyfin weekly query failed: %v", err)
100		http.Error(w, "Could not query Jellyfin. Check the server logs.", http.StatusBadGateway)
101		return
102	}
103	writeText(w, FormatWeek(start, end, items, a.labels))
104}
105
106func (a *app) writeMonth(w http.ResponseWriter, r *http.Request, offset int) {
107	now := a.now().In(a.location)
108	currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, a.location)
109	start := currentMonth.AddDate(0, offset, 0)
110	end := start.AddDate(0, 1, 0)
111	items, err := a.jellyfin.AddedBetween(r.Context(), start, end)
112	if err != nil {
113		log.Printf("Jellyfin monthly query failed: %v", err)
114		http.Error(w, "Could not query Jellyfin. Check the server logs.", http.StatusBadGateway)
115		return
116	}
117	writeText(w, FormatMonth(start, items, a.labels))
118}
119
120func writeText(w http.ResponseWriter, text string) {
121	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
122	w.Header().Set("Cache-Control", "no-store")
123	w.Write([]byte(text))
124}
125
126func readOffset(w http.ResponseWriter, r *http.Request) (int, bool) {
127	offset, err := strconv.Atoi(r.URL.Query().Get("offset"))
128	if err != nil || offset < -1200 || offset > -1 {
129		http.Error(w, "offset must be an integer between -1200 and -1", http.StatusBadRequest)
130		return 0, false
131	}
132	return offset, true
133}
134
135func startOfWeek(now time.Time) time.Time {
136	daysSinceMonday := (int(now.Weekday()) - int(time.Monday) + 7) % 7
137	return time.Date(now.Year(), now.Month(), now.Day()-daysSinceMonday, 0, 0, 0, 0, now.Location())
138}
139
140func env(name, fallback string) string {
141	if value := strings.TrimSpace(os.Getenv(name)); value != "" {
142		return value
143	}
144	return fallback
145}
146
147func loadLabels() Labels {
148	defaults := DefaultLabels()
149	return Labels{
150		WeeklyUpdate:   env("TEXT_WEEKLY_UPDATE", defaults.WeeklyUpdate),
151		MonthlyUpdate:  env("TEXT_MONTHLY_UPDATE", defaults.MonthlyUpdate),
152		Movies:         env("TEXT_MOVIES", defaults.Movies),
153		Shows:          env("TEXT_SHOWS", defaults.Shows),
154		Season:         env("TEXT_SEASON", defaults.Season),
155		Specials:       env("TEXT_SPECIALS", defaults.Specials),
156		Episode:        env("TEXT_EPISODE", defaults.Episode),
157		Episodes:       env("TEXT_EPISODES", defaults.Episodes),
158		UnknownShow:    env("TEXT_UNKNOWN_SHOW", defaults.UnknownShow),
159		NoWeeklyItems:  env("TEXT_NO_WEEKLY_ITEMS", defaults.NoWeeklyItems),
160		NoMonthlyItems: env("TEXT_NO_MONTHLY_ITEMS", defaults.NoMonthlyItems),
161	}
162}
163
164func displayAddress(address string) string {
165	if strings.HasPrefix(address, ":") {
166		return "localhost" + address
167	}
168	return address
169}