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

This commit is contained in:
2025-12-17 12:32:50 +13:00
commit 3015f48118
471 changed files with 141143 additions and 0 deletions

View 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"
}
}

View 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;

View 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';

View 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>;

View 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
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}