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.
ExerciseStudioOverride and applied on read.
Files beyond the five standard ones:
The exercise object
Every route returns the same shape: the columns inCLIENT_COLUMNS (an explicit allow-list in the repository) with the studio’s override applied by withOverride, plus two computed fields.
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[]. videoUrlis not merged intovideoUrl. It is returned separately ascustomVideoUrl, so the client can show the studio’s clip and still know the original.
- 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.
Plan-only rows
The plan import can create an exercise “for this plan only”. It is a normal studio row withsource: "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:exerciseTypesholds every category the exercise belongs to, fromEXERCISE_CATEGORIES:warmup,bodyweight,strength,aerobic,mobility,stability,plyometrics,agility. Usually one, sometimes several.strengthmeans gym strength with equipment. A strength move with no equipment isbodyweight.exerciseTypeis the older single type. The alternatives ranking matches on it.
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.
Search
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:
- The term matches a target muscle.
- The term matches a secondary muscle, and not a target muscle.
- The term matches only by name, English name, aliases, or the studio override’s name, English name or instructions.
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.integer
default:"1"
Page number, positive.
integer
default:"100"
Positive, maximum 2000. The high ceiling lets the builder preload the library.
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:
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
English name. Feeds the search haystack and the alternatives name match.
string
Video location.
string
Animated preview location.
string
Poster location.
string
Free text.
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.
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:
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.
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.
- Each entry in
topis a full exercise object plusmatch. The example leaves out some columns. topholds at most four entries and can hold fewer. It is never padded with looser matches.moreTotalis the size of the full pool, for a “show more” link.- When the source has no target muscle,
reasonis"no-target",topis empty andmoreTotalis0.
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.
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.
createExerciseBody.partial(): every create field, all optional.
- Studio row. The row is rewritten with an
updateManyscoped to(id, studioId).exerciseTypeis derived fromexerciseTypeswhen only the categories are sent.searchTextis 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. AvideoUrlin the body becomes the studio’s video override. If no field differs, nothing is written.
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.
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.
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.
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 withrepo.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 fromduplicateKeys:
- 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.
Score
secondaryJaccardis the Jaccard overlap of the two rows’ secondary muscles after folding fine labels to their parent (MUSCLE_PARENT: for examplefront delts,lateral deltsandrear deltsall count asdelts). When neither row has secondary muscles, it is 1.nameJaccardis 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 downtopulldown, plurals to singular), so “dumbbell bench press” and “barbell bench press” are the same movement.identicalSecondaryis 1 when both rows have exactly the same secondary labels.
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.
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.