Initial Upload
Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled
Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled
This commit is contained in:
29
packages/shared/package.json
Normal file
29
packages/shared/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@tracearr/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"clean": "rm -rf dist .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
314
packages/shared/src/constants.ts
Normal file
314
packages/shared/src/constants.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Shared constants for Tracearr
|
||||
*/
|
||||
|
||||
// Rule type definitions with default parameters
|
||||
export const RULE_DEFAULTS = {
|
||||
impossible_travel: {
|
||||
maxSpeedKmh: 500,
|
||||
ignoreVpnRanges: false,
|
||||
},
|
||||
simultaneous_locations: {
|
||||
minDistanceKm: 100,
|
||||
},
|
||||
device_velocity: {
|
||||
maxIps: 5,
|
||||
windowHours: 24,
|
||||
},
|
||||
concurrent_streams: {
|
||||
maxStreams: 3,
|
||||
},
|
||||
geo_restriction: {
|
||||
blockedCountries: [],
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Rule type display names
|
||||
export const RULE_DISPLAY_NAMES = {
|
||||
impossible_travel: 'Impossible Travel',
|
||||
simultaneous_locations: 'Simultaneous Locations',
|
||||
device_velocity: 'Device Velocity',
|
||||
concurrent_streams: 'Concurrent Streams',
|
||||
geo_restriction: 'Geo Restriction',
|
||||
} as const;
|
||||
|
||||
// Severity levels
|
||||
export const SEVERITY_LEVELS = {
|
||||
low: { label: 'Low', priority: 1 },
|
||||
warning: { label: 'Warning', priority: 2 },
|
||||
high: { label: 'High', priority: 3 },
|
||||
} as const;
|
||||
|
||||
// Type for severity priority numbers (1=low, 2=warning, 3=high)
|
||||
export type SeverityPriority = 1 | 2 | 3;
|
||||
|
||||
// Helper to get severity priority from string
|
||||
export function getSeverityPriority(severity: keyof typeof SEVERITY_LEVELS): SeverityPriority {
|
||||
return SEVERITY_LEVELS[severity]?.priority ?? 1;
|
||||
}
|
||||
|
||||
// WebSocket event names
|
||||
export const WS_EVENTS = {
|
||||
SESSION_STARTED: 'session:started',
|
||||
SESSION_STOPPED: 'session:stopped',
|
||||
SESSION_UPDATED: 'session:updated',
|
||||
VIOLATION_NEW: 'violation:new',
|
||||
STATS_UPDATED: 'stats:updated',
|
||||
IMPORT_PROGRESS: 'import:progress',
|
||||
SUBSCRIBE_SESSIONS: 'subscribe:sessions',
|
||||
UNSUBSCRIBE_SESSIONS: 'unsubscribe:sessions',
|
||||
} as const;
|
||||
|
||||
// Redis key prefixes
|
||||
export const REDIS_KEYS = {
|
||||
// Active sessions: SET of session IDs for atomic add/remove
|
||||
ACTIVE_SESSION_IDS: 'tracearr:sessions:active:ids',
|
||||
// Legacy: JSON array of sessions (deprecated, kept for migration)
|
||||
ACTIVE_SESSIONS: 'tracearr:sessions:active',
|
||||
// Individual session data
|
||||
SESSION_BY_ID: (id: string) => `tracearr:sessions:${id}`,
|
||||
USER_SESSIONS: (userId: string) => `tracearr:users:${userId}:sessions`,
|
||||
DASHBOARD_STATS: 'tracearr:stats:dashboard',
|
||||
RATE_LIMIT_LOGIN: (ip: string) => `tracearr:ratelimit:login:${ip}`,
|
||||
RATE_LIMIT_MOBILE_PAIR: (ip: string) => `tracearr:ratelimit:mobile:pair:${ip}`,
|
||||
RATE_LIMIT_MOBILE_REFRESH: (ip: string) => `tracearr:ratelimit:mobile:refresh:${ip}`,
|
||||
SERVER_HEALTH: (serverId: string) => `tracearr:servers:${serverId}:health`,
|
||||
PUBSUB_EVENTS: 'tracearr:events',
|
||||
// Notification rate limiting (sliding window counters)
|
||||
PUSH_RATE_MINUTE: (sessionId: string) => `tracearr:push:rate:minute:${sessionId}`,
|
||||
PUSH_RATE_HOUR: (sessionId: string) => `tracearr:push:rate:hour:${sessionId}`,
|
||||
// Location stats filter caching (includes serverIds hash for proper scoping)
|
||||
LOCATION_FILTERS: (userId: string, serverIds: string[]) => {
|
||||
// Sort and hash serverIds for stable cache key
|
||||
const serverHash = serverIds.length > 0 ? serverIds.slice().sort().join(',') : 'all';
|
||||
return `tracearr:filters:locations:${userId}:${serverHash}`;
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Cache TTLs in seconds
|
||||
export const CACHE_TTL = {
|
||||
DASHBOARD_STATS: 60,
|
||||
ACTIVE_SESSIONS: 300,
|
||||
USER_SESSIONS: 3600,
|
||||
RATE_LIMIT: 900,
|
||||
SERVER_HEALTH: 600, // 10 minutes - servers marked unhealthy if no update
|
||||
LOCATION_FILTERS: 300, // 5 minutes - filter options change infrequently
|
||||
} as const;
|
||||
|
||||
// Notification event types (must match NotificationEventType in types.ts)
|
||||
export const NOTIFICATION_EVENTS = {
|
||||
VIOLATION_DETECTED: 'violation_detected',
|
||||
STREAM_STARTED: 'stream_started',
|
||||
STREAM_STOPPED: 'stream_stopped',
|
||||
CONCURRENT_STREAMS: 'concurrent_streams',
|
||||
NEW_DEVICE: 'new_device',
|
||||
TRUST_SCORE_CHANGED: 'trust_score_changed',
|
||||
SERVER_DOWN: 'server_down',
|
||||
SERVER_UP: 'server_up',
|
||||
} as const;
|
||||
|
||||
// API version
|
||||
export const API_VERSION = 'v1';
|
||||
export const API_BASE_PATH = `/api/${API_VERSION}`;
|
||||
|
||||
// JWT configuration
|
||||
export const JWT_CONFIG = {
|
||||
ACCESS_TOKEN_EXPIRY: '48h',
|
||||
REFRESH_TOKEN_EXPIRY: '30d',
|
||||
ALGORITHM: 'HS256',
|
||||
} as const;
|
||||
|
||||
// Polling intervals in milliseconds
|
||||
export const POLLING_INTERVALS = {
|
||||
SESSIONS: 15000,
|
||||
STATS_REFRESH: 60000,
|
||||
SERVER_HEALTH: 30000,
|
||||
// Reconciliation interval when SSE is active (fallback check)
|
||||
SSE_RECONCILIATION: 30 * 1000, // 30 seconds
|
||||
} as const;
|
||||
|
||||
// SSE (Server-Sent Events) configuration
|
||||
export const SSE_CONFIG = {
|
||||
// Reconnection settings
|
||||
INITIAL_RETRY_DELAY_MS: 1000,
|
||||
MAX_RETRY_DELAY_MS: 30000,
|
||||
RETRY_MULTIPLIER: 2,
|
||||
MAX_RETRIES: 10,
|
||||
// Heartbeat/keepalive - how long without events before assuming connection died
|
||||
// Plex sends ping events every 10 seconds, so 30s = miss 3 pings = dead
|
||||
HEARTBEAT_TIMEOUT_MS: 30000, // 30 seconds
|
||||
// When to fall back to polling
|
||||
FALLBACK_THRESHOLD: 5, // consecutive failures before fallback
|
||||
} as const;
|
||||
|
||||
// Plex SSE notification types (from /:/eventsource/notifications)
|
||||
export const PLEX_SSE_EVENTS = {
|
||||
// Session-related
|
||||
PLAYING: 'playing',
|
||||
PROGRESS: 'progress',
|
||||
STOPPED: 'stopped',
|
||||
PAUSED: 'paused',
|
||||
RESUMED: 'resumed',
|
||||
// Library updates
|
||||
LIBRARY_UPDATE: 'library.update',
|
||||
LIBRARY_SCAN: 'library.scan',
|
||||
// Server status
|
||||
SERVER_BACKUP: 'server.backup',
|
||||
SERVER_UPDATE: 'server.update',
|
||||
// Activity
|
||||
ACTIVITY: 'activity',
|
||||
// Transcoder
|
||||
TRANSCODE_SESSION_UPDATE: 'transcodeSession.update',
|
||||
TRANSCODE_SESSION_END: 'transcodeSession.end',
|
||||
} as const;
|
||||
|
||||
// SSE connection states
|
||||
export const SSE_STATE = {
|
||||
CONNECTING: 'connecting',
|
||||
CONNECTED: 'connected',
|
||||
RECONNECTING: 'reconnecting',
|
||||
DISCONNECTED: 'disconnected',
|
||||
FALLBACK: 'fallback', // Using polling as fallback
|
||||
} as const;
|
||||
|
||||
// Pagination defaults
|
||||
export const PAGINATION = {
|
||||
DEFAULT_PAGE: 1,
|
||||
DEFAULT_PAGE_SIZE: 20,
|
||||
MAX_PAGE_SIZE: 100,
|
||||
} as const;
|
||||
|
||||
// GeoIP configuration
|
||||
export const GEOIP_CONFIG = {
|
||||
EARTH_RADIUS_KM: 6371,
|
||||
DEFAULT_UNKNOWN_LOCATION: 'Unknown',
|
||||
} as const;
|
||||
|
||||
// Unit conversion constants
|
||||
export const UNIT_CONVERSION = {
|
||||
KM_TO_MILES: 0.621371,
|
||||
MILES_TO_KM: 1.60934,
|
||||
} as const;
|
||||
|
||||
// Unit system types and utilities
|
||||
export type UnitSystem = 'metric' | 'imperial';
|
||||
|
||||
/**
|
||||
* Convert kilometers to miles
|
||||
*/
|
||||
export function kmToMiles(km: number): number {
|
||||
return km * UNIT_CONVERSION.KM_TO_MILES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert miles to kilometers
|
||||
*/
|
||||
export function milesToKm(miles: number): number {
|
||||
return miles * UNIT_CONVERSION.MILES_TO_KM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format distance based on unit system
|
||||
* @param km - Distance in kilometers (internal unit)
|
||||
* @param unitSystem - User's preferred unit system
|
||||
* @param decimals - Number of decimal places (default: 0)
|
||||
*/
|
||||
export function formatDistance(km: number, unitSystem: UnitSystem, decimals = 0): string {
|
||||
if (unitSystem === 'imperial') {
|
||||
const miles = kmToMiles(km);
|
||||
return `${miles.toFixed(decimals)} mi`;
|
||||
}
|
||||
return `${km.toFixed(decimals)} km`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format speed based on unit system
|
||||
* @param kmh - Speed in km/h (internal unit)
|
||||
* @param unitSystem - User's preferred unit system
|
||||
* @param decimals - Number of decimal places (default: 0)
|
||||
*/
|
||||
export function formatSpeed(kmh: number, unitSystem: UnitSystem, decimals = 0): string {
|
||||
if (unitSystem === 'imperial') {
|
||||
const mph = kmToMiles(kmh);
|
||||
return `${mph.toFixed(decimals)} mph`;
|
||||
}
|
||||
return `${kmh.toFixed(decimals)} km/h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get distance unit label
|
||||
*/
|
||||
export function getDistanceUnit(unitSystem: UnitSystem): string {
|
||||
return unitSystem === 'imperial' ? 'mi' : 'km';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get speed unit label
|
||||
*/
|
||||
export function getSpeedUnit(unitSystem: UnitSystem): string {
|
||||
return unitSystem === 'imperial' ? 'mph' : 'km/h';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert display value to internal metric value (for form inputs)
|
||||
* @param value - Value in user's preferred unit
|
||||
* @param unitSystem - User's preferred unit system
|
||||
* @returns Value in kilometers (internal unit)
|
||||
*/
|
||||
export function toMetricDistance(value: number, unitSystem: UnitSystem): number {
|
||||
if (unitSystem === 'imperial') {
|
||||
return milesToKm(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert internal metric value to display value (for form inputs)
|
||||
* @param km - Value in kilometers (internal unit)
|
||||
* @param unitSystem - User's preferred unit system
|
||||
* @returns Value in user's preferred unit
|
||||
*/
|
||||
export function fromMetricDistance(km: number, unitSystem: UnitSystem): number {
|
||||
if (unitSystem === 'imperial') {
|
||||
return kmToMiles(km);
|
||||
}
|
||||
return km;
|
||||
}
|
||||
|
||||
// Time constants in milliseconds (avoid magic numbers)
|
||||
export const TIME_MS = {
|
||||
SECOND: 1000,
|
||||
MINUTE: 60 * 1000,
|
||||
HOUR: 60 * 60 * 1000,
|
||||
DAY: 24 * 60 * 60 * 1000,
|
||||
WEEK: 7 * 24 * 60 * 60 * 1000,
|
||||
} as const;
|
||||
|
||||
// Server resource statistics configuration (CPU, RAM)
|
||||
// Used with Plex's undocumented /statistics/resources endpoint
|
||||
export const SERVER_STATS_CONFIG = {
|
||||
// Poll interval in seconds (how often we fetch new data)
|
||||
POLL_INTERVAL_SECONDS: 6,
|
||||
// Timespan parameter for Plex API (MUST be 6 - other values return empty!)
|
||||
TIMESPAN_SECONDS: 6,
|
||||
// Fixed 2-minute window (20 data points at 6s intervals)
|
||||
WINDOW_SECONDS: 120,
|
||||
// Data points to display (2 min / 6s = 20 points)
|
||||
DATA_POINTS: 20,
|
||||
} as const;
|
||||
|
||||
// Session limits
|
||||
export const SESSION_LIMITS = {
|
||||
MAX_RECENT_PER_USER: 100,
|
||||
RESUME_WINDOW_HOURS: 24,
|
||||
// Watch completion threshold - 85% is industry standard
|
||||
WATCH_COMPLETION_THRESHOLD: 0.85,
|
||||
// Stale session timeout - force stop after 5 minutes of no updates
|
||||
STALE_SESSION_TIMEOUT_SECONDS: 300,
|
||||
// Minimum play time to record session - filter short plays (2 minutes default)
|
||||
MIN_PLAY_TIME_MS: 120 * 1000,
|
||||
// Continued session threshold - max gap to consider a "resume" vs new watch
|
||||
CONTINUED_SESSION_THRESHOLD_MS: 60 * 1000,
|
||||
// Stale session sweep interval - how often to check for stale sessions (1 minute)
|
||||
STALE_SWEEP_INTERVAL_MS: 60 * 1000,
|
||||
} as const;
|
||||
207
packages/shared/src/index.ts
Normal file
207
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @tracearr/shared - Shared types, schemas, and constants
|
||||
*/
|
||||
|
||||
// Type exports
|
||||
export type {
|
||||
// Server
|
||||
ServerType,
|
||||
Server,
|
||||
// User
|
||||
User,
|
||||
ServerUser,
|
||||
ServerUserWithIdentity,
|
||||
ServerUserDetail,
|
||||
ServerUserFullDetail,
|
||||
ViolationSummary,
|
||||
UserRole,
|
||||
AuthUser,
|
||||
UserLocation,
|
||||
UserDevice,
|
||||
// Session
|
||||
SessionState,
|
||||
MediaType,
|
||||
Session,
|
||||
SessionWithDetails,
|
||||
ActiveSession,
|
||||
// Rule
|
||||
RuleType,
|
||||
ImpossibleTravelParams,
|
||||
SimultaneousLocationsParams,
|
||||
DeviceVelocityParams,
|
||||
ConcurrentStreamsParams,
|
||||
GeoRestrictionParams,
|
||||
RuleParams,
|
||||
Rule,
|
||||
// Violation
|
||||
ViolationSeverity,
|
||||
Violation,
|
||||
ViolationWithDetails,
|
||||
ViolationSessionInfo,
|
||||
// Stats
|
||||
DashboardStats,
|
||||
PlayStats,
|
||||
UserStats,
|
||||
LocationStats,
|
||||
LocationStatsSummary,
|
||||
LocationStatsResponse,
|
||||
LibraryStats,
|
||||
DayOfWeekStats,
|
||||
HourOfDayStats,
|
||||
QualityStats,
|
||||
TopUserStats,
|
||||
TopContentStats,
|
||||
PlatformStats,
|
||||
// Server resource stats
|
||||
ServerResourceDataPoint,
|
||||
ServerResourceStats,
|
||||
// Settings
|
||||
Settings,
|
||||
WebhookFormat,
|
||||
UnitSystem,
|
||||
// Tautulli import
|
||||
TautulliImportProgress,
|
||||
TautulliImportResult,
|
||||
// WebSocket
|
||||
ServerToClientEvents,
|
||||
ClientToServerEvents,
|
||||
// API
|
||||
PaginatedResponse,
|
||||
ApiError,
|
||||
// Mobile
|
||||
MobileToken,
|
||||
MobileSession,
|
||||
MobileConfig,
|
||||
MobilePairRequest,
|
||||
MobilePairResponse,
|
||||
MobilePairTokenResponse,
|
||||
MobileQRPayload,
|
||||
NotificationEventType,
|
||||
NotificationPreferences,
|
||||
RateLimitStatus,
|
||||
NotificationPreferencesWithStatus,
|
||||
NotificationChannel,
|
||||
NotificationChannelRouting,
|
||||
EncryptedPushPayload,
|
||||
PushNotificationPayload,
|
||||
// SSE (Server-Sent Events)
|
||||
SSEConnectionState,
|
||||
PlexSSENotification,
|
||||
PlexPlaySessionNotification,
|
||||
PlexActivityNotification,
|
||||
PlexStatusNotification,
|
||||
PlexTranscodeNotification,
|
||||
SSEConnectionStatus,
|
||||
// Termination logs
|
||||
TerminationTrigger,
|
||||
TerminationLogWithDetails,
|
||||
// Plex server discovery
|
||||
PlexDiscoveredConnection,
|
||||
PlexDiscoveredServer,
|
||||
PlexAvailableServersResponse,
|
||||
} from './types.js';
|
||||
|
||||
// Schema exports
|
||||
export {
|
||||
// Common
|
||||
uuidSchema,
|
||||
paginationSchema,
|
||||
// Auth
|
||||
loginSchema,
|
||||
callbackSchema,
|
||||
// Server
|
||||
createServerSchema,
|
||||
serverIdParamSchema,
|
||||
// User
|
||||
updateUserSchema,
|
||||
updateUserIdentitySchema,
|
||||
userIdParamSchema,
|
||||
// Session
|
||||
sessionQuerySchema,
|
||||
sessionIdParamSchema,
|
||||
terminateSessionBodySchema,
|
||||
// Rule
|
||||
impossibleTravelParamsSchema,
|
||||
simultaneousLocationsParamsSchema,
|
||||
deviceVelocityParamsSchema,
|
||||
concurrentStreamsParamsSchema,
|
||||
geoRestrictionParamsSchema,
|
||||
ruleParamsSchema,
|
||||
createRuleSchema,
|
||||
updateRuleSchema,
|
||||
ruleIdParamSchema,
|
||||
// Violation
|
||||
violationQuerySchema,
|
||||
violationIdParamSchema,
|
||||
// Stats
|
||||
serverIdFilterSchema,
|
||||
statsQuerySchema,
|
||||
locationStatsQuerySchema,
|
||||
// Settings
|
||||
updateSettingsSchema,
|
||||
// Tautulli import
|
||||
tautulliImportSchema,
|
||||
} from './schemas.js';
|
||||
|
||||
// Schema input type exports
|
||||
export type {
|
||||
LoginInput,
|
||||
CallbackInput,
|
||||
CreateServerInput,
|
||||
UpdateUserInput,
|
||||
UpdateUserIdentityInput,
|
||||
SessionQueryInput,
|
||||
CreateRuleInput,
|
||||
UpdateRuleInput,
|
||||
ViolationQueryInput,
|
||||
ServerIdFilterInput,
|
||||
StatsQueryInput,
|
||||
LocationStatsQueryInput,
|
||||
UpdateSettingsInput,
|
||||
TautulliImportInput,
|
||||
} from './schemas.js';
|
||||
|
||||
// Constant exports
|
||||
export {
|
||||
RULE_DEFAULTS,
|
||||
RULE_DISPLAY_NAMES,
|
||||
SEVERITY_LEVELS,
|
||||
getSeverityPriority,
|
||||
type SeverityPriority,
|
||||
WS_EVENTS,
|
||||
REDIS_KEYS,
|
||||
CACHE_TTL,
|
||||
NOTIFICATION_EVENTS,
|
||||
API_VERSION,
|
||||
API_BASE_PATH,
|
||||
JWT_CONFIG,
|
||||
POLLING_INTERVALS,
|
||||
PAGINATION,
|
||||
GEOIP_CONFIG,
|
||||
TIME_MS,
|
||||
SESSION_LIMITS,
|
||||
SERVER_STATS_CONFIG,
|
||||
// SSE
|
||||
SSE_CONFIG,
|
||||
PLEX_SSE_EVENTS,
|
||||
SSE_STATE,
|
||||
// Unit conversion
|
||||
UNIT_CONVERSION,
|
||||
kmToMiles,
|
||||
milesToKm,
|
||||
formatDistance,
|
||||
formatSpeed,
|
||||
getDistanceUnit,
|
||||
getSpeedUnit,
|
||||
toMetricDistance,
|
||||
fromMetricDistance,
|
||||
} from './constants.js';
|
||||
|
||||
// Role helper exports
|
||||
export {
|
||||
ROLE_PERMISSIONS,
|
||||
canLogin,
|
||||
hasMinRole,
|
||||
isOwner,
|
||||
isActive,
|
||||
} from './types.js';
|
||||
253
packages/shared/src/schemas.ts
Normal file
253
packages/shared/src/schemas.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Zod validation schemas for API requests
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// Common schemas
|
||||
export const uuidSchema = z.uuid();
|
||||
export const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
pageSize: z.coerce.number().int().positive().max(100).default(20),
|
||||
});
|
||||
|
||||
// Auth schemas
|
||||
export const loginSchema = z.object({
|
||||
serverType: z.enum(['plex', 'jellyfin', 'emby']),
|
||||
returnUrl: z.url().optional(),
|
||||
});
|
||||
|
||||
export const callbackSchema = z.object({
|
||||
code: z.string().optional(),
|
||||
token: z.string().optional(),
|
||||
serverType: z.enum(['plex', 'jellyfin', 'emby']),
|
||||
});
|
||||
|
||||
// Server schemas
|
||||
export const createServerSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
type: z.enum(['plex', 'jellyfin', 'emby']),
|
||||
url: z.url(),
|
||||
token: z.string().min(1),
|
||||
});
|
||||
|
||||
export const serverIdParamSchema = z.object({
|
||||
id: uuidSchema,
|
||||
});
|
||||
|
||||
// User schemas
|
||||
export const updateUserSchema = z.object({
|
||||
allowGuest: z.boolean().optional(),
|
||||
trustScore: z.number().int().min(0).max(100).optional(),
|
||||
});
|
||||
|
||||
export const updateUserIdentitySchema = z.object({
|
||||
name: z.string().max(255).nullable().optional(),
|
||||
});
|
||||
|
||||
export type UpdateUserIdentityInput = z.infer<typeof updateUserIdentitySchema>;
|
||||
|
||||
export const userIdParamSchema = z.object({
|
||||
id: uuidSchema,
|
||||
});
|
||||
|
||||
// Session schemas
|
||||
export const sessionQuerySchema = paginationSchema.extend({
|
||||
serverUserId: uuidSchema.optional(),
|
||||
serverId: uuidSchema.optional(),
|
||||
state: z.enum(['playing', 'paused', 'stopped']).optional(),
|
||||
mediaType: z.enum(['movie', 'episode', 'track']).optional(),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
export const sessionIdParamSchema = z.object({
|
||||
id: uuidSchema,
|
||||
});
|
||||
|
||||
// Session termination schema
|
||||
export const terminateSessionBodySchema = z.object({
|
||||
/** Optional message to display to user (Plex only, ignored by Jellyfin/Emby) */
|
||||
reason: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
// Rule schemas
|
||||
export const impossibleTravelParamsSchema = z.object({
|
||||
maxSpeedKmh: z.number().positive().default(500),
|
||||
ignoreVpnRanges: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const simultaneousLocationsParamsSchema = z.object({
|
||||
minDistanceKm: z.number().positive().default(100),
|
||||
});
|
||||
|
||||
export const deviceVelocityParamsSchema = z.object({
|
||||
maxIps: z.number().int().positive().default(5),
|
||||
windowHours: z.number().int().positive().default(24),
|
||||
});
|
||||
|
||||
export const concurrentStreamsParamsSchema = z.object({
|
||||
maxStreams: z.number().int().positive().default(3),
|
||||
});
|
||||
|
||||
export const geoRestrictionParamsSchema = z.object({
|
||||
blockedCountries: z.array(z.string().length(2)).default([]),
|
||||
});
|
||||
|
||||
export const ruleParamsSchema = z.union([
|
||||
impossibleTravelParamsSchema,
|
||||
simultaneousLocationsParamsSchema,
|
||||
deviceVelocityParamsSchema,
|
||||
concurrentStreamsParamsSchema,
|
||||
geoRestrictionParamsSchema,
|
||||
]);
|
||||
|
||||
export const createRuleSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
type: z.enum([
|
||||
'impossible_travel',
|
||||
'simultaneous_locations',
|
||||
'device_velocity',
|
||||
'concurrent_streams',
|
||||
'geo_restriction',
|
||||
]),
|
||||
params: z.record(z.string(), z.unknown()),
|
||||
serverUserId: uuidSchema.nullable().default(null),
|
||||
isActive: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const updateRuleSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
params: z.record(z.string(), z.unknown()).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const ruleIdParamSchema = z.object({
|
||||
id: uuidSchema,
|
||||
});
|
||||
|
||||
// Violation schemas
|
||||
export const violationQuerySchema = paginationSchema.extend({
|
||||
serverId: uuidSchema.optional(),
|
||||
serverUserId: uuidSchema.optional(),
|
||||
ruleId: uuidSchema.optional(),
|
||||
severity: z.enum(['low', 'warning', 'high']).optional(),
|
||||
acknowledged: z.coerce.boolean().optional(),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
export const violationIdParamSchema = z.object({
|
||||
id: uuidSchema,
|
||||
});
|
||||
|
||||
// Stats schemas
|
||||
export const serverIdFilterSchema = z.object({
|
||||
serverId: uuidSchema.optional(),
|
||||
});
|
||||
|
||||
export const statsQuerySchema = z
|
||||
.object({
|
||||
period: z.enum(['day', 'week', 'month', 'year', 'all', 'custom']).default('week'),
|
||||
startDate: z.iso.datetime().optional(),
|
||||
endDate: z.iso.datetime().optional(),
|
||||
serverId: uuidSchema.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// Custom period requires both dates
|
||||
if (data.period === 'custom') {
|
||||
return data.startDate && data.endDate;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: 'Custom period requires startDate and endDate' }
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
// If dates provided, start must be before end
|
||||
if (data.startDate && data.endDate) {
|
||||
return new Date(data.startDate) < new Date(data.endDate);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: 'startDate must be before endDate' }
|
||||
);
|
||||
|
||||
// Location stats with full filtering - uses same period system as statsQuerySchema
|
||||
export const locationStatsQuerySchema = z
|
||||
.object({
|
||||
period: z.enum(['day', 'week', 'month', 'year', 'all', 'custom']).default('month'),
|
||||
startDate: z.iso.datetime().optional(),
|
||||
endDate: z.iso.datetime().optional(),
|
||||
serverUserId: uuidSchema.optional(),
|
||||
serverId: uuidSchema.optional(),
|
||||
mediaType: z.enum(['movie', 'episode', 'track']).optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.period === 'custom') {
|
||||
return data.startDate && data.endDate;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: 'Custom period requires startDate and endDate' }
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.startDate && data.endDate) {
|
||||
return new Date(data.startDate) < new Date(data.endDate);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: 'startDate must be before endDate' }
|
||||
);
|
||||
|
||||
// Webhook format enum
|
||||
export const webhookFormatSchema = z.enum(['json', 'ntfy', 'apprise']);
|
||||
|
||||
// Unit system enum for display preferences
|
||||
export const unitSystemSchema = z.enum(['metric', 'imperial']);
|
||||
|
||||
// Settings schemas
|
||||
export const updateSettingsSchema = z.object({
|
||||
allowGuestAccess: z.boolean().optional(),
|
||||
// Display preferences
|
||||
unitSystem: unitSystemSchema.optional(),
|
||||
discordWebhookUrl: z.url().nullable().optional(),
|
||||
customWebhookUrl: z.url().nullable().optional(),
|
||||
webhookFormat: webhookFormatSchema.nullable().optional(),
|
||||
ntfyTopic: z.string().max(200).nullable().optional(),
|
||||
// Poller settings
|
||||
pollerEnabled: z.boolean().optional(),
|
||||
pollerIntervalMs: z.number().int().min(5000).max(300000).optional(),
|
||||
// Tautulli integration
|
||||
tautulliUrl: z.url().nullable().optional(),
|
||||
tautulliApiKey: z.string().nullable().optional(),
|
||||
// Network/access settings
|
||||
externalUrl: z.url().nullable().optional(),
|
||||
basePath: z.string().max(100).optional(),
|
||||
trustProxy: z.boolean().optional(),
|
||||
// Authentication settings
|
||||
primaryAuthMethod: z.enum(['jellyfin', 'local']).optional(),
|
||||
});
|
||||
|
||||
// Tautulli import schemas
|
||||
export const tautulliImportSchema = z.object({
|
||||
serverId: uuidSchema, // Which Tracearr server to import into
|
||||
});
|
||||
|
||||
// Type exports from schemas
|
||||
export type LoginInput = z.infer<typeof loginSchema>;
|
||||
export type CallbackInput = z.infer<typeof callbackSchema>;
|
||||
export type CreateServerInput = z.infer<typeof createServerSchema>;
|
||||
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
|
||||
export type SessionQueryInput = z.infer<typeof sessionQuerySchema>;
|
||||
export type CreateRuleInput = z.infer<typeof createRuleSchema>;
|
||||
export type UpdateRuleInput = z.infer<typeof updateRuleSchema>;
|
||||
export type ViolationQueryInput = z.infer<typeof violationQuerySchema>;
|
||||
export type ServerIdFilterInput = z.infer<typeof serverIdFilterSchema>;
|
||||
export type StatsQueryInput = z.infer<typeof statsQuerySchema>;
|
||||
export type LocationStatsQueryInput = z.infer<typeof locationStatsQuerySchema>;
|
||||
export type UpdateSettingsInput = z.infer<typeof updateSettingsSchema>;
|
||||
export type TautulliImportInput = z.infer<typeof tautulliImportSchema>;
|
||||
910
packages/shared/src/types.ts
Normal file
910
packages/shared/src/types.ts
Normal file
@@ -0,0 +1,910 @@
|
||||
/**
|
||||
* Core type definitions for Tracearr
|
||||
*/
|
||||
|
||||
// User role - combined permission level and account status
|
||||
// Can log in: owner, admin, viewer
|
||||
// Cannot log in: member (default for synced users), disabled, pending
|
||||
export type UserRole = 'owner' | 'admin' | 'viewer' | 'member' | 'disabled' | 'pending';
|
||||
|
||||
// Role permission hierarchy (higher = more permissions)
|
||||
export const ROLE_PERMISSIONS: Record<UserRole, number> = {
|
||||
owner: 4,
|
||||
admin: 3,
|
||||
viewer: 2,
|
||||
member: 1, // Synced from media server, no Tracearr login until promoted
|
||||
disabled: 0,
|
||||
pending: 0,
|
||||
} as const;
|
||||
|
||||
// Roles that can log into Tracearr
|
||||
const LOGIN_ROLES: UserRole[] = ['owner', 'admin', 'viewer'];
|
||||
|
||||
// Role helper functions
|
||||
export const canLogin = (role: UserRole): boolean =>
|
||||
LOGIN_ROLES.includes(role);
|
||||
|
||||
export const hasMinRole = (
|
||||
userRole: UserRole,
|
||||
required: 'owner' | 'admin' | 'viewer'
|
||||
): boolean => ROLE_PERMISSIONS[userRole] >= ROLE_PERMISSIONS[required];
|
||||
|
||||
export const isOwner = (role: UserRole): boolean => role === 'owner';
|
||||
export const isActive = (role: UserRole): boolean => canLogin(role);
|
||||
|
||||
// Server types
|
||||
export type ServerType = 'plex' | 'jellyfin' | 'emby';
|
||||
|
||||
export interface Server {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ServerType;
|
||||
url: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// User types - Identity layer (the real human)
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string; // Login identifier (unique)
|
||||
name: string | null; // Display name (optional)
|
||||
thumbnail: string | null;
|
||||
email: string | null;
|
||||
role: UserRole; // Combined permission level and account status
|
||||
aggregateTrustScore: number;
|
||||
totalViolations: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Server User types - Account on a specific media server
|
||||
export interface ServerUser {
|
||||
id: string;
|
||||
userId: string;
|
||||
serverId: string;
|
||||
externalId: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
thumbUrl: string | null;
|
||||
isServerAdmin: boolean;
|
||||
trustScore: number;
|
||||
sessionCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Server User with identity info - returned by /users API endpoints
|
||||
export interface ServerUserWithIdentity extends ServerUser {
|
||||
serverName: string;
|
||||
identityName: string | null;
|
||||
role: UserRole; // From linked User identity
|
||||
}
|
||||
|
||||
// Server User detail with stats - returned by GET /users/:id
|
||||
export interface ServerUserDetail extends ServerUserWithIdentity {
|
||||
stats: {
|
||||
totalSessions: number;
|
||||
totalWatchTime: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Violation summary for embedded responses (simpler than ViolationWithDetails)
|
||||
export interface ViolationSummary {
|
||||
id: string;
|
||||
ruleId: string;
|
||||
rule: {
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
serverUserId: string;
|
||||
sessionId: string;
|
||||
mediaTitle: string | null;
|
||||
severity: string;
|
||||
data: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
acknowledgedAt: Date | null;
|
||||
}
|
||||
|
||||
// Full user detail with all related data - returned by GET /users/:id/full
|
||||
// This aggregate response reduces 6 API calls to 1 for the UserDetail page
|
||||
export interface ServerUserFullDetail {
|
||||
user: ServerUserDetail;
|
||||
sessions: {
|
||||
data: Session[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
locations: UserLocation[];
|
||||
devices: UserDevice[];
|
||||
violations: {
|
||||
data: ViolationSummary[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
terminations: {
|
||||
data: TerminationLogWithDetails[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
serverIds: string[];
|
||||
mobile?: boolean; // True for mobile app tokens
|
||||
deviceId?: string; // Device identifier for mobile tokens
|
||||
}
|
||||
|
||||
// Session types
|
||||
export type SessionState = 'playing' | 'paused' | 'stopped';
|
||||
export type MediaType = 'movie' | 'episode' | 'track';
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
serverId: string;
|
||||
serverUserId: string;
|
||||
sessionKey: string;
|
||||
state: SessionState;
|
||||
mediaType: MediaType;
|
||||
mediaTitle: string;
|
||||
// Enhanced media metadata for episodes
|
||||
grandparentTitle: string | null; // Show name (for episodes)
|
||||
seasonNumber: number | null; // Season number (for episodes)
|
||||
episodeNumber: number | null; // Episode number (for episodes)
|
||||
year: number | null; // Release year
|
||||
thumbPath: string | null; // Poster path (e.g., /library/metadata/123/thumb)
|
||||
ratingKey: string | null; // Plex/Jellyfin media identifier
|
||||
externalSessionId: string | null; // External reference for deduplication
|
||||
startedAt: Date;
|
||||
stoppedAt: Date | null;
|
||||
durationMs: number | null; // Actual watch duration (excludes paused time)
|
||||
totalDurationMs: number | null; // Total media length
|
||||
progressMs: number | null; // Current playback position
|
||||
// Pause tracking - accumulates total paused time across pause/resume cycles
|
||||
lastPausedAt: Date | null; // When current pause started (null if not paused)
|
||||
pausedDurationMs: number; // Accumulated pause time in milliseconds
|
||||
// Session grouping for "resume where left off" tracking
|
||||
referenceId: string | null; // Links to first session in resume chain
|
||||
watched: boolean; // True if user watched 80%+ of content
|
||||
// Network and device info
|
||||
ipAddress: string;
|
||||
geoCity: string | null;
|
||||
geoRegion: string | null; // State/province/subdivision
|
||||
geoCountry: string | null;
|
||||
geoLat: number | null;
|
||||
geoLon: number | null;
|
||||
playerName: string | null; // Friendly device name
|
||||
deviceId: string | null; // Unique device identifier (machineIdentifier)
|
||||
product: string | null; // Product/app name (e.g., "Plex for iOS")
|
||||
device: string | null; // Device type (e.g., "iPhone")
|
||||
platform: string | null;
|
||||
quality: string | null;
|
||||
isTranscode: boolean;
|
||||
bitrate: number | null;
|
||||
}
|
||||
|
||||
export interface ActiveSession extends Session {
|
||||
user: Pick<ServerUser, 'id' | 'username' | 'thumbUrl'> & { identityName: string | null };
|
||||
server: Pick<Server, 'id' | 'name' | 'type'>;
|
||||
}
|
||||
|
||||
// Session with user/server details (from paginated API)
|
||||
// When returned from history queries, sessions are grouped by reference_id
|
||||
// Note: The single session endpoint (GET /sessions/:id) returns totalDurationMs,
|
||||
// while paginated list queries aggregate duration and don't include it.
|
||||
export interface SessionWithDetails extends Omit<Session, 'ratingKey' | 'externalSessionId'> {
|
||||
username: string;
|
||||
userThumb: string | null;
|
||||
serverName: string;
|
||||
serverType: ServerType;
|
||||
// Number of pause/resume segments in this grouped play (1 = no pauses)
|
||||
segmentCount?: number;
|
||||
}
|
||||
|
||||
// Rule types
|
||||
export type RuleType =
|
||||
| 'impossible_travel'
|
||||
| 'simultaneous_locations'
|
||||
| 'device_velocity'
|
||||
| 'concurrent_streams'
|
||||
| 'geo_restriction';
|
||||
|
||||
export interface ImpossibleTravelParams {
|
||||
maxSpeedKmh: number;
|
||||
ignoreVpnRanges?: boolean;
|
||||
}
|
||||
|
||||
export interface SimultaneousLocationsParams {
|
||||
minDistanceKm: number;
|
||||
}
|
||||
|
||||
export interface DeviceVelocityParams {
|
||||
maxIps: number;
|
||||
windowHours: number;
|
||||
}
|
||||
|
||||
export interface ConcurrentStreamsParams {
|
||||
maxStreams: number;
|
||||
}
|
||||
|
||||
export interface GeoRestrictionParams {
|
||||
blockedCountries: string[];
|
||||
}
|
||||
|
||||
export type RuleParams =
|
||||
| ImpossibleTravelParams
|
||||
| SimultaneousLocationsParams
|
||||
| DeviceVelocityParams
|
||||
| ConcurrentStreamsParams
|
||||
| GeoRestrictionParams;
|
||||
|
||||
export interface Rule {
|
||||
id: string;
|
||||
name: string;
|
||||
type: RuleType;
|
||||
params: RuleParams;
|
||||
serverUserId: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Violation types
|
||||
export type ViolationSeverity = 'low' | 'warning' | 'high';
|
||||
|
||||
export interface Violation {
|
||||
id: string;
|
||||
ruleId: string;
|
||||
serverUserId: string;
|
||||
sessionId: string;
|
||||
severity: ViolationSeverity;
|
||||
data: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
acknowledgedAt: Date | null;
|
||||
}
|
||||
|
||||
// Session info for violations (used in both session and relatedSessions)
|
||||
export interface ViolationSessionInfo {
|
||||
id: string;
|
||||
mediaTitle: string;
|
||||
mediaType: MediaType;
|
||||
grandparentTitle: string | null;
|
||||
seasonNumber: number | null;
|
||||
episodeNumber: number | null;
|
||||
year: number | null;
|
||||
ipAddress: string;
|
||||
geoCity: string | null;
|
||||
geoRegion: string | null;
|
||||
geoCountry: string | null;
|
||||
geoLat: number | null;
|
||||
geoLon: number | null;
|
||||
playerName: string | null;
|
||||
device: string | null;
|
||||
deviceId: string | null;
|
||||
platform: string | null;
|
||||
product: string | null;
|
||||
quality: string | null;
|
||||
startedAt: Date;
|
||||
}
|
||||
|
||||
export interface ViolationWithDetails extends Violation {
|
||||
rule: Pick<Rule, 'id' | 'name' | 'type'>;
|
||||
user: Pick<ServerUser, 'id' | 'username' | 'thumbUrl' | 'serverId'> & { identityName: string | null };
|
||||
server?: Pick<Server, 'id' | 'name' | 'type'>;
|
||||
session?: ViolationSessionInfo;
|
||||
relatedSessions?: ViolationSessionInfo[];
|
||||
userHistory?: {
|
||||
previousIPs: string[];
|
||||
previousDevices: string[];
|
||||
previousLocations: Array<{ city: string | null; country: string | null; ip: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
// Stats types
|
||||
export interface DashboardStats {
|
||||
activeStreams: number;
|
||||
todayPlays: number;
|
||||
watchTimeHours: number;
|
||||
alertsLast24h: number;
|
||||
activeUsersToday: number;
|
||||
}
|
||||
|
||||
export interface PlayStats {
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
serverUserId: string;
|
||||
username: string;
|
||||
thumbUrl: string | null;
|
||||
playCount: number;
|
||||
watchTimeHours: number;
|
||||
}
|
||||
|
||||
export interface LocationUserInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
thumbUrl: string | null;
|
||||
}
|
||||
|
||||
export interface LocationStats {
|
||||
city: string | null;
|
||||
region: string | null; // State/province
|
||||
country: string | null;
|
||||
lat: number;
|
||||
lon: number;
|
||||
count: number;
|
||||
lastActivity?: Date;
|
||||
firstActivity?: Date;
|
||||
// Contextual data - populated based on filters
|
||||
users?: LocationUserInfo[]; // Top users at this location (when not filtering by userId)
|
||||
deviceCount?: number; // Unique devices from this location
|
||||
}
|
||||
|
||||
export interface LocationStatsSummary {
|
||||
totalStreams: number;
|
||||
uniqueLocations: number;
|
||||
topCity: string | null;
|
||||
}
|
||||
|
||||
export interface LocationFilterOptions {
|
||||
users: { id: string; username: string; identityName: string | null }[];
|
||||
servers: { id: string; name: string }[];
|
||||
mediaTypes: ('movie' | 'episode' | 'track')[];
|
||||
}
|
||||
|
||||
export interface LocationStatsResponse {
|
||||
data: LocationStats[];
|
||||
summary: LocationStatsSummary;
|
||||
availableFilters: LocationFilterOptions;
|
||||
}
|
||||
|
||||
export interface LibraryStats {
|
||||
movies: number;
|
||||
shows: number;
|
||||
episodes: number;
|
||||
tracks: number;
|
||||
}
|
||||
|
||||
export interface DayOfWeekStats {
|
||||
day: number; // 0 = Sunday, 6 = Saturday
|
||||
name: string; // 'Sun', 'Mon', etc.
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface HourOfDayStats {
|
||||
hour: number; // 0-23
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface QualityStats {
|
||||
directPlay: number;
|
||||
transcode: number;
|
||||
total: number;
|
||||
directPlayPercent: number;
|
||||
transcodePercent: number;
|
||||
}
|
||||
|
||||
export interface TopUserStats {
|
||||
serverUserId: string;
|
||||
username: string;
|
||||
identityName: string | null;
|
||||
thumbUrl: string | null;
|
||||
serverId: string | null;
|
||||
trustScore: number;
|
||||
playCount: number;
|
||||
watchTimeHours: number;
|
||||
topMediaType: string | null; // "movie", "episode", etc.
|
||||
topContent: string | null; // Most watched show/movie name
|
||||
}
|
||||
|
||||
export interface TopContentStats {
|
||||
title: string;
|
||||
type: string;
|
||||
showTitle: string | null; // For episodes, this is the show name
|
||||
year: number | null;
|
||||
playCount: number;
|
||||
watchTimeHours: number;
|
||||
thumbPath: string | null;
|
||||
serverId: string | null;
|
||||
ratingKey: string | null;
|
||||
}
|
||||
|
||||
export interface PlatformStats {
|
||||
platform: string | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// Server resource statistics (CPU, RAM)
|
||||
// From Plex's undocumented /statistics/resources endpoint
|
||||
export interface ServerResourceDataPoint {
|
||||
/** Unix timestamp */
|
||||
at: number;
|
||||
/** Timespan interval in seconds */
|
||||
timespan: number;
|
||||
/** System-wide CPU utilization percentage */
|
||||
hostCpuUtilization: number;
|
||||
/** Plex process CPU utilization percentage */
|
||||
processCpuUtilization: number;
|
||||
/** System-wide memory utilization percentage */
|
||||
hostMemoryUtilization: number;
|
||||
/** Plex process memory utilization percentage */
|
||||
processMemoryUtilization: number;
|
||||
}
|
||||
|
||||
export interface ServerResourceStats {
|
||||
/** Server ID these stats belong to */
|
||||
serverId: string;
|
||||
/** Data points (newest first based on 'at' timestamp) */
|
||||
data: ServerResourceDataPoint[];
|
||||
/** When this data was fetched */
|
||||
fetchedAt: Date;
|
||||
}
|
||||
|
||||
// Webhook format types
|
||||
export type WebhookFormat = 'json' | 'ntfy' | 'apprise';
|
||||
|
||||
// Unit system for display preferences (stored in settings)
|
||||
export type UnitSystem = 'metric' | 'imperial';
|
||||
|
||||
// Settings types
|
||||
export interface Settings {
|
||||
allowGuestAccess: boolean;
|
||||
// Display preferences
|
||||
unitSystem: UnitSystem;
|
||||
discordWebhookUrl: string | null;
|
||||
customWebhookUrl: string | null;
|
||||
webhookFormat: WebhookFormat | null;
|
||||
ntfyTopic: string | null;
|
||||
// Poller settings
|
||||
pollerEnabled: boolean;
|
||||
pollerIntervalMs: number;
|
||||
// Tautulli integration
|
||||
tautulliUrl: string | null;
|
||||
tautulliApiKey: string | null;
|
||||
// Network/access settings
|
||||
externalUrl: string | null;
|
||||
basePath: string;
|
||||
trustProxy: boolean;
|
||||
// Mobile access
|
||||
mobileEnabled: boolean;
|
||||
// Authentication settings
|
||||
primaryAuthMethod: 'jellyfin' | 'local';
|
||||
}
|
||||
|
||||
// Tautulli import types
|
||||
export interface TautulliImportProgress {
|
||||
status: 'idle' | 'fetching' | 'processing' | 'complete' | 'error';
|
||||
/** Expected total from API (may differ from actual if API count is stale) */
|
||||
totalRecords: number;
|
||||
/** Actual records fetched from API so far */
|
||||
fetchedRecords: number;
|
||||
/** Records processed (looped through) */
|
||||
processedRecords: number;
|
||||
/** New sessions inserted */
|
||||
importedRecords: number;
|
||||
/** Existing sessions updated with new data */
|
||||
updatedRecords: number;
|
||||
/** Total skipped (sum of duplicate + unknownUser + activeSession) */
|
||||
skippedRecords: number;
|
||||
/** Skipped: already exists in DB or duplicate in this import */
|
||||
duplicateRecords: number;
|
||||
/** Skipped: user not found in Tracearr (need to sync server first) */
|
||||
unknownUserRecords: number;
|
||||
/** Skipped: in-progress sessions without reference_id */
|
||||
activeSessionRecords: number;
|
||||
/** Records that failed to process */
|
||||
errorRecords: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TautulliImportResult {
|
||||
success: boolean;
|
||||
imported: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
errors: number;
|
||||
message: string;
|
||||
/** Details about users that were skipped (not found in Tracearr) */
|
||||
skippedUsers?: {
|
||||
tautulliUserId: number;
|
||||
username: string;
|
||||
recordCount: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
// WebSocket event types
|
||||
export interface ServerToClientEvents {
|
||||
'session:started': (session: ActiveSession) => void;
|
||||
'session:stopped': (sessionId: string) => void;
|
||||
'session:updated': (session: ActiveSession) => void;
|
||||
'violation:new': (violation: ViolationWithDetails) => void;
|
||||
'stats:updated': (stats: DashboardStats) => void;
|
||||
'import:progress': (progress: TautulliImportProgress) => void;
|
||||
}
|
||||
|
||||
export interface ClientToServerEvents {
|
||||
'subscribe:sessions': () => void;
|
||||
'unsubscribe:sessions': () => void;
|
||||
}
|
||||
|
||||
// User location aggregation (derived from sessions)
|
||||
export interface UserLocation {
|
||||
city: string | null;
|
||||
region: string | null; // State/province/subdivision
|
||||
country: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
sessionCount: number;
|
||||
lastSeenAt: Date;
|
||||
ipAddresses: string[];
|
||||
}
|
||||
|
||||
// Device location summary (where a device has been used from)
|
||||
export interface DeviceLocation {
|
||||
city: string | null;
|
||||
region: string | null;
|
||||
country: string | null;
|
||||
sessionCount: number;
|
||||
lastSeenAt: Date;
|
||||
}
|
||||
|
||||
// User device aggregation (derived from sessions)
|
||||
export interface UserDevice {
|
||||
deviceId: string | null;
|
||||
playerName: string | null;
|
||||
product: string | null;
|
||||
device: string | null;
|
||||
platform: string | null;
|
||||
sessionCount: number;
|
||||
lastSeenAt: Date;
|
||||
locations: DeviceLocation[]; // Where this device has been used from
|
||||
}
|
||||
|
||||
// API response types
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
statusCode: number;
|
||||
error: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Mobile App Types
|
||||
// ============================================
|
||||
|
||||
// Mobile pairing token (one-time use)
|
||||
export interface MobileToken {
|
||||
id: string;
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
usedAt: Date | null;
|
||||
}
|
||||
|
||||
// Mobile pairing token response (when generating new token)
|
||||
export interface MobilePairTokenResponse {
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
// Mobile session (paired device)
|
||||
export interface MobileSession {
|
||||
id: string;
|
||||
deviceName: string;
|
||||
deviceId: string;
|
||||
platform: 'ios' | 'android';
|
||||
expoPushToken: string | null;
|
||||
lastSeenAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// Mobile config returned to web dashboard
|
||||
export interface MobileConfig {
|
||||
isEnabled: boolean;
|
||||
sessions: MobileSession[];
|
||||
serverName: string;
|
||||
pendingTokens: number; // Count of unexpired, unused tokens
|
||||
maxDevices: number; // Maximum allowed devices (5)
|
||||
}
|
||||
|
||||
// Mobile pairing request (from mobile app)
|
||||
export interface MobilePairRequest {
|
||||
token: string; // Mobile access token from QR/manual entry
|
||||
deviceName: string; // e.g., "iPhone 15 Pro"
|
||||
deviceId: string; // Unique device identifier
|
||||
platform: 'ios' | 'android';
|
||||
}
|
||||
|
||||
// Mobile pairing response
|
||||
export interface MobilePairResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
server: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'plex' | 'jellyfin' | 'emby';
|
||||
};
|
||||
user: {
|
||||
userId: string;
|
||||
username: string;
|
||||
role: 'owner'; // Mobile access is owner-only for v1
|
||||
};
|
||||
}
|
||||
|
||||
// QR code payload (base64 encoded in tracearr://pair?data=<base64>)
|
||||
export interface MobileQRPayload {
|
||||
url: string; // Server URL
|
||||
token: string; // Mobile access token
|
||||
name: string; // Server name
|
||||
}
|
||||
|
||||
// Notification event types
|
||||
export type NotificationEventType =
|
||||
| 'violation_detected'
|
||||
| 'stream_started'
|
||||
| 'stream_stopped'
|
||||
| 'concurrent_streams'
|
||||
| 'new_device'
|
||||
| 'trust_score_changed'
|
||||
| 'server_down'
|
||||
| 'server_up';
|
||||
|
||||
// Notification preferences (per-device settings)
|
||||
export interface NotificationPreferences {
|
||||
id: string;
|
||||
mobileSessionId: string;
|
||||
|
||||
// Master toggle
|
||||
pushEnabled: boolean;
|
||||
|
||||
// Event toggles
|
||||
onViolationDetected: boolean;
|
||||
onStreamStarted: boolean;
|
||||
onStreamStopped: boolean;
|
||||
onConcurrentStreams: boolean;
|
||||
onNewDevice: boolean;
|
||||
onTrustScoreChanged: boolean;
|
||||
onServerDown: boolean;
|
||||
onServerUp: boolean;
|
||||
|
||||
// Violation filtering
|
||||
violationMinSeverity: number; // 1=low, 2=warning, 3=high
|
||||
violationRuleTypes: string[]; // Empty = all rule types
|
||||
|
||||
// Rate limiting
|
||||
maxPerMinute: number;
|
||||
maxPerHour: number;
|
||||
|
||||
// Quiet hours
|
||||
quietHoursEnabled: boolean;
|
||||
quietHoursStart: string | null; // "23:00"
|
||||
quietHoursEnd: string | null; // "08:00"
|
||||
quietHoursTimezone: string;
|
||||
quietHoursOverrideCritical: boolean;
|
||||
|
||||
// Timestamps
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Rate limit status (returned with preferences for UI display)
|
||||
export interface RateLimitStatus {
|
||||
remainingMinute: number;
|
||||
remainingHour: number;
|
||||
resetMinuteIn: number; // seconds until minute window resets
|
||||
resetHourIn: number; // seconds until hour window resets
|
||||
}
|
||||
|
||||
// Extended preferences response including live rate limit status
|
||||
export interface NotificationPreferencesWithStatus extends NotificationPreferences {
|
||||
rateLimitStatus?: RateLimitStatus;
|
||||
}
|
||||
|
||||
// Notification channel types
|
||||
export type NotificationChannel = 'discord' | 'webhook' | 'push' | 'webToast';
|
||||
|
||||
// Notification channel routing configuration (per-event type)
|
||||
export interface NotificationChannelRouting {
|
||||
id: string;
|
||||
eventType: NotificationEventType;
|
||||
discordEnabled: boolean;
|
||||
webhookEnabled: boolean;
|
||||
pushEnabled: boolean;
|
||||
webToastEnabled: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Encrypted push payload (AES-256-GCM with separate authTag per security best practices)
|
||||
export interface EncryptedPushPayload {
|
||||
v: 1; // Version for future-proofing
|
||||
iv: string; // Base64-encoded 12-byte IV
|
||||
salt: string; // Base64-encoded 16-byte PBKDF2 salt
|
||||
ct: string; // Base64-encoded ciphertext (without authTag)
|
||||
tag: string; // Base64-encoded 16-byte authentication tag
|
||||
}
|
||||
|
||||
// Push notification payload structure (before encryption)
|
||||
export interface PushNotificationPayload {
|
||||
type: NotificationEventType;
|
||||
title: string;
|
||||
body: string;
|
||||
data?: Record<string, unknown>;
|
||||
channelId?: string; // Android notification channel
|
||||
badge?: number; // iOS badge count
|
||||
sound?: string | boolean;
|
||||
priority?: 'default' | 'high';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SSE (Server-Sent Events) Types
|
||||
// =============================================================================
|
||||
|
||||
// SSE connection states
|
||||
export type SSEConnectionState =
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'reconnecting'
|
||||
| 'disconnected'
|
||||
| 'fallback';
|
||||
|
||||
// Plex SSE notification container (outer wrapper)
|
||||
export interface PlexSSENotification {
|
||||
NotificationContainer: {
|
||||
type: string;
|
||||
size: number;
|
||||
PlaySessionStateNotification?: PlexPlaySessionNotification[];
|
||||
ActivityNotification?: PlexActivityNotification[];
|
||||
StatusNotification?: PlexStatusNotification[];
|
||||
TranscodeSession?: PlexTranscodeNotification[];
|
||||
};
|
||||
}
|
||||
|
||||
// Play session state notification (start/stop/pause/resume)
|
||||
export interface PlexPlaySessionNotification {
|
||||
sessionKey: string;
|
||||
clientIdentifier: string;
|
||||
guid: string;
|
||||
ratingKey: string;
|
||||
url: string;
|
||||
key: string;
|
||||
viewOffset: number;
|
||||
playQueueItemID: number;
|
||||
state: 'playing' | 'paused' | 'stopped' | 'buffering';
|
||||
}
|
||||
|
||||
// Activity notification (library scans, etc.)
|
||||
export interface PlexActivityNotification {
|
||||
event: string;
|
||||
uuid: string;
|
||||
Activity: {
|
||||
uuid: string;
|
||||
type: string;
|
||||
cancellable: boolean;
|
||||
userID: number;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
progress: number;
|
||||
Context?: {
|
||||
key: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Status notification (server updates, etc.)
|
||||
export interface PlexStatusNotification {
|
||||
title: string;
|
||||
description: string;
|
||||
notificationName: string;
|
||||
}
|
||||
|
||||
// Transcode session notification
|
||||
export interface PlexTranscodeNotification {
|
||||
key: string;
|
||||
throttled: boolean;
|
||||
complete: boolean;
|
||||
progress: number;
|
||||
size: number;
|
||||
speed: number;
|
||||
error: boolean;
|
||||
duration: number;
|
||||
remaining: number;
|
||||
context: string;
|
||||
sourceVideoCodec: string;
|
||||
sourceAudioCodec: string;
|
||||
videoDecision: string;
|
||||
audioDecision: string;
|
||||
subtitleDecision: string;
|
||||
protocol: string;
|
||||
container: string;
|
||||
videoCodec: string;
|
||||
audioCodec: string;
|
||||
audioChannels: number;
|
||||
transcodeHwRequested: boolean;
|
||||
transcodeHwDecoding: string;
|
||||
transcodeHwEncoding: string;
|
||||
transcodeHwDecodingTitle: string;
|
||||
transcodeHwEncodingTitle: string;
|
||||
}
|
||||
|
||||
// SSE connection status for monitoring
|
||||
export interface SSEConnectionStatus {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
state: SSEConnectionState;
|
||||
connectedAt: Date | null;
|
||||
lastEventAt: Date | null;
|
||||
reconnectAttempts: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Termination Log Types
|
||||
// =============================================================================
|
||||
|
||||
// Trigger source for stream terminations
|
||||
export type TerminationTrigger = 'manual' | 'rule';
|
||||
|
||||
// Termination log with joined details for display
|
||||
export interface TerminationLogWithDetails {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
serverId: string;
|
||||
serverUserId: string;
|
||||
trigger: TerminationTrigger;
|
||||
triggeredByUserId: string | null;
|
||||
triggeredByUsername: string | null; // Joined from users table
|
||||
ruleId: string | null;
|
||||
ruleName: string | null; // Joined from rules table
|
||||
violationId: string | null;
|
||||
reason: string | null;
|
||||
success: boolean;
|
||||
errorMessage: string | null;
|
||||
createdAt: Date;
|
||||
// Session info for context
|
||||
mediaTitle: string | null;
|
||||
mediaType: MediaType | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Plex Server Discovery Types
|
||||
// =============================================================================
|
||||
|
||||
// Connection details for a discovered Plex server
|
||||
export interface PlexDiscoveredConnection {
|
||||
uri: string;
|
||||
local: boolean;
|
||||
address: string;
|
||||
port: number;
|
||||
reachable: boolean; // Tested from Tracearr server
|
||||
latencyMs: number | null; // Response time if reachable
|
||||
}
|
||||
|
||||
// Discovered Plex server from plex.tv resources API
|
||||
export interface PlexDiscoveredServer {
|
||||
name: string;
|
||||
platform: string;
|
||||
version: string;
|
||||
clientIdentifier: string; // Unique server identifier
|
||||
recommendedUri: string | null; // Best reachable connection
|
||||
connections: PlexDiscoveredConnection[];
|
||||
}
|
||||
|
||||
// Response from GET /auth/plex/available-servers
|
||||
export interface PlexAvailableServersResponse {
|
||||
servers: PlexDiscoveredServer[];
|
||||
hasPlexToken: boolean; // False if user has no Plex servers connected
|
||||
}
|
||||
10
packages/shared/tsconfig.json
Normal file
10
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user