From 674c9f80c5b456e499f351d1230ef17fc05d8541 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sat, 14 Feb 2026 10:56:37 +0000 Subject: [PATCH] feat: enhance streaming functionality with format support and fallback mechanism - Added support for an optional 'format' query parameter in the streaming endpoint to allow raw file retrieval. - Implemented a fallback mechanism in the VideoPlayer to automatically switch to raw file playback when HLS streaming fails. - Improved error handling and logging for HLS playback issues, ensuring better user feedback and recovery attempts. - Updated the Indeehub API service to accommodate the new format parameter in the streaming URL request. --- backend/src/contents/contents.controller.ts | 29 +++-- src/components/ContentDetailModal.vue | 10 +- src/components/VideoPlayer.vue | 131 ++++++++++++++------ src/services/indeehub-api.service.ts | 14 ++- 4 files changed, 129 insertions(+), 55 deletions(-) diff --git a/backend/src/contents/contents.controller.ts b/backend/src/contents/contents.controller.ts index c239970..600486e 100644 --- a/backend/src/contents/contents.controller.ts +++ b/backend/src/contents/contents.controller.ts @@ -80,6 +80,7 @@ export class ContentsController { @UseGuards(OptionalHybridAuthGuard) async stream( @Param('id') id: string, + @Query('format') format: string, @Req() req: any, ) { // Try content ID first; if not found, try looking up project to find its content @@ -94,7 +95,7 @@ export class ContentsController { } this.logger.log( - `[stream] content=${content.id}, file=${content.file}, status=${content.status}, project=${content.project?.id}`, + `[stream] content=${content.id}, file=${content.file}, status=${content.status}, project=${content.project?.id}, format=${format ?? 'auto'}`, ); if (!content.file) { @@ -116,6 +117,10 @@ export class ContentsController { const dto = new StreamContentDTO(content); + // When ?format=raw is requested, skip HLS and go straight to presigned URL. + // This is used as a fallback when the HLS stream fails (e.g. key decryption issues). + const forceRaw = format === 'raw'; + // Check if the HLS manifest actually exists in the public bucket. // In dev (no transcoding pipeline) the raw upload lives in the // private bucket only — fall back to a presigned URL for it. @@ -125,17 +130,21 @@ export class ContentsController { ? `${getFileRoute(content.file)}${content.id}/transcoded/file.m3u8` : `${getFileRoute(content.file)}transcoded/file.m3u8`; - this.logger.log(`[stream] Checking HLS at bucket=${publicBucket}, key=${outputKey}`); + if (!forceRaw) { + this.logger.log(`[stream] Checking HLS at bucket=${publicBucket}, key=${outputKey}`); - const hlsExists = await this.uploadService.objectExists( - outputKey, - publicBucket, - ); + const hlsExists = await this.uploadService.objectExists( + outputKey, + publicBucket, + ); - if (hlsExists) { - const publicUrl = getPublicS3Url(outputKey); - this.logger.log(`[stream] HLS found, returning publicUrl=${publicUrl}`); - return { ...dto, file: publicUrl }; + if (hlsExists) { + const publicUrl = getPublicS3Url(outputKey); + this.logger.log(`[stream] HLS found, returning publicUrl=${publicUrl}`); + return { ...dto, file: publicUrl }; + } + } else { + this.logger.log(`[stream] Raw format requested, skipping HLS check`); } // HLS not available — check that the raw file actually exists before diff --git a/src/components/ContentDetailModal.vue b/src/components/ContentDetailModal.vue index 6860379..7c2a8a2 100644 --- a/src/components/ContentDetailModal.vue +++ b/src/components/ContentDetailModal.vue @@ -308,8 +308,14 @@ async function checkRentalAccess() { const result = await libraryService.checkRentExists(contentId) hasActiveRental.value = result.exists rentalExpiresAt.value = result.expiresAt ? new Date(result.expiresAt) : null - } catch (err) { - console.warn('Rental check failed:', err) + } catch (err: any) { + // 401/403 means the user's token is expired or missing — not a real + // error, just means they don't have rental access. Only log actual + // unexpected failures. + const status = err?.response?.status + if (status !== 401 && status !== 403) { + console.warn('Rental check failed:', err) + } // Owner playback is handled by the isOwner computed property, // so we don't fake a rental here. This keeps the "Rented" badge // accurate and still shows the rental price for non-owners. diff --git a/src/components/VideoPlayer.vue b/src/components/VideoPlayer.vue index 5b2b6f9..f097322 100644 --- a/src/components/VideoPlayer.vue +++ b/src/components/VideoPlayer.vue @@ -60,7 +60,7 @@

{{ streamError }}

- @@ -297,7 +297,10 @@ onUnmounted(() => { // ── Stream fetching ───────────────────────────────────────────────── -async function fetchStream() { +/** Whether we already tried and failed the HLS path for this content */ +let hlsFailed = false + +async function fetchStream(format?: string) { // Prefer the content entity ID; fall back to the project ID. // The backend stream endpoint now accepts both — it will look up // content by ID first, then try project ID if not found. @@ -309,7 +312,8 @@ async function fetchStream() { console.log('[VideoPlayer] fetchStream — contentId:', contentId, 'content.contentId:', props.content?.contentId, - 'content.id:', props.content?.id) + 'content.id:', props.content?.id, + format ? `format=${format}` : '') isLoadingStream.value = true streamError.value = null @@ -318,7 +322,7 @@ async function fetchStream() { try { // Try the backend stream endpoint (handles DRM, presigned URLs, etc.) - const info = await indeehubApiService.getStreamingUrl(contentId) + const info = await indeehubApiService.getStreamingUrl(contentId, format) console.log('[VideoPlayer] Stream API response:', JSON.stringify(info)) // Check if backend returned an error message inside the response @@ -363,6 +367,18 @@ async function fetchStream() { } } +/** + * Automatically fall back to the raw (non-HLS) file when HLS fails. + * Destroys the current HLS instance and re-fetches with ?format=raw. + */ +async function fallbackToRaw() { + console.warn('[VideoPlayer] HLS playback failed, falling back to raw file...') + hlsFailed = true + destroyHls() + streamError.value = null + await fetchStream('raw') +} + function initPlayer(url: string) { destroyHls() hlsRecoveryAttempts = 0 @@ -383,9 +399,13 @@ function initPlayer(url: string) { if (context.url?.includes('/contents/') && context.url?.includes('/key')) { try { const parsed = new URL(context.url) - const correctedUrl = `${window.location.origin}${parsed.pathname}` - console.log('[VideoPlayer] Rewriting key URL:', context.url, '→', correctedUrl) - context.url = correctedUrl + // Only rewrite if the key URL is on a different origin + // (e.g. CDN domain vs app domain) + if (parsed.origin !== window.location.origin) { + const correctedUrl = `${window.location.origin}${parsed.pathname}` + console.log('[VideoPlayer] Rewriting key URL:', context.url, '→', correctedUrl) + context.url = correctedUrl + } } catch { /* keep original URL */ } } super.load(context, config, callbacks) @@ -415,39 +435,39 @@ function initPlayer(url: string) { }) }) hls.on(Hls.Events.ERROR, (_event, data) => { - // Log detailed error info including the fragment URL when available + // Only log fatal errors to avoid flooding the console + if (!data.fatal) return + const fragUrl = (data as any).frag?.url || (data as any).url || '' - console.error( - '[VideoPlayer] HLS error:', data.type, data.details, - data.fatal ? '(FATAL)' : '', + console.warn( + `[VideoPlayer] HLS fatal error: ${data.type}/${data.details}`, fragUrl ? `frag=${fragUrl.substring(0, 120)}` : '', (data as any).response ? `status=${(data as any).response.code}` : '', ) - if (!data.fatal) return - // Track recovery attempts to avoid infinite loops hlsRecoveryAttempts++ if (hlsRecoveryAttempts > MAX_HLS_RECOVERY_ATTEMPTS) { - console.error(`[VideoPlayer] Max recovery attempts (${MAX_HLS_RECOVERY_ATTEMPTS}) exceeded, giving up.`) + console.warn(`[VideoPlayer] Max recovery attempts (${MAX_HLS_RECOVERY_ATTEMPTS}) exceeded.`) - // fragParsingError usually means the segments are encrypted or - // the file is corrupt / served incorrectly - if (data.details === 'fragParsingError') { - streamError.value = 'Unable to play this video. The stream segments could not be decoded — they may be encrypted or the file may still be processing.' - } else { - streamError.value = 'Playback failed after multiple attempts. Please try again later.' + // fragParsingError usually means the AES-128 key doesn't match + // the encryption used for the segments. Automatically fall back + // to the raw (non-encrypted) file so playback still works. + if (data.details === 'fragParsingError' && !hlsFailed) { + fallbackToRaw() + return } + + // For other errors or if raw fallback already failed, show error + streamError.value = 'Playback failed after multiple attempts. Please try again later.' destroyHls() return } if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { - console.warn(`[VideoPlayer] HLS network error, retrying... (attempt ${hlsRecoveryAttempts}/${MAX_HLS_RECOVERY_ATTEMPTS})`) hls?.startLoad() } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { - console.warn(`[VideoPlayer] HLS media error, recovering... (attempt ${hlsRecoveryAttempts}/${MAX_HLS_RECOVERY_ATTEMPTS})`) hls?.recoverMediaError() } else { streamError.value = 'Playback error. Please try again.' @@ -508,6 +528,7 @@ function resetState() { isLoadingStream.value = false showSpeedMenu.value = false playbackRate.value = 1 + hlsFailed = false } // ── Controls ──────────────────────────────────────────────────────── @@ -804,13 +825,15 @@ function formatTime(seconds: number): string { /* Controls */ .video-controls { - background: linear-gradient(to top, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0.6) 60%, transparent 100%); - padding: 40px 24px 16px; + background: linear-gradient(to top, rgba(0, 0, 0, 0.95) 0%, rgba(0, 0, 0, 0.85) 40%, rgba(0, 0, 0, 0.5) 75%, transparent 100%); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + padding: 48px 24px 20px; position: absolute; bottom: 0; left: 0; right: 0; - z-index: 10; + z-index: 20; } .progress-container { @@ -819,16 +842,16 @@ function formatTime(seconds: number): string { .progress-bar { position: relative; - height: 6px; - background: rgba(255, 255, 255, 0.15); - border-radius: 3px; + height: 8px; + background: rgba(255, 255, 255, 0.25); + border-radius: 4px; cursor: pointer; margin-bottom: 8px; transition: height 0.15s ease; } .progress-bar:hover { - height: 8px; + height: 12px; } .progress-buffered { @@ -836,8 +859,8 @@ function formatTime(seconds: number): string { top: 0; left: 0; height: 100%; - background: rgba(255, 255, 255, 0.2); - border-radius: 3px; + background: rgba(255, 255, 255, 0.3); + border-radius: 4px; } .progress-filled { @@ -846,18 +869,18 @@ function formatTime(seconds: number): string { left: 0; height: 100%; background: #F7931A; - border-radius: 3px; + border-radius: 4px; } .progress-handle { position: absolute; top: 50%; transform: translate(-50%, -50%) scale(0); - width: 14px; - height: 14px; + width: 16px; + height: 16px; background: white; border-radius: 50%; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: transform 0.15s ease; } @@ -869,7 +892,7 @@ function formatTime(seconds: number): string { display: flex; align-items: center; gap: 6px; - font-size: 13px; + font-size: 14px; color: white; font-variant-numeric: tabular-nums; } @@ -894,7 +917,7 @@ function formatTime(seconds: number): string { } .control-btn:hover { - background: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.15); transform: scale(1.05); } @@ -941,7 +964,7 @@ function formatTime(seconds: number): string { /* Content info panel */ .content-info-panel { position: absolute; - bottom: 120px; + bottom: 160px; left: 24px; max-width: 600px; background: rgba(0, 0, 0, 0.7); @@ -950,7 +973,7 @@ function formatTime(seconds: number): string { border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 16px; padding: 20px; - z-index: 10; + z-index: 15; } /* Transitions */ @@ -989,6 +1012,28 @@ function formatTime(seconds: number): string { pointer-events: none; } +@media (min-width: 769px) { + .video-controls { + padding: 56px 32px 24px; + } + + .control-btn { + padding: 10px; + } + + .time-display { + font-size: 15px; + } + + .progress-bar { + height: 8px; + } + + .progress-bar:hover { + height: 14px; + } +} + @media (max-width: 768px) { .content-info-panel { display: none; @@ -1003,5 +1048,13 @@ function formatTime(seconds: number): string { .video-controls { padding: 30px 16px 12px; } + + .progress-bar { + height: 6px; + } + + .progress-bar:hover { + height: 10px; + } } diff --git a/src/services/indeehub-api.service.ts b/src/services/indeehub-api.service.ts index 1f0b64a..817e66c 100644 --- a/src/services/indeehub-api.service.ts +++ b/src/services/indeehub-api.service.ts @@ -130,16 +130,22 @@ class IndeehubApiService { } /** - * Get streaming URL for a content item - * Returns different data based on deliveryMode (native vs partner) + * Get streaming URL for a content item. + * Returns different data based on deliveryMode (native vs partner). + * + * @param contentId - The content or project ID + * @param format - Optional format override. Pass 'raw' to skip HLS and + * get a presigned URL to the original file (useful as a + * fallback when HLS decryption fails). */ - async getStreamingUrl(contentId: string): Promise<{ + async getStreamingUrl(contentId: string, format?: string): Promise<{ url: string deliveryMode: 'native' | 'partner' keyUrl?: string drmToken?: string }> { - const response = await this.client.get(`/contents/${contentId}/stream`) + const params = format ? { format } : undefined + const response = await this.client.get(`/contents/${contentId}/stream`, { params }) return response.data }