diff --git a/pkg/spxrpc/place_stream_media_finalizelivestream.go b/pkg/spxrpc/place_stream_media_finalizelivestream.go index 7bebfb35..0ac1d9a2 100644 --- a/pkg/spxrpc/place_stream_media_finalizelivestream.go +++ b/pkg/spxrpc/place_stream_media_finalizelivestream.go @@ -14,9 +14,11 @@ import ( // handlePlaceStreamMediaFinalizeLivestream turns a finished livestream into a // VOD. It creates a synthetic Upload row and enqueues a background finalize // task that concatenates the livestream's recorded MUXL objects into a content -// blob and publishes the track records — landing in the same place a finished -// resumable upload does, so the client polls getUploadStatus and then -// publishVideo with the returned uploadId, unchanged. +// blob and publishes the track records. It also creates a draft VOD in the +// 'processing' state (inheriting the livestream's metadata) that the user +// publishes later from the Drafts tab via place.stream.vod.publishDraft — the +// client no longer polls getUploadStatus or calls publishVideo. Returns the +// draft's ats:// URI so the client can navigate to it. func (s *Server) handlePlaceStreamMediaFinalizeLivestream(ctx context.Context, body *placestream.MediaFinalizeLivestream_Input) (*placestream.MediaFinalizeLivestream_Output, error) { session, _ := oatproxy.GetOAuthSession(ctx) if session == nil { diff --git a/pkg/statedb/draft_video.go b/pkg/statedb/draft_video.go index 397fd7ec..fa024663 100644 --- a/pkg/statedb/draft_video.go +++ b/pkg/statedb/draft_video.go @@ -10,6 +10,7 @@ import ( comatproto "github.com/bluesky-social/indigo/api/atproto" "gorm.io/gorm" + "gorm.io/gorm/clause" "stream.place/streamplace/pkg/spid" "stream.place/streamplace/pkg/streamplace" ) @@ -164,7 +165,13 @@ func (state *StatefulDB) ListDrafts(ctx context.Context, did string, limit int, func (state *StatefulDB) UpdateDraftMetadata(ctx context.Context, uri string, apply func(rec *streamplace.VodDraftVideo)) (*DraftVideo, error) { var dv DraftVideo err := state.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Where("uri = ?", uri).First(&dv).Error; err != nil { + // FOR UPDATE (Postgres) so a concurrent SetDraftReady/SetDraftError + // transaction blocks until this one commits — preventing a lost update + // where the user's metadata edit clobbers the server's ready/error + // transition (or vice versa). SQLite serializes writes, so the clause + // is a no-op there. + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("uri = ?", uri).First(&dv).Error; err != nil { return err } rec, err := unmarshalDraft(dv.Data) @@ -268,8 +275,12 @@ func (state *StatefulDB) SetDraftError(ctx context.Context, originUploadID, errM // denormalized SQL columns (via applyRow) for the draft tied to originUploadID. func (state *StatefulDB) updateDraftByUpload(ctx context.Context, originUploadID string, apply func(rec *streamplace.VodDraftVideo), applyRow func(dv *DraftVideo)) error { return state.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // FOR UPDATE (Postgres) — see UpdateDraftMetadata. A concurrent user + // metadata edit blocks until this ready/error transition commits, so it + // re-reads the new status rather than clobbering it. var dv DraftVideo - if err := tx.Where("origin_upload_id = ?", originUploadID).First(&dv).Error; err != nil { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("origin_upload_id = ?", originUploadID).First(&dv).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { // No draft for this upload (e.g. a pre-drafts-era upload, or a // draft that was deleted). Nothing to update; not an error. diff --git a/pkg/vod/publish_draft.go b/pkg/vod/publish_draft.go index 8f7f9f94..f0c9794d 100644 --- a/pkg/vod/publish_draft.go +++ b/pkg/vod/publish_draft.go @@ -76,9 +76,6 @@ func PublishDraft(ctx context.Context, state *statedb.StatefulDB, store blob.Sto DurationMs: derefInt64(rec.DurationMs), CreatedAt: time.Now().UTC().Format(time.RFC3339), } - if rec.Description != nil { - video.Description = rec.Description - } video.DescriptionFacets = rec.DescriptionFacets video.Tags = rec.Tags video.Connections = draftConnectionsToVideo(rec.Connections)