diff --git a/internal/playback/service.go b/internal/playback/service.go index 0d68c1a..0b8adeb 100644 --- a/internal/playback/service.go +++ b/internal/playback/service.go @@ -14,7 +14,6 @@ import ( netutil "mal/pkg/net" "net/http" "net/url" - "sort" "strconv" "strings" "time" @@ -69,165 +68,6 @@ func (s *playbackService) ResolveProxyToken(token string, scope string) (string, return target.targetURL, target.referer, nil } -func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (domain.WatchPageData, error) { - anime, err := s.jikan.GetAnimeByID(ctx, animeID) - if err != nil { - return domain.WatchPageData{}, fmt.Errorf("failed to fetch anime: %w", err) - } - - animeData := domain.Anime{Anime: anime} - searchTitles := buildSearchTitles(animeData, titleCandidates) - canonicalEpisodes, err := s.episodes.GetCanonicalEpisodes(ctx, animeData, false) - if err != nil { - return domain.WatchPageData{}, fmt.Errorf("failed to fetch episodes: %w", err) - } - - mode, modeSwitchedFrom := resolveMode(episode, mode, canonicalEpisodes.Episodes) - modeSources, result := s.resolveModeSources(ctx, animeID, searchTitles, episode, mode) - if len(modeSources) == 0 { - return domain.WatchPageData{}, fmt.Errorf("no streams found") - } - if result == nil { - return domain.WatchPageData{}, fmt.Errorf("no streams found for mode %s", mode) - } - - startTime, watchlistStatus, watchlistIDs := s.loadWatchProgress(ctx, userID, animeID, anime.Episodes, episode) - go s.warmStreamURL(result.URL, result.Referer) - seasons := s.loadSeasons(ctx, animeID) - segments := s.fetchSkipSegments(ctx, userID, animeID, episode) - watchData := buildWatchDataPayload(animeData, animeID, episode, startTime, canonicalEpisodes.Episodes, modeSources, mode, modeSwitchedFrom, segments) - return buildWatchPageData(animeData, canonicalEpisodes.Episodes, episode, watchlistStatus, watchlistIDs, seasons, watchData), nil -} - -func buildWatchDataPayload(anime domain.Anime, animeID int, episode string, startTime float64, episodes []domain.CanonicalEpisode, modeSources map[string]domain.ModeSource, mode string, modeSwitchedFrom string, segments []domain.SkipSegment) domain.WatchData { - return domain.WatchData{ - MalID: animeID, - Title: anime.DisplayTitle(), - CurrentEpisode: episode, - StartTimeSeconds: startTime, - Episodes: episodes, - Providers: []domain.ProviderData{{Streams: []domain.ProviderStream{{ - Name: "Primary", - Quality: "Auto", - MalID: animeID, - IsCurrent: true, - }}}}, - ModeSources: modeSources, - InitialMode: mode, - ModeSwitchedFrom: modeSwitchedFrom, - AvailableModes: availableModes(modeSources), - Segments: segments, - Airing: anime.Airing, - } -} - -func buildWatchPageData(anime domain.Anime, episodes []domain.CanonicalEpisode, episode string, watchlistStatus string, watchlistIDs []int64, seasons []domain.SeasonEntry, watchData domain.WatchData) domain.WatchPageData { - return domain.WatchPageData{ - WatchData: watchData, - Anime: anime, - Episodes: episodes, - CurrentEpID: episode, - WatchlistStatus: watchlistStatus, - WatchlistIDs: watchlistIDs, - Seasons: seasons, - } -} - -func buildSearchTitles(anime domain.Anime, titleCandidates []string) []string { - seen := map[string]struct{}{} - out := make([]string, 0, 3+len(anime.TitleSynonyms)+len(titleCandidates)) - - appendTitle := func(title string) { - title = strings.TrimSpace(title) - if title == "" { - return - } - if _, ok := seen[title]; ok { - return - } - seen[title] = struct{}{} - out = append(out, title) - } - - appendTitle(anime.Title) - appendTitle(anime.TitleEnglish) - appendTitle(anime.TitleJapanese) - for _, syn := range anime.TitleSynonyms { - appendTitle(syn) - } - for _, candidate := range titleCandidates { - appendTitle(candidate) - } - - return out -} - -func resolveMode(episode string, requestedMode string, episodes []domain.CanonicalEpisode) (string, string) { - if requestedMode != "dub" { - return requestedMode, "" - } - - epNum, err := strconv.Atoi(episode) - if err != nil { - return requestedMode, "" - } - - for _, ep := range episodes { - if ep.Number == epNum && !ep.HasDub && ep.HasSub { - return "sub", requestedMode - } - } - - return requestedMode, "" -} - -func (s *playbackService) resolveModeSources(ctx context.Context, animeID int, searchTitles []string, episode string, mode string) (map[string]domain.ModeSource, *domain.StreamResult) { - modeSources := map[string]domain.ModeSource{} - var result *domain.StreamResult - - for _, currentMode := range []string{"sub", "dub"} { - res := s.resolveStreamResult(ctx, animeID, searchTitles, episode, currentMode) - if res == nil { - continue - } - - modeSources[currentMode] = s.buildModeSource(res) - if currentMode == mode { - result = res - } - } - - return modeSources, result -} - -func (s *playbackService) resolveStreamResult(ctx context.Context, animeID int, searchTitles []string, episode string, mode string) *domain.StreamResult { - for _, p := range s.providers { - res, err := p.GetStreams(ctx, animeID, searchTitles, episode, mode) - if err == nil && res != nil { - return res - } - } - - return nil -} - -func (s *playbackService) buildModeSource(res *domain.StreamResult) domain.ModeSource { - subtitles := make([]domain.SubtitleItem, 0, len(res.Subtitles)) - for _, sub := range res.Subtitles { - token, _ := s.SignProxyToken(sub.URL, res.Referer, "subtitle") - subtitles = append(subtitles, domain.SubtitleItem{ - Lang: sub.Label, - Token: token, - }) - } - - streamToken, _ := s.SignProxyToken(res.URL, res.Referer, "stream") - return domain.ModeSource{ - Token: streamToken, - Subtitles: subtitles, - } -} - func (s *playbackService) loadWatchProgress(ctx context.Context, userID string, animeID int, totalEpisodes int, episode string) (float64, string, []int64) { if userID == "" { return 0, "", nil @@ -280,43 +120,6 @@ func resumeTimeForEpisode(currentEpisode sql.NullInt64, currentTimeSeconds float return 0 } -func (s *playbackService) loadSeasons(ctx context.Context, animeID int) []domain.SeasonEntry { - relations, _ := s.jikan.GetFullRelations(ctx, animeID, jikan.WatchOrderModeMain) - seasons := make([]domain.SeasonEntry, 0, len(relations)) - tvCounter := 1 - - for _, rel := range relations { - animeType := strings.ToLower(rel.Anime.Type) - if animeType != "tv" && animeType != "movie" { - continue - } - - season := domain.SeasonEntry{ - MalID: rel.Anime.MalID, - Title: rel.Anime.DisplayTitle(), - Prefix: rel.Relation, - IsCurrent: rel.IsCurrent, - } - if rel.Relation == "TV" { - season.Prefix = fmt.Sprintf("S%d", tvCounter) - tvCounter++ - } - - seasons = append(seasons, season) - } - - return seasons -} - -func availableModes(modeSources map[string]domain.ModeSource) []string { - modes := make([]string, 0, len(modeSources)) - for mode := range modeSources { - modes = append(modes, mode) - } - sort.Strings(modes) - return modes -} - func (s *playbackService) CompleteAnime(ctx context.Context, userID string, animeID int64) error { if err := s.repo.InTx(ctx, func(txCtx context.Context, repo domain.PlaybackRepository) error { entry, err := repo.GetWatchListEntry(txCtx, db.GetWatchListEntryParams{ diff --git a/internal/playback/watch_data.go b/internal/playback/watch_data.go new file mode 100644 index 0000000..1a962f3 --- /dev/null +++ b/internal/playback/watch_data.go @@ -0,0 +1,208 @@ +package playback + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + + "mal/integrations/jikan" + "mal/internal/domain" +) + +func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (domain.WatchPageData, error) { + anime, err := s.jikan.GetAnimeByID(ctx, animeID) + if err != nil { + return domain.WatchPageData{}, fmt.Errorf("failed to fetch anime: %w", err) + } + + animeData := domain.Anime{Anime: anime} + searchTitles := buildSearchTitles(animeData, titleCandidates) + canonicalEpisodes, err := s.episodes.GetCanonicalEpisodes(ctx, animeData, false) + if err != nil { + return domain.WatchPageData{}, fmt.Errorf("failed to fetch episodes: %w", err) + } + + mode, modeSwitchedFrom := resolveMode(episode, mode, canonicalEpisodes.Episodes) + modeSources, result := s.resolveModeSources(ctx, animeID, searchTitles, episode, mode) + if len(modeSources) == 0 { + return domain.WatchPageData{}, fmt.Errorf("no streams found") + } + if result == nil { + return domain.WatchPageData{}, fmt.Errorf("no streams found for mode %s", mode) + } + + startTime, watchlistStatus, watchlistIDs := s.loadWatchProgress(ctx, userID, animeID, anime.Episodes, episode) + go s.warmStreamURL(result.URL, result.Referer) + seasons := s.loadSeasons(ctx, animeID) + segments := s.fetchSkipSegments(ctx, userID, animeID, episode) + watchData := buildWatchDataPayload(animeData, animeID, episode, startTime, canonicalEpisodes.Episodes, modeSources, mode, modeSwitchedFrom, segments) + return buildWatchPageData(animeData, canonicalEpisodes.Episodes, episode, watchlistStatus, watchlistIDs, seasons, watchData), nil +} + +func buildWatchDataPayload(anime domain.Anime, animeID int, episode string, startTime float64, episodes []domain.CanonicalEpisode, modeSources map[string]domain.ModeSource, mode string, modeSwitchedFrom string, segments []domain.SkipSegment) domain.WatchData { + return domain.WatchData{ + MalID: animeID, + Title: anime.DisplayTitle(), + CurrentEpisode: episode, + StartTimeSeconds: startTime, + Episodes: episodes, + Providers: []domain.ProviderData{{Streams: []domain.ProviderStream{{ + Name: "Primary", + Quality: "Auto", + MalID: animeID, + IsCurrent: true, + }}}}, + ModeSources: modeSources, + InitialMode: mode, + ModeSwitchedFrom: modeSwitchedFrom, + AvailableModes: availableModes(modeSources), + Segments: segments, + Airing: anime.Airing, + } +} + +func buildWatchPageData(anime domain.Anime, episodes []domain.CanonicalEpisode, episode string, watchlistStatus string, watchlistIDs []int64, seasons []domain.SeasonEntry, watchData domain.WatchData) domain.WatchPageData { + return domain.WatchPageData{ + WatchData: watchData, + Anime: anime, + Episodes: episodes, + CurrentEpID: episode, + WatchlistStatus: watchlistStatus, + WatchlistIDs: watchlistIDs, + Seasons: seasons, + } +} + +func buildSearchTitles(anime domain.Anime, titleCandidates []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, 3+len(anime.TitleSynonyms)+len(titleCandidates)) + + appendTitle := func(title string) { + title = strings.TrimSpace(title) + if title == "" { + return + } + if _, ok := seen[title]; ok { + return + } + seen[title] = struct{}{} + out = append(out, title) + } + + appendTitle(anime.Title) + appendTitle(anime.TitleEnglish) + appendTitle(anime.TitleJapanese) + for _, syn := range anime.TitleSynonyms { + appendTitle(syn) + } + for _, candidate := range titleCandidates { + appendTitle(candidate) + } + + return out +} + +func resolveMode(episode string, requestedMode string, episodes []domain.CanonicalEpisode) (string, string) { + if requestedMode != "dub" { + return requestedMode, "" + } + + epNum, err := strconv.Atoi(episode) + if err != nil { + return requestedMode, "" + } + + for _, ep := range episodes { + if ep.Number == epNum && !ep.HasDub && ep.HasSub { + return "sub", requestedMode + } + } + + return requestedMode, "" +} + +func (s *playbackService) resolveModeSources(ctx context.Context, animeID int, searchTitles []string, episode string, mode string) (map[string]domain.ModeSource, *domain.StreamResult) { + modeSources := map[string]domain.ModeSource{} + var result *domain.StreamResult + + for _, currentMode := range []string{"sub", "dub"} { + res := s.resolveStreamResult(ctx, animeID, searchTitles, episode, currentMode) + if res == nil { + continue + } + + modeSources[currentMode] = s.buildModeSource(res) + if currentMode == mode { + result = res + } + } + + return modeSources, result +} + +func (s *playbackService) resolveStreamResult(ctx context.Context, animeID int, searchTitles []string, episode string, mode string) *domain.StreamResult { + for _, p := range s.providers { + res, err := p.GetStreams(ctx, animeID, searchTitles, episode, mode) + if err == nil && res != nil { + return res + } + } + + return nil +} + +func (s *playbackService) buildModeSource(res *domain.StreamResult) domain.ModeSource { + subtitles := make([]domain.SubtitleItem, 0, len(res.Subtitles)) + for _, sub := range res.Subtitles { + token, _ := s.SignProxyToken(sub.URL, res.Referer, "subtitle") + subtitles = append(subtitles, domain.SubtitleItem{ + Lang: sub.Label, + Token: token, + }) + } + + streamToken, _ := s.SignProxyToken(res.URL, res.Referer, "stream") + return domain.ModeSource{ + Token: streamToken, + Subtitles: subtitles, + } +} + +func (s *playbackService) loadSeasons(ctx context.Context, animeID int) []domain.SeasonEntry { + relations, _ := s.jikan.GetFullRelations(ctx, animeID, jikan.WatchOrderModeMain) + seasons := make([]domain.SeasonEntry, 0, len(relations)) + tvCounter := 1 + + for _, rel := range relations { + animeType := strings.ToLower(rel.Anime.Type) + if animeType != "tv" && animeType != "movie" { + continue + } + + season := domain.SeasonEntry{ + MalID: rel.Anime.MalID, + Title: rel.Anime.DisplayTitle(), + Prefix: rel.Relation, + IsCurrent: rel.IsCurrent, + } + if rel.Relation == "TV" { + season.Prefix = fmt.Sprintf("S%d", tvCounter) + tvCounter++ + } + + seasons = append(seasons, season) + } + + return seasons +} + +func availableModes(modeSources map[string]domain.ModeSource) []string { + modes := make([]string, 0, len(modeSources)) + for mode := range modeSources { + modes = append(modes, mode) + } + sort.Strings(modes) + return modes +}