mirror of
https://github.com/stashapp/stash.git
synced 2026-01-21 15:44:26 +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
420 lines
12 KiB
Go
420 lines
12 KiB
Go
package dlna
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
// mockSceneWriter is a mock implementation of SceneActivityWriter
|
|
type mockSceneWriter struct {
|
|
mu sync.Mutex
|
|
saveActivityCalls []saveActivityCall
|
|
addViewsCalls []addViewsCall
|
|
}
|
|
|
|
type saveActivityCall struct {
|
|
sceneID int
|
|
resumeTime *float64
|
|
playDuration *float64
|
|
}
|
|
|
|
type addViewsCall struct {
|
|
sceneID int
|
|
dates []time.Time
|
|
}
|
|
|
|
func (m *mockSceneWriter) SaveActivity(_ context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error) {
|
|
m.mu.Lock()
|
|
m.saveActivityCalls = append(m.saveActivityCalls, saveActivityCall{
|
|
sceneID: sceneID,
|
|
resumeTime: resumeTime,
|
|
playDuration: playDuration,
|
|
})
|
|
m.mu.Unlock()
|
|
return true, nil
|
|
}
|
|
|
|
func (m *mockSceneWriter) AddViews(_ context.Context, sceneID int, dates []time.Time) ([]time.Time, error) {
|
|
m.mu.Lock()
|
|
m.addViewsCalls = append(m.addViewsCalls, addViewsCall{
|
|
sceneID: sceneID,
|
|
dates: dates,
|
|
})
|
|
m.mu.Unlock()
|
|
return dates, nil
|
|
}
|
|
|
|
// mockConfig is a mock implementation of ActivityConfig
|
|
type mockConfig struct {
|
|
enabled bool
|
|
minPlayPercent int
|
|
}
|
|
|
|
func (c *mockConfig) GetDLNAActivityTrackingEnabled() bool {
|
|
return c.enabled
|
|
}
|
|
|
|
func (c *mockConfig) GetMinimumPlayPercent() int {
|
|
return c.minPlayPercent
|
|
}
|
|
|
|
func TestStreamSession_PercentWatched(t *testing.T) {
|
|
now := time.Now()
|
|
|
|
tests := []struct {
|
|
name string
|
|
startTime time.Time
|
|
lastActivity time.Time
|
|
videoDuration float64
|
|
expected float64
|
|
}{
|
|
{
|
|
name: "no video duration",
|
|
startTime: now.Add(-60 * time.Second),
|
|
lastActivity: now,
|
|
videoDuration: 0,
|
|
expected: 0,
|
|
},
|
|
{
|
|
name: "half watched",
|
|
startTime: now.Add(-60 * time.Second),
|
|
lastActivity: now,
|
|
videoDuration: 120.0, // 2 minutes, watched for 1 minute = 50%
|
|
expected: 50.0,
|
|
},
|
|
{
|
|
name: "fully watched",
|
|
startTime: now.Add(-120 * time.Second),
|
|
lastActivity: now,
|
|
videoDuration: 120.0, // 2 minutes, watched for 2 minutes = 100%
|
|
expected: 100.0,
|
|
},
|
|
{
|
|
name: "quarter watched",
|
|
startTime: now.Add(-30 * time.Second),
|
|
lastActivity: now,
|
|
videoDuration: 120.0, // 2 minutes, watched for 30 seconds = 25%
|
|
expected: 25.0,
|
|
},
|
|
{
|
|
name: "elapsed exceeds duration - capped at 100%",
|
|
startTime: now.Add(-180 * time.Second),
|
|
lastActivity: now,
|
|
videoDuration: 120.0, // 2 minutes, but 3 minutes elapsed = capped at 100%
|
|
expected: 100.0,
|
|
},
|
|
{
|
|
name: "no elapsed time",
|
|
startTime: now,
|
|
lastActivity: now,
|
|
videoDuration: 120.0,
|
|
expected: 0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
session := &streamSession{
|
|
StartTime: tt.startTime,
|
|
LastActivity: tt.lastActivity,
|
|
VideoDuration: tt.videoDuration,
|
|
}
|
|
result := session.percentWatched()
|
|
assert.InDelta(t, tt.expected, result, 0.01)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStreamSession_EstimatedResumeTime(t *testing.T) {
|
|
now := time.Now()
|
|
|
|
tests := []struct {
|
|
name string
|
|
startTime time.Time
|
|
lastActivity time.Time
|
|
videoDuration float64
|
|
expected float64
|
|
}{
|
|
{
|
|
name: "no elapsed time",
|
|
startTime: now,
|
|
lastActivity: now,
|
|
videoDuration: 120.0,
|
|
expected: 0,
|
|
},
|
|
{
|
|
name: "half way through",
|
|
startTime: now.Add(-60 * time.Second),
|
|
lastActivity: now,
|
|
videoDuration: 120.0, // 2 minutes, watched for 1 minute = resume at 60s
|
|
expected: 60.0,
|
|
},
|
|
{
|
|
name: "quarter way through",
|
|
startTime: now.Add(-30 * time.Second),
|
|
lastActivity: now,
|
|
videoDuration: 120.0, // 2 minutes, watched for 30 seconds = resume at 30s
|
|
expected: 30.0,
|
|
},
|
|
{
|
|
name: "98% complete - should reset to 0",
|
|
startTime: now.Add(-118 * time.Second),
|
|
lastActivity: now,
|
|
videoDuration: 120.0, // 98.3% elapsed, should reset
|
|
expected: 0,
|
|
},
|
|
{
|
|
name: "100% complete - should reset to 0",
|
|
startTime: now.Add(-120 * time.Second),
|
|
lastActivity: now,
|
|
videoDuration: 120.0,
|
|
expected: 0,
|
|
},
|
|
{
|
|
name: "elapsed exceeds duration - capped and reset to 0",
|
|
startTime: now.Add(-180 * time.Second),
|
|
lastActivity: now,
|
|
videoDuration: 120.0, // 150% elapsed, capped at 100%, reset to 0
|
|
expected: 0,
|
|
},
|
|
{
|
|
name: "no video duration",
|
|
startTime: now.Add(-60 * time.Second),
|
|
lastActivity: now,
|
|
videoDuration: 0,
|
|
expected: 0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
session := &streamSession{
|
|
StartTime: tt.startTime,
|
|
LastActivity: tt.lastActivity,
|
|
VideoDuration: tt.videoDuration,
|
|
}
|
|
result := session.estimatedResumeTime()
|
|
assert.InDelta(t, tt.expected, result, 1.0) // Allow 1 second tolerance
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSessionKey(t *testing.T) {
|
|
key := sessionKey("192.168.1.100", 42)
|
|
assert.Equal(t, "192.168.1.100:42", key)
|
|
}
|
|
|
|
func TestActivityTracker_RecordRequest(t *testing.T) {
|
|
config := &mockConfig{enabled: true, minPlayPercent: 50}
|
|
|
|
// Create tracker without starting the goroutine (for unit testing)
|
|
tracker := &ActivityTracker{
|
|
txnManager: nil, // Don't need DB for this test
|
|
sceneWriter: nil,
|
|
config: config,
|
|
sessionTimeout: DefaultSessionTimeout,
|
|
sessions: make(map[string]*streamSession),
|
|
}
|
|
|
|
// Record first request - should create new session
|
|
tracker.RecordRequest(42, "192.168.1.100", 120.0)
|
|
|
|
tracker.mutex.RLock()
|
|
session := tracker.sessions["192.168.1.100:42"]
|
|
tracker.mutex.RUnlock()
|
|
|
|
assert.NotNil(t, session)
|
|
assert.Equal(t, 42, session.SceneID)
|
|
assert.Equal(t, "192.168.1.100", session.ClientIP)
|
|
assert.Equal(t, 120.0, session.VideoDuration)
|
|
assert.False(t, session.StartTime.IsZero())
|
|
assert.False(t, session.LastActivity.IsZero())
|
|
|
|
// Record second request - should update LastActivity
|
|
firstActivity := session.LastActivity
|
|
time.Sleep(10 * time.Millisecond)
|
|
tracker.RecordRequest(42, "192.168.1.100", 120.0)
|
|
|
|
tracker.mutex.RLock()
|
|
session = tracker.sessions["192.168.1.100:42"]
|
|
tracker.mutex.RUnlock()
|
|
|
|
assert.True(t, session.LastActivity.After(firstActivity))
|
|
}
|
|
|
|
func TestActivityTracker_DisabledTracking(t *testing.T) {
|
|
config := &mockConfig{enabled: false, minPlayPercent: 50}
|
|
|
|
// Create tracker without starting the goroutine (for unit testing)
|
|
tracker := &ActivityTracker{
|
|
txnManager: nil,
|
|
sceneWriter: nil,
|
|
config: config,
|
|
sessionTimeout: DefaultSessionTimeout,
|
|
sessions: make(map[string]*streamSession),
|
|
}
|
|
|
|
// Record request - should be ignored when tracking is disabled
|
|
tracker.RecordRequest(42, "192.168.1.100", 120.0)
|
|
|
|
tracker.mutex.RLock()
|
|
sessionCount := len(tracker.sessions)
|
|
tracker.mutex.RUnlock()
|
|
|
|
assert.Equal(t, 0, sessionCount)
|
|
}
|
|
|
|
func TestActivityTracker_SessionExpiration(t *testing.T) {
|
|
// For this test, we'll test the session expiration logic directly
|
|
// without the full transaction manager integration
|
|
|
|
sceneWriter := &mockSceneWriter{}
|
|
config := &mockConfig{enabled: true, minPlayPercent: 10}
|
|
|
|
// Create a tracker with nil txnManager - we'll test processCompletedSession separately
|
|
// Here we just verify the session management logic
|
|
tracker := &ActivityTracker{
|
|
txnManager: nil, // Skip DB calls for this test
|
|
sceneWriter: sceneWriter,
|
|
config: config,
|
|
sessionTimeout: 100 * time.Millisecond,
|
|
sessions: make(map[string]*streamSession),
|
|
}
|
|
|
|
// Manually add a session
|
|
// Use a short video duration (1 second) so the test can verify expiration quickly.
|
|
now := time.Now()
|
|
tracker.sessions["192.168.1.100:42"] = &streamSession{
|
|
SceneID: 42,
|
|
ClientIP: "192.168.1.100",
|
|
StartTime: now.Add(-5 * time.Second), // Started 5 seconds ago
|
|
LastActivity: now.Add(-200 * time.Millisecond), // Last activity 200ms ago (> 100ms timeout)
|
|
VideoDuration: 1.0, // Short video so timeSinceStart > videoDuration
|
|
}
|
|
|
|
// Verify session exists
|
|
assert.Len(t, tracker.sessions, 1)
|
|
|
|
// Process expired sessions - this will try to save activity but txnManager is nil
|
|
// so it will skip the DB calls but still remove the session
|
|
tracker.processExpiredSessions()
|
|
|
|
// Verify session was removed (even though DB calls were skipped)
|
|
assert.Len(t, tracker.sessions, 0)
|
|
}
|
|
|
|
func TestActivityTracker_SessionExpiration_StoppedEarly(t *testing.T) {
|
|
// Test that sessions expire when user stops watching early (before video ends)
|
|
// This was a bug where sessions wouldn't expire until video duration passed
|
|
|
|
config := &mockConfig{enabled: true, minPlayPercent: 10}
|
|
tracker := &ActivityTracker{
|
|
txnManager: nil,
|
|
sceneWriter: nil,
|
|
config: config,
|
|
sessionTimeout: 100 * time.Millisecond,
|
|
sessions: make(map[string]*streamSession),
|
|
}
|
|
|
|
// User started watching a 30-minute video but stopped after 5 seconds
|
|
now := time.Now()
|
|
tracker.sessions["192.168.1.100:42"] = &streamSession{
|
|
SceneID: 42,
|
|
ClientIP: "192.168.1.100",
|
|
StartTime: now.Add(-5 * time.Second), // Started 5 seconds ago
|
|
LastActivity: now.Add(-200 * time.Millisecond), // Last activity 200ms ago (> 100ms timeout)
|
|
VideoDuration: 1800.0, // 30 minute video - much longer than elapsed time
|
|
}
|
|
|
|
assert.Len(t, tracker.sessions, 1)
|
|
|
|
// Session should expire because timeSinceActivity > timeout
|
|
// Even though the video is 30 minutes and only 5 seconds have passed
|
|
tracker.processExpiredSessions()
|
|
|
|
// Verify session was expired
|
|
assert.Len(t, tracker.sessions, 0, "Session should expire when user stops early, not wait for video duration")
|
|
}
|
|
|
|
func TestActivityTracker_MinimumPlayPercentThreshold(t *testing.T) {
|
|
// Test the threshold logic without full transaction integration
|
|
config := &mockConfig{enabled: true, minPlayPercent: 75} // High threshold
|
|
|
|
tracker := &ActivityTracker{
|
|
txnManager: nil,
|
|
sceneWriter: nil,
|
|
config: config,
|
|
sessionTimeout: 50 * time.Millisecond,
|
|
sessions: make(map[string]*streamSession),
|
|
}
|
|
|
|
// Test that getMinimumPlayPercent returns the configured value
|
|
assert.Equal(t, 75, tracker.getMinimumPlayPercent())
|
|
|
|
// Create a session with 30% watched (36 seconds of a 120 second video)
|
|
now := time.Now()
|
|
session := &streamSession{
|
|
SceneID: 42,
|
|
StartTime: now.Add(-36 * time.Second),
|
|
LastActivity: now,
|
|
VideoDuration: 120.0,
|
|
}
|
|
|
|
// 30% is below 75% threshold
|
|
percentWatched := session.percentWatched()
|
|
assert.InDelta(t, 30.0, percentWatched, 0.1)
|
|
assert.False(t, percentWatched >= float64(tracker.getMinimumPlayPercent()))
|
|
}
|
|
|
|
func TestActivityTracker_MultipleSessions(t *testing.T) {
|
|
config := &mockConfig{enabled: true, minPlayPercent: 50}
|
|
|
|
// Create tracker without starting the goroutine (for unit testing)
|
|
tracker := &ActivityTracker{
|
|
txnManager: nil,
|
|
sceneWriter: nil,
|
|
config: config,
|
|
sessionTimeout: DefaultSessionTimeout,
|
|
sessions: make(map[string]*streamSession),
|
|
}
|
|
|
|
// Different clients watching same scene
|
|
tracker.RecordRequest(42, "192.168.1.100", 120.0)
|
|
tracker.RecordRequest(42, "192.168.1.101", 120.0)
|
|
|
|
// Same client watching different scenes
|
|
tracker.RecordRequest(43, "192.168.1.100", 180.0)
|
|
|
|
tracker.mutex.RLock()
|
|
assert.Len(t, tracker.sessions, 3)
|
|
tracker.mutex.RUnlock()
|
|
}
|
|
|
|
func TestActivityTracker_ShortSessionIgnored(t *testing.T) {
|
|
// Test that short sessions are ignored
|
|
// Create a session with only ~0.8% watched (1 second of a 120 second video)
|
|
now := time.Now()
|
|
session := &streamSession{
|
|
SceneID: 42,
|
|
ClientIP: "192.168.1.100",
|
|
StartTime: now.Add(-1 * time.Second), // Only 1 second
|
|
LastActivity: now,
|
|
VideoDuration: 120.0, // 2 minutes
|
|
}
|
|
|
|
// Verify percent watched is below threshold (1s / 120s = 0.83%)
|
|
assert.InDelta(t, 0.83, session.percentWatched(), 0.1)
|
|
|
|
// Verify elapsed time is short
|
|
elapsed := session.LastActivity.Sub(session.StartTime).Seconds()
|
|
assert.InDelta(t, 1.0, elapsed, 0.5)
|
|
|
|
// Both are below the minimum thresholds (1% and 5 seconds)
|
|
percentWatched := session.percentWatched()
|
|
shouldSkip := percentWatched < 1 && elapsed < 5
|
|
assert.True(t, shouldSkip, "Short session should be skipped")
|
|
}
|