372 lines
12 KiB
Go
372 lines
12 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
// playbackSessionRow represents a live playback row from playback_sessions_sync
|
|
// enriched with user and media information. play_method is the current transport
|
|
// method for the active stream; component-level behavior is exposed separately
|
|
// via video_decision and audio_decision.
|
|
type playbackSessionRow struct {
|
|
SessionID string `json:"session_id"`
|
|
UserID int `json:"user_id"`
|
|
Username string `json:"username"`
|
|
ProfileID string `json:"profile_id"`
|
|
ProfileName string `json:"profile_name,omitempty"`
|
|
MediaFileID int `json:"media_file_id"`
|
|
RequestedMediaFileID int `json:"requested_media_file_id"`
|
|
ContentID string `json:"content_id,omitempty"`
|
|
MediaTitle string `json:"media_title"`
|
|
MediaType string `json:"media_type"`
|
|
SeriesName string `json:"series_name,omitempty"`
|
|
EpisodeName string `json:"episode_name,omitempty"`
|
|
SeasonNumber *int `json:"season_number,omitempty"`
|
|
EpisodeNumber *int `json:"episode_number,omitempty"`
|
|
PosterURL string `json:"poster_url,omitempty"`
|
|
PlayMethod string `json:"play_method"`
|
|
ReportingNode string `json:"reporting_node"`
|
|
NodeDisplayName string `json:"node_display_name,omitempty"`
|
|
FileDuration *int `json:"file_duration"`
|
|
StartedAt time.Time `json:"started_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
IsPaused bool `json:"is_paused"`
|
|
HasPlaybackControl bool `json:"has_playback_control"`
|
|
ClientIP string `json:"client_ip,omitempty"`
|
|
AudioTrackIndex int `json:"audio_track_index"`
|
|
TranscodeAudio bool `json:"transcode_audio"`
|
|
StreamBitrateKbps *int `json:"stream_bitrate_kbps"`
|
|
TranscodeNodeURL string `json:"-"`
|
|
TargetResolution string `json:"target_resolution,omitempty"`
|
|
TargetVideoCodec string `json:"target_video_codec,omitempty"`
|
|
TargetAudioCodec string `json:"target_audio_codec,omitempty"`
|
|
TargetBitrateKbps *int `json:"target_bitrate_kbps"`
|
|
SourceContainer string `json:"source_container,omitempty"`
|
|
SourceBitrateKbps *int `json:"source_bitrate_kbps"`
|
|
SourceVideoCodec string `json:"source_video_codec,omitempty"`
|
|
SourceVideoResolution string `json:"source_video_resolution,omitempty"`
|
|
SourceAudioCodec string `json:"source_audio_codec,omitempty"`
|
|
SourceAudioChannels *int `json:"source_audio_channels"`
|
|
SourceAudioLanguage string `json:"source_audio_language,omitempty"`
|
|
SourceAudioTitle string `json:"source_audio_title,omitempty"`
|
|
SourceAudioLayout string `json:"source_audio_layout,omitempty"`
|
|
RequestedVideoCodec string `json:"requested_video_codec,omitempty"`
|
|
RequestedVideoResolution string `json:"requested_video_resolution,omitempty"`
|
|
VideoDecision string `json:"video_decision,omitempty"`
|
|
AudioDecision string `json:"audio_decision,omitempty"`
|
|
}
|
|
|
|
// PlaybackSessionsQuery scopes live session listing.
|
|
type PlaybackSessionsQuery struct {
|
|
// UserID, when positive, limits results to sessions owned by that account.
|
|
UserID int
|
|
}
|
|
|
|
type playbackSessionsReader interface {
|
|
Load(ctx context.Context, r *http.Request, query PlaybackSessionsQuery) ([]playbackSessionRow, error)
|
|
}
|
|
|
|
func resolvePlaybackSessionsLoader(
|
|
loader *PlaybackSessionsLoader,
|
|
pool *pgxpool.Pool,
|
|
storeProv userstore.UserStoreProvider,
|
|
detailSvc *catalog.DetailService,
|
|
) (*PlaybackSessionsLoader, error) {
|
|
if loader != nil {
|
|
return loader, nil
|
|
}
|
|
if pool == nil {
|
|
return nil, errors.New("database not configured")
|
|
}
|
|
return NewPlaybackSessionsLoader(pool, storeProv, detailSvc), nil
|
|
}
|
|
|
|
// PlaybackSessionsLoader reads enriched rows from playback_sessions_sync.
|
|
type PlaybackSessionsLoader struct {
|
|
pool *pgxpool.Pool
|
|
storeProv userstore.UserStoreProvider
|
|
DetailSvc *catalog.DetailService
|
|
}
|
|
|
|
func NewPlaybackSessionsLoader(
|
|
pool *pgxpool.Pool,
|
|
storeProv userstore.UserStoreProvider,
|
|
detailSvc *catalog.DetailService,
|
|
) *PlaybackSessionsLoader {
|
|
return &PlaybackSessionsLoader{
|
|
pool: pool,
|
|
storeProv: storeProv,
|
|
DetailSvc: detailSvc,
|
|
}
|
|
}
|
|
|
|
func (l *PlaybackSessionsLoader) Load(
|
|
ctx context.Context,
|
|
r *http.Request,
|
|
query PlaybackSessionsQuery,
|
|
) ([]playbackSessionRow, error) {
|
|
if l == nil || l.pool == nil {
|
|
return nil, errors.New("database not configured")
|
|
}
|
|
|
|
sql := `
|
|
SELECT
|
|
s.session_id,
|
|
s.user_id,
|
|
COALESCE(u.username, ''),
|
|
COALESCE(s.profile_id, ''),
|
|
s.media_file_id,
|
|
COALESCE(s.requested_media_file_id, s.media_file_id, 0),
|
|
COALESCE(mf.episode_id, mf.content_id, ''),
|
|
COALESCE(mi.title, ''),
|
|
COALESCE(mi.type, ''),
|
|
COALESCE(series_mi.title, ''),
|
|
COALESCE(e.title, ''),
|
|
e.season_number,
|
|
e.episode_number,
|
|
COALESCE(CASE WHEN e.series_id IS NOT NULL THEN series_mi.poster_path ELSE mi.poster_path END, ''),
|
|
s.play_method,
|
|
s.reporting_node,
|
|
COALESCE(remote_node.name, ''),
|
|
mf.duration,
|
|
s.started_at,
|
|
s.updated_at,
|
|
COALESCE(s.is_paused, FALSE),
|
|
COALESCE(s.has_websocket, FALSE),
|
|
COALESCE(HOST(s.client_ip), ''),
|
|
COALESCE(s.audio_track_index, 0),
|
|
COALESCE(s.transcode_audio, FALSE),
|
|
s.stream_bitrate_kbps,
|
|
COALESCE(s.transcode_node_url, ''),
|
|
COALESCE(s.target_resolution, ''),
|
|
COALESCE(s.target_video_codec, ''),
|
|
COALESCE(s.target_audio_codec, ''),
|
|
s.target_bitrate_kbps,
|
|
COALESCE(mf.container, ''),
|
|
mf.bitrate,
|
|
COALESCE(mf.codec_video, ''),
|
|
COALESCE(mf.resolution, ''),
|
|
COALESCE(mf.codec_audio, ''),
|
|
mf.audio_channels,
|
|
COALESCE(mf.audio_tracks::text, '[]'),
|
|
COALESCE(requested_mf.codec_video, ''),
|
|
COALESCE(requested_mf.resolution, '')
|
|
FROM playback_sessions_sync s
|
|
LEFT JOIN users u ON u.id = s.user_id
|
|
LEFT JOIN media_files mf ON mf.id = s.media_file_id
|
|
LEFT JOIN media_files requested_mf ON requested_mf.id = COALESCE(s.requested_media_file_id, s.media_file_id)
|
|
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
|
LEFT JOIN episodes e ON e.content_id = mf.episode_id
|
|
LEFT JOIN media_items series_mi ON series_mi.content_id = e.series_id
|
|
LEFT JOIN stream_nodes remote_node ON remote_node.url = s.transcode_node_url`
|
|
|
|
var args []any
|
|
if query.UserID > 0 {
|
|
sql += " WHERE s.user_id = $1"
|
|
args = append(args, query.UserID)
|
|
}
|
|
sql += " ORDER BY s.started_at DESC LIMIT 200"
|
|
|
|
rows, err := l.pool.Query(ctx, sql, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying playback sessions: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
sessions := make([]playbackSessionRow, 0)
|
|
for rows.Next() {
|
|
var s playbackSessionRow
|
|
var posterPath string
|
|
var streamBitrateKbps *int
|
|
var targetBitrateKbps *int
|
|
var sourceBitrateKbps *int
|
|
var sourceAudioChannels *int
|
|
var audioTracksJSON []byte
|
|
if err := rows.Scan(
|
|
&s.SessionID, &s.UserID, &s.Username, &s.ProfileID, &s.MediaFileID, &s.RequestedMediaFileID, &s.ContentID,
|
|
&s.MediaTitle, &s.MediaType, &s.SeriesName, &s.EpisodeName, &s.SeasonNumber, &s.EpisodeNumber,
|
|
&posterPath,
|
|
&s.PlayMethod, &s.ReportingNode, &s.NodeDisplayName, &s.FileDuration, &s.StartedAt, &s.UpdatedAt,
|
|
&s.IsPaused, &s.HasPlaybackControl, &s.ClientIP, &s.AudioTrackIndex, &s.TranscodeAudio, &streamBitrateKbps,
|
|
&s.TranscodeNodeURL, &s.TargetResolution, &s.TargetVideoCodec, &s.TargetAudioCodec, &targetBitrateKbps,
|
|
&s.SourceContainer, &sourceBitrateKbps, &s.SourceVideoCodec, &s.SourceVideoResolution,
|
|
&s.SourceAudioCodec, &sourceAudioChannels, &audioTracksJSON, &s.RequestedVideoCodec, &s.RequestedVideoResolution,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scanning playback session: %w", err)
|
|
}
|
|
s.PosterURL = l.presignPosterURL(r, posterPath)
|
|
s.StreamBitrateKbps = streamBitrateKbps
|
|
s.TargetBitrateKbps = targetBitrateKbps
|
|
s.SourceBitrateKbps = sourceBitrateKbps
|
|
s.SourceAudioChannels = sourceAudioChannels
|
|
enrichPlaybackSessionRow(&s, audioTracksJSON)
|
|
sessions = append(sessions, s)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
l.populateProfileNames(ctx, sessions)
|
|
|
|
return sessions, nil
|
|
}
|
|
|
|
func (l *PlaybackSessionsLoader) presignPosterURL(r *http.Request, path string) string {
|
|
if l != nil && l.DetailSvc != nil {
|
|
return l.DetailSvc.PresignURL(r.Context(), cardThumbnailPath(path), "card")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func enrichPlaybackSessionRow(row *playbackSessionRow, audioTracksJSON []byte) {
|
|
if row == nil {
|
|
return
|
|
}
|
|
|
|
row.VideoDecision, row.AudioDecision = sessionComponentDecision(row.PlayMethod, row.TranscodeAudio, row.TargetVideoCodec)
|
|
|
|
var audioTracks []models.AudioTrack
|
|
if len(audioTracksJSON) > 0 {
|
|
_ = json.Unmarshal(audioTracksJSON, &audioTracks)
|
|
}
|
|
if track := pickAdminAudioTrack(audioTracks, row.AudioTrackIndex); track != nil {
|
|
if codec := strings.TrimSpace(track.Codec); codec != "" {
|
|
row.SourceAudioCodec = codec
|
|
}
|
|
if track.Channels > 0 {
|
|
channels := track.Channels
|
|
row.SourceAudioChannels = &channels
|
|
}
|
|
row.SourceAudioLanguage = strings.TrimSpace(track.Language)
|
|
row.SourceAudioTitle = firstNonEmptyValue(track.Title, track.EmbeddedTitle)
|
|
row.SourceAudioLayout = strings.TrimSpace(track.Layout)
|
|
}
|
|
|
|
if row.StreamBitrateKbps == nil {
|
|
switch {
|
|
case row.TargetBitrateKbps != nil:
|
|
row.StreamBitrateKbps = row.TargetBitrateKbps
|
|
case row.SourceBitrateKbps != nil:
|
|
row.StreamBitrateKbps = row.SourceBitrateKbps
|
|
}
|
|
}
|
|
|
|
if row.AudioDecision == "transcode" && strings.TrimSpace(row.TargetAudioCodec) == "" {
|
|
row.TargetAudioCodec = "aac"
|
|
}
|
|
if row.VideoDecision == "transcode" && strings.TrimSpace(row.TargetVideoCodec) == "" {
|
|
row.TargetVideoCodec = "h264"
|
|
}
|
|
if row.VideoDecision == "transcode" && strings.TrimSpace(row.TargetResolution) == "" {
|
|
row.TargetResolution = row.SourceVideoResolution
|
|
}
|
|
if strings.TrimSpace(row.NodeDisplayName) == "" {
|
|
if strings.TrimSpace(row.TranscodeNodeURL) != "" {
|
|
row.NodeDisplayName = "Remote transcode"
|
|
} else {
|
|
row.NodeDisplayName = "Local server"
|
|
}
|
|
}
|
|
}
|
|
|
|
func pickAdminAudioTrack(tracks []models.AudioTrack, index int) *models.AudioTrack {
|
|
if len(tracks) == 0 {
|
|
return nil
|
|
}
|
|
if index >= 0 && index < len(tracks) {
|
|
track := tracks[index]
|
|
return &track
|
|
}
|
|
for _, track := range tracks {
|
|
if track.Default {
|
|
cp := track
|
|
return &cp
|
|
}
|
|
}
|
|
track := tracks[0]
|
|
return &track
|
|
}
|
|
|
|
func sessionComponentDecision(playMethod string, transcodeAudio bool, targetVideoCodec string) (string, string) {
|
|
switch strings.TrimSpace(playMethod) {
|
|
case "direct":
|
|
return "direct", "direct"
|
|
case "remux":
|
|
if transcodeAudio {
|
|
return "remux", "transcode"
|
|
}
|
|
return "remux", "remux"
|
|
case "transcode":
|
|
videoDec := "transcode"
|
|
if strings.EqualFold(strings.TrimSpace(targetVideoCodec), "copy") {
|
|
videoDec = "remux"
|
|
}
|
|
audioDec := "transcode"
|
|
if !transcodeAudio {
|
|
audioDec = "direct"
|
|
}
|
|
return videoDec, audioDec
|
|
default:
|
|
return "", ""
|
|
}
|
|
}
|
|
|
|
func firstNonEmptyValue(values ...string) string {
|
|
for _, value := range values {
|
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (l *PlaybackSessionsLoader) populateProfileNames(ctx context.Context, sessions []playbackSessionRow) {
|
|
if l == nil || l.storeProv == nil || len(sessions) == 0 {
|
|
return
|
|
}
|
|
|
|
profileNamesByUser := make(map[int]map[string]string)
|
|
for i := range sessions {
|
|
if strings.TrimSpace(sessions[i].ProfileID) == "" {
|
|
continue
|
|
}
|
|
if _, ok := profileNamesByUser[sessions[i].UserID]; ok {
|
|
continue
|
|
}
|
|
store, err := l.storeProv.ForUser(ctx, sessions[i].UserID)
|
|
if err != nil || store == nil {
|
|
continue
|
|
}
|
|
profiles, err := store.ListProfiles(ctx)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
names := make(map[string]string, len(profiles))
|
|
for _, profile := range profiles {
|
|
if trimmed := strings.TrimSpace(profile.Name); trimmed != "" {
|
|
names[profile.ID] = trimmed
|
|
}
|
|
}
|
|
profileNamesByUser[sessions[i].UserID] = names
|
|
}
|
|
|
|
for i := range sessions {
|
|
names := profileNamesByUser[sessions[i].UserID]
|
|
if names == nil {
|
|
continue
|
|
}
|
|
sessions[i].ProfileName = names[sessions[i].ProfileID]
|
|
}
|
|
}
|