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