Skip to content

Legacy media import ​

Moving ~300 GB of legacy WordPress exercise videos off a Windows box, into S3, and from there into the platform catalog as static exercises.

The shape of it: the upload is dumb and happens once; every intelligent step happens server-side afterwards. The Windows box does a single verbatim aws s3 sync into a plain, disposable bucket. Nothing is renamed, sorted, or restructured there — the filenames are already the join key, and the one irreversible, un-repeatable, bandwidth-bound step is the worst possible place to be clever. Selecting, slugging, and laying files out into the bake pipeline's structure all happen later, as S3-to-S3 copies inside AWS that cost seconds and can be re-run at will.

The backup bucket ​

Deliberately not built from the storage-s3 Terraform module. That module sets prevent_destroy = true and enables versioning unconditionally, which is right for platform substrate and exactly wrong here: this bucket is scaffolding, and it must be easy to delete the day the migration finishes.

bash
aws s3api create-bucket \
  --bucket restartix-legacy-import \
  --region eu-central-1 \
  --create-bucket-configuration LocationConstraint=eu-central-1

aws s3api put-public-access-block \
  --bucket restartix-legacy-import \
  --public-access-block-configuration \
  "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"

# Orphaned parts from a failed 300 GB sync are invisible to `aws s3 ls`
# and billed as storage. This is the only lifecycle rule the bucket needs.
aws s3api put-bucket-lifecycle-configuration \
  --bucket restartix-legacy-import \
  --lifecycle-configuration '{"Rules":[{"ID":"abort-incomplete","Status":"Enabled","Filter":{},"AbortIncompleteMultipartUpload":{"DaysAfterInitiation":7}}]}'

No versioning (an overwrite should just overwrite), no Object Lock, no prevent_destroy. Same region as everything else — eu-central-1 — which is what makes the later copies free and instant.

Credentials for the Windows box ​

The Windows box needs its own key, scoped to this one bucket and nothing else — not your admin credentials. Four commands from a machine that already has IAM rights; no Console UI needed.

create-user grants programmatic access only — this user has no console password and cannot sign in to the AWS web console at all.

bash
aws iam create-user --user-name legacy-media-uploader

cat > /tmp/legacy-uploader-policy.json <<'JSON'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListTheBucket",
      "Effect": "Allow",
      "Action": ["s3:ListBucket", "s3:ListBucketMultipartUploads"],
      "Resource": "arn:aws:s3:::restartix-legacy-import"
    },
    {
      "Sid": "WriteUnderLegacyPrefix",
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:GetObject",
        "s3:AbortMultipartUpload",
        "s3:ListMultipartUploadParts"
      ],
      "Resource": "arn:aws:s3:::restartix-legacy-import/legacy/*"
    }
  ]
}
JSON

aws iam put-user-policy \
  --user-name legacy-media-uploader \
  --policy-name legacy-import-write \
  --policy-document file:///tmp/legacy-uploader-policy.json

aws iam create-access-key --user-name legacy-media-uploader

The last command prints AccessKeyId and SecretAccessKey. The secret is shown once and never again — if you lose it, delete the key and make a new one.

What the policy deliberately does not grant:

  • No s3:DeleteObject. A compromised Windows box cannot destroy an upload that is already 200 GB in. This is the single most valuable line in the policy.
  • No access to any other bucket. Not the exercise-assets buckets, not uploads, not backups. If that key leaks, the blast radius is one disposable bucket full of videos you already have a copy of.
  • No IAM, no EC2, no anything else.

s3:GetObject is granted, even though a one-way upload doesn't strictly need it — it costs nothing here (the box already holds the files) and it keeps aws s3 sync from failing partway through a 20-hour run over a permission it wanted for a comparison.

Now on the Windows box, in PowerShell:

powershell
aws configure --profile legacy
# AWS Access Key ID     [None]: AKIA...          <- paste from create-access-key
# AWS Secret Access Key [None]: ...              <- paste from create-access-key
# Default region name   [None]: eu-central-1
# Default output format [None]: json

aws s3 ls s3://restartix-legacy-import/ --profile legacy    # confirm it works

A named profile rather than [default], so nothing already configured on that box gets clobbered — and so --profile legacy in the sync command is an explicit reminder of which identity is doing the writing.

Revoking it afterwards ​

The key is long-lived and sits on a Windows box. Delete it the moment the upload is verified — don't wait for the bucket teardown.

bash
aws iam list-access-keys --user-name legacy-media-uploader     # get the AccessKeyId
aws iam delete-access-key --user-name legacy-media-uploader --access-key-id AKIA...
aws iam delete-user-policy --user-name legacy-media-uploader --policy-name legacy-import-write
aws iam delete-user --user-name legacy-media-uploader

IAM refuses to delete a user that still has keys or inline policies attached, so the order matters.

Uploading from Windows ​

Install AWS CLI v2 for Windows, then in PowerShell:

The CLI defaults are fine — none of the tuning below is required, and the sync works without any of it.

powershell
# Optional. Fewer, larger parts: ~4,800 upload requests across 300 GB
# instead of ~38,000. Less per-part overhead on a flaky connection.
aws configure set s3.multipart_chunksize 64MB --profile legacy

# Optional, and ONLY if the connection is needed for other things while
# this runs. It is a throttle, so a value above your real upstream does
# nothing at all — measure first and set ~70% of the upload figure
# (50 Mbit -> 5MB/s, 100 Mbit -> 9MB/s, 300 Mbit -> 26MB/s).
# aws configure set s3.max_bandwidth 9MB/s --profile legacy

Deliberately not raising s3.max_concurrent_requests (default 10): more parallel streams only help when the link has headroom the CLI is not already filling. On a desktop uplink, 10 saturates it, and raising it just adds TLS work for the same throughput.

powershell
aws s3 sync "D:\legacy-videos" s3://restartix-legacy-import/legacy/ `
  --profile legacy `
  --region eu-central-1 `
  --checksum-algorithm SHA256 `
  --exclude "*" --include "*.mp4" --include "*.mkv"

Check the extension breakdown before running this — anything the --include list misses is skipped silently, and you would only find out at the coverage check:

powershell
Get-ChildItem $src -Recurse -File | Group-Object Extension |
  Sort-Object Count -Descending | Select-Object Count, Name

Notes that matter:

  • Do not rename anything. The filename is _exercise_video_id and it is the only link to the title. The import script lowercases on the way out; the source stays untouched.
  • --checksum-algorithm SHA256 is not optional. S3's ETag is not an MD5 for multipart uploads, so without it there is no integrity check on the large files.
  • Stop the machine sleeping — powercfg /change standby-timeout-ac 0. A sleeping box mid-sync is the most common way this job silently stalls overnight.
  • Resumable and idempotent: re-run after any interruption and it skips what already landed. Verify by re-running with --dryrun — empty output means everything is there at matching size.
  • Check the connection's monthly data cap before starting. 300 GB of upstream overruns a lot of home plans.

Staging into an environment ​

Once the bytes are in the backup bucket, infra/scripts/legacy-import.py does the rest.

bash
./infra/scripts/legacy-import.py index

Lists the bucket, joins it against .platform/legacy_exercises.csv, and reports both directions — files with no CSV row, and CSV rows with no file. Run this first; it is the proof the upload actually completed.

bash
./infra/scripts/legacy-import.py apply --program 5360 --env dev

Stages one program's exercises for local testing. Program 5360 ("Exerciții pentru managementul durerii la genunchi") is the recommended trial: 5 exercises, ~280 MB, all of them real type=video rows. Each lands as the two files kind=static requires:

platform/lx-{video_id}/manifest.json
platform/lx-{video_id}/static-video-ro.mp4

Then in Console, run Sync from S3 — it scans for {slug}/manifest.json, creates a draft row per new slug, and projects kind from the manifest. Publish one and play it to confirm the whole chain works before committing to the full set.

bash
./infra/scripts/legacy-import.py apply --all --env production

Every type=video row — 1,430 exercises after de-duplication. Production prompts for typed confirmation.

Do not stage the full set into more than one environment. 300 GB × 3 is real money for no benefit; dev gets the handful of programs you actually test against, production gets everything.

Why the slug is lx-{video_id} and not the title ​

exercises.slug is the S3 directory name, and the Console sync projects it into the row — so a slug change means moving every object under that prefix. Deriving it from the title would make 1,430 naming decisions, each of which breaks the moment someone renames an exercise.

reference_code (EX-0042) is the right concept — stable, language-neutral, immutable by trigger — but it cannot be the folder name: reference_number comes from a Postgres sequence at INSERT, and the row is inserted from the S3 folder. The code does not exist until after the folder has been read.

So the slug carries the legacy id, which is already stable, already unique, and already the filename. The display title lives in exercises.name + .translations, where it can change as often as anyone likes without touching S3. Lowercasing the 1,978 distinct legacy ids was checked and produces zero collisions.

Titles and taxonomy ​

The Console sync seeds exercises.name from the slug — the manifest has no display-name field — so without a backfill every imported exercise is called lx-jsicwtenlt. legacy-import.py sql emits that backfill.

What the legacy export does and does not carry:

  • Titles — present for all 1,977, in legacy_exercises.Title.
  • Descriptions — absent. Content is empty on all 1,992 rows.
  • Taxonomy — absent, and there is nothing to re-export: the legacy WordPress never categorised exercises. Program-derived tagging is therefore not a fallback, it is the taxonomy, and this import is where it gets created. Apply it per selection (--tag body_region=shoulder), which is defensible because a program's exercises share a region by construction.

Two data shapes to know about. 676 rows carry a non-unique title — 178 Intro, 50 Introducere, 40 Outro — because they are per-session framing videos; --disambiguate rewrites them from the playlist position. And WordPress titles contain U+00A0 in places (Școala\xa0Spatelui), which renders identically to a space and breaks every search for the visible string; the script folds it at the boundary.

Rebuilding a legacy program ​

legacy-import.py structure turns a legacy program's playlist into a platform program.

The legacy shape is (phase, week, day) per playlist item. A (phase, week) pair is one stage (program_phases) and each day inside it is one session, so RestartiX Umăr Sănătos — 2 phases x 4 weeks x 5 days — becomes 8 stages of 5 sessions, 694 exercise rows. The legacy data validated clean: all 40 buckets ordered 1..N with no gaps, every one starting Intro and ending Outro.

Every legacy phase boundary becomes a kind='reassessment' checkpoint — no sessions, requires_unlock=true — so the second half stays shut until a specialist has actually reassessed. That is a stage in its own right, which is why the 8-stage program has 9 phases.

Two deliberate choices. Sessions are mode='video_only', sets=1: the source exercises are kind=static and a static video has no rep or hold structure to dose. And phase cadence is left NULL, which means inherit-from-protocol — the template stays undosed so the prescribe dialog asks per patient instead of baking one clinic's schedule into the content.

Re-running the program path deletes and rebuilds — structure cannot be diffed incrementally — so it refuses when anything depends on the program. The cascades are wide enough to matter: protocols.program_id is ON DELETE CASCADE, so deleting a template would delete every prescription using it, and session_runs.session_id is ON DELETE SET NULL, so play history would silently survive with no record of what was played. The guard checks prescriptions, derived instances, runs and appointments.

Not every legacy post is a program. --standalone emits a single library session (program_id NULL) for a flat playlist — Școala Spatelui is 12 ordered clips with no phase/week/day spread, and forcing that into a one-stage one-session program would invent structure the source does not have. sessions.program_id IS NULL is the schema's own word for it. The standalone path upserts instead: an existing session is UPDATED in place and keeps its id, so runs and appointments stay attached, and only session_exercises is rebuilt. Its identity key is idempotency_key (UNIQUE per org), because a session has no slug.

Known data problems ​

Surfaced while building this; none block the upload, all need a decision before the catalog is complete.

  • The export has a quote bug, and the script repairs it on load. One exercise title (WP id 953) begins with a stray " — a data-entry typo. In the playlist JSON that is a legitimate \\" escape, but the export wrote the quote through without doubling it, and a lone " inside a quoted CSV field terminates that field. Python's csv module therefore truncated _post_playlist mid-value on the six programs containing that exercise — 476 (Restart Coloană Lombară), 1782, 1783, 1787, 1788, 1789 — and shifted the rest of each row into garbage. They looked like they needed a fresh WordPress export; they did not. The data was always in the file: 8 occurrences of the sequence, one repair in read_rows(), and all 53 programs parse. 476 recovers whole — 600 items, 170 exercises, 8 stages, 40 sessions.
  • The linkage is _post_playlist, not _post_exercises. The latter points at sessions (its ids sit below 506, where the exercise id space starts) and is empty on almost every program. _post_playlist carries exerciseId plus the phase/week/day/order structure. Where it parses, the match rate against legacy_exercises.csv is 1,818 / 1,818.
  • 7 duplicate video ids — two WP rows sharing one file. One video id is one folder is one slug, so the script collapses them onto the lowest WP id rather than letting a twin silently overwrite.
  • 2 ids are not slug-safe: #Aniv413 and workshop-noi-orizonturi. Handle by hand.
  • 6 rows are not videos at all — 4 audio (meditations, carrying _exercise_audio_id) and 2 file (eBooks). Row 1042 has neither a title nor a video id.
  • 550 rows have no _exercise_type — 109.5 h, averaging ~12 min, sampling as recorded live sessions and webinars rather than exercises. They are roughly a third of the bytes and should not become static exercise rows. Their destination is the still-undesigned educational-video content type; leave them in the backup bucket until that exists.

Current state (2026-09-22) ​

The import is done for everything the pipeline can currently take.

Backup bucket2,252 objects, 289.3 GiB, $7.09/month. Every CSV-referenced video present; 275 orphans.
Production22 programs, 7 standalone sessions, 702 sessions, 9,279 exercise rows, 1,049 exercises — all published, all ownership_kind='org', zero drafts, zero renders outstanding.
Imported from legacy1,028 distinct legacy videos, reachable from 23 staged programs + 7 staged sessions. The gap to 1,049 is the 16 exercises recovered from the pre-wipe backup plus a handful of non-legacy rows.
Not yet imported957 videos, 279 h — 766 typed video, 191 untyped.

Everything on production is org-owned. Promotion to platform tier is a deliberate later step: promote-program-to-platform.sql is written and tested, and runs per program slug.

What the 957 look like ​

source lengthcounthours
< 5 min31610.8
5–15 min26344.7
15–30 min19773.4
30–60 min14090.1
> 60 min4159.9

181 of them — 150 hours, over half the remaining bytes — are longer than 30 minutes, which is where the current ingest path stops working (see below). The largest single block is Prenatal; the rest is the Provocarea de 5 Zile series and the Seniori family.

What is left ​

Three workstreams. None is started.

Items 1 and 2 are the same defect in two services. Both are long batch work behind a single synchronous HTTP response, bounded by a write deadline that has nothing to do with how long the work takes: the sync sweep is api → Console, the static ingest is media → its caller. In both cases the handler runs to completion and the caller records a failure — and in both cases the band-aid has already been tried once. Media's HTTP_WRITE_TIMEOUT was raised from 600 s to 1800 s on 2026-09-21 for exactly this reason, and item 2 is that same wall arriving again 20 minutes later.

Where they diverge is duration, and that changes the fix. A 2–3 minute sync can stay tied to its request — if the task is replaced mid-sweep, the operation is idempotent and you re-run it. A 60-minute encode cannot: losing it to a deploy or a scale-in costs an hour of Fargate and leaves an orphaned Bunny upload. So item 1 wants streaming (keep the response alive, report honestly) while item 2 wants durability (get the work off the request entirely) — which is what the composed render kinds already do, and which static ingest is the lone kind to have skipped.

Item 2 also has a half that is not about transport at all: whether the encode needs to happen. Item 3 is genuinely independent of both.

Doing item 1 first is still reasonable — it is smaller, and the deadline-clearing technique it establishes is what item 2's interim option reuses.

1. The sync button lies about its own outcome ​

Console → Sync Exercises reports failure on every run, and has never once actually failed. Measured 2026-09-22 across eight production runs: the handler takes 92–194 s and logs status: 200 every time. The rows commit. The client never learns.

Two independent client-side ceilings throw the response away:

ceilingvaluewhere
http.Server.WriteTimeout30 sHTTP_WRITE_TIMEOUT default in config.go — overridden for media (1800s) but never for api
ALB idle timeout60 sidle_timeout = 60, compute.tf:475

The Console side then compounds it: the server action catches the fetch error, returns "Failed to sync exercises", and — because the failure path returns early — refresh() never runs, so the list page stays stale as well. The admin sees a failure over a stale page describing work that succeeded.

Do not fix this by raising a timeout. There are two ceilings, not one, and the runtime grows with the catalog: ~120 s at 1,049 slugs, because phases 2 and 3 issue three Bunny LIST calls per slug. Whatever number is picked is wrong at the next size.

The fix is to stream the response. sse.NewWriter already does both halves of this and is used elsewhere: it clears the per-write deadline with http.NewResponseController(rw).SetWriteDeadline(time.Time{}), which disables HTTP_WRITE_TIMEOUT for that one response, and it sets X-Accel-Buffering: no so bytes leave promptly — and bytes flowing is exactly what stops the ALB's idle timer. Emit one event per phase (and per N slugs within a phase), then a final result event carrying the counters the response body carries today. The house rule is already SSE over polling.

The Console page becomes a client component holding an EventSource, which also gives live progress on an operation that currently shows a spinner for two to three minutes.

If a durable record of each run is wanted instead — "when did sync last run, what did it do" is not answerable today from anything but log lines — the alternative is a cmd/exercises-sync binary run through the same one-off Fargate wrapper as exercises-bulk-publish.sh, with a run row. Bigger, and it gives up the button. Streaming first is the recommendation; the run row is a separate question about observability.

2. Long videos do not survive the static-ingest path ​

The remaining catalog is mostly long — webinars, Q&A sessions, full recorded classes — and the ingest path is a synchronous HTTP request that covers S3 download + full re-encode + Bunny upload + two TV baseline rungs.

Measured encode rate, from 827 paired production ingests over 2026-09-21/22 (i.e. after the -t fix; pre-fix numbers are meaningless because the encode never terminated):

clips > 5 min
median0.46× realtime
p900.75× realtime
worst observed1.13× realtime

Longest actually completed: a 36.5-minute source in 10.5 min wall, and a 34.3-minute source in 17.7 min.

The budget is 30 minutes, and it is set in two places that must agree — media's HTTP_WRITE_TIMEOUT = 1800s and exercises-bulk-publish --media-timeout, whose default is also 30 m. Whichever is lower decides, and if it is the server one the work is thrown away rather than failed.

At p90 that budget covers a 40-minute source. At the worst rate observed, a 26-minute one. Which gives:

programmevideosmedianmaxover 30 minverdict today
RestartiX Aniversar 4 Ani2114m4526m0imports as-is
RestartiX Seniori (V1)6017m5742m2mostly fine, 2 will fail
RestartiX Neck System1720m0036m7coin flip
RestartiX Seniori Vol. 33029m2748m11coin flip
Seniori — Ziua Mondială1034m3147m7mostly fails
RestartiX Seniori Vol. 22039m5756m15mostly fails

Across those six: 148 distinct videos, 51 h, 35 (23 %) over 30 minutes. Across the whole un-imported remainder: 181 over 30 minutes.

So the answer to "will Seniori work" is no, not as-is — Aniversar will, Seniori Vol. 2 largely will not, and the rest are a coin flip that produces exactly the failure mode already seen with Neck Academy: media completes the work, the caller has hung up, and an orphaned Bunny upload is left that nothing points at.

Three ways forward, in increasing order of correctness:

  1. Raise both budgets to ~90 minutes. One Terraform value and one flag default. Covers every remaining video at the worst observed rate. It is the same shape of defect as item 1 — a batch job wearing an HTTP request — so it buys time rather than fixing anything, but it is honest about that and it unblocks the import this week.
  2. Skip the re-encode when the source is already conformant. Ingest normalises unconditionally to 720p / 24 fps / H.264 / AAC at CRF 20. A source that already matches needs a remux, not a transcode, which would turn a 40-minute encode into a copy. Worth measuring first: ffprobe a sample of the long legacy sources and count how many are already conformant. If most are, this is both the cheapest and the best fix, and it also stops re-compressing already-compressed video.
  3. Make static ingest asynchronous, like the composed kinds already are — EnqueueRender writing a pending row that the dispatcher walks. This is the structurally right answer and it also fixes the other standing complaint about static: a static publish is invisible on the render-pipeline page precisely because it never creates an in-flight row.

Ephemeral disk is not a constraint — media has 21 GiB and the largest legacy sources are around 1 GB.

Plan: import the Seniori family ​

Written 2026-09-22. Corrected the same day — the first draft made the fix a change to the transcode. That was wrong, and the reason it was wrong is worth keeping.

The fix is async. It is not a transcode change. — BUILT 2026-09-22 ​

Seniori fails because a 56-minute video takes ~42 minutes at p90 and the budget is 30. Move the work off the HTTP request and the budget stops existing — 42 minutes is simply fine. Nothing about the encode needs to change, and nothing that was tested against real devices gets touched.

Built, not deployed

The static lane exists in the repo. It has not been deployed, and no Seniori programme has been imported yet. What shipped is below; what it changed at the edges is in Operational changes further down.

The dispatcher already says this out loud:

go
// Static renders are ingested directly to ready, never enqueued
// — they never reach the dispatcher.

And _instructions already made this exact journey. bakeInstructions is documented as "the durable counterpart to the old fire-and-forget goroutine: a failure reschedules / dead-letters with backoff … so instructions survive an api restart / Fargate Spot reclaim and retry." A bakeStatic lane is a direct sibling of it, in a RunOnce that already partitions claimed rows into lanes by recipe_hash.

What it took:

  1. Enqueue instead of ingest. Publishing a static exercise writes a pending row at recipe_hash = '_static' (UpsertPendingStaticRender, sharing a body with the instructions one) rather than calling media.IngestStatic. Publish flips to publishing and returns 202, the same as a composed kind; only an already-ready render at the current asset_version still completes inline at 200.
  2. A bakeStatic lane in the dispatcher, modelled on bakeInstructions: claim → call media → persist to ready → reschedule or dead-letter on failure, then emit render_ready so CompletePublishOnRenderReady pins the preview and flips the exercise to published.
  3. exercises-bulk-publish stops waiting. It enqueues and reports encoding=N separately from published=N, so the summary never claims something is live while it is still in ffmpeg.

Three things the plan did not anticipate, each found while building it and each a way the naive version would have been worse than what it replaced:

  • The lease was sized for a compose bake. RENDER_DISPATCHER_LEASE_TIMEOUT is 40m. A static ingest can run longer than that, so the row would become re-claimable while the first worker was still encoding and a second worker would start the same hours of ffmpeg. _static now claims through its own call with its own lease, derived from media.StaticIngestBudget so the two cannot drift apart, and NewDispatcher panics rather than run with a lease that is too short.
  • BatchLimit is 4 and production runs 2 API replicas, so the obvious implementation would have put 8 simultaneous full re-encodes against a media route that is deliberately ungated. The static lane claims at most RENDER_DISPATCHER_STATIC_BATCH_LIMIT (default 1) per instance.
  • The media client would have aborted every long ingest. IngestStatic used the client with the fixed MEDIA_SERVICE_TIMEOUT (5m in production). It now uses the deadline-free client with an explicit 3h budget — the same pattern ComposeBatch already used for the same reason.

Three things fall out for free:

  • Static publishes become visible on the render-pipeline page. They are invisible today precisely because there is no in-flight row — the page renders exercise_renders, and static never creates one until it is already finished.
  • The work survives a deploy or a Spot reclaim. Today an hour of encode dies with the request.
  • No orphaned Bunny uploads. The failure mode where media completes and the caller has hung up cannot happen when there is no caller waiting.

This is not import-only — the import is its least important consumer ​

Console publish of a static exercise is broken today, and has been all along. The api runs at the default HTTP_WRITE_TIMEOUT = 30s; a static publish takes minutes. MEDIA_SERVICE_TIMEOUT is 5m, so the api will happily wait for media while its own response died four and a half minutes earlier. At the measured 0.46× realtime, 30 s of budget buys about 65 seconds of video — so anything longer than roughly a one-minute clip fails from the UI, with the rows committing anyway. It has stayed invisible because all 1,049 static exercises were published through exercises-bulk-publish, never through the Console.

A one-hour educational video does not fit either. The still-unbuilt educational-video type is specified to reuse this exact pipeline. At the measured rates a 60-minute podcast costs 28 min at median, 45 min at p90, 68 min at worst — over the current budget at median. Building that type on a synchronous ingest means meeting this same wall a third time, on content being created fresh rather than imported once.

So the ranking is the other way round from how it looks: the import is the one consumer that a timeout bump can carry, because it is a supervised one-off. The Console and the educational type are the ones that need the work off the request permanently.

Operational changes that come with it ​

media HTTP_WRITE_TIMEOUT: 1800s → 12600s (3.5h). This is not the band-aid the interim plan described — it is the third number in an ordering that has to hold, and it is the only one of the three that lives in another service's infrastructure rather than in code:

media.StaticIngestBudget (3h)  <  dispatcher StaticLeaseTimeout (4h)
media.StaticIngestBudget (3h)  <  media HTTP_WRITE_TIMEOUT (3.5h)

The first is now structural — the lease is derived from the budget. The second is prose here and a comment in compute.tf, because Terraform cannot read a Go constant. If media's value ever drops below the budget, media's http.Server closes the connection while its handler carries on to completion, and the dispatcher records a failure for work that succeeded — the exact 2026-09-22 failure, just with a bigger number on it.

It is allowed to be this large only because nothing is waiting on the other end any more. Before this change no value was correct, because the deadlines in front of it (the api's 30s write timeout, the ALB's 60s idle) were smaller than the work regardless.

--media-timeout now means something else. It covers the thumbnail calls only, so its default drops 30m → 10m. The ingest is unaffected by it.

The bulk publisher no longer waits. It exits in seconds. Media must stay awake — and production-apply.sh must stay unrun — until the queue drains, which is now long after the command prints done. Watch the render-pipeline page, which for the first time shows static renders in flight: they were invisible there precisely because the old path never created a pending row.

Import order ​

Unchanged, and it does not depend on which of the two above is done first. Climb the risk ladder, measuring at each step:

#programmeshapenmax
1Aniversar 4 Anistandalone session2126m
2Seniori V1program, 4 stages × 5 sessions6042m
3Neck Systemprogram, 1 stage × 5 sessions1736m
4Seniori V3program, 4 stages × 5 sessions3048m
5Seniori ZMFstandalone session1047m
6Seniori V2program, 4 stages × 5 sessions2056m

Structure needs no decisions: every one is a single phase, so there are no reassessment checkpoints, and ZMF + Aniversar are flat single buckets and are therefore standalone sessions, not programs. Org-owned, like everything else. Count rows after every step — sync reports failure on success.


DECIDED 2026-09-22: the pipeline stays as it is ​

The owner's call, after the analysis below was measured out: do not change the encode pipeline for static. Fix concurrency and move on.

Do not re-open this without a new reason. The reasoning is recorded so the next session does not re-derive it and arrive back here:

  • Pass 1 is measurably wasted — 371 of 374 probed sources already match the master invariants exactly, so the transcode changes only the frame rate, at 55% of the ingest cost. Skipping it needs -r 24 pinned on the TV rungs first (so they stop inheriting the master's rate) plus re-validation on real TV hardware. That validation is the cost, and it is not worth paying against a one-off import.
  • The instructions enqueue produces nothing for static — 1,093 static exercises, zero instructions renders ever; all 16 successes are composed. It costs a media round-trip and a row delete each, which is noise on the render-pipeline page rather than time.
  • The TV rungs are 45% of the cost and are only needed if long-form static content is ever played through the TV companion. That is a product question, still unanswered, and it is the one worth asking before the 279h of remaining source goes through.

What WAS done instead: static moved onto the render dispatcher, the static lane got its own concurrency and lease, and the media client stopped pinning every ingest to one task. Those are throughput fixes; none of them touches a byte of encoder output.

Separately: the ingest transcode is doing almost nothing ​

This is a real finding and it is not part of the plan above. It is recorded here so it is not lost, and kept out of the import because it changes bytes that were tested against physical devices.

Probed all 148 Seniori-family sources directly from the backup bucket:

h264 + yuv420p148 / 148
already exactly 1280×720145 / 148
AAC-LC, 48 kHz, stereo148 / 148
audio span == video span (within 0.5 s)148 / 148
frame rate25 fps (130) or 29.97 (17) — never the 24 target

So scale, pad, setsar, aresample, aformat and apad are all no-ops on this content, and the transcode's only real effects are the frame rate and re-compressing an already-compressed master at CRF 20. Of the 181 un-imported videos over 30 minutes, 178 look the same way.

It is roughly half the wall clock. Split from production logs, clips > 5 min:

phasemedianp90
S3 download + ingest transcode + verify~0.25×~0.40×
Bunny upload + 2 TV baseline rungs0.21×0.35×

Why it is not a free win ​

The TV rungs bake from the master, and inherit from it.bakeBaseline sets no frame rate, so a rung runs at whatever the master has, and it does -c:a copy under the comment "master is already AAC-LC 48k stereo 128k". Copying the source through instead of transcoding would hand the baseline bake 25 or 29.97 fps instead of 24, and the source's audio bitrate instead of 128k.

Level 3.1 permits 720p30, so it is very likely fine — but the TV rungs were validated on actual devices, and "very likely fine" is not the standard that validation bought. Frame rate is also not obviously inert further downstream: targetFPS's own comment says it exists so "the concat stays stream-uniform", which is a compose concern a static video does not have, but the rungs are a second consumer that comment does not speak for.

What it would take to actually land it ​

Not "add a fast path" — that is the easy half:

  1. Gate on the master invariants (h264 / yuv420p / exactly 1280×720 / AAC-LC 48 k stereo / audio span == video span), fall through to today's encode otherwise, and fall back to the transcode when verifyMaster fails rather than failing the request. Frame rate deliberately not in the gate — but see below.
  2. Either pin the rungs' frame rate explicitly (-r 24 in bakeBaseline, which keeps the device-tested output identical and makes the master's rate genuinely inert) or re-test the rungs on the same TVs at 25 and 29.97 fps. The first is cheap and is probably the right answer; it also removes an inherited-from-upstream dependency that nobody chose.
  3. Decide what happens to the audio bitrate the rung copies.

The prize is halving encode cost and stopping a generational re-compression of patient-facing video. The price is re-validating a device path. Worth doing on its own merits, on its own schedule — not under an import deadline.

3. Educational videos, and what lx-{video_id} means for them ​

See features/educational-videos for the design, which is still open. What the import settled, and what it did not:

The slug is a source key, not a classification. lx-{video_id} names the legacy video, deliberately (see Why the slug is lx-{video_id}) — so it says nothing about whether that video is an exercise or a lecture, and the same legacy video can honestly be both. RestartiX Neck Academy was imported exactly that way on purpose: it interleaves exercise clips with Q&A recordings, and it went in whole as static exercises.

That makes the open design question sharper than the spec states it. Giving educational_videos its own table and its own lx- slug collides with nothing — different table, and a different Bunny prefix (platform/educational/{slug}/ vs platform/exercises/{slug}/). But if the media artefact keys on the consumer rather than on the source, a video that is both gets encoded twice, stored twice on Bunny Stream, and served as two unrelated videos with separate watch statistics.

The fork to settle before building: does an educational video get its own ingest, or may it point at the content_files / exercise_renders row an exercise already produced for the same source? The spec's sketch assumes the former. The import argues for the latter, because every one of these arrives keyed by source video id in the first place.

What does not need settling yet: content already imported stays an exercise. The educational type only has to cover the 957 videos that are not exercises today, and for those the question never arises — there is no second copy to reconcile. That is the cheap path, and it is available whichever way the fork goes.

One correction to carry forward: the old claim in this document that the untyped rows are "~12 min webinars" was wrong as a guide — it is a mean dragged by a long tail. Checked during the import, the untyped rows inside the Restart Coloană programmes are a median of 2m36 and all under 8 minutes, which is why they imported as exercises without trouble. Type is not a reliable proxy for length; measure per selection.

Still open, unchanged by the import ​

  • Revoke the uploader key. legacy-media-uploader's access key is still Active on a Windows desktop and the upload finished months ago. See Revoking it afterwards. This is the only item here with a security clock on it.
  • Do not tear down the backup bucket. Ingest normalises every source to 720p / 24 fps at CRF 20; once the originals are gone there is no re-deriving at different settings — and item 2 above may well decide to stop normalising. $7/month is the cheapest insurance in this project.
  • Staging has nothing. S3 bundles only (430 objects), no DB rows. It owes a rebuild for unrelated reasons; when it gets one, the catalog replays from the generated SQL with no new decisions.
  • Summit 2024 (5209) has no exercise playlist. Its _post_playlist parses to type: null with zero items, so the 13 summit2024 files in the backup bucket have no linkage. A gap in the export, not a parse failure.
  • 275 orphan files — 174 provider-id-shaped, 61 numeric, 25 named (nutritional-1…12), 13 summit2024. They match no row in any export, which says the export did not cover every post type. Listed in .platform/legacy-orphans.txt.
  • Session covers. Every legacy program and session carries a live Image URL, but no session cover column exists and no session card renders one. The home question (catalog_entries.cover_url vs sessions.cover_url) is open.
  • Exercise names and stage numbers disagree. Framing clips read Umăr Sănătos — Intro 1.1.1 (legacy phase.week.day) while the program says Etapa 1 / Sesiunea 1. Deliberately deferred; one regenerate fixes it.
  • Two standalone session names still lack diacritics — 5 exercitii and 5 miscări. Exercise titles were swept; session names were not.
  • FFMPEG_THREADS is unset in services/media/.env.local, so local bakes run single-threaded. Production is fine (4).

Teardown ​

The backup bucket is scaffolding. Once every wanted file has been staged into production and verified, and .platform/legacy-mapping.csv is backed up somewhere outside this machine:

bash
aws s3 rm s3://restartix-legacy-import --recursive
aws s3api delete-bucket --bucket restartix-legacy-import --region eu-central-1

No versioning means no delete markers and no lingering noncurrent versions — the bucket empties and goes.

Keep legacy-mapping.csv — and keep the legacy_*.csv exports it was built from. .platform/ is gitignored (large media lives outside the repo), so none of them are in git: they exist on one machine until someone copies them elsewhere. They are the only record of which S3 object corresponds to which legacy exercise and title, and they are what the eventual DB import reads. Losing them after the backup bucket is deleted means 1,430 videos with no titles and no program structure.

What this does not do ​

Uploading is not importing. The ingest (POST /v1/exercises/static) transcodes every file through libx264 at CRF 20 — 328 hours of video on Fargate for the whole catalog, of which roughly 49 h has been done.

Production media runs at desired_count = 0 and CPU target-tracking cannot scale it off zero, so every bake run starts with production-media-toggle.sh up and ends with down. Two traps that both bit during the 2026-09 import: a production-apply.sh run mid-import resets the autoscaling floor to 0 and can scale the bake away underneath you, and the publisher must run inside the VPC because media.production.local:9400 has no public hostname — that is what exercises-bulk-publish.sh exists for.