add server-dockers/jellyfin-news, small util to get jellyfin weekly updates in text format to send over chat
arnaucube git@arnaucube.com
Wed, 02 Sep 2026 12:52:59 +0200
14 files changed,
875 insertions(+),
0 deletions(-)
jump to
M
server-dockers/docker-compose.yml
→
server-dockers/docker-compose.yml
@@ -42,6 +42,7 @@ # - lingarr: 8107
# - libretranslate: 8108 # - lidarr: 8109 # - swiparr: 8110 +# - jellyfin-news: 8111 # - qbittorrent: 8080 # - homepage: 8000 # - grafana: 3000@@ -303,6 +304,14 @@ volumes:
- /home/beelink/configurations/swiparr:/app/data ports: - 8110:4321 + + + jellyfin-news: + build: ./jellyfin-news + restart: unless-stopped + env_file: ./jellyfin-news/.env + ports: + - "8111:8080" homepage: image: ghcr.io/gethomepage/homepage:latest
A
server-dockers/jellyfin-news/.env.example
@@ -0,0 +1,20 @@
+JELLYFIN_URL=http://192.168.1.20:8096 +JELLYFIN_API_KEY=replace-with-a-jellyfin-api-key +# Optional: restrict results to libraries visible to this Jellyfin user. +JELLYFIN_USER_ID= + +TZ=Europe/London +LISTEN_ADDR=:8080 + +# Optional: customize every phrase printed in copyable recaps. +TEXT_WEEKLY_UPDATE=jellyfin weekly update +TEXT_MONTHLY_UPDATE=jellyfin monthly update +TEXT_MOVIES=Movies +TEXT_SHOWS=Shows +TEXT_SEASON=Season +TEXT_SPECIALS=Specials +TEXT_EPISODE=episode +TEXT_EPISODES=episodes +TEXT_UNKNOWN_SHOW=Unknown show +TEXT_NO_WEEKLY_ITEMS=No new movies or show episodes this week. +TEXT_NO_MONTHLY_ITEMS=No new movies or show episodes last month.
A
server-dockers/jellyfin-news/Dockerfile
@@ -0,0 +1,11 @@
+FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.mod *.go index.html ./ +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /jellyfin-news . + +FROM alpine:3.22 +RUN apk add --no-cache ca-certificates tzdata && adduser -D -u 10001 app +USER app +COPY --from=build /jellyfin-news /usr/local/bin/jellyfin-news +EXPOSE 8080 +ENTRYPOINT ["jellyfin-news"]
A
server-dockers/jellyfin-news/README.md
@@ -0,0 +1,59 @@
+# Jellyfin update webpage + +A small Go web server for generating copyable Jellyfin movie and episode recaps. It serves one embedded `index.html` page, a database, or third-party Go dependencies. + +The page provides: + +- **Current week**: from Monday at 00:00 through now. +- **Selected week**: `-1` means last week, `-2` means two weeks ago, and so on. +- **Selected month**: `-1` means last month, `-2` means two months ago, and so on. +- **Copy to clipboard**: copies the generated plain-text recap. + +Weekly output looks like: + +```text +2026-08-15 - 2026-08-22 jellyfin weekly update: + +Movies: +• Dune (2021) + +Shows: +• Example Show — Season 3, episodes 1-12 +``` + +## Configuration + +Copy the example and edit it: + +```sh +cp .env.example .env +``` + +## Run with Docker Compose + +```sh +docker compose up -d --build +docker compose logs -f +``` + +Then open: + +```text +http://<home-server-ip>:8080 +``` + +## Run as a local binary + +Go 1.23 or newer is required. + +```sh +go test ./... +go build -o jellyfin-news . +set -a +. ./.env +set +a +./jellyfin-news +``` + +The API key remains on the server and is never sent to the browser. The browser only receives formatted recap text. This app has no authentication, so keep port 8080 limited to your trusted home LAN and do not expose it directly to the internet. +
A
server-dockers/jellyfin-news/compose.yaml
@@ -0,0 +1,7 @@
+services: + jellyfin-news: + build: . + restart: unless-stopped + env_file: .env + ports: + - "8080:8080"
A
server-dockers/jellyfin-news/digest.go
@@ -0,0 +1,161 @@
+package main + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// Item is the subset of Jellyfin item metadata needed by a digest. +type Item struct { + Type string + Name string + SeriesID string + SeriesName string + SeasonNumber *int + EpisodeNumber *int + ProductionYear *int +} + +// Labels contains every translatable phrase used in generated recap text. +type Labels struct { + WeeklyUpdate string + MonthlyUpdate string + Movies string + Shows string + Season string + Specials string + Episode string + Episodes string + UnknownShow string + NoWeeklyItems string + NoMonthlyItems string +} + +func DefaultLabels() Labels { + return Labels{ + WeeklyUpdate: "jellyfin weekly update", + MonthlyUpdate: "jellyfin monthly update", + Movies: "Movies", + Shows: "Shows", + Season: "Season", + Specials: "Specials", + Episode: "episode", + Episodes: "episodes", + UnknownShow: "Unknown show", + NoWeeklyItems: "No new movies or show episodes this week.", + NoMonthlyItems: "No new movies or show episodes last month.", + } +} + +// FormatWeek creates a plain-text weekly message suitable for copying and sharing. +func FormatWeek(start, end time.Time, items []Item, labels Labels) string { + title := start.Format("2006-01-02") + " - " + end.Format("2006-01-02") + " " + labels.WeeklyUpdate + ":" + return format(title, labels.NoWeeklyItems, items, labels) +} + +// FormatMonth creates a recap for a calendar month. +func FormatMonth(month time.Time, items []Item, labels Labels) string { + return format(month.Format("2006-01")+" "+labels.MonthlyUpdate+":", labels.NoMonthlyItems, items, labels) +} + +func format(title, emptyMessage string, items []Item, labels Labels) string { + movies := make([]Item, 0) + type seasonKey struct { + seriesID, seriesName string + season int + } + seasons := make(map[seasonKey]map[int]struct{}) + + for _, item := range items { + switch item.Type { + case "Movie": + movies = append(movies, item) + case "Episode": + if item.SeasonNumber == nil || item.EpisodeNumber == nil { + continue + } + name := strings.TrimSpace(item.SeriesName) + if name == "" { + name = labels.UnknownShow + } + key := seasonKey{seriesID: item.SeriesID, seriesName: name, season: *item.SeasonNumber} + if seasons[key] == nil { + seasons[key] = make(map[int]struct{}) + } + seasons[key][*item.EpisodeNumber] = struct{}{} + } + } + + sort.Slice(movies, func(i, j int) bool { + return strings.ToLower(movies[i].Name) < strings.ToLower(movies[j].Name) + }) + keys := make([]seasonKey, 0, len(seasons)) + for key := range seasons { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + a, b := strings.ToLower(keys[i].seriesName), strings.ToLower(keys[j].seriesName) + if a == b { + return keys[i].season < keys[j].season + } + return a < b + }) + + var out strings.Builder + out.WriteString(title) + out.WriteByte('\n') + if len(movies) == 0 && len(keys) == 0 { + out.WriteByte('\n') + out.WriteString(emptyMessage) + return out.String() + } + if len(movies) > 0 { + fmt.Fprintf(&out, "\n%s:\n", labels.Movies) + for _, movie := range movies { + fmt.Fprintf(&out, "• %s", movie.Name) + if movie.ProductionYear != nil { + fmt.Fprintf(&out, " (%d)", *movie.ProductionYear) + } + out.WriteByte('\n') + } + } + if len(keys) > 0 { + fmt.Fprintf(&out, "\n%s:\n", labels.Shows) + for _, key := range keys { + episodes := make([]int, 0, len(seasons[key])) + for episode := range seasons[key] { + episodes = append(episodes, episode) + } + sort.Ints(episodes) + season := fmt.Sprintf("%s %d", labels.Season, key.season) + if key.season == 0 { + season = labels.Specials + } + label := labels.Episodes + if len(episodes) == 1 { + label = labels.Episode + } + fmt.Fprintf(&out, "• %s — %s, %s %s\n", key.seriesName, season, label, ranges(episodes)) + } + } + return strings.TrimRight(out.String(), "\n") +} + +func ranges(numbers []int) string { + parts := make([]string, 0) + for i := 0; i < len(numbers); { + j := i + for j+1 < len(numbers) && numbers[j+1] == numbers[j]+1 { + j++ + } + if i == j { + parts = append(parts, fmt.Sprint(numbers[i])) + } else { + parts = append(parts, fmt.Sprintf("%d-%d", numbers[i], numbers[j])) + } + i = j + 1 + } + return strings.Join(parts, ", ") +}
A
server-dockers/jellyfin-news/digest_test.go
@@ -0,0 +1,67 @@
+package main + +import ( + "testing" + "time" +) + +func intp(n int) *int { return &n } + +func TestFormatGroupsEpisodes(t *testing.T) { + items := []Item{ + {Type: "Movie", Name: "Dune", ProductionYear: intp(2021)}, + {Type: "Episode", SeriesID: "a", SeriesName: "Example Show", SeasonNumber: intp(3), EpisodeNumber: intp(12)}, + {Type: "Episode", SeriesID: "a", SeriesName: "Example Show", SeasonNumber: intp(3), EpisodeNumber: intp(1)}, + {Type: "Episode", SeriesID: "a", SeriesName: "Example Show", SeasonNumber: intp(3), EpisodeNumber: intp(2)}, + {Type: "Episode", SeriesID: "a", SeriesName: "Example Show", SeasonNumber: intp(3), EpisodeNumber: intp(3)}, + {Type: "Episode", SeriesID: "a", SeriesName: "Example Show", SeasonNumber: intp(3), EpisodeNumber: intp(5)}, + } + start := time.Date(2026, 8, 18, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC) + want := "2026-08-18 - 2026-08-25 jellyfin weekly update:\n\nMovies:\n• Dune (2021)\n\nShows:\n• Example Show — Season 3, episodes 1-3, 5, 12" + if got := FormatWeek(start, end, items, DefaultLabels()); got != want { + t.Fatalf("FormatWeek() = %q, want %q", got, want) + } +} + +func TestFormatEmpty(t *testing.T) { + start := time.Date(2026, 8, 18, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC) + want := "2026-08-18 - 2026-08-25 jellyfin weekly update:\n\nNo new movies or show episodes this week." + if got := FormatWeek(start, end, nil, DefaultLabels()); got != want { + t.Fatalf("FormatWeek() = %q, want %q", got, want) + } +} + +func TestFormatMonth(t *testing.T) { + want := "2026-07 jellyfin monthly update:\n\nNo new movies or show episodes last month." + if got := FormatMonth(time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), nil, DefaultLabels()); got != want { + t.Fatalf("FormatMonth() = %q, want %q", got, want) + } +} + +func TestFormatWithTranslatedLabels(t *testing.T) { + labels := Labels{ + WeeklyUpdate: "actualización semanal", + MonthlyUpdate: "actualización mensual", + Movies: "Películas", + Shows: "Series", + Season: "Temporada", + Specials: "Especiales", + Episode: "episodio", + Episodes: "episodios", + UnknownShow: "Serie desconocida", + NoWeeklyItems: "No hay novedades esta semana.", + NoMonthlyItems: "No hay novedades el mes pasado.", + } + items := []Item{ + {Type: "Movie", Name: "Una película"}, + {Type: "Episode", SeriesName: "Una serie", SeasonNumber: intp(2), EpisodeNumber: intp(4)}, + } + start := time.Date(2026, 8, 18, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC) + want := "2026-08-18 - 2026-08-25 actualización semanal:\n\nPelículas:\n• Una película\n\nSeries:\n• Una serie — Temporada 2, episodio 4" + if got := FormatWeek(start, end, items, labels); got != want { + t.Fatalf("FormatWeek() = %q, want %q", got, want) + } +}
A
server-dockers/jellyfin-news/index.html
@@ -0,0 +1,93 @@
+<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Jellyfin updates</title> + <style> + body { max-width: 760px; margin: 2rem auto; padding: 0 1rem; font: 16px/1.5 system-ui, sans-serif; color: #202124; } + h1 { margin-bottom: .25rem; } + .controls { display: flex; flex-wrap: wrap; gap: .65rem; margin: 1.5rem 0; align-items: center; } + button, input { font: inherit; padding: .6rem .8rem; } + button { cursor: pointer; } + input { width: 5rem; } + #status { min-height: 1.5rem; color: #555; } + textarea { box-sizing: border-box; width: 100%; min-height: 22rem; padding: 1rem; font: 15px/1.5 ui-monospace, monospace; resize: vertical; } + .copy { margin-top: .65rem; } + </style> +</head> +<body> + <h1>Jellyfin updates</h1> + <p>Choose a period, then copy the generated recap.</p> + + <div class="controls"> + <button data-report="/api/current-week">Current week</button> + </div> + + <div class="controls"> + <label for="week-offset">Week offset:</label> + <input id="week-offset" type="number" min="-1200" max="-1" value="-1" required> + <button id="selected-week">Load selected week</button> + <span>−1 is last week, −2 is two weeks ago</span> + </div> + + <div class="controls"> + <label for="month-offset">Month offset:</label> + <input id="month-offset" type="number" min="-1200" max="-1" value="-1" required> + <button id="selected-month">Load selected month</button> + <span>−1 is last month, −2 is two months ago</span> + </div> + + <p id="status" role="status"></p> + <textarea id="output" readonly aria-label="Jellyfin update output" placeholder="Your recap will appear here"></textarea> + <button id="copy" class="copy">Copy to clipboard</button> + + <script> + const output = document.querySelector('#output'); + const status = document.querySelector('#status'); + const buttons = [...document.querySelectorAll('button')]; + + async function loadReport(url) { + buttons.forEach(button => button.disabled = true); + status.textContent = 'Loading…'; + try { + const response = await fetch(url, { headers: { Accept: 'text/plain' } }); + const text = await response.text(); + if (!response.ok) throw new Error(text.trim() || `Request failed (${response.status})`); + output.value = text; + status.textContent = 'Recap ready.'; + } catch (error) { + status.textContent = error.message; + } finally { + buttons.forEach(button => button.disabled = false); + } + } + + document.querySelectorAll('[data-report]').forEach(button => { + button.addEventListener('click', () => loadReport(button.dataset.report)); + }); + + document.querySelector('#selected-week').addEventListener('click', () => { + const offset = document.querySelector('#week-offset').value; + loadReport(`/api/week?offset=${encodeURIComponent(offset)}`); + }); + + document.querySelector('#selected-month').addEventListener('click', () => { + const offset = document.querySelector('#month-offset').value; + loadReport(`/api/month?offset=${encodeURIComponent(offset)}`); + }); + + document.querySelector('#copy').addEventListener('click', async () => { + if (!output.value) return; + try { + await navigator.clipboard.writeText(output.value); + } catch (_) { + output.select(); + document.execCommand('copy'); + output.setSelectionRange(0, 0); + } + status.textContent = 'Copied to clipboard.'; + }); + </script> +</body> +</html>
A
server-dockers/jellyfin-news/jellyfin.go
@@ -0,0 +1,113 @@
+package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type Client struct { + baseURL string + apiKey string + userID string + http *http.Client +} + +func New(baseURL, apiKey, userID string, httpClient *http.Client) (*Client, error) { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if _, err := url.ParseRequestURI(baseURL); err != nil || !(strings.HasPrefix(baseURL, "http://") || strings.HasPrefix(baseURL, "https://")) { + return nil, fmt.Errorf("invalid JELLYFIN_URL %q", baseURL) + } + if strings.TrimSpace(apiKey) == "" { + return nil, fmt.Errorf("JELLYFIN_API_KEY is required") + } + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + return &Client{baseURL: baseURL, apiKey: apiKey, userID: strings.TrimSpace(userID), http: httpClient}, nil +} + +type response struct { + Items []item `json:"Items"` + TotalRecordCount int `json:"TotalRecordCount"` +} + +type item struct { + Type string `json:"Type"` + Name string `json:"Name"` + SeriesID string `json:"SeriesId"` + SeriesName string `json:"SeriesName"` + ParentIndexNumber *int `json:"ParentIndexNumber"` + IndexNumber *int `json:"IndexNumber"` + ProductionYear *int `json:"ProductionYear"` + DateCreated string `json:"DateCreated"` +} + +// AddedBetween returns movies and episodes whose Jellyfin DateCreated is in [start, end). +func (c *Client) AddedBetween(ctx context.Context, start, end time.Time) ([]Item, error) { + const pageSize = 200 + var result []Item + for offset := 0; ; offset += pageSize { + params := url.Values{ + "IncludeItemTypes": {"Movie,Episode"}, + "Recursive": {"true"}, + "Fields": {"DateCreated"}, + "SortBy": {"DateCreated"}, + "SortOrder": {"Descending"}, + "EnableImages": {"false"}, + "EnableUserData": {"false"}, + "EnableTotalRecordCount": {"true"}, + "StartIndex": {strconv.Itoa(offset)}, + "Limit": {strconv.Itoa(pageSize)}, + } + if c.userID != "" { + params.Set("UserId", c.userID) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/Items?"+params.Encode(), nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Emby-Token", c.apiKey) + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("query Jellyfin: %w", err) + } + var page response + decodeErr := json.NewDecoder(resp.Body).Decode(&page) + resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("Jellyfin returned HTTP %d", resp.StatusCode) + } + if decodeErr != nil { + return nil, fmt.Errorf("decode Jellyfin response: %w", decodeErr) + } + pastStart := false + for _, raw := range page.Items { + created, err := time.Parse(time.RFC3339Nano, raw.DateCreated) + if err != nil { + continue + } + if created.Before(start) { + pastStart = true + continue + } + if !created.Before(end) { + continue + } + result = append(result, Item{ + Type: raw.Type, Name: raw.Name, SeriesID: raw.SeriesID, + SeriesName: raw.SeriesName, SeasonNumber: raw.ParentIndexNumber, + EpisodeNumber: raw.IndexNumber, ProductionYear: raw.ProductionYear, + }) + } + if pastStart || len(page.Items) < pageSize || (page.TotalRecordCount > 0 && offset+len(page.Items) >= page.TotalRecordCount) { + break + } + } + return result, nil +}
A
server-dockers/jellyfin-news/jellyfin_test.go
@@ -0,0 +1,43 @@
+package main + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + "time" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestAddedBetween(t *testing.T) { + httpClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Header.Get("X-Emby-Token") != "secret" { + t.Error("missing API key") + } + if r.URL.Query().Get("IncludeItemTypes") != "Movie,Episode" { + t.Error("wrong item filter") + } + body := `{"Items":[ + {"Id":"new","Type":"Movie","Name":"New","DateCreated":"2026-08-24T10:00:00Z"}, + {"Id":"old","Type":"Movie","Name":"Old","DateCreated":"2026-08-17T09:00:00Z"} + ],"TotalRecordCount":2}` + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + })} + c, err := New("http://jellyfin.test", "secret", "", httpClient) + if err != nil { + t.Fatal(err) + } + start := time.Date(2026, 8, 18, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC) + items, err := c.AddedBetween(context.Background(), start, end) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].Name != "New" { + t.Fatalf("items = %#v", items) + } +}
A
server-dockers/jellyfin-news/main.go
@@ -0,0 +1,169 @@
+package main + +import ( + "context" + _ "embed" + "errors" + "flag" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +//go:embed index.html +var indexHTML []byte + +type itemSource interface { + AddedBetween(context.Context, time.Time, time.Time) ([]Item, error) +} + +type app struct { + jellyfin itemSource + location *time.Location + labels Labels + now func() time.Time +} + +func main() { + listen := flag.String("listen", env("LISTEN_ADDR", ":8080"), "HTTP listen address") + flag.Parse() + + location, err := time.LoadLocation(env("TZ", "Local")) + if err != nil { + log.Fatalf("invalid TZ: %v", err) + } + client, err := New(os.Getenv("JELLYFIN_URL"), os.Getenv("JELLYFIN_API_KEY"), os.Getenv("JELLYFIN_USER_ID"), nil) + if err != nil { + log.Fatalf("configuration error: %v", err) + } + + application := &app{jellyfin: client, location: location, labels: loadLabels(), now: time.Now} + server := &http.Server{ + Addr: *listen, + Handler: application.routes(), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 60 * time.Second, + } + log.Printf("Jellyfin updates available at http://%s", displayAddress(*listen)) + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + } +} + +func (a *app) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /{$}", a.index) + mux.HandleFunc("GET /api/current-week", a.currentWeek) + mux.HandleFunc("GET /api/week", a.week) + mux.HandleFunc("GET /api/month", a.month) + return mux +} + +func (a *app) index(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(indexHTML) +} + +func (a *app) currentWeek(w http.ResponseWriter, r *http.Request) { + now := a.now().In(a.location) + start := startOfWeek(now) + a.writeWeek(w, r, start, now) +} + +func (a *app) week(w http.ResponseWriter, r *http.Request) { + offset, ok := readOffset(w, r) + if !ok { + return + } + currentWeek := startOfWeek(a.now().In(a.location)) + start := currentWeek.AddDate(0, 0, offset*7) + a.writeWeek(w, r, start, start.AddDate(0, 0, 7)) +} + +func (a *app) month(w http.ResponseWriter, r *http.Request) { + offset, ok := readOffset(w, r) + if !ok { + return + } + a.writeMonth(w, r, offset) +} + +func (a *app) writeWeek(w http.ResponseWriter, r *http.Request, start, end time.Time) { + items, err := a.jellyfin.AddedBetween(r.Context(), start, end) + if err != nil { + log.Printf("Jellyfin weekly query failed: %v", err) + http.Error(w, "Could not query Jellyfin. Check the server logs.", http.StatusBadGateway) + return + } + writeText(w, FormatWeek(start, end, items, a.labels)) +} + +func (a *app) writeMonth(w http.ResponseWriter, r *http.Request, offset int) { + now := a.now().In(a.location) + currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, a.location) + start := currentMonth.AddDate(0, offset, 0) + end := start.AddDate(0, 1, 0) + items, err := a.jellyfin.AddedBetween(r.Context(), start, end) + if err != nil { + log.Printf("Jellyfin monthly query failed: %v", err) + http.Error(w, "Could not query Jellyfin. Check the server logs.", http.StatusBadGateway) + return + } + writeText(w, FormatMonth(start, items, a.labels)) +} + +func writeText(w http.ResponseWriter, text string) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Write([]byte(text)) +} + +func readOffset(w http.ResponseWriter, r *http.Request) (int, bool) { + offset, err := strconv.Atoi(r.URL.Query().Get("offset")) + if err != nil || offset < -1200 || offset > -1 { + http.Error(w, "offset must be an integer between -1200 and -1", http.StatusBadRequest) + return 0, false + } + return offset, true +} + +func startOfWeek(now time.Time) time.Time { + daysSinceMonday := (int(now.Weekday()) - int(time.Monday) + 7) % 7 + return time.Date(now.Year(), now.Month(), now.Day()-daysSinceMonday, 0, 0, 0, 0, now.Location()) +} + +func env(name, fallback string) string { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + return fallback +} + +func loadLabels() Labels { + defaults := DefaultLabels() + return Labels{ + WeeklyUpdate: env("TEXT_WEEKLY_UPDATE", defaults.WeeklyUpdate), + MonthlyUpdate: env("TEXT_MONTHLY_UPDATE", defaults.MonthlyUpdate), + Movies: env("TEXT_MOVIES", defaults.Movies), + Shows: env("TEXT_SHOWS", defaults.Shows), + Season: env("TEXT_SEASON", defaults.Season), + Specials: env("TEXT_SPECIALS", defaults.Specials), + Episode: env("TEXT_EPISODE", defaults.Episode), + Episodes: env("TEXT_EPISODES", defaults.Episodes), + UnknownShow: env("TEXT_UNKNOWN_SHOW", defaults.UnknownShow), + NoWeeklyItems: env("TEXT_NO_WEEKLY_ITEMS", defaults.NoWeeklyItems), + NoMonthlyItems: env("TEXT_NO_MONTHLY_ITEMS", defaults.NoMonthlyItems), + } +} + +func displayAddress(address string) string { + if strings.HasPrefix(address, ":") { + return "localhost" + address + } + return address +}
A
server-dockers/jellyfin-news/main_test.go
@@ -0,0 +1,118 @@
+package main + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +type fakeSource struct { + start time.Time + end time.Time + items []Item + err error +} + +func (f *fakeSource) AddedBetween(_ context.Context, start, end time.Time) ([]Item, error) { + f.start, f.end = start, end + return f.items, f.err +} + +func testApp(source *fakeSource) *app { + return &app{ + jellyfin: source, + location: time.UTC, + labels: DefaultLabels(), + now: func() time.Time { + return time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC) + }, + } +} + +func TestCurrentWeek(t *testing.T) { + source := &fakeSource{} + request := httptest.NewRequest(http.MethodGet, "/api/current-week", nil) + response := httptest.NewRecorder() + testApp(source).routes().ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %q", response.Code, response.Body.String()) + } + if got := response.Body.String(); !strings.HasPrefix(got, "2026-08-31 - 2026-09-02 jellyfin weekly update:") { + t.Fatalf("body = %q", got) + } + wantStart := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC) + if !source.start.Equal(wantStart) || !source.end.Equal(testApp(source).now()) { + t.Fatalf("range = %v to %v", source.start, source.end) + } +} + +func TestStartOfWeekUsesMondayMidnight(t *testing.T) { + location := time.FixedZone("test", 2*60*60) + now := time.Date(2026, 9, 6, 23, 45, 0, 0, location) // Sunday + want := time.Date(2026, 8, 31, 0, 0, 0, 0, location) + if got := startOfWeek(now); !got.Equal(want) { + t.Fatalf("startOfWeek() = %v, want %v", got, want) + } +} + +func TestSelectedWeek(t *testing.T) { + source := &fakeSource{} + request := httptest.NewRequest(http.MethodGet, "/api/week?offset=-2", nil) + response := httptest.NewRecorder() + testApp(source).routes().ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %q", response.Code, response.Body.String()) + } + if got := response.Body.String(); !strings.HasPrefix(got, "2026-08-17 - 2026-08-24 jellyfin weekly update:") { + t.Fatalf("body = %q", got) + } +} + +func TestSelectedWeekRejectsInvalidOffset(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/api/week?offset=0", nil) + response := httptest.NewRecorder() + testApp(&fakeSource{}).routes().ServeHTTP(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("status = %d", response.Code) + } +} + +func TestSelectedMonth(t *testing.T) { + source := &fakeSource{} + request := httptest.NewRequest(http.MethodGet, "/api/month?offset=-2", nil) + response := httptest.NewRecorder() + testApp(source).routes().ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %q", response.Code, response.Body.String()) + } + if got := response.Body.String(); !strings.HasPrefix(got, "2026-07 jellyfin monthly update:") { + t.Fatalf("body = %q", got) + } + if source.start.Month() != time.July || source.end.Month() != time.August { + t.Fatalf("range = %v to %v", source.start, source.end) + } +} + +func TestSelectedMonthRejectsInvalidOffset(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/api/month?offset=0", nil) + response := httptest.NewRecorder() + testApp(&fakeSource{}).routes().ServeHTTP(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("status = %d", response.Code) + } +} + +func TestIndex(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/", nil) + response := httptest.NewRecorder() + testApp(&fakeSource{}).routes().ServeHTTP(response, request) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "Jellyfin updates") { + t.Fatalf("status = %d, body = %q", response.Code, response.Body.String()) + } +}