From 81575c253b145b77b3ba0b68db00da8d2992e81f Mon Sep 17 00:00:00 2001 From: Rombond Date: Thu, 12 Nov 2026 14:24:30 -0500 Subject: [PATCH] Stats: show genres in the viewer's language The top genre tile and the genre chart showed Jellyfin's genre names, which are TMDB's English names ("Adventure" on a French account) on iOS, Android and web. The stats endpoint now maps them through TMDB's genre lists (English to the user's language by genre id) and merges names that become the same genre. --- backend/internal/api/stats.go | 8 ++++++- backend/internal/media/service.go | 33 ++++++++++++++++++++++++++++ backend/internal/stats/stats.go | 22 ++++++++++++++++++- backend/internal/stats/stats_test.go | 21 ++++++++++++++++++ 4 files changed, 82 insertions(+), 2 deletions(-) diff --git a/backend/internal/api/stats.go b/backend/internal/api/stats.go index 71c1a3d1..8b258903 100644 --- a/backend/internal/api/stats.go +++ b/backend/internal/api/stats.go @@ -4,6 +4,7 @@ import ( "context" "net/http" + "github.com/Rombond/tipsarr/backend/internal/media" "github.com/Rombond/tipsarr/backend/internal/perms" "github.com/Rombond/tipsarr/backend/internal/stats" "github.com/danielgtaylor/huma/v2" @@ -35,7 +36,12 @@ func registerStats(api huma.API, d Deps) { default: target = in.User } - res, err := d.Stats.Compute(ctx, target, in.Period, u.Can(perms.ManageUsers)) + // Jellyfin's genres are TMDB's English names: show them in the viewer's language. + var genreName func(string) string + if d.Media != nil { + genreName = d.Media.GenreNamer(ctx, media.Opts{Language: u.Language, Region: u.Region}) + } + res, err := d.Stats.Compute(ctx, target, in.Period, u.Can(perms.ManageUsers), genreName) if err != nil { return nil, err } diff --git a/backend/internal/media/service.go b/backend/internal/media/service.go index 9d51d307..1aa8275e 100644 --- a/backend/internal/media/service.go +++ b/backend/internal/media/service.go @@ -276,6 +276,39 @@ func (s *Service) Genres(ctx context.Context, o Opts, mediaType string) ([]Genre return raw.Genres, nil } +// GenreNamer maps a genre name as Jellyfin stores it (TMDB's English name, e.g. "Adventure") to TMDB's name in +// o's language. Unknown names (custom genres, or already in another language) come back unchanged. +func (s *Service) GenreNamer(ctx context.Context, o Opts) func(string) string { + names := map[string]string{} + if o.lang() != defaultLang { + for _, mt := range []string{"movie", "tv"} { + en, err := s.Genres(ctx, Opts{}, mt) + if err != nil { + continue + } + loc, err := s.Genres(ctx, o, mt) + if err != nil { + continue + } + byID := map[int]string{} + for _, g := range loc { + byID[g.ID] = g.Name + } + for _, g := range en { + if n := byID[g.ID]; n != "" { + names[strings.ToLower(g.Name)] = n + } + } + } + } + return func(name string) string { + if n, ok := names[strings.ToLower(name)]; ok { + return n + } + return name + } +} + // ---- search -------------------------------------------------------------------- func (s *Service) Search(ctx context.Context, o Opts, query string, page int, tags bool) (*SearchResult, error) { diff --git a/backend/internal/stats/stats.go b/backend/internal/stats/stats.go index 37271d2d..20e4967f 100644 --- a/backend/internal/stats/stats.go +++ b/backend/internal/stats/stats.go @@ -102,7 +102,8 @@ type title struct { } // Compute returns the stats of one user ("" = everyone) for a period (30d, 12m or all). -func (s *Service) Compute(ctx context.Context, userID, period string, adminViewer bool) (*StatsReport, error) { +// genreName, when set, renames Jellyfin's genres (English, or the server's metadata language) into the viewer's language. +func (s *Service) Compute(ctx context.Context, userID, period string, adminViewer bool, genreName func(string) string) (*StatsReport, error) { now := time.Now() var since time.Time switch period { @@ -141,6 +142,9 @@ func (s *Service) Compute(ctx context.Context, userID, period string, adminViewe if err != nil { return nil, err } + if genreName != nil { + translateGenres(titles, genreName) + } aggregate(res, titles) st, err := s.store.RequestsByStatus(ctx, userID, since.Unix()*b2i(!since.IsZero())) @@ -274,6 +278,22 @@ func (s *Service) fromHistory(ctx context.Context, userID string, meta map[strin return nil } +// translateGenres renames each title's genres, dropping the duplicates a merge creates ("Science Fiction" and +// "Science-Fiction" on the same title both become one genre). +func translateGenres(titles map[string]*title, genreName func(string) string) { + for _, t := range titles { + seen := map[string]bool{} + out := make([]string, 0, len(t.genres)) + for _, g := range t.genres { + if n := genreName(g); !seen[n] { + seen[n] = true + out = append(out, n) + } + } + t.genres = out + } +} + func aggregate(res *StatsReport, titles map[string]*title) { genre := map[string]*StatsBucket{} decade := map[string]*StatsBucket{} diff --git a/backend/internal/stats/stats_test.go b/backend/internal/stats/stats_test.go index b53a1869..6d80c10a 100644 --- a/backend/internal/stats/stats_test.go +++ b/backend/internal/stats/stats_test.go @@ -31,3 +31,24 @@ func TestMostWatchedIsRankedByTimeSpent(t *testing.T) { t.Fatalf("per type = %+v / %+v", res.TopMovies, res.TopShows) } } + +// Genres are shown in the viewer's language, and two names that translate to the same genre merge. +func TestGenresAreTranslated(t *testing.T) { + fr := map[string]string{"Adventure": "Aventure", "Science Fiction": "Science-Fiction"} + name := func(g string) string { + if n, ok := fr[g]; ok { + return n + } + return g + } + titles := map[string]*title{ + "movie:1": {typ: "movie", tmdb: 1, plays: 1, secs: 7200, genres: []string{"Adventure", "Science Fiction", "Science-Fiction"}}, + "movie:2": {typ: "movie", tmdb: 2, plays: 1, secs: 3600, genres: []string{"Science-Fiction"}}, + } + translateGenres(titles, name) + res := &StatsReport{} + aggregate(res, titles) + if len(res.Genres) != 2 || res.Genres[0].Name != "Science-Fiction" || res.Genres[0].Titles != 2 || res.Genres[1].Name != "Aventure" { + t.Fatalf("genres = %+v", res.Genres) + } +}