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) {
|
||||
|
||||
Reference in New Issue
Block a user