mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 13:26:38 +08:00
Add Emby-compatible request logging and ordered playback progress
This commit is contained in:
@@ -37,7 +37,7 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
|
||||
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["PlaySessionId"] = fmt.Sprintf("remote-%s-%d", mountID, time.Now().Unix())
|
||||
out["PlaySessionId"] = fmt.Sprintf("remote-%s-%d", mountID, time.Now().UnixMilli())
|
||||
return out, nil
|
||||
}
|
||||
m, err := e.playableMedia(ctx, mediaID, userID)
|
||||
@@ -46,7 +46,7 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
|
||||
}
|
||||
return map[string]any{
|
||||
"MediaSources": e.mediaSourcesForItem(ctx, m, false, e.directPlayOnly(ctx)),
|
||||
"PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().Unix()),
|
||||
"PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().UnixMilli()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,15 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
|
||||
}
|
||||
|
||||
// RecordProgress 记录播放进度(来自 Emby 客户端的 /Sessions/Playing/Progress)。
|
||||
// 不携带 PlaySessionId 的旧调用仍保持兼容。
|
||||
func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64) error {
|
||||
return e.RecordProgressWithSession(ctx, userID, mediaID, positionTicks, runtimeTicks, "")
|
||||
}
|
||||
|
||||
// RecordProgressWithSession records an Emby progress update together with its
|
||||
// PlaySessionId. The server-issued ID contains a millisecond timestamp, which
|
||||
// lets the repository reject reports from an older playback session.
|
||||
func (e *EmbyService) RecordProgressWithSession(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64, playSessionID string) error {
|
||||
pos := positionTicks / 10_000
|
||||
dur := runtimeTicks / 10_000
|
||||
if dur <= 0 {
|
||||
@@ -89,14 +97,16 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string
|
||||
}
|
||||
}
|
||||
}
|
||||
completed := dur > 0 && pos >= dur*9/10
|
||||
err := e.repo.History.Upsert(ctx, &model.PlaybackHistory{
|
||||
UserID: userID,
|
||||
MediaID: mediaID,
|
||||
PositionMs: pos,
|
||||
DurationMs: dur,
|
||||
WatchedAt: time.Now(),
|
||||
Completed: completed,
|
||||
playSessionID = strings.TrimSpace(playSessionID)
|
||||
err := e.repo.History.UpsertProgress(ctx, &model.PlaybackHistory{
|
||||
UserID: userID,
|
||||
MediaID: mediaID,
|
||||
PositionMs: pos,
|
||||
DurationMs: dur,
|
||||
WatchedAt: time.Now(),
|
||||
Completed: playbackProgressCompleted(pos, dur),
|
||||
SessionID: playSessionID,
|
||||
SessionStartedAtMs: embyPlaySessionStartedAtMs(playSessionID),
|
||||
})
|
||||
if err == nil {
|
||||
e.invalidateEmbyItemsCache(ctx)
|
||||
@@ -104,6 +114,30 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string
|
||||
return err
|
||||
}
|
||||
|
||||
// embyPlaySessionStartedAtMs extracts the millisecond timestamp embedded in a
|
||||
// MeBox-issued PlaySessionId. Older IDs used seconds, so normalize those too.
|
||||
func embyPlaySessionStartedAtMs(playSessionID string) int64 {
|
||||
playSessionID = strings.TrimSpace(playSessionID)
|
||||
if playSessionID == "" {
|
||||
return 0
|
||||
}
|
||||
idx := strings.LastIndex(playSessionID, "-")
|
||||
if idx < 0 || idx == len(playSessionID)-1 {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.ParseInt(playSessionID[idx+1:], 10, 64)
|
||||
if err != nil || value <= 0 {
|
||||
return 0
|
||||
}
|
||||
if value >= 1_000_000_000 && value < 1_000_000_000_000 {
|
||||
value *= 1000
|
||||
}
|
||||
if value < 1_000_000_000_000 {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// mergeRemoteUserData applies the current MeBox user's locally recorded playback
|
||||
// and favourite state to remote Emby payloads.
|
||||
func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, payload any) error {
|
||||
|
||||
@@ -41,24 +41,62 @@ func (p *PlaybackService) SetEmbyRemote(remote *EmbyRemoteService) *PlaybackServ
|
||||
|
||||
// ─── History ────────────────────────────────────────────────────────────────
|
||||
|
||||
// RecordProgress upserts the resume position for a (user, media) pair. A
|
||||
// position within 30 seconds of the duration auto-flags the item as
|
||||
// completed so the home page can hide it from "Continue Watching".
|
||||
// ProgressUpdate is a playback-progress report. SessionID, SessionStartedAtMs
|
||||
// and Sequence are optional for compatibility with legacy callers.
|
||||
type ProgressUpdate struct {
|
||||
UserID string
|
||||
MediaID string
|
||||
PositionMs int64
|
||||
DurationMs int64
|
||||
SessionID string
|
||||
SessionStartedAtMs int64
|
||||
Sequence int64
|
||||
}
|
||||
|
||||
// RecordProgress records an unversioned progress report. It is retained for
|
||||
// older callers that do not provide playback-session metadata.
|
||||
func (p *PlaybackService) RecordProgress(ctx context.Context, userID, mediaID string, position, duration int64) error {
|
||||
if userID == "" || mediaID == "" {
|
||||
return errors.New("missing user or media")
|
||||
}
|
||||
dur := p.resolvePlaybackDuration(ctx, userID, mediaID, duration)
|
||||
completed := dur > 0 && position >= dur-30_000
|
||||
h := &model.PlaybackHistory{
|
||||
return p.RecordProgressUpdate(ctx, ProgressUpdate{
|
||||
UserID: userID,
|
||||
MediaID: mediaID,
|
||||
PositionMs: position,
|
||||
DurationMs: dur,
|
||||
WatchedAt: time.Now(),
|
||||
Completed: completed,
|
||||
DurationMs: duration,
|
||||
})
|
||||
}
|
||||
|
||||
// RecordProgressUpdate upserts the resume position for a (user, media) pair.
|
||||
// When session metadata is present, stale reports are ignored so a delayed
|
||||
// request cannot overwrite a newer position or completion state.
|
||||
func (p *PlaybackService) RecordProgressUpdate(ctx context.Context, update ProgressUpdate) error {
|
||||
if update.UserID == "" || update.MediaID == "" {
|
||||
return errors.New("missing user or media")
|
||||
}
|
||||
return p.repo.History.Upsert(ctx, h)
|
||||
dur := p.resolvePlaybackDuration(ctx, update.UserID, update.MediaID, update.DurationMs)
|
||||
h := &model.PlaybackHistory{
|
||||
UserID: update.UserID,
|
||||
MediaID: update.MediaID,
|
||||
PositionMs: update.PositionMs,
|
||||
DurationMs: dur,
|
||||
WatchedAt: time.Now(),
|
||||
Completed: playbackProgressCompleted(update.PositionMs, dur),
|
||||
SessionID: update.SessionID,
|
||||
SessionStartedAtMs: update.SessionStartedAtMs,
|
||||
Sequence: update.Sequence,
|
||||
}
|
||||
return p.repo.History.UpsertProgress(ctx, h)
|
||||
}
|
||||
|
||||
// playbackProgressCompleted deliberately requires a positive position. For
|
||||
// very short clips the 90% threshold avoids marking a zero-second sample as
|
||||
// finished; otherwise the last 30 seconds are treated as completed.
|
||||
func playbackProgressCompleted(position, duration int64) bool {
|
||||
if position <= 0 || duration <= 0 {
|
||||
return false
|
||||
}
|
||||
if duration <= 30_000 {
|
||||
return position*10 >= duration*9
|
||||
}
|
||||
return position >= duration-30_000
|
||||
}
|
||||
|
||||
// GetProgress returns the saved resume row for one media item, or nil when absent.
|
||||
|
||||
@@ -91,3 +91,25 @@ func TestListFavouritesIncludesLocalAndRemoteIDs(t *testing.T) {
|
||||
t.Fatalf("expected local favourite first by created_at desc, got %#v", items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackProgressCompletedThreshold(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
position int64
|
||||
duration int64
|
||||
want bool
|
||||
}{
|
||||
{name: "zero duration", position: 10_000, duration: 0, want: false},
|
||||
{name: "short clip early", position: 10_000, duration: 20_000, want: false},
|
||||
{name: "short clip near end", position: 18_000, duration: 20_000, want: true},
|
||||
{name: "movie before final window", position: 80_000, duration: 120_000, want: false},
|
||||
{name: "movie in final window", position: 95_000, duration: 120_000, want: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := playbackProgressCompleted(tt.position, tt.duration); got != tt.want {
|
||||
t.Fatalf("playbackProgressCompleted(%d, %d) = %t, want %t", tt.position, tt.duration, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user