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.
This commit is contained in:
Dorian
2026-02-14 10:56:37 +00:00
parent 0ae2638af9
commit 674c9f80c5
4 changed files with 129 additions and 55 deletions

View File

@@ -80,6 +80,7 @@ export class ContentsController {
@UseGuards(OptionalHybridAuthGuard) @UseGuards(OptionalHybridAuthGuard)
async stream( async stream(
@Param('id') id: string, @Param('id') id: string,
@Query('format') format: string,
@Req() req: any, @Req() req: any,
) { ) {
// Try content ID first; if not found, try looking up project to find its content // 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( 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) { if (!content.file) {
@@ -116,6 +117,10 @@ export class ContentsController {
const dto = new StreamContentDTO(content); 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. // Check if the HLS manifest actually exists in the public bucket.
// In dev (no transcoding pipeline) the raw upload lives in the // In dev (no transcoding pipeline) the raw upload lives in the
// private bucket only — fall back to a presigned URL for it. // 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)}${content.id}/transcoded/file.m3u8`
: `${getFileRoute(content.file)}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( const hlsExists = await this.uploadService.objectExists(
outputKey, outputKey,
publicBucket, publicBucket,
); );
if (hlsExists) { if (hlsExists) {
const publicUrl = getPublicS3Url(outputKey); const publicUrl = getPublicS3Url(outputKey);
this.logger.log(`[stream] HLS found, returning publicUrl=${publicUrl}`); this.logger.log(`[stream] HLS found, returning publicUrl=${publicUrl}`);
return { ...dto, file: 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 // HLS not available — check that the raw file actually exists before

View File

@@ -308,8 +308,14 @@ async function checkRentalAccess() {
const result = await libraryService.checkRentExists(contentId) const result = await libraryService.checkRentExists(contentId)
hasActiveRental.value = result.exists hasActiveRental.value = result.exists
rentalExpiresAt.value = result.expiresAt ? new Date(result.expiresAt) : null rentalExpiresAt.value = result.expiresAt ? new Date(result.expiresAt) : null
} catch (err) { } catch (err: any) {
console.warn('Rental check failed:', err) // 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, // Owner playback is handled by the isOwner computed property,
// so we don't fake a rental here. This keeps the "Rented" badge // so we don't fake a rental here. This keeps the "Rented" badge
// accurate and still shows the rental price for non-owners. // accurate and still shows the rental price for non-owners.

View File

@@ -60,7 +60,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg> </svg>
<p class="text-white/70 text-sm max-w-sm">{{ streamError }}</p> <p class="text-white/70 text-sm max-w-sm">{{ streamError }}</p>
<button @click="fetchStream" class="text-sm text-orange-400 hover:text-orange-300 transition-colors"> <button @click="() => fetchStream()" class="text-sm text-orange-400 hover:text-orange-300 transition-colors">
Try again Try again
</button> </button>
</div> </div>
@@ -297,7 +297,10 @@ onUnmounted(() => {
// ── Stream fetching ───────────────────────────────────────────────── // ── 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. // Prefer the content entity ID; fall back to the project ID.
// The backend stream endpoint now accepts both — it will look up // The backend stream endpoint now accepts both — it will look up
// content by ID first, then try project ID if not found. // content by ID first, then try project ID if not found.
@@ -309,7 +312,8 @@ async function fetchStream() {
console.log('[VideoPlayer] fetchStream — contentId:', contentId, console.log('[VideoPlayer] fetchStream — contentId:', contentId,
'content.contentId:', props.content?.contentId, 'content.contentId:', props.content?.contentId,
'content.id:', props.content?.id) 'content.id:', props.content?.id,
format ? `format=${format}` : '')
isLoadingStream.value = true isLoadingStream.value = true
streamError.value = null streamError.value = null
@@ -318,7 +322,7 @@ async function fetchStream() {
try { try {
// Try the backend stream endpoint (handles DRM, presigned URLs, etc.) // 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)) console.log('[VideoPlayer] Stream API response:', JSON.stringify(info))
// Check if backend returned an error message inside the response // 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) { function initPlayer(url: string) {
destroyHls() destroyHls()
hlsRecoveryAttempts = 0 hlsRecoveryAttempts = 0
@@ -383,9 +399,13 @@ function initPlayer(url: string) {
if (context.url?.includes('/contents/') && context.url?.includes('/key')) { if (context.url?.includes('/contents/') && context.url?.includes('/key')) {
try { try {
const parsed = new URL(context.url) const parsed = new URL(context.url)
const correctedUrl = `${window.location.origin}${parsed.pathname}` // Only rewrite if the key URL is on a different origin
console.log('[VideoPlayer] Rewriting key URL:', context.url, '→', correctedUrl) // (e.g. CDN domain vs app domain)
context.url = correctedUrl 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 */ } } catch { /* keep original URL */ }
} }
super.load(context, config, callbacks) super.load(context, config, callbacks)
@@ -415,39 +435,39 @@ function initPlayer(url: string) {
}) })
}) })
hls.on(Hls.Events.ERROR, (_event, data) => { 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 || '' const fragUrl = (data as any).frag?.url || (data as any).url || ''
console.error( console.warn(
'[VideoPlayer] HLS error:', data.type, data.details, `[VideoPlayer] HLS fatal error: ${data.type}/${data.details}`,
data.fatal ? '(FATAL)' : '',
fragUrl ? `frag=${fragUrl.substring(0, 120)}` : '', fragUrl ? `frag=${fragUrl.substring(0, 120)}` : '',
(data as any).response ? `status=${(data as any).response.code}` : '', (data as any).response ? `status=${(data as any).response.code}` : '',
) )
if (!data.fatal) return
// Track recovery attempts to avoid infinite loops // Track recovery attempts to avoid infinite loops
hlsRecoveryAttempts++ hlsRecoveryAttempts++
if (hlsRecoveryAttempts > MAX_HLS_RECOVERY_ATTEMPTS) { 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 // fragParsingError usually means the AES-128 key doesn't match
// the file is corrupt / served incorrectly // the encryption used for the segments. Automatically fall back
if (data.details === 'fragParsingError') { // to the raw (non-encrypted) file so playback still works.
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.' if (data.details === 'fragParsingError' && !hlsFailed) {
} else { fallbackToRaw()
streamError.value = 'Playback failed after multiple attempts. Please try again later.' return
} }
// For other errors or if raw fallback already failed, show error
streamError.value = 'Playback failed after multiple attempts. Please try again later.'
destroyHls() destroyHls()
return return
} }
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
console.warn(`[VideoPlayer] HLS network error, retrying... (attempt ${hlsRecoveryAttempts}/${MAX_HLS_RECOVERY_ATTEMPTS})`)
hls?.startLoad() hls?.startLoad()
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
console.warn(`[VideoPlayer] HLS media error, recovering... (attempt ${hlsRecoveryAttempts}/${MAX_HLS_RECOVERY_ATTEMPTS})`)
hls?.recoverMediaError() hls?.recoverMediaError()
} else { } else {
streamError.value = 'Playback error. Please try again.' streamError.value = 'Playback error. Please try again.'
@@ -508,6 +528,7 @@ function resetState() {
isLoadingStream.value = false isLoadingStream.value = false
showSpeedMenu.value = false showSpeedMenu.value = false
playbackRate.value = 1 playbackRate.value = 1
hlsFailed = false
} }
// ── Controls ──────────────────────────────────────────────────────── // ── Controls ────────────────────────────────────────────────────────
@@ -804,13 +825,15 @@ function formatTime(seconds: number): string {
/* Controls */ /* Controls */
.video-controls { .video-controls {
background: linear-gradient(to top, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0.6) 60%, transparent 100%); 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%);
padding: 40px 24px 16px; backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
padding: 48px 24px 20px;
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
z-index: 10; z-index: 20;
} }
.progress-container { .progress-container {
@@ -819,16 +842,16 @@ function formatTime(seconds: number): string {
.progress-bar { .progress-bar {
position: relative; position: relative;
height: 6px; height: 8px;
background: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.25);
border-radius: 3px; border-radius: 4px;
cursor: pointer; cursor: pointer;
margin-bottom: 8px; margin-bottom: 8px;
transition: height 0.15s ease; transition: height 0.15s ease;
} }
.progress-bar:hover { .progress-bar:hover {
height: 8px; height: 12px;
} }
.progress-buffered { .progress-buffered {
@@ -836,8 +859,8 @@ function formatTime(seconds: number): string {
top: 0; top: 0;
left: 0; left: 0;
height: 100%; height: 100%;
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.3);
border-radius: 3px; border-radius: 4px;
} }
.progress-filled { .progress-filled {
@@ -846,18 +869,18 @@ function formatTime(seconds: number): string {
left: 0; left: 0;
height: 100%; height: 100%;
background: #F7931A; background: #F7931A;
border-radius: 3px; border-radius: 4px;
} }
.progress-handle { .progress-handle {
position: absolute; position: absolute;
top: 50%; top: 50%;
transform: translate(-50%, -50%) scale(0); transform: translate(-50%, -50%) scale(0);
width: 14px; width: 16px;
height: 14px; height: 16px;
background: white; background: white;
border-radius: 50%; 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; transition: transform 0.15s ease;
} }
@@ -869,7 +892,7 @@ function formatTime(seconds: number): string {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
font-size: 13px; font-size: 14px;
color: white; color: white;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -894,7 +917,7 @@ function formatTime(seconds: number): string {
} }
.control-btn:hover { .control-btn:hover {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.15);
transform: scale(1.05); transform: scale(1.05);
} }
@@ -941,7 +964,7 @@ function formatTime(seconds: number): string {
/* Content info panel */ /* Content info panel */
.content-info-panel { .content-info-panel {
position: absolute; position: absolute;
bottom: 120px; bottom: 160px;
left: 24px; left: 24px;
max-width: 600px; max-width: 600px;
background: rgba(0, 0, 0, 0.7); 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: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px; border-radius: 16px;
padding: 20px; padding: 20px;
z-index: 10; z-index: 15;
} }
/* Transitions */ /* Transitions */
@@ -989,6 +1012,28 @@ function formatTime(seconds: number): string {
pointer-events: none; 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) { @media (max-width: 768px) {
.content-info-panel { .content-info-panel {
display: none; display: none;
@@ -1003,5 +1048,13 @@ function formatTime(seconds: number): string {
.video-controls { .video-controls {
padding: 30px 16px 12px; padding: 30px 16px 12px;
} }
.progress-bar {
height: 6px;
}
.progress-bar:hover {
height: 10px;
}
} }
</style> </style>

View File

@@ -130,16 +130,22 @@ class IndeehubApiService {
} }
/** /**
* Get streaming URL for a content item * Get streaming URL for a content item.
* Returns different data based on deliveryMode (native vs partner) * 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 url: string
deliveryMode: 'native' | 'partner' deliveryMode: 'native' | 'partner'
keyUrl?: string keyUrl?: string
drmToken?: 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 return response.data
} }