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,
|
||||
Logger,
|
||||
Req,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { HybridAuthGuard } from 'src/auth/guards/hybrid-auth.guard';
|
||||
@@ -71,13 +72,35 @@ export class ContentsController {
|
||||
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')
|
||||
@UseGuards(OptionalHybridAuthGuard)
|
||||
async stream(
|
||||
@Param('id') id: string,
|
||||
@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)
|
||||
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)}transcoded/file.m3u8`;
|
||||
|
||||
this.logger.log(`[stream] Checking HLS at bucket=${publicBucket}, key=${outputKey}`);
|
||||
|
||||
const hlsExists = await this.uploadService.objectExists(
|
||||
outputKey,
|
||||
publicBucket,
|
||||
);
|
||||
|
||||
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);
|
||||
this.logger.log(`[stream] HLS found, returning publicUrl=${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(
|
||||
`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({
|
||||
key: content.file,
|
||||
expires: 60 * 60 * 4, // 4 hours
|
||||
});
|
||||
this.logger.log(`[stream] Presigned URL generated (length=${presignedUrl.length})`);
|
||||
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')
|
||||
@UseGuards(TokenAuthGuard)
|
||||
async transcoding(
|
||||
|
||||
@@ -615,8 +615,34 @@ export class ContentsService {
|
||||
}
|
||||
|
||||
async stream(id: string) {
|
||||
const project = await this.findOne(id, ['project', 'captions', 'trailer']);
|
||||
return project;
|
||||
return this.findOne(id, ['project', 'captions', 'trailer']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
|
||||
@@ -20,7 +20,7 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
CACHEBUST: "18"
|
||||
CACHEBUST: "19"
|
||||
VITE_USE_MOCK_DATA: "false"
|
||||
VITE_CONTENT_ORIGIN: ${FRONTEND_URL}
|
||||
VITE_INDEEHUB_API_URL: /api
|
||||
@@ -47,7 +47,7 @@ services:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
CACHEBUST: "10"
|
||||
CACHEBUST: "11"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# ── Core ─────────────────────────────────────────────
|
||||
@@ -179,7 +179,7 @@ services:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile.ffmpeg
|
||||
args:
|
||||
CACHEBUST: "12"
|
||||
CACHEBUST: "13"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ENVIRONMENT: production
|
||||
|
||||
@@ -296,71 +296,72 @@ onUnmounted(() => {
|
||||
// ── Stream fetching ─────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
if (!contentId) {
|
||||
streamError.value = 'Content ID not available.'
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[VideoPlayer] fetchStream — contentId:', contentId,
|
||||
'content.contentId:', props.content?.contentId,
|
||||
'content.id:', props.content?.id)
|
||||
|
||||
isLoadingStream.value = true
|
||||
streamError.value = null
|
||||
|
||||
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)
|
||||
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
|
||||
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
|
||||
await nextTick()
|
||||
initPlayer(streamFile)
|
||||
} 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.
|
||||
// The transcoded HLS files live in the public bucket at a predictable path.
|
||||
// This works because the user already passed the access check before the
|
||||
// player opened (rental verified in ContentDetailModal.handlePlay).
|
||||
const directUrl = buildDirectHlsUrl()
|
||||
if (directUrl) {
|
||||
console.log('Using direct HLS URL:', directUrl)
|
||||
hlsStreamUrl.value = directUrl
|
||||
await nextTick()
|
||||
initPlayer(directUrl)
|
||||
// If the error is auth-related, show a clear message
|
||||
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 {
|
||||
const status = err?.response?.status
|
||||
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)
|
||||
streamError.value = `Unable to load the stream (${status || 'network error'}). Please try again.`
|
||||
}
|
||||
console.error('[VideoPlayer] Failed to fetch stream info:', err)
|
||||
} finally {
|
||||
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) {
|
||||
destroyHls()
|
||||
const video = videoEl.value
|
||||
if (!video) return
|
||||
|
||||
const isHls = url.includes('.m3u8')
|
||||
console.log(`[VideoPlayer] initPlayer — isHls=${isHls}, url=${url.substring(0, 120)}...`)
|
||||
|
||||
if (isHls && Hls.isSupported()) {
|
||||
hls = new Hls({
|
||||
@@ -370,15 +371,19 @@ function initPlayer(url: string) {
|
||||
hls.loadSource(url)
|
||||
hls.attachMedia(video)
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
console.log('[VideoPlayer] HLS manifest parsed, starting playback')
|
||||
video.play().catch(() => {
|
||||
// Autoplay blocked — user needs to click play
|
||||
})
|
||||
})
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
console.error('[VideoPlayer] HLS error:', data.type, data.details, data.fatal ? '(FATAL)' : '')
|
||||
if (data.fatal) {
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||
console.warn('[VideoPlayer] HLS network error, retrying...')
|
||||
hls?.startLoad()
|
||||
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
console.warn('[VideoPlayer] HLS media error, recovering...')
|
||||
hls?.recoverMediaError()
|
||||
} else {
|
||||
streamError.value = 'Playback error. Please try again.'
|
||||
@@ -390,16 +395,26 @@ function initPlayer(url: string) {
|
||||
// Safari native HLS
|
||||
video.src = url
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
video.play().catch(() => {})
|
||||
}, { once: true })
|
||||
} else if (!isHls) {
|
||||
// Direct video file (mp4, mov, webm) — use native <video>
|
||||
video.src = url
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
console.log('[VideoPlayer] Safari HLS loaded, starting playback')
|
||||
video.play().catch(() => {})
|
||||
}, { once: true })
|
||||
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 })
|
||||
} else {
|
||||
streamError.value = 'Your browser does not support HLS video playback.'
|
||||
|
||||
Reference in New Issue
Block a user