FEAT: Video Search In Discovery
Build, Push and Deploy Discovery / build-push-deploy (push) Successful in 1m57s

This commit is contained in:
FahdShalhoub
2026-08-29 17:56:11 +03:00
parent 6f9e04ee97
commit c3465bedb1
20 changed files with 915 additions and 9 deletions
+1 -1
View File
@@ -145,7 +145,7 @@ var SwaggerInfo = &swag.Spec{
BasePath: "/",
Schemes: []string{},
Title: "Thamanyah Discovery API",
Description: "JSON API for browsing the Thamanyah catalogue. Read-side counterpart to the CMS, which is what ingests videos.",
Description: "JSON API for browsing the Thamanyah catalogue. Read-side counterpart to the CMS, which is what ingests videos.\n\nNot listed below: `QUERY /api/videos`, the catalogue search. It takes a JSON body `{title, categories, limit, cursor}` and answers `{videos, nextCursor}` — title is matched lexically with the last word as a prefix, categories narrow to videos filed under any one of them, limit defaults to 20 and is capped at 100, and cursor is the opaque `nextCursor` of a previous search. It is absent here because OpenAPI has no slot for the QUERY method, not because it is unsupported; see handlers.SearchVideos.",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
LeftDelim: "{{",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"swagger": "2.0",
"info": {
"description": "JSON API for browsing the Thamanyah catalogue. Read-side counterpart to the CMS, which is what ingests videos.",
"description": "JSON API for browsing the Thamanyah catalogue. Read-side counterpart to the CMS, which is what ingests videos.\n\nNot listed below: `QUERY /api/videos`, the catalogue search. It takes a JSON body `{title, categories, limit, cursor}` and answers `{videos, nextCursor}` — title is matched lexically with the last word as a prefix, categories narrow to videos filed under any one of them, limit defaults to 20 and is capped at 100, and cursor is the opaque `nextCursor` of a previous search. It is absent here because OpenAPI has no slot for the QUERY method, not because it is unsupported; see handlers.SearchVideos.",
"title": "Thamanyah Discovery API",
"contact": {},
"version": "1.0"
+4 -2
View File
@@ -43,8 +43,10 @@ definitions:
type: object
info:
contact: {}
description: JSON API for browsing the Thamanyah catalogue. Read-side counterpart
to the CMS, which is what ingests videos.
description: |-
JSON API for browsing the Thamanyah catalogue. Read-side counterpart to the CMS, which is what ingests videos.
Not listed below: `QUERY /api/videos`, the catalogue search. It takes a JSON body `{title, categories, limit, cursor}` and answers `{videos, nextCursor}` — title is matched lexically with the last word as a prefix, categories narrow to videos filed under any one of them, limit defaults to 20 and is capped at 100, and cursor is the opaque `nextCursor` of a previous search. It is absent here because OpenAPI has no slot for the QUERY method, not because it is unsupported; see handlers.SearchVideos.
title: Thamanyah Discovery API
version: "1.0"
paths:
+31
View File
@@ -11,3 +11,34 @@ type Video struct {
PlaybackURL string `json:"playbackUrl" example:"https://d111111abcdef8.cloudfront.net/videos/abc123/index.m3u8"`
Categories []string `json:"categories" example:"documentary,news"`
}
// SearchRequest is the body of QUERY /api/videos: what a reader is looking
// for. Every member is optional — an empty body browses the whole catalogue.
type SearchRequest struct {
// Title is matched lexically against video titles: the words are all
// required, and the last is treated as a prefix so a part-typed word still
// finds something.
Title string `json:"title" example:"desert fal"`
// Categories narrows the search to videos filed under any one of these
// names — not all of them, so it widens the choice of category while
// narrowing the results. Empty means every category.
Categories []string `json:"categories" example:"documentary,news"`
// Limit is how many videos to return. Absent or zero takes the default of
// 20; anything above the maximum of 100 is capped to it rather than
// refused, and the cursor still reaches the rest.
Limit int `json:"limit" example:"20"`
// Cursor asks for the page after the one a previous search ended at. Send
// back the nextCursor from that search, unchanged; it is opaque, and the
// only thing to do with it is return it.
Cursor string `json:"cursor"`
}
// SearchResults is one page of search results.
type SearchResults struct {
// Videos are the matches, most relevant first. Never null: a search that
// matches nothing is an empty list.
Videos []Video `json:"videos"`
// NextCursor reaches the page after this one. Empty on the last page,
// which is how a reader knows there is no more to ask for.
NextCursor string `json:"nextCursor"`
}
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS videos_search_vector_idx;
ALTER TABLE videos DROP COLUMN IF EXISTS search_vector;
@@ -0,0 +1,25 @@
-- Lexical search over video titles.
--
-- The vector is a generated column rather than something the consumer writes:
-- the catalogue's only writer is SaveVideo, and a title that arrives in an
-- announcement should be searchable because it is stored, not because a second
-- statement remembered to index it. STORED because a GIN index needs the value
-- on disk to index it at all.
--
-- 'arabic' is the text search configuration, not 'english' or 'simple': it
-- stems Arabic (الوثائقي -> وثايق) while leaving Latin-script tokens as written,
-- which is the right trade for a mixed catalogue. The cost is that English
-- words are not stemmed, so "documentaries" does not find "documentary".
--
-- The configuration is named explicitly rather than left to default_text_search_config,
-- which is a per-session GUC — an expression that reads it is only STABLE, and
-- a generated column requires IMMUTABLE. Naming it is also what keeps the
-- column and every query in step: a query built with a different
-- configuration would stem its terms differently and silently match nothing.
ALTER TABLE videos
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('arabic', title)) STORED;
-- GIN, not GiST: the catalogue is read far more than it is written, and GIN
-- answers @@ faster at the cost of a slower update — the right way round here.
CREATE INDEX videos_search_vector_idx ON videos USING GIN (search_vector);
@@ -0,0 +1 @@
DROP INDEX IF EXISTS videos_categories_idx;
@@ -0,0 +1,7 @@
-- Narrowing a search by category.
--
-- categories is a TEXT[] of names, and the search overlaps it against the
-- names a reader asked for (&&). GIN is the index type that operator can use;
-- without it the overlap is a filter applied after the rows are read, which is
-- the whole table once the title term is broad.
CREATE INDEX videos_categories_idx ON videos USING GIN (categories);
@@ -0,0 +1 @@
DROP INDEX IF EXISTS videos_recent_idx;
@@ -0,0 +1,17 @@
-- Browsing the catalogue, and paging through it.
--
-- Every search orders by (rank, created_at, id). When there is no title term
-- the rank is a constant, and Postgres folds a constant sort key away — so the
-- order that remains is exactly this index, and a browse becomes an index-only
-- scan that stops as soon as the page is full instead of sorting the table.
--
-- Measured at 200k rows: a first page of a browse goes from a 15 ms parallel
-- sequential scan with a top-N sort to 0.03 ms. It matters more for the cursor
-- than for the first page — the keyset comparison (created_at, id) < (…)
-- becomes an index condition rather than a filter, so a deep page is a seek
-- to the right place in the index rather than a walk through everything
-- before it.
--
-- DESC on both columns to match the ORDER BY: a btree can be read backwards,
-- but only as a whole, so a mixed-direction ordering could not use it.
CREATE INDEX videos_recent_idx ON videos (created_at DESC, id DESC);
@@ -7,3 +7,8 @@ import "errors"
// ErrVideoNotFound reports that the catalogue holds no video with that id.
var ErrVideoNotFound = errors.New("video not found")
// ErrInvalidCursor reports that a paging cursor could not be read. It is the
// caller's mistake, not a fault in the catalogue, so it must not reach the
// handler as an indistinguishable query failure.
var ErrInvalidCursor = errors.New("invalid cursor")
@@ -3,8 +3,13 @@ package repositories
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"strings"
"thamanyah/discovery/internal/models"
"time"
"unicode"
"github.com/lib/pq"
)
@@ -57,3 +62,191 @@ func (svc VideoRepository) GetVideoByID(ctx context.Context, id string) (models.
return v, nil
}
// searchConfig is the text search configuration the search_vector column is
// generated with. Every query has to name the same one: a tsquery built under
// a different configuration stems its terms differently and matches nothing.
const searchConfig = "arabic"
// tsQueryFor turns what a reader typed into a tsquery.
//
// The words are ANDed, and the last one is given a :* prefix match — someone
// typing into a search box is usually part-way through their last word, so
// "desert fal" should still find "Desert Falcons". Earlier words are matched
// whole, because they have been finished.
//
// The input is split on everything that is not a letter or a digit, which
// drops every character tsquery gives a meaning to (&, |, !, :, *, parens) and
// leaves nothing that could change the shape of the query. That is what makes
// it safe to interpolate the result into to_tsquery: the alternative,
// websearch_to_tsquery, parses user syntax but cannot express a prefix match.
//
// It returns "" when there is nothing to search for, which callers read as
// "no title filter" rather than "match nothing".
func tsQueryFor(term string) string {
words := strings.FieldsFunc(term, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
if len(words) == 0 {
return ""
}
for i, word := range words {
words[i] = strings.ToLower(word)
}
return strings.Join(words, " & ") + ":*"
}
// searchCursor is where a page of results stopped: the sort key of its last
// row. Paging resumes by asking for the rows that sort after it.
//
// It carries the rank as well as the row's identity because the results are
// ordered by relevance first, and "the row after this one" is only a
// well-defined place if every term of the ordering is pinned.
type searchCursor struct {
Rank float64 `json:"r"`
CreatedAt time.Time `json:"t"`
ID string `json:"i"`
}
// encode renders a cursor as an opaque string. Opaque on purpose: it is a
// place in a result set, not a number a client should do arithmetic on, and
// making it look like one invites callers to guess a page.
func (c searchCursor) encode() (string, error) {
raw, err := json.Marshal(c)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func decodeCursor(encoded string) (searchCursor, error) {
raw, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return searchCursor{}, ErrInvalidCursor
}
var c searchCursor
if err := json.Unmarshal(raw, &c); err != nil {
return searchCursor{}, ErrInvalidCursor
}
if strings.TrimSpace(c.ID) == "" {
return searchCursor{}, ErrInvalidCursor
}
return c, nil
}
// SearchVideos finds catalogued videos whose title matches the given term and
// which are filed under any of the given categories, most relevant first.
//
// It returns the page and the cursor that reaches the page after it; that
// cursor is empty on the last page, which is how a caller knows to stop.
//
// The title predicate is skipped entirely when the term yields no words, and
// the category predicate when no categories are named, so a search with
// neither browses the whole catalogue newest-first.
//
// Paging is by key rather than by offset. OFFSET makes the database walk and
// discard every row it skips, so deep pages get steadily more expensive, and
// it addresses a page by its position — which moves whenever a video is
// announced, repeating or skipping rows for a reader mid-browse. Comparing
// against the last row's sort key has neither problem: the work is the same at
// any depth, and the page after a given row is that same page however much has
// been added since.
func (svc VideoRepository) SearchVideos(ctx context.Context, term string, categories []string, limit int, cursor string) ([]models.Video, string, error) {
query := tsQueryFor(term)
// Never nil: a nil slice reaches Postgres as NULL, and cardinality(NULL)
// is NULL rather than 0, so the "no category filter" branch would not fire
// and every search would come back empty.
if categories == nil {
categories = []string{}
}
// A zero cursor is passed as NULL, which the query reads as "start at the
// beginning" rather than as a row to seek past.
var after *searchCursor
if strings.TrimSpace(cursor) != "" {
decoded, err := decodeCursor(cursor)
if err != nil {
return nil, "", err
}
after = &decoded
}
var (
afterRank *float64
afterTime *time.Time
afterID *string
)
if after != nil {
afterRank, afterTime, afterID = &after.Rank, &after.CreatedAt, &after.ID
}
// One row more than asked for: if it comes back, there is another page and
// the extra row is discarded. That is what lets the last page say so,
// rather than handing out a cursor to an empty page.
//
// The rank is computed in the subquery and referenced by name in both the
// cursor comparison and the ORDER BY, so the two cannot drift apart — if
// they did, "the row after this one" would mean something different from
// the order the rows are actually in, and pages would overlap.
rows, err := svc.SQLDB.QueryContext(ctx, `
SELECT id, title, playback_url, categories, created_at, rank
FROM (
SELECT id, title, playback_url, categories, created_at,
CASE WHEN $1 = '' THEN 0::float8
ELSE ts_rank(search_vector, to_tsquery('`+searchConfig+`', $1))::float8
END AS rank
FROM videos
WHERE ($1 = '' OR search_vector @@ to_tsquery('`+searchConfig+`', $1))
AND (cardinality($2::text[]) = 0 OR categories && $2)
) ranked
WHERE $4::float8 IS NULL
OR (rank, created_at, id) < ($4::float8, $5::timestamptz, $6::uuid)
ORDER BY rank DESC, created_at DESC, id DESC
LIMIT $3
`, query, pq.Array(categories), limit+1, afterRank, afterTime, afterID)
if err != nil {
return nil, "", err
}
defer rows.Close()
// Never nil: a search that matches nothing is an empty list on the wire,
// not null.
videos := []models.Video{}
ranks := []float64{}
for rows.Next() {
var (
v models.Video
rank float64
)
if err := rows.Scan(&v.ID, &v.Title, &v.PlaybackURL, pq.Array(&v.Categories), &v.CreatedAt, &rank); err != nil {
return nil, "", err
}
videos = append(videos, v)
ranks = append(ranks, rank)
}
if err := rows.Err(); err != nil {
return nil, "", err
}
if len(videos) <= limit {
return videos, "", nil
}
videos, ranks = videos[:limit], ranks[:limit]
last := videos[len(videos)-1]
next, err := searchCursor{
Rank: ranks[len(ranks)-1],
CreatedAt: last.CreatedAt,
ID: last.ID,
}.encode()
if err != nil {
return nil, "", err
}
return videos, next, nil
}
+74
View File
@@ -1,6 +1,7 @@
package handlers
import (
"encoding/json"
"errors"
"log"
"net/http"
@@ -47,3 +48,76 @@ func GetVideo(w http.ResponseWriter, r *http.Request) {
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})
}
+5
View File
@@ -23,6 +23,8 @@ import (
// @title Thamanyah Discovery API
// @version 1.0
// @description JSON API for browsing the Thamanyah catalogue. Read-side counterpart to the CMS, which is what ingests videos.
// @description
// @description Not listed below: `QUERY /api/videos`, the catalogue search. It takes a JSON body `{title, categories, limit, cursor}` and answers `{videos, nextCursor}` — title is matched lexically with the last word as a prefix, categories narrow to videos filed under any one of them, limit defaults to 20 and is capped at 100, and cursor is the opaque `nextCursor` of a previous search. It is absent here because OpenAPI has no slot for the QUERY method, not because it is unsupported; see handlers.SearchVideos.
// @BasePath /
func main() {
@@ -81,6 +83,9 @@ func runServer() {
mux.HandleFunc("GET /health", handlers.Health)
mux.HandleFunc("GET /api/videos/{id}", handlers.GetVideo)
// QUERY, not GET: a search is safe and idempotent but carries a structured
// body. See handlers.SearchVideos for why it is absent from the spec.
mux.HandleFunc("QUERY /api/videos", handlers.SearchVideos)
// Swagger UI and the generated spec. The UI assets are embedded in the
// binary by swaggo/files, so this needs no static directory on disk.