mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 19:46:38 +08:00
优化视频头尾跳过功能
This commit is contained in:
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user