From 0917410c9e2131dea2fe540104f24ffa90b44069 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 13 Feb 2026 22:36:41 +0000 Subject: [PATCH] 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 --- backend/src/contents/contents.controller.ts | 124 +++++++++++++++++++- backend/src/contents/contents.service.ts | 30 ++++- docker-compose.yml | 6 +- src/components/VideoPlayer.vue | 99 +++++++++------- 4 files changed, 206 insertions(+), 53 deletions(-) diff --git a/backend/src/contents/contents.controller.ts b/backend/src/contents/contents.controller.ts index ea0f08a..c239970 100644 --- a/backend/src/contents/contents.controller.ts +++ b/backend/src/contents/contents.controller.ts @@ -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( diff --git a/backend/src/contents/contents.service.ts b/backend/src/contents/contents.service.ts index 224f49d..8245745 100644 --- a/backend/src/contents/contents.service.ts +++ b/backend/src/contents/contents.service.ts @@ -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) { diff --git a/docker-compose.yml b/docker-compose.yml index 3cdc6d0..e6c0faf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/src/components/VideoPlayer.vue b/src/components/VideoPlayer.vue index 4b614d1..1c3e766 100644 --- a/src/components/VideoPlayer.vue +++ b/src/components/VideoPlayer.vue @@ -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