feat: add skip segment overrides backend

This commit is contained in:
2026-05-19 11:02:54 +02:00
parent bcee65cbad
commit 1a9e1d60bc
6 changed files with 215 additions and 2 deletions

View File

@@ -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;

View File

@@ -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 havent 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
}

View File

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

View File

@@ -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 {

View File

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

View File

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