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:
Dorian
2026-02-13 00:04:53 +00:00
parent 0da83f461c
commit 7e9a35a963
9 changed files with 706 additions and 262 deletions

View File

@@ -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')

View File

@@ -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);
}
}

View File

@@ -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',
);

View File

@@ -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',
);
}
}
/**

View File

@@ -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,
});