Files
2026-10-21 12:02:11 +08:00

110 lines
3.1 KiB
Go

package service
import (
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"unicode"
)
var ErrInvalidHLSPlaybackScope = errors.New("invalid HLS playback scope")
var ErrHLSGenerationSuperseded = errors.New("HLS playback generation superseded")
// HLSPlaybackScope separates a media's independent users and player instances.
// UserID must come from server authentication, never from a request parameter.
type HLSPlaybackScope struct {
UserID string
SessionID string
}
func HLSPlaybackScopeForRequest(r *http.Request, userID string) (HLSPlaybackScope, error) {
scope := HLSPlaybackScope{UserID: strings.TrimSpace(userID)}
if scope.UserID == "" || r == nil {
return scope, ErrInvalidHLSPlaybackScope
}
for _, key := range []string{"session_id", "PlaySessionId", "playSessionId", "DeviceId", "deviceId"} {
if value := strings.TrimSpace(r.URL.Query().Get(key)); value != "" {
scope.SessionID = value
break
}
}
if scope.SessionID == "" {
if device := strings.TrimSpace(r.Header.Get("X-Emby-Device-Id")); device != "" {
scope.SessionID = device
}
}
if len(scope.SessionID) > 256 || strings.IndexFunc(scope.SessionID, unicode.IsControl) >= 0 {
return scope, ErrInvalidHLSPlaybackScope
}
// Clients without a session/device identifier remain compatible but only
// share a legacy task with the same authenticated user, never other users.
return scope, nil
}
func (scope HLSPlaybackScope) jobKey(mediaID string) string {
if scope.UserID == "" && scope.SessionID == "" {
return mediaID // Existing internal callers/tests retain their legacy API.
}
sum := sha256.Sum256([]byte(scope.UserID + "\x00" + mediaID + "\x00" + scope.SessionID))
return "player-" + hex.EncodeToString(sum[:])
}
func hlsPlaybackJobKey(mediaID string, scopes []HLSPlaybackScope) string {
if len(scopes) == 0 {
return mediaID
}
return scopes[0].jobKey(mediaID)
}
func (job *hlsJob) key() string {
if job.jobKey != "" {
return job.jobKey
}
return job.mediaID
}
func (t *TranscoderService) jobPlaylistPath(job *hlsJob) string {
dir := job.outputDir
if dir == "" {
dir = t.HLSDir(job.key())
}
return filepath.Join(dir, "index.m3u8")
}
// hlsOutputDir captures one immutable run directory. Even if a new run starts
// after this check, the request cannot accidentally open its replacement files.
func (t *TranscoderService) hlsOutputDir(key string, generation int64) (string, error) {
t.mu.Lock()
defer t.mu.Unlock()
job := t.jobs[key]
if job == nil {
return "", ErrMediaNotFound
}
if job.seekGen != generation {
return "", ErrHLSGenerationSuperseded
}
t.touchJobLocked(key)
if job.outputDir != "" {
return job.outputDir, nil
}
return t.HLSDir(key), nil
}
func (t *TranscoderService) StopPlayback(mediaID string, scope HLSPlaybackScope) {
key := scope.jobKey(mediaID)
unlock := t.lockJobStart(key)
defer unlock()
t.mu.Lock()
job := t.detachJobLocked(key)
t.mu.Unlock()
waitJobExit(job, 12*time.Second)
// Keep cleanup under the same gate as start: a delayed stop must not wipe
// an output directory created by a concurrent replacement request.
_ = os.RemoveAll(t.HLSDir(key))
}