diff --git a/internal/service/emby_items_detail.go b/internal/service/emby_items_detail.go index 1293fb3..1f4f8ad 100644 --- a/internal/service/emby_items_detail.go +++ b/internal/service/emby_items_detail.go @@ -600,10 +600,12 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, seriesID := m.SeriesID seriesName := "" seasonID := "" + seasonNum := m.SeasonNum if e.mediaShouldBeEpisode(ctx, m) { itemType = "Episode" seriesID = e.seriesIDForMedia(ctx, m) seriesName = e.seriesNameForMedia(ctx, m) + seasonNum = embySeasonNumForMedia(m) seasonID = e.seasonIDForMedia(ctx, m) parentID = seasonID episodeTitle := strings.TrimSpace(m.EpisodeTitle) @@ -648,7 +650,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, "MediaType": "Video", "IsFolder": false, "ProductionYear": m.Year, - "ParentIndexNumber": m.SeasonNum, + "ParentIndexNumber": seasonNum, "IndexNumber": m.EpisodeNum, "Overview": m.Overview, "RunTimeTicks": runTimeTicks, @@ -660,7 +662,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, "Path": m.Path, "ParentId": parentID, "SeasonId": seasonID, - "SeasonName": seasonName(m.SeasonNum), + "SeasonName": seasonName(seasonNum), "SeriesId": seriesID, "SeriesName": seriesName, "ImageTags": imageTags, diff --git a/internal/service/emby_series.go b/internal/service/emby_series.go index 3d17ae3..ce652e5 100644 --- a/internal/service/emby_series.go +++ b/internal/service/emby_series.go @@ -143,8 +143,11 @@ func (e *EmbyService) findSeasonGroupBySeriesCandidates(ctx context.Context, id, } matched := make([]string, 0, 1) for _, cand := range candidates { - if seasonID(cand.SeriesID, cand.SeasonNum) == id { - matched = append(matched, cand.SeriesID) + for _, seasonNum := range embySeasonCandidates(cand.SeasonNum) { + if seasonID(cand.SeriesID, seasonNum) == id { + matched = append(matched, cand.SeriesID) + break + } } } for _, matchedSeries := range matched { @@ -257,10 +260,7 @@ func (e *EmbyService) seasonsForSeries(series embySeriesGroup) []embySeasonGroup bySeason := map[int]*embySeasonGroup{} order := []int{} for _, episode := range series.Episodes { - seasonNum := episode.SeasonNum - if seasonNum < 0 { - seasonNum = 1 - } + seasonNum := embySeasonNumForMedia(&episode) season, ok := bySeason[seasonNum] if !ok { season = &embySeasonGroup{ diff --git a/internal/service/emby_series_hierarchy_test.go b/internal/service/emby_series_hierarchy_test.go index c1eaed0..870a652 100644 --- a/internal/service/emby_series_hierarchy_test.go +++ b/internal/service/emby_series_hierarchy_test.go @@ -215,6 +215,113 @@ func TestEmbyItemsKeepSpecialsInSeasonZero(t *testing.T) { } } +func TestEmbySeparatesOVAAndOADFromSeasonOne(t *testing.T) { + svc := newTestEmbyService(t) + lib := model.Library{Name: "动漫", Path: `/media/影视库/动漫`, Type: "anime", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + for _, media := range []model.Media{ + { + Base: model.Base{ID: "tolove-s01e01"}, + LibraryID: lib.ID, + Title: "出包王女", + Path: `/media/影视库/动漫/出包王女/S01/To LOVE-Ru S01E01.mkv.strm`, + SeasonNum: 1, + EpisodeNum: 1, + }, + { + Base: model.Base{ID: "tolove-ova01"}, + LibraryID: lib.ID, + Title: "出包王女", + Path: `/media/影视库/动漫/出包王女/S01/To LOVE-Ru [OVA01].mkv.strm`, + SeasonNum: 0, + EpisodeNum: 1, + }, + { + Base: model.Base{ID: "tolove-oad01"}, + LibraryID: lib.ID, + Title: "出包王女", + Path: `/media/影视库/动漫/出包王女/OAD/To LOVE-Ru Darkness [OAD01].mkv.strm`, + SeasonNum: -1, + EpisodeNum: 1, + }, + } { + if err := svc.repo.DB.Create(&media).Error; err != nil { + t.Fatalf("create media: %v", err) + } + } + + root, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 50}) + if err != nil { + t.Fatalf("library items: %v", err) + } + rootItems := root["Items"].([]map[string]any) + if len(rootItems) != 1 || rootItems[0]["Type"] != "Series" { + t.Fatalf("expected one series card, got %#v", rootItems) + } + if rootItems[0]["ChildCount"] != 3 { + t.Fatalf("series ChildCount = %#v, want 3", rootItems[0]["ChildCount"]) + } + + seasons, err := svc.Items(t.Context(), ItemsParams{ParentID: rootItems[0]["Id"].(string), Limit: 50}) + if err != nil { + t.Fatalf("series seasons: %v", err) + } + seasonItems := seasons["Items"].([]map[string]any) + if len(seasonItems) != 3 { + t.Fatalf("expected season 1, OVA and OAD separately, got %#v", seasonItems) + } + + seasonByIndex := make(map[int]map[string]any, len(seasonItems)) + for _, season := range seasonItems { + index, ok := season["IndexNumber"].(int) + if !ok { + t.Fatalf("season index has unexpected type: %#v", season) + } + seasonByIndex[index] = season + } + for index, name := range map[int]string{ + 1: "第 1 季", + embySeasonOVA: "OVA", + embySeasonOAD: "OAD", + } { + season := seasonByIndex[index] + if season == nil || season["Name"] != name || season["ChildCount"] != 1 { + t.Fatalf("season %d = %#v, want name=%q with one episode", index, season, name) + } + episodes, err := svc.Items(t.Context(), ItemsParams{ + ParentID: season["Id"].(string), + IncludeItemTypes: []string{"Episode"}, + Recursive: true, + Limit: 50, + }) + if err != nil { + t.Fatalf("season %d episodes: %v", index, err) + } + episodeItems := episodes["Items"].([]map[string]any) + if len(episodeItems) != 1 || episodeItems[0]["ParentIndexNumber"] != index { + t.Fatalf("season %d episodes = %#v", index, episodeItems) + } + } +} + +func TestEmbySeasonCandidatesCoverPersistedSpecialNumbers(t *testing.T) { + for _, persisted := range []int{0, -1} { + candidates := embySeasonCandidates(persisted) + found := map[int]bool{} + for _, seasonNum := range candidates { + found[seasonNum] = true + } + if !found[embySeasonOVA] || !found[embySeasonOAD] { + t.Fatalf("candidates for persisted season %d = %#v, want OVA and OAD", persisted, candidates) + } + } + if candidates := embySeasonCandidates(2); len(candidates) != 1 || candidates[0] != 2 { + t.Fatalf("regular season candidates = %#v, want [2]", candidates) + } +} + func TestEmbyEpisodeStillIsPrimaryImageNotArt(t *testing.T) { svc := newTestEmbyService(t) lib := model.Library{Name: "剧集", Path: `/media/tv`, Type: "tv", Enabled: true} diff --git a/internal/service/emby_series_ids.go b/internal/service/emby_series_ids.go index c6ca2d0..71b5846 100644 --- a/internal/service/emby_series_ids.go +++ b/internal/service/emby_series_ids.go @@ -22,7 +22,7 @@ func (e *EmbyService) seriesIDForMedia(ctx context.Context, m *model.Media) stri } func (e *EmbyService) seasonIDForMedia(ctx context.Context, m *model.Media) string { - return seasonID(e.seriesIDForMedia(ctx, m), m.SeasonNum) + return seasonID(e.seriesIDForMedia(ctx, m), embySeasonNumForMedia(m)) } func (e *EmbyService) seriesNameForMedia(ctx context.Context, m *model.Media) string { @@ -100,19 +100,110 @@ func stableEmbyID(prefix string, parts ...string) string { return prefix + hex.EncodeToString(h.Sum(nil))[:32] } -func seasonID(seriesID string, seasonNum int) string { - if seasonNum < 0 { - seasonNum = 1 +const ( + embySeasonTheatrical = -1 + embySeasonOVA = -2 + embySeasonOAD = -3 + embySeasonOVD = -4 + embySeasonONA = -5 + embySeasonExtra = -6 + embySeasonBonus = -7 + embySeasonOmake = -8 + embySeasonPictureDrama = -9 + embySeasonNCOP = -10 + embySeasonNCED = -11 + embySeasonGenericSpecial = 0 +) + +// embySeasonNumForMedia keeps MeBox's special categories distinct in the Emby +// hierarchy. Databases may store every special as season 0 or -1, so the path +// classification is authoritative when it identifies a special category. +func embySeasonNumForMedia(m *model.Media) int { + if m == nil { + return 0 } + switch mediaSpecialKind(m.Path) { + case mediaSpecialTheatrical: + return embySeasonTheatrical + case mediaSpecialOVA: + return embySeasonOVA + case mediaSpecialOAD: + return embySeasonOAD + case mediaSpecialOVD: + return embySeasonOVD + case mediaSpecialONA: + return embySeasonONA + case mediaSpecialExtra: + return embySeasonExtra + case mediaSpecialBonus: + return embySeasonBonus + case mediaSpecialOmake: + return embySeasonOmake + case mediaSpecialPicture: + return embySeasonPictureDrama + case mediaSpecialNCOP: + return embySeasonNCOP + case mediaSpecialNCED: + return embySeasonNCED + case mediaSpecialGeneric: + return embySeasonGenericSpecial + } + if m.SeasonNum < 0 { + return embySeasonGenericSpecial + } + return m.SeasonNum +} + +func embySeasonCandidates(seasonNum int) []int { + if seasonNum > 0 { + return []int{seasonNum} + } + return []int{ + embySeasonGenericSpecial, + embySeasonTheatrical, + embySeasonOVA, + embySeasonOAD, + embySeasonOVD, + embySeasonONA, + embySeasonExtra, + embySeasonBonus, + embySeasonOmake, + embySeasonPictureDrama, + embySeasonNCOP, + embySeasonNCED, + } +} + +func seasonID(seriesID string, seasonNum int) string { return stableEmbyID(embyVirtualSeasonPrefix, seriesID, strconv.Itoa(seasonNum)) } func seasonName(seasonNum int) string { - if seasonNum == 0 { + switch seasonNum { + case embySeasonGenericSpecial: return "特别篇" - } - if seasonNum < 0 { - seasonNum = 1 + case embySeasonTheatrical: + return "剧场版" + case embySeasonOVA: + return "OVA" + case embySeasonOAD: + return "OAD" + case embySeasonOVD: + return "OVD" + case embySeasonONA: + return "ONA" + case embySeasonExtra: + return "Extra" + case embySeasonBonus: + return "Bonus" + case embySeasonOmake: + return "Omake" + case embySeasonPictureDrama: + return "Picture Drama" + case embySeasonNCOP: + return "NCOP" + case embySeasonNCED: + return "NCED" } return fmt.Sprintf("第 %d 季", seasonNum) }