diff --git a/CLAUDE.md b/CLAUDE.md index f946a55..fc647b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,11 +130,18 @@ it ever matters. actually relied on) — v7 keeps primary-key inserts roughly time-ordered, avoiding B-tree fragmentation as the table grows. - `categories` — a small fixed lookup table (`documentary`, `news`, - `entertainment`, `podcast`, `other`), seeded by migration `0001`. + `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, `ON DELETE CASCADE`) added in migration `0002`: a video can belong to *multiple* categories, not just one. `CreateVideo` inserts the `videos` row - and its `video_categories` links inside a single transaction. + 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 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 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. -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 `s3://raw-uploads-bucket/` → `s3://encoded-bucket/` (H.264/AAC → MP4, QVBR rate control — QVBR requires `MaxBitrate` to be set explicitly), diff --git a/cms/docs/docs.go b/cms/docs/docs.go index 539ffeb..558499a 100644 --- a/cms/docs/docs.go +++ b/cms/docs/docs.go @@ -17,7 +17,7 @@ const docTemplate = `{ "paths": { "/api/categories": { "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": [ "application/json" ], @@ -56,7 +56,7 @@ const docTemplate = `{ "summary": "Register an uploaded video", "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", "in": "body", "required": true, @@ -69,7 +69,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/api.VideoResponse" + "$ref": "#/definitions/api.CompleteResponse" } }, "400": { @@ -79,13 +79,13 @@ const docTemplate = `{ } }, "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": { "$ref": "#/definitions/api.ProblemDetails" } }, "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": { "$ref": "#/definitions/api.ProblemDetails" } @@ -173,26 +173,35 @@ const docTemplate = `{ "categories": { "type": "array", "items": { - "type": "string" - }, - "example": [ - "documentary", - "news" - ] + "$ref": "#/definitions/api.Category" + } + } + } + }, + "api.Category": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "name": { + "type": "string", + "example": "documentary" } } }, "api.CompleteRequest": { "type": "object", "properties": { - "categories": { + "categoryIds": { "type": "array", "items": { - "type": "string" + "type": "integer" }, "example": [ - "documentary", - "news" + 1, + 2 ] }, "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": { "type": "object", "properties": { @@ -276,68 +347,6 @@ const docTemplate = `{ "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" - } - } } } }` diff --git a/cms/docs/swagger.json b/cms/docs/swagger.json index 0d93c6b..c1306a5 100644 --- a/cms/docs/swagger.json +++ b/cms/docs/swagger.json @@ -10,7 +10,7 @@ "paths": { "/api/categories": { "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": [ "application/json" ], @@ -49,7 +49,7 @@ "summary": "Register an uploaded video", "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", "in": "body", "required": true, @@ -62,7 +62,7 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/api.VideoResponse" + "$ref": "#/definitions/api.CompleteResponse" } }, "400": { @@ -72,13 +72,13 @@ } }, "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": { "$ref": "#/definitions/api.ProblemDetails" } }, "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": { "$ref": "#/definitions/api.ProblemDetails" } @@ -166,26 +166,35 @@ "categories": { "type": "array", "items": { - "type": "string" - }, - "example": [ - "documentary", - "news" - ] + "$ref": "#/definitions/api.Category" + } + } + } + }, + "api.Category": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "name": { + "type": "string", + "example": "documentary" } } }, "api.CompleteRequest": { "type": "object", "properties": { - "categories": { + "categoryIds": { "type": "array", "items": { - "type": "string" + "type": "integer" }, "example": [ - "documentary", - "news" + 1, + 2 ] }, "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": { "type": "object", "properties": { @@ -269,68 +340,6 @@ "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" - } - } } } } \ No newline at end of file diff --git a/cms/docs/swagger.yaml b/cms/docs/swagger.yaml index 57c93f1..6e2489a 100644 --- a/cms/docs/swagger.yaml +++ b/cms/docs/swagger.yaml @@ -3,21 +3,27 @@ definitions: api.CategoriesResponse: properties: categories: - example: - - documentary - - news items: - type: string + $ref: '#/definitions/api.Category' type: array type: object + api.Category: + properties: + id: + example: 1 + type: integer + name: + example: documentary + type: string + type: object api.CompleteRequest: properties: - categories: + categoryIds: example: - - documentary - - news + - 1 + - 2 items: - type: string + type: integer type: array description: example: A behind-the-scenes look at the evening bulletin. @@ -35,6 +41,51 @@ definitions: example: Inside the Newsroom type: string 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: properties: status: @@ -78,51 +129,6 @@ definitions: example: about:blank type: string 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: contact: {} description: 'JSON API for ingesting videos into the Thamanyah catalogue. Uploads @@ -133,9 +139,9 @@ info: paths: /api/categories: 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: - application/json responses: @@ -161,8 +167,9 @@ paths: "ready" or "failed" — though nothing currently moves it on once transcoding finishes. 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. in: body name: request required: true @@ -174,19 +181,19 @@ paths: "201": description: Created schema: - $ref: '#/definitions/api.VideoResponse' + $ref: '#/definitions/api.CompleteResponse' "400": description: Request body was not valid JSON schema: $ref: '#/definitions/api.ProblemDetails' "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: $ref: '#/definitions/api.ProblemDetails' "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: $ref: '#/definitions/api.ProblemDetails' summary: Register an uploaded video diff --git a/cms/internal/api/api.go b/cms/internal/api/api.go index b039162..e4ad100 100644 --- a/cms/internal/api/api.go +++ b/cms/internal/api/api.go @@ -1,6 +1,6 @@ // 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 +// annotations on the handlers reference these types by name (api.CompleteResponse // and so on), so renaming one changes the generated spec. package api diff --git a/cms/internal/api/videos.go b/cms/internal/api/videos.go index e202995..0d055e1 100644 --- a/cms/internal/api/videos.go +++ b/cms/internal/api/videos.go @@ -2,9 +2,16 @@ package api 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. type CategoriesResponse struct { - Categories []string `json:"categories" example:"documentary,news"` + Categories []Category `json:"categories"` } // PresignRequest is the body of POST /api/videos/presign. @@ -19,24 +26,26 @@ type PresignResponse struct { 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 { - 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"` + Title string `json:"title" example:"Inside the Newsroom"` + Description string `json:"description" example:"A behind-the-scenes look at the evening bulletin."` + CategoryIDs []int16 `json:"categoryIds" example:"1,2"` + 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. +// 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 // 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"` 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"` + CategoryIDs []int16 `json:"categoryIds" example:"1,2"` Tags string `json:"tags" example:"media, press, riyadh"` FileName string `json:"fileName" example:"interview-cut.mov"` StorageKey string `json:"storageKey" example:"videos/a1b2c3d4e5f6.mov"` diff --git a/cms/internal/handlers/videos.go b/cms/internal/handlers/videos.go index bc6fab8..ac0e74d 100644 --- a/cms/internal/handlers/videos.go +++ b/cms/internal/handlers/videos.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "crypto/rand" "encoding/hex" "encoding/json" @@ -8,6 +9,7 @@ import ( "log" "net/http" "path/filepath" + "strconv" "strings" "thamanyah/cms/v2/internal/api" "thamanyah/cms/v2/internal/services" @@ -23,7 +25,7 @@ const ( // ListCategories returns the categories a video may be assigned to. // // @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 // @Produce json // @Success 200 {object} api.CategoriesResponse @@ -38,10 +40,11 @@ func ListCategories(w http.ResponseWriter, r *http.Request) { return } - if categories == nil { - categories = []string{} + body := make([]api.Category, 0, len(categories)) + 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, @@ -108,11 +111,11 @@ func PresignVideoUpload(w http.ResponseWriter, r *http.Request) { // @Tags videos // @Accept 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." -// @Success 201 {object} api.VideoResponse +// @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.CompleteResponse // @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 500 {object} api.ProblemDetails "Transcoding job could not be queued, or the video record could not be saved" +// @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 "Categories could not be read, the transcoding job could not be queued, or the video record could not be saved" // @Router /api/videos [post] func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxJSONBodySize) @@ -138,11 +141,23 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) { return } - categories := make([]string, 0, len(req.Categories)) - for _, category := range req.Categories { - if category = strings.TrimSpace(category); category != "" { - categories = append(categories, category) - } + if len(req.CategoryIDs) == 0 { + writeProblem(w, http.StatusUnprocessableEntity, "At least one category is required.", + "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.") + 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) @@ -156,7 +171,7 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) { video, err := services.DB.CreateVideo(r.Context(), services.Video{ Title: title, Description: strings.TrimSpace(req.Description), - Categories: categories, + CategoryIDs: categoryIDs, Tags: strings.TrimSpace(req.Tags), FileName: strings.TrimSpace(req.FileName), StorageKey: key, @@ -175,7 +190,7 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) { ID: video.ID, Title: video.Title, Description: video.Description, - Categories: video.Categories, + CategoryIDs: video.CategoryIDs, Tags: video.Tags, FileName: video.FileName, 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) { ext := filepath.Ext(filepath.Base(original)) buf := make([]byte, 16) diff --git a/cms/internal/services/db.go b/cms/internal/services/db.go index 70f6e0f..8d8bbc2 100644 --- a/cms/internal/services/db.go +++ b/cms/internal/services/db.go @@ -5,17 +5,23 @@ import ( "database/sql" "fmt" "log" + "strings" "thamanyah/cms/v2/internal/db" "time" ) var DB DBClient +type Category struct { + ID int16 + Name string +} + type Video struct { ID string Title string Description string - Categories []string + CategoryIDs []int16 Tags string FileName string StorageKey string @@ -28,7 +34,7 @@ type Video struct { type DBClient interface { CreateVideo(ctx context.Context, v Video) (Video, error) - ListCategories(ctx context.Context) ([]string, error) + ListCategories(ctx context.Context) ([]Category, error) } type DBConcrete struct { @@ -62,14 +68,13 @@ func (svc DBConcrete) CreateVideo(ctx context.Context, v Video) (Video, error) { return Video{}, err } - for _, category := range v.Categories { - var categoryID int64 - if err := tx.QueryRowContext(ctx, `SELECT id FROM categories WHERE name = $1`, category).Scan(&categoryID); err != nil { - 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 { - return Video{}, fmt.Errorf("linking category %q: %w", category, err) - } + rows := make([]string, 0, len(v.CategoryIDs)) + for _, categoryID := range v.CategoryIDs { + rows = append(rows, fmt.Sprintf("($1, %d)", categoryID)) + } + + 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 categories %v: %w", v.CategoryIDs, err) } if err := tx.Commit(); err != nil { @@ -79,20 +84,20 @@ func (svc DBConcrete) CreateVideo(ctx context.Context, v Video) (Video, error) { return v, nil } -func (svc DBConcrete) ListCategories(ctx context.Context) ([]string, error) { - rows, err := svc.SQLDB.QueryContext(ctx, `SELECT name FROM categories ORDER BY name`) +func (svc DBConcrete) ListCategories(ctx context.Context) ([]Category, error) { + rows, err := svc.SQLDB.QueryContext(ctx, `SELECT id, name FROM categories ORDER BY name`) if err != nil { return nil, err } defer rows.Close() - var categories []string + var categories []Category for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { + var category Category + if err := rows.Scan(&category.ID, &category.Name); err != nil { return nil, err } - categories = append(categories, name) + categories = append(categories, category) } return categories, rows.Err() } diff --git a/cms/internal/services/status.go b/cms/internal/services/status.go index 70799f5..b2cc7aa 100644 --- a/cms/internal/services/status.go +++ b/cms/internal/services/status.go @@ -4,7 +4,7 @@ package services // 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. +// and extending the `enums` annotation on api.CompleteResponse.Status. type VideoStatus string const (