The exercise library has two kinds of rows in one table, ExerciseLibraryItem:
  • Shared (global) rows have studioId: null. Every studio sees them. They come from the seeded catalog.
  • Studio rows have a studioId. Only that studio sees them.
A studio can edit a shared row without changing it for anyone else. The edit is stored as a private override in ExerciseStudioOverride and applied on read. Files beyond the five standard ones:

The exercise object

Every route returns the same shape: the columns in CLIENT_COLUMNS (an explicit allow-list in the repository) with the studio’s override applied by withOverride, plus two computed fields.
aliases and searchText are in the response so the builder’s exercise picker can search a preloaded library with the same rules the server uses. They are search-only. No UI surface or export may render them.
The values are illustrative. Real catalog names are in Hebrew.

Concepts

Per-studio overrides

ExerciseStudioOverride has one row per (studioId, exerciseId). It can override name, englishName, exerciseType, exerciseTypes, bodyParts, targetMuscles, secondaryMuscles, equipment, instructions, gifUrl, thumbnailUrl and videoUrl. How an override is read (withOverride):
  • A text field with a non-null override value replaces the base value.
  • A list field with a non-empty override replaces the base list. An empty override list means “inherit”.
  • The list [""] is a sentinel for “the studio cleared this list”. It is read back as [].
  • videoUrl is not merged into videoUrl. It is returned separately as customVideoUrl, so the client can show the studio’s clip and still know the original.
How an override is written (the update route, for a shared row):
  • A text field equal to the base value after trimming is stored as null, which removes the override for that field.
  • A list equal to the base list is stored as [] (inherit). An emptied list is stored as [""].
  • Only the keys present in the request are written, so renaming an exercise never clears its muscles.
Because the exercise id does not change, programs that already reference the exercise show the studio’s wording too. List filters are override-aware. A filter on type, equipment, body part or muscle matches a shared row by its base value only when the studio has not overridden that field, and by the override value when it has.

Plan-only rows

The plan import can create an exercise “for this plan only”. It is a normal studio row with source: "ai-private" (PLAN_ONLY_SOURCE in lib/library-visibility.ts). Such a row never appears in a list, a search, an equipment menu or an alternatives pool. Every list query ANDs listedLibraryRows() into its filter. Reads by id still resolve it, so the plan that uses it keeps rendering.

Types and categories

There are two fields:
  • exerciseTypes holds every category the exercise belongs to, from EXERCISE_CATEGORIES: warmup, bodyweight, strength, aerobic, mobility, stability, plyometrics, agility. Usually one, sometimes several. strength means gym strength with equipment. A strength move with no equipment is bodyweight.
  • exerciseType is the older single type. The alternatives ranking matches on it.
The single type is derived from the categories whenever only categories are sent (legacyTypeOf): strength if the categories include strength or bodyweight, otherwise the first of plyometrics, aerobic, stability, mobility, agility that is present, otherwise mobility for a lone warmup, otherwise strength. On create with no categories, they are derived the other way (categoriesFor): a non-strength type becomes its own category. A strength exercise with equipment body weight is bodyweight, with any other equipment strength, and with no equipment the name decides: a name that mentions gym equipment in Hebrew or English is strength, otherwise bodyweight.

Equipment

EQUIPMENT_SLUGS is the v3 vocabulary: ab wheel, assisted, barbell, battle rope, body weight, bosu ball, cable, dumbbell, exercise ball, foam roller, hex bar, jump rope, kettlebell, machine, medicine ball, plate, resistance band, rope, sled, smith machine, trx, vipr. It mirrors EQUIPMENTS in the frontend’s exercise-taxonomy.ts. The request bodies accept any string, so a legacy value cannot block a save. A value outside the vocabulary is logged as a warning (exercises: equipment value is outside the v3 vocabulary) and stored as sent. normalizeSearchToken lowercases the term, removes quote-like characters (ASCII quotes, the Hebrew geresh and gershayim, and typographic quotes) and collapses whitespace. searchText is built with the same rule from the name, the English name and the aliases, and is recomputed on every write. It is never client supplied. A search is relevance ranked across three disjoint tiers, paged in this order:
  1. The term matches a target muscle.
  2. The term matches a secondary muscle, and not a target muscle.
  3. The term matches only by name, English name, aliases, or the studio override’s name, English name or instructions.
Muscles are stored as English slugs while coaches type Hebrew, so the term is first translated through muscleSlugsForTerm and MUSCLE_HE. Inside a tier, rows keep the default newest-first order. All facet filters apply to every tier. total is the sum of the three tier counts.

Endpoints

GET /v1/web/exercises

Lists the exercises the studio sees, with filters and ranked search. Auth: web lane, any role.
string
default:"all"
all (shared plus the studio’s own), global (shared only) or custom (the studio’s own only).
string
One legacy type: strength, aerobic, mobility, stability, plyometrics, agility.
string
Categories, comma-separated or as a repeated key, for example types=warmup,bodyweight. Matches an exercise in any of them. Each value must be one of the eight categories.
string
Exact match inside bodyParts.
string
Exact match inside targetMuscles.
string
Exact match inside secondaryMuscles.
string
Exact match on equipment.
Free text. See Search.
integer
default:"1"
Page number, positive.
integer
default:"100"
Positive, maximum 2000. The high ceiling lets the builder preload the library.
Without a search term, rows are ordered by createdAt descending. The controller also loads equipmentOptions in parallel: the distinct non-null equipment values across every listed row the studio sees, sorted. It is not narrowed by the current filters and reads base values only, not overrides. Response:
Errors: VALIDATION (422) for a bad query, such as an unknown category in types. UNAUTHORIZED with no studio context.

POST /v1/web/exercises

Creates a studio exercise. Returns 201. Auth: web lane, any role.
string
required
Minimum length 1.
string
One legacy type. Derived from exerciseTypes when omitted.
string[]
Categories, at most eight. Derived from the type, equipment and name when omitted or empty.
string[]
default:"[]"
Body part slugs.
string[]
default:"[]"
Target muscle slugs. The first non-blank one is the label alternatives are matched on.
string[]
default:"[]"
Secondary muscle slugs.
string
Any string. See Equipment.
string
English name. Feeds the search haystack and the alternatives name match.
string
Video location.
string
Animated preview location.
string
Poster location.
string
Free text.
The row is created with the caller’s studioId, isSeeded: false and a computed searchText. Response: the created exercise object with customVideoUrl: null and isOverridden: false. Errors: VALIDATION for a bad body. UNAUTHORIZED.

POST /v1/web/exercises/media

Uploads a video or image for an exercise and returns its public URL. Returns 201. Auth: web lane, any role.
string
required
The file as base64, either raw or as a data: URL. For a data URL, everything after the first comma is decoded.
string
required
One of video/mp4, video/quicktime, video/webm, image/png, image/jpeg, image/webp, image/gif.
string
Accepted but not used. The stored name is random.
The service decodes the body and writes it to R2 under exercises/<studioId>/<24 hex characters>.<extension>. The extension comes from the content type (mp4, mov, webm, png, jpg, webp, gif). The route only stores the file. It does not attach it to an exercise. Send the returned url in a create, update or video override call. Size limits: the service allows up to 99,000,000 bytes for video and 25,000,000 bytes for images. The request is a JSON body, and the global express.json limit in app.ts is 24mb, so the body parser cuts off a base64 payload well before the video ceiling. In practice this route carries files up to roughly 17 MB. Response:
Errors:

GET /v1/web/exercises/:id/alternatives/more

The whole pool of alternatives for one exercise: ranked, searchable, filterable by equipment, and paged. Auth: web lane, any role.
string
required
The source exercise id.
string
Maximum 200 characters. Matched against the name, English name and aliases with the list endpoint’s normalization.
string
Maximum 100 characters. Exact match after trimming and lowercasing.
integer
default:"1"
Page number, positive.
integer
default:"60"
Positive, maximum 100.
Returns the ranker’s more list (see Automatic alternatives) after the search and equipment filters. The ranked order is kept. equipmentOptions is computed from the whole pool before filtering, so the menu does not shrink while the coach types. A source with no target muscle returns an empty result. Response: items are exercise objects, without the match field.
items is emptied in the example. Errors: NOT_FOUND (exercise not found), VALIDATION.

GET /v1/web/exercises/:id/alternatives

The top automatic alternatives for one exercise, each with why it matched. Auth: web lane, any role.
string
required
The source exercise id. A plan-only row of the studio can be a source.
Response:
  • Each entry in top is a full exercise object plus match. The example leaves out some columns.
  • top holds at most four entries and can hold fewer. It is never padded with looser matches.
  • moreTotal is the size of the full pool, for a “show more” link.
  • When the source has no target muscle, reason is "no-target", top is empty and moreTotal is 0.
Errors: NOT_FOUND (exercise not found).

GET /v1/web/exercises/:id

Returns one exercise the studio can see, with its override applied. Auth: web lane, any role.
string
required
The exercise id.
Response: one exercise object. Errors: NOT_FOUND (exercise not found) for an unknown id or another studio’s exercise.

PATCH /v1/web/exercises/:id

Edits an exercise. The behaviour depends on who owns the row. Auth: web lane, any role.
string
required
The exercise id.
The body is createExerciseBody.partial(): every create field, all optional.
bodyParts, targetMuscles and secondaryMuscles keep their .default([]) through .partial(). With the installed Zod 4, a patch that leaves them out parses them as [], not as absent. On a studio row that empties the three lists. On a shared row it stores the “cleared” sentinel in the override, so the studio sees the exercise with no muscles. Always send all three lists in a patch, even when you only change the name.
  • Studio row. The row is rewritten with an updateMany scoped to (id, studioId). exerciseType is derived from exerciseTypes when only the categories are sent. searchText is rebuilt from the row as it will look after the patch.
  • Shared row. Nothing is written to the shared row. The service builds a field override by comparing each sent field with the base row, as described under Per-studio overrides, and upserts it into ExerciseStudioOverride. A videoUrl in the body becomes the studio’s video override. If no field differs, nothing is written.
Response: the exercise object after the change, with isOverridden and customVideoUrl reflecting the override. Errors: NOT_FOUND (exercise not found), VALIDATION.

DELETE /v1/web/exercises/:id

Deletes a studio exercise. Returns 204 with no body. Auth: web lane, any role.
string
required
The exercise id.
Only rows owned by the studio can be deleted. The row is hard deleted. Plans that reference the id are not rewritten by this route. Errors:

PATCH /v1/web/exercises/:id/video

Sets the studio’s own video for an exercise, shared or studio-owned. Auth: web lane, any role.
string
required
The exercise id.
string
required
Minimum length 1.
Upserts ExerciseStudioOverride.videoUrl for (studioId, exerciseId). Other override fields are left as they are. Response: the exercise object with customVideoUrl set. Errors: NOT_FOUND (exercise not found), VALIDATION.

DELETE /v1/web/exercises/:id/video

Removes the studio’s video override. Returns 200 with the exercise, not 204. Auth: web lane, any role.
string
required
The exercise id.
Sets videoUrl to null on the override row. The row itself stays, so other overridden fields survive. Response: the exercise object with customVideoUrl: null. Errors: NOT_FOUND (exercise not found).

Automatic alternatives

rankExerciseAlternatives(source, pool) in exercise-alternatives.ts decides which library rows are the same exercise as a source row. It is pure, with no I/O. The design rule in the file header is “identical, not similar”.

The pool

The service loads candidates with repo.alternativePool: listed rows the studio sees whose target muscles include the source’s exact target label and whose exerciseType matches, using the same override-aware filters as the list. The pool is capped at 3000 rows. Every candidate has the studio’s override applied before ranking. The ranker then drops the source itself, plan-only rows, and any row that is a duplicate of the source.

Duplicates

Two rows are the same exercise filmed again when they share either key from duplicateKeys:
  • The Hebrew name with version, filming angle, side and (2) markers removed.
  • The English name with filming angle and side markers removed, combined with the equipment. A goblet squat with a dumbbell and one with a kettlebell stay two exercises. An English version number alone never merges two rows, because the catalog uses it for real variants.
Among duplicates inside the pool, one row is kept: the shared row first, then the one with a video, then the newest.

Score

  • secondaryJaccard is the Jaccard overlap of the two rows’ secondary muscles after folding fine labels to their parent (MUSCLE_PARENT: for example front delts, lateral delts and rear delts all count as delts). When neither row has secondary muscles, it is 1.
  • nameJaccard is the overlap of movement words. English names are used when both rows have one, otherwise Hebrew names. Equipment, stance and filler words are removed first, and common spellings are folded (pull down to pulldown, plurals to singular), so “dumbbell bench press” and “barbell bench press” are the same movement.
  • identicalSecondary is 1 when both rows have exactly the same secondary labels.
Ties break on having a video, then shared before studio rows, then name in Hebrew collation, then id. match.score is the score rounded to two decimals.

The top list

A candidate is eligible for the top list only when it passes the gate:
  • If the source has secondary muscles, the candidate must share at least two of them, or all of them when the source has only one.
  • If the source has none, the candidate must share at least one movement word.
From the gated candidates, best first, the ranker picks up to four (TOP_ALTERNATIVES), allowing at most two per equipment value (MAX_PER_EQUIPMENT). If that variety rule leaves fewer than four, the skipped candidates fill the remaining slots. The more list is the whole de-duplicated pool by score, gate ignored.

Batch form

autoAlternativeIds(ports, studioId, sourceIds) in auto-alternatives.ts runs the same ranking for many sources at once and returns a map of source id to top alternative ids. It loads one pool per distinct pair of target label and exercise type. A source with no target muscle maps to [], and a source the studio cannot see is absent. The trainee side uses it when a training plan has meta.autoAlts on, so a trainee is offered exactly what this page’s alternatives endpoint shows the coach.

External exercise mapping

source-keys.ts and exercise-source-map.repository.ts support the AutoFit import. sourceKeysForAutofitExercise builds the lookup keys for one external exercise, most specific first: exercise:<id>, premade:<id>, video:<normalized url>, name:<normalized name>. ExerciseSourceMap rows map a (source, sourceKey) pair to a catalog exercise id with a method (video, name, fuzzy or manual) and a confidence. These helpers have no HTTP route.

Who else uses this module

modules/trainee/, modules/assistant/, modules/plan-import/ and modules/autofit-import/writers/exercises.ts import from this module. Their endpoints are documented on their own pages.