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