arnaucube
arnaucube/configsPublic Self-hosted repository
configs/server-dockers/jellyfin-news/digest.go
server-dockers/jellyfin-news/digest.goRaw
  1package main
  2
  3import (
  4	"fmt"
  5	"sort"
  6	"strings"
  7	"time"
  8)
  9
 10// Item is the subset of Jellyfin item metadata needed by a digest.
 11type Item struct {
 12	Type           string
 13	Name           string
 14	SeriesID       string
 15	SeriesName     string
 16	SeasonNumber   *int
 17	EpisodeNumber  *int
 18	ProductionYear *int
 19}
 20
 21// Labels contains every translatable phrase used in generated recap text.
 22type Labels struct {
 23	WatchedUpdate  string
 24	NoWatchedItems string
 25	Users          string
 26	WatchedButton  string
 27	WeeklyUpdate   string
 28	MonthlyUpdate  string
 29	Movies         string
 30	Shows          string
 31	Season         string
 32	Specials       string
 33	Episode        string
 34	Episodes       string
 35	UnknownShow    string
 36	NoWeeklyItems  string
 37	NoMonthlyItems string
 38}
 39
 40func DefaultLabels() Labels {
 41	return Labels{
 42		WatchedUpdate:  "jellyfin watched recap",
 43		NoWatchedItems: "No movies or shows watched in this period.",
 44		Users:          "users",
 45		WatchedButton:  "Watched in selected period",
 46		WeeklyUpdate:   "jellyfin weekly update",
 47		MonthlyUpdate:  "jellyfin monthly update",
 48		Movies:         "Movies",
 49		Shows:          "Shows",
 50		Season:         "Season",
 51		Specials:       "Specials",
 52		Episode:        "episode",
 53		Episodes:       "episodes",
 54		UnknownShow:    "Unknown show",
 55		NoWeeklyItems:  "No new movies or show episodes this week.",
 56		NoMonthlyItems: "No new movies or show episodes last month.",
 57	}
 58}
 59
 60// FormatWeek creates a plain-text weekly message suitable for copying and sharing.
 61func FormatWeek(start, end time.Time, items []Item, labels Labels) string {
 62	title := start.Format("2006-01-02") + " - " + end.Format("2006-01-02") + " " + labels.WeeklyUpdate + ":"
 63	return format(title, labels.NoWeeklyItems, items, labels)
 64}
 65
 66// FormatMonth creates a recap for a calendar month.
 67func FormatMonth(month time.Time, items []Item, labels Labels) string {
 68	return format(month.Format("2006-01")+" "+labels.MonthlyUpdate+":", labels.NoMonthlyItems, items, labels)
 69}
 70
 71func format(title, emptyMessage string, items []Item, labels Labels) string {
 72	movies := make([]Item, 0)
 73	type seasonKey struct {
 74		seriesID, seriesName string
 75		season               int
 76	}
 77	seasons := make(map[seasonKey]map[int]struct{})
 78
 79	for _, item := range items {
 80		switch item.Type {
 81		case "Movie":
 82			movies = append(movies, item)
 83		case "Episode":
 84			if item.SeasonNumber == nil || item.EpisodeNumber == nil {
 85				continue
 86			}
 87			name := strings.TrimSpace(item.SeriesName)
 88			if name == "" {
 89				name = labels.UnknownShow
 90			}
 91			key := seasonKey{seriesID: item.SeriesID, seriesName: name, season: *item.SeasonNumber}
 92			if seasons[key] == nil {
 93				seasons[key] = make(map[int]struct{})
 94			}
 95			seasons[key][*item.EpisodeNumber] = struct{}{}
 96		}
 97	}
 98
 99	sort.Slice(movies, func(i, j int) bool {
100		return strings.ToLower(movies[i].Name) < strings.ToLower(movies[j].Name)
101	})
102	keys := make([]seasonKey, 0, len(seasons))
103	for key := range seasons {
104		keys = append(keys, key)
105	}
106	sort.Slice(keys, func(i, j int) bool {
107		a, b := strings.ToLower(keys[i].seriesName), strings.ToLower(keys[j].seriesName)
108		if a == b {
109			return keys[i].season < keys[j].season
110		}
111		return a < b
112	})
113
114	var out strings.Builder
115	out.WriteString(title)
116	out.WriteByte('\n')
117	if len(movies) == 0 && len(keys) == 0 {
118		out.WriteByte('\n')
119		out.WriteString(emptyMessage)
120		return out.String()
121	}
122	if len(movies) > 0 {
123		fmt.Fprintf(&out, "\n%s:\n", labels.Movies)
124		for _, movie := range movies {
125			fmt.Fprintf(&out, "• %s", movie.Name)
126			if movie.ProductionYear != nil {
127				fmt.Fprintf(&out, " (%d)", *movie.ProductionYear)
128			}
129			out.WriteByte('\n')
130		}
131	}
132	if len(keys) > 0 {
133		fmt.Fprintf(&out, "\n%s:\n", labels.Shows)
134		for _, key := range keys {
135			episodes := make([]int, 0, len(seasons[key]))
136			for episode := range seasons[key] {
137				episodes = append(episodes, episode)
138			}
139			sort.Ints(episodes)
140			season := fmt.Sprintf("%s %d", labels.Season, key.season)
141			if key.season == 0 {
142				season = labels.Specials
143			}
144			label := labels.Episodes
145			if len(episodes) == 1 {
146				label = labels.Episode
147			}
148			fmt.Fprintf(&out, "• %s — %s, %s %s\n", key.seriesName, season, label, ranges(episodes))
149		}
150	}
151	return strings.TrimRight(out.String(), "\n")
152}
153
154func ranges(numbers []int) string {
155	parts := make([]string, 0)
156	for i := 0; i < len(numbers); {
157		j := i
158		for j+1 < len(numbers) && numbers[j+1] == numbers[j]+1 {
159			j++
160		}
161		if i == j {
162			parts = append(parts, fmt.Sprint(numbers[i]))
163		} else {
164			parts = append(parts, fmt.Sprintf("%d-%d", numbers[i], numbers[j]))
165		}
166		i = j + 1
167	}
168	return strings.Join(parts, ", ")
169}