Add private S3 HLS playlist rewriting
This commit is contained in:
@@ -6,9 +6,12 @@ import { MediaUrlInterceptor } from './media-url.interceptor';
|
|||||||
describe('MediaUrlInterceptor', () => {
|
describe('MediaUrlInterceptor', () => {
|
||||||
const storageService = {
|
const storageService = {
|
||||||
shouldSignResponseUrls: jest.fn(() => true),
|
shouldSignResponseUrls: jest.fn(() => true),
|
||||||
resolveResponseUrl: jest.fn(async (url: string) =>
|
resolveResponseUrl: jest.fn(async (url: string) => {
|
||||||
url.includes('s3.cumin.dev') ? `${url}?signed=true` : url,
|
if (url.endsWith('.m3u8')) {
|
||||||
),
|
return 'https://api.oudelaa.test/api/v1/media/hls/uploads/posts/hls/master.m3u8';
|
||||||
|
}
|
||||||
|
return url.includes('s3.cumin.dev') ? `${url}?signed=true` : url;
|
||||||
|
}),
|
||||||
} as unknown as MediaStorageService;
|
} as unknown as MediaStorageService;
|
||||||
const interceptor = new MediaUrlInterceptor(storageService);
|
const interceptor = new MediaUrlInterceptor(storageService);
|
||||||
const context = {
|
const context = {
|
||||||
@@ -48,4 +51,25 @@ describe('MediaUrlInterceptor', () => {
|
|||||||
expect(result.media.preferredPlaybackUrl).toContain('?signed=true');
|
expect(result.media.preferredPlaybackUrl).toContain('?signed=true');
|
||||||
expect(result.externalUrl).toBe('https://cdn.example.com/image.jpg');
|
expect(result.externalUrl).toBe('https://cdn.example.com/image.jpg');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the rewritten HLS endpoint while keeping signed MP4 as fallback', async () => {
|
||||||
|
const response = {
|
||||||
|
videoUrl: 'https://s3.cumin.dev/uploads/posts/video/video.mp4',
|
||||||
|
hlsUrl: 'https://s3.cumin.dev/uploads/posts/hls/stream/master.m3u8',
|
||||||
|
media: {
|
||||||
|
videoUrl: 'https://s3.cumin.dev/uploads/posts/video/video.mp4',
|
||||||
|
hlsUrl: 'https://s3.cumin.dev/uploads/posts/hls/stream/master.m3u8',
|
||||||
|
preferredPlaybackUrl: 'https://s3.cumin.dev/uploads/posts/hls/stream/master.m3u8',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const next = { handle: () => of(response) } as CallHandler;
|
||||||
|
|
||||||
|
const result = (await lastValueFrom(interceptor.intercept(context, next))) as typeof response;
|
||||||
|
|
||||||
|
expect(result.hlsUrl).toContain('/api/v1/media/hls/');
|
||||||
|
expect(result.media.hlsUrl).toContain('/api/v1/media/hls/');
|
||||||
|
expect(result.media.preferredPlaybackUrl).toContain('/api/v1/media/hls/');
|
||||||
|
expect(result.videoUrl).toContain('signed=true');
|
||||||
|
expect(result.media.videoUrl).toContain('signed=true');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { AppLoggerService } from '../../infrastructure/logging/app-logger.service';
|
||||||
|
import { HlsPlaylistService } from './hls-playlist.service';
|
||||||
|
import { MediaStorageService } from './media-storage.service';
|
||||||
|
|
||||||
|
describe('HlsPlaylistService', () => {
|
||||||
|
const storageService = {
|
||||||
|
shouldSignResponseUrls: jest.fn(() => true),
|
||||||
|
getBucketName: jest.fn(() => 'oudelaa'),
|
||||||
|
isManagedKey: jest.fn((key: string) => key.startsWith('uploads/')),
|
||||||
|
getObjectText: jest.fn(),
|
||||||
|
getHlsPlaylistUrl: jest.fn((key: string) => `https://api.oudelaa.test/api/v1/media/hls/${key}`),
|
||||||
|
getSignedObjectUrl: jest.fn(
|
||||||
|
async (key: string) => `https://s3.cumin.dev/oudelaa/${key}?X-Amz-Signature=signed`,
|
||||||
|
),
|
||||||
|
resolveKeyFromUrl: jest.fn(() => null),
|
||||||
|
} as unknown as MediaStorageService;
|
||||||
|
const logger = {
|
||||||
|
log: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
} as unknown as AppLoggerService;
|
||||||
|
const service = new HlsPlaylistService(storageService, logger);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewrites master playlist variants through the backend endpoint', async () => {
|
||||||
|
(storageService.getObjectText as jest.Mock).mockResolvedValue(
|
||||||
|
[
|
||||||
|
'#EXTM3U',
|
||||||
|
'#EXT-X-STREAM-INF:BANDWIDTH=800000',
|
||||||
|
'stream_0/playlist.m3u8',
|
||||||
|
'#EXT-X-STREAM-INF:BANDWIDTH=1600000',
|
||||||
|
'stream_1/playlist.m3u8',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.rewritePlaylist('uploads/posts/hls/stream-1/master.m3u8');
|
||||||
|
|
||||||
|
expect(result).toContain(
|
||||||
|
'https://api.oudelaa.test/api/v1/media/hls/uploads/posts/hls/stream-1/stream_0/playlist.m3u8',
|
||||||
|
);
|
||||||
|
expect(result).toContain(
|
||||||
|
'https://api.oudelaa.test/api/v1/media/hls/uploads/posts/hls/stream-1/stream_1/playlist.m3u8',
|
||||||
|
);
|
||||||
|
expect(storageService.getSignedObjectUrl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewrites variant segments and EXT-X-MAP assets to presigned S3 URLs', async () => {
|
||||||
|
(storageService.getObjectText as jest.Mock).mockResolvedValue(
|
||||||
|
[
|
||||||
|
'#EXTM3U',
|
||||||
|
'#EXT-X-MAP:URI="init.mp4"',
|
||||||
|
'#EXTINF:4.0,',
|
||||||
|
'segment-000.m4s',
|
||||||
|
'#EXTINF:4.0,',
|
||||||
|
'segment-001.m4s',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.rewritePlaylist(
|
||||||
|
'uploads/posts/hls/stream-1/stream_0/playlist.m3u8',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toContain(
|
||||||
|
'URI="https://s3.cumin.dev/oudelaa/uploads/posts/hls/stream-1/stream_0/init.mp4?X-Amz-Signature=signed"',
|
||||||
|
);
|
||||||
|
expect(result).toContain(
|
||||||
|
'https://s3.cumin.dev/oudelaa/uploads/posts/hls/stream-1/stream_0/segment-000.m4s?X-Amz-Signature=signed',
|
||||||
|
);
|
||||||
|
expect(storageService.getSignedObjectUrl).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects playlists outside managed storage', async () => {
|
||||||
|
await expect(service.rewritePlaylist('private/master.m3u8')).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { BadGatewayException, BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { posix } from 'path';
|
||||||
|
import { AppLoggerService } from '../../infrastructure/logging/app-logger.service';
|
||||||
|
import { MediaStorageService } from './media-storage.service';
|
||||||
|
|
||||||
|
const PLAYLIST_EXTENSION = '.m3u8';
|
||||||
|
const SIGNED_ASSET_EXTENSIONS = new Set(['.m4s', '.ts', '.mp4', '.aac', '.vtt']);
|
||||||
|
const URI_ATTRIBUTE_PATTERN = /URI=(["'])(.*?)\1/g;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HlsPlaylistService {
|
||||||
|
constructor(
|
||||||
|
private readonly mediaStorageService: MediaStorageService,
|
||||||
|
private readonly appLogger: AppLoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async rewritePlaylist(requestedPath: string): Promise<string> {
|
||||||
|
if (!this.mediaStorageService.shouldSignResponseUrls()) {
|
||||||
|
throw new BadRequestException('Private HLS rewrite is disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = this.normalizePlaylistKey(requestedPath);
|
||||||
|
const bucket = this.mediaStorageService.getBucketName();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
this.appLogger.log(
|
||||||
|
{
|
||||||
|
event: 'hls_playlist_rewrite_started',
|
||||||
|
key,
|
||||||
|
bucket,
|
||||||
|
},
|
||||||
|
HlsPlaylistService.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const playlist = await this.mediaStorageService.getObjectText(key);
|
||||||
|
const rewrittenLines = await Promise.all(
|
||||||
|
playlist.split(/\r?\n/).map((line) => this.rewriteLine(line, key)),
|
||||||
|
);
|
||||||
|
const rewritten = rewrittenLines.join('\n');
|
||||||
|
|
||||||
|
this.appLogger.log(
|
||||||
|
{
|
||||||
|
event: 'hls_playlist_rewrite_completed',
|
||||||
|
key,
|
||||||
|
bucket,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
},
|
||||||
|
HlsPlaylistService.name,
|
||||||
|
);
|
||||||
|
return rewritten;
|
||||||
|
} catch (error) {
|
||||||
|
const errorName = error instanceof Error ? error.name : 'UnknownError';
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
this.appLogger.error(
|
||||||
|
{
|
||||||
|
event: 'hls_playlist_rewrite_failed',
|
||||||
|
key,
|
||||||
|
bucket,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
errorName,
|
||||||
|
errorMessage,
|
||||||
|
},
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
HlsPlaylistService.name,
|
||||||
|
);
|
||||||
|
throw new BadGatewayException('Unable to load HLS playlist');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async rewriteLine(line: string, playlistKey: string): Promise<string> {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmed.startsWith('#')) {
|
||||||
|
return this.rewriteUri(trimmed, playlistKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = Array.from(line.matchAll(URI_ATTRIBUTE_PATTERN));
|
||||||
|
if (!matches.length) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rewritten = line;
|
||||||
|
for (const match of matches) {
|
||||||
|
const originalUri = match[2];
|
||||||
|
const rewrittenUri = await this.rewriteUri(originalUri, playlistKey);
|
||||||
|
rewritten = rewritten.replace(match[0], `URI=${match[1]}${rewrittenUri}${match[1]}`);
|
||||||
|
}
|
||||||
|
return rewritten;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async rewriteUri(uri: string, playlistKey: string): Promise<string> {
|
||||||
|
const targetKey = this.resolveTargetKey(uri, playlistKey);
|
||||||
|
if (!targetKey) {
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = posix.extname(targetKey).toLowerCase();
|
||||||
|
if (extension === PLAYLIST_EXTENSION) {
|
||||||
|
return this.mediaStorageService.getHlsPlaylistUrl(targetKey);
|
||||||
|
}
|
||||||
|
if (!SIGNED_ASSET_EXTENSIONS.has(extension)) {
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedUrl = await this.mediaStorageService.getSignedObjectUrl(targetKey);
|
||||||
|
if (!signedUrl) {
|
||||||
|
throw new Error(`Failed to sign HLS asset "${targetKey}"`);
|
||||||
|
}
|
||||||
|
return signedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveTargetKey(uri: string, playlistKey: string): string | null {
|
||||||
|
const cleanUri = uri.split('#')[0].split('?')[0].trim();
|
||||||
|
if (!cleanUri) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(cleanUri) || cleanUri.startsWith('/')) {
|
||||||
|
const resolvedKey = this.mediaStorageService.resolveKeyFromUrl(cleanUri);
|
||||||
|
return resolvedKey && this.mediaStorageService.isManagedKey(resolvedKey) ? resolvedKey : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let decodedUri = cleanUri;
|
||||||
|
try {
|
||||||
|
decodedUri = decodeURIComponent(cleanUri);
|
||||||
|
} catch {
|
||||||
|
decodedUri = cleanUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetKey = posix.normalize(posix.join(posix.dirname(playlistKey), decodedUri));
|
||||||
|
return targetKey.startsWith('../') ||
|
||||||
|
targetKey === '..' ||
|
||||||
|
!this.mediaStorageService.isManagedKey(targetKey)
|
||||||
|
? null
|
||||||
|
: targetKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizePlaylistKey(requestedPath: string): string {
|
||||||
|
let decodedPath = requestedPath;
|
||||||
|
try {
|
||||||
|
decodedPath = decodeURIComponent(requestedPath);
|
||||||
|
} catch {
|
||||||
|
decodedPath = requestedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = decodedPath.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||||
|
if (
|
||||||
|
!normalized ||
|
||||||
|
!normalized.toLowerCase().endsWith(PLAYLIST_EXTENSION) ||
|
||||||
|
normalized.split('/').some((segment) => !segment || segment === '.' || segment === '..') ||
|
||||||
|
!this.mediaStorageService.isManagedKey(normalized)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Invalid HLS playlist path');
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { HlsPlaylistService } from './hls-playlist.service';
|
||||||
import { MediaStorageService } from './media-storage.service';
|
import { MediaStorageService } from './media-storage.service';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [MediaStorageService],
|
providers: [MediaStorageService, HlsPlaylistService],
|
||||||
exports: [MediaStorageService],
|
exports: [MediaStorageService, HlsPlaylistService],
|
||||||
})
|
})
|
||||||
export class MediaStorageModule {}
|
export class MediaStorageModule {}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ describe('MediaStorageService response URLs', () => {
|
|||||||
'storage.s3.secretAccessKey': 'test-secret-key',
|
'storage.s3.secretAccessKey': 'test-secret-key',
|
||||||
'storage.s3.forcePathStyle': true,
|
'storage.s3.forcePathStyle': true,
|
||||||
publicBaseUrl: 'https://api.oudelaa.test',
|
publicBaseUrl: 'https://api.oudelaa.test',
|
||||||
|
globalPrefix: 'api/v1',
|
||||||
};
|
};
|
||||||
const configService = {
|
const configService = {
|
||||||
get: jest.fn((key: string, defaultValue?: unknown) => config[key] ?? defaultValue),
|
get: jest.fn((key: string, defaultValue?: unknown) => config[key] ?? defaultValue),
|
||||||
@@ -58,6 +59,17 @@ describe('MediaStorageService response URLs', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rewrites a managed HLS URL to the backend playlist endpoint', async () => {
|
||||||
|
const service = new MediaStorageService(configService, appLogger);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.resolveResponseUrl('https://s3.cumin.dev/uploads/posts/hls/stream-1/master.m3u8'),
|
||||||
|
).resolves.toBe(
|
||||||
|
'https://api.oudelaa.test/api/v1/media/hls/uploads/posts/hls/stream-1/master.m3u8',
|
||||||
|
);
|
||||||
|
expect(mockedGetSignedUrl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('leaves external URLs unchanged', async () => {
|
it('leaves external URLs unchanged', async () => {
|
||||||
const service = new MediaStorageService(configService, appLogger);
|
const service = new MediaStorageService(configService, appLogger);
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,10 @@ export class MediaStorageService implements OnModuleDestroy {
|
|||||||
return fileUrl;
|
return fileUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key.toLowerCase().endsWith('.m3u8')) {
|
||||||
|
return this.getHlsPlaylistUrl(key);
|
||||||
|
}
|
||||||
|
|
||||||
return this.getSignedObjectUrl(key);
|
return this.getSignedObjectUrl(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +199,55 @@ export class MediaStorageService implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getObjectText(key: string): Promise<string> {
|
||||||
|
const normalizedKey = this.normalizeKey(key);
|
||||||
|
if (!normalizedKey) {
|
||||||
|
throw new BadRequestException('Invalid media storage key');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.getS3Client().send(
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: this.getBucket(),
|
||||||
|
Key: normalizedKey,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!response.Body) {
|
||||||
|
throw new Error(`S3 object body is empty for key "${normalizedKey}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Body.transformToString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
getHlsPlaylistUrl(key: string): string {
|
||||||
|
const normalizedKey = this.normalizeKey(key);
|
||||||
|
if (!normalizedKey || !normalizedKey.toLowerCase().endsWith('.m3u8')) {
|
||||||
|
throw new BadRequestException('Invalid HLS playlist key');
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicBaseUrl = (
|
||||||
|
this.configService.get<string>('publicBaseUrl', { infer: true }) ?? ''
|
||||||
|
).replace(/\/$/, '');
|
||||||
|
const globalPrefix = (
|
||||||
|
this.configService.get<string>('globalPrefix', { infer: true }) ?? 'api/v1'
|
||||||
|
).replace(/^\/+|\/+$/g, '');
|
||||||
|
const encodedKey = normalizedKey
|
||||||
|
.split('/')
|
||||||
|
.map((segment) => encodeURIComponent(segment))
|
||||||
|
.join('/');
|
||||||
|
const path = `/${globalPrefix}/media/hls/${encodedKey}`;
|
||||||
|
|
||||||
|
return publicBaseUrl ? `${publicBaseUrl}${path}` : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBucketName(): string {
|
||||||
|
return this.getBucket();
|
||||||
|
}
|
||||||
|
|
||||||
|
isManagedKey(key: string): boolean {
|
||||||
|
const normalizedKey = this.normalizeKey(key);
|
||||||
|
return !!normalizedKey && normalizedKey.startsWith(`${this.getBasePath()}/`);
|
||||||
|
}
|
||||||
|
|
||||||
onModuleDestroy(): void {
|
onModuleDestroy(): void {
|
||||||
this.s3Client = null;
|
this.s3Client = null;
|
||||||
this.signedUrlCache.clear();
|
this.signedUrlCache.clear();
|
||||||
|
|||||||
@@ -35,4 +35,15 @@ describe('post media response util', () => {
|
|||||||
expect(media.waveformPeaksPreview).toEqual([]);
|
expect(media.waveformPeaksPreview).toEqual([]);
|
||||||
expect(media.waveformPeaksDetailed).toEqual([]);
|
expect(media.waveformPeaksDetailed).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefers rewritten HLS playback and keeps MP4 as fallback', () => {
|
||||||
|
const media = buildPostMediaResponse({
|
||||||
|
postType: PostType.VIDEO,
|
||||||
|
hlsUrl: 'https://api.oudelaa.test/api/v1/media/hls/uploads/posts/hls/stream/master.m3u8',
|
||||||
|
videoUrl: 'https://s3.cumin.dev/oudelaa/uploads/posts/video/video.mp4?X-Amz-Signature=signed',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(media.preferredPlaybackUrl).toBe(media.hlsUrl);
|
||||||
|
expect(media.videoUrl).toContain('X-Amz-Signature=signed');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,33 @@
|
|||||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Res, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Response } from 'express';
|
||||||
import { Throttle } from '../../common/decorators/throttle.decorator';
|
import { Throttle } from '../../common/decorators/throttle.decorator';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||||
|
import { HlsPlaylistService } from '../../common/media/hls-playlist.service';
|
||||||
import { TextToMusicDto } from './dto/text-to-music.dto';
|
import { TextToMusicDto } from './dto/text-to-music.dto';
|
||||||
import { MediaService } from './media.service';
|
import { MediaService } from './media.service';
|
||||||
|
|
||||||
@ApiTags('Media')
|
@ApiTags('Media')
|
||||||
@Controller('media')
|
@Controller('media')
|
||||||
export class MediaController {
|
export class MediaController {
|
||||||
constructor(private readonly mediaService: MediaService) {}
|
constructor(
|
||||||
|
private readonly mediaService: MediaService,
|
||||||
|
private readonly hlsPlaylistService: HlsPlaylistService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('hls/*')
|
||||||
|
@ApiExcludeEndpoint()
|
||||||
|
async hlsPlaylist(
|
||||||
|
@Param() params: Record<string, string>,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
): Promise<string> {
|
||||||
|
response.type('application/vnd.apple.mpegurl');
|
||||||
|
response.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
return this.hlsPlaylistService.rewritePlaylist(params['0'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
|
|||||||
Reference in New Issue
Block a user