@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.
- package/LICENSE +661 -0
- package/README.md +7 -0
- package/dist/catalogLetters.d.ts +3 -0
- package/dist/catalogLetters.d.ts.map +1 -0
- package/dist/catalogLetters.js +3 -0
- package/dist/catalogLetters.js.map +1 -0
- package/dist/constants.d.ts +475 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +847 -0
- package/dist/constants.js.map +1 -0
- package/dist/dynamicRange.d.ts +16 -0
- package/dist/dynamicRange.d.ts.map +1 -0
- package/dist/dynamicRange.js +46 -0
- package/dist/dynamicRange.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +68 -0
- package/dist/index.js.map +1 -0
- package/dist/media.d.ts +16 -0
- package/dist/media.d.ts.map +1 -0
- package/dist/media.js +18 -0
- package/dist/media.js.map +1 -0
- package/dist/resolution.d.ts +56 -0
- package/dist/resolution.d.ts.map +1 -0
- package/dist/resolution.js +130 -0
- package/dist/resolution.js.map +1 -0
- package/dist/schemas.d.ts +1702 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +1057 -0
- package/dist/schemas.js.map +1 -0
- package/dist/serverScope.d.ts +27 -0
- package/dist/serverScope.d.ts.map +1 -0
- package/dist/serverScope.js +36 -0
- package/dist/serverScope.js.map +1 -0
- package/dist/types.d.ts +2484 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +57 -0
- package/dist/types.js.map +1 -0
- package/dist/violations.d.ts +37 -0
- package/dist/violations.d.ts.map +1 -0
- package/dist/violations.js +377 -0
- package/dist/violations.js.map +1 -0
- 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
|