优化视频头尾跳过功能

This commit is contained in:
truewhile
2026-09-23 16:07:28 +08:00
parent 44fde6c5a0
commit 34ccf14cda
26 changed files with 1992 additions and 47 deletions
@@ -0,0 +1,61 @@
package repository
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/truewhile/MeBox/internal/model"
)
// MediaProbeRepository 持久化 ffprobe 全量探测的结果缓存。
type MediaProbeRepository struct{ db *gorm.DB }
// Get 返回某媒体的探测缓存,未探测过时返回 (nil, nil)。
func (r *MediaProbeRepository) Get(ctx context.Context, mediaID string) (*model.MediaProbe, error) {
var row model.MediaProbe
err := r.db.WithContext(ctx).
Where("media_id = ?", mediaID).
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
// Upsert 写入一次成功的探测结果(含裁剪后的媒体信息)。
func (r *MediaProbeRepository) Upsert(ctx context.Context, row *model.MediaProbe) error {
onConflict := clause.OnConflict{
Columns: []clause.Column{{Name: "media_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"signature", "source",
"container", "duration_sec", "bit_rate",
"width", "height", "video_codec", "audio_codec",
"video_streams", "audio_streams", "subtitle_streams", "chapter_count",
"payload", "probed_at", "last_error",
"deleted_at",
}),
}
return r.db.WithContext(ctx).Clauses(onConflict).Create(row).Error
}
// MarkFailure 记录一次失败的探测。它只更新「时间 + 错误信息」,刻意不碰
// payload 与其它字段:一次失败的重探不该把上一次成功拿到的媒体信息抹掉。
func (r *MediaProbeRepository) MarkFailure(ctx context.Context, mediaID, message string, probedAt time.Time) error {
row := model.MediaProbe{MediaID: mediaID, ProbedAt: probedAt, LastError: message}
onConflict := clause.OnConflict{
Columns: []clause.Column{{Name: "media_id"}},
DoUpdates: clause.Assignments(map[string]any{
"probed_at": probedAt,
"last_error": message,
"deleted_at": nil,
}),
}
return r.db.WithContext(ctx).Clauses(onConflict).Create(&row).Error
}
@@ -0,0 +1,105 @@
package repository
import (
"testing"
"time"
"github.com/truewhile/MeBox/internal/model"
)
func TestMediaProbeUpsertReplacesAndGetReturnsLatest(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
first := &model.MediaProbe{
MediaID: "m-1", Signature: "sig-1", Source: "local",
Container: "matroska,webm", DurationSec: 1451, ChapterCount: 2,
Payload: `{"container":"matroska,webm"}`, ProbedAt: time.Now(),
}
if err := repos.MediaProbe.Upsert(ctx, first); err != nil {
t.Fatalf("upsert #1: %v", err)
}
second := &model.MediaProbe{
MediaID: "m-1", Signature: "sig-2", Source: "strm",
Container: "mp4", DurationSec: 900, ChapterCount: 0,
Payload: `{"container":"mp4"}`, ProbedAt: time.Now(),
}
if err := repos.MediaProbe.Upsert(ctx, second); err != nil {
t.Fatalf("upsert #2: %v", err)
}
got, err := repos.MediaProbe.Get(ctx, "m-1")
if err != nil {
t.Fatal(err)
}
if got == nil {
t.Fatal("probe row missing")
}
if got.Container != "mp4" || got.DurationSec != 900 || got.Signature != "sig-2" || got.Payload != `{"container":"mp4"}` {
t.Fatalf("probe = %#v, want the second upsert to win", got)
}
// 重复探测不能累积重复行:media_id 是唯一索引。
var count int64
if err := repos.DB.Model(&model.MediaProbe{}).Where("media_id = ?", "m-1").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("rows = %d, want 1", count)
}
}
func TestMediaProbeGetReturnsNilWhenMissing(t *testing.T) {
repos := newSegmentTestRepos(t)
got, err := repos.MediaProbe.Get(t.Context(), "nope")
if err != nil {
t.Fatalf("Get: %v", err)
}
if got != nil {
t.Fatalf("probe = %#v, want nil", got)
}
}
func TestMediaProbeMarkFailureKeepsPreviousPayload(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
if err := repos.MediaProbe.Upsert(ctx, &model.MediaProbe{
MediaID: "m-1", Container: "matroska,webm", DurationSec: 1451,
ChapterCount: 2, Payload: `{"container":"matroska,webm"}`, ProbedAt: time.Now(),
}); err != nil {
t.Fatal(err)
}
if err := repos.MediaProbe.MarkFailure(ctx, "m-1", "ffprobe full: exit status 1", time.Now()); err != nil {
t.Fatal(err)
}
got, err := repos.MediaProbe.Get(ctx, "m-1")
if err != nil {
t.Fatal(err)
}
if got == nil || got.LastError != "ffprobe full: exit status 1" {
t.Fatalf("probe = %#v, want the recorded failure", got)
}
// 一次失败的重探不该把上一次成功拿到的媒体信息抹掉。
if got.Container != "matroska,webm" || got.DurationSec != 1451 || got.Payload == "" || got.ChapterCount != 2 {
t.Fatalf("a failed re-probe wiped the previous summary: %#v", got)
}
}
func TestMediaProbeMarkFailureInsertsRowWhenAbsent(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
// 没有历史成功记录时,失败也必须落一行:否则冷却期没有时间戳,
// 每次播放都会重跑一次注定失败的探测。
if err := repos.MediaProbe.MarkFailure(ctx, "m-2", "boom", time.Now()); err != nil {
t.Fatal(err)
}
got, err := repos.MediaProbe.Get(ctx, "m-2")
if err != nil {
t.Fatal(err)
}
if got == nil || got.LastError != "boom" || got.ProbedAt.IsZero() {
t.Fatalf("probe = %#v, want a failure row with a timestamp", got)
}
}
@@ -23,6 +23,18 @@ func (r *MediaSegmentRepository) ListByMedia(ctx context.Context, mediaID string
return rows, err
}
// ListByMediaSource returns the segments contributed by one source only, ordered
// by start. Used to read the ffprobe-extracted chapters independently of the
// community-database rows, so the player can pick between them.
func (r *MediaSegmentRepository) ListByMediaSource(ctx context.Context, mediaID, source string) ([]model.MediaSegment, error) {
rows := make([]model.MediaSegment, 0, 4)
err := r.db.WithContext(ctx).
Where("media_id = ? AND source = ?", mediaID, source).
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.
//
+2
View File
@@ -16,6 +16,7 @@ type Container struct {
Series *SeriesRepository
History *HistoryRepository
MediaSegment *MediaSegmentRepository
MediaProbe *MediaProbeRepository
Favorite *FavoriteRepository
Playlist *PlaylistRepository
Setting *SettingRepository
@@ -47,6 +48,7 @@ func New(db *gorm.DB) *Container {
Series: &SeriesRepository{db: db},
History: &HistoryRepository{db: db},
MediaSegment: &MediaSegmentRepository{db: db},
MediaProbe: &MediaProbeRepository{db: db},
Favorite: &FavoriteRepository{db: db},
Playlist: &PlaylistRepository{db: db},
Setting: &SettingRepository{db: db},