From 27fd28d42650fadf028b5b178fe3a1fe0e66698a Mon Sep 17 00:00:00 2001 From: FahdShalhoub Date: Sun, 16 Aug 2026 22:42:17 +0300 Subject: [PATCH] FEAT: Load Catagories from backend --- CLAUDE.md | 76 ++++++++--- cms/internal/handlers/videos.go | 9 +- cms/internal/services/db.go | 25 ++++ cms/internal/views/video.templ | 10 +- cms/internal/views/video_templ.go | 204 ++++++++++++++++++------------ 5 files changed, 218 insertions(+), 106 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f1ebfc6..f3a46bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,8 @@ deployable. ### cms (Go 1.25, module `thamanyah/cms/v2`) ```bash cd cms -go run . # serves on :8081 +go run . # serves on :8081 (requires DB_*/S3_*/MEDIACONVERT_* env vars — see below) +go run . migrate # applies pending DB migrations, then exits (no HTTP server) go build ./... go vet ./... ``` @@ -49,14 +50,25 @@ state and Pulumi state isn't safe to update concurrently with CI. ### cms service Plain `net/http` (Go 1.22+ pattern-based `ServeMux`), no framework. Entry -point `cms/main.go` wires up AWS SDK v2 clients (S3, MediaConvert) from env -vars and assigns them to package-level interface vars in `internal/services` -(`services.S3Client`, `services.MediaConvertClient`) — handlers call through -these interfaces, and `S3Concrete`/`MediaConvertConcrete` are the only -implementations, which is what makes the handlers testable even though no -tests exist yet. On boot, `S3Concrete.AssertSuccessfulConnection` proactively -exercises head/put/get/presign against the bucket and panics on failure, -rather than letting the service come up in a broken state. +point `cms/main.go` wires up AWS SDK v2 clients (S3, MediaConvert) and a +Postgres connection from env vars and assigns them to package-level interface +vars in `internal/services` (`services.S3Client`, `services.MediaConvertClient`, +`services.DB`) — handlers call through these interfaces, and +`S3Concrete`/`MediaConvertConcrete`/`DBConcrete` are the only implementations, +which is what makes the handlers testable even though no tests exist yet. On +boot, `S3Concrete.AssertSuccessfulConnection` proactively exercises +head/put/get/presign against the bucket, and `DBConcrete.AssertSuccessfulConnection` +pings Postgres — both panic on failure rather than letting the service come +up in a broken state. + +`cms/main.go` has two entry paths, dispatched on `os.Args[1]`: the default +path (`runServer`) boots the HTTP server; `./cms migrate` (`runMigrate`) +only opens the DB connection, applies pending migrations via +`cms/internal/db.Migrate` (golang-migrate, `iofs` source, SQL files embedded +from `cms/internal/db/migrations/*.sql`), and exits — it does not touch +S3/MediaConvert or start the server. This is run as its own ECS container +before the main container starts (see infrastructure below), so `runServer` +never runs migrations itself, only `AssertSuccessfulConnection`. Routes (`cms/main.go`): `GET /`, `GET /health`, `GET /videos/new`, `POST /videos/presign`, `POST /videos`, static files under `/static/`. @@ -65,6 +77,23 @@ Views live in `cms/internal/views` (templ components) with a shared `layouts.Layout` wrapper; `types.go` holds view-model structs like `VideoMetadata` used by the upload-success page. +### Data model (Postgres, `cms/internal/db/migrations/`) +- `videos` — one row per uploaded video: `title`, `description`, `tags` + (free-text, comma-separated — not normalized), `file_name`, `storage_key` + (the S3 key, unique), `mediaconvert_job_id`, `status` (written once as + `"processing"` on insert — see Known gaps), `size_bytes`, timestamps. + `id` is a `UUID` generated application-side as a UUIDv7 + (`uuid.NewV7()` in `services.DBConcrete.CreateVideo`) rather than via the + column's own `DEFAULT gen_random_uuid()` (which generates v4 and is never + actually relied on) — v7 keeps primary-key inserts roughly time-ordered, + avoiding B-tree fragmentation as the table grows. +- `categories` — a small fixed lookup table (`documentary`, `news`, + `entertainment`, `podcast`, `other`), seeded by migration `0001`. +- `video_categories` — join table (`video_id`, `category_id`, composite PK, + `ON DELETE CASCADE`) added in migration `0002`: a video can belong to + *multiple* categories, not just one. `CreateVideo` inserts the `videos` row + and its `video_categories` links inside a single transaction. + ### Video upload → transcode pipeline 1. Browser calls `POST /videos/presign` → cms returns a presigned S3 `PUT` URL for `raw-uploads-bucket`, key `videos/.`. @@ -73,13 +102,17 @@ Views live in `cms/internal/views` (templ components) with a shared 3. Browser calls `POST /videos` with the metadata + key → cms calls `MediaConvertClient.QueueEncodingJob(key)`, submitting a MediaConvert job `s3://raw-uploads-bucket/` → `s3://encoded-bucket/` (H.264/AAC → - MP4, QVBR rate control — QVBR requires `MaxBitrate` to be set explicitly). + MP4, QVBR rate control — QVBR requires `MaxBitrate` to be set explicitly), + then `services.DB.CreateVideo` persists the `videos` row (status + `"processing"`) and its `video_categories` links. 4. Finished output lands in `encoded-bucket`, served via CloudFront. Config wiring: cms reads `S3_BUCKET`, `MEDIACONVERT_INPUT_BUCKET`, -`MEDIACONVERT_OUTPUT_BUCKET`, `MEDIACONVERT_ROLE_ARN`, `AWS_REGION` from env -vars injected by the ECS task definition (`extraEnv` in -`deployFargateService`, `infrastructure/main.go`). +`MEDIACONVERT_OUTPUT_BUCKET`, `MEDIACONVERT_ROLE_ARN`, `AWS_REGION`, +`DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` from env vars +injected by the ECS task definition (`extraEnv` and the unconditional DB_* +vars in `deployFargateService`, `infrastructure/main.go`) — `main.go` panics +on boot if any required var is empty. ### infrastructure (`infrastructure/main.go`, single Pulumi Go program, region `us-east-1`, stack `main`) - **Postgres**: one shared RDS instance (`db.t3.micro`, single-AZ, no @@ -91,7 +124,17 @@ vars injected by the ECS task definition (`extraEnv` in ports). `deployFargateService(...)` is the shared helper building a service's ECR repo, CloudWatch log group, task definition, ECS service, and ALB. `taskRole` is optional (nil = no AWS identity beyond the shared - execution role); `extraEnv` appends container env vars beyond the DB_* set. + execution role); `extraEnv` appends container env vars beyond the DB_* set; + `runMigrations` (true for `cms`, false for `discovery`) adds a second, + non-essential `-migrate` container to the task — same image, + `command: ["migrate"]` — with the main container's `dependsOn` set to + `condition: "COMPLETE"` on it. This is ECS's container-dependency + mechanism, the Fargate equivalent of a Kubernetes init container: ECS runs + the migrate container to completion (exit 0) before starting the main + container, so schema migrations always finish before the service accepts + traffic. No separate CI/Docker migration step exists — `Dockerfile`'s + `ENTRYPOINT ["./cms"]` plus the container's `command` override composes to + `./cms migrate`. - **S3 + CloudFront**: `encoded-bucket` holds finished transcoded output, served publicly via CloudFront using Origin Access Control (OAC) — the bucket itself blocks all public access; only CloudFront's OAC principal @@ -144,6 +187,9 @@ vars injected by the ECS task definition (`extraEnv` in - `cms/internal/services/mediaconvert.go` never calls `DescribeEndpoints` and configures no custom MediaConvert endpoint — relies on the SDK's default regional endpoint. +- No MediaConvert completion webhook or poller exists — a `videos.status` + row is written once as `"processing"` in `CreateVideo` and never updated, + even after the transcode job actually finishes or fails. - `discovery` has infra provisioned (ECR repo, ECS service, ALB, Postgres - DB/role) but no application code. + DB/role) but no application code — it doesn't touch its database at all. - No automated tests exist for `cms`, `discovery`, or `infrastructure`. diff --git a/cms/internal/handlers/videos.go b/cms/internal/handlers/videos.go index c12e7ca..f8cecdb 100644 --- a/cms/internal/handlers/videos.go +++ b/cms/internal/handlers/videos.go @@ -21,7 +21,14 @@ const ( ) func NewVideo(w http.ResponseWriter, r *http.Request) { - views.VideoUpload().Render(r.Context(), w) + categories, err := services.DB.ListCategories(r.Context()) + if err != nil { + log.Printf("Something Went Wrong Loading Categories: %s", err) + http.Error(w, "Something Went Wrong Loading Categories", http.StatusInternalServerError) + return + } + + views.VideoUpload(categories).Render(r.Context(), w) } type presignRequest struct { diff --git a/cms/internal/services/db.go b/cms/internal/services/db.go index 7a4845e..93dee34 100644 --- a/cms/internal/services/db.go +++ b/cms/internal/services/db.go @@ -29,6 +29,7 @@ type Video struct { type DBClient interface { CreateVideo(ctx context.Context, v Video) (Video, error) + ListCategories(ctx context.Context) ([]string, error) } type DBConcrete struct { @@ -81,6 +82,30 @@ func (svc DBConcrete) CreateVideo(ctx context.Context, v Video) (Video, error) { return v, nil } +func (svc DBConcrete) ListCategories(ctx context.Context) ([]string, error) { + sqlDB, err := sql.Open("postgres", svc.ConnectionString) + if err != nil { + return nil, err + } + defer sqlDB.Close() + + rows, err := sqlDB.QueryContext(ctx, `SELECT name FROM categories ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var categories []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + categories = append(categories, name) + } + return categories, rows.Err() +} + func (svc DBConcrete) Migrate() { sqlDB, err := sql.Open("postgres", svc.ConnectionString) if err != nil { diff --git a/cms/internal/views/video.templ b/cms/internal/views/video.templ index b595d6e..672a3c9 100644 --- a/cms/internal/views/video.templ +++ b/cms/internal/views/video.templ @@ -2,7 +2,7 @@ package views import "thamanyah/cms/v2/internal/views/layouts" -templ VideoUpload() { +templ VideoUpload(categories []string) { @layouts.Layout("Upload Video · Thamanyah CMS") {

Upload a video

Provide the video file along with its metadata.

@@ -18,11 +18,9 @@ templ VideoUpload() {
diff --git a/cms/internal/views/video_templ.go b/cms/internal/views/video_templ.go index f78c0e6..9fc84e8 100644 --- a/cms/internal/views/video_templ.go +++ b/cms/internal/views/video_templ.go @@ -10,7 +10,7 @@ import templruntime "github.com/a-h/templ/runtime" import "thamanyah/cms/v2/internal/views/layouts" -func VideoUpload() templ.Component { +func VideoUpload(categories []string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -43,7 +43,43 @@ func VideoUpload() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Upload a video

Provide the video file along with its metadata.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Upload a video

Provide the video file along with its metadata.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -73,123 +109,123 @@ func VideoUploadSuccess(v VideoMetadata) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var3 := templ.GetChildren(ctx) - if templ_7745c5c3_Var3 == nil { - templ_7745c5c3_Var3 = templ.NopComponent + templ_7745c5c3_Var5 := templ.GetChildren(ctx) + if templ_7745c5c3_Var5 == nil { + templ_7745c5c3_Var5 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

Upload complete

Title
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(v.Title) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 131, Col: 16} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if v.Description != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
Description
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var5 string - templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(v.Description) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 134, Col: 23} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
Categories
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

Upload complete

Title
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(joinCategories(v.Categories)) + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(v.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 137, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 129, Col: 16} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
Enqueue Job ID
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(v.JobID) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 139, Col: 16} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if v.Tags != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
Tags
") + if v.Description != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
Description
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(v.Tags) + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(v.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 142, Col: 16} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 132, Col: 23} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
File
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
Categories
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(joinCategories(v.Categories)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 135, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
Enqueue Job ID
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(v.FileName) + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(v.JobID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 146, Col: 16} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 137, Col: 16} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " (") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(formatSize(v.SizeBytes)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 146, Col: 45} + if v.Tags != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
Tags
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(v.Tags) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 140, Col: 16} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
File
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ")
") + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(v.FileName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 144, Col: 16} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " (") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(formatSize(v.SizeBytes)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 144, Col: 45} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ")
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -213,25 +249,25 @@ func VideoUploadError(message string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var11 := templ.GetChildren(ctx) - if templ_7745c5c3_Var11 == nil { - templ_7745c5c3_Var11 = templ.NopComponent + templ_7745c5c3_Var13 := templ.GetChildren(ctx) + if templ_7745c5c3_Var13 == nil { + templ_7745c5c3_Var13 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(message) + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(message) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 154, Col: 34} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 152, Col: 34} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }