diff --git a/internal/episodes/service/classifications.go b/internal/episodes/service/classifications.go new file mode 100644 index 00000000..9be153c8 --- /dev/null +++ b/internal/episodes/service/classifications.go @@ -0,0 +1,81 @@ +package service + +import ( + "context" + "errors" + "strconv" + + "mal/integrations/jikan" + "mal/internal/domain" + "mal/internal/observability" +) + +func (s *EpisodeService) EnrichEpisodeClassifications(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) { + payload, _, ok := s.cachedEpisodePayload(ctx, anime) + if !ok { + return domain.CanonicalEpisodeList{}, errors.New("episode classifications: episode availability is not cached") + } + if payload.ClassificationChecked { + return payload, nil + } + if s.jikan == nil { + return payload, errors.New("episode classifications: provider is not configured") + } + + value, err, _ := s.classificationLoad.Do(strconv.Itoa(anime.MalID), func() (any, error) { + return s.loadEpisodeClassifications(ctx, anime) + }) + if err != nil { + return payload, err + } + return value.(domain.CanonicalEpisodeList), nil +} + +func (s *EpisodeService) loadEpisodeClassifications(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) { + episodes, err := s.jikan.GetAllEpisodes(ctx, anime.MalID) + if err != nil { + return domain.CanonicalEpisodeList{}, err + } + + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + + payload, row, ok := s.cachedEpisodePayload(ctx, anime) + if !ok { + return domain.CanonicalEpisodeList{}, errors.New("episode classifications: episode availability cache disappeared") + } + mergeEpisodeClassifications(payload.Episodes, episodes) + payload.ClassificationChecked = true + if err := s.storeEnrichedPayload(ctx, row, payload); err != nil { + return domain.CanonicalEpisodeList{}, err + } + + observability.Info( + "episode_classifications_enriched", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + "episodes": len(episodes), + }, + ) + return payload, nil +} + +func mergeEpisodeClassifications(canonical []domain.CanonicalEpisode, episodes []jikan.Episode) { + byNumber := make(map[int]jikan.Episode, len(episodes)) + for i, episode := range episodes { + number, ok := jikanEpisodeNumber(episode, i) + if ok { + byNumber[number] = episode + } + } + for i := range canonical { + classification, ok := byNumber[canonical[i].Number] + if !ok { + continue + } + canonical[i].Filler = classification.Filler + canonical[i].Recap = classification.Recap + } +} diff --git a/internal/episodes/service/classifications_test.go b/internal/episodes/service/classifications_test.go new file mode 100644 index 00000000..c18217f9 --- /dev/null +++ b/internal/episodes/service/classifications_test.go @@ -0,0 +1,32 @@ +package service + +import ( + "testing" + + "mal/integrations/jikan" + "mal/internal/domain" +) + +func TestMergeEpisodeClassificationsRestoresFillerAndRecap(t *testing.T) { + episodes := []domain.CanonicalEpisode{ + {Number: 1, Title: "First", HasSub: true}, + {Number: 2, Title: "Second", HasSub: true}, + {Number: 3, Title: "Third", HasSub: true}, + } + + mergeEpisodeClassifications(episodes, []jikan.Episode{ + {Episode: "1"}, + {Episode: "2", Filler: true}, + {Episode: "3", Recap: true}, + }) + + if episodes[0].Filler || episodes[0].Recap { + t.Fatalf("episode 1 = %#v", episodes[0]) + } + if !episodes[1].Filler || episodes[1].Recap { + t.Fatalf("episode 2 = %#v", episodes[1]) + } + if episodes[2].Filler || !episodes[2].Recap { + t.Fatalf("episode 3 = %#v", episodes[2]) + } +}