@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,1057 @@
1
+ /**
2
+ * Zod validation schemas for API requests
3
+ */
4
+ import { z } from 'zod';
5
+ import { isValidTimezone } from './constants.js';
6
+ // ============================================================================
7
+ // Shared Enum Constants
8
+ // ============================================================================
9
+ /** Server types supported by Tracearr */
10
+ const SERVER_TYPES = ['plex', 'jellyfin', 'emby'];
11
+ export const serverTypeSchema = z.enum(SERVER_TYPES);
12
+ /** Media types for content filtering */
13
+ const MEDIA_TYPES = ['movie', 'episode', 'track', 'live'];
14
+ export const mediaTypeSchema = z.enum(MEDIA_TYPES);
15
+ /** Time periods for statistics queries */
16
+ const STAT_PERIODS = ['day', 'week', 'month', 'year', 'all', 'custom'];
17
+ export const statPeriodSchema = z.enum(STAT_PERIODS);
18
+ // ============================================================================
19
+ // Shared Date Validation Refinements
20
+ // ============================================================================
21
+ /**
22
+ * Refinement: Custom period requires both startDate and endDate
23
+ */
24
+ function requireDatesForCustomPeriod(data) {
25
+ if (data.period === 'custom') {
26
+ return data.startDate && data.endDate;
27
+ }
28
+ return true;
29
+ }
30
+ /**
31
+ * Refinement: If dates provided, startDate must be before endDate
32
+ */
33
+ function validateDateOrder(data) {
34
+ if (data.startDate && data.endDate) {
35
+ return new Date(data.startDate) < new Date(data.endDate);
36
+ }
37
+ return true;
38
+ }
39
+ /** Standard date validation refinements for stats queries */
40
+ export const dateValidationRefinements = {
41
+ customPeriodRequiresDates: {
42
+ refinement: requireDatesForCustomPeriod,
43
+ message: 'Custom period requires startDate and endDate',
44
+ },
45
+ startBeforeEnd: {
46
+ refinement: validateDateOrder,
47
+ message: 'startDate must be before endDate',
48
+ },
49
+ };
50
+ // ============================================================================
51
+ // Common Schemas
52
+ // ============================================================================
53
+ export const uuidSchema = z.uuid();
54
+ // Accepts either a single UUID string or an array of UUID strings from query params
55
+ export const serverIdsQuerySchema = z
56
+ .union([uuidSchema.transform((id) => [id]), z.array(uuidSchema)])
57
+ .optional();
58
+ // Same shape as serverIdsQuerySchema, used for identity (users.id) filters
59
+ export const userIdsQuerySchema = z
60
+ .union([uuidSchema.transform((id) => [id]), z.array(uuidSchema)])
61
+ .optional();
62
+ // `${serverId}:${libraryId}` composite key for the catalog Library filter -
63
+ // a library id is only unique within its own server, so the filter has to
64
+ // pin both.
65
+ export const libraryKeySchema = z.string().refine((value) => {
66
+ const separator = value.indexOf(':');
67
+ if (separator === -1)
68
+ return false;
69
+ const serverId = value.slice(0, separator);
70
+ const libraryId = value.slice(separator + 1);
71
+ return uuidSchema.safeParse(serverId).success && libraryId.length >= 1 && libraryId.length <= 100;
72
+ }, 'Invalid library key');
73
+ export const paginationSchema = z.object({
74
+ page: z.coerce.number().int().positive().default(1),
75
+ pageSize: z.coerce.number().int().positive().max(100).default(20),
76
+ });
77
+ // Parses boolean query params - z.coerce.boolean() treats "false" as truthy
78
+ export const booleanStringSchema = z
79
+ .union([z.boolean(), z.string()])
80
+ .transform((val) => (typeof val === 'boolean' ? val : val === 'true'));
81
+ // IANA timezone string validation (e.g., 'America/Los_Angeles', 'Europe/London')
82
+ // Uses shared isValidTimezone helper which validates via Intl API
83
+ export const timezoneSchema = z
84
+ .string()
85
+ .min(1)
86
+ .max(100)
87
+ .refine(isValidTimezone, { message: 'Invalid IANA timezone identifier' })
88
+ .optional();
89
+ // ============================================================================
90
+ // Auth Schemas
91
+ // ============================================================================
92
+ export const loginSchema = z.object({
93
+ serverType: serverTypeSchema,
94
+ returnUrl: z.url().optional(),
95
+ });
96
+ export const callbackSchema = z.object({
97
+ code: z.string().optional(),
98
+ token: z.string().optional(),
99
+ serverType: serverTypeSchema,
100
+ });
101
+ // ============================================================================
102
+ // Server Schemas
103
+ // ============================================================================
104
+ export const createServerSchema = z.object({
105
+ name: z.string().min(1).max(100),
106
+ type: serverTypeSchema,
107
+ url: z.url(),
108
+ token: z.string().min(1),
109
+ });
110
+ export const serverIdParamSchema = z.object({
111
+ id: uuidSchema,
112
+ });
113
+ export const reorderServersSchema = z.object({
114
+ servers: z.array(z.object({
115
+ id: uuidSchema,
116
+ displayOrder: z.number().int().min(0),
117
+ })),
118
+ });
119
+ export const updateServerSchema = z
120
+ .object({
121
+ name: z.string().min(1).max(100).optional(),
122
+ url: z.url().optional(),
123
+ clientIdentifier: z.string().optional(),
124
+ color: z
125
+ .string()
126
+ .regex(/^#[0-9a-fA-F]{6}$/, 'Color must be a valid hex color (e.g. #3b82f6)')
127
+ .optional()
128
+ .nullable(),
129
+ })
130
+ .refine((data) => data.name !== undefined || data.url !== undefined || data.color !== undefined, {
131
+ message: 'At least one of name, url, or color is required',
132
+ });
133
+ // ============================================================================
134
+ // User Schemas
135
+ // ============================================================================
136
+ export const updateUserSchema = z.object({
137
+ allowGuest: z.boolean().optional(),
138
+ trustScore: z.number().int().min(0).max(100).optional(),
139
+ });
140
+ export const updateUserIdentitySchema = z.object({
141
+ name: z.string().max(255).nullable().optional(),
142
+ });
143
+ export const userIdParamSchema = z.object({
144
+ id: uuidSchema,
145
+ });
146
+ // scope=identity expands a per-account query to every account the caller can
147
+ // access under the same person (identity). Absent = today's single-account
148
+ // behavior, unchanged.
149
+ export const identityScopeQuerySchema = z.object({
150
+ scope: z.enum(['identity']).optional(),
151
+ });
152
+ export const identityScopedPaginationSchema = paginationSchema.extend({
153
+ scope: z.enum(['identity']).optional(),
154
+ });
155
+ export const mergeUsersBodySchema = z.object({
156
+ targetUserId: uuidSchema,
157
+ confirmSameServerCombine: z.boolean().default(false),
158
+ });
159
+ export const mergeUserParamSchema = z.object({ id: uuidSchema });
160
+ export const splitServerUserParamSchema = z.object({ id: uuidSchema });
161
+ export const userSortFieldSchema = z.enum(['username', 'trustScore', 'joinedAt', 'lastActivityAt']);
162
+ // ============================================================================
163
+ // Session Schemas
164
+ // ============================================================================
165
+ export const sessionQuerySchema = paginationSchema.extend({
166
+ serverUserId: uuidSchema.optional(),
167
+ serverId: uuidSchema.optional(),
168
+ state: z.enum(['playing', 'paused', 'stopped']).optional(),
169
+ mediaType: mediaTypeSchema.optional(),
170
+ startDate: z.coerce.date().optional(),
171
+ endDate: z.coerce.date().optional(),
172
+ });
173
+ /**
174
+ * Enhanced history query schema with comprehensive filtering for the History page.
175
+ * Supports cursor-based pagination for efficient infinite scroll and
176
+ * all available session fields for filtering.
177
+ */
178
+ const commaSeparatedArray = (schema) => z
179
+ .union([schema.array(), z.string().transform((s) => (s ? s.split(',') : []))])
180
+ .optional()
181
+ .transform((arr) => (arr && arr.length > 0 ? arr : undefined));
182
+ export const historyQuerySchema = z.object({
183
+ // Pagination - cursor-based for infinite scroll (more efficient than offset for large datasets)
184
+ cursor: z.string().optional(), // Composite: `${startedAt.getTime()}_${playId}`
185
+ pageSize: z.coerce.number().int().positive().max(100).default(50),
186
+ // User filter - supports multi-select (comma-separated UUIDs in query string)
187
+ serverUserIds: commaSeparatedArray(uuidSchema),
188
+ // Server filter - serverIds takes precedence over serverId when both are provided
189
+ serverId: uuidSchema.optional(),
190
+ serverIds: serverIdsQuerySchema,
191
+ state: z.enum(['playing', 'paused', 'stopped']).optional(),
192
+ // Media type filter - supports multi-select
193
+ mediaTypes: commaSeparatedArray(mediaTypeSchema),
194
+ startDate: z.coerce.date().optional(),
195
+ endDate: z.coerce.date().optional(),
196
+ // Title/content search (ILIKE on mediaTitle and grandparentTitle)
197
+ search: z.string().max(200).optional(),
198
+ // Platform filter - supports multi-select (comma-separated in query string)
199
+ platforms: commaSeparatedArray(z.string().max(100)),
200
+ product: z.string().max(255).optional(), // Plex for Windows, Jellyfin Web
201
+ device: z.string().max(255).optional(), // iPhone, Android TV
202
+ playerName: z.string().max(255).optional(), // Device friendly name
203
+ // Network/location filters
204
+ ipAddress: z.string().max(45).optional(), // Exact IP match
205
+ // Country filter - supports multi-select (comma-separated in query string)
206
+ geoCountries: commaSeparatedArray(z.string().max(100)),
207
+ geoCity: z.string().max(255).optional(), // City name
208
+ geoRegion: z.string().max(255).optional(), // State/province
209
+ transcodeDecisions: commaSeparatedArray(z.enum(['directplay', 'copy', 'transcode'])),
210
+ // Status filters
211
+ watched: booleanStringSchema.optional(), // 85%+ completion
212
+ excludeShortSessions: booleanStringSchema.optional(), // Exclude <120s sessions
213
+ // Sorting
214
+ orderBy: z.enum(['startedAt', 'durationMs', 'mediaTitle']).default('startedAt'),
215
+ orderDir: z.enum(['asc', 'desc']).default('desc'),
216
+ });
217
+ // Aggregates query - same filters as history but without sorting/pagination
218
+ // Used for separate aggregates endpoint so sorting changes don't reload stats
219
+ export const historyAggregatesQuerySchema = historyQuerySchema.omit({
220
+ cursor: true,
221
+ pageSize: true,
222
+ orderBy: true,
223
+ orderDir: true,
224
+ });
225
+ // Filter options query - scoping params for /sessions/filter-options dropdowns
226
+ export const filterOptionsQuerySchema = z
227
+ .object({
228
+ serverId: uuidSchema.optional(),
229
+ serverIds: serverIdsQuerySchema,
230
+ startDate: z.coerce.date().optional(),
231
+ endDate: z.coerce.date().optional(),
232
+ includeAllCountries: booleanStringSchema.optional(),
233
+ })
234
+ .refine((data) => {
235
+ if (data.startDate && data.endDate) {
236
+ return data.startDate <= data.endDate;
237
+ }
238
+ return true;
239
+ }, { message: 'startDate must be before endDate' });
240
+ export const sessionIdParamSchema = z.object({
241
+ id: uuidSchema,
242
+ });
243
+ // Session termination schema
244
+ export const terminateSessionBodySchema = z.object({
245
+ /** Optional message to display to user (Plex only, ignored by Jellyfin/Emby) */
246
+ reason: z.string().max(500).optional(),
247
+ });
248
+ // ============================================================================
249
+ // Rule Schemas
250
+ // ============================================================================
251
+ export const impossibleTravelParamsSchema = z.object({
252
+ maxSpeedKmh: z.number().positive().default(500),
253
+ ignoreVpnRanges: z.boolean().optional(),
254
+ });
255
+ export const simultaneousLocationsParamsSchema = z.object({
256
+ minDistanceKm: z.number().positive().default(100),
257
+ });
258
+ export const deviceVelocityParamsSchema = z.object({
259
+ maxIps: z.number().int().positive().default(5),
260
+ windowHours: z.number().int().positive().default(24),
261
+ });
262
+ export const concurrentStreamsParamsSchema = z.object({
263
+ maxStreams: z.number().int().positive().default(3),
264
+ });
265
+ export const geoRestrictionParamsSchema = z.object({
266
+ mode: z.enum(['blocklist', 'allowlist']).default('blocklist'),
267
+ countries: z.array(z.string().length(2)).default([]),
268
+ });
269
+ export const accountInactivityParamsSchema = z.object({
270
+ inactivityValue: z.number().int().positive().default(30),
271
+ inactivityUnit: z.enum(['days', 'weeks', 'months']).default('days'),
272
+ });
273
+ export const ruleParamsSchema = z.union([
274
+ impossibleTravelParamsSchema,
275
+ simultaneousLocationsParamsSchema,
276
+ deviceVelocityParamsSchema,
277
+ concurrentStreamsParamsSchema,
278
+ geoRestrictionParamsSchema,
279
+ accountInactivityParamsSchema,
280
+ ]);
281
+ export const createRuleSchema = z.object({
282
+ name: z.string().min(1).max(100),
283
+ type: z.enum([
284
+ 'impossible_travel',
285
+ 'simultaneous_locations',
286
+ 'device_velocity',
287
+ 'concurrent_streams',
288
+ 'geo_restriction',
289
+ 'account_inactivity',
290
+ ]),
291
+ params: z.record(z.string(), z.unknown()),
292
+ serverUserId: uuidSchema.nullable().default(null),
293
+ isActive: z.boolean().default(true),
294
+ });
295
+ export const updateRuleSchema = z.object({
296
+ name: z.string().min(1).max(100).optional(),
297
+ params: z.record(z.string(), z.unknown()).optional(),
298
+ isActive: z.boolean().optional(),
299
+ });
300
+ export const ruleIdParamSchema = z.object({
301
+ id: uuidSchema,
302
+ });
303
+ // ============================================
304
+ // Rules Builder V2 - Validation Schemas
305
+ // ============================================
306
+ // Operators
307
+ export const comparisonOperatorSchema = z.enum(['eq', 'neq', 'gt', 'gte', 'lt', 'lte']);
308
+ export const arrayOperatorSchema = z.enum(['in', 'not_in']);
309
+ export const stringOperatorSchema = z.enum(['contains', 'not_contains']);
310
+ export const operatorSchema = z.union([
311
+ comparisonOperatorSchema,
312
+ arrayOperatorSchema,
313
+ stringOperatorSchema,
314
+ ]);
315
+ // Condition fields by category
316
+ export const sessionBehaviorFieldSchema = z.enum([
317
+ 'concurrent_streams',
318
+ 'active_session_distance_km',
319
+ 'travel_speed_kmh',
320
+ 'unique_ips_in_window',
321
+ 'unique_devices_in_window',
322
+ 'inactive_days',
323
+ 'current_pause_minutes',
324
+ 'total_pause_minutes',
325
+ ]);
326
+ export const streamQualityFieldSchema = z.enum([
327
+ 'source_resolution',
328
+ 'output_resolution',
329
+ 'is_transcoding',
330
+ 'is_transcode_downgrade',
331
+ 'source_bitrate_mbps',
332
+ ]);
333
+ export const transcodingConditionValueSchema = z.enum([
334
+ 'video',
335
+ 'audio',
336
+ 'video_or_audio',
337
+ 'neither',
338
+ ]);
339
+ export const userAttributeFieldSchema = z.enum(['user_id', 'trust_score', 'account_age_days']);
340
+ export const deviceClientFieldSchema = z.enum(['device_type', 'client_name', 'platform']);
341
+ export const networkLocationFieldSchema = z.enum(['is_local_network', 'country', 'ip_in_range']);
342
+ export const scopeFieldSchema = z.enum(['server_id', 'media_type']);
343
+ export const conditionFieldSchema = z.union([
344
+ sessionBehaviorFieldSchema,
345
+ streamQualityFieldSchema,
346
+ userAttributeFieldSchema,
347
+ deviceClientFieldSchema,
348
+ networkLocationFieldSchema,
349
+ scopeFieldSchema,
350
+ ]);
351
+ // Enums
352
+ export const videoResolutionSchema = z.enum(['4K', '1080p', '720p', '480p', 'SD', 'unknown']);
353
+ export const deviceTypeSchema = z.enum(['mobile', 'tablet', 'tv', 'desktop', 'browser', 'unknown']);
354
+ export const platformSchema = z.enum([
355
+ 'ios',
356
+ 'android',
357
+ 'windows',
358
+ 'macos',
359
+ 'linux',
360
+ 'tvos',
361
+ 'androidtv',
362
+ 'roku',
363
+ 'webos',
364
+ 'tizen',
365
+ 'unknown',
366
+ ]);
367
+ export const mediaTypeEnumSchema = z.enum([
368
+ 'movie',
369
+ 'episode',
370
+ 'track',
371
+ 'photo',
372
+ 'live',
373
+ 'trailer',
374
+ ]);
375
+ // Condition value
376
+ export const conditionValueSchema = z.union([
377
+ z.string(),
378
+ z.number(),
379
+ z.boolean(),
380
+ z.array(z.string()),
381
+ z.array(z.number()),
382
+ ]);
383
+ // Single condition
384
+ export const conditionSchema = z.object({
385
+ field: conditionFieldSchema,
386
+ operator: operatorSchema,
387
+ value: conditionValueSchema,
388
+ params: z
389
+ .object({
390
+ // The history fetch sizes itself from the largest active window; 168h
391
+ // (7 days) bounds that fetch and matches the sessions hypertable's
392
+ // compression boundary.
393
+ window_hours: z
394
+ .number()
395
+ .int()
396
+ .positive()
397
+ .max(168, 'Window cannot exceed 168 hours (7 days)')
398
+ .optional(),
399
+ exclude_same_device: z.boolean().optional(),
400
+ exclude_same_ip: z.boolean().optional(),
401
+ count_device_types: z.array(deviceTypeSchema).optional(),
402
+ })
403
+ .optional(),
404
+ });
405
+ // Condition group (OR logic)
406
+ export const conditionGroupSchema = z.object({
407
+ conditions: z.array(conditionSchema).min(1),
408
+ });
409
+ /**
410
+ * Condition fields evaluable for a dormant account outside a playback
411
+ * session. Rules containing inactive_days run in the hourly inactivity
412
+ * worker, where no session exists, so they may only use these fields.
413
+ */
414
+ export const INACTIVITY_COMPATIBLE_FIELDS = [
415
+ 'inactive_days',
416
+ 'server_id',
417
+ 'user_id',
418
+ 'trust_score',
419
+ 'account_age_days',
420
+ ];
421
+ function inactivityFieldsCompatible(conditions) {
422
+ const all = conditions.groups.flatMap((g) => g.conditions);
423
+ if (!all.some((c) => c.field === 'inactive_days'))
424
+ return true;
425
+ return all.every((c) => INACTIVITY_COMPATIBLE_FIELDS.includes(c.field));
426
+ }
427
+ // Rule conditions (AND logic between groups)
428
+ export const ruleConditionsSchema = z
429
+ .object({
430
+ groups: z.array(conditionGroupSchema).min(1),
431
+ })
432
+ .refine(inactivityFieldsCompatible, {
433
+ message: 'inactive_days rules run outside a playback session, so they can only be combined with server, user, trust score, or account age conditions',
434
+ });
435
+ // Action types
436
+ export const actionTypeSchema = z.enum([
437
+ 'log_only',
438
+ 'notify',
439
+ 'adjust_trust',
440
+ 'set_trust',
441
+ 'reset_trust',
442
+ 'kill_stream',
443
+ 'message_client',
444
+ ]);
445
+ export const notificationChannelV2Schema = z.enum(['push', 'discord', 'email', 'webhook']);
446
+ // Individual action schemas
447
+ export const logOnlyActionSchema = z.object({
448
+ type: z.literal('log_only'),
449
+ message: z.string().max(500).optional(),
450
+ });
451
+ export const notifyActionSchema = z.object({
452
+ type: z.literal('notify'),
453
+ channels: z.array(notificationChannelV2Schema).min(1),
454
+ cooldown_minutes: z.number().int().nonnegative().optional(),
455
+ });
456
+ export const adjustTrustActionSchema = z.object({
457
+ type: z.literal('adjust_trust'),
458
+ amount: z.number().int().min(-100).max(100),
459
+ });
460
+ export const setTrustActionSchema = z.object({
461
+ type: z.literal('set_trust'),
462
+ value: z.number().int().min(0).max(100),
463
+ });
464
+ export const resetTrustActionSchema = z.object({
465
+ type: z.literal('reset_trust'),
466
+ });
467
+ export const sessionTargetSchema = z.enum([
468
+ 'triggering',
469
+ 'oldest',
470
+ 'newest',
471
+ 'all_except_one',
472
+ 'all_user',
473
+ ]);
474
+ export const killStreamActionSchema = z.object({
475
+ type: z.literal('kill_stream'),
476
+ /** Seconds to wait before killing. The kill only fires if the rule condition still holds after the wait; 0 (default) still re-checks once before killing. */
477
+ delay_seconds: z.number().int().min(0).max(300).optional(),
478
+ require_confirmation: z.boolean().optional(),
479
+ cooldown_minutes: z.number().int().nonnegative().optional(),
480
+ /** Message to display to user before termination. If omitted, terminates silently. */
481
+ message: z.string().min(1).max(500).optional(),
482
+ target: sessionTargetSchema.optional(),
483
+ });
484
+ export const messageClientActionSchema = z.object({
485
+ type: z.literal('message_client'),
486
+ message: z.string().min(1).max(500),
487
+ target: sessionTargetSchema.optional(),
488
+ });
489
+ // Union of all actions
490
+ export const actionSchema = z.discriminatedUnion('type', [
491
+ logOnlyActionSchema,
492
+ notifyActionSchema,
493
+ adjustTrustActionSchema,
494
+ setTrustActionSchema,
495
+ resetTrustActionSchema,
496
+ killStreamActionSchema,
497
+ messageClientActionSchema,
498
+ ]);
499
+ // Rule actions container (actions are optional side-effects; violations are always auto-created)
500
+ export const ruleActionsSchema = z.object({
501
+ actions: z.array(actionSchema),
502
+ });
503
+ export const violationSeveritySchema = z.enum(['low', 'warning', 'high']);
504
+ // A rule may be scoped to at most one of server, account, or person.
505
+ export function hasAtMostOneScope(data) {
506
+ return [data.serverId, data.serverUserId, data.userId].filter((v) => v != null).length <= 1;
507
+ }
508
+ export const RULE_SCOPE_ERROR_MESSAGE = 'A rule can only be scoped to one of server, account, or person';
509
+ const scopeRefinement = {
510
+ message: RULE_SCOPE_ERROR_MESSAGE,
511
+ };
512
+ // A server-scoped rule detects on that server's sessions only; enforcing its
513
+ // actions across every server would kill sessions the rule cannot see.
514
+ export function scopeAllowsCrossServerEnforcement(data) {
515
+ return !(data.serverId != null && data.enforceAcrossServers === true);
516
+ }
517
+ export const RULE_CROSS_SERVER_ENFORCEMENT_ERROR_MESSAGE = 'A server-scoped rule cannot enforce actions across all servers';
518
+ const crossServerEnforcementRefinement = {
519
+ message: RULE_CROSS_SERVER_ENFORCEMENT_ERROR_MESSAGE,
520
+ };
521
+ // Create rule V2 schema
522
+ export const createRuleV2Schema = z
523
+ .object({
524
+ name: z.string().min(1).max(100),
525
+ description: z.string().max(500).nullable().optional(),
526
+ serverId: uuidSchema.nullable().optional(),
527
+ serverUserId: uuidSchema.nullable().optional(),
528
+ userId: uuidSchema.nullable().optional(),
529
+ enforceAcrossServers: z.boolean().optional().default(false),
530
+ isActive: z.boolean().default(true),
531
+ severity: violationSeveritySchema.default('warning'),
532
+ conditions: ruleConditionsSchema,
533
+ actions: ruleActionsSchema,
534
+ })
535
+ .refine(hasAtMostOneScope, scopeRefinement)
536
+ .refine(scopeAllowsCrossServerEnforcement, crossServerEnforcementRefinement);
537
+ // Update rule V2 schema
538
+ export const updateRuleV2Schema = z
539
+ .object({
540
+ name: z.string().min(1).max(100).optional(),
541
+ description: z.string().max(500).nullable().optional(),
542
+ serverId: uuidSchema.nullable().optional(),
543
+ serverUserId: uuidSchema.nullable().optional(),
544
+ userId: uuidSchema.nullable().optional(),
545
+ enforceAcrossServers: z.boolean().optional(),
546
+ isActive: z.boolean().optional(),
547
+ severity: violationSeveritySchema.optional(),
548
+ conditions: ruleConditionsSchema.optional(),
549
+ actions: ruleActionsSchema.optional(),
550
+ })
551
+ .refine(hasAtMostOneScope, scopeRefinement)
552
+ .refine(scopeAllowsCrossServerEnforcement, crossServerEnforcementRefinement);
553
+ // Bulk operations schemas
554
+ export const bulkUpdateRulesSchema = z.object({
555
+ ids: z.array(uuidSchema).min(1, 'At least one rule ID is required'),
556
+ isActive: z.boolean(),
557
+ });
558
+ export const bulkDeleteRulesSchema = z.object({
559
+ ids: z.array(uuidSchema).min(1, 'At least one rule ID is required'),
560
+ });
561
+ export const bulkMigrateRulesSchema = z.object({
562
+ ids: z.array(uuidSchema).optional(),
563
+ });
564
+ // ============================================================================
565
+ // Violation Schemas
566
+ // ============================================================================
567
+ export const violationSortFieldSchema = z.enum(['createdAt', 'severity', 'user', 'rule']);
568
+ export const violationQuerySchema = paginationSchema.extend({
569
+ serverId: uuidSchema.optional(),
570
+ serverIds: serverIdsQuerySchema,
571
+ serverUserId: uuidSchema.optional(),
572
+ // Identity-level filter: matches violations from every server account under
573
+ // this person (users.id), scoped to the caller's accessible servers.
574
+ // userIds is the multi-select form; userId stays supported for back-compat.
575
+ userId: uuidSchema.optional(),
576
+ userIds: userIdsQuerySchema,
577
+ ruleId: uuidSchema.optional(),
578
+ severity: z.enum(['low', 'warning', 'high']).optional(),
579
+ acknowledged: booleanStringSchema.optional(),
580
+ startDate: z.coerce.date().optional(),
581
+ endDate: z.coerce.date().optional(),
582
+ orderBy: violationSortFieldSchema.optional(),
583
+ orderDir: z.enum(['asc', 'desc']).optional(),
584
+ });
585
+ export const violationIdParamSchema = z.object({
586
+ id: uuidSchema,
587
+ });
588
+ // ============================================================================
589
+ // Stats Schemas
590
+ // ============================================================================
591
+ export const serverIdFilterSchema = z.object({
592
+ serverId: uuidSchema.optional(),
593
+ serverIds: serverIdsQuerySchema,
594
+ });
595
+ // Dashboard query schema with timezone support
596
+ export const dashboardQuerySchema = z.object({
597
+ serverId: uuidSchema.optional(),
598
+ serverIds: serverIdsQuerySchema,
599
+ timezone: timezoneSchema,
600
+ });
601
+ export const statsQuerySchema = z
602
+ .object({
603
+ period: statPeriodSchema.default('week'),
604
+ startDate: z.iso.datetime().optional(),
605
+ endDate: z.iso.datetime().optional(),
606
+ serverId: uuidSchema.optional(),
607
+ serverIds: serverIdsQuerySchema,
608
+ timezone: timezoneSchema,
609
+ })
610
+ .refine(dateValidationRefinements.customPeriodRequiresDates.refinement, {
611
+ message: dateValidationRefinements.customPeriodRequiresDates.message,
612
+ })
613
+ .refine(dateValidationRefinements.startBeforeEnd.refinement, {
614
+ message: dateValidationRefinements.startBeforeEnd.message,
615
+ });
616
+ // Location stats with full filtering - uses same period system as statsQuerySchema
617
+ export const locationStatsQuerySchema = z
618
+ .object({
619
+ period: statPeriodSchema.default('month'),
620
+ startDate: z.iso.datetime().optional(),
621
+ endDate: z.iso.datetime().optional(),
622
+ serverUserId: uuidSchema.optional(),
623
+ // Additive multi-account filter: when set, takes precedence over serverUserId so a
624
+ // merged person's full history (all their server accounts) can be filtered at once.
625
+ serverUserIds: commaSeparatedArray(uuidSchema),
626
+ serverId: uuidSchema.optional(),
627
+ serverIds: serverIdsQuerySchema,
628
+ mediaType: mediaTypeSchema.optional(),
629
+ })
630
+ .refine(dateValidationRefinements.customPeriodRequiresDates.refinement, {
631
+ message: dateValidationRefinements.customPeriodRequiresDates.message,
632
+ })
633
+ .refine(dateValidationRefinements.startBeforeEnd.refinement, {
634
+ message: dateValidationRefinements.startBeforeEnd.message,
635
+ });
636
+ // ============================================================================
637
+ // Webhook & Settings Schemas
638
+ // ============================================================================
639
+ // Webhook format enum
640
+ export const webhookFormatSchema = z.enum(['json', 'ntfy', 'apprise', 'pushover', 'gotify']);
641
+ // Unit system enum for display preferences
642
+ export const unitSystemSchema = z.enum(['metric', 'imperial']);
643
+ const permissiveUrlSchema = z.string().refine((val) => {
644
+ // Must start with http:// or https://
645
+ if (!/^https?:\/\//i.test(val))
646
+ return false;
647
+ // Must have something after the protocol
648
+ const afterProtocol = val.replace(/^https?:\/\//i, '');
649
+ if (!afterProtocol || afterProtocol === '/')
650
+ return false;
651
+ // Check hostname doesn't have whitespace
652
+ const hostPart = afterProtocol.split('/')[0];
653
+ if (!hostPart || /\s/.test(hostPart))
654
+ return false;
655
+ return true;
656
+ }, { message: 'Invalid URL. Must start with http:// or https:// followed by a hostname' });
657
+ // Nullable URL schema that converts empty strings to null (for clearing fields)
658
+ // Auto-prepends http:// if a bare hostname is provided (no protocol)
659
+ const nullableUrlSchema = z.preprocess((val) => {
660
+ if (val === '' || val === null || val === undefined)
661
+ return null;
662
+ const str = String(val).trim();
663
+ if (!str)
664
+ return null;
665
+ // Auto-prepend http:// if no protocol specified (for convenience)
666
+ if (str && !/^https?:\/\//i.test(str)) {
667
+ return `http://${str}`;
668
+ }
669
+ return str;
670
+ }, permissiveUrlSchema.nullable());
671
+ // Nullable string schema that converts empty strings to null (for clearing fields)
672
+ const nullableStringSchema = (maxLength) => z.preprocess((val) => (val === '' ? null : val), maxLength ? z.string().max(maxLength).nullable() : z.string().nullable());
673
+ // Settings schemas
674
+ export const updateSettingsSchema = z.object({
675
+ allowGuestAccess: z.boolean().optional(),
676
+ // Display preferences
677
+ unitSystem: unitSystemSchema.optional(),
678
+ discordWebhookUrl: nullableUrlSchema.optional(),
679
+ customWebhookUrl: nullableUrlSchema.optional(),
680
+ webhookFormat: webhookFormatSchema.nullable().optional(),
681
+ ntfyTopic: z.string().max(200).nullable().optional(),
682
+ ntfyAuthToken: nullableStringSchema(500).optional(),
683
+ pushoverUserKey: nullableStringSchema(200).optional(),
684
+ pushoverApiToken: nullableStringSchema(200).optional(),
685
+ // Poller settings
686
+ pollerEnabled: z.boolean().optional(),
687
+ pollerIntervalMs: z.number().int().min(5000).max(300000).optional(),
688
+ // GeoIP settings
689
+ usePlexGeoip: z.boolean().optional(),
690
+ // Tautulli integration
691
+ tautulliUrl: nullableUrlSchema.optional(),
692
+ tautulliApiKey: nullableStringSchema().optional(),
693
+ // Network/access settings
694
+ externalUrl: nullableUrlSchema.optional(),
695
+ trustProxy: z.boolean().optional(),
696
+ // Tailscale VPN
697
+ tailscaleHostname: z
698
+ .string()
699
+ .max(255)
700
+ .regex(/^[a-zA-Z0-9-]*$/, 'Hostname may only contain letters, numbers, and hyphens')
701
+ .nullable()
702
+ .optional(),
703
+ // Backup settings
704
+ backupScheduleType: z.enum(['disabled', 'daily', 'weekly', 'monthly']).optional(),
705
+ backupScheduleTime: z
706
+ .string()
707
+ .regex(/^\d{2}:\d{2}$/, 'Time must be in HH:MM format')
708
+ .optional(),
709
+ backupScheduleDayOfWeek: z.number().int().min(0).max(6).optional(),
710
+ backupScheduleDayOfMonth: z.number().int().min(1).max(31).optional(),
711
+ backupRetentionCount: z.number().int().min(1).max(30).optional(),
712
+ // Watch completion thresholds (percent, per media type)
713
+ watchedThresholdMovie: z.number().int().min(1).max(100).optional(),
714
+ watchedThresholdTv: z.number().int().min(1).max(100).optional(),
715
+ watchedThresholdMusic: z.number().int().min(1).max(100).optional(),
716
+ // Public API v2
717
+ publicApiRateLimitPerMinute: z.number().int().min(1).optional(),
718
+ // Media browsing: warm poster caches for a server after its library sync completes
719
+ imagePrecacheEnabled: z.boolean().optional(),
720
+ // Media browsing: server whose poster wins when a title exists on multiple servers
721
+ preferredPosterServerId: z.string().uuid().nullable().optional(),
722
+ });
723
+ // ============================================================================
724
+ // Tailscale Schemas
725
+ // ============================================================================
726
+ export const tailscaleEnableSchema = z.object({
727
+ hostname: z
728
+ .string()
729
+ .max(63)
730
+ .regex(/^[a-zA-Z0-9-]*$/, 'Hostname may only contain letters, numbers, and hyphens')
731
+ .optional(),
732
+ });
733
+ export const tailscaleExitNodeSchema = z.object({
734
+ id: z.string().nullable().optional(),
735
+ });
736
+ // ============================================================================
737
+ // Tautulli Import Schemas
738
+ // ============================================================================
739
+ export const tautulliImportSchema = z.object({
740
+ serverId: uuidSchema, // Which Tracearr server to import into
741
+ overwriteFriendlyNames: z.boolean().optional(), // Whether to overwrite existing identity names
742
+ includeStreamDetails: z.boolean().optional(), // (BETA) Fetch detailed codec/bitrate info via additional API calls
743
+ });
744
+ // ============================================================================
745
+ // Jellystat Import Schemas
746
+ // ============================================================================
747
+ /**
748
+ * PlayState object from Jellystat backup
749
+ * Uses loose() to allow extra fields that Jellystat may include
750
+ */
751
+ export const jellystatPlayStateSchema = z.looseObject({
752
+ IsPaused: z.boolean().nullable().optional(),
753
+ PositionTicks: z.number().nullable().optional(),
754
+ RuntimeTicks: z.number().nullable().optional(),
755
+ Completed: z.boolean().nullable().optional(),
756
+ }); // Allow extra fields like IsMuted, VolumeLevel, CanSeek, etc.
757
+ /**
758
+ * TranscodingInfo object from Jellystat backup
759
+ * Uses looseObject() to allow extra fields like AudioCodec, VideoCodec, etc.
760
+ */
761
+ export const jellystatTranscodingInfoSchema = z
762
+ .looseObject({
763
+ Bitrate: z.number().nullable().optional(),
764
+ }) // Allow extra fields like AudioCodec, VideoCodec, Container, etc.
765
+ .nullable()
766
+ .optional();
767
+ /**
768
+ * Individual playback activity record from Jellystat export
769
+ * Uses looseObject() to allow extra fields like ApplicationVersion, MediaStreams, etc.
770
+ */
771
+ export const jellystatPlaybackActivitySchema = z.looseObject({
772
+ Id: z.string(),
773
+ UserId: z.string(),
774
+ UserName: z.string().nullable().optional(),
775
+ NowPlayingItemId: z.string(),
776
+ NowPlayingItemName: z.string(),
777
+ SeriesName: z.string().nullable().optional(),
778
+ SeasonId: z.string().nullable().optional(),
779
+ EpisodeId: z.string().nullable().optional(),
780
+ PlaybackDuration: z.union([z.string(), z.number()]), // Can be string or number
781
+ ActivityDateInserted: z.string(), // ISO 8601 timestamp
782
+ PlayMethod: z
783
+ .string()
784
+ .refine((val) => val === 'DirectPlay' || val === 'DirectStream' || val.startsWith('Transcode'), {
785
+ message: 'PlayMethod must be DirectPlay, DirectStream, or Transcode (with optional codec info)',
786
+ })
787
+ .nullable()
788
+ .optional(),
789
+ PlayState: jellystatPlayStateSchema.nullable().optional(),
790
+ TranscodingInfo: jellystatTranscodingInfoSchema,
791
+ RemoteEndPoint: z.string().nullable().optional(),
792
+ Client: z.string().nullable().optional(),
793
+ DeviceName: z.string().nullable().optional(),
794
+ DeviceId: z.string().nullable().optional(),
795
+ IsPaused: z.boolean().nullable().optional(), // Top-level IsPaused (separate from PlayState.IsPaused)
796
+ }); // Allow extra fields like ApplicationVersion, MediaStreams, ServerId, etc.
797
+ /**
798
+ * Jellystat backup file structure
799
+ * The backup is an array with a single object containing table data
800
+ * Individual activity records are validated separately during import to skip bad records
801
+ */
802
+ export const jellystatBackupSchema = z.array(z.object({
803
+ jf_playback_activity: z.array(z.unknown()).optional(), // Validate records individually during import
804
+ }));
805
+ /**
806
+ * Request body for Jellystat import (multipart form data is parsed separately)
807
+ */
808
+ export const jellystatImportBodySchema = z.object({
809
+ serverId: uuidSchema, // Which Tracearr server to import into
810
+ enrichMedia: z.coerce.boolean().default(true), // Fetch season/episode from Jellyfin API
811
+ updateStreamDetails: z.coerce.boolean().default(false), // Update existing records with stream/transcode data
812
+ });
813
+ /**
814
+ * Import job status response
815
+ */
816
+ export const importJobStatusSchema = z.object({
817
+ jobId: z.string(),
818
+ state: z.enum(['queued', 'active', 'completed', 'failed', 'delayed']),
819
+ progress: z.number().min(0).max(100).optional(),
820
+ result: z
821
+ .object({
822
+ imported: z.number(),
823
+ skipped: z.number(),
824
+ errors: z.number(),
825
+ enriched: z.number().optional(),
826
+ })
827
+ .optional(),
828
+ failedReason: z.string().optional(),
829
+ });
830
+ // ============================================================================
831
+ // Engagement Stats Schemas
832
+ // ============================================================================
833
+ // Engagement tier enum for validation
834
+ export const engagementTierSchema = z.enum([
835
+ 'abandoned',
836
+ 'sampled',
837
+ 'engaged',
838
+ 'watched',
839
+ 'rewatched',
840
+ 'unknown',
841
+ ]);
842
+ // User behavior type enum for validation
843
+ export const userBehaviorTypeSchema = z.enum([
844
+ 'inactive',
845
+ 'sampler',
846
+ 'casual',
847
+ 'completionist',
848
+ 'rewatcher',
849
+ ]);
850
+ // Engagement stats query schema - extends base stats query
851
+ export const engagementQuerySchema = z
852
+ .object({
853
+ period: statPeriodSchema.default('week'),
854
+ startDate: z.iso.datetime().optional(),
855
+ endDate: z.iso.datetime().optional(),
856
+ serverId: uuidSchema.optional(),
857
+ serverIds: serverIdsQuerySchema,
858
+ timezone: timezoneSchema,
859
+ // Engagement-specific filters
860
+ mediaType: mediaTypeSchema.optional(),
861
+ limit: z.coerce.number().int().positive().max(100).default(10),
862
+ })
863
+ .refine(dateValidationRefinements.customPeriodRequiresDates.refinement, {
864
+ message: dateValidationRefinements.customPeriodRequiresDates.message,
865
+ })
866
+ .refine(dateValidationRefinements.startBeforeEnd.refinement, {
867
+ message: dateValidationRefinements.startBeforeEnd.message,
868
+ });
869
+ // Show stats query schema
870
+ export const showsQuerySchema = z
871
+ .object({
872
+ period: statPeriodSchema.default('month'),
873
+ startDate: z.iso.datetime().optional(),
874
+ endDate: z.iso.datetime().optional(),
875
+ serverId: uuidSchema.optional(),
876
+ serverIds: serverIdsQuerySchema,
877
+ timezone: timezoneSchema,
878
+ limit: z.coerce.number().int().positive().max(100).default(20),
879
+ orderBy: z
880
+ .enum(['totalEpisodeViews', 'totalWatchHours', 'bingeScore', 'uniqueViewers'])
881
+ .default('totalEpisodeViews'),
882
+ })
883
+ .refine(dateValidationRefinements.customPeriodRequiresDates.refinement, {
884
+ message: dateValidationRefinements.customPeriodRequiresDates.message,
885
+ })
886
+ .refine(dateValidationRefinements.startBeforeEnd.refinement, {
887
+ message: dateValidationRefinements.startBeforeEnd.message,
888
+ });
889
+ // ============================================================================
890
+ // Library Stats Schemas
891
+ // ============================================================================
892
+ // Library stats query schema
893
+ export const libraryStatsQuerySchema = z.object({
894
+ serverId: z.uuid().optional(),
895
+ serverIds: serverIdsQuerySchema,
896
+ libraryId: z.uuid().optional(),
897
+ timezone: timezoneSchema,
898
+ });
899
+ // Library status query schema
900
+ export const libraryStatusQuerySchema = z.object({
901
+ serverId: z.uuid().optional(),
902
+ serverIds: serverIdsQuerySchema,
903
+ });
904
+ // Library growth query schema (time-series)
905
+ export const libraryGrowthQuerySchema = z
906
+ .object({
907
+ serverId: z.uuid().optional(),
908
+ serverIds: serverIdsQuerySchema,
909
+ libraryId: z.uuid().optional(),
910
+ period: z.enum(['7d', '30d', '90d', '1y', 'all']).default('30d'),
911
+ startDate: z.iso.datetime().optional(),
912
+ endDate: z.iso.datetime().optional(),
913
+ timezone: timezoneSchema,
914
+ })
915
+ .refine(dateValidationRefinements.startBeforeEnd.refinement, {
916
+ message: dateValidationRefinements.startBeforeEnd.message,
917
+ });
918
+ // Library quality evolution query schema
919
+ export const libraryQualityQuerySchema = z.object({
920
+ serverId: z.uuid().optional(),
921
+ period: z.enum(['7d', '30d', '90d', '1y', 'all']).default('30d'),
922
+ mediaType: z.enum(['all', 'movies', 'shows']).default('all'),
923
+ timezone: timezoneSchema,
924
+ });
925
+ // Library storage analytics query schema
926
+ export const libraryStorageQuerySchema = z.object({
927
+ serverId: z.uuid().optional(),
928
+ serverIds: serverIdsQuerySchema, // Combined scope so mirror dedup spans servers
929
+ libraryId: z.uuid().optional(),
930
+ period: z.enum(['7d', '30d', '90d', '1y', 'all']).default('30d'),
931
+ timezone: timezoneSchema,
932
+ });
933
+ // Library duplicates query schema (cross-server duplicate detection)
934
+ export const libraryDuplicatesQuerySchema = z.object({
935
+ serverId: z.uuid().optional(), // Filter to show duplicates involving this server
936
+ serverIds: serverIdsQuerySchema, // Restrict detection to a subset of accessible servers
937
+ mediaType: z.enum(['movie', 'episode', 'show']).optional(),
938
+ minConfidence: z.coerce.number().min(0).max(100).default(70),
939
+ includeFuzzy: booleanStringSchema.default(true), // Include fuzzy title matches
940
+ page: z.coerce.number().int().positive().default(1),
941
+ pageSize: z.coerce.number().int().min(1).max(100).default(50),
942
+ });
943
+ // Library stale content query schema
944
+ export const libraryStaleQuerySchema = z.object({
945
+ serverId: z.uuid().optional(),
946
+ serverIds: serverIdsQuerySchema,
947
+ libraryId: z.uuid().optional(),
948
+ mediaType: z.enum(['movie', 'show', 'artist']).optional(),
949
+ staleDays: z.coerce.number().int().min(1).default(90), // Configurable threshold
950
+ category: z.enum(['all', 'never_watched', 'stale']).default('all'),
951
+ sortBy: z.enum(['size', 'days_stale', 'title', 'added_at']).default('size'),
952
+ sortOrder: z.enum(['asc', 'desc']).default('desc'),
953
+ page: z.coerce.number().int().positive().default(1),
954
+ pageSize: z.coerce.number().int().min(1).max(100).default(50),
955
+ timezone: timezoneSchema,
956
+ });
957
+ // Library watch statistics query schema
958
+ export const libraryWatchQuerySchema = z.object({
959
+ serverId: uuidSchema.optional(),
960
+ serverIds: serverIdsQuerySchema,
961
+ libraryId: z.string().optional(),
962
+ mediaType: z.enum(['movie', 'episode', 'show']).optional(),
963
+ minWatchCount: z.coerce.number().int().min(0).optional(),
964
+ maxWatchCount: z.coerce.number().int().min(0).optional(),
965
+ includeUnwatched: z.coerce.boolean().default(true),
966
+ sortBy: z.enum(['watch_count', 'last_watched', 'title', 'file_size']).default('watch_count'),
967
+ sortOrder: z.enum(['asc', 'desc']).default('desc'),
968
+ page: z.coerce.number().int().positive().default(1),
969
+ pageSize: z.coerce.number().int().positive().max(100).default(20),
970
+ });
971
+ // Library ROI (Return on Investment) query schema
972
+ export const libraryRoiQuerySchema = z.object({
973
+ serverId: uuidSchema.optional(),
974
+ serverIds: serverIdsQuerySchema,
975
+ libraryId: z.string().optional(),
976
+ mediaType: z.enum(['movie', 'show', 'artist', 'all']).default('all'),
977
+ // Filter by value category
978
+ valueCategory: z.enum(['low_value', 'moderate_value', 'high_value', 'all']).default('all'),
979
+ // Time range for watch calculations (affects recency weighting)
980
+ periodDays: z.coerce.number().int().min(30).max(365).default(90),
981
+ // Include age decay in value calculation
982
+ includeAgeDecay: z.coerce.boolean().default(true),
983
+ // Minimum file size to include (bytes) - filter out tiny files
984
+ minFileSize: z.coerce.number().int().min(0).default(0),
985
+ sortBy: z
986
+ .enum(['watch_hours_per_gb', 'value_score', 'file_size', 'title'])
987
+ .default('watch_hours_per_gb'),
988
+ sortOrder: z.enum(['asc', 'desc']).default('asc'), // Low value first by default
989
+ page: z.coerce.number().int().positive().default(1),
990
+ pageSize: z.coerce.number().int().positive().max(100).default(20),
991
+ });
992
+ // Library watch patterns query schema (binge, peak times, seasonal)
993
+ export const libraryPatternsQuerySchema = z.object({
994
+ serverId: uuidSchema.optional(),
995
+ serverIds: serverIdsQuerySchema,
996
+ libraryId: z.string().optional(),
997
+ // Time range for pattern analysis (default: 52 weeks per CONTEXT.md)
998
+ periodWeeks: z.coerce.number().int().min(4).max(104).default(52),
999
+ // Scope: per-user patterns or server-wide aggregate
1000
+ scope: z.enum(['user', 'server']).default('server'),
1001
+ // Which patterns to include
1002
+ includeBinge: z.coerce.boolean().default(true),
1003
+ includePeakTimes: z.coerce.boolean().default(true),
1004
+ includeSeasonalTrends: z.coerce.boolean().default(true),
1005
+ // For binge: minimum episodes to consider a binge session
1006
+ bingeThreshold: z.coerce.number().int().min(2).max(10).default(3),
1007
+ // Limit for top binge shows
1008
+ limit: z.coerce.number().int().positive().max(50).default(10),
1009
+ // Timezone for hour/day extraction (defaults to UTC on backend)
1010
+ timezone: timezoneSchema,
1011
+ });
1012
+ // Library completion rate analysis query schema
1013
+ export const libraryCompletionQuerySchema = z.object({
1014
+ serverId: uuidSchema.optional(),
1015
+ libraryId: z.string().optional(),
1016
+ mediaType: z.enum(['movie', 'episode', 'show']).optional(),
1017
+ // For TV: aggregate to episode, season, or series level
1018
+ aggregateLevel: z.enum(['item', 'season', 'series']).default('item'),
1019
+ // Completion status filter
1020
+ status: z.enum(['completed', 'in_progress', 'not_started', 'all']).default('all'),
1021
+ minCompletionPct: z.coerce.number().min(0).max(100).optional(),
1022
+ maxCompletionPct: z.coerce.number().min(0).max(100).optional(),
1023
+ sortBy: z.enum(['completion_pct', 'title', 'last_watched']).default('completion_pct'),
1024
+ sortOrder: z.enum(['asc', 'desc']).default('desc'),
1025
+ page: z.coerce.number().int().positive().default(1),
1026
+ pageSize: z.coerce.number().int().positive().max(100).default(20),
1027
+ });
1028
+ // Library top content query schema (for top movies and top shows endpoints)
1029
+ export const topContentQuerySchema = z.object({
1030
+ serverId: uuidSchema.optional(),
1031
+ serverIds: serverIdsQuerySchema,
1032
+ period: z.enum(['7d', '30d', '90d', '1y', 'all']).default('30d'),
1033
+ sortBy: z
1034
+ .enum(['plays', 'watch_hours', 'viewers', 'completion_rate', 'binge_score'])
1035
+ .default('plays'),
1036
+ sortOrder: z.enum(['asc', 'desc']).default('desc'),
1037
+ page: z.coerce.number().int().positive().default(1),
1038
+ pageSize: z.coerce.number().int().positive().max(50).default(20),
1039
+ });
1040
+ // Library shelves query schema (windowed recently-added/most-popular/dead-weight
1041
+ // command center) - same day/week/month/year/all/custom period convention as
1042
+ // statsQuerySchema, matching the frontend's TimeRangePicker/TimeRangeValue shape.
1043
+ export const shelvesQuerySchema = z
1044
+ .object({
1045
+ period: statPeriodSchema.default('month'),
1046
+ startDate: z.iso.datetime().optional(),
1047
+ endDate: z.iso.datetime().optional(),
1048
+ serverIds: serverIdsQuerySchema,
1049
+ includeDeadWeight: booleanStringSchema.default(true),
1050
+ })
1051
+ .refine(dateValidationRefinements.customPeriodRequiresDates.refinement, {
1052
+ message: dateValidationRefinements.customPeriodRequiresDates.message,
1053
+ })
1054
+ .refine(dateValidationRefinements.startBeforeEnd.refinement, {
1055
+ message: dateValidationRefinements.startBeforeEnd.message,
1056
+ });
1057
+ //# sourceMappingURL=schemas.js.map