From 34cc7270f6acdaa566f915b3f0a6a908c948c811 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Fri, 4 Sep 2026 10:37:21 +0200 Subject: [PATCH 1/2] Second review pass over the HLS media epic Races: a slow encode start could write another document's hlsUrl and key onto the one now open, and leaked its EventSource; the Encode button went "checking" on every poll tick and window focus; the poller could re-arm after unmount; encrypted video loaded twice in the app; the CMS preview could take a stale key after a document switch. Duplication: one unmaskKeyHex, one mediaUrl resolver and one fetchHlsKey in shared; one effective-bucket rule; one loadBucket and one MASTER in documentProcessing, using isInOurStorage. Dead code: the app's video.js/videojs-*/m3u8-parser/iso-639-2 dependencies, their shims and stale mocks; LDialog's unused props; a stale coverage snapshot. Also: no-store on the credential endpoint, a relative enums import, two `any`s typed, and the flagged comment paragraphs cut to a why. --- .../deleteMediaCollection.ts | 53 ++-- .../migrateMediaCollection.ts | 57 +--- .../documentProcessing/processPostTagDto.ts | 55 +--- api/src/configuration.ts | 3 - api/src/dto/MediaDto.ts | 2 +- api/src/endpoints/encoderConfig.controller.ts | 13 +- api/src/endpoints/sidecar.controller.ts | 7 +- api/src/util/maskKey.ts | 4 +- app/package-lock.json | 284 +----------------- app/package.json | 7 - .../components/content/VideoPlayer.spec.ts | 42 +-- app/src/components/content/VideoPlayer.vue | 43 ++- .../__tests__/SingleContent.ssr.spec.ts | 1 - .../__tests__/SingleContent.ssrrtext.spec.ts | 1 - app/src/types/videojs-mobile-ui.d.ts | 1 - app/src/types/videojs-youtube.d.ts | 1 - app/src/util/videoSource.ts | 32 +- app/test-coverage.md | 147 --------- cms/src/components/common/LDialog.vue | 4 - .../content/EditContentMedia.spec.ts | 27 +- .../components/content/EditContentMedia.vue | 20 +- .../content/EditContentVideo.spec.ts | 4 +- .../components/content/VideoPreview.spec.ts | 57 ++-- cms/src/components/content/VideoPreview.vue | 14 +- .../composables/useEditContentSource.ts | 7 +- .../media/MediaBucketSelect.spec.ts | 3 + .../components/media/MediaBucketSelect.vue | 8 +- cms/src/composables/storageSelection.ts | 10 + cms/src/composables/useMediaEncoder.spec.ts | 11 +- cms/src/composables/useMediaEncoder.ts | 56 ++-- cms/src/util/mediaEncoder.spec.ts | 43 --- cms/src/util/mediaEncoder.ts | 32 +- cms/src/util/mediaUrl.ts | 36 --- shared/src/util/hlsKey.spec.ts | 52 ++++ shared/src/util/hlsKey.ts | 19 ++ shared/src/util/index.ts | 2 + shared/src/util/mediaUrl.ts | 27 ++ 37 files changed, 354 insertions(+), 831 deletions(-) delete mode 100644 app/src/types/videojs-mobile-ui.d.ts delete mode 100644 app/src/types/videojs-youtube.d.ts delete mode 100644 app/test-coverage.md delete mode 100644 cms/src/util/mediaUrl.ts create mode 100644 shared/src/util/hlsKey.spec.ts create mode 100644 shared/src/util/hlsKey.ts create mode 100644 shared/src/util/mediaUrl.ts diff --git a/api/src/changeRequests/documentProcessing/deleteMediaCollection.ts b/api/src/changeRequests/documentProcessing/deleteMediaCollection.ts index e7d3aae0b4..fc042f268f 100644 --- a/api/src/changeRequests/documentProcessing/deleteMediaCollection.ts +++ b/api/src/changeRequests/documentProcessing/deleteMediaCollection.ts @@ -1,7 +1,7 @@ import { MediaDto } from "../../dto/MediaDto"; import { DbService } from "../../db/db.service"; import { S3Service } from "../../s3/s3.service"; -import { isBucketRelative, toStoredMediaUrl } from "./mediaUrl"; +import { isBucketRelative, isInOurStorage } from "./mediaUrl"; /** * Where a collection lives in its bucket, or why we will not touch it. @@ -22,7 +22,23 @@ export type PrefixResolution = { prefix: string } | { refusal: string }; const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** What the encoder publishes at the root of a collection. */ -const MASTER = "/master.m3u8"; +export const MASTER = "/master.m3u8"; + +/** Where a bucket publishes its objects, and what to call it in a warning. */ +export type Bucket = { publicUrl?: string; name?: string }; + +export async function loadBucket( + bucketId: string, + db: DbService, +): Promise<{ bucket: Bucket } | { error: string }> { + try { + const result = await db.getDoc(bucketId); + if (!result.docs?.length) return { error: `bucket ${bucketId} no longer exists` }; + return { bucket: result.docs[0] }; + } catch (error) { + return { error: error.message }; + } +} /** * Turn a published `hlsUrl` into the object prefix holding that collection. @@ -121,32 +137,19 @@ export async function deleteMediaCollection( return warnings; } - let bucket: { publicUrl?: string; name?: string }; - try { - const result = await db.getDoc(bucketId); - if (!result.docs?.length) { - warnings.push( - `Media files were not deleted: bucket ${bucketId} no longer exists. ` + - "Please remove them on the storage provider.", - ); - return warnings; - } - bucket = result.docs[0]; - } catch (error) { - warnings.push(`Media files were not deleted: ${error.message}`); + const loaded = await loadBucket(bucketId, db); + if ("error" in loaded) { + warnings.push( + `Media files were not deleted: ${loaded.error}. ` + + "Please remove them on the storage provider.", + ); return warnings; } + const bucket = loaded.bucket; - // Media hosted elsewhere — a YouTube link, a master on someone else's CDN — - // has nothing here to delete, and telling the operator to go and remove it - // "on the storage provider" would send them looking for files their bucket - // never held. - if ( - !isBucketRelative(media.hlsUrl) && - toStoredMediaUrl(media.hlsUrl, bucket.publicUrl) === media.hlsUrl - ) { - return warnings; - } + // Media hosted elsewhere has nothing here to delete, and a warning would send the + // operator looking for files their bucket never held. + if (!isInOurStorage(media.hlsUrl, [bucket.publicUrl])) return warnings; const resolved = resolveCollectionPrefix(media.hlsUrl, bucket.publicUrl); if ("refusal" in resolved) { diff --git a/api/src/changeRequests/documentProcessing/migrateMediaCollection.ts b/api/src/changeRequests/documentProcessing/migrateMediaCollection.ts index 5182b424e1..9e41b73cb2 100644 --- a/api/src/changeRequests/documentProcessing/migrateMediaCollection.ts +++ b/api/src/changeRequests/documentProcessing/migrateMediaCollection.ts @@ -1,33 +1,12 @@ import { MediaDto } from "../../dto/MediaDto"; import { DbService } from "../../db/db.service"; import { S3Service } from "../../s3/s3.service"; -import { resolveCollectionPrefix } from "./deleteMediaCollection"; -import { isBucketRelative, toStoredMediaUrl } from "./mediaUrl"; - -/** What the encoder publishes at the root of a collection. */ -const MASTER = "master.m3u8"; +import { MASTER, loadBucket, resolveCollectionPrefix } from "./deleteMediaCollection"; +import { isBucketRelative, isInOurStorage } from "./mediaUrl"; /** The S3 API's own ceiling on keys per delete call. */ const DELETE_BATCH = 1000; -/** - * Where a bucket publishes its objects, and what to call it in a warning. - */ -type Bucket = { publicUrl?: string; name?: string }; - -async function loadBucket( - bucketId: string, - db: DbService, -): Promise<{ bucket: Bucket } | { error: string }> { - try { - const result = await db.getDoc(bucketId); - if (!result.docs?.length) return { error: `bucket ${bucketId} no longer exists` }; - return { bucket: result.docs[0] }; - } catch (error) { - return { error: error.message }; - } -} - /** * Move a media collection from one bucket to another, then point the document at * its new home. @@ -88,17 +67,9 @@ export async function migrateMediaCollection( return { failed: true, warnings }; } - // The same proof used before deleting: a prefix we cannot derive from the - // bucket's own public base is a collection we did not write. - // Media that is not in the old bucket is not ours to move: a YouTube link - // or a master on someone else's CDN belongs to whoever serves it, and the - // bucket change is about where *future* output goes. Treating that as a - // failed migration would revert a change the user made deliberately and - // warn about files that were never going anywhere. - const external = - !isBucketRelative(previousHlsUrl) && - toStoredMediaUrl(previousHlsUrl, oldBucket.publicUrl) === previousHlsUrl; - if (external) return { failed: false, warnings }; + // External media is not ours to move, and a bucket change is about where future + // output goes; calling it a failed migration would revert a deliberate change. + if (!isInOurStorage(previousHlsUrl, [oldBucket.publicUrl])) return { failed: false, warnings }; const resolved = resolveCollectionPrefix(previousHlsUrl, oldBucket.publicUrl); if ("refusal" in resolved) { @@ -141,14 +112,10 @@ export async function migrateMediaCollection( } } - // Only now is the new location real, so only now may the document name it. - // - // A relative URL already names a path inside whichever bucket the - // document points at, so moving buckets does not change it — which is - // the point of storing it that way. Only the legacy absolute form has - // to be rewritten. + // Only now is the new location real. A relative URL already names a path inside + // whichever bucket the document points at; only the legacy absolute form moves. if (!isBucketRelative(media.hlsUrl)) { - media.hlsUrl = `${newBucket.publicUrl.replace(/\/+$/, "")}/${prefix}/${MASTER}`; + media.hlsUrl = `${newBucket.publicUrl.replace(/\/+$/, "")}/${prefix}${MASTER}`; } // Last, and its failure is not the migration's failure: the files are in @@ -177,11 +144,9 @@ export async function migrateMediaCollection( ); return { failed: false, warnings }; } catch (error) { - // Nothing was deleted and the URL was not rewritten, so the collection is - // still whole and still where the document says it is. Anything already - // copied is left in place: it is unreferenced, harmless, and overwritten by - // a retry — whereas deleting it on the way out of a failure risks removing - // objects we did not put there. + // Nothing was deleted and the URL was not rewritten. Copies already made are + // left: a retry overwrites them, and deleting on the way out risks objects we + // did not put there. warnings.push( `Media migration failed: ${error.message}. The files were left in ` + `${oldBucket.name ?? oldBucketId}.`, diff --git a/api/src/changeRequests/documentProcessing/processPostTagDto.ts b/api/src/changeRequests/documentProcessing/processPostTagDto.ts index d52ae45893..ea6804a5b7 100644 --- a/api/src/changeRequests/documentProcessing/processPostTagDto.ts +++ b/api/src/changeRequests/documentProcessing/processPostTagDto.ts @@ -8,6 +8,7 @@ import { processMedia } from "./processMediaDto"; import { deleteMediaCollection } from "./deleteMediaCollection"; import { migrateMediaCollection } from "./migrateMediaCollection"; import { isInOurStorage } from "./mediaUrl"; +import { StorageDto } from "../../dto/StorageDto"; import { deleteSidecar, deleteSidecarsForParent, @@ -43,22 +44,16 @@ export default async function processPostTagDto( warnings.push(...imageWarnings); } - // Media files go only when the user asked for them in the delete - // confirmation. Opt-in because it is irreversible and because the - // collection may be referenced somewhere this API cannot see; the previous - // document is the authority on where the files are, and the incoming one - // carries the intent. + // Opt-in from the delete confirmation: irreversible, and the collection may be + // referenced somewhere this API cannot see. prevDoc knows where the files are. if (doc.media?.deleteFiles) { warnings.push( ...(await deleteMediaCollection(prevDoc?.media, prevDoc?.mediaBucketId, db)), ); } - // Sidecars are children of this document — nothing else references them and - // no client holds a copy, so they go with it. Hard delete, no DeleteCmd. A - // failure here must not block the content delete: an orphaned sidecar is - // unreadable once the parent is gone (GET /sidecar 404s), so warn rather - // than throw, matching deleteImage's precedent. + // Sidecars go with their parent (hard delete, no DeleteCmd). Warn rather than + // block the delete, as deleteImage does: an orphan is unreadable anyway. try { await deleteSidecarsForParent(db, doc._id); } catch (error) { @@ -121,32 +116,20 @@ export default async function processPostTagDto( delete (doc as any).image; // Remove the legacy image field } - // Process media if (doc.media) { - // 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 — so it is required - // for a collection in our own storage, and meaningless for one that is - // not. A YouTube link, or an HLS master on someone else's CDN, has no - // bucket to be relative to and nothing here to migrate or delete; - // demanding one records a bucket that does not own anything. - // - // Asked of the configured buckets rather than assumed from the shape of - // the URL: an absolute URL under a bucket's public URL is ours, and is - // exactly the case that must not be saved without naming its bucket — - // stored un-relative, it breaks the moment that bucket is renamed. + // A collection in our own storage must name its bucket: that is how the URL is + // stored relative, migrated and deleted. External media has no bucket to name. if (doc.media.hlsUrl && !doc.mediaBucketId) { const buckets = await db.getDocsByType(DocType.Storage); - const publicUrls = buckets.docs.map((b: any) => b.publicUrl); + const publicUrls = buckets.docs.map((b: StorageDto) => b.publicUrl); if (isInOurStorage(doc.media.hlsUrl, publicUrls)) { throw new Error("Bucket is not specified for media processing."); } } - // A bucket change has to take the files with it. `mediaBucketId` and - // `hlsUrl` must name the same bucket: if they diverge, the collection can no - // longer be resolved from the URL, and deleting the document then leaves the - // files behind for good. + // A bucket change takes the files with it: `mediaBucketId` and `hlsUrl` must + // name the same bucket, or a later delete cannot find the collection. if (prevDoc?.mediaBucketId && prevDoc.mediaBucketId !== doc.mediaBucketId) { const migration = await migrateMediaCollection( doc.media, @@ -165,21 +148,15 @@ export default async function processPostTagDto( } } - // A failed key store must fail the change request, not become a warning: the - // plaintext key exists only for the duration of this request (processMedia has - // already dropped it by the time the error is caught), so saving the Post with - // an `hlsUrl` and no `hlsKey_id` would leave an unplayable, unrecoverable - // collection. Let it throw — processChangeRequest has no catch here, so the CR - // fails and the editor still holds the key to retry. + // Deliberately not caught: the plaintext key exists only for this request, so a + // saved `hlsUrl` without its `hlsKey_id` would be unrecoverable. Failing the + // change request leaves the editor holding the key to retry. warnings.push(...(await processMedia(doc.media, doc, db))); } - // A key that was referenced and no longer is has been removed by the editor — - // whether they cleared the key field or the whole media object. Outside the - // `if (doc.media)` block on purpose: removing the collection drops doc.media - // entirely, which is the case a check inside processMedia would never see. A - // fresh hlsKey in the same request means replace, not delete — processMedia - // has already recreated the sidecar at the same id. See ADR 0019. + // Outside `if (doc.media)`: removing the whole media object removes the key too. + // A fresh `hlsKey` in the same request is a replacement processMedia has already + // stored at the same sidecar id (ADR 0019). if (prevDoc?.media?.hlsKey_id && !doc.media?.hlsKey_id && !doc.media?.hlsKey) { await deleteSidecar(db, doc._id, SidecarType.HlsEncryptionKey); } diff --git a/api/src/configuration.ts b/api/src/configuration.ts index 440ccfe1a1..c02f920a6a 100644 --- a/api/src/configuration.ts +++ b/api/src/configuration.ts @@ -1,6 +1,3 @@ -// RateLimiterConfig shape: { enabled, freeStrikes, baseBackoffMs, maxBackoffMs, strikeDecayMs }. -// Every endpoint-specific rate-limit config below is one of these, keyed under its own env prefix -// and its own config namespace, so limiters never share a bucket across endpoints. import { RateLimiterConfig } from "./ratelimit/rateLimiter.service"; export type DatabaseConfig = { diff --git a/api/src/dto/MediaDto.ts b/api/src/dto/MediaDto.ts index a9b864d856..88a5cbd955 100644 --- a/api/src/dto/MediaDto.ts +++ b/api/src/dto/MediaDto.ts @@ -1,7 +1,7 @@ import "reflect-metadata"; // https://stackoverflow.com/questions/72009995/typeerror-reflect-getmetadata-is-not-a-function import { IsBoolean, IsOptional, IsString, Matches } from "class-validator"; import { Expose } from "class-transformer"; -import { Uuid } from "src/enums"; +import { Uuid } from "../enums"; /** * Database structured Media object diff --git a/api/src/endpoints/encoderConfig.controller.ts b/api/src/endpoints/encoderConfig.controller.ts index 1a46883cdd..cc36588c5c 100644 --- a/api/src/endpoints/encoderConfig.controller.ts +++ b/api/src/endpoints/encoderConfig.controller.ts @@ -1,4 +1,13 @@ -import { Controller, Get, Query, UseGuards, Req, HttpException, HttpStatus } from "@nestjs/common"; +import { + Controller, + Get, + Header, + 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"; @@ -48,6 +57,8 @@ export class EncoderConfigController { @Get("encoderconfig") @UseGuards(AuthGuard) + // Live bucket credentials must not sit in a browser or proxy cache. + @Header("Cache-Control", "no-store") async getEncoderConfig( @Query("bucketId") bucketId: string, @Query("apiVersion") apiVersion: string, diff --git a/api/src/endpoints/sidecar.controller.ts b/api/src/endpoints/sidecar.controller.ts index f897b0bc5d..27af545c41 100644 --- a/api/src/endpoints/sidecar.controller.ts +++ b/api/src/endpoints/sidecar.controller.ts @@ -108,11 +108,8 @@ export class SidecarController { probeFail(HttpStatus.NOT_FOUND, "Not found"); } - // A View grant is permanent; publication state is not. Draft/scheduled/expired - // parents are refused to a non-CMS caller even holding View (ADR 0019). The - // CMS is exempt — an editor previewing media ahead of publish holds only CmsView and - // has no live Content yet, mirroring the cms-exempts-publish-gating rule in - // query.service.ts / ftsSearch.service.ts. + // A View grant is permanent; publication state is not (ADR 0019). The CMS is + // exempt so an editor can preview ahead of publish, as in query.service.ts. if (!isCms) { const available = await isParentAvailable(this.dbService, parentId, Date.now()); if (!available) { diff --git a/api/src/util/maskKey.ts b/api/src/util/maskKey.ts index e655701091..facd0522a5 100644 --- a/api/src/util/maskKey.ts +++ b/api/src/util/maskKey.ts @@ -4,8 +4,8 @@ import { createHash } from "crypto"; * XOR `keyHex` with SHA-256(seed)[0..15]. Self-inverse: applying it twice returns the input, * so this is both the mask (API write path) and the unmask (client read path, browser copy). * - * Deliberate duplication of `cms/src/util/mediaEncoder.ts` `unmaskKeyHex` — the API cannot - * import from shared/cms. The shared test vector in both specs catches divergence. + * Deliberate duplication of `shared/src/util/unmaskKeyHex.ts` — the API cannot import from + * shared. The shared test vector in both specs catches divergence. * Seed is the sidecar `_id`; the mask is obscurity, not a secret (see ADR 0019, * docs/adr/0019-hls-encryption-keys-as-non-replicated-sidecars.md). */ diff --git a/app/package-lock.json b/app/package-lock.json index 5bc2261a0b..6fe11a8c5e 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "name": "luminary-app", + "hasInstallScript": true, "dependencies": { "@headlessui/vue": "^1.7.19", "@luminary-media-converter/player-web-legacy": "file:../luminary-media-convert/player-web-legacy", @@ -12,20 +13,15 @@ "@vueuse/core": "^10.9.0", "dexie": "^4.0.11", "dotenv": "^16.4.5", - "iso-639-2": "^3.0.2", "lodash-es": "^4.17.21", "luminary-shared": "file:../shared", "luxon": "^3.4.4", - "m3u8-parser": "^7.2.0", "oidc-client-ts": "^3.5.0", "pinia": "^2.1.7", "rand-seed": "^2.1.7", "tailwind-scrollbar-hide": "^4.0.0", "tailwindcss": "^3.4.17", "thumbhash": "^0.1.1", - "video.js": "^8.10.0", - "videojs-mobile-ui": "^1.1.1", - "videojs-youtube": "^3.0.1", "vue": "^3.5.13", "vue-i18n": "^10.0.8", "vue-router": "^4.2.5" @@ -39,9 +35,7 @@ "@types/jsdom": "^21.1.6", "@types/lodash-es": "^4.17.12", "@types/luxon": "^3.4.2", - "@types/m3u8-parser": "^7.2.2", "@types/node": "^18.19.3", - "@types/videojs-mobile-ui": "^0.8.3", "@unhead/vue": "^1.11.20", "@vitejs/plugin-vue": "^4.5.2", "@vitest/coverage-v8": "^1.6.1", @@ -1735,6 +1729,7 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3403,13 +3398,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/m3u8-parser": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/@types/m3u8-parser/-/m3u8-parser-7.2.5.tgz", - "integrity": "sha512-e4aoWGzTV+tQRGN1CfJhp1jt5yIHW0X0Lt2rjflDh/cTXYY9nDLRehX7S5iu+gyoaqVLDletBtPcDcPcQBKaeg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -3455,23 +3443,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/video.js": { - "version": "7.3.58", - "resolved": "https://registry.npmjs.org/@types/video.js/-/video.js-7.3.58.tgz", - "integrity": "sha512-1CQjuSrgbv1/dhmcfQ83eVyYbvGyqhTvb2Opxr0QCV+iJ4J6/J+XWQ3Om59WiwCd1MN3rDUHasx5XRrpUtewYQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/videojs-mobile-ui": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/@types/videojs-mobile-ui/-/videojs-mobile-ui-0.8.3.tgz", - "integrity": "sha512-0PqZblRCggDP6GRMkQA707ZKWi3qFbw5jeLEF6FxqtAQGFrRJOCj1Tuk70n7dnA/CMKy7gwZfskqvCOvgnFzZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/video.js": "*" - } - }, "node_modules/@types/web-bluetooth": { "version": "0.0.20", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", @@ -3744,54 +3715,6 @@ "vue": ">=2.7 || >=3" } }, - "node_modules/@videojs/http-streaming": { - "version": "3.17.2", - "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.2.tgz", - "integrity": "sha512-VBQ3W4wnKnVKb/limLdtSD2rAd5cmHN70xoMf4OmuDd0t2kfJX04G+sfw6u2j8oOm2BXYM9E1f4acHruqKnM1g==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/vhs-utils": "^4.1.1", - "aes-decrypter": "^4.0.2", - "global": "^4.4.0", - "m3u8-parser": "^7.2.0", - "mpd-parser": "^1.3.1", - "mux.js": "7.1.0", - "video.js": "^7 || ^8" - }, - "engines": { - "node": ">=8", - "npm": ">=5" - }, - "peerDependencies": { - "video.js": "^8.19.0" - } - }, - "node_modules/@videojs/vhs-utils": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-4.1.1.tgz", - "integrity": "sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5", - "global": "^4.4.0" - }, - "engines": { - "node": ">=8", - "npm": ">=5" - } - }, - "node_modules/@videojs/xhr": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.7.0.tgz", - "integrity": "sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.5.5", - "global": "~4.4.0", - "is-function": "^1.0.1" - } - }, "node_modules/@vitejs/plugin-vue": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz", @@ -4218,15 +4141,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", @@ -4280,18 +4194,6 @@ "node": ">=0.4.0" } }, - "node_modules/aes-decrypter": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-4.0.2.tgz", - "integrity": "sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/vhs-utils": "^4.1.1", - "global": "^4.4.0", - "pkcs7": "^1.0.4" - } - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -5453,11 +5355,6 @@ "node": ">=16" } }, - "node_modules/dom-walk": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", - "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" - }, "node_modules/dot-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", @@ -6617,16 +6514,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/global": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", - "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", - "license": "MIT", - "dependencies": { - "min-document": "^2.19.0", - "process": "^0.11.10" - } - }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -7221,12 +7108,6 @@ "node": ">=8" } }, - "node_modules/is-function": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz", - "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==", - "license": "MIT" - }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -7539,16 +7420,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/iso-639-2": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/iso-639-2/-/iso-639-2-3.0.2.tgz", - "integrity": "sha512-tna50aWwcGTIn81S9MzD1NSovHYTpFgmPVszHiLF5Vg/xmXAJ9XAkMOB9a8TH9Vi7qwf/x/8NJy2F+lM5OEwAw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -8084,17 +7955,6 @@ "node": ">=12" } }, - "node_modules/m3u8-parser": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-7.2.0.tgz", - "integrity": "sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/vhs-utils": "^4.1.1", - "global": "^4.4.0" - } - }, "node_modules/magic-string": { "version": "0.30.19", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", @@ -8223,14 +8083,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/min-document": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", - "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==", - "dependencies": { - "dom-walk": "^0.1.0" - } - }, "node_modules/minimatch": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", @@ -8286,21 +8138,6 @@ "dev": true, "license": "MIT" }, - "node_modules/mpd-parser": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.3.1.tgz", - "integrity": "sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/vhs-utils": "^4.0.0", - "@xmldom/xmldom": "^0.8.3", - "global": "^4.4.0" - }, - "bin": { - "mpd-to-m3u8-json": "bin/parse.js" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8359,23 +8196,6 @@ "node": "*" } }, - "node_modules/mux.js": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/mux.js/-/mux.js-7.1.0.tgz", - "integrity": "sha512-NTxawK/BBELJrYsZThEulyUMDVlLizKdxyAsMuzoCD1eFj97BVaA8D/CvKsKu6FOLYkFojN5CbM9h++ZTZtknA==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.11.2", - "global": "^4.4.0" - }, - "bin": { - "muxjs-transmux": "bin/transmux.js" - }, - "engines": { - "node": ">=8", - "npm": ">=5" - } - }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -9081,18 +8901,6 @@ "node": ">= 6" } }, - "node_modules/pkcs7": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz", - "integrity": "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.5.5" - }, - "bin": { - "pkcs7": "bin/cli.js" - } - }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -9447,15 +9255,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -11380,85 +11179,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/video.js": { - "version": "8.23.4", - "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.4.tgz", - "integrity": "sha512-qI0VTlYmKzEqRsz1Nppdfcaww4RSxZAq77z2oNSl3cNg2h6do5C8Ffl0KqWQ1OpD8desWXsCrde7tKJ9gGTEyQ==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/http-streaming": "^3.17.2", - "@videojs/vhs-utils": "^4.1.1", - "@videojs/xhr": "2.7.0", - "aes-decrypter": "^4.0.2", - "global": "4.4.0", - "m3u8-parser": "^7.2.0", - "mpd-parser": "^1.3.1", - "mux.js": "^7.0.1", - "videojs-contrib-quality-levels": "4.1.0", - "videojs-font": "4.2.0", - "videojs-vtt.js": "0.15.5" - } - }, - "node_modules/videojs-contrib-quality-levels": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-4.1.0.tgz", - "integrity": "sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA==", - "license": "Apache-2.0", - "dependencies": { - "global": "^4.4.0" - }, - "engines": { - "node": ">=16", - "npm": ">=8" - }, - "peerDependencies": { - "video.js": "^8" - } - }, - "node_modules/videojs-font": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-4.2.0.tgz", - "integrity": "sha512-YPq+wiKoGy2/M7ccjmlvwi58z2xsykkkfNMyIg4xb7EZQQNwB71hcSsB3o75CqQV7/y5lXkXhI/rsGAS7jfEmQ==", - "license": "Apache-2.0" - }, - "node_modules/videojs-mobile-ui": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/videojs-mobile-ui/-/videojs-mobile-ui-1.1.1.tgz", - "integrity": "sha512-q7vx74++bqu2763Tc/GG4qFcMt42emC8uXe/z+zFVpBIiysgAf89AgorE6m30YHWtVJWgbRIyzFVYNOxCk9qow==", - "license": "MIT", - "dependencies": { - "global": "^4.4.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6" - }, - "peerDependencies": { - "video.js": "^8" - } - }, - "node_modules/videojs-vtt.js": { - "version": "0.15.5", - "resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.5.tgz", - "integrity": "sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==", - "license": "Apache-2.0", - "dependencies": { - "global": "^4.3.1" - } - }, - "node_modules/videojs-youtube": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/videojs-youtube/-/videojs-youtube-3.0.1.tgz", - "integrity": "sha512-0gKgag7Zno/dDwIdk+h48ODKDulR4IW62RxGE81PrMwi0OX/wUcKO6m1j+DFYI+7qjtWMZTKnbtQoHGxvUrFQg==", - "license": "MIT", - "dependencies": { - "video.js": "5.x || 6.x || 7.x || 8.x" - }, - "peerDependencies": { - "video.js": "5.x || 6.x || 7.x || 8.x" - } - }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/app/package.json b/app/package.json index 9a681188d4..525f66ef17 100644 --- a/app/package.json +++ b/app/package.json @@ -25,20 +25,15 @@ "@vueuse/core": "^10.9.0", "dexie": "^4.0.11", "dotenv": "^16.4.5", - "iso-639-2": "^3.0.2", "lodash-es": "^4.17.21", "luminary-shared": "file:../shared", "luxon": "^3.4.4", - "m3u8-parser": "^7.2.0", "oidc-client-ts": "^3.5.0", "pinia": "^2.1.7", "rand-seed": "^2.1.7", "tailwind-scrollbar-hide": "^4.0.0", "tailwindcss": "^3.4.17", "thumbhash": "^0.1.1", - "video.js": "^8.10.0", - "videojs-mobile-ui": "^1.1.1", - "videojs-youtube": "^3.0.1", "vue": "^3.5.13", "vue-i18n": "^10.0.8", "vue-router": "^4.2.5" @@ -52,9 +47,7 @@ "@types/jsdom": "^21.1.6", "@types/lodash-es": "^4.17.12", "@types/luxon": "^3.4.2", - "@types/m3u8-parser": "^7.2.2", "@types/node": "^18.19.3", - "@types/videojs-mobile-ui": "^0.8.3", "@unhead/vue": "^1.11.20", "@vitejs/plugin-vue": "^4.5.2", "@vitest/coverage-v8": "^1.6.1", diff --git a/app/src/components/content/VideoPlayer.spec.ts b/app/src/components/content/VideoPlayer.spec.ts index 23762457bf..12cd4abf33 100644 --- a/app/src/components/content/VideoPlayer.spec.ts +++ b/app/src/components/content/VideoPlayer.spec.ts @@ -20,7 +20,7 @@ const playMock = vi.hoisted(() => vi.fn(() => Promise.resolve(true))); const pauseMock = vi.hoisted(() => vi.fn()); const enterFullscreenMock = vi.hoisted(() => vi.fn(() => Promise.resolve())); const exitFullscreenMock = vi.hoisted(() => vi.fn()); -const getSidecarMock = vi.hoisted(() => vi.fn()); +const fetchHlsKeyMock = vi.hoisted(() => vi.fn()); // Built inside the factory: vi.mock is hoisted above the imports, so a stub // defined at module scope is not there yet when the factory runs. @@ -54,7 +54,7 @@ vi.mock("@/composables/useBucketInfo", () => ({ vi.mock("luminary-shared", async (importOriginal) => ({ ...(await importOriginal()), - getRest: () => ({ getSidecar: getSidecarMock }), + fetchHlsKey: fetchHlsKeyMock, })); const setMediaProgressMock = vi.hoisted(() => vi.fn()); @@ -101,7 +101,7 @@ const stub = (wrapper: any) => wrapper.findComponent({ name: "LuminaryPlayer" }) beforeEach(() => { vi.clearAllMocks(); getMediaProgressMock.mockReturnValue(0); - getSidecarMock.mockResolvedValue(undefined); + fetchHlsKeyMock.mockResolvedValue(undefined); }); describe("VideoPlayer", () => { @@ -124,43 +124,47 @@ describe("VideoPlayer", () => { }); describe("the decryption key", () => { - // Real (seed, masked) → key vector shared with api/src/util/maskKey.spec.ts, - // cms/src/util/mediaEncoder.spec.ts and shared/src/util/unmaskKeyHex.spec.ts — - // exercises the real unmaskKeyHex rather than a mock of it. - const SIDECAR_ID = "sidecar-post-abc-hlsEncryptionKey"; - const MASKED_KEY_HEX = "98ceb55553113bf2fdd5a74b3fa6e8d8"; const KEY_HEX = "000102030405060708090a0b0c0d0e0f"; it("is fetched and handed to the player when the media is encrypted", async () => { - getSidecarMock.mockResolvedValue({ - sidecarId: SIDECAR_ID, - parentId: mockEnglishContentDto.parentId, - sidecarType: "hlsEncryptionKey", - data: { maskedKeyHex: MASKED_KEY_HEX }, - }); + fetchHlsKeyMock.mockResolvedValue(KEY_HEX); const wrapper = await mountPlayer({ parentMedia: { hlsUrl: RELATIVE, hlsKey_id: "sidecar-1" }, }); await waitForExpect(() => expect(stub(wrapper).props("source").keyHex).toBe(KEY_HEX)); - expect(getSidecarMock).toHaveBeenCalledWith( - mockEnglishContentDto.parentId, - "hlsEncryptionKey", + expect(fetchHlsKeyMock).toHaveBeenCalledWith(mockEnglishContentDto.parentId); + }); + + it("is in hand before the player is given the source, so the stream loads once", async () => { + // Handing over the URL first and the key a tick later makes the player + // load, fail on the key, and load again. + let resolveKey!: (key: string) => void; + fetchHlsKeyMock.mockImplementationOnce( + () => new Promise((resolve) => (resolveKey = resolve)), ); + + const wrapper = await mountPlayer({ + parentMedia: { hlsUrl: RELATIVE, hlsKey_id: "sidecar-1" }, + }); + expect(stub(wrapper).exists()).toBe(false); + + resolveKey(KEY_HEX); + await waitForExpect(() => expect(stub(wrapper).props("source").keyHex).toBe(KEY_HEX)); }); it("is not asked for when the media is not encrypted", async () => { // Unencrypted is the common case; a request per video would be waste. await mountPlayer(); - expect(getSidecarMock).not.toHaveBeenCalled(); + expect(fetchHlsKeyMock).not.toHaveBeenCalled(); }); it("still plays when the key cannot be had", async () => { // "Not encrypted" and "not yours to have" are the same answer here: // play what the playlists give, and let playback fail if it must. - getSidecarMock.mockResolvedValue(undefined); + fetchHlsKeyMock.mockResolvedValue(undefined); const wrapper = await mountPlayer({ parentMedia: { hlsUrl: RELATIVE, hlsKey_id: "sidecar-1" }, diff --git a/app/src/components/content/VideoPlayer.vue b/app/src/components/content/VideoPlayer.vue index bfde4fa6f1..74ea45522c 100644 --- a/app/src/components/content/VideoPlayer.vue +++ b/app/src/components/content/VideoPlayer.vue @@ -3,20 +3,13 @@ * Video playback for a content document. * * The player itself is `LuminaryPlayer` from the encoder's `player-web-legacy` - * package — the same Video.js 8 chrome this component used to build by hand, - * now maintained next to the encoder that produces the streams. What lives here - * is everything that is Luminary's rather than the player's: which URL to play, - * where the decryption key comes from, resume position, and the engagement - * signals a finished video sends. - * - * The player owns, and this file no longer does: control-bar layout, auto-hiding - * controls, the iOS keep-alive audio element, rotation/fullscreen handling, - * audio-track selection by preferred language, the stall nudge, audio-only mode, - * and the whole YouTube branch. + * package. What lives here is what is Luminary's rather than the player's: which + * URL to play, where the decryption key comes from, resume position, and the + * engagement signals a finished video sends. */ import { computed, ref, watch } from "vue"; import { LuminaryPlayer, type PlayerSource } from "@luminary-media-converter/player-web-legacy"; -import { type ContentDto, SidecarType, getRest, unmaskKeyHex } from "luminary-shared"; +import { type ContentDto, fetchHlsKey } from "luminary-shared"; import LImage from "../images/LImage.vue"; import { appLanguagesPreferredAsRef, queryParams } from "@/globalConfig"; import { getMediaProgress, removeMediaProgress, setMediaProgress } from "@/contentProgress"; @@ -78,31 +71,29 @@ const preferredLanguage = computed( */ const controls = { subtitlesMenu: false }; +/** + * Whether the key question has been answered for this document. An encrypted + * stream must not be handed to the player before its key is in hand, or it is + * loaded, fails, and is loaded again. + */ +const keyResolved = ref(false); + const source = computed(() => { const url = videoSource.value; - if (!url) return null; + if (!url || !keyResolved.value) return null; return { masterUrl: url, keyHex: keyHex.value }; }); -/** - * Fetches the key for whatever document is being played. - * - * Runs before the source is built, so an encrypted stream is loaded once with - * its key rather than loaded, failed, and reloaded. A document with no key - * answers 404 and leaves `keyHex` undefined, which is the unencrypted case and - * needs no special handling. - */ watch( () => props.content?._id, async () => { keyHex.value = undefined; + keyResolved.value = false; const parentId = props.content?.parentId; - if (!parentId || !props.content?.parentMedia?.hlsKey_id) return; - const sidecar = await getRest().getSidecar(parentId, SidecarType.HlsEncryptionKey); - if (!sidecar) return; - const data = sidecar.data as { maskedKeyHex: string } | undefined; - if (!data?.maskedKeyHex) return; - keyHex.value = await unmaskKeyHex(sidecar.sidecarId, data.maskedKeyHex); + if (parentId && props.content?.parentMedia?.hlsKey_id) { + keyHex.value = await fetchHlsKey(parentId); + } + keyResolved.value = true; }, { immediate: true }, ); diff --git a/app/src/pages/SingleContent/__tests__/SingleContent.ssr.spec.ts b/app/src/pages/SingleContent/__tests__/SingleContent.ssr.spec.ts index 71000d0bb3..79049f8860 100644 --- a/app/src/pages/SingleContent/__tests__/SingleContent.ssr.spec.ts +++ b/app/src/pages/SingleContent/__tests__/SingleContent.ssr.spec.ts @@ -105,7 +105,6 @@ vi.mock("@/composables/useBucketInfo", () => ({ useBucketInfo: () => ({ bucketBaseUrl: computed(() => "") }), })); -vi.mock("video.js", () => ({ default: vi.fn() })); // Stub every presentational child that is not part of the query chain under test. // RelatedContent and ReadMore stay real — their queries and rendered output are the diff --git a/app/src/pages/SingleContent/__tests__/SingleContent.ssrrtext.spec.ts b/app/src/pages/SingleContent/__tests__/SingleContent.ssrrtext.spec.ts index f3324e1314..24af3b9cf4 100644 --- a/app/src/pages/SingleContent/__tests__/SingleContent.ssrrtext.spec.ts +++ b/app/src/pages/SingleContent/__tests__/SingleContent.ssrrtext.spec.ts @@ -105,7 +105,6 @@ vi.mock("@/router", () => ({ markInternalNavigation: vi.fn(), })); -vi.mock("video.js", () => ({ default: vi.fn() })); function passthrough(name: string) { return defineComponent({ name, inheritAttrs: false, setup: (_, { slots }) => () => h("div", slots.default?.()) }); diff --git a/app/src/types/videojs-mobile-ui.d.ts b/app/src/types/videojs-mobile-ui.d.ts deleted file mode 100644 index 2eb87b8048..0000000000 --- a/app/src/types/videojs-mobile-ui.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module "videojs-mobile-ui"; diff --git a/app/src/types/videojs-youtube.d.ts b/app/src/types/videojs-youtube.d.ts deleted file mode 100644 index d87e864c33..0000000000 --- a/app/src/types/videojs-youtube.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module "videojs-youtube"; diff --git a/app/src/util/videoSource.ts b/app/src/util/videoSource.ts index 95b13d6298..322e88b596 100644 --- a/app/src/util/videoSource.ts +++ b/app/src/util/videoSource.ts @@ -1,16 +1,8 @@ -import type { ContentDto } from "luminary-shared"; +import { type ContentDto, toAbsoluteMediaUrl } 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. + * The video a content document should play. The encoded collection on the parent + * wins over the legacy per-language `video` URL, which the CMS no longer edits. */ export function videoSourceFor( content: Pick | undefined | null, @@ -26,24 +18,12 @@ export function hasVideoSource( } /** - * The URL a player should actually fetch. - * - * `parentMedia.hlsUrl` is stored relative to the document's own media bucket — - * `/prefix/master.m3u8` — so that the bucket's address lives in one place and - * cannot drift from the collection's path. Joining the two is the same thing - * `LImage` does for images, and for the same reason. - * - * External sources — a YouTube link, an HLS master on someone else's CDN — are - * stored absolute and come back untouched, so a caller never has to ask which - * kind it is holding. Returns undefined while the bucket is still loading, which - * is the honest answer: the URL is not knowable yet. + * The URL a player should actually fetch: the stored source resolved against the + * document's media bucket. Undefined while the bucket is still loading. */ export function resolveVideoSource( content: Pick | undefined | null, bucketBaseUrl: string | undefined, ): string | undefined { - const source = videoSourceFor(content); - if (!source || !source.startsWith("/")) return source; - if (!bucketBaseUrl) return undefined; - return `${bucketBaseUrl.replace(/\/+$/, "")}${source}`; + return toAbsoluteMediaUrl(videoSourceFor(content), bucketBaseUrl); } diff --git a/app/test-coverage.md b/app/test-coverage.md deleted file mode 100644 index e084d8b426..0000000000 --- a/app/test-coverage.md +++ /dev/null @@ -1,147 +0,0 @@ -# Test Coverage Report - -## Summary - -- **Overall Statement Coverage**: 90.31% -- **Test Framework**: Vitest + Vue Test Utils -- **Test Runner**: `npx vitest run --coverage` - -## Coverage Table - -| File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s | -|---------------------------------------|---------|----------|---------|---------|-------------------------------------------| -| **All files** | 90.31 | 84.66 | 70.18 | 90.31 | | -| .eslintrc.cjs | 0 | 0 | 0 | 0 | 1-15 | -| dynamic-import-helper.js | 100 | 66.66 | 100 | 100 | 1 | -| App.vue | 87.59 | 100 | 40 | 87.59 | 28-30,35-36,90-98,102,106-107 | -| docsIndex.ts | 100 | 100 | 100 | 100 | | -| globalConfig.ts | 92.61 | 79.06 | 94.44 | 92.61 | 268-269,302,405 | -| i18n.ts | 96.73 | 95.45 | 100 | 96.73 | 85,89-90 | -| sync.ts | 92.81 | 88.88 | 100 | 92.81 | 139-140,146-155 | -| BasePage.vue | 100 | 100 | 100 | 100 | | -| IgnorePagePadding.vue | 100 | 100 | 100 | 100 | | -| LoadingSpinner.vue | 100 | 100 | 100 | 100 | | -| contentByTag.ts | 98.93 | 85.18 | 100 | 98.93 | 47 | -| PinnedTopics.vue | 100 | 100 | 100 | 100 | | -| UnpinnedTopics.vue | 100 | 100 | 100 | 100 | | -| ContinueListening.vue | 50.9 | 50 | 100 | 50.9 | 16-37,49-53 | -| ContinueWatching.vue | 97.95 | 85.71 | 75 | 97.95 | 34-35 | -| HomePageNewest.vue | 100 | 100 | 100 | 100 | | -| HomePagePinned.vue | 100 | 100 | 100 | 100 | | -| PinnedVideo.vue | 100 | 100 | 100 | 100 | | -| UnpinnedVideo.vue | 100 | 100 | 100 | 100 | | -| LButton.vue | 100 | 0 | 100 | 100 | 77 | -| DropdownMenu.vue | 100 | 100 | 100 | 100 | | -| LCard.vue | 97.4 | 83.33 | 100 | 97.4 | 21-22 | -| LDialog.vue | 100 | 80 | 0 | 100 | 41 | -| LHighlightable.vue | 57.57 | 75.6 | 58.82 | 57.57 | See "Partially Testable" section | -| LTeleport.vue | 86.36 | 0 | 100 | 86.36 | 15-17 | -| AudioPlayer.vue | 100 | 100 | 100 | 100 | | -| ContentTile.vue | 99.46 | 87.5 | 100 | 99.46 | 35 | -| CopyrightBanner.vue | 100 | 75 | 100 | 100 | 19 | -| HorizontalContentTileCollection.vue | 93.7 | 77.27 | 71.42 | 93.7 | 39-42,71-72,93-94 | -| RelatedContent.vue | 100 | 100 | 100 | 100 | | -| VideoPlayer.vue | 59.13 | 65.95 | 55.55 | 59.13 | See "Partially Testable" section | -| extractAndBuildAudioMaster.ts | 100 | 96.96 | 100 | 100 | 143 | -| AudioVideoToggle.vue | 100 | 100 | 100 | 100 | | -| BaseModal.vue | 100 | 100 | 100 | 100 | | -| LModal.vue | 100 | 100 | 50 | 100 | | -| ImageModal.vue | 72.42 | 86.84 | 47.82 | 72.42 | See "Partially Testable" section | -| LImage.vue | 98.11 | 87.5 | 100 | 98.11 | 59-60 | -| LImageProvider.vue | 89.08 | 77.39 | 42.85 | 89.08 | 153-158,212,235-240,243,320-333 | -| DesktopMenu.vue | 97.05 | 100 | 0 | 97.05 | 12-13 | -| LanguageModal.vue | 94.08 | 86.66 | 25 | 94.08 | 39-40,44,49-55 | -| MobileMenu.vue | 96.7 | 83.33 | 50 | 96.7 | 16,29-30 | -| PrivacyPolicyModal.vue | 67.58 | 75.4 | 62.5 | 67.58 | See "Partially Testable" section | -| ProfileMenu.vue | 97.08 | 93.1 | 50 | 97.08 | 57-60,131-133 | -| SearchModal.vue | 89.31 | 82.65 | 83.87 | 89.31 | 577-584,611-612,832-838,842-846 | -| ThemeSelectorModal.vue | 97.46 | 91.66 | 40 | 97.46 | 36-37 | -| TopBar.vue | 92.42 | 70 | 50 | 92.42 | 41-42,50-52,68,76-79 | -| navigationItems.ts | 97.72 | 50 | 100 | 97.72 | 42 | -| NotificationBanner.vue | 94.64 | 92.85 | 0 | 94.64 | 61-66 | -| NotificationBannerManager.vue | 100 | 100 | 100 | 100 | | -| NotificationBottom.vue | 98.52 | 72.72 | 100 | 98.52 | 19 | -| NotificationBottomManager.vue | 100 | 100 | 100 | 100 | | -| NotificationToast.vue | 100 | 86.66 | 100 | 100 | | -| NotificationToastManager.vue | 100 | 100 | 100 | 100 | | -| VerticalTagViewer.vue | 98.9 | 42.85 | 100 | 98.9 | 21 | -| useAuthWithPrivacyPolicy.ts | 100 | 100 | 100 | 100 | | -| useBucketInfo.ts | 100 | 100 | 100 | 100 | | -| useHighlightState.ts | 100 | 100 | 100 | 100 | | -| useSearchOverlay.ts | 93.1 | 100 | 75 | 93.1 | 20-21 | -| BookmarksPage.vue | 100 | 92.3 | 100 | 100 | 15 | -| ExplorePage.vue | 100 | 100 | 100 | 100 | | -| HomePage.vue | 100 | 100 | 100 | 100 | | -| NotFoundPage.vue | 100 | 100 | 100 | 100 | | -| SettingsPage.vue | 100 | 100 | 100 | 100 | | -| VideoPage.vue | 100 | 100 | 100 | 100 | | -| SingleContent.vue | 87.72 | 83.97 | 65.21 | 87.72 | 540-541,563-566,678-680,799-801 | -| examplePlugin.ts | 100 | 100 | 100 | 100 | | -| router/index.ts | 98.16 | 100 | 40 | 98.16 | 17-18 | -| notification.ts | 97.5 | 92.3 | 100 | 97.5 | 41-42 | -| mockdata.ts | 100 | 100 | 100 | 100 | | -| isLangSwitch.ts | 100 | 100 | 100 | 100 | | -| loadFallbackImages.ts | 100 | 100 | 100 | 100 | | -| mangoIsPublished.ts | 100 | 100 | 100 | 100 | | -| pluginLoader.ts | 92.3 | 92.3 | 100 | 92.3 | 29-31 | -| watchEffectOnce.ts | 100 | 100 | 100 | 100 | | -| youtube.ts | 100 | 100 | 100 | 100 | | - -## Untestable / Excluded Files - -### `.eslintrc.cjs` (0% coverage) -Configuration file, not executable application code. Should be excluded from coverage reports via `vitest.config.ts` coverage exclusions. - -### `ContinueListening.vue` — query logic unreachable -The `useDexieLiveQueryWithDeps` callback (lines 14-37) contains `contentIds` hardcoded as an empty array (`const contentIds: string[] = []`), so the query always returns `[]` and the filtering/sorting logic on lines 17-37 is unreachable. This is documented with a TODO in the source: _"Replace with central watch/listen/read service when implemented (separate ticket)"_. The component's rendering logic is tested (it correctly renders nothing). - -## Partially Testable Files - -### `VideoPlayer.vue` (59% coverage) -Lines 430-535 (`watch(audioMode)`) involve complex Video.js player API interactions that are difficult to test in jsdom: -- Audio playlist generation with base64 encoding -- Track restoration logic during mode switches -- Player source switching between audio/video modes -- `play()` promise handling - -**Why**: Video.js is a browser-only library. The mock captures event registrations and basic API calls, but complex multi-step flows (mode switching with `one("loadedmetadata")` + `one("canplay")` callbacks, `btoa()` encoding of audio manifests) require a real browser player instance. - -**What IS tested**: YouTube detection, HLS source setting, poster image, event handler registration (ended/pause/play/timeupdate/ready), progress save/restore, player disposal on unmount. - -### `LHighlightable.vue` (58% coverage) -Lines involving DOM mutations and browser Selection API: -- `removeHighlight()` partial removal (lines 213-293): TreeWalker-based text node splitting/reconstruction -- Touch event handlers (lines 391-393): `handleSelectStart` is a no-op function -- Some paths in `wrapTextNodes` with complex multi-node selections - -**Why**: jsdom's Selection/Range API is limited — `getBoundingClientRect()` must be manually mocked, and TreeWalker behavior with DOM mutations differs from real browsers. - -**What IS tested**: Color application via selection, highlight removal (full), save/restore to IndexedDB, error handling, context menu prevention, long-press detection, event listener lifecycle. - -### `ImageModal.vue` (72% coverage) -Uncovered lines involve: -- Mouse drag when zoomed (lines 213-222): `onMouseMove`/`onMouseUp` handlers -- Touch event branches (lines 242-244): `TouchEvent` instanceof check in `onDblClick` - -**Why**: Touch events require constructing realistic `TouchEvent` objects with `changedTouches`/`touches` arrays, which jsdom doesn't fully support. - -**What IS tested**: Ctrl+wheel zoom, double-click zoom toggle, keyboard navigation, swipe via arrows, mobile defaults, zoom reset on image change. - -### `PrivacyPolicyModal.vue` (68% coverage) -Lines 108-200 contain an `h()` render function inside a `watch()` inside a `setTimeout(2000)` that creates banner notification action buttons. The VNode tree is complex with conditional rendering. - -**Why**: Testing `h()` render function output requires extracting the notification's `actions()` callback from the Pinia store and rendering/inspecting the returned VNodes, which is indirect. - -**What IS tested**: Banner notification creation after 2s delay, banner removal on acceptance, modal button rendering (accept/necessaryOnly/moreInfo), status computation, pending login behavior. - -## Files at 100% Coverage - -The following files have full test coverage: -- All page components (BookmarksPage, ExplorePage, HomePage, NotFoundPage, SettingsPage, VideoPage) -- All composables (useAuthWithPrivacyPolicy, useBucketInfo, useHighlightState) -- Core utilities (isLangSwitch, loadFallbackImages, mangoIsPublished, watchEffectOnce, youtube) -- Form components (AudioVideoToggle, BaseModal, LModal) -- Content components (AudioPlayer, RelatedContent, extractAndBuildAudioMaster) -- Navigation sub-components (PinnedTopics, UnpinnedTopics, PinnedVideo, UnpinnedVideo) -- Notification managers (BannerManager, BottomManager, ToastManager) -- BasePage, IgnorePagePadding, LoadingSpinner, DropdownMenu diff --git a/cms/src/components/common/LDialog.vue b/cms/src/components/common/LDialog.vue index c947617981..c30af136e8 100644 --- a/cms/src/components/common/LDialog.vue +++ b/cms/src/components/common/LDialog.vue @@ -14,8 +14,6 @@ type Props = { context?: "default" | "danger"; primaryButtonDisabled?: boolean; largeModal?: boolean; - wide?: boolean; - preventBackdropClose?: boolean; stickToEdges?: boolean; showClosingButton?: boolean; }; @@ -34,8 +32,6 @@ withDefaults(defineProps(), { :heading="title" :noDivider="true" :largeModal="largeModal" - :wide="wide" - :preventBackdropClose="preventBackdropClose" :stickToEdges="stickToEdges" :showClosingButton="showClosingButton" > diff --git a/cms/src/components/content/EditContentMedia.spec.ts b/cms/src/components/content/EditContentMedia.spec.ts index 3189f577a0..c78ae87b51 100644 --- a/cms/src/components/content/EditContentMedia.spec.ts +++ b/cms/src/components/content/EditContentMedia.spec.ts @@ -26,6 +26,7 @@ vi.mock("@/composables/useMediaEncoder", () => ({ useMediaEncoder: () => encoder vi.mock("@/composables/storageSelection", () => ({ storageSelection: () => ({ autoSelectMediaBucket: ref("bucket-1"), + effectiveMediaBucketId: (persisted?: string) => persisted ?? "bucket-1", mediaBuckets: ref([{ _id: "bucket-1", name: "media" }]), hasMediaBuckets: ref(true), getBucketById: () => undefined, @@ -124,20 +125,38 @@ describe("EditContentMedia", () => { await wrapper.find('[data-test="encode-media-button"]').trigger("click"); const { onMediaReady } = encoder.start.mock.calls[0][0]; - onMediaReady({ hlsUrl: "https://cdn/master.m3u8", hlsKey: "abc" }); + onMediaReady({ hlsUrl: "https://cdn/master.m3u8", hlsKey: "abc" }, mockData.mockPostDto._id); await settle(); expect(wrapper.props("parent")!.media?.hlsUrl).toBe("https://cdn/master.m3u8"); expect(wrapper.props("parent")!.media?.hlsKey).toBe("abc"); }); + it("refuses a result for a document the editor has since left", async () => { + // The encoder's trust prompt can hold start() open for as long as the user + // likes; by the time it answers, this section may be showing another post. + const wrapper = mountSection(); + await settle(); + await wrapper.find('[data-test="encode-media-button"]').trigger("click"); + const { onMediaReady } = encoder.start.mock.calls[0][0]; + + await wrapper.setProps({ parent: { ...parent(), _id: "post-2", media: undefined } as PostDto }); + onMediaReady({ hlsUrl: "https://cdn/master.m3u8", hlsKey: "abc" }, mockData.mockPostDto._id); + await settle(); + + expect(wrapper.props("parent")!.media).toBeUndefined(); + }); + it("keeps the audio already on the document when the encoder writes a video", async () => { const wrapper = mountSection(); await settle(); await wrapper.find('[data-test="encode-media-button"]').trigger("click"); const before = wrapper.props("parent")!.media?.fileCollections; - encoder.start.mock.calls[0][0].onMediaReady({ hlsUrl: "https://cdn/master.m3u8" }); + encoder.start.mock.calls[0][0].onMediaReady( + { hlsUrl: "https://cdn/master.m3u8" }, + mockData.mockPostDto._id, + ); await settle(); expect(wrapper.props("parent")!.media?.fileCollections).toEqual(before); @@ -168,8 +187,8 @@ describe("EditContentMedia resume", () => { }); it("writes back a URL recovered from a resumed session", async () => { - encoder.resume.mockImplementation(async ({ onMediaReady }: any) => { - onMediaReady({ hlsUrl: "https://cdn/resumed.m3u8" }); + encoder.resume.mockImplementation(async ({ documentId, onMediaReady }: any) => { + onMediaReady({ hlsUrl: "https://cdn/resumed.m3u8" }, documentId); return true; }); diff --git a/cms/src/components/content/EditContentMedia.vue b/cms/src/components/content/EditContentMedia.vue index 93f9445247..a6c7e52865 100644 --- a/cms/src/components/content/EditContentMedia.vue +++ b/cms/src/components/content/EditContentMedia.vue @@ -45,14 +45,11 @@ const { watchForEncoder, start, resume, + stop, } = useMediaEncoder(); -// The bucket to encode into, on the same rule the bucket selector uses: a lone media -// bucket counts as selected even though nothing has written it to the document yet. -// Requiring the persisted value would leave the button dead on every post that has -// never had media attached, which is every post this feature is for. -const effectiveBucketId = computed( - () => parent.value?.mediaBucketId ?? bucketSelection.autoSelectMediaBucket.value ?? undefined, +const effectiveBucketId = computed(() => + bucketSelection.effectiveMediaBucketId(parent.value?.mediaBucketId), ); /** @@ -61,8 +58,10 @@ const effectiveBucketId = computed( * persists it, and the app's coming-soon state covers the gap until the first * segments are in the bucket. */ -const handleEncodedMedia = (media: Pick) => { - if (!parent.value) return; +const handleEncodedMedia = (media: Pick, documentId: string) => { + // The editor may have moved to another document while the encoder was slow to + // answer; that document must not receive this one's collection. + if (!parent.value || parent.value._id !== documentId) return; parent.value.media = { ...(parent.value.media ?? { fileCollections: [] }), @@ -115,7 +114,10 @@ onMounted(() => void checkAndResume()); // the previous document's encode is not this one's. watch( () => parent.value?._id, - () => void checkAndResume(), + () => { + stop(); + void checkAndResume(); + }, ); diff --git a/cms/src/components/content/EditContentVideo.spec.ts b/cms/src/components/content/EditContentVideo.spec.ts index 84106d45e1..d1b707a65f 100644 --- a/cms/src/components/content/EditContentVideo.spec.ts +++ b/cms/src/components/content/EditContentVideo.spec.ts @@ -81,9 +81,9 @@ describe("EditContentVideo.vue", () => { }); 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 + // After a save the key itself is gone — the API keeps it in a sidecar 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" })); + const wrapper = mountVideo(parentWith({ hlsUrl: HLS_URL, hlsKey_id: "sidecar-1" })); expect(wrapper.find('[data-test="video-key-note"]').text()).toContain( "encryption key is saved", diff --git a/cms/src/components/content/VideoPreview.spec.ts b/cms/src/components/content/VideoPreview.spec.ts index 326e294808..57ca9c9490 100644 --- a/cms/src/components/content/VideoPreview.spec.ts +++ b/cms/src/components/content/VideoPreview.spec.ts @@ -3,7 +3,7 @@ import { mount } from "@vue/test-utils"; import waitForExpect from "wait-for-expect"; import VideoPreview from "./VideoPreview.vue"; -const getSidecarMock = vi.hoisted(() => vi.fn()); +const fetchHlsKeyMock = vi.hoisted(() => vi.fn()); const retryMock = vi.hoisted(() => vi.fn()); // Which panel the stubbed player is showing, so a test can put it in the state // the real player reaches on a missing playlist or a bad key. @@ -39,7 +39,7 @@ vi.mock("@luminary-media-converter/player-web-legacy", async () => { vi.mock("luminary-shared", async (importOriginal) => ({ ...(await importOriginal()), - getRest: () => ({ getSidecar: getSidecarMock }), + fetchHlsKey: fetchHlsKeyMock, })); vi.mock("@/composables/storageSelection", () => ({ @@ -61,7 +61,7 @@ async function mountAndOpen(media: Record | undefined) { beforeEach(() => { vi.clearAllMocks(); getBucketByIdMock.mockReturnValue({ publicUrl: "https://cdn.example.com/media" }); - getSidecarMock.mockResolvedValue(undefined); + fetchHlsKeyMock.mockResolvedValue(undefined); }); describe("VideoPreview", () => { @@ -131,15 +131,10 @@ describe("VideoPreview", () => { // A YouTube video has nothing to decrypt. await mountAndOpen({ hlsUrl: YT }); - expect(getSidecarMock).not.toHaveBeenCalled(); + expect(fetchHlsKeyMock).not.toHaveBeenCalled(); }); }); - // Real (seed, masked) → key vector shared with api/src/util/maskKey.spec.ts, - // cms/src/util/mediaEncoder.spec.ts and shared/src/util/unmaskKeyHex.spec.ts — - // exercises the real unmaskKeyHex rather than a mock of it. - const SIDECAR_ID = "sidecar-post-abc-hlsEncryptionKey"; - const MASKED_KEY_HEX = "98ceb55553113bf2fdd5a74b3fa6e8d8"; const KEY_HEX = "000102030405060708090a0b0c0d0e0f"; it("uses a key the editor has just typed, before it is ever saved", async () => { @@ -147,35 +142,45 @@ describe("VideoPreview", () => { const wrapper = await mountAndOpen({ hlsUrl: "/abc/master.m3u8", hlsKey: "a".repeat(32) }); expect(player(wrapper).props("source").keyHex).toBe("a".repeat(32)); - expect(getSidecarMock).not.toHaveBeenCalled(); + expect(fetchHlsKeyMock).not.toHaveBeenCalled(); }); it("fetches a saved key, which the document cannot show again", async () => { - getSidecarMock.mockResolvedValue({ - sidecarId: SIDECAR_ID, - parentId: "post-1", - sidecarType: "hlsEncryptionKey", - data: { maskedKeyHex: MASKED_KEY_HEX }, - }); + fetchHlsKeyMock.mockResolvedValue(KEY_HEX); const wrapper = await mountAndOpen({ hlsUrl: "/abc/master.m3u8", hlsKey_id: "sidecar-1" }); - expect(getSidecarMock).toHaveBeenCalledWith("post-1", "hlsEncryptionKey", { cms: true }); - // Two awaits deep, not one: the sidecar fetch, then unmaskKeyHex's - // crypto.subtle.digest. A single tick wins that race locally and loses it - // on a slower runner. + expect(fetchHlsKeyMock).toHaveBeenCalledWith("post-1", { cms: true }); await waitForExpect(() => expect(player(wrapper).props("source").keyHex).toBe(KEY_HEX)); }); + it("drops a key that arrives for the document the editor has already left", async () => { + // The component is reused across documents; a slow fetch for the previous + // one must not become this one's key. + let resolveFirst!: (key: string) => void; + fetchHlsKeyMock.mockImplementationOnce( + () => new Promise((resolve) => (resolveFirst = resolve)), + ); + fetchHlsKeyMock.mockResolvedValue("b".repeat(32)); + + const wrapper = mount(VideoPreview, { + props: { parent: parent({ hlsUrl: "/abc/master.m3u8", hlsKey_id: "sidecar-1" }) }, + }); + await wrapper.setProps({ + parent: { ...parent({ hlsUrl: "/def/master.m3u8", hlsKey_id: "sidecar-2" }), _id: "post-2" }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + resolveFirst("a".repeat(32)); + await new Promise((resolve) => setTimeout(resolve, 0)); + await wrapper.find('[data-test="video-preview-load"]').trigger("click"); + + expect(player(wrapper).props("source").keyHex).toBe("b".repeat(32)); + }); + it("prefers the typed key over the saved one", async () => { // The editor is replacing it; previewing the old one would check the // wrong thing. - getSidecarMock.mockResolvedValue({ - sidecarId: SIDECAR_ID, - parentId: "post-1", - sidecarType: "hlsEncryptionKey", - data: { maskedKeyHex: MASKED_KEY_HEX }, - }); + fetchHlsKeyMock.mockResolvedValue(KEY_HEX); const wrapper = await mountAndOpen({ hlsUrl: "/abc/master.m3u8", diff --git a/cms/src/components/content/VideoPreview.vue b/cms/src/components/content/VideoPreview.vue index dd9c21b634..49e56b1b0c 100644 --- a/cms/src/components/content/VideoPreview.vue +++ b/cms/src/components/content/VideoPreview.vue @@ -18,9 +18,8 @@ import LBadge from "@/components/common/LBadge.vue"; import { PlayCircleIcon } from "@heroicons/vue/24/outline"; import { LockClosedIcon, LockOpenIcon } from "@heroicons/vue/16/solid"; import { LuminaryPlayer, type PlayerSource } from "@luminary-media-converter/player-web-legacy"; -import { type ContentParentDto, SidecarType, getRest, unmaskKeyHex } from "luminary-shared"; +import { type ContentParentDto, fetchHlsKey, toAbsoluteMediaUrl } from "luminary-shared"; import { storageSelection } from "@/composables/storageSelection"; -import { toAbsoluteMediaUrl } from "@/util/mediaUrl"; type Props = { parent: ContentParentDto | undefined; @@ -52,17 +51,18 @@ const masterUrl = computed(() => { * document. */ const storedKey = ref(undefined); +let keyRequest = 0; watch( () => [props.parent?._id, props.parent?.media?.hlsKey_id] as const, async ([id, keyId]) => { storedKey.value = undefined; if (!id || !keyId) return; - const sidecar = await getRest().getSidecar(id, SidecarType.HlsEncryptionKey, { cms: true }); - if (!sidecar) return; - const data = sidecar.data as { maskedKeyHex: string } | undefined; - if (!data?.maskedKeyHex) return; - storedKey.value = await unmaskKeyHex(sidecar.sidecarId, data.maskedKeyHex); + // This component survives a switch of document, so a slow fetch for the + // previous one must not land as this one's key. + const request = ++keyRequest; + const key = await fetchHlsKey(id, { cms: true }); + if (request === keyRequest) storedKey.value = key; }, { immediate: true }, ); diff --git a/cms/src/components/content/composables/useEditContentSource.ts b/cms/src/components/content/composables/useEditContentSource.ts index a4a9458e01..46c017ea13 100644 --- a/cms/src/components/content/composables/useEditContentSource.ts +++ b/cms/src/components/content/composables/useEditContentSource.ts @@ -158,11 +158,8 @@ export function useEditContentSource(options: UseEditContentSourceOptions): UseE const { remove: removeParent } = parentEditable; const contentEditable = toEditable(contentSource, { persistOffline: true, - // The API owns these: `memberOf` and the `parent*` family are re-stamped from the - // parent on every parent save, and the rest are derived. None is editable here, so - // without back-patching, this editor's own save comes back as "changed remotely by - // someone else" — most visibly during an encode, where the parent's media changes - // mid-session and `parentMedia` follows it onto every translation. + // Server-owned fields, re-stamped on every parent save. Without back-patching, + // this editor's own save comes back as "changed remotely by someone else". backPatchFields: [ "memberOf", "parentTags", diff --git a/cms/src/components/media/MediaBucketSelect.spec.ts b/cms/src/components/media/MediaBucketSelect.spec.ts index bb37534f57..f2aa53082a 100644 --- a/cms/src/components/media/MediaBucketSelect.spec.ts +++ b/cms/src/components/media/MediaBucketSelect.spec.ts @@ -32,6 +32,9 @@ vi.mock("@/composables/storageSelection", () => { autoSelectMediaBucket: _computed(() => mockMediaBuckets.value.length === 1 ? mockMediaBuckets.value[0]._id : null, ), + effectiveMediaBucketId: (persisted?: string) => + persisted ?? + (mockMediaBuckets.value.length === 1 ? mockMediaBuckets.value[0]._id : undefined), }), }; }); diff --git a/cms/src/components/media/MediaBucketSelect.vue b/cms/src/components/media/MediaBucketSelect.vue index 67697efc7f..b4491ab693 100644 --- a/cms/src/components/media/MediaBucketSelect.vue +++ b/cms/src/components/media/MediaBucketSelect.vue @@ -25,12 +25,8 @@ const parent = defineModel("parent"); const bucketSelection = storageSelection(); -// The bucket to treat as "current". Falls back to the auto-selected bucket when the -// parent has none persisted yet — so a single-bucket setup behaves as if it were -// selected without mutating the parent (which would create a fake dirty state on -// legacy docs). The parent is only written to when the user actually picks one. -const effectiveMediaBucketId = computed( - () => parent.value?.mediaBucketId ?? bucketSelection.autoSelectMediaBucket.value ?? undefined, +const effectiveMediaBucketId = computed(() => + bucketSelection.effectiveMediaBucketId(parent.value?.mediaBucketId), ); const bucketOptions = computed(() => diff --git a/cms/src/composables/storageSelection.ts b/cms/src/composables/storageSelection.ts index 8f6e75ff6a..cbbf5cbf41 100644 --- a/cms/src/composables/storageSelection.ts +++ b/cms/src/composables/storageSelection.ts @@ -56,6 +56,15 @@ export function storageSelection() { return mediaBuckets.value.length === 1 ? mediaBuckets.value[0]._id : null; }); + /** + * The media bucket a document is treated as using: the persisted one, else a + * lone media bucket. The fallback is not written to the document — that would + * dirty every legacy doc — so the parent records it only when the user picks a + * bucket or starts an encode. + */ + const effectiveMediaBucketId = (persisted: string | undefined | null): string | undefined => + persisted ?? autoSelectMediaBucket.value ?? undefined; + /** * Check if bucket selection is needed (more than one bucket) */ @@ -89,6 +98,7 @@ export function storageSelection() { // Auto-selection autoSelectImageBucket, autoSelectMediaBucket, + effectiveMediaBucketId, // Selection state selectedImageBucket, diff --git a/cms/src/composables/useMediaEncoder.spec.ts b/cms/src/composables/useMediaEncoder.spec.ts index 47f0a8a340..53e0969395 100644 --- a/cms/src/composables/useMediaEncoder.spec.ts +++ b/cms/src/composables/useMediaEncoder.spec.ts @@ -171,10 +171,13 @@ describe("useMediaEncoder resume", () => { await useMediaEncoder().resume({ documentId: "post-1", onMediaReady }); - expect(onMediaReady).toHaveBeenCalledWith({ - hlsUrl: "https://cdn.example.com/media/s1/master.m3u8", - hlsKey: "aabbccddeeff00112233445566778899", - }); + expect(onMediaReady).toHaveBeenCalledWith( + { + hlsUrl: "https://cdn.example.com/media/s1/master.m3u8", + hlsKey: "aabbccddeeff00112233445566778899", + }, + "post-1", + ); }); it("does not follow a finished session, having nothing left to send", async () => { diff --git a/cms/src/composables/useMediaEncoder.ts b/cms/src/composables/useMediaEncoder.ts index 1367f174dd..caae6d9f3c 100644 --- a/cms/src/composables/useMediaEncoder.ts +++ b/cms/src/composables/useMediaEncoder.ts @@ -1,5 +1,11 @@ import { computed, ref, onUnmounted } from "vue"; import { getRest, type MediaDto } from "luminary-shared"; + +/** Called once per encode, as soon as it has a published URL, naming the document it is for. */ +export type MediaReadyHandler = ( + media: Pick, + documentId: string, +) => void; import { browserCanReachEncoder, checkEncoderHealth, @@ -54,9 +60,16 @@ export function useMediaEncoder() { let unsubscribe: (() => void) | undefined; + // The awaits in start/resume/publish can outlive the component (the encoder's + // trust prompt, a slow key fetch); nothing after them may touch a dead instance. + let disposed = false; + /** Is the encoder installed and running? Safe to call repeatedly. */ async function refreshAvailability(): Promise { - availability.value = "checking"; + // Only the first check shows as "checking": every later one is a re-check on + // a poll tick or window focus, and flipping the state then disables the Encode + // button under the editor's cursor. + if (availability.value === "unknown") availability.value = "checking"; const health = await checkEncoderHealth(); encoderVersion.value = health.apiVersion; @@ -72,19 +85,6 @@ export function useMediaEncoder() { return false; } - /** - * Notice when the encoder appears, without the editor having to try again. - * - * Nothing tells this page that a desktop app has started, and the launch - * link's one re-check was too early — the encoder boots Nest and probes the - * machine's encoders first, which takes longer than the couple of seconds an - * app usually needs. It also did nothing at all for someone who opened the - * app from the Dock rather than the link. - * - * Polling stops the moment it answers, and only runs while the tab is - * visible: a background tab is not a person waiting for a window to appear, - * and this is a request per interval to a port that may have nothing on it. - */ /** * Keep the encoder's availability true, in both directions. * @@ -110,7 +110,7 @@ export function useMediaEncoder() { async function tick() { // A hidden tab is nobody waiting for an answer; the next focus asks. if (!document.hidden) await refreshAvailability(); - schedule(); + if (!disposed) schedule(); } function schedule() { @@ -159,7 +159,7 @@ export function useMediaEncoder() { documentId: string; title: string; mediaBucketId: string; - onMediaReady: (media: Pick) => void; + onMediaReady: MediaReadyHandler; }): Promise { error.value = undefined; status.value = undefined; @@ -194,9 +194,10 @@ export function useMediaEncoder() { eventsUrl: session.eventsUrl, }; // Stored before the first event, so a reload during the encode can - // find it again. + // find it again — and so a page that has since moved on can resume it. rememberEncoderSession(options.documentId, handle); + if (disposed) return; follow(handle, options.documentId, options.onMediaReady); } catch (err: any) { error.value = err?.message ?? String(err); @@ -208,8 +209,9 @@ export function useMediaEncoder() { /** Hand the caller the playback URL and, when the session has one, its key. */ async function publish( handle: EncoderSessionHandle, + documentId: string, hlsUrl: string, - onMediaReady: (media: Pick) => void, + onMediaReady: MediaReadyHandler, ): Promise { // An unencrypted session has no key, which the encoder answers with a 404 // and this reports as undefined. @@ -217,7 +219,8 @@ export function useMediaEncoder() { () => undefined, ); - onMediaReady({ hlsUrl, hlsKey }); + if (disposed) return; + onMediaReady({ hlsUrl, hlsKey }, documentId); } /** @@ -229,7 +232,7 @@ export function useMediaEncoder() { function follow( handle: EncoderSessionHandle, documentId: string, - onMediaReady: (media: Pick) => void, + onMediaReady: MediaReadyHandler, alreadyPublished = false, ): void { sessionId.value = handle.sessionId; @@ -249,7 +252,7 @@ export function useMediaEncoder() { if (saved || !event.hlsUrl) return; saved = true; - void publish(handle, event.hlsUrl, onMediaReady); + void publish(handle, documentId, event.hlsUrl, onMediaReady); }, onError: () => { // The stream drops when the encoder quits or the session ends. @@ -272,13 +275,14 @@ export function useMediaEncoder() { */ async function resume(options: { documentId: string; - onMediaReady: (media: Pick) => void; + onMediaReady: MediaReadyHandler; }): Promise { const handle = recallEncoderSession(options.documentId); if (!handle) return false; - if (!(await refreshAvailability())) return false; + if (!(await refreshAvailability()) || disposed) return false; const session = await fetchEncoderSessionStatus(handle.sessionId, handle.readToken); + if (disposed) return false; if (!session) { forgetEncoderSession(options.documentId); return false; @@ -297,7 +301,10 @@ export function useMediaEncoder() { // Written again on resume because a reload before the first event would // otherwise lose the URL the encoder is already writing to. - if (session.hlsUrl) await publish(handle, session.hlsUrl, options.onMediaReady); + if (session.hlsUrl) { + await publish(handle, options.documentId, session.hlsUrl, options.onMediaReady); + if (disposed) return false; + } if (isFinished(session.status)) { forgetEncoderSession(options.documentId); @@ -309,6 +316,7 @@ export function useMediaEncoder() { } onUnmounted(() => { + disposed = true; stop(); stopWatching(); if (typeof document !== "undefined") { diff --git a/cms/src/util/mediaEncoder.spec.ts b/cms/src/util/mediaEncoder.spec.ts index 017b300375..c0a2d0c25f 100644 --- a/cms/src/util/mediaEncoder.spec.ts +++ b/cms/src/util/mediaEncoder.spec.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { createHash } from "crypto"; import { isEncoderOutdated, - unmaskKeyHex, browserCanReachEncoder, checkEncoderHealth, createEncoderSession, @@ -24,48 +23,6 @@ afterEach(() => { vi.unstubAllGlobals(); }); -describe("unmaskKeyHex", () => { - // Shared test vector — the same (seed, key) → masked literal is asserted in - // api/src/util/maskKey.spec.ts (maskKeyHex) and shared/src/util/unmaskKeyHex.spec.ts. - // A divergence between the implementations fails a test here rather than a video - // in the player. - it("matches the shared test vector", async () => { - const seed = "sidecar-post-abc-hlsEncryptionKey"; - const keyHex = "000102030405060708090a0b0c0d0e0f"; - - expect(maskKey(seed, keyHex)).toBe("98ceb55553113bf2fdd5a74b3fa6e8d8"); - expect(await unmaskKeyHex(seed, "98ceb55553113bf2fdd5a74b3fa6e8d8")).toBe(keyHex); - }); - - 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( diff --git a/cms/src/util/mediaEncoder.ts b/cms/src/util/mediaEncoder.ts index 87fd30580c..2899461b6d 100644 --- a/cms/src/util/mediaEncoder.ts +++ b/cms/src/util/mediaEncoder.ts @@ -15,6 +15,7 @@ * 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. */ +import { unmaskKeyHex } from "luminary-shared"; /** * Where the encoder listens. @@ -236,19 +237,9 @@ export async function fetchEncoderSessionStatus( } /** - * 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. + * Fetch the session's AES-128 key, unmasked. The encoder serves it masked with + * SHA-256(sessionId) from its own endpoint — obscurity, not DRM. Undefined when + * the session is unencrypted (404). */ export async function fetchEncoderSessionKey( sessionId: string, @@ -267,21 +258,6 @@ export async function fetchEncoderSessionKey( 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(""); -} - /** * A session handle, kept so a reload can pick the encode back up. * diff --git a/cms/src/util/mediaUrl.ts b/cms/src/util/mediaUrl.ts deleted file mode 100644 index c956dd46bd..0000000000 --- a/cms/src/util/mediaUrl.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Resolving a stored media URL for playback in the CMS. - * - * `hlsUrl` is stored relative to the bucket the document names, so that the two - * cannot drift when a bucket is renamed or re-pointed. A player needs the - * absolute form, and the bucket's `publicUrl` is what turns one into the other. - * - * The rule is the API's — `api/src/changeRequests/documentProcessing/mediaUrl.ts` - * decides the stored shape on save, and this is its inverse. The app resolves - * the same way in `util/videoSource.ts`. Three readers of one convention; if it - * ever grows a case, it belongs in `luminary-shared` rather than in a fourth - * copy. - */ - -/** Whether a stored URL is a path inside the document's own bucket. */ -export function isBucketRelative(url: string | undefined): boolean { - return typeof url === "string" && url.startsWith("/"); -} - -/** - * The absolute URL a player should fetch, given what is stored. - * - * An already-absolute URL is returned untouched — media hosted elsewhere has no - * bucket to be relative to — so a caller can apply this to every media URL - * without first asking which kind it holds. A relative URL with no bucket to - * resolve against is `undefined` rather than a broken path: there is no address - * to fetch, and half of one is worse than none. - */ -export function toAbsoluteMediaUrl( - stored: string | undefined, - publicUrl: string | undefined, -): string | undefined { - if (!stored || !isBucketRelative(stored)) return stored; - if (!publicUrl) return undefined; - return `${publicUrl.replace(/\/+$/, "")}${stored}`; -} diff --git a/shared/src/util/hlsKey.spec.ts b/shared/src/util/hlsKey.spec.ts new file mode 100644 index 0000000000..36be27d8e5 --- /dev/null +++ b/shared/src/util/hlsKey.spec.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fetchHlsKey } from "./hlsKey"; + +const getSidecarMock = vi.hoisted(() => vi.fn()); + +vi.mock("../api/RestApi", () => ({ getRest: () => ({ getSidecar: getSidecarMock }) })); + +// Real (seed, masked) → key vector shared with api/src/util/maskKey.spec.ts and +// ./unmaskKeyHex.spec.ts, so a divergence fails here rather than in a player. +const SIDECAR_ID = "sidecar-post-abc-hlsEncryptionKey"; +const MASKED_KEY_HEX = "98ceb55553113bf2fdd5a74b3fa6e8d8"; +const KEY_HEX = "000102030405060708090a0b0c0d0e0f"; + +const sidecar = (data: unknown) => ({ + sidecarId: SIDECAR_ID, + parentId: "post-1", + sidecarType: "hlsEncryptionKey", + data, +}); + +beforeEach(() => { + getSidecarMock.mockReset(); +}); + +describe("fetchHlsKey", () => { + it("asks for the parent's key sidecar and unmasks what comes back", async () => { + getSidecarMock.mockResolvedValue(sidecar({ maskedKeyHex: MASKED_KEY_HEX })); + + expect(await fetchHlsKey("post-1")).toBe(KEY_HEX); + expect(getSidecarMock).toHaveBeenCalledWith("post-1", "hlsEncryptionKey", {}); + }); + + it("passes the CMS flag through, which is what exempts an editor from publish gating", async () => { + getSidecarMock.mockResolvedValue(sidecar({ maskedKeyHex: MASKED_KEY_HEX })); + + await fetchHlsKey("post-1", { cms: true }); + + expect(getSidecarMock).toHaveBeenCalledWith("post-1", "hlsEncryptionKey", { cms: true }); + }); + + it("answers undefined when there is no sidecar to use", async () => { + getSidecarMock.mockResolvedValue(undefined); + + expect(await fetchHlsKey("post-1")).toBeUndefined(); + }); + + it("answers undefined for a payload without a key, rather than an unmasked garbage string", async () => { + getSidecarMock.mockResolvedValue(sidecar({})); + + expect(await fetchHlsKey("post-1")).toBeUndefined(); + }); +}); diff --git a/shared/src/util/hlsKey.ts b/shared/src/util/hlsKey.ts new file mode 100644 index 0000000000..fa1d4ce792 --- /dev/null +++ b/shared/src/util/hlsKey.ts @@ -0,0 +1,19 @@ +import { getRest } from "../api/RestApi"; +import { SidecarType } from "../types/enum"; +import type { HlsEncryptionKeyData } from "../types/dto"; +import { unmaskKeyHex } from "./unmaskKeyHex"; + +/** + * The plaintext AES-128 key (hex) for a parent's encrypted HLS collection, or + * `undefined` when there is none to use — no sidecar, no permission, or a + * malformed payload all read the same to a player. + */ +export async function fetchHlsKey( + parentId: string, + opts: { cms?: boolean } = {}, +): Promise { + const sidecar = await getRest().getSidecar(parentId, SidecarType.HlsEncryptionKey, opts); + const data = sidecar?.data as HlsEncryptionKeyData | undefined; + if (!sidecar || !data?.maskedKeyHex) return undefined; + return await unmaskKeyHex(sidecar.sidecarId, data.maskedKeyHex); +} diff --git a/shared/src/util/index.ts b/shared/src/util/index.ts index 82e058232e..47c4139c2c 100644 --- a/shared/src/util/index.ts +++ b/shared/src/util/index.ts @@ -6,3 +6,5 @@ export * from "./MangoQuery"; export * from "./HybridQuery"; export * from "./watchValue"; export * from "./unmaskKeyHex"; +export * from "./hlsKey"; +export * from "./mediaUrl"; diff --git a/shared/src/util/mediaUrl.ts b/shared/src/util/mediaUrl.ts new file mode 100644 index 0000000000..1ac8f27b65 --- /dev/null +++ b/shared/src/util/mediaUrl.ts @@ -0,0 +1,27 @@ +/** + * Resolving a stored media URL for playback. + * + * `hlsUrl` is stored relative to the bucket the document names so the two cannot + * drift when a bucket is renamed or re-pointed; a player needs the absolute form. + * The stored shape is decided by the API on save (`documentProcessing/mediaUrl.ts`); + * this is its inverse, shared by the app and the CMS. + */ + +/** Whether a stored URL is a path inside the document's own bucket. */ +export function isBucketRelative(url: string | undefined): boolean { + return typeof url === "string" && url.startsWith("/"); +} + +/** + * The absolute URL a player should fetch. Absolute (external) URLs pass through + * untouched; a relative URL with no bucket to resolve against is `undefined` + * rather than a broken path. + */ +export function toAbsoluteMediaUrl( + stored: string | undefined, + publicUrl: string | undefined, +): string | undefined { + if (!stored || !isBucketRelative(stored)) return stored; + if (!publicUrl) return undefined; + return `${publicUrl.replace(/\/+$/, "")}${stored}`; +} From 8c7d690fed03d5f847fbba473cb8901176eeed3a Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Fri, 4 Sep 2026 10:37:21 +0200 Subject: [PATCH 2/2] v21: stamp parentMedia on migrated children Only a change request stamps parentMedia, so after the upgrade the app read neither parentMedia.hlsUrl nor video and every migrated post showed no video until its parent was next saved; the idempotency guards meant a re-run could not repair it. --- api/src/db/schemaUpgrade/v21.spec.ts | 18 +++++++++++- api/src/db/schemaUpgrade/v21.ts | 42 +++++++++++++++------------- 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/api/src/db/schemaUpgrade/v21.spec.ts b/api/src/db/schemaUpgrade/v21.spec.ts index 944a01f30a..a12d2f28ea 100644 --- a/api/src/db/schemaUpgrade/v21.spec.ts +++ b/api/src/db/schemaUpgrade/v21.spec.ts @@ -24,7 +24,7 @@ describe("v21 — legacy video field moved to media.hlsUrl", () => { return { _id: id, type: DocType.Post, ...(media !== undefined ? { media } : {}) }; } - function content(id: string, parentId: string, video?: string) { + function content(id: string, parentId: string, video?: string): any { return { _id: id, type: DocType.Content, parentId, ...(video ? { video } : {}) }; } @@ -37,11 +37,26 @@ describe("v21 — legacy video field moved to media.hlsUrl", () => { expect(p.media).toEqual({ fileCollections: [], hlsUrl: "https://example.com/master.m3u8" }); expect(c.video).toBeUndefined(); + expect(c.parentMedia).toEqual(p.media); expect(upserted).toContain(p); expect(upserted).toContain(c); expect(db.setSchemaVersion).toHaveBeenCalledWith(21); }); + it("stamps parentMedia on the siblings that never had a video of their own", async () => { + const p = post("post-1"); + const withVideo = content("content-en", "post-1", "https://example.com/a.m3u8"); + const without = content("content-fr", "post-1"); + const { db, upserted } = mockDb(20, { [DocType.Post]: [p], [DocType.Tag]: [] }, { + "post-1": [withVideo, without], + }); + + await v21(db); + + expect(without.parentMedia).toEqual(p.media); + expect(upserted).toContain(without); + }); + it("leaves an existing parent hlsUrl untouched but still clears the child's video", async () => { const p = post("post-1", { fileCollections: [], hlsUrl: "https://example.com/existing.m3u8" }); const c = content("content-1", "post-1", "https://example.com/stale.m3u8"); @@ -51,6 +66,7 @@ describe("v21 — legacy video field moved to media.hlsUrl", () => { expect(p.media.hlsUrl).toBe("https://example.com/existing.m3u8"); expect(c.video).toBeUndefined(); + expect(c.parentMedia).toBeUndefined(); expect(upserted).not.toContain(p); expect(upserted).toContain(c); }); diff --git a/api/src/db/schemaUpgrade/v21.ts b/api/src/db/schemaUpgrade/v21.ts index 8b41bd8500..c9afd3c7dc 100644 --- a/api/src/db/schemaUpgrade/v21.ts +++ b/api/src/db/schemaUpgrade/v21.ts @@ -1,23 +1,14 @@ import { DbService } from "../db.service"; import { DocType } from "../../enums"; +import { ContentDto } from "../../dto/ContentDto"; /** - * Upgrade the database schema from version 20 to 21. + * Upgrade the database schema from version 20 to 21: the legacy per-language + * `ContentDto.video` URL moves onto the parent's `media.hlsUrl`. * - * Moves the legacy per-language `ContentDto.video` URL onto the parent's - * `media.hlsUrl` (`_contentParentDto.media`, `MediaDto.hlsUrl`). The CMS video editor - * (`EditContentVideo.vue`) already writes exclusively to `parent.media.hlsUrl`, and the - * app already prefers `parentMedia.hlsUrl` over `content.video` (`videoSourceFor`) — so - * `video` is dead weight on any Content doc whose parent already has an `hlsUrl`, and a - * stale leftover on parents that don't. - * - * For each Post/Tag with no `media.hlsUrl`, the first non-empty `video` found among its - * child Content docs (across languages) is copied onto `parent.media.hlsUrl` — the - * per-parent `media` field can only hold one collection, so this is a many-to-one - * collapse; any other distinct value among the remaining children is logged and - * dropped. `video` is then deleted from every child that had it, regardless of whether - * its value was the one kept, since a per-child video field is no longer read anywhere - * once `parentMedia.hlsUrl` exists. + * A parent holds one collection, so the first child's value wins and any other + * distinct value is logged and dropped. `video` is then cleared from every child, + * and `parentMedia` stamped on them as a change request would. */ export default async function (db: DbService) { try { @@ -44,10 +35,12 @@ export default async function (db: DbService) { for (const parent of parents) { stats.parentsScanned++; - const { docs: children } = await db.getContentByParentId(parent._id); - const withVideo = (children as any[]).filter((c) => c.video); + const { docs } = await db.getContentByParentId(parent._id); + const children = docs as ContentDto[]; + const withVideo = children.filter((c) => c.video); if (!withVideo.length) continue; + let parentUpdated = false; if (!parent.media?.hlsUrl) { if (!parent.media) parent.media = { fileCollections: [] }; parent.media.hlsUrl = withVideo[0].video; @@ -63,13 +56,24 @@ export default async function (db: DbService) { parent.updatedTimeUtc = Date.now(); await db.upsertDoc(parent); stats.parentsUpdated++; + parentUpdated = true; } - for (const child of withVideo) { + // `parentMedia` is only ever stamped by a change request, so a migrated + // parent's children must get it here or the app shows no video until + // the parent is next saved. + for (const child of children) { + const hadVideo = Boolean(child.video); + if (!hadVideo && !parentUpdated) continue; + delete child.video; + if (parentUpdated) { + child.parentMedia = parent.media; + child.parentMediaBucketId = parent.mediaBucketId; + } child.updatedTimeUtc = Date.now(); await db.upsertDoc(child); - stats.childrenCleared++; + if (hadVideo) stats.childrenCleared++; } } }