添加跳过片头片尾功能

This commit is contained in:
truewhile
2026-09-23 10:22:13 +08:00
parent 016c6687e9
commit 94ef0116b1
26 changed files with 2115 additions and 4 deletions
@@ -0,0 +1,74 @@
package repository
import (
"context"
"errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/truewhile/MeBox/internal/model"
)
// MediaSegmentRepository persists intro/recap/credits/preview ranges.
type MediaSegmentRepository struct{ db *gorm.DB }
// ListByMedia returns every stored segment for a media item, ordered by start.
func (r *MediaSegmentRepository) ListByMedia(ctx context.Context, mediaID string) ([]model.MediaSegment, error) {
rows := make([]model.MediaSegment, 0, 4)
err := r.db.WithContext(ctx).
Where("media_id = ?", mediaID).
Order("start_ms asc").
Find(&rows).Error
return rows, err
}
// ReplaceForMedia swaps the segments contributed by one source in a single
// transaction, so a provider refresh can never leave a half-updated set.
//
// Rows are hard-deleted rather than soft-deleted: the unique index on
// (media_id, kind, start_ms) would otherwise collide with the tombstoned rows
// on the next insert.
func (r *MediaSegmentRepository) ReplaceForMedia(ctx context.Context, mediaID, source string, rows []model.MediaSegment) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().
Where("media_id = ? AND source = ?", mediaID, source).
Delete(&model.MediaSegment{}).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
return tx.Create(&rows).Error
})
}
// GetFetch returns the fetch ledger row for (media, source), or (nil, nil).
func (r *MediaSegmentRepository) GetFetch(ctx context.Context, mediaID, source string) (*model.MediaSegmentFetch, error) {
var row model.MediaSegmentFetch
err := r.db.WithContext(ctx).
Where("media_id = ? AND source = ?", mediaID, source).
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
// UpsertFetch records the outcome of a provider lookup. The `deleted_at: nil`
// assignment revives a previously deleted row instead of failing on the unique
// index, mirroring the playback history upsert.
func (r *MediaSegmentRepository) UpsertFetch(ctx context.Context, row *model.MediaSegmentFetch) error {
onConflict := clause.OnConflict{
Columns: []clause.Column{{Name: "media_id"}, {Name: "source"}},
DoUpdates: clause.Assignments(map[string]any{
"fetched_at": row.FetchedAt,
"found": row.Found,
"deleted_at": nil,
}),
}
return r.db.WithContext(ctx).Clauses(onConflict).Create(row).Error
}
@@ -0,0 +1,154 @@
package repository
import (
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/model"
)
func newSegmentTestRepos(t *testing.T) *Container {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
return New(db)
}
func TestReplaceForMediaIsIdempotentAcrossRefreshes(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
rows := []model.MediaSegment{
{MediaID: "m-1", Kind: model.SegmentKindIntro, StartMs: 228_664, EndMs: 246_143, Source: "theintrodb"},
{MediaID: "m-1", Kind: model.SegmentKindCredits, StartMs: 3_431_000, EndMs: 0, Source: "theintrodb"},
}
// 重复刷新不能因为 (media_id, kind, start_ms, source) 唯一索引而失败,也不能累积重复行。
for i := 0; i < 2; i++ {
if err := repos.MediaSegment.ReplaceForMedia(ctx, "m-1", "theintrodb", rows); err != nil {
t.Fatalf("replace #%d: %v", i+1, err)
}
}
got, err := repos.MediaSegment.ListByMedia(ctx, "m-1")
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("rows = %d, want 2 after two refreshes of the same source", len(got))
}
// 替换只影响同一来源:另一个来源的数据必须保留。
other := []model.MediaSegment{
{MediaID: "m-1", Kind: model.SegmentKindIntro, StartMs: 10, EndMs: 20, Source: "manual"},
}
if err := repos.MediaSegment.ReplaceForMedia(ctx, "m-1", "manual", other); err != nil {
t.Fatal(err)
}
got, err = repos.MediaSegment.ListByMedia(ctx, "m-1")
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Fatalf("rows = %d, want 3 (2 theintrodb + 1 manual)", len(got))
}
if got[0].Source != "manual" || got[0].StartMs != 10 {
t.Fatalf("rows should be ordered by start_ms, got first = %#v", got[0])
}
}
func TestReplaceForMediaAllowsSameRangeFromDifferentSources(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
span := model.MediaSegment{MediaID: "m-1", Kind: model.SegmentKindIntro, StartMs: 1_000, EndMs: 2_000}
provider := span
provider.Source = "theintrodb"
if err := repos.MediaSegment.ReplaceForMedia(ctx, "m-1", "theintrodb", []model.MediaSegment{provider}); err != nil {
t.Fatal(err)
}
// 人工修正给出完全相同的区间:唯一索引含 source,两个来源必须能共存。
manual := span
manual.Source = "manual"
if err := repos.MediaSegment.ReplaceForMedia(ctx, "m-1", "manual", []model.MediaSegment{manual}); err != nil {
t.Fatalf("same range from another source: %v", err)
}
got, err := repos.MediaSegment.ListByMedia(ctx, "m-1")
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("rows = %d, want 2 (one per source)", len(got))
}
}
func TestReplaceForMediaClearsRowsWhenLookupReturnsNothing(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
rows := []model.MediaSegment{
{MediaID: "m-1", Kind: model.SegmentKindIntro, StartMs: 1_000, EndMs: 2_000, Source: "theintrodb"},
}
if err := repos.MediaSegment.ReplaceForMedia(ctx, "m-1", "theintrodb", rows); err != nil {
t.Fatal(err)
}
// 提供方后来把这段数据删掉了,本地必须跟着清空,否则会一直跳一个不存在的片头。
if err := repos.MediaSegment.ReplaceForMedia(ctx, "m-1", "theintrodb", nil); err != nil {
t.Fatal(err)
}
got, err := repos.MediaSegment.ListByMedia(ctx, "m-1")
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("rows = %d, want 0 after an empty refresh", len(got))
}
}
func TestUpsertFetchKeepsOneRowPerMediaAndSource(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
now := time.Now()
if err := repos.MediaSegment.UpsertFetch(ctx, &model.MediaSegmentFetch{
MediaID: "m-1", Source: "theintrodb", FetchedAt: now, Found: false,
}); err != nil {
t.Fatalf("first upsert: %v", err)
}
got, err := repos.MediaSegment.GetFetch(ctx, "m-1", "theintrodb")
if err != nil {
t.Fatal(err)
}
if got == nil || got.Found {
t.Fatalf("first lookup should be recorded as a miss, got %#v", got)
}
later := now.Add(time.Hour)
if err := repos.MediaSegment.UpsertFetch(ctx, &model.MediaSegmentFetch{
MediaID: "m-1", Source: "theintrodb", FetchedAt: later, Found: true,
}); err != nil {
t.Fatalf("second upsert: %v", err)
}
var count int64
if err := repos.DB.Model(&model.MediaSegmentFetch{}).
Where("media_id = ? AND source = ?", "m-1", "theintrodb").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("fetch ledger rows = %d, want 1", count)
}
got, err = repos.MediaSegment.GetFetch(ctx, "m-1", "theintrodb")
if err != nil {
t.Fatal(err)
}
if got == nil || !got.Found {
t.Fatalf("ledger should be updated in place, got %#v", got)
}
}
+2
View File
@@ -15,6 +15,7 @@ type Container struct {
Media *MediaRepository
Series *SeriesRepository
History *HistoryRepository
MediaSegment *MediaSegmentRepository
Favorite *FavoriteRepository
Playlist *PlaylistRepository
Setting *SettingRepository
@@ -45,6 +46,7 @@ func New(db *gorm.DB) *Container {
Media: &MediaRepository{db: db},
Series: &SeriesRepository{db: db},
History: &HistoryRepository{db: db},
MediaSegment: &MediaSegmentRepository{db: db},
Favorite: &FavoriteRepository{db: db},
Playlist: &PlaylistRepository{db: db},
Setting: &SettingRepository{db: db},