FEAT: Discovery Video Service Subscription
Build, Push and Deploy CMS / build-push-deploy (push) Successful in 2m35s
Build, Push and Deploy Discovery / build-push-deploy (push) Successful in 1m55s
Deploy Infrastructure / pulumi-up (push) Successful in 2s

This commit is contained in:
FahdShalhoub
2026-08-29 16:50:58 +03:00
parent 98950aba20
commit 6f9e04ee97
34 changed files with 1754 additions and 65 deletions
+25 -1
View File
@@ -187,7 +187,7 @@ func (c MediaConvertEvents) handleJobEvent(ctx context.Context, body string) boo
}
}
err := repositories.VideoRepo.UpdateVideoOutcomeByJobID(ctx, event.Detail.JobID, status, playbackURL)
video, err := repositories.VideoRepo.UpdateVideoOutcomeByJobID(ctx, event.Detail.JobID, status, playbackURL)
if errors.Is(err, repositories.ErrVideoNotFound) {
// Not a fault: the topic carries every job in the account, including
// ones this service never submitted.
@@ -202,5 +202,29 @@ func (c MediaConvertEvents) handleJobEvent(ctx context.Context, body string) boo
}
log.Printf("Recorded A Job Outcome: job=%q status=%q playback=%q", event.Detail.JobID, status, playbackURL)
// Only a video that came out ready is worth showing, so only that one is
// announced. A failed job has already had its status recorded above, and
// the catalogue has no use for it.
if status != models.VideoStatusReady {
return true
}
if err := services.CatalogueClient.AnnounceVideo(ctx, services.CatalogueVideo{
VideoID: video.ID,
Title: video.Title,
PlaybackURL: video.PlaybackURL,
Categories: video.CategoryNames,
}); err != nil {
// Worth another attempt: the outcome is already recorded, and the
// update is idempotent, so a redelivery re-runs it and tries the
// announcement again. The cost is that a consumer can see the same
// video twice, which is what at-least-once delivery means anyway.
log.Printf("Something Went Wrong Announcing A Ready Video: video=%q job=%q: %s",
video.ID, event.Detail.JobID, err)
return false
}
log.Printf("Announced A Ready Video: video=%q playback=%q", video.ID, video.PlaybackURL)
return true
}
+36 -9
View File
@@ -21,25 +21,52 @@ var VideoRepo VideoRepository
// is cleared along with the status. It returns ErrVideoNotFound when no video
// carries that job id, which the consumer treats as a message to drop rather
// than a fault.
func (svc VideoRepository) UpdateVideoOutcomeByJobID(ctx context.Context, jobID string, status models.VideoStatus, playbackURL string) error {
result, err := svc.SQLDB.ExecContext(ctx, `
//
// It returns the video it updated, carrying the members an announcement is
// built from — the row's id and title, and the names of the categories it is
// filed under. The consumer would otherwise have to read back what it just
// wrote, and reading it here keeps that SQL on this side of the seam.
func (svc VideoRepository) UpdateVideoOutcomeByJobID(ctx context.Context, jobID string, status models.VideoStatus, playbackURL string) (models.Video, error) {
updated := models.Video{
Status: status,
PlaybackURL: playbackURL,
MediaConvertJobID: jobID,
}
err := svc.SQLDB.QueryRowContext(ctx, `
UPDATE videos
SET status = $1, playback_url = $2, updated_at = now()
WHERE mediaconvert_job_id = $3
`, string(status), playbackURL, jobID)
RETURNING id, title
`, string(status), playbackURL, jobID).Scan(&updated.ID, &updated.Title)
if errors.Is(err, sql.ErrNoRows) {
return models.Video{}, ErrVideoNotFound
}
if err != nil {
return err
return models.Video{}, err
}
affected, err := result.RowsAffected()
rows, err := svc.SQLDB.QueryContext(ctx, `
SELECT c.name
FROM video_categories vc
JOIN categories c ON c.id = vc.category_id
WHERE vc.video_id = $1
ORDER BY c.name
`, updated.ID)
if err != nil {
return err
return models.Video{}, err
}
if affected == 0 {
return ErrVideoNotFound
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return models.Video{}, err
}
updated.CategoryNames = append(updated.CategoryNames, name)
}
return nil
return updated, rows.Err()
}
// GetVideoByID reads one video and the ids of the categories it is filed
+8 -4
View File
@@ -3,10 +3,14 @@ package models
import "time"
type Video struct {
ID string
Title string
Description string
CategoryIDs []int16
ID string
Title string
Description string
CategoryIDs []int16
// CategoryNames is the same filing read by name rather than id. It is
// filled only where a caller needs categories to be self-describing —
// the catalogue announcement — and is empty elsewhere.
CategoryNames []string
Tags string
FileName string
StorageKey string
+75
View File
@@ -0,0 +1,75 @@
package services
import (
"context"
"encoding/json"
"fmt"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/sns"
)
var CatalogueClient Catalogue
// CatalogueVideo is what cms announces when a video becomes ready. It is a
// published contract — every subscriber of the catalogue topic decodes it —
// so the JSON names here are as much an API as the ones in internal/api, and
// changing one is a change every subscriber sees.
//
// Categories are named rather than numbered on purpose: the ids are cms's
// own, and a consumer holding a name needs nothing from cms to make sense
// of it.
type CatalogueVideo struct {
VideoID string `json:"videoId"`
Title string `json:"title"`
PlaybackURL string `json:"playbackUrl"`
Categories []string `json:"categories"`
}
type Catalogue interface {
AnnounceVideo(ctx context.Context, video CatalogueVideo) error
}
type CatalogueConcrete struct {
SNSClient *sns.Client
TopicARN string
}
// AnnounceVideo publishes one video to the catalogue topic, which fans it out
// to whatever has subscribed. Delivery is at-least-once: the caller
// republishes when a later step fails, so a subscriber has to tolerate seeing
// the same video twice.
//
// A topic delivers only to the subscriptions that exist at the moment it
// publishes, so a subscriber that wants the backlog needs its queue in place
// before the announcement, not after.
func (svc CatalogueConcrete) AnnounceVideo(ctx context.Context, video CatalogueVideo) error {
// A nil slice marshals to null, which would make "has no categories" and
// "categories unknown" indistinguishable to a reader. An empty list says
// the first, which is what an unfiled video means.
if video.Categories == nil {
video.Categories = []string{}
}
body, err := json.Marshal(video)
if err != nil {
return err
}
_, err = svc.SNSClient.Publish(ctx, &sns.PublishInput{
TopicArn: aws.String(svc.TopicARN),
Message: aws.String(string(body)),
})
return err
}
// AssertSuccessfulConnection verifies the topic exists and is readable with
// the configured credentials. It panics on failure: a service that cannot
// announce would transcode videos the catalogue never hears about.
func (svc CatalogueConcrete) AssertSuccessfulConnection(ctx context.Context) {
if _, err := svc.SNSClient.GetTopicAttributes(ctx, &sns.GetTopicAttributesInput{
TopicArn: aws.String(svc.TopicARN),
}); err != nil {
panic(fmt.Errorf("sns: cannot connect to catalogue topic %q: %w", svc.TopicARN, err))
}
}