diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 9ef8fe2f..3bed1f72 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -485,6 +485,8 @@ func main() { FFmpegLogSink: playback.NewSlogFFmpegLogSink(slog.Default(), nodeID), PublicURL: os.Getenv("SILO_PUBLIC_URL"), } + adminJobCancelRegistry := adminjob.NewCancelRegistry() + deps.AdminJobCancelRegistry = adminJobCancelRegistry if needsWorkers && deps.DB != nil { deps.IntroRepository = intromarkers.NewRepository(deps.DB) deps.IntroAnalyzer = intromarkers.NewAnalyzer( @@ -870,6 +872,8 @@ func main() { providerIDRepo := catalog.NewProviderIDRepository(deps.DB) movieQueueRepo = metadata.NewMovieMatchQueueRepository(deps.DB, deps.FileRepo) seriesQueueRepo = metadata.NewSeriesRootMatchQueueRepository(deps.DB) + deps.MovieMatchQueueRepo = movieQueueRepo + deps.SeriesRootMatchQueueRepo = seriesQueueRepo matchQueueCoordinator = metadata.NewMatchQueueCoordinator(movieQueueRepo, seriesQueueRepo) rootClaimRepo = catalog.NewRootClaimRepository(deps.DB) groupClaimRepo = catalog.NewGroupClaimRepository(deps.DB) @@ -900,6 +904,7 @@ func main() { } matchWorker = metadata.NewMatchWorker(metadataService, deps.FileRepo, cfg.Matcher.Workers, cfg.Matcher.BatchSize, 30*time.Second) + matchWorker.SetRealtimeHub(deps.RealtimeHub) if movieQueueRepo != nil { matchWorker.SetMovieFileClaimer(movieQueueRepo) } @@ -1629,6 +1634,7 @@ func main() { templateBundleApplyExecutor, deps.RealtimeHub, ) + adminJobRunner.SetCancelRegistry(adminJobCancelRegistry) adminJobRunner.Start() defer adminJobRunner.Stop() diff --git a/cmd/silo/main_test.go b/cmd/silo/main_test.go index d5419357..b5a9cf04 100644 --- a/cmd/silo/main_test.go +++ b/cmd/silo/main_test.go @@ -132,6 +132,9 @@ func TestBuildLiveSessionSync_UsesTransportPlayMethod(t *testing.T) { RequestedMediaFileID: 41, PlayMethod: playback.PlayTranscode, BasePlayMethod: playback.PlayRemux, + TranscodeHWAccel: "qsv", + Position: 125.5, + IsPaused: true, }, want: "transcode", }, @@ -164,6 +167,18 @@ func TestBuildLiveSessionSync_UsesTransportPlayMethod(t *testing.T) { if got.SessionID != tc.session.ID { t.Fatalf("SessionID = %q, want %q", got.SessionID, tc.session.ID) } + if got.ProfileID != tc.session.ProfileID { + t.Fatalf("ProfileID = %q, want %q", got.ProfileID, tc.session.ProfileID) + } + if got.PositionSeconds != tc.session.Position { + t.Fatalf("PositionSeconds = %v, want %v", got.PositionSeconds, tc.session.Position) + } + if got.IsPaused != tc.session.IsPaused { + t.Fatalf("IsPaused = %v, want %v", got.IsPaused, tc.session.IsPaused) + } + if got.TranscodeHWAccel != tc.session.TranscodeHWAccel { + t.Fatalf("TranscodeHWAccel = %q, want %q", got.TranscodeHWAccel, tc.session.TranscodeHWAccel) + } }) } } diff --git a/cmd/silo/session_sync.go b/cmd/silo/session_sync.go index 08d699d8..267ba0bb 100644 --- a/cmd/silo/session_sync.go +++ b/cmd/silo/session_sync.go @@ -31,8 +31,10 @@ func buildLiveSessionSync(s *playback.Session, reportingNode string) worker.Sess TargetVideoCodec: s.TargetVideoCodec, TargetAudioCodec: s.TargetAudioCodec, TargetBitrateKbps: s.TargetBitrateKbps, + TranscodeHWAccel: s.TranscodeHWAccel, StartedAt: s.StartedAt, UpdatedAt: s.UpdatedAt, + PositionSeconds: s.Position, IsPaused: s.IsPaused, HasWebSocket: s.HasWebSocket, } diff --git a/internal/adminjob/cancel_registry.go b/internal/adminjob/cancel_registry.go new file mode 100644 index 00000000..f34d639c --- /dev/null +++ b/internal/adminjob/cancel_registry.go @@ -0,0 +1,55 @@ +package adminjob + +import ( + "context" + "sync" +) + +// CancelRegistry bridges HTTP cancellation requests to in-process job contexts. +// Queued jobs are cancelled in the repository; running jobs need their active +// context cancelled so executors stop doing work before the row is finalized. +type CancelRegistry struct { + mu sync.Mutex + cancels map[string]cancelEntry +} + +type cancelEntry struct { + cancel context.CancelFunc + token *struct{} +} + +func NewCancelRegistry() *CancelRegistry { + return &CancelRegistry{cancels: make(map[string]cancelEntry)} +} + +func (r *CancelRegistry) Register(jobID string, cancel context.CancelFunc) func() { + if r == nil || jobID == "" || cancel == nil { + return func() {} + } + token := &struct{}{} + r.mu.Lock() + r.cancels[jobID] = cancelEntry{cancel: cancel, token: token} + r.mu.Unlock() + + return func() { + r.mu.Lock() + if entry := r.cancels[jobID]; entry.token == token { + delete(r.cancels, jobID) + } + r.mu.Unlock() + } +} + +func (r *CancelRegistry) Cancel(jobID string) bool { + if r == nil || jobID == "" { + return false + } + r.mu.Lock() + cancel := r.cancels[jobID].cancel + r.mu.Unlock() + if cancel == nil { + return false + } + cancel() + return true +} diff --git a/internal/adminjob/item_refresh.go b/internal/adminjob/item_refresh.go index d27321ea..c38d18f8 100644 --- a/internal/adminjob/item_refresh.go +++ b/internal/adminjob/item_refresh.go @@ -542,17 +542,19 @@ func (e *ItemRefreshExecutor) Execute(ctx context.Context, req ItemRefreshReques e.publish(cache.EventMetadataUpdated, refreshContentID) if e.realtimeHub != nil { if scanResult != nil && (scanResult.New > 0 || scanResult.Updated > 0 || scanResult.Missing > 0 || matched > 0) { - _ = e.realtimeHub.PublishLibraryChanged(ctx, notifications.LibraryChangeEvent{ + _ = e.realtimeHub.PublishCatalogLibraryChanged(ctx, notifications.LibraryChangeEvent{ LibraryID: req.ScanFolderID, + Reason: "item_refresh", New: scanResult.New, Updated: scanResult.Updated, Missing: scanResult.Missing, MatchedFiles: matched, }) } - _ = e.realtimeHub.PublishMetadataUpdated(ctx, notifications.MetadataUpdateEvent{ + _ = e.realtimeHub.PublishCatalogItemChanged(ctx, notifications.MetadataUpdateEvent{ LibraryID: req.ScanFolderID, ContentID: refreshContentID, + Change: "metadata_updated", }) } diff --git a/internal/adminjob/library_refresh.go b/internal/adminjob/library_refresh.go index c504bc11..44a8e691 100644 --- a/internal/adminjob/library_refresh.go +++ b/internal/adminjob/library_refresh.go @@ -251,8 +251,9 @@ func (e *LibraryRefreshExecutor) Execute( if result.RefreshedOK > 0 || result.PipelineOK > 0 { e.publish(cache.EventMetadataUpdated, strconv.Itoa(req.LibraryID)) if e.realtimeHub != nil { - _ = e.realtimeHub.PublishMetadataUpdated(ctx, notifications.MetadataUpdateEvent{ + _ = e.realtimeHub.PublishCatalogItemChanged(ctx, notifications.MetadataUpdateEvent{ LibraryID: req.LibraryID, + Change: "metadata_updated", }) } } @@ -321,6 +322,7 @@ func (e *LibraryRefreshExecutor) refreshItemsWithIDs( result.RefreshedFailed++ } else { result.RefreshedOK++ + e.publishCatalogItemChanged(ctx, result.LibraryID, itemResult.contentID) } advance("Refreshing items with external IDs") } @@ -386,6 +388,7 @@ func (e *LibraryRefreshExecutor) refreshUnmatchedItem(ctx context.Context, libra if err := e.refresher.RefreshItemForLibrary(ctx, req.RefreshContentID, req.ScanFolderID); err != nil { return fmt.Errorf("refresh metadata: %w", err) } + e.publishCatalogItemChanged(ctx, libraryID, req.RefreshContentID) return nil } @@ -399,6 +402,17 @@ func (e *LibraryRefreshExecutor) publish(eventType, payload string) { }) } +func (e *LibraryRefreshExecutor) publishCatalogItemChanged(ctx context.Context, libraryID int, contentID string) { + if e == nil || e.realtimeHub == nil || contentID == "" { + return + } + _ = e.realtimeHub.PublishCatalogItemChanged(ctx, notifications.MetadataUpdateEvent{ + LibraryID: libraryID, + ContentID: contentID, + Change: "metadata_updated", + }) +} + func decodeLibraryRefreshRequest(data json.RawMessage) (LibraryRefreshRequest, error) { var req LibraryRefreshRequest if len(data) == 0 { diff --git a/internal/adminjob/repository.go b/internal/adminjob/repository.go index a91f3ce9..de2e3406 100644 --- a/internal/adminjob/repository.go +++ b/internal/adminjob/repository.go @@ -24,11 +24,13 @@ const ( StatusRunning = "running" StatusCompleted = "completed" StatusFailed = "failed" + StatusCancelled = "cancelled" ) var ( ErrJobNotFound = errors.New("admin job not found") ErrActiveJobConflict = errors.New("admin job already active for type") + ErrJobNotCancellable = errors.New("admin job is not cancellable") ) type ActiveJobConflictError struct { @@ -471,6 +473,83 @@ func (r *Repository) Fail(ctx context.Context, id string, input FailJobInput) er return nil } +func (r *Repository) Cancel(ctx context.Context, id, message string, expiresAt time.Time) (*models.AdminJob, error) { + if message == "" { + message = "Admin job cancelled" + } + job, err := scanAdminJob(r.pool.QueryRow(ctx, ` + UPDATE admin_jobs + SET status = $2, + message = $3, + error_message = '', + completed_at = NOW(), + heartbeat_at = NOW(), + expires_at = $4, + updated_at = NOW() + WHERE id = $1 + AND status IN ($5, $6) + RETURNING `+adminJobColumns, + id, + StatusCancelled, + message, + expiresAt, + StatusQueued, + StatusRunning, + )) + if err == nil { + return job, nil + } + if !errors.Is(err, ErrJobNotFound) { + return nil, err + } + existing, lookupErr := r.GetByID(ctx, id) + if lookupErr != nil { + return nil, lookupErr + } + if existing.Status != StatusQueued && existing.Status != StatusRunning { + return nil, ErrJobNotCancellable + } + return nil, ErrJobNotFound +} + +func (r *Repository) CancelQueued(ctx context.Context, id, message string, expiresAt time.Time) (*models.AdminJob, error) { + if message == "" { + message = "Admin job cancelled" + } + job, err := scanAdminJob(r.pool.QueryRow(ctx, ` + UPDATE admin_jobs + SET status = $2, + message = $3, + error_message = '', + completed_at = NOW(), + heartbeat_at = NOW(), + expires_at = $4, + updated_at = NOW() + WHERE id = $1 + AND status = $5 + RETURNING `+adminJobColumns, + id, + StatusCancelled, + message, + expiresAt, + StatusQueued, + )) + if err == nil { + return job, nil + } + if !errors.Is(err, ErrJobNotFound) { + return nil, err + } + existing, lookupErr := r.GetByID(ctx, id) + if lookupErr != nil { + return nil, lookupErr + } + if existing.Status != StatusQueued { + return nil, ErrJobNotCancellable + } + return nil, ErrJobNotFound +} + func (r *Repository) RequeueStaleRunning(ctx context.Context, before time.Time) (int, error) { tag, err := r.pool.Exec(ctx, ` UPDATE admin_jobs diff --git a/internal/adminjob/runner.go b/internal/adminjob/runner.go index 197b06c2..31fdc6d5 100644 --- a/internal/adminjob/runner.go +++ b/internal/adminjob/runner.go @@ -57,6 +57,7 @@ type Runner struct { heartbeatInterval time.Duration staleAfter time.Duration retention time.Duration + cancelRegistry *CancelRegistry stop chan struct{} stopOnce sync.Once } @@ -99,10 +100,17 @@ func NewRunner( heartbeatInterval: 10 * time.Second, staleAfter: 2 * time.Minute, retention: 7 * 24 * time.Hour, + cancelRegistry: NewCancelRegistry(), stop: make(chan struct{}), } } +func (r *Runner) SetCancelRegistry(registry *CancelRegistry) { + if registry != nil { + r.cancelRegistry = registry + } +} + func (r *Runner) Start() { go func() { r.requeueStaleJobs() @@ -329,6 +337,8 @@ func (r *Runner) executeLibraryRefresh(job *models.AdminJob) { ctx, cancel := context.WithTimeout(context.Background(), libraryRefreshTimeout) defer cancel() + unregisterCancel := r.cancelRegistry.Register(job.ID, cancel) + defer unregisterCancel() heartbeatStop := make(chan struct{}) go r.heartbeatLoop(ctx, job.ID, heartbeatStop) @@ -357,6 +367,10 @@ func (r *Runner) executeLibraryRefresh(job *models.AdminJob) { r.publishJobByID(ctx, notifications.TypeJobProgress, job.ID) }) if err != nil { + if errors.Is(ctx.Err(), context.Canceled) { + r.cancelJob(job.ID, current, total, "Library metadata refresh cancelled") + return + } msg := err.Error() if ctx.Err() != nil { msg = fmt.Sprintf("timed out after %s: %s", libraryRefreshTimeout, msg) @@ -846,3 +860,21 @@ func (r *Runner) failJob(id string, current, total int, message, errorMessage st } r.publishJobByID(ctx, notifications.TypeJobFailed, id) } + +func (r *Runner) cancelJob(id string, current, total int, message string) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := r.repo.UpdateProgress(ctx, id, current, total, message); err != nil { + slog.Warn("admin jobs: failed to update cancellation progress", "job_id", id, "error", err) + } + job, err := r.repo.Cancel(ctx, id, message, time.Now().UTC().Add(r.retention)) + if err != nil { + slog.Warn("admin jobs: failed to mark job cancelled", "job_id", id, "error", err) + return + } + if r.realtimeHub != nil { + if err := r.realtimeHub.PublishJob(ctx, notifications.TypeJobCancelled, job); err != nil { + slog.Warn("admin jobs: failed to publish job cancellation", "job_id", id, "error", err) + } + } +} diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index 4c84e8e1..295f5e74 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -875,6 +875,9 @@ func (h *AdminHandler) HandleGetStats(w http.ResponseWriter, r *http.Request) { var resp AdminStats if h.StatsSource != nil { + if isTruthyQuery(r.URL.Query().Get("refresh")) { + h.StatsSource.Invalidate() + } stats, err := h.StatsSource.Get(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get stats") @@ -901,6 +904,15 @@ func (h *AdminHandler) HandleGetStats(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } +func isTruthyQuery(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + func (h *AdminHandler) invalidateStats(ctx context.Context, channel, eventType, payload string) { if h.StatsSource != nil { h.StatsSource.Invalidate() @@ -1257,6 +1269,9 @@ type adminDeviceDetailResponse struct { DeviceID string `json:"device_id"` DeviceName string `json:"device_name"` DevicePlatform string `json:"device_platform"` + OverrideCount int `json:"override_count"` + ProfileCount int `json:"profile_count"` + Profiles []adminDeviceProfileSummary `json:"profiles"` LastUpdated string `json:"last_updated"` Settings []adminDeviceSettingResponse `json:"settings"` } @@ -1499,6 +1514,12 @@ func (h *AdminHandler) HandleUpdateUserDeviceSetting(w http.ResponseWriter, r *h if existing != nil { entry.DeviceName = existing.DeviceName entry.DevicePlatform = existing.DevicePlatform + } else if registered, err := registeredDeviceForProfile(r.Context(), store, profileID, deviceID); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device") + return + } else if registered != nil { + entry.DeviceName = registered.DeviceName + entry.DevicePlatform = registered.DevicePlatform } if err := store.SetDeviceSetting(r.Context(), entry); err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update device setting") @@ -1621,6 +1642,10 @@ func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request) if err != nil { return fmt.Errorf("list device settings: %w", err) } + devices, err := listRegisteredDevices(gctx, store) + if err != nil { + return fmt.Errorf("list devices: %w", err) + } profileNames, err := listProfileNamesByID(gctx, store) if err != nil { slog.Warn("admin list devices profile lookup failed", @@ -1629,7 +1654,14 @@ func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request) ) profileNames = map[string]string{} } - perUser[i] = buildAdminDeviceSummaries(user.ID, user.Username, user.Email, entries, profileNames) + perUser[i] = buildAdminDeviceSummaries( + user.ID, + user.Username, + user.Email, + entries, + devices, + profileNames, + ) return nil }) } @@ -1693,6 +1725,11 @@ func (h *AdminHandler) HandleGetDevice(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device") return } + registeredDevices, err := listRegisteredDevices(r.Context(), store) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device") + return + } profileNames, err := listProfileNamesByID(r.Context(), store) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles") @@ -1705,12 +1742,26 @@ func (h *AdminHandler) HandleGetDevice(w http.ResponseWriter, r *http.Request) { deviceEntries = append(deviceEntries, entry) } } - if len(deviceEntries) == 0 { + deviceRegistrations := make([]userstore.DeviceEntry, 0) + for _, entry := range registeredDevices { + if entry.DeviceID == deviceID { + deviceRegistrations = append(deviceRegistrations, entry) + } + } + summaries := buildAdminDeviceSummaries( + user.ID, + user.Username, + user.Email, + deviceEntries, + deviceRegistrations, + profileNames, + ) + if len(summaries) == 0 { writeError(w, http.StatusNotFound, "not_found", "Device not found") return } - summary := buildAdminDeviceSummaries(user.ID, user.Username, user.Email, deviceEntries, profileNames)[0] + summary := summaries[0] writeJSON(w, http.StatusOK, adminDeviceDetailResponse{ UserID: user.ID, Username: user.Username, @@ -1718,11 +1769,41 @@ func (h *AdminHandler) HandleGetDevice(w http.ResponseWriter, r *http.Request) { DeviceID: summary.DeviceID, DeviceName: summary.DeviceName, DevicePlatform: summary.DevicePlatform, + OverrideCount: summary.OverrideCount, + ProfileCount: summary.ProfileCount, + Profiles: summary.Profiles, LastUpdated: summary.LastUpdated, Settings: buildAdminDeviceSettingsResponse(user.ID, profileNames, deviceEntries).Settings, }) } +func listRegisteredDevices(ctx context.Context, store userstore.UserStore) ([]userstore.DeviceEntry, error) { + registry, ok := store.(userstore.DeviceRegistry) + if !ok { + return nil, nil + } + return registry.ListDevices(ctx) +} + +func registeredDeviceForProfile( + ctx context.Context, + store userstore.UserStore, + profileID string, + deviceID string, +) (*userstore.DeviceEntry, error) { + devices, err := listRegisteredDevices(ctx, store) + if err != nil { + return nil, err + } + for _, device := range devices { + if device.ProfileID == profileID && device.DeviceID == deviceID { + matched := device + return &matched, nil + } + } + return nil, nil +} + func buildAdminDeviceSettingsResponse(userID int, profileNames map[string]string, entries []userstore.DeviceSettingEntry) adminDeviceSettingsListResponse { resp := adminDeviceSettingsListResponse{ Settings: make([]adminDeviceSettingResponse, 0, len(entries)), @@ -1748,6 +1829,7 @@ func buildAdminDeviceSummaries( username string, email string, entries []userstore.DeviceSettingEntry, + registeredDevices []userstore.DeviceEntry, profileNames map[string]string, ) []adminDeviceSummaryResponse { type profileAccumulator struct { @@ -1761,10 +1843,10 @@ func buildAdminDeviceSummaries( } byDevice := make(map[string]*summary) - for _, entry := range entries { - deviceID := strings.TrimSpace(entry.DeviceID) + + ensureDevice := func(deviceID, deviceName, devicePlatform, lastUpdated string) *summary { if deviceID == "" { - continue + return nil } current, ok := byDevice[deviceID] if !ok { @@ -1774,49 +1856,86 @@ func buildAdminDeviceSummaries( Username: username, Email: email, DeviceID: deviceID, - DeviceName: entry.DeviceName, - DevicePlatform: entry.DevicePlatform, - LastUpdated: entry.UpdatedAt, + DeviceName: deviceName, + DevicePlatform: devicePlatform, + LastUpdated: lastUpdated, }, keys: make(map[string]struct{}), profiles: make(map[string]*profileAccumulator), } byDevice[deviceID] = current } - current.keys[entry.ProfileID+":"+entry.Key] = struct{}{} + if current.device.DeviceName == "" && deviceName != "" { + current.device.DeviceName = deviceName + } + if current.device.DevicePlatform == "" && devicePlatform != "" { + current.device.DevicePlatform = devicePlatform + } + if lastUpdated > current.device.LastUpdated { + current.device.LastUpdated = lastUpdated + if deviceName != "" { + current.device.DeviceName = deviceName + } + if devicePlatform != "" { + current.device.DevicePlatform = devicePlatform + } + } + return current + } + + ensureProfile := func(current *summary, profileID, lastUpdated string) *profileAccumulator { + if current == nil || profileID == "" { + return nil + } + profile, exists := current.profiles[profileID] + if !exists { + profile = &profileAccumulator{ + summary: adminDeviceProfileSummary{ + ProfileID: profileID, + ProfileName: profileNames[profileID], + LastUpdated: lastUpdated, + }, + keys: make(map[string]struct{}), + } + current.profiles[profileID] = profile + return profile + } + if lastUpdated > profile.summary.LastUpdated { + profile.summary.LastUpdated = lastUpdated + } + return profile + } + + for _, device := range registeredDevices { + deviceID := strings.TrimSpace(device.DeviceID) + profileID := strings.TrimSpace(device.ProfileID) + current := ensureDevice( + deviceID, + device.DeviceName, + device.DevicePlatform, + device.LastSeenAt, + ) + ensureProfile(current, profileID, device.LastSeenAt) + } + + for _, entry := range entries { + deviceID := strings.TrimSpace(entry.DeviceID) profileID := strings.TrimSpace(entry.ProfileID) - if profileID != "" { - profile, exists := current.profiles[profileID] - if !exists { - profile = &profileAccumulator{ - summary: adminDeviceProfileSummary{ - ProfileID: profileID, - ProfileName: profileNames[profileID], - LastUpdated: entry.UpdatedAt, - }, - keys: make(map[string]struct{}), - } - current.profiles[profileID] = profile - } + current := ensureDevice( + deviceID, + entry.DeviceName, + entry.DevicePlatform, + entry.UpdatedAt, + ) + if current == nil { + continue + } + if profileID != "" && entry.Key != "" { + current.keys[profileID+":"+entry.Key] = struct{}{} + } + profile := ensureProfile(current, profileID, entry.UpdatedAt) + if profile != nil && entry.Key != "" { profile.keys[entry.Key] = struct{}{} - if entry.UpdatedAt > profile.summary.LastUpdated { - profile.summary.LastUpdated = entry.UpdatedAt - } - } - if current.device.DeviceName == "" && entry.DeviceName != "" { - current.device.DeviceName = entry.DeviceName - } - if current.device.DevicePlatform == "" && entry.DevicePlatform != "" { - current.device.DevicePlatform = entry.DevicePlatform - } - if entry.UpdatedAt > current.device.LastUpdated { - current.device.LastUpdated = entry.UpdatedAt - if entry.DeviceName != "" { - current.device.DeviceName = entry.DeviceName - } - if entry.DevicePlatform != "" { - current.device.DevicePlatform = entry.DevicePlatform - } } } diff --git a/internal/api/handlers/admin_jobs.go b/internal/api/handlers/admin_jobs.go index 20ae2ea6..44e64bdc 100644 --- a/internal/api/handlers/admin_jobs.go +++ b/internal/api/handlers/admin_jobs.go @@ -14,6 +14,7 @@ import ( 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/models" + "github.com/Silo-Server/silo-server/internal/notifications" ) const adminJobDownloadExpiry = 15 * time.Minute @@ -23,12 +24,22 @@ type AdminJobArtifactStore interface { PresignGetURL(ctx context.Context, bucket, key string, expiry time.Duration) (string, error) } -type AdminJobsHandler struct { - repo *adminjob.Repository - store AdminJobArtifactStore +type adminJobRepository interface { + List(ctx context.Context, opts adminjob.ListJobsOptions) ([]*models.AdminJob, error) + GetByID(ctx context.Context, id string) (*models.AdminJob, error) + Cancel(ctx context.Context, id, message string, expiresAt time.Time) (*models.AdminJob, error) + CancelQueued(ctx context.Context, id, message string, expiresAt time.Time) (*models.AdminJob, error) + UpdateProgress(ctx context.Context, id string, current, total int, message string) error } -func NewAdminJobsHandler(repo *adminjob.Repository, store AdminJobArtifactStore) *AdminJobsHandler { +type AdminJobsHandler struct { + repo adminJobRepository + store AdminJobArtifactStore + CancelRegistry *adminjob.CancelRegistry + RealtimeHub *notifications.Hub +} + +func NewAdminJobsHandler(repo adminJobRepository, store AdminJobArtifactStore) *AdminJobsHandler { return &AdminJobsHandler{repo: repo, store: store} } @@ -120,6 +131,105 @@ func (h *AdminJobsHandler) HandleGet(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, adminJobToResponseForClaims(r, job, h.store, claims)) } +func (h *AdminJobsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) { + if h == nil || h.repo == nil { + writeError(w, http.StatusServiceUnavailable, "not_configured", "Admin jobs are not configured") + return + } + + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Job ID is required") + return + } + + job, err := h.repo.GetByID(r.Context(), id) + if err != nil { + if errors.Is(err, adminjob.ErrJobNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Job not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load admin job") + return + } + + if job.JobType != adminjob.JobTypeLibraryRefresh { + writeError(w, http.StatusConflict, "not_cancellable", "Only library metadata refresh jobs can be cancelled") + return + } + + switch job.Status { + case adminjob.StatusQueued: + cancelled, cancelErr := h.repo.CancelQueued( + r.Context(), + id, + "Library metadata refresh cancelled", + time.Now().UTC().Add(7*24*time.Hour), + ) + if cancelErr != nil { + if errors.Is(cancelErr, adminjob.ErrJobNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Job not found") + return + } + if errors.Is(cancelErr, adminjob.ErrJobNotCancellable) { + advanced, loadErr := h.repo.GetByID(r.Context(), id) + if loadErr != nil { + if errors.Is(loadErr, adminjob.ErrJobNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Job not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load admin job") + return + } + if advanced.Status == adminjob.StatusRunning { + h.requestRunningCancellation(w, r, advanced) + return + } + writeError(w, http.StatusConflict, "not_cancellable", "Job is no longer cancellable") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel job") + return + } + if h.RealtimeHub != nil { + publishEventJob( + r.Context(), + h.RealtimeHub.EventsHub(), + string(notifications.TypeJobCancelled), + cancelled, + ) + } + writeJSON(w, http.StatusOK, adminJobToResponse(r, cancelled, h.store)) + case adminjob.StatusRunning: + h.requestRunningCancellation(w, r, job) + default: + writeError(w, http.StatusConflict, "not_cancellable", "Job is no longer cancellable") + } +} + +func (h *AdminJobsHandler) requestRunningCancellation(w http.ResponseWriter, r *http.Request, job *models.AdminJob) { + if h.CancelRegistry == nil || !h.CancelRegistry.Cancel(job.ID) { + writeError(w, http.StatusConflict, "not_cancellable", "Running job is not cancellable on this node") + return + } + if err := h.repo.UpdateProgress( + r.Context(), + job.ID, + job.ProgressCurrent, + job.ProgressTotal, + "Cancellation requested", + ); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to request cancellation") + return + } + updated, err := h.repo.GetByID(r.Context(), job.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load admin job") + return + } + writeJSON(w, http.StatusAccepted, adminJobToResponse(r, updated, h.store)) +} + func adminJobToResponse(r *http.Request, job *models.AdminJob, store AdminJobArtifactStore) adminJobResponse { resp := adminJobResponse{ ID: job.ID, diff --git a/internal/api/handlers/admin_jobs_test.go b/internal/api/handlers/admin_jobs_test.go index d7dd5ec1..80b9b6c1 100644 --- a/internal/api/handlers/admin_jobs_test.go +++ b/internal/api/handlers/admin_jobs_test.go @@ -2,12 +2,21 @@ package handlers import ( "bytes" + "context" "encoding/json" + "errors" + "net/http" + "net/http/httptest" "testing" + "time" + + "github.com/go-chi/chi/v5" "github.com/Silo-Server/silo-server/internal/adminjob" "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/cache" "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/notifications" ) func TestCanReadAdminJob_AdminCanReadAnyJob(t *testing.T) { @@ -79,3 +88,195 @@ func TestAdminJobToResponseForClaims_NonAdminSanitizesItemRefreshPayloads(t *tes t.Fatalf("ErrorMessage = %q, want stripped for non-admin", resp.ErrorMessage) } } + +func TestAdminJobsHandleCancel_RunningRegistryMissDoesNotUpdateProgress(t *testing.T) { + repo := &fakeAdminJobRepository{ + job: &models.AdminJob{ + ID: "job-1", + JobType: adminjob.JobTypeLibraryRefresh, + Status: adminjob.StatusRunning, + CreatedByUserID: 1, + Message: "Running", + RequestedAt: time.Now().UTC(), + }, + } + handler := NewAdminJobsHandler(repo, nil) + + rec := httptest.NewRecorder() + handler.HandleCancel(rec, adminJobCancelRequest("job-1")) + + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusConflict) + } + if repo.updateProgressCalls != 0 { + t.Fatalf("UpdateProgress called %d times, want 0", repo.updateProgressCalls) + } +} + +func TestAdminJobsHandleCancel_QueuedPublishesCancelledEvent(t *testing.T) { + repo := &fakeAdminJobRepository{ + job: &models.AdminJob{ + ID: "job-1", + JobType: adminjob.JobTypeLibraryRefresh, + Status: adminjob.StatusQueued, + CreatedByUserID: 1, + Message: "Queued", + RequestedAt: time.Now().UTC(), + }, + } + hub := notifications.NewHub("test", &cache.NoopEventBus{}) + events, unsubscribe := hub.Subscribe() + defer unsubscribe() + + handler := NewAdminJobsHandler(repo, nil) + handler.RealtimeHub = hub + + rec := httptest.NewRecorder() + handler.HandleCancel(rec, adminJobCancelRequest("job-1")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + select { + case event := <-events: + if event.Type != notifications.TypeJobCancelled { + t.Fatalf("event.Type = %q, want %q", event.Type, notifications.TypeJobCancelled) + } + if event.Job == nil || event.Job.ID != "job-1" { + t.Fatalf("event.Job = %#v, want job-1", event.Job) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for job.cancelled event") + } +} + +func TestAdminJobsHandleCancel_QueuedSnapshotFallsBackToRunningCancellation(t *testing.T) { + repo := &fakeAdminJobRepository{ + job: &models.AdminJob{ + ID: "job-1", + JobType: adminjob.JobTypeLibraryRefresh, + Status: adminjob.StatusQueued, + CreatedByUserID: 1, + Message: "Queued", + RequestedAt: time.Now().UTC(), + ProgressCurrent: 3, + ProgressTotal: 10, + }, + promoteToRunningOnCancelQueued: true, + } + cancelled := false + registry := adminjob.NewCancelRegistry() + unregister := registry.Register("job-1", func() { + cancelled = true + }) + defer unregister() + + handler := NewAdminJobsHandler(repo, nil) + handler.CancelRegistry = registry + + rec := httptest.NewRecorder() + handler.HandleCancel(rec, adminJobCancelRequest("job-1")) + + if rec.Code != http.StatusAccepted { + t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusAccepted, rec.Body.String()) + } + if !cancelled { + t.Fatal("expected running job cancel registry to be invoked") + } + if repo.cancelQueuedCalls != 1 { + t.Fatalf("CancelQueued called %d times, want 1", repo.cancelQueuedCalls) + } + if repo.cancelCalls != 0 { + t.Fatalf("Cancel called %d times, want 0", repo.cancelCalls) + } + if repo.updateProgressCalls != 1 { + t.Fatalf("UpdateProgress called %d times, want 1", repo.updateProgressCalls) + } + if repo.job.Status != adminjob.StatusRunning { + t.Fatalf("job status = %q, want %q", repo.job.Status, adminjob.StatusRunning) + } + if repo.job.Message != "Cancellation requested" { + t.Fatalf("job message = %q, want cancellation request", repo.job.Message) + } +} + +func adminJobCancelRequest(id string) *http.Request { + req := httptest.NewRequest(http.MethodPost, "/admin/jobs/"+id+"/cancel", nil) + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("id", id) + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)) +} + +type fakeAdminJobRepository struct { + job *models.AdminJob + updateProgressCalls int + cancelCalls int + cancelQueuedCalls int + promoteToRunningOnCancelQueued bool +} + +func (r *fakeAdminJobRepository) List(context.Context, adminjob.ListJobsOptions) ([]*models.AdminJob, error) { + if r.job == nil { + return nil, nil + } + return []*models.AdminJob{r.job}, nil +} + +func (r *fakeAdminJobRepository) GetByID(_ context.Context, id string) (*models.AdminJob, error) { + if r.job == nil || r.job.ID != id { + return nil, adminjob.ErrJobNotFound + } + cp := *r.job + return &cp, nil +} + +func (r *fakeAdminJobRepository) Cancel(_ context.Context, id, message string, _ time.Time) (*models.AdminJob, error) { + r.cancelCalls++ + if r.job == nil || r.job.ID != id { + return nil, adminjob.ErrJobNotFound + } + if r.job.Status != adminjob.StatusQueued && r.job.Status != adminjob.StatusRunning { + return nil, adminjob.ErrJobNotCancellable + } + cp := *r.job + cp.Status = adminjob.StatusCancelled + cp.Message = message + r.job = &cp + return &cp, nil +} + +func (r *fakeAdminJobRepository) CancelQueued(_ context.Context, id, message string, _ time.Time) (*models.AdminJob, error) { + r.cancelQueuedCalls++ + if r.job == nil || r.job.ID != id { + return nil, adminjob.ErrJobNotFound + } + if r.promoteToRunningOnCancelQueued { + cp := *r.job + cp.Status = adminjob.StatusRunning + r.job = &cp + return nil, adminjob.ErrJobNotCancellable + } + if r.job.Status != adminjob.StatusQueued { + return nil, adminjob.ErrJobNotCancellable + } + cp := *r.job + cp.Status = adminjob.StatusCancelled + cp.Message = message + r.job = &cp + return &cp, nil +} + +func (r *fakeAdminJobRepository) UpdateProgress(_ context.Context, id string, current, total int, message string) error { + r.updateProgressCalls++ + if r.job == nil || r.job.ID != id { + return adminjob.ErrJobNotFound + } + if message == "" { + return errors.New("message is required") + } + r.job.ProgressCurrent = current + r.job.ProgressTotal = total + r.job.Message = message + return nil +} diff --git a/internal/api/handlers/events_publish.go b/internal/api/handlers/events_publish.go index fcc52f5e..0e4f9df7 100644 --- a/internal/api/handlers/events_publish.go +++ b/internal/api/handlers/events_publish.go @@ -23,9 +23,10 @@ func publishEventMetadataUpdate(ctx context.Context, hub *evt.Hub, libraryID int if hub == nil { return } - if err := hub.PublishJSON(ctx, evt.ChannelCatalog, "metadata.updated", map[string]any{ + if err := hub.PublishJSON(ctx, evt.ChannelCatalog, "catalog.item.changed", map[string]any{ "library_id": libraryID, "content_id": contentID, + "change": "metadata_updated", }, evt.PublishOptions{}); err != nil { slog.Warn("events: failed to publish metadata update", "library_id", libraryID, "content_id", contentID, "error", err) } diff --git a/internal/api/handlers/favorites.go b/internal/api/handlers/favorites.go index 4d7abd54..ef562ee2 100644 --- a/internal/api/handlers/favorites.go +++ b/internal/api/handlers/favorites.go @@ -216,7 +216,9 @@ func (h *PersonalDataHandler) HandleAddFavorite(w http.ResponseWriter, r *http.R h.dispatchLocalFavoriteEvent(r.Context(), watchsync.LocalFavoriteEventAdded, userID, profileID, itemID) triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID) - publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "favorite", "favorite.updated") + publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "favorite", userStateEventState{ + IsFavorite: boolPtr(true), + }) w.WriteHeader(http.StatusNoContent) } @@ -244,7 +246,9 @@ func (h *PersonalDataHandler) HandleRemoveFavorite(w http.ResponseWriter, r *htt h.dispatchLocalFavoriteEvent(r.Context(), watchsync.LocalFavoriteEventRemoved, userID, profileID, itemID) triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID) - publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "favorite", "favorite.updated") + publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "favorite", userStateEventState{ + IsFavorite: boolPtr(false), + }) w.WriteHeader(http.StatusNoContent) } @@ -366,7 +370,9 @@ func (h *PersonalDataHandler) HandleAddToWatchlist(w http.ResponseWriter, r *htt } triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID) - publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "watchlist", "watchlist.updated") + publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "watchlist", userStateEventState{ + InWatchlist: boolPtr(true), + }) w.WriteHeader(http.StatusNoContent) } @@ -393,7 +399,9 @@ func (h *PersonalDataHandler) HandleRemoveFromWatchlist(w http.ResponseWriter, r } triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID) - publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "watchlist", "watchlist.updated") + publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "watchlist", userStateEventState{ + InWatchlist: boolPtr(false), + }) w.WriteHeader(http.StatusNoContent) } @@ -492,7 +500,7 @@ func (h *PersonalDataHandler) HandleRemoveHistory(w http.ResponseWriter, r *http mediaItemID, "", "history", - "history.updated", + userStateEventState{}, ) } w.WriteHeader(http.StatusNoContent) diff --git a/internal/api/handlers/home_dismissals.go b/internal/api/handlers/home_dismissals.go index d090b943..b00e2b9c 100644 --- a/internal/api/handlers/home_dismissals.go +++ b/internal/api/handlers/home_dismissals.go @@ -8,11 +8,13 @@ import ( "github.com/go-chi/chi/v5" apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + evt "github.com/Silo-Server/silo-server/internal/events" "github.com/Silo-Server/silo-server/internal/userstore" ) type HomeDismissalHandler struct { storeProvider userstore.UserStoreProvider + EventsHub *evt.Hub } type upsertHomeDismissalRequest struct { @@ -76,6 +78,16 @@ func (h *HomeDismissalHandler) HandleUpsertDismissal(w http.ResponseWriter, r *h writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save dismissal") return } + publishUserStateEvent( + r.Context(), + h.EventsHub, + userID, + profileID, + itemID, + req.SeriesID, + "home_dismissal", + userStateEventState{}, + ) w.WriteHeader(http.StatusNoContent) } @@ -104,6 +116,16 @@ func (h *HomeDismissalHandler) HandleDeleteDismissal(w http.ResponseWriter, r *h writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete dismissal") return } + publishUserStateEvent( + r.Context(), + h.EventsHub, + userID, + profileID, + itemID, + "", + "home_dismissal", + userStateEventState{}, + ) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/api/handlers/items.go b/internal/api/handlers/items.go index afc5f927..d4dbd372 100644 --- a/internal/api/handlers/items.go +++ b/internal/api/handlers/items.go @@ -476,7 +476,9 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ } triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID) - publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, id, "", "watched", "watched.updated") + publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, id, "", "watched", userStateEventState{ + Played: boolPtr(played), + }) writeJSON(w, http.StatusOK, watchedStateResponse{ ContentID: id, diff --git a/internal/api/handlers/libraries.go b/internal/api/handlers/libraries.go index 8fe0df76..a8f26282 100644 --- a/internal/api/handlers/libraries.go +++ b/internal/api/handlers/libraries.go @@ -51,6 +51,10 @@ type LibraryHandler struct { PluginInstallations pluginInstallationLister SkippedRootRepo *metadata.SkippedRootRepository StaleIDRepo *metadata.StaleMediaIDRepository + MovieMatchQueueRepo libraryMovieMatchQueue + SeriesMatchQueueRepo librarySeriesMatchQueue + RawMatchBacklogRepo libraryRawMatchBacklog + TVSeriesRootQueue bool ScannedGroupRepo *scanner.ScannedGroupRepository GroupOverrideRepo *scanner.MediaGroupOverrideRepository ObservedLocationRepo *scanner.ObservedLocationRepository @@ -94,6 +98,27 @@ type libraryScanQueuer interface { CancelByLibrary(ctx context.Context, libraryID int) (int, error) } +type libraryMovieMatchQueue interface { + SyncForFolder(ctx context.Context, folderID int) error + DeleteByFolder(ctx context.Context, folderID int) (int, error) + CountByFolder(ctx context.Context, folderID int) (int, error) + ListByFolder(ctx context.Context, folderID int, limit int, offset int) ([]models.MovieMatchQueueEntry, int, error) +} + +type librarySeriesMatchQueue interface { + SyncForFolder(ctx context.Context, folderID int) error + DeleteByFolder(ctx context.Context, folderID int) (int, error) + CountByFolder(ctx context.Context, folderID int) (int, error) + ListByFolder(ctx context.Context, folderID int, limit int, offset int) ([]models.SeriesRootMatchQueueEntry, int, error) +} + +type libraryRawMatchBacklog interface { + CountUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode scanner.RawMatchBacklogMode) (int, error) + ListUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode scanner.RawMatchBacklogMode, limit int, offset int) ([]*models.MediaFile, int, error) + SuppressUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode scanner.RawMatchBacklogMode) (int, error) + RetryUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode scanner.RawMatchBacklogMode) (int, error) +} + // NewLibraryHandler creates a new LibraryHandler backed by the given folder // repository and ingest executor. The ingester may be nil if scan endpoints are not needed. func NewLibraryHandler( @@ -1061,6 +1086,9 @@ func (h *LibraryHandler) recordAcceptedScan(scanID string, target *scantrigger.T Trigger: target.Trigger, Status: "accepted", }) + if run, ok := h.ScanRegistry.Get(scanID); ok { + h.publishScanEvent(context.Background(), "scan.accepted", run) + } } func (h *LibraryHandler) markScanRunning(scanID string) { @@ -1279,6 +1307,371 @@ type refreshLibraryMetadataRequest struct { Mode string `json:"mode"` } +type libraryMetadataMatchQueueStatusResponse struct { + LibraryID int `json:"library_id"` + MovieCount int `json:"movie_count"` + SeriesCount int `json:"series_count"` + RawFileCount int `json:"raw_file_count"` + TotalCount int `json:"total_count"` +} + +type libraryMetadataMatchQueueActionResponse struct { + Status string `json:"status"` + LibraryID int `json:"library_id"` + MovieCancelled int `json:"movie_cancelled,omitempty"` + SeriesCancelled int `json:"series_cancelled,omitempty"` + RawFileCancelled int `json:"raw_file_cancelled,omitempty"` + RawFileRetried int `json:"raw_file_retried,omitempty"` + TotalCancelled int `json:"total_cancelled,omitempty"` + Queue libraryMetadataMatchQueueStatusResponse `json:"queue"` +} + +type libraryMetadataMatchQueueDetailResponse struct { + libraryMetadataMatchQueueStatusResponse + Movies []libraryMovieMatchQueueEntryResponse `json:"movies"` + Series []librarySeriesMatchQueueEntryResponse `json:"series"` + RawFiles []libraryRawMatchBacklogEntryResponse `json:"raw_files"` +} + +type libraryMovieMatchQueueEntryResponse struct { + MediaFileID int `json:"media_file_id"` + MediaFolderID int `json:"media_folder_id"` + FilePath string `json:"file_path"` + FirstQueuedAt time.Time `json:"first_queued_at"` + AvailableAt time.Time `json:"available_at"` + LastAttemptedAt *time.Time `json:"last_attempted_at,omitempty"` + AttemptCount int `json:"attempt_count"` + LastError string `json:"last_error,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +type librarySeriesMatchQueueEntryResponse struct { + MediaFolderID int `json:"media_folder_id"` + ObservedRootPath string `json:"observed_root_path"` + FirstQueuedAt time.Time `json:"first_queued_at"` + AvailableAt time.Time `json:"available_at"` + LastAttemptedAt *time.Time `json:"last_attempted_at,omitempty"` + AttemptCount int `json:"attempt_count"` + LastError string `json:"last_error,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +type libraryRawMatchBacklogEntryResponse struct { + MediaFileID int `json:"media_file_id"` + MediaFolderID int `json:"media_folder_id"` + FilePath string `json:"file_path"` + BaseTitle string `json:"base_title,omitempty"` + BaseYear int `json:"base_year,omitempty"` + BaseType string `json:"base_type,omitempty"` + LastAttemptedAt *time.Time `json:"last_attempted_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (h *LibraryHandler) HandleListMetadataMatchQueues(w http.ResponseWriter, r *http.Request) { + if h.folderRepo == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Library repository is not configured") + return + } + + folders, err := h.folderRepo.List(r.Context()) + if err != nil { + slog.Error("metadata queue: failed to list libraries", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher queues") + return + } + + resp := make([]libraryMetadataMatchQueueStatusResponse, 0, len(folders)) + for _, folder := range folders { + if folder == nil { + continue + } + status, err := h.metadataMatchQueueStatus(r.Context(), folder.ID) + if err != nil { + slog.Error("metadata queue: failed to load queue status", "library_id", folder.ID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue") + return + } + resp = append(resp, status) + } + + writeJSON(w, http.StatusOK, resp) +} + +func (h *LibraryHandler) HandleGetMetadataMatchQueue(w http.ResponseWriter, r *http.Request) { + if !h.metadataMatchBacklogConfigured() { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Metadata matcher backlog is not configured") + return + } + + id, err := parseIDParam(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") + return + } + if _, err := h.folderRepo.GetByID(r.Context(), id); err != nil { + if errors.Is(err, catalog.ErrFolderNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Library not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") + return + } + + limit := 10 + if value := strings.TrimSpace(r.URL.Query().Get("limit")); value != "" { + if parsed, parseErr := strconv.Atoi(value); parseErr == nil && parsed > 0 && parsed <= 50 { + limit = parsed + } + } + + status, err := h.metadataMatchQueueStatus(r.Context(), id) + if err != nil { + slog.Error("metadata queue: failed to load queue status", "library_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue") + return + } + + resp := libraryMetadataMatchQueueDetailResponse{ + libraryMetadataMatchQueueStatusResponse: status, + Movies: []libraryMovieMatchQueueEntryResponse{}, + Series: []librarySeriesMatchQueueEntryResponse{}, + RawFiles: []libraryRawMatchBacklogEntryResponse{}, + } + if h.MovieMatchQueueRepo != nil { + movies, _, err := h.MovieMatchQueueRepo.ListByFolder(r.Context(), id, limit, 0) + if err != nil { + slog.Error("metadata queue: failed to list movie queue", "library_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher queue") + return + } + for _, entry := range movies { + resp.Movies = append(resp.Movies, libraryMovieMatchQueueEntryResponse{ + MediaFileID: entry.MediaFileID, + MediaFolderID: entry.MediaFolderID, + FilePath: entry.FilePath, + FirstQueuedAt: entry.FirstQueuedAt, + AvailableAt: entry.AvailableAt, + LastAttemptedAt: entry.LastAttemptedAt, + AttemptCount: entry.AttemptCount, + LastError: entry.LastError, + UpdatedAt: entry.UpdatedAt, + }) + } + } + if h.SeriesMatchQueueRepo != nil { + series, _, err := h.SeriesMatchQueueRepo.ListByFolder(r.Context(), id, limit, 0) + if err != nil { + slog.Error("metadata queue: failed to list series queue", "library_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher queue") + return + } + for _, entry := range series { + resp.Series = append(resp.Series, librarySeriesMatchQueueEntryResponse{ + MediaFolderID: entry.MediaFolderID, + ObservedRootPath: entry.ObservedRootPath, + FirstQueuedAt: entry.FirstQueuedAt, + AvailableAt: entry.AvailableAt, + LastAttemptedAt: entry.LastAttemptedAt, + AttemptCount: entry.AttemptCount, + LastError: entry.LastError, + UpdatedAt: entry.UpdatedAt, + }) + } + } + if h.RawMatchBacklogRepo != nil { + rawFiles, _, err := h.RawMatchBacklogRepo.ListUnmatchedMatchBacklogByFolder(r.Context(), id, h.rawMatchBacklogMode(), limit, 0) + if err != nil { + slog.Error("metadata queue: failed to list raw backlog", "library_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher backlog") + return + } + for _, file := range rawFiles { + if file == nil { + continue + } + resp.RawFiles = append(resp.RawFiles, libraryRawMatchBacklogEntryResponse{ + MediaFileID: file.ID, + MediaFolderID: file.MediaFolderID, + FilePath: file.FilePath, + BaseTitle: file.BaseTitle, + BaseYear: file.BaseYear, + BaseType: file.BaseType, + LastAttemptedAt: file.MatchAttemptedAt, + CreatedAt: file.CreatedAt, + UpdatedAt: file.UpdatedAt, + }) + } + } + + writeJSON(w, http.StatusOK, resp) +} + +func (h *LibraryHandler) HandleRetryMetadataMatchQueue(w http.ResponseWriter, r *http.Request) { + if !h.metadataMatchBacklogConfigured() { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Metadata matcher backlog is not configured") + return + } + + id, err := parseIDParam(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") + return + } + if _, err := h.folderRepo.GetByID(r.Context(), id); err != nil { + if errors.Is(err, catalog.ErrFolderNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Library not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") + return + } + + if h.SeriesMatchQueueRepo != nil { + if err := h.SeriesMatchQueueRepo.SyncForFolder(r.Context(), id); err != nil { + slog.Error("metadata queue: failed to retry series queue", "library_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher") + return + } + } + if h.MovieMatchQueueRepo != nil { + if err := h.MovieMatchQueueRepo.SyncForFolder(r.Context(), id); err != nil { + slog.Error("metadata queue: failed to retry movie queue", "library_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher") + return + } + } + rawFileRetried := 0 + if h.RawMatchBacklogRepo != nil { + rawFileRetried, err = h.RawMatchBacklogRepo.RetryUnmatchedMatchBacklogByFolder(r.Context(), id, h.rawMatchBacklogMode()) + if err != nil { + slog.Error("metadata queue: failed to retry raw backlog", "library_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher") + return + } + } + + status, err := h.metadataMatchQueueStatus(r.Context(), id) + if err != nil { + slog.Error("metadata queue: failed to load retried queue status", "library_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue") + return + } + writeJSON(w, http.StatusOK, libraryMetadataMatchQueueActionResponse{ + Status: "queued", + LibraryID: id, + RawFileRetried: rawFileRetried, + Queue: status, + }) +} + +func (h *LibraryHandler) HandleCancelMetadataMatchQueue(w http.ResponseWriter, r *http.Request) { + if !h.metadataMatchBacklogConfigured() { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Metadata matcher backlog is not configured") + return + } + + id, err := parseIDParam(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") + return + } + if _, err := h.folderRepo.GetByID(r.Context(), id); err != nil { + if errors.Is(err, catalog.ErrFolderNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Library not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") + return + } + + seriesCancelled := 0 + if h.SeriesMatchQueueRepo != nil { + seriesCancelled, err = h.SeriesMatchQueueRepo.DeleteByFolder(r.Context(), id) + if err != nil { + slog.Error("metadata queue: failed to cancel series queue", "library_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel metadata matcher") + return + } + } + movieCancelled := 0 + if h.MovieMatchQueueRepo != nil { + movieCancelled, err = h.MovieMatchQueueRepo.DeleteByFolder(r.Context(), id) + if err != nil { + slog.Error("metadata queue: failed to cancel movie queue", "library_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel metadata matcher") + return + } + } + rawFileCancelled := 0 + if h.RawMatchBacklogRepo != nil { + rawFileCancelled, err = h.RawMatchBacklogRepo.SuppressUnmatchedMatchBacklogByFolder(r.Context(), id, h.rawMatchBacklogMode()) + if err != nil { + slog.Error("metadata queue: failed to suppress raw backlog", "library_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel metadata matcher") + return + } + } + + status, err := h.metadataMatchQueueStatus(r.Context(), id) + if err != nil { + slog.Error("metadata queue: failed to load cancelled queue status", "library_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue") + return + } + writeJSON(w, http.StatusOK, libraryMetadataMatchQueueActionResponse{ + Status: "cancelled", + LibraryID: id, + MovieCancelled: movieCancelled, + SeriesCancelled: seriesCancelled, + RawFileCancelled: rawFileCancelled, + TotalCancelled: movieCancelled + seriesCancelled + rawFileCancelled, + Queue: status, + }) +} + +func (h *LibraryHandler) metadataMatchBacklogConfigured() bool { + return h.folderRepo != nil && + (h.MovieMatchQueueRepo != nil || h.SeriesMatchQueueRepo != nil || h.RawMatchBacklogRepo != nil) +} + +func (h *LibraryHandler) metadataMatchQueueStatus(ctx context.Context, libraryID int) (libraryMetadataMatchQueueStatusResponse, error) { + resp := libraryMetadataMatchQueueStatusResponse{LibraryID: libraryID} + if h.MovieMatchQueueRepo != nil { + count, err := h.MovieMatchQueueRepo.CountByFolder(ctx, libraryID) + if err != nil { + return resp, err + } + resp.MovieCount = count + } + if h.SeriesMatchQueueRepo != nil { + count, err := h.SeriesMatchQueueRepo.CountByFolder(ctx, libraryID) + if err != nil { + return resp, err + } + resp.SeriesCount = count + } + if h.RawMatchBacklogRepo != nil { + count, err := h.RawMatchBacklogRepo.CountUnmatchedMatchBacklogByFolder(ctx, libraryID, h.rawMatchBacklogMode()) + if err != nil { + return resp, err + } + resp.RawFileCount = count + } + resp.TotalCount = resp.MovieCount + resp.SeriesCount + resp.RawFileCount + return resp, nil +} + +func (h *LibraryHandler) rawMatchBacklogMode() scanner.RawMatchBacklogMode { + if h.TVSeriesRootQueue && h.MovieMatchQueueRepo != nil { + return scanner.RawMatchBacklogMixed + } + if h.TVSeriesRootQueue { + return scanner.RawMatchBacklogNonSeries + } + return scanner.RawMatchBacklogGeneric +} + // HandleRefreshLibraryMetadata handles POST /libraries/{id}/refresh-metadata. // It queues a background admin job to refresh metadata for items in the // specified library. Quick mode is the default. diff --git a/internal/api/handlers/libraries_match_test.go b/internal/api/handlers/libraries_match_test.go index fd8017e6..6d36a0a9 100644 --- a/internal/api/handlers/libraries_match_test.go +++ b/internal/api/handlers/libraries_match_test.go @@ -1,12 +1,15 @@ package handlers import ( + "context" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/models" ) // TestLibraryMatchUnmatchedItems_NilPool verifies that the unmatched-items @@ -166,6 +169,79 @@ func TestLibraryMatchRematch_DeprecatedStillRoutes(t *testing.T) { } } +func TestLibraryMetadataMatchQueueHandlers_NilFolderRepo(t *testing.T) { + h := &LibraryHandler{MovieMatchQueueRepo: noopMovieMatchQueue{}} + + tests := []struct { + name string + method string + path string + handler http.HandlerFunc + }{ + { + name: "get", + method: http.MethodGet, + path: "/libraries/1/metadata-match-queue", + handler: h.HandleGetMetadataMatchQueue, + }, + { + name: "retry", + method: http.MethodPost, + path: "/libraries/1/metadata-match-queue/retry", + handler: h.HandleRetryMetadataMatchQueue, + }, + { + name: "cancel", + method: http.MethodDelete, + path: "/libraries/1/metadata-match-queue", + handler: h.HandleCancelMetadataMatchQueue, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := chi.NewRouter() + r.Method(tt.method, "/libraries/{id}/metadata-match-queue", tt.handler) + r.Method(tt.method, "/libraries/{id}/metadata-match-queue/retry", tt.handler) + + req := httptest.NewRequest(tt.method, tt.path, nil) + rec := httptest.NewRecorder() + + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503 for nil folderRepo, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp errorResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decoding error response: %v", err) + } + if resp.Error != "unavailable" { + t.Errorf("expected error code 'unavailable', got %q", resp.Error) + } + }) + } +} + +type noopMovieMatchQueue struct{} + +func (noopMovieMatchQueue) SyncForFolder(context.Context, int) error { + return nil +} + +func (noopMovieMatchQueue) DeleteByFolder(context.Context, int) (int, error) { + return 0, nil +} + +func (noopMovieMatchQueue) CountByFolder(context.Context, int) (int, error) { + return 0, nil +} + +func (noopMovieMatchQueue) ListByFolder(context.Context, int, int, int) ([]models.MovieMatchQueueEntry, int, error) { + return nil, 0, nil +} + func keys(m map[string]any) []string { out := make([]string, 0, len(m)) for k := range m { diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go index 11f1dd57..a4a2bbfd 100644 --- a/internal/api/handlers/playback.go +++ b/internal/api/handlers/playback.go @@ -1544,6 +1544,7 @@ func (h *PlaybackHandler) HandleUpdateProgress(w http.ResponseWriter, r *http.Re writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update progress") return } + h.syncSessionsNow(r.Context(), "progress") // Persist progress to UserStore (best-effort). if sess, getErr := h.sessionMgr.GetSession(sessionID); getErr == nil { @@ -1851,6 +1852,7 @@ type transcodeStartState struct { file *models.MediaFile session *playback.Session switchedFileID *int + hwAccel string } // finalizeTranscodeStart updates the playback session state after a transcode @@ -1891,6 +1893,7 @@ func (h *PlaybackHandler) finalizeTranscodeStart(r *http.Request, st transcodeSt TargetVideoCodec: st.req.TargetCodecVideo, TargetAudioCodec: st.req.TargetCodecAudio, TargetBitrateKbps: st.req.TargetBitrateKbps, + TranscodeHWAccel: st.hwAccel, }); err != nil { slog.Error("failed to update transcode stream state", "session", st.req.SessionID, "error", err, "playback_session_id", st.req.SessionID) } @@ -2065,16 +2068,35 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re writeError(w, http.StatusBadGateway, "transcode_node_unavailable", "Transcode node is unavailable") return } - resp.Body.Close() + defer resp.Body.Close() if resp.StatusCode != http.StatusAccepted { slog.Error("remote transcode start rejected", "status", resp.StatusCode, "node", tcNode.URL) writeError(w, http.StatusBadGateway, "transcode_start_failed", "Transcode node rejected the request") return } + var nodeResp transcodenode.TranscodeStartResponse + if err := json.NewDecoder(resp.Body).Decode(&nodeResp); err != nil { + slog.Warn("remote transcode start response decode failed", + "error", err, + "node", tcNode.URL, + "session", req.SessionID, + "playback_session_id", req.SessionID, + ) + } + effectiveHWAccel := strings.TrimSpace(nodeResp.HWAccel) + if effectiveHWAccel == "" { + effectiveHWAccel = strings.TrimSpace(nodeReq.HWAccel) + } manifestURL := h.buildProxyManifestURL(req.SessionID, session, tcNode.URL) - h.finalizeTranscodeStart(r, transcodeStartState{req: req, file: file, session: session, switchedFileID: switchedFileID}) + h.finalizeTranscodeStart(r, transcodeStartState{ + req: req, + file: file, + session: session, + switchedFileID: switchedFileID, + hwAccel: effectiveHWAccel, + }) writeJSON(w, http.StatusAccepted, buildTranscodeStartResponse(req, file, switchedFileID, manifestURL)) return } @@ -2122,7 +2144,13 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re h.monitorLocalTranscodeExit(req.SessionID, transcodeSession) manifestURL := fmt.Sprintf("/playback/transcode/%s/master.m3u8", req.SessionID) - h.finalizeTranscodeStart(r, transcodeStartState{req: req, file: file, session: session, switchedFileID: switchedFileID}) + h.finalizeTranscodeStart(r, transcodeStartState{ + req: req, + file: file, + session: session, + switchedFileID: switchedFileID, + hwAccel: transcodeSession.Opts().HWAccel, + }) writeJSON(w, http.StatusAccepted, buildTranscodeStartResponse(req, file, switchedFileID, manifestURL)) } diff --git a/internal/api/handlers/playback_sessions.go b/internal/api/handlers/playback_sessions.go index 78569003..a101efc6 100644 --- a/internal/api/handlers/playback_sessions.go +++ b/internal/api/handlers/playback_sessions.go @@ -42,6 +42,7 @@ type playbackSessionRow struct { FileDuration *int `json:"file_duration"` StartedAt time.Time `json:"started_at"` UpdatedAt time.Time `json:"updated_at"` + PositionSeconds float64 `json:"position_seconds"` IsPaused bool `json:"is_paused"` HasPlaybackControl bool `json:"has_playback_control"` ClientIP string `json:"client_ip,omitempty"` @@ -53,6 +54,7 @@ type playbackSessionRow struct { TargetVideoCodec string `json:"target_video_codec,omitempty"` TargetAudioCodec string `json:"target_audio_codec,omitempty"` TargetBitrateKbps *int `json:"target_bitrate_kbps"` + TranscodeHWAccel string `json:"transcode_hw_accel,omitempty"` SourceContainer string `json:"source_container,omitempty"` SourceBitrateKbps *int `json:"source_bitrate_kbps"` SourceVideoCodec string `json:"source_video_codec,omitempty"` @@ -143,6 +145,7 @@ func (l *PlaybackSessionsLoader) Load( mf.duration, s.started_at, s.updated_at, + COALESCE(s.position_seconds, 0), COALESCE(s.is_paused, FALSE), COALESCE(s.has_websocket, FALSE), COALESCE(HOST(s.client_ip), ''), @@ -154,6 +157,7 @@ func (l *PlaybackSessionsLoader) Load( COALESCE(s.target_video_codec, ''), COALESCE(s.target_audio_codec, ''), s.target_bitrate_kbps, + COALESCE(s.transcode_hw_accel, ''), COALESCE(mf.container, ''), mf.bitrate, COALESCE(mf.codec_video, ''), @@ -199,9 +203,9 @@ func (l *PlaybackSessionsLoader) Load( &s.MediaTitle, &s.MediaType, &s.SeriesName, &s.EpisodeName, &s.SeasonNumber, &s.EpisodeNumber, &posterPath, &s.PlayMethod, &s.ReportingNode, &s.NodeDisplayName, &s.FileDuration, &s.StartedAt, &s.UpdatedAt, - &s.IsPaused, &s.HasPlaybackControl, &s.ClientIP, &s.AudioTrackIndex, &s.TranscodeAudio, &streamBitrateKbps, + &s.PositionSeconds, &s.IsPaused, &s.HasPlaybackControl, &s.ClientIP, &s.AudioTrackIndex, &s.TranscodeAudio, &streamBitrateKbps, &s.TranscodeNodeURL, &s.TargetResolution, &s.TargetVideoCodec, &s.TargetAudioCodec, &targetBitrateKbps, - &s.SourceContainer, &sourceBitrateKbps, &s.SourceVideoCodec, &s.SourceVideoResolution, + &s.TranscodeHWAccel, &s.SourceContainer, &sourceBitrateKbps, &s.SourceVideoCodec, &s.SourceVideoResolution, &s.SourceAudioCodec, &sourceAudioChannels, &audioTracksJSON, &s.RequestedVideoCodec, &s.RequestedVideoResolution, ); err != nil { return nil, fmt.Errorf("scanning playback session: %w", err) @@ -314,7 +318,7 @@ func sessionComponentDecision(playMethod string, transcodeAudio bool, targetVide } audioDec := "transcode" if !transcodeAudio { - audioDec = "direct" + audioDec = "remux" } return videoDec, audioDec default: diff --git a/internal/api/handlers/playback_sessions_test.go b/internal/api/handlers/playback_sessions_test.go new file mode 100644 index 00000000..e28e795c --- /dev/null +++ b/internal/api/handlers/playback_sessions_test.go @@ -0,0 +1,14 @@ +package handlers + +import "testing" + +func TestSessionComponentDecisionLabelsCopiedAudioDuringHLSAsRemux(t *testing.T) { + videoDecision, audioDecision := sessionComponentDecision("transcode", false, "copy") + + if videoDecision != "remux" { + t.Fatalf("videoDecision = %q, want remux", videoDecision) + } + if audioDecision != "remux" { + t.Fatalf("audioDecision = %q, want remux", audioDecision) + } +} diff --git a/internal/api/handlers/progress.go b/internal/api/handlers/progress.go index 8125985b..1abbb887 100644 --- a/internal/api/handlers/progress.go +++ b/internal/api/handlers/progress.go @@ -258,7 +258,7 @@ func (h *ProgressHandler) HandleSyncProgress(w http.ResponseWriter, r *http.Requ item.MediaItemID, "", "progress", - "progress.updated", + userStateEventState{}, ) } } diff --git a/internal/api/handlers/settings.go b/internal/api/handlers/settings.go index 59737dc9..be2d1883 100644 --- a/internal/api/handlers/settings.go +++ b/internal/api/handlers/settings.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "math" "net/http" "strconv" @@ -372,6 +373,7 @@ func (h *SettingsHandler) HandleGetDeviceSetting(w http.ResponseWriter, r *http. writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") return } + registerRequestDevice(r.Context(), store, profileID, device) value, err := store.GetDeviceSetting(r.Context(), profileID, device.DeviceID, key) if err != nil { @@ -471,6 +473,7 @@ func (h *SettingsHandler) HandleDeleteDeviceSetting(w http.ResponseWriter, r *ht writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") return } + registerRequestDevice(r.Context(), store, profileID, device) if err := store.DeleteDeviceSetting(r.Context(), profileID, device.DeviceID, key); err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device setting") @@ -500,6 +503,7 @@ func (h *SettingsHandler) HandleGetEffectiveSettings(w http.ResponseWriter, r *h } device := deviceMetadataFromRequest(r) + registerRequestDevice(r.Context(), store, profileID, device) keys := parseSettingKeys(keysParam) resp := effectiveSettingsResponse{ Settings: make([]effectiveSettingResponse, 0, len(keys)), @@ -530,6 +534,7 @@ func (h *SettingsHandler) HandleGetEffectiveSubtitleAppearance(w http.ResponseWr writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") return } + registerRequestDevice(r.Context(), store, profileID, device) resolved, err := h.resolveEffectiveSetting(r.Context(), store, profileID, device, subtitleAppearanceSettingKey) if err != nil { @@ -590,6 +595,36 @@ func deviceMetadataFromRequest(r *http.Request) requestDeviceMetadata { } } +func registerRequestDevice( + ctx context.Context, + store userstore.UserStore, + profileID string, + device requestDeviceMetadata, +) { + if strings.TrimSpace(profileID) == "" || strings.TrimSpace(device.DeviceID) == "" { + return + } + if store == nil { + return + } + registry, ok := store.(userstore.DeviceRegistry) + if !ok { + return + } + if err := registry.RegisterDevice(ctx, userstore.DeviceEntry{ + ProfileID: profileID, + DeviceID: device.DeviceID, + DeviceName: device.DeviceName, + DevicePlatform: device.DevicePlatform, + }); err != nil { + slog.Warn("failed to register request device", + "profile_id", profileID, + "device_id", device.DeviceID, + "error", err, + ) + } +} + func clampHeaderValue(value string, maxLen int) string { value = strings.TrimSpace(value) runes := []rune(value) diff --git a/internal/api/handlers/settings_device_test.go b/internal/api/handlers/settings_device_test.go index 139fecc6..48beb120 100644 --- a/internal/api/handlers/settings_device_test.go +++ b/internal/api/handlers/settings_device_test.go @@ -47,6 +47,14 @@ func (r testAdminUserRepo) GetByID(_ context.Context, id int) (*models.User, err return r.users[id], nil } +func TestRegisterRequestDeviceNilStore(t *testing.T) { + registerRequestDevice(context.Background(), nil, "profile-1", requestDeviceMetadata{ + DeviceID: "device-1", + DeviceName: "Living Room", + DevicePlatform: "web", + }) +} + type mappedTestUserStoreProvider struct { stores map[int]userstore.UserStore } @@ -472,6 +480,18 @@ func TestAdminCanListAndInspectDevicesAcrossUsers(t *testing.T) { }); err != nil { t.Fatalf("SetDeviceSetting store1 second: %v", err) } + store1Registry, ok := store1.(userstore.DeviceRegistry) + if !ok { + t.Fatalf("store1 does not support device registry") + } + if err := store1Registry.RegisterDevice(context.Background(), userstore.DeviceEntry{ + ProfileID: "profile-1", + DeviceID: "bedroom", + DeviceName: "Bedroom TV", + DevicePlatform: "Android TV", + }); err != nil { + t.Fatalf("RegisterDevice store1: %v", err) + } if err := store2.SetDeviceSetting(context.Background(), userstore.DeviceSettingEntry{ ProfileID: "profile-1", DeviceID: "phone", @@ -508,8 +528,21 @@ func TestAdminCanListAndInspectDevicesAcrossUsers(t *testing.T) { if err := json.NewDecoder(rec.Body).Decode(&listResp); err != nil { t.Fatalf("decode list: %v", err) } - if len(listResp.Devices) != 2 { - t.Fatalf("devices len = %d, want 2", len(listResp.Devices)) + if len(listResp.Devices) != 3 { + t.Fatalf("devices len = %d, want 3", len(listResp.Devices)) + } + var bedroom *adminDeviceSummaryResponse + for i := range listResp.Devices { + if listResp.Devices[i].DeviceID == "bedroom" { + bedroom = &listResp.Devices[i] + break + } + } + if bedroom == nil { + t.Fatalf("registered device without overrides missing: %#v", listResp.Devices) + } + if bedroom.OverrideCount != 0 || bedroom.ProfileCount != 1 || bedroom.DeviceName != "Bedroom TV" { + t.Fatalf("registered device summary = %#v", bedroom) } req = httptest.NewRequest(http.MethodGet, "/admin/devices/7/living-room", nil) @@ -529,6 +562,26 @@ func TestAdminCanListAndInspectDevicesAcrossUsers(t *testing.T) { if len(detailResp.Settings) != 2 { t.Fatalf("detail settings len = %d, want 2", len(detailResp.Settings)) } + + req = httptest.NewRequest(http.MethodGet, "/admin/devices/7/bedroom", nil) + req = withRouteParams(req, map[string]string{"user_id": "7", "device_id": "bedroom"}) + rec = httptest.NewRecorder() + handler.HandleGetDevice(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("registered detail status = %d body=%s", rec.Code, rec.Body.String()) + } + if err := json.NewDecoder(rec.Body).Decode(&detailResp); err != nil { + t.Fatalf("decode registered detail: %v", err) + } + if detailResp.DeviceName != "Bedroom TV" || detailResp.OverrideCount != 0 { + t.Fatalf("registered detail response = %#v", detailResp) + } + if len(detailResp.Settings) != 0 { + t.Fatalf("registered detail settings len = %d, want 0", len(detailResp.Settings)) + } + if len(detailResp.Profiles) != 1 || detailResp.Profiles[0].ProfileID != "profile-1" { + t.Fatalf("registered detail profiles = %#v", detailResp.Profiles) + } } func TestAdminCanResetAllOverridesForOneDevice(t *testing.T) { @@ -559,6 +612,24 @@ func TestAdminCanResetAllOverridesForOneDevice(t *testing.T) { if len(entries) != 1 || entries[0].DeviceID != "phone" { t.Fatalf("entries after delete = %#v", entries) } + registry, ok := store.(userstore.DeviceRegistry) + if !ok { + t.Fatalf("store does not support device registry") + } + devices, err := registry.ListDevices(context.Background()) + if err != nil { + t.Fatalf("ListDevices: %v", err) + } + foundLivingRoom := false + for _, device := range devices { + if device.ProfileID == "profile-1" && device.DeviceID == "living-room" { + foundLivingRoom = true + break + } + } + if !foundLivingRoom { + t.Fatalf("registry devices after delete = %#v", devices) + } } func withRouteParams(req *http.Request, params map[string]string) *http.Request { diff --git a/internal/api/handlers/user_state_events.go b/internal/api/handlers/user_state_events.go index 24c17b5e..03797e61 100644 --- a/internal/api/handlers/user_state_events.go +++ b/internal/api/handlers/user_state_events.go @@ -7,26 +7,41 @@ import ( ) type userStateEventPayload struct { - ProfileID string `json:"profile_id"` - ContentID string `json:"content_id,omitempty"` - SeriesID string `json:"series_id,omitempty"` - Change string `json:"change"` + ProfileID string `json:"profile_id"` + ContentID string `json:"content_id,omitempty"` + SeriesID string `json:"series_id,omitempty"` + Change string `json:"change"` + Played *bool `json:"played,omitempty"` + IsFavorite *bool `json:"is_favorite,omitempty"` + InWatchlist *bool `json:"in_watchlist,omitempty"` } +type userStateEventState struct { + Played *bool + IsFavorite *bool + InWatchlist *bool +} + +const userStateChangedEvent = "user_state.changed" + func publishUserStateEvent( ctx context.Context, hub *evt.Hub, userID int, - profileID, contentID, seriesID, change, eventName string, + profileID, contentID, seriesID, change string, + state userStateEventState, ) { - if hub == nil || userID == 0 || profileID == "" || change == "" || eventName == "" { + if hub == nil || userID == 0 || profileID == "" || change == "" { return } - _ = hub.PublishJSON(ctx, evt.ChannelUserState, eventName, userStateEventPayload{ - ProfileID: profileID, - ContentID: contentID, - SeriesID: seriesID, - Change: change, + _ = hub.PublishJSON(ctx, evt.ChannelUserState, userStateChangedEvent, userStateEventPayload{ + ProfileID: profileID, + ContentID: contentID, + SeriesID: seriesID, + Change: change, + Played: state.Played, + IsFavorite: state.IsFavorite, + InWatchlist: state.InWatchlist, }, evt.PublishOptions{ UserID: userID, ProfileID: profileID, diff --git a/internal/api/router.go b/internal/api/router.go index 1a93ccf0..a8234b11 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -89,11 +89,13 @@ type Dependencies struct { SessionMgr *playback.SessionManager // playback session manager (may be nil) SkippedRootRepo *metadata.SkippedRootRepository // skipped root repository (may be nil) StaleIDRepo *metadata.StaleMediaIDRepository // stale media ID repository (may be nil) - Refresher handlers.AdminMetadataRefresher // metadata refresher (may be nil) - NodeRepo *nodepool.Repository // stream node repository (may be nil) - ProxyPool *nodepool.ProxyPool // proxy node pool (may be nil) - TranscodePool *nodepool.TranscodePool // transcode node pool (may be nil) - SessionSyncer handlers.PlaybackSessionSyncer // optional; immediate playback session sync trigger + MovieMatchQueueRepo *metadata.MovieMatchQueueRepository + SeriesRootMatchQueueRepo *metadata.SeriesRootMatchQueueRepository + Refresher handlers.AdminMetadataRefresher // metadata refresher (may be nil) + NodeRepo *nodepool.Repository // stream node repository (may be nil) + ProxyPool *nodepool.ProxyPool // proxy node pool (may be nil) + TranscodePool *nodepool.TranscodePool // transcode node pool (may be nil) + SessionSyncer handlers.PlaybackSessionSyncer // optional; immediate playback session sync trigger EventBus cache.EventBus AdminStatsProvider handlers.AdminStatsSource Recommender recommendations.Recommender // nil when disabled @@ -116,6 +118,7 @@ type Dependencies struct { FFmpegLogSink playback.FFmpegLogSink RedisClient *redis.Client // for session listing (may be nil) TaskManager *taskmanager.TaskManager // task manager (may be nil) + AdminJobCancelRegistry *adminjob.CancelRegistry IntroRepository *intromarkers.Repository IntroAnalyzer *intromarkers.Analyzer MarkerRegistry *markers.Registry @@ -287,6 +290,12 @@ func NewRouter(deps Dependencies) chi.Router { libraryHandler.EventsHub = deps.EventsHub libraryHandler.ScanRegistry = deps.ScanRegistry libraryHandler.ScanQueue = deps.LibraryScanQueue + libraryHandler.MovieMatchQueueRepo = deps.MovieMatchQueueRepo + libraryHandler.SeriesMatchQueueRepo = deps.SeriesRootMatchQueueRepo + libraryHandler.RawMatchBacklogRepo = deps.FileRepo + if deps.Config != nil { + libraryHandler.TVSeriesRootQueue = deps.Config.Matcher.TVSeriesRootQueueEnabled() + } if deps.DB != nil { libraryHandler.JobRepo = adminjob.NewRepository(deps.DB) } @@ -499,6 +508,7 @@ func NewRouter(deps Dependencies) chi.Router { settingsHandler.SetServerSettings(settingsRepo) } homeDismissalHandler = handlers.NewHomeDismissalHandler(deps.UserStoreProvider) + homeDismissalHandler.EventsHub = deps.EventsHub subtitlePrefHandler = handlers.NewSubtitlePrefHandler(deps.UserStoreProvider) audioPrefHandler = handlers.NewAudioPrefHandler(deps.UserStoreProvider) libraryPlaybackPrefHandler = handlers.NewLibraryPlaybackPrefHandler(deps.UserStoreProvider) @@ -706,6 +716,8 @@ func NewRouter(deps Dependencies) chi.Router { catalogSeedHandler = handlers.NewCatalogSeedHandler(catalogseed.NewService(deps.DB, deps.PersonRepo, recommendations.NewRepo(deps.DB)), jobRepo, privateStore) catalogSeedHandler.RealtimeHub = deps.RealtimeHub adminJobsHandler = handlers.NewAdminJobsHandler(jobRepo, privateStore) + adminJobsHandler.CancelRegistry = deps.AdminJobCancelRegistry + adminJobsHandler.RealtimeHub = deps.RealtimeHub if adminHandler != nil && deps.FolderRepo != nil && deps.FileRepo != nil && itemRepo != nil && episodeRepo != nil { adminHandler.JobRepo = jobRepo adminHandler.ItemRefreshResolver = adminjob.NewItemRefreshResolver( @@ -1301,12 +1313,16 @@ func NewRouter(deps Dependencies) chi.Router { r.Get("/stale-ids", libraryHandler.HandleListStaleIDs) r.Post("/stale-ids/{contentID}/rematch", libraryHandler.HandleRematchStaleID) r.Get("/unmatched-items", libraryHandler.HandleListUnmatchedItems) + r.Get("/metadata-match-queue", libraryHandler.HandleListMetadataMatchQueues) r.Post("/", libraryHandler.HandleCreateLibrary) r.Put("/reorder", libraryHandler.HandleReorderLibraries) r.Put("/{id}", libraryHandler.HandleUpdateLibrary) r.Delete("/{id}", libraryHandler.HandleDeleteLibrary) r.Post("/{id}/check-mount", libraryHandler.HandleCheckLibraryMount) r.Post("/{id}/confirm-empty-root-cleanup", libraryHandler.HandleConfirmEmptyRootCleanup) + r.Get("/{id}/metadata-match-queue", libraryHandler.HandleGetMetadataMatchQueue) + r.Post("/{id}/metadata-match-queue/retry", libraryHandler.HandleRetryMetadataMatchQueue) + r.Post("/{id}/metadata-match-queue/cancel", libraryHandler.HandleCancelMetadataMatchQueue) r.Post("/{id}/refresh-metadata", libraryHandler.HandleRefreshLibraryMetadata) r.Get("/{id}/providers", libraryHandler.HandleGetLibraryProviders) r.Put("/{id}/providers", libraryHandler.HandleSetLibraryProviders) @@ -1855,6 +1871,7 @@ func NewRouter(deps Dependencies) chi.Router { if adminJobsHandler != nil { r.Route("/jobs", func(r chi.Router) { r.Get("/", adminJobsHandler.HandleList) + r.Post("/{id}/cancel", adminJobsHandler.HandleCancel) }) } diff --git a/internal/libraryingest/executor.go b/internal/libraryingest/executor.go index 688e326c..bc53e73f 100644 --- a/internal/libraryingest/executor.go +++ b/internal/libraryingest/executor.go @@ -358,8 +358,9 @@ func (e *Executor) ingest(ctx context.Context, folder *models.MediaFolder, mode } } if shouldPublish(result) && e.realtime != nil { - if err := e.realtime.PublishLibraryChanged(scanCtx, notifications.LibraryChangeEvent{ + if err := e.realtime.PublishCatalogLibraryChanged(scanCtx, notifications.LibraryChangeEvent{ LibraryID: folder.ID, + Reason: "scan", New: scanResultCount(scanResult, func(value *scanner.ScanResult) int { return value.New }), Updated: scanResultCount(scanResult, func(value *scanner.ScanResult) int { return value.Updated }), Missing: scanResultCount(scanResult, func(value *scanner.ScanResult) int { return value.Missing }), diff --git a/internal/metadata/movie_match_queue_repo.go b/internal/metadata/movie_match_queue_repo.go index ba7dd186..36240e86 100644 --- a/internal/metadata/movie_match_queue_repo.go +++ b/internal/metadata/movie_match_queue_repo.go @@ -450,6 +450,23 @@ func (r *MovieMatchQueueRepository) Delete(ctx context.Context, mediaFileID int) return nil } +func (r *MovieMatchQueueRepository) DeleteByFolder(ctx context.Context, folderID int) (int, error) { + if err := r.requireConfigured(); err != nil { + return 0, err + } + if err := requirePositiveMovieQueueID("folder id", folderID); err != nil { + return 0, err + } + tag, err := r.pool.Exec(ctx, ` + DELETE FROM movie_match_queue + WHERE media_folder_id = $1 + `, folderID) + if err != nil { + return 0, fmt.Errorf("deleting movie queue rows for folder: %w", err) + } + return int(tag.RowsAffected()), nil +} + func (r *MovieMatchQueueRepository) UpdateError(ctx context.Context, mediaFileID int, errText string) error { if err := r.requireConfigured(); err != nil { return err @@ -485,10 +502,20 @@ func (r *MovieMatchQueueRepository) ListByFolder(ctx context.Context, folderID i } rows, err := r.pool.Query(ctx, ` - SELECT media_file_id, media_folder_id, first_queued_at, available_at, last_attempted_at, attempt_count, last_error, updated_at - FROM movie_match_queue - WHERE media_folder_id = $1 - ORDER BY available_at ASC, last_attempted_at ASC NULLS FIRST, media_file_id ASC + SELECT + q.media_file_id, + q.media_folder_id, + COALESCE(mf.file_path, '') AS file_path, + q.first_queued_at, + q.available_at, + q.last_attempted_at, + q.attempt_count, + q.last_error, + q.updated_at + FROM movie_match_queue q + LEFT JOIN media_files mf ON mf.id = q.media_file_id + WHERE q.media_folder_id = $1 + ORDER BY q.available_at ASC, q.last_attempted_at ASC NULLS FIRST, q.media_file_id ASC LIMIT $2 OFFSET $3 `, folderID, limit, offset) if err != nil { @@ -502,6 +529,7 @@ func (r *MovieMatchQueueRepository) ListByFolder(ctx context.Context, folderID i if err := rows.Scan( &entry.MediaFileID, &entry.MediaFolderID, + &entry.FilePath, &entry.FirstQueuedAt, &entry.AvailableAt, &entry.LastAttemptedAt, diff --git a/internal/metadata/series_root_match_queue_repo.go b/internal/metadata/series_root_match_queue_repo.go index 3995e878..e832d405 100644 --- a/internal/metadata/series_root_match_queue_repo.go +++ b/internal/metadata/series_root_match_queue_repo.go @@ -520,6 +520,23 @@ func (r *SeriesRootMatchQueueRepository) Delete(ctx context.Context, folderID in return nil } +func (r *SeriesRootMatchQueueRepository) DeleteByFolder(ctx context.Context, folderID int) (int, error) { + if err := r.requireConfigured(); err != nil { + return 0, err + } + if err := requirePositiveSeriesQueueID("folder id", folderID); err != nil { + return 0, err + } + tag, err := r.pool.Exec(ctx, ` + DELETE FROM series_root_match_queue + WHERE media_folder_id = $1 + `, folderID) + if err != nil { + return 0, fmt.Errorf("deleting series root queue rows for folder: %w", err) + } + return int(tag.RowsAffected()), nil +} + func (r *SeriesRootMatchQueueRepository) UpdateError(ctx context.Context, folderID int, observedRootPath string, errText string) error { if err := r.requireConfigured(); err != nil { return err diff --git a/internal/metadata/worker.go b/internal/metadata/worker.go index bec12554..1e5efec8 100644 --- a/internal/metadata/worker.go +++ b/internal/metadata/worker.go @@ -15,6 +15,7 @@ import ( "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/notifications" ) // MatchWorker processes unmatched files in the background. @@ -25,6 +26,7 @@ type MatchWorker struct { movieClaimer MovieFileClaimer seriesClaimer SeriesRootClaimer enableTVSeriesRootQueue bool + realtimeHub *notifications.Hub workers int batchSize int interval time.Duration @@ -40,6 +42,10 @@ type MixedFileClaimer interface { ClaimUnmatchedMixedByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int, attemptBefore time.Time) ([]*models.MediaFile, error) } +type MatchSuppressionChecker interface { + IsMatchSuppressed(ctx context.Context, fileID int) (bool, error) +} + type MovieFileClaimer interface { Claim(ctx context.Context, limit int) ([]*models.MediaFile, error) ClaimByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int, attemptBefore time.Time) ([]*models.MediaFile, error) @@ -98,6 +104,14 @@ func (w *MatchWorker) SetMovieFileClaimer(claimer MovieFileClaimer) { w.movieClaimer = claimer } +// SetRealtimeHub publishes catalog item changes as metadata enrichment commits. +func (w *MatchWorker) SetRealtimeHub(hub *notifications.Hub) { + if w == nil { + return + } + w.realtimeHub = hub +} + // Run starts the match worker loop. It blocks until ctx is cancelled. func (w *MatchWorker) Run(ctx context.Context) { ticker := time.NewTicker(w.interval) @@ -234,7 +248,9 @@ func (w *MatchWorker) processFileWithFolderCache(ctx context.Context, file *mode "content_id", skeleton.ContentID, "error", fbErr) } } + return } + w.publishCatalogItemChanged(ctx, file.MediaFolderID, resultContentID(result, skeleton.ContentID), "metadata_updated") } func (w *MatchWorker) buildProcessRequestForGroup(ctx context.Context, representative *models.MediaFile, skeleton *skeletonResult, preloadedGroupFiles []*models.MediaFile) ProcessRequest { @@ -492,6 +508,9 @@ func (w *MatchWorker) processFiles(ctx context.Context, files []*models.MediaFil if ctx.Err() != nil { return } + if w.isMatchSuppressed(ctx, file) { + continue + } w.processFileWithFolderCache(ctx, file, &folders, &deferredSeriesLinks) processed.Add(1) } @@ -517,6 +536,32 @@ func (w *MatchWorker) processFiles(ctx context.Context, files []*models.MediaFil return claimedCount } +func (w *MatchWorker) isMatchSuppressed(ctx context.Context, file *models.MediaFile) bool { + if file == nil { + return true + } + checker, ok := w.fileLister.(MatchSuppressionChecker) + if !ok { + return false + } + suppressed, err := checker.IsMatchSuppressed(ctx, file.ID) + if err != nil { + slog.Warn("metadata: failed to check match suppression", + "file_id", file.ID, + "path", file.FilePath, + "error", err, + ) + return true + } + if suppressed { + slog.Info("metadata: skipping suppressed unmatched file", + "file_id", file.ID, + "path", file.FilePath, + ) + } + return suppressed +} + func (w *MatchWorker) processQueuedMovieFiles(ctx context.Context, files []*models.MediaFile) int { if len(files) == 0 { return 0 @@ -627,6 +672,7 @@ func (w *MatchWorker) processQueuedMovieFile(ctx context.Context, file *models.M w.logStatusUpdateFailure(ctx, skeleton.ContentID, "unmatched", "content_id", skeleton.ContentID, "file_id", file.ID, "path", file.FilePath) return false } + w.publishCatalogItemChanged(ctx, file.MediaFolderID, resultContentID(result, skeleton.ContentID), "metadata_updated") } if err := w.movieClaimer.Delete(ctx, file.ID); err != nil { @@ -837,6 +883,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo } return 0, nil } + w.publishCatalogItemChanged(ctx, job.MediaFolderID, resultContentID(result, skeleton.ContentID), "metadata_updated") } if err := w.service.ensureSeriesEpisodeLinks(ctx, representative.ContentID); err != nil { if errors.Is(err, catalog.ErrItemNotFound) { @@ -932,6 +979,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo } return 0, nil } + w.publishCatalogItemChanged(ctx, job.MediaFolderID, resultContentID(result, skeleton.ContentID), "metadata_updated") } finalContentID, err := w.service.fileRepo.FindContentIDByObservedRootPath(ctx, job.MediaFolderID, job.ObservedRootPath, "series") @@ -1242,7 +1290,9 @@ func (w *MatchWorker) RetryUnmatchedItemsByFolderAndPathPrefix(ctx context.Conte } if result == nil || !result.Updated { stillUnmatched++ + continue } + w.publishCatalogItemChanged(ctx, folderID, resultContentID(result, contentID), "metadata_updated") } if repaired, repairErr := w.service.repairMatchedDuplicateProviderOwnersByFolderAndPathPrefix(ctx, folderID, pathPrefix); repairErr != nil { @@ -1262,3 +1312,28 @@ func (w *MatchWorker) RetryUnmatchedItemsByFolderAndPathPrefix(ctx context.Conte func formatFolderID(id int) string { return strconv.Itoa(id) } + +func (w *MatchWorker) publishCatalogItemChanged(ctx context.Context, libraryID int, contentID string, change string) { + if w == nil || w.realtimeHub == nil || libraryID <= 0 || strings.TrimSpace(contentID) == "" { + return + } + if err := w.realtimeHub.PublishCatalogItemChanged(ctx, notifications.MetadataUpdateEvent{ + LibraryID: libraryID, + ContentID: contentID, + Change: change, + }); err != nil { + slog.Warn("metadata: failed to publish catalog item change", + "content_id", contentID, + "library_id", libraryID, + "change", change, + "error", err, + ) + } +} + +func resultContentID(result *ProcessResult, fallback string) string { + if result != nil && strings.TrimSpace(result.ContentID) != "" { + return result.ContentID + } + return fallback +} diff --git a/internal/models/admin_job.go b/internal/models/admin_job.go index 28ecf8fc..ec24dc97 100644 --- a/internal/models/admin_job.go +++ b/internal/models/admin_job.go @@ -7,25 +7,25 @@ import ( // AdminJob represents a reusable background job row for admin workflows. type AdminJob struct { - ID string - JobType string - Status string - CreatedByUserID int - RequestPayload json.RawMessage - ResultPayload json.RawMessage - Message string - ErrorMessage string - ProgressCurrent int - ProgressTotal int - ArtifactBucket string - ArtifactKey string - ArtifactSizeBytes int64 - PublicURL string - RequestedAt time.Time - StartedAt *time.Time - CompletedAt *time.Time - HeartbeatAt *time.Time - ExpiresAt *time.Time - PublishedAt *time.Time - UpdatedAt time.Time + ID string `json:"id"` + JobType string `json:"job_type"` + Status string `json:"status"` + CreatedByUserID int `json:"created_by_user_id"` + RequestPayload json.RawMessage `json:"request_payload"` + ResultPayload json.RawMessage `json:"result_payload"` + Message string `json:"message"` + ErrorMessage string `json:"error_message,omitempty"` + ProgressCurrent int `json:"progress_current"` + ProgressTotal int `json:"progress_total"` + ArtifactBucket string `json:"-"` + ArtifactKey string `json:"-"` + ArtifactSizeBytes int64 `json:"artifact_size_bytes"` + PublicURL string `json:"public_url,omitempty"` + RequestedAt time.Time `json:"requested_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + HeartbeatAt *time.Time `json:"heartbeat_at,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + PublishedAt *time.Time `json:"published_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/internal/models/admin_job_test.go b/internal/models/admin_job_test.go new file mode 100644 index 00000000..a2783d51 --- /dev/null +++ b/internal/models/admin_job_test.go @@ -0,0 +1,54 @@ +package models + +import ( + "encoding/json" + "testing" + "time" +) + +func TestAdminJobJSONUsesAPIFieldNames(t *testing.T) { + job := AdminJob{ + ID: "job-1", + JobType: "library_refresh", + Status: "running", + CreatedByUserID: 7, + RequestPayload: json.RawMessage(`{"library_id":42}`), + ResultPayload: json.RawMessage(`{}`), + Message: "Refreshing metadata", + ProgressCurrent: 3, + ProgressTotal: 10, + RequestedAt: time.Unix(1_700_000_000, 0).UTC(), + UpdatedAt: time.Unix(1_700_000_001, 0).UTC(), + } + + data, err := json.Marshal(job) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + var payload map[string]json.RawMessage + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + for _, key := range []string{ + "id", + "job_type", + "created_by_user_id", + "request_payload", + "progress_current", + "progress_total", + "requested_at", + "updated_at", + } { + if _, ok := payload[key]; !ok { + t.Fatalf("marshaled job missing key %q in %s", key, data) + } + } + + for _, key := range []string{"ID", "JobType", "CreatedByUserID", "ProgressCurrent"} { + if _, ok := payload[key]; ok { + t.Fatalf("marshaled job unexpectedly included key %q in %s", key, data) + } + } +} diff --git a/internal/models/media_group.go b/internal/models/media_group.go index 2bae58fe..4aee3335 100644 --- a/internal/models/media_group.go +++ b/internal/models/media_group.go @@ -94,6 +94,7 @@ type SeriesRootMatchJob struct { type MovieMatchQueueEntry struct { MediaFileID int MediaFolderID int + FilePath string FirstQueuedAt time.Time AvailableAt time.Time LastAttemptedAt *time.Time diff --git a/internal/notifications/hub.go b/internal/notifications/hub.go index e317999c..5d8292c8 100644 --- a/internal/notifications/hub.go +++ b/internal/notifications/hub.go @@ -13,13 +13,17 @@ import ( type Type string const ( - TypeLibraryChanged Type = "library.changed" - TypeLibraryItemAdded Type = "library.item_added" - TypeMetadataUpdated Type = "metadata.updated" + TypeCatalogLibraryChanged Type = "catalog.library.changed" + TypeCatalogItemChanged Type = "catalog.item.changed" + + TypeLibraryChanged Type = "library.changed" // legacy catalog event name + TypeLibraryItemAdded Type = "library.item_added" // legacy catalog event name + TypeMetadataUpdated Type = "metadata.updated" // legacy catalog event name TypeJobCreated Type = "job.created" TypeJobProgress Type = "job.progress" TypeJobCompleted Type = "job.completed" TypeJobFailed Type = "job.failed" + TypeJobCancelled Type = "job.cancelled" ) type Envelope struct { @@ -29,6 +33,8 @@ type Envelope struct { AdminOnly bool `json:"admin_only,omitempty"` LibraryID int `json:"library_id,omitempty"` ContentID string `json:"content_id,omitempty"` + Reason string `json:"reason,omitempty"` + Change string `json:"change,omitempty"` New int `json:"new,omitempty"` Updated int `json:"updated,omitempty"` Missing int `json:"missing,omitempty"` @@ -40,6 +46,7 @@ type Envelope struct { type LibraryChangeEvent struct { LibraryID int + Reason string New int Updated int Missing int @@ -51,6 +58,7 @@ type LibraryChangeEvent struct { type MetadataUpdateEvent struct { LibraryID int ContentID string + Change string } type Hub struct { @@ -95,15 +103,7 @@ func (h *Hub) PublishLibraryChanged(ctx context.Context, event LibraryChangeEven return nil } - payload := map[string]any{ - "library_id": event.LibraryID, - "new": event.New, - "updated": event.Updated, - "missing": event.Missing, - "matched_files": event.MatchedFiles, - "retried_items": event.RetriedItems, - "still_unmatched_warnings": event.StillUnmatchedWarnings, - } + payload := libraryChangePayload(event) if err := h.inner.PublishJSON(ctx, evt.ChannelCatalog, string(TypeLibraryChanged), payload, evt.PublishOptions{}); err != nil { return err } @@ -116,6 +116,16 @@ func (h *Hub) PublishLibraryChanged(ctx context.Context, event LibraryChangeEven }, evt.PublishOptions{}) } +func (h *Hub) PublishCatalogLibraryChanged(ctx context.Context, event LibraryChangeEvent) error { + if h == nil || h.inner == nil { + return nil + } + if err := h.inner.PublishJSON(ctx, evt.ChannelCatalog, string(TypeCatalogLibraryChanged), libraryChangePayload(event), evt.PublishOptions{}); err != nil { + return err + } + return h.PublishLibraryChanged(ctx, event) +} + func (h *Hub) PublishMetadataUpdated(ctx context.Context, event MetadataUpdateEvent) error { if h == nil || h.inner == nil { return nil @@ -126,6 +136,24 @@ func (h *Hub) PublishMetadataUpdated(ctx context.Context, event MetadataUpdateEv }, evt.PublishOptions{}) } +func (h *Hub) PublishCatalogItemChanged(ctx context.Context, event MetadataUpdateEvent) error { + if h == nil || h.inner == nil { + return nil + } + change := event.Change + if change == "" { + change = "metadata_updated" + } + if err := h.inner.PublishJSON(ctx, evt.ChannelCatalog, string(TypeCatalogItemChanged), map[string]any{ + "library_id": event.LibraryID, + "content_id": event.ContentID, + "change": change, + }, evt.PublishOptions{}); err != nil { + return err + } + return h.PublishMetadataUpdated(ctx, event) +} + func (h *Hub) PublishJob(ctx context.Context, eventType Type, job *models.AdminJob) error { if h == nil || h.inner == nil || job == nil { return nil @@ -142,6 +170,23 @@ func (h *Hub) EventsHub() *evt.Hub { return h.inner } +func libraryChangePayload(event LibraryChangeEvent) map[string]any { + reason := event.Reason + if reason == "" { + reason = "scan" + } + return map[string]any{ + "library_id": event.LibraryID, + "reason": reason, + "new": event.New, + "updated": event.Updated, + "missing": event.Missing, + "matched_files": event.MatchedFiles, + "retried_items": event.RetriedItems, + "still_unmatched_warnings": event.StillUnmatchedWarnings, + } +} + func convertEnvelope(env evt.Envelope) (Envelope, bool) { converted := Envelope{ Type: Type(env.Event), @@ -157,6 +202,8 @@ func convertEnvelope(env evt.Envelope) (Envelope, bool) { var payload struct { LibraryID int `json:"library_id"` ContentID string `json:"content_id"` + Reason string `json:"reason"` + Change string `json:"change"` New int `json:"new"` Updated int `json:"updated"` Missing int `json:"missing"` @@ -169,6 +216,8 @@ func convertEnvelope(env evt.Envelope) (Envelope, bool) { } converted.LibraryID = payload.LibraryID converted.ContentID = payload.ContentID + converted.Reason = payload.Reason + converted.Change = payload.Change converted.New = payload.New converted.Updated = payload.Updated converted.Missing = payload.Missing diff --git a/internal/notifications/hub_test.go b/internal/notifications/hub_test.go new file mode 100644 index 00000000..4f81da27 --- /dev/null +++ b/internal/notifications/hub_test.go @@ -0,0 +1,44 @@ +package notifications + +import ( + "context" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/cache" +) + +func TestPublishCatalogItemChangedAlsoPublishesLegacyMetadataUpdated(t *testing.T) { + hub := NewHub("test", &cache.NoopEventBus{}) + events, unsubscribe := hub.Subscribe() + defer unsubscribe() + + err := hub.PublishCatalogItemChanged(context.Background(), MetadataUpdateEvent{ + LibraryID: 12, + ContentID: "item-1", + Change: "metadata_updated", + }) + if err != nil { + t.Fatalf("PublishCatalogItemChanged() error = %v", err) + } + + got := map[Type]Envelope{} + for len(got) < 2 { + select { + case event := <-events: + got[event.Type] = event + case <-time.After(time.Second): + t.Fatalf("timed out waiting for events, got %#v", got) + } + } + + for _, eventType := range []Type{TypeCatalogItemChanged, TypeMetadataUpdated} { + event, ok := got[eventType] + if !ok { + t.Fatalf("missing event %q in %#v", eventType, got) + } + if event.LibraryID != 12 || event.ContentID != "item-1" { + t.Fatalf("event %q payload = library %d content %q", eventType, event.LibraryID, event.ContentID) + } + } +} diff --git a/internal/playback/session.go b/internal/playback/session.go index c8a3ad2b..23d0e0ed 100644 --- a/internal/playback/session.go +++ b/internal/playback/session.go @@ -29,6 +29,7 @@ type Session struct { TargetVideoCodec string // requested output video codec for transcodes TargetAudioCodec string // requested output audio codec when audio is transcoded TargetBitrateKbps int // requested output bitrate cap for transcodes + TranscodeHWAccel string // effective hardware acceleration mode for transcodes Position float64 IsPaused bool @@ -54,6 +55,7 @@ type SessionStreamState struct { TargetVideoCodec string TargetAudioCodec string TargetBitrateKbps int + TranscodeHWAccel string } // SessionManager tracks active playback sessions and enforces stream limits. @@ -299,6 +301,7 @@ func (m *SessionManager) UpdateStreamState(sessionID string, state SessionStream s.TargetVideoCodec = state.TargetVideoCodec s.TargetAudioCodec = state.TargetAudioCodec s.TargetBitrateKbps = state.TargetBitrateKbps + s.TranscodeHWAccel = state.TranscodeHWAccel m.touchSessionLocked(s) return nil } diff --git a/internal/playback/transcode.go b/internal/playback/transcode.go index fe8a3dd2..d83a64d4 100644 --- a/internal/playback/transcode.go +++ b/internal/playback/transcode.go @@ -120,6 +120,7 @@ func StartTranscode(ctx context.Context, opts TranscodeOpts) (*TranscodeSession, if opts.SegmentDuration <= 0 { opts.SegmentDuration = defaultSegmentDuration } + opts.HWAccel = resolveEffectiveTranscodeHWAccel(opts) // Ensure output directory exists. if err := os.MkdirAll(opts.OutputDir, 0o755); err != nil { @@ -219,18 +220,10 @@ func IsMPEG4Part2VideoCodec(codec string) bool { func buildFFmpegArgs(opts TranscodeOpts) []string { // Resolve "auto" into a concrete accel method once so all downstream // helpers (appendHWAccelArgs, appendVideoArgs, etc.) see the real value. - opts.HWAccel = ResolveHWAccel(opts.HWAccel) + opts.HWAccel = resolveEffectiveTranscodeHWAccel(opts) isVideoCopy := opts.TargetCodecVideo == "copy" isAudioCopy := opts.TargetCodecAudio == "copy" - if !isVideoCopy && IsMPEG4Part2VideoCodec(opts.SourceVideoCodec) && opts.HWAccel != "none" { - slog.Info("disabling hardware video transcode for MPEG-4 Part 2 source", - "source_video_codec", opts.SourceVideoCodec, - "requested_hw_accel", opts.HWAccel, - "playback_session_id", opts.SessionID, - ) - opts.HWAccel = "none" - } args := []string{ "-hide_banner", @@ -355,6 +348,20 @@ func buildFFmpegArgs(opts TranscodeOpts) []string { return args } +func resolveEffectiveTranscodeHWAccel(opts TranscodeOpts) string { + hwAccel := ResolveHWAccel(opts.HWAccel) + if hwAccel == "" { + return "" + } + if strings.EqualFold(opts.TargetCodecVideo, "copy") { + return "none" + } + if IsMPEG4Part2VideoCodec(opts.SourceVideoCodec) { + return "none" + } + return hwAccel +} + // appendStreamSelectionArgs limits output to primary video/audio streams. func appendStreamSelectionArgs(args []string, audioTrackIndex int) []string { args = append(args, "-map", "0:v:0") diff --git a/internal/playback/transcode_args_test.go b/internal/playback/transcode_args_test.go index 5a2ec062..f96c536f 100644 --- a/internal/playback/transcode_args_test.go +++ b/internal/playback/transcode_args_test.go @@ -211,6 +211,42 @@ func TestBuildFFmpegArgs_MPEG4Part2DisablesHardwareDecode(t *testing.T) { } } +func TestResolveEffectiveTranscodeHWAccel(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts TranscodeOpts + want string + }{ + { + name: "hardware video transcode", + opts: TranscodeOpts{HWAccel: "qsv", SourceVideoCodec: "h264", TargetCodecVideo: "h264"}, + want: "qsv", + }, + { + name: "copy video does not use hardware encode", + opts: TranscodeOpts{HWAccel: "qsv", SourceVideoCodec: "h264", TargetCodecVideo: "copy"}, + want: "none", + }, + { + name: "mpeg4 part 2 falls back to software", + opts: TranscodeOpts{HWAccel: "vaapi", SourceVideoCodec: "mpeg4", TargetCodecVideo: "h264"}, + want: "none", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := resolveEffectiveTranscodeHWAccel(tt.opts); got != tt.want { + t.Fatalf("resolveEffectiveTranscodeHWAccel() = %q, want %q", got, tt.want) + } + }) + } +} + func TestBuildFFmpegArgs_VAAPIScalingUsesHardwareFilter(t *testing.T) { args := buildFFmpegArgs(TranscodeOpts{ InputPath: "/media/movie.mkv", diff --git a/internal/scanner/file_repo.go b/internal/scanner/file_repo.go index 85711901..959084c5 100644 --- a/internal/scanner/file_repo.go +++ b/internal/scanner/file_repo.go @@ -25,6 +25,14 @@ type FileRepository struct { pool *pgxpool.Pool } +type RawMatchBacklogMode string + +const ( + RawMatchBacklogGeneric RawMatchBacklogMode = "generic" + RawMatchBacklogNonSeries RawMatchBacklogMode = "non_series" + RawMatchBacklogMixed RawMatchBacklogMode = "mixed" +) + // Pool returns the underlying connection pool (used by tests). func (r *FileRepository) Pool() *pgxpool.Pool { return r.pool } @@ -819,6 +827,7 @@ func (r *FileRepository) Upsert(ctx context.Context, mf models.MediaFile) (*mode multi_episode_end = EXCLUDED.multi_episode_end, probe_source = EXCLUDED.probe_source, probe_updated_at = EXCLUDED.probe_updated_at, + match_suppressed_at = NULL, missing_since = NULL, updated_at = NOW() RETURNING ` + fileColumns @@ -1285,6 +1294,7 @@ func (r *FileRepository) GetUnmatched(ctx context.Context, limit int) ([]*models JOIN media_folders folders ON folders.id = mf.media_folder_id WHERE (mf.content_id IS NULL OR mf.content_id = '') AND mf.missing_since IS NULL + AND mf.match_suppressed_at IS NULL AND folders.enabled = true ORDER BY mf.match_attempted_at ASC NULLS FIRST, mf.id ASC LIMIT $1` @@ -1322,6 +1332,7 @@ func (r *FileRepository) ClaimUnmatched(ctx context.Context, limit int) ([]*mode JOIN media_folders folders ON folders.id = mf.media_folder_id WHERE (mf.content_id IS NULL OR mf.content_id = '') AND mf.missing_since IS NULL + AND mf.match_suppressed_at IS NULL AND folders.enabled = true ORDER BY mf.match_attempted_at ASC NULLS FIRST, mf.id ASC LIMIT $1 @@ -1352,6 +1363,7 @@ func (r *FileRepository) ClaimUnmatched(ctx context.Context, limit int) ([]*mode SET match_attempted_at = NOW() WHERE (mf.content_id IS NULL OR mf.content_id = '') AND mf.missing_since IS NULL + AND mf.match_suppressed_at IS NULL AND EXISTS ( SELECT 1 FROM representatives rep @@ -1391,6 +1403,7 @@ func (r *FileRepository) ClaimUnmatchedNonSeries(ctx context.Context, limit int) JOIN media_folders folders ON folders.id = mf.media_folder_id WHERE (mf.content_id IS NULL OR mf.content_id = '') AND mf.missing_since IS NULL + AND mf.match_suppressed_at IS NULL AND folders.enabled = true AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows') ORDER BY mf.match_attempted_at ASC NULLS FIRST, mf.id ASC @@ -1435,6 +1448,7 @@ func (r *FileRepository) ClaimUnmatchedMixed(ctx context.Context, limit int) ([] JOIN media_folders folders ON folders.id = mf.media_folder_id WHERE (mf.content_id IS NULL OR mf.content_id = '') AND mf.missing_since IS NULL + AND mf.match_suppressed_at IS NULL AND folders.enabled = true AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows', 'movie', 'movies') AND lower(trim(COALESCE(mf.base_type, ''))) NOT IN ('series', 'movie') @@ -1473,6 +1487,173 @@ func (r *FileRepository) MarkMatchAttempted(ctx context.Context, fileID int) err return err } +// IsMatchSuppressed reports whether a raw unmatched file has been cancelled +// from background matching. +func (r *FileRepository) IsMatchSuppressed(ctx context.Context, fileID int) (bool, error) { + var suppressed bool + err := r.pool.QueryRow(ctx, ` + SELECT match_suppressed_at IS NOT NULL + FROM media_files + WHERE id = $1 + `, fileID).Scan(&suppressed) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return false, fmt.Errorf("checking match suppression: %w", err) + } + return suppressed, nil +} + +// CountUnmatchedMatchBacklogByFolder counts raw unmatched files that the +// background matcher can still claim for a library. +func (r *FileRepository) CountUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode RawMatchBacklogMode) (int, error) { + var total int + err := r.pool.QueryRow(ctx, ` + SELECT COUNT(*) + FROM media_files mf + JOIN media_folders folders ON folders.id = mf.media_folder_id + WHERE mf.media_folder_id = $1 + AND (mf.content_id IS NULL OR mf.content_id = '') + AND mf.missing_since IS NULL + AND mf.match_suppressed_at IS NULL + AND folders.enabled = true + AND ( + $2 = 'generic' + OR ($2 = 'non_series' AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows')) + OR ( + $2 = 'mixed' + AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows', 'movie', 'movies') + AND lower(trim(COALESCE(mf.base_type, ''))) NOT IN ('series', 'movie') + ) + ) + `, folderID, string(normalizeRawMatchBacklogMode(mode))).Scan(&total) + if err != nil { + return 0, fmt.Errorf("counting unmatched match backlog: %w", err) + } + return total, nil +} + +// ListUnmatchedMatchBacklogByFolder lists raw unmatched files that are still +// eligible for the background matcher. +func (r *FileRepository) ListUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode RawMatchBacklogMode, limit int, offset int) ([]*models.MediaFile, int, error) { + if limit <= 0 { + limit = 50 + } + if offset < 0 { + offset = 0 + } + + normalizedMode := normalizeRawMatchBacklogMode(mode) + total, err := r.CountUnmatchedMatchBacklogByFolder(ctx, folderID, normalizedMode) + if err != nil { + return nil, 0, err + } + + rows, err := r.pool.Query(ctx, ` + SELECT `+mfFileColumns+` + FROM media_files mf + JOIN media_folders folders ON folders.id = mf.media_folder_id + WHERE mf.media_folder_id = $1 + AND (mf.content_id IS NULL OR mf.content_id = '') + AND mf.missing_since IS NULL + AND mf.match_suppressed_at IS NULL + AND folders.enabled = true + AND ( + $2 = 'generic' + OR ($2 = 'non_series' AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows')) + OR ( + $2 = 'mixed' + AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows', 'movie', 'movies') + AND lower(trim(COALESCE(mf.base_type, ''))) NOT IN ('series', 'movie') + ) + ) + ORDER BY mf.match_attempted_at ASC NULLS FIRST, mf.id ASC + LIMIT $3 OFFSET $4 + `, folderID, string(normalizedMode), limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("listing unmatched match backlog: %w", err) + } + defer rows.Close() + + files, err := scanMediaFiles(rows) + if err != nil { + return nil, 0, err + } + return files, total, nil +} + +// SuppressUnmatchedMatchBacklogByFolder prevents raw unmatched files in a +// library from being claimed by the background matcher until they are retried +// or seen by a new scan. +func (r *FileRepository) SuppressUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode RawMatchBacklogMode) (int, error) { + normalizedMode := string(normalizeRawMatchBacklogMode(mode)) + tag, err := r.pool.Exec(ctx, ` + UPDATE media_files mf + SET match_suppressed_at = NOW(), updated_at = NOW() + FROM media_folders folders + WHERE folders.id = mf.media_folder_id + AND mf.media_folder_id = $1 + AND (mf.content_id IS NULL OR mf.content_id = '') + AND mf.missing_since IS NULL + AND mf.match_suppressed_at IS NULL + AND folders.enabled = true + AND ( + $2 = 'generic' + OR ($2 = 'non_series' AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows')) + OR ( + $2 = 'mixed' + AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows', 'movie', 'movies') + AND lower(trim(COALESCE(mf.base_type, ''))) NOT IN ('series', 'movie') + ) + ) + `, folderID, normalizedMode) + if err != nil { + return 0, fmt.Errorf("suppressing unmatched match backlog: %w", err) + } + return int(tag.RowsAffected()), nil +} + +// RetryUnmatchedMatchBacklogByFolder re-enables raw unmatched files in a +// library and moves them to the front of the background matcher order. +func (r *FileRepository) RetryUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode RawMatchBacklogMode) (int, error) { + normalizedMode := string(normalizeRawMatchBacklogMode(mode)) + tag, err := r.pool.Exec(ctx, ` + UPDATE media_files mf + SET match_suppressed_at = NULL, + match_attempted_at = NULL, + updated_at = NOW() + FROM media_folders folders + WHERE folders.id = mf.media_folder_id + AND mf.media_folder_id = $1 + AND (mf.content_id IS NULL OR mf.content_id = '') + AND mf.missing_since IS NULL + AND folders.enabled = true + AND ( + $2 = 'generic' + OR ($2 = 'non_series' AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows')) + OR ( + $2 = 'mixed' + AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows', 'movie', 'movies') + AND lower(trim(COALESCE(mf.base_type, ''))) NOT IN ('series', 'movie') + ) + ) + `, folderID, normalizedMode) + if err != nil { + return 0, fmt.Errorf("retrying unmatched match backlog: %w", err) + } + return int(tag.RowsAffected()), nil +} + +func normalizeRawMatchBacklogMode(mode RawMatchBacklogMode) RawMatchBacklogMode { + switch mode { + case RawMatchBacklogNonSeries, RawMatchBacklogMixed: + return mode + default: + return RawMatchBacklogGeneric + } +} + // GetUnmatchedByFolderAndPathPrefix returns unmatched files for a single media // folder restricted to a subtree path. func (r *FileRepository) GetUnmatchedByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int) ([]*models.MediaFile, error) { @@ -1481,6 +1662,7 @@ func (r *FileRepository) GetUnmatchedByFolderAndPathPrefix(ctx context.Context, WHERE mf.media_folder_id = $1 AND (mf.content_id IS NULL OR mf.content_id = '') AND mf.missing_since IS NULL + AND mf.match_suppressed_at IS NULL AND folders.enabled = true AND (mf.file_path = $2 OR mf.file_path LIKE $3 ESCAPE '\') ORDER BY mf.id ASC` @@ -1535,6 +1717,7 @@ func (r *FileRepository) ClaimUnmatchedByFolderAndPathPrefix( WHERE mf.media_folder_id = $1 AND (mf.content_id IS NULL OR mf.content_id = '') AND mf.missing_since IS NULL + AND mf.match_suppressed_at IS NULL AND folders.enabled = true AND (mf.file_path = $2 OR mf.file_path LIKE $3 ESCAPE '\') `) @@ -1575,6 +1758,7 @@ func (r *FileRepository) ClaimUnmatchedByFolderAndPathPrefix( WHERE mf.media_folder_id = $1 AND (mf.content_id IS NULL OR mf.content_id = '') AND mf.missing_since IS NULL + AND mf.match_suppressed_at IS NULL AND EXISTS ( SELECT 1 FROM representatives rep @@ -1627,6 +1811,7 @@ func (r *FileRepository) ClaimUnmatchedNonSeriesByFolderAndPathPrefix( WHERE mf.media_folder_id = $1 AND (mf.content_id IS NULL OR mf.content_id = '') AND mf.missing_since IS NULL + AND mf.match_suppressed_at IS NULL AND folders.enabled = true AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows') AND (mf.file_path = $2 OR mf.file_path LIKE $3 ESCAPE '\') @@ -1693,6 +1878,7 @@ func (r *FileRepository) ClaimUnmatchedMixedByFolderAndPathPrefix( WHERE mf.media_folder_id = $1 AND (mf.content_id IS NULL OR mf.content_id = '') AND mf.missing_since IS NULL + AND mf.match_suppressed_at IS NULL AND folders.enabled = true AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows', 'movie', 'movies') AND lower(trim(COALESCE(mf.base_type, ''))) NOT IN ('series', 'movie') diff --git a/internal/scanqueue/service.go b/internal/scanqueue/service.go index 38faa0e5..629ae714 100644 --- a/internal/scanqueue/service.go +++ b/internal/scanqueue/service.go @@ -222,6 +222,9 @@ func (s *Service) CancelByLibrary(ctx context.Context, libraryID int) (int, erro } if changed { cancelled++ + if cancelledRun, err := s.repo.GetByID(ctx, run.ID); err == nil { + s.publish(ctx, "scan.cancelled", cancelledRun) + } } } return cancelled, nil diff --git a/internal/taskmanager/manager.go b/internal/taskmanager/manager.go index a615077a..bbc8bad0 100644 --- a/internal/taskmanager/manager.go +++ b/internal/taskmanager/manager.go @@ -171,11 +171,12 @@ func (m *TaskManager) triggerLoop(ctx context.Context, w *taskWorker) { // rearmTriggers stops and restarts all triggers for a worker. func (m *TaskManager) rearmTriggers(w *taskWorker) { w.mu.Lock() - defer w.mu.Unlock() for _, tr := range w.triggers { tr.Stop() tr.Start(w.lastResult) } + w.mu.Unlock() + w.notify() } // Stop stops all triggers and cancels running tasks. diff --git a/internal/taskmanager/manager_test.go b/internal/taskmanager/manager_test.go index c1993be7..9f7ff7e9 100644 --- a/internal/taskmanager/manager_test.go +++ b/internal/taskmanager/manager_test.go @@ -57,8 +57,16 @@ type fakeTrigger struct { stopCh chan struct{} } -func (t *fakeTrigger) Start(_ *taskmanager.ExecutionResult) { - t.next = time.Now().Add(time.Minute) +func (t *fakeTrigger) Start(lastResult *taskmanager.ExecutionResult) { + interval := time.Minute + if t.cfg.IntervalMs > 0 { + interval = time.Duration(t.cfg.IntervalMs) * time.Millisecond + } + base := time.Now() + if lastResult != nil && !lastResult.CompletedAt.IsZero() { + base = lastResult.CompletedAt + } + t.next = base.Add(interval) } func (t *fakeTrigger) Stop() { @@ -117,6 +125,26 @@ func newFakeTrigger(cfg taskmanager.TriggerConfig) taskmanager.Trigger { } } +type recordingObserver struct { + mu sync.Mutex + updates []taskmanager.TaskInfo +} + +func (o *recordingObserver) TaskUpdated(info taskmanager.TaskInfo) { + o.mu.Lock() + defer o.mu.Unlock() + o.updates = append(o.updates, info) +} + +func (o *recordingObserver) last() taskmanager.TaskInfo { + o.mu.Lock() + defer o.mu.Unlock() + if len(o.updates) == 0 { + return taskmanager.TaskInfo{} + } + return o.updates[len(o.updates)-1] +} + func TestTaskManagerStartSeedsCleanupTaskDefaults(t *testing.T) { triggerRepo := &fakeTriggerRepository{triggers: map[string][]taskmanager.TriggerConfig{}} settings := &fakeServerSettings{ @@ -191,3 +219,49 @@ func TestTaskManagerStartPreservesExistingTriggers(t *testing.T) { t.Fatalf("worker triggers = %#v, want %#v", got, existing) } } + +func TestTaskManagerRunTaskNotifiesAfterTriggerRearm(t *testing.T) { + const taskKey = "refresh_metadata" + triggerRepo := &fakeTriggerRepository{ + triggers: map[string][]taskmanager.TriggerConfig{ + taskKey: { + {Type: taskmanager.TriggerTypeInterval, IntervalMs: int64(time.Hour / time.Millisecond)}, + }, + }, + } + observer := &recordingObserver{} + manager := taskmanager.New( + triggerRepo, + fakeExecutionRepository{}, + newFakeTrigger, + slog.New(slog.NewTextHandler(io.Discard, nil)), + ) + manager.AddObserver(observer) + manager.Register(stubTask{key: taskKey}) + + ctx, cancel := context.WithCancel(context.Background()) + defer manager.Stop() + defer cancel() + manager.Start(ctx) + + if err := manager.RunTask(ctx, taskKey); err != nil { + t.Fatalf("RunTask() error = %v", err) + } + + last := observer.last() + if last.LastExecution == nil { + t.Fatal("last notification missing execution result") + } + if last.NextRunAt == nil { + t.Fatal("last notification missing next run time") + } + if !last.NextRunAt.After(last.LastExecution.CompletedAt) { + t.Fatalf("next run = %s, want after completed_at %s", + last.NextRunAt.Format(time.RFC3339Nano), + last.LastExecution.CompletedAt.Format(time.RFC3339Nano)) + } + if last.NextRunAt.Sub(last.LastExecution.CompletedAt) < 59*time.Minute { + t.Fatalf("next run was not rearmed from the latest completion: got %s after completion", + last.NextRunAt.Sub(last.LastExecution.CompletedAt)) + } +} diff --git a/internal/taskmanager/tasks/match_media.go b/internal/taskmanager/tasks/match_media.go index 974dff35..69ad779a 100644 --- a/internal/taskmanager/tasks/match_media.go +++ b/internal/taskmanager/tasks/match_media.go @@ -28,7 +28,7 @@ func (t *MatchMediaTask) Description() string { return "Matches unmatched media files to metadata from providers" } func (t *MatchMediaTask) Category() taskmanager.TaskCategory { return taskmanager.TaskCategoryMetadata } -func (t *MatchMediaTask) IsHidden() bool { return true } +func (t *MatchMediaTask) IsHidden() bool { return false } func (t *MatchMediaTask) DefaultTriggers() []taskmanager.TriggerConfig { return []taskmanager.TriggerConfig{ diff --git a/internal/taskmanager/tasks/match_media_test.go b/internal/taskmanager/tasks/match_media_test.go new file mode 100644 index 00000000..f2473df1 --- /dev/null +++ b/internal/taskmanager/tasks/match_media_test.go @@ -0,0 +1,23 @@ +package tasks + +import ( + "context" + "testing" +) + +type fakeBatchMatcher struct { + processed int + err error +} + +func (f *fakeBatchMatcher) ProcessBatch(context.Context) (int, error) { + return f.processed, f.err +} + +func TestMatchMediaTaskIsVisible(t *testing.T) { + task := NewMatchMediaTask(&fakeBatchMatcher{}) + + if task.IsHidden() { + t.Fatal("IsHidden() = true, want false") + } +} diff --git a/internal/taskmanager/triggers/startup.go b/internal/taskmanager/triggers/startup.go index 21ecd9c8..ee2ad3f6 100644 --- a/internal/taskmanager/triggers/startup.go +++ b/internal/taskmanager/triggers/startup.go @@ -14,6 +14,7 @@ const startupDelay = 5 * time.Second type StartupTrigger struct { cfg taskmanager.TriggerConfig ch chan struct{} + delay time.Duration nextRun time.Time timer *time.Timer stopCh chan struct{} @@ -23,8 +24,9 @@ type StartupTrigger struct { func NewStartupTrigger(cfg taskmanager.TriggerConfig) *StartupTrigger { return &StartupTrigger{ - cfg: cfg, - ch: make(chan struct{}, 1), + cfg: cfg, + ch: make(chan struct{}, 1), + delay: startupDelay, } } @@ -39,13 +41,14 @@ func (s *StartupTrigger) Start(_ *taskmanager.ExecutionResult) { } if s.fired { + s.nextRun = time.Time{} return } s.fired = true s.stopCh = make(chan struct{}) - s.nextRun = time.Now().Add(startupDelay) - s.timer = time.NewTimer(startupDelay) + s.nextRun = time.Now().Add(s.delay) + s.timer = time.NewTimer(s.delay) go func() { select { @@ -58,6 +61,9 @@ func (s *StartupTrigger) Start(_ *taskmanager.ExecutionResult) { } return case <-s.timer.C: + s.mu.Lock() + s.nextRun = time.Time{} + s.mu.Unlock() select { case s.ch <- struct{}{}: default: @@ -76,6 +82,7 @@ func (s *StartupTrigger) Stop() { close(s.stopCh) } } + s.nextRun = time.Time{} } func (s *StartupTrigger) NextRunTime() time.Time { diff --git a/internal/taskmanager/triggers/startup_test.go b/internal/taskmanager/triggers/startup_test.go new file mode 100644 index 00000000..2560d1e5 --- /dev/null +++ b/internal/taskmanager/triggers/startup_test.go @@ -0,0 +1,48 @@ +package triggers + +import ( + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/taskmanager" +) + +func TestStartupTriggerClearsNextRunAfterFire(t *testing.T) { + trigger := NewStartupTrigger(taskmanager.TriggerConfig{Type: taskmanager.TriggerTypeStartup}) + trigger.delay = time.Millisecond + + trigger.Start(nil) + if trigger.NextRunTime().IsZero() { + t.Fatal("expected startup trigger to report next run before firing") + } + + select { + case <-trigger.C(): + case <-time.After(time.Second): + t.Fatal("startup trigger did not fire") + } + + if next := trigger.NextRunTime(); !next.IsZero() { + t.Fatalf("next run after fire = %s, want zero", next.Format(time.RFC3339Nano)) + } + + trigger.Start(nil) + if next := trigger.NextRunTime(); !next.IsZero() { + t.Fatalf("next run after rearm = %s, want zero", next.Format(time.RFC3339Nano)) + } +} + +func TestStartupTriggerClearsNextRunWhenStopped(t *testing.T) { + trigger := NewStartupTrigger(taskmanager.TriggerConfig{Type: taskmanager.TriggerTypeStartup}) + trigger.delay = time.Second + + trigger.Start(nil) + if trigger.NextRunTime().IsZero() { + t.Fatal("expected startup trigger to report next run before stop") + } + + trigger.Stop() + if next := trigger.NextRunTime(); !next.IsZero() { + t.Fatalf("next run after stop = %s, want zero", next.Format(time.RFC3339Nano)) + } +} diff --git a/internal/transcodenode/server.go b/internal/transcodenode/server.go index 48ed2c27..f4019ded 100644 --- a/internal/transcodenode/server.go +++ b/internal/transcodenode/server.go @@ -44,6 +44,7 @@ type TranscodeStartRequest struct { type TranscodeStartResponse struct { SessionID string `json:"session_id"` Status string `json:"status"` + HWAccel string `json:"hw_accel,omitempty"` } // HealthResponse is the JSON response for GET /api/v1/health. @@ -238,6 +239,7 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) { s.activeJobs.Add(1) // Track session in Redis + effectiveHWAccel := session.Opts().HWAccel s.tracker.Track(r.Context(), nodesessions.SessionInfo{ SessionID: req.SessionID, NodeURL: s.tracker.NodeURL(), @@ -246,7 +248,7 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) { CodecVideo: req.TargetCodecVideo, CodecAudio: req.TargetCodecAudio, Resolution: req.TargetResolution, - HWAccel: opts.HWAccel, + HWAccel: effectiveHWAccel, StartedAt: time.Now().UTC().Format(time.RFC3339), }) @@ -254,6 +256,7 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(TranscodeStartResponse{ SessionID: req.SessionID, Status: "started", + HWAccel: effectiveHWAccel, }) } diff --git a/internal/userdb/schema.go b/internal/userdb/schema.go index 0c54eaf5..b3cf379d 100644 --- a/internal/userdb/schema.go +++ b/internal/userdb/schema.go @@ -195,6 +195,15 @@ CREATE TABLE IF NOT EXISTS user_device_settings ( PRIMARY KEY (profile_id, device_id, key) ); +CREATE TABLE IF NOT EXISTS user_devices ( + profile_id TEXT NOT NULL, + device_id TEXT NOT NULL, + device_name TEXT NOT NULL DEFAULT '', + device_platform TEXT NOT NULL DEFAULT '', + last_seen_at TEXT NOT NULL, + PRIMARY KEY (profile_id, device_id) +); + CREATE TABLE IF NOT EXISTS downloads ( id TEXT PRIMARY KEY, profile_id TEXT NOT NULL, @@ -275,7 +284,10 @@ func InitSchema(db *sql.DB) error { if err := ensureWatchHistoryIdentityColumn(db); err != nil { return err } - return migratePlaybackSettingsToDeviceScope(db) + if err := migratePlaybackSettingsToDeviceScope(db); err != nil { + return err + } + return backfillUserDevices(db) } func ensureAutoSkipRecapPreviewColumns(db *sql.DB) error { @@ -427,6 +439,40 @@ func ensureDeviceSettingsProfileColumn(db *sql.DB) error { return tx.Commit() } +func backfillUserDevices(db *sql.DB) error { + _, err := db.Exec(` + INSERT INTO user_devices ( + profile_id, device_id, device_name, device_platform, last_seen_at + ) + SELECT + profile_id, + device_id, + COALESCE(MAX(device_name), ''), + COALESCE(MAX(device_platform), ''), + MAX(updated_at) + FROM user_device_settings + WHERE TRIM(device_id) <> '' + GROUP BY profile_id, device_id + ON CONFLICT(profile_id, device_id) DO UPDATE SET + device_name = CASE + WHEN excluded.device_name <> '' THEN excluded.device_name + ELSE user_devices.device_name + END, + device_platform = CASE + WHEN excluded.device_platform <> '' THEN excluded.device_platform + ELSE user_devices.device_platform + END, + last_seen_at = CASE + WHEN excluded.last_seen_at > user_devices.last_seen_at THEN excluded.last_seen_at + ELSE user_devices.last_seen_at + END + `) + if err != nil { + return fmt.Errorf("backfilling user_devices: %w", err) + } + return nil +} + func ensureProfileSectionOverridesRemovedColumn(db *sql.DB) error { var count int if err := db.QueryRow( diff --git a/internal/userdb/settings.go b/internal/userdb/settings.go index b928bae8..83a16625 100644 --- a/internal/userdb/settings.go +++ b/internal/userdb/settings.go @@ -3,6 +3,7 @@ package userdb import ( "database/sql" "fmt" + "strings" "time" "github.com/Silo-Server/silo-server/internal/userstore" @@ -81,7 +82,64 @@ func GetDeviceSetting(db *sql.DB, profileID, deviceID, key string) (*userstore.D return &entry, nil } +func RegisterDevice(db *sql.DB, entry userstore.DeviceEntry) error { + if strings.TrimSpace(entry.ProfileID) == "" || strings.TrimSpace(entry.DeviceID) == "" { + return nil + } + lastSeenAt := time.Now().UTC().Format(time.RFC3339Nano) + _, err := db.Exec( + `INSERT INTO user_devices + (profile_id, device_id, device_name, device_platform, last_seen_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(profile_id, device_id) DO UPDATE SET + device_name = CASE + WHEN excluded.device_name <> '' THEN excluded.device_name + ELSE user_devices.device_name + END, + device_platform = CASE + WHEN excluded.device_platform <> '' THEN excluded.device_platform + ELSE user_devices.device_platform + END, + last_seen_at = excluded.last_seen_at`, + entry.ProfileID, entry.DeviceID, entry.DeviceName, entry.DevicePlatform, lastSeenAt, + ) + if err != nil { + return fmt.Errorf("registering device %q: %w", entry.DeviceID, err) + } + return nil +} + +func ListDevices(db *sql.DB) ([]userstore.DeviceEntry, error) { + rows, err := db.Query( + `SELECT profile_id, device_id, device_name, device_platform, last_seen_at + FROM user_devices + ORDER BY last_seen_at DESC, profile_id ASC, device_name ASC, device_id ASC`, + ) + if err != nil { + return nil, fmt.Errorf("listing devices: %w", err) + } + defer rows.Close() + + var entries []userstore.DeviceEntry + for rows.Next() { + var entry userstore.DeviceEntry + if err := rows.Scan(&entry.ProfileID, &entry.DeviceID, &entry.DeviceName, &entry.DevicePlatform, &entry.LastSeenAt); err != nil { + return nil, fmt.Errorf("scanning device: %w", err) + } + entries = append(entries, entry) + } + return entries, rows.Err() +} + func SetDeviceSetting(db *sql.DB, entry userstore.DeviceSettingEntry) error { + if err := RegisterDevice(db, userstore.DeviceEntry{ + ProfileID: entry.ProfileID, + DeviceID: entry.DeviceID, + DeviceName: entry.DeviceName, + DevicePlatform: entry.DevicePlatform, + }); err != nil { + return err + } updatedAt := time.Now().UTC().Format(time.RFC3339Nano) _, err := db.Exec( `INSERT INTO user_device_settings diff --git a/internal/userdb/sqlitestore.go b/internal/userdb/sqlitestore.go index 203a3a99..20009d67 100644 --- a/internal/userdb/sqlitestore.go +++ b/internal/userdb/sqlitestore.go @@ -21,6 +21,7 @@ func NewSQLiteUserStore(db *sql.DB) *SQLiteUserStore { // Compile-time interface check. var _ userstore.UserStore = (*SQLiteUserStore)(nil) +var _ userstore.DeviceRegistry = (*SQLiteUserStore)(nil) // --- Profiles --- @@ -291,6 +292,14 @@ func (s *SQLiteUserStore) GetDeviceSetting(_ context.Context, profileID, deviceI return GetDeviceSetting(s.db, profileID, deviceID, key) } +func (s *SQLiteUserStore) RegisterDevice(_ context.Context, entry userstore.DeviceEntry) error { + return RegisterDevice(s.db, entry) +} + +func (s *SQLiteUserStore) ListDevices(_ context.Context) ([]userstore.DeviceEntry, error) { + return ListDevices(s.db) +} + func (s *SQLiteUserStore) SetDeviceSetting(_ context.Context, entry userstore.DeviceSettingEntry) error { return SetDeviceSetting(s.db, entry) } diff --git a/internal/userstore/pgstore/pgstore.go b/internal/userstore/pgstore/pgstore.go index 9a2cac28..46e79c57 100644 --- a/internal/userstore/pgstore/pgstore.go +++ b/internal/userstore/pgstore/pgstore.go @@ -18,6 +18,7 @@ type PostgresUserStore struct { // Compile-time interface check. var _ userstore.UserStore = (*PostgresUserStore)(nil) +var _ userstore.DeviceRegistry = (*PostgresUserStore)(nil) // newStore creates a PostgresUserStore scoped to a user. func newStore(pool *pgxpool.Pool, userID int) *PostgresUserStore { diff --git a/internal/userstore/pgstore/settings.go b/internal/userstore/pgstore/settings.go index 80799325..55a35e86 100644 --- a/internal/userstore/pgstore/settings.go +++ b/internal/userstore/pgstore/settings.go @@ -3,6 +3,7 @@ package pgstore import ( "context" "fmt" + "strings" "github.com/jackc/pgx/v5" @@ -85,7 +86,65 @@ func (s *PostgresUserStore) GetDeviceSetting(ctx context.Context, profileID, dev return &entry, nil } +func (s *PostgresUserStore) RegisterDevice(ctx context.Context, entry userstore.DeviceEntry) error { + if strings.TrimSpace(entry.ProfileID) == "" || strings.TrimSpace(entry.DeviceID) == "" { + return nil + } + _, err := s.pool.Exec(ctx, + `INSERT INTO user_devices + (user_id, profile_id, device_id, device_name, device_platform, last_seen_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT(user_id, profile_id, device_id) DO UPDATE SET + device_name = CASE + WHEN excluded.device_name <> '' THEN excluded.device_name + ELSE user_devices.device_name + END, + device_platform = CASE + WHEN excluded.device_platform <> '' THEN excluded.device_platform + ELSE user_devices.device_platform + END, + last_seen_at = NOW()`, + s.userID, entry.ProfileID, entry.DeviceID, entry.DeviceName, entry.DevicePlatform, + ) + if err != nil { + return fmt.Errorf("registering device %q: %w", entry.DeviceID, err) + } + return nil +} + +func (s *PostgresUserStore) ListDevices(ctx context.Context) ([]userstore.DeviceEntry, error) { + rows, err := s.pool.Query(ctx, + `SELECT profile_id, device_id, device_name, device_platform, last_seen_at::text + FROM user_devices + WHERE user_id = $1 + ORDER BY last_seen_at DESC, profile_id ASC, device_name ASC, device_id ASC`, + s.userID, + ) + if err != nil { + return nil, fmt.Errorf("listing devices: %w", err) + } + defer rows.Close() + + var entries []userstore.DeviceEntry + for rows.Next() { + var entry userstore.DeviceEntry + if err := rows.Scan(&entry.ProfileID, &entry.DeviceID, &entry.DeviceName, &entry.DevicePlatform, &entry.LastSeenAt); err != nil { + return nil, fmt.Errorf("scanning device: %w", err) + } + entries = append(entries, entry) + } + return entries, rows.Err() +} + func (s *PostgresUserStore) SetDeviceSetting(ctx context.Context, entry userstore.DeviceSettingEntry) error { + if err := s.RegisterDevice(ctx, userstore.DeviceEntry{ + ProfileID: entry.ProfileID, + DeviceID: entry.DeviceID, + DeviceName: entry.DeviceName, + DevicePlatform: entry.DevicePlatform, + }); err != nil { + return err + } _, err := s.pool.Exec(ctx, `INSERT INTO user_device_settings (user_id, profile_id, device_id, key, value, device_name, device_platform, updated_at) diff --git a/internal/userstore/store.go b/internal/userstore/store.go index ac492d71..28da107d 100644 --- a/internal/userstore/store.go +++ b/internal/userstore/store.go @@ -108,3 +108,10 @@ type UserStore interface { UpsertLibraryPlaybackPreference(ctx context.Context, pref LibraryPlaybackPreference) error DeleteLibraryPlaybackPreference(ctx context.Context, profileID string, libraryID int) error } + +// DeviceRegistry is implemented by stores that track observed devices even +// when they do not currently have any device-scoped overrides. +type DeviceRegistry interface { + RegisterDevice(ctx context.Context, entry DeviceEntry) error + ListDevices(ctx context.Context) ([]DeviceEntry, error) +} diff --git a/internal/userstore/types.go b/internal/userstore/types.go index 1f6603c5..bc06098e 100644 --- a/internal/userstore/types.go +++ b/internal/userstore/types.go @@ -39,6 +39,16 @@ type DeviceSettingEntry struct { UpdatedAt string } +// DeviceEntry is a durable app/device identity observed for one profile +// within a user. It lets admins configure a device before it has overrides. +type DeviceEntry struct { + ProfileID string + DeviceID string + DeviceName string + DevicePlatform string + LastSeenAt string +} + // UpdateProfileInput holds optional fields for updating a profile. type UpdateProfileInput struct { Name *string diff --git a/internal/worker/reconciler.go b/internal/worker/reconciler.go index 048e01f1..eda8cd5a 100644 --- a/internal/worker/reconciler.go +++ b/internal/worker/reconciler.go @@ -34,8 +34,10 @@ type SessionSync struct { TargetVideoCodec string TargetAudioCodec string TargetBitrateKbps int + TranscodeHWAccel string StartedAt time.Time UpdatedAt time.Time + PositionSeconds float64 IsPaused bool HasWebSocket bool } @@ -138,8 +140,8 @@ func (r *Reconciler) ReconcileNodeSessions(ctx context.Context, reportingNode st reporting_node, started_at, updated_at, last_sync_at, client_ip, audio_track_index, transcode_audio, stream_bitrate_kbps, transcode_node_url, target_resolution, target_video_codec, target_audio_codec, target_bitrate_kbps, - is_paused, has_websocket) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), $10::inet, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) + transcode_hw_accel, position_seconds, is_paused, has_websocket) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), $10::inet, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) ON CONFLICT (session_id) DO UPDATE SET user_id = EXCLUDED.user_id, profile_id = EXCLUDED.profile_id, @@ -158,6 +160,8 @@ func (r *Reconciler) ReconcileNodeSessions(ctx context.Context, reportingNode st target_video_codec = EXCLUDED.target_video_codec, target_audio_codec = EXCLUDED.target_audio_codec, target_bitrate_kbps = EXCLUDED.target_bitrate_kbps, + transcode_hw_accel = EXCLUDED.transcode_hw_accel, + position_seconds = EXCLUDED.position_seconds, is_paused = EXCLUDED.is_paused, has_websocket = EXCLUDED.has_websocket, last_sync_at = NOW() @@ -166,6 +170,7 @@ func (r *Reconciler) ReconcileNodeSessions(ctx context.Context, reportingNode st s.TranscodeAudio, nullableInt(s.StreamBitrateKbps), nullableString(s.TranscodeNodeURL), nullableString(s.TargetResolution), nullableString(s.TargetVideoCodec), nullableString(s.TargetAudioCodec), nullableInt(s.TargetBitrateKbps), + nullableString(s.TranscodeHWAccel), normalizePositionSeconds(s.PositionSeconds), s.IsPaused, s.HasWebSocket) if err != nil { return fmt.Errorf("upserting session %s: %w", s.SessionID, err) @@ -233,8 +238,10 @@ func loadNodeSessionsSnapshot(ctx context.Context, tx pgx.Tx, reportingNode stri COALESCE(target_video_codec, ''), COALESCE(target_audio_codec, ''), COALESCE(target_bitrate_kbps, 0), + COALESCE(transcode_hw_accel, ''), started_at, updated_at, + COALESCE(position_seconds, 0), COALESCE(is_paused, FALSE), COALESCE(has_websocket, FALSE) FROM playback_sessions_sync @@ -266,8 +273,10 @@ func loadNodeSessionsSnapshot(ctx context.Context, tx pgx.Tx, reportingNode stri &s.TargetVideoCodec, &s.TargetAudioCodec, &s.TargetBitrateKbps, + &s.TranscodeHWAccel, &s.StartedAt, &s.UpdatedAt, + &s.PositionSeconds, &s.IsPaused, &s.HasWebSocket, ); err != nil { @@ -317,8 +326,10 @@ func sessionSnapshotsEqual(left, right []SessionSync) bool { left[i].TargetVideoCodec != right[i].TargetVideoCodec || left[i].TargetAudioCodec != right[i].TargetAudioCodec || left[i].TargetBitrateKbps != right[i].TargetBitrateKbps || + left[i].TranscodeHWAccel != right[i].TranscodeHWAccel || !left[i].StartedAt.Equal(right[i].StartedAt) || !left[i].UpdatedAt.Equal(right[i].UpdatedAt) || + normalizePositionSeconds(left[i].PositionSeconds) != normalizePositionSeconds(right[i].PositionSeconds) || left[i].IsPaused != right[i].IsPaused || left[i].HasWebSocket != right[i].HasWebSocket { return false @@ -327,6 +338,13 @@ func sessionSnapshotsEqual(left, right []SessionSync) bool { return true } +func normalizePositionSeconds(position float64) float64 { + if position < 0 { + return 0 + } + return position +} + func nullableIP(ip string) any { if strings.TrimSpace(ip) == "" { return nil diff --git a/migrations/001_schema.up.sql b/migrations/001_schema.up.sql index 2bdf889e..9a5336d7 100644 --- a/migrations/001_schema.up.sql +++ b/migrations/001_schema.up.sql @@ -90,7 +90,7 @@ CREATE TABLE public.admin_jobs ( heartbeat_at timestamp with time zone, expires_at timestamp with time zone, updated_at timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT admin_jobs_status_check CHECK ((status = ANY (ARRAY['queued'::text, 'running'::text, 'completed'::text, 'failed'::text]))) + CONSTRAINT admin_jobs_status_check CHECK ((status = ANY (ARRAY['queued'::text, 'running'::text, 'completed'::text, 'failed'::text, 'cancelled'::text]))) ); @@ -945,6 +945,7 @@ CREATE TABLE public.user_settings ( CREATE TABLE public.user_device_settings ( user_id integer NOT NULL, + profile_id text NOT NULL, device_id text NOT NULL, key text NOT NULL, value text NOT NULL, @@ -953,6 +954,15 @@ CREATE TABLE public.user_device_settings ( updated_at timestamp with time zone DEFAULT now() NOT NULL ); +CREATE TABLE public.user_devices ( + user_id integer NOT NULL, + profile_id text NOT NULL, + device_id text NOT NULL, + device_name text DEFAULT ''::text NOT NULL, + device_platform text DEFAULT ''::text NOT NULL, + last_seen_at timestamp with time zone DEFAULT now() NOT NULL +); + -- -- Name: user_subtitle_preferences; Type: TABLE; Schema: public; Owner: - @@ -1578,7 +1588,10 @@ ALTER TABLE ONLY public.user_settings ADD CONSTRAINT user_settings_pkey PRIMARY KEY (user_id, key); ALTER TABLE ONLY public.user_device_settings - ADD CONSTRAINT user_device_settings_pkey PRIMARY KEY (user_id, device_id, key); + ADD CONSTRAINT user_device_settings_pkey PRIMARY KEY (user_id, profile_id, device_id, key); + +ALTER TABLE ONLY public.user_devices + ADD CONSTRAINT user_devices_pkey PRIMARY KEY (user_id, profile_id, device_id); -- @@ -2017,6 +2030,13 @@ CREATE INDEX idx_user_collections_profile ON public.user_personal_collections US CREATE INDEX idx_user_favorites_profile ON public.user_favorites USING btree (user_id, profile_id, added_at DESC); +-- +-- Name: idx_user_device_settings_user_key; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_user_device_settings_user_key ON public.user_device_settings USING btree (user_id, key); + + -- -- Name: idx_user_profile_allowed_libraries_lookup; Type: INDEX; Schema: public; Owner: - -- @@ -2348,6 +2368,15 @@ ALTER TABLE ONLY public.user_settings ALTER TABLE ONLY public.user_device_settings ADD CONSTRAINT user_device_settings_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; +ALTER TABLE ONLY public.user_device_settings + ADD CONSTRAINT user_device_settings_profile_fkey FOREIGN KEY (user_id, profile_id) REFERENCES public.user_profiles(user_id, id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.user_devices + ADD CONSTRAINT user_devices_profile_fkey FOREIGN KEY (user_id, profile_id) REFERENCES public.user_profiles(user_id, id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.user_devices + ADD CONSTRAINT user_devices_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + -- -- Name: user_subtitle_preferences user_subtitle_preferences_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - diff --git a/migrations/176_playback_session_position.down.sql b/migrations/176_playback_session_position.down.sql new file mode 100644 index 00000000..d1dec280 --- /dev/null +++ b/migrations/176_playback_session_position.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE playback_sessions_sync + DROP COLUMN IF EXISTS position_seconds; diff --git a/migrations/176_playback_session_position.up.sql b/migrations/176_playback_session_position.up.sql new file mode 100644 index 00000000..5bb3cf35 --- /dev/null +++ b/migrations/176_playback_session_position.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE playback_sessions_sync + ADD COLUMN IF NOT EXISTS position_seconds DOUBLE PRECISION NOT NULL DEFAULT 0; diff --git a/migrations/177_playback_session_transcode_hw_accel.down.sql b/migrations/177_playback_session_transcode_hw_accel.down.sql new file mode 100644 index 00000000..53738e05 --- /dev/null +++ b/migrations/177_playback_session_transcode_hw_accel.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE playback_sessions_sync + DROP COLUMN IF EXISTS transcode_hw_accel; diff --git a/migrations/177_playback_session_transcode_hw_accel.up.sql b/migrations/177_playback_session_transcode_hw_accel.up.sql new file mode 100644 index 00000000..4c2e2d5f --- /dev/null +++ b/migrations/177_playback_session_transcode_hw_accel.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE playback_sessions_sync + ADD COLUMN IF NOT EXISTS transcode_hw_accel text; diff --git a/migrations/178_admin_job_cancelled_status.down.sql b/migrations/178_admin_job_cancelled_status.down.sql new file mode 100644 index 00000000..a63b7b89 --- /dev/null +++ b/migrations/178_admin_job_cancelled_status.down.sql @@ -0,0 +1,23 @@ +UPDATE public.admin_jobs +SET status = 'failed', + message = CASE + WHEN message = '' THEN 'Admin job cancelled' + ELSE message + END, + error_message = CASE + WHEN error_message = '' THEN 'Cancelled status removed by rollback' + ELSE error_message + END +WHERE status = 'cancelled'; + +ALTER TABLE public.admin_jobs + DROP CONSTRAINT IF EXISTS admin_jobs_status_check; + +ALTER TABLE public.admin_jobs + ADD CONSTRAINT admin_jobs_status_check + CHECK ((status = ANY (ARRAY[ + 'queued'::text, + 'running'::text, + 'completed'::text, + 'failed'::text + ]))); diff --git a/migrations/178_admin_job_cancelled_status.up.sql b/migrations/178_admin_job_cancelled_status.up.sql new file mode 100644 index 00000000..ae0fcfa2 --- /dev/null +++ b/migrations/178_admin_job_cancelled_status.up.sql @@ -0,0 +1,12 @@ +ALTER TABLE public.admin_jobs + DROP CONSTRAINT IF EXISTS admin_jobs_status_check; + +ALTER TABLE public.admin_jobs + ADD CONSTRAINT admin_jobs_status_check + CHECK ((status = ANY (ARRAY[ + 'queued'::text, + 'running'::text, + 'completed'::text, + 'failed'::text, + 'cancelled'::text + ]))); diff --git a/migrations/179_media_file_match_suppression.down.sql b/migrations/179_media_file_match_suppression.down.sql new file mode 100644 index 00000000..2a123119 --- /dev/null +++ b/migrations/179_media_file_match_suppression.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_media_files_unmatched_match_ready; + +ALTER TABLE media_files + DROP COLUMN IF EXISTS match_suppressed_at; diff --git a/migrations/179_media_file_match_suppression.up.sql b/migrations/179_media_file_match_suppression.up.sql new file mode 100644 index 00000000..e4798c2c --- /dev/null +++ b/migrations/179_media_file_match_suppression.up.sql @@ -0,0 +1,7 @@ +ALTER TABLE media_files + ADD COLUMN IF NOT EXISTS match_suppressed_at timestamp with time zone; + +CREATE INDEX IF NOT EXISTS idx_media_files_unmatched_match_ready + ON media_files (media_folder_id, match_suppressed_at, match_attempted_at ASC NULLS FIRST, id ASC) + WHERE (content_id IS NULL OR content_id = '') + AND missing_since IS NULL; diff --git a/migrations/180_user_devices.down.sql b/migrations/180_user_devices.down.sql new file mode 100644 index 00000000..2cd42e37 --- /dev/null +++ b/migrations/180_user_devices.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.user_devices; diff --git a/migrations/180_user_devices.up.sql b/migrations/180_user_devices.up.sql new file mode 100644 index 00000000..dc696ee3 --- /dev/null +++ b/migrations/180_user_devices.up.sql @@ -0,0 +1,62 @@ +CREATE TABLE IF NOT EXISTS public.user_devices ( + user_id integer NOT NULL, + profile_id text NOT NULL, + device_id text NOT NULL, + device_name text NOT NULL DEFAULT '', + device_platform text NOT NULL DEFAULT '', + last_seen_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT user_devices_pkey PRIMARY KEY (user_id, profile_id, device_id), + CONSTRAINT user_devices_user_id_fkey + FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE, + CONSTRAINT user_devices_profile_fkey + FOREIGN KEY (user_id, profile_id) REFERENCES public.user_profiles(user_id, id) ON DELETE CASCADE +); + +INSERT INTO public.user_devices ( + user_id, + profile_id, + device_id, + device_name, + device_platform, + last_seen_at +) +SELECT DISTINCT ON (user_id, profile_id, device_id) + user_id, + profile_id, + device_id, + COALESCE(device_name, ''), + COALESCE(device_platform, ''), + updated_at +FROM ( + SELECT + settings.user_id, + COALESCE(valid_profiles.id, fallback_profiles.id) AS profile_id, + settings.device_id, + settings.device_name, + settings.device_platform, + settings.updated_at + FROM public.user_device_settings AS settings + LEFT JOIN public.user_profiles AS valid_profiles + ON valid_profiles.user_id = settings.user_id + AND valid_profiles.id = settings.profile_id + LEFT JOIN LATERAL ( + SELECT profiles.id + FROM public.user_profiles AS profiles + WHERE profiles.user_id = settings.user_id + ORDER BY profiles.is_primary DESC, profiles.created_at ASC, profiles.id ASC + LIMIT 1 + ) AS fallback_profiles ON true + WHERE settings.device_id <> '' +) AS resolved_settings +WHERE profile_id IS NOT NULL +ORDER BY user_id, profile_id, device_id, updated_at DESC +ON CONFLICT (user_id, profile_id, device_id) DO UPDATE SET + device_name = CASE + WHEN excluded.device_name <> '' THEN excluded.device_name + ELSE user_devices.device_name + END, + device_platform = CASE + WHEN excluded.device_platform <> '' THEN excluded.device_platform + ELSE user_devices.device_platform + END, + last_seen_at = GREATEST(user_devices.last_seen_at, excluded.last_seen_at); diff --git a/web/src/App.tsx b/web/src/App.tsx index 050a6c0d..ed501a89 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -18,6 +18,8 @@ import { ErrorBoundary } from "@/components/ErrorBoundary"; import ImpersonationBanner from "@/components/ImpersonationBanner"; import { loadStoredImpersonationAdminSession } from "@/lib/impersonationSession"; import { Toaster } from "@/components/ui/sonner"; +import { RealtimeEventsProvider } from "@/components/RealtimeEventsProvider"; +import { useEventChannel } from "@/components/realtimeEventsContext"; import Layout from "@/components/Layout"; import AdminLayout from "@/components/AdminLayout"; import Home from "@/pages/Home"; @@ -537,6 +539,23 @@ function AppRoutes() { ); } +function RealtimeEventChannels() { + const { user } = useAuth(); + + useEventChannel("catalog"); + useEventChannel("user_state"); + + return user?.role === "admin" ? : null; +} + +function AdminRealtimeEventChannels() { + useEventChannel("jobs"); + useEventChannel("sessions"); + useEventChannel("tasks"); + useEventChannel("scans"); + return null; +} + export default function App() { return ( @@ -546,14 +565,17 @@ export default function App() { - - - - - - - - + + + + + + + + + + + diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 7179d259..7d5ea05a 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1972,6 +1972,7 @@ export interface AdminSession { file_duration: number | null; started_at: string; updated_at: string; + position_seconds: number; is_paused: boolean; has_playback_control?: boolean; client_ip?: string; @@ -1982,6 +1983,7 @@ export interface AdminSession { target_video_codec?: string; target_audio_codec?: string; target_bitrate_kbps: number | null; + transcode_hw_accel?: string; source_container?: string; source_bitrate_kbps: number | null; source_video_codec?: string; @@ -2186,6 +2188,9 @@ export interface AdminDeviceDetail { device_id: string; device_name: string; device_platform: string; + override_count: number; + profile_count: number; + profiles: AdminDeviceProfileSummary[]; last_updated: string; settings: { user_id: number; @@ -2250,6 +2255,66 @@ export interface LibraryMountCheckResponse { roots: LibraryMountCheckRoot[]; } +export interface LibraryMetadataMatchQueueStatus { + library_id: number; + movie_count: number; + series_count: number; + raw_file_count: number; + total_count: number; +} + +export interface LibraryMovieMatchQueueEntry { + media_file_id: number; + media_folder_id: number; + file_path: string; + first_queued_at: string; + available_at: string; + last_attempted_at?: string; + attempt_count: number; + last_error?: string; + updated_at: string; +} + +export interface LibrarySeriesMatchQueueEntry { + media_folder_id: number; + observed_root_path: string; + first_queued_at: string; + available_at: string; + last_attempted_at?: string; + attempt_count: number; + last_error?: string; + updated_at: string; +} + +export interface LibraryRawMatchBacklogEntry { + media_file_id: number; + media_folder_id: number; + file_path: string; + base_title?: string; + base_year?: number; + base_type?: string; + last_attempted_at?: string; + created_at: string; + updated_at: string; +} + +export interface LibraryMetadataMatchQueueDetail extends LibraryMetadataMatchQueueStatus { + movies: LibraryMovieMatchQueueEntry[]; + series: LibrarySeriesMatchQueueEntry[]; + raw_files: LibraryRawMatchBacklogEntry[]; +} + +export interface LibraryMetadataMatchQueueActionResponse { + status: "queued" | "cancelled"; + library_id: number; + movie_cancelled?: number; + series_cancelled?: number; + raw_file_cancelled?: number; + raw_file_retried?: number; + total_cancelled?: number; + queue: LibraryMetadataMatchQueueStatus; +} + export interface LibrarySkippedRoot { library_id: number; library_name: string; @@ -2402,7 +2467,7 @@ export interface CatalogSeedImportResponse { unmatched_roots?: string[]; } -export type AdminJobStatus = "queued" | "running" | "completed" | "failed"; +export type AdminJobStatus = "queued" | "running" | "completed" | "failed" | "cancelled"; export interface LibraryRefreshJobRequest { library_id: number; diff --git a/web/src/components/AdminLayout.tsx b/web/src/components/AdminLayout.tsx index fd3daf63..5b8186ea 100644 --- a/web/src/components/AdminLayout.tsx +++ b/web/src/components/AdminLayout.tsx @@ -2,8 +2,6 @@ import { useState } from "react"; import { Outlet, useLocation } from "react-router"; import AdminSidebar from "@/components/AdminSidebar"; import ServerActivity from "@/components/ServerActivity"; -import { RealtimeEventsProvider } from "@/components/RealtimeEventsProvider"; -import { useEventChannel } from "@/components/realtimeEventsContext"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; import { resolveAdminDocumentTitle } from "@/lib/documentTitle"; @@ -18,77 +16,64 @@ export default function AdminLayout() { useDocumentTitle(resolveAdminDocumentTitle(location.pathname)); return ( - - -
- - Skip to content - -
- {/* Desktop sidebar */} -
- -
- - {/* Mobile header */} -
-
- -
-
- ▶ -
- Admin -
-
- -
- - {/* Desktop activity indicator */} -
- -
- - {/* Mobile sidebar drawer */} - - - - Admin Navigation - - setMobileOpen(false)} /> - - - -
-
- -
-
+
+ + Skip to content + +
+ {/* Desktop sidebar */} +
+
- + + {/* Mobile header */} +
+
+ +
+
+ ▶ +
+ Admin +
+
+ +
+ + {/* Desktop activity indicator */} +
+ +
+ + {/* Mobile sidebar drawer */} + + + + Admin Navigation + + setMobileOpen(false)} /> + + + +
+
+ +
+
+
); } - -function AdminLayoutEventChannels() { - useEventChannel("catalog"); - useEventChannel("user_state"); - useEventChannel("jobs"); - useEventChannel("sessions"); - useEventChannel("tasks"); - useEventChannel("scans"); - return null; -} diff --git a/web/src/components/AdminSessionActions.tsx b/web/src/components/AdminSessionActions.tsx index 325be454..74f6ee34 100644 --- a/web/src/components/AdminSessionActions.tsx +++ b/web/src/components/AdminSessionActions.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { useEffect, useState } from "react"; import { MoreHorizontal, MessageSquare, Pause, Play, Square, OctagonAlert } from "lucide-react"; import { toast } from "sonner"; @@ -27,6 +28,10 @@ type SessionActionKind = "pause" | "resume" | "stop" | "terminate" | "message"; interface AdminSessionActionsProps { session: AdminSession; compact?: boolean; + layout?: "menu" | "inline"; + extraMenuItems?: ReactNode; + inlinePrefixActions?: ReactNode; + showInlineTerminate?: boolean; } type SessionCommandResponse = { @@ -52,7 +57,14 @@ const fallbackMessages: Record, string> = const unsupportedPlaybackControlCopy = "This session does not support live pause, resume, or messages. Stop and Terminate can still end playback."; -export function AdminSessionActions({ session, compact = false }: AdminSessionActionsProps) { +export function AdminSessionActions({ + session, + compact = false, + layout = "menu", + extraMenuItems, + inlinePrefixActions, + showInlineTerminate = false, +}: AdminSessionActionsProps) { const [pendingAction, setPendingAction] = useState(null); const [messageOpen, setMessageOpen] = useState(false); const [message, setMessage] = useState(""); @@ -123,58 +135,110 @@ export function AdminSessionActions({ session, compact = false }: AdminSessionAc } } + const terminateButton = showInlineTerminate ? ( + + ) : null; + + const menu = ( + + + + + + {layout === "menu" ? ( + <> + + {supportsPlaybackControl ? "Playback Actions" : "Limited Playback Actions"} + + {supportsPlaybackControl ? ( + void runAction(primaryAction.action)}> + {primaryAction.action === "pause" ? ( + + ) : ( + + )} + {primaryAction.label} + + ) : ( + <> +
+ {unsupportedPlaybackControlCopy} +
+ + + )} + void runAction("stop")}> + + Stop + + + ) : ( + Session Actions + )} + {extraMenuItems ? ( + <> + {layout === "menu" ? : null} + {extraMenuItems} + + ) : null} + {supportsPlaybackControl ? ( + setMessageOpen(true)}> + + Message… + + ) : layout === "inline" ? ( + <> +
+ {unsupportedPlaybackControlCopy} +
+ + + ) : null} + {showInlineTerminate ? null : ( + <> + + void runAction("terminate")}> + + Terminate + + + )} +
+
+ ); + return ( <> - - - - - - - {supportsPlaybackControl ? "Playback Actions" : "Limited Playback Actions"} - - {supportsPlaybackControl ? ( - void runAction(primaryAction.action)}> - {primaryAction.action === "pause" ? ( - - ) : ( - - )} - {primaryAction.label} - - ) : ( - <> -
- {unsupportedPlaybackControlCopy} -
- - - )} - void runAction("stop")}> - - Stop - - {supportsPlaybackControl ? ( - setMessageOpen(true)}> - - Message… - - ) : null} - - void runAction("terminate")}> - - Terminate - -
-
+ {layout === "inline" ? ( +
+ {inlinePrefixActions} + {terminateButton} + {menu} +
+ ) : ( + menu + )} {/* Logo */} -
- +
+ + +
{/* Nav sections */}