FEAT: Discovery Video Service Subscription
This commit is contained in:
+4
-3
@@ -3,7 +3,7 @@ module thamanyah/cms/v2
|
||||
go 1.25.12
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.44.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.45.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.36
|
||||
github.com/aws/aws-sdk-go-v2/service/mediaconvert v1.97.2
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.1
|
||||
@@ -20,14 +20,15 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.35 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.36 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.37 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.36 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.37 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.5.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sns v1.44.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.33.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.45.5 // indirect
|
||||
|
||||
@@ -6,6 +6,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.44.0 h1:4IbaHhtzy+4h37z4JQyO9a2QsiCml3CNYHtq5hIHigo=
|
||||
github.com/aws/aws-sdk-go-v2 v1.44.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks=
|
||||
github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.17 h1:mn+Vxb9zgz/FE/yDTcFim3DZ1qpcrxR+qBQkBrl6bzA=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.17/go.mod h1:eDfmEFxu+BSVsUGLbzJhWjpOurv1mqczClS97yI8wdk=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.36 h1:mX6ietU7UlB4w/2IUaexJdsyUDvhTd+jYPjVePiyi6s=
|
||||
@@ -16,8 +18,12 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.36 h1:gucL1KH/PAYbpTpBg09CiV
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.36/go.mod h1:usTB+PHhNMhrx2dxUeHcM7OrT5pySvmjYI++IsefPN0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40 h1:UIXlbijuB2XK1Kr57fo8iIxCuaSHJzwZ1uo+2tbEYIk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40/go.mod h1:wcEsL6jscjZjVUinb0Q5qD/GXOG1yT3GNfmT9HuDwzU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 h1:pc138gM1CW+XPc60rEwUlwwuwWFQK16CI1T7v1F9Oec=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1/go.mod h1:1+koxpPIbfBdfzP6vojm5/zTpTQ/micYwlxIiNB3TxI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40 h1:xLQVRDs2NddDmK9BEyh5KSlJ1Gpy5/GIJXrV6WcVGAE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40/go.mod h1:XRXnpFVFGLaEVK+olDdFIM1vNa04ETW452oFGEPUxAo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 h1:K0JsbZQj+1h208Ro1zHeA4l7bMp0NvRffHQ91q8Ol1s=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1/go.mod h1:W3/vL6EtCIatICGy9ab29QhMuae+cOKPWcMxv02CO+Q=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.37 h1:oyd3ke4V9AhKcRR7rRgxk1VyI+DjK2CBQtbxh3OkdaA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.37/go.mod h1:aA9D7SqfG9IC1b7FLD7Iyc8Q4JN0a8gHhNjN4zPlIaI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.16 h1:iE4NGbvqUZnHDqddQAauZzCILYtFjOHwRM5MOOKLB5A=
|
||||
@@ -34,6 +40,8 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.107.1 h1:VUTtUJMuRNMkb/7NIKmd8NQaeQLP
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.1/go.mod h1:WvUaO0lP5GNMs1R6cs6qvB3mqo16GLta8yfOuf55Rpc=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.5.5 h1:0VTFBfOgPJrUSpGMgzoi8qLcXF5dbmiBuxpo14eBWUw=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.5.5/go.mod h1:sNZYlBxoohYMBYl47BO/bFtAM6I8HSsPa1qwwPPRGoQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sns v1.44.1 h1:dMIcbUQ8fPJPbX9jZV19JtL2lCAgEh1LUNlpe6sdgqE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sns v1.44.1/go.mod h1:ucBUMGW8avqGmbyQoXyoC6Cgt+WsNBrhL9DA4Bb+jN4=
|
||||
github.com/aws/aws-sdk-go-v2/service/sqs v1.47.0 h1:vNsYthHgT4sUo0KVqpkZlz+8ZDqy/MdlqdvZdP6IoAc=
|
||||
github.com/aws/aws-sdk-go-v2/service/sqs v1.47.0/go.mod h1:FSB4mnod1TCBhs3vp2tWVVGHbqxluzA0Fo6LBOXZByw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.33.5 h1:jDQARFp1mJ2PEnllQf01nfFXGfWMJ59e0/HCHUTTZCk=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+16
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/service/mediaconvert"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/sns"
|
||||
"github.com/aws/aws-sdk-go-v2/service/sqs"
|
||||
_ "github.com/lib/pq"
|
||||
httpSwagger "github.com/swaggo/http-swagger/v2"
|
||||
@@ -73,6 +74,13 @@ func runServer() {
|
||||
panic(fmt.Errorf("missing required env var: MEDIACONVERT_EVENTS_QUEUE_URL"))
|
||||
}
|
||||
|
||||
// The topic ready videos are announced on, which fans out to whatever has
|
||||
// subscribed — the read side's queue, today.
|
||||
catalogueEventsTopicARN := os.Getenv("CATALOGUE_EVENTS_TOPIC_ARN")
|
||||
if catalogueEventsTopicARN == "" {
|
||||
panic(fmt.Errorf("missing required env var: CATALOGUE_EVENTS_TOPIC_ARN"))
|
||||
}
|
||||
|
||||
// The public host the encoded output is served from — the CloudFront
|
||||
// distribution in front of the output bucket. MediaConvert reports where
|
||||
// it wrote as an s3:// path into a bucket that blocks public access, so
|
||||
@@ -117,6 +125,14 @@ func runServer() {
|
||||
|
||||
services.SQSClient = concreteSQSClient
|
||||
|
||||
concreteCatalogueClient := &services.CatalogueConcrete{
|
||||
SNSClient: sns.NewFromConfig(awsConfig),
|
||||
TopicARN: catalogueEventsTopicARN,
|
||||
}
|
||||
concreteCatalogueClient.AssertSuccessfulConnection(context.Background())
|
||||
|
||||
services.CatalogueClient = concreteCatalogueClient
|
||||
|
||||
concreteDBClient := db.CreateDBConnection(requireDBConnectionString())
|
||||
defer db.CloseConnection(concreteDBClient)
|
||||
db.AssertSuccessfulConnection(context.Background(), concreteDBClient)
|
||||
|
||||
Reference in New Issue
Block a user