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}