stash/internal/dlna/service.go
CJ 66ceceeaf1
feat(dlna): add activity tracking for DLNA playback (#6407)
Adds time-based activity tracking for scenes played via DLNA, enabling
play count, play duration, and resume time tracking similar to the
web frontend.

Key features:
- Uses existing 'trackActivity' UI setting (no new config needed)
- Time-based tracking (elapsed session time / video duration)
- 5-minute session timeout to handle aggressive client buffering
- Minimum thresholds before saving (1% watched or 5 seconds)
- Respects minimumPlayPercent setting for play count increment

Implementation:
- New ActivityTracker in internal/dlna/activity.go
- Session management with automatic expiration
- Integration via DLNA service initialization

Limitations:
- Cannot detect actual playback position (only elapsed time)
- Cannot detect seeking or pause state
- Designed for upstream compatibility (no complex dependencies)
2026-01-05 16:10:52 +11:00

384 lines
8.8 KiB
Go

package dlna
import (
"context"
"fmt"
"net"
"net/http"
"path/filepath"
"sync"
"time"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/txn"
)
type Repository struct {
TxnManager models.TxnManager
SceneFinder SceneFinder
FileGetter models.FileGetter
StudioFinder StudioFinder
TagFinder TagFinder
PerformerFinder PerformerFinder
GroupFinder GroupFinder
}
func NewRepository(repo models.Repository) Repository {
return Repository{
TxnManager: repo.TxnManager,
FileGetter: repo.File,
SceneFinder: repo.Scene,
StudioFinder: repo.Studio,
TagFinder: repo.Tag,
PerformerFinder: repo.Performer,
GroupFinder: repo.Group,
}
}
func (r *Repository) WithReadTxn(ctx context.Context, fn txn.TxnFunc) error {
return txn.WithReadTxn(ctx, r.TxnManager, fn)
}
type Status struct {
Running bool `json:"running"`
// If not currently running, time until it will be started. If running, time until it will be stopped
Until *time.Time `json:"until"`
RecentIPAddresses []string `json:"recentIPAddresses"`
AllowedIPAddresses []*Dlnaip `json:"allowedIPAddresses"`
}
type Dlnaip struct {
IPAddress string `json:"ipAddress"`
// Time until IP will be no longer allowed/disallowed
Until *time.Time `json:"until"`
}
type dmsConfig struct {
Path string
IfNames []string
Http string
FriendlyName string
LogHeaders bool
StallEventSubscribe bool
NotifyInterval time.Duration
VideoSortOrder string
}
type sceneServer interface {
StreamSceneDirect(scene *models.Scene, w http.ResponseWriter, r *http.Request)
ServeScreenshot(scene *models.Scene, w http.ResponseWriter, r *http.Request)
}
type Config interface {
GetDLNAInterfaces() []string
GetDLNAServerName() string
GetDLNADefaultIPWhitelist() []string
GetVideoSortOrder() string
GetDLNAPortAsString() string
GetDLNAActivityTrackingEnabled() bool
}
// activityConfig wraps Config to implement ActivityConfig.
type activityConfig struct {
config Config
minPlayPercent int // cached from UI config
}
func (c *activityConfig) GetDLNAActivityTrackingEnabled() bool {
return c.config.GetDLNAActivityTrackingEnabled()
}
func (c *activityConfig) GetMinimumPlayPercent() int {
return c.minPlayPercent
}
type Service struct {
repository Repository
config Config
sceneServer sceneServer
ipWhitelistMgr *ipWhitelistManager
activityTracker *ActivityTracker
server *Server
running bool
mutex sync.Mutex
startTimer *time.Timer
startTime *time.Time
stopTimer *time.Timer
stopTime *time.Time
}
func (s *Service) getInterfaces() ([]net.Interface, error) {
var ifs []net.Interface
var err error
ifNames := s.config.GetDLNAInterfaces()
if len(ifNames) == 0 {
ifs, err = net.Interfaces()
} else {
for _, n := range ifNames {
if_, err := net.InterfaceByName(n)
if err != nil {
return nil, fmt.Errorf("error getting interface for name %s: %s", n, err.Error())
}
if if_ != nil {
ifs = append(ifs, *if_)
}
}
}
if err != nil {
return nil, err
}
var tmp []net.Interface
for _, if_ := range ifs {
if if_.Flags&net.FlagUp == 0 || if_.MTU <= 0 {
continue
}
tmp = append(tmp, if_)
}
ifs = tmp
return ifs, nil
}
func (s *Service) init() error {
friendlyName := s.config.GetDLNAServerName()
if friendlyName == "" {
friendlyName = "stash"
}
var dmsConfig = &dmsConfig{
Path: "",
IfNames: s.config.GetDLNADefaultIPWhitelist(),
Http: s.config.GetDLNAPortAsString(),
FriendlyName: friendlyName,
LogHeaders: false,
NotifyInterval: 30 * time.Second,
VideoSortOrder: s.config.GetVideoSortOrder(),
}
interfaces, err := s.getInterfaces()
if err != nil {
return err
}
s.server = &Server{
repository: s.repository,
sceneServer: s.sceneServer,
ipWhitelistManager: s.ipWhitelistMgr,
activityTracker: s.activityTracker,
Interfaces: interfaces,
HTTPConn: func() net.Listener {
conn, err := net.Listen("tcp", dmsConfig.Http)
if err != nil {
logger.Error(err.Error())
}
return conn
}(),
FriendlyName: dmsConfig.FriendlyName,
RootObjectPath: filepath.Clean(dmsConfig.Path),
LogHeaders: dmsConfig.LogHeaders,
// Icons: []Icon{
// {
// Width: 48,
// Height: 48,
// Depth: 8,
// Mimetype: "image/png",
// //ReadSeeker: readIcon(config.Config.Interfaces.DLNA.ServiceImage, 48),
// },
// {
// Width: 128,
// Height: 128,
// Depth: 8,
// Mimetype: "image/png",
// //ReadSeeker: readIcon(config.Config.Interfaces.DLNA.ServiceImage, 128),
// },
// },
StallEventSubscribe: dmsConfig.StallEventSubscribe,
NotifyInterval: dmsConfig.NotifyInterval,
VideoSortOrder: dmsConfig.VideoSortOrder,
}
return nil
}
// func getIconReader(fn string) (io.Reader, error) {
// b, err := assets.ReadFile("dlna/" + fn + ".png")
// return bytes.NewReader(b), err
// }
// func readIcon(path string, size uint) *bytes.Reader {
// r, err := getIconReader(path)
// if err != nil {
// panic(err)
// }
// imageData, _, err := image.Decode(r)
// if err != nil {
// panic(err)
// }
// return resizeImage(imageData, size)
// }
// func resizeImage(imageData image.Image, size uint) *bytes.Reader {
// img := resize.Resize(size, size, imageData, resize.Lanczos3)
// var buff bytes.Buffer
// png.Encode(&buff, img)
// return bytes.NewReader(buff.Bytes())
// }
// NewService initialises and returns a new DLNA service.
// The sceneWriter parameter should implement SceneActivityWriter (typically models.SceneReaderWriter).
// The minPlayPercent parameter is the minimum percentage of video that must be played to increment play count.
func NewService(repo Repository, cfg Config, sceneServer sceneServer, sceneWriter SceneActivityWriter, minPlayPercent int) *Service {
activityCfg := &activityConfig{
config: cfg,
minPlayPercent: minPlayPercent,
}
ret := &Service{
repository: repo,
sceneServer: sceneServer,
config: cfg,
ipWhitelistMgr: &ipWhitelistManager{
config: cfg,
},
activityTracker: NewActivityTracker(repo.TxnManager, sceneWriter, activityCfg),
mutex: sync.Mutex{},
}
return ret
}
// Start starts the DLNA service. If duration is provided, then the service
// is stopped after the duration has elapsed.
func (s *Service) Start(duration *time.Duration) error {
s.mutex.Lock()
defer s.mutex.Unlock()
if !s.running {
if err := s.init(); err != nil {
logger.Error(err)
return err
}
go func() {
logger.Info("Starting DLNA " + s.server.HTTPConn.Addr().String())
if err := s.server.Serve(); err != nil {
logger.Error(err)
}
}()
s.running = true
if s.startTimer != nil {
s.startTimer.Stop()
s.startTimer = nil
s.startTime = nil
}
}
if duration != nil {
// clear the existing stop timer
if s.stopTimer != nil {
s.stopTimer.Stop()
s.stopTime = nil
}
if s.stopTimer == nil {
s.stopTimer = time.AfterFunc(*duration, func() {
s.Stop(nil)
})
t := time.Now().Add(*duration)
s.stopTime = &t
}
}
return nil
}
// Stop stops the DLNA service. If duration is provided, then the service
// is started after the duration has elapsed.
func (s *Service) Stop(duration *time.Duration) {
s.mutex.Lock()
defer s.mutex.Unlock()
if s.running {
logger.Info("Stopping DLNA")
// Stop activity tracker first to process any pending sessions
if s.activityTracker != nil {
s.activityTracker.Stop()
}
err := s.server.Close()
if err != nil {
logger.Error(err)
}
s.running = false
if s.stopTimer != nil {
s.stopTimer.Stop()
s.stopTimer = nil
s.stopTime = nil
}
}
if duration != nil {
// clear the existing stop timer
if s.startTimer != nil {
s.startTimer.Stop()
}
if s.startTimer == nil {
s.startTimer = time.AfterFunc(*duration, func() {
if err := s.Start(nil); err != nil {
logger.Warnf("error restarting DLNA server: %v", err)
}
})
t := time.Now().Add(*duration)
s.startTime = &t
}
}
}
// IsRunning returns true if the DLNA service is running.
func (s *Service) IsRunning() bool {
s.mutex.Lock()
defer s.mutex.Unlock()
return s.running
}
func (s *Service) Status() *Status {
s.mutex.Lock()
defer s.mutex.Unlock()
ret := &Status{
Running: s.running,
RecentIPAddresses: s.ipWhitelistMgr.getRecent(),
AllowedIPAddresses: s.ipWhitelistMgr.getTempAllowed(),
}
if s.startTime != nil {
t := *s.startTime
ret.Until = &t
}
if s.stopTime != nil {
t := *s.stopTime
ret.Until = &t
}
return ret
}
func (s *Service) AddTempDLNAIP(pattern string, duration *time.Duration) {
s.ipWhitelistMgr.allowPattern(pattern, duration)
}
func (s *Service) RemoveTempDLNAIP(pattern string) bool {
return s.ipWhitelistMgr.removePattern(pattern)
}