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:
Suspense
2026-07-10 22:30:04 -04:00
committed by GitHub
co-authored by Quick104
parent 3fd0912cb3
commit 28c6ddc237
30 changed files with 772 additions and 81 deletions
-20
View File
@@ -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
}
+4
View File
@@ -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),
+12
View File
@@ -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,
+52
View File
@@ -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
+178
View File
@@ -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")
+4 -2
View File
@@ -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)
+23 -1
View File
@@ -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)
+8
View File
@@ -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
+6
View File
@@ -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
+6 -4
View File
@@ -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.
+47 -7
View File
@@ -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()
+78 -6
View File
@@ -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",
+4 -5
View File
@@ -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
+1
View File
@@ -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,
+6
View File
@@ -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"`
+5 -1
View File
@@ -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,
+16
View File
@@ -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
View File
@@ -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;
+6
View File
@@ -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>
);
}
+38 -11
View File
@@ -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);
});
});
+28 -11
View File
@@ -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
+18 -11
View File
@@ -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 &&
+4 -2
View File
@@ -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) {
+39
View File
@@ -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();
});
});
+31
View File
@@ -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;
}