feat(playback): add per-user transcoding controls (#375)
* feat(playback): add per-user transcoding controls * fix(playback): enforce forced video transcode permission * chore: address transcode control review feedback * fix(playback): recheck transcode permission on audio switch --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
This commit is contained in:
@@ -1550,26 +1550,6 @@ func main() {
|
||||
|
||||
// Step 6: Create playback session manager and wire into dependencies.
|
||||
sessionMgr := playback.NewSessionManager(6, 2) // defaults from plan: max_streams=6, max_transcodes=2
|
||||
if deps.DB != nil {
|
||||
userRepo := auth.NewUserRepository(deps.DB)
|
||||
sessionMgr.SetLimitProvider(func(ctx context.Context, userID int) (playback.SessionLimits, error) {
|
||||
user, err := userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return playback.SessionLimits{}, err
|
||||
}
|
||||
effective, err := access.EffectivePolicyForUser(ctx, user, accessGroupStore)
|
||||
if err != nil {
|
||||
return playback.SessionLimits{}, err
|
||||
}
|
||||
return playback.SessionLimits{
|
||||
MaxStreams: effective.MaxStreams,
|
||||
MaxTranscodes: effective.MaxTranscodes,
|
||||
}, nil
|
||||
})
|
||||
if policySystem != nil {
|
||||
sessionMgr.SetAdmissionDecider(policy.NewPlaybackAdmissionDecider(policySystem.PDP()))
|
||||
}
|
||||
}
|
||||
if userStoreProvider != nil {
|
||||
deps.UserStoreProvider = userStoreProvider
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ type EffectiveUserPolicy struct {
|
||||
MaxPlaybackQuality string
|
||||
DownloadAllowed bool
|
||||
DownloadTranscodeAllowed bool
|
||||
TranscodeAllowed bool
|
||||
AudioTranscodeAllowed bool
|
||||
MaxStreams int
|
||||
MaxTranscodes int
|
||||
Permissions []string
|
||||
@@ -62,6 +64,8 @@ func ApplyGroupPolicy(user *models.User, group *GroupPolicy) EffectiveUserPolicy
|
||||
MaxPlaybackQuality: user.MaxPlaybackQuality,
|
||||
DownloadAllowed: user.DownloadAllowed,
|
||||
DownloadTranscodeAllowed: user.DownloadTranscodeAllowed,
|
||||
TranscodeAllowed: user.TranscodeAllowed,
|
||||
AudioTranscodeAllowed: user.AudioTranscodeAllowed,
|
||||
MaxStreams: user.MaxStreams,
|
||||
MaxTranscodes: user.MaxTranscodes,
|
||||
Permissions: cloneStrings(user.Permissions),
|
||||
|
||||
@@ -140,6 +140,8 @@ type createUserRequest struct {
|
||||
MaxPlaybackQuality string `json:"max_playback_quality"`
|
||||
MaxStreams *int `json:"max_streams,omitempty"`
|
||||
MaxTranscodes *int `json:"max_transcodes,omitempty"`
|
||||
TranscodeAllowed *bool `json:"transcode_allowed,omitempty"`
|
||||
AudioTranscodeAllowed *bool `json:"audio_transcode_allowed,omitempty"`
|
||||
MaxProfiles *int `json:"max_profiles,omitempty"`
|
||||
DownloadAllowed *bool `json:"download_allowed,omitempty"`
|
||||
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
|
||||
@@ -215,6 +217,8 @@ type updateUserRequest struct {
|
||||
MaxPlaybackQuality *string `json:"max_playback_quality,omitempty"`
|
||||
MaxStreams *int `json:"max_streams,omitempty"`
|
||||
MaxTranscodes *int `json:"max_transcodes,omitempty"`
|
||||
TranscodeAllowed *bool `json:"transcode_allowed,omitempty"`
|
||||
AudioTranscodeAllowed *bool `json:"audio_transcode_allowed,omitempty"`
|
||||
MaxProfiles *int `json:"max_profiles,omitempty"`
|
||||
DownloadAllowed *bool `json:"download_allowed,omitempty"`
|
||||
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
|
||||
@@ -252,6 +256,8 @@ type adminUserResponse struct {
|
||||
MaxPlaybackQuality string `json:"max_playback_quality"`
|
||||
MaxStreams int `json:"max_streams"`
|
||||
MaxTranscodes int `json:"max_transcodes"`
|
||||
TranscodeAllowed bool `json:"transcode_allowed"`
|
||||
AudioTranscodeAllowed bool `json:"audio_transcode_allowed"`
|
||||
MaxProfiles int `json:"max_profiles"`
|
||||
DownloadAllowed bool `json:"download_allowed"`
|
||||
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
|
||||
@@ -317,6 +323,8 @@ func toAdminUserResponse(u *models.User) adminUserResponse {
|
||||
MaxPlaybackQuality: access.NormalizePlaybackQuality(u.MaxPlaybackQuality),
|
||||
MaxStreams: u.MaxStreams,
|
||||
MaxTranscodes: u.MaxTranscodes,
|
||||
TranscodeAllowed: u.TranscodeAllowed,
|
||||
AudioTranscodeAllowed: u.AudioTranscodeAllowed,
|
||||
MaxProfiles: u.MaxProfiles,
|
||||
DownloadAllowed: u.DownloadAllowed,
|
||||
DownloadTranscodeAllowed: u.DownloadTranscodeAllowed,
|
||||
@@ -468,6 +476,8 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request)
|
||||
MaxPlaybackQuality: maxPlaybackQuality,
|
||||
MaxStreams: req.MaxStreams,
|
||||
MaxTranscodes: req.MaxTranscodes,
|
||||
TranscodeAllowed: req.TranscodeAllowed,
|
||||
AudioTranscodeAllowed: req.AudioTranscodeAllowed,
|
||||
MaxProfiles: req.MaxProfiles,
|
||||
DownloadAllowed: req.DownloadAllowed,
|
||||
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
|
||||
@@ -555,6 +565,8 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
|
||||
MaxPlaybackQuality: maxPlaybackQuality,
|
||||
MaxStreams: req.MaxStreams,
|
||||
MaxTranscodes: req.MaxTranscodes,
|
||||
TranscodeAllowed: req.TranscodeAllowed,
|
||||
AudioTranscodeAllowed: req.AudioTranscodeAllowed,
|
||||
MaxProfiles: req.MaxProfiles,
|
||||
DownloadAllowed: req.DownloadAllowed,
|
||||
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
|
||||
|
||||
@@ -61,6 +61,30 @@ type sessionStarterWithFilesContext interface {
|
||||
StartSessionWithFilesContext(ctx context.Context, userID int, profileID string, effectiveFileID int, requestedFileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error)
|
||||
}
|
||||
|
||||
type transcodePermissionChecker interface {
|
||||
CheckTranscodingAllowed(ctx context.Context, userID int, requiresVideoTranscode bool) error
|
||||
}
|
||||
|
||||
func (h *PlaybackHandler) ensureUserTranscodingAllowed(w http.ResponseWriter, r *http.Request, userID int, requiresVideoTranscode bool) bool {
|
||||
checker, ok := h.sessionMgr.(transcodePermissionChecker)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if err := checker.CheckTranscodingAllowed(r.Context(), userID, requiresVideoTranscode); err != nil {
|
||||
if errors.Is(err, playback.ErrTranscodingDisabled) {
|
||||
writeError(w, http.StatusForbidden, "transcoding_disabled", "Transcoding is disabled for your user")
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, playback.ErrAudioTranscodingDisabled) {
|
||||
writeError(w, http.StatusForbidden, "audio_transcoding_disabled", "Audio transcoding is disabled for your user")
|
||||
return false
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to verify transcoding access")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type PlaybackItemAccessChecker interface {
|
||||
EnsureAccessible(ctx context.Context, contentID string, filter catalog.AccessFilter) error
|
||||
}
|
||||
@@ -1505,6 +1529,14 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
writeError(w, http.StatusTooManyRequests, "too_many_transcodes", "Too many concurrent transcodes")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, playback.ErrTranscodingDisabled) {
|
||||
writeError(w, http.StatusForbidden, "transcoding_disabled", "Transcoding is disabled for your user")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, playback.ErrAudioTranscodingDisabled) {
|
||||
writeError(w, http.StatusForbidden, "audio_transcoding_disabled", "Audio transcoding is disabled for your user")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, playback.ErrPlaybackNotAllowed) {
|
||||
writeError(w, http.StatusForbidden, "playback_not_allowed", "Playback denied by server policy")
|
||||
return
|
||||
@@ -1964,6 +1996,14 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
|
||||
transcodeAudio = true
|
||||
}
|
||||
|
||||
requiresVideoTranscode := baseMethod == playback.PlayTranscode ||
|
||||
(session.PlayMethod == playback.PlayTranscode &&
|
||||
!strings.EqualFold(session.TargetVideoCodec, "copy"))
|
||||
if (requiresVideoTranscode || transcodeAudio) &&
|
||||
!h.ensureUserTranscodingAllowed(w, r, userID, requiresVideoTranscode) {
|
||||
return
|
||||
}
|
||||
|
||||
targetResolution := ""
|
||||
targetVideoCodec := ""
|
||||
targetAudioCodec := ""
|
||||
@@ -2471,6 +2511,10 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user")
|
||||
return
|
||||
}
|
||||
requiresVideoTranscode := !strings.EqualFold(req.TargetCodecVideo, "copy")
|
||||
if !h.ensureUserTranscodingAllowed(w, r, userID, requiresVideoTranscode) {
|
||||
return
|
||||
}
|
||||
// Close any existing transcode so a new one can start at different quality.
|
||||
// Check both local sessions AND remote node assignments — without the
|
||||
// remote check, switching quality on a transcode node never sends DELETE,
|
||||
@@ -2573,6 +2617,14 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
req.TargetCodecVideo = "h264"
|
||||
}
|
||||
|
||||
// The request-level permission check above intentionally runs before the
|
||||
// existing transcode is closed. Recheck when server-side normalization has
|
||||
// upgraded an allowed copy-video request into actual video encoding.
|
||||
if !requiresVideoTranscode && !strings.EqualFold(req.TargetCodecVideo, "copy") &&
|
||||
!h.ensureUserTranscodingAllowed(w, r, userID, true) {
|
||||
return
|
||||
}
|
||||
|
||||
// 4K transcode guard: if source is 4K and allow_4k_transcode is disabled,
|
||||
// switch to an alternate non-4K file version for transcoding.
|
||||
// Skip the guard when target_codec_video is "copy" — no actual video
|
||||
|
||||
@@ -1191,6 +1191,109 @@ func newRemoteAudioSwitchSession(t *testing.T, sessionMgr *playback.SessionManag
|
||||
return session.ID
|
||||
}
|
||||
|
||||
func TestHandleChangeAudioTrack_RechecksVideoTranscodePermission(t *testing.T) {
|
||||
transcodingDisabled := false
|
||||
sessionMgr := playback.NewSessionManager(0, 0)
|
||||
sessionMgr.SetLimitProvider(func(context.Context, int) (playback.SessionLimits, error) {
|
||||
return playback.SessionLimits{TranscodingDisabled: transcodingDisabled}, nil
|
||||
})
|
||||
file := &models.MediaFile{
|
||||
ID: 42,
|
||||
AudioTracks: []models.AudioTrack{
|
||||
{Codec: "aac", Default: true},
|
||||
{Codec: "aac"},
|
||||
},
|
||||
}
|
||||
session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayTranscode, true)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession: %v", err)
|
||||
}
|
||||
if err := sessionMgr.UpdateStreamState(session.ID, playback.SessionStreamState{
|
||||
PlayMethod: playback.PlayTranscode,
|
||||
BasePlayMethod: playback.PlayTranscode,
|
||||
AudioTrackIndex: 0,
|
||||
TranscodeAudio: true,
|
||||
TargetVideoCodec: "h264",
|
||||
TargetAudioCodec: "aac",
|
||||
TargetResolution: "720p",
|
||||
TargetBitrateKbps: 2000,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateStreamState: %v", err)
|
||||
}
|
||||
transcodingDisabled = true
|
||||
|
||||
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/playback/"+session.ID+"/audio",
|
||||
strings.NewReader(`{"audio_track_index":1,"position":10}`),
|
||||
)
|
||||
request = request.WithContext(newAuthorizedPlaybackContext())
|
||||
request = withPlaybackRouteParam(request, "session_id", session.ID)
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
handler.HandleChangeAudioTrack(response, request)
|
||||
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d, body = %s", response.Code, http.StatusForbidden, response.Body.String())
|
||||
}
|
||||
var body errorResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if body.Error != "transcoding_disabled" {
|
||||
t.Fatalf("error = %q, want transcoding_disabled", body.Error)
|
||||
}
|
||||
updated, err := sessionMgr.GetSession(session.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if updated.AudioTrackIndex != 0 {
|
||||
t.Fatalf("AudioTrackIndex = %d, want unchanged 0", updated.AudioTrackIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleChangeAudioTrack_AllowsAudioOnlyTranscodeWhenVideoDisabled(t *testing.T) {
|
||||
sessionMgr := playback.NewSessionManager(0, 0)
|
||||
sessionMgr.SetLimitProvider(func(context.Context, int) (playback.SessionLimits, error) {
|
||||
return playback.SessionLimits{TranscodingDisabled: true}, nil
|
||||
})
|
||||
file := &models.MediaFile{
|
||||
ID: 42,
|
||||
AudioTracks: []models.AudioTrack{
|
||||
{Codec: "aac", Default: true},
|
||||
{Codec: "ac3"},
|
||||
},
|
||||
}
|
||||
session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayRemux, false)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession: %v", err)
|
||||
}
|
||||
|
||||
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/playback/"+session.ID+"/audio",
|
||||
strings.NewReader(`{"audio_track_index":1,"position":10}`),
|
||||
)
|
||||
request = request.WithContext(newAuthorizedPlaybackContext())
|
||||
request = withPlaybackRouteParam(request, "session_id", session.ID)
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
handler.HandleChangeAudioTrack(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body = %s", response.Code, http.StatusOK, response.Body.String())
|
||||
}
|
||||
updated, err := sessionMgr.GetSession(session.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if !updated.TranscodeAudio {
|
||||
t.Fatal("TranscodeAudio = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe
|
||||
// verifies BUG A + BUG B: an audio switch on an offloaded transcode POSTs a
|
||||
// fresh /transcode/start to the node carrying the NEW AudioTrackIndex (so the
|
||||
@@ -1525,6 +1628,81 @@ func TestHandleStartTranscode_MPEG2SeekedCopyRemainsCopyVideo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStartTranscode_ForcedVideoEncodingRechecksPermission(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
requestBody func(sessionID string) string
|
||||
}{
|
||||
{
|
||||
name: "seeked copy video",
|
||||
requestBody: func(sessionID string) string {
|
||||
return `{"session_id":"` + sessionID + `","seek_seconds":18.261,"target_resolution":"1080p","target_codec_video":"copy","target_codec_audio":"aac","target_bitrate_kbps":0,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "subtitle burn in",
|
||||
requestBody: func(sessionID string) string {
|
||||
return `{"session_id":"` + sessionID + `","seek_seconds":0,"target_resolution":"1080p","target_codec_video":"copy","target_codec_audio":"aac","target_bitrate_kbps":0,"segment_duration":2,"subtitle_track_index":0,"subtitle_burn_in":true}`
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sessionMgr := playback.NewSessionManager(0, 0)
|
||||
sessionMgr.SetLimitProvider(func(context.Context, int) (playback.SessionLimits, error) {
|
||||
return playback.SessionLimits{TranscodingDisabled: true}, nil
|
||||
})
|
||||
file := &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "movie-1",
|
||||
FilePath: writePlaybackTestMediaFile(t, "movie.mkv"),
|
||||
Resolution: "1080p",
|
||||
CodecVideo: "h264",
|
||||
CodecAudio: "ac3",
|
||||
Container: "mkv",
|
||||
Bitrate: 8000,
|
||||
Duration: 3600,
|
||||
AudioTracks: []models.AudioTrack{
|
||||
{Codec: "ac3", Default: true},
|
||||
},
|
||||
SubtitleTracks: []models.SubtitleTrack{
|
||||
{Index: 0, Language: "en", Codec: "subrip"},
|
||||
},
|
||||
}
|
||||
session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayRemux, true)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession: %v", err)
|
||||
}
|
||||
|
||||
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
|
||||
transcodeReq := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/playback/transcode/start",
|
||||
strings.NewReader(tc.requestBody(session.ID)),
|
||||
)
|
||||
transcodeReq = transcodeReq.WithContext(newAuthorizedPlaybackContext())
|
||||
|
||||
transcodeRR := httptest.NewRecorder()
|
||||
handler.HandleStartTranscode(transcodeRR, transcodeReq)
|
||||
|
||||
if transcodeRR.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d, body = %s", transcodeRR.Code, http.StatusForbidden, transcodeRR.Body.String())
|
||||
}
|
||||
var response errorResponse
|
||||
if err := json.NewDecoder(transcodeRR.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.Error != "transcoding_disabled" {
|
||||
t.Fatalf("error = %q, want transcoding_disabled", response.Error)
|
||||
}
|
||||
if transcodeSession := handler.tm.GetTranscodeSession(session.ID); transcodeSession != nil {
|
||||
t.Fatal("transcode session started despite disabled video transcoding")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStartTranscode_BitmapBurnInForcesEncodeAndResolvesCodec(t *testing.T) {
|
||||
sessionMgr := playback.NewSessionManager(0, 0)
|
||||
filePath := writePlaybackTestMediaFile(t, "movie-pgs.mkv")
|
||||
|
||||
@@ -419,8 +419,10 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
return playback.SessionLimits{}, err
|
||||
}
|
||||
return playback.SessionLimits{
|
||||
MaxStreams: effective.MaxStreams,
|
||||
MaxTranscodes: effective.MaxTranscodes,
|
||||
MaxStreams: effective.MaxStreams,
|
||||
MaxTranscodes: effective.MaxTranscodes,
|
||||
TranscodingDisabled: !effective.TranscodeAllowed,
|
||||
AudioTranscodingDisabled: !effective.AudioTranscodeAllowed,
|
||||
}, nil
|
||||
})
|
||||
if deps.PolicySystem != nil {
|
||||
|
||||
@@ -172,6 +172,10 @@ func writeNativePlaybackStartError(w http.ResponseWriter, err error) {
|
||||
http.Error(w, "too many concurrent streams", http.StatusTooManyRequests)
|
||||
case errors.Is(err, playback.ErrTooManyTranscodes):
|
||||
http.Error(w, "too many concurrent transcodes", http.StatusTooManyRequests)
|
||||
case errors.Is(err, playback.ErrTranscodingDisabled):
|
||||
http.Error(w, "transcoding is disabled for your user", http.StatusForbidden)
|
||||
case errors.Is(err, playback.ErrAudioTranscodingDisabled):
|
||||
http.Error(w, "audio transcoding is disabled for your user", http.StatusForbidden)
|
||||
default:
|
||||
slog.Error("abs play: start native playback session failed", "error", err)
|
||||
http.Error(w, "failed to start playback session", http.StatusInternalServerError)
|
||||
|
||||
@@ -51,7 +51,7 @@ func NewUserRepository(pool *pgxpool.Pool) *UserRepository {
|
||||
// Kept in one place so scanUser stays in sync.
|
||||
const allColumns = `id, email, username, password_hash, local_password_login_enabled, role, permissions, enabled,
|
||||
library_ids, max_playback_quality, access_policy_revision,
|
||||
max_streams, max_transcodes, max_profiles, download_allowed,
|
||||
max_streams, max_transcodes, transcode_allowed, audio_transcode_allowed, max_profiles, download_allowed,
|
||||
download_transcode_allowed, access_group_id, created_at, updated_at`
|
||||
|
||||
// scanUser scans a single row into a *models.User.
|
||||
@@ -71,6 +71,8 @@ func scanUser(row pgx.Row) (*models.User, error) {
|
||||
&u.AccessPolicyRevision,
|
||||
&u.MaxStreams,
|
||||
&u.MaxTranscodes,
|
||||
&u.TranscodeAllowed,
|
||||
&u.AudioTranscodeAllowed,
|
||||
&u.MaxProfiles,
|
||||
&u.DownloadAllowed,
|
||||
&u.DownloadTranscodeAllowed,
|
||||
@@ -106,6 +108,8 @@ func scanUsers(rows pgx.Rows) ([]*models.User, error) {
|
||||
&u.AccessPolicyRevision,
|
||||
&u.MaxStreams,
|
||||
&u.MaxTranscodes,
|
||||
&u.TranscodeAllowed,
|
||||
&u.AudioTranscodeAllowed,
|
||||
&u.MaxProfiles,
|
||||
&u.DownloadAllowed,
|
||||
&u.DownloadTranscodeAllowed,
|
||||
@@ -167,6 +171,14 @@ func (r *UserRepository) Create(ctx context.Context, input models.CreateUserInpu
|
||||
cols = append(cols, "max_transcodes")
|
||||
args = append(args, *input.MaxTranscodes)
|
||||
}
|
||||
if input.TranscodeAllowed != nil {
|
||||
cols = append(cols, "transcode_allowed")
|
||||
args = append(args, *input.TranscodeAllowed)
|
||||
}
|
||||
if input.AudioTranscodeAllowed != nil {
|
||||
cols = append(cols, "audio_transcode_allowed")
|
||||
args = append(args, *input.AudioTranscodeAllowed)
|
||||
}
|
||||
if input.MaxProfiles != nil {
|
||||
cols = append(cols, "max_profiles")
|
||||
args = append(args, *input.MaxProfiles)
|
||||
@@ -311,6 +323,16 @@ func (r *UserRepository) Update(ctx context.Context, id int, input models.Update
|
||||
args = append(args, *input.MaxTranscodes)
|
||||
argIndex++
|
||||
}
|
||||
if input.TranscodeAllowed != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("transcode_allowed = $%d", argIndex))
|
||||
args = append(args, *input.TranscodeAllowed)
|
||||
argIndex++
|
||||
}
|
||||
if input.AudioTranscodeAllowed != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("audio_transcode_allowed = $%d", argIndex))
|
||||
args = append(args, *input.AudioTranscodeAllowed)
|
||||
argIndex++
|
||||
}
|
||||
if input.MaxProfiles != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("max_profiles = $%d", argIndex))
|
||||
args = append(args, *input.MaxProfiles)
|
||||
|
||||
@@ -3308,6 +3308,14 @@ func writeCompatUpstreamError(w http.ResponseWriter, err error) {
|
||||
writeError(w, http.StatusTooManyRequests, "TooManyTranscodes", "Too many concurrent transcodes")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, playback.ErrTranscodingDisabled) {
|
||||
writeError(w, http.StatusForbidden, "TranscodingDisabled", "Transcoding is disabled for your user")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, playback.ErrAudioTranscodingDisabled) {
|
||||
writeError(w, http.StatusForbidden, "AudioTranscodingDisabled", "Audio transcoding is disabled for your user")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, playback.ErrPlaybackNotAllowed) {
|
||||
writeError(w, http.StatusForbidden, "PlaybackNotAllowed", "Playback denied by server policy")
|
||||
return
|
||||
|
||||
@@ -17,6 +17,8 @@ type User struct {
|
||||
AccessPolicyRevision int64
|
||||
MaxStreams int
|
||||
MaxTranscodes int
|
||||
TranscodeAllowed bool
|
||||
AudioTranscodeAllowed bool
|
||||
MaxProfiles int
|
||||
DownloadAllowed bool
|
||||
DownloadTranscodeAllowed bool
|
||||
@@ -37,6 +39,8 @@ type CreateUserInput struct {
|
||||
MaxPlaybackQuality string
|
||||
MaxStreams *int // nil = use DB default (0 = unrestricted at the user layer; the access group governs)
|
||||
MaxTranscodes *int // nil = use DB default (0 = unrestricted at the user layer; the access group governs)
|
||||
TranscodeAllowed *bool // nil = use DB default (true)
|
||||
AudioTranscodeAllowed *bool // nil = use DB default (true)
|
||||
MaxProfiles *int // nil = use DB default (5); minimum 1
|
||||
DownloadAllowed *bool // nil = use DB default (true)
|
||||
DownloadTranscodeAllowed *bool // nil = use DB default (false)
|
||||
@@ -57,6 +61,8 @@ type UpdateUserInput struct {
|
||||
MaxPlaybackQuality *string
|
||||
MaxStreams *int
|
||||
MaxTranscodes *int
|
||||
TranscodeAllowed *bool
|
||||
AudioTranscodeAllowed *bool
|
||||
MaxProfiles *int
|
||||
DownloadAllowed *bool
|
||||
DownloadTranscodeAllowed *bool
|
||||
|
||||
@@ -4,10 +4,12 @@ import "errors"
|
||||
|
||||
// Sentinel errors for playback operations.
|
||||
var (
|
||||
ErrNoVersions = errors.New("no file versions available")
|
||||
ErrSessionNotFound = errors.New("playback session not found")
|
||||
ErrTooManyStreams = errors.New("too many concurrent streams")
|
||||
ErrTooManyTranscodes = errors.New("too many concurrent transcodes")
|
||||
ErrNoVersions = errors.New("no file versions available")
|
||||
ErrSessionNotFound = errors.New("playback session not found")
|
||||
ErrTooManyStreams = errors.New("too many concurrent streams")
|
||||
ErrTooManyTranscodes = errors.New("too many concurrent transcodes")
|
||||
ErrTranscodingDisabled = errors.New("transcoding is disabled for this user")
|
||||
ErrAudioTranscodingDisabled = errors.New("audio transcoding is disabled for this user")
|
||||
// ErrPlaybackNotAllowed is the generic policy admission denial: a denial
|
||||
// without a recognized concurrency-limit code (e.g. an admin custom
|
||||
// override, or a failed policy evaluation) must not masquerade as one.
|
||||
|
||||
@@ -127,8 +127,10 @@ type SessionManager struct {
|
||||
|
||||
// SessionLimits stores per-user admission limits. Zero values mean unlimited.
|
||||
type SessionLimits struct {
|
||||
MaxStreams int
|
||||
MaxTranscodes int
|
||||
MaxStreams int
|
||||
MaxTranscodes int
|
||||
TranscodingDisabled bool
|
||||
AudioTranscodingDisabled bool
|
||||
}
|
||||
|
||||
// SessionLimitProvider returns the current admission limits for a user.
|
||||
@@ -142,6 +144,8 @@ type AdmissionRequest struct {
|
||||
CurrentActiveStreams int
|
||||
CurrentActiveTranscodes int
|
||||
RequestedMethod PlayMethod
|
||||
RequiresVideoTranscode bool
|
||||
RequiresAudioTranscode bool
|
||||
}
|
||||
|
||||
// AdmissionDecision is the result of an optional policy admission decision.
|
||||
@@ -158,8 +162,10 @@ type AdmissionDecision struct {
|
||||
// (policy's adapters import playback), so the shared values are pinned by
|
||||
// tests on both sides.
|
||||
const (
|
||||
AdmissionReasonMaxStreamsExceeded = "max_streams_exceeded"
|
||||
AdmissionReasonMaxTranscodesExceeded = "max_transcodes_exceeded"
|
||||
AdmissionReasonMaxStreamsExceeded = "max_streams_exceeded"
|
||||
AdmissionReasonMaxTranscodesExceeded = "max_transcodes_exceeded"
|
||||
AdmissionReasonTranscodingDisabled = "transcoding_disabled"
|
||||
AdmissionReasonAudioTranscodingDisabled = "audio_transcoding_disabled"
|
||||
)
|
||||
|
||||
// AdmissionDecider can replace SessionManager's inline limit comparison while
|
||||
@@ -264,6 +270,7 @@ func (m *SessionManager) StartSessionWithContext(
|
||||
// Returns ErrTooManyStreams if the user has reached the max active stream count.
|
||||
// Returns ErrTooManyTranscodes if the user has reached the max transcode count
|
||||
// and the requested method is transcode.
|
||||
// Returns ErrTranscodingDisabled when the user may not start a video or audio transcode.
|
||||
func (m *SessionManager) StartSessionWithFiles(
|
||||
userID int,
|
||||
profileID string,
|
||||
@@ -298,7 +305,7 @@ func (m *SessionManager) StartSessionWithFilesContext(
|
||||
m.mu.Lock()
|
||||
decider := m.admissionDecider
|
||||
if decider == nil {
|
||||
if err := m.inlineAdmissionErrorLocked(userID, method, limits); err != nil {
|
||||
if err := m.inlineAdmissionErrorLocked(userID, method, transcodeAudio, limits); err != nil {
|
||||
m.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
@@ -317,6 +324,8 @@ func (m *SessionManager) StartSessionWithFilesContext(
|
||||
CurrentActiveStreams: activeStreams,
|
||||
CurrentActiveTranscodes: activeTranscodes,
|
||||
RequestedMethod: method,
|
||||
RequiresVideoTranscode: method == PlayTranscode,
|
||||
RequiresAudioTranscode: transcodeAudio,
|
||||
})
|
||||
if err != nil {
|
||||
// Fail closed, but make an engine outage distinguishable from a
|
||||
@@ -341,7 +350,11 @@ func (m *SessionManager) StartSessionWithFilesContext(
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SessionManager) inlineAdmissionErrorLocked(userID int, method PlayMethod, limits SessionLimits) error {
|
||||
func (m *SessionManager) inlineAdmissionErrorLocked(userID int, method PlayMethod, transcodeAudio bool, limits SessionLimits) error {
|
||||
if err := transcodingDisabledError(method == PlayTranscode, transcodeAudio, limits); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if limits.MaxStreams > 0 && m.activeCountLocked(userID) >= limits.MaxStreams {
|
||||
return ErrTooManyStreams
|
||||
}
|
||||
@@ -392,6 +405,10 @@ func admissionDenyError(reasonCode string) error {
|
||||
return ErrTooManyStreams
|
||||
case AdmissionReasonMaxTranscodesExceeded:
|
||||
return ErrTooManyTranscodes
|
||||
case AdmissionReasonTranscodingDisabled:
|
||||
return ErrTranscodingDisabled
|
||||
case AdmissionReasonAudioTranscodingDisabled:
|
||||
return ErrAudioTranscodingDisabled
|
||||
default:
|
||||
return ErrPlaybackNotAllowed
|
||||
}
|
||||
@@ -437,7 +454,7 @@ func (m *SessionManager) RegisterReconstructed(s *Session) *Session {
|
||||
// Legitimately reconstructing a user's own surviving sessions still succeeds:
|
||||
// the cap counts the user's *currently-live* sessions, and the one being rebuilt
|
||||
// is not yet in the map, so the first MaxStreams reconstructs admit. Only the
|
||||
// over-cap replay is refused (ErrTooManyStreams / ErrTooManyTranscodes). If an
|
||||
// over-cap replay or disabled transcode is refused. If an
|
||||
// identical session id is already live (a concurrent reconstruct won), it is
|
||||
// returned without re-counting. Caps are looked up via the same limit provider
|
||||
// as StartSession.
|
||||
@@ -462,6 +479,9 @@ func (m *SessionManager) RegisterReconstructedWithLimits(ctx context.Context, s
|
||||
|
||||
// The session being reconstructed is not yet in the map, so the live counts
|
||||
// reflect the user's *other* sessions; admitting one more must stay within cap.
|
||||
if err := transcodingDisabledError(s.PlayMethod == PlayTranscode, s.TranscodeAudio, limits); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limits.MaxStreams > 0 && m.activeCountLocked(s.UserID) >= limits.MaxStreams {
|
||||
return nil, ErrTooManyStreams
|
||||
}
|
||||
@@ -503,6 +523,26 @@ func (m *SessionManager) limitsForUser(ctx context.Context, userID int) (Session
|
||||
return limits, nil
|
||||
}
|
||||
|
||||
// CheckTranscodingAllowed verifies account-level restrictions before an
|
||||
// existing session switches to video or audio transcoding.
|
||||
func (m *SessionManager) CheckTranscodingAllowed(ctx context.Context, userID int, requiresVideoTranscode bool) error {
|
||||
limits, err := m.limitsForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return transcodingDisabledError(requiresVideoTranscode, !requiresVideoTranscode, limits)
|
||||
}
|
||||
|
||||
func transcodingDisabledError(requiresVideoTranscode, requiresAudioTranscode bool, limits SessionLimits) error {
|
||||
if requiresVideoTranscode && limits.TranscodingDisabled {
|
||||
return ErrTranscodingDisabled
|
||||
}
|
||||
if requiresAudioTranscode && limits.TranscodingDisabled && limits.AudioTranscodingDisabled {
|
||||
return ErrAudioTranscodingDisabled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateProgress updates the playback position and pause state for a session.
|
||||
func (m *SessionManager) UpdateProgress(sessionID string, position float64, isPaused bool) error {
|
||||
m.mu.Lock()
|
||||
|
||||
@@ -328,6 +328,76 @@ func TestSessionManager_UserLimitProviderAppliesTranscodeLimitOnlyToTranscodes(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManager_DisabledVideoTranscodingAllowsAudioByDefault(t *testing.T) {
|
||||
sm := playback.NewSessionManager(0, 0)
|
||||
sm.SetLimitProvider(func(context.Context, int) (playback.SessionLimits, error) {
|
||||
return playback.SessionLimits{TranscodingDisabled: true}, nil
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
method playback.PlayMethod
|
||||
transcodeAudio bool
|
||||
wantErr error
|
||||
}{
|
||||
{name: "direct play", method: playback.PlayDirect},
|
||||
{name: "container remux", method: playback.PlayRemux},
|
||||
{name: "video transcode", method: playback.PlayTranscode, wantErr: playback.ErrTranscodingDisabled},
|
||||
{name: "audio transcode", method: playback.PlayRemux, transcodeAudio: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := sm.StartSession(1, "profile-1", 100, tc.method, tc.transcodeAudio)
|
||||
if !errors.Is(err, tc.wantErr) {
|
||||
t.Fatalf("StartSession() error = %v, want %v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManager_DisabledAudioTranscodingRejectsAudioTranscode(t *testing.T) {
|
||||
sm := playback.NewSessionManager(0, 0)
|
||||
sm.SetLimitProvider(func(context.Context, int) (playback.SessionLimits, error) {
|
||||
return playback.SessionLimits{
|
||||
TranscodingDisabled: true,
|
||||
AudioTranscodingDisabled: true,
|
||||
}, nil
|
||||
})
|
||||
|
||||
_, err := sm.StartSession(1, "profile-1", 100, playback.PlayRemux, true)
|
||||
if !errors.Is(err, playback.ErrAudioTranscodingDisabled) {
|
||||
t.Fatalf("StartSession() error = %v, want ErrAudioTranscodingDisabled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManager_CheckTranscodingAllowed(t *testing.T) {
|
||||
sm := playback.NewSessionManager(0, 0)
|
||||
sm.SetLimitProvider(func(context.Context, int) (playback.SessionLimits, error) {
|
||||
return playback.SessionLimits{
|
||||
TranscodingDisabled: true,
|
||||
AudioTranscodingDisabled: true,
|
||||
}, nil
|
||||
})
|
||||
|
||||
if err := sm.CheckTranscodingAllowed(context.Background(), 1, true); !errors.Is(err, playback.ErrTranscodingDisabled) {
|
||||
t.Fatalf("CheckTranscodingAllowed() error = %v, want ErrTranscodingDisabled", err)
|
||||
}
|
||||
if err := sm.CheckTranscodingAllowed(context.Background(), 1, false); !errors.Is(err, playback.ErrAudioTranscodingDisabled) {
|
||||
t.Fatalf("CheckTranscodingAllowed(audio) error = %v, want ErrAudioTranscodingDisabled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManager_PolicyAllowsAudioOnlyTranscodeWhenVideoTranscodingDisabled(t *testing.T) {
|
||||
sm := playback.NewSessionManager(0, 0)
|
||||
sm.SetLimitProvider(func(context.Context, int) (playback.SessionLimits, error) {
|
||||
return playback.SessionLimits{TranscodingDisabled: true}, nil
|
||||
})
|
||||
sm.SetAdmissionDecider(policy.NewPlaybackAdmissionDecider(newPlaybackPolicyPDP(t)))
|
||||
|
||||
if _, err := sm.StartSession(1, "profile-1", 100, playback.PlayRemux, true); err != nil {
|
||||
t.Fatalf("StartSession(audio transcode) error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManager_PolicyAdmissionDeciderMatchesLegacy(t *testing.T) {
|
||||
pdp := newPlaybackPolicyPDP(t)
|
||||
ctx := context.Background()
|
||||
@@ -387,6 +457,8 @@ func TestSessionManager_AdmissionReasonCodesMapToSentinelErrors(t *testing.T) {
|
||||
}{
|
||||
{"max streams", playback.AdmissionReasonMaxStreamsExceeded, playback.ErrTooManyStreams},
|
||||
{"max transcodes", playback.AdmissionReasonMaxTranscodesExceeded, playback.ErrTooManyTranscodes},
|
||||
{"transcoding disabled", playback.AdmissionReasonTranscodingDisabled, playback.ErrTranscodingDisabled},
|
||||
{"audio transcoding disabled", playback.AdmissionReasonAudioTranscodingDisabled, playback.ErrAudioTranscodingDisabled},
|
||||
// A custom-override denial carries free text and the custom_denial
|
||||
// code; it must not surface as a concurrency-limit error.
|
||||
{"custom denial", "custom_denial", playback.ErrPlaybackNotAllowed},
|
||||
@@ -641,12 +713,12 @@ func TestUpdateStreamState(t *testing.T) {
|
||||
}
|
||||
|
||||
err = mgr.UpdateStreamState(session.ID, playback.SessionStreamState{
|
||||
PlayMethod: playback.PlayTranscode,
|
||||
BasePlayMethod: playback.PlayRemux,
|
||||
AudioTrackIndex: 2,
|
||||
TranscodeAudio: true,
|
||||
ClientIP: "10.0.0.10",
|
||||
StreamBitrateKbps: 4200,
|
||||
PlayMethod: playback.PlayTranscode,
|
||||
BasePlayMethod: playback.PlayRemux,
|
||||
AudioTrackIndex: 2,
|
||||
TranscodeAudio: true,
|
||||
ClientIP: "10.0.0.10",
|
||||
StreamBitrateKbps: 4200,
|
||||
TargetResolution: "1080p",
|
||||
TargetVideoCodec: "h264",
|
||||
TargetAudioCodec: "aac",
|
||||
|
||||
@@ -374,11 +374,10 @@ func (m *TranscodeManager) ReconstructSession(ctx context.Context, sessionID str
|
||||
// replay is rejected.
|
||||
session, err := m.Sessions.RegisterReconstructedWithLimits(ctx, s)
|
||||
if err != nil {
|
||||
// A genuine over-cap rejection (the user is at their concurrent stream /
|
||||
// transcode limit) must still refuse: a replayed token cannot reconstruct
|
||||
// past the cap a fresh StartSession would enforce.
|
||||
if errors.Is(err, ErrTooManyStreams) || errors.Is(err, ErrTooManyTranscodes) {
|
||||
slog.WarnContext(ctx, "playback session reconstruct refused by admission cap", "component", "playback",
|
||||
// Admission denials must still refuse: a replayed token cannot reconstruct
|
||||
// past a current cap or after transcoding has been disabled for the user.
|
||||
if errors.Is(err, ErrTooManyStreams) || errors.Is(err, ErrTooManyTranscodes) || errors.Is(err, ErrTranscodingDisabled) || errors.Is(err, ErrAudioTranscodingDisabled) {
|
||||
slog.WarnContext(ctx, "playback session reconstruct refused by admission policy", "component", "playback",
|
||||
"session", sessionID, "playback_session_id", sessionID,
|
||||
"user", card.UserID, "method", method, "error", err)
|
||||
return nil
|
||||
|
||||
@@ -85,6 +85,7 @@ func TestActionParityPlaybackAdmission(t *testing.T) {
|
||||
UserID: 42,
|
||||
MaxStreams: limits.MaxStreams,
|
||||
MaxTranscodes: limits.MaxTranscodes,
|
||||
TranscodeAllowed: true,
|
||||
CurrentActiveStreams: activeStreams,
|
||||
CurrentActiveTranscodes: activeTranscodes,
|
||||
RequestedAction: requested,
|
||||
|
||||
@@ -12,6 +12,8 @@ const (
|
||||
RequestedActionDirectPlay = "direct_play"
|
||||
// RequestedActionTranscode is the playback admission fact for transcode playback.
|
||||
RequestedActionTranscode = "transcode"
|
||||
// RequestedActionAudioTranscode is the playback admission fact for copy-video, transcoded-audio playback.
|
||||
RequestedActionAudioTranscode = "audio_transcode"
|
||||
|
||||
// PermissionActingAdmin is the pseudo-permission used for acting-admin gates.
|
||||
PermissionActingAdmin = "acting_admin"
|
||||
@@ -133,6 +135,8 @@ const (
|
||||
ReasonCodeContentRatingExceeded = "content_rating_exceeded"
|
||||
ReasonCodeMaxStreamsExceeded = "max_streams_exceeded"
|
||||
ReasonCodeMaxTranscodesExceeded = "max_transcodes_exceeded"
|
||||
ReasonCodeTranscodingDisabled = "transcoding_disabled"
|
||||
ReasonCodeAudioTranscodingDisabled = "audio_transcoding_disabled"
|
||||
)
|
||||
|
||||
// ActionInput is the policy input document for download eligibility,
|
||||
@@ -149,6 +153,8 @@ type ActionInput struct {
|
||||
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
|
||||
MaxStreams int `json:"max_streams"`
|
||||
MaxTranscodes int `json:"max_transcodes"`
|
||||
TranscodeAllowed bool `json:"transcode_allowed"`
|
||||
AudioTranscodeAllowed bool `json:"audio_transcode_allowed"`
|
||||
|
||||
DownloadsEnabled bool `json:"downloads_enabled"`
|
||||
TranscodeEnabled bool `json:"transcode_enabled"`
|
||||
|
||||
@@ -17,8 +17,10 @@ type ActionChecker interface {
|
||||
func NewPlaybackAdmissionDecider(checker ActionChecker) playback.AdmissionDecider {
|
||||
return func(ctx context.Context, req playback.AdmissionRequest) (playback.AdmissionDecision, error) {
|
||||
requestedAction := RequestedActionDirectPlay
|
||||
if req.RequestedMethod == playback.PlayTranscode {
|
||||
if req.RequiresVideoTranscode {
|
||||
requestedAction = RequestedActionTranscode
|
||||
} else if req.RequiresAudioTranscode {
|
||||
requestedAction = RequestedActionAudioTranscode
|
||||
}
|
||||
decision, _, err := checker.CheckAction(ctx, ActionInput{
|
||||
SchemaVersion: 1,
|
||||
@@ -26,6 +28,8 @@ func NewPlaybackAdmissionDecider(checker ActionChecker) playback.AdmissionDecide
|
||||
UserID: req.UserID,
|
||||
MaxStreams: req.Limits.MaxStreams,
|
||||
MaxTranscodes: req.Limits.MaxTranscodes,
|
||||
TranscodeAllowed: !req.Limits.TranscodingDisabled,
|
||||
AudioTranscodeAllowed: !req.Limits.AudioTranscodingDisabled,
|
||||
CurrentActiveStreams: req.CurrentActiveStreams,
|
||||
CurrentActiveTranscodes: req.CurrentActiveTranscodes,
|
||||
RequestedAction: requestedAction,
|
||||
|
||||
Vendored
+16
@@ -51,8 +51,14 @@ download_transcode_decision(i) := allow if {
|
||||
} else := deny("content rating exceeded", "content_rating_exceeded")
|
||||
|
||||
playback_admission_decision(i) := allow if {
|
||||
transcode_allowed(i)
|
||||
stream_limit_allows(i)
|
||||
transcode_limit_allows(i)
|
||||
} else := deny("audio transcoding disabled for user", "audio_transcoding_disabled") if {
|
||||
requested_action(i) == "audio_transcode"
|
||||
not transcode_allowed(i)
|
||||
} else := deny("transcoding disabled for user", "transcoding_disabled") if {
|
||||
not transcode_allowed(i)
|
||||
} else := deny("max streams exceeded", "max_streams_exceeded") if {
|
||||
not stream_limit_allows(i)
|
||||
} else := deny("max transcodes exceeded", "max_transcodes_exceeded")
|
||||
@@ -94,6 +100,16 @@ transcode_enabled(i) if {
|
||||
object.get(i, "transcode_enabled", false) == true
|
||||
}
|
||||
|
||||
transcode_allowed(i) if {
|
||||
requested_action(i) != "transcode"
|
||||
requested_action(i) != "audio_transcode"
|
||||
} else if {
|
||||
object.get(i, "transcode_allowed", true) == true
|
||||
} else if {
|
||||
requested_action(i) == "audio_transcode"
|
||||
object.get(i, "audio_transcode_allowed", true) == true
|
||||
}
|
||||
|
||||
download_allowed(i) if {
|
||||
object.get(i, "download_allowed", false) == true
|
||||
}
|
||||
|
||||
+54
@@ -10,6 +10,8 @@ base_input := {
|
||||
"download_transcode_allowed": true,
|
||||
"max_streams": 2,
|
||||
"max_transcodes": 1,
|
||||
"transcode_allowed": true,
|
||||
"audio_transcode_allowed": true,
|
||||
"downloads_enabled": true,
|
||||
"transcode_enabled": true,
|
||||
"artifacts_available": true,
|
||||
@@ -155,6 +157,58 @@ test_playback_admission_rejects_transcode_limit_at_limit if {
|
||||
got.reason_code == "max_transcodes_exceeded"
|
||||
}
|
||||
|
||||
test_playback_admission_rejects_disabled_transcoding if {
|
||||
got := decision with input as object.union(base_input, {
|
||||
"action": "playback_admission",
|
||||
"requested_action": "transcode",
|
||||
"transcode_allowed": false,
|
||||
})
|
||||
not got.allowed
|
||||
got.reason == "transcoding disabled for user"
|
||||
got.reason_code == "transcoding_disabled"
|
||||
}
|
||||
|
||||
test_playback_admission_allows_direct_play_when_transcoding_disabled if {
|
||||
got := decision with input as object.union(base_input, {
|
||||
"action": "playback_admission",
|
||||
"requested_action": "direct_play",
|
||||
"transcode_allowed": false,
|
||||
})
|
||||
got.allowed
|
||||
}
|
||||
|
||||
test_playback_admission_allows_audio_transcode_when_video_transcoding_disabled if {
|
||||
got := decision with input as object.union(base_input, {
|
||||
"action": "playback_admission",
|
||||
"requested_action": "audio_transcode",
|
||||
"transcode_allowed": false,
|
||||
"audio_transcode_allowed": true,
|
||||
})
|
||||
got.allowed
|
||||
}
|
||||
|
||||
test_playback_admission_allows_audio_transcode_when_video_transcoding_enabled if {
|
||||
got := decision with input as object.union(base_input, {
|
||||
"action": "playback_admission",
|
||||
"requested_action": "audio_transcode",
|
||||
"transcode_allowed": true,
|
||||
"audio_transcode_allowed": false,
|
||||
})
|
||||
got.allowed
|
||||
}
|
||||
|
||||
test_playback_admission_rejects_disabled_audio_transcoding if {
|
||||
got := decision with input as object.union(base_input, {
|
||||
"action": "playback_admission",
|
||||
"requested_action": "audio_transcode",
|
||||
"transcode_allowed": false,
|
||||
"audio_transcode_allowed": false,
|
||||
})
|
||||
not got.allowed
|
||||
got.reason == "audio transcoding disabled for user"
|
||||
got.reason_code == "audio_transcoding_disabled"
|
||||
}
|
||||
|
||||
test_playback_admission_direct_ignores_transcode_limit if {
|
||||
got := decision with input as object.union(base_input, {
|
||||
"action": "playback_admission",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE public.users
|
||||
ADD COLUMN transcode_allowed boolean NOT NULL DEFAULT true;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE public.users
|
||||
DROP COLUMN transcode_allowed;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE public.users
|
||||
ADD COLUMN audio_transcode_allowed boolean NOT NULL DEFAULT true;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE public.users
|
||||
DROP COLUMN audio_transcode_allowed;
|
||||
@@ -2294,6 +2294,8 @@ export interface AdminUser {
|
||||
max_playback_quality: string;
|
||||
max_streams: number;
|
||||
max_transcodes: number;
|
||||
transcode_allowed: boolean;
|
||||
audio_transcode_allowed: boolean;
|
||||
max_profiles: number;
|
||||
download_allowed: boolean;
|
||||
download_transcode_allowed: boolean;
|
||||
@@ -2314,6 +2316,8 @@ export interface CreateUserRequest {
|
||||
max_playback_quality?: string;
|
||||
max_streams?: number;
|
||||
max_transcodes?: number;
|
||||
transcode_allowed?: boolean;
|
||||
audio_transcode_allowed?: boolean;
|
||||
max_profiles?: number;
|
||||
download_allowed?: boolean;
|
||||
download_transcode_allowed?: boolean;
|
||||
@@ -2331,6 +2335,8 @@ export interface UpdateUserRequest {
|
||||
max_playback_quality?: string;
|
||||
max_streams?: number;
|
||||
max_transcodes?: number;
|
||||
transcode_allowed?: boolean;
|
||||
audio_transcode_allowed?: boolean;
|
||||
max_profiles?: number;
|
||||
download_allowed?: boolean;
|
||||
download_transcode_allowed?: boolean;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Ban, Check } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
interface UserTranscodeLimitFieldProps {
|
||||
id: string;
|
||||
maxTranscodes: number;
|
||||
onMaxTranscodesChange: (value: number) => void;
|
||||
transcodeAllowed: boolean;
|
||||
onTranscodeAllowedChange: (allowed: boolean) => void;
|
||||
audioTranscodeAllowed: boolean;
|
||||
onAudioTranscodeAllowedChange: (allowed: boolean) => void;
|
||||
}
|
||||
|
||||
export function UserTranscodeLimitField({
|
||||
id,
|
||||
maxTranscodes,
|
||||
onMaxTranscodesChange,
|
||||
transcodeAllowed,
|
||||
onTranscodeAllowedChange,
|
||||
audioTranscodeAllowed,
|
||||
onAudioTranscodeAllowedChange,
|
||||
}: UserTranscodeLimitFieldProps) {
|
||||
const audioTranscodeId = `${id}-audio`;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={id}>Max Transcodes</Label>
|
||||
<div className="flex">
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxTranscodes}
|
||||
disabled={!transcodeAllowed}
|
||||
onChange={(event) => onMaxTranscodesChange(Number(event.target.value))}
|
||||
className="rounded-r-none"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant={transcodeAllowed ? "outline" : "secondary"}
|
||||
onClick={() => onTranscodeAllowedChange(!transcodeAllowed)}
|
||||
aria-label={transcodeAllowed ? "Disable video transcoding" : "Enable video transcoding"}
|
||||
aria-pressed={!transcodeAllowed}
|
||||
className="rounded-l-none border-l-0"
|
||||
>
|
||||
{transcodeAllowed ? <Ban /> : <Check />}
|
||||
{transcodeAllowed ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex min-h-6 items-center justify-between gap-3">
|
||||
<p
|
||||
className="text-muted-foreground min-w-0 truncate text-xs"
|
||||
title={transcodeAllowed ? undefined : "Video transcoding disabled"}
|
||||
>
|
||||
{transcodeAllowed ? "0 = unlimited" : "Video transcoding disabled"}
|
||||
</p>
|
||||
{!transcodeAllowed && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Label
|
||||
htmlFor={audioTranscodeId}
|
||||
className="text-muted-foreground text-xs"
|
||||
title="Allow audio conversion without video encoding"
|
||||
>
|
||||
Audio transcodes
|
||||
</Label>
|
||||
<Switch
|
||||
id={audioTranscodeId}
|
||||
checked={audioTranscodeAllowed}
|
||||
onCheckedChange={onAudioTranscodeAllowedChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,8 @@ const adminUser: AdminUser = {
|
||||
max_playback_quality: "source",
|
||||
max_streams: 0,
|
||||
max_transcodes: 0,
|
||||
transcode_allowed: true,
|
||||
audio_transcode_allowed: true,
|
||||
max_profiles: 4,
|
||||
download_allowed: true,
|
||||
download_transcode_allowed: true,
|
||||
@@ -145,18 +147,18 @@ function renderUserDetail() {
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
||||
installPointerCaptureMocks();
|
||||
mocks.updateUserMutate.mockReset();
|
||||
mocks.beginImpersonation.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("AdminUserDetail access group picker", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
||||
installPointerCaptureMocks();
|
||||
mocks.updateUserMutate.mockReset();
|
||||
mocks.beginImpersonation.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders group options and includes access_group_id in the save payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderUserDetail();
|
||||
@@ -180,3 +182,28 @@ describe("AdminUserDetail access group picker", () => {
|
||||
expect(call?.body.access_group_id).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AdminUserDetail transcode limits", () => {
|
||||
it("disables transcoding and includes the flag in the save payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderUserDetail();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await user.click(screen.getByRole("tab", { name: "Limits" }));
|
||||
expect(screen.queryByRole("switch", { name: "Audio transcodes" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Disable video transcoding" }));
|
||||
|
||||
expect(screen.getByText("Video transcoding disabled")).toBeInTheDocument();
|
||||
expect(screen.getByRole("spinbutton", { name: "Max Transcodes" })).toBeDisabled();
|
||||
const audioTranscodeSwitch = screen.getByRole("switch", { name: "Audio transcodes" });
|
||||
expect(audioTranscodeSwitch).toBeChecked();
|
||||
await user.click(audioTranscodeSwitch);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(mocks.updateUserMutate).toHaveBeenCalled());
|
||||
const call = mocks.updateUserMutate.mock.calls[0]?.[0] as UpdateUserMutationArg | undefined;
|
||||
expect(call?.body.transcode_allowed).toBe(false);
|
||||
expect(call?.body.audio_transcode_allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LibraryAccessSelector } from "@/components/LibraryAccessSelector";
|
||||
import { UserTranscodeLimitField } from "@/components/UserTranscodeLimitField";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -319,8 +320,20 @@ function OverviewTab({ user }: { user: AdminUser }) {
|
||||
/>
|
||||
<DetailRow
|
||||
label="Max Transcodes"
|
||||
value={user.max_transcodes === 0 ? "Unlimited" : String(user.max_transcodes)}
|
||||
value={
|
||||
!user.transcode_allowed
|
||||
? "Disabled"
|
||||
: user.max_transcodes === 0
|
||||
? "Unlimited"
|
||||
: String(user.max_transcodes)
|
||||
}
|
||||
/>
|
||||
{!user.transcode_allowed && (
|
||||
<DetailRow
|
||||
label="Audio Transcodes"
|
||||
value={user.audio_transcode_allowed ? "Allowed" : "Not allowed"}
|
||||
/>
|
||||
)}
|
||||
<DetailRow label="Max Profiles" value={String(user.max_profiles)} />
|
||||
<DetailRow label="Downloads" value={user.download_allowed ? "Allowed" : "Not allowed"} />
|
||||
<DetailRow
|
||||
@@ -918,6 +931,8 @@ function EditUserForm({ user, onClose }: { user: AdminUser; onClose: () => void
|
||||
const [accessGroupID, setAccessGroupID] = useState<number | null>(user.access_group_id);
|
||||
const [maxStreams, setMaxStreams] = useState(user.max_streams);
|
||||
const [maxTranscodes, setMaxTranscodes] = useState(user.max_transcodes);
|
||||
const [transcodeAllowed, setTranscodeAllowed] = useState(user.transcode_allowed);
|
||||
const [audioTranscodeAllowed, setAudioTranscodeAllowed] = useState(user.audio_transcode_allowed);
|
||||
const [maxProfiles, setMaxProfiles] = useState(user.max_profiles);
|
||||
const [maxPlaybackQualityPreset, setMaxPlaybackQualityPreset] = useState<PlaybackQualityPreset>(
|
||||
playbackQualityPresetFromValue(user.max_playback_quality),
|
||||
@@ -927,6 +942,7 @@ function EditUserForm({ user, onClose }: { user: AdminUser; onClose: () => void
|
||||
user.download_transcode_allowed,
|
||||
);
|
||||
const accessGroupSelectId = useId();
|
||||
const maxTranscodesId = useId();
|
||||
const markerEditId = useId();
|
||||
const metadataCurationId = useId();
|
||||
const updateMutation = useUpdateUser();
|
||||
@@ -946,6 +962,8 @@ function EditUserForm({ user, onClose }: { user: AdminUser; onClose: () => void
|
||||
access_group_id: accessGroupID,
|
||||
max_streams: maxStreams,
|
||||
max_transcodes: maxTranscodes,
|
||||
transcode_allowed: transcodeAllowed,
|
||||
audio_transcode_allowed: audioTranscodeAllowed,
|
||||
max_profiles: maxProfiles,
|
||||
max_playback_quality: playbackQualityValueFromPreset(maxPlaybackQualityPreset),
|
||||
download_allowed: downloadAllowed,
|
||||
@@ -1112,16 +1130,15 @@ function EditUserForm({ user, onClose }: { user: AdminUser; onClose: () => void
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">0 = unlimited</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Max Transcodes</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxTranscodes}
|
||||
onChange={(e) => setMaxTranscodes(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">0 = unlimited</p>
|
||||
</div>
|
||||
<UserTranscodeLimitField
|
||||
id={maxTranscodesId}
|
||||
maxTranscodes={maxTranscodes}
|
||||
onMaxTranscodesChange={setMaxTranscodes}
|
||||
transcodeAllowed={transcodeAllowed}
|
||||
onTranscodeAllowedChange={setTranscodeAllowed}
|
||||
audioTranscodeAllowed={audioTranscodeAllowed}
|
||||
onAudioTranscodeAllowedChange={setAudioTranscodeAllowed}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label>Max Profiles</Label>
|
||||
<Input
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@/hooks/queries/admin/users";
|
||||
import { useAdminLibraries } from "@/hooks/queries/admin/libraries";
|
||||
import { LibraryAccessSelector } from "@/components/LibraryAccessSelector";
|
||||
import { UserTranscodeLimitField } from "@/components/UserTranscodeLimitField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -522,6 +523,10 @@ function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => vo
|
||||
// access group is the source of default policy and composes on top.
|
||||
const [maxStreams, setMaxStreams] = useState<number>(user?.max_streams ?? 0);
|
||||
const [maxTranscodes, setMaxTranscodes] = useState<number>(user?.max_transcodes ?? 0);
|
||||
const [transcodeAllowed, setTranscodeAllowed] = useState(user?.transcode_allowed ?? true);
|
||||
const [audioTranscodeAllowed, setAudioTranscodeAllowed] = useState(
|
||||
user?.audio_transcode_allowed ?? true,
|
||||
);
|
||||
const [maxProfiles, setMaxProfiles] = useState<number>(user?.max_profiles ?? 5);
|
||||
const [maxPlaybackQualityPreset, setMaxPlaybackQualityPreset] = useState<PlaybackQualityPreset>(
|
||||
playbackQualityPresetFromValue(user?.max_playback_quality),
|
||||
@@ -559,6 +564,8 @@ function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => vo
|
||||
library_ids: libraryIDs,
|
||||
max_streams: maxStreams,
|
||||
max_transcodes: maxTranscodes,
|
||||
transcode_allowed: transcodeAllowed,
|
||||
audio_transcode_allowed: audioTranscodeAllowed,
|
||||
max_profiles: maxProfiles,
|
||||
max_playback_quality: playbackQualityValueFromPreset(maxPlaybackQualityPreset),
|
||||
download_allowed: downloadAllowed,
|
||||
@@ -576,6 +583,8 @@ function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => vo
|
||||
create_default_profile: true,
|
||||
max_streams: maxStreams,
|
||||
max_transcodes: maxTranscodes,
|
||||
transcode_allowed: transcodeAllowed,
|
||||
audio_transcode_allowed: audioTranscodeAllowed,
|
||||
max_profiles: maxProfiles,
|
||||
max_playback_quality: playbackQualityValueFromPreset(maxPlaybackQualityPreset) || undefined,
|
||||
download_allowed: downloadAllowed,
|
||||
@@ -739,17 +748,15 @@ function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => vo
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">0 = unlimited</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={maxTranscodesId}>Max Transcodes</Label>
|
||||
<Input
|
||||
id={maxTranscodesId}
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxTranscodes}
|
||||
onChange={(e) => setMaxTranscodes(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">0 = unlimited</p>
|
||||
</div>
|
||||
<UserTranscodeLimitField
|
||||
id={maxTranscodesId}
|
||||
maxTranscodes={maxTranscodes}
|
||||
onMaxTranscodesChange={setMaxTranscodes}
|
||||
transcodeAllowed={transcodeAllowed}
|
||||
onTranscodeAllowedChange={setTranscodeAllowed}
|
||||
audioTranscodeAllowed={audioTranscodeAllowed}
|
||||
onAudioTranscodeAllowedChange={setAudioTranscodeAllowed}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={maxProfilesId}>Max Profiles</Label>
|
||||
<Input
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { usePlayerConfig } from "../context/PlayerConfigContext";
|
||||
import { PlayerFetchError, playerFetch } from "../player-fetch";
|
||||
import { describeTranscodingPolicyError } from "../playback-errors";
|
||||
import {
|
||||
useCodecDetection,
|
||||
QUALITY_TO_RESOLUTION,
|
||||
@@ -119,6 +120,11 @@ function describePlaybackSessionError(
|
||||
error: unknown,
|
||||
fallbackMessage: string,
|
||||
): PlaybackSessionErrorState {
|
||||
const policyError = describeTranscodingPolicyError(error);
|
||||
if (policyError) {
|
||||
return policyError;
|
||||
}
|
||||
|
||||
if (error instanceof PlayerFetchError) {
|
||||
if (
|
||||
error.status === 404 &&
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { usePlayerConfig } from "../context/PlayerConfigContext";
|
||||
import { PlayerFetchError, playerFetch } from "../player-fetch";
|
||||
import { describeTranscodingPolicyError } from "../playback-errors";
|
||||
import type { PlayMethod, PlayerFileVersion, QualityOption, TranscodeStartRequest } from "../types";
|
||||
import { QUALITY_TO_RESOLUTION } from "./useCodecDetection";
|
||||
|
||||
@@ -442,8 +443,9 @@ export function useTranscodeQuality({
|
||||
setError("No lower resolution version available for transcoding");
|
||||
return;
|
||||
}
|
||||
const policyError = describeTranscodingPolicyError(err);
|
||||
if (playMethod === "transcode") {
|
||||
setError(`Couldn't start ${option.label}.`);
|
||||
setError(policyError?.message ?? `Couldn't start ${option.label}.`);
|
||||
} else {
|
||||
setActiveQualityId("original");
|
||||
setTranscodeStreamUrl(null);
|
||||
@@ -451,7 +453,7 @@ export function useTranscodeQuality({
|
||||
setStreamOriginSeconds(0);
|
||||
setCanSeekAnywhere(true);
|
||||
setDurationSeconds(null);
|
||||
setError(`Couldn't switch to ${option.label}.`);
|
||||
setError(policyError?.message ?? `Couldn't switch to ${option.label}.`);
|
||||
}
|
||||
} finally {
|
||||
if (!abortController.signal.aborted) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { describeTranscodingPolicyError } from "./playback-errors";
|
||||
import { PlayerFetchError } from "./player-fetch";
|
||||
|
||||
describe("describeTranscodingPolicyError", () => {
|
||||
it("describes disabled video transcoding", () => {
|
||||
const error = new PlayerFetchError(
|
||||
403,
|
||||
"Transcoding is disabled for your user",
|
||||
"transcoding_disabled",
|
||||
);
|
||||
|
||||
expect(describeTranscodingPolicyError(error)).toEqual({
|
||||
title: "Transcoding is disabled",
|
||||
message: "Transcoding is disabled for your user. Ask your server administrator for access.",
|
||||
});
|
||||
});
|
||||
|
||||
it("describes disabled audio transcoding", () => {
|
||||
const error = new PlayerFetchError(
|
||||
403,
|
||||
"Audio transcoding is disabled for your user",
|
||||
"audio_transcoding_disabled",
|
||||
);
|
||||
|
||||
expect(describeTranscodingPolicyError(error)).toEqual({
|
||||
title: "Audio transcoding is disabled",
|
||||
message:
|
||||
"This item requires audio conversion, but audio transcoding is disabled for your user.",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores unrelated player errors", () => {
|
||||
expect(
|
||||
describeTranscodingPolicyError(new PlayerFetchError(500, "Failed", "internal_error")),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { PlayerFetchError } from "./player-fetch";
|
||||
|
||||
export interface PlaybackPolicyErrorDescription {
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function describeTranscodingPolicyError(
|
||||
error: unknown,
|
||||
): PlaybackPolicyErrorDescription | null {
|
||||
if (!(error instanceof PlayerFetchError) || error.status !== 403) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error.code === "transcoding_disabled") {
|
||||
return {
|
||||
title: "Transcoding is disabled",
|
||||
message: "Transcoding is disabled for your user. Ask your server administrator for access.",
|
||||
};
|
||||
}
|
||||
|
||||
if (error.code === "audio_transcoding_disabled") {
|
||||
return {
|
||||
title: "Audio transcoding is disabled",
|
||||
message:
|
||||
"This item requires audio conversion, but audio transcoding is disabled for your user.",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user