Exercise Media Type Refactor — Detailed Changes (2026-04-20)¶
Summary¶
Cross-repo internal refactor consolidating exercise media handling into a shared module per platform: a single source of truth for video provider priority, S3 signed-URL generation, and the structured ExerciseMedia type returned by the API.
- OktaPT-API: new
src/media/exerciseMedia.tswith structured types, builders, attach helpers, signed-URL request context, and a backward-compat legacy payload helper. Endpoint call sites converted to useattachExerciseMedia*. - Okta-Mobile: new
lib/exerciseMedia.tsmirroring the API types, plusadaptLegacyExerciseMediafor endpoints that still return the flat shape. - OktaPT-FE: new
lib/utils/enrichExerciseMedia.tsto hydrate AI-generated exercises (which arrive with onlyid+name) so plan-creation thumbnails render. Existinglib/utils/exercise.tsstill drives the web players. - New audit:
OktaPT-API/scripts/auditExerciseMedia.tsreports DB media-column combinations and validates a curated set of read endpoints. - No user-visible behavior change, except that uploaded
s3videos are now first-class video sources end to end and a new structuredmediafield is returned alongside legacy fields on exercise responses.
Motivation¶
Before this refactor, every API endpoint hand-rolled its own Prisma select for media columns and re-implemented the priority fallback. Every client did the same. Three problems:
- Drift risk — the priority order had to be mirrored in three places.
- No s3 path — therapist-uploaded videos (
uploadedVideoKey) never had a structured place in the playback chain. Endpoints had to remember to sign the URL ad hoc. - N×1 signed-URL traffic — list endpoints signed S3 GETs once per exercise even if the same key repeated.
A shared module fixes all three: the API computes the prioritized videoSources array with cached signed URLs and returns it as media on every exercise response.
API Layer¶
New module: OktaPT-API/src/media/exerciseMedia.ts¶
| Export | Role |
|---|---|
ExerciseVideoSource, ExercisePhotoSource, ExerciseMedia |
Structured media types — single shape consumed by all clients |
exerciseMediaSelect |
Prisma.ExerciseSelect constant for the columns required to build ExerciseMedia |
createExerciseMediaRequestContext |
Per-request S3-signing wrapper with an in-memory Map<storageKey, Promise<url>> cache |
buildExerciseMedia |
Lower-level: turns persistence input + optional personalized video into ExerciseMedia |
buildExerciseMediaFromRaw |
Convenience: takes the raw row shape and calls buildExerciseMedia |
attachExerciseMedia |
Adds media to a single exercise object |
attachExerciseMediaList |
Adds media to an array, sharing the request context |
getExercisePlaybackSource, getExerciseThumbnailUrl, hasPlayableExerciseVideo, getVideoPlaybackUrl |
Helper readers used by both the API and tests |
getCloudflarePlaybackUrl, getCloudflareThumbnailUrl, getKinescopePlaybackUrl, getKinescopeThumbnailUrl, getYouTubeVideoId, getYouTubeThumbnailUrl |
Provider-specific URL builders |
buildLegacyExerciseMediaPayload |
Backward-compat builder returning the old { video, photo, videoStreamId, videoPlaybackUrl, photoSignedUrl, videoProvider } shape |
Endpoints converted¶
Direct call sites (excluding tests and the module itself):
| Call site | Helper used |
|---|---|
src/routes/exercises/shared.ts (serializeExercisesForResponse) |
attachExerciseMediaList + exerciseMediaSelect |
src/routes/exercises/exercises.ts:233 (list endpoint) |
attachExerciseMediaList |
src/routes/workouts/patientUpcoming.ts:64 |
attachExerciseMedia |
src/routes/doctor/controller.ts:618 and :2099 (workout review) |
attachExerciseMedia |
src/routes/legacy.ts:2280 (legacy patient workouts) |
buildLegacyExerciseMediaPayload |
The exercises list helper (getExerciseListSelect) spreads exerciseMediaSelect into its Prisma selection, so any future endpoint reusing the helper picks up media columns automatically.
s3 provider added to the priority chain¶
uploadedVideoKey was previously used only for therapist-uploaded videos shown in the exercise library. It now appears as a fifth entry in videoSources (lowest playback priority) with a presigned playback URL generated through the request context. The old buildLegacyExerciseMediaPayload exposes the same URL through the legacy videoPlaybackUrl field for clients that haven't moved to structured media yet.
New tests¶
| Test file | Coverage |
|---|---|
src/media/__tests__/exerciseMedia.test.ts |
Builder + priority + signed-URL caching |
src/routes/__tests__/exercise-get.test.ts |
GET /v2/exercises/:id returns media |
src/routes/__tests__/exercises-personalized.test.ts |
GET /v2/exercises?patientId=… attaches personalized video metadata and 403s without an active connection |
src/routes/__tests__/patient-upcoming-media.test.ts |
Patient upcoming workouts include media |
src/routes/__tests__/doctor-workout-media.test.ts |
Doctor workout-review routes include media |
src/routes/__tests__/doctor-hep-management.test.ts |
Doctor HEP routes include media |
src/routes/__tests__/workouts.test.ts (extended) |
Existing workout tests updated for media |
Mobile Layer¶
New module: Okta-Mobile/lib/exerciseMedia.ts¶
Mirrors the API types and exposes:
adaptLegacyExerciseMedia(carrier)— accepts a legacy exercise object (flat fields + optionalpersonalizedVideo) and returns a structuredExerciseMediaso all consumers can speak one shape regardless of whether the API response has been migrated yet.getPlaybackSource(input),getPlaybackSources(input)— top source / all sources.getThumbnailUrl(input)— thumbnail-priority chain.hasPlayableVideo(input)— convenience predicate.- Provider-specific URL builders (Cloudflare, Kinescope, YouTube).
A small resolveExerciseMedia helper inside the module decides whether to use the structured media field directly or run the legacy adapter. This keeps call sites short — they pass either shape and get the right answer.
Tests¶
lib/__tests__/exerciseMedia.test.ts — adapter, priority, and helper coverage.
Web Layer¶
New helper: OktaPT-FE/lib/utils/enrichExerciseMedia.ts¶
enrichExerciseWithMedia(ex, detailsMap) fills in missing media fields on an ExerciseInWorkout by looking it up in a Map<exerciseId, Exercise>. This is purpose-built for the AI-plan-generation flow, where exercises arrive with only { exerciseId, name } and the UI needs to render thumbnails as soon as the plan appears.
Used by InitialPlanCreationModal, EditPlanModal, DayWorkoutPreview, PlanComparisonView, and (transitively, via the underlying state hook) SelectedExerciseCard. The lookup map is populated by useExerciseDetails(exerciseIds, patientId) which now accepts an optional patientId so personalized video metadata is included when fetching details from the patient-scoped endpoint.
Existing module retained¶
lib/utils/exercise.ts still owns the web's getExerciseThumbnailUrl, hasVideoSource, getVideoSource, and the YouTube/Kinescope/Cloudflare URL helpers used by VideoPreviewModal, ExerciseThumbnailRow, and the patient workout page. These weren't migrated to the new module — the web still consumes the legacy flat shape directly because the consumers are scattered.
Audit Script¶
OktaPT-API/scripts/auditExerciseMedia.ts does two things:
- Database audit — reads every active
Exerciserow and tallies populated combinations ofcloudflareStreamId,kinescopeVideoId,videoUrl,uploadedVideoKey. Reports: - The count of each combination signature (e.g.
cloudflare+kinescope,youtube,none). - Exercises with multiple providers populated (potential ambiguity).
- Exercises with only
uploadedVideoKey(the new s3 path — used to track migration progress). PersonalizedExerciseVideorows that areCOMPLETED && isActivebut missing acloudflareStreamId(broken processing).- Endpoint audit — hits a curated set of read endpoints and asserts each response includes the expected
mediaobject with the right source mix. Any miss is reported withpath,checked,ok, anddetail.
Use after migrations or large media-related changes to confirm nothing regressed.
Related Docs¶
- Public reference: exercise-media-rendering.md — the new structured type, helpers, priorities, and key files.
- Per-patient overrides that ride on the same priority chain: personalized-exercise-videos.md.