341 lines
10 KiB
Go
341 lines
10 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/cache"
|
|
)
|
|
|
|
const (
|
|
adminStatsCacheKey = "global"
|
|
adminStatsCacheTTL = 15 * time.Second
|
|
)
|
|
|
|
// AdminStats represents system statistics for the admin dashboard.
|
|
type AdminStats struct {
|
|
TotalItems int `json:"total_items"`
|
|
TotalFiles int `json:"total_files"`
|
|
TotalUsers int `json:"total_users"`
|
|
TotalMovies int `json:"total_movies"`
|
|
TotalShows int `json:"total_shows"`
|
|
ActiveStreams int `json:"active_streams"`
|
|
TotalStorageBytes int64 `json:"total_storage_bytes"`
|
|
WatchProviderActivity WatchProviderActivity `json:"watch_provider_activity"`
|
|
}
|
|
|
|
type WatchProviderActivity struct {
|
|
TraktConnectedProfiles int64 `json:"trakt_connected_profiles"`
|
|
TraktEnabledProfiles int64 `json:"trakt_enabled_profiles"`
|
|
TraktExportEnabled int64 `json:"trakt_export_enabled"`
|
|
TraktScrobbleEnabled int64 `json:"trakt_scrobble_enabled"`
|
|
LastSyncCompletedAt *time.Time `json:"last_sync_completed_at,omitempty"`
|
|
SyncRuns24h int64 `json:"sync_runs_24h"`
|
|
SyncErrors24h int64 `json:"sync_errors_24h"`
|
|
ImportedWatched24h int64 `json:"imported_watched_24h"`
|
|
ImportedProgress24h int64 `json:"imported_progress_24h"`
|
|
ExportedWatched24h int64 `json:"exported_watched_24h"`
|
|
PendingExports int64 `json:"pending_exports"`
|
|
FailedExports int64 `json:"failed_exports"`
|
|
OpenScrobbles int64 `json:"open_scrobbles"`
|
|
Scrobbles24h int64 `json:"scrobbles_24h"`
|
|
}
|
|
|
|
// AdminStatsSource returns cached or freshly queried admin stats.
|
|
type AdminStatsSource interface {
|
|
Get(ctx context.Context) (AdminStats, error)
|
|
Invalidate()
|
|
}
|
|
|
|
// AdminStatsProvider serves exact admin stats with a short in-process TTL and
|
|
// optional cross-node invalidation via the shared event bus.
|
|
type AdminStatsProvider struct {
|
|
pool *pgxpool.Pool
|
|
cache *cache.TTLCache[AdminStats]
|
|
ttl time.Duration
|
|
}
|
|
|
|
var _ AdminStatsSource = (*AdminStatsProvider)(nil)
|
|
|
|
// NewAdminStatsProvider creates a cached provider and subscribes it to the
|
|
// shared invalidation channels when an event bus is configured.
|
|
func NewAdminStatsProvider(ctx context.Context, pool *pgxpool.Pool, bus cache.EventBus) (*AdminStatsProvider, error) {
|
|
provider := &AdminStatsProvider{
|
|
pool: pool,
|
|
cache: cache.NewTTLCache[AdminStats](),
|
|
ttl: adminStatsCacheTTL,
|
|
}
|
|
|
|
if bus == nil || ctx == nil {
|
|
return provider, nil
|
|
}
|
|
|
|
handler := func(cache.Event) {
|
|
provider.Invalidate()
|
|
}
|
|
for _, channel := range []string{cache.ChannelCatalog, cache.ChannelAdmin, cache.ChannelPlayback} {
|
|
if err := bus.Subscribe(ctx, channel, handler); err != nil {
|
|
provider.Close()
|
|
return nil, fmt.Errorf("subscribing admin stats provider to %s: %w", channel, err)
|
|
}
|
|
}
|
|
|
|
return provider, nil
|
|
}
|
|
|
|
// Get returns cached stats when available, otherwise it queries Postgres and
|
|
// stores the exact result for a short period.
|
|
func (p *AdminStatsProvider) Get(ctx context.Context) (AdminStats, error) {
|
|
if p == nil || p.pool == nil {
|
|
return AdminStats{}, fmt.Errorf("admin stats provider is not configured")
|
|
}
|
|
if stats, ok := p.cache.Get(adminStatsCacheKey); ok {
|
|
return stats, nil
|
|
}
|
|
|
|
stats, err := queryAdminStats(ctx, p.pool)
|
|
if err != nil {
|
|
return AdminStats{}, err
|
|
}
|
|
p.cache.Set(adminStatsCacheKey, stats, p.ttl)
|
|
return stats, nil
|
|
}
|
|
|
|
// Invalidate drops the current cached stats snapshot.
|
|
func (p *AdminStatsProvider) Invalidate() {
|
|
if p == nil || p.cache == nil {
|
|
return
|
|
}
|
|
p.cache.Invalidate(adminStatsCacheKey)
|
|
}
|
|
|
|
// Close stops the background TTL sweeper.
|
|
func (p *AdminStatsProvider) Close() {
|
|
if p == nil || p.cache == nil {
|
|
return
|
|
}
|
|
p.cache.Close()
|
|
}
|
|
|
|
func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error) {
|
|
if pool == nil {
|
|
return AdminStats{}, fmt.Errorf("database not configured")
|
|
}
|
|
|
|
var (
|
|
totalUsers int64
|
|
totalItems int64
|
|
totalFiles int64
|
|
totalMovies int64
|
|
totalShows int64
|
|
activeStreams int64
|
|
totalStorage int64
|
|
)
|
|
|
|
row := pool.QueryRow(ctx, `
|
|
WITH user_stats AS (
|
|
SELECT COUNT(*)::bigint AS total_users
|
|
FROM users
|
|
),
|
|
item_stats AS (
|
|
SELECT
|
|
COUNT(*)::bigint AS total_items,
|
|
COUNT(*) FILTER (WHERE type = 'movie')::bigint AS total_movies,
|
|
COUNT(*) FILTER (WHERE type = 'series')::bigint AS total_shows
|
|
FROM media_items
|
|
),
|
|
file_stats AS (
|
|
SELECT
|
|
COUNT(*)::bigint AS total_files,
|
|
COALESCE(SUM(file_size), 0)::bigint AS total_storage_bytes
|
|
FROM media_files
|
|
),
|
|
session_stats AS (
|
|
SELECT COUNT(*)::bigint AS active_streams
|
|
FROM playback_sessions_sync
|
|
)
|
|
SELECT
|
|
user_stats.total_users,
|
|
item_stats.total_items,
|
|
file_stats.total_files,
|
|
item_stats.total_movies,
|
|
item_stats.total_shows,
|
|
session_stats.active_streams,
|
|
file_stats.total_storage_bytes
|
|
FROM user_stats
|
|
CROSS JOIN item_stats
|
|
CROSS JOIN file_stats
|
|
CROSS JOIN session_stats
|
|
`)
|
|
if err := row.Scan(
|
|
&totalUsers,
|
|
&totalItems,
|
|
&totalFiles,
|
|
&totalMovies,
|
|
&totalShows,
|
|
&activeStreams,
|
|
&totalStorage,
|
|
); err != nil {
|
|
return AdminStats{}, fmt.Errorf("querying admin stats: %w", err)
|
|
}
|
|
|
|
activity, err := queryWatchProviderActivity(ctx, pool)
|
|
if err != nil {
|
|
slog.Warn("failed to query watch provider admin stats", "error", err)
|
|
activity = WatchProviderActivity{}
|
|
}
|
|
|
|
return AdminStats{
|
|
TotalUsers: int(totalUsers),
|
|
TotalItems: int(totalItems),
|
|
TotalFiles: int(totalFiles),
|
|
TotalMovies: int(totalMovies),
|
|
TotalShows: int(totalShows),
|
|
ActiveStreams: int(activeStreams),
|
|
TotalStorageBytes: totalStorage,
|
|
WatchProviderActivity: activity,
|
|
}, nil
|
|
}
|
|
|
|
func queryWatchProviderActivity(ctx context.Context, pool *pgxpool.Pool) (WatchProviderActivity, error) {
|
|
ready, err := watchProviderStatsTablesReady(ctx, pool)
|
|
if err != nil {
|
|
return WatchProviderActivity{}, err
|
|
}
|
|
if !ready {
|
|
return WatchProviderActivity{}, nil
|
|
}
|
|
|
|
var activity WatchProviderActivity
|
|
row := pool.QueryRow(ctx, `
|
|
WITH watch_provider_connection_stats AS (
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE provider = 'trakt')::bigint AS trakt_connected_profiles,
|
|
COUNT(*) FILTER (
|
|
WHERE provider = 'trakt'
|
|
AND (
|
|
import_watched_enabled
|
|
OR import_progress_enabled
|
|
OR export_watched_enabled
|
|
OR scrobble_enabled
|
|
)
|
|
)::bigint AS trakt_enabled_profiles,
|
|
COUNT(*) FILTER (WHERE provider = 'trakt' AND export_watched_enabled)::bigint AS trakt_export_enabled,
|
|
COUNT(*) FILTER (WHERE provider = 'trakt' AND scrobble_enabled)::bigint AS trakt_scrobble_enabled
|
|
FROM watch_provider_connections
|
|
),
|
|
watch_provider_sync_stats AS (
|
|
SELECT
|
|
MAX(completed_at) FILTER (WHERE provider = 'trakt') AS last_sync_completed_at,
|
|
COUNT(*) FILTER (
|
|
WHERE provider = 'trakt'
|
|
AND started_at >= now() - interval '24 hours'
|
|
)::bigint AS sync_runs_24h,
|
|
COUNT(*) FILTER (
|
|
WHERE provider = 'trakt'
|
|
AND status = 'failed'
|
|
AND started_at >= now() - interval '24 hours'
|
|
)::bigint AS sync_errors_24h,
|
|
COALESCE(SUM(inbound_watched_imported) FILTER (
|
|
WHERE provider = 'trakt'
|
|
AND started_at >= now() - interval '24 hours'
|
|
), 0)::bigint AS imported_watched_24h,
|
|
COALESCE(SUM(inbound_progress_imported) FILTER (
|
|
WHERE provider = 'trakt'
|
|
AND started_at >= now() - interval '24 hours'
|
|
), 0)::bigint AS imported_progress_24h,
|
|
COALESCE(SUM(outbound_sent) FILTER (
|
|
WHERE provider = 'trakt'
|
|
AND started_at >= now() - interval '24 hours'
|
|
), 0)::bigint AS exported_watched_24h
|
|
FROM watch_provider_sync_runs
|
|
),
|
|
watch_provider_export_stats AS (
|
|
SELECT
|
|
COUNT(*) FILTER (
|
|
WHERE c.provider = 'trakt'
|
|
AND e.status = 'pending'
|
|
)::bigint AS pending_exports,
|
|
COUNT(*) FILTER (
|
|
WHERE c.provider = 'trakt'
|
|
AND e.status = 'failed'
|
|
)::bigint AS failed_exports
|
|
FROM watch_provider_history_exports e
|
|
JOIN watch_provider_connections c ON c.id = e.connection_id
|
|
),
|
|
watch_provider_scrobble_stats AS (
|
|
SELECT
|
|
COUNT(*) FILTER (
|
|
WHERE c.provider = 'trakt'
|
|
AND s.stop_sent_at IS NULL
|
|
)::bigint AS open_scrobbles,
|
|
COUNT(*) FILTER (
|
|
WHERE c.provider = 'trakt'
|
|
AND s.updated_at >= now() - interval '24 hours'
|
|
)::bigint AS scrobbles_24h
|
|
FROM watch_provider_scrobble_sessions s
|
|
JOIN watch_provider_connections c ON c.id = s.connection_id
|
|
)
|
|
SELECT
|
|
watch_provider_connection_stats.trakt_connected_profiles,
|
|
watch_provider_connection_stats.trakt_enabled_profiles,
|
|
watch_provider_connection_stats.trakt_export_enabled,
|
|
watch_provider_connection_stats.trakt_scrobble_enabled,
|
|
watch_provider_sync_stats.last_sync_completed_at,
|
|
watch_provider_sync_stats.sync_runs_24h,
|
|
watch_provider_sync_stats.sync_errors_24h,
|
|
watch_provider_sync_stats.imported_watched_24h,
|
|
watch_provider_sync_stats.imported_progress_24h,
|
|
watch_provider_sync_stats.exported_watched_24h,
|
|
watch_provider_export_stats.pending_exports,
|
|
watch_provider_export_stats.failed_exports,
|
|
watch_provider_scrobble_stats.open_scrobbles,
|
|
watch_provider_scrobble_stats.scrobbles_24h
|
|
FROM watch_provider_connection_stats
|
|
CROSS JOIN watch_provider_sync_stats
|
|
CROSS JOIN watch_provider_export_stats
|
|
CROSS JOIN watch_provider_scrobble_stats
|
|
`)
|
|
if err := row.Scan(
|
|
&activity.TraktConnectedProfiles,
|
|
&activity.TraktEnabledProfiles,
|
|
&activity.TraktExportEnabled,
|
|
&activity.TraktScrobbleEnabled,
|
|
&activity.LastSyncCompletedAt,
|
|
&activity.SyncRuns24h,
|
|
&activity.SyncErrors24h,
|
|
&activity.ImportedWatched24h,
|
|
&activity.ImportedProgress24h,
|
|
&activity.ExportedWatched24h,
|
|
&activity.PendingExports,
|
|
&activity.FailedExports,
|
|
&activity.OpenScrobbles,
|
|
&activity.Scrobbles24h,
|
|
); err != nil {
|
|
return WatchProviderActivity{}, fmt.Errorf("querying watch provider activity stats: %w", err)
|
|
}
|
|
|
|
return activity, nil
|
|
}
|
|
|
|
func watchProviderStatsTablesReady(ctx context.Context, pool *pgxpool.Pool) (bool, error) {
|
|
var ready bool
|
|
err := pool.QueryRow(ctx, `
|
|
SELECT bool_and(to_regclass(table_name) IS NOT NULL)
|
|
FROM unnest($1::text[]) AS table_name
|
|
`, []string{
|
|
"public.watch_provider_connections",
|
|
"public.watch_provider_sync_runs",
|
|
"public.watch_provider_history_exports",
|
|
"public.watch_provider_scrobble_sessions",
|
|
}).Scan(&ready)
|
|
if err != nil {
|
|
return false, fmt.Errorf("checking watch provider stats tables: %w", err)
|
|
}
|
|
return ready, nil
|
|
}
|