From b578bd661e2911e2a2f353bbf51809df004a29f8 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 13 Jun 2026 22:07:21 +0200 Subject: [PATCH] refactor: extract skip segments handling --- internal/playback/service.go | 196 --------------------------- internal/playback/skip_segments.go | 210 +++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 196 deletions(-) create mode 100644 internal/playback/skip_segments.go diff --git a/internal/playback/service.go b/internal/playback/service.go index 0b8adeb..1aee006 100644 --- a/internal/playback/service.go +++ b/internal/playback/service.go @@ -6,16 +6,13 @@ import ( "database/sql" "encoding/json" "fmt" - "io" "mal/integrations/jikan" "mal/internal/db" "mal/internal/domain" "mal/internal/observability" netutil "mal/pkg/net" "net/http" - "net/url" "strconv" - "strings" "time" "github.com/google/uuid" @@ -204,55 +201,6 @@ func (s *playbackService) SaveProgress(ctx context.Context, userID string, anime return nil } -func normalizeSkipType(skipType string) (string, error) { - switch strings.ToLower(strings.TrimSpace(skipType)) { - case "op", "opening", "intro": - return "op", nil - case "ed", "ending", "outro": - return "ed", nil - default: - return "", fmt.Errorf("invalid skip_type") - } -} - -func (s *playbackService) UpsertSkipSegmentOverride(ctx context.Context, userID string, animeID int64, episode int, skipType string, startTime, endTime float64) error { - if userID == "" { - return fmt.Errorf("not authenticated") - } - if animeID <= 0 || episode <= 0 { - return fmt.Errorf("invalid anime/episode") - } - t, err := normalizeSkipType(skipType) - if err != nil { - return err - } - if !(startTime >= 0) || !(endTime > startTime) { - return fmt.Errorf("invalid interval") - } - // let the player-side filters ignore obviously wrong durations, but keep some sanity. - if endTime-startTime < 5 || endTime-startTime > 10*60 { - return fmt.Errorf("interval duration out of range") - } - return s.repo.UpsertSkipSegmentOverride(ctx, db.SkipSegmentOverrideRow{ - ID: uuid.New().String(), - UserID: userID, - AnimeID: animeID, - Episode: int64(episode), - SkipType: t, - StartTime: startTime, - EndTime: endTime, - }) -} - -func (s *playbackService) fetchSkipSegments(ctx context.Context, userID string, malID int, episode string) []domain.SkipSegment { - if malID <= 0 || strings.TrimSpace(episode) == "" { - return []domain.SkipSegment{} - } - - segments := s.fetchAniSkipSegments(ctx, malID, episode) - return s.applySkipSegmentOverrides(ctx, segments, userID, malID, episode) -} - func (s *playbackService) warmStreamURL(targetURL, referer string) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -272,147 +220,3 @@ func (s *playbackService) warmStreamURL(targetURL, referer string) { } _ = resp.Body.Close() } - -func (s *playbackService) fetchAniSkipSegments(ctx context.Context, malID int, episode string) []domain.SkipSegment { - endpoint := fmt.Sprintf("https://api.aniskip.com/v1/skip-times/%s/%s?types=op&types=ed", url.PathEscape(strconv.Itoa(malID)), url.PathEscape(episode)) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil - } - req.Header.Set("User-Agent", netutil.Generic) - - resp, err := s.httpClient.Do(req) - if err != nil { - return nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil - } - - body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.KiB512)) - if err != nil { - return nil - } - - return parseAniSkipSegments(body) -} - -func parseAniSkipSegments(body []byte) []domain.SkipSegment { - type resultItem struct { - SkipType string `json:"skip_type"` - Interval struct { - StartTime float64 `json:"start_time"` - EndTime float64 `json:"end_time"` - } `json:"interval"` - } - type apiResponse struct { - Found bool `json:"found"` - Result []resultItem `json:"results"` - } - - var parsed apiResponse - if err := json.Unmarshal(body, &parsed); err != nil || !parsed.Found || len(parsed.Result) == 0 { - return nil - } - - segments := make([]domain.SkipSegment, 0, len(parsed.Result)) - for _, item := range parsed.Result { - segments = append(segments, domain.SkipSegment{ - Type: normalizeSkipSegmentLabel(item.SkipType), - Start: item.Interval.StartTime, - End: item.Interval.EndTime, - Source: "aniskip", - }) - } - - return segments -} - -func normalizeSkipSegmentLabel(skipType string) string { - switch strings.ToLower(strings.TrimSpace(skipType)) { - case "op": - return "opening" - case "ed": - return "ending" - default: - return strings.ToLower(strings.TrimSpace(skipType)) - } -} - -func (s *playbackService) applySkipSegmentOverrides(ctx context.Context, segments []domain.SkipSegment, userID string, malID int, episode string) []domain.SkipSegment { - epNum, err := strconv.ParseInt(strings.TrimSpace(episode), 10, 64) - if userID == "" || err != nil || epNum <= 0 { - return segments - } - - ok, err := s.repo.HasSkipSegmentOverrideTable(ctx) - if err != nil || !ok { - return segments - } - - overrides, err := s.repo.ListSkipSegmentOverrides(ctx, userID, int64(malID), epNum) - if err != nil { - return segments - } - - overrideByType := buildOverrideSegments(overrides) - if len(overrideByType) == 0 { - return segments - } - - return mergeSkipSegments(segments, overrideByType) -} - -func buildOverrideSegments(overrides []db.SkipSegmentOverrideRow) map[string]domain.SkipSegment { - byType := make(map[string]domain.SkipSegment, len(overrides)) - for _, override := range overrides { - skipType, ok := normalizeOverrideSkipType(override.SkipType) - if !ok { - continue - } - - byType[skipType] = domain.SkipSegment{ - Type: skipType, - Start: override.StartTime, - End: override.EndTime, - Source: "override", - } - } - - return byType -} - -func normalizeOverrideSkipType(skipType string) (string, bool) { - switch strings.ToLower(strings.TrimSpace(skipType)) { - case "op", "opening", "intro": - return "opening", true - case "ed", "ending", "outro": - return "ending", true - default: - return "", false - } -} - -func mergeSkipSegments(segments []domain.SkipSegment, overrides map[string]domain.SkipSegment) []domain.SkipSegment { - merged := make([]domain.SkipSegment, 0, len(segments)+len(overrides)) - seen := make(map[string]bool, len(segments)) - - for _, segment := range segments { - if override, ok := overrides[segment.Type]; ok { - merged = append(merged, override) - } else { - merged = append(merged, segment) - } - seen[segment.Type] = true - } - - for skipType, override := range overrides { - if !seen[skipType] { - merged = append(merged, override) - } - } - - return merged -} diff --git a/internal/playback/skip_segments.go b/internal/playback/skip_segments.go new file mode 100644 index 0000000..6ca9a89 --- /dev/null +++ b/internal/playback/skip_segments.go @@ -0,0 +1,210 @@ +package playback + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/google/uuid" + + "mal/internal/db" + "mal/internal/domain" + netutil "mal/pkg/net" +) + +func normalizeSkipType(skipType string) (string, error) { + switch strings.ToLower(strings.TrimSpace(skipType)) { + case "op", "opening", "intro": + return "op", nil + case "ed", "ending", "outro": + return "ed", nil + default: + return "", fmt.Errorf("invalid skip_type") + } +} + +func (s *playbackService) UpsertSkipSegmentOverride(ctx context.Context, userID string, animeID int64, episode int, skipType string, startTime, endTime float64) error { + if userID == "" { + return fmt.Errorf("not authenticated") + } + if animeID <= 0 || episode <= 0 { + return fmt.Errorf("invalid anime/episode") + } + t, err := normalizeSkipType(skipType) + if err != nil { + return err + } + if !(startTime >= 0) || !(endTime > startTime) { + return fmt.Errorf("invalid interval") + } + if endTime-startTime < 5 || endTime-startTime > 10*60 { + return fmt.Errorf("interval duration out of range") + } + return s.repo.UpsertSkipSegmentOverride(ctx, db.SkipSegmentOverrideRow{ + ID: uuid.New().String(), + UserID: userID, + AnimeID: animeID, + Episode: int64(episode), + SkipType: t, + StartTime: startTime, + EndTime: endTime, + }) +} + +func (s *playbackService) fetchSkipSegments(ctx context.Context, userID string, malID int, episode string) []domain.SkipSegment { + if malID <= 0 || strings.TrimSpace(episode) == "" { + return []domain.SkipSegment{} + } + + segments := s.fetchAniSkipSegments(ctx, malID, episode) + return s.applySkipSegmentOverrides(ctx, segments, userID, malID, episode) +} + +func (s *playbackService) fetchAniSkipSegments(ctx context.Context, malID int, episode string) []domain.SkipSegment { + endpoint := fmt.Sprintf("https://api.aniskip.com/v1/skip-times/%s/%s?types=op&types=ed", url.PathEscape(strconv.Itoa(malID)), url.PathEscape(episode)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil + } + req.Header.Set("User-Agent", netutil.Generic) + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.KiB512)) + if err != nil { + return nil + } + + return parseAniSkipSegments(body) +} + +func parseAniSkipSegments(body []byte) []domain.SkipSegment { + type resultItem struct { + SkipType string `json:"skip_type"` + Interval struct { + StartTime float64 `json:"start_time"` + EndTime float64 `json:"end_time"` + } `json:"interval"` + } + type apiResponse struct { + Found bool `json:"found"` + Result []resultItem `json:"results"` + } + + var parsed apiResponse + if err := json.Unmarshal(body, &parsed); err != nil || !parsed.Found || len(parsed.Result) == 0 { + return nil + } + + segments := make([]domain.SkipSegment, 0, len(parsed.Result)) + for _, item := range parsed.Result { + segments = append(segments, domain.SkipSegment{ + Type: normalizeSkipSegmentLabel(item.SkipType), + Start: item.Interval.StartTime, + End: item.Interval.EndTime, + Source: "aniskip", + }) + } + + return segments +} + +func normalizeSkipSegmentLabel(skipType string) string { + switch strings.ToLower(strings.TrimSpace(skipType)) { + case "op": + return "opening" + case "ed": + return "ending" + default: + return strings.ToLower(strings.TrimSpace(skipType)) + } +} + +func (s *playbackService) applySkipSegmentOverrides(ctx context.Context, segments []domain.SkipSegment, userID string, malID int, episode string) []domain.SkipSegment { + epNum, err := strconv.ParseInt(strings.TrimSpace(episode), 10, 64) + if userID == "" || err != nil || epNum <= 0 { + return segments + } + + ok, err := s.repo.HasSkipSegmentOverrideTable(ctx) + if err != nil || !ok { + return segments + } + + overrides, err := s.repo.ListSkipSegmentOverrides(ctx, userID, int64(malID), epNum) + if err != nil { + return segments + } + + overrideByType := buildOverrideSegments(overrides) + if len(overrideByType) == 0 { + return segments + } + + return mergeSkipSegments(segments, overrideByType) +} + +func buildOverrideSegments(overrides []db.SkipSegmentOverrideRow) map[string]domain.SkipSegment { + byType := make(map[string]domain.SkipSegment, len(overrides)) + for _, override := range overrides { + skipType, ok := normalizeOverrideSkipType(override.SkipType) + if !ok { + continue + } + + byType[skipType] = domain.SkipSegment{ + Type: skipType, + Start: override.StartTime, + End: override.EndTime, + Source: "override", + } + } + + return byType +} + +func normalizeOverrideSkipType(skipType string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(skipType)) { + case "op", "opening", "intro": + return "opening", true + case "ed", "ending", "outro": + return "ending", true + default: + return "", false + } +} + +func mergeSkipSegments(segments []domain.SkipSegment, overrides map[string]domain.SkipSegment) []domain.SkipSegment { + merged := make([]domain.SkipSegment, 0, len(segments)+len(overrides)) + seen := make(map[string]bool, len(segments)) + + for _, segment := range segments { + if override, ok := overrides[segment.Type]; ok { + merged = append(merged, override) + } else { + merged = append(merged, segment) + } + seen[segment.Type] = true + } + + for skipType, override := range overrides { + if !seen[skipType] { + merged = append(merged, override) + } + } + + return merged +}