een-api-toolkit 0.3.16 → 0.3.22
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/.claude/agents/docs-accuracy-reviewer.md +146 -0
- package/.claude/agents/een-auth-agent.md +168 -0
- package/.claude/agents/een-devices-agent.md +294 -0
- package/.claude/agents/een-events-agent.md +375 -0
- package/.claude/agents/een-media-agent.md +256 -0
- package/.claude/agents/een-setup-agent.md +126 -0
- package/.claude/agents/een-users-agent.md +239 -0
- package/.claude/agents/test-runner.md +144 -0
- package/CHANGELOG.md +151 -10
- package/README.md +1 -0
- package/dist/index.cjs +3 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +561 -0
- package/dist/index.js +483 -260
- package/dist/index.js.map +1 -1
- package/docs/AI-CONTEXT.md +128 -1648
- package/docs/ai-reference/AI-AUTH.md +288 -0
- package/docs/ai-reference/AI-DEVICES.md +569 -0
- package/docs/ai-reference/AI-EVENTS.md +1745 -0
- package/docs/ai-reference/AI-MEDIA.md +974 -0
- package/docs/ai-reference/AI-SETUP.md +267 -0
- package/docs/ai-reference/AI-USERS.md +255 -0
- package/examples/vue-event-subscriptions/.env.example +15 -0
- package/examples/vue-event-subscriptions/README.md +103 -0
- package/examples/vue-event-subscriptions/e2e/app.spec.ts +71 -0
- package/examples/vue-event-subscriptions/e2e/auth.spec.ts +290 -0
- package/examples/vue-event-subscriptions/index.html +13 -0
- package/examples/vue-event-subscriptions/package-lock.json +1726 -0
- package/examples/vue-event-subscriptions/package.json +29 -0
- package/examples/vue-event-subscriptions/playwright.config.ts +47 -0
- package/examples/vue-event-subscriptions/src/App.vue +193 -0
- package/examples/vue-event-subscriptions/src/composables/useHlsPlayer.ts +272 -0
- package/examples/vue-event-subscriptions/src/main.ts +25 -0
- package/examples/vue-event-subscriptions/src/router/index.ts +68 -0
- package/examples/vue-event-subscriptions/src/stores/connection.ts +101 -0
- package/examples/vue-event-subscriptions/src/stores/mediaSession.ts +79 -0
- package/examples/vue-event-subscriptions/src/views/Callback.vue +76 -0
- package/examples/vue-event-subscriptions/src/views/Home.vue +192 -0
- package/examples/vue-event-subscriptions/src/views/LiveEvents.vue +901 -0
- package/examples/vue-event-subscriptions/src/views/Login.vue +33 -0
- package/examples/vue-event-subscriptions/src/views/Logout.vue +65 -0
- package/examples/vue-event-subscriptions/src/views/Subscriptions.vue +389 -0
- package/examples/vue-event-subscriptions/src/vite-env.d.ts +12 -0
- package/examples/vue-event-subscriptions/tsconfig.json +21 -0
- package/examples/vue-event-subscriptions/tsconfig.node.json +10 -0
- package/examples/vue-event-subscriptions/vite.config.ts +12 -0
- package/examples/vue-events/package-lock.json +8 -1
- package/examples/vue-events/package.json +1 -0
- package/examples/vue-events/src/components/EventsModal.vue +269 -47
- package/examples/vue-events/src/composables/useHlsPlayer.ts +272 -0
- package/examples/vue-events/src/stores/mediaSession.ts +79 -0
- package/package.json +10 -2
- package/scripts/setup-agents.ts +116 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vue-event-subscriptions-example",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"stop": "lsof -ti :3333 2>/dev/null | xargs -r kill -9 || echo 'Port 3333 is free'",
|
|
8
|
+
"dev": "npm run stop && vite",
|
|
9
|
+
"build": "vue-tsc && vite build",
|
|
10
|
+
"preview": "vite preview",
|
|
11
|
+
"test:e2e": "playwright test",
|
|
12
|
+
"test:e2e:ui": "playwright test --ui"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"een-api-toolkit": "file:../..",
|
|
16
|
+
"hls.js": "^1.6.15",
|
|
17
|
+
"pinia": "^3.0.4",
|
|
18
|
+
"vue": "^3.4.0",
|
|
19
|
+
"vue-router": "^4.2.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@playwright/test": "^1.57.0",
|
|
23
|
+
"@vitejs/plugin-vue": "^6.0.0",
|
|
24
|
+
"dotenv": "^17.2.3",
|
|
25
|
+
"typescript": "~5.8.0",
|
|
26
|
+
"vite": "^7.3.0",
|
|
27
|
+
"vue-tsc": "^3.2.1"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { defineConfig, devices } from '@playwright/test'
|
|
2
|
+
import dotenv from 'dotenv'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
|
|
8
|
+
// Load .env files: parent first, then local with override to replace any conflicts
|
|
9
|
+
// In CI, env vars are passed directly via workflow
|
|
10
|
+
dotenv.config({ path: path.resolve(__dirname, '../../.env') })
|
|
11
|
+
dotenv.config({ path: path.resolve(__dirname, '.env'), override: true })
|
|
12
|
+
|
|
13
|
+
const redirectUri = process.env.VITE_REDIRECT_URI || 'http://127.0.0.1:3333'
|
|
14
|
+
if (!redirectUri.startsWith('http://127.0.0.1:') && !redirectUri.startsWith('http://localhost:')) {
|
|
15
|
+
throw new Error('VITE_REDIRECT_URI must use localhost or 127.0.0.1 for security')
|
|
16
|
+
}
|
|
17
|
+
export const baseURL = redirectUri
|
|
18
|
+
|
|
19
|
+
export default defineConfig({
|
|
20
|
+
testDir: './e2e',
|
|
21
|
+
testMatch: '**/*.spec.ts',
|
|
22
|
+
fullyParallel: false, // Run tests sequentially for predictable order
|
|
23
|
+
forbidOnly: !!process.env.CI,
|
|
24
|
+
retries: 0, // No retries - fail fast
|
|
25
|
+
maxFailures: 1, // Stop on first failure
|
|
26
|
+
workers: 1,
|
|
27
|
+
reporter: [['html', { open: 'never' }]],
|
|
28
|
+
timeout: 60000, // Longer timeout for SSE operations
|
|
29
|
+
use: {
|
|
30
|
+
baseURL,
|
|
31
|
+
trace: 'on-first-retry',
|
|
32
|
+
video: 'retain-on-failure'
|
|
33
|
+
},
|
|
34
|
+
outputDir: './e2e-results/',
|
|
35
|
+
projects: [
|
|
36
|
+
{
|
|
37
|
+
name: 'chromium',
|
|
38
|
+
use: { ...devices['Desktop Chrome'] }
|
|
39
|
+
}
|
|
40
|
+
],
|
|
41
|
+
webServer: {
|
|
42
|
+
command: 'npm run dev',
|
|
43
|
+
url: baseURL,
|
|
44
|
+
reuseExistingServer: !process.env.CI,
|
|
45
|
+
timeout: 30000
|
|
46
|
+
}
|
|
47
|
+
})
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useAuthStore } from 'een-api-toolkit'
|
|
3
|
+
import { computed } from 'vue'
|
|
4
|
+
import { useRouter } from 'vue-router'
|
|
5
|
+
|
|
6
|
+
const authStore = useAuthStore()
|
|
7
|
+
const router = useRouter()
|
|
8
|
+
|
|
9
|
+
const isAuthenticated = computed(() => authStore.isAuthenticated)
|
|
10
|
+
const refreshFailed = computed(() => authStore.refreshFailed)
|
|
11
|
+
const refreshFailedMessage = computed(() => authStore.refreshFailedMessage)
|
|
12
|
+
const isRefreshing = computed(() => authStore.isRefreshing)
|
|
13
|
+
|
|
14
|
+
function dismissRefreshError() {
|
|
15
|
+
authStore.clearRefreshFailed()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function handleRelogin() {
|
|
19
|
+
authStore.logout()
|
|
20
|
+
router.push('/login')
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<div class="app">
|
|
26
|
+
<!-- Session refresh banner -->
|
|
27
|
+
<div v-if="refreshFailed" class="refresh-banner error-banner">
|
|
28
|
+
<span>Session refresh failed: {{ refreshFailedMessage }}</span>
|
|
29
|
+
<div class="banner-actions">
|
|
30
|
+
<button class="small" @click="handleRelogin">Re-login</button>
|
|
31
|
+
<button class="small secondary" @click="dismissRefreshError">Dismiss</button>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
<div v-else-if="isRefreshing" class="refresh-banner info-banner">
|
|
35
|
+
<span>Refreshing session...</span>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<header>
|
|
39
|
+
<h1 data-testid="app-title">EEN Event Subscriptions</h1>
|
|
40
|
+
<nav>
|
|
41
|
+
<router-link data-testid="nav-home" to="/">Home</router-link>
|
|
42
|
+
<router-link data-testid="nav-subscriptions" v-if="isAuthenticated" to="/subscriptions">Subscriptions</router-link>
|
|
43
|
+
<router-link data-testid="nav-live" v-if="isAuthenticated" to="/live">Live Events</router-link>
|
|
44
|
+
<router-link data-testid="nav-login" v-if="!isAuthenticated" to="/login">Login</router-link>
|
|
45
|
+
<router-link data-testid="nav-logout" v-if="isAuthenticated" to="/logout">Logout</router-link>
|
|
46
|
+
</nav>
|
|
47
|
+
</header>
|
|
48
|
+
<main>
|
|
49
|
+
<router-view />
|
|
50
|
+
</main>
|
|
51
|
+
</div>
|
|
52
|
+
</template>
|
|
53
|
+
|
|
54
|
+
<style>
|
|
55
|
+
* {
|
|
56
|
+
box-sizing: border-box;
|
|
57
|
+
margin: 0;
|
|
58
|
+
padding: 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
body {
|
|
62
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
|
63
|
+
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
|
64
|
+
line-height: 1.6;
|
|
65
|
+
color: #333;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.app {
|
|
69
|
+
max-width: 1200px;
|
|
70
|
+
margin: 0 auto;
|
|
71
|
+
padding: 20px;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
header {
|
|
75
|
+
display: flex;
|
|
76
|
+
justify-content: space-between;
|
|
77
|
+
align-items: center;
|
|
78
|
+
margin-bottom: 30px;
|
|
79
|
+
padding-bottom: 20px;
|
|
80
|
+
border-bottom: 1px solid #eee;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
header h1 {
|
|
84
|
+
font-size: 1.5rem;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
nav {
|
|
88
|
+
display: flex;
|
|
89
|
+
gap: 20px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
nav a {
|
|
93
|
+
color: #42b883;
|
|
94
|
+
text-decoration: none;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
nav a:hover {
|
|
98
|
+
text-decoration: underline;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
nav a.router-link-active {
|
|
102
|
+
font-weight: bold;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
button {
|
|
106
|
+
background: #42b883;
|
|
107
|
+
color: white;
|
|
108
|
+
border: none;
|
|
109
|
+
padding: 10px 20px;
|
|
110
|
+
border-radius: 4px;
|
|
111
|
+
cursor: pointer;
|
|
112
|
+
font-size: 1rem;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
button:hover {
|
|
116
|
+
background: #3aa876;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
button:disabled {
|
|
120
|
+
background: #ccc;
|
|
121
|
+
cursor: not-allowed;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
button.danger {
|
|
125
|
+
background: #e74c3c;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
button.danger:hover {
|
|
129
|
+
background: #c0392b;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
button.secondary {
|
|
133
|
+
background: #95a5a6;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
button.secondary:hover {
|
|
137
|
+
background: #7f8c8d;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.error {
|
|
141
|
+
color: #e74c3c;
|
|
142
|
+
padding: 10px;
|
|
143
|
+
background: #fdf2f2;
|
|
144
|
+
border-radius: 4px;
|
|
145
|
+
margin: 10px 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.loading {
|
|
149
|
+
color: #666;
|
|
150
|
+
font-style: italic;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.success {
|
|
154
|
+
color: #27ae60;
|
|
155
|
+
padding: 10px;
|
|
156
|
+
background: #eafaf1;
|
|
157
|
+
border-radius: 4px;
|
|
158
|
+
margin: 10px 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* Session refresh banner */
|
|
162
|
+
.refresh-banner {
|
|
163
|
+
display: flex;
|
|
164
|
+
justify-content: space-between;
|
|
165
|
+
align-items: center;
|
|
166
|
+
padding: 12px 20px;
|
|
167
|
+
margin-bottom: 15px;
|
|
168
|
+
border-radius: 4px;
|
|
169
|
+
font-size: 14px;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.error-banner {
|
|
173
|
+
background: #fdf2f2;
|
|
174
|
+
color: #e74c3c;
|
|
175
|
+
border: 1px solid #f5c6cb;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.info-banner {
|
|
179
|
+
background: #e8f4fd;
|
|
180
|
+
color: #2980b9;
|
|
181
|
+
border: 1px solid #bee5eb;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.banner-actions {
|
|
185
|
+
display: flex;
|
|
186
|
+
gap: 8px;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
button.small {
|
|
190
|
+
padding: 6px 12px;
|
|
191
|
+
font-size: 12px;
|
|
192
|
+
}
|
|
193
|
+
</style>
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { ref, nextTick, onUnmounted, type Ref } from 'vue'
|
|
2
|
+
import { listMedia, formatTimestamp, useAuthStore } from 'een-api-toolkit'
|
|
3
|
+
import Hls from 'hls.js'
|
|
4
|
+
import { useMediaSessionStore } from '../stores/mediaSession'
|
|
5
|
+
|
|
6
|
+
// Constants
|
|
7
|
+
const SEARCH_WINDOW_MS = 60 * 60 * 1000 // 1 hour before/after target timestamp
|
|
8
|
+
const MAX_MEDIA_PAGE_SIZE = 100 // Limit results for performance
|
|
9
|
+
const MAX_NETWORK_RETRIES = 3 // Maximum retry attempts for network errors
|
|
10
|
+
|
|
11
|
+
// Debug utility - logs only when VITE_DEBUG=true
|
|
12
|
+
const isDebug = import.meta.env?.VITE_DEBUG === 'true'
|
|
13
|
+
function debugError(...args: unknown[]): void {
|
|
14
|
+
if (isDebug) {
|
|
15
|
+
console.error('[useHlsPlayer]', ...args)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Return type for the useHlsPlayer composable */
|
|
20
|
+
export interface HlsPlayerReturn {
|
|
21
|
+
videoUrl: Ref<string | null>
|
|
22
|
+
videoError: Ref<string | null>
|
|
23
|
+
loadingVideo: Ref<boolean>
|
|
24
|
+
videoRef: Ref<HTMLVideoElement | null>
|
|
25
|
+
loadVideo: (deviceId: string, timestamp: string) => Promise<void>
|
|
26
|
+
resetVideo: () => void
|
|
27
|
+
destroyHls: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Composable for HLS video playback from EEN recordings.
|
|
32
|
+
* Handles media session initialization, interval search, and HLS.js setup.
|
|
33
|
+
* Uses Pinia store for media session state to ensure consistent behavior
|
|
34
|
+
* across all component instances.
|
|
35
|
+
*/
|
|
36
|
+
export function useHlsPlayer(): HlsPlayerReturn {
|
|
37
|
+
const authStore = useAuthStore()
|
|
38
|
+
const mediaSessionStore = useMediaSessionStore()
|
|
39
|
+
|
|
40
|
+
// State
|
|
41
|
+
const videoUrl = ref<string | null>(null)
|
|
42
|
+
const videoError = ref<string | null>(null)
|
|
43
|
+
const loadingVideo = ref(false)
|
|
44
|
+
const videoRef = ref<HTMLVideoElement | null>(null)
|
|
45
|
+
|
|
46
|
+
let hlsInstance: Hls | null = null
|
|
47
|
+
let networkRetryCount = 0
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Initialize media session with caching via Pinia store.
|
|
51
|
+
* Only calls the API once per session, subsequent calls return cached result.
|
|
52
|
+
*/
|
|
53
|
+
async function ensureMediaSession(): Promise<boolean> {
|
|
54
|
+
const success = await mediaSessionStore.ensureInitialized()
|
|
55
|
+
if (!success && mediaSessionStore.error) {
|
|
56
|
+
videoError.value = mediaSessionStore.error
|
|
57
|
+
}
|
|
58
|
+
return success
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Destroy the HLS instance and clean up resources.
|
|
63
|
+
*/
|
|
64
|
+
function destroyHls() {
|
|
65
|
+
if (hlsInstance) {
|
|
66
|
+
hlsInstance.destroy()
|
|
67
|
+
hlsInstance = null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Initialize HLS.js with proper authentication and error handling.
|
|
73
|
+
*/
|
|
74
|
+
function initHls() {
|
|
75
|
+
if (!videoUrl.value || !videoRef.value) return
|
|
76
|
+
|
|
77
|
+
destroyHls()
|
|
78
|
+
|
|
79
|
+
// Always use hls.js even on Safari - native HLS cannot send Authorization headers
|
|
80
|
+
if (!Hls.isSupported()) {
|
|
81
|
+
videoError.value = 'HLS is not supported in this browser. Please use a modern browser like Chrome, Firefox, or Edge.'
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Configure hls.js to send Authorization header for authentication
|
|
86
|
+
hlsInstance = new Hls({
|
|
87
|
+
xhrSetup: function(xhr) {
|
|
88
|
+
xhr.setRequestHeader('Authorization', `Bearer ${authStore.token}`)
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
hlsInstance.loadSource(videoUrl.value)
|
|
93
|
+
hlsInstance.attachMedia(videoRef.value)
|
|
94
|
+
|
|
95
|
+
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
96
|
+
// Reset retry counter on successful manifest parse
|
|
97
|
+
networkRetryCount = 0
|
|
98
|
+
videoRef.value?.play().catch(() => {
|
|
99
|
+
// Autoplay may be blocked, user can manually play
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// Enhanced error handling for different error types
|
|
104
|
+
hlsInstance.on(Hls.Events.ERROR, (_, data) => {
|
|
105
|
+
debugError('HLS error:', data)
|
|
106
|
+
|
|
107
|
+
if (data.fatal) {
|
|
108
|
+
switch (data.type) {
|
|
109
|
+
case Hls.ErrorTypes.NETWORK_ERROR:
|
|
110
|
+
// Network error - could be auth issue or connectivity
|
|
111
|
+
if (data.response?.code === 401) {
|
|
112
|
+
videoError.value = 'Authentication expired. Please refresh the page and try again.'
|
|
113
|
+
// Don't retry on auth errors - requires user action
|
|
114
|
+
destroyHls()
|
|
115
|
+
} else if (data.response?.code === 403) {
|
|
116
|
+
videoError.value = 'Access denied to video stream.'
|
|
117
|
+
// Don't retry on permission errors
|
|
118
|
+
destroyHls()
|
|
119
|
+
} else {
|
|
120
|
+
// Retry other network errors with limit
|
|
121
|
+
networkRetryCount++
|
|
122
|
+
if (networkRetryCount <= MAX_NETWORK_RETRIES) {
|
|
123
|
+
videoError.value = `Network error loading video: ${data.details}. Retry ${networkRetryCount}/${MAX_NETWORK_RETRIES}...`
|
|
124
|
+
hlsInstance?.startLoad()
|
|
125
|
+
} else {
|
|
126
|
+
videoError.value = `Network error loading video: ${data.details}. Max retries (${MAX_NETWORK_RETRIES}) exceeded.`
|
|
127
|
+
destroyHls()
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
break
|
|
131
|
+
|
|
132
|
+
case Hls.ErrorTypes.MEDIA_ERROR:
|
|
133
|
+
// Media error - try to recover
|
|
134
|
+
videoError.value = `Media error: ${data.details}. Attempting recovery...`
|
|
135
|
+
hlsInstance?.recoverMediaError()
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
default:
|
|
139
|
+
// Other fatal errors
|
|
140
|
+
videoError.value = `HLS error: ${data.type} - ${data.details}`
|
|
141
|
+
destroyHls()
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Load and play HLS video for a given device and timestamp.
|
|
149
|
+
*
|
|
150
|
+
* @param deviceId - The camera device ID
|
|
151
|
+
* @param timestamp - ISO timestamp string (the target time to find video for)
|
|
152
|
+
* @returns Promise that resolves when video is ready or error occurs
|
|
153
|
+
*/
|
|
154
|
+
async function loadVideo(deviceId: string, timestamp: string): Promise<void> {
|
|
155
|
+
loadingVideo.value = true
|
|
156
|
+
videoError.value = null
|
|
157
|
+
videoUrl.value = null
|
|
158
|
+
networkRetryCount = 0 // Reset retry counter for new video load
|
|
159
|
+
|
|
160
|
+
// Initialize media session (cached after first call)
|
|
161
|
+
const sessionOk = await ensureMediaSession()
|
|
162
|
+
if (!sessionOk) {
|
|
163
|
+
loadingVideo.value = false
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Search for recordings around the target timestamp
|
|
168
|
+
const targetTime = new Date(timestamp)
|
|
169
|
+
const searchStartTime = new Date(targetTime.getTime() - SEARCH_WINDOW_MS)
|
|
170
|
+
const searchEndTime = new Date(targetTime.getTime() + SEARCH_WINDOW_MS)
|
|
171
|
+
|
|
172
|
+
// Use 'main' type for video - HLS is typically only available for main feeds
|
|
173
|
+
const result = await listMedia({
|
|
174
|
+
deviceId,
|
|
175
|
+
type: 'main',
|
|
176
|
+
mediaType: 'video',
|
|
177
|
+
startTimestamp: formatTimestamp(searchStartTime.toISOString()),
|
|
178
|
+
endTimestamp: formatTimestamp(searchEndTime.toISOString()),
|
|
179
|
+
include: ['hlsUrl'],
|
|
180
|
+
pageSize: MAX_MEDIA_PAGE_SIZE
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
if (result.error) {
|
|
184
|
+
videoError.value = result.error.message
|
|
185
|
+
loadingVideo.value = false
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const intervals = result.data?.results ?? []
|
|
190
|
+
|
|
191
|
+
// Validate target timestamp
|
|
192
|
+
const targetTimeMs = targetTime.getTime()
|
|
193
|
+
if (isNaN(targetTimeMs)) {
|
|
194
|
+
videoError.value = `Invalid timestamp format: ${timestamp}`
|
|
195
|
+
loadingVideo.value = false
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Find an interval that contains the target timestamp and has an HLS URL
|
|
200
|
+
const interval = intervals.find(i => {
|
|
201
|
+
if (!i.hlsUrl) return false
|
|
202
|
+
const intervalStart = new Date(i.startTimestamp).getTime()
|
|
203
|
+
const intervalEnd = new Date(i.endTimestamp).getTime()
|
|
204
|
+
// Skip intervals with invalid timestamps
|
|
205
|
+
if (isNaN(intervalStart) || isNaN(intervalEnd)) return false
|
|
206
|
+
return targetTimeMs >= intervalStart && targetTimeMs <= intervalEnd
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
if (!interval?.hlsUrl) {
|
|
210
|
+
// Provide detailed error message
|
|
211
|
+
if (intervals.length === 0) {
|
|
212
|
+
videoError.value = 'No recordings found for this time range'
|
|
213
|
+
} else if (!intervals.some(i => i.hlsUrl)) {
|
|
214
|
+
videoError.value = 'Recordings found but HLS not available'
|
|
215
|
+
} else {
|
|
216
|
+
videoError.value = `No recording contains timestamp ${timestamp}`
|
|
217
|
+
}
|
|
218
|
+
loadingVideo.value = false
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Set the HLS URL
|
|
223
|
+
videoUrl.value = interval.hlsUrl
|
|
224
|
+
loadingVideo.value = false
|
|
225
|
+
|
|
226
|
+
// Initialize HLS.js after the DOM has been updated
|
|
227
|
+
await nextTick()
|
|
228
|
+
initHls()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Reset all video state.
|
|
233
|
+
*/
|
|
234
|
+
function resetVideo() {
|
|
235
|
+
destroyHls()
|
|
236
|
+
videoUrl.value = null
|
|
237
|
+
videoError.value = null
|
|
238
|
+
loadingVideo.value = false
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Cleanup on unmount
|
|
242
|
+
onUnmounted(() => {
|
|
243
|
+
destroyHls()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
// State
|
|
248
|
+
videoUrl,
|
|
249
|
+
videoError,
|
|
250
|
+
loadingVideo,
|
|
251
|
+
videoRef,
|
|
252
|
+
|
|
253
|
+
// Methods
|
|
254
|
+
loadVideo,
|
|
255
|
+
resetVideo,
|
|
256
|
+
destroyHls
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Reset the media session cache.
|
|
262
|
+
* Call this when the user logs out or the session expires to ensure
|
|
263
|
+
* a fresh media session is initialized on next use.
|
|
264
|
+
*
|
|
265
|
+
* @remarks
|
|
266
|
+
* This delegates to the Pinia media session store's reset method.
|
|
267
|
+
* Must be called from within a Vue component context or after Pinia is installed.
|
|
268
|
+
*/
|
|
269
|
+
export function resetMediaSessionCache(): void {
|
|
270
|
+
const mediaSessionStore = useMediaSessionStore()
|
|
271
|
+
mediaSessionStore.reset()
|
|
272
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createApp } from 'vue'
|
|
2
|
+
import { createPinia } from 'pinia'
|
|
3
|
+
import { initEenToolkit } from 'een-api-toolkit'
|
|
4
|
+
import App from './App.vue'
|
|
5
|
+
import router from './router'
|
|
6
|
+
|
|
7
|
+
const app = createApp(App)
|
|
8
|
+
|
|
9
|
+
// Install Pinia (required before initEenToolkit)
|
|
10
|
+
app.use(createPinia())
|
|
11
|
+
|
|
12
|
+
// Initialize EEN API Toolkit with sessionStorage
|
|
13
|
+
// Note: Using 'sessionStorage' means tokens persist within the browser tab session
|
|
14
|
+
initEenToolkit({
|
|
15
|
+
proxyUrl: import.meta.env.VITE_PROXY_URL,
|
|
16
|
+
clientId: import.meta.env.VITE_EEN_CLIENT_ID,
|
|
17
|
+
redirectUri: import.meta.env.VITE_REDIRECT_URI,
|
|
18
|
+
storageStrategy: 'sessionStorage',
|
|
19
|
+
debug: import.meta.env.VITE_DEBUG === 'true'
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
// Install router
|
|
23
|
+
app.use(router)
|
|
24
|
+
|
|
25
|
+
app.mount('#app')
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createRouter, createWebHistory } from 'vue-router'
|
|
2
|
+
import { useAuthStore } from 'een-api-toolkit'
|
|
3
|
+
import Home from '../views/Home.vue'
|
|
4
|
+
import Login from '../views/Login.vue'
|
|
5
|
+
import Callback from '../views/Callback.vue'
|
|
6
|
+
import Subscriptions from '../views/Subscriptions.vue'
|
|
7
|
+
import LiveEvents from '../views/LiveEvents.vue'
|
|
8
|
+
import Logout from '../views/Logout.vue'
|
|
9
|
+
|
|
10
|
+
const router = createRouter({
|
|
11
|
+
history: createWebHistory(),
|
|
12
|
+
routes: [
|
|
13
|
+
{
|
|
14
|
+
path: '/',
|
|
15
|
+
name: 'home',
|
|
16
|
+
component: Home,
|
|
17
|
+
// Handle OAuth callback on root path (EEN IDP redirects to http://127.0.0.1:3333)
|
|
18
|
+
beforeEnter: (to, _from, next) => {
|
|
19
|
+
// If URL has code and state params, it's an OAuth callback
|
|
20
|
+
if (to.query.code && to.query.state) {
|
|
21
|
+
next({ name: 'callback', query: to.query })
|
|
22
|
+
} else {
|
|
23
|
+
next()
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
path: '/login',
|
|
29
|
+
name: 'login',
|
|
30
|
+
component: Login
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
path: '/callback',
|
|
34
|
+
name: 'callback',
|
|
35
|
+
component: Callback
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
path: '/subscriptions',
|
|
39
|
+
name: 'subscriptions',
|
|
40
|
+
component: Subscriptions,
|
|
41
|
+
meta: { requiresAuth: true }
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
path: '/live',
|
|
45
|
+
name: 'live',
|
|
46
|
+
component: LiveEvents,
|
|
47
|
+
meta: { requiresAuth: true }
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
path: '/logout',
|
|
51
|
+
name: 'logout',
|
|
52
|
+
component: Logout
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// Navigation guard for protected routes
|
|
58
|
+
router.beforeEach((to, _from, next) => {
|
|
59
|
+
const authStore = useAuthStore()
|
|
60
|
+
|
|
61
|
+
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
|
62
|
+
next({ name: 'login' })
|
|
63
|
+
} else {
|
|
64
|
+
next()
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
export default router
|