een-api-toolkit 0.3.47 → 0.3.49

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 (42) hide show
  1. package/.claude/agents/een-jobs-agent.md +676 -0
  2. package/CHANGELOG.md +7 -8
  3. package/dist/index.cjs +3 -3
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.ts +1172 -28
  6. package/dist/index.js +796 -333
  7. package/dist/index.js.map +1 -1
  8. package/docs/AI-CONTEXT.md +22 -1
  9. package/docs/ai-reference/AI-AUTH.md +1 -1
  10. package/docs/ai-reference/AI-AUTOMATIONS.md +1 -1
  11. package/docs/ai-reference/AI-DEVICES.md +1 -1
  12. package/docs/ai-reference/AI-EVENTS.md +1 -1
  13. package/docs/ai-reference/AI-GROUPING.md +1 -1
  14. package/docs/ai-reference/AI-JOBS.md +1084 -0
  15. package/docs/ai-reference/AI-MEDIA.md +1 -1
  16. package/docs/ai-reference/AI-SETUP.md +1 -1
  17. package/docs/ai-reference/AI-USERS.md +1 -1
  18. package/examples/vue-jobs/.env.example +11 -0
  19. package/examples/vue-jobs/README.md +245 -0
  20. package/examples/vue-jobs/e2e/app.spec.ts +79 -0
  21. package/examples/vue-jobs/e2e/auth.spec.ts +382 -0
  22. package/examples/vue-jobs/e2e/delete-features.spec.ts +564 -0
  23. package/examples/vue-jobs/e2e/timelapse.spec.ts +361 -0
  24. package/examples/vue-jobs/index.html +13 -0
  25. package/examples/vue-jobs/package-lock.json +1722 -0
  26. package/examples/vue-jobs/package.json +28 -0
  27. package/examples/vue-jobs/playwright.config.ts +47 -0
  28. package/examples/vue-jobs/src/App.vue +154 -0
  29. package/examples/vue-jobs/src/main.ts +25 -0
  30. package/examples/vue-jobs/src/router/index.ts +82 -0
  31. package/examples/vue-jobs/src/views/Callback.vue +76 -0
  32. package/examples/vue-jobs/src/views/CreateExport.vue +284 -0
  33. package/examples/vue-jobs/src/views/Files.vue +424 -0
  34. package/examples/vue-jobs/src/views/Home.vue +195 -0
  35. package/examples/vue-jobs/src/views/JobDetail.vue +392 -0
  36. package/examples/vue-jobs/src/views/Jobs.vue +297 -0
  37. package/examples/vue-jobs/src/views/Login.vue +33 -0
  38. package/examples/vue-jobs/src/views/Logout.vue +59 -0
  39. package/examples/vue-jobs/src/vite-env.d.ts +1 -0
  40. package/examples/vue-jobs/tsconfig.json +25 -0
  41. package/examples/vue-jobs/vite.config.ts +12 -0
  42. package/package.json +1 -1
@@ -0,0 +1,1084 @@
1
+ # Jobs, Exports, Files & Downloads - EEN API Toolkit
2
+
3
+ > **Version:** 0.3.49
4
+ >
5
+ > Complete reference for async jobs, video exports, and file management.
6
+ > Load this document when implementing export workflows or file downloads.
7
+
8
+ ---
9
+
10
+ ## Overview
11
+
12
+ | Concept | Description |
13
+ |---------|-------------|
14
+ | **Export Jobs** | Create video/timelapse exports from camera recordings |
15
+ | **Jobs** | Track async job progress (pending, started, success, failure) |
16
+ | **Files** | Access files created by completed export jobs |
17
+ | **Downloads** | Access downloadable content |
18
+
19
+ ### Export Workflow
20
+
21
+ ```
22
+ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────────┐
23
+ │ createExportJob │────▶│ Poll with │────▶│ downloadFile │
24
+ │ (camera, time) │ │ getJob() │ │ (extract fileId from URL) │
25
+ └─────────────────┘ └─────────────────┘ └─────────────────────────────┘
26
+
27
+
28
+ Job States:
29
+ pending → started → success/failure
30
+
31
+ When job.state === 'success':
32
+ File URL at: job.result?.intervals?.[0]?.files?.[0]?.url
33
+ Extract fileId from URL (last path segment)
34
+ ```
35
+
36
+ ---
37
+
38
+ ## Job Types
39
+
40
+ ```typescript
41
+ type JobState = 'pending' | 'started' | 'success' | 'failure' | 'revoked'
42
+
43
+ // Nested structures for Job response
44
+ interface JobResultFile {
45
+ name: string
46
+ path?: string
47
+ size?: number
48
+ startTimestamp?: string
49
+ endTimestamp?: string
50
+ url?: string // Download URL - extract fileId from last segment
51
+ checksum?: string
52
+ }
53
+
54
+ interface JobResultInterval {
55
+ startTimestamp?: string
56
+ endTimestamp?: string
57
+ state?: string
58
+ files?: JobResultFile[]
59
+ error?: string | null
60
+ }
61
+
62
+ interface JobResult {
63
+ state?: string
64
+ error?: string | null
65
+ intervals?: JobResultInterval[]
66
+ }
67
+
68
+ interface JobOriginalRequest {
69
+ type?: string
70
+ name?: string // Job display name set at creation
71
+ directory?: string
72
+ startTimestamp?: string
73
+ endTimestamp?: string
74
+ notes?: string | null
75
+ tags?: string[] | null
76
+ }
77
+
78
+ interface JobArguments {
79
+ deviceId?: string
80
+ originalRequest?: JobOriginalRequest
81
+ }
82
+
83
+ interface Job {
84
+ id: string
85
+ namespace?: string
86
+ type: string // 'video' | 'timeLapse' | 'bundle'
87
+ userId: string
88
+ state: JobState
89
+ detailedState?: string | null
90
+ progress?: number // 0-1 float (multiply by 100 for percentage)
91
+ error?: string | null // Set when job fails
92
+ arguments?: JobArguments // Contains originalRequest with name
93
+ result?: JobResult // Contains intervals with file URLs
94
+ createTimestamp: string
95
+ updateTimestamp?: string
96
+ expireTimestamp?: string
97
+ scheduleTimestamp?: string | null
98
+ }
99
+
100
+ // Access nested fields:
101
+ // - Job name: job.arguments?.originalRequest?.name
102
+ // - File URL: job.result?.intervals?.[0]?.files?.[0]?.url
103
+ // - Request timestamps: job.arguments?.originalRequest?.startTimestamp
104
+
105
+ interface ListJobsParams {
106
+ pageSize?: number
107
+ pageToken?: string
108
+ state__in?: JobState[] // Filter by state
109
+ type__in?: string[] // Filter by job type
110
+ userId?: string // Filter by user
111
+ createTimestamp__gte?: string // Filter by creation time
112
+ createTimestamp__lte?: string
113
+ sort?: string[] // Sort fields
114
+ }
115
+
116
+ interface GetJobParams {
117
+ include?: string[]
118
+ }
119
+ ```
120
+
121
+ ---
122
+
123
+ ## Export Types
124
+
125
+ ```typescript
126
+ type ExportType = 'bundle' | 'timeLapse' | 'video'
127
+
128
+ interface CreateExportParams {
129
+ type: ExportType // Required
130
+ cameraId: string // Required
131
+ startTimestamp: string // Required: ISO 8601 with +00:00 timezone
132
+ endTimestamp: string // Required: ISO 8601 with +00:00 timezone
133
+ name?: string // Optional display name
134
+ playbackMultiplier?: number // Required for timeLapse/bundle (1-48)
135
+ autoDelete?: boolean // Auto-delete after 2 weeks (default: false)
136
+ directory?: string // Archive directory (default: '/')
137
+ notes?: string // Optional notes
138
+ tags?: string[] // Optional tags
139
+ }
140
+
141
+ interface ExportJobResponse {
142
+ id: string
143
+ type: ExportType
144
+ name?: string
145
+ state: JobState
146
+ createTimestamp: string
147
+ }
148
+ ```
149
+
150
+ ---
151
+
152
+ ## File Types
153
+
154
+ ```typescript
155
+ type FileType = 'video' | 'image' | 'bundle' | 'timeLapse' | 'other'
156
+
157
+ interface EenFile {
158
+ id: string
159
+ name: string
160
+ type?: FileType
161
+ sizeBytes?: number
162
+ contentType?: string
163
+ createTimestamp: string
164
+ }
165
+
166
+ interface ListFilesParams {
167
+ pageSize?: number
168
+ pageToken?: string
169
+ type__in?: FileType[]
170
+ name__contains?: string
171
+ }
172
+
173
+ interface DownloadFileResult {
174
+ blob: Blob // Binary file data
175
+ filename: string // Parsed from Content-Disposition
176
+ contentType: string // MIME type
177
+ size: number // File size in bytes
178
+ }
179
+ ```
180
+
181
+ ---
182
+
183
+ ## Download Types
184
+
185
+ ```typescript
186
+ type DownloadStatus = 'available' | 'expired' | 'pending' | 'error'
187
+
188
+ interface Download {
189
+ id: string
190
+ name?: string
191
+ status: DownloadStatus
192
+ sizeBytes?: number
193
+ contentType?: string
194
+ downloadUrl?: string
195
+ expiresAt?: string
196
+ createTimestamp: string
197
+ }
198
+
199
+ interface ListDownloadsParams {
200
+ pageSize?: number
201
+ pageToken?: string
202
+ status__in?: DownloadStatus[]
203
+ name__contains?: string
204
+ }
205
+
206
+ interface DownloadDownloadResult {
207
+ blob: Blob
208
+ filename: string
209
+ contentType: string
210
+ size: number
211
+ }
212
+ ```
213
+
214
+ ---
215
+
216
+ ## Export Functions
217
+
218
+ ### createExportJob(params)
219
+
220
+ Create a video export from a camera:
221
+
222
+ ```typescript
223
+ import { createExportJob, formatTimestamp, type ExportType } from 'een-api-toolkit'
224
+
225
+ async function createExport(cameraId: string, durationMinutes: number = 15) {
226
+ const endTime = new Date()
227
+ const startTime = new Date(endTime.getTime() - durationMinutes * 60 * 1000)
228
+
229
+ const { data, error } = await createExportJob({
230
+ name: `Export - ${new Date().toLocaleString()}`,
231
+ type: 'video',
232
+ cameraId,
233
+ startTimestamp: formatTimestamp(startTime.toISOString()),
234
+ endTimestamp: formatTimestamp(endTime.toISOString())
235
+ })
236
+
237
+ if (error) {
238
+ console.error('Failed to create export:', error.message)
239
+ return null
240
+ }
241
+
242
+ console.log('Export job created:', data.id)
243
+ return data
244
+ }
245
+ ```
246
+
247
+ ---
248
+
249
+ ## Job Functions
250
+
251
+ ### listJobs(params?)
252
+
253
+ List jobs with optional state filtering:
254
+
255
+ ```typescript
256
+ import { listJobs, type Job, type JobState } from 'een-api-toolkit'
257
+
258
+ const { data, error } = await listJobs({
259
+ pageSize: 20,
260
+ state__in: ['pending', 'started', 'success']
261
+ })
262
+
263
+ if (data) {
264
+ data.results.forEach(job => {
265
+ const progressPercent = Math.round((job.progress || 0) * 100)
266
+ console.log(`${job.name}: ${job.state} (${progressPercent}%)`)
267
+ })
268
+ }
269
+ ```
270
+
271
+ ### getJob(jobId, params?)
272
+
273
+ Get a single job with polling pattern:
274
+
275
+ ```typescript
276
+ import { ref, onUnmounted } from 'vue'
277
+ import { getJob, type Job } from 'een-api-toolkit'
278
+
279
+ const job = ref<Job | null>(null)
280
+ let pollInterval: ReturnType<typeof setInterval> | null = null
281
+
282
+ async function fetchJob(jobId: string) {
283
+ const { data, error } = await getJob(jobId)
284
+
285
+ if (error) {
286
+ stopPolling()
287
+ return
288
+ }
289
+
290
+ job.value = data
291
+
292
+ // Auto-manage polling based on job state
293
+ if (['pending', 'started'].includes(data.state)) {
294
+ startPolling(jobId)
295
+ } else {
296
+ stopPolling()
297
+ }
298
+ }
299
+
300
+ function startPolling(jobId: string) {
301
+ if (pollInterval) return
302
+ pollInterval = setInterval(() => fetchJob(jobId), 3000)
303
+ }
304
+
305
+ function stopPolling() {
306
+ if (pollInterval) {
307
+ clearInterval(pollInterval)
308
+ pollInterval = null
309
+ }
310
+ }
311
+
312
+ onUnmounted(() => stopPolling())
313
+ ```
314
+
315
+ ---
316
+
317
+ ## File Functions
318
+
319
+ ### listFiles(params?)
320
+
321
+ List available files:
322
+
323
+ ```typescript
324
+ import { listFiles, type EenFile } from 'een-api-toolkit'
325
+
326
+ const { data, error } = await listFiles({
327
+ pageSize: 20
328
+ })
329
+
330
+ if (data) {
331
+ data.results.forEach(file => {
332
+ console.log(`${file.name} (${file.sizeBytes} bytes)`)
333
+ })
334
+ }
335
+ ```
336
+
337
+ ### downloadFile(fileId)
338
+
339
+ Download a file by ID:
340
+
341
+ ```typescript
342
+ import { downloadFile, type EenFile } from 'een-api-toolkit'
343
+
344
+ async function handleDownload(file: EenFile) {
345
+ const { data, error } = await downloadFile(file.id)
346
+
347
+ if (error) {
348
+ console.error('Download failed:', error.message)
349
+ return
350
+ }
351
+
352
+ // Create browser download
353
+ const url = URL.createObjectURL(data.blob)
354
+ const a = document.createElement('a')
355
+ a.href = url
356
+ a.download = data.filename || file.name
357
+ document.body.appendChild(a)
358
+ a.click()
359
+ document.body.removeChild(a)
360
+ URL.revokeObjectURL(url)
361
+ }
362
+ ```
363
+
364
+ ---
365
+
366
+ ## Download Functions
367
+
368
+ ### listDownloads(params?)
369
+
370
+ List available downloads:
371
+
372
+ ```typescript
373
+ import { listDownloads, type Download } from 'een-api-toolkit'
374
+
375
+ const { data, error } = await listDownloads({
376
+ status__in: ['available'],
377
+ pageSize: 20
378
+ })
379
+ ```
380
+
381
+ ### downloadDownload(downloadId)
382
+
383
+ Download from downloads endpoint:
384
+
385
+ ```typescript
386
+ import { downloadDownload, type Download } from 'een-api-toolkit'
387
+
388
+ async function handleDownload(download: Download) {
389
+ const { data, error } = await downloadDownload(download.id)
390
+
391
+ if (error) {
392
+ console.error('Download failed:', error.message)
393
+ return
394
+ }
395
+
396
+ const url = URL.createObjectURL(data.blob)
397
+ const a = document.createElement('a')
398
+ a.href = url
399
+ a.download = data.filename || download.name || 'download'
400
+ a.click()
401
+ URL.revokeObjectURL(url)
402
+ }
403
+ ```
404
+
405
+ ---
406
+
407
+ ## Complete Export Workflow
408
+
409
+ ```typescript
410
+ import { ref, onUnmounted } from 'vue'
411
+ import {
412
+ createExportJob,
413
+ getJob,
414
+ downloadFile,
415
+ formatTimestamp,
416
+ type Job
417
+ } from 'een-api-toolkit'
418
+
419
+ const job = ref<Job | null>(null)
420
+ const error = ref<string | null>(null)
421
+ let pollInterval: ReturnType<typeof setInterval> | null = null
422
+
423
+ async function startExport(cameraId: string, durationMinutes: number) {
424
+ const endTime = new Date()
425
+ const startTime = new Date(endTime.getTime() - durationMinutes * 60 * 1000)
426
+
427
+ const result = await createExportJob({
428
+ type: 'video',
429
+ cameraId,
430
+ startTimestamp: formatTimestamp(startTime.toISOString()),
431
+ endTimestamp: formatTimestamp(endTime.toISOString())
432
+ })
433
+
434
+ if (result.error) {
435
+ error.value = result.error.message
436
+ return
437
+ }
438
+
439
+ job.value = result.data
440
+ pollInterval = setInterval(() => pollJob(result.data.id), 3000)
441
+ }
442
+
443
+ async function pollJob(jobId: string) {
444
+ const result = await getJob(jobId)
445
+ if (result.error) {
446
+ error.value = result.error.message
447
+ stopPolling()
448
+ return
449
+ }
450
+
451
+ job.value = result.data
452
+
453
+ if (!['pending', 'started'].includes(result.data.state)) {
454
+ stopPolling()
455
+ }
456
+ }
457
+
458
+ function stopPolling() {
459
+ if (pollInterval) {
460
+ clearInterval(pollInterval)
461
+ pollInterval = null
462
+ }
463
+ }
464
+
465
+ async function downloadExport() {
466
+ // Extract file URL from job result
467
+ const fileUrl = job.value?.result?.intervals?.[0]?.files?.[0]?.url
468
+ if (!fileUrl) return
469
+
470
+ // Extract fileId from URL (last path segment)
471
+ const fileId = fileUrl.substring(fileUrl.lastIndexOf('/') + 1)
472
+ if (!fileId) return
473
+
474
+ const result = await downloadFile(fileId)
475
+ if (result.error) {
476
+ error.value = result.error.message
477
+ return
478
+ }
479
+
480
+ const url = URL.createObjectURL(result.data.blob)
481
+ const a = document.createElement('a')
482
+ a.href = url
483
+ a.download = result.data.filename || `export-${job.value?.id}.mp4`
484
+ document.body.appendChild(a)
485
+ a.click()
486
+ document.body.removeChild(a)
487
+ URL.revokeObjectURL(url)
488
+ }
489
+
490
+ onUnmounted(() => stopPolling())
491
+ ```
492
+
493
+ ---
494
+
495
+ ## Vue Component Examples
496
+
497
+ ### Jobs.vue
498
+
499
+ ```typescript
500
+ import { ref, computed, onMounted } from 'vue'
501
+ import { listJobs, getCurrentUser, deleteJob, type Job, type EenError, type ListJobsParams, type JobState } from 'een-api-toolkit'
502
+ import { useRouter } from 'vue-router'
503
+
504
+ const router = useRouter()
505
+
506
+ // Reactive state
507
+ const jobs = ref<Job[]>([])
508
+ const loading = ref(false)
509
+ const error = ref<EenError | null>(null)
510
+ const nextPageToken = ref<string | undefined>(undefined)
511
+ const currentUserId = ref<string | null>(null)
512
+ const deletingId = ref<string | null>(null)
513
+
514
+ const hasNextPage = computed(() => !!nextPageToken.value)
515
+
516
+ // Filter state
517
+ const selectedStates = ref<JobState[]>([])
518
+ const stateOptions: { value: JobState; label: string }[] = [
519
+ { value: 'pending', label: 'Pending' },
520
+ { value: 'started', label: 'Started' },
521
+ { value: 'success', label: 'Success' },
522
+ { value: 'failure', label: 'Failure' },
523
+ { value: 'revoked', label: 'Revoked' }
524
+ ]
525
+
526
+ const params = ref<ListJobsParams>({ pageSize: 20 })
527
+
528
+ async function fetchJobs(fetchParams?: ListJobsParams, append = false) {
529
+ loading.value = true
530
+ error.value = null
531
+
532
+ // Get current user ID if not already fetched
533
+ if (!currentUserId.value) {
534
+ const userResult = await getCurrentUser()
535
+ if (userResult.error) {
536
+ error.value = userResult.error
537
+ loading.value = false
538
+ return { error: userResult.error, data: null }
539
+ }
540
+ currentUserId.value = userResult.data.id
541
+ }
542
+
543
+ const mergedParams: ListJobsParams = { ...params.value, ...fetchParams, userId: currentUserId.value }
544
+
545
+ // Add state filter if selected
546
+ if (selectedStates.value.length > 0) {
547
+ mergedParams.state__in = selectedStates.value
548
+ }
549
+
550
+ const result = await listJobs(mergedParams)
551
+
552
+ if (result.error) {
553
+ error.value = result.error
554
+ if (!append) {
555
+ jobs.value = []
556
+ }
557
+ nextPageToken.value = undefined
558
+ } else {
559
+ if (append) {
560
+ jobs.value = [...jobs.value, ...result.data.results]
561
+ } else {
562
+ jobs.value = result.data.results
563
+ }
564
+ nextPageToken.value = result.data.nextPageToken
565
+ }
566
+
567
+ loading.value = false
568
+ return result
569
+ }
570
+
571
+ function refresh() {
572
+ return fetchJobs()
573
+ }
574
+
575
+ async function fetchNextPage() {
576
+ if (!nextPageToken.value) return
577
+ return fetchJobs({ ...params.value, pageToken: nextPageToken.value }, true)
578
+ }
579
+
580
+ function viewJob(jobId: string) {
581
+ router.push(`/jobs/${jobId}`)
582
+ }
583
+
584
+ async function handleDelete(job: Job) {
585
+ const jobName = getJobName(job)
586
+ if (!confirm(`Are you sure you want to delete job "${jobName}"?`)) {
587
+ return
588
+ }
589
+
590
+ deletingId.value = job.id
591
+ const result = await deleteJob(job.id)
592
+
593
+ if (result.error) {
594
+ error.value = result.error
595
+ } else {
596
+ // Remove the job from the list
597
+ jobs.value = jobs.value.filter(j => j.id !== job.id)
598
+ }
599
+
600
+ deletingId.value = null
601
+ }
602
+
603
+ function formatDate(timestamp: string) {
604
+ return new Date(timestamp).toLocaleString()
605
+ }
606
+
607
+ function getStateBadgeClass(state: JobState) {
608
+ return `badge badge-${state}`
609
+ }
610
+
611
+ function getJobName(job: Job) {
612
+ return job.arguments?.originalRequest?.name || job.id
613
+ }
614
+
615
+ onMounted(() => {
616
+ fetchJobs()
617
+ })
618
+ ```
619
+
620
+ ### JobDetail.vue
621
+
622
+ ```typescript
623
+ import { ref, onMounted, onUnmounted, computed } from 'vue'
624
+ import { useRoute, useRouter } from 'vue-router'
625
+ import { getJob, downloadFile, type Job, type EenError } from 'een-api-toolkit'
626
+
627
+ const route = useRoute()
628
+ const router = useRouter()
629
+
630
+ const job = ref<Job | null>(null)
631
+ const loading = ref(false)
632
+ const error = ref<EenError | null>(null)
633
+ const downloading = ref(false)
634
+ const videoUrl = ref<string | null>(null)
635
+ const loadingVideo = ref(false)
636
+
637
+ // Polling state
638
+ const isPolling = ref(false)
639
+ let pollInterval: ReturnType<typeof setInterval> | null = null
640
+
641
+ const jobId = computed(() => route.params.id as string)
642
+
643
+ // Extract name from nested structure
644
+ const jobName = computed(() => {
645
+ return job.value?.arguments?.originalRequest?.name || '-'
646
+ })
647
+
648
+ // Extract file URL from result (for download)
649
+ const fileUrl = computed(() => {
650
+ return job.value?.result?.intervals?.[0]?.files?.[0]?.url
651
+ })
652
+
653
+ // Extract request timestamps from arguments
654
+ const requestStartTimestamp = computed(() => {
655
+ return job.value?.arguments?.originalRequest?.startTimestamp
656
+ })
657
+
658
+ const requestEndTimestamp = computed(() => {
659
+ return job.value?.arguments?.originalRequest?.endTimestamp
660
+ })
661
+
662
+ const canDownload = computed(() => {
663
+ return job.value?.state === 'success' && fileUrl.value
664
+ })
665
+
666
+ const canPlay = computed(() => {
667
+ // Can play video exports
668
+ return canDownload.value && (job.value?.type === 'video' || job.value?.type === 'export')
669
+ })
670
+
671
+ async function fetchJob() {
672
+ loading.value = true
673
+ error.value = null
674
+
675
+ const result = await getJob(jobId.value)
676
+
677
+ if (result.error) {
678
+ error.value = result.error
679
+ job.value = null
680
+ stopPolling()
681
+ } else {
682
+ job.value = result.data
683
+
684
+ // Auto-start polling if job is pending or started
685
+ if (['pending', 'started'].includes(result.data.state) && !isPolling.value) {
686
+ startPolling()
687
+ }
688
+
689
+ // Stop polling if job is complete
690
+ if (['success', 'failure', 'revoked'].includes(result.data.state)) {
691
+ stopPolling()
692
+ }
693
+ }
694
+
695
+ loading.value = false
696
+ }
697
+
698
+ function startPolling() {
699
+ if (isPolling.value) return
700
+ isPolling.value = true
701
+ pollInterval = setInterval(fetchJob, 3000) // Poll every 3 seconds
702
+ }
703
+
704
+ function stopPolling() {
705
+ if (pollInterval) {
706
+ clearInterval(pollInterval)
707
+ pollInterval = null
708
+ }
709
+ isPolling.value = false
710
+ }
711
+
712
+ async function handleDownload() {
713
+ const url = fileUrl.value
714
+ if (!url) return
715
+
716
+ // Extract file ID from URL (last path segment)
717
+ const urlParts = url.split('/')
718
+ const fileId = urlParts[urlParts.length - 1]
719
+
720
+ downloading.value = true
721
+ const result = await downloadFile(fileId)
722
+
723
+ if (result.error) {
724
+ error.value = result.error
725
+ } else {
726
+ // Create download link
727
+ const blobUrl = URL.createObjectURL(result.data.blob)
728
+ const a = document.createElement('a')
729
+ a.href = blobUrl
730
+ a.download = result.data.filename
731
+ a.click()
732
+ URL.revokeObjectURL(blobUrl)
733
+ }
734
+
735
+ downloading.value = false
736
+ }
737
+
738
+ async function handlePlay() {
739
+ const url = fileUrl.value
740
+ if (!url) return
741
+
742
+ // Extract file ID from URL (last path segment)
743
+ const urlParts = url.split('/')
744
+ const fileId = urlParts[urlParts.length - 1]
745
+
746
+ loadingVideo.value = true
747
+ error.value = null
748
+
749
+ const result = await downloadFile(fileId)
750
+
751
+ if (result.error) {
752
+ error.value = result.error
753
+ } else {
754
+ // Revoke previous URL if exists
755
+ if (videoUrl.value) {
756
+ URL.revokeObjectURL(videoUrl.value)
757
+ }
758
+ // Create blob URL for video playback
759
+ videoUrl.value = URL.createObjectURL(result.data.blob)
760
+ }
761
+
762
+ loadingVideo.value = false
763
+ }
764
+
765
+ function closePlayer() {
766
+ if (videoUrl.value) {
767
+ URL.revokeObjectURL(videoUrl.value)
768
+ videoUrl.value = null
769
+ }
770
+ }
771
+
772
+ function goBack() {
773
+ router.push('/jobs')
774
+ }
775
+
776
+ function formatDate(timestamp: string | undefined) {
777
+ if (!timestamp) return '-'
778
+ return new Date(timestamp).toLocaleString()
779
+ }
780
+
781
+ function getStateBadgeClass(state: string) {
782
+ return `badge badge-${state}`
783
+ }
784
+
785
+ onMounted(() => {
786
+ fetchJob()
787
+ })
788
+
789
+ onUnmounted(() => {
790
+ stopPolling()
791
+ if (videoUrl.value) {
792
+ URL.revokeObjectURL(videoUrl.value)
793
+ }
794
+ })
795
+ ```
796
+
797
+ ### CreateExport.vue
798
+
799
+ ```typescript
800
+ import { ref, onMounted, computed } from 'vue'
801
+ import { useRouter } from 'vue-router'
802
+ import { getCameras, createExportJob, formatTimestamp, type Camera, type EenError, type ExportType } from 'een-api-toolkit'
803
+
804
+ const router = useRouter()
805
+
806
+ // Form state
807
+ const selectedCamera = ref<string>('')
808
+ const exportType = ref<ExportType>('video')
809
+ const exportName = ref('')
810
+ const duration = ref(15) // minutes
811
+ const playbackMultiplier = ref(10) // default multiplier for timeLapse/bundle
812
+
813
+ // Check if playbackMultiplier is needed
814
+ const needsPlaybackMultiplier = computed(() => {
815
+ return exportType.value === 'timeLapse' || exportType.value === 'bundle'
816
+ })
817
+
818
+ // Data state
819
+ const cameras = ref<Camera[]>([])
820
+ const loading = ref(false)
821
+ const submitting = ref(false)
822
+ const error = ref<EenError | null>(null)
823
+ const success = ref<string | null>(null)
824
+
825
+ const exportTypes: { value: ExportType; label: string }[] = [
826
+ { value: 'video', label: 'Video' },
827
+ { value: 'timeLapse', label: 'Time Lapse' },
828
+ { value: 'bundle', label: 'Bundle (Video + Images)' }
829
+ ]
830
+
831
+ const durationOptions = [
832
+ { value: 5, label: '5 minutes' },
833
+ { value: 15, label: '15 minutes' },
834
+ { value: 30, label: '30 minutes' },
835
+ { value: 60, label: '1 hour' }
836
+ ]
837
+
838
+ const playbackMultiplierOptions = [
839
+ { value: 2, label: '2x (30 min → 15 min)' },
840
+ { value: 5, label: '5x (30 min → 6 min)' },
841
+ { value: 10, label: '10x (30 min → 3 min)' },
842
+ { value: 20, label: '20x (30 min → 1.5 min)' },
843
+ { value: 48, label: '48x (30 min → 37 sec)' }
844
+ ]
845
+
846
+ const canSubmit = computed(() => {
847
+ if (!selectedCamera.value || !exportType.value || submitting.value) {
848
+ return false
849
+ }
850
+ // Validate playbackMultiplier for timeLapse/bundle
851
+ if (needsPlaybackMultiplier.value) {
852
+ return playbackMultiplier.value >= 1 && playbackMultiplier.value <= 48
853
+ }
854
+ return true
855
+ })
856
+
857
+ async function fetchCameras() {
858
+ loading.value = true
859
+ error.value = null
860
+
861
+ const result = await getCameras({
862
+ pageSize: 100,
863
+ status__in: ['online', 'streaming']
864
+ })
865
+
866
+ if (result.error) {
867
+ error.value = result.error
868
+ } else {
869
+ cameras.value = result.data.results
870
+ }
871
+
872
+ loading.value = false
873
+ }
874
+
875
+ async function handleSubmit() {
876
+ if (!canSubmit.value) return
877
+
878
+ submitting.value = true
879
+ error.value = null
880
+ success.value = null
881
+
882
+ const endTime = new Date()
883
+ const startTime = new Date(endTime.getTime() - duration.value * 60 * 1000)
884
+
885
+ const exportParams: Parameters<typeof createExportJob>[0] = {
886
+ name: exportName.value || `Export - ${new Date().toLocaleString()}`,
887
+ type: exportType.value,
888
+ cameraId: selectedCamera.value,
889
+ startTimestamp: formatTimestamp(startTime.toISOString()),
890
+ endTimestamp: formatTimestamp(endTime.toISOString())
891
+ }
892
+
893
+ // Add playbackMultiplier for timeLapse and bundle exports
894
+ if (needsPlaybackMultiplier.value) {
895
+ exportParams.playbackMultiplier = playbackMultiplier.value
896
+ }
897
+
898
+ const result = await createExportJob(exportParams)
899
+
900
+ if (result.error) {
901
+ error.value = result.error
902
+ } else {
903
+ success.value = `Export job created successfully! Job ID: ${result.data.id}`
904
+ // Redirect to job detail page after a moment
905
+ setTimeout(() => {
906
+ router.push(`/jobs/${result.data.id}`)
907
+ }, 2000)
908
+ }
909
+
910
+ submitting.value = false
911
+ }
912
+
913
+ onMounted(() => {
914
+ fetchCameras()
915
+ })
916
+ ```
917
+
918
+ ### Files.vue
919
+
920
+ ```typescript
921
+ import { ref, computed, onMounted } from 'vue'
922
+ import { listFiles, downloadFile, deleteFile, type EenFile, type EenError, type ListFilesParams, type FileIncludeField } from 'een-api-toolkit'
923
+
924
+ // Reactive state
925
+ const files = ref<EenFile[]>([])
926
+ const loading = ref(false)
927
+ const error = ref<EenError | null>(null)
928
+ const nextPageToken = ref<string | undefined>(undefined)
929
+ const downloadingId = ref<string | null>(null)
930
+ const deletingId = ref<string | null>(null)
931
+ const selectedFile = ref<EenFile | null>(null)
932
+
933
+ const hasNextPage = computed(() => !!nextPageToken.value)
934
+
935
+ // Request size and createTimestamp by default for better display
936
+ const defaultInclude: FileIncludeField[] = ['size', 'createTimestamp']
937
+
938
+ const params = ref<ListFilesParams>({
939
+ pageSize: 20,
940
+ include: defaultInclude,
941
+ sort: ['-createTimestamp']
942
+ })
943
+
944
+ async function fetchFiles(fetchParams?: ListFilesParams, append = false) {
945
+ loading.value = true
946
+ error.value = null
947
+
948
+ const mergedParams = { ...params.value, ...fetchParams }
949
+ const result = await listFiles(mergedParams)
950
+
951
+ if (result.error) {
952
+ error.value = result.error
953
+ if (!append) {
954
+ files.value = []
955
+ }
956
+ nextPageToken.value = undefined
957
+ } else {
958
+ if (append) {
959
+ files.value = [...files.value, ...result.data.results]
960
+ } else {
961
+ files.value = result.data.results
962
+ }
963
+ nextPageToken.value = result.data.nextPageToken
964
+ }
965
+
966
+ loading.value = false
967
+ return result
968
+ }
969
+
970
+ function refresh() {
971
+ return fetchFiles()
972
+ }
973
+
974
+ async function fetchNextPage() {
975
+ if (!nextPageToken.value) return
976
+ return fetchFiles({ ...params.value, pageToken: nextPageToken.value }, true)
977
+ }
978
+
979
+ async function handleDownload(file: EenFile) {
980
+ downloadingId.value = file.id
981
+ const result = await downloadFile(file.id)
982
+
983
+ if (result.error) {
984
+ error.value = result.error
985
+ } else {
986
+ // Create download link
987
+ const url = URL.createObjectURL(result.data.blob)
988
+ const a = document.createElement('a')
989
+ a.href = url
990
+ a.download = result.data.filename || file.name
991
+ a.click()
992
+ URL.revokeObjectURL(url)
993
+ }
994
+
995
+ downloadingId.value = null
996
+ }
997
+
998
+ async function handleDelete(file: EenFile) {
999
+ if (!confirm(`Are you sure you want to delete "${file.name}"? This will move it to the recycle bin.`)) {
1000
+ return
1001
+ }
1002
+
1003
+ deletingId.value = file.id
1004
+ const result = await deleteFile(file.id)
1005
+
1006
+ if (result.error) {
1007
+ error.value = result.error
1008
+ } else {
1009
+ // Remove the file from the list
1010
+ files.value = files.value.filter(f => f.id !== file.id)
1011
+ }
1012
+
1013
+ deletingId.value = null
1014
+ }
1015
+
1016
+ function formatDate(timestamp: string | undefined) {
1017
+ if (!timestamp) return '-'
1018
+ return new Date(timestamp).toLocaleString()
1019
+ }
1020
+
1021
+ function getFileType(file: EenFile) {
1022
+ // Use type if available, otherwise derive from mimeType
1023
+ if (file.type) return file.type
1024
+ if (file.mimeType) {
1025
+ if (file.mimeType.startsWith('video/')) return 'video'
1026
+ if (file.mimeType.startsWith('image/')) return 'image'
1027
+ if (file.mimeType === 'application/directory') return 'folder'
1028
+ return file.mimeType.split('/')[1] || file.mimeType
1029
+ }
1030
+ return '-'
1031
+ }
1032
+
1033
+ function formatSize(bytes: number | undefined) {
1034
+ if (bytes === undefined || bytes === null) return '-'
1035
+ if (bytes === 0) return '0 B'
1036
+ if (bytes < 1024) return `${bytes} B`
1037
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
1038
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
1039
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
1040
+ }
1041
+
1042
+ function openFileDetails(file: EenFile) {
1043
+ selectedFile.value = file
1044
+ }
1045
+
1046
+ function closeModal() {
1047
+ selectedFile.value = null
1048
+ }
1049
+
1050
+ onMounted(() => {
1051
+ fetchFiles()
1052
+ })
1053
+ ```
1054
+
1055
+ ---
1056
+
1057
+ ## Error Handling
1058
+
1059
+ | Error Code | HTTP Status | Meaning | Action |
1060
+ |------------|-------------|---------|--------|
1061
+ | AUTH_REQUIRED | 401 | Not authenticated | Redirect to login |
1062
+ | CAMERA_NOT_FOUND | 404 | Invalid camera ID | Verify camera exists |
1063
+ | INVALID_TIMESTAMP | 400 | Bad timestamp format | Use formatTimestamp() |
1064
+ | JOB_NOT_FOUND | 404 | Job ID doesn't exist | Check job ID |
1065
+ | FILE_NOT_FOUND | 404 | File ID doesn't exist | Job may not be complete |
1066
+ | DOWNLOAD_EXPIRED | 410 | Download link expired | Request new download |
1067
+ | EXPORT_FAILED | 500 | Export processing failed | Check job.error |
1068
+
1069
+ ---
1070
+
1071
+ ## Best Practices
1072
+
1073
+ 1. **Always use formatTimestamp()** for timestamp parameters
1074
+ 2. **Poll at reasonable intervals** (3-5 seconds recommended)
1075
+ 3. **Clean up polling** on component unmount
1076
+ 4. **Check job.state === 'success'** before attempting download
1077
+ 5. **Extract fileId from job.result URL** when calling downloadFile()
1078
+ 6. **Handle large downloads** appropriately (show progress)
1079
+
1080
+ ---
1081
+
1082
+ ## Reference Examples
1083
+
1084
+ - `examples/vue-jobs/` - Complete jobs, exports, files example