diff --git a/internal/database/migrations/020_add_skip_segment_overrides.sql b/internal/database/migrations/020_add_skip_segment_overrides.sql new file mode 100644 index 0000000..614698e --- /dev/null +++ b/internal/database/migrations/020_add_skip_segment_overrides.sql @@ -0,0 +1,20 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS skip_segment_override ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + anime_id INTEGER NOT NULL, + episode INTEGER NOT NULL, + skip_type TEXT NOT NULL, -- 'op' or 'ed' + start_time REAL NOT NULL, + end_time REAL NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, anime_id, episode, skip_type) +); + +CREATE INDEX IF NOT EXISTS idx_skip_segment_override_lookup + ON skip_segment_override(user_id, anime_id, episode); + +-- +goose Down +DROP TABLE IF EXISTS skip_segment_override; + diff --git a/internal/db/skip_segment_overrides.go b/internal/db/skip_segment_overrides.go new file mode 100644 index 0000000..6050aec --- /dev/null +++ b/internal/db/skip_segment_overrides.go @@ -0,0 +1,70 @@ +package db + +import ( + "context" + "database/sql" + "fmt" +) + +type SkipSegmentOverride struct { + ID string + UserID string + AnimeID int64 + Episode int64 + SkipType string + StartTime float64 + EndTime float64 +} + +func (q *Queries) ListSkipSegmentOverrides(ctx context.Context, userID string, animeID int64, episode int64) ([]SkipSegmentOverride, error) { + const query = ` +SELECT id, user_id, anime_id, episode, skip_type, start_time, end_time +FROM skip_segment_override +WHERE user_id = ? AND anime_id = ? AND episode = ? +ORDER BY skip_type ASC; +` + rows, err := q.db.QueryContext(ctx, query, userID, animeID, episode) + if err != nil { + return nil, fmt.Errorf("list skip segment overrides: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []SkipSegmentOverride + for rows.Next() { + var r SkipSegmentOverride + if err := rows.Scan(&r.ID, &r.UserID, &r.AnimeID, &r.Episode, &r.SkipType, &r.StartTime, &r.EndTime); err != nil { + return nil, fmt.Errorf("scan skip segment override: %w", err) + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate skip segment overrides: %w", err) + } + return out, nil +} + +func (q *Queries) UpsertSkipSegmentOverride(ctx context.Context, r SkipSegmentOverride) error { + const query = ` +INSERT INTO skip_segment_override (id, user_id, anime_id, episode, skip_type, start_time, end_time) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(user_id, anime_id, episode, skip_type) DO UPDATE SET + start_time = excluded.start_time, + end_time = excluded.end_time, + updated_at = CURRENT_TIMESTAMP; +` + _, err := q.db.ExecContext(ctx, query, r.ID, r.UserID, r.AnimeID, r.Episode, r.SkipType, r.StartTime, r.EndTime) + if err != nil { + return fmt.Errorf("upsert skip segment override: %w", err) + } + return nil +} + +func (q *Queries) HasSkipSegmentOverrideTable(ctx context.Context) (bool, error) { + // Defensive: in case migrations haven’t run yet in some env. + const query = `SELECT name FROM sqlite_master WHERE type='table' AND name='skip_segment_override' LIMIT 1;` + var name sql.NullString + if err := q.db.QueryRowContext(ctx, query).Scan(&name); err != nil { + return false, fmt.Errorf("check skip segment override table: %w", err) + } + return name.Valid && name.String != "", nil +} diff --git a/internal/domain/playback.go b/internal/domain/playback.go index 3dadb18..e018f35 100644 --- a/internal/domain/playback.go +++ b/internal/domain/playback.go @@ -10,6 +10,7 @@ type PlaybackService interface { SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error CompleteAnime(ctx context.Context, userID string, animeID int64) error ResolveProxyToken(token string) (string, string, error) + UpsertSkipSegmentOverride(ctx context.Context, userID string, animeID int64, episode int, skipType string, startTime, endTime float64) error } type ProviderStream struct { @@ -38,4 +39,7 @@ type PlaybackRepository interface { UpsertWatchListEntry(ctx context.Context, params db.UpsertWatchListEntryParams) (db.WatchListEntry, error) UpsertContinueWatchingEntry(ctx context.Context, params db.UpsertContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) DeleteContinueWatchingEntry(ctx context.Context, params db.DeleteContinueWatchingEntryParams) error + ListSkipSegmentOverrides(ctx context.Context, userID string, animeID int64, episode int64) ([]db.SkipSegmentOverride, error) + UpsertSkipSegmentOverride(ctx context.Context, r db.SkipSegmentOverride) error + HasSkipSegmentOverrideTable(ctx context.Context) (bool, error) } diff --git a/internal/playback/handler/handler.go b/internal/playback/handler/handler.go index 33358c2..b4650e1 100644 --- a/internal/playback/handler/handler.go +++ b/internal/playback/handler/handler.go @@ -41,6 +41,7 @@ func (h *PlaybackHandler) Register(r *gin.Engine) { r.POST("/api/watch-progress", h.HandleSaveProgress) r.POST("/api/watch-complete", h.HandleWatchComplete) r.GET("/api/watch/episode/:animeId/:episode", h.HandleEpisodeData) + r.POST("/api/watch/segments", h.HandleUpsertSkipSegment) r.GET("/api/watch/thumbnails/:animeId", h.HandleEpisodeThumbnails) r.GET("/watch/proxy/stream", h.HandleProxyStream) r.GET("/watch/proxy/subtitle", h.HandleProxySubtitle) @@ -197,6 +198,37 @@ func (h *PlaybackHandler) HandleWatchComplete(c *gin.Context) { c.Status(http.StatusOK) } +func (h *PlaybackHandler) HandleUpsertSkipSegment(c *gin.Context) { + user, _ := c.Get("User") + userID := "" + if u, ok := user.(*domain.User); ok { + userID = u.ID + } + if userID == "" { + c.Status(http.StatusUnauthorized) + return + } + + var req struct { + MalID int64 `json:"mal_id"` + Episode int `json:"episode"` + SkipType string `json:"skip_type"` // 'op' or 'ed' (also accepts 'opening'/'ending') + StartTime float64 `json:"start_time"` + EndTime float64 `json:"end_time"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.Status(http.StatusBadRequest) + return + } + + if err := h.svc.UpsertSkipSegmentOverride(c.Request.Context(), userID, req.MalID, req.Episode, req.SkipType, req.StartTime, req.EndTime); err != nil { + c.Status(http.StatusBadRequest) + return + } + + c.Status(http.StatusOK) +} + func (h *PlaybackHandler) HandleEpisodeThumbnails(c *gin.Context) { id, err := strconv.Atoi(c.Param("animeId")) if err != nil { diff --git a/internal/playback/repository/repository.go b/internal/playback/repository/repository.go index 4951de4..b1b4354 100644 --- a/internal/playback/repository/repository.go +++ b/internal/playback/repository/repository.go @@ -37,3 +37,15 @@ func (r *playbackRepository) UpsertContinueWatchingEntry(ctx context.Context, pa func (r *playbackRepository) DeleteContinueWatchingEntry(ctx context.Context, params db.DeleteContinueWatchingEntryParams) error { return r.queries.DeleteContinueWatchingEntry(ctx, params) } + +func (r *playbackRepository) ListSkipSegmentOverrides(ctx context.Context, userID string, animeID int64, episode int64) ([]db.SkipSegmentOverride, error) { + return r.queries.ListSkipSegmentOverrides(ctx, userID, animeID, episode) +} + +func (r *playbackRepository) UpsertSkipSegmentOverride(ctx context.Context, s db.SkipSegmentOverride) error { + return r.queries.UpsertSkipSegmentOverride(ctx, s) +} + +func (r *playbackRepository) HasSkipSegmentOverrideTable(ctx context.Context) (bool, error) { + return r.queries.HasSkipSegmentOverrideTable(ctx) +} diff --git a/internal/playback/service/service.go b/internal/playback/service/service.go index c36b5bb..f183310 100644 --- a/internal/playback/service/service.go +++ b/internal/playback/service/service.go @@ -272,7 +272,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title } // Final assembly - segments := s.fetchSkipSegments(ctx, animeID, episode) + segments := s.fetchSkipSegments(ctx, userID, animeID, episode) watchData := map[string]any{ "MalID": animeID, @@ -351,7 +351,41 @@ func (s *playbackService) SaveProgress(ctx context.Context, userID string, anime return err } -func (s *playbackService) fetchSkipSegments(ctx context.Context, malID int, episode string) []SkipSegment { +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 := strings.ToLower(strings.TrimSpace(skipType)) + switch t { + case "op", "opening", "intro": + t = "op" + case "ed", "ending", "outro": + t = "ed" + default: + return fmt.Errorf("invalid skip_type") + } + 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.SkipSegmentOverride{ + 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) []SkipSegment { if malID <= 0 || strings.TrimSpace(episode) == "" { return []SkipSegment{} } @@ -415,6 +449,47 @@ func (s *playbackService) fetchSkipSegments(ctx context.Context, malID int, epis }) } + epNum, _ := strconv.ParseInt(strings.TrimSpace(episode), 10, 64) + if userID != "" && epNum > 0 { + if ok, err := s.repo.HasSkipSegmentOverrideTable(ctx); err == nil && ok { + if overrides, err := s.repo.ListSkipSegmentOverrides(ctx, userID, int64(malID), epNum); err == nil { + // Build map keyed by normalized type ("opening"/"ending") + overrideByType := make(map[string]SkipSegment, len(overrides)) + for _, o := range overrides { + t := strings.ToLower(strings.TrimSpace(o.SkipType)) + switch t { + case "op", "opening", "intro": + t = "opening" + case "ed", "ending", "outro": + t = "ending" + default: + continue + } + overrideByType[t] = SkipSegment{Type: t, Start: o.StartTime, End: o.EndTime} + } + if len(overrideByType) > 0 { + merged := make([]SkipSegment, 0, len(segments)+len(overrideByType)) + seen := map[string]bool{} + for _, seg := range segments { + if o, ok := overrideByType[seg.Type]; ok { + merged = append(merged, o) + seen[seg.Type] = true + } else { + merged = append(merged, seg) + seen[seg.Type] = true + } + } + for t, o := range overrideByType { + if !seen[t] { + merged = append(merged, o) + } + } + segments = merged + } + } + } + } + return segments }