Skip to content

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.ts with structured types, builders, attach helpers, signed-URL request context, and a backward-compat legacy payload helper. Endpoint call sites converted to use attachExerciseMedia*.
  • Okta-Mobile: new lib/exerciseMedia.ts mirroring the API types, plus adaptLegacyExerciseMedia for endpoints that still return the flat shape.
  • OktaPT-FE: new lib/utils/enrichExerciseMedia.ts to hydrate AI-generated exercises (which arrive with only id + name) so plan-creation thumbnails render. Existing lib/utils/exercise.ts still drives the web players.
  • New audit: OktaPT-API/scripts/auditExerciseMedia.ts reports DB media-column combinations and validates a curated set of read endpoints.
  • No user-visible behavior change, except that uploaded s3 videos are now first-class video sources end to end and a new structured media field 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:

  1. Drift risk — the priority order had to be mirrored in three places.
  2. 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.
  3. 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 + optional personalizedVideo) and returns a structured ExerciseMedia so 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:

  1. Database audit — reads every active Exercise row and tallies populated combinations of cloudflareStreamId, kinescopeVideoId, videoUrl, uploadedVideoKey. Reports:
  2. The count of each combination signature (e.g. cloudflare+kinescope, youtube, none).
  3. Exercises with multiple providers populated (potential ambiguity).
  4. Exercises with only uploadedVideoKey (the new s3 path — used to track migration progress).
  5. PersonalizedExerciseVideo rows that are COMPLETED && isActive but missing a cloudflareStreamId (broken processing).
  6. Endpoint audit — hits a curated set of read endpoints and asserts each response includes the expected media object with the right source mix. Any miss is reported with path, checked, ok, and detail.

Use after migrations or large media-related changes to confirm nothing regressed.