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	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}