diff --git a/backend/src/contents/contents.controller.ts b/backend/src/contents/contents.controller.ts
index c239970..600486e 100644
--- a/backend/src/contents/contents.controller.ts
+++ b/backend/src/contents/contents.controller.ts
@@ -80,6 +80,7 @@ export class ContentsController {
@UseGuards(OptionalHybridAuthGuard)
async stream(
@Param('id') id: string,
+ @Query('format') format: string,
@Req() req: any,
) {
// Try content ID first; if not found, try looking up project to find its content
@@ -94,7 +95,7 @@ export class ContentsController {
}
this.logger.log(
- `[stream] content=${content.id}, file=${content.file}, status=${content.status}, project=${content.project?.id}`,
+ `[stream] content=${content.id}, file=${content.file}, status=${content.status}, project=${content.project?.id}, format=${format ?? 'auto'}`,
);
if (!content.file) {
@@ -116,6 +117,10 @@ export class ContentsController {
const dto = new StreamContentDTO(content);
+ // When ?format=raw is requested, skip HLS and go straight to presigned URL.
+ // This is used as a fallback when the HLS stream fails (e.g. key decryption issues).
+ const forceRaw = format === 'raw';
+
// 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.
@@ -125,17 +130,21 @@ export class ContentsController {
? `${getFileRoute(content.file)}${content.id}/transcoded/file.m3u8`
: `${getFileRoute(content.file)}transcoded/file.m3u8`;
- this.logger.log(`[stream] Checking HLS at bucket=${publicBucket}, key=${outputKey}`);
+ if (!forceRaw) {
+ this.logger.log(`[stream] Checking HLS at bucket=${publicBucket}, key=${outputKey}`);
- const hlsExists = await this.uploadService.objectExists(
- outputKey,
- publicBucket,
- );
+ const hlsExists = await this.uploadService.objectExists(
+ outputKey,
+ publicBucket,
+ );
- if (hlsExists) {
- const publicUrl = getPublicS3Url(outputKey);
- this.logger.log(`[stream] HLS found, returning publicUrl=${publicUrl}`);
- return { ...dto, file: publicUrl };
+ if (hlsExists) {
+ const publicUrl = getPublicS3Url(outputKey);
+ this.logger.log(`[stream] HLS found, returning publicUrl=${publicUrl}`);
+ return { ...dto, file: publicUrl };
+ }
+ } else {
+ this.logger.log(`[stream] Raw format requested, skipping HLS check`);
}
// HLS not available — check that the raw file actually exists before
diff --git a/src/components/ContentDetailModal.vue b/src/components/ContentDetailModal.vue
index 6860379..7c2a8a2 100644
--- a/src/components/ContentDetailModal.vue
+++ b/src/components/ContentDetailModal.vue
@@ -308,8 +308,14 @@ async function checkRentalAccess() {
const result = await libraryService.checkRentExists(contentId)
hasActiveRental.value = result.exists
rentalExpiresAt.value = result.expiresAt ? new Date(result.expiresAt) : null
- } catch (err) {
- console.warn('Rental check failed:', err)
+ } catch (err: any) {
+ // 401/403 means the user's token is expired or missing — not a real
+ // error, just means they don't have rental access. Only log actual
+ // unexpected failures.
+ const status = err?.response?.status
+ if (status !== 401 && status !== 403) {
+ console.warn('Rental check failed:', err)
+ }
// Owner playback is handled by the isOwner computed property,
// so we don't fake a rental here. This keeps the "Rented" badge
// accurate and still shows the rental price for non-owners.
diff --git a/src/components/VideoPlayer.vue b/src/components/VideoPlayer.vue
index 5b2b6f9..f097322 100644
--- a/src/components/VideoPlayer.vue
+++ b/src/components/VideoPlayer.vue
@@ -60,7 +60,7 @@