Files
thamanyah/discovery/internal/handlers/videos.go
T
FahdShalhoub c3465bedb1
Build, Push and Deploy Discovery / build-push-deploy (push) Successful in 1m57s
FEAT: Video Search In Discovery
2026-08-29 17:56:11 +03:00

124 lines
5.1 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"log"
"net/http"
"thamanyah/discovery/internal/api"
"thamanyah/discovery/internal/db/repositories"
)
// GetVideo serves the catalogue's copy of one video.
//
// @Summary Get a catalogued video
// @Description Returns the catalogue's copy of a video: what a reader needs to show and play it. A video appears here only after the CMS has announced it as ready, so a video that is still transcoding — or one the CMS never made ready — is a 404.
// @Tags videos
// @Produce json
// @Param id path string true "Video id, as issued by the CMS"
// @Success 200 {object} api.Video
// @Failure 404 {object} api.ProblemDetails
// @Failure 500 {object} api.ProblemDetails
// @Router /api/videos/{id} [get]
func GetVideo(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
video, err := repositories.VideoRepo.GetVideoByID(r.Context(), id)
if errors.Is(err, repositories.ErrVideoNotFound) {
writeProblem(w, http.StatusNotFound, "Video Not In The Catalogue",
"No video with that id is in the catalogue. A video appears here once the CMS announces it as ready, so one that is still being transcoded is not here yet.")
return
}
if err != nil {
log.Printf("Something Went Wrong Loading A Catalogued Video: id=%q: %s", id, err)
writeProblem(w, http.StatusInternalServerError, "Something Went Wrong Loading The Catalogue",
"The video 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
}
// Translated field by field rather than returned as-is: models.Video is the
// row, api.Video is the published schema, and neither should drag the other
// along when it changes.
writeJSON(w, http.StatusOK, api.Video{
ID: video.ID,
Title: video.Title,
PlaybackURL: video.PlaybackURL,
// Never null on the wire: a video with no categories has an empty
// list, which is a different thing from "unknown".
Categories: append([]string{}, video.Categories...),
})
}
const (
// searchPageSize is how many videos a search returns when the caller does
// not say. Small enough to keep the common request cheap.
searchPageSize = 20
// maxSearchPageSize is the largest page the catalogue will build, however
// big a page the caller asks for. The endpoint is read constantly, and a
// page size is a request for work: without a ceiling, one client asking
// for the whole catalogue in a single response makes every other reader
// wait behind it.
//
// Asking for more is not an error — the page is simply capped, and the
// cursor still reaches the rest — because a client that wants everything
// is doing something legitimate, just not in one request.
maxSearchPageSize = 100
)
// SearchVideos serves the catalogue's lexical search.
//
// It is served on QUERY rather than GET or POST: a search is safe and
// idempotent, which POST is not, but its parameters are a structured document
// that does not belong in a query string. QUERY says both. It is specified in
// draft-ietf-httpbis-safe-method-w-body, not in RFC 9110 — still a draft, so
// expect intermediaries that have never heard of it. Note it also cannot be
// expressed in the generated OpenAPI spec — Swagger 2.0 and OpenAPI 3.x define a fixed set of
// per-method fields with no slot for QUERY, and swag rejects the annotation
// outright — so this endpoint is deliberately absent from /swagger and
// documented here instead.
func SearchVideos(w http.ResponseWriter, r *http.Request) {
var request api.SearchRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
writeProblem(w, http.StatusBadRequest, "Malformed Search",
"The request body could not be read as JSON. Send a JSON object describing what to search for; an empty object browses the whole catalogue.")
return
}
limit := request.Limit
if limit <= 0 {
limit = searchPageSize
}
if limit > maxSearchPageSize {
limit = maxSearchPageSize
}
found, next, err := repositories.VideoRepo.SearchVideos(r.Context(), request.Title, request.Categories, limit, request.Cursor)
if errors.Is(err, repositories.ErrInvalidCursor) {
writeProblem(w, http.StatusBadRequest, "Malformed Search Cursor",
"The cursor could not be read. Send back the nextCursor from a previous search unchanged, or omit it to start from the first page.")
return
}
if err != nil {
log.Printf("Something Went Wrong Searching The Catalogue: title=%q: %s", request.Title, err)
writeProblem(w, http.StatusInternalServerError, "Something Went Wrong Searching The Catalogue",
"The catalogue could not be searched. This is a server-side fault and the request was not processed; retrying in a few moments may succeed.")
return
}
// Translated field by field, for the same reason GetVideo does it: the row
// and the published schema are different things.
videos := make([]api.Video, 0, len(found))
for _, v := range found {
videos = append(videos, api.Video{
ID: v.ID,
Title: v.Title,
PlaybackURL: v.PlaybackURL,
Categories: append([]string{}, v.Categories...),
})
}
writeJSON(w, http.StatusOK, api.SearchResults{Videos: videos, NextCursor: next})
}