mirror of
https://github.com/stashapp/stash.git
synced 2026-01-21 07:34:58 +01:00
* fix(dlna): improve activity tracking accuracy and efficiency - Remove play duration tracking: DLNA clients buffer aggressively and don't report playback position, making duration estimates unreliable. Saving inaccurate values corrupts analytics. - Combine database transactions: Resume time and view count updates now happen in a single transaction for atomicity and performance. - Keep resume time tracking: While imprecise, it provides useful "continue watching" hints. The cost of being wrong is low (user just seeks). * remove elasped time check
333 lines
10 KiB
Go
333 lines
10 KiB
Go
package dlna
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/stashapp/stash/pkg/logger"
|
|
"github.com/stashapp/stash/pkg/txn"
|
|
)
|
|
|
|
const (
|
|
// DefaultSessionTimeout is the time after which a session is considered complete
|
|
// if no new requests are received.
|
|
// This is set high (5 minutes) because DLNA clients buffer aggressively and may not
|
|
// send any HTTP requests for extended periods while the user is still watching.
|
|
DefaultSessionTimeout = 5 * time.Minute
|
|
|
|
// monitorInterval is how often we check for expired sessions.
|
|
monitorInterval = 10 * time.Second
|
|
)
|
|
|
|
// ActivityConfig provides configuration options for DLNA activity tracking.
|
|
type ActivityConfig interface {
|
|
// GetDLNAActivityTrackingEnabled returns true if activity tracking should be enabled.
|
|
// If not implemented, defaults to true.
|
|
GetDLNAActivityTrackingEnabled() bool
|
|
|
|
// GetMinimumPlayPercent returns the minimum percentage of a video that must be
|
|
// watched before incrementing the play count. Uses UI setting if available.
|
|
GetMinimumPlayPercent() int
|
|
}
|
|
|
|
// SceneActivityWriter provides methods for saving scene activity.
|
|
type SceneActivityWriter interface {
|
|
SaveActivity(ctx context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error)
|
|
AddViews(ctx context.Context, sceneID int, dates []time.Time) ([]time.Time, error)
|
|
}
|
|
|
|
// streamSession represents an active DLNA streaming session.
|
|
type streamSession struct {
|
|
SceneID int
|
|
ClientIP string
|
|
StartTime time.Time
|
|
LastActivity time.Time
|
|
VideoDuration float64
|
|
PlayCountAdded bool
|
|
}
|
|
|
|
// sessionKey generates a unique key for a session based on client IP and scene ID.
|
|
func sessionKey(clientIP string, sceneID int) string {
|
|
return fmt.Sprintf("%s:%d", clientIP, sceneID)
|
|
}
|
|
|
|
// percentWatched calculates the estimated percentage of video watched.
|
|
// Uses a time-based approach since DLNA clients buffer aggressively and byte
|
|
// positions don't correlate with actual playback position.
|
|
//
|
|
// The key insight: you cannot have watched more of the video than time has elapsed.
|
|
// If the video is 30 minutes and only 1 minute has passed, maximum watched is ~3.3%.
|
|
func (s *streamSession) percentWatched() float64 {
|
|
if s.VideoDuration <= 0 {
|
|
return 0
|
|
}
|
|
|
|
// Calculate elapsed time from session start to last activity
|
|
elapsed := s.LastActivity.Sub(s.StartTime).Seconds()
|
|
if elapsed <= 0 {
|
|
return 0
|
|
}
|
|
|
|
// Maximum possible percent is based on elapsed time
|
|
// You can't watch more of the video than time has passed
|
|
timeBasedPercent := (elapsed / s.VideoDuration) * 100
|
|
|
|
// Cap at 100%
|
|
if timeBasedPercent > 100 {
|
|
return 100
|
|
}
|
|
|
|
return timeBasedPercent
|
|
}
|
|
|
|
// estimatedResumeTime calculates the estimated resume time based on elapsed time.
|
|
// Since DLNA clients buffer aggressively, byte positions don't correlate with playback.
|
|
// Instead, we estimate based on how long the session has been active.
|
|
// Returns the time in seconds, or 0 if the video is nearly complete (>=98%).
|
|
func (s *streamSession) estimatedResumeTime() float64 {
|
|
if s.VideoDuration <= 0 {
|
|
return 0
|
|
}
|
|
|
|
// Calculate elapsed time from session start
|
|
elapsed := s.LastActivity.Sub(s.StartTime).Seconds()
|
|
if elapsed <= 0 {
|
|
return 0
|
|
}
|
|
|
|
// If elapsed time exceeds 98% of video duration, reset resume time (matches frontend behavior)
|
|
if elapsed >= s.VideoDuration*0.98 {
|
|
return 0
|
|
}
|
|
|
|
// Resume time is approximately where the user was watching
|
|
// Capped by video duration
|
|
if elapsed > s.VideoDuration {
|
|
elapsed = s.VideoDuration
|
|
}
|
|
|
|
return elapsed
|
|
}
|
|
|
|
// ActivityTracker tracks DLNA streaming activity and saves it to the database.
|
|
type ActivityTracker struct {
|
|
txnManager txn.Manager
|
|
sceneWriter SceneActivityWriter
|
|
config ActivityConfig
|
|
sessionTimeout time.Duration
|
|
|
|
sessions map[string]*streamSession
|
|
mutex sync.RWMutex
|
|
|
|
ctx context.Context
|
|
cancelFunc context.CancelFunc
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// NewActivityTracker creates a new ActivityTracker.
|
|
func NewActivityTracker(
|
|
txnManager txn.Manager,
|
|
sceneWriter SceneActivityWriter,
|
|
config ActivityConfig,
|
|
) *ActivityTracker {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
tracker := &ActivityTracker{
|
|
txnManager: txnManager,
|
|
sceneWriter: sceneWriter,
|
|
config: config,
|
|
sessionTimeout: DefaultSessionTimeout,
|
|
sessions: make(map[string]*streamSession),
|
|
ctx: ctx,
|
|
cancelFunc: cancel,
|
|
}
|
|
|
|
// Start the session monitor goroutine
|
|
tracker.wg.Add(1)
|
|
go tracker.monitorSessions()
|
|
|
|
return tracker
|
|
}
|
|
|
|
// Stop stops the activity tracker and processes any remaining sessions.
|
|
func (t *ActivityTracker) Stop() {
|
|
t.cancelFunc()
|
|
t.wg.Wait()
|
|
|
|
// Process any remaining sessions
|
|
t.mutex.Lock()
|
|
sessions := make([]*streamSession, 0, len(t.sessions))
|
|
for _, session := range t.sessions {
|
|
sessions = append(sessions, session)
|
|
}
|
|
t.sessions = make(map[string]*streamSession)
|
|
t.mutex.Unlock()
|
|
|
|
for _, session := range sessions {
|
|
t.processCompletedSession(session)
|
|
}
|
|
}
|
|
|
|
// RecordRequest records a streaming request for activity tracking.
|
|
// Each request updates the session's LastActivity time, which is used for
|
|
// time-based tracking of watch progress.
|
|
func (t *ActivityTracker) RecordRequest(sceneID int, clientIP string, videoDuration float64) {
|
|
if !t.isEnabled() {
|
|
return
|
|
}
|
|
|
|
key := sessionKey(clientIP, sceneID)
|
|
now := time.Now()
|
|
|
|
t.mutex.Lock()
|
|
defer t.mutex.Unlock()
|
|
|
|
session, exists := t.sessions[key]
|
|
if !exists {
|
|
session = &streamSession{
|
|
SceneID: sceneID,
|
|
ClientIP: clientIP,
|
|
StartTime: now,
|
|
VideoDuration: videoDuration,
|
|
}
|
|
t.sessions[key] = session
|
|
logger.Debugf("[DLNA Activity] New session started: scene=%d, client=%s", sceneID, clientIP)
|
|
}
|
|
|
|
session.LastActivity = now
|
|
}
|
|
|
|
// monitorSessions periodically checks for expired sessions and processes them.
|
|
func (t *ActivityTracker) monitorSessions() {
|
|
defer t.wg.Done()
|
|
|
|
ticker := time.NewTicker(monitorInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-t.ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
t.processExpiredSessions()
|
|
}
|
|
}
|
|
}
|
|
|
|
// processExpiredSessions finds and processes sessions that have timed out.
|
|
func (t *ActivityTracker) processExpiredSessions() {
|
|
now := time.Now()
|
|
var expiredSessions []*streamSession
|
|
|
|
t.mutex.Lock()
|
|
for key, session := range t.sessions {
|
|
timeSinceStart := now.Sub(session.StartTime)
|
|
timeSinceActivity := now.Sub(session.LastActivity)
|
|
|
|
// Must have no HTTP activity for the full timeout period
|
|
if timeSinceActivity <= t.sessionTimeout {
|
|
continue
|
|
}
|
|
|
|
// DLNA clients buffer aggressively - they fetch most/all of the video quickly,
|
|
// then play from cache with NO further HTTP requests.
|
|
//
|
|
// Two scenarios:
|
|
// 1. User watched the whole video: timeSinceStart >= videoDuration
|
|
// -> Set LastActivity to when timeout began (they finished watching)
|
|
// 2. User stopped early: timeSinceStart < videoDuration
|
|
// -> Keep LastActivity as-is (best estimate of when they stopped)
|
|
|
|
videoDuration := time.Duration(session.VideoDuration) * time.Second
|
|
if timeSinceStart >= videoDuration && videoDuration > 0 {
|
|
// User likely watched the whole video, then it timed out
|
|
// Estimate they watched until the timeout period started
|
|
session.LastActivity = now.Add(-t.sessionTimeout)
|
|
}
|
|
// else: User stopped early - LastActivity is already our best estimate
|
|
|
|
expiredSessions = append(expiredSessions, session)
|
|
delete(t.sessions, key)
|
|
}
|
|
t.mutex.Unlock()
|
|
|
|
for _, session := range expiredSessions {
|
|
t.processCompletedSession(session)
|
|
}
|
|
}
|
|
|
|
// processCompletedSession saves activity data for a completed streaming session.
|
|
func (t *ActivityTracker) processCompletedSession(session *streamSession) {
|
|
percentWatched := session.percentWatched()
|
|
resumeTime := session.estimatedResumeTime()
|
|
|
|
logger.Debugf("[DLNA Activity] Session completed: scene=%d, client=%s, videoDuration=%.1fs, percent=%.1f%%, resume=%.1fs",
|
|
session.SceneID, session.ClientIP, session.VideoDuration, percentWatched, resumeTime)
|
|
|
|
// Only save if there was meaningful activity (at least 1% watched)
|
|
if percentWatched < 1 {
|
|
logger.Debugf("[DLNA Activity] Session too short, skipping save")
|
|
return
|
|
}
|
|
|
|
// Skip DB operations if txnManager is nil (for testing)
|
|
if t.txnManager == nil {
|
|
logger.Debugf("[DLNA Activity] No transaction manager, skipping DB save")
|
|
return
|
|
}
|
|
|
|
// Determine what needs to be saved
|
|
shouldSaveResume := resumeTime > 0
|
|
shouldAddView := !session.PlayCountAdded && percentWatched >= float64(t.getMinimumPlayPercent())
|
|
|
|
// Nothing to save
|
|
if !shouldSaveResume && !shouldAddView {
|
|
return
|
|
}
|
|
|
|
// Save everything in a single transaction
|
|
ctx := context.Background()
|
|
if err := txn.WithTxn(ctx, t.txnManager, func(ctx context.Context) error {
|
|
// Save resume time only. DLNA clients buffer aggressively and don't report
|
|
// playback position, so we can't accurately track play duration - saving
|
|
// guesses would corrupt analytics. Resume time is still useful as a
|
|
// "continue watching" hint even if imprecise.
|
|
if shouldSaveResume {
|
|
if _, err := t.sceneWriter.SaveActivity(ctx, session.SceneID, &resumeTime, nil); err != nil {
|
|
return fmt.Errorf("save resume time: %w", err)
|
|
}
|
|
}
|
|
|
|
// Increment play count (also updates last_played_at via view date)
|
|
if shouldAddView {
|
|
if _, err := t.sceneWriter.AddViews(ctx, session.SceneID, []time.Time{time.Now()}); err != nil {
|
|
return fmt.Errorf("add view: %w", err)
|
|
}
|
|
session.PlayCountAdded = true
|
|
logger.Debugf("[DLNA Activity] Incremented play count for scene %d (%.1f%% watched)",
|
|
session.SceneID, percentWatched)
|
|
}
|
|
|
|
return nil
|
|
}); err != nil {
|
|
logger.Warnf("[DLNA Activity] Failed to save activity for scene %d: %v", session.SceneID, err)
|
|
}
|
|
}
|
|
|
|
// isEnabled returns true if activity tracking is enabled.
|
|
func (t *ActivityTracker) isEnabled() bool {
|
|
if t.config == nil {
|
|
return true // Default to enabled
|
|
}
|
|
return t.config.GetDLNAActivityTrackingEnabled()
|
|
}
|
|
|
|
// getMinimumPlayPercent returns the minimum play percentage for incrementing play count.
|
|
func (t *ActivityTracker) getMinimumPlayPercent() int {
|
|
if t.config == nil {
|
|
return 0 // Default: any play increments count (matches frontend default)
|
|
}
|
|
return t.config.GetMinimumPlayPercent()
|
|
}
|