FEAT: Added MOre Video Statuses
Build, Push and Deploy CMS / build-push-deploy (push) Failing after 2m2s
Build, Push and Deploy CMS / build-push-deploy (push) Failing after 2m2s
This commit is contained in:
@@ -90,6 +90,13 @@ nothing is fetched from a CDN at runtime.
|
|||||||
|
|
||||||
Handlers live in `cms/internal/handlers` — `handlers.go` holds `Health` and
|
Handlers live in `cms/internal/handlers` — `handlers.go` holds `Health` and
|
||||||
the shared response writers, `videos.go` the categories and upload endpoints.
|
the shared response writers, `videos.go` the categories and upload endpoints.
|
||||||
|
The request/response bodies they serve are *not* in that package: every wire
|
||||||
|
struct lives in `cms/internal/api` (`api.go` for `HealthResponse` and
|
||||||
|
`ProblemDetails`, `videos.go` for the rest), exported and referenced by the
|
||||||
|
handlers' swaggo annotations as `api.VideoResponse` and so on — so the
|
||||||
|
generated spec's definition names track that package, and renaming a type
|
||||||
|
there changes the published schema names.
|
||||||
|
|
||||||
There is no view layer: the `internal/views` templ package, the `static/`
|
There is no view layer: the `internal/views` templ package, the `static/`
|
||||||
directory, and the htmx frontend were all removed when the service became a
|
directory, and the htmx frontend were all removed when the service became a
|
||||||
JSON API, along with the `templ` dependency.
|
JSON API, along with the `templ` dependency.
|
||||||
@@ -111,6 +118,12 @@ it ever matters.
|
|||||||
(free-text, comma-separated — not normalized), `file_name`, `storage_key`
|
(free-text, comma-separated — not normalized), `file_name`, `storage_key`
|
||||||
(the S3 key, unique), `mediaconvert_job_id`, `status` (written once as
|
(the S3 key, unique), `mediaconvert_job_id`, `status` (written once as
|
||||||
`"processing"` on insert — see Known gaps), `size_bytes`, timestamps.
|
`"processing"` on insert — see Known gaps), `size_bytes`, timestamps.
|
||||||
|
`status` is an enum on both sides: the `services.VideoStatus` string type
|
||||||
|
in `cms/internal/services/status.go` (`processing`, `ready`, `failed`) and
|
||||||
|
the `videos_status_check` CHECK constraint added by migration `0003`.
|
||||||
|
Extending it means adding a constant there, widening the constraint in a
|
||||||
|
new migration, and extending the `enums` annotation on
|
||||||
|
`api.VideoResponse.Status` before regenerating the spec.
|
||||||
`id` is a `UUID` generated application-side as a UUIDv7
|
`id` is a `UUID` generated application-side as a UUIDv7
|
||||||
(`uuid.NewV7()` in `services.DBConcrete.CreateVideo`) rather than via the
|
(`uuid.NewV7()` in `services.DBConcrete.CreateVideo`) rather than via the
|
||||||
column's own `DEFAULT gen_random_uuid()` (which generates v4 and is never
|
column's own `DEFAULT gen_random_uuid()` (which generates v4 and is never
|
||||||
@@ -219,8 +232,9 @@ on boot if any required var is empty.
|
|||||||
and configures no custom MediaConvert endpoint — relies on the SDK's
|
and configures no custom MediaConvert endpoint — relies on the SDK's
|
||||||
default regional endpoint.
|
default regional endpoint.
|
||||||
- No MediaConvert completion webhook or poller exists — a `videos.status`
|
- No MediaConvert completion webhook or poller exists — a `videos.status`
|
||||||
row is written once as `"processing"` in `CreateVideo` and never updated,
|
row is written once as `VideoStatusProcessing` in `CreateVideo` and never
|
||||||
even after the transcode job actually finishes or fails.
|
updated, even after the transcode job actually finishes or fails. The
|
||||||
|
`ready` and `failed` states are defined but nothing sets them yet.
|
||||||
- `discovery` has infra provisioned (ECR repo, ECS service, ALB, Postgres
|
- `discovery` has infra provisioned (ECR repo, ECS service, ALB, Postgres
|
||||||
DB/role) but no application code — it doesn't touch its database at all.
|
DB/role) but no application code — it doesn't touch its database at all.
|
||||||
- No automated tests exist for `cms`, `discovery`, or `infrastructure`.
|
- No automated tests exist for `cms`, `discovery`, or `infrastructure`.
|
||||||
|
|||||||
+26
-21
@@ -29,13 +29,13 @@ const docTemplate = `{
|
|||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.categoriesResponse"
|
"$ref": "#/definitions/api.CategoriesResponse"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Categories could not be read from the database",
|
"description": "Categories could not be read from the database",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.problemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@ const docTemplate = `{
|
|||||||
},
|
},
|
||||||
"/api/videos": {
|
"/api/videos": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Step 2 of the upload flow. Takes the storage key returned by POST /api/videos/presign — after the file has been PUT to the presigned URL — submits a MediaConvert transcoding job, and persists the video with its category links. The returned record has status \"processing\"; note that nothing currently updates that status once transcoding finishes.",
|
"description": "Step 2 of the upload flow. Takes the storage key returned by POST /api/videos/presign — after the file has been PUT to the presigned URL — submits a MediaConvert transcoding job, and persists the video with its category links. The returned record has status \"processing\" — one of the states \"processing\", \"ready\" or \"failed\" — though nothing currently moves it on once transcoding finishes.",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -61,7 +61,7 @@ const docTemplate = `{
|
|||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.completeRequest"
|
"$ref": "#/definitions/api.CompleteRequest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -69,25 +69,25 @@ const docTemplate = `{
|
|||||||
"201": {
|
"201": {
|
||||||
"description": "Created",
|
"description": "Created",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.videoResponse"
|
"$ref": "#/definitions/api.VideoResponse"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "Request body was not valid JSON",
|
"description": "Request body was not valid JSON",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.problemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"422": {
|
"422": {
|
||||||
"description": "title was empty, or key was missing or not a key issued by this API",
|
"description": "title was empty, or key was missing or not a key issued by this API",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.problemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Transcoding job could not be queued, or the video record could not be saved",
|
"description": "Transcoding job could not be queued, or the video record could not be saved",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.problemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,7 +113,7 @@ const docTemplate = `{
|
|||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.presignRequest"
|
"$ref": "#/definitions/api.PresignRequest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -121,25 +121,25 @@ const docTemplate = `{
|
|||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.presignResponse"
|
"$ref": "#/definitions/api.PresignResponse"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "Request body was not valid JSON",
|
"description": "Request body was not valid JSON",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.problemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"422": {
|
"422": {
|
||||||
"description": "contentType was not 'video/mp4' or 'video/quicktime'",
|
"description": "contentType was not 'video/mp4' or 'video/quicktime'",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.problemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Upload URL could not be issued",
|
"description": "Upload URL could not be issued",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.problemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,7 @@ const docTemplate = `{
|
|||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.healthResponse"
|
"$ref": "#/definitions/api.HealthResponse"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,7 +167,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"handlers.categoriesResponse": {
|
"api.CategoriesResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -182,7 +182,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"handlers.completeRequest": {
|
"api.CompleteRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -217,7 +217,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"handlers.healthResponse": {
|
"api.HealthResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"status": {
|
"status": {
|
||||||
@@ -226,7 +226,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"handlers.presignRequest": {
|
"api.PresignRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"contentType": {
|
"contentType": {
|
||||||
@@ -243,7 +243,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"handlers.presignResponse": {
|
"api.PresignResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"key": {
|
"key": {
|
||||||
@@ -256,7 +256,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"handlers.problemDetails": {
|
"api.ProblemDetails": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"detail": {
|
"detail": {
|
||||||
@@ -277,7 +277,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"handlers.videoResponse": {
|
"api.VideoResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -315,6 +315,11 @@ const docTemplate = `{
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"processing",
|
||||||
|
"ready",
|
||||||
|
"failed"
|
||||||
|
],
|
||||||
"example": "processing"
|
"example": "processing"
|
||||||
},
|
},
|
||||||
"storageKey": {
|
"storageKey": {
|
||||||
|
|||||||
+26
-21
@@ -22,13 +22,13 @@
|
|||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.categoriesResponse"
|
"$ref": "#/definitions/api.CategoriesResponse"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Categories could not be read from the database",
|
"description": "Categories could not be read from the database",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.problemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
},
|
},
|
||||||
"/api/videos": {
|
"/api/videos": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Step 2 of the upload flow. Takes the storage key returned by POST /api/videos/presign — after the file has been PUT to the presigned URL — submits a MediaConvert transcoding job, and persists the video with its category links. The returned record has status \"processing\"; note that nothing currently updates that status once transcoding finishes.",
|
"description": "Step 2 of the upload flow. Takes the storage key returned by POST /api/videos/presign — after the file has been PUT to the presigned URL — submits a MediaConvert transcoding job, and persists the video with its category links. The returned record has status \"processing\" — one of the states \"processing\", \"ready\" or \"failed\" — though nothing currently moves it on once transcoding finishes.",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.completeRequest"
|
"$ref": "#/definitions/api.CompleteRequest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -62,25 +62,25 @@
|
|||||||
"201": {
|
"201": {
|
||||||
"description": "Created",
|
"description": "Created",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.videoResponse"
|
"$ref": "#/definitions/api.VideoResponse"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "Request body was not valid JSON",
|
"description": "Request body was not valid JSON",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.problemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"422": {
|
"422": {
|
||||||
"description": "title was empty, or key was missing or not a key issued by this API",
|
"description": "title was empty, or key was missing or not a key issued by this API",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.problemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Transcoding job could not be queued, or the video record could not be saved",
|
"description": "Transcoding job could not be queued, or the video record could not be saved",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.problemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.presignRequest"
|
"$ref": "#/definitions/api.PresignRequest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -114,25 +114,25 @@
|
|||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.presignResponse"
|
"$ref": "#/definitions/api.PresignResponse"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "Request body was not valid JSON",
|
"description": "Request body was not valid JSON",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.problemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"422": {
|
"422": {
|
||||||
"description": "contentType was not 'video/mp4' or 'video/quicktime'",
|
"description": "contentType was not 'video/mp4' or 'video/quicktime'",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.problemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Upload URL could not be issued",
|
"description": "Upload URL could not be issued",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.problemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.healthResponse"
|
"$ref": "#/definitions/api.HealthResponse"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"handlers.categoriesResponse": {
|
"api.CategoriesResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"handlers.completeRequest": {
|
"api.CompleteRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -210,7 +210,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"handlers.healthResponse": {
|
"api.HealthResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"status": {
|
"status": {
|
||||||
@@ -219,7 +219,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"handlers.presignRequest": {
|
"api.PresignRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"contentType": {
|
"contentType": {
|
||||||
@@ -236,7 +236,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"handlers.presignResponse": {
|
"api.PresignResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"key": {
|
"key": {
|
||||||
@@ -249,7 +249,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"handlers.problemDetails": {
|
"api.ProblemDetails": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"detail": {
|
"detail": {
|
||||||
@@ -270,7 +270,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"handlers.videoResponse": {
|
"api.VideoResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -308,6 +308,11 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"processing",
|
||||||
|
"ready",
|
||||||
|
"failed"
|
||||||
|
],
|
||||||
"example": "processing"
|
"example": "processing"
|
||||||
},
|
},
|
||||||
"storageKey": {
|
"storageKey": {
|
||||||
|
|||||||
+27
-22
@@ -1,6 +1,6 @@
|
|||||||
basePath: /
|
basePath: /
|
||||||
definitions:
|
definitions:
|
||||||
handlers.categoriesResponse:
|
api.CategoriesResponse:
|
||||||
properties:
|
properties:
|
||||||
categories:
|
categories:
|
||||||
example:
|
example:
|
||||||
@@ -10,7 +10,7 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
type: array
|
type: array
|
||||||
type: object
|
type: object
|
||||||
handlers.completeRequest:
|
api.CompleteRequest:
|
||||||
properties:
|
properties:
|
||||||
categories:
|
categories:
|
||||||
example:
|
example:
|
||||||
@@ -35,13 +35,13 @@ definitions:
|
|||||||
example: Inside the Newsroom
|
example: Inside the Newsroom
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
handlers.healthResponse:
|
api.HealthResponse:
|
||||||
properties:
|
properties:
|
||||||
status:
|
status:
|
||||||
example: ok
|
example: ok
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
handlers.presignRequest:
|
api.PresignRequest:
|
||||||
properties:
|
properties:
|
||||||
contentType:
|
contentType:
|
||||||
enum:
|
enum:
|
||||||
@@ -53,7 +53,7 @@ definitions:
|
|||||||
example: interview-cut.mov
|
example: interview-cut.mov
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
handlers.presignResponse:
|
api.PresignResponse:
|
||||||
properties:
|
properties:
|
||||||
key:
|
key:
|
||||||
example: videos/a1b2c3d4e5f6.mov
|
example: videos/a1b2c3d4e5f6.mov
|
||||||
@@ -62,7 +62,7 @@ definitions:
|
|||||||
example: https://raw-uploads-bucket.s3.amazonaws.com/videos/a1b2....mov?X-Amz-Signature=...
|
example: https://raw-uploads-bucket.s3.amazonaws.com/videos/a1b2....mov?X-Amz-Signature=...
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
handlers.problemDetails:
|
api.ProblemDetails:
|
||||||
properties:
|
properties:
|
||||||
detail:
|
detail:
|
||||||
example: The 'title' field was absent or contained only whitespace. Every
|
example: The 'title' field was absent or contained only whitespace. Every
|
||||||
@@ -78,7 +78,7 @@ definitions:
|
|||||||
example: about:blank
|
example: about:blank
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
handlers.videoResponse:
|
api.VideoResponse:
|
||||||
properties:
|
properties:
|
||||||
categories:
|
categories:
|
||||||
example:
|
example:
|
||||||
@@ -105,6 +105,10 @@ definitions:
|
|||||||
example: 60
|
example: 60
|
||||||
type: integer
|
type: integer
|
||||||
status:
|
status:
|
||||||
|
enum:
|
||||||
|
- processing
|
||||||
|
- ready
|
||||||
|
- failed
|
||||||
example: processing
|
example: processing
|
||||||
type: string
|
type: string
|
||||||
storageKey:
|
storageKey:
|
||||||
@@ -138,11 +142,11 @@ paths:
|
|||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/handlers.categoriesResponse'
|
$ref: '#/definitions/api.CategoriesResponse'
|
||||||
"500":
|
"500":
|
||||||
description: Categories could not be read from the database
|
description: Categories could not be read from the database
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/handlers.problemDetails'
|
$ref: '#/definitions/api.ProblemDetails'
|
||||||
summary: List video categories
|
summary: List video categories
|
||||||
tags:
|
tags:
|
||||||
- categories
|
- categories
|
||||||
@@ -153,8 +157,9 @@ paths:
|
|||||||
description: Step 2 of the upload flow. Takes the storage key returned by POST
|
description: Step 2 of the upload flow. Takes the storage key returned by POST
|
||||||
/api/videos/presign — after the file has been PUT to the presigned URL — submits
|
/api/videos/presign — after the file has been PUT to the presigned URL — submits
|
||||||
a MediaConvert transcoding job, and persists the video with its category links.
|
a MediaConvert transcoding job, and persists the video with its category links.
|
||||||
The returned record has status "processing"; note that nothing currently updates
|
The returned record has status "processing" — one of the states "processing",
|
||||||
that status once transcoding finishes.
|
"ready" or "failed" — though nothing currently moves it on once transcoding
|
||||||
|
finishes.
|
||||||
parameters:
|
parameters:
|
||||||
- description: Video metadata plus the storage key from the presign step. title
|
- description: Video metadata plus the storage key from the presign step. title
|
||||||
and key are required; categories must be values from GET /api/categories.
|
and key are required; categories must be values from GET /api/categories.
|
||||||
@@ -162,28 +167,28 @@ paths:
|
|||||||
name: request
|
name: request
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/handlers.completeRequest'
|
$ref: '#/definitions/api.CompleteRequest'
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
"201":
|
"201":
|
||||||
description: Created
|
description: Created
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/handlers.videoResponse'
|
$ref: '#/definitions/api.VideoResponse'
|
||||||
"400":
|
"400":
|
||||||
description: Request body was not valid JSON
|
description: Request body was not valid JSON
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/handlers.problemDetails'
|
$ref: '#/definitions/api.ProblemDetails'
|
||||||
"422":
|
"422":
|
||||||
description: title was empty, or key was missing or not a key issued by
|
description: title was empty, or key was missing or not a key issued by
|
||||||
this API
|
this API
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/handlers.problemDetails'
|
$ref: '#/definitions/api.ProblemDetails'
|
||||||
"500":
|
"500":
|
||||||
description: Transcoding job could not be queued, or the video record could
|
description: Transcoding job could not be queued, or the video record could
|
||||||
not be saved
|
not be saved
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/handlers.problemDetails'
|
$ref: '#/definitions/api.ProblemDetails'
|
||||||
summary: Register an uploaded video
|
summary: Register an uploaded video
|
||||||
tags:
|
tags:
|
||||||
- videos
|
- videos
|
||||||
@@ -204,26 +209,26 @@ paths:
|
|||||||
name: request
|
name: request
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/handlers.presignRequest'
|
$ref: '#/definitions/api.PresignRequest'
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/handlers.presignResponse'
|
$ref: '#/definitions/api.PresignResponse'
|
||||||
"400":
|
"400":
|
||||||
description: Request body was not valid JSON
|
description: Request body was not valid JSON
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/handlers.problemDetails'
|
$ref: '#/definitions/api.ProblemDetails'
|
||||||
"422":
|
"422":
|
||||||
description: contentType was not 'video/mp4' or 'video/quicktime'
|
description: contentType was not 'video/mp4' or 'video/quicktime'
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/handlers.problemDetails'
|
$ref: '#/definitions/api.ProblemDetails'
|
||||||
"500":
|
"500":
|
||||||
description: Upload URL could not be issued
|
description: Upload URL could not be issued
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/handlers.problemDetails'
|
$ref: '#/definitions/api.ProblemDetails'
|
||||||
summary: Create a presigned upload URL
|
summary: Create a presigned upload URL
|
||||||
tags:
|
tags:
|
||||||
- videos
|
- videos
|
||||||
@@ -237,7 +242,7 @@ paths:
|
|||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/handlers.healthResponse'
|
$ref: '#/definitions/api.HealthResponse'
|
||||||
summary: Health check
|
summary: Health check
|
||||||
tags:
|
tags:
|
||||||
- system
|
- system
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Package api holds the request and response bodies of the HTTP API — the
|
||||||
|
// wire contract, kept apart from the handlers that serve it. The swaggo
|
||||||
|
// annotations on the handlers reference these types by name (api.VideoResponse
|
||||||
|
// and so on), so renaming one changes the generated spec.
|
||||||
|
package api
|
||||||
|
|
||||||
|
// HealthResponse is the body of GET /health.
|
||||||
|
type HealthResponse struct {
|
||||||
|
Status string `json:"status" example:"ok"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProblemDetails is an error body in the RFC 9457 "Problem Details for HTTP
|
||||||
|
// APIs" format. Type stays "about:blank" — the value RFC 9457 defines for
|
||||||
|
// problems with no dedicated documentation URI. Title is a short summary that
|
||||||
|
// stays identical for every occurrence of the same problem, so clients can
|
||||||
|
// branch on it; Detail explains this particular occurrence and is the only
|
||||||
|
// member that varies with request data.
|
||||||
|
type ProblemDetails struct {
|
||||||
|
Type string `json:"type" example:"about:blank"`
|
||||||
|
Title string `json:"title" example:"Title is required."`
|
||||||
|
Status int `json:"status" example:"422"`
|
||||||
|
Detail string `json:"detail,omitempty" example:"The 'title' field was absent or contained only whitespace. Every video needs a non-empty title."`
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// CategoriesResponse is the body of GET /api/categories.
|
||||||
|
type CategoriesResponse struct {
|
||||||
|
Categories []string `json:"categories" example:"documentary,news"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresignRequest is the body of POST /api/videos/presign.
|
||||||
|
type PresignRequest struct {
|
||||||
|
FileName string `json:"fileName" example:"interview-cut.mov"`
|
||||||
|
ContentType string `json:"contentType" enums:"video/mp4,video/quicktime" example:"video/quicktime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresignResponse is the body returned by POST /api/videos/presign.
|
||||||
|
type PresignResponse struct {
|
||||||
|
UploadURL string `json:"uploadUrl" example:"https://raw-uploads-bucket.s3.amazonaws.com/videos/a1b2....mov?X-Amz-Signature=..."`
|
||||||
|
Key string `json:"key" example:"videos/a1b2c3d4e5f6.mov"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteRequest is the body of POST /api/videos.
|
||||||
|
type CompleteRequest struct {
|
||||||
|
Title string `json:"title" example:"Inside the Newsroom"`
|
||||||
|
Description string `json:"description" example:"A behind-the-scenes look at the evening bulletin."`
|
||||||
|
Categories []string `json:"categories" example:"documentary,news"`
|
||||||
|
Tags string `json:"tags" example:"media, press, riyadh"`
|
||||||
|
FileName string `json:"fileName" example:"interview-cut.mov"`
|
||||||
|
Key string `json:"key" example:"videos/a1b2c3d4e5f6.mov"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VideoResponse is a persisted video record as returned by POST /api/videos.
|
||||||
|
// Status carries the values of services.VideoStatus; the enums tag has to be
|
||||||
|
// widened by hand whenever that type gains a state.
|
||||||
|
type VideoResponse struct {
|
||||||
|
ID string `json:"id" example:"0199f3a1-7c2e-7b21-9f0d-1a2b3c4d5e6f"`
|
||||||
|
Title string `json:"title" example:"Inside the Newsroom"`
|
||||||
|
Description string `json:"description" example:"A behind-the-scenes look at the evening bulletin."`
|
||||||
|
Categories []string `json:"categories" example:"documentary,news"`
|
||||||
|
Tags string `json:"tags" example:"media, press, riyadh"`
|
||||||
|
FileName string `json:"fileName" example:"interview-cut.mov"`
|
||||||
|
StorageKey string `json:"storageKey" example:"videos/a1b2c3d4e5f6.mov"`
|
||||||
|
MediaConvertJobID string `json:"mediaConvertJobId" example:"1755300000000-abcdef"`
|
||||||
|
Status string `json:"status" enums:"processing,ready,failed" example:"processing"`
|
||||||
|
SizeBytes int64 `json:"sizeBytes" example:"60"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
@@ -4,35 +4,19 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"thamanyah/cms/v2/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
type healthResponse struct {
|
|
||||||
Status string `json:"status" example:"ok"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Health is the liveness probe the ALB target group polls.
|
// Health is the liveness probe the ALB target group polls.
|
||||||
//
|
//
|
||||||
// @Summary Health check
|
// @Summary Health check
|
||||||
// @Description Reports that the service is up and serving. Used as the ALB target group health check; it does not verify the database or S3 connections.
|
// @Description Reports that the service is up and serving. Used as the ALB target group health check; it does not verify the database or S3 connections.
|
||||||
// @Tags system
|
// @Tags system
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} healthResponse
|
// @Success 200 {object} api.HealthResponse
|
||||||
// @Router /health [get]
|
// @Router /health [get]
|
||||||
func Health(w http.ResponseWriter, r *http.Request) {
|
func Health(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, healthResponse{Status: "ok"})
|
writeJSON(w, http.StatusOK, api.HealthResponse{Status: "ok"})
|
||||||
}
|
|
||||||
|
|
||||||
// problemDetails is an error body in the RFC 9457 "Problem Details for HTTP
|
|
||||||
// APIs" format. Type stays "about:blank" — the value RFC 9457 defines for
|
|
||||||
// problems with no dedicated documentation URI. Title is a short summary that
|
|
||||||
// stays identical for every occurrence of the same problem, so clients can
|
|
||||||
// branch on it; Detail explains this particular occurrence and is the only
|
|
||||||
// member that varies with request data.
|
|
||||||
type problemDetails struct {
|
|
||||||
Type string `json:"type" example:"about:blank"`
|
|
||||||
Title string `json:"title" example:"Title is required."`
|
|
||||||
Status int `json:"status" example:"422"`
|
|
||||||
Detail string `json:"detail,omitempty" example:"The 'title' field was absent or contained only whitespace. Every video needs a non-empty title."`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||||
@@ -40,7 +24,7 @@ func writeJSON(w http.ResponseWriter, status int, body any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeProblem(w http.ResponseWriter, status int, title, detail string) {
|
func writeProblem(w http.ResponseWriter, status int, title, detail string) {
|
||||||
writeJSONContent(w, "application/problem+json", status, problemDetails{
|
writeJSONContent(w, "application/problem+json", status, api.ProblemDetails{
|
||||||
Type: "about:blank",
|
Type: "about:blank",
|
||||||
Title: title,
|
Title: title,
|
||||||
Status: status,
|
Status: status,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"thamanyah/cms/v2/internal/api"
|
||||||
"thamanyah/cms/v2/internal/services"
|
"thamanyah/cms/v2/internal/services"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -19,18 +20,14 @@ const (
|
|||||||
maxJSONBodySize = 1 << 20 // 1 MiB
|
maxJSONBodySize = 1 << 20 // 1 MiB
|
||||||
)
|
)
|
||||||
|
|
||||||
type categoriesResponse struct {
|
|
||||||
Categories []string `json:"categories" example:"documentary,news"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListCategories returns the categories a video may be assigned to.
|
// ListCategories returns the categories a video may be assigned to.
|
||||||
//
|
//
|
||||||
// @Summary List video categories
|
// @Summary List video categories
|
||||||
// @Description Returns the fixed lookup set of categories a video can belong to. Values from this list are the only ones accepted in the `categories` field of POST /api/videos.
|
// @Description Returns the fixed lookup set of categories a video can belong to. Values from this list are the only ones accepted in the `categories` field of POST /api/videos.
|
||||||
// @Tags categories
|
// @Tags categories
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} categoriesResponse
|
// @Success 200 {object} api.CategoriesResponse
|
||||||
// @Failure 500 {object} problemDetails "Categories could not be read from the database"
|
// @Failure 500 {object} api.ProblemDetails "Categories could not be read from the database"
|
||||||
// @Router /api/categories [get]
|
// @Router /api/categories [get]
|
||||||
func ListCategories(w http.ResponseWriter, r *http.Request) {
|
func ListCategories(w http.ResponseWriter, r *http.Request) {
|
||||||
categories, err := services.DB.ListCategories(r.Context())
|
categories, err := services.DB.ListCategories(r.Context())
|
||||||
@@ -44,7 +41,7 @@ func ListCategories(w http.ResponseWriter, r *http.Request) {
|
|||||||
if categories == nil {
|
if categories == nil {
|
||||||
categories = []string{}
|
categories = []string{}
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, categoriesResponse{Categories: categories})
|
writeJSON(w, http.StatusOK, api.CategoriesResponse{Categories: categories})
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowedUploadContentTypes is the set of media types accepted for upload,
|
// allowedUploadContentTypes is the set of media types accepted for upload,
|
||||||
@@ -57,16 +54,6 @@ var allowedUploadContentTypes = map[string]struct{}{
|
|||||||
"video/quicktime": {}, // .mov
|
"video/quicktime": {}, // .mov
|
||||||
}
|
}
|
||||||
|
|
||||||
type presignRequest struct {
|
|
||||||
FileName string `json:"fileName" example:"interview-cut.mov"`
|
|
||||||
ContentType string `json:"contentType" enums:"video/mp4,video/quicktime" example:"video/quicktime"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type presignResponse struct {
|
|
||||||
UploadURL string `json:"uploadUrl" example:"https://raw-uploads-bucket.s3.amazonaws.com/videos/a1b2....mov?X-Amz-Signature=..."`
|
|
||||||
Key string `json:"key" example:"videos/a1b2c3d4e5f6.mov"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PresignVideoUpload issues a presigned S3 PUT URL for a video upload.
|
// PresignVideoUpload issues a presigned S3 PUT URL for a video upload.
|
||||||
//
|
//
|
||||||
// @Summary Create a presigned upload URL
|
// @Summary Create a presigned upload URL
|
||||||
@@ -74,16 +61,16 @@ type presignResponse struct {
|
|||||||
// @Tags videos
|
// @Tags videos
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param request body presignRequest true "Name and media type of the file to be uploaded. contentType must be exactly 'video/mp4' or 'video/quicktime'."
|
// @Param request body api.PresignRequest true "Name and media type of the file to be uploaded. contentType must be exactly 'video/mp4' or 'video/quicktime'."
|
||||||
// @Success 200 {object} presignResponse
|
// @Success 200 {object} api.PresignResponse
|
||||||
// @Failure 400 {object} problemDetails "Request body was not valid JSON"
|
// @Failure 400 {object} api.ProblemDetails "Request body was not valid JSON"
|
||||||
// @Failure 422 {object} problemDetails "contentType was not 'video/mp4' or 'video/quicktime'"
|
// @Failure 422 {object} api.ProblemDetails "contentType was not 'video/mp4' or 'video/quicktime'"
|
||||||
// @Failure 500 {object} problemDetails "Upload URL could not be issued"
|
// @Failure 500 {object} api.ProblemDetails "Upload URL could not be issued"
|
||||||
// @Router /api/videos/presign [post]
|
// @Router /api/videos/presign [post]
|
||||||
func PresignVideoUpload(w http.ResponseWriter, r *http.Request) {
|
func PresignVideoUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBodySize)
|
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBodySize)
|
||||||
|
|
||||||
var req presignRequest
|
var req api.PresignRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeProblem(w, http.StatusBadRequest, "Invalid request.",
|
writeProblem(w, http.StatusBadRequest, "Invalid request.",
|
||||||
fmt.Sprintf("The request body could not be parsed as JSON (%s). Send an object with the string fields 'fileName' and 'contentType', and keep the body under 1 MiB.", err))
|
fmt.Sprintf("The request body could not be parsed as JSON (%s). Send an object with the string fields 'fileName' and 'contentType', and keep the body under 1 MiB.", err))
|
||||||
@@ -111,50 +98,26 @@ func PresignVideoUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, presignResponse{UploadURL: presignedURL, Key: key})
|
writeJSON(w, http.StatusOK, api.PresignResponse{UploadURL: presignedURL, Key: key})
|
||||||
}
|
|
||||||
|
|
||||||
type completeRequest struct {
|
|
||||||
Title string `json:"title" example:"Inside the Newsroom"`
|
|
||||||
Description string `json:"description" example:"A behind-the-scenes look at the evening bulletin."`
|
|
||||||
Categories []string `json:"categories" example:"documentary,news"`
|
|
||||||
Tags string `json:"tags" example:"media, press, riyadh"`
|
|
||||||
FileName string `json:"fileName" example:"interview-cut.mov"`
|
|
||||||
Key string `json:"key" example:"videos/a1b2c3d4e5f6.mov"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type videoResponse struct {
|
|
||||||
ID string `json:"id" example:"0199f3a1-7c2e-7b21-9f0d-1a2b3c4d5e6f"`
|
|
||||||
Title string `json:"title" example:"Inside the Newsroom"`
|
|
||||||
Description string `json:"description" example:"A behind-the-scenes look at the evening bulletin."`
|
|
||||||
Categories []string `json:"categories" example:"documentary,news"`
|
|
||||||
Tags string `json:"tags" example:"media, press, riyadh"`
|
|
||||||
FileName string `json:"fileName" example:"interview-cut.mov"`
|
|
||||||
StorageKey string `json:"storageKey" example:"videos/a1b2c3d4e5f6.mov"`
|
|
||||||
MediaConvertJobID string `json:"mediaConvertJobId" example:"1755300000000-abcdef"`
|
|
||||||
Status string `json:"status" example:"processing"`
|
|
||||||
SizeBytes int64 `json:"sizeBytes" example:"60"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompleteVideoUpload registers an uploaded video and queues it for transcoding.
|
// CompleteVideoUpload registers an uploaded video and queues it for transcoding.
|
||||||
//
|
//
|
||||||
// @Summary Register an uploaded video
|
// @Summary Register an uploaded video
|
||||||
// @Description Step 2 of the upload flow. Takes the storage key returned by POST /api/videos/presign — after the file has been PUT to the presigned URL — submits a MediaConvert transcoding job, and persists the video with its category links. The returned record has status "processing"; note that nothing currently updates that status once transcoding finishes.
|
// @Description Step 2 of the upload flow. Takes the storage key returned by POST /api/videos/presign — after the file has been PUT to the presigned URL — submits a MediaConvert transcoding job, and persists the video with its category links. The returned record has status "processing" — one of the states "processing", "ready" or "failed" — though nothing currently moves it on once transcoding finishes.
|
||||||
// @Tags videos
|
// @Tags videos
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param request body completeRequest true "Video metadata plus the storage key from the presign step. title and key are required; categories must be values from GET /api/categories."
|
// @Param request body api.CompleteRequest true "Video metadata plus the storage key from the presign step. title and key are required; categories must be values from GET /api/categories."
|
||||||
// @Success 201 {object} videoResponse
|
// @Success 201 {object} api.VideoResponse
|
||||||
// @Failure 400 {object} problemDetails "Request body was not valid JSON"
|
// @Failure 400 {object} api.ProblemDetails "Request body was not valid JSON"
|
||||||
// @Failure 422 {object} problemDetails "title was empty, or key was missing or not a key issued by this API"
|
// @Failure 422 {object} api.ProblemDetails "title was empty, or key was missing or not a key issued by this API"
|
||||||
// @Failure 500 {object} problemDetails "Transcoding job could not be queued, or the video record could not be saved"
|
// @Failure 500 {object} api.ProblemDetails "Transcoding job could not be queued, or the video record could not be saved"
|
||||||
// @Router /api/videos [post]
|
// @Router /api/videos [post]
|
||||||
func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) {
|
func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBodySize)
|
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBodySize)
|
||||||
|
|
||||||
var req completeRequest
|
var req api.CompleteRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeProblem(w, http.StatusBadRequest, "Invalid request.",
|
writeProblem(w, http.StatusBadRequest, "Invalid request.",
|
||||||
fmt.Sprintf("The request body could not be parsed as JSON (%s). Send an object containing at least 'title' and 'key', and keep the body under 1 MiB.", err))
|
fmt.Sprintf("The request body could not be parsed as JSON (%s). Send an object containing at least 'title' and 'key', and keep the body under 1 MiB.", err))
|
||||||
@@ -198,7 +161,7 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
FileName: strings.TrimSpace(req.FileName),
|
FileName: strings.TrimSpace(req.FileName),
|
||||||
StorageKey: key,
|
StorageKey: key,
|
||||||
MediaConvertJobID: jobID,
|
MediaConvertJobID: jobID,
|
||||||
Status: "processing",
|
Status: services.VideoStatusProcessing,
|
||||||
SizeBytes: 60,
|
SizeBytes: 60,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -208,7 +171,7 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, videoResponse{
|
writeJSON(w, http.StatusCreated, api.CompleteResponse{
|
||||||
ID: video.ID,
|
ID: video.ID,
|
||||||
Title: video.Title,
|
Title: video.Title,
|
||||||
Description: video.Description,
|
Description: video.Description,
|
||||||
@@ -217,7 +180,7 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
FileName: video.FileName,
|
FileName: video.FileName,
|
||||||
StorageKey: video.StorageKey,
|
StorageKey: video.StorageKey,
|
||||||
MediaConvertJobID: video.MediaConvertJobID,
|
MediaConvertJobID: video.MediaConvertJobID,
|
||||||
Status: video.Status,
|
Status: video.Status.String(),
|
||||||
SizeBytes: video.SizeBytes,
|
SizeBytes: video.SizeBytes,
|
||||||
CreatedAt: video.CreatedAt,
|
CreatedAt: video.CreatedAt,
|
||||||
UpdatedAt: video.UpdatedAt,
|
UpdatedAt: video.UpdatedAt,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ type Video struct {
|
|||||||
FileName string
|
FileName string
|
||||||
StorageKey string
|
StorageKey string
|
||||||
MediaConvertJobID string
|
MediaConvertJobID string
|
||||||
Status string
|
Status VideoStatus
|
||||||
SizeBytes int64
|
SizeBytes int64
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
@@ -56,7 +56,7 @@ func (svc DBConcrete) CreateVideo(ctx context.Context, v Video) (Video, error) {
|
|||||||
INSERT INTO videos (title, description, tags, file_name, storage_key, mediaconvert_job_id, status, size_bytes)
|
INSERT INTO videos (title, description, tags, file_name, storage_key, mediaconvert_job_id, status, size_bytes)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING id, created_at, updated_at
|
RETURNING id, created_at, updated_at
|
||||||
`, v.Title, v.Description, v.Tags, v.FileName, v.StorageKey, v.MediaConvertJobID, v.Status, v.SizeBytes)
|
`, v.Title, v.Description, v.Tags, v.FileName, v.StorageKey, v.MediaConvertJobID, string(v.Status), v.SizeBytes)
|
||||||
|
|
||||||
if err := row.Scan(&v.ID, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
if err := row.Scan(&v.ID, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
||||||
return Video{}, err
|
return Video{}, err
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
// VideoStatus is the transcoding lifecycle state of a video, stored verbatim
|
||||||
|
// in videos.status. The set is closed on both sides: the database rejects
|
||||||
|
// anything outside it via the videos_status_check constraint. Adding a state
|
||||||
|
// means adding a constant here, widening that constraint in a new migration,
|
||||||
|
// and extending the `enums` annotation on api.VideoResponse.Status.
|
||||||
|
type VideoStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// VideoStatusProcessing is set when the MediaConvert job is queued. Every
|
||||||
|
// video starts here, and nothing moves it on yet — see Known gaps.
|
||||||
|
VideoStatusProcessing VideoStatus = "processing"
|
||||||
|
// VideoStatusReady means transcoding finished and the output is in the
|
||||||
|
// encoded bucket, reachable through CloudFront.
|
||||||
|
VideoStatusReady VideoStatus = "ready"
|
||||||
|
// VideoStatusFailed means the MediaConvert job ended in an error.
|
||||||
|
VideoStatusFailed VideoStatus = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s VideoStatus) String() string {
|
||||||
|
return string(s)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user