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