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:
Dorian
2026-02-13 22:36:41 +00:00
parent f715534c06
commit 0917410c9e
4 changed files with 206 additions and 53 deletions

View File

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

View File

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

View File

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

View File

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