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