diff --git a/js/app/src/screens/upload.tsx b/js/app/src/screens/upload.tsx
index 28173e3eb..ba8aae3d4 100644
--- a/js/app/src/screens/upload.tsx
+++ b/js/app/src/screens/upload.tsx
@@ -348,6 +348,10 @@ export default function UploadScreen() {
const record: PlaceStreamVideo.Record = {
$type: "place.stream.video",
title: title.trim() || file?.name || "Untitled",
+ // Like source + durationMs, createdAt is server-authoritative: the
+ // server overrides this with the publish time. We still send a value
+ // to satisfy the (required) record type.
+ createdAt: new Date().toISOString(),
durationMs,
source: {
$type: "place.stream.media.defs#sourceTracks",
@@ -505,6 +509,9 @@ export default function UploadScreen() {
const existingRec = existing.data.value as any;
record.source = existingRec.source;
record.durationMs = existingRec.durationMs;
+ // Preserve the original creation timestamp on edit; fall back for
+ // records that predate the createdAt field.
+ record.createdAt = existingRec.createdAt || new Date().toISOString();
if (description.trim()) record.description = description.trim();
if (activity) record.activity = activity;
if (tags.length > 0) record.tags = tags;
diff --git a/js/docs/src/content/docs/lex-reference/place-stream-video.md b/js/docs/src/content/docs/lex-reference/place-stream-video.md
index 6604c169e..586c8053a 100644
--- a/js/docs/src/content/docs/lex-reference/place-stream-video.md
+++ b/js/docs/src/content/docs/lex-reference/place-stream-video.md
@@ -22,6 +22,7 @@ Some audiovisual content.
| Name | Type | Req'd | Description | Constraints |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| `title` | `string` | ✅ | Title of the video referenced by this record | Max Length: 1400
Max Graphemes: 140 |
+| `createdAt` | `string` | ✅ | Timestamp when this video record was created. Populated server-side at publication time by place.stream.media.publishVideo. | Format: `datetime` |
| `source` | Union of:
[`place.stream.media.defs#sourceTracks`](/lex-reference/place-stream-media-defs#sourcetracks)
[`place.stream.media.defs#sourceClip`](/lex-reference/place-stream-media-defs#sourceclip) | ✅ | What is the source of this video? | |
| `description` | `string` | ❌ | Description of this video | Max Length: 50000
Max Graphemes: 5000 |
| `durationMs` | `integer` | ✅ | Duration of the video in milliseconds | |
@@ -61,7 +62,7 @@ Some audiovisual content.
"description": "Some audiovisual content.",
"key": "tid",
"record": {
- "required": ["title", "source", "durationMs"],
+ "required": ["title", "source", "durationMs", "createdAt"],
"type": "object",
"properties": {
"title": {
@@ -70,6 +71,11 @@ Some audiovisual content.
"maxGraphemes": 140,
"description": "Title of the video referenced by this record"
},
+ "createdAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Timestamp when this video record was created. Populated server-side at publication time by place.stream.media.publishVideo."
+ },
"source": {
"type": "union",
"refs": [
diff --git a/lexicons/place/stream/video.json b/lexicons/place/stream/video.json
index d72d66652..9f3a7aaf9 100644
--- a/lexicons/place/stream/video.json
+++ b/lexicons/place/stream/video.json
@@ -7,7 +7,7 @@
"description": "Some audiovisual content.",
"key": "tid",
"record": {
- "required": ["title", "source", "durationMs"],
+ "required": ["title", "source", "durationMs", "createdAt"],
"type": "object",
"properties": {
"title": {
@@ -16,6 +16,11 @@
"maxGraphemes": 140,
"description": "Title of the video referenced by this record"
},
+ "createdAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Timestamp when this video record was created. Populated server-side at publication time by place.stream.media.publishVideo."
+ },
"source": {
"type": "union",
"refs": [
diff --git a/pkg/streamplace/cbor_gen.go b/pkg/streamplace/cbor_gen.go
index 71aa568bf..c9f37b356 100644
--- a/pkg/streamplace/cbor_gen.go
+++ b/pkg/streamplace/cbor_gen.go
@@ -8292,7 +8292,7 @@ func (t *Video) MarshalCBOR(w io.Writer) error {
}
cw := cbg.NewCborWriter(w)
- fieldCount := 12
+ fieldCount := 13
if t.Activity == nil {
fieldCount--
@@ -8462,6 +8462,29 @@ func (t *Video) MarshalCBOR(w io.Writer) error {
}
}
+ // t.CreatedAt (string) (string)
+ if len("createdAt") > 1000000 {
+ return xerrors.Errorf("Value in field \"createdAt\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("createdAt")); err != nil {
+ return err
+ }
+
+ if len(t.CreatedAt) > 1000000 {
+ return xerrors.Errorf("Value in field t.CreatedAt was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
+ return err
+ }
+
// t.DurationMs (int64) (int64)
if len("durationMs") > 1000000 {
return xerrors.Errorf("Value in field \"durationMs\" was too long")
@@ -8777,6 +8800,17 @@ func (t *Video) UnmarshalCBOR(r io.Reader) (err error) {
}
}
+ // t.CreatedAt (string) (string)
+ case "createdAt":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.CreatedAt = string(sval)
+ }
// t.DurationMs (int64) (int64)
case "durationMs":
{
diff --git a/pkg/streamplace/streamvideo.go b/pkg/streamplace/streamvideo.go
index 0833d9a08..306d94540 100644
--- a/pkg/streamplace/streamvideo.go
+++ b/pkg/streamplace/streamvideo.go
@@ -29,6 +29,8 @@ type Video struct {
ContentRights *MetadataContentRights `json:"contentRights,omitempty" cborgen:"contentRights,omitempty"`
// contentWarnings: content warning data for this VOD
ContentWarnings *MetadataContentWarnings `json:"contentWarnings,omitempty" cborgen:"contentWarnings,omitempty"`
+ // createdAt: Timestamp when this video record was created. Populated server-side at publication time by place.stream.media.publishVideo.
+ CreatedAt string `json:"createdAt" cborgen:"createdAt"`
// description: Description of this video
Description *string `json:"description,omitempty" cborgen:"description,omitempty"`
// descriptionFacets: Annotations of text (mentions, URLs, etc)
diff --git a/pkg/vod/publish_video.go b/pkg/vod/publish_video.go
index bb16366d6..0b96abd87 100644
--- a/pkg/vod/publish_video.go
+++ b/pkg/vod/publish_video.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "time"
comatproto "github.com/bluesky-social/indigo/api/atproto"
lexutil "github.com/bluesky-social/indigo/lex/util"
@@ -62,10 +63,12 @@ func PublishVideo(ctx context.Context, state *statedb.StatefulDB, store blob.Sto
return "", "", ErrUploadNotReady
}
- // Server-authoritative fields: source tracks and duration come from
- // processing, not from whatever the client put in the record.
+ // Server-authoritative fields: source tracks, duration, and the
+ // creation timestamp come from the server at publish time, not from
+ // whatever the client put in the record.
video.LexiconTypeID = constants.PLACE_STREAM_VIDEO
video.DurationMs = upload.DurationMS
+ video.CreatedAt = time.Now().UTC().Format(time.RFC3339)
tracks, err := sourceTracksFromUpload(upload)
if err != nil {
span.RecordError(err)