diff --git a/AGENTS.md b/AGENTS.md index 35f16f352..bfab5da9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,3 +132,7 @@ web/src/ React 19 + Vite + TypeScript + Tailwind v4 2. Torrent state cached in-memory with delta updates 3. Frontend fetches via REST API, real-time updates via SSE 4. Cross-seed service listens for torrent completion events + +## Notes + +- SSE migration follow-up: `web/src/components/torrents/TorrentDetailsPanel.tsx` still uses polling for live torrent state/files/pieces; next step is stream-backed detail updates where practical. diff --git a/go.mod b/go.mod index 556d53699..7731e360b 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 + github.com/tmaxmax/go-sse v0.11.0 github.com/ulikunitz/xz v0.5.15 golang.org/x/crypto v0.47.0 golang.org/x/image v0.35.0 diff --git a/go.sum b/go.sum index 65d526f6c..f78dd57b0 100644 --- a/go.sum +++ b/go.sum @@ -357,6 +357,8 @@ github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSW github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= +github.com/tmaxmax/go-sse v0.11.0 h1:nogmJM6rJUoOLoAwEKeQe5XlVpt9l7N82SS1jI7lWFg= +github.com/tmaxmax/go-sse v0.11.0/go.mod h1:u/2kZQR1tyngo1lKaNCj1mJmhXGZWS1Zs5yiSOD+Eg8= github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= diff --git a/internal/api/handlers/qbittorrent_info.go b/internal/api/handlers/qbittorrent_info.go index cab5a10e2..07245632e 100644 --- a/internal/api/handlers/qbittorrent_info.go +++ b/internal/api/handlers/qbittorrent_info.go @@ -25,24 +25,6 @@ func NewQBittorrentInfoHandler(clientPool *internalqbittorrent.ClientPool) *QBit } } -// QBittorrentBuildInfo represents qBittorrent build information -type QBittorrentBuildInfo struct { - Qt string `json:"qt"` - Libtorrent string `json:"libtorrent"` - Boost string `json:"boost"` - OpenSSL string `json:"openssl"` - Zlib string `json:"zlib"` - Bitness int `json:"bitness"` - Platform string `json:"platform,omitempty"` -} - -// QBittorrentAppInfo represents qBittorrent application information -type QBittorrentAppInfo struct { - Version string `json:"version"` - WebAPIVersion string `json:"webAPIVersion,omitempty"` - BuildInfo *QBittorrentBuildInfo `json:"buildInfo,omitempty"` -} - // GetQBittorrentAppInfo returns qBittorrent application version and build information func (h *QBittorrentInfoHandler) GetQBittorrentAppInfo(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -77,42 +59,15 @@ func (h *QBittorrentInfoHandler) GetQBittorrentAppInfo(w http.ResponseWriter, r } // getQBittorrentAppInfo fetches application info from qBittorrent API -func (h *QBittorrentInfoHandler) getQBittorrentAppInfo(ctx context.Context, client *internalqbittorrent.Client) (*QBittorrentAppInfo, error) { - // Get qBittorrent application version - version, err := client.GetAppVersionCtx(ctx) +func (h *QBittorrentInfoHandler) getQBittorrentAppInfo(ctx context.Context, client *internalqbittorrent.Client) (*internalqbittorrent.AppInfo, error) { + appInfo, err := client.GetAppInfo(ctx) if err != nil { return nil, err } - // Get qBittorrent Web API version - webAPIVersion, err := client.GetWebAPIVersionCtx(ctx) - if err != nil { - return nil, err - } - - // Get build information from qBittorrent API - buildInfo, err := client.GetBuildInfoCtx(ctx) - if err != nil { - return nil, err - } - - // Log the buildinfo - log.Trace().Msgf("qBittorrent BuildInfo - App Version: %s, Web API Version: %s, Platform: %s, Libtorrent: %s, Qt: %s, Bitness: %d", - version, webAPIVersion, buildInfo.Platform, buildInfo.Libtorrent, buildInfo.Qt, buildInfo.Bitness) - - // Convert from go-qbittorrent BuildInfo to our QBittorrentBuildInfo - appInfo := &QBittorrentAppInfo{ - Version: version, - WebAPIVersion: webAPIVersion, - BuildInfo: &QBittorrentBuildInfo{ - Qt: buildInfo.Qt, - Libtorrent: buildInfo.Libtorrent, - Boost: buildInfo.Boost, - OpenSSL: buildInfo.Openssl, - Zlib: buildInfo.Zlib, - Bitness: buildInfo.Bitness, - Platform: buildInfo.Platform, - }, + if appInfo != nil && appInfo.BuildInfo != nil { + log.Trace().Msgf("qBittorrent BuildInfo - App Version: %s, Web API Version: %s, Platform: %s, Libtorrent: %s, Qt: %s, Bitness: %d", + appInfo.Version, appInfo.WebAPIVersion, appInfo.BuildInfo.Platform, appInfo.BuildInfo.Libtorrent, appInfo.BuildInfo.Qt, appInfo.BuildInfo.Bitness) } return appInfo, nil diff --git a/internal/api/handlers/torrents.go b/internal/api/handlers/torrents.go index 12e78a04e..b63bc6389 100644 --- a/internal/api/handlers/torrents.go +++ b/internal/api/handlers/torrents.go @@ -184,6 +184,17 @@ func (h *TorrentsHandler) ListTorrents(w http.ResponseWriter, r *http.Request) { } } + // Determine freshness preference + preferParam := strings.TrimSpace(r.URL.Query().Get("prefer")) + preferCached := strings.EqualFold(preferParam, "stale") || + strings.EqualFold(preferParam, "cache") || + strings.EqualFold(preferParam, "cached") + + ctx := r.Context() + if preferCached { + ctx = qbittorrent.WithSkipFreshData(ctx) + } + // Debug logging with truncated expression to prevent log bloat logEvent := log.Debug(). Int("instanceID", instanceID). @@ -192,6 +203,7 @@ func (h *TorrentsHandler) ListTorrents(w http.ResponseWriter, r *http.Request) { Int("page", page). Int("limit", limit). Str("search", search). + Bool("preferCached", preferCached). Str("sessionID", sessionID) // Log filters but truncate long expressions @@ -215,14 +227,14 @@ func (h *TorrentsHandler) ListTorrents(w http.ResponseWriter, r *http.Request) { // Get torrents with search, sorting and filters // The sync manager will handle stale-while-revalidate internally - response, err := h.syncManager.GetTorrentsWithFilters(r.Context(), instanceID, limit, offset, sort, order, search, filters) + response, err := h.syncManager.GetTorrentsWithFilters(ctx, instanceID, limit, offset, sort, order, search, filters) if err != nil { if respondIfInstanceDisabled(w, err, instanceID, "torrents:list") { return } // Record error for user visibility errorStore := h.syncManager.GetErrorStore() - if recordErr := errorStore.RecordError(r.Context(), instanceID, err); recordErr != nil { + if recordErr := errorStore.RecordError(ctx, instanceID, err); recordErr != nil { log.Error().Err(recordErr).Int("instanceID", instanceID).Msg("Failed to record torrent error") } @@ -231,8 +243,14 @@ func (h *TorrentsHandler) ListTorrents(w http.ResponseWriter, r *http.Request) { return } - // Data is always fresh from sync manager - w.Header().Set("X-Data-Source", "fresh") + switch { + case response.CacheMetadata != nil && response.CacheMetadata.Source != "": + w.Header().Set("X-Data-Source", response.CacheMetadata.Source) + case preferCached: + w.Header().Set("X-Data-Source", "cache") + default: + w.Header().Set("X-Data-Source", "fresh") + } RespondJSON(w, http.StatusOK, response) } diff --git a/internal/api/server.go b/internal/api/server.go index 80b9108cd..39233d973 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -21,6 +21,7 @@ import ( "github.com/autobrr/qui/internal/api/handlers" "github.com/autobrr/qui/internal/api/middleware" + "github.com/autobrr/qui/internal/api/sse" "github.com/autobrr/qui/internal/auth" "github.com/autobrr/qui/internal/backups" "github.com/autobrr/qui/internal/config" @@ -66,6 +67,7 @@ type Server struct { updateService *update.Service trackerIconService *trackericons.Service backupService *backups.Service + streamManager *sse.StreamManager filesManager *filesmanager.Service crossSeedService *crossseed.Service jackettService *jackett.Service @@ -126,6 +128,11 @@ type Dependencies struct { } func NewServer(deps *Dependencies) *Server { + streamManager := sse.NewStreamManager(deps.ClientPool, deps.SyncManager, deps.InstanceStore) + if deps.ClientPool != nil { + deps.ClientPool.SetSyncEventSink(streamManager) + } + s := Server{ server: &http.Server{ ReadHeaderTimeout: time.Second * 15, @@ -150,6 +157,7 @@ func NewServer(deps *Dependencies) *Server { updateService: deps.UpdateService, trackerIconService: deps.TrackerIconService, backupService: deps.BackupService, + streamManager: streamManager, filesManager: deps.FilesManager, crossSeedService: deps.CrossSeedService, reannounceService: deps.ReannounceService, @@ -247,6 +255,15 @@ func (s *Server) tryToServe(addr, protocol string, ready chan<- struct{}) error } func (s *Server) Shutdown(ctx context.Context) error { + if s.streamManager != nil { + shutdownCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + if err := s.streamManager.Shutdown(shutdownCtx); err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + s.logger.Warn().Err(err).Msg("failed to shut down stream manager cleanly") + } + } + return s.server.Shutdown(ctx) } @@ -443,6 +460,10 @@ func (s *Server) Handler() (*chi.Mux, error) { // Version endpoint for update checks r.Get("/version/latest", versionHandler.GetLatestVersion) + if s.streamManager != nil { + r.Get("/stream", s.streamManager.Serve) + } + // Instance management r.Route("/instances", func(r chi.Router) { r.Get("/", instancesHandler.ListInstances) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 41ed48e6f..d4ce93799 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -9,9 +9,11 @@ import ( "fmt" "net/http" "path/filepath" + "reflect" "sort" "strings" "testing" + "unsafe" "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" @@ -42,6 +44,7 @@ type routeKey struct { var undocumentedRoutes = map[routeKey]struct{}{ {Method: http.MethodGet, Path: "/api/auth/validate"}: {}, + {Method: http.MethodGet, Path: "/api/stream"}: {}, {Method: http.MethodPost, Path: "/api/instances/{instanceId}/backups/run"}: {}, {Method: http.MethodGet, Path: "/api/instances/{instanceId}/backups/runs"}: {}, {Method: http.MethodDelete, Path: "/api/instances/{instanceId}/backups/runs"}: {}, @@ -69,6 +72,21 @@ var undocumentedRoutes = map[routeKey]struct{}{ {Method: http.MethodPut, Path: "/api/dashboard-settings"}: {}, } +func TestNewServerRegistersStreamManagerAsSyncSink(t *testing.T) { + clientPool := &qbittorrent.ClientPool{} + + server := NewServer(&Dependencies{ + Config: &config.AppConfig{Config: &domain.Config{BaseURL: "/"}}, + ClientPool: clientPool, + }) + + require.NotNil(t, server.streamManager, "expected stream manager to be initialized") + + sink := getClientPoolSyncEventSink(t, clientPool) + require.NotNil(t, sink, "expected client pool to have a sync sink registered") + require.Same(t, server.streamManager, sink, "stream manager should be registered as sync sink") +} + func TestAllEndpointsDocumented(t *testing.T) { server := NewServer(newTestDependencies(t)) router, err := server.Handler() @@ -283,3 +301,21 @@ func formatRoutes(routes []routeKey) string { } return strings.Join(lines, "\n") } + +func getClientPoolSyncEventSink(t *testing.T, pool *qbittorrent.ClientPool) qbittorrent.SyncEventSink { + t.Helper() + + value := reflect.ValueOf(pool).Elem().FieldByName("syncEventSink") + if !value.IsValid() { + t.Fatalf("client pool does not expose syncEventSink field") + } + + exposed := reflect.NewAt(value.Type(), unsafe.Pointer(value.UnsafeAddr())).Elem() + if exposed.IsNil() { + return nil + } + + sink, ok := exposed.Interface().(qbittorrent.SyncEventSink) + require.True(t, ok, "unexpected sink type stored on client pool") + return sink +} diff --git a/internal/api/sse/manager.go b/internal/api/sse/manager.go new file mode 100644 index 000000000..81676298d --- /dev/null +++ b/internal/api/sse/manager.go @@ -0,0 +1,1142 @@ +// Copyright (c) 2025, s0up and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package sse + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + qbt "github.com/autobrr/go-qbittorrent" + "github.com/rs/zerolog/log" + "github.com/tmaxmax/go-sse" + + "github.com/autobrr/qui/internal/models" + "github.com/autobrr/qui/internal/qbittorrent" +) + +const ( + defaultLimit = 300 + maxLimit = 2000 + streamEventInit = "init" + streamEventUpdate = "update" + streamEventError = "stream-error" + streamEventHeartbeat = "heartbeat" + defaultSyncInterval = 2 * time.Second + maxSyncInterval = 30 * time.Second + heartbeatInterval = 5 * time.Second +) + +var ( + errInvalidInstanceID = errors.New("invalid instance id") + errNoStreamRequests = errors.New("no stream subscriptions requested") +) + +type ctxKey string + +const subscriptionIDsContextKey ctxKey = "qui.sse.subscriptionIDs" + +// StreamOptions captures the torrent view that the subscriber wants to keep in sync. +type StreamOptions struct { + InstanceID int + Page int + Limit int + Sort string + Order string + Search string + Filters qbittorrent.FilterOptions +} + +type streamRequest struct { + key string + options StreamOptions +} + +func streamOptionsKey(opts StreamOptions) string { + filtersKey := "__none__" + raw, err := json.Marshal(opts.Filters) + if err != nil { + log.Warn().Err(err).Msg("Failed to marshal filter options for stream key; using fallback") + } else if len(raw) > 0 && string(raw) != "null" { + filtersKey = string(raw) + } + + return fmt.Sprintf( + "%d|%d|%d|%s|%s|%s|%s", + opts.InstanceID, + opts.Page, + opts.Limit, + strconv.Quote(opts.Sort), + strconv.Quote(opts.Order), + strconv.Quote(opts.Search), + strconv.Quote(filtersKey), + ) +} + +// StreamManager owns the SSE server and keeps subscriptions in sync with qBittorrent updates. +// +// Lock hierarchy (acquire in this order to prevent deadlock): +// 1. m.mu (StreamManager.mu) - protects subscriptions, groups, loops +// 2. group.mu (subscriptionGroup.mu) - protects pending queue state +// 3. group.subsMu (subscriptionGroup.subsMu) - protects subscriber list +type StreamManager struct { + server *sse.Server + clientPool *qbittorrent.ClientPool + syncManager *qbittorrent.SyncManager + instanceDB *models.InstanceStore + + counter atomic.Uint64 + closing atomic.Bool + mu sync.RWMutex + + subscriptions map[string]*subscriptionState + instanceIndex map[int]map[string]*subscriptionState + groups map[string]*subscriptionGroup + instanceGroups map[int]map[string]*subscriptionGroup + syncLoops map[int]*syncLoopState + heartbeatLoops map[int]*heartbeatLoopState + syncBackoff map[int]*backoffState + + ctx context.Context //nolint:containedctx // lifecycle root context used only for coordinated shutdown + cancel context.CancelFunc +} + +type subscriptionState struct { + id string + options StreamOptions + created time.Time + groupKey string + clientKey string +} + +type subscriptionGroup struct { + key string + options StreamOptions + + mu sync.Mutex + sending bool + hasPending bool + pendingMeta *StreamMeta + pendingType string + + subsMu sync.RWMutex + subs map[string]*subscriptionState +} + +type syncLoopState struct { + cancel context.CancelFunc + interval time.Duration +} + +type heartbeatLoopState struct { + cancel context.CancelFunc +} + +type backoffState struct { + attempt int + interval time.Duration +} + +// StreamPayload is the message envelope sent to the frontend. +type StreamPayload struct { + Type string `json:"type"` + Data *qbittorrent.TorrentResponse `json:"data,omitempty"` + Meta *StreamMeta `json:"meta,omitempty"` + Err string `json:"error,omitempty"` +} + +// StreamMeta carries lightweight metadata about the sync update. +type StreamMeta struct { + InstanceID int `json:"instanceId"` + RID int64 `json:"rid,omitempty"` + FullUpdate bool `json:"fullUpdate,omitempty"` + Timestamp time.Time `json:"timestamp"` + RetryInSeconds int `json:"retryInSeconds,omitempty"` + StreamKey string `json:"streamKey,omitempty"` +} + +// NewStreamManager constructs a manager with a configured SSE server. +func NewStreamManager(clientPool *qbittorrent.ClientPool, syncManager *qbittorrent.SyncManager, instanceStore *models.InstanceStore) *StreamManager { + replayer, err := sse.NewFiniteReplayer(4, true) + if err != nil { + // Constructor only errors on invalid parameters; fall back to nil replayer just in case. + log.Warn().Err(err).Msg("Failed to create SSE replayer; reconnecting clients may miss events") + replayer = nil + } + + ctx, cancel := context.WithCancel(context.Background()) + + m := &StreamManager{ + server: &sse.Server{ + Provider: &sse.Joe{Replayer: replayer}, + }, + clientPool: clientPool, + syncManager: syncManager, + instanceDB: instanceStore, + subscriptions: make(map[string]*subscriptionState), + instanceIndex: make(map[int]map[string]*subscriptionState), + groups: make(map[string]*subscriptionGroup), + instanceGroups: make(map[int]map[string]*subscriptionGroup), + syncLoops: make(map[int]*syncLoopState), + heartbeatLoops: make(map[int]*heartbeatLoopState), + syncBackoff: make(map[int]*backoffState), + ctx: ctx, + cancel: cancel, + } + + m.server.OnSession = m.onSession + return m +} + +// Server exposes the underlying SSE HTTP handler. +func (m *StreamManager) Server() http.Handler { + return m.server +} + +// PrepareBatch registers one or more subscribers and returns a context that carries their session ids. +func (m *StreamManager) PrepareBatch(ctx context.Context, requests []streamRequest) (context.Context, []string, error) { + if m.closing.Load() { + return ctx, nil, errors.New("stream manager shutting down") + } + + if len(requests) == 0 { + return ctx, nil, errNoStreamRequests + } + + ids := make([]string, 0, len(requests)) + for _, req := range requests { + if req.options.InstanceID <= 0 { + m.unregisterMany(ids) + return ctx, nil, errInvalidInstanceID + } + + clientKey := req.key + if clientKey == "" { + clientKey = streamOptionsKey(req.options) + } + + id, err := m.registerSubscription(req.options, clientKey) + if err != nil { + m.unregisterMany(ids) + return ctx, nil, err + } + + ids = append(ids, id) + } + + return context.WithValue(ctx, subscriptionIDsContextKey, ids), ids, nil +} + +func (m *StreamManager) registerSubscription(opts StreamOptions, clientKey string) (string, error) { + if m.closing.Load() { + return "", errors.New("stream manager shutting down") + } + + id := fmt.Sprintf("qui-session-%d", m.counter.Add(1)) + state := &subscriptionState{ + id: id, + options: opts, + created: time.Now(), + groupKey: streamOptionsKey(opts), + clientKey: clientKey, + } + + m.mu.Lock() + group, ok := m.groups[state.groupKey] + if !ok { + group = &subscriptionGroup{ + key: state.groupKey, + options: opts, + subs: make(map[string]*subscriptionState), + } + m.groups[state.groupKey] = group + if _, exists := m.instanceGroups[opts.InstanceID]; !exists { + m.instanceGroups[opts.InstanceID] = make(map[string]*subscriptionGroup) + } + m.instanceGroups[opts.InstanceID][state.groupKey] = group + } + + group.subsMu.Lock() + group.subs[id] = state + group.subsMu.Unlock() + + m.subscriptions[id] = state + if _, ok := m.instanceIndex[opts.InstanceID]; !ok { + m.instanceIndex[opts.InstanceID] = make(map[string]*subscriptionState) + } + m.instanceIndex[opts.InstanceID][id] = state + + backoff := m.ensureBackoffStateLocked(opts.InstanceID) + if _, running := m.syncLoops[opts.InstanceID]; !running { + m.syncLoops[opts.InstanceID] = m.startSyncLoop(opts.InstanceID, backoff.interval) + } + if _, running := m.heartbeatLoops[opts.InstanceID]; !running && heartbeatInterval > 0 { + m.heartbeatLoops[opts.InstanceID] = m.startHeartbeatLoop(opts.InstanceID) + } + m.mu.Unlock() + + return id, nil +} + +// Unregister removes and cleans up a subscriber when the HTTP connection closes. +func (m *StreamManager) Unregister(id string) { + if id == "" { + return + } + + var instanceID int + + m.mu.Lock() + if state, ok := m.subscriptions[id]; ok { + instanceID = state.options.InstanceID + groupKey := state.groupKey + delete(m.subscriptions, id) + + if group, exists := m.groups[groupKey]; exists { + group.subsMu.Lock() + delete(group.subs, id) + remaining := len(group.subs) + group.subsMu.Unlock() + + if remaining == 0 { + delete(m.groups, groupKey) + if groups := m.instanceGroups[instanceID]; groups != nil { + delete(groups, groupKey) + if len(groups) == 0 { + delete(m.instanceGroups, instanceID) + } + } + } + } + + if subs := m.instanceIndex[instanceID]; subs != nil { + delete(subs, id) + if len(subs) == 0 { + delete(m.instanceIndex, instanceID) + if loop, ok := m.syncLoops[instanceID]; ok { + loop.cancel() + delete(m.syncLoops, instanceID) + } + if hbLoop, ok := m.heartbeatLoops[instanceID]; ok { + hbLoop.cancel() + delete(m.heartbeatLoops, instanceID) + } + delete(m.syncBackoff, instanceID) + } + } + } + m.mu.Unlock() +} + +func (m *StreamManager) unregisterMany(ids []string) { + for _, id := range ids { + m.Unregister(id) + } +} + +// HandleMainData implements qbittorrent.SyncEventSink. +func (m *StreamManager) HandleMainData(instanceID int, data *qbt.MainData) { + if data == nil { + return + } + + if m.closing.Load() { + return + } + + m.markSyncSuccess(instanceID) + + meta := &StreamMeta{ + InstanceID: instanceID, + RID: data.Rid, + FullUpdate: data.FullUpdate, + Timestamp: time.Now(), + } + + go m.publishInstance(instanceID, streamEventUpdate, meta) +} + +// HandleSyncError implements qbittorrent.SyncEventSink. +func (m *StreamManager) HandleSyncError(instanceID int, err error) { + if err == nil { + return + } + + if m.closing.Load() { + return + } + + backoff := m.markSyncFailure(instanceID) + retrySeconds := int(backoff.Seconds()) + if retrySeconds <= 0 { + retrySeconds = int(defaultSyncInterval.Round(time.Second) / time.Second) + } + + log.Warn(). + Err(err). + Int("instanceID", instanceID). + Dur("retryIn", backoff). + Msg("Sync manager error propagated to SSE stream") + + message := fmt.Sprintf("Sync with qBittorrent failed (%s); retrying in %ds", err.Error(), retrySeconds) + + payload := &StreamPayload{ + Type: streamEventError, + Meta: &StreamMeta{ + InstanceID: instanceID, + Timestamp: time.Now(), + RetryInSeconds: retrySeconds, + }, + Err: message, + } + + m.publishToInstance(instanceID, payload) +} + +// Serve implements the HTTP handler for GET /stream and multiplexes multiple subscriptions over one SSE session. +func (m *StreamManager) Serve(w http.ResponseWriter, r *http.Request) { + if m.closing.Load() { + http.Error(w, "stream shutting down", http.StatusServiceUnavailable) + return + } + + requests, err := parseStreamRequests(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + instanceIDs := make(map[int]struct{}, len(requests)) + for _, req := range requests { + instanceIDs[req.options.InstanceID] = struct{}{} + } + + for instanceID := range instanceIDs { + exists, err := m.instanceExists(r.Context(), instanceID) + if err != nil { + log.Error().Err(err).Int("instanceID", instanceID).Msg("failed to check instance existence") + http.Error(w, "failed to validate instance", http.StatusInternalServerError) + return + } + if !exists { + http.Error(w, "instance not found", http.StatusNotFound) + return + } + } + + ctx, subscriptionIDs, err := m.PrepareBatch(r.Context(), requests) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, errInvalidInstanceID) || errors.Is(err, errNoStreamRequests) { + status = http.StatusBadRequest + } + log.Error().Err(err).Msg("failed to prepare SSE subscriptions") + http.Error(w, "failed to prepare SSE stream", status) + return + } + defer m.unregisterMany(subscriptionIDs) + + req := r.WithContext(ctx) + + // SSE connections are long-lived; disable the write deadline inherited from + // the main HTTP server so streams aren't terminated by global WriteTimeout. + _ = http.NewResponseController(w).SetWriteDeadline(time.Time{}) + + // ServeHTTP blocks until the client disconnects. + m.server.ServeHTTP(w, req) +} + +func (m *StreamManager) onSession(w http.ResponseWriter, r *http.Request) ([]string, bool) { + if m.closing.Load() { + http.Error(w, "stream shutting down", http.StatusServiceUnavailable) + return nil, false + } + + raw, _ := r.Context().Value(subscriptionIDsContextKey).([]string) + if len(raw) == 0 { + http.Error(w, "missing subscription context", http.StatusBadRequest) + return nil, false + } + + for _, id := range raw { + sub := m.getSubscription(id) + if sub == nil { + http.Error(w, "subscription not found", http.StatusBadRequest) + return nil, false + } + + group := m.getGroup(sub.groupKey) + if group == nil { + http.Error(w, "subscription group not found", http.StatusBadRequest) + return nil, false + } + + // Send initial snapshot once the subscription is active. + m.enqueueGroup(group, streamEventInit, &StreamMeta{ + InstanceID: sub.options.InstanceID, + FullUpdate: true, + Timestamp: time.Now(), + }) + } + + return raw, true +} + +func (m *StreamManager) publishInstance(instanceID int, eventType string, meta *StreamMeta) { + if m.closing.Load() { + return + } + + groups := m.groupsForInstance(instanceID) + if len(groups) == 0 { + return + } + + for _, group := range groups { + m.enqueueGroup(group, eventType, meta) + } +} + +func (m *StreamManager) groupsForInstance(instanceID int) []*subscriptionGroup { + if m.closing.Load() { + return nil + } + + m.mu.RLock() + groupMap := m.instanceGroups[instanceID] + if groupMap == nil { + m.mu.RUnlock() + return nil + } + + result := make([]*subscriptionGroup, 0, len(groupMap)) + for _, group := range groupMap { + result = append(result, group) + } + m.mu.RUnlock() + return result +} + +func (m *StreamManager) enqueueGroup(group *subscriptionGroup, eventType string, meta *StreamMeta) { + if group == nil || m.closing.Load() { + return + } + + metaCopy := cloneMeta(meta) + + group.mu.Lock() + group.pendingMeta = metaCopy + group.pendingType = eventType + group.hasPending = true + if group.sending { + group.mu.Unlock() + return + } + group.sending = true + group.mu.Unlock() + + go m.processGroup(group.key) +} + +func (m *StreamManager) processGroup(groupKey string) { + for { + if m.closing.Load() { + return + } + + group := m.getGroup(groupKey) + if group == nil { + return + } + + group.mu.Lock() + if !group.hasPending { + group.sending = false + group.mu.Unlock() + return + } + eventType := group.pendingType + meta := group.pendingMeta + opts := group.options + group.hasPending = false + group.mu.Unlock() + + subs := group.snapshotSubscribers() + if len(subs) == 0 { + continue + } + + payload := m.buildGroupPayload(group, opts, eventType, meta) + if payload == nil { + continue + } + + for _, sub := range subs { + m.publish(sub.id, clonePayloadForSubscriber(payload, sub)) + } + } +} + +func (m *StreamManager) buildGroupPayload(group *subscriptionGroup, opts StreamOptions, eventType string, meta *StreamMeta) *StreamPayload { + if group == nil || m.syncManager == nil { + return nil + } + + if m.closing.Load() { + return nil + } + + metaCopy := cloneMeta(meta) + + ctx, cancel := context.WithTimeout(m.ctx, 10*time.Second) + defer cancel() + ctx = qbittorrent.WithSkipFreshData(ctx) + + response, err := m.syncManager.GetTorrentsWithFilters( + ctx, + opts.InstanceID, + opts.Limit, + opts.Page*opts.Limit, + opts.Sort, + opts.Order, + opts.Search, + opts.Filters, + ) + if err != nil { + errMsg := "failed to refresh torrent list" + if errors.Is(err, context.DeadlineExceeded) { + errMsg = "torrent list refresh timed out" + } else if errors.Is(err, context.Canceled) { + errMsg = "refresh was cancelled" + } + + log.Error().Err(err). + Int("instanceID", opts.InstanceID). + Str("groupKey", group.key). + Msg("Failed to build torrent response for SSE subscribers") + + return &StreamPayload{ + Type: streamEventError, + Meta: metaCopy, + Err: errMsg, + } + } + + // Populate instance metadata for real-time health updates + response.InstanceMeta = m.buildInstanceMeta(ctx, opts.InstanceID) + + return &StreamPayload{ + Type: eventType, + Data: response, + Meta: metaCopy, + } +} + +// buildInstanceMeta creates real-time instance health metadata for SSE subscribers. +func (m *StreamManager) buildInstanceMeta(ctx context.Context, instanceID int) *qbittorrent.InstanceMeta { + if m.clientPool == nil { + return nil + } + + // Check client health + client, clientErr := m.clientPool.GetClientOffline(ctx, instanceID) + if clientErr != nil { + log.Warn().Err(clientErr).Int("instanceID", instanceID).Msg("Failed to get client for instance meta") + } + + // Get instance to check if it's active + instance, err := m.instanceDB.Get(ctx, instanceID) + if err != nil { + return nil + } + + healthy := client != nil && client.IsHealthy() && instance.IsActive + + // Check for decryption errors + decryptionErrorInstances := m.clientPool.GetInstancesWithDecryptionErrors() + hasDecryptionError := slices.Contains(decryptionErrorInstances, instanceID) + + meta := &qbittorrent.InstanceMeta{ + Connected: healthy, + HasDecryptionError: hasDecryptionError, + } + + // Fetch recent errors for disconnected instances + if instance.IsActive && !healthy { + errorStore := m.clientPool.GetErrorStore() + if errorStore != nil { + recentErrors, err := errorStore.GetRecentErrors(ctx, instanceID, 5) + if err != nil { + log.Debug().Err(err).Int("instanceID", instanceID).Msg("Failed to fetch recent errors for instance meta") + } else if len(recentErrors) > 0 { + meta.RecentErrors = make([]qbittorrent.InstanceError, 0, len(recentErrors)) + for _, e := range recentErrors { + meta.RecentErrors = append(meta.RecentErrors, qbittorrent.InstanceError{ + ID: e.ID, + InstanceID: e.InstanceID, + ErrorType: e.ErrorType, + ErrorMessage: e.ErrorMessage, + OccurredAt: e.OccurredAt.Format(time.RFC3339), + }) + } + } + } + } + + return meta +} + +func (m *StreamManager) getGroup(key string) *subscriptionGroup { + if key == "" { + return nil + } + + m.mu.RLock() + group := m.groups[key] + m.mu.RUnlock() + return group +} + +func (g *subscriptionGroup) snapshotSubscribers() []*subscriptionState { + g.subsMu.RLock() + defer g.subsMu.RUnlock() + + result := make([]*subscriptionState, 0, len(g.subs)) + for _, sub := range g.subs { + result = append(result, sub) + } + return result +} + +func (m *StreamManager) publishToInstance(instanceID int, payload *StreamPayload) { + if payload == nil || m.closing.Load() { + return + } + + m.mu.RLock() + subscribers := m.instanceIndex[instanceID] + if len(subscribers) == 0 { + m.mu.RUnlock() + return + } + + ids := make([]string, 0, len(subscribers)) + messages := make(map[string]*StreamPayload, len(subscribers)) + for id, sub := range subscribers { + ids = append(ids, id) + messages[id] = clonePayloadForSubscriber(payload, sub) + } + m.mu.RUnlock() + + for _, id := range ids { + m.publish(id, messages[id]) + } +} + +func (m *StreamManager) publish(id string, payload *StreamPayload) { + if payload == nil { + return + } + + message := &sse.Message{ + Type: sse.Type(payload.Type), + } + + encoded, err := json.Marshal(payload) + if err != nil { + log.Error().Err(err).Str("subscriptionID", id).Msg("Failed to marshal SSE payload") + + // Send error event to client so they know something went wrong + errorPayload := &StreamPayload{ + Type: streamEventError, + Meta: &StreamMeta{ + Timestamp: time.Now(), + }, + Err: "Internal error: failed to serialize update", + } + if payload.Meta != nil { + errorPayload.Meta.InstanceID = payload.Meta.InstanceID + errorPayload.Meta.StreamKey = payload.Meta.StreamKey + } + + if errorBytes, marshalErr := json.Marshal(errorPayload); marshalErr == nil { + errMsg := &sse.Message{Type: sse.Type(streamEventError)} + errMsg.AppendData(string(errorBytes)) + if pubErr := m.server.Publish(errMsg, id); pubErr != nil && !errors.Is(pubErr, sse.ErrProviderClosed) { + log.Error().Err(pubErr).Str("subscriptionID", id).Msg("Failed to publish error event after marshal failure") + } + } + return + } + + message.AppendData(string(encoded)) + + if err := m.server.Publish(message, id); err != nil && !errors.Is(err, sse.ErrProviderClosed) { + log.Error().Err(err).Str("subscriptionID", id).Msg("Failed to publish SSE message") + } +} + +func (m *StreamManager) getSubscription(id string) *subscriptionState { + m.mu.RLock() + defer m.mu.RUnlock() + return m.subscriptions[id] +} + +func cloneMeta(meta *StreamMeta) *StreamMeta { + if meta == nil { + return nil + } + clone := *meta + return &clone +} + +func clonePayloadForSubscriber(payload *StreamPayload, sub *subscriptionState) *StreamPayload { + if payload == nil { + return nil + } + + clone := *payload + if payload.Meta != nil { + metaCopy := *payload.Meta + if metaCopy.InstanceID == 0 { + metaCopy.InstanceID = sub.options.InstanceID + } + metaCopy.StreamKey = sub.clientKey + clone.Meta = &metaCopy + } else if sub != nil { + clone.Meta = &StreamMeta{ + InstanceID: sub.options.InstanceID, + StreamKey: sub.clientKey, + Timestamp: time.Now(), + } + } + + return &clone +} + +func (m *StreamManager) Shutdown(ctx context.Context) error { + if m == nil { + return nil + } + + if !m.closing.CompareAndSwap(false, true) { + return nil + } + + m.cancel() + + m.mu.Lock() + loops := make([]*syncLoopState, 0, len(m.syncLoops)) + for _, loop := range m.syncLoops { + loops = append(loops, loop) + } + heartbeatLoops := make([]*heartbeatLoopState, 0, len(m.heartbeatLoops)) + for _, loop := range m.heartbeatLoops { + heartbeatLoops = append(heartbeatLoops, loop) + } + m.syncLoops = make(map[int]*syncLoopState) + m.heartbeatLoops = make(map[int]*heartbeatLoopState) + m.syncBackoff = make(map[int]*backoffState) + m.mu.Unlock() + + for _, loop := range loops { + if loop != nil && loop.cancel != nil { + loop.cancel() + } + } + for _, loop := range heartbeatLoops { + if loop != nil && loop.cancel != nil { + loop.cancel() + } + } + + if ctx == nil { + ctx = context.Background() + } + + if err := m.server.Shutdown(ctx); err != nil && + !errors.Is(err, sse.ErrProviderClosed) && + !errors.Is(err, context.Canceled) && + !errors.Is(err, context.DeadlineExceeded) { + return err + } + + return nil +} + +func (m *StreamManager) markSyncFailure(instanceID int) time.Duration { + m.mu.Lock() + defer m.mu.Unlock() + + state := m.ensureBackoffStateLocked(instanceID) + state.attempt++ + + exponent := state.attempt + exponent = min(exponent, 4) + interval := defaultSyncInterval * time.Duration(1< maxLimit { + return StreamOptions{}, errors.New("invalid limit value") + } + + page := p.Page + if page < 0 { + return StreamOptions{}, errors.New("invalid page value") + } + + sort := p.Sort + if sort == "" { + sort = "added_on" + } + + order := strings.ToLower(p.Order) + if order != "asc" && order != "desc" { + order = "desc" + } + + var filters qbittorrent.FilterOptions + if p.Filters != nil { + filters = *p.Filters + } + + return StreamOptions{ + InstanceID: p.InstanceID, + Page: page, + Limit: limit, + Sort: sort, + Order: order, + Search: p.Search, + Filters: filters, + }, nil +} diff --git a/internal/api/sse/manager_test.go b/internal/api/sse/manager_test.go new file mode 100644 index 000000000..e1cd5c79a --- /dev/null +++ b/internal/api/sse/manager_test.go @@ -0,0 +1,865 @@ +// Copyright (c) 2025, s0up and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package sse + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/tmaxmax/go-sse" + + "github.com/autobrr/qui/internal/database" + "github.com/autobrr/qui/internal/models" +) + +func TestStreamManagerHandleSyncErrorPublishesErrorEvent(t *testing.T) { + manager := NewStreamManager(nil, nil, nil) + provider := newRecordingProvider() + manager.server.Provider = provider + + sub := &subscriptionState{ + id: "subscription-1", + options: StreamOptions{InstanceID: 42}, + created: time.Now(), + } + + manager.subscriptions[sub.id] = sub + manager.instanceIndex[sub.options.InstanceID] = map[string]*subscriptionState{ + sub.id: sub, + } + + manager.HandleSyncError(sub.options.InstanceID, errors.New("sync failed")) + + messages := provider.messagesFor(sub.id) + require.Len(t, messages, 1, "expected a single broadcast message") + + payload := decodeStreamPayload(t, messages[0]) + require.Equal(t, streamEventError, payload.Type) + require.Equal(t, sub.options.InstanceID, payload.Meta.InstanceID) + require.Positive(t, payload.Meta.RetryInSeconds, "expected retry interval to be populated") + require.Contains(t, payload.Err, "sync failed") +} + +func TestStreamManagerHandleSyncErrorWithoutSubscribers(t *testing.T) { + manager := NewStreamManager(nil, nil, nil) + provider := newRecordingProvider() + manager.server.Provider = provider + + manager.HandleSyncError(7, errors.New("boom")) + + require.Empty(t, provider.allMessages(), "no subscribers should result in no messages") +} + +func TestStreamManagerHeartbeatPublishesEvent(t *testing.T) { + manager := NewStreamManager(nil, nil, nil) + provider := newRecordingProvider() + manager.server.Provider = provider + + sub := &subscriptionState{ + id: "subscription-keepalive", + options: StreamOptions{InstanceID: 21}, + groupKey: "group-keepalive", + clientKey: "client-keepalive", + } + + manager.subscriptions[sub.id] = sub + manager.instanceIndex[sub.options.InstanceID] = map[string]*subscriptionState{ + sub.id: sub, + } + + manager.publishHeartbeat(sub.options.InstanceID) + + messages := provider.messagesFor(sub.id) + require.Len(t, messages, 1, "expected heartbeat payload to be published") + + payload := decodeStreamPayload(t, messages[0]) + require.Equal(t, streamEventHeartbeat, payload.Type) + require.Equal(t, sub.options.InstanceID, payload.Meta.InstanceID) + require.Equal(t, sub.clientKey, payload.Meta.StreamKey) + require.False(t, payload.Meta.Timestamp.IsZero(), "heartbeat should include timestamp") +} + +func TestStreamManagerServeInstanceNotFound(t *testing.T) { + store, cleanup := newTestInstanceStore(t) + defer cleanup() + + manager := NewStreamManager(nil, nil, store) + + payload := []map[string]any{ + { + "key": "stream-99", + "instanceId": 99, + "page": 0, + "limit": 50, + "sort": "added_on", + "order": "desc", + "search": "", + "filters": nil, + }, + } + raw, err := json.Marshal(payload) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/stream?streams="+url.QueryEscape(string(raw)), nil) + + manager.Serve(recorder, request) + require.Equal(t, http.StatusNotFound, recorder.Code) +} + +func TestStreamManagerServeInstanceValidationError(t *testing.T) { + store, cleanup := newTestInstanceStore(t) + defer cleanup() + + ctx := context.Background() + _, err := store.Create(ctx, "Test Instance", "http://localhost:8080", "user", "password", nil, nil, false, nil) + require.NoError(t, err, "failed to seed instance") + + manager := NewStreamManager(nil, nil, store) + + payload := []map[string]any{ + { + "key": "invalid-limit", + "instanceId": 1, + "page": -1, + "limit": 50, + "sort": "added_on", + "order": "desc", + "search": "", + "filters": nil, + }, + } + raw, err := json.Marshal(payload) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/stream?streams="+url.QueryEscape(string(raw)), nil) + + manager.Serve(recorder, request) + require.Equal(t, http.StatusBadRequest, recorder.Code) +} + +func TestStreamManagerServeMissingInstanceStore(t *testing.T) { + manager := NewStreamManager(nil, nil, nil) + + payload := []map[string]any{ + { + "key": "stream-1", + "instanceId": 1, + "page": 0, + "limit": 50, + "sort": "added_on", + "order": "desc", + }, + } + raw, err := json.Marshal(payload) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/stream?streams="+url.QueryEscape(string(raw)), nil) + + require.NotPanics(t, func() { + manager.Serve(recorder, request) + }) + require.Equal(t, http.StatusInternalServerError, recorder.Code) +} + +// recordingProvider is a minimal sse.Provider that captures published messages for assertions. +type recordingProvider struct { + mu sync.Mutex + messages map[string][]*sse.Message +} + +func newRecordingProvider() *recordingProvider { + return &recordingProvider{ + messages: make(map[string][]*sse.Message), + } +} + +func (p *recordingProvider) Subscribe(_ context.Context, _ sse.Subscription) error { + return nil +} + +func (p *recordingProvider) Publish(message *sse.Message, topics []string) error { + p.mu.Lock() + defer p.mu.Unlock() + + for _, topic := range topics { + p.messages[topic] = append(p.messages[topic], message) + } + return nil +} + +func (p *recordingProvider) Shutdown(context.Context) error { + return nil +} + +func (p *recordingProvider) messagesFor(topic string) []*sse.Message { + p.mu.Lock() + defer p.mu.Unlock() + + return append([]*sse.Message(nil), p.messages[topic]...) +} + +func (p *recordingProvider) allMessages() []*sse.Message { + p.mu.Lock() + defer p.mu.Unlock() + + total := 0 + for _, msgs := range p.messages { + total += len(msgs) + } + + result := make([]*sse.Message, 0, total) + for _, msgs := range p.messages { + result = append(result, msgs...) + } + return result +} + +func decodeStreamPayload(t *testing.T, message *sse.Message) *StreamPayload { + t.Helper() + + raw, err := message.MarshalText() + require.NoError(t, err, "failed to marshal SSE message") + + lines := strings.Split(strings.TrimSpace(string(raw)), "\n") + var builder strings.Builder + for _, line := range lines { + if strings.HasPrefix(line, "data: ") { + if builder.Len() > 0 { + builder.WriteByte('\n') + } + builder.WriteString(strings.TrimPrefix(line, "data: ")) + } + } + + var payload StreamPayload + err = json.Unmarshal([]byte(builder.String()), &payload) + require.NoError(t, err, "failed to decode stream payload") + return &payload +} + +func newTestInstanceStore(t *testing.T) (*models.InstanceStore, func()) { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "sse-manager-test.db") + db, err := database.New(dbPath) + require.NoError(t, err, "failed to create test database") + + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + + store, err := models.NewInstanceStore(db, key) + require.NoError(t, err, "failed to create instance store") + + return store, func() { + _ = db.Close() + } +} + +func TestMarkSyncFailure_ExponentialBackoff(t *testing.T) { + manager := NewStreamManager(nil, nil, nil) + + // First failure: 2s * 2^1 = 4s + interval1 := manager.markSyncFailure(1) + require.Equal(t, 4*time.Second, interval1, "first failure should yield 4s interval") + + // Second failure: 2s * 2^2 = 8s + interval2 := manager.markSyncFailure(1) + require.Equal(t, 8*time.Second, interval2, "second failure should yield 8s interval") + + // Third failure: 2s * 2^3 = 16s + interval3 := manager.markSyncFailure(1) + require.Equal(t, 16*time.Second, interval3, "third failure should yield 16s interval") + + // Fourth failure: 2s * 2^4 = 32s -> capped to 30s + interval4 := manager.markSyncFailure(1) + require.Equal(t, 30*time.Second, interval4, "fourth failure should yield 30s (capped)") + + // Fifth failure: still capped at 30s + interval5 := manager.markSyncFailure(1) + require.Equal(t, 30*time.Second, interval5, "fifth failure should still be 30s (capped)") + + // Verify internal state + state := manager.syncBackoff[1] + require.Equal(t, 5, state.attempt, "attempt counter should be 5") + require.Equal(t, 30*time.Second, state.interval, "interval should be maxSyncInterval") +} + +func TestMarkSyncSuccess_ResetsBackoff(t *testing.T) { + manager := NewStreamManager(nil, nil, nil) + + // Simulate some failures first + manager.markSyncFailure(1) + manager.markSyncFailure(1) + + // Verify backoff state exists with failures + state := manager.syncBackoff[1] + require.Equal(t, 2, state.attempt, "should have 2 failures recorded") + require.Equal(t, 8*time.Second, state.interval, "interval should be 8s after 2 failures") + + // Success should reset + manager.markSyncSuccess(1) + + // Verify state was reset + state = manager.syncBackoff[1] + require.Equal(t, 0, state.attempt, "attempt should be reset to 0") + require.Equal(t, defaultSyncInterval, state.interval, "interval should be reset to default") +} + +func TestMarkSyncSuccess_NoOpWithoutPriorState(t *testing.T) { + manager := NewStreamManager(nil, nil, nil) + + // Calling success without prior failure should be a no-op + manager.markSyncSuccess(99) + + // Backoff state should not exist for this instance + _, exists := manager.syncBackoff[99] + require.False(t, exists, "backoff state should not be created by markSyncSuccess") +} + +func TestBackoffState_IndependentPerInstance(t *testing.T) { + manager := NewStreamManager(nil, nil, nil) + + // Instance 1: 2 failures + manager.markSyncFailure(1) + manager.markSyncFailure(1) + + // Instance 2: 1 failure + manager.markSyncFailure(2) + + // Verify independent state + state1 := manager.syncBackoff[1] + state2 := manager.syncBackoff[2] + + require.Equal(t, 2, state1.attempt, "instance 1 should have 2 failures") + require.Equal(t, 8*time.Second, state1.interval, "instance 1 should have 8s interval") + + require.Equal(t, 1, state2.attempt, "instance 2 should have 1 failure") + require.Equal(t, 4*time.Second, state2.interval, "instance 2 should have 4s interval") + + // Reset instance 1, verify instance 2 is unaffected + manager.markSyncSuccess(1) + + state1 = manager.syncBackoff[1] + state2 = manager.syncBackoff[2] + + require.Equal(t, 0, state1.attempt, "instance 1 should be reset") + require.Equal(t, 1, state2.attempt, "instance 2 should still have 1 failure") +} + +func TestStreamManager_ConcurrentSubscribeUnsubscribe(t *testing.T) { + manager := NewStreamManager(nil, nil, nil) + provider := newRecordingProvider() + manager.server.Provider = provider + + const numGoroutines = 50 + const numIterations = 100 + + var wg sync.WaitGroup + wg.Add(numGoroutines) + + // Run concurrent subscribe/unsubscribe operations + for i := range numGoroutines { + go func(workerID int) { + defer wg.Done() + + for j := range numIterations { + instanceID := (workerID % 5) + 1 // Use 5 different instances + subID := "sub-" + string(rune('A'+workerID)) + "-" + string(rune('0'+j%10)) + + // Create subscription + sub := &subscriptionState{ + id: subID, + options: StreamOptions{InstanceID: instanceID, Page: 0, Limit: 50}, + created: time.Now(), + groupKey: "group-" + subID, + clientKey: "client-" + subID, + } + + // Register subscription + manager.mu.Lock() + manager.subscriptions[sub.id] = sub + if manager.instanceIndex[instanceID] == nil { + manager.instanceIndex[instanceID] = make(map[string]*subscriptionState) + } + manager.instanceIndex[instanceID][sub.id] = sub + manager.mu.Unlock() + + // Immediately unregister + manager.Unregister(sub.id) + } + }(i) + } + + wg.Wait() + + // Verify manager is in a consistent state + manager.mu.RLock() + subCount := len(manager.subscriptions) + manager.mu.RUnlock() + + require.Equal(t, 0, subCount, "all subscriptions should be unregistered") +} + +func TestStreamManager_ShutdownDuringActiveOperations(t *testing.T) { + manager := NewStreamManager(nil, nil, nil) + provider := newRecordingProvider() + manager.server.Provider = provider + + // Create several active subscriptions + for i := 1; i <= 3; i++ { + sub := &subscriptionState{ + id: "sub-" + string(rune('0'+i)), + options: StreamOptions{InstanceID: i, Page: 0, Limit: 50}, + created: time.Now(), + groupKey: "group-" + string(rune('0'+i)), + clientKey: "client-" + string(rune('0'+i)), + } + + manager.mu.Lock() + manager.subscriptions[sub.id] = sub + if manager.instanceIndex[i] == nil { + manager.instanceIndex[i] = make(map[string]*subscriptionState) + } + manager.instanceIndex[i][sub.id] = sub + manager.mu.Unlock() + } + + // Start concurrent operations + var wg sync.WaitGroup + wg.Add(2) + + // Goroutine 1: Publishing events + go func() { + defer wg.Done() + for range 100 { + if manager.closing.Load() { + return + } + manager.HandleSyncError(1, errors.New("test error")) + } + }() + + // Goroutine 2: Shutdown after brief delay + go func() { + defer wg.Done() + time.Sleep(10 * time.Millisecond) + _ = manager.Shutdown(context.Background()) + }() + + wg.Wait() + + // Verify shutdown completed + require.True(t, manager.closing.Load(), "manager should be marked as closing") +} + +func TestStreamManager_ProcessGroupCoalescing(t *testing.T) { + // Test the coalescing behavior of enqueueGroup + // Multiple rapid enqueues should coalesce into pending state + + group := &subscriptionGroup{ + key: "test-group", + options: StreamOptions{InstanceID: 1, Page: 0, Limit: 50}, + subs: make(map[string]*subscriptionState), + } + + // Simulate rapid enqueues without starting the processGroup goroutine + // by directly testing the pending state coalescing + + // First enqueue sets hasPending and sends + group.mu.Lock() + group.pendingMeta = &StreamMeta{InstanceID: 1, Timestamp: time.Now()} + group.pendingType = streamEventUpdate + group.hasPending = true + group.sending = true // Simulate that processGroup is already running + group.mu.Unlock() + + // Second enqueue should just update pending state, not spawn new goroutine + newMeta := &StreamMeta{InstanceID: 1, Timestamp: time.Now().Add(time.Second)} + group.mu.Lock() + group.pendingMeta = newMeta + group.pendingType = streamEventUpdate + group.hasPending = true + // sending stays true - no new goroutine needed + group.mu.Unlock() + + // Third enqueue - same behavior + finalMeta := &StreamMeta{InstanceID: 1, Timestamp: time.Now().Add(2 * time.Second)} + group.mu.Lock() + group.pendingMeta = finalMeta + group.pendingType = streamEventUpdate + group.hasPending = true + group.mu.Unlock() + + // Verify the coalescing - only the final meta should be present + group.mu.Lock() + require.True(t, group.hasPending, "should have pending update") + require.True(t, group.sending, "should still be marked as sending") + require.Equal(t, finalMeta, group.pendingMeta, "should have coalesced to final meta") + group.mu.Unlock() +} + +func TestUnregister_MultipleSubscribersInSameGroup(t *testing.T) { + manager := NewStreamManager(nil, nil, nil) + provider := newRecordingProvider() + manager.server.Provider = provider + + // Create two subscriptions with identical StreamOptions (same group) + opts := StreamOptions{InstanceID: 1, Page: 0, Limit: 50, Sort: "added_on", Order: "desc"} + groupKey := streamOptionsKey(opts) + + sub1 := &subscriptionState{ + id: "sub-1", + options: opts, + created: time.Now(), + groupKey: groupKey, + clientKey: "client-1", + } + sub2 := &subscriptionState{ + id: "sub-2", + options: opts, + created: time.Now(), + groupKey: groupKey, + clientKey: "client-2", + } + + // Create group with both subscribers + group := &subscriptionGroup{ + key: groupKey, + options: opts, + subs: make(map[string]*subscriptionState), + } + group.subs[sub1.id] = sub1 + group.subs[sub2.id] = sub2 + + // Register both subscriptions + manager.mu.Lock() + manager.subscriptions[sub1.id] = sub1 + manager.subscriptions[sub2.id] = sub2 + manager.instanceIndex[opts.InstanceID] = map[string]*subscriptionState{ + sub1.id: sub1, + sub2.id: sub2, + } + manager.groups[groupKey] = group + manager.instanceGroups[opts.InstanceID] = map[string]*subscriptionGroup{ + groupKey: group, + } + manager.mu.Unlock() + + // Unregister sub1 + manager.Unregister(sub1.id) + + // Verify sub1 is removed but sub2 remains + manager.mu.RLock() + _, sub1Exists := manager.subscriptions[sub1.id] + _, sub2Exists := manager.subscriptions[sub2.id] + groupStillExists := manager.groups[groupKey] != nil + instanceIndexExists := manager.instanceIndex[opts.InstanceID] != nil + instanceGroupsExist := manager.instanceGroups[opts.InstanceID] != nil + manager.mu.RUnlock() + + require.False(t, sub1Exists, "sub1 should be removed from subscriptions") + require.True(t, sub2Exists, "sub2 should still exist in subscriptions") + require.True(t, groupStillExists, "group should still exist with remaining subscriber") + require.True(t, instanceIndexExists, "instance index should still exist") + require.True(t, instanceGroupsExist, "instance groups should still exist") + + // Verify group still has sub2 + group.subsMu.RLock() + _, sub1InGroup := group.subs[sub1.id] + _, sub2InGroup := group.subs[sub2.id] + groupSubCount := len(group.subs) + group.subsMu.RUnlock() + + require.False(t, sub1InGroup, "sub1 should be removed from group") + require.True(t, sub2InGroup, "sub2 should still be in group") + require.Equal(t, 1, groupSubCount, "group should have exactly 1 subscriber") + + // Unregister sub2 - now everything should be cleaned up + manager.Unregister(sub2.id) + + manager.mu.RLock() + _, sub2StillExists := manager.subscriptions[sub2.id] + groupGone := manager.groups[groupKey] == nil + instanceIndexGone := manager.instanceIndex[opts.InstanceID] == nil + instanceGroupsGone := manager.instanceGroups[opts.InstanceID] == nil + manager.mu.RUnlock() + + require.False(t, sub2StillExists, "sub2 should be removed") + require.True(t, groupGone, "group should be cleaned up when empty") + require.True(t, instanceIndexGone, "instance index should be cleaned up") + require.True(t, instanceGroupsGone, "instance groups should be cleaned up") +} + +func TestHandleMainData_NilData(t *testing.T) { + manager := NewStreamManager(nil, nil, nil) + provider := newRecordingProvider() + manager.server.Provider = provider + + sub := &subscriptionState{ + id: "sub-nil-test", + options: StreamOptions{InstanceID: 1}, + created: time.Now(), + groupKey: "group-nil-test", + clientKey: "client-nil-test", + } + + manager.mu.Lock() + manager.subscriptions[sub.id] = sub + manager.instanceIndex[sub.options.InstanceID] = map[string]*subscriptionState{ + sub.id: sub, + } + manager.mu.Unlock() + + // Call with nil data - should return early without publishing + manager.HandleMainData(sub.options.InstanceID, nil) + + // Give a brief moment for any async operations + time.Sleep(10 * time.Millisecond) + + messages := provider.allMessages() + require.Empty(t, messages, "nil data should not produce any messages") +} + +func TestHandleSyncError_NilError(t *testing.T) { + manager := NewStreamManager(nil, nil, nil) + provider := newRecordingProvider() + manager.server.Provider = provider + + sub := &subscriptionState{ + id: "sub-nil-err", + options: StreamOptions{InstanceID: 1}, + created: time.Now(), + groupKey: "group-nil-err", + clientKey: "client-nil-err", + } + + manager.mu.Lock() + manager.subscriptions[sub.id] = sub + manager.instanceIndex[sub.options.InstanceID] = map[string]*subscriptionState{ + sub.id: sub, + } + manager.mu.Unlock() + + // Call with nil error - should return early without publishing + manager.HandleSyncError(sub.options.InstanceID, nil) + + messages := provider.allMessages() + require.Empty(t, messages, "nil error should not produce any messages") +} + +func TestParseStreamRequests_EmptyStreamsParam(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/stream", nil) + _, err := parseStreamRequests(req) + require.Error(t, err) + require.Contains(t, err.Error(), "missing streams parameter") +} + +func TestParseStreamRequests_MalformedJSON(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/stream?streams=not-json", nil) + _, err := parseStreamRequests(req) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid streams payload") +} + +func TestParseStreamRequests_EmptyArray(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/stream?streams=[]", nil) + _, err := parseStreamRequests(req) + require.Error(t, err) + require.ErrorIs(t, err, errNoStreamRequests) +} + +func TestParseStreamRequests_InvalidInstanceID(t *testing.T) { + tests := []struct { + name string + instanceID int + }{ + {"zero", 0}, + {"negative", -1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payload := []map[string]any{ + { + "key": "test-stream", + "instanceId": tt.instanceID, + "page": 0, + "limit": 50, + "sort": "added_on", + "order": "desc", + }, + } + raw, err := json.Marshal(payload) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/api/stream?streams="+url.QueryEscape(string(raw)), nil) + _, err = parseStreamRequests(req) + require.Error(t, err) + require.ErrorIs(t, err, errInvalidInstanceID) + }) + } +} + +func TestParseStreamRequests_LimitExceedsMax(t *testing.T) { + payload := []map[string]any{ + { + "key": "test-stream", + "instanceId": 1, + "page": 0, + "limit": 3000, // exceeds maxLimit of 2000 + "sort": "added_on", + "order": "desc", + }, + } + raw, err := json.Marshal(payload) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/api/stream?streams="+url.QueryEscape(string(raw)), nil) + _, err = parseStreamRequests(req) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid limit") +} + +func TestParseStreamRequests_NegativePage(t *testing.T) { + payload := []map[string]any{ + { + "key": "test-stream", + "instanceId": 1, + "page": -1, + "limit": 50, + "sort": "added_on", + "order": "desc", + }, + } + raw, err := json.Marshal(payload) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/api/stream?streams="+url.QueryEscape(string(raw)), nil) + _, err = parseStreamRequests(req) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid page") +} + +func TestParseStreamRequests_DefaultsApplied(t *testing.T) { + // Request with minimal fields - defaults should be applied + payload := []map[string]any{ + { + "instanceId": 1, + // page, limit, sort, order all omitted + }, + } + raw, err := json.Marshal(payload) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/api/stream?streams="+url.QueryEscape(string(raw)), nil) + requests, err := parseStreamRequests(req) + require.NoError(t, err) + require.Len(t, requests, 1) + + opts := requests[0].options + require.Equal(t, 1, opts.InstanceID) + require.Equal(t, 0, opts.Page, "page should default to 0") + require.Equal(t, defaultLimit, opts.Limit, "limit should default to 300") + require.Equal(t, "added_on", opts.Sort, "sort should default to added_on") + require.Equal(t, "desc", opts.Order, "order should default to desc") +} + +func TestParseStreamRequests_OrderNormalization(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"ASC", "asc"}, + {"DESC", "desc"}, + {"Asc", "asc"}, + {"invalid", "desc"}, // invalid values should default to desc + {"", "desc"}, // empty should default to desc + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + payload := []map[string]any{ + { + "instanceId": 1, + "order": tt.input, + }, + } + raw, err := json.Marshal(payload) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/api/stream?streams="+url.QueryEscape(string(raw)), nil) + requests, err := parseStreamRequests(req) + require.NoError(t, err) + require.Len(t, requests, 1) + require.Equal(t, tt.expected, requests[0].options.Order) + }) + } +} + +func TestRegisterSubscription_DuringShutdown(t *testing.T) { + manager := NewStreamManager(nil, nil, nil) + + // Start shutdown + err := manager.Shutdown(context.Background()) + require.NoError(t, err) + + // Attempt to register after shutdown + _, err = manager.registerSubscription(StreamOptions{InstanceID: 1, Limit: 50}, "test-key") + require.Error(t, err) + require.Contains(t, err.Error(), "shutting down") +} + +func TestPrepareBatch_DuringShutdown(t *testing.T) { + manager := NewStreamManager(nil, nil, nil) + + // Start shutdown + err := manager.Shutdown(context.Background()) + require.NoError(t, err) + + // Attempt to prepare batch after shutdown + requests := []streamRequest{ + {key: "test", options: StreamOptions{InstanceID: 1, Limit: 50}}, + } + _, _, err = manager.PrepareBatch(context.Background(), requests) + require.Error(t, err) + require.Contains(t, err.Error(), "shutting down") +} + +func TestShutdown_WithNilContext(t *testing.T) { + manager := NewStreamManager(nil, nil, nil) + + // Shutdown with nil context should not panic + var shutdownCtx context.Context + err := manager.Shutdown(shutdownCtx) + require.NoError(t, err) + require.True(t, manager.closing.Load()) +} + +func TestShutdown_Idempotent(t *testing.T) { + manager := NewStreamManager(nil, nil, nil) + + // First shutdown + err := manager.Shutdown(context.Background()) + require.NoError(t, err) + + // Second shutdown should be a no-op (idempotent) + err = manager.Shutdown(context.Background()) + require.NoError(t, err) + require.True(t, manager.closing.Load()) +} diff --git a/internal/qbittorrent/app_info.go b/internal/qbittorrent/app_info.go new file mode 100644 index 000000000..fd9bf97bf --- /dev/null +++ b/internal/qbittorrent/app_info.go @@ -0,0 +1,124 @@ +// Copyright (c) 2025, s0up and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package qbittorrent + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/rs/zerolog/log" +) + +const appInfoCacheTTL = 5 * time.Minute +const appInfoRequestTimeout = 10 * time.Second + +// AppBuildInfo represents the qBittorrent build information reported by the API. +type AppBuildInfo struct { + Qt string `json:"qt"` + Libtorrent string `json:"libtorrent"` + Boost string `json:"boost"` + OpenSSL string `json:"openssl"` + Zlib string `json:"zlib"` + Bitness int `json:"bitness"` + Platform string `json:"platform,omitempty"` +} + +// AppInfo captures the qBittorrent application metadata exposed via the API. +type AppInfo struct { + Version string `json:"version"` + WebAPIVersion string `json:"webAPIVersion,omitempty"` + BuildInfo *AppBuildInfo `json:"buildInfo,omitempty"` +} + +func cloneAppInfo(info *AppInfo) *AppInfo { + if info == nil { + return nil + } + + clone := *info + if info.BuildInfo != nil { + buildClone := *info.BuildInfo + clone.BuildInfo = &buildClone + } + return &clone +} + +// GetAppInfo returns cached qBittorrent app information, refreshing it when stale. +func (c *Client) GetAppInfo(ctx context.Context) (*AppInfo, error) { + if ctx == nil { + ctx = context.Background() + } + + c.appInfoMu.RLock() + if c.appInfoCache != nil && time.Since(c.appInfoFetchedAt) < appInfoCacheTTL { + cached := cloneAppInfo(c.appInfoCache) + c.appInfoMu.RUnlock() + return cached, nil + } + c.appInfoMu.RUnlock() + + return c.refreshAppInfo(ctx) +} + +func (c *Client) refreshAppInfo(ctx context.Context) (*AppInfo, error) { + requestCtx, cancel := context.WithTimeout(ctx, appInfoRequestTimeout) + defer cancel() + + version, err := c.GetAppVersionCtx(requestCtx) + if err != nil { + return nil, fmt.Errorf("get app version: %w", err) + } + + webAPIVersion, err := c.GetWebAPIVersionCtx(requestCtx) + if err != nil { + return nil, fmt.Errorf("get web API version: %w", err) + } + + webAPIVersion = strings.TrimSpace(webAPIVersion) + if webAPIVersion == "" { + return nil, errors.New("web API version is empty") + } + + buildInfo, err := c.GetBuildInfoCtx(requestCtx) + if err != nil { + return nil, fmt.Errorf("get build info: %w", err) + } + + info := &AppInfo{ + Version: strings.TrimSpace(version), + WebAPIVersion: webAPIVersion, + BuildInfo: &AppBuildInfo{ + Qt: buildInfo.Qt, + Libtorrent: buildInfo.Libtorrent, + Boost: buildInfo.Boost, + OpenSSL: buildInfo.Openssl, + Zlib: buildInfo.Zlib, + Bitness: buildInfo.Bitness, + Platform: buildInfo.Platform, + }, + } + + c.mu.Lock() + previousVersion := c.webAPIVersion + c.applyCapabilitiesLocked(webAPIVersion) + c.mu.Unlock() + + if previousVersion != webAPIVersion { + log.Trace(). + Int("instanceID", c.instanceID). + Str("previousWebAPIVersion", previousVersion). + Str("webAPIVersion", webAPIVersion). + Msg("Updated qBittorrent capabilities from app info refresh") + } + + c.appInfoMu.Lock() + c.appInfoCache = info + c.appInfoFetchedAt = time.Now() + c.appInfoMu.Unlock() + + return cloneAppInfo(info), nil +} diff --git a/internal/qbittorrent/client.go b/internal/qbittorrent/client.go index 23597d73f..54878b961 100644 --- a/internal/qbittorrent/client.go +++ b/internal/qbittorrent/client.go @@ -54,16 +54,23 @@ type Client struct { syncManager *qbt.SyncManager peerSyncManager map[string]*qbt.PeerSyncManager // Map of torrent hash to PeerSyncManager // optimisticUpdates stores temporary optimistic state changes for this instance - optimisticUpdates *ttlcache.Cache[string, *OptimisticTorrentUpdate] - trackerExclusions map[string]map[string]struct{} // Domains to hide hashes from until fresh sync arrives - lastServerState *qbt.ServerState - mu sync.RWMutex - serverStateMu sync.RWMutex - healthMu sync.RWMutex - completionMu sync.Mutex - completionState map[string]bool - completionHandler TorrentCompletionHandler - completionInit bool + optimisticUpdates *ttlcache.Cache[string, *OptimisticTorrentUpdate] + trackerExclusions map[string]map[string]struct{} // Domains to hide hashes from until fresh sync arrives + lastServerState *qbt.ServerState + appInfoCache *AppInfo + appInfoFetchedAt time.Time + mu sync.RWMutex + serverStateMu sync.RWMutex + healthMu sync.RWMutex + appInfoMu sync.RWMutex + preferencesCache *qbt.AppPreferences + preferencesFetchedAt time.Time + preferencesMu sync.RWMutex + syncEventSink SyncEventSink + completionMu sync.Mutex + completionState map[string]bool + completionHandler TorrentCompletionHandler + completionInit bool addedMu sync.Mutex addedState map[string]struct{} addedHandler TorrentAddedHandler @@ -135,12 +142,16 @@ func NewClientWithTimeout(instanceID int, instanceHost, username, password strin client.handleCompletionUpdates(data) client.handleAddedUpdates(data) log.Trace().Int("instanceID", instanceID).Int("torrentCount", len(data.Torrents)).Msg("Sync manager update received, marking client as healthy") + + client.dispatchMainData(data) } syncOpts.OnError = func(err error) { client.updateHealthStatus(false) client.clearServerState() log.Warn().Err(err).Int("instanceID", instanceID).Msg("Sync manager error received, marking client as unhealthy") + + client.dispatchSyncError(err) } client.syncManager = qbtClient.NewSyncManager(syncOpts) @@ -217,6 +228,13 @@ func (c *Client) SupportsTrackerEditing() bool { return c.supportsTrackerEditing } +// SetSyncEventSink registers the sink that should receive sync notifications. +func (c *Client) SetSyncEventSink(sink SyncEventSink) { + c.mu.Lock() + c.syncEventSink = sink + c.mu.Unlock() +} + func (c *Client) SupportsTorrentExport() bool { c.mu.RLock() defer c.mu.RUnlock() @@ -490,6 +508,32 @@ func (c *Client) invalidateTrackerCache(hashes ...string) { } } +func (c *Client) getSyncEventSink() SyncEventSink { + c.mu.RLock() + defer c.mu.RUnlock() + return c.syncEventSink +} + +func (c *Client) dispatchMainData(data *qbt.MainData) { + if data == nil { + return + } + + if sink := c.getSyncEventSink(); sink != nil { + sink.HandleMainData(c.instanceID, data) + } +} + +func (c *Client) dispatchSyncError(err error) { + if err == nil { + return + } + + if sink := c.getSyncEventSink(); sink != nil { + sink.HandleSyncError(c.instanceID, err) + } +} + // SetTorrentCompletionHandler registers a callback to be invoked when torrents finish downloading. func (c *Client) SetTorrentCompletionHandler(handler TorrentCompletionHandler) { c.completionMu.Lock() diff --git a/internal/qbittorrent/client_test.go b/internal/qbittorrent/client_test.go index 3e35b5649..1c417ae2e 100644 --- a/internal/qbittorrent/client_test.go +++ b/internal/qbittorrent/client_test.go @@ -4,12 +4,55 @@ package qbittorrent import ( + "errors" + "sync" "testing" "time" qbt "github.com/autobrr/go-qbittorrent" ) +// mockSyncEventSink is a test helper that records calls to HandleMainData and HandleSyncError. +type mockSyncEventSink struct { + mu sync.Mutex + mainData []*mainDataCall + syncErrors []*syncErrorCall +} + +type mainDataCall struct { + instanceID int + data *qbt.MainData +} + +type syncErrorCall struct { + instanceID int + err error +} + +func (m *mockSyncEventSink) HandleMainData(instanceID int, data *qbt.MainData) { + m.mu.Lock() + defer m.mu.Unlock() + m.mainData = append(m.mainData, &mainDataCall{instanceID: instanceID, data: data}) +} + +func (m *mockSyncEventSink) HandleSyncError(instanceID int, err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.syncErrors = append(m.syncErrors, &syncErrorCall{instanceID: instanceID, err: err}) +} + +func (m *mockSyncEventSink) getMainDataCalls() []*mainDataCall { + m.mu.Lock() + defer m.mu.Unlock() + return m.mainData +} + +func (m *mockSyncEventSink) getSyncErrorCalls() []*syncErrorCall { + m.mu.Lock() + defer m.mu.Unlock() + return m.syncErrors +} + func TestClientUpdateServerStateDoesNotBlockOnClientMutex(t *testing.T) { t.Parallel() @@ -33,3 +76,151 @@ func TestClientUpdateServerStateDoesNotBlockOnClientMutex(t *testing.T) { t.Fatal("updateServerState blocked waiting for Client.mu write lock") } } + +func TestClientDispatchMainDataCallsSink(t *testing.T) { + t.Parallel() + + sink := &mockSyncEventSink{} + client := &Client{ + instanceID: 42, + syncEventSink: sink, + } + + testData := &qbt.MainData{ + Rid: 123, + Torrents: map[string]qbt.Torrent{ + "abc123": {Name: "Test Torrent"}, + }, + } + + client.dispatchMainData(testData) + + calls := sink.getMainDataCalls() + if len(calls) != 1 { + t.Fatalf("expected 1 call to HandleMainData, got %d", len(calls)) + } + + if calls[0].instanceID != 42 { + t.Errorf("expected instanceID 42, got %d", calls[0].instanceID) + } + + if calls[0].data.Rid != 123 { + t.Errorf("expected Rid 123, got %d", calls[0].data.Rid) + } +} + +func TestClientDispatchMainDataNilSinkDoesNotPanic(t *testing.T) { + t.Parallel() + + client := &Client{ + instanceID: 42, + syncEventSink: nil, + } + + testData := &qbt.MainData{Rid: 123} + + // Should not panic + client.dispatchMainData(testData) +} + +func TestClientDispatchMainDataNilDataDoesNotCallSink(t *testing.T) { + t.Parallel() + + sink := &mockSyncEventSink{} + client := &Client{ + instanceID: 42, + syncEventSink: sink, + } + + client.dispatchMainData(nil) + + calls := sink.getMainDataCalls() + if len(calls) != 0 { + t.Errorf("expected 0 calls to HandleMainData for nil data, got %d", len(calls)) + } +} + +func TestClientDispatchSyncErrorCallsSink(t *testing.T) { + t.Parallel() + + sink := &mockSyncEventSink{} + client := &Client{ + instanceID: 42, + syncEventSink: sink, + } + + testErr := errors.New("connection refused") + + client.dispatchSyncError(testErr) + + calls := sink.getSyncErrorCalls() + if len(calls) != 1 { + t.Fatalf("expected 1 call to HandleSyncError, got %d", len(calls)) + } + + if calls[0].instanceID != 42 { + t.Errorf("expected instanceID 42, got %d", calls[0].instanceID) + } + + if calls[0].err.Error() != "connection refused" { + t.Errorf("expected error 'connection refused', got '%s'", calls[0].err.Error()) + } +} + +func TestClientDispatchSyncErrorNilSinkDoesNotPanic(t *testing.T) { + t.Parallel() + + client := &Client{ + instanceID: 42, + syncEventSink: nil, + } + + testErr := errors.New("connection refused") + + // Should not panic + client.dispatchSyncError(testErr) +} + +func TestClientDispatchSyncErrorNilErrorDoesNotCallSink(t *testing.T) { + t.Parallel() + + sink := &mockSyncEventSink{} + client := &Client{ + instanceID: 42, + syncEventSink: sink, + } + + client.dispatchSyncError(nil) + + calls := sink.getSyncErrorCalls() + if len(calls) != 0 { + t.Errorf("expected 0 calls to HandleSyncError for nil error, got %d", len(calls)) + } +} + +func TestClientSetSyncEventSinkUpdatesDispatch(t *testing.T) { + t.Parallel() + + client := &Client{instanceID: 42} + + // Initially no sink + client.dispatchMainData(&qbt.MainData{Rid: 1}) + + // Set sink + sink := &mockSyncEventSink{} + client.SetSyncEventSink(sink) + + // Now dispatches should reach sink + client.dispatchMainData(&qbt.MainData{Rid: 2}) + client.dispatchSyncError(errors.New("test error")) + + mainCalls := sink.getMainDataCalls() + if len(mainCalls) != 1 { + t.Errorf("expected 1 main data call after setting sink, got %d", len(mainCalls)) + } + + errorCalls := sink.getSyncErrorCalls() + if len(errorCalls) != 1 { + t.Errorf("expected 1 error call after setting sink, got %d", len(errorCalls)) + } +} diff --git a/internal/qbittorrent/context.go b/internal/qbittorrent/context.go index 3a58c1af8..ff0cc170f 100644 --- a/internal/qbittorrent/context.go +++ b/internal/qbittorrent/context.go @@ -7,7 +7,10 @@ import "context" type contextKey string -const skipTrackerHydrationKey contextKey = "qui_skip_tracker_hydration" +const ( + skipTrackerHydrationKey contextKey = "qui_skip_tracker_hydration" + skipFreshDataKey contextKey = "qui_skip_fresh_data" +) // WithSkipTrackerHydration marks the context so tracker enrichment/hydration is skipped. func WithSkipTrackerHydration(ctx context.Context) context.Context { @@ -25,3 +28,20 @@ func shouldSkipTrackerHydration(ctx context.Context) bool { val, ok := ctx.Value(skipTrackerHydrationKey).(bool) return ok && val } + +// WithSkipFreshData marks the context so qBittorrent cache reads avoid triggering fresh syncs. +func WithSkipFreshData(ctx context.Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, skipFreshDataKey, true) +} + +// shouldSkipFreshData returns true when the context prefers cached qBittorrent data. +func shouldSkipFreshData(ctx context.Context) bool { + if ctx == nil { + return false + } + val, ok := ctx.Value(skipFreshDataKey).(bool) + return ok && val +} diff --git a/internal/qbittorrent/pool.go b/internal/qbittorrent/pool.go index 9f7d4af23..91c574bcb 100644 --- a/internal/qbittorrent/pool.go +++ b/internal/qbittorrent/pool.go @@ -63,6 +63,7 @@ type ClientPool struct { stopHealth chan struct{} failureTracker map[int]*failureInfo decryptionTracker map[int]*decryptionErrorInfo + syncEventSink SyncEventSink completionHandler TorrentCompletionHandler addedHandler TorrentAddedHandler syncManager *SyncManager // Reference for starting background tasks @@ -92,6 +93,18 @@ func NewClientPool(instanceStore *models.InstanceStore, errorStore *models.Insta return cp, nil } +// SetSyncEventSink configures the sink that should receive sync notifications +// from every client managed by this pool. Existing clients are updated +// immediately. +func (cp *ClientPool) SetSyncEventSink(sink SyncEventSink) { + cp.mu.Lock() + cp.syncEventSink = sink + for _, client := range cp.clients { + client.SetSyncEventSink(sink) + } + cp.mu.Unlock() +} + // SetTorrentCompletionHandler registers a callback for new and existing clients when torrents complete. func (cp *ClientPool) SetTorrentCompletionHandler(handler TorrentCompletionHandler) { cp.mu.Lock() @@ -261,6 +274,9 @@ func (cp *ClientPool) createClientWithTimeout(ctx context.Context, instanceID in // Store in pool (need write lock for this) cp.mu.Lock() + if cp.syncEventSink != nil { + client.SetSyncEventSink(cp.syncEventSink) + } cp.clients[instanceID] = client // Reset failure tracking on successful connection cp.resetFailureTrackingLocked(instanceID) diff --git a/internal/qbittorrent/pool_test.go b/internal/qbittorrent/pool_test.go index f833cdb28..5fd0b4055 100644 --- a/internal/qbittorrent/pool_test.go +++ b/internal/qbittorrent/pool_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "testing" + qbt "github.com/autobrr/go-qbittorrent" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -244,3 +245,88 @@ func TestClientPool_IsBanError(t *testing.T) { }) } } + +func TestClientPoolSetSyncEventSinkUpdatesExistingClients(t *testing.T) { + pool := setupTestPool(t) + defer pool.Close() + + // Create clients manually and add them to the pool + // These clients don't have a sink yet + client1 := &Client{instanceID: 1} + client2 := &Client{instanceID: 2} + + pool.mu.Lock() + pool.clients[1] = client1 + pool.clients[2] = client2 + pool.mu.Unlock() + + // Verify clients have no sink initially + assert.Nil(t, client1.getSyncEventSink(), "client1 should have no sink initially") + assert.Nil(t, client2.getSyncEventSink(), "client2 should have no sink initially") + + // Create a mock sink + sink := &mockPoolSyncEventSink{} + + // Set the sink on the pool + pool.SetSyncEventSink(sink) + + // Verify all existing clients were updated with the sink + assert.Equal(t, sink, client1.getSyncEventSink(), "client1 should have the sink after SetSyncEventSink") + assert.Equal(t, sink, client2.getSyncEventSink(), "client2 should have the sink after SetSyncEventSink") + + // Verify the pool itself stored the sink + pool.mu.RLock() + poolSink := pool.syncEventSink + pool.mu.RUnlock() + assert.Equal(t, sink, poolSink, "pool should have stored the sink") +} + +func TestClientPoolSetSyncEventSinkWithNoClients(t *testing.T) { + pool := setupTestPool(t) + defer pool.Close() + + // Verify pool starts with no clients + pool.mu.RLock() + clientCount := len(pool.clients) + pool.mu.RUnlock() + assert.Equal(t, 0, clientCount, "pool should start with no clients") + + // Setting sink should not panic when there are no clients + sink := &mockPoolSyncEventSink{} + pool.SetSyncEventSink(sink) + + // Verify the pool stored the sink + pool.mu.RLock() + poolSink := pool.syncEventSink + pool.mu.RUnlock() + assert.Equal(t, sink, poolSink, "pool should have stored the sink even with no clients") +} + +func TestClientPoolSetSyncEventSinkReplacesExisting(t *testing.T) { + pool := setupTestPool(t) + defer pool.Close() + + // Create a client and add to pool + client := &Client{instanceID: 1} + pool.mu.Lock() + pool.clients[1] = client + pool.mu.Unlock() + + // Set first sink + sink1 := &mockPoolSyncEventSink{id: 1} + pool.SetSyncEventSink(sink1) + assert.Equal(t, sink1, client.getSyncEventSink(), "client should have sink1") + + // Set second sink (replaces first) + sink2 := &mockPoolSyncEventSink{id: 2} + pool.SetSyncEventSink(sink2) + assert.Equal(t, sink2, client.getSyncEventSink(), "client should have sink2 after replacement") +} + +// mockPoolSyncEventSink is a simple mock for testing pool sink propagation. +type mockPoolSyncEventSink struct { + id int +} + +func (m *mockPoolSyncEventSink) HandleMainData(_ int, _ *qbt.MainData) {} +func (m *mockPoolSyncEventSink) HandleSyncError(_ int, _ error) {} diff --git a/internal/qbittorrent/preferences.go b/internal/qbittorrent/preferences.go new file mode 100644 index 000000000..6803228ba --- /dev/null +++ b/internal/qbittorrent/preferences.go @@ -0,0 +1,76 @@ +// Copyright (c) 2025, s0up and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package qbittorrent + +import ( + "context" + "fmt" + "time" + + qbt "github.com/autobrr/go-qbittorrent" +) + +const appPreferencesCacheTTL = 30 * time.Second +const appPreferencesRequestTimeout = 10 * time.Second + +func cloneAppPreferences(prefs *qbt.AppPreferences) *qbt.AppPreferences { + if prefs == nil { + return nil + } + + clone := *prefs + return &clone +} + +// GetAppPreferences returns cached qBittorrent app preferences, refreshing them when stale. +func (c *Client) GetAppPreferences(ctx context.Context) (*qbt.AppPreferences, error) { + if ctx == nil { + ctx = context.Background() + } + + c.preferencesMu.RLock() + if c.preferencesCache != nil && time.Since(c.preferencesFetchedAt) < appPreferencesCacheTTL { + cached := cloneAppPreferences(c.preferencesCache) + c.preferencesMu.RUnlock() + return cached, nil + } + c.preferencesMu.RUnlock() + + return c.refreshAppPreferences(ctx) +} + +func (c *Client) refreshAppPreferences(ctx context.Context) (*qbt.AppPreferences, error) { + requestCtx, cancel := context.WithTimeout(ctx, appPreferencesRequestTimeout) + defer cancel() + + prefs, err := c.GetAppPreferencesCtx(requestCtx) + if err != nil { + return nil, fmt.Errorf("get app preferences: %w", err) + } + + cloned := cloneAppPreferences(&prefs) + + c.preferencesMu.Lock() + c.preferencesCache = cloned + c.preferencesFetchedAt = time.Now() + c.preferencesMu.Unlock() + + return cloneAppPreferences(cloned), nil +} + +// GetCachedAppPreferences returns the last cached app preferences without triggering a refresh. +func (c *Client) GetCachedAppPreferences() *qbt.AppPreferences { + c.preferencesMu.RLock() + defer c.preferencesMu.RUnlock() + + return cloneAppPreferences(c.preferencesCache) +} + +// InvalidateAppPreferencesCache clears the cached preferences to force a refresh on next access. +func (c *Client) InvalidateAppPreferencesCache() { + c.preferencesMu.Lock() + c.preferencesCache = nil + c.preferencesFetchedAt = time.Time{} + c.preferencesMu.Unlock() +} diff --git a/internal/qbittorrent/sync_events.go b/internal/qbittorrent/sync_events.go new file mode 100644 index 000000000..0874d9f6b --- /dev/null +++ b/internal/qbittorrent/sync_events.go @@ -0,0 +1,17 @@ +// Copyright (c) 2025, s0up and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package qbittorrent + +import ( + qbt "github.com/autobrr/go-qbittorrent" +) + +// SyncEventSink receives notifications from qBittorrent sync managers whenever +// new MainData snapshots arrive or a sync error occurs. Implementations are +// expected to return quickly; heavy processing should be offloaded to other +// goroutines to avoid blocking the sync loop. +type SyncEventSink interface { + HandleMainData(instanceID int, data *qbt.MainData) + HandleSyncError(instanceID int, err error) +} diff --git a/internal/qbittorrent/sync_manager.go b/internal/qbittorrent/sync_manager.go index f11a8284e..34320d07d 100644 --- a/internal/qbittorrent/sync_manager.go +++ b/internal/qbittorrent/sync_manager.go @@ -107,22 +107,43 @@ type CrossInstanceTorrentView struct { InstanceName string `json:"instance_name"` } +// InstanceMeta provides real-time instance connection status for SSE subscribers. +// This allows the frontend to get instance health without separate polling. +type InstanceMeta struct { + Connected bool `json:"connected"` + HasDecryptionError bool `json:"hasDecryptionError"` + RecentErrors []InstanceError `json:"recentErrors,omitempty"` +} + +// InstanceError represents a recent error for an instance (mirrors models.InstanceError for SSE). +type InstanceError struct { + ID int `json:"id"` + InstanceID int `json:"instanceId"` + ErrorType string `json:"errorType"` + ErrorMessage string `json:"errorMessage"` + OccurredAt string `json:"occurredAt"` // ISO8601 string for JSON +} + type TorrentResponse struct { Torrents []TorrentView `json:"torrents"` CrossInstanceTorrents []CrossInstanceTorrentView `json:"cross_instance_torrents,omitempty"` Total int `json:"total"` + ActiveTaskCount int `json:"activeTaskCount"` Stats *TorrentStats `json:"stats,omitempty"` Counts *TorrentCounts `json:"counts,omitempty"` // Include counts for sidebar Categories map[string]qbt.Category `json:"categories,omitempty"` // Include categories for sidebar Tags []string `json:"tags,omitempty"` // Include tags for sidebar ServerState *qbt.ServerState `json:"serverState,omitempty"` // Include server state for Dashboard + AppInfo *AppInfo `json:"appInfo,omitempty"` // Include qBittorrent application info + AppPreferences *qbt.AppPreferences `json:"preferences,omitempty"` // Include qBittorrent application preferences UseSubcategories bool `json:"useSubcategories"` // Whether subcategories are enabled HasMore bool `json:"hasMore"` // Whether more pages are available SessionID string `json:"sessionId,omitempty"` // Optional session tracking CacheMetadata *CacheMetadata `json:"cacheMetadata,omitempty"` TrackerHealthSupported bool `json:"trackerHealthSupported"` - IsCrossInstance bool `json:"isCrossInstance"` // Whether this is a cross-instance response - PartialResults bool `json:"partialResults"` // Whether some instances failed to respond + IsCrossInstance bool `json:"isCrossInstance"` // Whether this is a cross-instance response + PartialResults bool `json:"partialResults"` // Whether some instances failed to respond + InstanceMeta *InstanceMeta `json:"instanceMeta,omitempty"` // Real-time instance health for SSE } // TorrentStats represents aggregated torrent statistics @@ -850,9 +871,7 @@ func (sm *SyncManager) validateTorrentsExist(client *Client, hashes []string, op } // GetTorrentsWithFilters gets torrents with filters, search, sorting, and pagination -// Always fetches fresh data from sync manager for real-time updates func (sm *SyncManager) GetTorrentsWithFilters(ctx context.Context, instanceID int, limit, offset int, sort, order, search string, filters FilterOptions) (*TorrentResponse, error) { - // Always get fresh data from sync manager for real-time updates var filteredTorrents []qbt.Torrent var allTorrentsForCounts []qbt.Torrent var err error @@ -863,6 +882,7 @@ func (sm *SyncManager) GetTorrentsWithFilters(ctx context.Context, instanceID in return nil, err } + skipFreshData := shouldSkipFreshData(ctx) skipTrackerHydration := shouldSkipTrackerHydration(ctx) trackerHealthSupported := client != nil && client.supportsTrackerInclude() @@ -872,7 +892,27 @@ func (sm *SyncManager) GetTorrentsWithFilters(ctx context.Context, instanceID in needsTrackerHealthSorting := trackerHealthSupported && sort == "state" // Get MainData for tracker filtering (if needed) - mainData := syncManager.GetData() + var mainData *qbt.MainData + if skipFreshData { + mainData = syncManager.GetDataUnchecked() + if mainData == nil { + mainData = syncManager.GetData() + } + } else { + mainData = syncManager.GetData() + } + + // Choose torrent getter based on freshness preference + // Use a wrapper for GetTorrentsUnchecked to fall back to GetTorrents if cache is empty + getTorrents := syncManager.GetTorrents + if skipFreshData { + getTorrents = func(opts qbt.TorrentFilterOptions) []qbt.Torrent { + if torrents := syncManager.GetTorrentsUnchecked(opts); torrents != nil { + return torrents + } + return syncManager.GetTorrents(opts) + } + } // Determine if we can use library filtering or need manual filtering // Use library filtering only if we have single filters that the library supports @@ -951,7 +991,7 @@ func (sm *SyncManager) GetTorrentsWithFilters(ctx context.Context, instanceID in if useManualFiltering { // Use manual filtering - get all torrents and filter manually - log.Debug(). + log.Trace(). Int("instanceID", instanceID). Bool("multipleStatus", hasMultipleStatusFilters). Bool("multipleCategories", hasMultipleCategoryFilters). @@ -974,7 +1014,7 @@ func (sm *SyncManager) GetTorrentsWithFilters(ctx context.Context, instanceID in torrentFilterOptions.Sort = sort torrentFilterOptions.Reverse = (order == "desc") - filteredTorrents = syncManager.GetTorrents(torrentFilterOptions) + filteredTorrents = getTorrents(torrentFilterOptions) // Keep reference to unfiltered torrents for counts (enrichment and filtering return new slices, so no copy needed) allTorrentsForCounts = filteredTorrents @@ -987,7 +1027,7 @@ func (sm *SyncManager) GetTorrentsWithFilters(ctx context.Context, instanceID in filteredTorrents = sm.applyManualFilters(client, filteredTorrents, filters, mainData, categories, useSubcategories) } else { // Use library filtering for single selections - log.Debug(). + log.Trace(). Int("instanceID", instanceID). Int("hashFilters", len(filters.Hashes)). Msg("Using library filtering for single selections") @@ -1042,14 +1082,14 @@ func (sm *SyncManager) GetTorrentsWithFilters(ctx context.Context, instanceID in torrentFilterOptions.Reverse = (order == "desc") // Use library filtering and sorting - filteredTorrents = syncManager.GetTorrents(torrentFilterOptions) + filteredTorrents = getTorrents(torrentFilterOptions) if trackerHealthSupported && needsTrackerHealthSorting { filteredTorrents, trackerMap, _ = sm.enrichTorrentsWithTrackerData(ctx, client, filteredTorrents, trackerMap) } } - log.Debug(). + log.Trace(). Int("instanceID", instanceID). Int("totalCount", len(filteredTorrents)). Bool("useManualFiltering", useManualFiltering). @@ -1060,7 +1100,7 @@ func (sm *SyncManager) GetTorrentsWithFilters(ctx context.Context, instanceID in filteredTorrents = sm.filterTorrentsBySearch(filteredTorrents, search) } - log.Debug(). + log.Trace(). Int("instanceID", instanceID). Int("filtered", len(filteredTorrents)). Msg("Applied search filtering") @@ -1133,7 +1173,7 @@ func (sm *SyncManager) GetTorrentsWithFilters(ctx context.Context, instanceID in if useManualFiltering { allTorrents = allTorrentsForCounts } else { - allTorrents = syncManager.GetTorrents(qbt.TorrentFilterOptions{}) + allTorrents = getTorrents(qbt.TorrentFilterOptions{}) } useSubcategories = resolveUseSubcategories(supportsSubcategories, mainData, categories) @@ -1143,6 +1183,13 @@ func (sm *SyncManager) GetTorrentsWithFilters(ctx context.Context, instanceID in if skipTrackerHydration { counts = nil } else { + if len(allTorrents) == 0 { + log.Trace(). + Int("instanceID", instanceID). + Bool("useManualFiltering", useManualFiltering). + Msg("All torrent list empty when calculating counts; refetching") + allTorrents = getTorrents(qbt.TorrentFilterOptions{}) + } counts, trackerMap, enrichedAll = sm.calculateCountsFromTorrentsWithTrackers(ctx, client, allTorrents, mainData, trackerMap, trackerHealthSupported, useSubcategories) } @@ -1207,41 +1254,65 @@ func (sm *SyncManager) GetTorrentsWithFilters(ctx context.Context, instanceID in // Determine cache metadata based on last sync update time var cacheMetadata *CacheMetadata var serverState *qbt.ServerState - client, clientErr := sm.clientPool.GetClient(ctx, instanceID) - if clientErr == nil { - syncManager := client.GetSyncManager() - if syncManager != nil { - lastSyncTime := syncManager.LastSyncTime() - now := time.Now() - age := int(now.Sub(lastSyncTime).Seconds()) - isFresh := age <= 1 // Fresh if updated within the last second + var appInfo *AppInfo + var appPreferences *qbt.AppPreferences - source := "cache" - if isFresh { - source = "fresh" - } + if syncManager != nil { + lastSyncTime := syncManager.LastSyncTime() + now := time.Now() + age := int(now.Sub(lastSyncTime).Seconds()) + isFresh := age <= 1 // Fresh if updated within the last second - cacheMetadata = &CacheMetadata{ - Source: source, - Age: age, - IsStale: !isFresh, - NextRefresh: now.Add(time.Second).Format(time.RFC3339), - } + source := "cache" + if isFresh { + source = "fresh" + } + + cacheMetadata = &CacheMetadata{ + Source: source, + Age: age, + IsStale: !isFresh, + NextRefresh: now.Add(time.Second).Format(time.RFC3339), } + } + if client != nil { if cached := client.GetCachedServerState(); cached != nil { serverState = cached } + + if info, err := client.GetAppInfo(ctx); err != nil { + log.Error(). + Err(err). + Int("instanceID", instanceID). + Msg("Failed to retrieve qBittorrent app info for torrent stream") + } else { + appInfo = info + } + + if skipFreshData { + appPreferences = client.GetCachedAppPreferences() + } else if prefs, err := client.GetAppPreferences(ctx); err != nil { + log.Warn(). + Err(err). + Int("instanceID", instanceID). + Msg("Failed to retrieve qBittorrent app preferences for torrent stream") + } else { + appPreferences = prefs + } } response := &TorrentResponse{ Torrents: paginatedViews, Total: len(filteredTorrents), + ActiveTaskCount: sm.GetActiveTaskCount(ctx, instanceID), Stats: stats, Counts: counts, // Include counts for sidebar Categories: categories, // Include categories for sidebar Tags: tags, // Include tags for sidebar ServerState: serverState, // Include server state for Dashboard + AppInfo: appInfo, // Include application info for frontend consumers + AppPreferences: appPreferences, UseSubcategories: useSubcategories, HasMore: hasMore, CacheMetadata: cacheMetadata, @@ -1252,7 +1323,7 @@ func (sm *SyncManager) GetTorrentsWithFilters(ctx context.Context, instanceID in // This ensures real-time updates are always reflected // The sync manager is the single source of truth - log.Debug(). + log.Trace(). Int("instanceID", instanceID). Int("count", len(paginatedViews)). Int("total", len(filteredTorrents)). @@ -1818,8 +1889,15 @@ func (sm *SyncManager) GetCategories(ctx context.Context, instanceID int) (map[s return nil, err } - // Get categories from sync manager (real-time) - categories := syncManager.GetCategories() + skipFreshData := shouldSkipFreshData(ctx) + + // Get categories from sync manager + var categories map[string]qbt.Category + if skipFreshData { + categories = syncManager.GetCategoriesUnchecked() + } else { + categories = syncManager.GetCategories() + } return categories, nil } @@ -1832,8 +1910,15 @@ func (sm *SyncManager) GetTags(ctx context.Context, instanceID int) ([]string, e return nil, err } - // Get tags from sync manager (real-time) - tags := syncManager.GetTags() + skipFreshData := shouldSkipFreshData(ctx) + + // Get tags from sync manager + var tags []string + if skipFreshData { + tags = syncManager.GetTagsUnchecked() + } else { + tags = syncManager.GetTags() + } slices.SortFunc(tags, func(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) @@ -4004,7 +4089,7 @@ torrentsLoop: filtered = append(filtered, torrent) } - log.Debug(). + log.Trace(). Int("inputTorrents", len(torrents)). Int("filteredTorrents", len(filtered)). Int("statusFilters", len(filters.Status)). @@ -5018,12 +5103,16 @@ func (sm *SyncManager) GetAppPreferences(ctx context.Context, instanceID int) (q return qbt.AppPreferences{}, fmt.Errorf("failed to get client: %w", err) } - prefs, err := client.GetAppPreferencesCtx(ctx) + prefs, err := client.GetAppPreferences(ctx) if err != nil { return qbt.AppPreferences{}, fmt.Errorf("failed to get app preferences: %w", err) } - return prefs, nil + if prefs == nil { + return qbt.AppPreferences{}, errors.New("failed to get app preferences: empty response") + } + + return *prefs, nil } // SetAppPreferences updates app preferences @@ -5037,6 +5126,8 @@ func (sm *SyncManager) SetAppPreferences(ctx context.Context, instanceID int, pr return fmt.Errorf("failed to set preferences: %w", err) } + client.InvalidateAppPreferencesCache() + // Sync after modification sm.syncAfterModification(instanceID, client, "set_app_preferences") diff --git a/web/src/App.tsx b/web/src/App.tsx index 3cb07db7a..4df940e36 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import { Toaster } from "@/components/ui/sonner" import { TooltipProvider } from "@/components/ui/tooltip" +import { SyncStreamProvider } from "@/contexts/SyncStreamContext" import { useDynamicFavicon } from "@/hooks/useDynamicFavicon" import { initializePWANativeTheme } from "@/utils/pwaNativeTheme" import { initializeTheme } from "@/utils/theme" @@ -38,10 +39,12 @@ function App() { return ( - - - - + + + + + + ) } diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 5c4e2288f..ede8f48ac 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -26,6 +26,7 @@ import { TooltipTrigger } from "@/components/ui/tooltip" import { useLayoutRoute } from "@/contexts/LayoutRouteContext" +import { useSyncStream } from "@/contexts/SyncStreamContext" import { useTorrentSelection } from "@/contexts/TorrentSelectionContext" import { useAuth } from "@/hooks/useAuth" import { useCrossSeedInstanceState } from "@/hooks/useCrossSeedInstanceState" @@ -36,7 +37,7 @@ import { usePersistedFilterSidebarState } from "@/hooks/usePersistedFilterSideba import { useTheme } from "@/hooks/useTheme" import { api } from "@/lib/api" import { cn } from "@/lib/utils" -import type { InstanceCapabilities } from "@/types" +import type { InstanceCapabilities, TorrentStreamPayload } from "@/types" import { useQuery } from "@tanstack/react-query" import { Link, useNavigate, useSearch } from "@tanstack/react-router" import { Archive, ChevronsUpDown, Cog, Download, FileEdit, FileText, FunnelPlus, FunnelX, GitBranch, HardDrive, Home, Info, ListTodo, Loader2, LogOut, Menu, Plus, Rss, Search, SearchCode, Server, Settings, X, Zap } from "lucide-react" @@ -142,15 +143,56 @@ export function Header({ ) const { theme } = useTheme() const { viewMode } = usePersistedCompactViewState("normal") + const [streamActiveTaskCount, setStreamActiveTaskCount] = useState(null) - // Query active task count for badge (lightweight endpoint, only for instance routes) - const { data: activeTaskCount = 0 } = useQuery({ + useEffect(() => { + setStreamActiveTaskCount(null) + }, [selectedInstanceId]) + + const activeTaskStreamParams = useMemo(() => { + if (!shouldShowInstanceControls || selectedInstanceId === null) { + return null + } + + return { + instanceId: selectedInstanceId, + page: 0, + limit: 1, + sort: "added_on", + order: "desc" as const, + } + }, [selectedInstanceId, shouldShowInstanceControls]) + + const handleActiveTaskStreamMessage = useCallback((payload: TorrentStreamPayload) => { + const value = payload.data?.activeTaskCount + if (typeof value === "number") { + setStreamActiveTaskCount(value) + } + }, []) + + const activeTaskStreamState = useSyncStream(activeTaskStreamParams, { + enabled: Boolean(activeTaskStreamParams), + onMessage: handleActiveTaskStreamMessage, + }) + + const shouldUseActiveTaskFallback = + shouldShowInstanceControls && + selectedInstanceId !== null && + ( + !activeTaskStreamState.connected || + !!activeTaskStreamState.error || + streamActiveTaskCount === null + ) + + // Active task count is streamed via SSE; REST polling only runs as fallback. + const { data: polledActiveTaskCount = 0 } = useQuery({ queryKey: ["active-task-count", selectedInstanceId], queryFn: () => selectedInstanceId !== null ? api.getActiveTaskCount(selectedInstanceId) : Promise.resolve(0), - enabled: shouldShowInstanceControls && selectedInstanceId !== null, - refetchInterval: 30000, // Poll every 30 seconds (lightweight check) + enabled: shouldUseActiveTaskFallback, + refetchInterval: shouldUseActiveTaskFallback ? 30000 : false, refetchIntervalInBackground: true, }) + const activeTaskCount = streamActiveTaskCount ?? polledActiveTaskCount // Query for available updates const { data: updateInfo } = useQuery({ diff --git a/web/src/components/torrents/FilterSidebar.tsx b/web/src/components/torrents/FilterSidebar.tsx index 8fcf7f4cd..0e28e9d45 100644 --- a/web/src/components/torrents/FilterSidebar.tsx +++ b/web/src/components/torrents/FilterSidebar.tsx @@ -206,7 +206,7 @@ const FilterSidebarComponent = ({ const supportsSubcategories = capabilities?.supportsSubcategories ?? false const { preferences } = useInstancePreferences( instanceId, - { enabled: isInstanceActive } + { fetchIfMissing: false, enabled: isInstanceActive } ) const preferenceUseSubcategories = preferences?.use_subcategories const subcategoriesEnabled = Boolean( diff --git a/web/src/components/torrents/GlobalStatusBar.tsx b/web/src/components/torrents/GlobalStatusBar.tsx deleted file mode 100644 index 87e223760..000000000 --- a/web/src/components/torrents/GlobalStatusBar.tsx +++ /dev/null @@ -1,407 +0,0 @@ -/* - * Copyright (c) 2025-2026, s0up and the autobrr contributors. - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -import { useMutation, useQueryClient } from "@tanstack/react-query" -import { useNavigate } from "@tanstack/react-router" -import { - ArrowUpDown, - Ban, - BrickWallFire, - ChevronDown, - ChevronUp, - EthernetPort, - Eye, - EyeOff, - Globe, - HardDrive, - LayoutGrid, - Loader2, - Rabbit, - RefreshCcw, - Rows3, - Table as TableIcon, - Turtle, -} from "lucide-react" -import { memo, useCallback, useEffect, useMemo, useState } from "react" - -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" -import { usePersistedCompactViewState } from "@/hooks/usePersistedCompactViewState" -import { api } from "@/lib/api" -import { useIncognitoMode } from "@/lib/incognito" -import { formatSpeedWithUnit, useSpeedUnits } from "@/lib/speedUnits" -import { cn, formatBytes } from "@/lib/utils" -import type { Instance, ServerState } from "@/types" - -const TABLE_ALLOWED_VIEW_MODES = ["normal", "dense", "compact"] as const - -export interface SelectionInfo { - effectiveSelectionCount: number - isAllSelected: boolean - excludedFromSelectAllSize: number - selectedFormattedSize: string - torrentsLength: number - totalCount: number - hasLoadedAll: boolean - isLoading: boolean - isLoadingMore: boolean - isCachedData: boolean - isStaleData: boolean - emptyStateMessage: string - safeLoadedRows: number - rowsLength: number -} - -interface ExternalIPAddressProps { - address?: string | null - incognitoMode: boolean - label: string -} - -const ExternalIPAddress = memo( - ({ address, incognitoMode, label }: ExternalIPAddressProps) => { - if (!address) return null - - return ( - - - - - {label} - - - -

- {address} -

-
-
- ) - }, - (prev, next) => - prev.address === next.address && - prev.incognitoMode === next.incognitoMode && - prev.label === next.label -) - -interface GlobalStatusBarProps { - instanceId: number - serverState: ServerState | null - instance?: Instance | null - listenPort?: number | null - selectionInfo?: SelectionInfo | null -} - -export const GlobalStatusBar = memo(function GlobalStatusBar({ - instanceId, - serverState, - instance, - listenPort, - selectionInfo, -}: GlobalStatusBarProps) { - const navigate = useNavigate() - const queryClient = useQueryClient() - const [incognitoMode, setIncognitoMode] = useIncognitoMode() - const [speedUnit, setSpeedUnit] = useSpeedUnits() - const { viewMode: desktopViewMode, cycleViewMode } = usePersistedCompactViewState("normal", TABLE_ALLOWED_VIEW_MODES) - - // Detect platform for keyboard shortcuts - const isMac = useMemo(() => { - return typeof window !== "undefined" && /Mac|iPhone|iPad|iPod/.test(window.navigator.userAgent) - }, []) - - // Alt speed toggle state - const [altSpeedOverride, setAltSpeedOverride] = useState(null) - const serverAltSpeedEnabled = serverState?.use_alt_speed_limits - const hasAltSpeedStatus = typeof serverAltSpeedEnabled === "boolean" - const isAltSpeedKnown = altSpeedOverride !== null || hasAltSpeedStatus - const altSpeedEnabled = altSpeedOverride ?? serverAltSpeedEnabled ?? false - const AltSpeedIcon = altSpeedEnabled ? Turtle : Rabbit - const altSpeedIconClass = isAltSpeedKnown ? altSpeedEnabled ? "text-destructive" : "text-green-500" : "text-muted-foreground" - - useEffect(() => { - setAltSpeedOverride(null) - }, [instanceId]) - - const { mutateAsync: toggleAltSpeedLimits, isPending: isTogglingAltSpeed } = useMutation({ - mutationFn: () => api.toggleAlternativeSpeedLimits(instanceId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["torrents-list", instanceId] }) - queryClient.invalidateQueries({ queryKey: ["alternative-speed-limits", instanceId] }) - }, - }) - - useEffect(() => { - if (altSpeedOverride === null) { - return - } - - if (serverAltSpeedEnabled === altSpeedOverride) { - setAltSpeedOverride(null) - } - }, [serverAltSpeedEnabled, altSpeedOverride]) - - const handleToggleAltSpeedLimits = useCallback(async () => { - if (isTogglingAltSpeed) { - return - } - - const current = altSpeedOverride ?? serverAltSpeedEnabled ?? false - const next = !current - - setAltSpeedOverride(next) - - try { - await toggleAltSpeedLimits() - } catch { - setAltSpeedOverride(current) - } - }, [altSpeedOverride, serverAltSpeedEnabled, toggleAltSpeedLimits, isTogglingAltSpeed]) - - const altSpeedTooltip = isAltSpeedKnown ? altSpeedEnabled ? "Alternative speed limits: On" : "Alternative speed limits: Off" : "Alternative speed limits status unknown" - const altSpeedAriaLabel = isAltSpeedKnown ? altSpeedEnabled ? "Disable alternative speed limits" : "Enable alternative speed limits" : "Alternative speed limits status unknown" - - // Connection status - const rawConnectionStatus = serverState?.connection_status ?? "" - const normalizedConnectionStatus = rawConnectionStatus ? rawConnectionStatus.trim().toLowerCase() : "" - const formattedConnectionStatus = normalizedConnectionStatus ? normalizedConnectionStatus.replace(/_/g, " ") : "" - const connectionStatusDisplay = formattedConnectionStatus ? formattedConnectionStatus.replace(/\b\w/g, (char: string) => char.toUpperCase()) : "" - const hasConnectionStatus = Boolean(formattedConnectionStatus) - const isConnectable = normalizedConnectionStatus === "connected" - const isFirewalled = normalizedConnectionStatus === "firewalled" - const ConnectionStatusIcon = isConnectable ? Globe : isFirewalled ? BrickWallFire : hasConnectionStatus ? Ban : Globe - const connectionStatusTooltip = hasConnectionStatus - ? `${isConnectable ? "Connectable" : connectionStatusDisplay}${listenPort ? `. Port: ${listenPort}` : ""}` - : "Connection status unknown" - const connectionStatusIconClass = hasConnectionStatus ? isConnectable ? "text-green-500" : isFirewalled ? "text-amber-500" : "text-destructive" : "text-muted-foreground" - const connectionStatusAriaLabel = hasConnectionStatus ? `qBittorrent connection status: ${connectionStatusDisplay || formattedConnectionStatus}` : "qBittorrent connection status unknown" - - return ( -
- {/* Left: Selection/Count Info */} -
- {selectionInfo ? ( - selectionInfo.effectiveSelectionCount > 0 ? ( - <> - - {selectionInfo.isAllSelected && selectionInfo.excludedFromSelectAllSize === 0 ? "All" : selectionInfo.effectiveSelectionCount} selected - {selectionInfo.selectedFormattedSize && <> • {selectionInfo.selectedFormattedSize}} - - {/* Keyboard shortcuts helper - only show on desktop */} - - - - Selection shortcuts - - - -
-
Shift+click for range
-
{isMac ? "Cmd" : "Ctrl"}+click for multiple
-
-
-
- - ) : ( - <> - {/* Show special loading message when fetching without cache (cold load) */} - {selectionInfo.isLoading && !selectionInfo.isCachedData && !selectionInfo.isStaleData && selectionInfo.torrentsLength === 0 ? ( - <> - - Loading torrents... - - ) : selectionInfo.totalCount === 0 ? ( - selectionInfo.emptyStateMessage - ) : ( - <> - {selectionInfo.hasLoadedAll ? ( - `${selectionInfo.torrentsLength} torrent${selectionInfo.torrentsLength !== 1 ? "s" : ""}` - ) : selectionInfo.isLoadingMore ? ( - "Loading more torrents..." - ) : ( - `${selectionInfo.torrentsLength} of ${selectionInfo.totalCount} torrents loaded` - )} - {selectionInfo.hasLoadedAll && selectionInfo.safeLoadedRows < selectionInfo.rowsLength && " (scroll for more)"} - - )} - - ) - ) : ( - Loading... - )} -
- - {/* Right: Speed, controls, network info */} -
- {/* Speed & Controls */} -
- - {formatSpeedWithUnit(serverState?.dl_info_speed ?? 0, speedUnit)} - - {formatSpeedWithUnit(serverState?.up_info_speed ?? 0, speedUnit)} - - - - - - {speedUnit === "bytes" ? "Switch to bits per second (bps)" : "Switch to bytes per second (B/s)"} - - - - - - - {altSpeedTooltip} - - {instance?.reannounceSettings?.enabled && ( - - - - - Automatic tracker reannounce enabled - Click to configure - - )} -
- - {/* View Controls */} -
- - -
- - {/* Free Space */} - {serverState?.free_space_on_disk !== undefined && ( -
- - - - - - Free Space - -
- )} - - {/* Network Status */} -
- - - - - - - - -

{connectionStatusTooltip}

-
-
-
-
-
- ) -}) diff --git a/web/src/components/torrents/TorrentCardsMobile.tsx b/web/src/components/torrents/TorrentCardsMobile.tsx index 56bf0d1b2..50693b2da 100644 --- a/web/src/components/torrents/TorrentCardsMobile.tsx +++ b/web/src/components/torrents/TorrentCardsMobile.tsx @@ -28,6 +28,7 @@ import { Progress } from "@/components/ui/progress" import { ScrollToTopButton } from "@/components/ui/scroll-to-top-button" import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { Switch } from "@/components/ui/switch" +import { useSyncStream } from "@/contexts/SyncStreamContext" import { useCrossSeedWarning } from "@/hooks/useCrossSeedWarning" import { useCrossSeedBlocklistActions } from "@/hooks/useCrossSeedBlocklistActions" import { useDebounce } from "@/hooks/useDebounce" @@ -88,7 +89,7 @@ import { getLinuxCategory, getLinuxIsoName, getLinuxRatio, getLinuxTags, getLinu import { formatSpeedWithUnit, useSpeedUnits, type SpeedUnit } from "@/lib/speedUnits" import { getStateLabel } from "@/lib/torrent-state-utils" import { cn, formatBytes, getRatioColor } from "@/lib/utils" -import type { Category, Torrent, TorrentCounts, TorrentFilters } from "@/types" +import type { Category, Torrent, TorrentCounts, TorrentFilters, TorrentStreamPayload } from "@/types" import { useQuery } from "@tanstack/react-query" import { getDefaultSortOrder, TORRENT_SORT_OPTIONS, type TorrentSortOptionValue } from "./torrentSortOptions" @@ -1114,7 +1115,7 @@ export function TorrentCardsMobile({ const { instances } = useInstances() const instance = useMemo(() => instances?.find(i => i.id === instanceId), [instances, instanceId]) - const { data: metadata } = useInstanceMetadata(instanceId) + const { data: metadata } = useInstanceMetadata(instanceId, { fallbackDelayMs: 1500 }) const availableTags = metadata?.tags || [] const availableCategories = metadata?.categories || {} const preferences = metadata?.preferences @@ -1125,14 +1126,48 @@ export function TorrentCardsMobile({ const effectiveSearch = searchFromRoute || immediateSearch || debouncedSearch const navigate = useNavigate() + const [streamActiveTaskCount, setStreamActiveTaskCount] = useState(null) - // Query active task count for badge (lightweight endpoint) - const { data: activeTaskCount = 0 } = useQuery({ + useEffect(() => { + setStreamActiveTaskCount(null) + }, [instanceId]) + + const activeTaskStreamParams = useMemo(() => { + return { + instanceId, + page: 0, + limit: 1, + sort: "added_on", + order: "desc" as const, + } + }, [instanceId]) + + const handleActiveTaskStreamMessage = useCallback((payload: TorrentStreamPayload) => { + const value = payload.data?.activeTaskCount + if (typeof value === "number") { + setStreamActiveTaskCount(value) + } + }, []) + + const activeTaskStreamState = useSyncStream(activeTaskStreamParams, { + enabled: true, + onMessage: handleActiveTaskStreamMessage, + }) + + const shouldUseActiveTaskFallback = + !activeTaskStreamState.connected || + !!activeTaskStreamState.error || + streamActiveTaskCount === null + + // Active task count is streamed via SSE; REST polling only runs as fallback. + const { data: polledActiveTaskCount = 0 } = useQuery({ queryKey: ["active-task-count", instanceId], queryFn: () => api.getActiveTaskCount(instanceId), - refetchInterval: 30000, // Poll every 30 seconds (lightweight check) + enabled: shouldUseActiveTaskFallback, + refetchInterval: shouldUseActiveTaskFallback ? 30000 : false, refetchIntervalInBackground: true, }) + const activeTaskCount = streamActiveTaskCount ?? polledActiveTaskCount useEffect(() => { if (typeof window === "undefined") { diff --git a/web/src/components/torrents/TorrentDetailsPanel.tsx b/web/src/components/torrents/TorrentDetailsPanel.tsx index 939af9de5..5fcef2486 100644 --- a/web/src/components/torrents/TorrentDetailsPanel.tsx +++ b/web/src/components/torrents/TorrentDetailsPanel.tsx @@ -15,6 +15,7 @@ import { Separator } from "@/components/ui/separator" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Textarea } from "@/components/ui/textarea" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { useSyncStream } from "@/contexts/SyncStreamContext" import { useDateTimeFormatters } from "@/hooks/useDateTimeFormatters" import { useInstanceCapabilities } from "@/hooks/useInstanceCapabilities" import { useInstanceMetadata } from "@/hooks/useInstanceMetadata" @@ -29,7 +30,7 @@ import { getStateLabel } from "@/lib/torrent-state-utils" import { resolveTorrentHashes } from "@/lib/torrent-utils" import { getTrackerStatusBadge } from "@/lib/tracker-utils" import { cn, copyTextToClipboard, formatBytes, formatDuration } from "@/lib/utils" -import type { SortedPeersResponse, Torrent, TorrentFile, TorrentPeer, TorrentTracker } from "@/types" +import type { SortedPeersResponse, Torrent, TorrentFile, TorrentFilters, TorrentStreamPayload, TorrentTracker, TorrentPeer } from "@/types" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import "flag-icons/css/flag-icons.min.css" import { Ban, Copy, Loader2, Trash2, UserPlus, X } from "lucide-react" @@ -127,6 +128,63 @@ export const TorrentDetailsPanel = memo(function TorrentDetailsPanel({ instanceI const isContentTabActive = activeTab === "content" const isCrossSeedTabActive = activeTab === "crossseed" + const hashFilter = useMemo(() => { + if (!torrent?.hash) { + return undefined + } + + return { + expr: `Hash == "${torrent.hash}"`, + status: [], + excludeStatus: [], + categories: [], + excludeCategories: [], + tags: [], + excludeTags: [], + trackers: [], + excludeTrackers: [], + } + }, [torrent?.hash]) + const streamParams = useMemo(() => { + if (!hashFilter || !isReady) { + return null + } + + return { + instanceId, + page: 0, + limit: 1, + sort: "added_on", + order: "desc" as const, + filters: hashFilter, + } + }, [hashFilter, instanceId, isReady]) + const [streamTorrent, setStreamTorrent] = useState(null) + const handleStreamPayload = useCallback( + (payload: TorrentStreamPayload) => { + if (!payload?.data || !torrent?.hash) { + return + } + + const nextTorrent = payload.data.torrents?.find(item => item.hash === torrent.hash) ?? null + if (!nextTorrent && payload.data.total === 0) { + setStreamTorrent(null) + return + } + if (nextTorrent) { + setStreamTorrent(nextTorrent) + } + }, + [torrent?.hash] + ) + const streamState = useSyncStream(streamParams, { + enabled: Boolean(streamParams), + onMessage: handleStreamPayload, + }) + + useEffect(() => { + setStreamTorrent(null) + }, [torrent?.hash]) // Fetch torrent properties const { data: properties, isLoading: loadingProperties } = useQuery({ @@ -188,33 +246,25 @@ export const TorrentDetailsPanel = memo(function TorrentDetailsPanel({ instanceI gcTime: 5 * 60 * 1000, }) - // Poll for live torrent data (the prop is a stale snapshot from selection time) - // This keeps state, priority, progress, and other fields up-to-date across all tabs - const { data: liveTorrent } = useQuery({ + const shouldUseFallbackPolling = !!torrent && isReady && (!streamState.connected || !!streamState.error) + + // SSE is primary for live row state; polling only runs while stream is unavailable. + const { data: polledLiveTorrent } = useQuery({ queryKey: ["torrent-live-state", instanceId, torrent?.hash], queryFn: async () => { const response = await api.getTorrents(instanceId, { - filters: { - expr: `Hash == "${torrent!.hash}"`, - status: [], - excludeStatus: [], - categories: [], - excludeCategories: [], - tags: [], - excludeTags: [], - trackers: [], - excludeTrackers: [], - }, + filters: hashFilter!, limit: 1, }) return response.torrents[0] ?? null }, - enabled: !!torrent && isReady, + enabled: shouldUseFallbackPolling && !!hashFilter, staleTime: 1000, - refetchInterval: 2000, + refetchInterval: shouldUseFallbackPolling ? 2000 : false, }) // Merge live data with prop, preferring live values for frequently-changing fields + const liveTorrent = streamTorrent ?? polledLiveTorrent ?? null const displayTorrent = useMemo(() => { if (!torrent) return null if (!liveTorrent) return torrent diff --git a/web/src/components/torrents/TorrentManagementBar.tsx b/web/src/components/torrents/TorrentManagementBar.tsx index 89748cbdd..791db06d3 100644 --- a/web/src/components/torrents/TorrentManagementBar.tsx +++ b/web/src/components/torrents/TorrentManagementBar.tsx @@ -91,7 +91,9 @@ export const TorrentManagementBar = memo(function TorrentManagementBar({ const safeInstanceId = typeof instanceId === "number" && instanceId > 0 ? instanceId : 0 // Use shared metadata hook to leverage cache from table and filter sidebar - const { data: metadata, isLoading: isMetadataLoading } = useInstanceMetadata(safeInstanceId) + const { data: metadata, isLoading: isMetadataLoading } = useInstanceMetadata(safeInstanceId, { + fallbackDelayMs: 1500, + }) const availableTags = metadata?.tags || [] const availableCategories = metadata?.categories || {} const preferences = metadata?.preferences diff --git a/web/src/components/torrents/TorrentTableOptimized.tsx b/web/src/components/torrents/TorrentTableOptimized.tsx index c15865433..7c2919f4d 100644 --- a/web/src/components/torrents/TorrentTableOptimized.tsx +++ b/web/src/components/torrents/TorrentTableOptimized.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026, s0up and the autobrr contributors. + * Copyright (c) 2025, s0up and the autobrr contributors. * SPDX-License-Identifier: GPL-2.0-or-later */ @@ -7,7 +7,6 @@ import { useCrossSeedWarning } from "@/hooks/useCrossSeedWarning" import { useCrossSeedBlocklistActions } from "@/hooks/useCrossSeedBlocklistActions" import { useDateTimeFormatters } from "@/hooks/useDateTimeFormatters" import { useDebounce } from "@/hooks/useDebounce" -import { useDelayedVisibility } from "@/hooks/useDelayedVisibility" import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation" import { usePersistedColumnFilters } from "@/hooks/usePersistedColumnFilters" import { usePersistedColumnOrder } from "@/hooks/usePersistedColumnOrder" @@ -17,13 +16,10 @@ import { usePersistedColumnVisibility } from "@/hooks/usePersistedColumnVisibili import { usePersistedCompactViewState } from "@/hooks/usePersistedCompactViewState" import { TORRENT_ACTIONS, useTorrentActions } from "@/hooks/useTorrentActions" import { useTorrentExporter } from "@/hooks/useTorrentExporter" -import { useTorrentsList } from "@/hooks/useTorrentsList" -import { useTrackerCustomizations } from "@/hooks/useTrackerCustomizations" +import { TORRENT_STREAM_POLL_INTERVAL_SECONDS, useTorrentsList } from "@/hooks/useTorrentsList" import { useTrackerIcons } from "@/hooks/useTrackerIcons" import { columnFiltersToExpr } from "@/lib/column-filter-utils" -import { buildTrackerCustomizationLookup, extractTrackerHost, getTrackerCustomizationsCacheKey, resolveTrackerDisplay, type TrackerCustomizationLookup } from "@/lib/tracker-customizations" -import { resolveTrackerIconSrc } from "@/lib/tracker-icons" -import { formatBytes, getRatioColor } from "@/lib/utils" +import { formatBytes } from "@/lib/utils" import { DndContext, MouseSensor, @@ -95,22 +91,35 @@ import type { TorrentCounts, TorrentFilters } from "@/types" -import { useQuery } from "@tanstack/react-query" -import { useSearch } from "@tanstack/react-router" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useNavigate, useSearch } from "@tanstack/react-router" import { ArrowUpDown, + Ban, + BrickWallFire, ChevronDown, ChevronUp, Columns3, + EthernetPort, + Eye, + EyeOff, Folder, + Globe, + HardDrive, + LayoutGrid, + Loader2, + Rabbit, + RefreshCcw, + Rows3, + Table as TableIcon, Tag, + Turtle, X } from "lucide-react" import { createPortal } from "react-dom" import { AddTorrentDialog, type AddTorrentDropPayload } from "./AddTorrentDialog" import { DeleteTorrentDialog } from "./DeleteTorrentDialog" import { DraggableTableHeader } from "./DraggableTableHeader" -import type { SelectionInfo } from "./GlobalStatusBar" import { SelectAllHotkey } from "./SelectAllHotkey" import { AddTagsDialog, @@ -176,6 +185,9 @@ const DEFAULT_COLUMN_VISIBILITY = { instance: false, // Hidden by default, shown when cross-seed filtering } const DEFAULT_COLUMN_SIZING = {} +const STREAM_STATUS_TRANSITION_DELAY_MS = 800 + +type StreamPhase = "connecting" | "healthy" | "reconnecting" | "fallback" // Helper function to get default column order (module scope for stable reference) function getDefaultColumnOrder(): string[] { @@ -332,6 +344,35 @@ const TrackerIcon = memo(({ title, fallback, src, size = "md", className }: Trac prev.className === next.className ) +const getTrackerDisplayMeta = (tracker?: string) => { + if (!tracker) { + return { + host: "", + fallback: "#", + title: "", + } + } + + const trimmed = tracker.trim() + const fallbackLetter = trimmed ? trimmed.charAt(0).toUpperCase() : "#" + + let host = trimmed + try { + if (trimmed.includes("://")) { + const url = new URL(trimmed) + host = url.hostname + } + } catch { + // Keep host as trimmed value if URL parsing fails + } + + return { + host, + fallback: fallbackLetter, + title: host, + } +} + // Compact row component for desktop interface CompactRowProps { torrent: Torrent @@ -339,7 +380,6 @@ interface CompactRowProps { rowIndex: number isSelected: boolean isRowSelected: boolean - showCheckbox: boolean onClick: (e: React.MouseEvent) => void onContextMenu: () => void onCheckboxPointerDown: (event: React.PointerEvent) => void @@ -348,7 +388,6 @@ interface CompactRowProps { speedUnit: "bytes" | "bits" supportsTrackerHealth: boolean trackerIcons?: Record - trackerCustomizationLookup: TrackerCustomizationLookup style: React.CSSProperties } @@ -358,7 +397,6 @@ const CompactRow = memo(({ rowIndex, isSelected, isRowSelected, - showCheckbox, onClick, onContextMenu, onCheckboxPointerDown, @@ -367,7 +405,6 @@ const CompactRow = memo(({ speedUnit, supportsTrackerHealth, trackerIcons, - trackerCustomizationLookup, style, }: CompactRowProps) => { const displayName = incognitoMode ? getLinuxIsoName(torrent.hash) : torrent.name @@ -380,16 +417,9 @@ const CompactRow = memo(({ [torrent, supportsTrackerHealth] ) - // Resolve tracker display name and icon using customizations - const trackerRaw = incognitoMode ? getLinuxTracker(torrent.hash) : torrent.tracker - const trackerHost = useMemo(() => extractTrackerHost(trackerRaw), [trackerRaw]) - const trackerDisplayInfo = useMemo( - () => resolveTrackerDisplay(trackerHost, trackerCustomizationLookup), - [trackerHost, trackerCustomizationLookup] - ) - const trackerLabel = trackerDisplayInfo.displayName || "" - const trackerIconSrc = resolveTrackerIconSrc(trackerIcons, trackerDisplayInfo.primaryDomain, trackerHost) - const trackerTitle = trackerDisplayInfo.isCustomized ? `${trackerDisplayInfo.displayName} (${trackerHost})` : trackerHost + const trackerValue = incognitoMode ? getLinuxTracker(torrent.hash) : torrent.tracker + const trackerMeta = useMemo(() => getTrackerDisplayMeta(trackerValue), [trackerValue]) + const trackerIconSrc = trackerMeta.host ? trackerIcons?.[trackerMeta.host] ?? null : null // Compact view return ( @@ -414,36 +444,32 @@ const CompactRow = memo(({ )} {/* Name with progress inline */}
- {showCheckbox && ( -
- onCheckboxChange(torrent, rowId, checked === true)} - aria-label="Select torrent" - className="h-4 w-4" +
+ onCheckboxChange(torrent, rowId, checked === true)} + aria-label="Select torrent" + className="h-4 w-4" + /> +
+
+
+ +

+ {displayName} +

- )} -
- - {trackerLabel && ( - - {trackerLabel} - - )}
-

- {displayName} -

{statusBadgeLabel} @@ -456,10 +482,10 @@ const CompactRow = memo(({
Ratio: - + = 1 ? "[color:var(--chart-3)]" : "[color:var(--chart-4)]" + )}> {displayRatio === -1 ? "∞" : displayRatio.toFixed(2)}
@@ -471,13 +497,13 @@ const CompactRow = memo(({
{displayCategory && ( - + {displayCategory} )} {displayTags && (
- + {Array.isArray(displayTags) ? displayTags.join(", ") : displayTags} @@ -531,15 +557,49 @@ const CompactRow = memo(({ prev.torrent.ratio === next.torrent.ratio && prev.isSelected === next.isSelected && prev.isRowSelected === next.isRowSelected && - prev.showCheckbox === next.showCheckbox && prev.incognitoMode === next.incognitoMode && prev.speedUnit === next.speedUnit && prev.supportsTrackerHealth === next.supportsTrackerHealth && prev.trackerIcons === next.trackerIcons && - prev.trackerCustomizationLookup === next.trackerCustomizationLookup && prev.style === next.style ) +interface ExternalIPAddressProps { + address?: string | null + incognitoMode: boolean + label: string +} + +const ExternalIPAddress = memo( + ({ address, incognitoMode, label }: ExternalIPAddressProps) => { + if (!address) return null + + return ( + + + + + {label} + + + +

+ {address} +

+
+
+ ) + }, + (prev, next) => + prev.address === next.address && + prev.incognitoMode === next.incognitoMode && + prev.label === next.label +) + interface TorrentTableOptimizedProps { instanceId: number filters?: TorrentFilters @@ -569,8 +629,6 @@ interface TorrentTableOptimizedProps { canCrossSeedSearch?: boolean onCrossSeedSearch?: (torrent: Torrent) => void isCrossSeedSearching?: boolean - onServerStateUpdate?: (serverState: ServerState | null, listenPort?: number | null) => void - onSelectionInfoUpdate?: (info: SelectionInfo) => void } export const TorrentTableOptimized = memo(function TorrentTableOptimized({ @@ -587,8 +645,6 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ canCrossSeedSearch, onCrossSeedSearch, isCrossSeedSearching, - onServerStateUpdate, - onSelectionInfoUpdate, }: TorrentTableOptimizedProps) { // State management // Move default values outside the component for stable references @@ -607,19 +663,19 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ const [preferencesOpen, setPreferencesOpen] = useState(false) // Filter lifecycle state machine to replace fragile timing-based coordination - type FilterLifecycleState = "idle" | "clearing-all" | "clearing-columns-only" | "cleared" - const [filterLifecycleState, setFilterLifecycleState] = useState("idle") + type FilterLifecycleState = 'idle' | 'clearing-all' | 'clearing-columns-only' | 'cleared' + const [filterLifecycleState, setFilterLifecycleState] = useState('idle') - const [incognitoMode] = useIncognitoMode() + const [incognitoMode, setIncognitoMode] = useIncognitoMode() const { exportTorrents, isExporting: isExportingTorrent } = useTorrentExporter({ instanceId, incognitoMode }) - const [speedUnit] = useSpeedUnits() + const [speedUnit, setSpeedUnit] = useSpeedUnits() const { formatTimestamp } = useDateTimeFormatters() - const { preferences } = useInstancePreferences(instanceId) + const { preferences } = useInstancePreferences(instanceId, { fetchIfMissing: false }) const { instances } = useInstances() const instance = useMemo(() => instances?.find(i => i.id === instanceId), [instances, instanceId]) // Desktop view mode state (separate from mobile view mode) - const { viewMode: desktopViewMode } = usePersistedCompactViewState("normal", TABLE_ALLOWED_VIEW_MODES) + const { viewMode: desktopViewMode, cycleViewMode } = usePersistedCompactViewState("normal", TABLE_ALLOWED_VIEW_MODES) const trackerIconsQuery = useTrackerIcons() const trackerIconsRef = useRef | undefined>(undefined) @@ -638,30 +694,6 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ return latest }, [trackerIconsQuery.data]) - // Tracker customizations for custom display names and merged domains - const trackerCustomizationsQuery = useTrackerCustomizations() - const trackerCustomizationsRef = useRef<{ key: string; lookup: TrackerCustomizationLookup } | undefined>(undefined) - const trackerCustomizationLookup = useMemo(() => { - const latest = trackerCustomizationsQuery.data - if (!latest) { - return trackerCustomizationsRef.current?.lookup ?? new Map() - } - - // Build a cache key from ids + updatedAt to detect any changes - const newKey = getTrackerCustomizationsCacheKey(latest) - - // Check if the lookup has changed using the cache key - const previous = trackerCustomizationsRef.current - if (previous && previous.key === newKey) { - return previous.lookup - } - - // Build a new lookup map from the customizations - const newLookup = buildTrackerCustomizationLookup(latest) - trackerCustomizationsRef.current = { key: newKey, lookup: newLookup } - return newLookup - }, [trackerCustomizationsQuery.data]) - // Detect platform for keyboard shortcuts const isMac = useMemo(() => { return typeof window !== "undefined" && /Mac|iPhone|iPad|iPod/.test(window.navigator.userAgent) @@ -673,6 +705,7 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ const previousInstanceIdRef = useRef(instanceId) const previousSearchRef = useRef("") const lastMetadataRef = useRef<{ + instanceId?: number counts?: TorrentCounts categories?: Record tags?: string[] @@ -727,15 +760,6 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ // Column filters with persistence const [columnFilters, setColumnFilters] = usePersistedColumnFilters(instanceId) - // Remove filters for columns that are no longer visible - useEffect(() => { - setColumnFilters(prev => { - if (prev.length === 0) return prev - const filtered = prev.filter(f => columnVisibility[f.columnId] !== false) - return filtered.length === prev.length ? prev : filtered - }) - }, [columnVisibility, setColumnFilters]) - // Progressive loading state with async management const [loadedRows, setLoadedRows] = useState(100) const [isLoadingMoreRows, setIsLoadingMoreRows] = useState(false) @@ -833,7 +857,6 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ instanceName: instance?.name ?? "", torrents: contextTorrents, }) - const hasCrossSeedTag = useMemo( () => anyTorrentHasTag(contextTorrents, "cross-seed") || anyTorrentHasTag(crossSeedWarning.affectedTorrents, "cross-seed"), [contextTorrents, crossSeedWarning.affectedTorrents] @@ -842,7 +865,9 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ const { blockCrossSeedHashes } = useCrossSeedBlocklistActions(instanceId) // Fetch metadata using shared hook - const { data: metadata, isLoading: isMetadataLoading } = useInstanceMetadata(instanceId) + const { data: metadata, isLoading: isMetadataLoading } = useInstanceMetadata(instanceId, { + fallbackDelayMs: 1500, + }) const availableTags = metadata?.tags || [] const availableCategories = metadata?.categories || {} const isLoadingTags = isMetadataLoading && availableTags.length === 0 @@ -888,6 +913,7 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ // Debounce search to prevent excessive filtering (200ms delay for faster response) const debouncedSearch = useDebounce(globalFilter, 200) const routeSearch = useSearch({ strict: false }) as { q?: string } + const navigate = useNavigate() const rawRouteSearch = typeof routeSearch?.q === "string" ? routeSearch.q : "" const searchFromRoute = rawRouteSearch.trim() @@ -907,7 +933,7 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ // Detect if this is cross-seed filtering (same logic as in useTorrentsList) const isDoingCrossSeedFiltering = useMemo(() => { - return filters?.expr?.includes("Hash ==") && filters?.expr?.includes("||") + return filters?.expr?.includes('Hash ==') && filters?.expr?.includes('||') }, [filters?.expr]) // Combine column filters with any existing filter expression @@ -973,9 +999,6 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ const effectiveIncludedCategories = filters?.expandedCategories ?? filters?.categories ?? [] const effectiveExcludedCategories = filters?.expandedExcludeCategories ?? filters?.excludeCategories ?? [] - const { isHiddenDelayed, isVisible } = useDelayedVisibility(3000) - const isVisibilitySettled = isHiddenDelayed || isVisible - // Fetch torrents data with backend sorting const { torrents, @@ -993,10 +1016,15 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ isLoadingMore, hasLoadedAll, loadMore: backendLoadMore, + streamConnected, + streamMeta, + isStreaming, + streamError, + streamRetrying, + streamNextRetryAt, + streamRetryAttempt, isCrossSeedFiltering, } = useTorrentsList(instanceId, { - enabled: true, - pollingEnabled: isVisibilitySettled, search: effectiveSearch, filters: { status: filters?.status || [], @@ -1015,7 +1043,109 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ order: activeSortOrder, }) - const supportsTrackerHealth = capabilities?.supportsTrackerHealth ?? false + const derivedStreamPhase = useMemo(() => { + if (streamRetrying || typeof streamNextRetryAt === "number") { + return "reconnecting" + } + if (streamError) { + return "fallback" + } + if (isStreaming) { + return "healthy" + } + return "connecting" + }, [isStreaming, streamError, streamNextRetryAt, streamRetrying]) + + const stableStreamPhase = useDebounce( + derivedStreamPhase, + derivedStreamPhase === "healthy" || derivedStreamPhase === "fallback" ? 0 : STREAM_STATUS_TRANSITION_DELAY_MS + ) + + const streamStatus = useMemo(() => { + if (isCrossSeedFiltering) { + return { + label: "Cross-instance polling", + message: "Aggregated cross-seed results refresh via polling.", + secondary: "SSE disabled • polling every 10s", + tone: "muted" as const, + animate: false, + } + } + + const serverRetrySeconds = + typeof streamMeta?.retryInSeconds === "number" && streamMeta.retryInSeconds > 0 + ? streamMeta.retryInSeconds + : null + const safeRetryAttempt = + typeof streamRetryAttempt === "number" && streamRetryAttempt > 0 ? streamRetryAttempt : 1 + const hasClientRetryScheduled = typeof streamNextRetryAt === "number" + + switch (stableStreamPhase) { + case "reconnecting": + return { + label: "Stream reconnecting…", + message: streamError ?? "Attempting to restore SSE connection.", + secondary: hasClientRetryScheduled + ? `Retry attempt ${safeRetryAttempt} queued` + : "Polling continues while the stream recovers.", + tone: "warning" as const, + animate: true, + } + case "fallback": + return { + label: "Stream offline – using polling", + message: streamError ?? "Falling back to periodic refresh while the stream is unavailable.", + secondary: + serverRetrySeconds && serverRetrySeconds > 0 + ? `Server retry in ${serverRetrySeconds}s — polling fallback active` + : "Retrying automatically with polling fallback", + tone: "error" as const, + animate: false, + } + case "healthy": + return { + label: "", + message: null, + secondary: null, + tone: "success" as const, + animate: false, + } + default: + return { + label: "Connecting to stream…", + message: `Using ${TORRENT_STREAM_POLL_INTERVAL_SECONDS}s polling until the SSE connection is ready.`, + secondary: `Polling every ${TORRENT_STREAM_POLL_INTERVAL_SECONDS}s`, + tone: streamConnected ? ("warning" as const) : ("muted" as const), + animate: !streamConnected, + } + } + }, [ + isCrossSeedFiltering, + stableStreamPhase, + streamConnected, + streamError, + streamMeta, + streamNextRetryAt, + streamRetryAttempt, + ]) + + const streamToneStyles = useMemo(() => { + switch (streamStatus.tone) { + case "success": + return { dot: "bg-emerald-500 shadow-[0_0_0_2px] shadow-emerald-500/25", text: "text-emerald-600 dark:text-emerald-400" } + case "error": + return { dot: "bg-destructive shadow-[0_0_0_2px] shadow-destructive/20", text: "text-destructive" } + case "warning": + return { dot: "bg-amber-400 shadow-[0_0_0_2px] shadow-amber-400/25", text: "text-amber-600 dark:text-amber-400" } + default: + return { dot: "bg-muted-foreground/60", text: "text-muted-foreground" } + } + }, [streamStatus.tone]) + const hasStreamStatusLabel = streamStatus.label.length > 0 + const hasStreamStatusDetails = + hasStreamStatusLabel || Boolean(streamStatus.message) || Boolean(streamStatus.secondary) + + const supportsTrackerHealth = capabilities?.supportsTrackerHealth ?? true const supportsSubcategories = capabilities?.supportsSubcategories ?? false const allowSubcategories = supportsSubcategories && (preferences?.use_subcategories ?? subcategoriesFromData ?? false) @@ -1102,13 +1232,18 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ return } - const nextCounts = counts ?? lastMetadataRef.current.counts - const nextCategories = categories ?? lastMetadataRef.current.categories - const nextTags = tags ?? lastMetadataRef.current.tags - const prevSupportsSubcategories = lastMetadataRef.current.supportsSubcategories ?? false - const previousUseSubcategories = lastMetadataRef.current.useSubcategories ?? false + const cachedMetadata = + lastMetadataRef.current.instanceId === instanceId + ? lastMetadataRef.current + : ({} as typeof lastMetadataRef.current) + + const nextCounts = counts ?? cachedMetadata.counts + const nextCategories = categories ?? cachedMetadata.categories + const nextTags = tags ?? cachedMetadata.tags + const prevSupportsSubcategories = cachedMetadata.supportsSubcategories ?? false + const previousUseSubcategories = cachedMetadata.useSubcategories ?? false const nextSupportsSubcategories = supportsSubcategories - const nextUseSubcategories = nextSupportsSubcategories ? (subcategoriesFromData ?? previousUseSubcategories) : false + const nextUseSubcategories = nextSupportsSubcategories? (subcategoriesFromData ?? previousUseSubcategories): false const nextTotalCount = totalCount const hasAnyMetadata = @@ -1123,14 +1258,14 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ } const metadataChanged = - nextCounts !== lastMetadataRef.current.counts || - nextCategories !== lastMetadataRef.current.categories || - nextTags !== lastMetadataRef.current.tags || + nextCounts !== cachedMetadata.counts || + nextCategories !== cachedMetadata.categories || + nextTags !== cachedMetadata.tags || nextSupportsSubcategories !== prevSupportsSubcategories || nextUseSubcategories !== previousUseSubcategories || - nextTotalCount !== lastMetadataRef.current.totalCount + nextTotalCount !== cachedMetadata.totalCount - const torrentsLengthChanged = torrents.length !== (lastMetadataRef.current.torrentsLength ?? -1) + const torrentsLengthChanged = torrents.length !== (cachedMetadata.torrentsLength ?? -1) if (!metadataChanged && !torrentsLengthChanged) { return @@ -1146,6 +1281,7 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ ) lastMetadataRef.current = { + instanceId, counts: nextCounts, categories: nextCategories, tags: nextTags, @@ -1160,8 +1296,8 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ const sortedTorrents = torrents // Atomic filter clearing callback - const clearFiltersAtomically = useCallback((mode: "all" | "columns-only" = "all") => { - setFilterLifecycleState(mode === "all" ? "clearing-all" : "clearing-columns-only"); + const clearFiltersAtomically = useCallback((mode: 'all' | 'columns-only' = 'all') => { + setFilterLifecycleState(mode === 'all' ? 'clearing-all' : 'clearing-columns-only'); }, []); const effectiveServerState = useMemo(() => { const cached = serverStateRef.current @@ -1185,12 +1321,6 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ return cached.state }, [serverState, instanceId]) - // Notify parent of server state updates - const listenPort = metadata?.preferences?.listen_port - useEffect(() => { - onServerStateUpdate?.(effectiveServerState, listenPort) - }, [effectiveServerState, listenPort, onServerStateUpdate]) - const selectedRowIds = useMemo(() => { const ids: string[] = [] for (const [rowId, isSelected] of Object.entries(rowSelection)) { @@ -1322,8 +1452,8 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ onRowSelection: handleRowSelection, isAllSelected, excludedFromSelectAll, - }, speedUnit, trackerIcons, formatTimestamp, preferences, supportsTrackerHealth, isCrossSeedFiltering, desktopViewMode as TableViewMode, trackerCustomizationLookup), - [incognitoMode, speedUnit, trackerIcons, formatTimestamp, handleSelectAll, isSelectAllChecked, isSelectAllIndeterminate, handleRowSelection, isAllSelected, excludedFromSelectAll, preferences, supportsTrackerHealth, isCrossSeedFiltering, desktopViewMode, trackerCustomizationLookup] + }, speedUnit, trackerIcons, formatTimestamp, preferences, supportsTrackerHealth, isCrossSeedFiltering, desktopViewMode as TableViewMode), + [incognitoMode, speedUnit, trackerIcons, formatTimestamp, handleSelectAll, isSelectAllChecked, isSelectAllIndeterminate, handleRowSelection, isAllSelected, excludedFromSelectAll, preferences, supportsTrackerHealth, isCrossSeedFiltering, desktopViewMode] ) const torrentIdentityCounts = useMemo(() => { @@ -1374,8 +1504,8 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ ...(isCrossSeedFiltering && { columnFilters: columnFilters.map(filter => ({ id: filter.columnId, - value: filter.value, - })), + value: filter.value + })) }), }, onSortingChange: setSorting, @@ -1397,7 +1527,7 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ // Fix virtualization when column filters are cleared in cross-seed mode // Only run when lifecycle is idle to avoid racing with filter lifecycle handler useEffect(() => { - if (filterLifecycleState === "idle" && isCrossSeedFiltering && columnFilters.length === 0) { + if (filterLifecycleState === 'idle' && isCrossSeedFiltering && columnFilters.length === 0) { // Reset loadedRows to ensure all rows are visible when filters are cleared const targetRows = Math.min(100, sortedTorrents.length) // Use functional update to ensure idempotent, non-racing updates @@ -1584,6 +1714,75 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ return getTotalSize(selectedTorrents) }, [isAllSelected, stats?.totalSize, excludedFromSelectAll, sortedTorrents, selectedTorrents]) const selectedFormattedSize = useMemo(() => formatBytes(selectedTotalSize), [selectedTotalSize]) + const queryClient = useQueryClient() + + const [altSpeedOverride, setAltSpeedOverride] = useState(null) + const serverAltSpeedEnabled = effectiveServerState?.use_alt_speed_limits + const hasAltSpeedStatus = typeof serverAltSpeedEnabled === "boolean" + const isAltSpeedKnown = altSpeedOverride !== null || hasAltSpeedStatus + const altSpeedEnabled = altSpeedOverride ?? serverAltSpeedEnabled ?? false + const AltSpeedIcon = altSpeedEnabled ? Turtle : Rabbit + const altSpeedIconClass = isAltSpeedKnown ? altSpeedEnabled ? "text-destructive" : "text-green-500" : "text-muted-foreground" + + useEffect(() => { + setAltSpeedOverride(null) + }, [instanceId]) + + const { mutateAsync: toggleAltSpeedLimits, isPending: isTogglingAltSpeed } = useMutation({ + mutationFn: () => api.toggleAlternativeSpeedLimits(instanceId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["torrents-list", instanceId] }) + queryClient.invalidateQueries({ queryKey: ["alternative-speed-limits", instanceId] }) + }, + }) + + useEffect(() => { + if (altSpeedOverride === null) { + return + } + + if (serverAltSpeedEnabled === altSpeedOverride) { + setAltSpeedOverride(null) + } + }, [serverAltSpeedEnabled, altSpeedOverride]) + + // Poll for async cross-seed filtering status updates + + + const handleToggleAltSpeedLimits = useCallback(async () => { + if (isTogglingAltSpeed) { + return + } + + const current = altSpeedOverride ?? serverAltSpeedEnabled ?? false + const next = !current + + setAltSpeedOverride(next) + + try { + await toggleAltSpeedLimits() + } catch { + setAltSpeedOverride(current) + } + }, [altSpeedOverride, serverAltSpeedEnabled, toggleAltSpeedLimits, isTogglingAltSpeed]) + + const altSpeedTooltip = isAltSpeedKnown ? altSpeedEnabled ? "Alternative speed limits: On" : "Alternative speed limits: Off" : "Alternative speed limits status unknown" + const altSpeedAriaLabel = isAltSpeedKnown ? altSpeedEnabled ? "Disable alternative speed limits" : "Enable alternative speed limits" : "Alternative speed limits status unknown" + + const rawConnectionStatus = effectiveServerState?.connection_status ?? "" + const normalizedConnectionStatus = rawConnectionStatus ? rawConnectionStatus.trim().toLowerCase() : "" + const formattedConnectionStatus = normalizedConnectionStatus ? normalizedConnectionStatus.replace(/_/g, " ") : "" + const connectionStatusDisplay = formattedConnectionStatus ? formattedConnectionStatus.replace(/\b\w/g, (char: string) => char.toUpperCase()) : "" + const hasConnectionStatus = Boolean(formattedConnectionStatus) + const isConnectable = normalizedConnectionStatus === "connected" + const isFirewalled = normalizedConnectionStatus === "firewalled" + const ConnectionStatusIcon = isConnectable ? Globe : isFirewalled ? BrickWallFire : hasConnectionStatus ? Ban : Globe + const listenPort = metadata?.preferences?.listen_port + const connectionStatusTooltip = hasConnectionStatus + ? `${isConnectable ? "Connectable" : connectionStatusDisplay}${listenPort ? `. Port: ${listenPort}` : ""}` + : "Connection status unknown" + const connectionStatusIconClass = hasConnectionStatus ? isConnectable ? "text-green-500" : isFirewalled ? "text-amber-500" : "text-destructive" : "text-muted-foreground" + const connectionStatusAriaLabel = hasConnectionStatus ? `qBittorrent connection status: ${connectionStatusDisplay || formattedConnectionStatus}` : "qBittorrent connection status unknown" // Size shown in destructive dialogs - prefer the aggregate when select-all is active const deleteDialogTotalSize = useMemo(() => { @@ -1653,30 +1852,6 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ } }, [onSelectionChange, selectedHashes, selectedTorrents, isAllSelected, effectiveSelectionCount, excludedFromSelectAll, selectedTotalSize, selectAllFilters, filters]) - // Callback for context menu to fetch field for matching torrents - const fetchAllTorrentField = useCallback(async (field: "name" | "hash" | "full_path"): Promise => { - const response = await api.getTorrentField(instanceId, field, { - sort: activeSortField, - order: activeSortOrder, - search: effectiveSearch, - filters: { - status: filters?.status || [], - excludeStatus: filters?.excludeStatus || [], - categories: effectiveIncludedCategories, - excludeCategories: effectiveExcludedCategories, - tags: filters?.tags || [], - excludeTags: filters?.excludeTags || [], - trackers: filters?.trackers || [], - excludeTrackers: filters?.excludeTrackers || [], - expandedCategories: filters?.expandedCategories, - expandedExcludeCategories: filters?.expandedExcludeCategories, - expr: combinedFiltersExpr || undefined, - }, - excludeHashes: excludedFromSelectAll.size > 0 ? Array.from(excludedFromSelectAll) : undefined, - }) - return response.values - }, [instanceId, filters, effectiveIncludedCategories, effectiveExcludedCategories, combinedFiltersExpr, activeSortField, activeSortOrder, effectiveSearch, excludedFromSelectAll]) - // Virtualization setup with progressive loading const { rows } = table.getRowModel() const parentRef = useRef(null) @@ -1710,47 +1885,11 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ // Also keep loadedRows in sync with actual data to prevent status display issues useEffect(() => { - if (filterLifecycleState === "idle" && loadedRows > rows.length && rows.length > 0) { + if (filterLifecycleState === 'idle' && loadedRows > rows.length && rows.length > 0) { setLoadedRows(rows.length) } }, [loadedRows, rows.length, filterLifecycleState]) - // Notify parent of selection info updates - useEffect(() => { - onSelectionInfoUpdate?.({ - effectiveSelectionCount, - isAllSelected, - excludedFromSelectAllSize: excludedFromSelectAll.size, - selectedFormattedSize, - torrentsLength: torrents.length, - totalCount, - hasLoadedAll, - isLoading, - isLoadingMore, - isCachedData, - isStaleData, - emptyStateMessage, - safeLoadedRows, - rowsLength: rows.length, - }) - }, [ - onSelectionInfoUpdate, - effectiveSelectionCount, - isAllSelected, - excludedFromSelectAll.size, - selectedFormattedSize, - torrents.length, - totalCount, - hasLoadedAll, - isLoading, - isLoadingMore, - isCachedData, - isStaleData, - emptyStateMessage, - safeLoadedRows, - rows.length, - ]) - // Compute estimated row height based on view mode - used by virtualizer and keyboard navigation const estimatedRowHeight = useMemo(() => { switch (desktopViewMode) { @@ -1794,7 +1933,7 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ // Filter lifecycle state machine useLayoutEffect(() => { - if (filterLifecycleState === "clearing-all" || filterLifecycleState === "clearing-columns-only") { + if (filterLifecycleState === 'clearing-all' || filterLifecycleState === 'clearing-columns-only') { // Perform clearing operations atomically setColumnFilters([]); @@ -1807,7 +1946,7 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ setLoadedRows(newLoadedRows); // Only clear parent filters if clearing all (not just columns) - if (filterLifecycleState === "clearing-all") { + if (filterLifecycleState === 'clearing-all') { const emptyFilters: TorrentFilters = { status: [], excludeStatus: [], @@ -1816,16 +1955,16 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ tags: [], excludeTags: [], trackers: [], - excludeTrackers: [], + excludeTrackers: [] }; onFilterChange?.(emptyFilters); } // Transition to cleared state - setFilterLifecycleState("cleared"); - } else if (filterLifecycleState === "cleared") { + setFilterLifecycleState('cleared'); + } else if (filterLifecycleState === 'cleared') { // Reset to idle state after clearing is complete - setFilterLifecycleState("idle"); + setFilterLifecycleState('idle'); } }, [filterLifecycleState, virtualizer, onFilterChange, setLoadedRows, sortedTorrents.length]); @@ -1926,56 +2065,6 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ lastSelectedIndexRef.current = null }, [sortedTorrents.length, setIsAllSelected, setExcludedFromSelectAll, setRowSelection]) - // Open delete dialog with Delete key for current selection. - useEffect(() => { - const handleDeleteHotkey = (event: KeyboardEvent) => { - const isDeleteKey = event.key === "Delete" - const isBackspaceDelete = isMac && event.key === "Backspace" && !event.metaKey && !event.ctrlKey && !event.altKey - - // Mac keyboards commonly emit Backspace for the key labeled Delete. - if (!isDeleteKey && !isBackspaceDelete) { - return - } - - if (showDeleteDialog || isPending || effectiveSelectionCount === 0) { - return - } - - const target = event.target - const elementTarget = target instanceof Element ? target : null - - if ( - elementTarget && - (elementTarget.tagName === "INPUT" || - elementTarget.tagName === "TEXTAREA" || - elementTarget.tagName === "SELECT" || - elementTarget instanceof HTMLElement && elementTarget.isContentEditable || - elementTarget.closest("[role=\"dialog\"]") || - elementTarget.closest("[role=\"combobox\"]")) - ) { - return - } - - event.preventDefault() - event.stopPropagation() - prepareDeleteAction(selectedHashes, selectedTorrents) - } - - window.addEventListener("keydown", handleDeleteHotkey) - - return () => { - window.removeEventListener("keydown", handleDeleteHotkey) - } - }, [ - effectiveSelectionCount, - isMac, - isPending, - prepareDeleteAction, - selectedHashes, - selectedTorrents, - showDeleteDialog, - ]) - // Wrapper functions to adapt hook handlers to component needs const selectAllOptions = useMemo(() => ({ selectAll: isAllSelected, @@ -2032,10 +2121,14 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ } // Include cross-seed hashes if user opted to delete them - const hashesToDelete = deleteCrossSeeds ? [...contextHashes, ...crossSeedWarning.affectedTorrents.map(t => t.hash)] : contextHashes + const hashesToDelete = deleteCrossSeeds + ? [...contextHashes, ...crossSeedWarning.affectedTorrents.map(t => t.hash)] + : contextHashes // Update count to include cross-seeds for accurate toast message - const deleteClientMeta = deleteCrossSeeds ? { clientHashes: hashesToDelete, totalSelected: hashesToDelete.length } : contextClientMeta + const deleteClientMeta = deleteCrossSeeds + ? { clientHashes: hashesToDelete, totalSelected: hashesToDelete.length } + : contextClientMeta await handleDelete( hashesToDelete, @@ -2047,8 +2140,8 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ ) }, [ blockCrossSeedHashes, - contextHashes, contextClientMeta, + contextHashes, contextTorrents, crossSeedWarning.affectedTorrents, deleteCrossSeeds, @@ -2274,56 +2367,21 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ enabled={sortedTorrents.length > 0} />
- {/* Search and Actions */} -
- {/* Search bar row */} -
- {/* Action buttons - now handled by Management Bar in Header */} -
- - {/* Column controls next to search via portal, with inline fallback */} - {(() => { - const container = typeof document !== "undefined" ? document.getElementById("header-search-actions") : null - const actions = ( - <> - {desktopViewMode === "compact" && compactSortOptions.length > 0 && ( -
- - - { - e.preventDefault() - }} - > - - - - - Change sort field - - - Sort by - - handleCompactSortFieldChange(value as TorrentSortOptionValue)} - > - {compactSortOptions.map(option => ( - - {option.label} - - ))} - - - + {/* Search and Actions */} +
+ {/* Search bar row */} +
+ {/* Action buttons - now handled by Management Bar in Header */} +
+ + {/* Column controls next to search via portal, with inline fallback */} + {(() => { + const container = typeof document !== "undefined" ? document.getElementById("header-search-actions") : null + const actions = ( + <> + {desktopViewMode === "compact" && compactSortOptions.length > 0 && ( +
+ - + + + - Sort {activeSortOrder === "desc" ? "ascending" : "descending"} + Change sort field -
- )} - - {columnFilters.length > 0 && ( - + + Sort by + + handleCompactSortFieldChange(value as TorrentSortOptionValue)} + > + {compactSortOptions.map(option => ( + + {option.label} + + ))} + + + + { - // Prevent tooltip from showing on focus - only show on hover e.preventDefault() }} > - Clear all column filters ({columnFilters.length}) + Sort {activeSortOrder === "desc" ? "ascending" : "descending"} - )} +
+ )} - {desktopViewMode !== "compact" && ( - - - { - // Prevent tooltip from showing on focus - only show on hover - e.preventDefault() - }} - > - - + + Clear all column filters ({columnFilters.length}) + + )} + + {desktopViewMode !== "compact" && ( + + + { + // Prevent tooltip from showing on focus - only show on hover + e.preventDefault() + }} + > + + + + + Toggle columns + + + Toggle columns + + {table + .getAllColumns() + .filter( + (column) => + column.id !== "select" && // Never show select in visibility options + column.getCanHide() + ) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + onSelect={(e) => e.preventDefault()} > - - Toggle columns - - - - Toggle columns - - - Toggle columns - - {table - .getAllColumns() - .filter( - (column) => - column.getCanHide() + + {(column.columnDef.meta as { headerString?: string })?.headerString || + (typeof column.columnDef.header === "string" ? column.columnDef.header : column.id)} + + ) - .map((column) => { - return ( - - column.toggleVisibility(!!value) - } - onSelect={(e) => e.preventDefault()} - > - - {(column.columnDef.meta as { headerString?: string })?.headerString || - (typeof column.columnDef.header === "string" ? column.columnDef.header : column.id)} - - - ) - })} - - - )} - - ) - - return container ? createPortal(actions, container) : actions - })()} - - -
+ })} + + + )} + + ) + + return container ? createPortal(actions, container) : actions + })()} + +
+
- {/* Table container */} -
- {/* Virtual scroll container with paint containment optimization for improved rendering performance */} - - {/* Loading overlay - positioned absolute to scroll container */} - {torrents.length === 0 && showLoadingState && ( -
-
- -

Loading torrents...

-
+ {/* Table container */} +
+ {/* Virtual scroll container with paint containment optimization for improved rendering performance */} + + {/* Loading overlay - positioned absolute to scroll container */} + {torrents.length === 0 && showLoadingState && ( +
+
+ +

Loading torrents...

- )} - {torrents.length === 0 && !isLoading && ( -
+ )} + {torrents.length === 0 && !isLoading && ( +
+
+

{emptyStateMessage}

+ {hasFilterControls && ( + )} - > -
-

{emptyStateMessage}

- {hasFilterControls && ( - - )} -
- )} +
+ )} -
- {/* Header - show in normal and dense table views */} - {desktopViewMode !== "compact" && ( -
- { - const { active, over } = event - if (!active || !over || active.id === over.id) { - return - } - - setColumnOrder((currentOrder: string[]) => { - const allColumnIds = table.getAllLeafColumns().map((col) => col.id) - - // Normalize current order to include all current columns exactly once - const sanitizedOrder = [ - ...currentOrder.filter((id) => allColumnIds.includes(id)), - ...allColumnIds.filter((id) => !currentOrder.includes(id)), - ] - - const oldIndex = sanitizedOrder.indexOf(active.id as string) - const newIndex = sanitizedOrder.indexOf(over.id as string) - - if (oldIndex === -1 || newIndex === -1) { - return sanitizedOrder - } +
+ {/* Header - show in normal and dense table views */} + {desktopViewMode !== "compact" && ( +
+ { + const { active, over } = event + if (!active || !over || active.id === over.id) { + return + } - return arrayMove(sanitizedOrder, oldIndex, newIndex) - }) - }} - modifiers={[restrictToHorizontalAxis]} - > - {table.getHeaderGroups().map(headerGroup => { - const headers = headerGroup.headers - const headerIds = headers.map(h => h.column.id) + setColumnOrder((currentOrder: string[]) => { + const allColumnIds = table.getAllLeafColumns().map((col) => col.id) - // Use memoized minTableWidth + // Normalize current order to include all current columns exactly once + const sanitizedOrder = [ + ...currentOrder.filter((id) => allColumnIds.includes(id)), + ...allColumnIds.filter((id) => !currentOrder.includes(id)), + ] - return ( - -
- {headers.map(header => ( - { - if (filter === null) { - setColumnFilters(columnFilters.filter(f => f.columnId !== columnId)) - } else { - const existing = columnFilters.findIndex(f => f.columnId === columnId) - if (existing >= 0) { - const newFilters = [...columnFilters] - newFilters[existing] = filter - setColumnFilters(newFilters) - } else { - setColumnFilters([...columnFilters, filter]) - } - } - }} - /> - ))} -
-
- ) - })} -
-
- )} + const oldIndex = sanitizedOrder.indexOf(active.id as string) + const newIndex = sanitizedOrder.indexOf(over.id as string) - {/* Body */} -
{ - // Click on empty table space clears all selection. - if (e.target !== e.currentTarget) { - return - } + if (oldIndex === -1 || newIndex === -1) { + return sanitizedOrder + } - if (!isAllSelected && selectedRowIds.length === 0) { - return - } - - resetSelectionState() - onTorrentSelect?.(null) - }} - style={{ - height: `${virtualizer.getTotalSize()}px`, - width: "100%", - position: "relative", + return arrayMove(sanitizedOrder, oldIndex, newIndex) + }) }} + modifiers={[restrictToHorizontalAxis]} > - {virtualRows.map(virtualRow => { - const row = rows[virtualRow.index] - if (!row || !row.original) return null - const torrent = row.original - const isSelected = selectedTorrent?.hash === torrent.hash - const isRowSelected = isAllSelected ? !excludedFromSelectAll.has(torrent.hash) : row.getIsSelected() - - // Render compact view for compact mode - if (desktopViewMode === "compact") { - return ( - - { - const target = e.target as HTMLElement - const isCheckboxElement = target.closest("[data-slot=\"checkbox\"]") || target.closest("[role=\"checkbox\"]") - if (isCheckboxElement) { - return - } - // Handle shift-click for range selection - if (e.shiftKey) { - e.preventDefault() - const allRows = table.getRowModel().rows - const currentIndex = allRows.findIndex(r => r.id === row.id) - if (lastSelectedIndexRef.current !== null) { - const start = Math.min(lastSelectedIndexRef.current, currentIndex) - const end = Math.max(lastSelectedIndexRef.current, currentIndex) - for (let i = start; i <= end; i++) { - const targetRow = allRows[i] - if (targetRow) { - handleRowSelection(targetRow.original.hash, true, targetRow.id) - } - } + {table.getHeaderGroups().map(headerGroup => { + const headers = headerGroup.headers + const headerIds = headers.map(h => h.column.id) + + // Use memoized minTableWidth + + return ( + +
+ {headers.map(header => ( + { + if (filter === null) { + setColumnFilters(columnFilters.filter(f => f.columnId !== columnId)) } else { - handleRowSelection(torrent.hash, true, row.id) - lastSelectedIndexRef.current = currentIndex - } - } else if (e.ctrlKey || e.metaKey) { - const allRows = table.getRowModel().rows - const currentIndex = allRows.findIndex(r => r.id === row.id) - handleRowSelection(torrent.hash, !isRowSelected, row.id) - lastSelectedIndexRef.current = currentIndex - } else { - // Plain click - open details panel - // Re-clicking the currently focused row toggles both details and selection off. - if (isSelected && isRowSelected) { - if (isAllSelected) { - handleRowSelection(torrent.hash, false, row.id) + const existing = columnFilters.findIndex(f => f.columnId === columnId) + if (existing >= 0) { + const newFilters = [...columnFilters] + newFilters[existing] = filter + setColumnFilters(newFilters) } else { - setRowSelection(prev => { - if (!prev[row.id]) { - return prev - } - - const next = { ...prev } - delete next[row.id] - return next - }) - - if (selectedRowIds.length <= 1) { - lastSelectedIndexRef.current = null - } + setColumnFilters([...columnFilters, filter]) } - - onTorrentSelect?.(null) - return } + }} + /> + ))} +
+
+ ) + })} + +
+ )} - // If row is not selected, select only this torrent (replace selection). - if (!isRowSelected) { - const allRows = table.getRowModel().rows - const currentIndex = allRows.findIndex(r => r.id === row.id) - setIsAllSelected(false) - setExcludedFromSelectAll(new Set()) - setRowSelection({ [row.id]: true }) - lastSelectedIndexRef.current = currentIndex - } - onTorrentSelect?.(torrent) - } - }} - onContextMenu={() => { - if (!isRowSelected && selectedHashes.length <= 1) { - setRowSelection({ [row.id]: true }) - } - }} - incognitoMode={incognitoMode} - speedUnit={speedUnit} - supportsTrackerHealth={supportsTrackerHealth} - trackerIcons={trackerIcons} - trackerCustomizationLookup={trackerCustomizationLookup} - onCheckboxPointerDown={handleCompactCheckboxPointerDown} - onCheckboxChange={handleCompactCheckboxChange} - style={{ - position: "absolute", - top: 0, - left: 0, - width: "100%", - height: `${virtualRow.size}px`, - transform: `translateY(${virtualRow.start}px)`, - }} - /> - - ) - } - - // Use memoized minTableWidth for normal table view + {/* Body */} +
+ {virtualRows.map(virtualRow => { + const row = rows[virtualRow.index] + if (!row || !row.original) return null + const torrent = row.original + const isSelected = selectedTorrent?.hash === torrent.hash + const isRowSelected = isAllSelected ? !excludedFromSelectAll.has(torrent.hash) : row.getIsSelected() + + // Render compact view for compact mode + if (desktopViewMode === "compact") { return ( -
{ - // Don't select when clicking checkbox or its wrapper const target = e.target as HTMLElement - const isCheckbox = target.closest("[data-slot=\"checkbox\"]") || target.closest("[role=\"checkbox\"]") || target.closest(".p-1.-m-1") - if (!isCheckbox) { - // Handle shift-click for range selection - EXACTLY like checkbox - if (e.shiftKey) { - e.preventDefault() // Prevent text selection - - const allRows = table.getRowModel().rows - const currentIndex = allRows.findIndex(r => r.id === row.id) - - if (lastSelectedIndexRef.current !== null) { - const start = Math.min(lastSelectedIndexRef.current, currentIndex) - const end = Math.max(lastSelectedIndexRef.current, currentIndex) - - // Select range EXACTLY like checkbox does - for (let i = start; i <= end; i++) { - const targetRow = allRows[i] - if (targetRow) { - handleRowSelection(targetRow.original.hash, true, targetRow.id) - } + const isCheckboxElement = target.closest("[data-slot=\"checkbox\"]") || target.closest("[role=\"checkbox\"]") + if (isCheckboxElement) { + return + } + // Handle shift-click for range selection + if (e.shiftKey) { + e.preventDefault() + const allRows = table.getRowModel().rows + const currentIndex = allRows.findIndex(r => r.id === row.id) + if (lastSelectedIndexRef.current !== null) { + const start = Math.min(lastSelectedIndexRef.current, currentIndex) + const end = Math.max(lastSelectedIndexRef.current, currentIndex) + for (let i = start; i <= end; i++) { + const targetRow = allRows[i] + if (targetRow) { + handleRowSelection(targetRow.original.hash, true, targetRow.id) } - } else { - // No anchor - just select this row - handleRowSelection(torrent.hash, true, row.id) - lastSelectedIndexRef.current = currentIndex } - - // Don't update lastSelectedIndexRef on shift-click (keeps anchor stable) - } else if (e.ctrlKey || e.metaKey) { - // Ctrl/Cmd click - toggle single row EXACTLY like checkbox + } else { + handleRowSelection(torrent.hash, true, row.id) + lastSelectedIndexRef.current = currentIndex + } + } else if (e.ctrlKey || e.metaKey) { + const allRows = table.getRowModel().rows + const currentIndex = allRows.findIndex(r => r.id === row.id) + handleRowSelection(torrent.hash, !isRowSelected, row.id) + lastSelectedIndexRef.current = currentIndex + } else { + // Plain click - open details panel + // If row is already selected, keep selection intact + // Otherwise, select only this torrent (replace selection) + if (!isRowSelected) { const allRows = table.getRowModel().rows const currentIndex = allRows.findIndex(r => r.id === row.id) - - handleRowSelection(torrent.hash, !isRowSelected, row.id) + setIsAllSelected(false) + setExcludedFromSelectAll(new Set()) + setRowSelection({ [row.id]: true }) lastSelectedIndexRef.current = currentIndex - } else { - // Plain click - open details panel - // Re-clicking the currently focused row toggles both details and selection off. - if (isSelected && isRowSelected) { - if (isAllSelected) { - handleRowSelection(torrent.hash, false, row.id) - } else { - setRowSelection(prev => { - if (!prev[row.id]) { - return prev - } - - const next = { ...prev } - delete next[row.id] - return next - }) - - if (selectedRowIds.length <= 1) { - lastSelectedIndexRef.current = null - } - } - - onTorrentSelect?.(null) - return - } - - // If row is not selected, select only this torrent (replace selection). - if (!isRowSelected) { - const allRows = table.getRowModel().rows - const currentIndex = allRows.findIndex(r => r.id === row.id) - setIsAllSelected(false) - setExcludedFromSelectAll(new Set()) - setRowSelection({ [row.id]: true }) - lastSelectedIndexRef.current = currentIndex - } - onTorrentSelect?.(torrent) } + onTorrentSelect?.(torrent) } }} onContextMenu={() => { - // Only select this row if not already selected and not part of a multi-selection if (!isRowSelected && selectedHashes.length <= 1) { setRowSelection({ [row.id]: true }) } }} - > - {row.getVisibleCells().map(cell => { - // Compact columns (tracker_icon, status_icon) use px-0 to match header - const isCompactColumn = cell.column.id === "tracker_icon" || cell.column.id === "status_icon" - const isSelectColumn = cell.column.id === "select" - return ( -
- {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} -
- ) - })} -
+ incognitoMode={incognitoMode} + speedUnit={speedUnit} + supportsTrackerHealth={supportsTrackerHealth} + trackerIcons={trackerIcons} + onCheckboxPointerDown={handleCompactCheckboxPointerDown} + onCheckboxChange={handleCompactCheckboxChange} + style={{ + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: `${virtualRow.size}px`, + transform: `translateY(${virtualRow.start}px)`, + }} + />
) - })} + } + + // Use memoized minTableWidth for normal table view + return ( + +
{ + // Don't select when clicking checkbox or its wrapper + const target = e.target as HTMLElement + const isCheckbox = target.closest("[data-slot=\"checkbox\"]") || target.closest("[role=\"checkbox\"]") || target.closest(".p-1.-m-1") + if (!isCheckbox) { + // Handle shift-click for range selection - EXACTLY like checkbox + if (e.shiftKey) { + e.preventDefault() // Prevent text selection + + const allRows = table.getRowModel().rows + const currentIndex = allRows.findIndex(r => r.id === row.id) + + if (lastSelectedIndexRef.current !== null) { + const start = Math.min(lastSelectedIndexRef.current, currentIndex) + const end = Math.max(lastSelectedIndexRef.current, currentIndex) + + // Select range EXACTLY like checkbox does + for (let i = start; i <= end; i++) { + const targetRow = allRows[i] + if (targetRow) { + handleRowSelection(targetRow.original.hash, true, targetRow.id) + } + } + } else { + // No anchor - just select this row + handleRowSelection(torrent.hash, true, row.id) + lastSelectedIndexRef.current = currentIndex + } + + // Don't update lastSelectedIndexRef on shift-click (keeps anchor stable) + } else if (e.ctrlKey || e.metaKey) { + // Ctrl/Cmd click - toggle single row EXACTLY like checkbox + const allRows = table.getRowModel().rows + const currentIndex = allRows.findIndex(r => r.id === row.id) + + handleRowSelection(torrent.hash, !isRowSelected, row.id) + lastSelectedIndexRef.current = currentIndex + } else { + // Plain click - open details panel + // If row is already selected, keep selection intact + // Otherwise, select only this torrent (replace selection) + if (!isRowSelected) { + const allRows = table.getRowModel().rows + const currentIndex = allRows.findIndex(r => r.id === row.id) + setIsAllSelected(false) + setExcludedFromSelectAll(new Set()) + setRowSelection({ [row.id]: true }) + lastSelectedIndexRef.current = currentIndex + } + onTorrentSelect?.(torrent) + } + } + }} + onContextMenu={() => { + // Only select this row if not already selected and not part of a multi-selection + if (!isRowSelected && selectedHashes.length <= 1) { + setRowSelection({ [row.id]: true }) + } + }} + > + {row.getVisibleCells().map(cell => { + // Compact columns (tracker_icon, status_icon) use px-0 to match header + const isCompactColumn = cell.column.id === "tracker_icon" || cell.column.id === "status_icon" + const isSelectColumn = cell.column.id === "select" + return ( +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+ ) + })} +
+
+ ) + })} +
+
+ + + {/* Status bar */} +
+
+ {/* Compact SSE status */} + {hasStreamStatusDetails ? ( + + +
+ + {hasStreamStatusLabel && ( + {streamStatus.label} + )} +
+
+ +
+ {hasStreamStatusLabel &&

{streamStatus.label}

} + {streamStatus.message &&

{streamStatus.message}

} + {streamStatus.secondary &&

{streamStatus.secondary}

} +
+
+
+ ) : ( +
+ +
+ )} +
+ {effectiveSelectionCount > 0 ? ( + <> + + {isAllSelected && excludedFromSelectAll.size === 0 ? "All" : effectiveSelectionCount} selected + {selectedTotalSize > 0 && <> • {selectedFormattedSize}} + + {/* Keyboard shortcuts helper - only show on desktop */} + + + + Selection shortcuts + + + +
+
Shift+click for range
+
{isMac ? "Cmd" : "Ctrl"}+click for multiple
+
+
+
+ + ) : ( + <> + {/* Show special loading message when fetching without cache (cold load) */} + {isLoading && !isCachedData && !isStaleData && torrents.length === 0 ? ( + <> + + Loading torrents... + + ) : totalCount === 0 ? ( + emptyStateMessage + ) : ( + <> + {hasLoadedAll ? ( + `${torrents.length} torrent${torrents.length !== 1 ? "s" : ""}` + ) : isLoadingMore ? ( + "Loading more torrents..." + ) : ( + `${torrents.length} of ${totalCount} torrents loaded` + )} + {hasLoadedAll && safeLoadedRows < rows.length && " (scroll for more)"} + + )} + + )} +
+
+ +
+
+ + {formatSpeedWithUnit(effectiveServerState?.dl_info_speed ?? 0, speedUnit)} + + {formatSpeedWithUnit(effectiveServerState?.up_info_speed ?? 0, speedUnit)} + + + + + + {speedUnit === "bytes" ? "Switch to bits per second (bps)" : "Switch to bytes per second (B/s)"} + + + + + + + {altSpeedTooltip} + + {instance?.reannounceSettings?.enabled && ( + + + + + Automatic tracker reannounce enabled - Click to configure + + )} +
+
+ + +
+ {effectiveServerState?.free_space_on_disk !== undefined && ( +
+ + + + + + Free Space +
+ )} +
+ + + + + + + + +

{connectionStatusTooltip}

+
+
- +
+
- { - if (!open) { - closeDeleteDialog() - crossSeedWarning.reset() - } - }} - count={isAllSelected ? effectiveSelectionCount : contextHashes.length} - totalSize={deleteDialogTotalSize} - formattedSize={deleteDialogFormattedSize} - deleteFiles={deleteFiles} - onDeleteFilesChange={setDeleteFiles} - isDeleteFilesLocked={isDeleteFilesLocked} - onToggleDeleteFilesLock={toggleDeleteFilesLock} - showBlockCrossSeeds={hasCrossSeedTag} - blockCrossSeeds={blockCrossSeeds} - onBlockCrossSeedsChange={setBlockCrossSeeds} - deleteCrossSeeds={deleteCrossSeeds} - onDeleteCrossSeedsChange={setDeleteCrossSeeds} - crossSeedWarning={crossSeedWarning} - onConfirm={handleDeleteWrapper} - /> + { + if (!open) { + closeDeleteDialog() + crossSeedWarning.reset() + } + }} + count={isAllSelected ? effectiveSelectionCount : contextHashes.length} + totalSize={deleteDialogTotalSize} + formattedSize={deleteDialogFormattedSize} + deleteFiles={deleteFiles} + onDeleteFilesChange={setDeleteFiles} + isDeleteFilesLocked={isDeleteFilesLocked} + onToggleDeleteFilesLock={toggleDeleteFilesLock} + deleteCrossSeeds={deleteCrossSeeds} + onDeleteCrossSeedsChange={setDeleteCrossSeeds} + showBlockCrossSeeds={hasCrossSeedTag} + blockCrossSeeds={blockCrossSeeds} + onBlockCrossSeedsChange={setBlockCrossSeeds} + crossSeedWarning={crossSeedWarning} + onConfirm={handleDeleteWrapper} + /> - {/* Add Tags Dialog */} - + {/* Add Tags Dialog */} + - {/* Set Tags Dialog */} - + {/* Set Tags Dialog */} + - {/* Set Category Dialog */} - + {/* Set Category Dialog */} + - {/* Create and Assign Category Dialog */} - + {/* Create and Assign Category Dialog */} + - + - + - {/* Set Location Dialog */} - + {/* Set Location Dialog */} + - {/* Rename dialogs */} - - - + {/* Rename dialogs */} + + + - {/* Remove Tags Dialog */} - + {/* Remove Tags Dialog */} + - {/* Force Recheck Confirmation Dialog */} - - - - Force Recheck {isAllSelected ? effectiveSelectionCount : contextHashes.length} torrent(s)? - - This will force qBittorrent to recheck all pieces of the selected torrents. This process may take some time and will temporarily pause the torrents. - - - - - - - - - - {/* Reannounce Confirmation Dialog */} - - - - Reannounce {isAllSelected ? effectiveSelectionCount : contextHashes.length} torrent(s)? - - This will force the selected torrents to reannounce to all their trackers. This is useful when trackers are not responding or you want to refresh your connection. - - - - - - - - - - {/* TMM Confirmation Dialog */} - + {/* Force Recheck Confirmation Dialog */} + + + + Force Recheck {isAllSelected ? effectiveSelectionCount : contextHashes.length} torrent(s)? + + This will force qBittorrent to recheck all pieces of the selected torrents. This process may take some time and will temporarily pause the torrents. + + + + + + + + + + {/* Reannounce Confirmation Dialog */} + + + + Reannounce {isAllSelected ? effectiveSelectionCount : contextHashes.length} torrent(s)? + + This will force the selected torrents to reannounce to all their trackers. This is useful when trackers are not responding or you want to refresh your connection. + + + + + + + + + + {/* TMM Confirmation Dialog */} + - {/* Location Warning Dialog */} - + {/* Location Warning Dialog */} + - {/* Instance Preferences Dialog */} - {instance && ( - - )} + {/* Instance Preferences Dialog */} + {instance && ( + + )} - {/* Scroll to top button*/} -
- -
+ {/* Scroll to top button*/} +
+ +
) diff --git a/web/src/components/torrents/TorrentTableResponsive.tsx b/web/src/components/torrents/TorrentTableResponsive.tsx index c9c279e22..1d0daa09a 100644 --- a/web/src/components/torrents/TorrentTableResponsive.tsx +++ b/web/src/components/torrents/TorrentTableResponsive.tsx @@ -6,9 +6,8 @@ import { useTorrentSelection } from "@/contexts/TorrentSelectionContext" import { useCrossSeedSearch } from "@/hooks/useCrossSeedSearch" import { useIsMobile } from "@/hooks/useMediaQuery" -import type { Category, ServerState, Torrent, TorrentCounts, TorrentFilters } from "@/types" +import type { Category, Torrent, TorrentCounts, TorrentFilters } from "@/types" import { useEffect } from "react" -import type { SelectionInfo } from "./GlobalStatusBar" import { TorrentCardsMobile } from "./TorrentCardsMobile" import { TorrentTableOptimized } from "./TorrentTableOptimized" @@ -28,8 +27,6 @@ interface TorrentTableResponsiveProps { useSubcategories?: boolean ) => void onFilterChange?: (filters: TorrentFilters) => void - onServerStateUpdate?: (serverState: ServerState | null, listenPort?: number | null) => void - onSelectionInfoUpdate?: (info: SelectionInfo) => void } export function TorrentTableResponsive(props: TorrentTableResponsiveProps) { diff --git a/web/src/contexts/SyncStreamContext.tsx b/web/src/contexts/SyncStreamContext.tsx new file mode 100644 index 000000000..202f4a701 --- /dev/null +++ b/web/src/contexts/SyncStreamContext.tsx @@ -0,0 +1,823 @@ +/* + * Copyright (c) 2025, s0up and the autobrr contributors. + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import { api } from "@/lib/api" +import type { TorrentFilters, TorrentStreamMeta, TorrentStreamPayload } from "@/types" +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react" + +const RETRY_BASE_DELAY_MS = 4000 +const RETRY_MAX_DELAY_MS = 30000 +const MAX_RETRY_ATTEMPTS = 6 +const HANDOFF_GRACE_PERIOD_MS = 1200 +const ENTRY_TEARDOWN_DELAY_MS = 200 + +export interface StreamParams { + instanceId: number + page: number + limit: number + sort: string + order: "asc" | "desc" + search?: string + filters?: TorrentFilters +} + +type StreamListener = (payload: TorrentStreamPayload) => void + +export interface StreamState { + connected: boolean + error: string | null + lastMeta?: TorrentStreamMeta + retrying: boolean + retryAttempt: number + nextRetryAt?: number +} + +interface SyncStreamContextValue { + connect: ( + params: StreamParams, + listener: StreamListener, + options?: { preserveConnected?: boolean } + ) => () => void + getState: (key: string | null) => StreamState | undefined + subscribe: (key: string, listener: (state: StreamState) => void) => () => void +} + +interface StreamEntry { + key: string + params: StreamParams + listeners: Set + connected: boolean + error: string | null + lastMeta?: TorrentStreamMeta + handoffTimer?: number + handoffPending?: boolean + teardownTimer?: number +} + +interface StreamConnection { + source?: EventSource + handlers?: { + payload: (event: MessageEvent | Event) => void + networkError: (event: Event) => void + } + signature?: string + retryAttempt: number + retryTimer?: number + nextRetryAt?: number +} + +interface PendingConnectionUpdate { + timer?: number + preserveState?: boolean + resetRetry?: boolean +} + +const SyncStreamContext = createContext(null) + +const DEFAULT_STREAM_STATE: StreamState = { + connected: false, + error: null, + retrying: false, + retryAttempt: 0, + nextRetryAt: undefined, +} + +export function SyncStreamProvider({ children }: { children: React.ReactNode }) { + const streamsRef = useRef>({}) + const stateSubscribersRef = useRef void>>>({}) + const connectionRef = useRef({ retryAttempt: 0 }) + const scheduleReconnectRef = useRef<() => void>(() => {}) + const pendingConnectionUpdateRef = useRef(null) + const clearEntryTeardown = useCallback((entry: StreamEntry) => { + if (entry.teardownTimer === undefined) { + return + } + if (typeof window !== "undefined") { + window.clearTimeout(entry.teardownTimer) + } else { + clearTimeout(entry.teardownTimer) + } + entry.teardownTimer = undefined + }, []) + + const getSnapshot = useCallback( + (key: string): StreamState => { + const entry = streamsRef.current[key] + const connection = connectionRef.current + if (!entry) { + return { + ...DEFAULT_STREAM_STATE, + retrying: connection.retryTimer !== undefined, + retryAttempt: connection.retryAttempt, + nextRetryAt: connection.nextRetryAt, + } + } + + return { + connected: entry.connected, + error: entry.error, + lastMeta: entry.lastMeta, + retrying: connection.retryTimer !== undefined, + retryAttempt: connection.retryAttempt, + nextRetryAt: connection.nextRetryAt, + } + }, + [] + ) + + const notifyStateSubscribers = useCallback( + (key: string) => { + const subscribers = stateSubscribersRef.current[key] + if (!subscribers || subscribers.size === 0) { + return + } + + const snapshot = getSnapshot(key) + + subscribers.forEach(listener => { + try { + listener(snapshot) + } catch (err) { + console.error("SyncStream subscriber failed", err) + } + }) + }, + [getSnapshot] + ) + + const subscribeToState = useCallback( + (key: string, listener: (state: StreamState) => void) => { + if (!stateSubscribersRef.current[key]) { + stateSubscribersRef.current[key] = new Set() + } + stateSubscribersRef.current[key].add(listener) + + return () => { + const subscribers = stateSubscribersRef.current[key] + if (!subscribers) { + return + } + + subscribers.delete(listener) + if (subscribers.size === 0) { + delete stateSubscribersRef.current[key] + } + } + }, + [] + ) + + const clearHandoffState = useCallback((entry: StreamEntry) => { + if (entry.handoffTimer !== undefined) { + if (typeof window !== "undefined") { + window.clearTimeout(entry.handoffTimer) + } else { + clearTimeout(entry.handoffTimer) + } + entry.handoffTimer = undefined + } + entry.handoffPending = false + }, []) + + const clearConnectionRetryState = useCallback(() => { + const connection = connectionRef.current + if (connection.retryTimer !== undefined) { + if (typeof window !== "undefined") { + window.clearTimeout(connection.retryTimer) + } else { + clearTimeout(connection.retryTimer) + } + connection.retryTimer = undefined + } + connection.retryAttempt = 0 + connection.nextRetryAt = undefined + }, []) + + const notifyAllStateSubscribers = useCallback(() => { + Object.keys(stateSubscribersRef.current).forEach(key => { + notifyStateSubscribers(key) + }) + }, [notifyStateSubscribers]) + + const closeConnection = useCallback( + (options: { preserveRetry?: boolean } = {}) => { + const { preserveRetry = false } = options + const connection = connectionRef.current + if (!connection.source) { + if (!preserveRetry) { + clearConnectionRetryState() + } + connection.signature = undefined + connection.handlers = undefined + return + } + + const { source, handlers } = connection + if (handlers) { + source.removeEventListener("init", handlers.payload) + source.removeEventListener("update", handlers.payload) + source.removeEventListener("stream-error", handlers.payload) + } + + source.onopen = null + source.onerror = null + source.close() + + connection.source = undefined + connection.handlers = undefined + connection.signature = undefined + + if (!preserveRetry) { + clearConnectionRetryState() + } + }, + [clearConnectionRetryState] + ) + + const buildStreamPayload = (entries: StreamEntry[]) => + entries + .map(entry => ({ + key: entry.key, + instanceId: entry.params.instanceId, + page: entry.params.page, + limit: entry.params.limit, + sort: entry.params.sort, + order: entry.params.order, + search: entry.params.search ?? "", + filters: entry.params.filters ?? null, + })) + .sort((a, b) => a.key.localeCompare(b.key)) + + const openConnection = useCallback( + ( + entries: StreamEntry[], + options: { preserveState?: boolean; resetRetry?: boolean } = {} + ) => { + const normalized = buildStreamPayload(entries) + const signature = JSON.stringify(normalized) + const connection = connectionRef.current + + if (connection.signature === signature && connection.source) { + return + } + + const preserveState = options.preserveState ?? Boolean(connection.source) + const resetRetry = options.resetRetry ?? false + + if (typeof window === "undefined" || typeof EventSource === "undefined") { + entries.forEach(entry => { + entry.connected = false + entry.error = "Server-sent events are not supported in this environment" + clearHandoffState(entry) + notifyStateSubscribers(entry.key) + }) + closeConnection() + return + } + + if (resetRetry) { + clearConnectionRetryState() + } else if (connection.retryTimer !== undefined) { + if (typeof window !== "undefined") { + window.clearTimeout(connection.retryTimer) + } else { + clearTimeout(connection.retryTimer) + } + connection.retryTimer = undefined + connection.nextRetryAt = undefined + } + + if (preserveState) { + entries.forEach(entry => { + if (!entry.connected || entry.handoffPending) { + return + } + entry.handoffPending = true + if (entry.handoffTimer !== undefined) { + if (typeof window !== "undefined") { + window.clearTimeout(entry.handoffTimer) + } else { + clearTimeout(entry.handoffTimer) + } + } + const timer = (typeof window !== "undefined" + ? window.setTimeout + : (setTimeout as unknown as (handler: () => void, timeout: number) => number))(() => { + entry.handoffTimer = undefined + if (!entry.handoffPending) { + return + } + entry.handoffPending = false + entry.connected = false + notifyStateSubscribers(entry.key) + }, HANDOFF_GRACE_PERIOD_MS) + entry.handoffTimer = timer + }) + } else { + entries.forEach(entry => { + entry.connected = false + clearHandoffState(entry) + notifyStateSubscribers(entry.key) + }) + } + + const url = api.getTorrentsStreamBatchUrl(normalized) + closeConnection({ preserveRetry: true }) + + const payloadHandler = (event: MessageEvent | Event) => { + if (!("data" in event)) { + return + } + const rawData = typeof event.data === "string" ? event.data.trim() : "" + if (rawData.length === 0) { + return + } + + let payload: TorrentStreamPayload + try { + payload = JSON.parse(rawData) as TorrentStreamPayload + } catch (parseErr) { + console.error("Failed to parse SSE payload JSON:", parseErr, "raw data:", rawData.substring(0, 200)) + return + } + + const streamKey = payload.meta?.streamKey + if (!streamKey) { + return + } + + const entry = streamsRef.current[streamKey] + if (!entry) { + return + } + + entry.lastMeta = payload.meta + + if (payload.type === "stream-error" && payload.error) { + entry.error = payload.error + entry.connected = false + } else { + entry.error = null + entry.connected = true + } + + clearHandoffState(entry) + + // Notify listeners with individual error handling to prevent one failure from affecting others + entry.listeners.forEach((listener, index) => { + try { + listener(payload) + } catch (listenerErr) { + console.error(`SSE listener #${index} for stream "${streamKey}" failed:`, listenerErr) + } + }) + + notifyStateSubscribers(streamKey) + } + + const handleNetworkError = (_event?: Event) => { + closeConnection({ preserveRetry: true }) + + Object.values(streamsRef.current).forEach(entry => { + clearHandoffState(entry) + if (!entry.error) { + entry.error = "Stream disconnected" + } + entry.connected = false + notifyStateSubscribers(entry.key) + }) + + scheduleReconnectRef.current() + } + + const source = new EventSource(url, { withCredentials: true }) + source.addEventListener("init", payloadHandler) + source.addEventListener("update", payloadHandler) + source.addEventListener("stream-error", payloadHandler) + source.onopen = () => { + clearConnectionRetryState() + connection.retryAttempt = 0 + connection.nextRetryAt = undefined + normalized.forEach(({ key }) => { + const entry = streamsRef.current[key] + if (!entry) { + return + } + if (!entry.handoffPending) { + entry.error = null + } + notifyStateSubscribers(key) + }) + } + source.onerror = handleNetworkError + + connection.source = source + connection.handlers = { + payload: payloadHandler, + networkError: handleNetworkError, + } + connection.signature = signature + }, + [clearConnectionRetryState, clearHandoffState, closeConnection, notifyStateSubscribers] + ) + + const ensureConnection = useCallback( + (options: { preserveState?: boolean; resetRetry?: boolean } = {}) => { + const entries = Object.values(streamsRef.current) + if (entries.length === 0) { + closeConnection() + clearConnectionRetryState() + notifyAllStateSubscribers() + return + } + + openConnection(entries, options) + }, + [clearConnectionRetryState, closeConnection, notifyAllStateSubscribers, openConnection] + ) + + const queueConnectionUpdate = useCallback( + (options: { preserveState?: boolean; resetRetry?: boolean } = {}) => { + const pending: PendingConnectionUpdate = + pendingConnectionUpdateRef.current ?? { + timer: undefined, + preserveState: undefined, + resetRetry: undefined, + } + pending.preserveState = pending.preserveState || options.preserveState + pending.resetRetry = pending.resetRetry || options.resetRetry + + if (pending.timer === undefined) { + const schedule = + typeof window !== "undefined" + ? window.setTimeout + : (setTimeout as unknown as (handler: () => void, timeout: number) => number) + pending.timer = schedule(() => { + const { preserveState, resetRetry } = pendingConnectionUpdateRef.current ?? {} + pendingConnectionUpdateRef.current = null + ensureConnection({ + preserveState, + resetRetry, + }) + }, 0) + } + + pendingConnectionUpdateRef.current = pending + }, + [ensureConnection] + ) + + const scheduleReconnect = useCallback(() => { + const connection = connectionRef.current + if (connection.retryTimer !== undefined) { + return + } + + connection.retryAttempt = Math.min(connection.retryAttempt + 1, MAX_RETRY_ATTEMPTS) + + // Notify user when max retries reached + if (connection.retryAttempt >= MAX_RETRY_ATTEMPTS) { + Object.values(streamsRef.current).forEach(entry => { + entry.error = "Connection failed repeatedly. Check your network or server status." + notifyStateSubscribers(entry.key) + }) + } + + const exponent = Math.max(0, connection.retryAttempt - 1) + const delay = Math.min(RETRY_BASE_DELAY_MS * Math.pow(2, exponent), RETRY_MAX_DELAY_MS) + + connection.nextRetryAt = Date.now() + delay + + const timer = (typeof window !== "undefined" + ? window.setTimeout + : (setTimeout as unknown as (handler: () => void, timeout: number) => number))(() => { + connection.retryTimer = undefined + connection.nextRetryAt = undefined + + if (Object.keys(streamsRef.current).length === 0) { + clearConnectionRetryState() + notifyAllStateSubscribers() + return + } + + ensureConnection({ preserveState: false }) + notifyAllStateSubscribers() + }, delay) + + connection.retryTimer = timer + notifyAllStateSubscribers() + }, [clearConnectionRetryState, ensureConnection, notifyAllStateSubscribers, notifyStateSubscribers]) + + scheduleReconnectRef.current = scheduleReconnect + + const ensureStream = useCallback( + (params: StreamParams, options: { preserveConnected?: boolean } = {}) => { + const key = createStreamKey(params) + let entry = streamsRef.current[key] + + if (!entry) { + entry = { + key, + params, + listeners: new Set(), + connected: options.preserveConnected ?? false, + error: null, + } + streamsRef.current[key] = entry + queueConnectionUpdate({ preserveState: true }) + } else if (!isSameParams(entry.params, params)) { + entry.params = params + entry.error = null + queueConnectionUpdate({ preserveState: true, resetRetry: true }) + } else { + queueConnectionUpdate({ preserveState: true }) + } + + clearEntryTeardown(entry) + return entry + }, + [clearEntryTeardown, queueConnectionUpdate] + ) + + const scheduleEntryRemoval = useCallback( + (entry: StreamEntry) => { + clearEntryTeardown(entry) + const schedule = + typeof window !== "undefined" + ? window.setTimeout + : (setTimeout as unknown as (handler: () => void, timeout: number) => number) + + const timer = schedule(() => { + entry.teardownTimer = undefined + delete streamsRef.current[entry.key] + clearHandoffState(entry) + entry.connected = false + entry.error = null + notifyStateSubscribers(entry.key) + queueConnectionUpdate({ preserveState: true }) + }, ENTRY_TEARDOWN_DELAY_MS) + entry.teardownTimer = timer + }, + [clearEntryTeardown, clearHandoffState, notifyStateSubscribers, queueConnectionUpdate] + ) + + const connect = useCallback( + ( + params: StreamParams, + listener: StreamListener, + options: { preserveConnected?: boolean } = {} + ) => { + const entry = ensureStream(params, options) + entry.listeners.add(listener) + notifyStateSubscribers(entry.key) + + return () => { + entry.listeners.delete(listener) + if (entry.listeners.size === 0) { + scheduleEntryRemoval(entry) + } else { + notifyStateSubscribers(entry.key) + } + } + }, + [ensureStream, notifyStateSubscribers, scheduleEntryRemoval] + ) + + const getState = useCallback( + (key: string | null) => { + if (!key) { + return undefined + } + + const entry = streamsRef.current[key] + if (!entry) { + return undefined + } + + const connection = connectionRef.current + return { + connected: entry.connected, + error: entry.error, + lastMeta: entry.lastMeta, + retrying: connection.retryTimer !== undefined, + retryAttempt: connection.retryAttempt, + nextRetryAt: connection.nextRetryAt, + } + }, + [] + ) + + const contextValue = useMemo( + () => ({ + connect, + getState, + subscribe: subscribeToState, + }), + [connect, getState, subscribeToState] + ) + + useEffect(() => { + if (typeof window === "undefined") { + return + } + + const handleBeforeUnload = () => { + closeConnection() + } + + window.addEventListener("beforeunload", handleBeforeUnload) + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload) + } + }, [closeConnection]) + + // Reconnect when tab becomes visible again + useEffect(() => { + if (typeof document === "undefined") { + return + } + + const handleVisibilityChange = () => { + if (document.visibilityState !== "visible") { + return + } + + const connection = connectionRef.current + const hasStreams = Object.keys(streamsRef.current).length > 0 + + if (!hasStreams) { + return + } + + // Check if connection is dead or disconnected + const source = connection.source + const isDisconnected = !source || source.readyState === EventSource.CLOSED + + if (isDisconnected) { + // Reset retry state and force immediate reconnection + clearConnectionRetryState() + ensureConnection({ preserveState: false, resetRetry: true }) + } + } + + document.addEventListener("visibilitychange", handleVisibilityChange) + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange) + } + }, [clearConnectionRetryState, ensureConnection]) + + useEffect(() => { + return () => { + const pending = pendingConnectionUpdateRef.current + if (pending?.timer !== undefined) { + if (typeof window !== "undefined") { + window.clearTimeout(pending.timer) + } else { + clearTimeout(pending.timer) + } + } + pendingConnectionUpdateRef.current = null + closeConnection() + Object.values(streamsRef.current).forEach(entry => { + clearEntryTeardown(entry) + clearHandoffState(entry) + }) + streamsRef.current = {} + } + }, [clearEntryTeardown, clearHandoffState, closeConnection]) + + return {children} +} + +export function useSyncStream( + params: StreamParams | null, + options: { enabled?: boolean; onMessage?: StreamListener } = {} +) { + const context = useContext(SyncStreamContext) + if (!context) { + throw new Error("useSyncStream must be used within a SyncStreamProvider") + } + + const { enabled = true, onMessage } = options + + const key = useMemo(() => (params ? createStreamKey(params) : null), [params]) + + const [state, setState] = useState(() => { + if (!enabled || !key) { + return DEFAULT_STREAM_STATE + } + return context.getState(key) ?? DEFAULT_STREAM_STATE + }) + + const listenerRef = useRef(onMessage) + useEffect(() => { + listenerRef.current = onMessage + }, [onMessage]) + + const lastStateRef = useRef(state) + useEffect(() => { + lastStateRef.current = state + }, [state]) + + const paramsRef = useRef(params) + useEffect(() => { + paramsRef.current = params + }, [params]) + + const previousParamsRef = useRef(params ?? null) + + useEffect(() => { + if (!enabled || !key || !paramsRef.current) { + return + } + + const nextParams = paramsRef.current + const previousParams = previousParamsRef.current + + const canPreserve = + previousParams !== null && + nextParams !== null && + previousParams.instanceId === nextParams.instanceId && + previousParams.page === nextParams.page && + previousParams.limit === nextParams.limit + + const shouldPreserve = + canPreserve && + lastStateRef.current.connected && + !lastStateRef.current.error + + const connectOptions = shouldPreserve ? { preserveConnected: true } : undefined + + return context.connect( + nextParams, + payload => { + listenerRef.current?.(payload) + }, + connectOptions + ) + }, [context, enabled, key]) + + useEffect(() => { + previousParamsRef.current = params ?? null + }, [params]) + + useEffect(() => { + if (!enabled || !key) { + setState(DEFAULT_STREAM_STATE) + return + } + + setState(context.getState(key) ?? DEFAULT_STREAM_STATE) + + return context.subscribe(key, snapshot => { + setState(snapshot) + }) + }, [context, enabled, key]) + + return state +} + +export function useSyncStreamManager(): SyncStreamContextValue { + const context = useContext(SyncStreamContext) + if (!context) { + throw new Error("useSyncStreamManager must be used within a SyncStreamProvider") + } + return context +} + +export function createStreamKey(params: StreamParams): string { + try { + return JSON.stringify({ + instanceId: params.instanceId, + page: params.page, + limit: params.limit, + sort: params.sort, + order: params.order, + search: params.search ?? "", + filters: params.filters ?? null, + }) + } catch (err) { + // Fallback for non-serializable filters - log for debugging + console.error("Failed to serialize stream params, using degraded key:", err, params) + return `${params.instanceId}-${params.page}-${params.limit}-${params.sort}-${params.order}-${Date.now()}` + } +} + +function isSameParams(a: StreamParams, b: StreamParams): boolean { + if ( + a.instanceId !== b.instanceId || + a.page !== b.page || + a.limit !== b.limit || + a.sort !== b.sort || + a.order !== b.order || + (a.search || "") !== (b.search || "") + ) { + return false + } + + const aFilters = a.filters ? JSON.stringify(a.filters) : "" + const bFilters = b.filters ? JSON.stringify(b.filters) : "" + return aFilters === bFilters +} diff --git a/web/src/hooks/useAlternativeSpeedLimits.ts b/web/src/hooks/useAlternativeSpeedLimits.ts index 242e0b6c4..0fdd44973 100644 --- a/web/src/hooks/useAlternativeSpeedLimits.ts +++ b/web/src/hooks/useAlternativeSpeedLimits.ts @@ -3,18 +3,61 @@ * SPDX-License-Identifier: GPL-2.0-or-later */ +import { useSyncStream } from "@/contexts/SyncStreamContext" import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { api } from "@/lib/api" +import type { TorrentStreamPayload } from "@/types" +import { useCallback, useEffect, useMemo, useState } from "react" export function useAlternativeSpeedLimits(instanceId: number | undefined) { const queryClient = useQueryClient() + const [streamEnabled, setStreamEnabled] = useState(undefined) - const { data, isLoading, error } = useQuery({ + const streamParams = useMemo(() => { + if (!instanceId) { + return null + } + + return { + instanceId, + page: 0, + limit: 1, + sort: "added_on", + order: "desc" as const, + } + }, [instanceId]) + + const handleStreamMessage = useCallback((payload: TorrentStreamPayload) => { + if (!instanceId) { + return + } + + const value = payload.data?.serverState?.use_alt_speed_limits + if (typeof value !== "boolean") { + return + } + + setStreamEnabled(value) + queryClient.setQueryData(["alternative-speed-limits", instanceId], { enabled: value }) + }, [instanceId, queryClient]) + + const streamState = useSyncStream(streamParams, { + enabled: Boolean(streamParams), + onMessage: handleStreamMessage, + }) + + useEffect(() => { + setStreamEnabled(undefined) + }, [instanceId]) + + const shouldUseFallbackQuery = Boolean(instanceId) && (!streamState.connected || !!streamState.error) + + const { data, isLoading: isFallbackLoading, error } = useQuery({ queryKey: ["alternative-speed-limits", instanceId], queryFn: () => instanceId ? api.getAlternativeSpeedLimitsMode(instanceId) : null, - enabled: !!instanceId, + enabled: shouldUseFallbackQuery, staleTime: 5000, // 5 seconds - refetchInterval: 30000, // Refetch every 30 seconds + refetchInterval: shouldUseFallbackQuery ? 30000 : false, placeholderData: (previousData) => previousData, }) @@ -62,10 +105,10 @@ export function useAlternativeSpeedLimits(instanceId: number | undefined) { }) return { - enabled: data?.enabled ?? false, - isLoading, + enabled: streamEnabled ?? data?.enabled ?? false, + isLoading: streamEnabled === undefined && isFallbackLoading, error, toggle: toggleMutation.mutate, isToggling: toggleMutation.isPending, } -} \ No newline at end of file +} diff --git a/web/src/hooks/useCrossSeedInstanceState.ts b/web/src/hooks/useCrossSeedInstanceState.ts index fb51d8f32..55ddd5060 100644 --- a/web/src/hooks/useCrossSeedInstanceState.ts +++ b/web/src/hooks/useCrossSeedInstanceState.ts @@ -43,9 +43,7 @@ export function useCrossSeedInstanceState(): CrossSeedInstanceStateResult { const searchStatusQuery = useQuery({ queryKey: ["cross-seed", "search-status"], queryFn: () => api.getCrossSeedSearchStatus(), - refetchInterval: (query) => { - return query.state.data?.running ? 5_000 : 60_000 - }, + refetchInterval: (query) => query.state.data?.running ? 5_000 : 60_000, staleTime: 3_000, }) diff --git a/web/src/hooks/useInstanceMetadata.ts b/web/src/hooks/useInstanceMetadata.ts index aaabf938f..b5e0c7d7c 100644 --- a/web/src/hooks/useInstanceMetadata.ts +++ b/web/src/hooks/useInstanceMetadata.ts @@ -1,16 +1,23 @@ /* - * Copyright (c) 2025-2026, s0up and the autobrr contributors. + * Copyright (c) 2025, s0up and the autobrr contributors. * SPDX-License-Identifier: GPL-2.0-or-later */ -import { useQuery } from "@tanstack/react-query" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useQuery, useQueryClient } from "@tanstack/react-query" import { api } from "@/lib/api" import type { AppPreferences, Category } from "@/types" -interface InstanceMetadata { +export interface InstanceMetadata { categories: Record tags: string[] - preferences: AppPreferences + preferences?: AppPreferences +} + +const DEFAULT_PREF_FALLBACK_DELAY_MS = 400 + +interface UseInstanceMetadataOptions { + fallbackDelayMs?: number } /** @@ -18,27 +25,123 @@ interface InstanceMetadata { * This prevents duplicate API calls when multiple components need the same data * Note: Counts are now included in the torrents response, so we don't fetch them separately */ -export function useInstanceMetadata(instanceId: number) { - const query = useQuery({ - queryKey: ["instance-metadata", instanceId], - enabled: instanceId > 0, - queryFn: async () => { - // Fetch metadata in parallel for efficiency - const [categories, tags, preferences] = await Promise.all([ - api.getCategories(instanceId), - api.getTags(instanceId), - api.getInstancePreferences(instanceId), - ]) - - return { categories, tags, preferences } +export function useInstanceMetadata(instanceId: number, options: UseInstanceMetadataOptions = {}) { + const queryClient = useQueryClient() + const queryKey = useMemo(() => ["instance-metadata", instanceId] as const, [instanceId]) + const fallbackDelay = options.fallbackDelayMs ?? DEFAULT_PREF_FALLBACK_DELAY_MS + + const [error, setError] = useState(null) + const [isFetchingFallback, setIsFetchingFallback] = useState(false) + + const emptyMetadataRef = useRef({ categories: {}, tags: [] }) + const getSnapshot = useCallback( + () => { + if (!instanceId) { + return undefined + } + return queryClient.getQueryData(queryKey) }, - staleTime: 60000, // 1 minute - metadata doesn't change often - gcTime: 1800000, // Keep in cache for 30 minutes to support cross-instance navigation - refetchInterval: 30000, // Refetch every 30 seconds - refetchIntervalInBackground: false, // Don't refetch when tab is not active - // IMPORTANT: Keep showing previous data while fetching new data - placeholderData: (previousData) => previousData, + [instanceId, queryClient, queryKey] + ) + + const { data: metadata, refetch: refetchMetadata } = useQuery({ + queryKey, + queryFn: async () => getSnapshot() ?? emptyMetadataRef.current, + initialData: () => getSnapshot() ?? emptyMetadataRef.current, + placeholderData: previous => previous ?? emptyMetadataRef.current, + enabled: Boolean(instanceId), + staleTime: 5 * 60 * 1000, + gcTime: 30 * 60 * 1000, + refetchOnMount: false, + refetchOnWindowFocus: false, }) - return query + const fallbackRef = useRef<{ + timeoutId: ReturnType | null + inflight: boolean + }>({ timeoutId: null, inflight: false }) + + useEffect(() => { + setError(null) + fallbackRef.current.inflight = false + if (fallbackRef.current.timeoutId !== null) { + (typeof window === "undefined" ? clearTimeout : window.clearTimeout)(fallbackRef.current.timeoutId) + fallbackRef.current.timeoutId = null + } + }, [instanceId]) + + useEffect(() => { + if (!instanceId) { + return + } + + if (metadata?.preferences) { + if (fallbackRef.current.timeoutId !== null) { + (typeof window === "undefined" ? clearTimeout : window.clearTimeout)(fallbackRef.current.timeoutId) + fallbackRef.current.timeoutId = null + } + fallbackRef.current.inflight = false + return + } + + if (!Number.isFinite(fallbackDelay) || fallbackDelay < 0) { + return + } + + if (fallbackRef.current.inflight || fallbackRef.current.timeoutId !== null) { + return + } + + const timeoutId = (typeof window === "undefined" ? setTimeout : window.setTimeout)(async () => { + fallbackRef.current.timeoutId = null + fallbackRef.current.inflight = true + setIsFetchingFallback(true) + + try { + const preferences = await api.getInstancePreferences(instanceId) + + const cached = queryClient.getQueryData(queryKey) + const next: InstanceMetadata = { + categories: cached?.categories ?? metadata?.categories ?? {}, + tags: cached?.tags ?? metadata?.tags ?? [], + preferences, + } + queryClient.setQueryData(queryKey, next) + setError(null) + } catch (err) { + if (err instanceof Error) { + setError(err) + } else { + setError(new Error("Failed to load instance preferences")) + } + } finally { + fallbackRef.current.inflight = false + setIsFetchingFallback(false) + } + }, fallbackDelay) + + fallbackRef.current.timeoutId = timeoutId + + return () => { + if (fallbackRef.current.timeoutId !== null) { + (typeof window === "undefined" ? clearTimeout : window.clearTimeout)(fallbackRef.current.timeoutId) + fallbackRef.current.timeoutId = null + } + fallbackRef.current.inflight = false + } + }, [fallbackDelay, instanceId, metadata?.preferences, queryClient, queryKey]) + + const hasPreferences = Boolean(metadata?.preferences) + const isLoading = + Boolean(instanceId) && + !hasPreferences && + (isFetchingFallback || metadata === emptyMetadataRef.current || !metadata) + + return { + data: instanceId ? metadata : undefined, + isLoading, + isError: error !== null, + error, + refreshMetadata: refetchMetadata, + } } diff --git a/web/src/hooks/useInstancePreferences.ts b/web/src/hooks/useInstancePreferences.ts index 4da0a424a..081cc46fd 100644 --- a/web/src/hooks/useInstancePreferences.ts +++ b/web/src/hooks/useInstancePreferences.ts @@ -3,70 +3,150 @@ * SPDX-License-Identifier: GPL-2.0-or-later */ +import { useMemo } from "react" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { api } from "@/lib/api" +import type { InstanceMetadata } from "@/hooks/useInstanceMetadata" import type { AppPreferences } from "@/types" -type UseInstancePreferencesOptions = { +interface UseInstancePreferencesOptions { + fetchIfMissing?: boolean enabled?: boolean } -export function useInstancePreferences(instanceId: number | undefined, options: UseInstancePreferencesOptions = {}) { +export function useInstancePreferences( + instanceId: number | undefined, + options: UseInstancePreferencesOptions = {} +) { + const { fetchIfMissing = true, enabled: externalEnabled = true } = options const queryClient = useQueryClient() - const shouldEnable = options.enabled ?? true - const queryEnabled = shouldEnable && typeof instanceId === "number" - const queryKey = ["instance-preferences", instanceId] as const + const metadataQueryKey = useMemo( + () => ["instance-metadata", instanceId] as const, + [instanceId] + ) + const preferencesQueryKey = useMemo( + () => ["instance-preferences", instanceId] as const, + [instanceId] + ) - const { data: preferences, isLoading, error } = useQuery({ - queryKey, - queryFn: () => api.getInstancePreferences(instanceId!), + const cachedMetadata = queryClient.getQueryData(metadataQueryKey) + const cachedPreferences = + queryClient.getQueryData(preferencesQueryKey) ?? + cachedMetadata?.preferences + + const queryEnabled = + Boolean(externalEnabled) && fetchIfMissing && typeof instanceId === "number" && !cachedPreferences + + const { data: preferences, isLoading, error } = useQuery({ + queryKey: preferencesQueryKey, + queryFn: async () => { + if (instanceId === undefined) { + return undefined + } + + if (cachedMetadata?.preferences) { + return cachedMetadata.preferences + } + + const fresh = await api.getInstancePreferences(instanceId) + queryClient.setQueryData(metadataQueryKey, (previous: InstanceMetadata | undefined) => { + if (!previous) { + return { + categories: {}, + tags: [], + preferences: fresh, + } + } + + return { + ...previous, + preferences: fresh, + } + }) + + return fresh + }, enabled: queryEnabled, - staleTime: 5000, // 5 seconds - refetchInterval: 60000, // Refetch every minute - placeholderData: (previousData) => previousData, + staleTime: cachedPreferences ? Infinity : 60000, + gcTime: 1800000, + refetchInterval: false, + placeholderData: previousData => previousData, + initialData: () => cachedPreferences, }) - const updateMutation = useMutation({ - mutationFn: (updatedPreferences: Partial) => { + const resolvedPreferences = preferences ?? cachedPreferences + + const updateMutation = useMutation< + AppPreferences, + Error, + Partial, + { previousPreferences?: AppPreferences; previousMetadata?: InstanceMetadata } + >({ + mutationFn: (partialPreferences: Partial) => { if (instanceId === undefined) throw new Error("No instance ID") - return api.updateInstancePreferences(instanceId, updatedPreferences) + return api.updateInstancePreferences(instanceId, partialPreferences) }, onMutate: async (newPreferences) => { if (instanceId === undefined) { - return { previousPreferences: undefined } + return { previousPreferences: undefined, previousMetadata: undefined } } - // Cancel outgoing refetches + await queryClient.cancelQueries({ - queryKey, + queryKey: preferencesQueryKey, }) - // Snapshot previous value - const previousPreferences = queryClient.getQueryData(queryKey) + const previousPreferences = queryClient.getQueryData( + preferencesQueryKey + ) + const previousMetadata = queryClient.getQueryData( + metadataQueryKey + ) + + const basePreferences = + previousPreferences ?? previousMetadata?.preferences + + if (basePreferences) { + const optimistic = { ...basePreferences, ...newPreferences } + queryClient.setQueryData(preferencesQueryKey, optimistic) - // Optimistically update - if (previousPreferences) { - queryClient.setQueryData( - queryKey, - { ...previousPreferences, ...newPreferences } - ) + if (previousMetadata) { + queryClient.setQueryData(metadataQueryKey, { + ...previousMetadata, + preferences: optimistic, + }) + } } - return { previousPreferences } + return { previousPreferences, previousMetadata } }, onError: (_err, _newPreferences, context) => { - // Rollback on error - if (context?.previousPreferences) { - queryClient.setQueryData( - queryKey, - context.previousPreferences - ) + const rollbackPreferences = + context?.previousPreferences ?? context?.previousMetadata?.preferences + + if (rollbackPreferences) { + queryClient.setQueryData(preferencesQueryKey, rollbackPreferences) + } + + if (context?.previousMetadata) { + queryClient.setQueryData(metadataQueryKey, context.previousMetadata) } }, - onSuccess: () => { - // Invalidate and refetch - queryClient.invalidateQueries({ - queryKey, + onSuccess: (updatedPreferences) => { + queryClient.setQueryData(preferencesQueryKey, updatedPreferences) + queryClient.setQueryData(metadataQueryKey, previous => { + if (!previous) { + return { + categories: {}, + tags: [], + preferences: updatedPreferences, + } + } + + return { + ...previous, + preferences: updatedPreferences, + } }) }, }) @@ -74,8 +154,8 @@ export function useInstancePreferences(instanceId: number | undefined, options: type UpdatePreferencesOptions = Parameters[1] return { - preferences, - isLoading, + preferences: resolvedPreferences, + isLoading: fetchIfMissing && externalEnabled ? (isLoading && !resolvedPreferences) : false, error, updatePreferences: (updatedPreferences: Partial, options?: UpdatePreferencesOptions) => updateMutation.mutate(updatedPreferences, options), diff --git a/web/src/hooks/useInstances.ts b/web/src/hooks/useInstances.ts index 86a710d3b..b1100ad2f 100644 --- a/web/src/hooks/useInstances.ts +++ b/web/src/hooks/useInstances.ts @@ -13,7 +13,8 @@ export function useInstances() { const { data: instances, isLoading, error } = useQuery({ queryKey: ["instances"], queryFn: () => api.getInstances(), - refetchInterval: 30000, // Refetch every 30 seconds + // Instance list rarely changes; real-time connection status now comes from SSE + refetchInterval: 2 * 60 * 1000, // Refetch every 2 minutes }) const createMutation = useMutation({ diff --git a/web/src/hooks/useQBittorrentAppInfo.ts b/web/src/hooks/useQBittorrentAppInfo.ts index 570dba92e..6a1a0ca06 100644 --- a/web/src/hooks/useQBittorrentAppInfo.ts +++ b/web/src/hooks/useQBittorrentAppInfo.ts @@ -3,10 +3,11 @@ * SPDX-License-Identifier: GPL-2.0-or-later */ -import { useQuery } from "@tanstack/react-query" -import { useMemo } from "react" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { useEffect, useMemo } from "react" import { api } from "@/lib/api" +import type { QBittorrentAppInfo } from "@/types" export interface QBittorrentVersionInfo { appVersion: string @@ -47,6 +48,11 @@ export interface QBittorrentFieldVisibility { isError: boolean } +export interface UseQBittorrentAppInfoOptions { + initialData?: QBittorrentAppInfo | null + fetchIfMissing?: boolean +} + function parseLibtorrentMajor(version: string | undefined): number | undefined { if (!version) { return undefined @@ -109,15 +115,49 @@ export function getFieldVisibility( /** * Hook to fetch qBittorrent application version and build information */ -export function useQBittorrentAppInfo(instanceId: number | undefined) { +export function useQBittorrentAppInfo( + instanceId: number | undefined, + options: UseQBittorrentAppInfoOptions = {} +) { + return useQBittorrentAppInfoImpl(instanceId, options) +} + +function useQBittorrentAppInfoImpl( + instanceId: number | undefined, + options: UseQBittorrentAppInfoOptions +) { + const queryClient = useQueryClient() + const queryKey = useMemo( + () => ["qbittorrent-app-info", instanceId] as const, + [instanceId] + ) + const cachedData = instanceId + ? queryClient.getQueryData(queryKey) + : undefined + const providedInitial = options.initialData ?? undefined + const initialData = providedInitial ?? cachedData + const hasInitialData = initialData !== undefined && initialData !== null + const shouldFetch = + !!instanceId && (options.fetchIfMissing ?? true) && !hasInitialData + const query = useQuery({ - queryKey: ["qbittorrent-app-info", instanceId], + queryKey, queryFn: () => api.getQBittorrentAppInfo(instanceId!), - enabled: !!instanceId, + enabled: shouldFetch, + initialData: hasInitialData ? (initialData as QBittorrentAppInfo) : undefined, + initialDataUpdatedAt: hasInitialData ? Date.now() : undefined, staleTime: 5 * 60 * 1000, // 5 minutes - app info doesn't change often refetchOnWindowFocus: false, }) + useEffect(() => { + if (!instanceId || !options.initialData) { + return + } + + queryClient.setQueryData(queryKey, options.initialData) + }, [instanceId, options.initialData, queryClient, queryKey]) + const versionInfo = useMemo(() => { const appVersion = query.data?.version || "" const webAPIVersion = query.data?.webAPIVersion || "" diff --git a/web/src/hooks/useTitleBarSpeeds.ts b/web/src/hooks/useTitleBarSpeeds.ts index 24f1226f1..aa6ee80de 100644 --- a/web/src/hooks/useTitleBarSpeeds.ts +++ b/web/src/hooks/useTitleBarSpeeds.ts @@ -3,12 +3,14 @@ * SPDX-License-Identifier: GPL-2.0-or-later */ +import { useSyncStream } from "@/contexts/SyncStreamContext" import { useDelayedVisibility } from "@/hooks/useDelayedVisibility" import { useRouteTitle } from "@/hooks/useRouteTitle" import { api } from "@/lib/api" import { formatSpeedWithUnit, useSpeedUnits } from "@/lib/speedUnits" +import type { TorrentStreamPayload } from "@/types" import { useQuery } from "@tanstack/react-query" -import { useEffect, useRef } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" const DEFAULT_DOCUMENT_TITLE = "qui" @@ -63,18 +65,62 @@ export function useTitleBarSpeeds({ const baseTitle = useRouteTitle() const lastSpeedTitleRef = useRef(null) const lastBackgroundSpeedsRef = useRef<{ dl: number; up: number } | null>(null) + const [streamSpeeds, setStreamSpeeds] = useState<{ dl: number; up: number } | undefined>(undefined) const lastHiddenAtRef = useRef(0) const lastForegroundUpdateAtRef = useRef(0) const wasHiddenRef = useRef(false) const { isHidden, isHiddenDelayed, isVisible } = useDelayedVisibility(3000) + const streamParams = useMemo(() => { + if (!enabled || typeof instanceId !== "number") { + return null + } + + return { + instanceId, + page: 0, + limit: 1, + sort: "added_on", + order: "desc" as const, + } + }, [enabled, instanceId]) + + const handleStreamMessage = useCallback((payload: TorrentStreamPayload) => { + const serverState = payload.data?.serverState + if (!serverState) { + return + } + + setStreamSpeeds({ + dl: serverState.dl_info_speed ?? 0, + up: serverState.up_info_speed ?? 0, + }) + }, []) + + const streamState = useSyncStream(streamParams, { + enabled: Boolean(streamParams), + onMessage: handleStreamMessage, + }) + + useEffect(() => { + setStreamSpeeds(undefined) + }, [instanceId]) + const isForegroundStale = !isHidden && lastHiddenAtRef.current > lastForegroundUpdateAtRef.current const shouldPollBackground = enabled && (isHiddenDelayed || !foregroundSpeeds || isForegroundStale) + const shouldUseFallbackPolling = shouldPollBackground && + !backgroundSpeedsOverride && + (!streamState.connected || !!streamState.error || !streamSpeeds) const backgroundSpeedsQuery = useServerStateSpeeds( instanceId, - shouldPollBackground && !backgroundSpeedsOverride + shouldUseFallbackPolling ) - const backgroundSpeeds = backgroundSpeedsOverride ?? backgroundSpeedsQuery + const backgroundSpeeds = backgroundSpeedsOverride ?? + ( + shouldUseFallbackPolling + ? (backgroundSpeedsQuery ?? streamSpeeds) + : (streamSpeeds ?? backgroundSpeedsQuery) + ) const cachedBackgroundSpeeds = lastBackgroundSpeedsRef.current const effectiveSpeeds = isHiddenDelayed ? (backgroundSpeeds ?? cachedBackgroundSpeeds) diff --git a/web/src/hooks/useTorrentsList.ts b/web/src/hooks/useTorrentsList.ts index d08480ce6..d16e545f8 100644 --- a/web/src/hooks/useTorrentsList.ts +++ b/web/src/hooks/useTorrentsList.ts @@ -1,13 +1,28 @@ /* - * Copyright (c) 2025-2026, s0up and the autobrr contributors. + * Copyright (c) 2025, s0up and the autobrr contributors. * SPDX-License-Identifier: GPL-2.0-or-later */ +import { useSyncStream } from "@/contexts/SyncStreamContext" import { useInstanceCapabilities } from "@/hooks/useInstanceCapabilities" +import type { InstanceMetadata } from "@/hooks/useInstanceMetadata" import { api } from "@/lib/api" -import type { Torrent, TorrentFilters, TorrentResponse } from "@/types" -import { useQuery } from "@tanstack/react-query" -import { useEffect, useMemo, useState } from "react" +import type { + AppPreferences, + QBittorrentAppInfo, + Torrent, + TorrentFilters, + TorrentResponse, + TorrentStreamPayload, +} from "@/types" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { useCallback, useEffect, useMemo, useState } from "react" + +export const TORRENT_STREAM_POLL_INTERVAL_MS = 3000 +export const TORRENT_STREAM_POLL_INTERVAL_SECONDS = Math.max( + 1, + Math.round(TORRENT_STREAM_POLL_INTERVAL_MS / 1000) +) interface UseTorrentsListOptions { enabled?: boolean @@ -34,7 +49,6 @@ export function useTorrentsList( sort = "added_on", order = "desc", } = options - const shouldEnableQuery = enabled const [currentPage, setCurrentPage] = useState(0) const [allTorrents, setAllTorrents] = useState([]) @@ -43,7 +57,181 @@ export function useTorrentsList( const [lastRequestTime, setLastRequestTime] = useState(0) const [lastKnownTotal, setLastKnownTotal] = useState(0) const [lastProcessedPage, setLastProcessedPage] = useState(-1) + const [lastStreamSnapshot, setLastStreamSnapshot] = useState(null) const pageSize = 300 // Load 300 at a time (backend default) + const queryClient = useQueryClient() + + const metadataQueryKey = useMemo( + () => ["instance-metadata", instanceId] as const, + [instanceId] + ) + + const appInfoQueryKey = useMemo( + () => ["qbittorrent-app-info", instanceId] as const, + [instanceId] + ) + + const updateMetadataCache = useCallback( + (source?: TorrentResponse | null) => { + if (!source) { + return + } + + const hasPreferences = Object.prototype.hasOwnProperty.call(source, "preferences") + const isCrossInstanceSource = source.isCrossInstance === true + + if (isCrossInstanceSource && !hasPreferences) { + return + } + + queryClient.setQueryData( + metadataQueryKey, + previous => { + // Treat omitted metadata arrays/maps as empty for regular instance responses. + // Backend omitempty omits empty tags/categories, and we must clear stale cache values. + const nextCategories = isCrossInstanceSource + ? (previous?.categories ?? {}) + : (source.categories ?? {}) + const nextTags = isCrossInstanceSource + ? (previous?.tags ?? []) + : (source.tags ?? []) + const nextPreferences = + hasPreferences && source.preferences !== undefined + ? (source.preferences as AppPreferences | undefined) ?? previous?.preferences + : previous?.preferences + + const next: InstanceMetadata = { + categories: nextCategories, + tags: nextTags, + preferences: nextPreferences, + } + + return next + } + ) + + if (hasPreferences && source.preferences !== undefined) { + const nextPreferences = source.preferences as AppPreferences | undefined + if (nextPreferences !== undefined) { + queryClient.setQueryData( + ["instance-preferences", instanceId], + nextPreferences + ) + } + } + }, + [instanceId, metadataQueryKey, queryClient] + ) + + const updateAppInfoCache = useCallback( + (source?: Pick | null) => { + if (!source?.appInfo) { + return + } + + queryClient.setQueryData(appInfoQueryKey, source.appInfo) + }, + [appInfoQueryKey, queryClient] + ) + + const isCrossSeedFiltering = useMemo(() => { + return filters?.expr?.includes("Hash ==") && filters?.expr?.includes("||") + }, [filters?.expr]) + + const streamQueryKey = useMemo( + () => ["torrents-list", instanceId, 0, filters, search, sort, order, isCrossSeedFiltering] as const, + [instanceId, filters, search, sort, order, isCrossSeedFiltering] + ) + + const streamParams = useMemo(() => { + if (!enabled || isCrossSeedFiltering) { + return null + } + + return { + instanceId, + page: 0, + limit: pageSize, + sort, + order, + search: search || undefined, + filters, + } + }, [enabled, filters, instanceId, isCrossSeedFiltering, order, pageSize, search, sort]) + + const handleStreamPayload = useCallback( + (payload: TorrentStreamPayload) => { + if (!payload?.data) { + return + } + setLastStreamSnapshot(payload.data) + updateAppInfoCache(payload.data) + updateMetadataCache(payload.data) + queryClient.setQueryData(streamQueryKey, payload.data) + setAllTorrents(prev => { + const nextTorrents = payload.data?.torrents ?? [] + + if (payload.data?.total === 0 || nextTorrents.length === 0) { + return [] + } + + if (prev.length === 0) { + return nextTorrents + } + + const totalFromPayload = + typeof payload.data?.total === "number" ? payload.data.total : undefined + + const pageFromMeta = + typeof payload.meta?.page === "number" && payload.meta.page >= 0 + ? payload.meta.page + : undefined + const pageIndex = pageFromMeta ?? 0 + const pageStart = Math.max(0, pageIndex * pageSize) + const pageEnd = pageStart + nextTorrents.length + + const seen = new Set(nextTorrents.map(torrent => torrent.hash)) + + const leadingSliceEnd = Math.min(pageStart, prev.length) + const leading = leadingSliceEnd > 0 ? prev.slice(0, leadingSliceEnd) : [] + const trailingStart = Math.min(pageEnd, prev.length) + const trailing = trailingStart < prev.length ? prev.slice(trailingStart) : [] + const displacedSlice = prev.slice(pageStart, Math.min(pageEnd, prev.length)) + + const dedupedLeading = leading.filter(torrent => !seen.has(torrent.hash)) + const dedupedDisplaced = displacedSlice.filter(torrent => !seen.has(torrent.hash)) + const dedupedTrailing = trailing.filter(torrent => !seen.has(torrent.hash)) + + const merged = [...dedupedLeading, ...nextTorrents, ...dedupedDisplaced, ...dedupedTrailing] + + if (totalFromPayload !== undefined && merged.length > totalFromPayload) { + return merged.slice(0, totalFromPayload) + } + + return merged + }) + + if (typeof payload.data.total === "number") { + setLastKnownTotal(payload.data.total) + } + + if (currentPage === 0 && typeof payload.data.hasMore === "boolean") { + setHasLoadedAll(!payload.data.hasMore) + } + }, + [currentPage, pageSize, queryClient, streamQueryKey, updateAppInfoCache, updateMetadataCache] + ) + + const streamState = useSyncStream(streamParams, { + enabled: Boolean(streamParams), + onMessage: handleStreamPayload, + }) + + const shouldDisablePolling = Boolean(streamParams) && streamState.connected && !streamState.error + const preferCachedQuery = currentPage === 0 && shouldDisablePolling + const queryEnabled = + enabled && + (currentPage > 0 || Boolean(streamState.error) || !streamParams) // Reset state when instanceId, filters, search, or sort changes // Use JSON.stringify to avoid resetting on every object reference change during polling @@ -56,12 +244,19 @@ export function useTorrentsList( setHasLoadedAll(false) setLastKnownTotal(0) setLastProcessedPage(-1) + setLastStreamSnapshot(null) }, [instanceId, filterKey, searchKey, sort, order]) - // Detect if this is cross-seed filtering based on expression content - const isCrossSeedFiltering = useMemo(() => { - return filters?.expr?.includes('Hash ==') && filters?.expr?.includes('||') - }, [filters?.expr]) + useEffect(() => { + if (lastKnownTotal <= 0) { + return + } + + setHasLoadedAll(previous => { + const next = allTorrents.length >= lastKnownTotal + return previous === next ? previous : next + }) + }, [allTorrents.length, lastKnownTotal]) // Query for torrents - backend handles stale-while-revalidate const { data, isLoading, isFetching, isPlaceholderData } = useQuery({ @@ -77,7 +272,7 @@ export function useTorrentsList( filters, }) } - + return api.getTorrents(instanceId, { page: currentPage, limit: pageSize, @@ -85,6 +280,7 @@ export function useTorrentsList( order, search, filters, + preferCached: preferCachedQuery, }) }, // Trust backend cache - it returns immediately with stale data if needed @@ -93,16 +289,28 @@ export function useTorrentsList( // Reuse the previous page's data while the next page is loading so the UI doesn't flash empty state placeholderData: currentPage > 0 ? ((previousData) => previousData) : undefined, // Only poll the first page to get fresh data - don't poll pagination pages - // Reduce polling frequency for cross-instance calls since they're more expensive - refetchInterval: currentPage === 0 - ? (pollingEnabled ? (isCrossSeedFiltering ? 10000 : 3000) : false) - : false, - refetchIntervalInBackground, // Controls background polling behavior - refetchOnWindowFocus: currentPage === 0, - enabled: shouldEnableQuery, + refetchInterval: + currentPage === 0 + ? ( + pollingEnabled + ? (isCrossSeedFiltering ? 10000 : (shouldDisablePolling ? false : TORRENT_STREAM_POLL_INTERVAL_MS)) + : false + ) + : false, + refetchIntervalInBackground, + refetchOnWindowFocus: currentPage === 0 && pollingEnabled, + enabled: queryEnabled, }) - const { data: capabilities } = useInstanceCapabilities(instanceId, { enabled: shouldEnableQuery }) + const { data: capabilities } = useInstanceCapabilities(instanceId, { enabled }) + + const activeData = useMemo(() => { + if (shouldDisablePolling && lastStreamSnapshot) { + return lastStreamSnapshot + } + + return data ?? lastStreamSnapshot ?? null + }, [data, lastStreamSnapshot, shouldDisablePolling]) // Update torrents when data arrives or changes (including optimistic updates) useEffect(() => { @@ -121,6 +329,9 @@ export function useTorrentsList( return } + updateAppInfoCache(data) + updateMetadataCache(data) + if (data.total !== undefined) { setLastKnownTotal(data.total) } @@ -136,10 +347,10 @@ export function useTorrentsList( } // Handle both regular torrents and cross-instance torrents - const torrentsData = data.isCrossInstance + const torrentsData = data.isCrossInstance ? (data.crossInstanceTorrents || data.cross_instance_torrents) : data.torrents - + if (!torrentsData) { setIsLoadingMore(false) return @@ -177,7 +388,7 @@ export function useTorrentsList( } setIsLoadingMore(false) - }, [data, currentPage, lastProcessedPage, isFetching, isPlaceholderData]) + }, [data, currentPage, lastProcessedPage, isFetching, isPlaceholderData, updateAppInfoCache, updateMetadataCache]) // Load more function for pagination - following TanStack Query best practices const loadMore = () => { @@ -206,37 +417,57 @@ export function useTorrentsList( // Extract stats from response or calculate defaults const stats = useMemo(() => { - if (data?.stats) { + const source = activeData ?? data + + if (source?.stats) { return { - total: data.total || data.stats.total || 0, - downloading: data.stats.downloading || 0, - seeding: data.stats.seeding || 0, - paused: data.stats.paused || 0, - error: data.stats.error || 0, - totalDownloadSpeed: data.stats.totalDownloadSpeed || 0, - totalUploadSpeed: data.stats.totalUploadSpeed || 0, - totalSize: data.stats.totalSize || 0, + total: source.total || source.stats.total || 0, + downloading: source.stats.downloading || 0, + seeding: source.stats.seeding || 0, + paused: source.stats.paused || 0, + error: source.stats.error || 0, + totalDownloadSpeed: source.stats.totalDownloadSpeed || 0, + totalUploadSpeed: source.stats.totalUploadSpeed || 0, + totalSize: source.stats.totalSize || 0, } } return { - total: data?.total || 0, + total: source?.total || 0, downloading: 0, seeding: 0, paused: 0, error: 0, totalDownloadSpeed: 0, totalUploadSpeed: 0, - totalSize: data?.stats?.totalSize || 0, + totalSize: source?.stats?.totalSize || 0, } - }, [data]) + }, [activeData, data]) // Check if data is from cache or fresh (backend provides this info) - const isCachedData = data?.cacheMetadata?.source === "cache" - const isStaleData = data?.cacheMetadata?.isStale === true + const cacheMetadata = activeData?.cacheMetadata ?? data?.cacheMetadata + const isCachedData = cacheMetadata?.source === "cache" + const isStaleData = cacheMetadata?.isStale === true + + const isInitialStreamLoading = + currentPage === 0 && + enabled && + Boolean(streamParams) && + !streamState.error && + !lastStreamSnapshot && + !data + + const effectiveIsLoading = + currentPage === 0 ? (isInitialStreamLoading || (queryEnabled && isLoading)) : isLoading + + const effectiveIsFetching = + currentPage === 0 ? (queryEnabled && isFetching) : isFetching // Use lastKnownTotal when loading more pages to prevent flickering - const effectiveTotalCount = currentPage > 0 && !data?.total ? lastKnownTotal : (data?.total ?? 0) + const effectiveTotalCount = + currentPage > 0 && typeof activeData?.total !== "number" + ? lastKnownTotal + : activeData?.total ?? lastKnownTotal const supportsSubcategories = capabilities?.supportsSubcategories ?? false @@ -244,17 +475,24 @@ export function useTorrentsList( torrents: allTorrents, totalCount: effectiveTotalCount, stats, - counts: data?.counts, - categories: data?.categories, - tags: data?.tags, + counts: activeData?.counts ?? data?.counts, + appInfo: activeData?.appInfo ?? data?.appInfo ?? null, + categories: activeData?.categories ?? data?.categories, + tags: activeData?.tags ?? data?.tags, supportsTorrentCreation: capabilities?.supportsTorrentCreation ?? true, capabilities, - serverState: data?.serverState ?? null, + serverState: activeData?.serverState ?? data?.serverState ?? null, useSubcategories: supportsSubcategories - ? (data?.useSubcategories ?? data?.serverState?.use_subcategories ?? false) + ? ( + activeData?.useSubcategories ?? + activeData?.serverState?.use_subcategories ?? + data?.useSubcategories ?? + data?.serverState?.use_subcategories ?? + false + ) : false, - isLoading: isLoading && currentPage === 0, - isFetching, + isLoading: effectiveIsLoading, + isFetching: effectiveIsFetching, isLoadingMore, hasLoadedAll, loadMore, @@ -265,7 +503,13 @@ export function useTorrentsList( isFreshData: !isCachedData || !isStaleData, isCachedData, isStaleData, - cacheAge: data?.cacheMetadata?.age, + cacheAge: cacheMetadata?.age, + isStreaming: shouldDisablePolling, + streamConnected: streamState.connected, + streamError: streamState.error, + streamMeta: streamState.lastMeta, + streamRetrying: streamState.retrying, + streamNextRetryAt: streamState.nextRetryAt, + streamRetryAttempt: streamState.retryAttempt, } } - diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 6aac0b48e..b7d483d48 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -788,6 +788,7 @@ class ApiClient { order?: "asc" | "desc" search?: string filters?: TorrentFilters + preferCached?: boolean } ): Promise { const searchParams = new URLSearchParams() @@ -797,12 +798,40 @@ class ApiClient { if (params.order) searchParams.set("order", params.order) if (params.search) searchParams.set("search", params.search) if (params.filters) searchParams.set("filters", JSON.stringify(params.filters)) + if (params.preferCached) searchParams.set("prefer", "stale") return this.request( `/instances/${instanceId}/torrents?${searchParams}` ) } + getTorrentsStreamBatchUrl( + streams: Array<{ + key: string + instanceId: number + page: number + limit: number + sort: string + order: "asc" | "desc" + search?: string + filters?: TorrentFilters | null + }> + ): string { + const normalized = streams.map(stream => ({ + key: stream.key, + instanceId: stream.instanceId, + page: stream.page, + limit: stream.limit, + sort: stream.sort, + order: stream.order, + search: stream.search ?? "", + filters: stream.filters ?? null, + })) + + const payload = encodeURIComponent(JSON.stringify(normalized)) + return withBasePath(`/api/stream?streams=${payload}`) + } + async getTorrentField( instanceId: number, field: "name" | "hash" | "full_path", diff --git a/web/src/pages/CrossSeedPage.tsx b/web/src/pages/CrossSeedPage.tsx index c06855e6a..368276529 100644 --- a/web/src/pages/CrossSeedPage.tsx +++ b/web/src/pages/CrossSeedPage.tsx @@ -726,7 +726,8 @@ export function CrossSeedPage({ activeTab, onTabChange }: CrossSeedPageProps) { const { data: searchStatus, refetch: refetchSearchStatus } = useQuery({ queryKey: ["cross-seed", "search-status"], queryFn: () => api.getCrossSeedSearchStatus(), - refetchInterval: 5_000, + // Only poll frequently when search is running, otherwise poll slowly + refetchInterval: (query) => query.state.data?.running ? 5_000 : 60_000, }) const { data: searchRuns, refetch: refetchSearchRuns } = useQuery({ diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 0d832a4c3..9e390253b 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -1,11 +1,10 @@ /* - * Copyright (c) 2025-2026, s0up and the autobrr contributors. + * Copyright (c) 2025, s0up and the autobrr contributors. * SPDX-License-Identifier: GPL-2.0-or-later */ import { InstanceErrorDisplay } from "@/components/instances/InstanceErrorDisplay" import { InstanceSettingsButton } from "@/components/instances/InstanceSettingsButton" -import { MagnetHandlerBanner } from "@/components/MagnetHandlerBanner" import { PasswordIssuesBanner } from "@/components/instances/PasswordIssuesBanner" import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import { @@ -38,19 +37,28 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Textarea } from "@/components/ui/textarea" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { TrackerIconImage } from "@/components/ui/tracker-icon" -import { useDelayedVisibility } from "@/hooks/useDelayedVisibility" import { useInstancePreferences } from "@/hooks/useInstancePreferences" import { useInstances } from "@/hooks/useInstances" -import { usePersistedTitleBarSpeeds } from "@/hooks/usePersistedTitleBarSpeeds" import { useQBittorrentAppInfo } from "@/hooks/useQBittorrentAppInfo" -import { useTitleBarSpeeds } from "@/hooks/useTitleBarSpeeds" import { api } from "@/lib/api" import { copyTextToClipboard, formatBytes, getRatioColor } from "@/lib/utils" -import type { InstanceResponse, ServerState, TorrentCounts, TorrentResponse, TorrentStats } from "@/types" +import type { + CacheMetadata, + DashboardSettings, + InstanceMeta, + InstanceResponse, + QBittorrentAppInfo, + ServerState, + TorrentResponse, + TorrentCounts, + TorrentStats, + TrackerCustomization, + TrackerTransferStats +} from "@/types" import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query" import { Link } from "@tanstack/react-router" import { Activity, AlertCircle, AlertTriangle, ArrowDown, ArrowUp, ArrowUpDown, Ban, BrickWallFire, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Download, ExternalLink, Eye, EyeOff, Globe, HardDrive, Info, Link2, Minus, Pencil, Plus, Rabbit, RefreshCcw, Trash2, Turtle, Upload, X, Zap } from "lucide-react" -import { useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { toast } from "sonner" import { @@ -63,21 +71,96 @@ import { } from "@/components/ui/dropdown-menu" import { DashboardSettingsDialog } from "@/components/dashboard-settings-dialog" +import { createStreamKey, useSyncStreamManager } from "@/contexts/SyncStreamContext" import { DEFAULT_DASHBOARD_SETTINGS, useDashboardSettings, useUpdateDashboardSettings } from "@/hooks/useDashboardSettings" import { useCreateTrackerCustomization, useDeleteTrackerCustomization, useTrackerCustomizations, useUpdateTrackerCustomization } from "@/hooks/useTrackerCustomizations" import { useTrackerIcons } from "@/hooks/useTrackerIcons" import { getLinuxTrackerDomain, useIncognitoMode } from "@/lib/incognito" import { formatSpeedWithUnit, useSpeedUnits } from "@/lib/speedUnits" -import type { DashboardSettings, TrackerCustomization, TrackerTransferStats } from "@/types" +import type { TorrentStreamPayload } from "@/types" interface DashboardInstanceStats { instance: InstanceResponse stats: TorrentStats | null serverState: ServerState | null torrentCounts?: TorrentCounts + appInfo: QBittorrentAppInfo | null altSpeedEnabled: boolean isLoading: boolean error: unknown + streamConnected: boolean + streamError: string | null + cacheMetadata: CacheMetadata | null | undefined + instanceMeta: InstanceMeta | null // Real-time instance health from SSE +} + +type InstanceStreamData = { + stats: TorrentStats | null + serverState: ServerState | null + torrentCounts?: TorrentCounts + appInfo: QBittorrentAppInfo | null + altSpeedEnabled: boolean + isLoading: boolean + error: unknown + streamConnected: boolean + streamError: string | null + cacheMetadata: CacheMetadata | null | undefined + instanceMeta: InstanceMeta | null // Real-time instance health from SSE +} + +const createDefaultInstanceStreamData = (): InstanceStreamData => ({ + stats: null, + serverState: null, + torrentCounts: undefined, + appInfo: null, + altSpeedEnabled: false, + isLoading: true, + error: null, + streamConnected: false, + streamError: null, + cacheMetadata: null, + instanceMeta: null, +}) + +const STREAM_REFRESH_INTERVAL_MS = 2000 + +type InstanceUpdateResult = + | InstanceStreamData + | { + data: InstanceStreamData + immediate?: boolean + } + +function cloneInstanceDataRecord(source: Record) { + const next: Record = {} + for (const [key, value] of Object.entries(source)) { + next[Number(key)] = value + } + return next +} + +function recordsShallowEqual( + a: Record, + b: Record +) { + if (a === b) { + return true + } + + const aKeys = Object.keys(a) + const bKeys = Object.keys(b) + if (aKeys.length !== bKeys.length) { + return false + } + + for (const key of aKeys) { + const numericKey = Number(key) + if (a[numericKey] !== b[numericKey]) { + return false + } + } + + return true } // Shared hook for computing global stats across all instances @@ -141,43 +224,363 @@ function useGlobalStats(statsData: DashboardInstanceStats[]) { } // Optimized hook to get all instance stats using shared TorrentResponse cache -function useAllInstanceStats(instances: InstanceResponse[], options: { enabled: boolean }): DashboardInstanceStats[] { - const dashboardQueries = useQueries({ - queries: instances.map(instance => ({ - // Use same query key pattern as useTorrentsList for first page with no filters - queryKey: ["torrents-list", instance.id, 0, undefined, undefined, "added_on", "desc"], - queryFn: () => api.getTorrents(instance.id, { - page: 0, - limit: 1, // Only need metadata, not actual torrents for Dashboard - sort: "added_on", - order: "desc" as const, - }), - enabled: options.enabled, - refetchInterval: 5000, // Match TorrentTable polling - refetchIntervalInBackground: false, - staleTime: 2000, - gcTime: 300000, // Match TorrentTable cache time - placeholderData: (previousData: TorrentResponse | undefined) => previousData, - retry: 1, - retryDelay: 1000, - })), +function useAllInstanceStats(instances: InstanceResponse[]): DashboardInstanceStats[] { + const syncStream = useSyncStreamManager() + const queryClient = useQueryClient() + const streamConnectionsRef = useRef( + new Map< + number, + { + key: string + disconnect: () => void + unsubscribe: () => void + cancelRef: { current: boolean } + } + >() + ) + const baseStreamParams = useMemo( + () => ({ + page: 0, + limit: 1, + sort: "added_on", + order: "desc" as const, + }), + [] + ) + const [instanceData, setInstanceData] = useState>({}) + const latestDataRef = useRef>({}) + const flushTimerRef = useRef | null>(null) + const fallbackQueries = useQueries({ + queries: instances.map(instance => { + const streamState = instanceData[instance.id] ?? latestDataRef.current[instance.id] + const fallbackEnabled = !streamState || !streamState.streamConnected + + return { + // Match first-page key used by torrent list so we can reuse query cache. + queryKey: ["torrents-list", instance.id, 0, undefined, undefined, "added_on", "desc"] as const, + queryFn: () => api.getTorrents(instance.id, { + page: 0, + limit: 1, + sort: "added_on", + order: "desc", + }), + enabled: fallbackEnabled, + refetchInterval: fallbackEnabled ? 5000 : false, + refetchIntervalInBackground: false, + staleTime: 2000, + gcTime: 300000, + placeholderData: (previousData: TorrentResponse | undefined) => previousData, + retry: 1, + retryDelay: 1000, + } + }), }) + const flushInstanceData = useCallback( + (force = false) => { + const snapshot = latestDataRef.current + setInstanceData(prev => { + if (!force && recordsShallowEqual(prev, snapshot)) { + return prev + } + return cloneInstanceDataRecord(snapshot) + }) + }, + [] + ) + + const scheduleFlush = useCallback(() => { + if (flushTimerRef.current) { + return + } + + flushTimerRef.current = setTimeout(() => { + flushTimerRef.current = null + flushInstanceData() + }, STREAM_REFRESH_INTERVAL_MS) + }, [flushInstanceData]) + + const applyInstanceData = useCallback( + (instanceId: number, buildUpdate: (current: InstanceStreamData) => InstanceUpdateResult) => { + const currentRecord = latestDataRef.current + let current = currentRecord[instanceId] + if (!current) { + current = createDefaultInstanceStreamData() + currentRecord[instanceId] = current + } + + const result = buildUpdate(current) + const { data: next, immediate } = + "data" in result ? result : { data: result } + + if (next === current) { + return + } + + currentRecord[instanceId] = next + + if (immediate) { + flushInstanceData(true) + } else { + scheduleFlush() + } + }, + [flushInstanceData, scheduleFlush] + ) + + useEffect(() => { + const nextRecord: Record = {} + instances.forEach(instance => { + nextRecord[instance.id] = latestDataRef.current[instance.id] ?? createDefaultInstanceStreamData() + }) + latestDataRef.current = nextRecord + flushInstanceData(true) + }, [instances, flushInstanceData]) + + useEffect(() => { + if (typeof document === "undefined") { + return + } + + const flushIfVisible = () => { + if (typeof document !== "undefined" && document.visibilityState !== "visible") { + return + } + + if (flushTimerRef.current) { + if (typeof window !== "undefined") { + window.clearTimeout(flushTimerRef.current) + } else { + clearTimeout(flushTimerRef.current) + } + flushTimerRef.current = null + } + + flushInstanceData(true) + } + + document.addEventListener("visibilitychange", flushIfVisible) + + if (typeof window !== "undefined") { + window.addEventListener("focus", flushIfVisible) + } + + return () => { + document.removeEventListener("visibilitychange", flushIfVisible) + + if (typeof window !== "undefined") { + window.removeEventListener("focus", flushIfVisible) + } + } + }, [flushInstanceData]) + + useEffect(() => { + const activeInstanceIds = new Set() + + instances.forEach(instance => { + const params = { + ...baseStreamParams, + instanceId: instance.id, + } + const streamKey = createStreamKey(params) + activeInstanceIds.add(instance.id) + + const existing = streamConnectionsRef.current.get(instance.id) + if (existing && existing.key === streamKey && !existing.cancelRef.current) { + return + } + + if (existing) { + existing.cancelRef.current = true + existing.disconnect() + existing.unsubscribe() + } + + const cancelRef = { current: false } + + const disconnect = syncStream.connect(params, (payload: TorrentStreamPayload) => { + if (cancelRef.current || !payload) { + return + } + + if (payload.type === "stream-error") { + applyInstanceData(instance.id, current => { + return { + data: { + ...current, + isLoading: false, + error: payload.error ?? current.error, + streamError: payload.error ?? current.streamError, + streamConnected: false, + instanceMeta: null, + }, + immediate: true, + } + }) + return + } + + if (!payload.data) { + return + } + + const data = payload.data + if (data.appInfo) { + queryClient.setQueryData(["qbittorrent-app-info", instance.id], data.appInfo) + } + + applyInstanceData(instance.id, current => { + const next: InstanceStreamData = { + stats: data.stats ?? null, + serverState: data.serverState ?? null, + torrentCounts: data.counts, + appInfo: data.appInfo ?? current.appInfo, + altSpeedEnabled: data.serverState?.use_alt_speed_limits || false, + isLoading: false, + error: null, + streamConnected: true, + streamError: null, + cacheMetadata: data.cacheMetadata ?? null, + instanceMeta: data.instanceMeta ?? current.instanceMeta, + } + + return { + data: next, + immediate: current.isLoading && !next.isLoading, + } + }) + }) + + const unsubscribe = syncStream.subscribe(streamKey, snapshot => { + if (cancelRef.current) { + return + } + + applyInstanceData(instance.id, current => { + const next: InstanceStreamData = { + ...current, + streamConnected: snapshot.connected, + streamError: snapshot.error ?? (snapshot.connected ? null : current.streamError), + } + + if (snapshot.error) { + next.error = snapshot.error + next.isLoading = false + } else if (snapshot.connected && !current.isLoading) { + next.error = null + } + + if (!snapshot.connected || snapshot.error) { + next.instanceMeta = null + } + + return next + }) + }) + + const initialSnapshot = syncStream.getState(streamKey) + if (initialSnapshot) { + applyInstanceData(instance.id, current => { + const next: InstanceStreamData = { + ...current, + streamConnected: initialSnapshot.connected, + streamError: initialSnapshot.error ?? current.streamError, + } + + if (initialSnapshot.error) { + next.error = initialSnapshot.error + next.isLoading = false + } + + if (!initialSnapshot.connected || initialSnapshot.error) { + next.instanceMeta = null + } + + return next + }) + } + + streamConnectionsRef.current.set(instance.id, { key: streamKey, disconnect, unsubscribe, cancelRef }) + }) + + streamConnectionsRef.current.forEach((entry, instanceId) => { + if (!activeInstanceIds.has(instanceId)) { + entry.cancelRef.current = true + entry.disconnect() + entry.unsubscribe() + streamConnectionsRef.current.delete(instanceId) + } + }) + }, [instances, syncStream, baseStreamParams, queryClient, applyInstanceData]) + + useEffect(() => { + return () => { + streamConnectionsRef.current.forEach(entry => { + entry.cancelRef.current = true + entry.disconnect() + entry.unsubscribe() + }) + streamConnectionsRef.current.clear() + if (flushTimerRef.current) { + clearTimeout(flushTimerRef.current) + flushTimerRef.current = null + } + } + }, []) + return instances.map((instance, index) => { - const query = dashboardQueries[index] - const data = query.data as TorrentResponse | undefined + const state = instanceData[instance.id] ?? createDefaultInstanceStreamData() + const fallbackQuery = fallbackQueries[index] + const fallbackData = fallbackQuery?.data as TorrentResponse | undefined + const isFallbackActive = !state.streamConnected + + const stats = isFallbackActive ? (fallbackData?.stats ?? state.stats) : state.stats + const serverState = isFallbackActive ? (fallbackData?.serverState ?? state.serverState) : state.serverState + const torrentCounts = isFallbackActive ? (fallbackData?.counts ?? state.torrentCounts) : state.torrentCounts + const appInfo = isFallbackActive ? (fallbackData?.appInfo ?? state.appInfo) : state.appInfo + const cacheMetadata = isFallbackActive ? (fallbackData?.cacheMetadata ?? state.cacheMetadata) : state.cacheMetadata + + const hasHydratedData = Boolean(stats || serverState || torrentCounts) + const isLoading = isFallbackActive + ? (!hasHydratedData && (state.isLoading || fallbackQuery?.isLoading || fallbackQuery?.isFetching)) + : state.isLoading + const error = (() => { + if (!isFallbackActive) { + return state.error + } + if (fallbackQuery?.error) { + return fallbackQuery.error + } + if (fallbackData) { + return null + } + return state.error + })() + + // Merge SSE instanceMeta into the instance object for real-time status updates + // This allows components to use SSE-based connection status instead of polled data + const mergedInstance: InstanceResponse = state.streamConnected && state.instanceMeta + ? { + ...instance, + connected: state.instanceMeta.connected, + hasDecryptionError: state.instanceMeta.hasDecryptionError, + recentErrors: state.instanceMeta.recentErrors, + } + : instance return { - instance, - // Return TorrentStats directly - no more backwards compatibility conversion - stats: data?.stats ?? null, - serverState: data?.serverState ?? null, - torrentCounts: data?.counts, - // Include alt speed status from server state to avoid separate API call - altSpeedEnabled: data?.serverState?.use_alt_speed_limits || false, - // Include loading/error state for individual instances - isLoading: query.isLoading, - error: query.error, + instance: mergedInstance, + stats, + serverState, + torrentCounts, + appInfo, + altSpeedEnabled: serverState?.use_alt_speed_limits ?? state.altSpeedEnabled, + isLoading, + error, + streamConnected: state.streamConnected, + streamError: state.streamError, + cacheMetadata, + instanceMeta: state.instanceMeta, } }) } @@ -192,7 +595,16 @@ function InstanceCard({ isAdvancedMetricsOpen: boolean setIsAdvancedMetricsOpen: (open: boolean) => void }) { - const { instance, stats, serverState, torrentCounts, altSpeedEnabled, isLoading, error } = instanceData + const { + instance, + stats, + serverState, + torrentCounts, + appInfo, + altSpeedEnabled, + isLoading, + error, + } = instanceData const [showSpeedLimitDialog, setShowSpeedLimitDialog] = useState(false) // Alternative speed limits toggle - no need to track state, just provide toggle function @@ -211,7 +623,10 @@ function InstanceCard({ const { data: qbittorrentAppInfo, versionInfo: qbittorrentVersionInfo, - } = useQBittorrentAppInfo(instance.id) + } = useQBittorrentAppInfo(instance.id, { + initialData: appInfo ?? undefined, + fetchIfMissing: !appInfo, + }) const { preferences } = useInstancePreferences(instance.id, { enabled: instance.connected }) const [incognitoMode, setIncognitoMode] = useIncognitoMode() const [speedUnit] = useSpeedUnits() @@ -232,18 +647,16 @@ function InstanceCard({ const connectionStatusDisplay = formattedConnectionStatus ? formattedConnectionStatus.replace(/\b\w/g, (char: string) => char.toUpperCase()) : "" const hasConnectionStatus = Boolean(formattedConnectionStatus) + const isConnectable = normalizedConnectionStatus === "connected" const isFirewalled = normalizedConnectionStatus === "firewalled" const ConnectionStatusIcon = isConnectable ? Globe : isFirewalled ? BrickWallFire : Ban - const connectionStatusIconClass = (() => { - if (!hasConnectionStatus) return "" - if (isConnectable) return "text-green-500" - if (isFirewalled) return "text-amber-500" - return "text-destructive" - })() + const connectionStatusIconClass = hasConnectionStatus ? isConnectable ? "text-green-500" : isFirewalled ? "text-amber-500" : "text-destructive" : "" const listenPort = preferences?.listen_port - const connectionStatusTooltip = connectionStatusDisplay? `${isConnectable ? "Connectable" : connectionStatusDisplay}${listenPort ? `. Port: ${listenPort}` : ""}`: "" + const connectionStatusTooltip = connectionStatusDisplay + ? `${isConnectable ? "Connectable" : connectionStatusDisplay}${listenPort ? `. Port: ${listenPort}` : ""}` + : "" // Determine if settings button should show const showSettingsButton = instance.connected && !isFirstLoad && !hasDecryptionOrRecentErrors @@ -273,7 +686,7 @@ function InstanceCard({ -
+
{instance.reannounceSettings?.enabled && ( @@ -287,48 +700,32 @@ function InstanceCard({ {instance.connected && !isFirstLoad && ( - { e.preventDefault() e.stopPropagation() - if (!isToggling) setShowSpeedLimitDialog(true) + setShowSpeedLimitDialog(true) }} - onKeyDown={(e) => { - if ((e.key === "Enter" || e.key === " ") && !isToggling) { - e.preventDefault() - setShowSpeedLimitDialog(true) - } - }} - className={`cursor-pointer ${isToggling ? "opacity-50" : ""}`} + disabled={isToggling} + className="h-8 w-8 p-0 shrink-0" > {altSpeedEnabled ? ( ) : ( )} - + Alternative speed limits: {altSpeedEnabled ? "On" : "Off"} )} - {instance.hasLocalFilesystemAccess && ( - - - - - - Local file access enabled - - - )}
@@ -341,7 +738,8 @@ function InstanceCard({ {altSpeedEnabled ? "Disable Alternative Speed Limits?" : "Enable Alternative Speed Limits?"} - {altSpeedEnabled? `This will disable alternative speed limits for ${instance.name} and return to normal speed limits.`: `This will enable alternative speed limits for ${instance.name}, which will reduce transfer speeds based on your configured limits.`} + {altSpeedEnabled ? `This will disable alternative speed limits for ${instance.name} and return to normal speed limits.` : `This will enable alternative speed limits for ${instance.name}, which will reduce transfer speeds based on your configured limits.` + } @@ -627,8 +1025,9 @@ function InstanceCard({ ) } -function MobileGlobalStatsCard({ globalStats }: { globalStats: GlobalStats }) { +function MobileGlobalStatsCard({ statsData }: { statsData: DashboardInstanceStats[] }) { const [speedUnit] = useSpeedUnits() + const globalStats = useGlobalStats(statsData) return ( @@ -682,10 +1081,9 @@ function MobileGlobalStatsCard({ globalStats }: { globalStats: GlobalStats }) { ) } -type GlobalStats = ReturnType - -function GlobalStatsCards({ globalStats }: { globalStats: GlobalStats }) { +function GlobalStatsCards({ statsData }: { statsData: DashboardInstanceStats[] }) { const [speedUnit] = useSpeedUnits() + const globalStats = useGlobalStats(statsData) return ( <> @@ -820,7 +1218,9 @@ function GlobalAllTimeStats({ statsData, isCollapsed, onCollapsedChange }: Globa {globalStats.totalPeers > 0 && (
Peers: - {globalStats.totalPeers} + + {globalStats.totalPeers} +
)}
@@ -855,7 +1255,9 @@ function GlobalAllTimeStats({ statsData, isCollapsed, onCollapsedChange }: Globa {globalStats.totalPeers > 0 && (
Peers: - {globalStats.totalPeers} + + {globalStats.totalPeers} +
)}
@@ -929,7 +1331,9 @@ function SortIcon({ column, sortColumn, sortDirection }: { column: TrackerSortCo if (sortColumn !== column) { return } - return sortDirection === "asc"? : + return sortDirection === "asc" + ? + : } // Extended tracker stats with customization support @@ -967,7 +1371,6 @@ function TrackerBreakdownCard({ statsData, settings, onSettingsChange, isCollaps // Selection state for merging/renaming const [selectedDomains, setSelectedDomains] = useState>(new Set()) - const [selectedGroupId, setSelectedGroupId] = useState(null) const [showCustomizeDialog, setShowCustomizeDialog] = useState(false) const [customizeDisplayName, setCustomizeDisplayName] = useState("") const [editingCustomization, setEditingCustomization] = useState<{ id: number; domains: string[]; includedInStats: string[] } | null>(null) @@ -1053,49 +1456,11 @@ function TrackerBreakdownCard({ statsData, settings, onSettingsChange, isCollaps existing.downloaded += stats.downloaded existing.totalSize += stats.totalSize existing.count += stats.count - continue } - - processed.set(customization.displayName, { - ...stats, - domain: customization.domains[0] ?? domain, - displayName: customization.displayName, - originalDomains: customization.domains, - customizationId: customization.id, - }) } } } - // Pass 3: Ensure merged groups remain visible even if the primary domain has no torrents. - // If no primary/included domain produced a group entry, fall back to whichever domain in the group - // currently has stats (pick the one with the highest torrent count to avoid double-counting). - const fallbackByDisplayName = new Map() - for (const [domain, stats] of aggregated) { - const customization = domainToCustomization.get(domain.toLowerCase()) - if (!customization) continue - if (processed.has(customization.displayName)) continue - - const existing = fallbackByDisplayName.get(customization.displayName) - if ( - !existing || - stats.count > existing.stats.count || - (stats.count === existing.stats.count && stats.uploaded > existing.stats.uploaded) - ) { - fallbackByDisplayName.set(customization.displayName, { customization, stats, domain }) - } - } - - for (const { customization, stats, domain } of fallbackByDisplayName.values()) { - processed.set(customization.displayName, { - ...stats, - domain: customization.domains[0] ?? domain, - displayName: customization.displayName, - originalDomains: customization.domains, - customizationId: customization.id, - }) - } - return Array.from(processed.values()) }, [statsData, customizations]) @@ -1155,52 +1520,24 @@ function TrackerBreakdownCard({ statsData, settings, onSettingsChange, isCollaps }) } - const toggleGroupSelection = (customizationId: number) => { - setSelectedGroupId(prev => prev === customizationId ? null : customizationId) - } - const clearSelection = () => { setSelectedDomains(new Set()) - setSelectedGroupId(null) - } - - // Merge into a group - const handleMergeIntoGroup = (targetGroupId: number, domain?: string) => { - const group = customizations?.find(c => c.id === targetGroupId) - if (!group) return - - const domainsSet = new Set(selectedDomains) - if (domain) domainsSet.add(domain) // no-op if already present - const domainsToMerge = Array.from(domainsSet) - - if (domainsToMerge.length === 0) return - - // Merge into selected group - const mergedDomains = [...new Set([...group.domains, ...domainsToMerge])] - updateCustomization.mutate({ - id: targetGroupId, - data: { - displayName: group.displayName, - domains: mergedDomains, - includedInStats: group.includedInStats ?? [], - }, - }, { - onSuccess: () => { - clearSelection() - }, - }) } // Save customization (create or update) const handleSaveCustomization = () => { if (!customizeDisplayName.trim()) return - const domains = editingCustomization? editingCustomization.domains: Array.from(selectedDomains) + const domains = editingCustomization + ? editingCustomization.domains + : Array.from(selectedDomains) if (domains.length === 0) return // Get included domains from state (secondary domains that contribute to stats) - const included = editingCustomization? editingCustomization.includedInStats: Array.from(includedInStats) + const included = editingCustomization + ? editingCustomization.includedInStats + : Array.from(includedInStats) if (editingCustomization) { // Update existing @@ -1290,7 +1627,7 @@ function TrackerBreakdownCard({ statsData, settings, onSettingsChange, isCollaps setEditingCustomization({ id: customizationId, domains, - includedInStats: fullCustomization?.includedInStats ?? [], + includedInStats: fullCustomization?.includedInStats ?? [] }) setCustomizeDisplayName(currentName) setShowCustomizeDialog(true) @@ -1357,7 +1694,9 @@ function TrackerBreakdownCard({ statsData, settings, onSettingsChange, isCollaps const handleToggleStatsInclusion = (domain: string, include: boolean) => { if (editingCustomization) { const domainLower = domain.toLowerCase() - const newIncluded = include? [...editingCustomization.includedInStats.filter(d => d.toLowerCase() !== domainLower), domain]: editingCustomization.includedInStats.filter(d => d.toLowerCase() !== domainLower) + const newIncluded = include + ? [...editingCustomization.includedInStats.filter(d => d.toLowerCase() !== domainLower), domain] + : editingCustomization.includedInStats.filter(d => d.toLowerCase() !== domainLower) setEditingCustomization({ ...editingCustomization, includedInStats: newIncluded }) } else { const newIncluded = new Set(includedInStats) @@ -1470,7 +1809,7 @@ function TrackerBreakdownCard({ statsData, settings, onSettingsChange, isCollaps includedInStats: entry.includedInStats ?? [], index, conflict: existingCustomization, - isIdentical, + isIdentical } }) @@ -1664,7 +2003,12 @@ function TrackerBreakdownCard({ statsData, settings, onSettingsChange, isCollaps @@ -1702,25 +2046,15 @@ function TrackerBreakdownCard({ statsData, settings, onSettingsChange, isCollaps const displayValue = incognitoMode ? getLinuxTrackerDomain(displayName) : displayName const iconDomain = incognitoMode ? getLinuxTrackerDomain(domain) : domain const isSelected = selectedDomains.has(domain) - const isGroupSelected = selectedGroupId === customizationId const isMerged = originalDomains.length > 1 const hasCustomization = Boolean(customizationId) return ( - +
- {hasCustomization ? ( - // Show group checkbox if no group selected or the group selected - (selectedGroupId === null || isGroupSelected) && ( - toggleGroupSelection(customizationId!)} - className="shrink-0" - /> - ) - ) : ( + {!hasCustomization && ( toggleSelection(domain)} @@ -1746,51 +2080,32 @@ function TrackerBreakdownCard({ statsData, settings, onSettingsChange, isCollaps
{hasCustomization && customizationId ? ( - // Show group merge if domains selected and if no other group is selected - selectedDomains.size > 0 && !(selectedGroupId !== null && selectedGroupId !== customizationId) ? ( + <> - ) : ( - <> - - - - ) + + ) : ( - - Merge selected trackers into this group - - ) : ( - <> - - - - ) + <> + + + ) : ( @@ -2042,16 +2330,9 @@ function TrackerBreakdownCard({ statsData, settings, onSettingsChange, isCollaps variant="ghost" size="sm" className="h-6 w-6 p-0" - onClick={(e) => { - e.stopPropagation() - if (selectedGroupId) { - handleMergeIntoGroup(selectedGroupId, domain) - } else { - openRenameDialog(domain) - } - }} + onClick={(e) => { e.stopPropagation(); openRenameDialog(domain) }} > - {selectedGroupId || selectedDomains.size > 0 ? ( + {selectedDomains.size > 0 ? ( ) : ( @@ -2059,7 +2340,7 @@ function TrackerBreakdownCard({ statsData, settings, onSettingsChange, isCollaps - {selectedGroupId ? "Merge into group" : selectedDomains.size > 0 ? "Add to merge" : "Rename"} + {selectedDomains.size > 0 ? "Add to merge" : "Rename"} )} @@ -2131,13 +2412,21 @@ function TrackerBreakdownCard({ statsData, settings, onSettingsChange, isCollaps {/* Customize Dialog (Rename/Merge/Edit) */} !open && closeCustomizeDialog()}> - - + + - {editingCustomization? "Edit Tracker Name": selectedDomains.size === 1? "Rename Tracker": "Merge Trackers"} + {editingCustomization + ? "Edit Tracker Name" + : selectedDomains.size === 1 + ? "Rename Tracker" + : "Merge Trackers"} - {editingCustomization? "Update the display name for this tracker.": selectedDomains.size === 1? "Give this tracker a custom display name.": "Combine these trackers into a single entry with a custom name."} + {editingCustomization + ? "Update the display name for this tracker." + : selectedDomains.size === 1 + ? "Give this tracker a custom display name." + : "Combine these trackers into a single entry with a custom name."}
@@ -2150,26 +2439,28 @@ function TrackerBreakdownCard({ statsData, settings, onSettingsChange, isCollaps placeholder="e.g., TorrentLeech" />
-
+
{((editingCustomization && editingCustomization.domains.length > 1) || (!editingCustomization && selectedDomains.size > 1)) && (

Uncheck duplicate tracker URLs to avoid counting the same torrents twice.

)} - +
{(editingCustomization ? editingCustomization.domains : Array.from(selectedDomains)).map((domain, index, arr) => { const hasMultiple = arr.length > 1 const isPrimary = index === 0 // Get inclusion state from appropriate source // Primary is always included; secondary domains only if in includedInStats - const currentIncluded = editingCustomization? editingCustomization.includedInStats: Array.from(includedInStats) + const currentIncluded = editingCustomization + ? editingCustomization.includedInStats + : Array.from(includedInStats) const isInList = currentIncluded.some(d => d.toLowerCase() === domain.toLowerCase()) const isIncluded = isPrimary || isInList return ( -
+
{hasMultiple && ( )} - {domain} - {hasMultiple && ( - isPrimary ? ( - Primary - ) : + {domain} + {isPrimary && hasMultiple && ( + Primary )} {hasMultiple && (
- + @@ -2208,7 +2497,13 @@ function TrackerBreakdownCard({ statsData, settings, onSettingsChange, isCollaps onClick={handleSaveCustomization} disabled={!customizeDisplayName.trim() || createCustomization.isPending || updateCustomization.isPending} > - {(createCustomization.isPending || updateCustomization.isPending)? "Saving...": editingCustomization? "Save": selectedDomains.size === 1? "Rename": "Merge"} + {(createCustomization.isPending || updateCustomization.isPending) + ? "Saving..." + : editingCustomization + ? "Save" + : selectedDomains.size === 1 + ? "Rename" + : "Merge"} @@ -2216,21 +2511,21 @@ function TrackerBreakdownCard({ statsData, settings, onSettingsChange, isCollaps {/* Import Dialog */} - - + + Import Tracker Customizations Paste JSON to import tracker customizations (renames and merges). -
+