From 7e9a35a963fe5691fc77c89f59e4b2812291bbd0 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 13 Feb 2026 00:04:53 +0000 Subject: [PATCH] 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. --- backend/src/contents/contents.controller.ts | 41 +- .../providers/services/btcpay.service.ts | 38 +- .../src/payment/services/payment.service.ts | 52 +- backend/src/rents/rents.service.ts | 55 +- backend/src/upload/upload.service.ts | 5 +- package-lock.json | 7 + package.json | 1 + src/components/VideoPlayer.vue | 758 +++++++++++++----- src/services/content.service.ts | 11 +- 9 files changed, 706 insertions(+), 262 deletions(-) diff --git a/backend/src/contents/contents.controller.ts b/backend/src/contents/contents.controller.ts index 040b440..47ca504 100644 --- a/backend/src/contents/contents.controller.ts +++ b/backend/src/contents/contents.controller.ts @@ -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') diff --git a/backend/src/payment/providers/services/btcpay.service.ts b/backend/src/payment/providers/services/btcpay.service.ts index f0323ae..7457aff 100644 --- a/backend/src/payment/providers/services/btcpay.service.ts +++ b/backend/src/payment/providers/services/btcpay.service.ts @@ -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( - 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); } } diff --git a/backend/src/payment/services/payment.service.ts b/backend/src/payment/services/payment.service.ts index 241d4c4..cbd7cb3 100644 --- a/backend/src/payment/services/payment.service.ts +++ b/backend/src/payment/services/payment.service.ts @@ -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 = { 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', ); diff --git a/backend/src/rents/rents.service.ts b/backend/src/rents/rents.service.ts index 23e917f..cdda366 100644 --- a/backend/src/rents/rents.service.ts +++ b/backend/src/rents/rents.service.ts @@ -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', + ); + } } /** diff --git a/backend/src/upload/upload.service.ts b/backend/src/upload/upload.service.ts index 625a4d0..4044561 100644 --- a/backend/src/upload/upload.service.ts +++ b/backend/src/upload/upload.service.ts @@ -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, }); diff --git a/package-lock.json b/package-lock.json index aa6e630..44a1614 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "applesauce-relay": "^5.1.0", "applesauce-signers": "^5.1.0", "axios": "^1.13.5", + "hls.js": "^1.6.15", "nostr-tools": "^2.23.0", "pinia": "^3.0.4", "qrcode": "^1.5.4", @@ -5748,6 +5749,12 @@ "node": ">= 0.4" } }, + "node_modules/hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" + }, "node_modules/hookable": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", diff --git a/package.json b/package.json index df06b4f..2732ea4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "applesauce-relay": "^5.1.0", "applesauce-signers": "^5.1.0", "axios": "^1.13.5", + "hls.js": "^1.6.15", "nostr-tools": "^2.23.0", "pinia": "^3.0.4", "qrcode": "^1.5.4", diff --git a/src/components/VideoPlayer.vue b/src/components/VideoPlayer.vue index 6735fdd..bae15a4 100644 --- a/src/components/VideoPlayer.vue +++ b/src/components/VideoPlayer.vue @@ -1,7 +1,7 @@