jellyfin-tools/jellyfin-year-review/internal/generate/platforms.go
jellyfin-year-review/internal/generate/platforms.goRaw
1package generate
2
3import (
4 "sort"
5 "strings"
6
7 "reelreview/internal/model"
8)
9
10// platformName folds common service aliases without treating production studios
11// as evidence of streaming availability.
12func platformName(name string) string {
13 switch strings.ToLower(strings.Join(strings.Fields(name), " ")) {
14 case "netflix", "netflix studios":
15 return "Netflix"
16 case "hbo", "hbo max", "max", "home box office", "hbo films":
17 return "HBO"
18 case "apple tv", "apple tv+", "appletv", "appletv+", "apple tv plus", "apple original films", "apple studios":
19 return "Apple TV+"
20 case "amazon", "amazon prime", "amazon prime video", "prime video", "amazon studios", "amazon mgm studios":
21 return "Prime Video"
22 case "disney+", "disney plus":
23 return "Disney+"
24 case "hulu":
25 return "Hulu"
26 case "paramount+", "paramount plus", "cbs all access":
27 return "Paramount+"
28 case "peacock":
29 return "Peacock"
30 case "showtime":
31 return "Showtime"
32 case "starz":
33 return "Starz"
34 case "discovery+", "discovery plus":
35 return "Discovery+"
36 case "amc+", "amc plus":
37 return "AMC+"
38 case "crunchyroll":
39 return "Crunchyroll"
40 }
41 return ""
42}
43
44// itemPlatforms prefers explicit snapshot associations; only recognized service
45// names are inferred from Jellyfin studios. Unknown studios remain unclassified.
46func itemPlatforms(item model.Item) []string {
47 seen := map[string]string{}
48 for _, name := range item.Platforms {
49 name = strings.Join(strings.Fields(name), " ")
50 if canonical := platformName(name); canonical != "" {
51 name = canonical
52 }
53 if name != "" {
54 key := strings.ToLower(name)
55 if previous, ok := seen[key]; !ok || name < previous {
56 seen[key] = name
57 }
58 }
59 }
60 if len(seen) == 0 {
61 for _, studio := range item.Studios {
62 if name := platformName(studio); name != "" {
63 seen[strings.ToLower(name)] = name
64 }
65 }
66 }
67 names := []string{}
68 for _, name := range seen {
69 names = append(names, name)
70 }
71 return names
72}
73
74// platforms consumes the already filtered distinct titles, so episodes, resumes,
75// and repeat viewings never multiply a title's contribution to a service.
76func platforms(movies, shows []model.Content, items map[string]model.Item) []model.Platform {
77 counts := map[string]*model.Platform{}
78 for kind, titles := range [][]model.Content{movies, shows} {
79 for _, title := range titles {
80 names := itemPlatforms(items[title.ID])
81 if len(names) == 0 {
82 names = []string{"Other"}
83 }
84 for _, name := range names {
85 key := strings.ToLower(name)
86 row := counts[key]
87 if row == nil {
88 row = &model.Platform{Name: name}
89 counts[key] = row
90 }
91 if name < row.Name {
92 row.Name = name
93 }
94 if kind == 0 {
95 row.Movies++
96 } else {
97 row.Shows++
98 }
99 }
100 }
101 }
102 out := []model.Platform{}
103 for _, row := range counts {
104 out = append(out, *row)
105 }
106 sort.Slice(out, func(i, j int) bool {
107 a, b := out[i], out[j]
108 if (a.Name == "Other") != (b.Name == "Other") {
109 return b.Name == "Other"
110 }
111 if a.Movies+a.Shows != b.Movies+b.Shows {
112 return a.Movies+a.Shows > b.Movies+b.Shows
113 }
114 return a.Name < b.Name
115 })
116 return out
117}