Files
thamanyah/cms/internal/handlers/videos.go
T
FahdShalhoub cdc5315401
Build, Push and Deploy CMS / build-push-deploy (push) Successful in 2m0s
CHANGE: Corrected The Use Of Title In The ProblemDetail Response
2026-08-16 23:28:46 +03:00

190 lines
6.8 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"`
}
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})
}
type presignRequest struct {
FileName string `json:"fileName"`
ContentType string `json:"contentType"`
}
type presignResponse struct {
UploadURL string `json:"uploadUrl"`
Key string `json:"key"`
}
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 !strings.HasPrefix(req.ContentType, "video/") {
writeProblem(w, http.StatusUnprocessableEntity, "Unsupported file type. Please upload a video.",
fmt.Sprintf("The 'contentType' field was %q, but only video/* media types can be uploaded. Set it to the file's own MIME type, for example 'video/mp4'.", 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, "video/mp4", 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"`
Description string `json:"description"`
Categories []string `json:"categories"`
Tags string `json:"tags"`
FileName string `json:"fileName"`
Key string `json:"key"`
}
type videoResponse struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Categories []string `json:"categories"`
Tags string `json:"tags"`
FileName string `json:"fileName"`
StorageKey string `json:"storageKey"`
MediaConvertJobID string `json:"mediaConvertJobId"`
Status string `json:"status"`
SizeBytes int64 `json:"sizeBytes"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
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
}