Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
258 changes: 130 additions & 128 deletions apps/api/src/app/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,8 +422,8 @@ export class AppController {
);
}

// Construct MinIO object path for playlist
const objectName = `processed-videos/${videoId}/720p/720p.m3u8`;
// Construct MinIO object path for master playlist
const objectName = `processed-videos/${videoId}/index.m3u8`;

try {
// Stream the playlist file from MinIO
Expand Down Expand Up @@ -465,13 +465,12 @@ export class AppController {
}
}

@Get('videos/:videoId/segments/:segment')
@Header('Content-Type', 'video/mp2t')
@Get('videos/:videoId/:resolution/:filename')
@Header('Access-Control-Allow-Origin', '*')
@ApiOperation({
summary: 'Get HLS segment for a video',
summary: 'Get HLS playlist or segment for a video',
description:
'Serves an HLS segment (.ts) file for a processed video. Only available for videos with COMPLETED status.',
'Serves resolution-specific HLS playlists (.m3u8) or segments (.ts) for a processed video. Handles paths like /videos/:videoId/360p/360p.m3u8 or /videos/:videoId/360p/segment-00001.ts',
})
@ApiParam({
name: 'videoId',
Expand All @@ -480,34 +479,33 @@ export class AppController {
example: '123e4567-e89b-12d3-a456-426614174000',
})
@ApiParam({
name: 'segment',
description: 'Segment filename (e.g., segment-00001.ts)',
name: 'resolution',
description: 'Resolution label (e.g., 360p, 720p, 1080p)',
type: 'string',
example: 'segment-00001.ts',
example: '360p',
})
@ApiOkResponse({
description: 'HLS segment file',
content: {
'video/mp2t': {
schema: {
type: 'string',
format: 'binary',
},
},
},
@ApiParam({
name: 'filename',
description: 'Playlist (.m3u8) or segment (.ts) filename',
type: 'string',
example: '360p.m3u8',
})
async getSegment(
async getResolutionFile(
@Param('videoId') videoId: string,
@Param('segment') segment: string,
@Param('resolution') resolution: string,
@Param('filename') filename: string,
@Res() res: Response,
): Promise<void> {
this.logger.log(
`Streaming segment request for video: ${videoId}, segment: ${segment}`,
);

// Validate segment filename to prevent path traversal
if (segment.includes('..') || segment.includes('/') || segment.includes('\\')) {
throw new NotFoundException('Invalid segment filename');
// Validate resolution and filename to prevent path traversal
if (
resolution.includes('..') ||
resolution.includes('/') ||
resolution.includes('\\') ||
filename.includes('..') ||
filename.includes('/') ||
filename.includes('\\')
) {
throw new NotFoundException('Invalid path');
}

// Check if video exists
Expand All @@ -527,34 +525,42 @@ export class AppController {
);
}

// Construct MinIO object path for segment
const objectName = `processed-videos/${videoId}/720p/${segment}`;
// Determine content type based on file extension
const isPlaylist = filename.endsWith('.m3u8');
const contentType = isPlaylist
? 'application/vnd.apple.mpegurl'
: 'video/mp2t';

res.setHeader('Content-Type', contentType);

// Construct MinIO object path
const objectName = `processed-videos/${videoId}/${resolution}/${filename}`;

try {
// Stream the segment file from MinIO
// Stream the file from MinIO
const stream = await this.storageService.downloadStream(objectName);
stream.pipe(res);

stream.on('error', (error) => {
this.logger.error(
`Error streaming segment ${segment} for video ${videoId}: ${error.message}`,
`Error streaming ${filename} for video ${videoId}/${resolution}: ${error.message}`,
);
if (!res.headersSent) {
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
error: 'Failed to stream segment file',
error: `Failed to stream ${isPlaylist ? 'playlist' : 'segment'} file`,
message: error.message,
});
}
});

stream.on('end', () => {
this.logger.log(
`Segment stream completed for video: ${videoId}, segment: ${segment}`,
`Stream completed for video: ${videoId}/${resolution}/${filename}`,
);
});
} catch (error) {
this.logger.error(
`Failed to stream segment ${segment} for video ${videoId}: ${error.message}`,
`Failed to stream ${filename} for video ${videoId}/${resolution}: ${error.message}`,
);

// Check if it's a not found error
Expand All @@ -564,21 +570,21 @@ export class AppController {
error.message?.includes('NotFound')
) {
throw new NotFoundException(
`HLS segment not found: ${segment} for video ${videoId}`,
`${isPlaylist ? 'Playlist' : 'Segment'} not found: ${filename} for video ${videoId}/${resolution}`,
);
}
// Re-throw other errors (will be handled by NestJS error handler)
throw error;
}
}

@Get('videos/:videoId/:segment')
@Get('videos/:videoId/segments/:segment')
@Header('Content-Type', 'video/mp2t')
@Header('Access-Control-Allow-Origin', '*')
@ApiOperation({
summary: 'Get HLS segment for a video (direct path)',
summary: 'Get HLS segment for a video (legacy endpoint)',
description:
'Serves an HLS segment (.ts) file directly under the video path. This handles FFmpeg-generated segment names like 720p0.ts, 720p1.ts, etc.',
'Legacy endpoint for segments. Use /videos/:videoId/:resolution/:filename instead.',
})
@ApiParam({
name: 'videoId',
Expand All @@ -588,9 +594,9 @@ export class AppController {
})
@ApiParam({
name: 'segment',
description: 'Segment filename (e.g., 720p0.ts)',
description: 'Segment filename (e.g., segment-00001.ts)',
type: 'string',
example: '720p0.ts',
example: 'segment-00001.ts',
})
@ApiOkResponse({
description: 'HLS segment file',
Expand All @@ -603,84 +609,100 @@ export class AppController {
},
},
})
async getSegmentDirect(
async getSegment(
@Param('videoId') videoId: string,
@Param('segment') segment: string,
@Res() res: Response,
): Promise<void> {
// Only handle .ts files to avoid conflicts with other routes
if (!segment.endsWith('.ts')) {
throw new NotFoundException('Invalid segment file');
}

this.logger.log(
`Streaming segment (direct) request for video: ${videoId}, segment: ${segment}`,
);

// Validate segment filename to prevent path traversal
if (segment.includes('..') || segment.includes('/') || segment.includes('\\')) {
throw new NotFoundException('Invalid segment filename');
// Legacy endpoint - try 720p first, then other resolutions
const resolutions = ['720p', '360p', '1080p'];

for (const resolution of resolutions) {
try {
const objectName = `processed-videos/${videoId}/${resolution}/${segment}`;
const stream = await this.storageService.downloadStream(objectName);
stream.pipe(res);
return;
} catch {
// Try next resolution
continue;
}
}
throw new NotFoundException(`Segment not found: ${segment} for video ${videoId}`);
}

// Check if video exists
const video = await this.videosService.findOne(videoId);
if (!video) {
this.logger.warn(`Video not found: ${videoId}`);
throw new NotFoundException(`Video with ID ${videoId} not found`);
/**
* Helper method to load HTML files from assets directory.
* Tries multiple possible paths for development and production environments.
*/
private loadHtmlFile(filename: string): string {
const possiblePaths = [
join(__dirname, '../../assets', filename), // Production (from dist/apps/api/app/app)
join(__dirname, '../assets', filename), // Alternative production path
join(process.cwd(), 'apps/api/src/assets', filename), // Development
join(process.cwd(), 'dist/apps/api/assets', filename), // Production from root
];

for (const htmlPath of possiblePaths) {
try {
const html = readFileSync(htmlPath, 'utf-8');
this.logger.log(`HTML file loaded from: ${htmlPath}`);
return html;
} catch {
// Try next path
continue;
}
}
throw new Error(`Could not find ${filename} in any expected location`);
}

// Check if video processing is completed
if (video.status !== VideoStatus.COMPLETED) {
this.logger.warn(
`Video ${videoId} is not ready for streaming. Status: ${video.status}`,
);
throw new ConflictException(
`Video processing not completed. Current status: ${video.status}`,
);
@Get('/')
@Header('Content-Type', 'text/html')
@ApiOperation({
summary: 'Get main dashboard',
description: 'Serves the main dashboard page with video list and upload functionality.',
})
@ApiOkResponse({
description: 'Dashboard HTML page',
content: {
'text/html': {
schema: {
type: 'string',
},
},
},
})
getDashboard(): string {
try {
return this.loadHtmlFile('index.html');
} catch (error) {
this.logger.error(`Failed to load dashboard HTML: ${error.message}`);
throw new Error('Failed to load dashboard page');
}
}

// Construct MinIO object path for segment
const objectName = `processed-videos/${videoId}/720p/${segment}`;

@Get('video')
@Header('Content-Type', 'text/html')
@ApiOperation({
summary: 'Get video viewer page',
description: 'Serves the video viewer page with HLS player and video details.',
})
@ApiOkResponse({
description: 'Video viewer HTML page',
content: {
'text/html': {
schema: {
type: 'string',
},
},
},
})
getVideoViewer(): string {
try {
// Stream the segment file from MinIO
const stream = await this.storageService.downloadStream(objectName);
stream.pipe(res);

stream.on('error', (error) => {
this.logger.error(
`Error streaming segment ${segment} for video ${videoId}: ${error.message}`,
);
if (!res.headersSent) {
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
error: 'Failed to stream segment file',
message: error.message,
});
}
});

stream.on('end', () => {
this.logger.log(
`Segment stream completed for video: ${videoId}, segment: ${segment}`,
);
});
return this.loadHtmlFile('video.html');
} catch (error) {
this.logger.error(
`Failed to stream segment ${segment} for video ${videoId}: ${error.message}`,
);

// Check if it's a not found error
if (
error.message?.includes('not found') ||
error.message?.includes('NoSuchKey') ||
error.message?.includes('NotFound')
) {
throw new NotFoundException(
`HLS segment not found: ${segment} for video ${videoId}`,
);
}
// Re-throw other errors (will be handled by NestJS error handler)
throw error;
this.logger.error(`Failed to load video viewer HTML: ${error.message}`);
throw new Error('Failed to load video viewer page');
}
}

Expand All @@ -703,27 +725,7 @@ export class AppController {
})
getTestPlayer(): string {
try {
// Try multiple possible paths for the HTML file
// In development: assets are in src/assets
// In production: assets are copied to dist/apps/api/assets
const possiblePaths = [
join(__dirname, '../../assets/test-player.html'), // Production (from dist/apps/api/app/app)
join(__dirname, '../assets/test-player.html'), // Alternative production path
join(process.cwd(), 'apps/api/src/assets/test-player.html'), // Development
join(process.cwd(), 'dist/apps/api/assets/test-player.html'), // Production from root
];

for (const htmlPath of possiblePaths) {
try {
const html = readFileSync(htmlPath, 'utf-8');
this.logger.log(`Test player HTML loaded from: ${htmlPath}`);
return html;
} catch {
// Try next path
continue;
}
}
throw new Error('Could not find test-player.html in any expected location');
return this.loadHtmlFile('test-player.html');
} catch (error) {
this.logger.error(`Failed to load test player HTML: ${error.message}`);
throw new Error('Failed to load test player page');
Expand Down
Loading