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)
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

View File

@@ -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.

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" />
</svg>
<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
</button>
</div>
@@ -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;
}
}
</style>

View File

@@ -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
}