Files
thamanyah/cms/internal/handlers/videos.go
T
FahdShalhoub 4b719f712d
Build, Push and Deploy CMS / build-push-deploy (push) Successful in 2m18s
FIX: Changed Content Type From Static mp4 To Input
2026-08-16 23:56:21 +03:00

235 lines
11 KiB
Go

package handlers
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"path/filepath"
"strings"
"thamanyah/cms/v2/internal/services"
"time"
)
const (
videoUploadPrefix = "videos"
uploadURLExpiry = 15 * time.Minute
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.
//
// @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.
// @Tags categories
// @Produce json
// @Success 200 {object} categoriesResponse
// @Failure 500 {object} problemDetails "Categories could not be read from the database"
// @Router /api/categories [get]
func ListCategories(w http.ResponseWriter, r *http.Request) {
categories, err := services.DB.ListCategories(r.Context())
if err != nil {
log.Printf("Something Went Wrong Loading Categories: %s", err)
writeProblem(w, http.StatusInternalServerError, "Something Went Wrong Loading Categories",
"The list of video categories could not be read from the database. This is a server-side fault and the request was not processed; retrying in a few moments may succeed.")
return
}
if categories == nil {
categories = []string{}
}
writeJSON(w, http.StatusOK, categoriesResponse{Categories: categories})
}
// allowedUploadContentTypes is the set of media types accepted for upload,
// keyed by the exact string a client must send. Matched verbatim rather than
// normalised: the value is signed into the presigned URL, so rewriting it
// here would leave the client PUTting a header that no longer matches the
// signature.
var allowedUploadContentTypes = map[string]struct{}{
"video/mp4": {}, // .mp4
"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.
//
// @Summary Create a presigned upload URL
// @Description Step 1 of the upload flow. Returns a short-lived presigned S3 URL that the client PUTs the video file to directly, plus the storage key identifying it. The submitted contentType is signed into that URL, so the PUT must carry an identical Content-Type header or S3 rejects it as a signature mismatch. Once the PUT succeeds, pass the key to POST /api/videos to register the video and start transcoding. The file itself never passes through this API.
// @Tags videos
// @Accept 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'."
// @Success 200 {object} presignResponse
// @Failure 400 {object} problemDetails "Request body was not valid JSON"
// @Failure 422 {object} problemDetails "contentType was not 'video/mp4' or 'video/quicktime'"
// @Failure 500 {object} problemDetails "Upload URL could not be issued"
// @Router /api/videos/presign [post]
func PresignVideoUpload(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBodySize)
var req presignRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
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))
return
}
if _, ok := allowedUploadContentTypes[req.ContentType]; !ok {
writeProblem(w, http.StatusUnprocessableEntity, "Unsupported file type. Please upload an MP4 or MOV video.",
fmt.Sprintf("The 'contentType' field was %q, but only 'video/mp4' (.mp4) and 'video/quicktime' (.mov) are accepted. Send one of those two values exactly — it is signed into the upload URL, so the PUT must use the identical header.", req.ContentType))
return
}
filename, err := randomFilename(req.FileName)
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Could not prepare upload.",
"A unique storage key could not be generated because the server's random source failed. This is a server-side fault; retry the request.")
return
}
key := videoUploadPrefix + "/" + filename
presignedURL, err := services.S3Client.GetPresignedURL(r.Context(), key, req.ContentType, time.Hour)
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Could not prepare upload.",
"A presigned upload URL could not be issued for the storage bucket. This is a server-side fault; no upload slot was reserved, so retry the request.")
return
}
writeJSON(w, http.StatusOK, 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.
//
// @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.
// @Tags videos
// @Accept 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."
// @Success 201 {object} videoResponse
// @Failure 400 {object} 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 500 {object} problemDetails "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)
var req completeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
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))
return
}
title := strings.TrimSpace(req.Title)
if title == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Title is required.",
"The 'title' field was absent or contained only whitespace. Every video needs a non-empty title.")
return
}
key := strings.TrimSpace(req.Key)
if key == "" || !strings.HasPrefix(key, videoUploadPrefix+"/") {
writeProblem(w, http.StatusUnprocessableEntity, "A video file is required.",
fmt.Sprintf("The 'key' field was %q, which is not a storage key issued by this API. Use the 'key' returned by POST /api/videos/presign, after the file has been PUT to the accompanying upload URL.", req.Key))
return
}
categories := make([]string, 0, len(req.Categories))
for _, category := range req.Categories {
if category = strings.TrimSpace(category); category != "" {
categories = append(categories, category)
}
}
jobID, err := services.MediaConvertClient.QueueEncodingJob(r.Context(), key)
if err != nil {
log.Printf("Something Went Wrong On Creation Of Transcoding Job: %s", err)
writeProblem(w, http.StatusInternalServerError, "Something Went Wrong On Creation Of Transcoding Job",
"The transcoding job could not be queued, so no video record was created. The uploaded file is still in storage; retry this request with the same 'key' rather than uploading the file again.")
return
}
video, err := services.DB.CreateVideo(r.Context(), services.Video{
Title: title,
Description: strings.TrimSpace(req.Description),
Categories: categories,
Tags: strings.TrimSpace(req.Tags),
FileName: strings.TrimSpace(req.FileName),
StorageKey: key,
MediaConvertJobID: jobID,
Status: "processing",
SizeBytes: 60,
})
if err != nil {
log.Printf("Something Went Wrong Saving The Video Record: %s", err)
writeProblem(w, http.StatusInternalServerError, "Something Went Wrong Saving The Video Record",
"The transcoding job was queued but its video record could not be written to the database, so the video may finish transcoding without ever appearing in the catalogue. Retry this request or escalate to an operator.")
return
}
writeJSON(w, http.StatusCreated, videoResponse{
ID: video.ID,
Title: video.Title,
Description: video.Description,
Categories: video.Categories,
Tags: video.Tags,
FileName: video.FileName,
StorageKey: video.StorageKey,
MediaConvertJobID: video.MediaConvertJobID,
Status: video.Status,
SizeBytes: video.SizeBytes,
CreatedAt: video.CreatedAt,
UpdatedAt: video.UpdatedAt,
})
}
func randomFilename(original string) (string, error) {
ext := filepath.Ext(filepath.Base(original))
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf) + ext, nil
}