feat(ai): per-user transcription quota for subtitle ASR jobs

Cap how many Whisper transcription jobs each user account can start per
rolling window (day/week/month), configurable from admin settings. The
player modal shows remaining usage and the server returns 429 with
details when the limit is hit.

Enforcement is atomic with the job insert (per-user advisory lock, same
pattern as media-request quotas), so concurrent requests cannot race
past the limit. Failed/cancelled jobs that never produced transcription
work are refunded. Exemption applies to the admin account's primary
profile only; other profiles on an admin account stay subject to the
quota. A partial index covers the quota count, a malformed quota
setting row degrades to "no quota" instead of blocking startup, and the
period vocabulary and admin-role predicate are each defined once
(ai.ValidQuotaPeriod, apimw.IsAdmin).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-06-10 16:02:42 -04:00
co-authored by Claude Fable 5
parent 39ba284c9d
commit ed4cebf3ba
18 changed files with 602 additions and 24 deletions
+14 -1
View File
@@ -20,8 +20,8 @@ import (
"golang.org/x/sync/errgroup"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/ai/llm"
"github.com/Silo-Server/silo-server/internal/adminjob"
"github.com/Silo-Server/silo-server/internal/ai/llm"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/cache"
@@ -30,6 +30,7 @@ import (
"github.com/Silo-Server/silo-server/internal/markers"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/notifications"
subtitleai "github.com/Silo-Server/silo-server/internal/subtitles/ai"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -2117,6 +2118,18 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
"metadata_ai.on_view must be off, button, or auto")
return
}
case "subtitle_ai.transcribe_quota_jobs":
if n, err := strconv.Atoi(req.Value); err != nil || n < 0 {
writeError(w, http.StatusBadRequest, "bad_request",
"subtitle_ai.transcribe_quota_jobs must be an integer >= 0 (0 = unlimited)")
return
}
case "subtitle_ai.transcribe_quota_period":
if !subtitleai.ValidQuotaPeriod(req.Value) {
writeError(w, http.StatusBadRequest, "bad_request",
"subtitle_ai.transcribe_quota_period must be day, week, or month")
return
}
}
if err := h.SettingsRepo.Set(r.Context(), key, req.Value); err != nil {
+2 -7
View File
@@ -124,11 +124,6 @@ type verifyPINResponse struct {
ExpiresAt string `json:"expires_at,omitempty"`
}
func isAdminProfileRequest(ctx context.Context) bool {
claims := apimw.GetClaims(ctx)
return claims != nil && claims.Role == "admin"
}
// canManageHouseholdProfiles reports whether the caller may create/update/delete
// profiles belonging to their user. Server admins always can. Otherwise the
// caller's active profile must be the primary profile for the household.
@@ -138,7 +133,7 @@ func isAdminProfileRequest(ctx context.Context) bool {
// bypass the profile lock by sending only X-Profile-Id.
func (h *ProfileHandler) canManageHouseholdProfiles(r *http.Request, store userstore.UserStore) (bool, error) {
ctx := r.Context()
if isAdminProfileRequest(ctx) {
if apimw.IsAdmin(ctx) {
return true, nil
}
activeProfileID := apimw.GetProfileID(ctx)
@@ -316,7 +311,7 @@ func (h *ProfileHandler) HandleCreateProfile(w http.ResponseWriter, r *http.Requ
// Access-policy fields only make sense when set by a manager on a managed
// profile. On bootstrap the caller is becoming primary themselves, so non-
// admin bootstrap creations must leave those fields at their defaults.
if isBootstrap && !isAdminProfileRequest(r.Context()) &&
if isBootstrap && !apimw.IsAdmin(r.Context()) &&
(req.IsChild || req.MaxContentRating != "" ||
req.LibraryRestrictionsEnabled || len(req.AllowedLibraryIDs) > 0 ||
req.MaxPlaybackQuality != "") {
+62
View File
@@ -3,6 +3,7 @@ package handlers
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
@@ -12,6 +13,7 @@ import (
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/subtitles/ai"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// SubtitleAIHandler exposes on-demand AI subtitle translation backed by the
@@ -21,6 +23,9 @@ import (
type SubtitleAIHandler struct {
service *ai.Service
FileAuthorizer *MediaFileAuthorizer
// StoreProvider resolves household profiles for the transcription quota
// exemption check; when nil the whole admin account is exempt.
StoreProvider userstore.UserStoreProvider
}
// NewSubtitleAIHandler creates a handler backed by the given service.
@@ -117,6 +122,7 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
SourceLanguage: req.SourceLanguage,
TargetLanguage: req.TargetLanguage,
RequestedBy: requestedBy,
QuotaExempt: h.quotaExempt(r),
SessionID: req.SessionID,
StartPosition: req.StartPosition,
})
@@ -125,6 +131,8 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
case errors.Is(err, ai.ErrEngineNotConfigured):
writeError(w, http.StatusServiceUnavailable, "not_configured",
"AI subtitle translation is not configured on this server")
case errors.Is(err, ai.ErrQuotaExceeded):
writeError(w, http.StatusTooManyRequests, "quota_exceeded", quotaExceededMessage(err))
case errors.Is(err, ai.ErrInvalidRequest):
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
default:
@@ -138,6 +146,60 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusAccepted, map[string]any{"job": job})
}
// quotaExempt reports whether the caller is exempt from the transcription
// quota: an admin account acting as itself (no profile context) or through
// its primary (household parent) profile. Other profiles on an admin account
// — e.g. a kid's profile — stay subject to the quota even though the account
// holds the admin role. X-Profile-Id is client-asserted, so this is a
// budgeting gate, not a security boundary; the quota itself is account-scoped.
func (h *SubtitleAIHandler) quotaExempt(r *http.Request) bool {
ctx := r.Context()
if !apimw.IsAdmin(ctx) {
return false
}
profileID := apimw.GetProfileID(ctx)
if profileID == "" {
profileID = r.Header.Get("X-Profile-Id")
}
if profileID == "" || h.StoreProvider == nil {
return true
}
store, err := h.StoreProvider.ForUser(ctx, apimw.GetUserID(ctx))
if err != nil {
return false // fail closed: the quota still applies
}
profile, err := store.GetProfile(ctx, profileID)
if err != nil || profile == nil {
return false
}
return profile.IsPrimary
}
// quotaExceededMessage turns a quota error into a user-facing message with the
// limit and window when available.
func quotaExceededMessage(err error) string {
var qe *ai.QuotaExceededError
if errors.As(err, &qe) {
return fmt.Sprintf(
"You've reached your transcription limit (%d per %s). Try again later or ask an admin to raise it.",
qe.Limit, qe.Period)
}
return "You've reached your transcription limit. Try again later or ask an admin to raise it."
}
// HandleQuota reports the calling user's transcription quota usage, so the
// player can show how many jobs they have left before they start one.
// GET /api/v1/subtitles/ai/quota
func (h *SubtitleAIHandler) HandleQuota(w http.ResponseWriter, r *http.Request) {
quota, err := h.service.TranscribeQuota(r.Context(), apimw.GetUserID(r.Context()), h.quotaExempt(r))
if err != nil {
slog.Error("failed to load transcription quota", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load transcription quota")
return
}
writeJSON(w, http.StatusOK, quota)
}
// HandleGetJob returns a job's current state. GET /api/v1/subtitles/ai/jobs/{job_id}
func (h *SubtitleAIHandler) HandleGetJob(w http.ResponseWriter, r *http.Request) {
job, ok := h.loadAuthorizedJob(w, r)
+8
View File
@@ -181,6 +181,14 @@ func GetClaims(ctx context.Context) *auth.Claims {
return claims
}
// IsAdmin reports whether the context's authenticated user account has the
// admin role. Returns false when no claims are present. Note this is the
// account-level role; it says nothing about which household profile is active.
func IsAdmin(ctx context.Context) bool {
claims := GetClaims(ctx)
return claims != nil && claims.Role == "admin"
}
// GetUserID retrieves the user ID from the JWT claims in the context.
// Returns 0 if no claims are present.
func GetUserID(ctx context.Context) int {
+15 -7
View File
@@ -194,6 +194,10 @@ func NewRouter(deps Dependencies) chi.Router {
r.Use(middleware.Recoverer)
r.Use(apimw.Metrics)
// Compress text-like responses (JSON, SVG, …); media content types are
// not in the middleware's allowlist and stream through untouched.
r.Use(middleware.Compress(5))
// Activity logging (before auth — captures all requests including failed auth).
if deps.ActivityLogWriter != nil {
r.Use(activitylog.NewMiddleware(deps.ActivityLogWriter, deps.NodeID))
@@ -944,13 +948,15 @@ func NewRouter(deps Dependencies) chi.Router {
transcribeEnabled = false
}
aiCfg := subtitleai.Config{
Configured: deps.Config.AI.BaseURL != "",
TranslateEnabled: deps.Config.SubtitleAI.Enabled,
TranscribeEnabled: transcribeEnabled,
ChatModel: deps.Config.AI.ChatModel,
ASRModel: deps.Config.AI.ASRModel,
BatchSize: deps.Config.SubtitleAI.BatchSize,
ContextNeighbors: deps.Config.SubtitleAI.ContextNeighbors,
Configured: deps.Config.AI.BaseURL != "",
TranslateEnabled: deps.Config.SubtitleAI.Enabled,
TranscribeEnabled: transcribeEnabled,
ChatModel: deps.Config.AI.ChatModel,
ASRModel: deps.Config.AI.ASRModel,
BatchSize: deps.Config.SubtitleAI.BatchSize,
ContextNeighbors: deps.Config.SubtitleAI.ContextNeighbors,
TranscribeQuotaJobs: deps.Config.SubtitleAI.TranscribeQuotaJobs,
TranscribeQuotaPeriod: deps.Config.SubtitleAI.TranscribeQuotaPeriod,
}
var aiNotifier subtitleai.Notifier
if subtitleAINotifier != nil {
@@ -972,6 +978,7 @@ func NewRouter(deps Dependencies) chi.Router {
)
aiService.Recover()
subtitleAIHandler = handlers.NewSubtitleAIHandler(aiService)
subtitleAIHandler.StoreProvider = deps.UserStoreProvider
}
// Metadata AI translation (descriptions into the localization tables).
@@ -1862,6 +1869,7 @@ func NewRouter(deps Dependencies) chi.Router {
r.Post("/detect-language", subtitleSearchHandler.HandleDetectLanguage)
if subtitleAIHandler != nil {
r.Get("/ai/status", subtitleAIHandler.HandleStatus)
r.Get("/ai/quota", subtitleAIHandler.HandleQuota)
r.Post("/ai/translate", subtitleAIHandler.HandleTranslate)
r.Get("/ai/jobs", subtitleAIHandler.HandleListJobs)
r.Get("/ai/jobs/{job_id}", subtitleAIHandler.HandleGetJob)
+5
View File
@@ -273,6 +273,11 @@ type SubtitleAIConfig struct {
// (60..600). Shorter chunks bound Whisper timestamp drift on long files;
// longer chunks mean fewer requests and fewer boundary word-clips.
ASRChunkSeconds int `yaml:"-"`
// TranscribeQuotaJobs caps how many transcription jobs each non-admin user
// may start per rolling quota period (0 = unlimited).
TranscribeQuotaJobs int `yaml:"-"`
// TranscribeQuotaPeriod is the rolling quota window: "day", "week", "month".
TranscribeQuotaPeriod string `yaml:"-"`
}
// MetadataAIConfig holds the metadata translation feature toggles. The
+17
View File
@@ -7,6 +7,8 @@ import (
"time"
"github.com/google/uuid"
subtitleai "github.com/Silo-Server/silo-server/internal/subtitles/ai"
)
// stringOr returns the value from the map for the given key, or the fallback if absent/empty.
@@ -470,6 +472,21 @@ func LoadFromDB(m map[string]string) (*Config, error) {
return nil, err
}
cfg.SubtitleAI.ASRChunkSeconds = subtitleAIChunkSeconds
// A bad quota row must not block startup: fall back to "no quota" /
// the daily window with a warning instead of failing the config load.
transcribeQuotaJobs, err := intOr(m, "subtitle_ai.transcribe_quota_jobs", 0)
if err != nil || transcribeQuotaJobs < 0 {
slog.Warn("invalid subtitle_ai.transcribe_quota_jobs setting; quota disabled",
"value", m["subtitle_ai.transcribe_quota_jobs"], "error", err)
transcribeQuotaJobs = 0
}
cfg.SubtitleAI.TranscribeQuotaJobs = transcribeQuotaJobs
period := stringOr(m, "subtitle_ai.transcribe_quota_period", subtitleai.QuotaPeriodDay)
if !subtitleai.ValidQuotaPeriod(period) {
slog.Warn("invalid subtitle_ai.transcribe_quota_period setting; using day", "value", period)
period = subtitleai.QuotaPeriodDay
}
cfg.SubtitleAI.TranscribeQuotaPeriod = period
// Metadata AI translation feature toggles.
metadataAIEnabled, err := boolOr(m, "metadata_ai.enabled", false)
+7
View File
@@ -17,6 +17,13 @@ type Config struct {
ASRModel string // audio-transcription model used for ASR
BatchSize int // cues per translation request
ContextNeighbors int // preceding source cues sent as untranslated context
// TranscribeQuotaJobs caps how many transcription jobs (transcribe /
// transcribe_translate) each non-admin user may start per rolling quota
// period; 0 means unlimited.
TranscribeQuotaJobs int
// TranscribeQuotaPeriod is the rolling window the quota counts against:
// "day", "week", or "month".
TranscribeQuotaPeriod string
}
// TranslateReady reports whether subtitle translation can currently run.
+4
View File
@@ -78,6 +78,10 @@ type JobRequest struct {
SourceLanguage string
TargetLanguage string
RequestedBy *int
// QuotaExempt skips the per-user transcription quota. The HTTP layer
// decides exemption policy (the household parent of an admin account
// manages the budget, so it is not bound by it); the service enforces.
QuotaExempt bool
// SessionID, when set, streams live cues to that playback session.
SessionID string
// StartPosition (seconds) makes translation start at the viewer's playhead.
+66 -2
View File
@@ -37,14 +37,69 @@ func scanJob(row pgx.Row) (*Job, error) {
return &j, nil
}
func (r *PgJobRepository) InsertJob(ctx context.Context, job *Job) error {
// transcribeQuotaCountSQL counts a user's quota-consuming transcription jobs.
// Failed/cancelled rows with zero progress never did ASR work (engine
// unreachable, bad media file, cancelled before start) and are excluded so
// server-side faults don't lock the user out; terminal rows with progress > 0
// consumed compute and still count. Shared by the quota status endpoint and
// the insert-time guard so the number shown always matches the one enforced.
const transcribeQuotaCountSQL = `SELECT count(*) FROM subtitle_ai_jobs
WHERE requested_by = $1 AND created_at >= $2
AND kind IN ('transcribe', 'transcribe_translate')
AND NOT (status IN ('failed', 'cancelled') AND progress = 0)`
// transcribeQuotaLockNamespace partitions advisory locks so the transcription
// quota's per-user lock cannot collide with advisory locks held elsewhere in
// the database (media requests use 139, autoscan 900173). The value is
// arbitrary; what matters is that it is stable.
const transcribeQuotaLockNamespace = 412
func (r *PgJobRepository) InsertJob(ctx context.Context, job *Job, quota *JobQuota) error {
if job.Engine == "" {
job.Engine = "openai"
}
if job.Status == "" {
job.Status = JobStatusPending
}
return r.pool.QueryRow(ctx,
if quota == nil {
return r.insertJob(ctx, r.pool, job)
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin insert subtitle ai job: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op after commit
// Serialize check-and-insert per user so concurrent requests cannot race
// past the limit (same pattern as the media-request quota).
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1::int4, $2::int4)`,
transcribeQuotaLockNamespace, quota.UserID); err != nil {
return fmt.Errorf("acquire transcription quota lock: %w", err)
}
var used int
if err := tx.QueryRow(ctx, transcribeQuotaCountSQL, quota.UserID, quota.Since).Scan(&used); err != nil {
return fmt.Errorf("count transcribe jobs for quota: %w", err)
}
if used >= quota.Limit {
return &QuotaExceededError{Limit: quota.Limit, Used: used, Period: quota.Period}
}
if err := r.insertJob(ctx, tx, job); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit insert subtitle ai job: %w", err)
}
return nil
}
// rowQuerier abstracts pool vs transaction for insertJob.
type rowQuerier interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
func (r *PgJobRepository) insertJob(ctx context.Context, q rowQuerier, job *Job) error {
return q.QueryRow(ctx,
`INSERT INTO subtitle_ai_jobs
(media_file_id, kind, source_index, source_language, target_language,
engine, model, status, progress, progress_message, idempotency_key, requested_by)
@@ -101,6 +156,15 @@ func (r *PgJobRepository) ListJobsByMediaFile(ctx context.Context, mediaFileID i
return jobs, rows.Err()
}
func (r *PgJobRepository) CountTranscribeJobsByUserSince(ctx context.Context, userID int, since time.Time) (int, error) {
var count int
err := r.pool.QueryRow(ctx, transcribeQuotaCountSQL, userID, since).Scan(&count)
if err != nil {
return 0, fmt.Errorf("count transcribe jobs by user: %w", err)
}
return count, nil
}
// UpdateProgress, CompleteJob, and FailJob only transition a job that is still
// active ("pending"/"running"). The guard makes them no-ops on an already
// terminal row, so a job that was cancelled or reaped as stale can never be
+127
View File
@@ -0,0 +1,127 @@
package ai
import (
"context"
"errors"
"fmt"
"time"
)
// ErrQuotaExceeded is returned when a transcription request would exceed the
// per-user job quota, so handlers can map it to 429.
var ErrQuotaExceeded = errors.New("transcription quota exceeded")
// Quota period names, stored in subtitle_ai.transcribe_quota_period. Periods
// are rolling windows ending now, not calendar buckets, so there is no reset
// moment to game.
const (
QuotaPeriodDay = "day"
QuotaPeriodWeek = "week"
QuotaPeriodMonth = "month"
)
// ValidQuotaPeriod reports whether period names a supported quota window.
// The settings write path and the config loader both validate against this,
// so the period vocabulary lives in one place.
func ValidQuotaPeriod(period string) bool {
switch period {
case QuotaPeriodDay, QuotaPeriodWeek, QuotaPeriodMonth:
return true
}
return false
}
// QuotaPeriodWindow maps a period name to its rolling window length. Unknown
// values fall back to a day, matching the config loader's default.
func QuotaPeriodWindow(period string) time.Duration {
switch period {
case QuotaPeriodWeek:
return 7 * 24 * time.Hour
case QuotaPeriodMonth:
return 30 * 24 * time.Hour
default:
return 24 * time.Hour
}
}
// JobQuota is the per-user cap InsertJob enforces atomically with the insert,
// so concurrent requests cannot race past the limit. A nil *JobQuota means
// the insert is unconditional.
type JobQuota struct {
UserID int
Limit int
Since time.Time
Period string // window name, carried into QuotaExceededError for messages
}
// QuotaExceededError carries the usage details behind ErrQuotaExceeded.
type QuotaExceededError struct {
Limit int
Used int
Period string
}
func (e *QuotaExceededError) Error() string {
return fmt.Sprintf("transcription limit reached: %d jobs per %s", e.Limit, e.Period)
}
func (e *QuotaExceededError) Unwrap() error { return ErrQuotaExceeded }
// QuotaStatus reports a user's transcription quota usage. Limited is false
// when no quota applies to the caller (quota disabled, or an exempt
// requester); the remaining fields are only meaningful when it is true.
type QuotaStatus struct {
Limited bool `json:"limited"`
Limit int `json:"limit"`
Used int `json:"used"`
Remaining int `json:"remaining"`
Period string `json:"period"`
}
// TranscribeQuota returns the transcription quota status for a user. Exempt
// callers always get an unlimited status. The count uses the same query the
// insert-time enforcement runs, so the number shown always matches the
// number enforced.
func (s *Service) TranscribeQuota(ctx context.Context, userID int, exempt bool) (QuotaStatus, error) {
if exempt || s.cfg.TranscribeQuotaJobs <= 0 {
return QuotaStatus{}, nil
}
used, err := s.repo.CountTranscribeJobsByUserSince(ctx, userID, s.quotaWindowStart())
if err != nil {
return QuotaStatus{}, err
}
remaining := s.cfg.TranscribeQuotaJobs - used
if remaining < 0 {
remaining = 0
}
return QuotaStatus{
Limited: true,
Limit: s.cfg.TranscribeQuotaJobs,
Used: used,
Remaining: remaining,
Period: s.cfg.TranscribeQuotaPeriod,
}, nil
}
// transcribeQuotaSpec returns the quota InsertJob must enforce for this
// request, or nil when none applies: translation-only jobs, exempt
// requesters, requests without an attributable user (internal callers), and
// servers with the quota disabled all pass through.
func (s *Service) transcribeQuotaSpec(req JobRequest) *JobQuota {
if !req.Kind.IsTranscribe() || req.QuotaExempt || req.RequestedBy == nil {
return nil
}
if s.cfg.TranscribeQuotaJobs <= 0 {
return nil
}
return &JobQuota{
UserID: *req.RequestedBy,
Limit: s.cfg.TranscribeQuotaJobs,
Since: s.quotaWindowStart(),
Period: s.cfg.TranscribeQuotaPeriod,
}
}
func (s *Service) quotaWindowStart() time.Time {
return time.Now().Add(-QuotaPeriodWindow(s.cfg.TranscribeQuotaPeriod))
}
+11 -1
View File
@@ -7,12 +7,22 @@ import (
// JobRepository persists AI subtitle jobs.
type JobRepository interface {
InsertJob(ctx context.Context, job *Job) error
// InsertJob inserts a new job. When quota is non-nil it enforces the
// per-user transcription cap atomically with the insert (serialized per
// user), returning a *QuotaExceededError instead of inserting once the
// user has quota.Limit countable jobs since quota.Since.
InsertJob(ctx context.Context, job *Job, quota *JobQuota) error
GetJob(ctx context.Context, id int64) (*Job, error)
// GetActiveJobByIdempotencyKey returns a pending/running job with the given
// key, or nil if none exists.
GetActiveJobByIdempotencyKey(ctx context.Context, key string) (*Job, error)
ListJobsByMediaFile(ctx context.Context, mediaFileID int) ([]Job, error)
// CountTranscribeJobsByUserSince counts the user's quota-consuming
// transcription jobs (transcribe / transcribe_translate) created at or
// after `since`. Failed or cancelled jobs that never produced any
// transcription work are excluded, so server-side faults don't lock the
// user out; jobs that burned ASR compute count even if they later failed.
CountTranscribeJobsByUserSince(ctx context.Context, userID int, since time.Time) (int, error)
UpdateProgress(ctx context.Context, id int64, status JobStatus, progress float64, message string) error
CompleteJob(ctx context.Context, id int64, subtitleID int) error
FailJob(ctx context.Context, id int64, status JobStatus, message string) error
+9 -1
View File
@@ -171,6 +171,12 @@ func (s *Service) Enqueue(ctx context.Context, req JobRequest) (*Job, error) {
return existing, nil
}
// Quota gates only the creation of a new job: collapsing onto an existing
// in-flight job above costs nothing, so it stays allowed at the limit.
// Enforcement happens inside InsertJob, atomically with the insert, so
// concurrent requests cannot race past the limit.
quota := s.transcribeQuotaSpec(req)
job := &Job{
MediaFileID: req.MediaFileID,
Kind: req.Kind,
@@ -186,8 +192,10 @@ func (s *Service) Enqueue(ctx context.Context, req JobRequest) (*Job, error) {
SessionID: req.SessionID,
StartPosition: req.StartPosition,
}
if err := s.repo.InsertJob(ctx, job); err != nil {
if err := s.repo.InsertJob(ctx, job, quota); err != nil {
// A racing duplicate trips the partial unique index; return the winner.
// The same lookup also covers a quota rejection that raced an identical
// request: collapsing onto the winner is free, so it stays allowed.
if existing, lookupErr := s.repo.GetActiveJobByIdempotencyKey(ctx, key); lookupErr == nil && existing != nil {
return existing, nil
}
+140 -3
View File
@@ -2,27 +2,49 @@ package ai
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/ai/jobrunner"
"github.com/Silo-Server/silo-server/internal/models"
)
// recordingRepo is a JobRepository that records ResetStaleJobs calls and no-ops
// everything else, so the recovery/reaper behavior can be tested in isolation.
// recordingRepo is a JobRepository that records ResetStaleJobs calls and
// insert/quota-count activity and no-ops everything else, so service behavior
// can be tested in isolation.
type recordingRepo struct {
mu sync.Mutex
resets int
lastBefore time.Time
inserts int
quotaUsed int // returned by CountTranscribeJobsByUserSince
}
func (r *recordingRepo) InsertJob(context.Context, *Job) error { return nil }
// InsertJob mirrors the Postgres repo's atomic quota guard: a non-nil quota
// is checked against quotaUsed before the insert, so the tests exercise the
// same enforcement path production uses.
func (r *recordingRepo) InsertJob(_ context.Context, job *Job, quota *JobQuota) error {
r.mu.Lock()
defer r.mu.Unlock()
if quota != nil && r.quotaUsed >= quota.Limit {
return &QuotaExceededError{Limit: quota.Limit, Used: r.quotaUsed, Period: quota.Period}
}
r.inserts++
job.ID = int64(r.inserts)
return nil
}
func (r *recordingRepo) GetJob(context.Context, int64) (*Job, error) { return nil, nil }
func (r *recordingRepo) GetActiveJobByIdempotencyKey(context.Context, string) (*Job, error) {
return nil, nil
}
func (r *recordingRepo) ListJobsByMediaFile(context.Context, int) ([]Job, error) { return nil, nil }
func (r *recordingRepo) CountTranscribeJobsByUserSince(context.Context, int, time.Time) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
return r.quotaUsed, nil
}
func (r *recordingRepo) UpdateProgress(context.Context, int64, JobStatus, float64, string) error {
return nil
}
@@ -44,6 +66,121 @@ func (r *recordingRepo) snapshot() (int, time.Time) {
return r.resets, r.lastBefore
}
// stubTranscriber satisfies Transcriber so TranscribeEnabled() is true in
// tests; it returns no cues.
type stubTranscriber struct{}
func (stubTranscriber) Transcribe(context.Context, TranscribeJobRequest,
func([]SubtitleCue, int, int)) ([]SubtitleCue, string, error) {
return nil, "", nil
}
// nilFileResolver reports every media file as missing, so dispatched test jobs
// fail fast instead of touching the filesystem.
type nilFileResolver struct{}
func (nilFileResolver) GetByID(context.Context, int) (*models.MediaFile, error) { return nil, nil }
// newQuotaTestService builds a transcribe-ready service whose repo reports
// `used` transcription jobs in the current quota window.
func newQuotaTestService(t *testing.T, used, limit int) (*Service, *recordingRepo) {
t.Helper()
repo := &recordingRepo{quotaUsed: used}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
cfg := Config{
Configured: true,
TranscribeEnabled: true,
ASRModel: "whisper-1",
TranscribeQuotaJobs: limit,
TranscribeQuotaPeriod: QuotaPeriodDay,
}
svc := NewService(ctx, cfg, repo, nil, stubTranscriber{}, nil, nil, nilFileResolver{}, nil, "", nil, nil)
return svc, repo
}
func transcribeRequest(userID int, exempt bool) JobRequest {
return JobRequest{
MediaFileID: 1,
Kind: JobKindTranscribe,
SourceIndex: -1,
RequestedBy: &userID,
QuotaExempt: exempt,
}
}
func TestEnqueueRejectsTranscribeOverQuota(t *testing.T) {
svc, repo := newQuotaTestService(t, 3, 3)
_, err := svc.Enqueue(context.Background(), transcribeRequest(42, false))
if !errors.Is(err, ErrQuotaExceeded) {
t.Fatalf("Enqueue error = %v, want ErrQuotaExceeded", err)
}
var qe *QuotaExceededError
if !errors.As(err, &qe) {
t.Fatalf("error %v does not unwrap to *QuotaExceededError", err)
}
if qe.Limit != 3 || qe.Used != 3 || qe.Period != QuotaPeriodDay {
t.Errorf("quota error = %+v, want limit=3 used=3 period=day", qe)
}
if repo.inserts != 0 {
t.Errorf("job was inserted despite exceeded quota (inserts=%d)", repo.inserts)
}
}
func TestEnqueueAllowsTranscribeUnderQuota(t *testing.T) {
svc, repo := newQuotaTestService(t, 2, 3)
if _, err := svc.Enqueue(context.Background(), transcribeRequest(42, false)); err != nil {
t.Fatalf("Enqueue under quota failed: %v", err)
}
if repo.inserts != 1 {
t.Errorf("inserts = %d, want 1", repo.inserts)
}
}
func TestEnqueueQuotaExemptions(t *testing.T) {
t.Run("exempt", func(t *testing.T) {
svc, repo := newQuotaTestService(t, 10, 1)
if _, err := svc.Enqueue(context.Background(), transcribeRequest(42, true)); err != nil {
t.Fatalf("exempt Enqueue blocked by quota: %v", err)
}
if repo.inserts != 1 {
t.Errorf("inserts = %d, want 1", repo.inserts)
}
})
t.Run("unlimited", func(t *testing.T) {
svc, repo := newQuotaTestService(t, 10, 0)
if _, err := svc.Enqueue(context.Background(), transcribeRequest(42, false)); err != nil {
t.Fatalf("Enqueue with quota disabled failed: %v", err)
}
if repo.inserts != 1 {
t.Errorf("inserts = %d, want 1", repo.inserts)
}
})
}
func TestTranscribeQuotaStatus(t *testing.T) {
svc, _ := newQuotaTestService(t, 2, 5)
got, err := svc.TranscribeQuota(context.Background(), 42, false)
if err != nil {
t.Fatalf("TranscribeQuota: %v", err)
}
want := QuotaStatus{Limited: true, Limit: 5, Used: 2, Remaining: 3, Period: QuotaPeriodDay}
if got != want {
t.Errorf("TranscribeQuota = %+v, want %+v", got, want)
}
exempt, err := svc.TranscribeQuota(context.Background(), 42, true)
if err != nil {
t.Fatalf("TranscribeQuota (exempt): %v", err)
}
if exempt.Limited {
t.Errorf("exempt quota = %+v, want unlimited", exempt)
}
}
// Recover reaps immediately using a heartbeat cutoff of now-staleJobThreshold,
// not "every active job", so a live worker's jobs survive a peer's startup.
func TestRecoverReapsStaleJobsImmediately(t *testing.T) {
@@ -0,0 +1,15 @@
-- +goose Up
-- +goose StatementBegin
-- The transcription quota counts a user's recent transcription jobs on every
-- enqueue and every quota lookup (player translate modal). Job rows are never
-- deleted, so without an index that count degrades to a full sequential scan
-- as job history grows.
CREATE INDEX subtitle_ai_jobs_quota_idx
ON public.subtitle_ai_jobs (requested_by, created_at)
WHERE kind IN ('transcribe', 'transcribe_translate');
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS subtitle_ai_jobs_quota_idx;
-- +goose StatementEnd
+10
View File
@@ -0,0 +1,10 @@
// Rolling-window labels for the subtitle-AI transcription quota periods.
// One source of truth for every surface that names a period; keep in sync
// with QuotaPeriodWindow in internal/subtitles/ai/quota.go.
export const QUOTA_PERIODS = ["day", "week", "month"] as const;
export const QUOTA_PERIOD_WINDOW_LABELS: Record<string, string> = {
day: "24 hours",
week: "7 days",
month: "30 days",
};
@@ -7,6 +7,7 @@ import {
} from "@/hooks/queries/admin/settings";
import { Button } from "@/components/ui/button";
import { QUOTA_PERIODS, QUOTA_PERIOD_WINDOW_LABELS } from "@/lib/quotaPeriods";
import { CredentialStatus } from "./CredentialStatus";
import { SettingField } from "./SettingField";
@@ -264,6 +265,8 @@ function AIFeaturesCard() {
const [batchSize, setBatchSize] = useState("40");
const [contextNeighbors, setContextNeighbors] = useState("2");
const [asrChunkSeconds, setAsrChunkSeconds] = useState("600");
const [transcribeQuotaJobs, setTranscribeQuotaJobs] = useState("0");
const [transcribeQuotaPeriod, setTranscribeQuotaPeriod] = useState("day");
useEffect(() => {
if (!settings) return;
@@ -274,6 +277,8 @@ function AIFeaturesCard() {
setBatchSize(settings["subtitle_ai.batch_size"] ?? "40");
setContextNeighbors(settings["subtitle_ai.context_neighbors"] ?? "2");
setAsrChunkSeconds(settings["subtitle_ai.asr_chunk_seconds"] ?? "600");
setTranscribeQuotaJobs(settings["subtitle_ai.transcribe_quota_jobs"] ?? "0");
setTranscribeQuotaPeriod(settings["subtitle_ai.transcribe_quota_period"] ?? "day");
}, [settings]);
function save() {
@@ -296,6 +301,11 @@ function AIFeaturesCard() {
toast.error("Transcription chunk length must be between 60 and 600 seconds.");
return;
}
const parsedQuotaJobs = Number.parseInt(transcribeQuotaJobs, 10);
if (!Number.isInteger(parsedQuotaJobs) || parsedQuotaJobs < 0) {
toast.error("Transcription limit must be zero (unlimited) or a positive whole number.");
return;
}
void Promise.all([
updateSetting.mutateAsync({ key: "subtitle_ai.enabled", value: subtitleTranslate }),
updateSetting.mutateAsync({ key: "subtitle_ai.transcribe_enabled", value: transcribe }),
@@ -310,6 +320,14 @@ function AIFeaturesCard() {
key: "subtitle_ai.asr_chunk_seconds",
value: String(parsedChunkSeconds),
}),
updateSetting.mutateAsync({
key: "subtitle_ai.transcribe_quota_jobs",
value: String(parsedQuotaJobs),
}),
updateSetting.mutateAsync({
key: "subtitle_ai.transcribe_quota_period",
value: transcribeQuotaPeriod,
}),
]);
}
@@ -376,6 +394,24 @@ function AIFeaturesCard() {
onChange={setAsrChunkSeconds}
hint="60–600. Shorter chunks keep Whisper timestamps tighter on long files (try 300 if subtitles drift), at the cost of more requests and occasional clipped words at chunk boundaries."
/>
<SettingField
label="Transcription limit per account"
type="number"
value={transcribeQuotaJobs}
onChange={setTranscribeQuotaJobs}
hint="Maximum transcription jobs per user account each period; profiles on an account share the limit. The admin account's primary profile is exempt. 0 = unlimited. Jobs that fail before transcribing anything don't count."
/>
<SettingField
label="Transcription limit period"
type="select"
value={transcribeQuotaPeriod}
onChange={setTranscribeQuotaPeriod}
options={QUOTA_PERIODS.map((p) => ({
value: p,
label: `Per ${p} (rolling ${QUOTA_PERIOD_WINDOW_LABELS[p]})`,
}))}
hint="Rolling window the transcription limit counts against."
/>
<div className="pt-2">
<Button type="button" onClick={save} disabled={updateSetting.isPending}>
{updateSetting.isPending ? "Saving..." : "Save Feature Settings"}
@@ -3,8 +3,9 @@ import { createPortal } from "react-dom";
import { toast } from "sonner";
import type { PlayerConfig } from "../context/PlayerConfigContext";
import type { PlayerAudioTrack, PlayerSubtitleInfo } from "../types";
import { playerFetch } from "../player-fetch";
import { playerFetch, PlayerFetchError } from "../player-fetch";
import { LANGUAGES, getLanguageName } from "../utils/languageNames";
import { QUOTA_PERIOD_WINDOW_LABELS } from "@/lib/quotaPeriods";
// Formats the server can parse directly from an external/downloaded file.
const TRANSLATABLE_TEXT_CODECS = new Set(["srt", "subrip", "vtt", "webvtt"]);
@@ -49,6 +50,16 @@ function audioLabel(track: PlayerAudioTrack, i: number): string {
return `${lang}${layout}${track.default ? " · default" : ""}`;
}
// Per-user transcription quota as reported by GET /subtitles/ai/quota.
// `limited` is false when no quota applies to the caller.
interface TranscribeQuota {
limited: boolean;
limit: number;
used: number;
remaining: number;
period: string;
}
export function SubtitleTranslateModal({
mediaFileId,
playerConfig,
@@ -74,8 +85,26 @@ export function SubtitleTranslateModal({
const [targetLang, setTargetLang] = useState("en");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [quota, setQuota] = useState<TranscribeQuota | null>(null);
const effectiveSourceIndex = sourceIndex ?? sourceTracks[0]?.index ?? null;
const quotaExhausted = quota !== null && quota.remaining <= 0;
const quotaPeriodLabel = quota ? (QUOTA_PERIOD_WINDOW_LABELS[quota.period] ?? quota.period) : "";
// Best-effort: a failed lookup just hides the counter — the server still
// enforces the quota.
const refreshQuota = useCallback(() => {
playerFetch<TranscribeQuota>(playerConfig, "/subtitles/ai/quota")
.then((q) => setQuota(q?.limited ? q : null))
.catch(() => setQuota(null));
}, [playerConfig]);
// Refresh the transcription quota each time the modal opens, so the user
// sees how many jobs they have left before starting one.
useEffect(() => {
if (!isOpen || !canTranscribe) return;
refreshQuota();
}, [isOpen, canTranscribe, refreshQuota]);
useEffect(() => {
if (!isOpen) return;
@@ -133,6 +162,12 @@ export function SubtitleTranslateModal({
// generated, then resumes once your position is covered.
onClose();
} catch (err) {
// A quota rejection means the cached counter was stale (e.g. another
// device used the last slot) — refresh it so the banner and the
// disabled Generate button match the error we're about to show.
if (err instanceof PlayerFetchError && err.code === "quota_exceeded") {
refreshQuota();
}
setError(
err instanceof Error
? err.message
@@ -155,6 +190,7 @@ export function SubtitleTranslateModal({
getStartPosition,
playerConfig,
onClose,
refreshQuota,
]);
if (!isOpen) return null;
@@ -271,6 +307,18 @@ export function SubtitleTranslateModal({
</select>
</label>
{mode === "audio" && quota && (
<p
className={`text-[11px] leading-relaxed ${
quotaExhausted ? "text-amber-300/90" : "text-white/35"
}`}
>
{quotaExhausted
? `You've used all ${quota.limit} transcriptions for the last ${quotaPeriodLabel}. Try again later.`
: `${quota.remaining} of ${quota.limit} transcriptions left for the last ${quotaPeriodLabel}.`}
</p>
)}
{error && (
<div role="alert" className="rounded bg-red-900/40 px-3 py-2 text-xs text-red-300">
{error}
@@ -289,7 +337,11 @@ export function SubtitleTranslateModal({
type="button"
className="rounded bg-white/10 px-3 py-1.5 text-sm font-medium hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none disabled:opacity-50"
onClick={handleTranslate}
disabled={submitting || (mode === "subtitles" && effectiveSourceIndex === null)}
disabled={
submitting ||
(mode === "subtitles" && effectiveSourceIndex === null) ||
(mode === "audio" && quotaExhausted)
}
>
{submitting ? "Starting…" : mode === "audio" ? "Generate" : "Translate"}
</button>