Add HLS.js support and enhance content streaming logic
- Integrated HLS.js version 1.6.15 into the project for improved video streaming capabilities. - Updated the ContentsController to check for HLS manifest availability and fall back to presigned URLs for original files if not found. - Enhanced the VideoPlayer component to handle loading and error states more effectively, improving user experience during streaming. - Refactored content service methods to return detailed streaming information, including HLS and DASH manifest URLs.
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
Body,
|
||||
UseInterceptors,
|
||||
Post,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { HybridAuthGuard } from 'src/auth/guards/hybrid-auth.guard';
|
||||
import { Subscriptions } from 'src/subscriptions/decorators/subscriptions.decorator';
|
||||
@@ -23,10 +24,17 @@ import { ContentsInterceptor } from './interceptor/contents.interceptor';
|
||||
import { TokenAuthGuard } from 'src/auth/guards/token.guard';
|
||||
import { StreamContentDTO } from './dto/response/stream-content.dto';
|
||||
import { TranscodingCompletedDTO } from './dto/request/transcoding-completed.dto';
|
||||
import { UploadService } from 'src/upload/upload.service';
|
||||
import { getFileRoute, getPublicS3Url } from 'src/common/helper';
|
||||
|
||||
@Controller('contents')
|
||||
export class ContentsController {
|
||||
constructor(private readonly contentsService: ContentsService) {}
|
||||
private readonly logger = new Logger(ContentsController.name);
|
||||
|
||||
constructor(
|
||||
private readonly contentsService: ContentsService,
|
||||
private readonly uploadService: UploadService,
|
||||
) {}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(HybridAuthGuard)
|
||||
@@ -64,7 +72,36 @@ export class ContentsController {
|
||||
@UseGuards(HybridAuthGuard, SubscriptionsGuard)
|
||||
@Subscriptions(['enthusiast', 'film-buff', 'cinephile'])
|
||||
async stream(@Param('id') id: string) {
|
||||
return new StreamContentDTO(await this.contentsService.stream(id));
|
||||
const content = await this.contentsService.stream(id);
|
||||
const dto = new StreamContentDTO(content);
|
||||
|
||||
// Check if the HLS manifest actually exists in the public bucket.
|
||||
// In dev (no transcoding pipeline) the raw upload lives in the
|
||||
// private bucket only — fall back to a presigned URL for it.
|
||||
const publicBucket = process.env.S3_PUBLIC_BUCKET_NAME || 'indeedhub-public';
|
||||
const outputKey =
|
||||
content.project.type === 'episodic'
|
||||
? `${getFileRoute(content.file)}${content.id}/transcoded/file.m3u8`
|
||||
: `${getFileRoute(content.file)}transcoded/file.m3u8`;
|
||||
|
||||
const hlsExists = await this.uploadService.objectExists(
|
||||
outputKey,
|
||||
publicBucket,
|
||||
);
|
||||
|
||||
if (hlsExists) {
|
||||
return dto;
|
||||
}
|
||||
|
||||
// HLS not available — serve a presigned URL for the original file
|
||||
this.logger.log(
|
||||
`HLS manifest not found for content ${id}, falling back to raw file`,
|
||||
);
|
||||
const presignedUrl = await this.uploadService.createPresignedUrl({
|
||||
key: content.file,
|
||||
expires: 60 * 60 * 4, // 4 hours
|
||||
});
|
||||
return { ...dto, file: presignedUrl };
|
||||
}
|
||||
|
||||
@Post(':id/transcoding')
|
||||
|
||||
@@ -353,25 +353,51 @@ export class BTCPayService implements LightningService {
|
||||
const bolt11 = await this.resolveLightningAddress(address, sats);
|
||||
|
||||
// Pay the BOLT11 via BTCPay's internal Lightning node
|
||||
const payUrl = this.storeUrl('/lightning/BTC/invoices/pay');
|
||||
Logger.log(
|
||||
`Paying ${sats} sats to ${address} via ${payUrl}`,
|
||||
'BTCPayService',
|
||||
);
|
||||
|
||||
const { data } = await axios.post<BTCPayLightningPayResponse>(
|
||||
this.storeUrl('/lightning/BTC/invoices/pay'),
|
||||
payUrl,
|
||||
{ BOLT11: bolt11 },
|
||||
{ headers: this.headers },
|
||||
);
|
||||
|
||||
if (data.result !== 'Ok') {
|
||||
Logger.log(
|
||||
`BTCPay pay response: ${JSON.stringify(data)}`,
|
||||
'BTCPayService',
|
||||
);
|
||||
|
||||
// BTCPay Greenfield API returns different shapes depending on version.
|
||||
// Older versions return { result, errorDetail, paymentHash }.
|
||||
// Newer versions may return the payment hash/preimage at top level
|
||||
// or an empty 200 on success.
|
||||
const result = data?.result;
|
||||
const paymentHash =
|
||||
data?.paymentHash ?? (data as any)?.payment_hash ?? 'btcpay-payment';
|
||||
|
||||
if (result && result !== 'Ok') {
|
||||
throw new Error(
|
||||
`Lightning payment failed: ${data.result} — ${data.errorDetail || 'unknown error'}`,
|
||||
`Lightning payment failed: ${result} — ${data?.errorDetail || 'unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: data.paymentHash || 'btcpay-payment',
|
||||
id: paymentHash,
|
||||
status: 'COMPLETED',
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error('BTCPay sendPayment failed: ' + error.message, 'BTCPayService');
|
||||
throw new Error(error.message);
|
||||
const detail =
|
||||
error.response?.data
|
||||
? JSON.stringify(error.response.data)
|
||||
: error.message;
|
||||
Logger.error(
|
||||
`BTCPay sendPayment failed: ${detail}`,
|
||||
'BTCPayService',
|
||||
);
|
||||
throw new Error(detail);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -161,25 +161,14 @@ export class PaymentService {
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async handlePaymentsCron() {
|
||||
if (
|
||||
process.env.ENVIRONMENT === 'development' ||
|
||||
process.env.ENVIRONMENT === 'local'
|
||||
)
|
||||
return;
|
||||
await Promise.all([
|
||||
this.handlePayment('watch'),
|
||||
this.handlePayment('rent'),
|
||||
]);
|
||||
Logger.log('Payments handled');
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_10AM)
|
||||
async handleDailyPayments() {
|
||||
if (
|
||||
process.env.ENVIRONMENT === 'development' ||
|
||||
process.env.ENVIRONMENT === 'local'
|
||||
)
|
||||
return;
|
||||
await Promise.all([
|
||||
this.handlePayment('watch', 'daily'),
|
||||
this.handlePayment('rent', 'daily'),
|
||||
@@ -188,11 +177,6 @@ export class PaymentService {
|
||||
|
||||
@Cron(CronExpression.EVERY_WEEK)
|
||||
async handleWeeklyPayments() {
|
||||
if (
|
||||
process.env.ENVIRONMENT === 'development' ||
|
||||
process.env.ENVIRONMENT === 'local'
|
||||
)
|
||||
return;
|
||||
await Promise.all([
|
||||
this.handlePayment('watch', 'weekly'),
|
||||
this.handlePayment('rent', 'weekly'),
|
||||
@@ -201,11 +185,6 @@ export class PaymentService {
|
||||
|
||||
@Cron(CronExpression.EVERY_1ST_DAY_OF_MONTH_AT_NOON)
|
||||
async handleMonthlyPayments() {
|
||||
if (
|
||||
process.env.ENVIRONMENT === 'development' ||
|
||||
process.env.ENVIRONMENT === 'local'
|
||||
)
|
||||
return;
|
||||
await Promise.all([
|
||||
this.handlePayment('watch', 'monthly'),
|
||||
this.handlePayment('rent', 'monthly'),
|
||||
@@ -218,9 +197,15 @@ export class PaymentService {
|
||||
) {
|
||||
const satoshiRate = await this.provider.getSatoshiRate();
|
||||
const column = type === 'watch' ? 'pendingRevenue' : 'rentPendingRevenue';
|
||||
|
||||
// Rental prices (and therefore rentPendingRevenue) are denominated in
|
||||
// sats, not USD. Use a 1-sat minimum threshold for rentals instead of
|
||||
// the USD-based satoshiRate.
|
||||
const minThreshold = type === 'rent' ? 1 : satoshiRate;
|
||||
|
||||
const options: FindManyOptions<Shareholder> = {
|
||||
where: {
|
||||
[column]: MoreThanOrEqual(satoshiRate),
|
||||
[column]: MoreThanOrEqual(minThreshold),
|
||||
filmmaker: {
|
||||
paymentMethods: {
|
||||
type: 'LIGHTNING',
|
||||
@@ -230,7 +215,7 @@ export class PaymentService {
|
||||
},
|
||||
},
|
||||
order: {
|
||||
pendingRevenue: 'DESC',
|
||||
[column]: 'DESC',
|
||||
},
|
||||
relations: ['filmmaker', 'filmmaker.paymentMethods'],
|
||||
take: 5,
|
||||
@@ -260,18 +245,27 @@ export class PaymentService {
|
||||
type === 'watch'
|
||||
? shareholder.pendingRevenue
|
||||
: shareholder.rentPendingRevenue;
|
||||
const sats = revenue / satoshiRate;
|
||||
|
||||
const rounded = Math.floor(sats);
|
||||
// Rental revenue is already in sats (rentalPrice is denominated in sats).
|
||||
// Subscription/watch revenue is in USD and must be converted.
|
||||
let rounded: number;
|
||||
let revenueToBeSent: number;
|
||||
|
||||
if (type === 'rent') {
|
||||
rounded = Math.floor(revenue);
|
||||
revenueToBeSent = rounded; // sats in, sats out
|
||||
} else {
|
||||
const sats = revenue / satoshiRate;
|
||||
rounded = Math.floor(sats);
|
||||
const missing = sats - rounded;
|
||||
const missingRevenue = missing * satoshiRate;
|
||||
revenueToBeSent = revenue - missingRevenue;
|
||||
}
|
||||
|
||||
if (rounded <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const missing = sats - rounded;
|
||||
const missingRevenue = missing * satoshiRate;
|
||||
const revenueToBeSent = revenue - missingRevenue;
|
||||
|
||||
const selectedLightningAddress = shareholder.filmmaker.paymentMethods.find(
|
||||
(method) => method.selected && method.type === 'LIGHTNING',
|
||||
);
|
||||
|
||||
@@ -183,7 +183,9 @@ export class RentsService {
|
||||
|
||||
async payShareholders(contentId: string, amount: number) {
|
||||
const total = amount * REVENUE_PERCENTAGE_TO_PAY;
|
||||
await this.shareholdersRepository.update(
|
||||
|
||||
// Try direct content match first
|
||||
const result = await this.shareholdersRepository.update(
|
||||
{
|
||||
contentId,
|
||||
deletedAt: IsNull(),
|
||||
@@ -193,6 +195,57 @@ export class RentsService {
|
||||
`rent_pending_revenue + (cast(share as decimal) / 100.00 * ${total})`,
|
||||
},
|
||||
);
|
||||
|
||||
if (result.affected && result.affected > 0) return;
|
||||
|
||||
// No shareholders found for this specific content — fall back to
|
||||
// project-level lookup. This handles cases where a project has
|
||||
// multiple content records (e.g. re-uploads) but shareholders were
|
||||
// assigned to an earlier content.
|
||||
try {
|
||||
const content = await this.contentsService.findOne(contentId);
|
||||
if (!content?.projectId) return;
|
||||
|
||||
const siblings = await this.shareholdersRepository
|
||||
.createQueryBuilder('sh')
|
||||
.innerJoin('contents', 'c', 'c.id = sh.content_id')
|
||||
.where('c.project_id = :projectId', {
|
||||
projectId: content.projectId,
|
||||
})
|
||||
.andWhere('sh.deleted_at IS NULL')
|
||||
.getMany();
|
||||
|
||||
if (siblings.length === 0) {
|
||||
Logger.warn(
|
||||
`No shareholders found for content ${contentId} or its project`,
|
||||
'RentsService',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Credit revenue to all project-level shareholders
|
||||
const ids = siblings.map((s) => s.id);
|
||||
await this.shareholdersRepository
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({
|
||||
rentPendingRevenue: () =>
|
||||
`rent_pending_revenue + (cast(share as decimal) / 100.00 * ${total})`,
|
||||
})
|
||||
.whereInIds(ids)
|
||||
.execute();
|
||||
|
||||
Logger.log(
|
||||
`Credited ${total} USD to ${ids.length} shareholder(s) via project fallback for content ${contentId}`,
|
||||
'RentsService',
|
||||
);
|
||||
} catch (err) {
|
||||
Logger.error(
|
||||
`Failed to pay shareholders for content ${contentId}: ${err.message}`,
|
||||
err.stack,
|
||||
'RentsService',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CopyObjectCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
DeleteObjectsCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsCommand,
|
||||
S3Client,
|
||||
@@ -173,9 +174,9 @@ export class UploadService {
|
||||
const keyPairId = process.env.CLOUDFRONT_KEY_PAIR_ID;
|
||||
|
||||
// If CloudFront is not configured (MinIO/self-hosted mode),
|
||||
// generate an S3 presigned URL instead
|
||||
// generate an S3 presigned URL that allows GET (streaming/download)
|
||||
if (!privateKey || !keyPairId) {
|
||||
const command = new HeadObjectCommand({
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: process.env.S3_PRIVATE_BUCKET_NAME,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user