REFACTOR: Optimized Catagory IDs
Build, Push and Deploy CMS / build-push-deploy (push) Successful in 2m19s
Build, Push and Deploy CMS / build-push-deploy (push) Successful in 2m19s
This commit is contained in:
@@ -130,11 +130,18 @@ it ever matters.
|
|||||||
actually relied on) — v7 keeps primary-key inserts roughly time-ordered,
|
actually relied on) — v7 keeps primary-key inserts roughly time-ordered,
|
||||||
avoiding B-tree fragmentation as the table grows.
|
avoiding B-tree fragmentation as the table grows.
|
||||||
- `categories` — a small fixed lookup table (`documentary`, `news`,
|
- `categories` — a small fixed lookup table (`documentary`, `news`,
|
||||||
`entertainment`, `podcast`, `other`), seeded by migration `0001`.
|
`entertainment`, `podcast`, `other`), seeded by migration `0001`. Its
|
||||||
|
`SMALLSERIAL` ids are part of the public API: `GET /api/categories` returns
|
||||||
|
`{id, name}` pairs and `POST /api/videos` takes `categoryIds`, so the seed
|
||||||
|
order in migration `0001` is what fixes which id means which name — never
|
||||||
|
renumber it.
|
||||||
- `video_categories` — join table (`video_id`, `category_id`, composite PK,
|
- `video_categories` — join table (`video_id`, `category_id`, composite PK,
|
||||||
`ON DELETE CASCADE`) added in migration `0002`: a video can belong to
|
`ON DELETE CASCADE`) added in migration `0002`: a video can belong to
|
||||||
*multiple* categories, not just one. `CreateVideo` inserts the `videos` row
|
*multiple* categories, not just one. `CreateVideo` inserts the `videos` row
|
||||||
and its `video_categories` links inside a single transaction.
|
and its `video_categories` links inside a single transaction, taking the
|
||||||
|
category ids as given — `handlers.resolveCategoryIDs` is what checks them
|
||||||
|
against `ListCategories` (422 on an unknown id) and drops duplicates before
|
||||||
|
the transcode job is queued, leaving the foreign key as a backstop.
|
||||||
|
|
||||||
### Video upload → transcode pipeline
|
### Video upload → transcode pipeline
|
||||||
1. Client calls `POST /api/videos/presign` → cms returns a presigned S3 `PUT`
|
1. Client calls `POST /api/videos/presign` → cms returns a presigned S3 `PUT`
|
||||||
@@ -142,7 +149,8 @@ it ever matters.
|
|||||||
2. Client `PUT`s the file directly to S3 (from a browser this requires the
|
2. Client `PUT`s the file directly to S3 (from a browser this requires the
|
||||||
bucket's CORS rule, set up in `infrastructure/main.go` — see Known gaps,
|
bucket's CORS rule, set up in `infrastructure/main.go` — see Known gaps,
|
||||||
its allowed origin is now stale). The file never passes through cms.
|
its allowed origin is now stale). The file never passes through cms.
|
||||||
3. Client calls `POST /api/videos` with the metadata + key → cms calls
|
3. Client calls `POST /api/videos` with the metadata (categories given as
|
||||||
|
`categoryIds` from `GET /api/categories`) + key → cms calls
|
||||||
`MediaConvertClient.QueueEncodingJob(key)`, submitting a MediaConvert job
|
`MediaConvertClient.QueueEncodingJob(key)`, submitting a MediaConvert job
|
||||||
`s3://raw-uploads-bucket/<key>` → `s3://encoded-bucket/<key>` (H.264/AAC →
|
`s3://raw-uploads-bucket/<key>` → `s3://encoded-bucket/<key>` (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),
|
||||||
|
|||||||
+86
-77
@@ -17,7 +17,7 @@ const docTemplate = `{
|
|||||||
"paths": {
|
"paths": {
|
||||||
"/api/categories": {
|
"/api/categories": {
|
||||||
"get": {
|
"get": {
|
||||||
"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, each with the id used to reference it. The ` + "`" + `id` + "`" + ` values from this list are the only ones accepted in the ` + "`" + `categoryIds` + "`" + ` field of POST /api/videos.",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -56,7 +56,7 @@ const docTemplate = `{
|
|||||||
"summary": "Register an uploaded video",
|
"summary": "Register an uploaded video",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"description": "Video metadata plus the storage key from the presign step. title and key are required; categories must be values from GET /api/categories.",
|
"description": "Video metadata plus the storage key from the presign step. title, key and categoryIds are required; categoryIds must hold at least one id from GET /api/categories.",
|
||||||
"name": "request",
|
"name": "request",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -69,7 +69,7 @@ const docTemplate = `{
|
|||||||
"201": {
|
"201": {
|
||||||
"description": "Created",
|
"description": "Created",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/api.VideoResponse"
|
"$ref": "#/definitions/api.CompleteResponse"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
@@ -79,13 +79,13 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"422": {
|
"422": {
|
||||||
"description": "title was empty, or key was missing or not a key issued by this API",
|
"description": "title was empty, key was missing or not a key issued by this API, or categoryIds was empty or contained an id that does not exist",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/api.ProblemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Transcoding job could not be queued, or the video record could not be saved",
|
"description": "Categories could not be read, the transcoding job could not be queued, or the video record could not be saved",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/api.ProblemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
@@ -173,26 +173,35 @@ const docTemplate = `{
|
|||||||
"categories": {
|
"categories": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"$ref": "#/definitions/api.Category"
|
||||||
},
|
}
|
||||||
"example": [
|
}
|
||||||
"documentary",
|
}
|
||||||
"news"
|
},
|
||||||
]
|
"api.Category": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "documentary"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"api.CompleteRequest": {
|
"api.CompleteRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"categories": {
|
"categoryIds": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"example": [
|
"example": [
|
||||||
"documentary",
|
1,
|
||||||
"news"
|
2
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
@@ -217,6 +226,68 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"api.CompleteResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"categoryIds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"example": [
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "A behind-the-scenes look at the evening bulletin."
|
||||||
|
},
|
||||||
|
"fileName": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "interview-cut.mov"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "0199f3a1-7c2e-7b21-9f0d-1a2b3c4d5e6f"
|
||||||
|
},
|
||||||
|
"mediaConvertJobId": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "1755300000000-abcdef"
|
||||||
|
},
|
||||||
|
"sizeBytes": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 60
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"processing",
|
||||||
|
"ready",
|
||||||
|
"failed"
|
||||||
|
],
|
||||||
|
"example": "processing"
|
||||||
|
},
|
||||||
|
"storageKey": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "videos/a1b2c3d4e5f6.mov"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "media, press, riyadh"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Inside the Newsroom"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"api.HealthResponse": {
|
"api.HealthResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -276,68 +347,6 @@ const docTemplate = `{
|
|||||||
"example": "about:blank"
|
"example": "about:blank"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"api.VideoResponse": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"categories": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"example": [
|
|
||||||
"documentary",
|
|
||||||
"news"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"createdAt": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"description": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "A behind-the-scenes look at the evening bulletin."
|
|
||||||
},
|
|
||||||
"fileName": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "interview-cut.mov"
|
|
||||||
},
|
|
||||||
"id": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "0199f3a1-7c2e-7b21-9f0d-1a2b3c4d5e6f"
|
|
||||||
},
|
|
||||||
"mediaConvertJobId": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "1755300000000-abcdef"
|
|
||||||
},
|
|
||||||
"sizeBytes": {
|
|
||||||
"type": "integer",
|
|
||||||
"example": 60
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"processing",
|
|
||||||
"ready",
|
|
||||||
"failed"
|
|
||||||
],
|
|
||||||
"example": "processing"
|
|
||||||
},
|
|
||||||
"storageKey": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "videos/a1b2c3d4e5f6.mov"
|
|
||||||
},
|
|
||||||
"tags": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "media, press, riyadh"
|
|
||||||
},
|
|
||||||
"title": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Inside the Newsroom"
|
|
||||||
},
|
|
||||||
"updatedAt": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|||||||
+86
-77
@@ -10,7 +10,7 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"/api/categories": {
|
"/api/categories": {
|
||||||
"get": {
|
"get": {
|
||||||
"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, each with the id used to reference it. The `id` values from this list are the only ones accepted in the `categoryIds` field of POST /api/videos.",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
"summary": "Register an uploaded video",
|
"summary": "Register an uploaded video",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"description": "Video metadata plus the storage key from the presign step. title and key are required; categories must be values from GET /api/categories.",
|
"description": "Video metadata plus the storage key from the presign step. title, key and categoryIds are required; categoryIds must hold at least one id from GET /api/categories.",
|
||||||
"name": "request",
|
"name": "request",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"201": {
|
"201": {
|
||||||
"description": "Created",
|
"description": "Created",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/api.VideoResponse"
|
"$ref": "#/definitions/api.CompleteResponse"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
@@ -72,13 +72,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"422": {
|
"422": {
|
||||||
"description": "title was empty, or key was missing or not a key issued by this API",
|
"description": "title was empty, key was missing or not a key issued by this API, or categoryIds was empty or contained an id that does not exist",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/api.ProblemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Transcoding job could not be queued, or the video record could not be saved",
|
"description": "Categories could not be read, the transcoding job could not be queued, or the video record could not be saved",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/api.ProblemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
@@ -166,26 +166,35 @@
|
|||||||
"categories": {
|
"categories": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"$ref": "#/definitions/api.Category"
|
||||||
},
|
}
|
||||||
"example": [
|
}
|
||||||
"documentary",
|
}
|
||||||
"news"
|
},
|
||||||
]
|
"api.Category": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "documentary"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"api.CompleteRequest": {
|
"api.CompleteRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"categories": {
|
"categoryIds": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"example": [
|
"example": [
|
||||||
"documentary",
|
1,
|
||||||
"news"
|
2
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
@@ -210,6 +219,68 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"api.CompleteResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"categoryIds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"example": [
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "A behind-the-scenes look at the evening bulletin."
|
||||||
|
},
|
||||||
|
"fileName": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "interview-cut.mov"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "0199f3a1-7c2e-7b21-9f0d-1a2b3c4d5e6f"
|
||||||
|
},
|
||||||
|
"mediaConvertJobId": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "1755300000000-abcdef"
|
||||||
|
},
|
||||||
|
"sizeBytes": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 60
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"processing",
|
||||||
|
"ready",
|
||||||
|
"failed"
|
||||||
|
],
|
||||||
|
"example": "processing"
|
||||||
|
},
|
||||||
|
"storageKey": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "videos/a1b2c3d4e5f6.mov"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "media, press, riyadh"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Inside the Newsroom"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"api.HealthResponse": {
|
"api.HealthResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -269,68 +340,6 @@
|
|||||||
"example": "about:blank"
|
"example": "about:blank"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"api.VideoResponse": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"categories": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"example": [
|
|
||||||
"documentary",
|
|
||||||
"news"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"createdAt": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"description": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "A behind-the-scenes look at the evening bulletin."
|
|
||||||
},
|
|
||||||
"fileName": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "interview-cut.mov"
|
|
||||||
},
|
|
||||||
"id": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "0199f3a1-7c2e-7b21-9f0d-1a2b3c4d5e6f"
|
|
||||||
},
|
|
||||||
"mediaConvertJobId": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "1755300000000-abcdef"
|
|
||||||
},
|
|
||||||
"sizeBytes": {
|
|
||||||
"type": "integer",
|
|
||||||
"example": 60
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"processing",
|
|
||||||
"ready",
|
|
||||||
"failed"
|
|
||||||
],
|
|
||||||
"example": "processing"
|
|
||||||
},
|
|
||||||
"storageKey": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "videos/a1b2c3d4e5f6.mov"
|
|
||||||
},
|
|
||||||
"tags": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "media, press, riyadh"
|
|
||||||
},
|
|
||||||
"title": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Inside the Newsroom"
|
|
||||||
},
|
|
||||||
"updatedAt": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+70
-63
@@ -3,21 +3,27 @@ definitions:
|
|||||||
api.CategoriesResponse:
|
api.CategoriesResponse:
|
||||||
properties:
|
properties:
|
||||||
categories:
|
categories:
|
||||||
example:
|
|
||||||
- documentary
|
|
||||||
- news
|
|
||||||
items:
|
items:
|
||||||
type: string
|
$ref: '#/definitions/api.Category'
|
||||||
type: array
|
type: array
|
||||||
type: object
|
type: object
|
||||||
|
api.Category:
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
example: 1
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
example: documentary
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
api.CompleteRequest:
|
api.CompleteRequest:
|
||||||
properties:
|
properties:
|
||||||
categories:
|
categoryIds:
|
||||||
example:
|
example:
|
||||||
- documentary
|
- 1
|
||||||
- news
|
- 2
|
||||||
items:
|
items:
|
||||||
type: string
|
type: integer
|
||||||
type: array
|
type: array
|
||||||
description:
|
description:
|
||||||
example: A behind-the-scenes look at the evening bulletin.
|
example: A behind-the-scenes look at the evening bulletin.
|
||||||
@@ -35,6 +41,51 @@ definitions:
|
|||||||
example: Inside the Newsroom
|
example: Inside the Newsroom
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
api.CompleteResponse:
|
||||||
|
properties:
|
||||||
|
categoryIds:
|
||||||
|
example:
|
||||||
|
- 1
|
||||||
|
- 2
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
type: array
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
example: A behind-the-scenes look at the evening bulletin.
|
||||||
|
type: string
|
||||||
|
fileName:
|
||||||
|
example: interview-cut.mov
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
example: 0199f3a1-7c2e-7b21-9f0d-1a2b3c4d5e6f
|
||||||
|
type: string
|
||||||
|
mediaConvertJobId:
|
||||||
|
example: 1755300000000-abcdef
|
||||||
|
type: string
|
||||||
|
sizeBytes:
|
||||||
|
example: 60
|
||||||
|
type: integer
|
||||||
|
status:
|
||||||
|
enum:
|
||||||
|
- processing
|
||||||
|
- ready
|
||||||
|
- failed
|
||||||
|
example: processing
|
||||||
|
type: string
|
||||||
|
storageKey:
|
||||||
|
example: videos/a1b2c3d4e5f6.mov
|
||||||
|
type: string
|
||||||
|
tags:
|
||||||
|
example: media, press, riyadh
|
||||||
|
type: string
|
||||||
|
title:
|
||||||
|
example: Inside the Newsroom
|
||||||
|
type: string
|
||||||
|
updatedAt:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
api.HealthResponse:
|
api.HealthResponse:
|
||||||
properties:
|
properties:
|
||||||
status:
|
status:
|
||||||
@@ -78,51 +129,6 @@ definitions:
|
|||||||
example: about:blank
|
example: about:blank
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
api.VideoResponse:
|
|
||||||
properties:
|
|
||||||
categories:
|
|
||||||
example:
|
|
||||||
- documentary
|
|
||||||
- news
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
type: array
|
|
||||||
createdAt:
|
|
||||||
type: string
|
|
||||||
description:
|
|
||||||
example: A behind-the-scenes look at the evening bulletin.
|
|
||||||
type: string
|
|
||||||
fileName:
|
|
||||||
example: interview-cut.mov
|
|
||||||
type: string
|
|
||||||
id:
|
|
||||||
example: 0199f3a1-7c2e-7b21-9f0d-1a2b3c4d5e6f
|
|
||||||
type: string
|
|
||||||
mediaConvertJobId:
|
|
||||||
example: 1755300000000-abcdef
|
|
||||||
type: string
|
|
||||||
sizeBytes:
|
|
||||||
example: 60
|
|
||||||
type: integer
|
|
||||||
status:
|
|
||||||
enum:
|
|
||||||
- processing
|
|
||||||
- ready
|
|
||||||
- failed
|
|
||||||
example: processing
|
|
||||||
type: string
|
|
||||||
storageKey:
|
|
||||||
example: videos/a1b2c3d4e5f6.mov
|
|
||||||
type: string
|
|
||||||
tags:
|
|
||||||
example: media, press, riyadh
|
|
||||||
type: string
|
|
||||||
title:
|
|
||||||
example: Inside the Newsroom
|
|
||||||
type: string
|
|
||||||
updatedAt:
|
|
||||||
type: string
|
|
||||||
type: object
|
|
||||||
info:
|
info:
|
||||||
contact: {}
|
contact: {}
|
||||||
description: 'JSON API for ingesting videos into the Thamanyah catalogue. Uploads
|
description: 'JSON API for ingesting videos into the Thamanyah catalogue. Uploads
|
||||||
@@ -133,9 +139,9 @@ info:
|
|||||||
paths:
|
paths:
|
||||||
/api/categories:
|
/api/categories:
|
||||||
get:
|
get:
|
||||||
description: Returns the fixed lookup set of categories a video can belong to.
|
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
|
each with the id used to reference it. The `id` values from this list are
|
||||||
of POST /api/videos.
|
the only ones accepted in the `categoryIds` field of POST /api/videos.
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -161,8 +167,9 @@ paths:
|
|||||||
"ready" or "failed" — though nothing currently moves it on once transcoding
|
"ready" or "failed" — though nothing currently moves it on once transcoding
|
||||||
finishes.
|
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.
|
key and categoryIds are required; categoryIds must hold at least one id
|
||||||
|
from GET /api/categories.
|
||||||
in: body
|
in: body
|
||||||
name: request
|
name: request
|
||||||
required: true
|
required: true
|
||||||
@@ -174,19 +181,19 @@ paths:
|
|||||||
"201":
|
"201":
|
||||||
description: Created
|
description: Created
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/api.VideoResponse'
|
$ref: '#/definitions/api.CompleteResponse'
|
||||||
"400":
|
"400":
|
||||||
description: Request body was not valid JSON
|
description: Request body was not valid JSON
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/api.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, key was missing or not a key issued by this
|
||||||
this API
|
API, or categoryIds was empty or contained an id that does not exist
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/api.ProblemDetails'
|
$ref: '#/definitions/api.ProblemDetails'
|
||||||
"500":
|
"500":
|
||||||
description: Transcoding job could not be queued, or the video record could
|
description: Categories could not be read, the transcoding job could not
|
||||||
not be saved
|
be queued, or the video record could not be saved
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/api.ProblemDetails'
|
$ref: '#/definitions/api.ProblemDetails'
|
||||||
summary: Register an uploaded video
|
summary: Register an uploaded video
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Package api holds the request and response bodies of the HTTP API — the
|
// 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
|
// wire contract, kept apart from the handlers that serve it. The swaggo
|
||||||
// annotations on the handlers reference these types by name (api.VideoResponse
|
// annotations on the handlers reference these types by name (api.CompleteResponse
|
||||||
// and so on), so renaming one changes the generated spec.
|
// and so on), so renaming one changes the generated spec.
|
||||||
package api
|
package api
|
||||||
|
|
||||||
|
|||||||
+20
-11
@@ -2,9 +2,16 @@ package api
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
|
// Category is one entry of the fixed category lookup table. Its ID is what
|
||||||
|
// clients send back in CompleteRequest.CategoryIDs.
|
||||||
|
type Category struct {
|
||||||
|
ID int16 `json:"id" example:"1"`
|
||||||
|
Name string `json:"name" example:"documentary"`
|
||||||
|
}
|
||||||
|
|
||||||
// CategoriesResponse is the body of GET /api/categories.
|
// CategoriesResponse is the body of GET /api/categories.
|
||||||
type CategoriesResponse struct {
|
type CategoriesResponse struct {
|
||||||
Categories []string `json:"categories" example:"documentary,news"`
|
Categories []Category `json:"categories"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PresignRequest is the body of POST /api/videos/presign.
|
// PresignRequest is the body of POST /api/videos/presign.
|
||||||
@@ -19,24 +26,26 @@ type PresignResponse struct {
|
|||||||
Key string `json:"key" example:"videos/a1b2c3d4e5f6.mov"`
|
Key string `json:"key" example:"videos/a1b2c3d4e5f6.mov"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompleteRequest is the body of POST /api/videos.
|
// CompleteRequest is the body of POST /api/videos. CategoryIDs holds ids from
|
||||||
|
// GET /api/categories, not category names, and must carry at least one — a
|
||||||
|
// video with no category is rejected.
|
||||||
type CompleteRequest struct {
|
type CompleteRequest struct {
|
||||||
Title string `json:"title" example:"Inside the Newsroom"`
|
Title string `json:"title" example:"Inside the Newsroom"`
|
||||||
Description string `json:"description" example:"A behind-the-scenes look at the evening bulletin."`
|
Description string `json:"description" example:"A behind-the-scenes look at the evening bulletin."`
|
||||||
Categories []string `json:"categories" example:"documentary,news"`
|
CategoryIDs []int16 `json:"categoryIds" example:"1,2"`
|
||||||
Tags string `json:"tags" example:"media, press, riyadh"`
|
Tags string `json:"tags" example:"media, press, riyadh"`
|
||||||
FileName string `json:"fileName" example:"interview-cut.mov"`
|
FileName string `json:"fileName" example:"interview-cut.mov"`
|
||||||
Key string `json:"key" example:"videos/a1b2c3d4e5f6.mov"`
|
Key string `json:"key" example:"videos/a1b2c3d4e5f6.mov"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// VideoResponse is a persisted video record as returned by POST /api/videos.
|
// CompleteResponse is a persisted video record as returned by POST /api/videos.
|
||||||
// Status carries the values of services.VideoStatus; the enums tag has to be
|
// Status carries the values of services.VideoStatus; the enums tag has to be
|
||||||
// widened by hand whenever that type gains a state.
|
// widened by hand whenever that type gains a state.
|
||||||
type VideoResponse struct {
|
type CompleteResponse struct {
|
||||||
ID string `json:"id" example:"0199f3a1-7c2e-7b21-9f0d-1a2b3c4d5e6f"`
|
ID string `json:"id" example:"0199f3a1-7c2e-7b21-9f0d-1a2b3c4d5e6f"`
|
||||||
Title string `json:"title" example:"Inside the Newsroom"`
|
Title string `json:"title" example:"Inside the Newsroom"`
|
||||||
Description string `json:"description" example:"A behind-the-scenes look at the evening bulletin."`
|
Description string `json:"description" example:"A behind-the-scenes look at the evening bulletin."`
|
||||||
Categories []string `json:"categories" example:"documentary,news"`
|
CategoryIDs []int16 `json:"categoryIds" example:"1,2"`
|
||||||
Tags string `json:"tags" example:"media, press, riyadh"`
|
Tags string `json:"tags" example:"media, press, riyadh"`
|
||||||
FileName string `json:"fileName" example:"interview-cut.mov"`
|
FileName string `json:"fileName" example:"interview-cut.mov"`
|
||||||
StorageKey string `json:"storageKey" example:"videos/a1b2c3d4e5f6.mov"`
|
StorageKey string `json:"storageKey" example:"videos/a1b2c3d4e5f6.mov"`
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"thamanyah/cms/v2/internal/api"
|
"thamanyah/cms/v2/internal/api"
|
||||||
"thamanyah/cms/v2/internal/services"
|
"thamanyah/cms/v2/internal/services"
|
||||||
@@ -23,7 +25,7 @@ const (
|
|||||||
// 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, each with the id used to reference it. The `id` values from this list are the only ones accepted in the `categoryIds` field of POST /api/videos.
|
||||||
// @Tags categories
|
// @Tags categories
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} api.CategoriesResponse
|
// @Success 200 {object} api.CategoriesResponse
|
||||||
@@ -38,10 +40,11 @@ func ListCategories(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if categories == nil {
|
body := make([]api.Category, 0, len(categories))
|
||||||
categories = []string{}
|
for _, category := range categories {
|
||||||
|
body = append(body, api.Category{ID: category.ID, Name: category.Name})
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, api.CategoriesResponse{Categories: categories})
|
writeJSON(w, http.StatusOK, api.CategoriesResponse{Categories: body})
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowedUploadContentTypes is the set of media types accepted for upload,
|
// allowedUploadContentTypes is the set of media types accepted for upload,
|
||||||
@@ -108,11 +111,11 @@ func PresignVideoUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
// @Tags videos
|
// @Tags videos
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @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."
|
// @Param request body api.CompleteRequest true "Video metadata plus the storage key from the presign step. title, key and categoryIds are required; categoryIds must hold at least one id from GET /api/categories."
|
||||||
// @Success 201 {object} api.VideoResponse
|
// @Success 201 {object} api.CompleteResponse
|
||||||
// @Failure 400 {object} api.ProblemDetails "Request body was not valid JSON"
|
// @Failure 400 {object} api.ProblemDetails "Request body was not valid JSON"
|
||||||
// @Failure 422 {object} api.ProblemDetails "title was empty, or key was missing or not a key issued by this API"
|
// @Failure 422 {object} api.ProblemDetails "title was empty, key was missing or not a key issued by this API, or categoryIds was empty or contained an id that does not exist"
|
||||||
// @Failure 500 {object} api.ProblemDetails "Transcoding job could not be queued, or the video record could not be saved"
|
// @Failure 500 {object} api.ProblemDetails "Categories could not be read, the 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)
|
||||||
@@ -138,11 +141,23 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
categories := make([]string, 0, len(req.Categories))
|
if len(req.CategoryIDs) == 0 {
|
||||||
for _, category := range req.Categories {
|
writeProblem(w, http.StatusUnprocessableEntity, "At least one category is required.",
|
||||||
if category = strings.TrimSpace(category); category != "" {
|
"The 'categoryIds' field was absent or empty. Every video belongs to at least one category; send ids taken from the 'id' field of GET /api/categories.")
|
||||||
categories = append(categories, category)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
categoryIDs, unknown, err := resolveCategoryIDs(r.Context(), req.CategoryIDs)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Something Went Wrong Loading Categories: %s", err)
|
||||||
|
writeProblem(w, http.StatusInternalServerError, "Something Went Wrong Loading Categories",
|
||||||
|
"The submitted category ids could not be checked against the database, so no video record was created. This is a server-side fault; the uploaded file is still in storage, so retry this request with the same 'key'.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(unknown) > 0 {
|
||||||
|
writeProblem(w, http.StatusUnprocessableEntity, "Unknown category.",
|
||||||
|
fmt.Sprintf("The 'categoryIds' field referenced %s, which do not exist. Send ids taken from the 'id' field of GET /api/categories.", formatCategoryIDs(unknown)))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
jobID, err := services.MediaConvertClient.QueueEncodingJob(r.Context(), key)
|
jobID, err := services.MediaConvertClient.QueueEncodingJob(r.Context(), key)
|
||||||
@@ -156,7 +171,7 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
video, err := services.DB.CreateVideo(r.Context(), services.Video{
|
video, err := services.DB.CreateVideo(r.Context(), services.Video{
|
||||||
Title: title,
|
Title: title,
|
||||||
Description: strings.TrimSpace(req.Description),
|
Description: strings.TrimSpace(req.Description),
|
||||||
Categories: categories,
|
CategoryIDs: categoryIDs,
|
||||||
Tags: strings.TrimSpace(req.Tags),
|
Tags: strings.TrimSpace(req.Tags),
|
||||||
FileName: strings.TrimSpace(req.FileName),
|
FileName: strings.TrimSpace(req.FileName),
|
||||||
StorageKey: key,
|
StorageKey: key,
|
||||||
@@ -175,7 +190,7 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
ID: video.ID,
|
ID: video.ID,
|
||||||
Title: video.Title,
|
Title: video.Title,
|
||||||
Description: video.Description,
|
Description: video.Description,
|
||||||
Categories: video.Categories,
|
CategoryIDs: video.CategoryIDs,
|
||||||
Tags: video.Tags,
|
Tags: video.Tags,
|
||||||
FileName: video.FileName,
|
FileName: video.FileName,
|
||||||
StorageKey: video.StorageKey,
|
StorageKey: video.StorageKey,
|
||||||
@@ -187,6 +202,53 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveCategoryIDs deduplicates the submitted category ids, preserving the
|
||||||
|
// order they arrived in, and splits them into ids that exist and ids that do
|
||||||
|
// not. Checking them here rather than leaning on the video_categories foreign
|
||||||
|
// key keeps an unknown id from surfacing only after a transcoding job has
|
||||||
|
// already been queued — and the duplicate pass keeps a repeated id from
|
||||||
|
// tripping the join table's composite primary key. Callers reject an empty
|
||||||
|
// list before calling, so a non-empty submitted list always yields either at
|
||||||
|
// least one known id or at least one unknown one.
|
||||||
|
func resolveCategoryIDs(ctx context.Context, submitted []int16) (known, unknown []int16, err error) {
|
||||||
|
known = make([]int16, 0, len(submitted))
|
||||||
|
|
||||||
|
categories, err := services.DB.ListCategories(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
exists := make(map[int16]struct{}, len(categories))
|
||||||
|
for _, category := range categories {
|
||||||
|
exists[category.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[int16]struct{}, len(submitted))
|
||||||
|
for _, id := range submitted {
|
||||||
|
if _, duplicate := seen[id]; duplicate {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
|
||||||
|
if _, ok := exists[id]; ok {
|
||||||
|
known = append(known, id)
|
||||||
|
} else {
|
||||||
|
unknown = append(unknown, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return known, unknown, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatCategoryIDs(ids []int16) string {
|
||||||
|
formatted := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
formatted = append(formatted, strconv.Itoa(int(id)))
|
||||||
|
}
|
||||||
|
if len(formatted) == 1 {
|
||||||
|
return "id " + formatted[0]
|
||||||
|
}
|
||||||
|
return "ids " + strings.Join(formatted, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
func randomFilename(original string) (string, error) {
|
func randomFilename(original string) (string, error) {
|
||||||
ext := filepath.Ext(filepath.Base(original))
|
ext := filepath.Ext(filepath.Base(original))
|
||||||
buf := make([]byte, 16)
|
buf := make([]byte, 16)
|
||||||
|
|||||||
+21
-16
@@ -5,17 +5,23 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"thamanyah/cms/v2/internal/db"
|
"thamanyah/cms/v2/internal/db"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var DB DBClient
|
var DB DBClient
|
||||||
|
|
||||||
|
type Category struct {
|
||||||
|
ID int16
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
type Video struct {
|
type Video struct {
|
||||||
ID string
|
ID string
|
||||||
Title string
|
Title string
|
||||||
Description string
|
Description string
|
||||||
Categories []string
|
CategoryIDs []int16
|
||||||
Tags string
|
Tags string
|
||||||
FileName string
|
FileName string
|
||||||
StorageKey string
|
StorageKey string
|
||||||
@@ -28,7 +34,7 @@ type Video struct {
|
|||||||
|
|
||||||
type DBClient interface {
|
type DBClient interface {
|
||||||
CreateVideo(ctx context.Context, v Video) (Video, error)
|
CreateVideo(ctx context.Context, v Video) (Video, error)
|
||||||
ListCategories(ctx context.Context) ([]string, error)
|
ListCategories(ctx context.Context) ([]Category, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type DBConcrete struct {
|
type DBConcrete struct {
|
||||||
@@ -62,14 +68,13 @@ func (svc DBConcrete) CreateVideo(ctx context.Context, v Video) (Video, error) {
|
|||||||
return Video{}, err
|
return Video{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, category := range v.Categories {
|
rows := make([]string, 0, len(v.CategoryIDs))
|
||||||
var categoryID int64
|
for _, categoryID := range v.CategoryIDs {
|
||||||
if err := tx.QueryRowContext(ctx, `SELECT id FROM categories WHERE name = $1`, category).Scan(&categoryID); err != nil {
|
rows = append(rows, fmt.Sprintf("($1, %d)", categoryID))
|
||||||
return Video{}, fmt.Errorf("looking up category %q: %w", category, err)
|
}
|
||||||
}
|
|
||||||
if _, err := tx.ExecContext(ctx, `INSERT INTO video_categories (video_id, category_id) VALUES ($1, $2)`, v.ID, categoryID); err != nil {
|
if _, err := tx.ExecContext(ctx, `INSERT INTO video_categories (video_id, category_id) VALUES `+strings.Join(rows, ", "), v.ID); err != nil {
|
||||||
return Video{}, fmt.Errorf("linking category %q: %w", category, err)
|
return Video{}, fmt.Errorf("linking categories %v: %w", v.CategoryIDs, err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
@@ -79,20 +84,20 @@ func (svc DBConcrete) CreateVideo(ctx context.Context, v Video) (Video, error) {
|
|||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc DBConcrete) ListCategories(ctx context.Context) ([]string, error) {
|
func (svc DBConcrete) ListCategories(ctx context.Context) ([]Category, error) {
|
||||||
rows, err := svc.SQLDB.QueryContext(ctx, `SELECT name FROM categories ORDER BY name`)
|
rows, err := svc.SQLDB.QueryContext(ctx, `SELECT id, name FROM categories ORDER BY name`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var categories []string
|
var categories []Category
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var name string
|
var category Category
|
||||||
if err := rows.Scan(&name); err != nil {
|
if err := rows.Scan(&category.ID, &category.Name); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
categories = append(categories, name)
|
categories = append(categories, category)
|
||||||
}
|
}
|
||||||
return categories, rows.Err()
|
return categories, rows.Err()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ package services
|
|||||||
// in videos.status. The set is closed on both sides: the database rejects
|
// 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
|
// anything outside it via the videos_status_check constraint. Adding a state
|
||||||
// means adding a constant here, widening that constraint in a new migration,
|
// means adding a constant here, widening that constraint in a new migration,
|
||||||
// and extending the `enums` annotation on api.VideoResponse.Status.
|
// and extending the `enums` annotation on api.CompleteResponse.Status.
|
||||||
type VideoStatus string
|
type VideoStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
Reference in New Issue
Block a user