fix: add stream diagnostics and project-ID fallback for video playback
- Stream endpoint now accepts both content ID and project ID, falling back to project lookup when content ID is not found - Added /contents/:id/stream-debug diagnostic endpoint that checks file existence in both private and public MinIO buckets - Stream endpoint now verifies raw file exists before generating presigned URL, returning a clear error if file is missing - Added comprehensive logging throughout the stream pipeline - VideoPlayer now logs stream URL, API responses, and playback errors to browser console for easier debugging - Bumped CACHEBUST for frontend (19), API (11), and ffmpeg-worker (13) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Logger,
|
Logger,
|
||||||
Req,
|
Req,
|
||||||
|
NotFoundException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { HybridAuthGuard } from 'src/auth/guards/hybrid-auth.guard';
|
import { HybridAuthGuard } from 'src/auth/guards/hybrid-auth.guard';
|
||||||
@@ -71,13 +72,35 @@ export class ContentsController {
|
|||||||
return new ContentDTO(updatedContent);
|
return new ContentDTO(updatedContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream a content item (by content ID or project ID).
|
||||||
|
* Checks for HLS availability, falls back to presigned raw file URL.
|
||||||
|
*/
|
||||||
@Get(':id/stream')
|
@Get(':id/stream')
|
||||||
@UseGuards(OptionalHybridAuthGuard)
|
@UseGuards(OptionalHybridAuthGuard)
|
||||||
async stream(
|
async stream(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Req() req: any,
|
@Req() req: any,
|
||||||
) {
|
) {
|
||||||
const content = await this.contentsService.stream(id);
|
// Try content ID first; if not found, try looking up project to find its content
|
||||||
|
let content;
|
||||||
|
try {
|
||||||
|
content = await this.contentsService.stream(id);
|
||||||
|
} catch {
|
||||||
|
this.logger.log(
|
||||||
|
`Content not found by ID ${id}, trying as project ID...`,
|
||||||
|
);
|
||||||
|
content = await this.contentsService.streamByProject(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[stream] content=${content.id}, file=${content.file}, status=${content.status}, project=${content.project?.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!content.file) {
|
||||||
|
this.logger.warn(`[stream] Content ${content.id} has no file attached`);
|
||||||
|
return { error: 'No video file uploaded for this content.' };
|
||||||
|
}
|
||||||
|
|
||||||
// Determine if the content is free (no payment required)
|
// Determine if the content is free (no payment required)
|
||||||
const projectPrice = Number(content.project?.rentalPrice ?? 0);
|
const projectPrice = Number(content.project?.rentalPrice ?? 0);
|
||||||
@@ -102,30 +125,119 @@ 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}`);
|
||||||
|
|
||||||
const hlsExists = await this.uploadService.objectExists(
|
const hlsExists = await this.uploadService.objectExists(
|
||||||
outputKey,
|
outputKey,
|
||||||
publicBucket,
|
publicBucket,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hlsExists) {
|
if (hlsExists) {
|
||||||
// Return the public S3 URL for the HLS manifest so the player
|
|
||||||
// can fetch it directly from MinIO/S3 (avoids proxying through
|
|
||||||
// the API and prevents CORS issues with relative segment paths).
|
|
||||||
const publicUrl = getPublicS3Url(outputKey);
|
const publicUrl = getPublicS3Url(outputKey);
|
||||||
|
this.logger.log(`[stream] HLS found, returning publicUrl=${publicUrl}`);
|
||||||
return { ...dto, file: publicUrl };
|
return { ...dto, file: publicUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
// HLS not available — serve a presigned URL for the original file
|
// HLS not available — check that the raw file actually exists before
|
||||||
|
// generating a presigned URL (avoids sending the player an invalid URL)
|
||||||
|
const privateBucket = process.env.S3_PRIVATE_BUCKET_NAME || 'indeedhub-private';
|
||||||
|
const rawExists = await this.uploadService.objectExists(
|
||||||
|
content.file,
|
||||||
|
privateBucket,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rawExists) {
|
||||||
|
this.logger.error(
|
||||||
|
`[stream] Raw file NOT found in MinIO! bucket=${privateBucket}, key=${content.file}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...dto,
|
||||||
|
file: null,
|
||||||
|
error: 'Video file not found in storage. It may still be uploading or the upload failed.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`HLS manifest not found for content ${id}, falling back to raw file`,
|
`[stream] HLS not found, raw file exists. Generating presigned URL for key=${content.file}`,
|
||||||
);
|
);
|
||||||
const presignedUrl = await this.uploadService.createPresignedUrl({
|
const presignedUrl = await this.uploadService.createPresignedUrl({
|
||||||
key: content.file,
|
key: content.file,
|
||||||
expires: 60 * 60 * 4, // 4 hours
|
expires: 60 * 60 * 4, // 4 hours
|
||||||
});
|
});
|
||||||
|
this.logger.log(`[stream] Presigned URL generated (length=${presignedUrl.length})`);
|
||||||
return { ...dto, file: presignedUrl };
|
return { ...dto, file: presignedUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diagnostic endpoint: returns detailed info about a content's streaming state
|
||||||
|
* without actually generating stream URLs. Useful for debugging playback issues.
|
||||||
|
*/
|
||||||
|
@Get(':id/stream-debug')
|
||||||
|
async streamDebug(@Param('id') id: string) {
|
||||||
|
// Try content ID first, then project ID
|
||||||
|
let content;
|
||||||
|
let lookupMethod = 'content-id';
|
||||||
|
try {
|
||||||
|
content = await this.contentsService.stream(id);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
content = await this.contentsService.streamByProject(id);
|
||||||
|
lookupMethod = 'project-id';
|
||||||
|
} catch {
|
||||||
|
return { error: `No content or project found for ID: ${id}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicBucket = process.env.S3_PUBLIC_BUCKET_NAME || 'indeedhub-public';
|
||||||
|
const privateBucket = process.env.S3_PRIVATE_BUCKET_NAME || 'indeedhub-private';
|
||||||
|
|
||||||
|
const outputKey = content.file
|
||||||
|
? (content.project?.type === 'episodic'
|
||||||
|
? `${getFileRoute(content.file)}${content.id}/transcoded/file.m3u8`
|
||||||
|
: `${getFileRoute(content.file)}transcoded/file.m3u8`)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const [rawExists, hlsExists] = await Promise.all([
|
||||||
|
content.file
|
||||||
|
? this.uploadService.objectExists(content.file, privateBucket)
|
||||||
|
: Promise.resolve(false),
|
||||||
|
outputKey
|
||||||
|
? this.uploadService.objectExists(outputKey, publicBucket)
|
||||||
|
: Promise.resolve(false),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
lookupMethod,
|
||||||
|
content: {
|
||||||
|
id: content.id,
|
||||||
|
status: content.status,
|
||||||
|
file: content.file || null,
|
||||||
|
title: content.title,
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
id: content.project?.id,
|
||||||
|
status: content.project?.status,
|
||||||
|
type: content.project?.type,
|
||||||
|
title: content.project?.title,
|
||||||
|
rentalPrice: content.project?.rentalPrice,
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
privateBucket,
|
||||||
|
publicBucket,
|
||||||
|
rawFileKey: content.file || null,
|
||||||
|
rawFileExists: rawExists,
|
||||||
|
hlsManifestKey: outputKey,
|
||||||
|
hlsManifestExists: hlsExists,
|
||||||
|
publicS3Url: outputKey ? getPublicS3Url(outputKey) : null,
|
||||||
|
},
|
||||||
|
pricing: {
|
||||||
|
projectPrice: Number(content.project?.rentalPrice ?? 0),
|
||||||
|
contentPrice: Number(content.rentalPrice ?? 0),
|
||||||
|
isFree: Number(content.project?.rentalPrice ?? 0) <= 0 && Number(content.rentalPrice ?? 0) <= 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/transcoding')
|
@Post(':id/transcoding')
|
||||||
@UseGuards(TokenAuthGuard)
|
@UseGuards(TokenAuthGuard)
|
||||||
async transcoding(
|
async transcoding(
|
||||||
|
|||||||
@@ -615,8 +615,34 @@ export class ContentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async stream(id: string) {
|
async stream(id: string) {
|
||||||
const project = await this.findOne(id, ['project', 'captions', 'trailer']);
|
return this.findOne(id, ['project', 'captions', 'trailer']);
|
||||||
return project;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the best content for a project and return it for streaming.
|
||||||
|
* Falls back when the frontend passes a project ID instead of content ID.
|
||||||
|
*/
|
||||||
|
async streamByProject(projectId: string) {
|
||||||
|
const contents = await this.contentsRepository.find({
|
||||||
|
where: { projectId },
|
||||||
|
relations: ['project', 'captions', 'trailer'],
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!contents.length) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`No content found for project or content ID: ${projectId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick the best content: prefer completed + has file, then processing + has file
|
||||||
|
const best = [...contents].sort((a, b) => {
|
||||||
|
const aCompleted = a.status === 'completed' ? 2 : a.file ? 1 : 0;
|
||||||
|
const bCompleted = b.status === 'completed' ? 2 : b.file ? 1 : 0;
|
||||||
|
return bCompleted - aCompleted;
|
||||||
|
})[0];
|
||||||
|
|
||||||
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
async addInvitedFilmmaker(filmmakerId: string, invite: Invite) {
|
async addInvitedFilmmaker(filmmakerId: string, invite: Invite) {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
CACHEBUST: "18"
|
CACHEBUST: "19"
|
||||||
VITE_USE_MOCK_DATA: "false"
|
VITE_USE_MOCK_DATA: "false"
|
||||||
VITE_CONTENT_ORIGIN: ${FRONTEND_URL}
|
VITE_CONTENT_ORIGIN: ${FRONTEND_URL}
|
||||||
VITE_INDEEHUB_API_URL: /api
|
VITE_INDEEHUB_API_URL: /api
|
||||||
@@ -47,7 +47,7 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
CACHEBUST: "10"
|
CACHEBUST: "11"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# ── Core ─────────────────────────────────────────────
|
# ── Core ─────────────────────────────────────────────
|
||||||
@@ -179,7 +179,7 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile.ffmpeg
|
dockerfile: Dockerfile.ffmpeg
|
||||||
args:
|
args:
|
||||||
CACHEBUST: "12"
|
CACHEBUST: "13"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
ENVIRONMENT: production
|
ENVIRONMENT: production
|
||||||
|
|||||||
@@ -296,71 +296,72 @@ onUnmounted(() => {
|
|||||||
// ── Stream fetching ─────────────────────────────────────────────────
|
// ── Stream fetching ─────────────────────────────────────────────────
|
||||||
|
|
||||||
async function fetchStream() {
|
async function fetchStream() {
|
||||||
|
// 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.
|
||||||
const contentId = props.content?.contentId || props.content?.id
|
const contentId = props.content?.contentId || props.content?.id
|
||||||
if (!contentId) {
|
if (!contentId) {
|
||||||
streamError.value = 'Content ID not available.'
|
streamError.value = 'Content ID not available.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[VideoPlayer] fetchStream — contentId:', contentId,
|
||||||
|
'content.contentId:', props.content?.contentId,
|
||||||
|
'content.id:', props.content?.id)
|
||||||
|
|
||||||
isLoadingStream.value = true
|
isLoadingStream.value = true
|
||||||
streamError.value = null
|
streamError.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try the backend stream endpoint first (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)
|
||||||
|
console.log('[VideoPlayer] Stream API response:', JSON.stringify(info))
|
||||||
|
|
||||||
|
// Check if backend returned an error message inside the response
|
||||||
|
const errorMsg = (info as any).error
|
||||||
|
if (errorMsg) {
|
||||||
|
console.warn('[VideoPlayer] Backend returned error:', errorMsg)
|
||||||
|
streamError.value = errorMsg
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const streamFile = (info as any).file || info.url
|
const streamFile = (info as any).file || info.url
|
||||||
|
if (!streamFile) {
|
||||||
|
console.error('[VideoPlayer] No stream URL in response:', info)
|
||||||
|
streamError.value = 'No video URL returned from server.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[VideoPlayer] Playing URL:', streamFile)
|
||||||
hlsStreamUrl.value = streamFile
|
hlsStreamUrl.value = streamFile
|
||||||
await nextTick()
|
await nextTick()
|
||||||
initPlayer(streamFile)
|
initPlayer(streamFile)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.warn('Stream API failed, trying direct HLS URL fallback:', err?.response?.status || err?.message)
|
const status = err?.response?.status
|
||||||
|
const responseData = err?.response?.data
|
||||||
|
console.warn('[VideoPlayer] Stream API failed:', status, responseData || err?.message)
|
||||||
|
|
||||||
// Fallback: construct the HLS URL directly from the public S3 bucket.
|
// If the error is auth-related, show a clear message
|
||||||
// The transcoded HLS files live in the public bucket at a predictable path.
|
if (status === 403) {
|
||||||
// This works because the user already passed the access check before the
|
streamError.value = 'You need an active subscription or rental to watch this content.'
|
||||||
// player opened (rental verified in ContentDetailModal.handlePlay).
|
} else if (status === 401) {
|
||||||
const directUrl = buildDirectHlsUrl()
|
streamError.value = 'Please sign in to watch this content.'
|
||||||
if (directUrl) {
|
|
||||||
console.log('Using direct HLS URL:', directUrl)
|
|
||||||
hlsStreamUrl.value = directUrl
|
|
||||||
await nextTick()
|
|
||||||
initPlayer(directUrl)
|
|
||||||
} else {
|
} else {
|
||||||
const status = err?.response?.status
|
streamError.value = `Unable to load the stream (${status || 'network error'}). Please try again.`
|
||||||
if (status === 403) {
|
|
||||||
streamError.value = 'You need an active subscription or rental to watch this content.'
|
|
||||||
} else if (status === 401) {
|
|
||||||
streamError.value = 'Please sign in to watch this content.'
|
|
||||||
} else {
|
|
||||||
streamError.value = 'Unable to load the stream. Please try again.'
|
|
||||||
}
|
|
||||||
console.error('Failed to fetch stream info:', err)
|
|
||||||
}
|
}
|
||||||
|
console.error('[VideoPlayer] Failed to fetch stream info:', err)
|
||||||
} finally {
|
} finally {
|
||||||
isLoadingStream.value = false
|
isLoadingStream.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a direct HLS URL from the content's API data.
|
|
||||||
* The public bucket stores transcoded HLS at:
|
|
||||||
* {CDN_URL}/projects/{projectId}/file/transcoded/file.m3u8
|
|
||||||
*/
|
|
||||||
function buildDirectHlsUrl(): string | null {
|
|
||||||
const projectId = props.content?.id
|
|
||||||
if (!projectId) return null
|
|
||||||
|
|
||||||
// Use the CDN/storage URL configured for the self-hosted backend
|
|
||||||
const cdnBase = import.meta.env.VITE_INDEEHUB_CDN_URL || 'http://localhost:9000/indeedhub-public'
|
|
||||||
return `${cdnBase}/projects/${projectId}/file/transcoded/file.m3u8`
|
|
||||||
}
|
|
||||||
|
|
||||||
function initPlayer(url: string) {
|
function initPlayer(url: string) {
|
||||||
destroyHls()
|
destroyHls()
|
||||||
const video = videoEl.value
|
const video = videoEl.value
|
||||||
if (!video) return
|
if (!video) return
|
||||||
|
|
||||||
const isHls = url.includes('.m3u8')
|
const isHls = url.includes('.m3u8')
|
||||||
|
console.log(`[VideoPlayer] initPlayer — isHls=${isHls}, url=${url.substring(0, 120)}...`)
|
||||||
|
|
||||||
if (isHls && Hls.isSupported()) {
|
if (isHls && Hls.isSupported()) {
|
||||||
hls = new Hls({
|
hls = new Hls({
|
||||||
@@ -370,15 +371,19 @@ function initPlayer(url: string) {
|
|||||||
hls.loadSource(url)
|
hls.loadSource(url)
|
||||||
hls.attachMedia(video)
|
hls.attachMedia(video)
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
console.log('[VideoPlayer] HLS manifest parsed, starting playback')
|
||||||
video.play().catch(() => {
|
video.play().catch(() => {
|
||||||
// Autoplay blocked — user needs to click play
|
// Autoplay blocked — user needs to click play
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||||
|
console.error('[VideoPlayer] HLS error:', data.type, data.details, data.fatal ? '(FATAL)' : '')
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||||
|
console.warn('[VideoPlayer] HLS network error, retrying...')
|
||||||
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...')
|
||||||
hls?.recoverMediaError()
|
hls?.recoverMediaError()
|
||||||
} else {
|
} else {
|
||||||
streamError.value = 'Playback error. Please try again.'
|
streamError.value = 'Playback error. Please try again.'
|
||||||
@@ -390,16 +395,26 @@ function initPlayer(url: string) {
|
|||||||
// Safari native HLS
|
// Safari native HLS
|
||||||
video.src = url
|
video.src = url
|
||||||
video.addEventListener('loadedmetadata', () => {
|
video.addEventListener('loadedmetadata', () => {
|
||||||
video.play().catch(() => {})
|
console.log('[VideoPlayer] Safari HLS loaded, starting playback')
|
||||||
}, { once: true })
|
|
||||||
} else if (!isHls) {
|
|
||||||
// Direct video file (mp4, mov, webm) — use native <video>
|
|
||||||
video.src = url
|
|
||||||
video.addEventListener('loadedmetadata', () => {
|
|
||||||
video.play().catch(() => {})
|
video.play().catch(() => {})
|
||||||
}, { once: true })
|
}, { once: true })
|
||||||
video.addEventListener('error', () => {
|
video.addEventListener('error', () => {
|
||||||
streamError.value = 'Unable to play this video format. Please try again.'
|
const err = video.error
|
||||||
|
console.error('[VideoPlayer] Safari HLS error:', err?.code, err?.message)
|
||||||
|
streamError.value = `Playback error (code ${err?.code}): ${err?.message || 'Unknown'}`
|
||||||
|
}, { once: true })
|
||||||
|
} else if (!isHls) {
|
||||||
|
// Direct video file (mp4, mov, webm) — use native <video>
|
||||||
|
console.log('[VideoPlayer] Using native video playback for direct file')
|
||||||
|
video.src = url
|
||||||
|
video.addEventListener('loadedmetadata', () => {
|
||||||
|
console.log('[VideoPlayer] Video metadata loaded, duration:', video.duration)
|
||||||
|
video.play().catch(() => {})
|
||||||
|
}, { once: true })
|
||||||
|
video.addEventListener('error', () => {
|
||||||
|
const err = video.error
|
||||||
|
console.error('[VideoPlayer] Native video error:', err?.code, err?.message)
|
||||||
|
streamError.value = `Unable to play video (code ${err?.code}): ${err?.message || 'Unknown format or network error'}`
|
||||||
}, { once: true })
|
}, { once: true })
|
||||||
} else {
|
} else {
|
||||||
streamError.value = 'Your browser does not support HLS video playback.'
|
streamError.value = 'Your browser does not support HLS video playback.'
|
||||||
|
|||||||
Reference in New Issue
Block a user