arnaucube
arnaucube/configsPublic Self-hosted repository
jellyfin-news: add week-start option at the config
arnaucube git@arnaucube.com
Thu, 17 Sep 2026 13:01:42 +0200
commit

6f714dc2edd7c0a6f22b8ba2a65e824913aba42f

parent

c216ca004f38fae0be9a99cc81f67366bc44afbf

M server-dockers/jellyfin-news/.env.example → server-dockers/jellyfin-news/.env.example

@@ -6,6 +6,11 @@

TZ=Europe/London LISTEN_ADDR=:8080 +# Weekly boundary in TZ. Full weekday name and 24-hour HH:MM. +# To start weeks on Sunday at 20:00, use Sunday and 20:00 below. +WEEK_START_DAY=Monday +WEEK_START_TIME=00:00 + # Optional: customize every phrase printed in copyable recaps. TEXT_WEEKLY_UPDATE=jellyfin weekly update TEXT_MONTHLY_UPDATE=jellyfin monthly update
M server-dockers/jellyfin-news/README.md → server-dockers/jellyfin-news/README.md

@@ -1,10 +1,10 @@

# Jellyfin update webpage -A small Go web server for generating copyable Jellyfin movie and episode recaps. It provides both: newly added movies and shows, and recently watched movies and shows. It serves one embedded `index.html` page, a database, or third-party Go dependencies. +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. +- **Current week**: from the configured weekly boundary through now (Monday at 00:00 by default). - **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.
M server-dockers/jellyfin-news/jellystat.go → server-dockers/jellyfin-news/jellystat.go

@@ -183,7 +183,7 @@ }

} } -// FormatWatched lists each title once without disclosing account identities. +// FormatWatched groups comma-separated titles without disclosing account identities. func FormatWatched(start, end time.Time, monthly bool, titles []WatchedTitle, labels Labels) string { period := start.Format("2006-01-02") + " - " + end.Format("2006-01-02") if monthly {

@@ -209,13 +209,17 @@ if title.Type != section.kind {

continue } if !heading { - fmt.Fprintf(&out, "\n%s:\n", section.label) + fmt.Fprintf(&out, "\n%s: ", section.label) heading = true + } else { + out.WriteString(", ") } - fmt.Fprintf(&out, "• %s", title.Name) + out.WriteString(title.Name) if title.Users > 1 { - fmt.Fprintf(&out, " — %d %s", title.Users, labels.Users) + fmt.Fprintf(&out, " (%d %s)", title.Users, labels.Users) } + } + if heading { out.WriteByte('\n') } }
M server-dockers/jellyfin-news/jellystat_test.go → server-dockers/jellyfin-news/jellystat_test.go

@@ -65,7 +65,7 @@ if err != nil {

t.Fatal(err) } got := FormatWatched(start, end, true, titles, DefaultLabels()) - want := "2026-08 jellyfin watched recap:\n\nMovies:\n• movie — 2 users\n\nShows:\n• show" + want := "2026-08 jellyfin watched recap:\n\nMovies: movie (2 users)\n\nShows: show" if got != want { t.Fatalf("got %q, want %q", got, want) }

@@ -152,3 +152,18 @@ if len(titles) != 1 || titles[0].Users != 2 || pages != 2 {

t.Fatalf("titles=%v pages=%d", titles, pages) } } + +func TestWatchedTitlesInline(t *testing.T) { + start := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + titles := []WatchedTitle{ + {Name: "Severance", Type: "Series", Users: 2}, + {Name: "Dune", Type: "Movie", Users: 3}, + {Name: "Arrival", Type: "Movie", Users: 1}, + {Name: "Andor", Type: "Series", Users: 1}, + } + got := FormatWatched(start, start.AddDate(0, 1, 0), true, titles, DefaultLabels()) + want := "2026-08 jellyfin watched recap:\n\nMovies: Arrival, Dune (3 users)\n\nShows: Andor, Severance (2 users)" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +}
M server-dockers/jellyfin-news/main.go → server-dockers/jellyfin-news/main.go

@@ -5,6 +5,7 @@ "context"

_ "embed" "errors" "flag" + "fmt" "html" "log" "net/http"

@@ -26,6 +27,7 @@ WatchedBetween(context.Context, time.Time, time.Time) ([]WatchedTitle, error)

} type app struct { + weekStart weekStart jellystat watchedSource jellyfin itemSource location *time.Location

@@ -41,12 +43,16 @@ location, err := time.LoadLocation(env("TZ", "Local"))

if err != nil { log.Fatalf("invalid TZ: %v", err) } + week, err := loadWeekStart() + if err != nil { + log.Fatal(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} + application := &app{weekStart: week, jellyfin: client, location: location, labels: loadLabels(), now: time.Now} if baseURL := os.Getenv("JELLYSTAT_URL"); strings.TrimSpace(baseURL) != "" { application.jellystat, err = NewJellystat(baseURL, os.Getenv("JELLYSTAT_API_KEY")) if err != nil {

@@ -84,7 +90,7 @@ }

func (a *app) currentWeek(w http.ResponseWriter, r *http.Request) { now := a.now().In(a.location) - start := startOfWeek(now) + start := a.weekStart.start(now) a.writeWeek(w, r, start, now) }

@@ -93,7 +99,7 @@ offset, ok := readOffset(w, r)

if !ok { return } - currentWeek := startOfWeek(a.now().In(a.location)) + currentWeek := a.weekStart.start(a.now().In(a.location)) start := currentWeek.AddDate(0, 0, offset*7) a.writeWeek(w, r, start, start.AddDate(0, 0, 7)) }

@@ -145,9 +151,44 @@ }

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()) +// weekStart defines a weekly boundary in the report's local timezone. +type weekStart struct { + day time.Weekday + hour, minute int +} + +// loadWeekStart rejects invalid settings rather than silently changing report periods. +func loadWeekStart() (weekStart, error) { + day := env("WEEK_START_DAY", "Monday") + clock := env("WEEK_START_TIME", "00:00") + var result weekStart + found := false + for d := time.Sunday; d <= time.Saturday; d++ { + if strings.EqualFold(day, d.String()) { + result.day = d + found = true + break + } + } + if !found { + return result, fmt.Errorf("WEEK_START_DAY must be a full weekday name (Monday through Sunday)") + } + parsed, err := time.Parse("15:04", clock) + if err != nil || parsed.Format("15:04") != clock { + return result, fmt.Errorf("WEEK_START_TIME must be HH:MM in 24-hour format") + } + result.hour, result.minute = parsed.Hour(), parsed.Minute() + return result, nil +} + +// start finds the latest weekly boundary, including before today's start time. +func (w weekStart) start(now time.Time) time.Time { + days := (int(now.Weekday()) - int(w.day) + 7) % 7 + boundary := time.Date(now.Year(), now.Month(), now.Day()-days, w.hour, w.minute, 0, 0, now.Location()) + if boundary.After(now) { + boundary = boundary.AddDate(0, 0, -7) + } + return boundary } func env(name, fallback string) string {

@@ -188,7 +229,7 @@

// watched uses the same calendar boundaries as the added-content reports. func (a *app) watched(w http.ResponseWriter, r *http.Request) { now := a.now().In(a.location) - start, end := startOfWeek(now), now + start, end := a.weekStart.start(now), now period := r.URL.Query().Get("period") switch period { case "current-week":
M server-dockers/jellyfin-news/main_test.go → server-dockers/jellyfin-news/main_test.go

@@ -24,9 +24,10 @@ }

func testApp(source *fakeSource) *app { return &app{ - jellyfin: source, - location: time.UTC, - labels: DefaultLabels(), + weekStart: weekStart{day: time.Monday}, + jellyfin: source, + location: time.UTC, + labels: DefaultLabels(), now: func() time.Time { return time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC) },

@@ -55,7 +56,7 @@ 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) { + if got := (weekStart{day: time.Monday}).start(now); !got.Equal(want) { t.Fatalf("startOfWeek() = %v, want %v", got, want) } }

@@ -140,7 +141,7 @@ source := &fakeWatched{}

a.jellystat = source response := httptest.NewRecorder() a.routes().ServeHTTP(response, httptest.NewRequest("GET", "/api/watched?"+tc.query, nil)) - if response.Code != 200 || !strings.Contains(response.Body.String(), "Example — 2 users") { + if response.Code != 200 || !strings.Contains(response.Body.String(), "Example (2 users)") { t.Fatalf("response: %d %s", response.Code, response.Body.String()) } if source.start.Format(time.RFC3339) != tc.start || source.end.Format(time.RFC3339) != tc.end {

@@ -183,3 +184,86 @@ if !strings.Contains(response.Body.String(), "&lt;Watch &amp; copy&gt;") {

t.Fatal("button translation not escaped") } } + +func TestWeekStartSettings(t *testing.T) { + for _, tc := range []struct { + day, clock string + want weekStart + valid bool + }{ + {"", "", weekStart{day: time.Monday}, true}, + {"sUnDaY", "20:00", weekStart{day: time.Sunday, hour: 20}, true}, + {"Friday", "19:30", weekStart{day: time.Friday, hour: 19, minute: 30}, true}, + {"Funday", "20:00", weekStart{}, false}, + {"Sunday", "24:00", weekStart{}, false}, + {"Sunday", "20:60", weekStart{}, false}, + {"Sunday", "8:00", weekStart{}, false}, + } { + t.Run(tc.day+tc.clock, func(t *testing.T) { + t.Setenv("WEEK_START_DAY", tc.day) + t.Setenv("WEEK_START_TIME", tc.clock) + got, err := loadWeekStart() + if (err == nil) != tc.valid { + t.Fatalf("error = %v", err) + } + if tc.valid && got != tc.want { + t.Fatalf("got %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestSundayEveningWeekBoundary(t *testing.T) { + location, err := time.LoadLocation("Europe/London") + if err != nil { + t.Fatal(err) + } + boundary := weekStart{day: time.Sunday, hour: 20, minute: 30} + for _, tc := range []struct{ now, want string }{ + {"2026-09-06T20:29:59+01:00", "2026-08-30T20:30:00+01:00"}, + {"2026-09-06T20:30:00+01:00", "2026-09-06T20:30:00+01:00"}, + {"2026-09-07T01:00:00+01:00", "2026-09-06T20:30:00+01:00"}, + {"2026-03-29T19:00:00+01:00", "2026-03-22T20:30:00Z"}, + {"2026-10-25T19:00:00Z", "2026-10-18T20:30:00+01:00"}, + } { + now, _ := time.Parse(time.RFC3339, tc.now) + if got := boundary.start(now.In(location)).Format(time.RFC3339); got != tc.want { + t.Fatalf("at %s got %s, want %s", tc.now, got, tc.want) + } + } +} + +func TestCustomWeekBothReportTypes(t *testing.T) { + for _, period := range []string{"current-week", "week", "month"} { + for _, watched := range []bool{false, true} { + added := &fakeSource{} + viewed := &fakeWatched{} + a := testApp(added) + a.jellystat = viewed + a.weekStart = weekStart{day: time.Sunday, hour: 20} + path := "/api/" + period + "?offset=-1" + if watched { + path = "/api/watched?period=" + period + "&offset=-1" + } + response := httptest.NewRecorder() + a.routes().ServeHTTP(response, httptest.NewRequest("GET", path, nil)) + if response.Code != 200 { + t.Fatalf("%s: %d", path, response.Code) + } + start, end := added.start, added.end + if watched { + start, end = viewed.start, viewed.end + } + wantStart, wantEnd := "2026-08-30T20:00:00Z", "2026-09-02T12:00:00Z" + if period == "week" { + wantStart, wantEnd = "2026-08-23T20:00:00Z", "2026-08-30T20:00:00Z" + } + if period == "month" { + wantStart, wantEnd = "2026-08-01T00:00:00Z", "2026-09-01T00:00:00Z" + } + if start.Format(time.RFC3339) != wantStart || end.Format(time.RFC3339) != wantEnd { + t.Fatalf("%s: %v to %v", path, start, end) + } + } + } +}