From c1a1d1a79163c62788c5de4d8a162caaac5d35f9 Mon Sep 17 00:00:00 2001 From: Ivan Slabbert Date: Tue, 4 Aug 2026 16:38:59 +0200 Subject: [PATCH 01/60] Update data model, including previous data model changes in datamodel.drawio.svg --- api/src/dto/MediaDto.ts | 33 +- .../diagrams/datamodel.drawio.svg | 1826 +---------------- 2 files changed, 22 insertions(+), 1837 deletions(-) diff --git a/api/src/dto/MediaDto.ts b/api/src/dto/MediaDto.ts index b2e8e05938..36af84f5d6 100644 --- a/api/src/dto/MediaDto.ts +++ b/api/src/dto/MediaDto.ts @@ -1,28 +1,31 @@ import "reflect-metadata"; // https://stackoverflow.com/questions/72009995/typeerror-reflect-getmetadata-is-not-a-function -import { IsArray, IsOptional, IsString, ValidateNested } from "class-validator"; -import { Expose, Type } from "class-transformer"; -import { MediaFileDto } from "./MediaFileDto"; -import { MediaUploadDataDto } from "./MediaUploadDataDto"; +import { IsOptional, IsString } from "class-validator"; +import { Expose } from "class-transformer"; +import { Uuid } from "src/enums"; /** * Database structured Media object */ export class MediaDto { - @IsOptional() @IsString() @Expose() - hlsUrl?: string; + hlsUrl: string; - @IsArray() - @ValidateNested({ each: true }) - @Type(() => MediaFileDto) // This throws an exception on validation failure, so we need to catch the error on validation. The message is less user-friendly but at least the validator fails and will protect our data. + /** + * ID to the CryptoObject where the (optional) encryption key is stored + */ + @IsOptional() + @IsString() @Expose() - fileCollections: MediaFileDto[] = []; + hlsKey_id?: Uuid; + /** + * Optional field for submitting an HLS encryption key for a newly added HLS URL. + * When set, this key is stored as a crypto object, and the crypto object ID is + * exposed as the hlsKey_id. + */ @IsOptional() - @IsArray() - @ValidateNested({ each: true }) - @Type(() => MediaUploadDataDto) // This throws an exception on validation failure, so we need to catch the error on validation. The message is less user-friendly but at least the validator fails and will protect our data. - @Expose() - uploadData?: MediaUploadDataDto[]; + @IsString() + @Expose({ toClassOnly: true }) + hlsKey?: string; } diff --git a/docs/architecture/diagrams/datamodel.drawio.svg b/docs/architecture/diagrams/datamodel.drawio.svg index ad682dd107..3711d56eb7 100644 --- a/docs/architecture/diagrams/datamodel.drawio.svg +++ b/docs/architecture/diagrams/datamodel.drawio.svg @@ -1,1822 +1,4 @@ - - - - - - - - - - -
-
-
-

- - ContentDto - -

-
-

- -

-

- - + parentId - - - : postId/tagId (Uuid) - -

-

- - + parentType: string - -

-

- - + language: languageId - -

-

- - + status: predefined string -
-
-

-

- + slug: string -

-

- + title: string -

-

- + summary: string -

-

- + author: authorId (future) -
-

-

- + text: string -

-

- + seoTitle: string -

-

- + seoSummary: string -

-

- + video: string (deprecated) -

-

- - + publishDate: number - -
-

-

- + expiryDate?: number -

-

- + parentTags?: [tagId] -

-

- + parentImage?: string -

-

- + parentImageData?: ImageDto -

-

- + parentTagType?: string -

-

- + parentPinned?: number -

-

- + parentTaggedDocs?: Uuid[] -

-

- + parentMedia?: MediaDto -

-

- + fts?: ["token:tf"] -

-

-

- + ftsTokenCount?: number -

-
-
-
-
- - ContentDto... - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-

- - ImageDto - -

-
-

- - + fileCollections: [ImageFileCollectionDto] - -
-

-

- + uploadData?: [ImageUploadDataDto] -

-
-

-
-

-
-
-
-
- - ImageDto... - -
-
-
- - - - - - - -
-
-
-

- - LanguageDto - -

-
-

- - + languageCode: string - -
-

-

- + name: string -

-

- + default?: predefined number (0: true, 1: false) -

-
-
-
-
- - LanguageDto... - -
-
-
- - - - - - - -
-
-
-

- - event -> extends post - -

-
-

- - + event_date: datetime - -
-

-

- - + duration: number - -

-

- - + fullday: boolean - -

-

- - + recurrence: event_recurrence - -

-
-
-
-
- - event -> extends post... - -
-
-
- - - - - - - -
-
-
-

- - event_recurrence - -

-
-

- + type: event_recurrence_type -
-

-

- + day_of_week: number -

-
-

-
-

-
-
-
-
- - event_recurrence... - -
-
-
- - - - - - - - -
-
-
- How will event queries be done to create a calendar for a given time period? -
-
-
-
- - How will event queries b... - -
-
-
- - - - - - - - -
-
-
- Should embedded content be linked to a post, or just rendered when rendering post content (dynamically loaded)? -
-
-
-
- - Should embedded content... - -
-
-
- - - - - - - - -
-
-
- Bookmarks? -
-
-
-
- - Bookmarks? - -
-
-
- - - - - - - - -
-
-
- Best practices: -
- Avoid to have text on images - text should rather be generated to avoid "missing text" when an image is not cached offline. -
-
-
-
- - Best practices:... - -
-
-
- - - - - - - -
-
-
-

- - PostDto - -

-
-

- - + postType: predefined string - -

-
-
-
-
- - PostDto... - -
-
-
- - - - - - - - -
-
-
- Subscriptions: -
- List of subscriptions to be available to user. Some subscriptions can be automatically selected based on e.g. region, login, etc. -
-
-
-
- - Subscriptions:... - -
-
-
- - - - - - - - - - - - - - - -
-
-
-

- - MediaDto - -

-
-

- + mediaType: MediaType (audio etc) - -
-
-

-

- - + url: string - -

-
-
-
-
- - MediaDto... - -
-
-
- - - - - - - -
-
-
-

- - VideoDto - -

-
-

- - + image: imageId - -
-

-

- - + downloads: [MediaDownload] - -

-
-

-
-

-
-
-
-
- - VideoDto... - -
-
-
- - - - - - - -
-
-
-

- - AudioDto - -

-
-

- - + image: imageId - -
-

-

- - + downloads: [MediaDownload] - -

-
-

-
-

-
-
-
-
- - AudioDto... - -
-
-
- - - - - - - -
-
-
-

- - MediaDownloadDto - -

-
-

- - + language: Language - -
-

-

- - + url: string - -

-
-

-
-

-
-
-
-
- - MediaDownloadDto... - -
-
-
- - - - - - - - -
-
-
- - Future - -
-
-
-
- - Future - -
-
-
- - - - - - - -
-
-
-

- - UserDto - -

-
-

- - + email: string - -

-

- - + name: string - - -
-
-

-

- - + lastLoggedIn: number - -

-
-
-
-
- - UserDto... - -
-
-
- - - - - - - -
-
-
-

- - GroupDto - -

-
-

- + name: string -

-

- - + acl: [{type, groupId, [permission]}] - -

- -
-
-
-
-
-
- - GroupDto... - -
-
-
- - - - - - - - - - - -
-
-
-

- - TagDto - -

-
-

- - + tagType: predefined string - -

-

- - + pinned: number (0 or 1) - -

-

- - + taggedDocs: Uuid[] - -

-

-
-

-

-
-
-
-
- - TagDto... - -
-
-
- - - - - - - - -
-
-
- Embedded content: Could be video, audio, or even (link to) -
- another post - so we don't have a specific "related posts" feature (in cases that can't be handled with tags) -
-
-
-
- - Embedded content: Could be... - -
-
-
- - - - - - - - -
-
-
- - To be discussed: -
-
- How are we going to do subscriber content (e.g. subscribe to available event calendars, etc.)? -
-
- Probably the best to use categories for that? -
-
-
-
- - To be discussed:... - -
-
-
- - - - - - - - - - - -
-
-
-

- - _contentBaseDto - -

-
-

- - + memberOf: [groupId] - -
-

-
-

-
-

-
-
-
-
- - _contentBaseDto... - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - -
-
-
-

- - _baseDto - -

-
-

- + _id: Uuid -

-

- + type: string -

-

- + updatedTimeUtc?: number -

-

- + deleteReq?: number (1 = true) -

-

- + updatedBy: string (Uuid) -

-
-

-
-

-
-
-
-
- - _baseDto... - -
-
-
- - - - - - - - -
-
-
- change referred documents key(s) to xxxId -
-
-
-
- - change referred... - -
-
-
- - - - - - - -
-
-
-

- - ChangeReqDto - -

-
- + id: Uuid -
- + doc: {} -
-
-

-
-

-
-
-
-
- - ChangeReqDto + id: Uuid... - -
-
-
- - - - - - - -
-
-
-

- - ChangeReqAckDto - -

-
- + id: Uuid -
- + type: string -
- + ack: string -
- + message: string -
-
-

-
-

-
-
-
-
- - ChangeReqAckDto + id: Uuid... - -
-
-
- - - - - - - -
-
-
-

- - ApiDataResponseDto - -

-
- + docs: any[] -
- + version?: number -
-
-

-
-

-
-
-
-
- - ApiDataResponseDto + docs: any[]... - -
-
-
- - - - - - - -
-
-
-

- - ClientDataRequestDto - -

-
- + version: number -
- + cms?: boolean -
- + accessMap?: AccessMap -
-
-

-
-

-
-
-
-
- - ClientDataRequestDto + version: nu... - -
-
-
- - - - - - - - - - - - - - - -
-
-
-

- - ImageFileCollectionDto - -

-
-

- - + aspectRatio: number - -
-

-

- + files: [ImageFileDto] -

-
-

-
-

-
-
-
-
- - ImageFileCollectionDto... - -
-
-
- - - - - - - - - - - -
-
-
-

- - ImageUploadDataDto - -

-
-

- + fileData: buffer -

-

- + preset: string -
-

-
-

-
-

-
-
-
-
- - ImageUploadDataDto... - -
-
-
- - - - - - - -
-
-
-

- - ImageFileDto - -

-
-

- + width: number -

-

- + height: number -

-

- + fileName: string -

-
-

-
-

-
-
-
-
- - ImageFileDto... - -
-
-
- - - - - - - -
-
-
-

- - _contentParentDto - -

-
-

- + imageData: ImageDto -

-

- + tags: Uuid[] -

-

- - + publishDateVisible - - : - - boolean - - ; -
- + media?: MediaDto -
-
-
-
-
- - _contentParentDto... - -
-
-
- - - - - - - - - - - - - - - - -
-
-
- Note: Do not use boolean values if these need to be indexed by IndexedDb -
-
-
-
- - Note: Do not use... - -
-
-
- - - - - - - -
-
-
-

- - RedirectDto - -

-
-

- - + redirectType: predefined string - -

-

- - + slug: string - - -
-
-

-

- - + toSlug?: string - -

-
-
-
-
- - RedirectDto... - -
-
-
- - - - - - - - -
-
-
- if "toSlug" is empty the user will be redirected to the homepage -
-
-
-
- - if "toSlug" is empty the... - -
-
-
- - - - - - - -
-
-
-

- - DeleteCmdDto - -

-
-

- + docId: Uuid -
-

-

- + docType: string -

-

- + deleteReason: "deleted" | "permissionChange" | "statusChange" -

- + memberOf?: [groupId] -
- + newMemberOf?: [groupId] -
-
-

-
-

-
-
-
-
- - DeleteCmdDto... - -
-
-
- - - - - - - -
-
-
-

- - MediaDto - -

-
-

- - + hlsUrl?: string - -
-

-

- + fileCollections: [MediaFileDto] -

-

- + uploadData?: [MediaUploadDataDto] -

-
-

-
-

-
-
-
-
- - MediaDto... - -
-
-
- - - - - - - -
-
-
-

- - MediaUploadDataDto - -

-
-

- + fileData: buffer -

-

- + mediaType: "video" | "audio" -

-

- + preset: "speech" | "music" | "default" -
-

-
-

-
-

-
-
-
-
- - MediaUploadDataDto... - -
-
-
- - - - - - - - - - - -
-
-
-

- - MediaFileDto - -

-
-

- - + languageId: string - -
-

-

- + fileUrl?: string -

-

- + bitrate: number -

-

- + mediaType: "audio" | "video" -

-

- + processingProgress?: number -

-
-

-
-

-
-
-
-
- - MediaFileDto... - -
-
-
- - - - - - - - - - - -
-
-
-

- - AuthProviderDto - -

-
- - - + memberOf?: Uuid[] - -
- - + domain: string - -
- - + audience: string - -
- - + clientId: string - -
-
- - - + configId: UUID -
-
- - + label?: string - -
- - + icon?: string - -
- - + backgroundColor?: string - -
- - + textColor?: string - -
- - + iconOpacity?: number - -
- - + imageBucketId?: string - -
- - + imageData?: ImageDto - -
-
-
-
-
-
- - AuthProviderDto + memberOf?: Uuid[... - -
-
-
- - - - - - - - - - - -
-
-
-

- - AuthProviderConfigDto - -

-
- - - - + memberOf?: Uuid[] - -
- - + claimNamespace?: string - -
- - + groupMappings?: - - - AuthProviderGroupMapping[] - -
- - + userFieldMappings?: { - - - - - externalUserId?, - -
-
-
- - - - - - email?, - - - -
-
- - - - - - name?, - - - -
-
-
- - - - emailVerified? - - - -
-
- - - - } - - - -
-

-

-
-
-
-
-
- - AuthProviderConfigDto + memberOf?:... - -
-
-
- - - - - - - - - - - -
-
-
-

- - AuthProviderGroupMapping - -

-
- - - - - - + groupId: string - -
- - + conditions: AuthProviderCondition[] - -
-
-
-

-
-

-
-
-
-
- - AuthProviderGroupMapping + groupId: string... - -
-
-
- - - - - - - -
-
-
-

- - AuthProviderCondition - -

-
- - - - + type: "authenticated" | "claimEquals" | "claimIn" - -
- - + claimPath?: string - -
- - + value?: string | string[] - -
- - + values?: string[] - -
-
-
-

-
-

-
-
-
-
- - AuthProviderCondition+ type: "authenticate... - -
-
-
- - - - -
- - - - - Text is not SVG - cannot display - - - -
\ No newline at end of file + + + +

ContentDto


+ parentId: postId/tagId (Uuid)

+ parentType: string

+ language: languageId

+ status: predefined string

+ slug: string

+ title: string

+ summary: string

+ author: authorId (future)

+ text: string

+ seoTitle: string

+ seoSummary: string

+ video: string (deprecated)

+ publishDate: number

+ expiryDate?: number

+ parentTags?: [tagId]

+ parentImage?: string

+ parentImageData?: ImageDto

+ parentTagType?: string

+ parentPinned?: number

+ parentTaggedDocs?: Uuid[]

+ parentMedia?: MediaDto

+ fts?: ["token:tf"]

+ ftsTokenCount?: number

ContentDto...

ImageDto


+ fileCollections: [ImageFileCollectionDto]

+ uploadData?: [ImageUploadDataDto]



ImageDto...

LanguageDto


+ languageCode: string

+ name: string

+ default?: predefined number (0: true, 1: false)

LanguageDto...

PostDto


+ postType: predefined string

PostDto...

UserDto


+ email: string

+ name: string

+ lastLoggedIn: number

UserDto...

GroupDto


+ name: string

+ acl: [{type, groupId, [permission]}]


GroupDto...

TagDto


+ tagType: predefined string

+ pinned: number (0 or 1)

+ taggedDocs: Uuid[]


TagDto...

_contentBaseDto


+ memberOf: [groupId]



_contentBaseDto...

_baseDto


+ _id: Uuid

+ type: string

+ updatedTimeUtc?: number

+ deleteReq?: number (1 = true)

+ updatedBy: string (Uuid)



_baseDto...

ChangeReqDto


 + id: Uuid
 + doc: {}


ChangeReqDto + id: Uuid...

ChangeReqAckDto


 + id: Uuid
 + type: string
 + ack: string
 + message: string


ChangeReqAckDto + id: Uuid...

ApiDataResponseDto


 + docs: any[]
 + version?: number


ApiDataResponseDto + docs: any[]...

ClientDataRequestDto


 + version: number
 + cms?: boolean
 + accessMap?: AccessMap


ClientDataRequestDto + version: nu...

ImageFileCollectionDto


+ aspectRatio: number

+ files: [ImageFileDto]



ImageFileCollectionDto...

ImageUploadDataDto


+ fileData: buffer

+ preset: string



ImageUploadDataDto...

ImageFileDto


+ width: number

+ height: number

+ fileName: string



ImageFileDto...

_contentParentDto


+ imageData: ImageDto

+ imageBucketId?: string

+ tags: Uuid[]

 + publishDateVisible: boolean
 + media?: MediaDto
 + mediaBucketId?: string
_contentParentDto...
Note: Do not use boolean values if these need to be indexed by IndexedDb
Note: Do not use...

RedirectDto


+ redirectType: predefined string

+ slug: string

+ toSlug?: string

RedirectDto...

DeleteCmdDto


+ docId: Uuid

+ docType: string

+ deleteReason: "deleted" | "permissionChange" | "statusChange"

 + memberOf?: [groupId]
 + newMemberOf?: [groupId]


DeleteCmdDto...

MediaDto


+ hlsUrl: string

+ hlsKey_id?: string (id to cryptoObject containing the encryption key)

+ hlsKey?: string (slot for key submission)



MediaDto...

AuthProviderDto


 + memberOf?: Uuid[]
 + domain: string
 + audience: string
 + clientId: string
 + configId: UUID
 + label?: string
 + icon?: string
 + backgroundColor?: string
 + textColor?: string
 + iconOpacity?: number
 + imageBucketId?: string
 + imageData?: ImageDto
AuthProviderDto + memberOf?: Uuid[...

AuthProviderConfigDto


 + memberOf?: Uuid[]
 + claimNamespace?: string
 + groupMappings?:    AuthProviderGroupMapping[]
 + userFieldMappings?: {    externalUserId?, 
 email?, 
       name?, 
          emailVerified? 
  }

   

AuthProviderConfigDto + memberOf?:...

AuthProviderGroupMapping


 + groupId: string
  + conditions: AuthProviderCondition[]


AuthProviderGroupMapping + groupId: string...

AuthProviderCondition


+ type: "authenticated" | "claimEquals" | "claimIn"
+ claimPath?: string
+ value?: string | string[]
+ values?: string[]


AuthProviderCondition+ type: "authenticate...

StorageDto


+ name: string;

+ mimeTypes:[string]

+ bucketType: "image" | "media"

+ publicUrl: string;

+ credential_id?: string

+ credential?: S3CredentialDto 




StorageDto...

CryptoDto


+ data: data (encrypted)





CryptoDto...

S3CredentialDto


+ endpoint: string;

+ bucket: string

+ accessKey: string; (encrypted)

+ secretKey: string; (encrypted)



S3CredentialDto...
Text is not SVG - cannot display
\ No newline at end of file From 8391a50669f21b592298103ea1911615fd8a1c6f Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Fri, 14 Aug 2026 08:05:40 +0200 Subject: [PATCH 02/60] feat(media): integrate Luminary Media Convert for audio encoding - Refactored MediaEditor component to remove legacy media upload functionality. - Added useMediaEncoder composable to manage encoding sessions with Luminary Media Convert. - Implemented health checks and session management for the encoder. - Created utility functions for handling encoder session keys and events. - Updated API to fetch encoder configuration for media buckets. - Enhanced media DTO to include HLS URL and key for encoded media. - Added tests for media encoder utilities and session management. --- api/src/app.module.ts | 2 + .../processMediaDto.spec.ts | 397 ++---------- .../documentProcessing/processMediaDto.ts | 456 +------------- .../processPostTagDto.spec.ts | 119 +--- .../documentProcessing/processPostTagDto.ts | 51 +- api/src/endpoints/encoderConfig.controller.ts | 128 ++++ app/src/components/content/ContentTile.vue | 10 +- .../components/content/VideoPlayer.spec.ts | 14 +- app/src/components/content/VideoPlayer.vue | 17 +- app/src/pages/SingleContent/SingleContent.vue | 3 +- app/src/util/videoSource.spec.ts | 59 ++ app/src/util/videoSource.ts | 26 + cms/src/components/content/EditContent.vue | 6 +- .../components/content/EditContentMedia.vue | 135 ++--- .../content/EditContentVideo.spec.ts | 122 ++-- .../components/content/EditContentVideo.vue | 88 ++- .../media/EncodeMediaButton.spec.ts | 116 ++++ .../components/media/EncodeMediaButton.vue | 162 +++++ cms/src/components/media/MediaEditor.spec.ts | 304 +++------- cms/src/components/media/MediaEditor.vue | 569 +++--------------- cms/src/composables/useMediaEncoder.ts | 141 +++++ cms/src/util/mediaEncoder.spec.ts | 146 +++++ cms/src/util/mediaEncoder.ts | 190 ++++++ shared/src/api/RestApi.ts | 27 + shared/src/types/dto.ts | 2 + 25 files changed, 1474 insertions(+), 1816 deletions(-) create mode 100644 api/src/endpoints/encoderConfig.controller.ts create mode 100644 app/src/util/videoSource.spec.ts create mode 100644 app/src/util/videoSource.ts create mode 100644 cms/src/components/media/EncodeMediaButton.spec.ts create mode 100644 cms/src/components/media/EncodeMediaButton.vue create mode 100644 cms/src/composables/useMediaEncoder.ts create mode 100644 cms/src/util/mediaEncoder.spec.ts create mode 100644 cms/src/util/mediaEncoder.ts diff --git a/api/src/app.module.ts b/api/src/app.module.ts index ff1ea16511..25df9b3e6b 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -15,6 +15,7 @@ import { QueryController } from "./endpoints/query.controller"; import { FtsSearchService } from "./endpoints/ftsSearch.service"; import { FtsSearchController } from "./endpoints/ftsSearch.controller"; import { StorageStatusController } from "./endpoints/storageStatus.controller"; +import { EncoderConfigController } from "./endpoints/encoderConfig.controller"; import { AuthIdentityService } from "./auth/authIdentity.service"; import { QueryRateLimiterService } from "./ratelimit/queryRateLimiter.service"; @@ -57,6 +58,7 @@ if (!process.env.NODE_ENV || process.env.NODE_ENV === "development") { QueryController, FtsSearchController, StorageStatusController, + EncoderConfigController, ], providers: [ DbService, diff --git a/api/src/changeRequests/documentProcessing/processMediaDto.spec.ts b/api/src/changeRequests/documentProcessing/processMediaDto.spec.ts index ddf28f8195..a7456ab5c6 100644 --- a/api/src/changeRequests/documentProcessing/processMediaDto.spec.ts +++ b/api/src/changeRequests/documentProcessing/processMediaDto.spec.ts @@ -1,393 +1,80 @@ import { processMedia } from "./processMediaDto"; -import { S3Service } from "../../s3/s3.service"; import { createTestingModule } from "../../test/testingModule"; -import * as fs from "fs"; -import * as path from "path"; -import { v4 as uuidv4 } from "uuid"; import { MediaDto } from "../../dto/MediaDto"; -import { MediaPreset, MediaType, DocType, StorageType } from "../../enums"; import { DbService } from "../../db/db.service"; -import { storeCryptoData } from "../../util/encryption"; -import { s3TestConfig, createTestCredentials } from "../../test/s3TestConfig"; +import { retrieveCryptoData } from "../../util/encryption"; + +const HLS_URL = "https://cdn.example.com/media/post-1/master.m3u8"; +const HLS_KEY = "0123456789abcdef0123456789abcdef"; describe("processMediaDto", () => { let db: DbService; - let s3Service: S3Service; - let testBucketId: string; - let testBucket: string; - const resMedia: MediaDto[] = []; - - const testCredentials = createTestCredentials(); beforeAll(async () => { const module = await createTestingModule("process-media-dto"); db = module.dbService; - - testBucket = `test-media-${uuidv4()}`; - testBucketId = `storage-test-${uuidv4()}`; - testCredentials.bucketName = testBucket; - - // Create encrypted credentials for the test bucket - const encryptedCredId = await storeCryptoData(db, testCredentials); - - // Create a bucket document - const bucketDoc = { - _id: testBucketId, - type: DocType.Storage, - name: "Test Media Bucket", - mimeTypes: ["audio/*"], - publicUrl: `${s3TestConfig.publicUrl}/${testBucket}`, - storageType: StorageType.Media, - credential_id: encryptedCredId, - memberOf: ["group-super-admins"], - updatedTimeUtc: Date.now(), - }; - - await db.upsertDoc(bucketDoc); - - // Create S3Service instance and create the bucket - s3Service = await S3Service.create(testBucketId, db); - await s3Service.makeBucket(); }); - afterAll(async () => { - // Cleanup uploaded media files - const removeFiles = Array.from( - new Set( - resMedia.flatMap((r) => - r.fileCollections.map((f) => f.fileUrl.split("/").pop()!).filter(Boolean), - ), - ), - ); - if (removeFiles.length > 0) { - try { - await s3Service.removeObjects(removeFiles); - } catch { - // Ignore errors during cleanup - } - } - try { - await s3Service.removeBucket(); - } catch { - // Ignore errors if bucket is not empty or doesn't exist - } - - // Clean up storage document - if (testBucketId) { - const storageDoc = (await db.getDoc(testBucketId)).docs[0]; - if (storageDoc) { - storageDoc.deleteReq = 1; - await db.upsertDoc(storageDoc); - } - } + it("stores a submitted HLS key as a crypto object and keeps only the reference", async () => { + const media: MediaDto = { hlsUrl: HLS_URL, hlsKey: HLS_KEY }; - S3Service.clearCache(); - }); + const warnings = await processMedia(media, db); - it("should be defined", () => { - expect(processMedia).toBeDefined(); + expect(warnings).toEqual([]); + expect(media.hlsKey_id).toBeDefined(); + // The key itself must not survive onto the document. + expect(media.hlsKey).toBeUndefined(); + expect(media.hlsUrl).toBe(HLS_URL); }); - it("can process and upload a media file", async () => { - const media = new MediaDto(); - media.fileCollections = []; - media.uploadData = [ - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-eng", - }, - ]; - const warnings = await processMedia(media, undefined, db, testBucketId); - expect(warnings.warnings.length).toBe(0); + it("stores a key that can be read back and decrypted", async () => { + const media: MediaDto = { hlsUrl: HLS_URL, hlsKey: HLS_KEY }; - // Check if files are uploaded (allow informational warnings like S3_PUBLIC_ACCESS_URL not configured) - const files = media.fileCollections.map((f) => f.fileUrl.split("/").pop()!); - expect(files.length).toBeGreaterThan(0); + await processMedia(media, db); - for (const file of files) { - const exists = await s3Service.objectExists(file); - expect(exists).toBe(true); - } - resMedia.push(media); + await expect(retrieveCryptoData(db, media.hlsKey_id!)).resolves.toBe(HLS_KEY); }); - it("can delete a removed media from S3", async () => { - const media = new MediaDto(); - media.fileCollections = []; - media.uploadData = [ - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-eng", - }, - ]; - await processMedia(media, undefined, db, testBucketId); - const originalFiles = media.fileCollections.map((f) => f.fileUrl.split("/").pop()!); - - // Simulate removing the media - const prevMedia = JSON.parse(JSON.stringify(media)) as MediaDto; - media.fileCollections = []; + it("gives each submission its own crypto object", async () => { + const first: MediaDto = { hlsUrl: HLS_URL, hlsKey: HLS_KEY }; + const second: MediaDto = { hlsUrl: HLS_URL, hlsKey: HLS_KEY }; - // Process with previous media - await processMedia(media, prevMedia, db, testBucketId); + await processMedia(first, db); + await processMedia(second, db); - // Check if removed files are gone - for (const file of originalFiles) { - const exists = await s3Service.objectExists(file); - expect(exists).toBe(false); - } + expect(first.hlsKey_id).not.toBe(second.hlsKey_id); }); - it("discards user-added file collection objects", async () => { - const media = new MediaDto(); - media.fileCollections = []; - media.uploadData = [ - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-eng", - }, - ]; - await processMedia(media, undefined, db, testBucketId); - - const media2 = JSON.parse(JSON.stringify(media)) as MediaDto; - media2.fileCollections.push({ - languageId: "invalid", - fileUrl: "http://example.com/invalid.mp3", - bitrate: 128, - mediaType: MediaType.Audio, - }); + it("leaves an unencrypted collection alone", async () => { + const media: MediaDto = { hlsUrl: HLS_URL }; - await processMedia(media2, media, db, testBucketId); + const warnings = await processMedia(media, db); - // Check if the client-added file collection is removed - expect(media2.fileCollections.length).toBe(1); - - resMedia.push(media); + expect(warnings).toEqual([]); + expect(media.hlsKey_id).toBeUndefined(); }); - it("should allow uploading media for different languages independently", async () => { - // First, upload media for English - const media = new MediaDto(); - media.fileCollections = []; - media.uploadData = [ - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-eng", - }, - ]; - await processMedia(media, undefined, db, testBucketId); - expect(media.fileCollections.length).toBe(1); - expect(media.fileCollections[0].languageId).toBe("lang-eng"); - - const englishFileUrl = media.fileCollections[0].fileUrl; - - // Now upload media for Spanish, keeping the English media - const media2 = JSON.parse(JSON.stringify(media)) as MediaDto; - media2.uploadData = [ - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-spa", - }, - ]; - - await processMedia(media2, media, db, testBucketId); - - // Should have both English and Spanish media - expect(media2.fileCollections.length).toBe(2); - expect(media2.fileCollections.find((f) => f.languageId === "lang-eng")).toBeDefined(); - expect(media2.fileCollections.find((f) => f.languageId === "lang-spa")).toBeDefined(); - expect(media2.fileCollections.find((f) => f.languageId === "lang-eng")?.fileUrl).toBe( - englishFileUrl, - ); + it("keeps an existing key reference when no new key is submitted", async () => { + const media: MediaDto = { hlsUrl: HLS_URL, hlsKey_id: "crypto-existing" }; - // Verify both files exist in S3 - for (const fileCollection of media2.fileCollections) { - const filename = fileCollection.fileUrl.split("/").pop()!; - const exists = await s3Service.objectExists(filename); - expect(exists).toBe(true); - } + await processMedia(media, db); - resMedia.push(media2); + expect(media.hlsKey_id).toBe("crypto-existing"); }); - it("should replace media when uploading for same language", async () => { - // First, upload media for English - const media = new MediaDto(); - media.fileCollections = []; - media.uploadData = [ - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-eng", - }, - ]; - await processMedia(media, undefined, db, testBucketId); - expect(media.fileCollections.length).toBe(1); + it("drops the key rather than persisting it in plain text when storing fails", async () => { + const media: MediaDto = { hlsUrl: HLS_URL, hlsKey: HLS_KEY }; + const failingDb = { + upsertDoc: () => Promise.reject(new Error("database unavailable")), + } as unknown as DbService; - const firstFileUrl = media.fileCollections[0].fileUrl; - - // Upload a new media for English (should replace the old one) - const media2 = JSON.parse(JSON.stringify(media)) as MediaDto; - media2.uploadData = [ - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-eng", - }, - ]; - - await processMedia(media2, media, db, testBucketId); - - // Should still have only one media file (the new one) - expect(media2.fileCollections.length).toBe(1); - expect(media2.fileCollections[0].languageId).toBe("lang-eng"); - expect(media2.fileCollections[0].fileUrl).not.toBe(firstFileUrl); - - resMedia.push(media2); - }); - - it("should warn when parentBucketId is not provided for upload", async () => { - const media = new MediaDto(); - media.fileCollections = []; - media.uploadData = [ - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-eng", - }, - ]; - - // Call without parentBucketId (undefined) - const result = await processMedia(media, undefined, db, undefined); - - // Should have a warning about missing bucket - expect(result.warnings.length).toBeGreaterThan(0); - }); - - it("should warn when bucket document is not found", async () => { - const media = new MediaDto(); - media.fileCollections = []; - media.uploadData = [ - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-eng", - }, - ]; - - // Call with a non-existent bucket ID - const result = await processMedia(media, undefined, db, "nonexistent-bucket-id"); - - // Should have warnings about bucket not found - expect(result.warnings.length).toBeGreaterThan(0); - }); - - it("should warn when db is not provided for file deletion", async () => { - const media = new MediaDto(); - media.fileCollections = []; - - const prevMedia = new MediaDto(); - prevMedia.fileCollections = [ - { - languageId: "lang-eng", - fileUrl: `http://localhost:9000/test/some-file-key`, - bitrate: 128, - mediaType: MediaType.Audio, - }, - ]; - - // Call with no db and no parentBucketId - files to delete but no way to delete them - const result = await processMedia(media, prevMedia, undefined as any, undefined); - - expect(result.warnings.some((w) => w.includes("cannot be automatically deleted"))).toBe( - true, + await expect(processMedia(media, failingDb)).rejects.toThrow( + /Failed to encrypt the HLS key/, ); - }); - - it("should delete media file from S3 when removed from fileCollections", async () => { - // First, upload media for English and Spanish - const media = new MediaDto(); - media.fileCollections = []; - media.uploadData = [ - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-eng", - }, - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-spa", - }, - ]; - await processMedia(media, undefined, db, testBucketId); - expect(media.fileCollections.length).toBe(2); - - const englishFile = media.fileCollections.find((f) => f.languageId === "lang-eng"); - const spanishFile = media.fileCollections.find((f) => f.languageId === "lang-spa"); - expect(englishFile).toBeDefined(); - expect(spanishFile).toBeDefined(); - - const englishKey = englishFile!.fileUrl.split("/").pop()!; - const spanishKey = spanishFile!.fileUrl.split("/").pop()!; - - // Verify both files exist in S3 - expect(await s3Service.objectExists(englishKey)).toBe(true); - expect(await s3Service.objectExists(spanishKey)).toBe(true); - - // Remove English media from fileCollections (simulate user deletion) - const media2 = JSON.parse(JSON.stringify(media)) as MediaDto; - media2.fileCollections = media2.fileCollections.filter((f) => f.languageId !== "lang-eng"); - - await processMedia(media2, media, db, testBucketId); - - // Should only have Spanish media now - expect(media2.fileCollections.length).toBe(1); - expect(media2.fileCollections[0].languageId).toBe("lang-spa"); - - // Verify English file is deleted from S3 - expect(await s3Service.objectExists(englishKey)).toBe(false); - - // Verify Spanish file still exists in S3 - expect(await s3Service.objectExists(spanishKey)).toBe(true); - resMedia.push(media2); + // The whole point of the finally: a key that could not be encrypted must + // not reach the document by way of the caller's error handling. + expect(media.hlsKey).toBeUndefined(); + expect(media.hlsKey_id).toBeUndefined(); }); }); diff --git a/api/src/changeRequests/documentProcessing/processMediaDto.ts b/api/src/changeRequests/documentProcessing/processMediaDto.ts index b6572455d9..0d7ee0f88d 100644 --- a/api/src/changeRequests/documentProcessing/processMediaDto.ts +++ b/api/src/changeRequests/documentProcessing/processMediaDto.ts @@ -1,451 +1,33 @@ import { MediaDto } from "../../dto/MediaDto"; -import { MediaUploadDataDto } from "../../dto/MediaUploadDataDto"; -import { MediaFileDto } from "../../dto/MediaFileDto"; -import { v4 as uuidv4 } from "uuid"; -import { S3Service } from "../../s3/s3.service"; import { DbService } from "../../db/db.service"; -import { StorageDto } from "../../dto/StorageDto"; -import { DocType } from "../../enums"; -import { getAudioFormatInfo } from "../../s3-audio/audioFormatDetection"; +import { storeCryptoData } from "../../util/encryption"; /** - * Migrates all media files from one bucket to another - * Supports migration between different S3 systems (e.g., MinIO to AWS S3, or different MinIO instances) - * Each bucket uses its own credentials and endpoint, enabling cross-system transfers - * Only deletes from old bucket if migration is successful + * Processes the media object on a content parent document. * - * @param media - The media DTO containing file collections to migrate - * @param oldBucketId - The ID of the source bucket - * @param newBucketId - The ID of the destination bucket - * @param db - Database service to retrieve bucket configurations - * @returns Object with migration failure status and warnings - */ -async function migrateMediaBetweenBuckets( - media: MediaDto, - oldBucketId: string, - newBucketId: string, - db: DbService, -): Promise<{ failed: boolean; warnings: string[] }> { - const warnings: string[] = []; - - try { - // Create S3Service instances for each bucket - const oldS3Service = await S3Service.create(oldBucketId, db); - const newS3Service = await S3Service.create(newBucketId, db); - - // Get all media files to migrate - const allFiles = media.fileCollections; - - if (allFiles.length === 0) { - warnings.push("No media files to migrate."); - return { failed: false, warnings }; - } - - const oldBucketName = oldS3Service.getBucketName(); - const newBucketName = newS3Service.getBucketName(); - - let successfulMigrations = 0; - let failedMigrations = 0; - - // Migrate each file - for (const fileCollection of allFiles) { - try { - // Extract filename from URL - const urlParts = fileCollection.fileUrl.split("/"); - const filename = urlParts[urlParts.length - 1]; - - // Download from old bucket - const fileStream = await oldS3Service.getObject(filename); - const chunks: Uint8Array[] = []; - - // Collect all chunks - await new Promise((resolve, reject) => { - fileStream.on("data", (chunk: Uint8Array) => chunks.push(chunk)); - fileStream.on("end", () => resolve()); - fileStream.on("error", (err) => reject(err)); - }); - - const fileBuffer = Buffer.concat(chunks); - - // Get metadata from old bucket - const stat = await oldS3Service.getClient().statObject(oldBucketName, filename); - const metadata = stat.metaData || { "Content-Type": "audio/mpeg" }; - - // Upload to new bucket - await newS3Service.uploadFile( - filename, - fileBuffer, - metadata["Content-Type"] || "audio/mpeg", - ); - - // Delete from old bucket only after successful upload - await oldS3Service.getClient().removeObject(oldBucketName, filename); - - successfulMigrations++; - } catch (error) { - failedMigrations++; - warnings.push( - `Failed to migrate media file from bucket ${oldBucketName} to ${newBucketName}: ${error.message}`, - ); - } - } - - if (successfulMigrations > 0) { - warnings.push( - `Successfully migrated ${successfulMigrations} media file(s) from bucket ${oldBucketName} to ${newBucketName}`, - ); - } - - if (failedMigrations > 0) { - warnings.push( - `Failed to migrate ${failedMigrations} media file(s). These files remain in the old bucket.`, - ); - } - - // Migration is considered failed if ANY files failed to migrate - return { failed: failedMigrations > 0, warnings }; - } catch (error) { - warnings.push(`Media migration failed: ${error.message}`); - return { failed: true, warnings }; - } -} - -/** - * Processes an embedded media upload by uploading to S3 - * Requires bucket-specific credentials configured at the post/tag level - * Bucket ID is passed from the parent post/tag document for consistency - * Returns object with migration failure status and warnings + * Media is an HLS collection produced by the Luminary Media Convert desktop app. + * That app writes to the storage bucket itself, so there is nothing to upload, + * migrate or delete here — the document carries a URL to a collection this API + * never handles the bytes of. + * + * What does need handling is the decryption key. It arrives once, on the change + * request that first saves the collection, and is stored as a crypto object so it + * never rests in plain text on the content document. */ -export async function processMedia( - media: MediaDto, - prevMedia: MediaDto | undefined, - db: DbService, - parentBucketId?: string, - prevParentBucketId?: string, -): Promise<{ migrationFailed: boolean; warnings: string[] }> { +export async function processMedia(media: MediaDto, db: DbService): Promise { const warnings: string[] = []; - let migrationFailed = false; - - try { - // Detect bucket change and migrate media if needed - if ( - prevMedia && - prevParentBucketId && - parentBucketId && - prevParentBucketId !== parentBucketId && - media.fileCollections.length > 0 - ) { - const migrationResult = await migrateMediaBetweenBuckets( - media, - prevParentBucketId, - parentBucketId, - db, - ); - warnings.push(...migrationResult.warnings); - migrationFailed = migrationResult.failed; - } - - if (prevMedia) { - // Track files to delete from S3 - const filesToDelete: string[] = []; - - // Strategy: The client sends ALL fileCollections it wants to keep - // We need to: - // 1. Delete files that are not in the client's list - // 2. Discard invalid files the client may have added - // 3. Replace files when uploading for the same language - - const languagesBeingUploaded = - media.uploadData?.map((u) => u.languageId).filter(Boolean) || []; - - // Get fileUrls from previous media (valid files) - const prevFileUrls = new Set(prevMedia.fileCollections.map((c) => c.fileUrl)); - - // Get fileUrls that the client is keeping (only keep if they were in prevMedia) - // BUT exclude files for languages that are being uploaded (they'll be replaced) - const keptFileUrls = new Set( - media.fileCollections - .filter((c) => { - // Only keep if it was in prevMedia - if (!prevFileUrls.has(c.fileUrl)) return false; - - // Don't keep if its language is being replaced by an upload - if (languagesBeingUploaded.includes(c.languageId)) return false; - - return true; - }) - .map((c) => c.fileUrl), - ); - - // Check each previous file collection - prevMedia.fileCollections.forEach((collection) => { - // If the file is not in the kept list, mark it for deletion - if (!keptFileUrls.has(collection.fileUrl)) { - // Extract key from URL - const urlParts = collection.fileUrl.split("/"); - const key = urlParts[urlParts.length - 1]; - if (key && key.length > 0) { - filesToDelete.push(key); - } - } - }); - - // Delete files from S3 using the parent bucket ID - if (filesToDelete.length > 0 && db && parentBucketId) { - try { - const result = await db.getDoc(parentBucketId); - if (!result.docs || result.docs.length === 0) { - warnings.push( - `Bucket ${parentBucketId} not found. Cannot delete ${ - filesToDelete.length - } files. Manual cleanup required for: ${filesToDelete.join(", ")}`, - ); - } else { - const bucketS3Service = await S3Service.create(parentBucketId, db); - - // Delete files from the bucket - for (const key of filesToDelete) { - try { - await bucketS3Service - .getClient() - .removeObject(bucketS3Service.getBucketName(), key); - } catch (error) { - warnings.push( - `Failed to delete ${key} from bucket ${bucketS3Service.getBucketName()}: ${ - error.message - }`, - ); - } - } - } - } catch (error) { - warnings.push( - `Failed to connect to bucket ${parentBucketId}: ${error.message}. Cannot delete ${filesToDelete.length} files.`, - ); - } - } else if (filesToDelete.length > 0 && (!db || !parentBucketId)) { - warnings.push( - `Warning: ${ - filesToDelete.length - } old media files cannot be automatically deleted without ${ - !db ? "database access" : "parent bucket ID" - }. ` + `Please manually clean up files on the storage provider`, - ); - } - - // Start with only valid files that the client is keeping - media.fileCollections = media.fileCollections.filter((c) => - keptFileUrls.has(c.fileUrl), - ); - } - - // Upload new files - if (media.uploadData) { - if (!db) { - warnings.push("Unable to upload media - system configuration error."); - return { migrationFailed, warnings }; - } - - if (!parentBucketId) { - warnings.push("Parent bucket ID is required for media uploads."); - return { migrationFailed, warnings }; - } - - const promises: Promise<{ success: boolean; warnings: string[] }>[] = []; - media.uploadData?.forEach((uploadData) => { - promises.push(processMediaUpload(uploadData, media, db, parentBucketId)); - }); - const results = await Promise.all(promises); - - // Collect all warnings from uploads - results.forEach((result) => { - warnings.push(...result.warnings); - }); - - // If any uploads failed completely, we should clean up any successful uploads - const successfulUploads = results.filter((r) => r.success).length; - const failedUploads = results.filter((r) => !r.success).length; - - if (failedUploads > 0) { - warnings.push(`${failedUploads} of ${results.length} media uploads failed`); - - if (successfulUploads === 0) { - warnings.push("All media uploads failed - no media were processed"); - } - } - - delete media.uploadData; // Remove upload data after processing - } - } catch (error) { - warnings.push(`Media processing failed: ${error.message}`); - } - - return { migrationFailed, warnings }; -} - -async function processMediaUpload( - uploadData: MediaUploadDataDto, - media: MediaDto, - db: DbService, - bucketId: string, -): Promise<{ success: boolean; warnings: string[] }> { - const warnings: string[] = []; + if (!media.hlsKey) return warnings; try { - let preset = uploadData?.preset || "default"; - if (preset != "default" && preset != "audio" && preset != "speech") { - preset = "default"; - } - - // Bucket ID is required - if (!bucketId) { - warnings.push( - "No bucket specified for media upload. Each post/tag must specify a target bucket with proper credentials.", - ); - return { success: false, warnings }; - } - - // Look up the bucket and create bucket-specific S3 client - let storage: StorageDto; - - try { - const bucketDocs = await db.getDocsByType(DocType.Storage); - const foundBucket = bucketDocs.docs.find( - (doc: any) => doc._id === bucketId, - ) as StorageDto; - - if (!foundBucket || !foundBucket.name) { - warnings.push( - `Bucket with ID ${bucketId} not found. Please configure a storage bucket with proper credentials before uploading media.`, - ); - return { success: false, warnings }; - } - - storage = foundBucket; - - // Validate file type against bucket's allowed mimeTypes (if specified) - // Use audio format detection to determine mimetype - if (storage.mimeTypes && storage.mimeTypes.length > 0) { - // Parse metadata to determine format - let detectedMimetype = "audio/mpeg"; // default - - try { - const { parseBuffer } = await import("music-metadata"); - const metadata = await parseBuffer(new Uint8Array(uploadData.fileData)); - const formatInfo = getAudioFormatInfo(metadata); - detectedMimetype = formatInfo.mime; - } catch { - // Fall back to default - } - - const isAllowed = storage.mimeTypes.some((allowedType) => { - // Support wildcards like "audio/*" - if (allowedType.endsWith("/*")) { - const prefix = allowedType.slice(0, -2); - return detectedMimetype.startsWith(prefix + "/"); - } - // Exact match - return detectedMimetype === allowedType; - }); - - if (!isAllowed) { - warnings.push( - `File type "${detectedMimetype}" is not allowed for bucket "${ - storage.name - }". Allowed types: ${storage.mimeTypes.join(", ")}`, - ); - return { success: false, warnings }; - } - } - - // Create bucket-specific S3 service with bucket's credentials - const s3Service = await S3Service.create(bucketId, db); - - // Process and upload the media file - const uploadResult = await uploadMediaFile(uploadData, s3Service, media, storage); - warnings.push(...uploadResult.warnings); - - if (!uploadResult.success) { - return { success: false, warnings }; - } - - return { success: true, warnings }; - } catch (error) { - warnings.push( - `Failed to connect to bucket ${bucketId}: ${error.message}. Please ensure the bucket has valid credentials configured.`, - ); - return { success: false, warnings }; - } + media.hlsKey_id = await storeCryptoData(db, media.hlsKey); } catch (error) { - warnings.push(`Media upload failed: ${error.message}`); - return { success: false, warnings }; + throw new Error(`Failed to encrypt the HLS key: ${error.message}`); + } finally { + // Dropped whether or not it was stored, and before the caller can catch: + // a key that failed to encrypt must not reach the document either. + delete media.hlsKey; } -} - -async function uploadMediaFile( - uploadData: MediaUploadDataDto, - s3Service: S3Service, - media: MediaDto, - storage: StorageDto, -): Promise<{ success: boolean; warnings: string[] }> { - const warnings: string[] = []; - - try { - // Parse metadata to infer bitrate and format info - let formatInfo = { ext: "", mime: "application/octet-stream", isValidAudio: false }; - let bitrate = 0; - const u8 = new Uint8Array(uploadData.fileData); - - try { - const { parseBuffer } = await import("music-metadata"); - const metadata = await parseBuffer(u8); - - // Use robust format detection - formatInfo = getAudioFormatInfo(metadata); - bitrate = Math.round(metadata.format.bitrate || 0); - } catch { - // Fall back; format/bitrate unknown in this environment - } - // Fallback to generic audio if we couldn't determine format - if (!formatInfo.ext) { - formatInfo.ext = "mp3"; // Use mp3 as safe default extension - formatInfo.mime = "audio/mpeg"; // safe default - } - - // Include file extension in the key for proper MIME type handling - const key = `${uuidv4()}-default.${formatInfo.ext}`; - - // Upload original buffer as-is - const buf = Buffer.from(u8); - - await s3Service.uploadFile(key, buf, formatInfo.mime); - - // Validate upload accessibility - const validateRes = await s3Service.objectExists(key); - if (!validateRes) { - warnings.push("Media file uploaded but not accessible"); - } - - // Construct the public URL using the bucket's publicUrl from StorageDto - // Remove trailing slash from publicUrl if present - const baseUrl = storage.publicUrl.replace(/\/$/, ""); - const fileUrl = `${baseUrl}/${key}`; - - const file = new MediaFileDto(); - file.languageId = uploadData.languageId; - file.fileUrl = fileUrl; - file.bitrate = bitrate; - file.mediaType = uploadData.mediaType; - - media.fileCollections.push(file); - - return { success: true, warnings }; - } catch (error) { - return { - success: false, - warnings: [`Failed to upload media file: ${error.message}\n`], - }; - } + return warnings; } diff --git a/api/src/changeRequests/documentProcessing/processPostTagDto.spec.ts b/api/src/changeRequests/documentProcessing/processPostTagDto.spec.ts index c5a8b24105..bded4a0111 100644 --- a/api/src/changeRequests/documentProcessing/processPostTagDto.spec.ts +++ b/api/src/changeRequests/documentProcessing/processPostTagDto.spec.ts @@ -33,7 +33,7 @@ describe("processPostTagDto", () => { jest.clearAllMocks(); (deleteImage as jest.Mock).mockResolvedValue([]); (processImage as jest.Mock).mockResolvedValue({ warnings: [] }); - (processMedia as jest.Mock).mockResolvedValue({ warnings: [] }); + (processMedia as jest.Mock).mockResolvedValue([]); }); it("should cascade Post/Tag delete request to content documents", async () => { @@ -312,14 +312,7 @@ describe("processPostTagDto", () => { changeRequest.doc._id = "post-blog6"; (changeRequest.doc as PostDto).mediaBucketId = "test-bucket-id"; (changeRequest.doc as PostDto).media = { - fileCollections: [ - { - languageId: "lang-eng", - fileUrl: "http://test.com/test-audio.mp3", - bitrate: 128, - mediaType: MediaType.Audio, - }, - ], + hlsUrl: "http://test.com/media/post-blog6/master.m3u8", }; // This should not throw an error even though prevDoc is undefined @@ -339,14 +332,7 @@ describe("processPostTagDto", () => { changeRequest.doc.deleteReq = 1; (changeRequest.doc as PostDto).mediaBucketId = "test-bucket-id"; (changeRequest.doc as PostDto).media = { - fileCollections: [ - { - languageId: "lang-eng", - fileUrl: "test-audio.mp3", - bitrate: 128, - mediaType: MediaType.Audio, - }, - ], + hlsUrl: "http://test.com/media/post-blog7/master.m3u8", }; // This should not throw an error even though prevDoc is undefined @@ -385,21 +371,17 @@ describe("processPostTagDto", () => { expect(result.warnings).toContain("Image cleanup warning"); }); - it("warns when media processing returns warnings during deletion", async () => { - (processMedia as jest.Mock).mockResolvedValueOnce({ - warnings: ["Media cleanup warning"], - }); - + it("does not process media when a document is deleted", async () => { const changeRequest = changeRequest_post(); changeRequest.doc._id = "post-delete-med-warn"; (changeRequest.doc as PostDto).mediaBucketId = "test-bucket"; (changeRequest.doc as PostDto).media = { - fileCollections: [ - { languageId: "en", fileUrl: "test.mp3", bitrate: 128, mediaType: MediaType.Audio }, - ], + hlsUrl: "http://test.com/media/post-delete-med-warn/master.m3u8", }; changeRequest.doc.deleteReq = 1; + (processMedia as jest.Mock).mockClear(); + const result = await processChangeRequest( "test-user", changeRequest, @@ -407,7 +389,10 @@ describe("processPostTagDto", () => { db, ); - expect(result.warnings).toContain("Media cleanup warning"); + // The collection is written by the encoder, not by this API, and nothing + // here knows which objects belong to it — so deletion leaves it alone. + expect(processMedia).not.toHaveBeenCalled(); + expect(result.result.ok).toBe(true); }); it("calls processImage without bucketId when imageBucketId is not specified", async () => { @@ -502,9 +487,7 @@ describe("processPostTagDto", () => { const changeRequest = changeRequest_post(); changeRequest.doc._id = "post-no-med-bucket"; (changeRequest.doc as PostDto).media = { - fileCollections: [ - { languageId: "en", fileUrl: "test.mp3", bitrate: 128, mediaType: MediaType.Audio }, - ], + hlsUrl: "http://test.com/media/post-no-med-bucket/master.m3u8", }; delete (changeRequest.doc as PostDto).mediaBucketId; @@ -513,51 +496,16 @@ describe("processPostTagDto", () => { ).rejects.toThrow("Bucket is not specified for media processing"); }); - it("reverts mediaBucketId when media migration fails", async () => { - // First create the post with old bucket - const changeRequest = changeRequest_post(); - changeRequest.doc._id = "post-med-migrate-fail"; - (changeRequest.doc as PostDto).mediaBucketId = "old-media-bucket"; - (changeRequest.doc as PostDto).media = { - fileCollections: [ - { languageId: "en", fileUrl: "test.mp3", bitrate: 128, mediaType: MediaType.Audio }, - ], - }; - (processMedia as jest.Mock).mockResolvedValueOnce({ warnings: [] }); - await processChangeRequest("test-user", changeRequest, ["group-super-admins"], db); - - // Now update with new bucket that fails migration - (processMedia as jest.Mock).mockResolvedValueOnce({ - warnings: [], - migrationFailed: true, - }); - (changeRequest.doc as PostDto).mediaBucketId = "new-media-bucket"; - const result = await processChangeRequest( - "test-user", - changeRequest, - ["group-super-admins"], - db, - ); - - expect(result.warnings.some((w) => w.includes("Media migration failed"))).toBe(true); - }); - - it("reverts mediaBucketId when processMedia throws an error", async () => { - // First create the post with old bucket + it("warns rather than failing the save when media processing throws", async () => { const changeRequest = changeRequest_post(); changeRequest.doc._id = "post-med-throw"; - (changeRequest.doc as PostDto).mediaBucketId = "old-media-bucket"; + (changeRequest.doc as PostDto).mediaBucketId = "media-bucket"; (changeRequest.doc as PostDto).media = { - fileCollections: [ - { languageId: "en", fileUrl: "test.mp3", bitrate: 128, mediaType: MediaType.Audio }, - ], + hlsUrl: "http://test.com/media/post-med-throw/master.m3u8", }; - (processMedia as jest.Mock).mockResolvedValueOnce({ warnings: [] }); - await processChangeRequest("test-user", changeRequest, ["group-super-admins"], db); - // Now update with new bucket where processMedia throws - (processMedia as jest.Mock).mockRejectedValueOnce(new Error("Media processing failed")); - (changeRequest.doc as PostDto).mediaBucketId = "new-media-bucket"; + (processMedia as jest.Mock).mockRejectedValueOnce(new Error("key store unavailable")); + const result = await processChangeRequest( "test-user", changeRequest, @@ -565,9 +513,10 @@ describe("processPostTagDto", () => { db, ); - expect(result.warnings.some((w) => w.includes("Bucket media processing failed"))).toBe( - true, - ); + // A key that could not be stored costs the encryption, not the document — + // processMedia has already dropped the plaintext key by this point. + expect(result.warnings.some((w) => w.includes("Media processing failed"))).toBe(true); + expect(result.result.ok).toBe(true); }); it("copies tag properties to content documents for Tag type", async () => { @@ -605,33 +554,19 @@ describe("processPostTagDto", () => { } }); - it("can remove media from S3 when a post/tag document is marked for deletion", async () => { + it("passes the media object and the db to processMedia on save", async () => { const changeRequest = changeRequest_post(); changeRequest.doc._id = "post-blog8"; (changeRequest.doc as PostDto).mediaBucketId = "test-bucket-id"; (changeRequest.doc as PostDto).media = { - fileCollections: [ - { - languageId: "lang-eng", - fileUrl: "test-audio.mp3", - bitrate: 128, - mediaType: MediaType.Audio, - }, - ], + hlsUrl: "http://test.com/media/post-blog8/master.m3u8", + hlsKey: "0123456789abcdef0123456789abcdef", }; - await processChangeRequest("test-user", changeRequest, ["group-super-admins"], db); + (processMedia as jest.Mock).mockClear(); - // Mark the post document for deletion - const deleteRequest = JSON.parse(JSON.stringify(changeRequest)) as ChangeReqDto; - deleteRequest.doc.deleteReq = 1; - await processChangeRequest("test-user", deleteRequest, ["group-super-admins"], db); + await processChangeRequest("test-user", changeRequest, ["group-super-admins"], db); - expect(processMedia).toHaveBeenCalledWith( - { fileCollections: [] }, // Empty fileCollections to remove the media from S3 - (changeRequest.doc as PostDto).media, - db, - (changeRequest.doc as PostDto).mediaBucketId, - ); + expect(processMedia).toHaveBeenCalledWith((changeRequest.doc as PostDto).media, db); }); }); diff --git a/api/src/changeRequests/documentProcessing/processPostTagDto.ts b/api/src/changeRequests/documentProcessing/processPostTagDto.ts index 5798691817..b0d9b0dd8b 100644 --- a/api/src/changeRequests/documentProcessing/processPostTagDto.ts +++ b/api/src/changeRequests/documentProcessing/processPostTagDto.ts @@ -35,18 +35,10 @@ export default async function processPostTagDto( warnings.push(...imageWarnings); } - // Remove medias from S3 - if (doc.media) { - const mediaResult = await processMedia( - { fileCollections: [] }, - prevDoc?.media, - db, - prevDoc?.mediaBucketId, // Delete from the bucket where files currently exist - ); - if (mediaResult && mediaResult.warnings && mediaResult.warnings.length > 0) { - warnings.push(...mediaResult.warnings); - } - } + // Media is an HLS collection written to the bucket by the encoder, not by + // this API, and nothing here tracks which objects belong to it. Deleting the + // document therefore leaves the collection in place, to be reclaimed by + // stale-collection cleanup rather than guessed at from a URL. return warnings; // no need to process further } @@ -104,43 +96,18 @@ export default async function processPostTagDto( delete (doc as any).image; // Remove the legacy image field } - // Process media uploads + // Process media if (doc.media) { - let mediaWarnings: string[] = []; - - // Check if bucket is specified for this upload + // The bucket is where the encoder was told to write, and is what a later + // edit of the collection has to be pointed back at. if (!doc.mediaBucketId) { throw new Error("Bucket is not specified for media processing."); } - // Use the new bucket processing with db service for bucket lookup try { - const result = await processMedia( - doc.media, - prevDoc?.media, - db, - doc.mediaBucketId, - prevDoc?.mediaBucketId, // Pass previous bucket ID for migration - ); - mediaWarnings = result.warnings; - - // If migration failed, revert to the old bucket ID to keep files accessible - if (result.migrationFailed && prevDoc?.mediaBucketId) { - doc.mediaBucketId = prevDoc.mediaBucketId; - warnings.push( - "Media migration failed. Reverted to previous bucket configuration to ensure files remain accessible.", - ); - } + warnings.push(...(await processMedia(doc.media, db))); } catch (error) { - // If processing throws an error, also revert bucket ID - if (prevDoc?.mediaBucketId && doc.mediaBucketId !== prevDoc.mediaBucketId) { - doc.mediaBucketId = prevDoc.mediaBucketId; - } - mediaWarnings.push(`Bucket media processing failed: ${error.message}`); - } - - if (mediaWarnings && mediaWarnings.length > 0) { - warnings.push(...mediaWarnings); + warnings.push(`Media processing failed: ${error.message}`); } } diff --git a/api/src/endpoints/encoderConfig.controller.ts b/api/src/endpoints/encoderConfig.controller.ts new file mode 100644 index 0000000000..edf706b172 --- /dev/null +++ b/api/src/endpoints/encoderConfig.controller.ts @@ -0,0 +1,128 @@ +import { Controller, Get, Query, UseGuards, Req, HttpException, HttpStatus } from "@nestjs/common"; +import { AuthGuard } from "../auth/auth.guard"; +import { DbService } from "../db/db.service"; +import { validateApiVersion } from "../validation/apiVersion"; +import { PermissionSystem } from "../permissions/permissions.service"; +import { AclPermission, DocType } from "../enums"; +import { S3CredentialDto } from "../dto/S3CredentialDto"; +import { retrieveCryptoData } from "../util/encryption"; +import { FastifyRequest } from "fastify"; + +/** + * Everything the local media encoder needs to write a collection to a bucket and + * to publish a URL for it. Shaped for the encoder's `POST /api/cms/sessions` body + * rather than for our own storage model, so the CMS forwards it without reshaping. + */ +export type EncoderConfigResponseDto = { + s3: { + endPoint: string; + port: number; + useSSL: boolean; + bucket: string; + accessKey: string; + secretKey: string; + }; + publicBaseUrl: string; +}; + +/** + * Hands out the S3 credentials for a media bucket. + * + * The encoder runs on the editor's own machine and uploads straight to the bucket, + * so it needs real credentials — there is no path where the server does the upload + * on its behalf. Credentials are stored encrypted and are not replicated to + * clients, which is why they are fetched here rather than read off the Storage + * document the CMS already holds. + * + * Gated on `Assign` rather than `View`: assigning a bucket is the right to publish + * into it, which is exactly what these credentials confer. `View` is what the + * status endpoint needs to render a connectivity dot, and is held far more widely. + */ +@Controller("storage") +export class EncoderConfigController { + constructor(private readonly dbService: DbService) {} + + @Get("encoderconfig") + @UseGuards(AuthGuard) + async getEncoderConfig( + @Query("bucketId") bucketId: string, + @Query("apiVersion") apiVersion: string, + @Req() request: FastifyRequest, + ): Promise { + await validateApiVersion(apiVersion); + + const userDetails = request.user; + + if (!bucketId) { + throw new HttpException("bucketId query parameter is required", HttpStatus.BAD_REQUEST); + } + + const bucketResult = await this.dbService.getDoc(bucketId); + if (!bucketResult.docs || bucketResult.docs.length === 0) { + throw new HttpException( + `Bucket configuration not found: ${bucketId}`, + HttpStatus.NOT_FOUND, + ); + } + + const bucket = bucketResult.docs[0]; + + const hasPermission = PermissionSystem.verifyAccess( + bucket.memberOf, + DocType.Storage, + AclPermission.Assign, + userDetails.groups, + ); + + if (!hasPermission) { + throw new HttpException( + "Insufficient permissions to encode to this bucket", + HttpStatus.FORBIDDEN, + ); + } + + if (!bucket.credential_id) { + throw new HttpException( + `No credentials configured for bucket: ${bucket.name}`, + HttpStatus.CONFLICT, + ); + } + + if (!bucket.publicUrl) { + throw new HttpException( + `No public URL configured for bucket: ${bucket.name}. The encoder needs one to ` + + "publish a playable address for the collection it writes.", + HttpStatus.CONFLICT, + ); + } + + const credentials = await retrieveCryptoData( + this.dbService, + bucket.credential_id, + ); + + if (!credentials?.accessKey || !credentials?.secretKey || !credentials?.bucketName) { + throw new HttpException( + `Stored credentials for bucket ${bucket.name} are incomplete`, + HttpStatus.CONFLICT, + ); + } + + // The encoder takes host, port and TLS as separate fields; we store one URL. + // Split the same way S3Service does, so both reach the same endpoint. + const url = new URL(credentials.endpoint); + const useSSL = url.protocol === "https:"; + + return { + s3: { + endPoint: url.hostname, + port: parseInt(url.port) || (useSSL ? 443 : 80), + useSSL, + bucket: credentials.bucketName, + accessKey: credentials.accessKey, + secretKey: credentials.secretKey, + }, + publicBaseUrl: bucket.publicUrl, + }; + } +} diff --git a/app/src/components/content/ContentTile.vue b/app/src/components/content/ContentTile.vue index 016987fbb2..6ba6427826 100644 --- a/app/src/components/content/ContentTile.vue +++ b/app/src/components/content/ContentTile.vue @@ -7,6 +7,7 @@ import { PlayIcon, SpeakerWaveIcon } from "@heroicons/vue/24/solid"; import { getMediaDuration, getMediaProgress, getReadingProgress } from "@/contentProgress"; import { computed } from "vue"; import { useI18n } from "vue-i18n"; +import { hasVideoSource, videoSourceFor } from "@/util/videoSource"; const { t } = useI18n(); @@ -39,9 +40,9 @@ const publishDateText = computed(() => { return db.toDateTime(props.content.publishDate).toLocaleString(DateTime.DATETIME_MED); }); -const hasVideo = computed(() => Boolean(props.content.video)); +const hasVideo = computed(() => hasVideoSource(props.content)); const hasAudio = computed( - () => !props.content.video && Boolean(props.content.parentMedia?.fileCollections?.length), + () => !hasVideo.value && Boolean(props.content.parentMedia?.fileCollections?.length), ); const mediaIconClass = computed(() => @@ -63,8 +64,9 @@ const isComingSoon = computed(() => { const mediaProgress = computed(() => { if (!props.showProgress) return 0; - const mediaIds = props.content.video - ? [props.content.video] + const videoSource = videoSourceFor(props.content); + const mediaIds = videoSource + ? [videoSource] : (props.content.parentMedia?.fileCollections ?? []).map((f) => f.fileUrl); for (const mediaId of mediaIds) { diff --git a/app/src/components/content/VideoPlayer.spec.ts b/app/src/components/content/VideoPlayer.spec.ts index a06363d7a6..9498c70091 100644 --- a/app/src/components/content/VideoPlayer.spec.ts +++ b/app/src/components/content/VideoPlayer.spec.ts @@ -150,8 +150,8 @@ describe("VideoPlayer", () => { it("renders the poster image for regular video", async () => { const content = { ...mockEnglishContentDto, - // VideoPlayer reads `content.video`; mock data only defines parentMedia.hlsUrl - video: mockEnglishContentDto.parentMedia!.hlsUrl!, + // The encoded collection is the source; no typed URL is involved. + video: undefined, }; const wrapper = mount(VideoPlayer, { @@ -162,7 +162,9 @@ describe("VideoPlayer", () => { }); await waitForExpect(() => { - expect(srcMock).toHaveBeenCalledWith(expect.objectContaining({ src: content.video })); + expect(srcMock).toHaveBeenCalledWith( + expect.objectContaining({ src: content.parentMedia!.hlsUrl }), + ); }); await waitForExpect(() => { @@ -186,6 +188,8 @@ describe("VideoPlayer", () => { it("handles YouTube videos correctly", async () => { const youtubeContent = { ...mockEnglishContentDto, + // No encoded collection: this post's video is the link somebody typed. + parentMedia: undefined, video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", }; @@ -236,6 +240,7 @@ describe("VideoPlayer", () => { it("sets HLS source for regular video", async () => { const contentWithVideo = { ...mockEnglishContentDto, + parentMedia: undefined, video: "https://example.com/stream.m3u8", }; @@ -349,6 +354,7 @@ describe("VideoPlayer", () => { it("disposes player on unmount", async () => { const contentWithVideo = { ...mockEnglishContentDto, + parentMedia: undefined, video: "https://example.com/stream.m3u8", }; @@ -372,6 +378,8 @@ describe("VideoPlayer", () => { it("hides audio toggle for YouTube videos", async () => { const youtubeContent = { ...mockEnglishContentDto, + // No encoded collection: this post's video is the link somebody typed. + parentMedia: undefined, video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", }; diff --git a/app/src/components/content/VideoPlayer.vue b/app/src/components/content/VideoPlayer.vue index 8dba8d1443..8a4f2b8fdf 100644 --- a/app/src/components/content/VideoPlayer.vue +++ b/app/src/components/content/VideoPlayer.vue @@ -11,6 +11,7 @@ import { appLanguagesPreferredAsRef, queryParams } from "@/globalConfig"; import { getMediaProgress, removeMediaProgress, setMediaProgress } from "@/contentProgress"; import { extractAndBuildAudioMaster } from "./extractAndBuildAudioMaster"; import { isYouTubeUrl, convertToVideoJSYouTubeUrl } from "@/util/youtube"; +import { videoSourceFor } from "@/util/videoSource"; type Props = { content: ContentDto; @@ -35,8 +36,8 @@ const isRestoringTrack = ref(false); const isYouTube = ref(false); // Check if the current video is a YouTube video -if (props.content.video) { - isYouTube.value = isYouTubeUrl(props.content.video); +if (videoSourceFor(props.content)) { + isYouTube.value = isYouTubeUrl(videoSourceFor(props.content)!); if (isYouTube.value) { // hides audio mode toggle for YouTube videos as it's not supported showAudioModeToggle.value = false; @@ -193,7 +194,7 @@ onMounted(async () => { player.poster(px); // Set the player poster to a 1px transparent image to prevent the default poster from showing // Set player source based on video type (YouTube vs regular) - const videoSource = props.content.video || props.content.video; + const videoSource = videoSourceFor(props.content); if (isYouTube.value && props.content.video) { // For YouTube videos, disable audio-only mode toggle since it's not supported for YouTube videos showAudioModeToggle.value = false; @@ -327,7 +328,7 @@ onMounted(async () => { const currentTime = player?.currentTime() || 0; const durationTime = player?.duration() || 0; - const videoSource = props.content.video || props.content.parentMedia?.hlsUrl; + const videoSource = videoSourceFor(props.content); if (durationTime == Infinity || !videoSource || currentTime < 60) return; // For YouTube videos, aggressively check if we're at the end and remove progress @@ -351,7 +352,7 @@ onMounted(async () => { // Get and apply the player saved progress (rewind 30 seconds) player.on("ready", () => { - const videoSource = props.content.video || props.content.parentMedia?.hlsUrl; + const videoSource = videoSourceFor(props.content); if (!videoSource) return; // For YouTube videos, wait for loadedmetadata to restore progress (iframe needs to be ready) @@ -362,7 +363,7 @@ onMounted(async () => { }); player.on("ended", () => { - const videoSource = props.content.video || props.content.parentMedia?.hlsUrl; + const videoSource = videoSourceFor(props.content); if (!videoSource) return; stopKeepAudioAlive(); @@ -379,7 +380,7 @@ onMounted(async () => { }); player.on("pause", () => { - const videoSource = props.content.video || props.content.parentMedia?.hlsUrl; + const videoSource = videoSourceFor(props.content); if (!videoSource) return; if (audioMode.value) syncKeepAudioStateAlive(); @@ -454,7 +455,7 @@ watch(audioMode, async (mode) => { // Generate the audio playlist with the currently selected track as default // This ensures the player loads the correct track immediately without needing to switch - const videoSource = props.content.video || props.content.parentMedia?.hlsUrl; + const videoSource = videoSourceFor(props.content); let audioPlaylistUrl: string | null = null; if (mode && videoSource) { const audioMaster = await extractAndBuildAudioMaster(videoSource, selectedTrackInfo); diff --git a/app/src/pages/SingleContent/SingleContent.vue b/app/src/pages/SingleContent/SingleContent.vue index 5616b27a48..11f775f106 100644 --- a/app/src/pages/SingleContent/SingleContent.vue +++ b/app/src/pages/SingleContent/SingleContent.vue @@ -65,6 +65,7 @@ import ContinueReadingPrompt from "@/components/content/ContinueReadingPrompt.vu import LHighlightable from "@/components/common/LHighlightable.vue"; import DropdownMenu from "@/components/common/DropdownMenu.vue"; import { markPageReady } from "@/util/renderState"; +import { hasVideoSource } from "@/util/videoSource"; import { computeEstimatedReadingMinutes, resolveReadingSpeedWpm, @@ -794,7 +795,7 @@ watch([isLoading, content, is404], async () => { :ignoreTop="true" > diff --git a/app/src/util/videoSource.spec.ts b/app/src/util/videoSource.spec.ts new file mode 100644 index 0000000000..4db1ae966b --- /dev/null +++ b/app/src/util/videoSource.spec.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { videoSourceFor, hasVideoSource } from "./videoSource"; +import type { ContentDto } from "luminary-shared"; + +const content = (video?: string, hlsUrl?: string) => + ({ + video, + parentMedia: hlsUrl ? { hlsUrl, fileCollections: [] } : undefined, + }) as unknown as ContentDto; + +describe("videoSourceFor", () => { + it("prefers the encoded collection over a hand-entered URL", () => { + // The case that matters: a post that once had a link and has since been + // encoded. The link is a leftover the CMS no longer even lets you edit. + const source = videoSourceFor(content("https://youtube.com/watch?v=x", "https://cdn/m.m3u8")); + + expect(source).toBe("https://cdn/m.m3u8"); + }); + + it("falls back to the typed URL when nothing has been encoded", () => { + expect(videoSourceFor(content("https://youtube.com/watch?v=x"))).toBe( + "https://youtube.com/watch?v=x", + ); + }); + + it("uses the encoded collection when there is no typed URL", () => { + expect(videoSourceFor(content(undefined, "https://cdn/m.m3u8"))).toBe( + "https://cdn/m.m3u8", + ); + }); + + it("returns undefined when the post has no video at all", () => { + expect(videoSourceFor(content())).toBeUndefined(); + }); + + it("treats an empty string as no video rather than as a source", () => { + expect(videoSourceFor(content("", "https://cdn/m.m3u8"))).toBe("https://cdn/m.m3u8"); + expect(videoSourceFor(content(""))).toBeUndefined(); + }); + + it("tolerates missing content", () => { + expect(videoSourceFor(undefined)).toBeUndefined(); + expect(videoSourceFor(null)).toBeUndefined(); + }); +}); + +describe("hasVideoSource", () => { + it("is true for an encoded collection with no typed URL", () => { + expect(hasVideoSource(content(undefined, "https://cdn/m.m3u8"))).toBe(true); + }); + + it("is true for a typed URL with nothing encoded", () => { + expect(hasVideoSource(content("https://youtube.com/watch?v=x"))).toBe(true); + }); + + it("is false when neither is set", () => { + expect(hasVideoSource(content())).toBe(false); + }); +}); diff --git a/app/src/util/videoSource.ts b/app/src/util/videoSource.ts new file mode 100644 index 0000000000..cf6de5f542 --- /dev/null +++ b/app/src/util/videoSource.ts @@ -0,0 +1,26 @@ +import type { ContentDto } from "luminary-shared"; + +/** + * The video a content document should play. + * + * Two fields can name one, and they do not carry equal weight. `parentMedia.hlsUrl` + * is the collection the encoder produced for this document — adaptive, multi-audio, + * and the thing the CMS treats as the video from the moment it exists. `video` is a + * URL somebody typed, which on a post that has since been encoded is a leftover. + * + * So the encoded collection wins wherever both are present. Reading them the other + * way round leaves a post playing a stale link that the CMS no longer even offers to + * edit. + */ +export function videoSourceFor( + content: Pick | undefined | null, +): string | undefined { + return content?.parentMedia?.hlsUrl || content?.video || undefined; +} + +/** Whether this content has a video to play at all. */ +export function hasVideoSource( + content: Pick | undefined | null, +): boolean { + return Boolean(videoSourceFor(content)); +} diff --git a/cms/src/components/content/EditContent.vue b/cms/src/components/content/EditContent.vue index 427b3bde0f..23cdb2e9ea 100644 --- a/cms/src/components/content/EditContent.vue +++ b/cms/src/components/content/EditContent.vue @@ -491,6 +491,7 @@ const actionsWrapperProps = computed(() => ({ :tagOrPostType="props.tagOrPostType" :disabled="!canEditParent" :newDocument="newDocument" + :title="editableContent?.[0]?.title" v-model:parent="editableParent" /> @@ -503,9 +504,10 @@ const actionsWrapperProps = computed(() => ({ aria-hidden="true" /> diff --git a/cms/src/components/content/EditContentMedia.vue b/cms/src/components/content/EditContentMedia.vue index 7028042e52..29b6d71049 100644 --- a/cms/src/components/content/EditContentMedia.vue +++ b/cms/src/components/content/EditContentMedia.vue @@ -1,16 +1,10 @@ @@ -62,26 +55,14 @@ const handleFileChange = () => { class="bg-white" >

- You can upload multiple audio files, one per language. Each language can have only - one audio file. Uploading a new file for a language that already has audio will - replace the existing file. -

-

- Supported formats: MP3, AAC, Opus, WAV. -
Maximum file size: {{ maxMediaUploadFileSizeMb }}MB. + Video and audio are produced by Luminary Media Convert. Use Encode to open it, + pick a file, and the encoded playlist is saved back to this document.

- +
@@ -115,16 +86,14 @@ const handleFileChange = () => {

Media

- - Upload Audio - - + @media-ready="handleEncodedMedia" + @bucket-selected="handleEncoderBucket" + />
- -

- You can upload multiple audio files, one per language. Each language can have only - one audio file. Uploading a new file for a language that already has audio will - replace the existing file. -

-

- Supported formats: MP3, AAC, Opus, WAV. -
Maximum file size: {{ maxMediaUploadFileSizeMb }}MB. + Video and audio are produced by Luminary Media Convert. Use Encode to open it, + pick a file, and the encoded playlist is saved back to this document.

- +
diff --git a/cms/src/components/content/EditContentVideo.spec.ts b/cms/src/components/content/EditContentVideo.spec.ts index 2176ab0b07..0a115e1149 100644 --- a/cms/src/components/content/EditContentVideo.spec.ts +++ b/cms/src/components/content/EditContentVideo.spec.ts @@ -2,17 +2,28 @@ import "fake-indexeddb/auto"; import { describe, it, expect, vi, beforeAll, afterAll } from "vitest"; import { mount } from "@vue/test-utils"; import { createTestingPinia } from "@pinia/testing"; -import { type ContentDto, accessMap } from "luminary-shared"; +import { type ContentParentDto, accessMap } from "luminary-shared"; import * as mockData from "@/tests/mockdata"; import { setActivePinia } from "pinia"; import { ref } from "vue"; import EditContentVideo from "./EditContentVideo.vue"; -import LInput from "../forms/LInput.vue"; + +const HLS_URL = "https://example.com/media/post/master.m3u8"; + +const parentWith = (media?: Partial>) => + ref({ + ...mockData.mockPostDto, + media: media ? ({ fileCollections: [], ...media } as any) : undefined, + } as ContentParentDto); + +const mountVideo = (parent: ReturnType) => + mount(EditContentVideo, { + props: { disabled: false, parent: parent.value }, + }); describe("EditContentVideo.vue", () => { beforeAll(async () => { setActivePinia(createTestingPinia()); - accessMap.value = mockData.fullAccessToAllContentMap; }); @@ -20,60 +31,77 @@ describe("EditContentVideo.vue", () => { vi.clearAllMocks(); }); - it("displays the video field, when it is defined", async () => { - const content = ref({ - ...mockData.mockEnglishContentDto, - video: "https://example.com/video.mp4", - }); - const wrapper = mount(EditContentVideo, { - props: { - disabled: false, - content: content.value, - }, - }); + it("displays the video card", async () => { + const wrapper = mountVideo(parentWith({ hlsUrl: HLS_URL })); - const videoContent = wrapper.find('div[data-test="videoContent"]'); - expect(videoContent.exists()).toBe(true); + expect(wrapper.find('div[data-test="videoContent"]').exists()).toBe(true); }); - it("displays video URL in the text input", async () => { - const content = ref({ - ...mockData.mockEnglishContentDto, - video: "https://example.com/video.mp4", - }); + it("shows the playlist URL from media", async () => { + const wrapper = mountVideo(parentWith({ hlsUrl: HLS_URL })); - const wrapper = mount(EditContentVideo, { - props: { - disabled: false, - content: content.value, - }, - }); + const input = wrapper.find("input[name='video']").element as HTMLInputElement; + expect(input.value).toBe(HLS_URL); + }); + + it("writes an edited URL back to media", async () => { + const parent = parentWith({ hlsUrl: HLS_URL }); + const wrapper = mountVideo(parent); - // Find the input field within LInput - const videoInputWrapper = wrapper.find("input[name='video']"); - const videoInput = videoInputWrapper.element as HTMLInputElement; + await wrapper.find("input[name='video']").setValue("https://example.com/new.m3u8"); - // Check if the input value is correctly set - expect(videoInput.value).toBe("https://example.com/video.mp4"); + expect(parent.value.media?.hlsUrl).toBe("https://example.com/new.m3u8"); }); - it("can update the video input field", async () => { - const content = ref({ - ...mockData.mockEnglishContentDto, - video: "https://example.com/video.mp4", - }); - const wrapper = mount(EditContentVideo, { - props: { - disabled: false, - content: content.value, - }, - }); + it("creates media on a document that has none, rather than dropping the edit", async () => { + const parent = parentWith(); + const wrapper = mountVideo(parent); + + await wrapper.find("input[name='video']").setValue(HLS_URL); - const videoInputWrapper = wrapper.findComponent(LInput).find("input[name='video']"); - const videoInput = videoInputWrapper.element as HTMLInputElement; + expect(parent.value.media?.hlsUrl).toBe(HLS_URL); + }); + + it("writes an entered encryption key to media", async () => { + const parent = parentWith({ hlsUrl: HLS_URL }); + const wrapper = mountVideo(parent); + + await wrapper.find("input[name='hlsKey']").setValue("0123456789abcdef"); + + expect(parent.value.media?.hlsKey).toBe("0123456789abcdef"); + }); + + it("clears the key rather than storing an empty string", async () => { + const parent = parentWith({ hlsUrl: HLS_URL, hlsKey: "0123456789abcdef" }); + const wrapper = mountVideo(parent); + + await wrapper.find("input[name='hlsKey']").setValue(""); + + expect(parent.value.media?.hlsKey).toBeUndefined(); + }); - await videoInputWrapper.setValue("https://example.com/new-video.mp4"); + it("says a key is saved when the document holds only its reference", async () => { + // After a save the key itself is gone — the API keeps a crypto object and + // returns its id — so an empty field must not read as "no key". + const wrapper = mountVideo(parentWith({ hlsUrl: HLS_URL, hlsKey_id: "crypto-1" })); + + expect(wrapper.find('[data-test="video-key-note"]').text()).toContain( + "encryption key is saved", + ); + }); + + it("explains when no key is needed", async () => { + const wrapper = mountVideo(parentWith({ hlsUrl: HLS_URL })); + + expect(wrapper.find('[data-test="video-key-note"]').text()).toContain("encrypted"); + }); + + it("disables both fields when the user cannot edit", async () => { + const wrapper = mount(EditContentVideo, { + props: { disabled: true, parent: parentWith({ hlsUrl: HLS_URL }).value }, + }); - expect(videoInput.value).toBe("https://example.com/new-video.mp4"); + expect(wrapper.find("input[name='video']").attributes("disabled")).toBeDefined(); + expect(wrapper.find("input[name='hlsKey']").attributes("disabled")).toBeDefined(); }); }); diff --git a/cms/src/components/content/EditContentVideo.vue b/cms/src/components/content/EditContentVideo.vue index 9c13a010b6..15a07bbe02 100644 --- a/cms/src/components/content/EditContentVideo.vue +++ b/cms/src/components/content/EditContentVideo.vue @@ -1,24 +1,67 @@ + + diff --git a/cms/src/components/media/MediaEditor.spec.ts b/cms/src/components/media/MediaEditor.spec.ts index c8ded41f53..5f55f7821a 100644 --- a/cms/src/components/media/MediaEditor.spec.ts +++ b/cms/src/components/media/MediaEditor.spec.ts @@ -1,29 +1,10 @@ import "fake-indexeddb/auto"; import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { mount } from "@vue/test-utils"; +import { nextTick } from "vue"; import * as mockData from "@/tests/mockdata"; import MediaEditor from "./MediaEditor.vue"; -import { MediaType, MediaPreset, type ContentParentDto, db } from "luminary-shared"; -import LDialog from "../common/LDialog.vue"; - -// Mock browser APIs -global.URL.createObjectURL = vi.fn(() => "mocked-url"); -global.URL.revokeObjectURL = vi.fn(); - -// Mock FileReader with proper event handling -class MockFileReader { - result: ArrayBuffer | null = null; - onload: ((event: any) => void) | null = null; - - readAsArrayBuffer(file: File) { - const buffer = new ArrayBuffer(file.size || 1024); - this.result = buffer; - if (this.onload) { - this.onload({ target: { result: buffer } }); - } - } -} -global.FileReader = MockFileReader as any; +import { MediaType, type ContentParentDto, db } from "luminary-shared"; // Mock storageSelection composable const mockMediaBuckets = vi.hoisted(() => { @@ -68,11 +49,24 @@ vi.mock("@/globalConfig", async (importOriginal) => { }; }); +const ONE_BUCKET = [mockMediaBuckets.value[0]]; +const TWO_BUCKETS = [ + mockMediaBuckets.value[0], + { + _id: "bucket-media-2", + name: "Second bucket", + publicUrl: "http://localhost:9000/media2", + storageType: "media", + mimeTypes: ["audio/*"], + }, +]; + describe("MediaEditor.vue", () => { let parent: ContentParentDto; beforeEach(async () => { parent = { ...mockData.mockCategoryDto }; + mockMediaBuckets.value = ONE_BUCKET; await db.docs.bulkPut([ mockData.mockLanguageDtoEng, @@ -85,243 +79,95 @@ describe("MediaEditor.vue", () => { await db.docs.clear(); }); - it("shows generic empty state message when no media exists", async () => { - const wrapper = mount(MediaEditor, { - props: { - parent: parent, - disabled: false, - }, - }); - - expect(wrapper.text()).toContain("No audio files uploaded yet."); - }); - - it("shows thumbnail area when media exists", async () => { - parent.media = { - fileCollections: [ - { - fileUrl: "https://example.com/audio-fr.mp3", - languageId: mockData.mockLanguageDtoFra._id, - bitrate: 128000, - mediaType: MediaType.Audio, - }, - ], - uploadData: [], - }; - - const wrapper = mount(MediaEditor, { - props: { - parent: parent, - disabled: false, - }, - }); - - const thumbnailArea = wrapper.find('[data-test="thumbnail-area"]'); - expect(thumbnailArea.exists()).toBe(true); - }); + const mountEditor = () => mount(MediaEditor, { props: { parent, disabled: false } }); - it("respects file input constraints", async () => { - const wrapper = mount(MediaEditor, { - props: { - parent: parent, - disabled: false, - }, + describe("existing audio", () => { + it("says so when the document has none", async () => { + expect(mountEditor().text()).toContain("No audio files"); }); - const fileInput = wrapper.find('[data-test="audio-upload"]'); - expect(fileInput.exists()).toBe(true); - expect(fileInput.attributes("multiple")).toBeUndefined(); - expect(fileInput.attributes("accept")).toContain("audio/*"); - }); - - it("shows empty message when media has empty fileCollections and no uploadData", async () => { - parent.media = { - fileCollections: [], - uploadData: [], - }; - - const wrapper = mount(MediaEditor, { - props: { - parent: parent, - disabled: false, - }, + it("lists audio the document already carries", async () => { + parent.media = { + fileCollections: [ + { + languageId: "lang-eng", + fileUrl: "http://localhost:9000/media/audio.mp3", + bitrate: 128, + mediaType: MediaType.Audio, + }, + ], + }; + + const wrapper = mountEditor(); + + expect(wrapper.find('[data-test="thumbnail-area"]').exists()).toBe(true); }); - expect(wrapper.text()).toContain("No audio files uploaded yet."); - }); + it("treats an empty collection list as no media", async () => { + parent.media = { fileCollections: [] }; - it("exposes handleFiles method", () => { - const wrapper = mount(MediaEditor, { - props: { - parent: parent, - disabled: false, - }, + expect(mountEditor().find('[data-test="no-media-message"]').exists()).toBe(true); }); - - expect((wrapper.vm as any).handleFiles).toBeDefined(); }); - it("shows upload data thumbnails when uploadData exists", () => { - parent.media = { - fileCollections: [], - uploadData: [ - { - languageId: mockData.mockLanguageDtoEng._id, - fileData: new ArrayBuffer(100), - mediaType: MediaType.Audio, - preset: MediaPreset.Speech, - }, - ], - }; + describe("uploading", () => { + it("offers no way to upload — media comes from the encoder", async () => { + parent.media = { fileCollections: [] }; + const wrapper = mountEditor(); - const wrapper = mount(MediaEditor, { - props: { - parent: parent, - disabled: false, - }, + // The CMS no longer processes uploads: the API dropped the pipeline, so a + // file picker here would produce data nothing acts on. + expect(wrapper.find("input[type='file']").exists()).toBe(false); + expect(wrapper.find('[data-test="audio-upload"]').exists()).toBe(false); + expect(wrapper.text().toLowerCase()).not.toContain("drop your files"); }); - - const thumbnailArea = wrapper.find('[data-test="thumbnail-area"]'); - expect(thumbnailArea.exists()).toBe(true); }); - // New tests covering storageSelection and language selection - - it("handleFiles shows error when no bucket is selected", async () => { - // Use multiple buckets so auto-select doesn't kick in - const origBuckets = [...mockMediaBuckets.value]; - mockMediaBuckets.value = [ - ...origBuckets, - { _id: "bucket-media-2", name: "Second Media", publicUrl: "http://test2.com", storageType: "media", mimeTypes: ["audio/*"] }, - ]; - - parent.mediaBucketId = undefined; - - const wrapper = mount(MediaEditor, { - props: { parent, disabled: false }, + describe("bucket selection", () => { + it("hides the selector when there is only one bucket", async () => { + expect(mountEditor().find('[data-test="bucket-select"]').exists()).toBe(false); }); - const mockFile = new File(["audio"], "test.mp3", { type: "audio/mp3" }); - const fileList = { 0: mockFile, length: 1, item: (i: number) => (i === 0 ? mockFile : null) }; - - const component = wrapper.vm as any; - component.handleFiles(fileList); - await wrapper.vm.$nextTick(); + it("offers a selector when there is more than one", async () => { + mockMediaBuckets.value = TWO_BUCKETS; - expect(wrapper.text()).toContain("Please select a storage bucket"); - - mockMediaBuckets.value = origBuckets; - }); - - it("handleFiles shows error when no buckets are configured", async () => { - const origBuckets = [...mockMediaBuckets.value]; - mockMediaBuckets.value = []; - parent.mediaBucketId = undefined; - - const wrapper = mount(MediaEditor, { - props: { parent, disabled: false }, + expect(mountEditor().find('[data-test="bucket-select"]').exists()).toBe(true); }); - await wrapper.vm.$nextTick(); - expect(wrapper.text()).toContain("No storage buckets configured"); - - mockMediaBuckets.value = origBuckets; - }); + it("auto-selects a single bucket without dirtying the document", async () => { + mountEditor(); - it("handleFiles opens language selector when bucket is configured", async () => { - parent.mediaBucketId = "bucket-media"; - parent.media = { fileCollections: [], uploadData: [] }; - - const wrapper = mount(MediaEditor, { - props: { parent, disabled: false }, + // Writing it here would mark an untouched legacy document as edited. + expect(parent.mediaBucketId).toBeUndefined(); }); - const mockFile = new File(["audio"], "test.mp3", { type: "audio/mp3" }); - const fileList = { 0: mockFile, length: 1, item: (i: number) => (i === 0 ? mockFile : null) }; - - const component = wrapper.vm as any; - component.handleFiles(fileList); - await wrapper.vm.$nextTick(); + it("clears a bucket that no longer exists", async () => { + parent.mediaBucketId = "bucket-that-was-deleted"; - // Language selector dialog should open - const dialogs = wrapper.findAllComponents(LDialog); - const languageDialog = dialogs.find((d) => d.props("title") === "Select Language for Audio"); - expect(languageDialog).toBeDefined(); - expect(languageDialog?.props("open")).toBe(true); - }); + mountEditor(); + await new Promise((r) => setTimeout(r, 0)); - it("cancelLanguageSelection resets state", async () => { - parent.mediaBucketId = "bucket-media"; - parent.media = { fileCollections: [], uploadData: [] }; - - const wrapper = mount(MediaEditor, { - props: { parent, disabled: false }, + expect(parent.mediaBucketId).toBeUndefined(); }); - // Open language selector - const mockFile = new File(["audio"], "test.mp3", { type: "audio/mp3" }); - const fileList = { 0: mockFile, length: 1, item: (i: number) => (i === 0 ? mockFile : null) }; - - const component = wrapper.vm as any; - component.handleFiles(fileList); - await wrapper.vm.$nextTick(); + it("warns when several buckets exist and none is chosen", async () => { + mockMediaBuckets.value = TWO_BUCKETS; + const wrapper = mountEditor(); + await nextTick(); - // Cancel - const dialogs = wrapper.findAllComponents(LDialog); - const languageDialog = dialogs.find((d) => d.props("title") === "Select Language for Audio"); - const cancelAction = languageDialog?.props("secondaryAction") as Function; - cancelAction(); - await wrapper.vm.$nextTick(); - - expect(languageDialog?.props("open")).toBe(false); - }); - - it("auto-selects the single available bucket without mutating parent on mount", async () => { - parent.mediaBucketId = undefined; - - const wrapper = mount(MediaEditor, { - props: { parent, disabled: false }, + expect(wrapper.find('[data-test="failure-message"]').text()).toContain( + "select a storage bucket", + ); }); - // Opening the editor must not write to the parent — that was the - // phantom-dirty bug. The auto-selection is resolved internally via - // effectiveMediaBucketId so the upload picker and validation behave - // as if the single bucket is selected. - expect(parent.mediaBucketId).toBeUndefined(); - expect(wrapper.text()).not.toContain("Please select a storage bucket"); - - // A genuine upload action persists the auto-selected bucket onto the - // parent, so the file lands in the right place. - const mockFile = new File(["audio"], "test.mp3", { type: "audio/mp3" }); - const fileList = { - 0: mockFile, - length: 1, - item: (i: number) => (i === 0 ? mockFile : null), - }; - (wrapper.vm as any).handleFiles(fileList); - await wrapper.vm.$nextTick(); + it("warns when no buckets are configured at all", async () => { + mockMediaBuckets.value = []; + const wrapper = mountEditor(); + await nextTick(); - expect(parent.mediaBucketId).toBe("bucket-media"); - }); - - it("language selector has a select element for choosing language", async () => { - parent.mediaBucketId = "bucket-media"; - parent.media = { fileCollections: [], uploadData: [] }; - - const wrapper = mount(MediaEditor, { - props: { parent, disabled: false }, + expect(wrapper.find('[data-test="failure-message"]').text()).toContain( + "No storage buckets configured", + ); }); - - // Open language selector - const mockFile = new File(["audio"], "test.mp3", { type: "audio/mp3" }); - const fileList = { 0: mockFile, length: 1, item: (i: number) => (i === 0 ? mockFile : null) }; - - const component = wrapper.vm as any; - component.handleFiles(fileList); - await wrapper.vm.$nextTick(); - - // Language select should be present in the dialog - const selects = wrapper.findAll("#language-select"); - expect(selects.length).toBeGreaterThan(0); }); }); diff --git a/cms/src/components/media/MediaEditor.vue b/cms/src/components/media/MediaEditor.vue index 3e5033d71a..9afa0a6fb5 100644 --- a/cms/src/components/media/MediaEditor.vue +++ b/cms/src/components/media/MediaEditor.vue @@ -1,28 +1,28 @@ diff --git a/cms/src/composables/useMediaEncoder.ts b/cms/src/composables/useMediaEncoder.ts new file mode 100644 index 0000000000..0095e877a8 --- /dev/null +++ b/cms/src/composables/useMediaEncoder.ts @@ -0,0 +1,141 @@ +import { ref, onUnmounted } from "vue"; +import { getRest, type MediaDto } from "luminary-shared"; +import { + checkEncoderHealth, + createEncoderSession, + fetchEncoderSessionKey, + subscribeToEncoderSession, + type EncoderSessionEvent, +} from "@/util/mediaEncoder"; + +export type EncoderAvailability = "unknown" | "checking" | "available" | "unavailable"; + +/** + * Drives one document's encode: check the encoder is there, hand it the bucket, and + * follow the session until it finishes. + * + * The media is saved at the *first* `encoding` event, not at completion. The + * destination key is settled the moment encoding starts, so the encoder publishes + * `hlsUrl` then — which lets an editor save and move on while a long encode runs. + * The URL 404s until the first segments land; the app's coming-soon state covers + * that window. + */ +export function useMediaEncoder() { + const availability = ref("unknown"); + const encoderVersion = ref(); + + const busy = ref(false); + const status = ref(); + const progress = ref(); + const error = ref(); + const sessionId = ref(); + + let unsubscribe: (() => void) | undefined; + + /** Is the encoder installed and running? Safe to call repeatedly. */ + async function refreshAvailability(): Promise { + availability.value = "checking"; + const health = await checkEncoderHealth(); + encoderVersion.value = health.apiVersion; + availability.value = health.available ? "available" : "unavailable"; + return health.available; + } + + function stop() { + unsubscribe?.(); + unsubscribe = undefined; + } + + /** + * Open an encoder session for this document and follow it. + * + * `onMediaReady` fires once, as soon as the encode has a published URL. The + * caller writes it to the document — persistence is the editor's business, not + * this composable's. + */ + async function start(options: { + documentId: string; + title: string; + mediaBucketId: string; + onMediaReady: (media: Pick) => void; + }): Promise { + error.value = undefined; + status.value = undefined; + progress.value = undefined; + busy.value = true; + + try { + if (!(await refreshAvailability())) { + throw new Error( + "Luminary Media Convert is not running. Start it and try again.", + ); + } + + // The credentials are held encrypted server-side and are not replicated + // to the browser, so they are fetched per encode rather than read off + // the bucket document this page already has. + const config = await getRest().getEncoderConfig(options.mediaBucketId); + if (!config) { + throw new Error("Could not read the storage configuration for this bucket."); + } + + const session = await createEncoderSession({ + documentId: options.documentId, + title: options.title, + s3: config.s3, + publicBaseUrl: config.publicBaseUrl, + encryption: { required: true }, + }); + + sessionId.value = session.sessionId; + + let saved = false; + stop(); + unsubscribe = subscribeToEncoderSession(session.eventsUrl, { + onEvent: (event: EncoderSessionEvent) => { + status.value = event.status; + progress.value = event.progress; + if (event.error) error.value = event.error; + + if (saved || !event.hlsUrl) return; + saved = true; + + void (async () => { + // An unencrypted session has no key, which the encoder + // answers with a 404 and this reports as undefined. + const hlsKey = await fetchEncoderSessionKey( + session.sessionId, + session.readToken, + ).catch(() => undefined); + + options.onMediaReady({ hlsUrl: event.hlsUrl, hlsKey }); + })(); + }, + onError: () => { + // The stream drops when the encoder quits or the session ends. + // Anything already saved stands. + stop(); + }, + }); + } catch (err: any) { + error.value = err?.message ?? String(err); + } finally { + busy.value = false; + } + } + + onUnmounted(stop); + + return { + availability, + encoderVersion, + busy, + status, + progress, + error, + sessionId, + refreshAvailability, + start, + stop, + }; +} diff --git a/cms/src/util/mediaEncoder.spec.ts b/cms/src/util/mediaEncoder.spec.ts new file mode 100644 index 0000000000..298aefcfe5 --- /dev/null +++ b/cms/src/util/mediaEncoder.spec.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { createHash } from "crypto"; +import { + unmaskKeyHex, + checkEncoderHealth, + createEncoderSession, + fetchEncoderSessionKey, +} from "./mediaEncoder"; + +/** The masking the encoder applies, computed here independently of the code under test. */ +function maskKey(sessionId: string, keyHex: string): string { + const mask = createHash("sha256").update(sessionId).digest().subarray(0, 16); + const key = Buffer.from(keyHex, "hex"); + return Buffer.from(key.map((byte, i) => byte ^ mask[i % mask.length])).toString("hex"); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("unmaskKeyHex", () => { + it("recovers a key masked with SHA-256(sessionId)[0..15]", async () => { + const sessionId = "abc123"; + const keyHex = "000102030405060708090a0b0c0d0e0f"; + + expect(await unmaskKeyHex(sessionId, maskKey(sessionId, keyHex))).toBe(keyHex); + }); + + it("is its own inverse, so masking twice returns the input", async () => { + const sessionId = "session-xyz"; + const keyHex = "ffeeddccbbaa99887766554433221100"; + + const once = await unmaskKeyHex(sessionId, keyHex); + expect(await unmaskKeyHex(sessionId, once)).toBe(keyHex); + }); + + it("produces a different key for a different session, so keys cannot be crossed", async () => { + const keyHex = "0f0e0d0c0b0a09080706050403020100"; + const masked = maskKey("session-a", keyHex); + + expect(await unmaskKeyHex("session-b", masked)).not.toBe(keyHex); + }); + + it("returns 16 bytes for a 16-byte key", async () => { + const keyHex = "112233445566778899aabbccddeeff00"; + + expect(await unmaskKeyHex("s", maskKey("s", keyHex))).toHaveLength(32); + }); +}); + +describe("checkEncoderHealth", () => { + it("reports available with the version when the encoder answers", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ status: "ok", apiVersion: "0.0.1" }), + }), + ); + + expect(await checkEncoderHealth()).toEqual({ available: true, apiVersion: "0.0.1" }); + }); + + it("reports unavailable rather than throwing when nothing is listening", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); + + expect(await checkEncoderHealth()).toEqual({ available: false }); + }); + + it("reports unavailable when something else is on the port", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: true, json: async () => ({ status: "nope" }) }), + ); + + expect(await checkEncoderHealth()).toEqual({ available: false, apiVersion: undefined }); + }); +}); + +describe("createEncoderSession", () => { + it("posts the session and returns the encoder's response", async () => { + const response = { + sessionId: "s1", + readToken: "read_1", + eventsUrl: "http://127.0.0.1:31711/api/sessions/s1/events?token=read_1", + apiVersion: "0.0.1", + reused: false, + }; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + text: async () => JSON.stringify(response), + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await createEncoderSession({ + documentId: "post-1", + title: "Episode 12", + s3: { endPoint: "minio.local", bucket: "media", accessKey: "a", secretKey: "b" }, + publicBaseUrl: "https://cdn.example.com/media", + }); + + expect(result).toEqual(response); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("http://127.0.0.1:31711/api/cms/sessions"); + expect(JSON.parse(init.body).documentId).toBe("post-1"); + }); + + it("surfaces a refusal, which includes the user declining the trust prompt", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: false, status: 403, text: async () => "Forbidden" }), + ); + + await expect( + createEncoderSession({ + documentId: "post-1", + title: "t", + s3: { endPoint: "e", bucket: "b", accessKey: "a", secretKey: "s" }, + publicBaseUrl: "https://cdn.example.com", + }), + ).rejects.toThrow(/403/); + }); +}); + +describe("fetchEncoderSessionKey", () => { + it("returns the unmasked key", async () => { + const sessionId = "s1"; + const keyHex = "0123456789abcdef0123456789abcdef"; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ maskedKeyHex: maskKey(sessionId, keyHex) }), + }), + ); + + expect(await fetchEncoderSessionKey(sessionId, "read_1")).toBe(keyHex); + }); + + it("returns undefined for an unencrypted session, which answers 404", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404 })); + + expect(await fetchEncoderSessionKey("s1", "read_1")).toBeUndefined(); + }); +}); diff --git a/cms/src/util/mediaEncoder.ts b/cms/src/util/mediaEncoder.ts new file mode 100644 index 0000000000..0ab9432305 --- /dev/null +++ b/cms/src/util/mediaEncoder.ts @@ -0,0 +1,190 @@ +/** + * Client for the Luminary Media Convert desktop encoder. + * + * The encoder is a local application listening on loopback, not a service we host. + * The browser talks to it directly: nothing is uploaded through the CMS, and the + * encoder writes to S3 itself with credentials this page forwards to it. + * + * Two consequences shape everything below: + * + * 1. It may simply not be running, which is an ordinary state rather than an error. + * Every entry point starts with a health check, and the fallback is a + * `luminary-convert://` link that launches the installed app. + * 2. Reaching 127.0.0.1 from a public origin is a private-network request. Chrome + * sends a Local Network Access preflight and the encoder answers it, then asks + * the user to trust this origin the first time. Firefox and Safari do not + * implement the grant, so this is Chrome-only at time of writing. + */ + +/** + * Where the encoder listens. + * + * 31711 is the port the desktop app binds (DEFAULT_PORT in the encoder's + * bootstrap.ts) and is the only value that matters in production — the address is + * fixed by the installed application, not configured by whoever deploys the CMS. + * VITE_ENCODER_URL exists for running the encoder's API standalone in development, + * where it defaults to 3000 instead. + */ +export const ENCODER_BASE_URL = + import.meta.env.VITE_ENCODER_URL || "http://127.0.0.1:31711"; + +/** Launch link for an encoder that is installed but not running. */ +export const ENCODER_PROTOCOL_URL = "luminary-convert://"; + +export type EncoderHealth = { + available: boolean; + apiVersion?: string; +}; + +export type EncoderS3Config = { + endPoint: string; + port?: number; + useSSL?: boolean; + bucket: string; + region?: string; + accessKey: string; + secretKey: string; + pathPrefix?: string; +}; + +export type CreateEncoderSessionRequest = { + /** Idempotency key: a repeat request for the same document reuses the session. */ + documentId: string; + title: string; + s3: EncoderS3Config; + publicBaseUrl: string; + encryption?: { required: boolean }; + existingMedia?: { hlsUrl: string; hlsKey?: string }; +}; + +export type CreateEncoderSessionResponse = { + sessionId: string; + readToken: string; + eventsUrl: string; + apiVersion: string; + reused: boolean; +}; + +/** The encoder's SSE frame. Only the fields the CMS acts on are named. */ +export type EncoderSessionEvent = { + sessionId: string; + status: string; + progress?: number; + error?: string; + hlsUrl?: string; +}; + +/** + * Is the encoder installed and running? Never throws: "not running" is the + * expected answer whenever the editor has not installed it. + */ +export async function checkEncoderHealth( + baseUrl: string = ENCODER_BASE_URL, +): Promise { + try { + const res = await fetch(`${baseUrl}/api/cms/health`); + if (!res.ok) return { available: false }; + const body = (await res.json()) as { status?: string; apiVersion?: string }; + return { available: body.status === "ok", apiVersion: body.apiVersion }; + } catch { + return { available: false }; + } +} + +/** + * Open (or reuse) an encoder session for a document. + * + * Authorised by this page's Origin rather than by a key — there is no credential a + * browser page could hold that the pages around it could not also read. The first + * request from a new origin raises a native trust prompt in the encoder, so a + * rejection here can mean "the user said no" as much as "something is broken". + */ +export async function createEncoderSession( + request: CreateEncoderSessionRequest, + baseUrl: string = ENCODER_BASE_URL, +): Promise { + const res = await fetch(`${baseUrl}/api/cms/sessions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); + const text = await res.text(); + if (!res.ok) { + throw new Error(`Encoder refused the session (HTTP ${res.status}): ${text}`); + } + return JSON.parse(text) as CreateEncoderSessionResponse; +} + +/** + * Subscribe to a session's progress. Returns the unsubscribe function. + * + * `eventsUrl` already carries the read token, which is watch-only: it cannot start, + * cancel, or reach the source file. + */ +export function subscribeToEncoderSession( + eventsUrl: string, + handlers: { + onEvent?: (event: EncoderSessionEvent) => void; + onError?: () => void; + }, +): () => void { + const source = new EventSource(eventsUrl); + + source.onmessage = (message) => { + try { + handlers.onEvent?.(JSON.parse(message.data) as EncoderSessionEvent); + } catch { + /* a frame we cannot parse is not worth tearing the stream down for */ + } + }; + source.onerror = () => handlers.onError?.(); + + return () => source.close(); +} + +/** + * Fetch the session's AES-128 key, unmasked. + * + * The key is never part of a status or event payload. It is served masked from its + * own endpoint and unmasked by the holder: + * + * mask = SHA-256(sessionId)[0..15] + * key = masked XOR mask (XOR is its own inverse) + * + * This keeps raw keys out of logs and proxies. It is obscurity, not DRM, and the + * encoder documents it as such. + * + * Returns undefined when the session is unencrypted (404) — which is an answer, + * not a failure. + */ +export async function fetchEncoderSessionKey( + sessionId: string, + readToken: string, + baseUrl: string = ENCODER_BASE_URL, +): Promise { + const res = await fetch( + `${baseUrl}/api/sessions/${encodeURIComponent(sessionId)}/key` + + `?token=${encodeURIComponent(readToken)}`, + ); + if (!res.ok) return undefined; + + const body = (await res.json()) as { maskedKeyHex?: string }; + if (!body.maskedKeyHex) return undefined; + + return await unmaskKeyHex(sessionId, body.maskedKeyHex); +} + +/** XOR the masked key with SHA-256(sessionId)[0..15]. Self-inverse. */ +export async function unmaskKeyHex(sessionId: string, maskedKeyHex: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(sessionId)); + const mask = new Uint8Array(digest).subarray(0, 16); + + const masked = new Uint8Array(maskedKeyHex.length >> 1); + for (let i = 0; i < masked.length; i++) { + masked[i] = parseInt(maskedKeyHex.substring(i * 2, i * 2 + 2), 16); + } + + return Array.from(masked, (byte, i) => + (byte ^ mask[i % mask.length]).toString(16).padStart(2, "0"), + ).join(""); +} diff --git a/shared/src/api/RestApi.ts b/shared/src/api/RestApi.ts index 4b45dad14c..3a03596dad 100644 --- a/shared/src/api/RestApi.ts +++ b/shared/src/api/RestApi.ts @@ -70,6 +70,22 @@ export type StorageStatusResponse = { message?: string; }; +/** + * The bucket's S3 credentials and public base URL, shaped for the local media + * encoder's session request so it can be forwarded without reshaping. + */ +export type EncoderConfigResponse = { + s3: { + endPoint: string; + port: number; + useSSL: boolean; + bucket: string; + accessKey: string; + secretKey: string; + }; + publicBaseUrl: string; +}; + class RestApi { private http: HttpReq; /** @@ -116,6 +132,17 @@ class RestApi { apiVersion: "0.0.0", }); } + + /** + * Fetch the encode destination for a media bucket. Requires Assign permission on + * the bucket, since the response carries credentials that can write to it. + */ + async getEncoderConfig(bucketId: string): Promise { + return await this.http.getWithQueryParams("storage/encoderconfig", { + bucketId, + apiVersion: "0.0.0", + }); + } } let rest: RestApi; diff --git a/shared/src/types/dto.ts b/shared/src/types/dto.ts index 7bdbe708a4..cf56a9581e 100644 --- a/shared/src/types/dto.ts +++ b/shared/src/types/dto.ts @@ -203,6 +203,8 @@ export type CryptoDto = BaseDocumentDto & { export type MediaDto = { hlsUrl?: string; + hlsKey_id?: Uuid; + hlsKey?: string; fileCollections: MediaFileDto[]; uploadData?: MediaUploadDataDto[]; }; From 8c5ff237445e39de3f42ab4ed0e4f8bd3d033a12 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Fri, 14 Aug 2026 08:51:47 +0200 Subject: [PATCH 03/60] feat(audio): implement audio track selection logic and update related tests --- app/src/components/content/VideoPlayer.vue | 57 ++++- .../content/audioTrackLanguage.spec.ts | 44 +++- .../components/content/audioTrackLanguage.ts | 20 ++ .../extractAndBuildAudioMaster.spec.ts | 26 +- .../content/extractAndBuildAudioMaster.ts | 239 +++++++++--------- cms/src/composables/useMediaEncoder.ts | 9 +- cms/src/util/mediaEncoder.ts | 2 +- docs/README.md | 1 + docs/guides/media-encoder-integration.md | 111 ++++++++ 9 files changed, 357 insertions(+), 152 deletions(-) create mode 100644 docs/guides/media-encoder-integration.md diff --git a/app/src/components/content/VideoPlayer.vue b/app/src/components/content/VideoPlayer.vue index 8a4f2b8fdf..570d615184 100644 --- a/app/src/components/content/VideoPlayer.vue +++ b/app/src/components/content/VideoPlayer.vue @@ -5,7 +5,7 @@ import "videojs-mobile-ui"; import type Player from "video.js/dist/types/player"; import { type ContentDto } from "luminary-shared"; import px from "./px.png"; -import { matchTrackLanguage } from "./audioTrackLanguage"; +import { pickAudioTrack } from "./audioTrackLanguage"; import LImage from "../images/LImage.vue"; import { appLanguagesPreferredAsRef, queryParams } from "@/globalConfig"; import { getMediaProgress, removeMediaProgress, setMediaProgress } from "@/contentProgress"; @@ -89,21 +89,18 @@ function setAudioTrackLanguage(languageCode: string | null) { return; } - let trackFound = false; - for (let i = 0; i < audioTracks.length; i++) { - const track = audioTracks[i]; + // Decide before changing anything: disabling as we go leaves every track + // disabled when none matches, which silently starves the stream of audio. + const tracks: any[] = []; + for (let i = 0; i < audioTracks.length; i++) tracks.push(audioTracks[i]); - if (matchTrackLanguage(track.language, languageCode)) { - track.enabled = true; - trackFound = true; - } else { - track.enabled = false; - } - } - - if (!trackFound) { + const match = pickAudioTrack(tracks, languageCode); + if (!match) { console.warn(`No matching audio track found for language: ${languageCode}`); + return; } + + for (const track of tracks) track.enabled = track === match; } function syncKeepAudioStateAlive() { @@ -191,6 +188,40 @@ onMounted(async () => { const playerEvent = new CustomEvent("vjsPlayer", { detail: player }); window.dispatchEvent(playerEvent); + // Playback diagnostics. A stream that stops part-way looks identical from + // outside the browser whether the player errored, ran dry waiting for a + // segment, or decided the media had ended — and those need different fixes. + // Enable with ?playerdebug=true. + if (queryParams.get("playerdebug") === "true") { + const at = () => `t=${player?.currentTime()?.toFixed(2)}s`; + const log = (event: string, extra?: unknown) => + console.log(`[player] ${event} ${at()}`, extra ?? ""); + + for (const event of ["waiting", "stalled", "ended", "pause", "suspend", "abort"]) { + player.on(event, () => log(event)); + } + player.on("error", () => log("error", player?.error())); + player.on("loadedmetadata", () => + log("loadedmetadata", { duration: player?.duration() }), + ); + player.on("progress", () => { + const b = player?.buffered(); + if (!b || b.length === 0) return log("progress", "nothing buffered"); + log("progress", `buffered to ${b.end(b.length - 1).toFixed(2)}s`); + }); + // VHS reports segment and playlist trouble the