···2626| `createdAt` | `string` | ✅ | Client-declared timestamp when this livestream started. | Format: `datetime` |
2727| `lastSeenAt` | `string` | ❌ | Client-declared timestamp when this livestream was last seen by the Streamplace station. | Format: `datetime` |
2828| `endedAt` | `string` | ❌ | Client-declared timestamp when this livestream ended. Ended livestreams are not supposed to start up again. | Format: `datetime` |
2929+| `idleTimeoutSeconds` | `integer` | ❌ | Time in seconds after which this livestream should be automatically ended if idle. Zero means no timeout. | |
2930| `post` | [`com.atproto.repo.strongRef`](https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo/strongref.json#undefined) | ❌ | The post that announced this livestream. | |
3031| `agent` | `string` | ❌ | The source of the livestream, if available, in a User Agent format: `<product> / <product-version> <comment>` e.g. Streamplace/0.7.5 iOS | |
3132| `canonicalUrl` | `string` | ❌ | The primary URL where this livestream can be viewed, if available. | Format: `uri` |
···168169 "type": "string",
169170 "format": "datetime",
170171 "description": "Client-declared timestamp when this livestream ended. Ended livestreams are not supposed to start up again."
172172+ },
173173+ "idleTimeoutSeconds": {
174174+ "type": "integer",
175175+ "description": "Time in seconds after which this livestream should be automatically ended if idle. Zero means no timeout."
171176 },
172177 "post": {
173178 "type": "ref",
+4
lexicons/place/stream/livestream.json
···3636 "format": "datetime",
3737 "description": "Client-declared timestamp when this livestream ended. Ended livestreams are not supposed to start up again."
3838 },
3939+ "idleTimeoutSeconds": {
4040+ "type": "integer",
4141+ "description": "Time in seconds after which this livestream should be automatically ended if idle. Zero means no timeout."
4242+ },
3943 "post": {
4044 "type": "ref",
4145 "ref": "com.atproto.repo.strongRef",
+17-13
pkg/atproto/sync.go
···372372 task := &statedb.FinalizeLivestreamTask{
373373 LivestreamURI: aturi.String(),
374374 }
375375- if rec.LastSeenAt != nil {
376376- scheduledAt, err := time.Parse(time.RFC3339, *rec.LastSeenAt)
377377- if err == nil {
378378- scheduledAt = scheduledAt.Add(constants.LivestreamInactiveCheckInterval).UTC()
379379- taskKey := fmt.Sprintf("finalize-livestream::%s::%s", aturi.String(), scheduledAt.Format(util.ISO8601))
380380- log.Warn(ctx, "queueing remove red circle task", "taskKey", taskKey, "scheduledAt", scheduledAt)
381381- _, err = atsync.StatefulDB.EnqueueTask(ctx, statedb.TaskFinalizeLivestream, task, statedb.WithTaskKey(taskKey), statedb.WithScheduledAt(scheduledAt))
382382- if err != nil {
383383- return fmt.Errorf("failed to enqueue remove red circle task: %w", err)
384384- }
385385- } else {
386386- log.Error(ctx, "failed to parse last seen at", "err", err)
387387- }
375375+ if rec.LastSeenAt == nil || rec.IdleTimeoutSeconds == nil || *rec.IdleTimeoutSeconds == 0 || rec.EndedAt != nil {
376376+ return nil
388377 }
378378+ scheduledAt, err := time.Parse(time.RFC3339, *rec.LastSeenAt)
379379+ if err != nil {
380380+ log.Error(ctx, "failed to parse last seen at", "err", err)
381381+ return nil
382382+ }
383383+384384+ // if we check after exactly rec.IdleTimeoutSeconds we might miss the finalization by a few seconds
385385+ scheduledAt = scheduledAt.Add((time.Duration(*rec.IdleTimeoutSeconds) * time.Second) + (10 * time.Second)).UTC()
386386+ taskKey := fmt.Sprintf("finalize-livestream::%s::%s", aturi.String(), scheduledAt.Format(util.ISO8601))
387387+ log.Warn(ctx, "queueing stream finalization task", "taskKey", taskKey, "scheduledAt", scheduledAt)
388388+ _, err = atsync.StatefulDB.EnqueueTask(ctx, statedb.TaskFinalizeLivestream, task, statedb.WithTaskKey(taskKey), statedb.WithScheduledAt(scheduledAt))
389389+ if err != nil {
390390+ return fmt.Errorf("failed to enqueue remove red circle task: %w", err)
391391+ }
392392+389393 }
390394391395 case *streamplace.LiveTeleport:
+6-3
pkg/statedb/queue_processor.go
···1212 lexutil "github.com/bluesky-social/indigo/lex/util"
1313 "github.com/bluesky-social/indigo/xrpc"
1414 "gorm.io/gorm"
1515- "stream.place/streamplace/pkg/constants"
1615 "stream.place/streamplace/pkg/integrations/webhook"
1716 "stream.place/streamplace/pkg/log"
1817 notificationpkg "stream.place/streamplace/pkg/notifications"
···105104 if err != nil {
106105 return fmt.Errorf("could not parse last seen at: %w", err)
107106 }
108108- if time.Since(lastSeenTime) < constants.LivestreamConsideredInactiveAfter {
109109- log.Warn(ctx, "livestream is active, skipping removal of red circle", "lastSeenAt", lastSeenTime)
107107+ if rec.IdleTimeoutSeconds == nil || *rec.IdleTimeoutSeconds == 0 {
108108+ log.Warn(ctx, "livestream has no idle timeout, skipping finalization", "uri", livestream.URI)
109109+ return nil
110110+ }
111111+ if time.Since(lastSeenTime) < (time.Duration(*rec.IdleTimeoutSeconds) * time.Second) {
112112+ log.Warn(ctx, "livestream is active, skipping finalization", "lastSeenAt", lastSeenTime)
110113 return nil
111114 }
112115 session, err := state.GetSessionByDID(livestream.RepoDID)
···2727 CreatedAt string `json:"createdAt" cborgen:"createdAt"`
2828 // endedAt: Client-declared timestamp when this livestream ended. Ended livestreams are not supposed to start up again.
2929 EndedAt *string `json:"endedAt,omitempty" cborgen:"endedAt,omitempty"`
3030+ // idleTimeoutSeconds: Time in seconds after which this livestream should be automatically ended if idle. Zero means no timeout.
3131+ IdleTimeoutSeconds *int64 `json:"idleTimeoutSeconds,omitempty" cborgen:"idleTimeoutSeconds,omitempty"`
3032 // lastSeenAt: Client-declared timestamp when this livestream was last seen by the Streamplace station.
3133 LastSeenAt *string `json:"lastSeenAt,omitempty" cborgen:"lastSeenAt,omitempty"`
3234 NotificationSettings *Livestream_NotificationSettings `json:"notificationSettings,omitempty" cborgen:"notificationSettings,omitempty"`