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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user