Files
2026-06-27 03:47:02 +08:00

183 lines
4.9 KiB
Go

package service
import (
"context"
"sort"
"strings"
"sync"
"time"
"go.uber.org/zap"
)
const (
realtimeSessionTTL = 30 * time.Minute
realtimeSessionOnlineTTL = 5 * time.Minute
)
func RealtimeDeletionGuardWindow() time.Duration {
return realtimeSessionTTL
}
// RealtimeSession is an in-memory Emby-compatible session view. It mirrors the
// information reported by Emby clients through AuthenticateByName and
// /Sessions/Playing/* without requiring Playback Reporting persistence.
type RealtimeSession struct {
ID string `json:"id"`
UserID string `json:"user_id"`
UserName string `json:"user_name,omitempty"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name,omitempty"`
Client string `json:"client,omitempty"`
RemoteEndPoint string `json:"remote_end_point,omitempty"`
LastActivityAt time.Time `json:"last_activity_at"`
ItemID string `json:"item_id,omitempty"`
PositionTicks int64 `json:"position_ticks,omitempty"`
RuntimeTicks int64 `json:"runtime_ticks,omitempty"`
IsPlaying bool `json:"is_playing"`
IsPaused bool `json:"is_paused"`
LastPlaybackAt *time.Time `json:"last_playback_at,omitempty"`
}
type realtimeSessionInput struct {
UserID string
UserName string
DeviceID string
DeviceName string
Client string
RemoteEndPoint string
ItemID string
PositionTicks int64
RuntimeTicks int64
IsPlaying bool
IsPaused bool
PlaybackUpdate bool
}
type userRealtimeActivity struct {
LastActivityAt *time.Time
ActiveDeviceCount int
Online bool
}
// SessionTrackerService keeps recent client state in memory. It is intentionally
// not durable: process restart clears transient online status, while normal
// login/playback requests repopulate it immediately.
type SessionTrackerService struct {
log *zap.Logger
mu sync.RWMutex
sessions map[string]RealtimeSession
activity map[string]time.Time
now func() time.Time
}
func NewSessionTrackerService(log *zap.Logger) *SessionTrackerService {
return &SessionTrackerService{
log: log,
sessions: make(map[string]RealtimeSession),
activity: make(map[string]time.Time),
now: time.Now,
}
}
func (s *SessionTrackerService) RecordLogin(ctx context.Context, userID, userName, deviceID, deviceName, client, remoteEndPoint string) {
s.RecordActivity(ctx, userID, userName, deviceID, deviceName, client, remoteEndPoint)
}
func (s *SessionTrackerService) RecordActivity(ctx context.Context, userID, userName, deviceID, deviceName, client, remoteEndPoint string) {
if s == nil {
return
}
s.upsert(ctx, realtimeSessionInput{
UserID: userID,
UserName: userName,
DeviceID: deviceID,
DeviceName: deviceName,
Client: client,
RemoteEndPoint: remoteEndPoint,
})
}
func (s *SessionTrackerService) RecordPlayback(ctx context.Context, userID, userName, deviceID, deviceName, client, remoteEndPoint, itemID string, positionTicks, runtimeTicks int64, stopped bool) {
if s == nil {
return
}
s.upsert(ctx, realtimeSessionInput{
UserID: userID,
UserName: userName,
DeviceID: deviceID,
DeviceName: deviceName,
Client: client,
RemoteEndPoint: remoteEndPoint,
ItemID: itemID,
PositionTicks: positionTicks,
RuntimeTicks: runtimeTicks,
IsPlaying: !stopped,
PlaybackUpdate: true,
})
}
func (s *SessionTrackerService) Logout(ctx context.Context, userID, deviceID, remoteEndPoint string) {
if s == nil {
return
}
userID = strings.TrimSpace(userID)
if userID == "" {
return
}
deviceID = strings.TrimSpace(deviceID)
remoteEndPoint = strings.TrimSpace(remoteEndPoint)
s.mu.Lock()
defer s.mu.Unlock()
if s.activity == nil {
s.activity = make(map[string]time.Time)
}
s.activity[userID] = s.now()
for key, sess := range s.sessions {
if sess.UserID != userID {
continue
}
if deviceID != "" && sess.DeviceID != deviceID {
continue
}
if deviceID == "" && remoteEndPoint != "" && sess.RemoteEndPoint != remoteEndPoint {
continue
}
delete(s.sessions, key)
}
}
func (s *SessionTrackerService) List(ctx context.Context) []RealtimeSession {
if s == nil {
return nil
}
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
s.pruneLocked(now)
out := make([]RealtimeSession, 0, len(s.sessions))
for _, sess := range s.sessions {
out = append(out, sess)
}
sort.SliceStable(out, func(i, j int) bool {
return out[i].LastActivityAt.After(out[j].LastActivityAt)
})
return out
}
func (s *SessionTrackerService) ListByUser(ctx context.Context, userID string) []RealtimeSession {
userID = strings.TrimSpace(userID)
if userID == "" {
return nil
}
all := s.List(ctx)
out := make([]RealtimeSession, 0, len(all))
for _, sess := range all {
if sess.UserID == userID {
out = append(out, sess)
}
}
return out
}