@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
package/dist/schemas.js
ADDED
|
@@ -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
|