@tracearr/shared 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +7 -0
  3. package/dist/catalogLetters.d.ts +3 -0
  4. package/dist/catalogLetters.d.ts.map +1 -0
  5. package/dist/catalogLetters.js +3 -0
  6. package/dist/catalogLetters.js.map +1 -0
  7. package/dist/constants.d.ts +475 -0
  8. package/dist/constants.d.ts.map +1 -0
  9. package/dist/constants.js +847 -0
  10. package/dist/constants.js.map +1 -0
  11. package/dist/dynamicRange.d.ts +16 -0
  12. package/dist/dynamicRange.d.ts.map +1 -0
  13. package/dist/dynamicRange.js +46 -0
  14. package/dist/dynamicRange.js.map +1 -0
  15. package/dist/index.d.ts +17 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +68 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/media.d.ts +16 -0
  20. package/dist/media.d.ts.map +1 -0
  21. package/dist/media.js +18 -0
  22. package/dist/media.js.map +1 -0
  23. package/dist/resolution.d.ts +56 -0
  24. package/dist/resolution.d.ts.map +1 -0
  25. package/dist/resolution.js +130 -0
  26. package/dist/resolution.js.map +1 -0
  27. package/dist/schemas.d.ts +1702 -0
  28. package/dist/schemas.d.ts.map +1 -0
  29. package/dist/schemas.js +1057 -0
  30. package/dist/schemas.js.map +1 -0
  31. package/dist/serverScope.d.ts +27 -0
  32. package/dist/serverScope.d.ts.map +1 -0
  33. package/dist/serverScope.js +36 -0
  34. package/dist/serverScope.js.map +1 -0
  35. package/dist/types.d.ts +2484 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/types.js +57 -0
  38. package/dist/types.js.map +1 -0
  39. package/dist/violations.d.ts +37 -0
  40. package/dist/violations.d.ts.map +1 -0
  41. package/dist/violations.js +377 -0
  42. package/dist/violations.js.map +1 -0
  43. package/package.json +46 -0
@@ -0,0 +1,847 @@
1
+ /**
2
+ * Shared constants for Tracearr
3
+ */
4
+ import { classifyByDimensions } from './resolution.js';
5
+ // Rule type definitions with default parameters
6
+ export const RULE_DEFAULTS = {
7
+ impossible_travel: {
8
+ maxSpeedKmh: 500,
9
+ ignoreVpnRanges: false,
10
+ excludePrivateIps: false,
11
+ },
12
+ simultaneous_locations: {
13
+ minDistanceKm: 100,
14
+ excludePrivateIps: false,
15
+ },
16
+ device_velocity: {
17
+ maxIps: 5,
18
+ windowHours: 24,
19
+ excludePrivateIps: false,
20
+ groupByDevice: false,
21
+ },
22
+ concurrent_streams: {
23
+ maxStreams: 3,
24
+ excludePrivateIps: false,
25
+ },
26
+ geo_restriction: {
27
+ mode: 'blocklist',
28
+ countries: [],
29
+ excludePrivateIps: false,
30
+ },
31
+ account_inactivity: {
32
+ inactivityValue: 30,
33
+ inactivityUnit: 'days',
34
+ },
35
+ };
36
+ // Rule type display names
37
+ export const RULE_DISPLAY_NAMES = {
38
+ impossible_travel: 'Impossible Travel',
39
+ simultaneous_locations: 'Simultaneous Locations',
40
+ device_velocity: 'Device Velocity',
41
+ concurrent_streams: 'Concurrent Streams',
42
+ geo_restriction: 'Geo Restriction',
43
+ account_inactivity: 'Account Inactivity',
44
+ };
45
+ // Condition fields whose evaluators are identity-aware (see belongsToIdentity in
46
+ // services/rules/evaluators/index.ts): they aggregate across every server_user id
47
+ // belonging to the same identity when the evaluation context carries
48
+ // identityServerUserIds. The UI (RuleBuilder) offers enforceAcrossServers as soon
49
+ // as ANY condition on the rule uses a field from this set, not only when every
50
+ // field does - one identity-aware condition is enough for cross-server action
51
+ // reach to make sense for the rule as a whole.
52
+ export const IDENTITY_AWARE_CONDITION_FIELDS = [
53
+ 'concurrent_streams',
54
+ 'active_session_distance_km',
55
+ 'travel_speed_kmh',
56
+ 'unique_ips_in_window',
57
+ 'unique_devices_in_window',
58
+ ];
59
+ // Severity levels
60
+ export const SEVERITY_LEVELS = {
61
+ low: { label: 'Low', priority: 1 },
62
+ warning: { label: 'Warning', priority: 2 },
63
+ high: { label: 'High', priority: 3 },
64
+ };
65
+ // Helper to get severity priority from string
66
+ export function getSeverityPriority(severity) {
67
+ return SEVERITY_LEVELS[severity]?.priority ?? 1;
68
+ }
69
+ // WebSocket event names
70
+ export const WS_EVENTS = {
71
+ SESSION_STARTED: 'session:started',
72
+ SESSION_STOPPED: 'session:stopped',
73
+ SESSION_UPDATED: 'session:updated',
74
+ VIOLATION_NEW: 'violation:new',
75
+ STATS_UPDATED: 'stats:updated',
76
+ IMPORT_PROGRESS: 'import:progress',
77
+ IMPORT_JELLYSTAT_PROGRESS: 'import:jellystat:progress',
78
+ MAINTENANCE_PROGRESS: 'maintenance:progress',
79
+ /** Library sync progress updates */
80
+ LIBRARY_SYNC_PROGRESS: 'library:sync:progress',
81
+ /** Unified running tasks updates */
82
+ TASKS_UPDATED: 'tasks:updated',
83
+ SUBSCRIBE_SESSIONS: 'subscribe:sessions',
84
+ UNSUBSCRIBE_SESSIONS: 'unsubscribe:sessions',
85
+ VERSION_UPDATE: 'version:update',
86
+ SERVER_DOWN: 'server:down',
87
+ SERVER_UP: 'server:up',
88
+ SERVER_CONNECTION: 'server:connection',
89
+ };
90
+ // Redis key prefix (set at startup via setRedisPrefix)
91
+ let _redisPrefix = '';
92
+ /**
93
+ * Set the global prefix prepended to all Redis keys.
94
+ * Call this at server startup before any Redis operations.
95
+ * @param prefix - Prefix string (e.g. 'myapp:')
96
+ */
97
+ export function setRedisPrefix(prefix) {
98
+ _redisPrefix = prefix;
99
+ }
100
+ /**
101
+ * Get the current Redis key prefix.
102
+ */
103
+ export function getRedisPrefix() {
104
+ return _redisPrefix;
105
+ }
106
+ // Redis key definitions (prefix-aware via getters)
107
+ export const REDIS_KEYS = {
108
+ // Active sessions: SET of session IDs for atomic add/remove
109
+ get ACTIVE_SESSION_IDS() {
110
+ return `${_redisPrefix}tracearr:sessions:active:ids`;
111
+ },
112
+ // Legacy: JSON array of sessions (deprecated, kept for migration)
113
+ get ACTIVE_SESSIONS() {
114
+ return `${_redisPrefix}tracearr:sessions:active`;
115
+ },
116
+ // Individual session data
117
+ SESSION_BY_ID: (id) => `${_redisPrefix}tracearr:sessions:${id}`,
118
+ /**
119
+ * Pending session data (before DB write) - keyed by serverId + sessionKey.
120
+ */
121
+ PENDING_SESSION: (serverId, sessionKey) => `${_redisPrefix}tracearr:sessions:pending:${serverId}:${sessionKey}`,
122
+ /** Set of all pending session keys (serverId:sessionKey format) for enumeration */
123
+ get PENDING_SESSION_IDS() {
124
+ return `${_redisPrefix}tracearr:sessions:pending:ids`;
125
+ },
126
+ get DASHBOARD_STATS() {
127
+ return `${_redisPrefix}tracearr:stats:dashboard`;
128
+ },
129
+ RATE_LIMIT_LOGIN: (ip) => `${_redisPrefix}tracearr:ratelimit:login:${ip}`,
130
+ RATE_LIMIT_MOBILE_PAIR: (ip) => `${_redisPrefix}tracearr:ratelimit:mobile:pair:${ip}`,
131
+ RATE_LIMIT_MOBILE_REFRESH: (ip) => `${_redisPrefix}tracearr:ratelimit:mobile:refresh:${ip}`,
132
+ SERVER_HEALTH: (serverId) => `${_redisPrefix}tracearr:servers:${serverId}:health`,
133
+ SERVER_HEALTH_FAIL_COUNT: (serverId) => `${_redisPrefix}tracearr:servers:${serverId}:health:fails`,
134
+ SERVER_CONNECTION: (serverId) => `${_redisPrefix}tracearr:servers:${serverId}:connection`,
135
+ get PUBSUB_EVENTS() {
136
+ return `${_redisPrefix}tracearr:events`;
137
+ },
138
+ // Notification rate limiting (sliding window counters)
139
+ PUSH_RATE_MINUTE: (sessionId) => `${_redisPrefix}tracearr:push:rate:minute:${sessionId}`,
140
+ PUSH_RATE_HOUR: (sessionId) => `${_redisPrefix}tracearr:push:rate:hour:${sessionId}`,
141
+ // Location stats filter caching (includes serverIds hash for proper scoping)
142
+ LOCATION_FILTERS: (userId, serverIds) => {
143
+ // Sort and hash serverIds for stable cache key
144
+ const serverHash = serverIds.length > 0 ? serverIds.slice().sort().join(',') : 'all';
145
+ return `${_redisPrefix}tracearr:filters:locations:${userId}:${serverHash}`;
146
+ },
147
+ // Version check cache
148
+ get VERSION_LATEST() {
149
+ return `${_redisPrefix}tracearr:version:latest`;
150
+ },
151
+ // Cooldown key to prevent hammering GitHub on restarts or retry storms
152
+ get VERSION_CHECK_COOLDOWN() {
153
+ return `${_redisPrefix}tracearr:version:check:cooldown`;
154
+ },
155
+ // Library statistics. The :v2 suffix marks the multi-version payload
156
+ // change (version-summed sizes, overlapping buckets); old keys expire via
157
+ // TTL. Bump again whenever a cached payload's meaning shifts.
158
+ get LIBRARY_STATS() {
159
+ return `${_redisPrefix}tracearr:library:stats:v2`;
160
+ },
161
+ get LIBRARY_GROWTH() {
162
+ return `${_redisPrefix}tracearr:library:growth`;
163
+ },
164
+ get LIBRARY_QUALITY() {
165
+ return `${_redisPrefix}tracearr:library:quality:v2`;
166
+ },
167
+ get LIBRARY_STALE() {
168
+ return `${_redisPrefix}tracearr:library:stale:v2`;
169
+ },
170
+ get LIBRARY_DUPLICATES() {
171
+ return `${_redisPrefix}tracearr:library:duplicates:v3`;
172
+ },
173
+ get LIBRARY_STORAGE() {
174
+ return `${_redisPrefix}tracearr:library:storage:v3`;
175
+ },
176
+ get LIBRARY_WATCH() {
177
+ return `${_redisPrefix}tracearr:library:watch:v2`;
178
+ },
179
+ get LIBRARY_ROI() {
180
+ return `${_redisPrefix}tracearr:library:roi:v2`;
181
+ },
182
+ get LIBRARY_PATTERNS() {
183
+ return `${_redisPrefix}tracearr:library:patterns`;
184
+ },
185
+ get LIBRARY_COMPLETION() {
186
+ return `${_redisPrefix}tracearr:library:completion`;
187
+ },
188
+ get LIBRARY_TOP_MOVIES() {
189
+ return `${_redisPrefix}tracearr:library:top-movies`;
190
+ },
191
+ get LIBRARY_TOP_SHOWS() {
192
+ return `${_redisPrefix}tracearr:library:top-shows`;
193
+ },
194
+ get LIBRARY_CODECS() {
195
+ return `${_redisPrefix}tracearr:library:codecs:v2`;
196
+ },
197
+ get LIBRARY_RESOLUTION() {
198
+ return `${_redisPrefix}tracearr:library:resolution:v2`;
199
+ },
200
+ get LIBRARY_SHELVES() {
201
+ return `${_redisPrefix}tracearr:library:shelves`;
202
+ },
203
+ get LIBRARY_GENRES() {
204
+ return `${_redisPrefix}tracearr:library:genres`;
205
+ },
206
+ get LIBRARY_CATALOG_LETTERS() {
207
+ return `${_redisPrefix}tracearr:library:catalog-letters:v2`;
208
+ },
209
+ get LIBRARY_LIBRARIES() {
210
+ return `${_redisPrefix}tracearr:library:libraries`;
211
+ },
212
+ // Watched-filtered ordered candidate list shared by /catalog and
213
+ // /catalog/letters (see getWatchedCandidates in catalog.ts)
214
+ get LIBRARY_CATALOG_WATCHED() {
215
+ return `${_redisPrefix}tracearr:library:catalog-watched:v2`;
216
+ },
217
+ // Catalog COUNT + total file size per filter set - O(catalog) to compute,
218
+ // so it must never run once per scroll page
219
+ get LIBRARY_CATALOG_TOTALS() {
220
+ return `${_redisPrefix}tracearr:library:catalog-totals:v2`;
221
+ },
222
+ // Single-flight compute lock for a cold cache miss (see
223
+ // withComputeSingleFlight in routes/library/utils.ts), shared by /shelves
224
+ // and the catalog watched-candidates compute. cacheKey already carries the
225
+ // prefix, so this only appends the lock segment.
226
+ LIBRARY_SINGLE_FLIGHT_LOCK: (cacheKey) => `${cacheKey}:lock`,
227
+ // Internal media detail responses (id/segment/scope composed by the caller);
228
+ // distinct from PUBLIC_MEDIA_STATS, which backs the public API v2 namespace
229
+ LIBRARY_MEDIA_DETAIL: (cacheKey) => `${_redisPrefix}tracearr:library:media-detail:v2:${cacheKey}`,
230
+ // Library sync state
231
+ LIBRARY_SYNC_LAST: (serverId, libraryId) => `${_redisPrefix}tracearr:library:sync:last:${serverId}:${libraryId}`,
232
+ LIBRARY_SYNC_COUNT: (serverId, libraryId) => `${_redisPrefix}tracearr:library:sync:count:${serverId}:${libraryId}`,
233
+ LIBRARY_SYNC_CYCLE: (serverId, libraryId) => `${_redisPrefix}tracearr:library:sync:cycle:${serverId}:${libraryId}`,
234
+ // Accepted structural shortfall from the last full scan - see COUNT_MISMATCH_* in librarySync.ts
235
+ LIBRARY_SYNC_SHORTFALL: (serverId, libraryId) => `${_redisPrefix}tracearr:library:sync:shortfall:${serverId}:${libraryId}`,
236
+ // Image precache watermark state (per server, not per library - the precache
237
+ // job walks library_items scoped only by server)
238
+ LIBRARY_PRECACHE_WATERMARK: (serverId) => `${_redisPrefix}tracearr:library:precache:watermark:${serverId}`,
239
+ LIBRARY_PRECACHE_LAST_FULL: (serverId) => `${_redisPrefix}tracearr:library:precache:last-full:${serverId}`,
240
+ // Auth tokens
241
+ REFRESH_TOKEN: (hash) => `${_redisPrefix}tracearr:refresh:${hash}`,
242
+ PLEX_TEMP_TOKEN: (token) => `${_redisPrefix}tracearr:plex_temp:${token}`,
243
+ MOBILE_REFRESH_TOKEN: (hash) => `${_redisPrefix}tracearr:mobile_refresh:${hash}`,
244
+ MOBILE_BLACKLISTED_TOKEN: (deviceId) => `${_redisPrefix}tracearr:mobile:blacklist:${deviceId}`,
245
+ MOBILE_LAST_SEEN: (deviceId) => `${_redisPrefix}tracearr:mobile:last_seen:${deviceId}`,
246
+ // Rate limiting
247
+ MOBILE_TOKEN_GEN_RATE: (userId) => `${_redisPrefix}mobile_token_gen:${userId}`,
248
+ // Distributed locks
249
+ get HEAVY_OPS_LOCK() {
250
+ return `${_redisPrefix}tracearr:heavy-ops:lock`;
251
+ },
252
+ SESSION_LOCK: (serverId, sessionKey) => `${_redisPrefix}session:lock:${serverId}:${sessionKey}`,
253
+ TERMINATION_COOLDOWN: (serverId, sessionKey, ratingKey) => `${_redisPrefix}termination:cooldown:${serverId}:${sessionKey}:${ratingKey}`,
254
+ TERMINATION_COOLDOWN_COMPOSITE: (serverId, serverUserId, deviceId, ratingKey) => `${_redisPrefix}termination:cooldown:composite:${serverId}:${serverUserId}:${deviceId}:${ratingKey}`,
255
+ // Rule cooldowns
256
+ RULE_COOLDOWN: (ruleId, targetId) => `${_redisPrefix}tracearr:rule:cooldown:${ruleId}:${targetId}`,
257
+ // Session write retry queue (for failed DB writes)
258
+ SESSION_WRITE_RETRY: (sessionId) => `${_redisPrefix}tracearr:session:write-retry:${sessionId}`,
259
+ get SESSION_WRITE_RETRY_SET() {
260
+ return `${_redisPrefix}tracearr:session:write-retry:pending`;
261
+ },
262
+ // Filter options caching
263
+ FILTER_OPTIONS: (userId, scopeHash) => `${_redisPrefix}tracearr:filter-options:${userId}:${scopeHash}`,
264
+ // v1 segment invalidates cached entries if the GeoLocation shape ever changes
265
+ PLEX_GEOIP: (ip) => `${_redisPrefix}tracearr:geoip:plex:v1:${ip}`,
266
+ // Public API v2 per-media stats/watchers responses
267
+ PUBLIC_MEDIA_STATS: (cacheKey) => `${_redisPrefix}tracearr:public:media-stats:${cacheKey}`,
268
+ };
269
+ // Cache TTLs in seconds
270
+ export const CACHE_TTL = {
271
+ DASHBOARD_STATS: 60,
272
+ PUBLIC_MEDIA_STATS: 60,
273
+ // Must exceed several SSE reconciliation intervals (30s): paused Plex sessions emit no events,
274
+ // and an expiring entry hides the session from the dashboard and from concurrent-stream limits.
275
+ ACTIVE_SESSIONS: 150,
276
+ PENDING_SESSIONS: 300, // 5 minutes - pending sessions need longer TTL for pause scenarios
277
+ RATE_LIMIT: 900,
278
+ SERVER_HEALTH: 600, // 10 minutes - servers marked unhealthy if no update
279
+ SERVER_CONNECTION: 600, // 10 minutes - live runtime state, not persisted to DB
280
+ LOCATION_FILTERS: 300, // 5 minutes - filter options change infrequently
281
+ VERSION_CHECK: 21600, // 6 hours - version check interval
282
+ // Library statistics
283
+ LIBRARY_STATS: 300, // 5 minutes
284
+ LIBRARY_GROWTH: 300, // 5 minutes
285
+ LIBRARY_QUALITY: 300, // 5 minutes
286
+ LIBRARY_STALE: 3600, // 1 hour (changes slowly)
287
+ LIBRARY_DUPLICATES: 3600, // 1 hour (changes slowly)
288
+ LIBRARY_STORAGE: 300, // 5 minutes
289
+ LIBRARY_WATCH: 300, // 5 minutes
290
+ LIBRARY_ROI: 3600, // 1 hour (ROI changes slowly)
291
+ LIBRARY_PATTERNS: 3600, // 1 hour (patterns change slowly)
292
+ LIBRARY_COMPLETION: 300, // 5 minutes
293
+ LIBRARY_TOP_MOVIES: 300, // 5 minutes
294
+ LIBRARY_TOP_SHOWS: 300, // 5 minutes
295
+ LIBRARY_CODECS: 300, // 5 minutes
296
+ LIBRARY_RESOLUTION: 300, // 5 minutes
297
+ LIBRARY_SHELVES: 300, // 5 minutes
298
+ LIBRARY_GENRES: 3600, // 1 hour
299
+ LIBRARY_CATALOG_LETTERS: 300, // 5 minutes, matches LIBRARY_SHELVES freshness
300
+ LIBRARY_LIBRARIES: 300, // 5 minutes - library list changes only on sync
301
+ LIBRARY_MEDIA_DETAIL: 60, // 1 minute, matches PUBLIC_MEDIA_STATS freshness
302
+ MOBILE_LAST_SEEN: 300, // 5 minutes - throttle for device activity updates
303
+ // Filter options (dropdown values change infrequently)
304
+ FILTER_OPTIONS: 120, // 2 minutes
305
+ PLEX_GEOIP: 86400,
306
+ // Fail-open: short negative cache keeps a down plex.tv from being hit every poll tick
307
+ PLEX_GEOIP_NEGATIVE: 600,
308
+ };
309
+ // Notification event types (must match NotificationEventType in types.ts)
310
+ export const NOTIFICATION_EVENTS = {
311
+ VIOLATION_DETECTED: 'violation_detected',
312
+ STREAM_STARTED: 'stream_started',
313
+ STREAM_STOPPED: 'stream_stopped',
314
+ CONCURRENT_STREAMS: 'concurrent_streams',
315
+ NEW_DEVICE: 'new_device',
316
+ TRUST_SCORE_CHANGED: 'trust_score_changed',
317
+ SERVER_DOWN: 'server_down',
318
+ SERVER_UP: 'server_up',
319
+ PLUGIN_UPDATE_AVAILABLE: 'plugin_update_available',
320
+ };
321
+ // API version
322
+ /**
323
+ * server_version_key of the placeholder version rows the multi-version
324
+ * migration seeds from flat library_items columns. Never surfaced as a real
325
+ * version and hard-deleted (not tombstoned) when observed versions replace it.
326
+ */
327
+ export const LEGACY_VERSION_SENTINEL = 'legacy:1';
328
+ export const API_VERSION = 'v1';
329
+ export const API_BASE_PATH = `/api/${API_VERSION}`;
330
+ export const API_VERSION_V2 = 'v2';
331
+ export const API_V2_BASE_PATH = `/api/${API_VERSION_V2}`;
332
+ // JWT configuration
333
+ export const JWT_CONFIG = {
334
+ ACCESS_TOKEN_EXPIRY: '48h',
335
+ REFRESH_TOKEN_EXPIRY: '30d',
336
+ ALGORITHM: 'HS256',
337
+ };
338
+ // Polling intervals in milliseconds
339
+ export const POLLING_INTERVALS = {
340
+ SESSIONS_ACTIVE: 3000,
341
+ SESSIONS_IDLE: 10000,
342
+ /** @deprecated Use SESSIONS_ACTIVE */
343
+ SESSIONS: 7000,
344
+ STATS_REFRESH: 60000,
345
+ SERVER_HEALTH: 30000,
346
+ // Reconciliation interval when SSE is active (fallback check)
347
+ SSE_RECONCILIATION: 30 * 1000, // 30 seconds
348
+ };
349
+ // Poller health detection
350
+ export const POLLER_CONFIG = {
351
+ DOWN_THRESHOLD: 3, // consecutive poll failures before declaring server down
352
+ };
353
+ // SSE (Server-Sent Events) configuration
354
+ export const SSE_CONFIG = {
355
+ // Reconnection settings
356
+ INITIAL_RETRY_DELAY_MS: 1000,
357
+ MAX_RETRY_DELAY_MS: 30000,
358
+ RETRY_MULTIPLIER: 2,
359
+ MAX_RETRIES: 10,
360
+ // Heartbeat/keepalive - how long without events before assuming connection died
361
+ // Plex sends ping events every 10 seconds, so 30s = miss 3 pings = dead
362
+ HEARTBEAT_TIMEOUT_MS: 30000, // 30 seconds
363
+ // When to fall back to polling
364
+ FALLBACK_THRESHOLD: 5, // consecutive failures before fallback
365
+ };
366
+ // Plex SSE notification types (from /:/eventsource/notifications)
367
+ export const PLEX_SSE_EVENTS = {
368
+ // Session-related
369
+ PLAYING: 'playing',
370
+ PROGRESS: 'progress',
371
+ STOPPED: 'stopped',
372
+ PAUSED: 'paused',
373
+ RESUMED: 'resumed',
374
+ // Library updates
375
+ LIBRARY_UPDATE: 'library.update',
376
+ LIBRARY_SCAN: 'library.scan',
377
+ // Server status
378
+ SERVER_BACKUP: 'server.backup',
379
+ SERVER_UPDATE: 'server.update',
380
+ // Activity
381
+ ACTIVITY: 'activity',
382
+ // Transcoder
383
+ TRANSCODE_SESSION_UPDATE: 'transcodeSession.update',
384
+ TRANSCODE_SESSION_END: 'transcodeSession.end',
385
+ };
386
+ // SSE connection states
387
+ export const SSE_STATE = {
388
+ CONNECTING: 'connecting',
389
+ CONNECTED: 'connected',
390
+ RECONNECTING: 'reconnecting',
391
+ DISCONNECTED: 'disconnected',
392
+ FALLBACK: 'fallback', // Using polling as fallback
393
+ };
394
+ // Pagination defaults
395
+ export const PAGINATION = {
396
+ DEFAULT_PAGE: 1,
397
+ DEFAULT_PAGE_SIZE: 20,
398
+ MAX_PAGE_SIZE: 100,
399
+ };
400
+ // GeoIP configuration
401
+ export const GEOIP_CONFIG = {
402
+ EARTH_RADIUS_KM: 6371,
403
+ DEFAULT_UNKNOWN_LOCATION: 'Unknown',
404
+ };
405
+ // Unit conversion constants
406
+ export const UNIT_CONVERSION = {
407
+ KM_TO_MILES: 0.621371,
408
+ MILES_TO_KM: 1.60934,
409
+ };
410
+ /**
411
+ * Convert kilometers to miles
412
+ */
413
+ export function kmToMiles(km) {
414
+ return km * UNIT_CONVERSION.KM_TO_MILES;
415
+ }
416
+ /**
417
+ * Convert miles to kilometers
418
+ */
419
+ export function milesToKm(miles) {
420
+ return miles * UNIT_CONVERSION.MILES_TO_KM;
421
+ }
422
+ /**
423
+ * Format distance based on unit system
424
+ * @param km - Distance in kilometers (internal unit)
425
+ * @param unitSystem - User's preferred unit system
426
+ * @param decimals - Number of decimal places (default: 0)
427
+ */
428
+ export function formatDistance(km, unitSystem, decimals = 0) {
429
+ if (unitSystem === 'imperial') {
430
+ const miles = kmToMiles(km);
431
+ return `${miles.toFixed(decimals)} mi`;
432
+ }
433
+ return `${km.toFixed(decimals)} km`;
434
+ }
435
+ /**
436
+ * Format speed based on unit system
437
+ * @param kmh - Speed in km/h (internal unit)
438
+ * @param unitSystem - User's preferred unit system
439
+ * @param decimals - Number of decimal places (default: 0)
440
+ */
441
+ export function formatSpeed(kmh, unitSystem, decimals = 0) {
442
+ if (unitSystem === 'imperial') {
443
+ const mph = kmToMiles(kmh);
444
+ return `${mph.toFixed(decimals)} mph`;
445
+ }
446
+ return `${kmh.toFixed(decimals)} km/h`;
447
+ }
448
+ /**
449
+ * Get distance unit label
450
+ */
451
+ export function getDistanceUnit(unitSystem) {
452
+ return unitSystem === 'imperial' ? 'mi' : 'km';
453
+ }
454
+ /**
455
+ * Get speed unit label
456
+ */
457
+ export function getSpeedUnit(unitSystem) {
458
+ return unitSystem === 'imperial' ? 'mph' : 'km/h';
459
+ }
460
+ /**
461
+ * Convert display value to internal metric value (for form inputs)
462
+ * @param value - Value in user's preferred unit
463
+ * @param unitSystem - User's preferred unit system
464
+ * @returns Value in kilometers (internal unit)
465
+ */
466
+ export function toMetricDistance(value, unitSystem) {
467
+ if (unitSystem === 'imperial') {
468
+ return milesToKm(value);
469
+ }
470
+ return value;
471
+ }
472
+ /**
473
+ * Convert internal metric value to display value (for form inputs)
474
+ * @param km - Value in kilometers (internal unit)
475
+ * @param unitSystem - User's preferred unit system
476
+ * @returns Value in user's preferred unit
477
+ */
478
+ export function fromMetricDistance(km, unitSystem) {
479
+ if (unitSystem === 'imperial') {
480
+ return kmToMiles(km);
481
+ }
482
+ return km;
483
+ }
484
+ /** Fields that store distance values in metric (km) */
485
+ const DISTANCE_FIELDS = ['active_session_distance_km'];
486
+ /** Fields that store speed values in metric (km/h) */
487
+ const SPEED_FIELDS = ['travel_speed_kmh'];
488
+ /**
489
+ * Convert a condition field value for display based on user's unit preference.
490
+ *
491
+ * @param value - The value to convert
492
+ * @param field - The condition field name
493
+ * @param unitSystem - User's preferred unit system
494
+ * @returns Object with displayValue (rounded) and unit label
495
+ */
496
+ export function formatConditionFieldValue(value, field, unitSystem) {
497
+ const isDistanceField = DISTANCE_FIELDS.includes(field);
498
+ const isSpeedField = SPEED_FIELDS.includes(field);
499
+ if (isDistanceField) {
500
+ return {
501
+ displayValue: Math.round(fromMetricDistance(value, unitSystem)),
502
+ unit: getDistanceUnit(unitSystem),
503
+ };
504
+ }
505
+ if (isSpeedField) {
506
+ return {
507
+ displayValue: Math.round(fromMetricDistance(value, unitSystem)),
508
+ unit: getSpeedUnit(unitSystem),
509
+ };
510
+ }
511
+ return { displayValue: value, unit: '' };
512
+ }
513
+ /**
514
+ * Format bitrate for display with appropriate unit (kbps, Mbps, Gbps)
515
+ * @param kbps - Bitrate in kilobits per second
516
+ * @returns Formatted string with unit (e.g., "20.5 Mbps", "800 kbps")
517
+ */
518
+ export function formatBitrate(kbps) {
519
+ if (!kbps)
520
+ return '—';
521
+ if (kbps >= 1_000_000) {
522
+ // Gbps
523
+ const gbps = kbps / 1_000_000;
524
+ const formatted = gbps % 1 === 0 ? gbps.toFixed(0) : gbps.toFixed(1);
525
+ return `${formatted} Gbps`;
526
+ }
527
+ if (kbps >= 1000) {
528
+ // Mbps
529
+ const mbps = kbps / 1000;
530
+ const formatted = mbps % 1 === 0 ? mbps.toFixed(0) : mbps.toFixed(1);
531
+ return `${formatted} Mbps`;
532
+ }
533
+ // kbps
534
+ return `${kbps} kbps`;
535
+ }
536
+ /**
537
+ * Display names for video/audio tech strings (resolution, codecs, dynamic range).
538
+ * Keys are lowercase, values are proper display casing.
539
+ */
540
+ const MEDIA_TECH_DISPLAY = {
541
+ // Resolution
542
+ '4k': '4K',
543
+ '2k': '2K',
544
+ uhd: 'UHD',
545
+ sd: 'SD',
546
+ hd: 'HD',
547
+ '1080p': '1080p',
548
+ '720p': '720p',
549
+ '480p': '480p',
550
+ // Dynamic range
551
+ sdr: 'SDR',
552
+ hdr: 'HDR',
553
+ hdr10: 'HDR10',
554
+ 'hdr10+': 'HDR10+',
555
+ hlg: 'HLG',
556
+ 'dolby vision': 'Dolby Vision',
557
+ dv: 'DV',
558
+ // Video codecs
559
+ hevc: 'HEVC',
560
+ h265: 'HEVC',
561
+ x265: 'HEVC',
562
+ h264: 'H.264',
563
+ avc: 'H.264',
564
+ x264: 'H.264',
565
+ av1: 'AV1',
566
+ vp9: 'VP9',
567
+ vp8: 'VP8',
568
+ 'mpeg-4': 'MPEG-4',
569
+ mpeg4: 'MPEG-4',
570
+ 'mpeg-2': 'MPEG-2',
571
+ mpeg2: 'MPEG-2',
572
+ mpeg2video: 'MPEG-2',
573
+ 'mpeg-1': 'MPEG-1',
574
+ mpeg1: 'MPEG-1',
575
+ 'vc-1': 'VC-1',
576
+ vc1: 'VC-1',
577
+ wmv: 'WMV',
578
+ theora: 'Theora',
579
+ prores: 'ProRes',
580
+ dnxhd: 'DNxHD',
581
+ // Audio codecs
582
+ aac: 'AAC',
583
+ ac3: 'AC3',
584
+ 'ac-3': 'AC3',
585
+ eac3: 'EAC3',
586
+ 'e-ac-3': 'EAC3',
587
+ truehd: 'TrueHD',
588
+ atmos: 'Atmos',
589
+ dts: 'DTS',
590
+ dca: 'DTS',
591
+ 'dts-hd': 'DTS-HD',
592
+ 'dts-hd ma': 'DTS-HD MA',
593
+ 'dca-ma': 'DTS-HD MA',
594
+ 'dts-hd hra': 'DTS-HD HRA',
595
+ 'dts:x': 'DTS:X',
596
+ dtsx: 'DTS:X',
597
+ flac: 'FLAC',
598
+ alac: 'ALAC',
599
+ mp2: 'MP2',
600
+ mp3: 'MP3',
601
+ opus: 'Opus',
602
+ vorbis: 'Vorbis',
603
+ pcm: 'PCM',
604
+ lpcm: 'PCM',
605
+ pcm_s16le: 'PCM',
606
+ pcm_s24le: 'PCM',
607
+ pcm_s32le: 'PCM',
608
+ aiff: 'AIFF',
609
+ wav: 'WAV',
610
+ wma: 'WMA',
611
+ wmav2: 'WMA',
612
+ wmapro: 'WMA Pro',
613
+ // Container formats
614
+ mkv: 'MKV',
615
+ matroska: 'MKV',
616
+ mp4: 'MP4',
617
+ avi: 'AVI',
618
+ mov: 'MOV',
619
+ webm: 'WebM',
620
+ flv: 'FLV',
621
+ ts: 'TS',
622
+ m2ts: 'M2TS',
623
+ mpegts: 'MPEG-TS',
624
+ // Subtitle formats
625
+ srt: 'SRT',
626
+ ass: 'ASS',
627
+ ssa: 'SSA',
628
+ pgs: 'PGS',
629
+ vobsub: 'VobSub',
630
+ dvdsub: 'DVD Sub',
631
+ webvtt: 'WebVTT',
632
+ vtt: 'VTT',
633
+ eia_608: 'EIA-608',
634
+ cc: 'CC',
635
+ };
636
+ /**
637
+ * Format a media tech string (resolution, codec, dynamic range) for display.
638
+ * Uses a lookup map for known values, falls back to uppercase for unknown.
639
+ *
640
+ * @param value - Tech string (e.g., "4k", "hevc", "truehd", "dolby vision")
641
+ * @returns Formatted string with proper casing
642
+ *
643
+ * @example
644
+ * formatMediaTech("4k") // "4K"
645
+ * formatMediaTech("hevc") // "HEVC"
646
+ * formatMediaTech("truehd") // "TrueHD"
647
+ * formatMediaTech("dolby vision") // "Dolby Vision"
648
+ */
649
+ export function formatMediaTech(value) {
650
+ if (!value)
651
+ return 'Unknown';
652
+ const lower = value.toLowerCase().trim();
653
+ return MEDIA_TECH_DISPLAY[lower] ?? value.toUpperCase();
654
+ }
655
+ /**
656
+ * Get video resolution label from width and height. Thin re-export of the
657
+ * shared dimension ladder in resolution.ts - kept here so existing imports
658
+ * of `getResolutionLabel` from `@tracearr/shared` keep working.
659
+ *
660
+ * @param width - Video width in pixels
661
+ * @param height - Video height in pixels
662
+ * @returns Resolution label: "8K", "4K", "1440p", "1080p", "720p", "480p", "SD", or null
663
+ *
664
+ * @example
665
+ * getResolutionLabel(7680, 4320) // "8K"
666
+ * getResolutionLabel(3840, 2160) // "4K"
667
+ * getResolutionLabel(2560, 1440) // "1440p"
668
+ * getResolutionLabel(1920, 1080) // "1080p"
669
+ * getResolutionLabel(1920, 800) // "1080p" (widescreen - width indicates quality)
670
+ * getResolutionLabel(1440, 1080) // "1080p" (4:3 - height indicates quality)
671
+ * getResolutionLabel(1280, 720) // "720p"
672
+ * getResolutionLabel(null, 1080) // "1080p" (fallback to height)
673
+ */
674
+ export const getResolutionLabel = classifyByDimensions;
675
+ /**
676
+ * Format video resolution with dimensions and label for display.
677
+ *
678
+ * @param width - Video width in pixels
679
+ * @param height - Video height in pixels
680
+ * @returns Formatted string like "1920×1080 (1080p)" or "—" if unknown
681
+ *
682
+ * @example
683
+ * formatResolutionDisplay(1920, 1080) // "1920×1080 (1080p)"
684
+ * formatResolutionDisplay(1440, 1080) // "1440×1080 (1080p)" - 4:3 correctly labeled
685
+ * formatResolutionDisplay(1920, 800) // "1920×800 (1080p)" - cinemascope correctly labeled
686
+ * formatResolutionDisplay(null, 1080) // "1080p (1080p)"
687
+ * formatResolutionDisplay(null, null) // "—"
688
+ */
689
+ export function formatResolutionDisplay(width, height) {
690
+ const label = getResolutionLabel(width, height);
691
+ if (!label)
692
+ return '—';
693
+ if (width && height)
694
+ return `${width}×${height} (${label})`;
695
+ if (width)
696
+ return `${width}w (${label})`;
697
+ if (height)
698
+ return `${height}p (${label})`;
699
+ return '—';
700
+ }
701
+ /**
702
+ * Format audio channels for display.
703
+ *
704
+ * @param channels - Number of audio channels
705
+ * @returns Formatted string: "7.1", "5.1", "Stereo", "Mono", or "Nch"
706
+ *
707
+ * @example
708
+ * formatAudioChannels(8) // "7.1"
709
+ * formatAudioChannels(6) // "5.1"
710
+ * formatAudioChannels(2) // "Stereo"
711
+ * formatAudioChannels(1) // "Mono"
712
+ */
713
+ export function formatAudioChannels(channels) {
714
+ if (!channels)
715
+ return null;
716
+ if (channels === 8)
717
+ return '7.1';
718
+ if (channels === 6)
719
+ return '5.1';
720
+ if (channels === 2)
721
+ return 'Stereo';
722
+ if (channels === 1)
723
+ return 'Mono';
724
+ return `${channels}ch`;
725
+ }
726
+ // Server color palette (40-60% HSL lightness, visible on both dark and light backgrounds)
727
+ export const SERVER_COLOR_PALETTE = [
728
+ { hex: '#F4A825', label: 'Gold' }, // Plex brand
729
+ { hex: '#895FDD', label: 'Purple' }, // Jellyfin brand
730
+ { hex: '#39C668', label: 'Green' }, // Emby brand
731
+ { hex: '#3B82F6', label: 'Blue' },
732
+ { hex: '#EF4444', label: 'Red' },
733
+ { hex: '#14B8A6', label: 'Teal' },
734
+ ];
735
+ export const SERVER_TYPE_BRAND_COLORS = {
736
+ plex: '#F4A825',
737
+ jellyfin: '#895FDD',
738
+ emby: '#39C668',
739
+ };
740
+ /** Pick best color for a server given its type and colors already used by other servers */
741
+ export function pickServerColor(type, usedColors) {
742
+ const used = new Set(usedColors.filter(Boolean).map((c) => c.toLowerCase()));
743
+ const brand = SERVER_TYPE_BRAND_COLORS[type] ?? '#3B82F6';
744
+ if (!used.has(brand.toLowerCase()))
745
+ return brand;
746
+ for (const preset of SERVER_COLOR_PALETTE) {
747
+ if (!used.has(preset.hex.toLowerCase()))
748
+ return preset.hex;
749
+ }
750
+ return brand; // all taken, duplicate is fine
751
+ }
752
+ // Time constants in milliseconds (avoid magic numbers)
753
+ export const TIME_MS = {
754
+ SECOND: 1000,
755
+ MINUTE: 60 * 1000,
756
+ HOUR: 60 * 60 * 1000,
757
+ DAY: 24 * 60 * 60 * 1000,
758
+ WEEK: 7 * 24 * 60 * 60 * 1000,
759
+ };
760
+ // Server resource statistics configuration (CPU, RAM)
761
+ // Used with Plex's undocumented /statistics/resources endpoint
762
+ export const SERVER_STATS_CONFIG = {
763
+ // Poll interval in seconds (how often we fetch new data)
764
+ POLL_INTERVAL_SECONDS: 6,
765
+ // Timespan parameter for Plex API (MUST be 6 - other values return empty!)
766
+ TIMESPAN_SECONDS: 6,
767
+ // Fixed 2-minute window (20 data points at 6s intervals)
768
+ WINDOW_SECONDS: 120,
769
+ // Data points to display (2 min / 6s = 20 points)
770
+ DATA_POINTS: 20,
771
+ };
772
+ // Server bandwidth statistics configuration (Local/Remote)
773
+ // Used with Plex's undocumented /statistics/bandwidth endpoint
774
+ // Data arrives per-second from Plex, displayed at 1-second granularity
775
+ export const BANDWIDTH_STATS_CONFIG = {
776
+ // Poll interval in seconds (how often we fetch new data)
777
+ POLL_INTERVAL_SECONDS: 6,
778
+ // Timespan parameter for Plex API
779
+ TIMESPAN_SECONDS: 6,
780
+ // Fixed 2-minute window (120 data points at 1s intervals)
781
+ WINDOW_SECONDS: 120,
782
+ // Data points to display (2 min * 1/s = 120 points)
783
+ DATA_POINTS: 120,
784
+ };
785
+ // Sentinel returned by the merge API when combining server users on the same
786
+ // server needs an explicit confirmation flag before proceeding
787
+ export const MERGE_SAME_SERVER_CONFIRMATION_REQUIRED = 'same_server_combine_requires_confirmation';
788
+ // Session limits
789
+ export const SESSION_LIMITS = {
790
+ MAX_RECENT_PER_USER: 100,
791
+ RESUME_WINDOW_HOURS: 24,
792
+ // Watch completion threshold - 85% is industry standard
793
+ WATCH_COMPLETION_THRESHOLD: 0.85,
794
+ // Stale session timeout - force stop after 5 minutes of no updates
795
+ STALE_SESSION_TIMEOUT_SECONDS: 300,
796
+ // Minimum play time to record session - filter short plays (2 minutes default)
797
+ MIN_PLAY_TIME_MS: 120 * 1000,
798
+ // Continued session threshold - max gap to consider a "resume" vs new watch
799
+ CONTINUED_SESSION_THRESHOLD_MS: 60 * 1000,
800
+ // Stale session sweep interval - how often to check for stale sessions (1 minute)
801
+ STALE_SWEEP_INTERVAL_MS: 60 * 1000,
802
+ };
803
+ /**
804
+ * Session write retry configuration.
805
+ * Used when DB writes fail during session stop.
806
+ */
807
+ export const SESSION_WRITE_RETRY = {
808
+ /** Number of immediate retries before queueing */
809
+ IMMEDIATE_RETRIES: 3,
810
+ /** Base delay for exponential backoff (ms) */
811
+ IMMEDIATE_BACKOFF_MS: 50,
812
+ /** Maximum total attempts before giving up */
813
+ MAX_TOTAL_ATTEMPTS: 5,
814
+ };
815
+ // ============================================================================
816
+ // Timezone Utilities
817
+ // ============================================================================
818
+ /**
819
+ * Get the client's IANA timezone identifier.
820
+ * Works in both browser and React Native environments.
821
+ *
822
+ * @returns IANA timezone string (e.g., 'America/Los_Angeles') or 'UTC' as fallback
823
+ */
824
+ export function getClientTimezone() {
825
+ try {
826
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
827
+ }
828
+ catch {
829
+ return 'UTC';
830
+ }
831
+ }
832
+ /**
833
+ * Validate an IANA timezone identifier.
834
+ *
835
+ * @param tz - Timezone string to validate
836
+ * @returns true if valid IANA timezone, false otherwise
837
+ */
838
+ export function isValidTimezone(tz) {
839
+ try {
840
+ Intl.DateTimeFormat('en-US', { timeZone: tz });
841
+ return true;
842
+ }
843
+ catch {
844
+ return false;
845
+ }
846
+ }
847
+ //# sourceMappingURL=constants.js.map