diff --git a/cmd/silo/main.go b/cmd/silo/main.go index c5c76e5e..b0939170 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -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 } diff --git a/internal/access/groups.go b/internal/access/groups.go index 2214bbb6..a0552ab6 100644 --- a/internal/access/groups.go +++ b/internal/access/groups.go @@ -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), diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index 74205677..f5631e54 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -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, diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go index 4d2c657f..3dd61e28 100644 --- a/internal/api/handlers/playback.go +++ b/internal/api/handlers/playback.go @@ -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 diff --git a/internal/api/handlers/playback_test.go b/internal/api/handlers/playback_test.go index 7bcf5e9e..582c5e7c 100644 --- a/internal/api/handlers/playback_test.go +++ b/internal/api/handlers/playback_test.go @@ -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") diff --git a/internal/api/router.go b/internal/api/router.go index be29f4ef..b508072f 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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 { diff --git a/internal/audiobooks/abs/native_sessions.go b/internal/audiobooks/abs/native_sessions.go index aac17492..f0a3f910 100644 --- a/internal/audiobooks/abs/native_sessions.go +++ b/internal/audiobooks/abs/native_sessions.go @@ -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) diff --git a/internal/auth/repository.go b/internal/auth/repository.go index 0e4547a8..cc6e729c 100644 --- a/internal/auth/repository.go +++ b/internal/auth/repository.go @@ -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) diff --git a/internal/jellycompat/handlers_items.go b/internal/jellycompat/handlers_items.go index a1f87f36..7097a9d7 100644 --- a/internal/jellycompat/handlers_items.go +++ b/internal/jellycompat/handlers_items.go @@ -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 diff --git a/internal/models/user.go b/internal/models/user.go index b267cba1..7a96f8bc 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -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 diff --git a/internal/playback/errors.go b/internal/playback/errors.go index 0a27c7da..c4386253 100644 --- a/internal/playback/errors.go +++ b/internal/playback/errors.go @@ -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. diff --git a/internal/playback/session.go b/internal/playback/session.go index 9a4ca674..615b00d0 100644 --- a/internal/playback/session.go +++ b/internal/playback/session.go @@ -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() diff --git a/internal/playback/session_test.go b/internal/playback/session_test.go index 8d77fd2d..b73a4cbf 100644 --- a/internal/playback/session_test.go +++ b/internal/playback/session_test.go @@ -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", diff --git a/internal/playback/transcode_manager.go b/internal/playback/transcode_manager.go index e6681d9a..633c6d6c 100644 --- a/internal/playback/transcode_manager.go +++ b/internal/playback/transcode_manager.go @@ -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 diff --git a/internal/policy/action_parity_test.go b/internal/policy/action_parity_test.go index b6919204..154dae0a 100644 --- a/internal/policy/action_parity_test.go +++ b/internal/policy/action_parity_test.go @@ -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, diff --git a/internal/policy/input.go b/internal/policy/input.go index 4879e27c..40e83798 100644 --- a/internal/policy/input.go +++ b/internal/policy/input.go @@ -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"` diff --git a/internal/policy/playback_adapter.go b/internal/policy/playback_adapter.go index 09df330d..debb2f3b 100644 --- a/internal/policy/playback_adapter.go +++ b/internal/policy/playback_adapter.go @@ -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, diff --git a/internal/policy/vendor/action.rego b/internal/policy/vendor/action.rego index 50da9b73..20739ce1 100644 --- a/internal/policy/vendor/action.rego +++ b/internal/policy/vendor/action.rego @@ -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 } diff --git a/internal/policy/vendor/action_test.rego b/internal/policy/vendor/action_test.rego index 601ad4b9..5cdebba6 100644 --- a/internal/policy/vendor/action_test.rego +++ b/internal/policy/vendor/action_test.rego @@ -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", diff --git a/migrations/sql/20260710192230_add_user_transcode_allowed.sql b/migrations/sql/20260710192230_add_user_transcode_allowed.sql new file mode 100644 index 00000000..9c8016f7 --- /dev/null +++ b/migrations/sql/20260710192230_add_user_transcode_allowed.sql @@ -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; diff --git a/migrations/sql/20260710203429_add_user_audio_transcode_allowed.sql b/migrations/sql/20260710203429_add_user_audio_transcode_allowed.sql new file mode 100644 index 00000000..be24a044 --- /dev/null +++ b/migrations/sql/20260710203429_add_user_audio_transcode_allowed.sql @@ -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; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 85c11dd6..bd4dfa3c 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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; diff --git a/web/src/components/UserTranscodeLimitField.tsx b/web/src/components/UserTranscodeLimitField.tsx new file mode 100644 index 00000000..086a1bc0 --- /dev/null +++ b/web/src/components/UserTranscodeLimitField.tsx @@ -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 ( +
+ {transcodeAllowed ? "0 = unlimited" : "Video transcoding disabled"} +
+ {!transcodeAllowed && ( +0 = unlimited
-0 = unlimited
-0 = unlimited
0 = unlimited
-