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}