From 5679be866ac2a67feadfa5b6a917ef2ec2b8be6f Mon Sep 17 00:00:00 2001 From: mkelvers Date: Mon, 6 Jul 2026 07:36:51 +0200 Subject: [PATCH] feat: add episode title enrichment with cache update --- internal/episodes/service/titles.go | 132 ++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 internal/episodes/service/titles.go diff --git a/internal/episodes/service/titles.go b/internal/episodes/service/titles.go new file mode 100644 index 00000000..2e25b61e --- /dev/null +++ b/internal/episodes/service/titles.go @@ -0,0 +1,132 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "mal/internal/db" + "mal/internal/domain" + "mal/internal/observability" +) + +func (s *EpisodeService) EnrichEpisodeTitles(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) { + payload, _, ok := s.cachedEpisodePayload(ctx, anime) + if !ok { + return domain.CanonicalEpisodeList{}, errors.New("episode titles: episode availability is not cached") + } + if !hasPlaceholderTitles(payload.Episodes) { + return payload, nil + } + if s.titles == nil { + return payload, errors.New("episode titles: provider is not configured") + } + + value, err, _ := s.titleLoad.Do(strconv.Itoa(anime.MalID), func() (any, error) { + return s.loadEpisodeTitles(ctx, anime) + }) + if err != nil { + return payload, err + } + return value.(domain.CanonicalEpisodeList), nil +} + +func (s *EpisodeService) loadEpisodeTitles(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) { + providerID, err := s.providerID(ctx, anime, s.titles, titleCandidates(anime)) + if err != nil { + return domain.CanonicalEpisodeList{}, err + } + + titles, err := s.titles.GetEpisodeTitlesByProviderID(ctx, providerID) + if err != nil { + return domain.CanonicalEpisodeList{}, err + } + + payload, row, ok := s.cachedEpisodePayload(ctx, anime) + if !ok { + return domain.CanonicalEpisodeList{}, errors.New("episode titles: episode availability cache disappeared") + } + + changed := mergeMissingTitles(payload.Episodes, titles) + if !changed { + return payload, nil + } + if err := s.storeEnrichedTitles(ctx, row, payload); err != nil { + return domain.CanonicalEpisodeList{}, err + } + + observability.Info( + "episode_titles_enriched", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + "provider": s.titles.Name(), + "titles": len(titles), + }, + ) + return payload, nil +} + +func (s *EpisodeService) cachedEpisodePayload(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, db.EpisodeAvailabilityCache, bool) { + row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(anime.MalID)) + if err != nil { + return domain.CanonicalEpisodeList{}, db.EpisodeAvailabilityCache{}, false + } + payload, ok := s.decodeCachedPayload(anime, row.Data) + if !ok { + return domain.CanonicalEpisodeList{}, db.EpisodeAvailabilityCache{}, false + } + return enrichCachedPayload(payload, row), row, true +} + +func (s *EpisodeService) storeEnrichedTitles(ctx context.Context, row db.EpisodeAvailabilityCache, payload domain.CanonicalEpisodeList) error { + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("episode titles: encode cache: %w", err) + } + err = s.queries.UpsertEpisodeAvailabilityCache(ctx, db.UpsertEpisodeAvailabilityCacheParams{ + AnimeID: row.AnimeID, + Data: string(body), + NextRefreshAt: row.NextRefreshAt, + RetryUntilAt: row.RetryUntilAt, + LastAttemptAt: row.LastAttemptAt, + LastSuccessAt: row.LastSuccessAt, + FailureCount: row.FailureCount, + LastError: row.LastError, + }) + if err != nil { + return fmt.Errorf("episode titles: update cache: %w", err) + } + return nil +} + +func hasPlaceholderTitles(episodes []domain.CanonicalEpisode) bool { + for _, episode := range episodes { + if isPlaceholderTitle(episode.Number, episode.Title) { + return true + } + } + return false +} + +func mergeMissingTitles(episodes []domain.CanonicalEpisode, titles map[int]string) bool { + changed := false + for i := range episodes { + episode := &episodes[i] + title := strings.TrimSpace(titles[episode.Number]) + if title == "" || !isPlaceholderTitle(episode.Number, episode.Title) || title == episode.Title { + continue + } + episode.Title = title + changed = true + } + return changed +} + +func isPlaceholderTitle(number int, title string) bool { + return strings.TrimSpace(title) == fmt.Sprintf("Episode %d", number) +}