mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2025-10-03 17:59:37 +02:00
Add Scheduled Lives functionality (#7144)
* Add Scheduled Lives functionality through originallyPublishedAt Implements #6604 by reusing the originallyPublishedAt field of isLive videos to mark "waiting for live" videos as scheduled at a set time. * Hide scheduled lives from Browse Videos page * Add tests for Scheduled Live videos * Make scheduled lives use a dedicated scheduledAt field in the VideoLive table * Plan live schedules to evolve in the future * Use a dedicated table to store live schedules, so we can add multiple schedules in the future and also add a title, description etc. for a specific schedule * Adapt REST API to use an array to store/get live schedules * Add REST API param so it's the client choice to include or not scheduled lives * Export schedules info in user import/export --------- Co-authored-by: Chocobozzz <me@florianbigard.com>
This commit is contained in:
parent
a5c087d3d4
commit
8c9b4abe45
62 changed files with 858 additions and 148 deletions
|
@ -51,15 +51,14 @@ export class AccountVideosComponent implements OnInit, OnDestroy, DisableForReus
|
||||||
}
|
}
|
||||||
|
|
||||||
getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
|
getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
|
||||||
const options = {
|
return this.videoService.listAccountVideos({
|
||||||
...filters.toVideosAPIObject(),
|
...filters.toVideosAPIObject(),
|
||||||
|
|
||||||
videoPagination: pagination,
|
videoPagination: pagination,
|
||||||
account: this.account,
|
account: this.account,
|
||||||
skipCount: true
|
skipCount: true,
|
||||||
}
|
includeScheduledLive: true
|
||||||
|
})
|
||||||
return this.videoService.listAccountVideos(options)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getSyndicationItems () {
|
getSyndicationItems () {
|
||||||
|
|
|
@ -57,15 +57,14 @@ export class VideoChannelVideosComponent implements OnInit, AfterViewInit, OnDes
|
||||||
}
|
}
|
||||||
|
|
||||||
getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
|
getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
|
||||||
const params = {
|
return this.videoService.listChannelVideos({
|
||||||
...filters.toVideosAPIObject(),
|
...filters.toVideosAPIObject(),
|
||||||
|
|
||||||
videoPagination: pagination,
|
videoPagination: pagination,
|
||||||
videoChannel: this.videoChannel,
|
videoChannel: this.videoChannel,
|
||||||
|
includeScheduledLive: true,
|
||||||
skipCount: true
|
skipCount: true
|
||||||
}
|
})
|
||||||
|
|
||||||
return this.videoService.listChannelVideos(params)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getSyndicationItems () {
|
getSyndicationItems () {
|
||||||
|
|
|
@ -2,8 +2,14 @@
|
||||||
This video will be published on {{ video().scheduledUpdate.updateAt | ptDate: 'full' }}.
|
This video will be published on {{ video().scheduledUpdate.updateAt | ptDate: 'full' }}.
|
||||||
</my-alert>
|
</my-alert>
|
||||||
|
|
||||||
<my-alert rounded="false" type="primary" i18n *ngIf="isWaitingForLive()">
|
<my-alert rounded="false" type="primary" *ngIf="isWaitingForLive()">
|
||||||
This live is not currently streaming.
|
<ng-container i18n>This live is not currently streaming.</ng-container>
|
||||||
|
|
||||||
|
@if (scheduledLiveDate()) {
|
||||||
|
<ng-container i18n>
|
||||||
|
It will start on {{ scheduledLiveDate() | ptDate: 'short' }}.
|
||||||
|
</ng-container>
|
||||||
|
}
|
||||||
</my-alert>
|
</my-alert>
|
||||||
|
|
||||||
<my-alert rounded="false" type="primary" i18n *ngIf="isLiveEnded()">
|
<my-alert rounded="false" type="primary" i18n *ngIf="isLiveEnded()">
|
||||||
|
|
|
@ -46,4 +46,11 @@ export class VideoAlertComponent {
|
||||||
isVideoPasswordProtected () {
|
isVideoPasswordProtected () {
|
||||||
return this.video()?.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
|
return this.video()?.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheduledLiveDate () {
|
||||||
|
const liveSchedules = this.video()?.liveSchedules
|
||||||
|
if (!liveSchedules || liveSchedules.length === 0) return undefined
|
||||||
|
|
||||||
|
return liveSchedules[0].startAt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -89,7 +89,8 @@ export class VideoGoLiveComponent implements OnInit, AfterViewInit, CanComponent
|
||||||
permanentLive: this.firstStepPermanentLive,
|
permanentLive: this.firstStepPermanentLive,
|
||||||
latencyMode: LiveVideoLatencyMode.DEFAULT,
|
latencyMode: LiveVideoLatencyMode.DEFAULT,
|
||||||
saveReplay: this.isReplayAllowed(),
|
saveReplay: this.isReplayAllowed(),
|
||||||
replaySettings: { privacy: this.highestPrivacy() }
|
replaySettings: { privacy: this.highestPrivacy() },
|
||||||
|
schedules: []
|
||||||
})
|
})
|
||||||
this.manageController.setConfig({ manageType: 'go-live', serverConfig: this.serverService.getHTMLConfig() })
|
this.manageController.setConfig({ manageType: 'go-live', serverConfig: this.serverService.getHTMLConfig() })
|
||||||
this.manageController.setVideoEdit(videoEdit)
|
this.manageController.setVideoEdit(videoEdit)
|
||||||
|
|
|
@ -46,9 +46,12 @@ type CommonUpdateForm =
|
||||||
nsfwFlagSex?: boolean
|
nsfwFlagSex?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type LiveUpdateForm = Omit<LiveVideoUpdate, 'replaySettings'> & {
|
type LiveUpdateForm = Omit<LiveVideoUpdate, 'replaySettings' | 'schedules'> & {
|
||||||
replayPrivacy?: VideoPrivacyType
|
replayPrivacy?: VideoPrivacyType
|
||||||
liveStreamKey?: string
|
liveStreamKey?: string
|
||||||
|
schedules?: {
|
||||||
|
startAt?: Date
|
||||||
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReplaceFileForm = {
|
type ReplaceFileForm = {
|
||||||
|
@ -72,7 +75,7 @@ type CreateFromImportOptions = LoadFromPublishOptions & Pick<VideoImportCreate,
|
||||||
|
|
||||||
type CreateFromLiveOptions =
|
type CreateFromLiveOptions =
|
||||||
& CreateFromUploadOptions
|
& CreateFromUploadOptions
|
||||||
& Required<Pick<LiveVideoCreate, 'permanentLive' | 'latencyMode' | 'saveReplay' | 'replaySettings'>>
|
& Required<Pick<LiveVideoCreate, 'permanentLive' | 'latencyMode' | 'saveReplay' | 'replaySettings' | 'schedules'>>
|
||||||
|
|
||||||
type UpdateFromAPIOptions = {
|
type UpdateFromAPIOptions = {
|
||||||
video?: Pick<
|
video?: Pick<
|
||||||
|
@ -124,6 +127,12 @@ type CommonUpdate = Omit<VideoUpdate, 'thumbnailfile' | 'originallyPublishedAt'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LiveUpdate = Omit<LiveVideoUpdate, 'schedules'> & {
|
||||||
|
schedules?: {
|
||||||
|
startAt: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
export class VideoEdit {
|
export class VideoEdit {
|
||||||
static readonly SPECIAL_SCHEDULED_PRIVACY = -1
|
static readonly SPECIAL_SCHEDULED_PRIVACY = -1
|
||||||
|
|
||||||
|
@ -131,7 +140,7 @@ export class VideoEdit {
|
||||||
private common: CommonUpdate = {}
|
private common: CommonUpdate = {}
|
||||||
private captions: VideoCaptionWithPathEdit[] = []
|
private captions: VideoCaptionWithPathEdit[] = []
|
||||||
private chapters: VideoChaptersEdit = new VideoChaptersEdit()
|
private chapters: VideoChaptersEdit = new VideoChaptersEdit()
|
||||||
private live: LiveVideoUpdate
|
private live: LiveUpdate
|
||||||
private replaceFile: File
|
private replaceFile: File
|
||||||
private studioTasks: VideoStudioTask[] = []
|
private studioTasks: VideoStudioTask[] = []
|
||||||
|
|
||||||
|
@ -175,7 +184,7 @@ export class VideoEdit {
|
||||||
common?: Omit<CommonUpdate, 'pluginData' | 'previewfile'>
|
common?: Omit<CommonUpdate, 'pluginData' | 'previewfile'>
|
||||||
previewfile?: { size: number }
|
previewfile?: { size: number }
|
||||||
|
|
||||||
live?: LiveVideoUpdate
|
live?: LiveUpdate
|
||||||
|
|
||||||
pluginData?: any
|
pluginData?: any
|
||||||
pluginDefaults?: Record<string, string | boolean>
|
pluginDefaults?: Record<string, string | boolean>
|
||||||
|
@ -236,8 +245,13 @@ export class VideoEdit {
|
||||||
permanentLive: options.permanentLive,
|
permanentLive: options.permanentLive,
|
||||||
|
|
||||||
saveReplay: options.saveReplay,
|
saveReplay: options.saveReplay,
|
||||||
|
|
||||||
replaySettings: options.replaySettings
|
replaySettings: options.replaySettings
|
||||||
? { privacy: options.replaySettings.privacy }
|
? { privacy: options.replaySettings.privacy }
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
schedules: options.schedules
|
||||||
|
? options.schedules.map(s => ({ startAt: new Date(s.startAt).toISOString() }))
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -411,6 +425,10 @@ export class VideoEdit {
|
||||||
|
|
||||||
replaySettings: live.replaySettings
|
replaySettings: live.replaySettings
|
||||||
? { privacy: live.replaySettings.privacy }
|
? { privacy: live.replaySettings.privacy }
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
schedules: live.schedules
|
||||||
|
? live.schedules.map(s => ({ startAt: new Date(s.startAt).toISOString() }))
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -620,6 +638,16 @@ export class VideoEdit {
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (values.schedules !== undefined) {
|
||||||
|
if (values.schedules === null || values.schedules.length === 0 || !values.schedules[0].startAt) {
|
||||||
|
this.live.schedules = []
|
||||||
|
} else {
|
||||||
|
this.live.schedules = values.schedules.map(s => ({
|
||||||
|
startAt: new Date(s.startAt).toISOString()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.updateAfterChange()
|
this.updateAfterChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -632,7 +660,11 @@ export class VideoEdit {
|
||||||
|
|
||||||
replayPrivacy: this.live.replaySettings
|
replayPrivacy: this.live.replaySettings
|
||||||
? this.live.replaySettings.privacy
|
? this.live.replaySettings.privacy
|
||||||
: VideoPrivacy.PRIVATE
|
: VideoPrivacy.PRIVATE,
|
||||||
|
|
||||||
|
schedules: this.live.schedules?.map(s => ({
|
||||||
|
startAt: new Date(s.startAt)
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -643,7 +675,9 @@ export class VideoEdit {
|
||||||
replaySettings: this.live.saveReplay
|
replaySettings: this.live.saveReplay
|
||||||
? this.live.replaySettings
|
? this.live.replaySettings
|
||||||
: undefined,
|
: undefined,
|
||||||
latencyMode: this.live.latencyMode
|
latencyMode: this.live.latencyMode,
|
||||||
|
|
||||||
|
schedules: this.live.schedules
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -654,7 +688,8 @@ export class VideoEdit {
|
||||||
permanentLive: this.live.permanentLive,
|
permanentLive: this.live.permanentLive,
|
||||||
latencyMode: this.live.latencyMode,
|
latencyMode: this.live.latencyMode,
|
||||||
saveReplay: this.live.saveReplay,
|
saveReplay: this.live.saveReplay,
|
||||||
replaySettings: this.live.replaySettings
|
replaySettings: this.live.replaySettings,
|
||||||
|
schedules: this.live.schedules
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,6 @@
|
||||||
<my-alert type="primary" i18n>Your live has ended.</my-alert>
|
<my-alert type="primary" i18n>Your live has ended.</my-alert>
|
||||||
} @else {
|
} @else {
|
||||||
<div [formGroup]="form">
|
<div [formGroup]="form">
|
||||||
|
|
||||||
<my-alert class="d-block mb-4" type="primary">
|
<my-alert class="d-block mb-4" type="primary">
|
||||||
@if (isStreaming()) {
|
@if (isStreaming()) {
|
||||||
<ng-container i18n>Live configuration is unavailable because you are streaming in this live.</ng-container>
|
<ng-container i18n>Live configuration is unavailable because you are streaming in this live.</ng-container>
|
||||||
|
@ -18,8 +17,8 @@
|
||||||
<my-live-documentation-link></my-live-documentation-link>
|
<my-live-documentation-link></my-live-documentation-link>
|
||||||
|
|
||||||
<div class="mt-3" i18n *ngIf="getMaxLiveDuration() >= 0">
|
<div class="mt-3" i18n *ngIf="getMaxLiveDuration() >= 0">
|
||||||
Max live duration configured on {{ getInstanceName() }} is {{ getMaxLiveDuration() | myTimeDurationFormatter }}.
|
Max live duration configured on {{ getInstanceName() }} is {{ getMaxLiveDuration() | myTimeDurationFormatter }}. If your live reaches this limit, it
|
||||||
If your live reaches this limit, it will be automatically terminated.
|
will be automatically terminated.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</my-alert>
|
</my-alert>
|
||||||
|
@ -28,12 +27,26 @@
|
||||||
<div>
|
<div>
|
||||||
<div *ngIf="getLive().rtmpUrl" class="form-group">
|
<div *ngIf="getLive().rtmpUrl" class="form-group">
|
||||||
<label for="liveVideoRTMPUrl" i18n>Live RTMP Url</label>
|
<label for="liveVideoRTMPUrl" i18n>Live RTMP Url</label>
|
||||||
<my-input-text inputId="liveVideoRTMPUrl" [value]="getLive().rtmpUrl" [withToggle]="false" [withCopy]="true" [show]="true" [readonly]="true"></my-input-text>
|
<my-input-text
|
||||||
|
inputId="liveVideoRTMPUrl"
|
||||||
|
[value]="getLive().rtmpUrl"
|
||||||
|
[withToggle]="false"
|
||||||
|
[withCopy]="true"
|
||||||
|
[show]="true"
|
||||||
|
[readonly]="true"
|
||||||
|
></my-input-text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div *ngIf="getLive().rtmpsUrl" class="form-group">
|
<div *ngIf="getLive().rtmpsUrl" class="form-group">
|
||||||
<label for="liveVideoRTMPSUrl" i18n>Live RTMPS Url</label>
|
<label for="liveVideoRTMPSUrl" i18n>Live RTMPS Url</label>
|
||||||
<my-input-text inputId="liveVideoRTMPSUrl" [value]="getLive().rtmpsUrl" [withToggle]="false" [withCopy]="true" [show]="true" [readonly]="true"></my-input-text>
|
<my-input-text
|
||||||
|
inputId="liveVideoRTMPSUrl"
|
||||||
|
[value]="getLive().rtmpsUrl"
|
||||||
|
[withToggle]="false"
|
||||||
|
[withCopy]="true"
|
||||||
|
[show]="true"
|
||||||
|
[readonly]="true"
|
||||||
|
></my-input-text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
@ -79,7 +92,7 @@
|
||||||
</my-peertube-checkbox>
|
</my-peertube-checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group mx-4" *ngIf="isSaveReplayEnabled()">
|
<div class="form-group ms-4" *ngIf="isSaveReplayEnabled()">
|
||||||
<label i18n for="replayPrivacy">Privacy of the new replay</label>
|
<label i18n for="replayPrivacy">Privacy of the new replay</label>
|
||||||
<my-select-options inputId="replayPrivacy" [items]="replayPrivacies" formControlName="replayPrivacy"></my-select-options>
|
<my-select-options inputId="replayPrivacy" [items]="replayPrivacies" formControlName="replayPrivacy"></my-select-options>
|
||||||
</div>
|
</div>
|
||||||
|
@ -92,6 +105,37 @@
|
||||||
{{ formErrors.latencyMode }}
|
{{ formErrors.latencyMode }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" formArrayName="schedules">
|
||||||
|
<ng-container formGroupName="0">
|
||||||
|
<div class="label-container">
|
||||||
|
<label i18n for="startAt">Live scheduled date</label>
|
||||||
|
|
||||||
|
<button i18n class="reset-button reset-button-small" (click)="resetSchedule()" *ngIf="hasScheduledDate()">
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group-description" i18n>
|
||||||
|
This date is used for displaying the live in your channel when it's not currently streaming. Scheduling a live does not prevent you from starting
|
||||||
|
the live at a different time.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p-datepicker
|
||||||
|
inputId="startAt"
|
||||||
|
formControlName="startAt"
|
||||||
|
[dateFormat]="calendarDateFormat"
|
||||||
|
[firstDayOfWeek]="0"
|
||||||
|
[showTime]="true"
|
||||||
|
[hideOnDateTimeSelect]="true"
|
||||||
|
[monthNavigator]="true"
|
||||||
|
[yearNavigator]="true"
|
||||||
|
[yearRange]="myYearRange"
|
||||||
|
baseZIndex="20000"
|
||||||
|
>
|
||||||
|
</p-datepicker>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { CommonModule } from '@angular/common'
|
import { CommonModule } from '@angular/common'
|
||||||
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
||||||
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormArray, FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { ServerService } from '@app/core'
|
import { ServerService } from '@app/core'
|
||||||
import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model'
|
import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model'
|
||||||
import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service'
|
import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service'
|
||||||
|
@ -28,6 +28,8 @@ import { VideoEdit } from '../common/video-edit.model'
|
||||||
import { VideoManageController } from '../video-manage-controller.service'
|
import { VideoManageController } from '../video-manage-controller.service'
|
||||||
import { LiveDocumentationLinkComponent } from './live-documentation-link.component'
|
import { LiveDocumentationLinkComponent } from './live-documentation-link.component'
|
||||||
import { LiveStreamInformationComponent } from './live-stream-information.component'
|
import { LiveStreamInformationComponent } from './live-stream-information.component'
|
||||||
|
import { I18nPrimengCalendarService } from '../common/i18n-primeng-calendar.service'
|
||||||
|
import { DatePickerModule } from 'primeng/datepicker'
|
||||||
|
|
||||||
const debugLogger = debug('peertube:video-manage')
|
const debugLogger = debug('peertube:video-manage')
|
||||||
|
|
||||||
|
@ -37,6 +39,12 @@ type Form = {
|
||||||
latencyMode: FormControl<LiveVideoLatencyModeType>
|
latencyMode: FormControl<LiveVideoLatencyModeType>
|
||||||
saveReplay: FormControl<boolean>
|
saveReplay: FormControl<boolean>
|
||||||
replayPrivacy: FormControl<VideoPrivacyType>
|
replayPrivacy: FormControl<VideoPrivacyType>
|
||||||
|
|
||||||
|
schedules: FormArray<
|
||||||
|
FormGroup<{
|
||||||
|
startAt: FormControl<Date>
|
||||||
|
}>
|
||||||
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
@ -52,6 +60,7 @@ type Form = {
|
||||||
PeerTubeTemplateDirective,
|
PeerTubeTemplateDirective,
|
||||||
SelectOptionsComponent,
|
SelectOptionsComponent,
|
||||||
InputTextComponent,
|
InputTextComponent,
|
||||||
|
DatePickerModule,
|
||||||
PeertubeCheckboxComponent,
|
PeertubeCheckboxComponent,
|
||||||
LiveDocumentationLinkComponent,
|
LiveDocumentationLinkComponent,
|
||||||
AlertComponent,
|
AlertComponent,
|
||||||
|
@ -64,6 +73,7 @@ export class VideoLiveSettingsComponent implements OnInit, OnDestroy {
|
||||||
private formReactiveService = inject(FormReactiveService)
|
private formReactiveService = inject(FormReactiveService)
|
||||||
private videoService = inject(VideoService)
|
private videoService = inject(VideoService)
|
||||||
private serverService = inject(ServerService)
|
private serverService = inject(ServerService)
|
||||||
|
private i18nPrimengCalendarService = inject(I18nPrimengCalendarService)
|
||||||
private manageController = inject(VideoManageController)
|
private manageController = inject(VideoManageController)
|
||||||
|
|
||||||
form: FormGroup<Form>
|
form: FormGroup<Form>
|
||||||
|
@ -72,6 +82,9 @@ export class VideoLiveSettingsComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
videoEdit: VideoEdit
|
videoEdit: VideoEdit
|
||||||
|
|
||||||
|
calendarDateFormat: string
|
||||||
|
myYearRange: string
|
||||||
|
|
||||||
replayPrivacies: VideoConstant<VideoPrivacyType>[] = []
|
replayPrivacies: VideoConstant<VideoPrivacyType>[] = []
|
||||||
|
|
||||||
latencyModes: SelectOptionsItem[] = [
|
latencyModes: SelectOptionsItem[] = [
|
||||||
|
@ -96,6 +109,11 @@ export class VideoLiveSettingsComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
private updatedSub: Subscription
|
private updatedSub: Subscription
|
||||||
|
|
||||||
|
constructor () {
|
||||||
|
this.calendarDateFormat = this.i18nPrimengCalendarService.getDateFormat()
|
||||||
|
this.myYearRange = this.i18nPrimengCalendarService.getVideoPublicationYearRange()
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit () {
|
ngOnInit () {
|
||||||
this.serverConfig = this.serverService.getHTMLConfig()
|
this.serverConfig = this.serverService.getHTMLConfig()
|
||||||
|
|
||||||
|
@ -137,6 +155,15 @@ export class VideoLiveSettingsComponent implements OnInit, OnDestroy {
|
||||||
this.formErrors = formErrors
|
this.formErrors = formErrors
|
||||||
this.validationMessages = validationMessages
|
this.validationMessages = validationMessages
|
||||||
|
|
||||||
|
this.form.addControl(
|
||||||
|
'schedules',
|
||||||
|
new FormArray([
|
||||||
|
new FormGroup({
|
||||||
|
startAt: new FormControl<Date>(defaultValues.schedules?.[0]?.startAt, null)
|
||||||
|
})
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
this.form.valueChanges.subscribe(() => {
|
this.form.valueChanges.subscribe(() => {
|
||||||
this.manageController.setFormError($localize`Live settings`, 'live-settings', this.formErrors)
|
this.manageController.setFormError($localize`Live settings`, 'live-settings', this.formErrors)
|
||||||
|
|
||||||
|
@ -196,4 +223,18 @@ export class VideoLiveSettingsComponent implements OnInit, OnDestroy {
|
||||||
getInstanceName () {
|
getInstanceName () {
|
||||||
return this.serverConfig.instance.name
|
return this.serverConfig.instance.name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasScheduledDate () {
|
||||||
|
return !!this.form.value.schedules?.length && this.form.value.schedules[0].startAt
|
||||||
|
}
|
||||||
|
|
||||||
|
resetSchedule () {
|
||||||
|
this.form.patchValue({
|
||||||
|
schedules: [
|
||||||
|
{
|
||||||
|
startAt: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
@use '_variables' as *;
|
@use "_variables" as *;
|
||||||
@use '_mixins' as *;
|
@use "_mixins" as *;
|
||||||
@use '_form-mixins' as *;
|
@use "_form-mixins" as *;
|
||||||
@import 'bootstrap/scss/mixins';
|
@import "bootstrap/scss/mixins";
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -22,7 +22,8 @@
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: pvar(--header-height);
|
top: pvar(--header-height);
|
||||||
background-color: pvar(--bg);
|
background-color: pvar(--bg);
|
||||||
z-index: 1;
|
// On top of input group addons
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-actions {
|
.video-actions {
|
||||||
|
|
|
@ -16,7 +16,7 @@ export type BuildFormArgumentTyped<Form> = ReplaceForm<Form, BuildFormValidator>
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export type FormDefault = {
|
export type FormDefault = {
|
||||||
[name: string]: Blob | Date | boolean | number | number[] | string | string[] | FormDefault
|
[name: string]: Blob | Date | boolean | number | number[] | string | string[] | FormDefault | FormDefault[]
|
||||||
}
|
}
|
||||||
export type FormDefaultTyped<Form> = Partial<UnwrapForm<Form>>
|
export type FormDefaultTyped<Form> = Partial<UnwrapForm<Form>>
|
||||||
|
|
||||||
|
|
|
@ -10,43 +10,61 @@ export class FromNowPipe implements PipeTransform {
|
||||||
|
|
||||||
transform (arg: number | Date | string) {
|
transform (arg: number | Date | string) {
|
||||||
const argDate = new Date(arg)
|
const argDate = new Date(arg)
|
||||||
const seconds = Math.floor((Date.now() - argDate.getTime()) / 1000)
|
const seconds = Math.trunc((Date.now() - argDate.getTime()) / 1000)
|
||||||
|
|
||||||
let interval = Math.floor(seconds / 31536000)
|
let interval = Math.trunc(seconds / 31536000)
|
||||||
if (interval >= 1) {
|
if (interval >= 1) {
|
||||||
return formatICU($localize`{interval, plural, =1 {1 year ago} other {{interval} years ago}}`, { interval })
|
return formatICU($localize`{interval, plural, =1 {1 year ago} other {{interval} years ago}}`, { interval })
|
||||||
}
|
}
|
||||||
|
if (interval <= -1) {
|
||||||
|
return formatICU($localize`{interval, plural, =1 {in 1 year} other {in {interval} years}}`, { interval: -interval })
|
||||||
|
}
|
||||||
|
|
||||||
interval = Math.floor(seconds / 2419200)
|
interval = Math.trunc(seconds / 2419200)
|
||||||
// 12 months = 360 days, but a year ~ 365 days
|
// 12 months = 360 days, but a year ~ 365 days
|
||||||
// Display "1 year ago" rather than "12 months ago"
|
// Display "1 year ago" rather than "12 months ago"
|
||||||
if (interval >= 12) return $localize`1 year ago`
|
if (interval >= 12) return $localize`1 year ago`
|
||||||
|
if (interval <= -12) return $localize`in 1 year`
|
||||||
|
|
||||||
if (interval >= 1) {
|
if (interval >= 1) {
|
||||||
return formatICU($localize`{interval, plural, =1 {1 month ago} other {{interval} months ago}}`, { interval })
|
return formatICU($localize`{interval, plural, =1 {1 month ago} other {{interval} months ago}}`, { interval })
|
||||||
}
|
}
|
||||||
|
if (interval <= -1) {
|
||||||
|
return formatICU($localize`{interval, plural, =1 {in 1 month} other {in {interval} months}}`, { interval: -interval })
|
||||||
|
}
|
||||||
|
|
||||||
interval = Math.floor(seconds / 604800)
|
interval = Math.trunc(seconds / 604800)
|
||||||
// 4 weeks ~ 28 days, but our month is 30 days
|
// 4 weeks ~ 28 days, but our month is 30 days
|
||||||
// Display "1 month ago" rather than "4 weeks ago"
|
// Display "1 month ago" rather than "4 weeks ago"
|
||||||
if (interval >= 4) return $localize`1 month ago`
|
if (interval >= 4) return $localize`1 month ago`
|
||||||
|
if (interval <= -4) return $localize`1 month from now`
|
||||||
|
|
||||||
if (interval >= 1) {
|
if (interval >= 1) {
|
||||||
return formatICU($localize`{interval, plural, =1 {1 week ago} other {{interval} weeks ago}}`, { interval })
|
return formatICU($localize`{interval, plural, =1 {1 week ago} other {{interval} weeks ago}}`, { interval })
|
||||||
}
|
}
|
||||||
|
if (interval <= -1) {
|
||||||
|
return formatICU($localize`{interval, plural, =1 {in 1 week} other {in {interval} weeks}}`, { interval: -interval })
|
||||||
|
}
|
||||||
|
|
||||||
interval = Math.floor(seconds / 86400)
|
interval = Math.trunc(seconds / 86400)
|
||||||
if (interval >= 1) {
|
if (interval >= 1) {
|
||||||
return formatICU($localize`{interval, plural, =1 {1 day ago} other {{interval} days ago}}`, { interval })
|
return formatICU($localize`{interval, plural, =1 {1 day ago} other {{interval} days ago}}`, { interval })
|
||||||
}
|
}
|
||||||
|
if (interval <= -1) {
|
||||||
|
return formatICU($localize`{interval, plural, =1 {in 1 day} other {in {interval} days}}`, { interval: -interval })
|
||||||
|
}
|
||||||
|
|
||||||
interval = Math.floor(seconds / 3600)
|
interval = Math.trunc(seconds / 3600)
|
||||||
if (interval >= 1) {
|
if (interval >= 1) {
|
||||||
return formatICU($localize`{interval, plural, =1 {1 hour ago} other {{interval} hours ago}}`, { interval })
|
return formatICU($localize`{interval, plural, =1 {1 hour ago} other {{interval} hours ago}}`, { interval })
|
||||||
}
|
}
|
||||||
|
if (interval <= -1) {
|
||||||
|
return formatICU($localize`{interval, plural, =1 {in 1 hour} other {in {interval} hours}}`, { interval: -interval })
|
||||||
|
}
|
||||||
|
|
||||||
interval = Math.floor(seconds / 60)
|
interval = Math.trunc(seconds / 60)
|
||||||
if (interval >= 1) return $localize`${interval} min ago`
|
if (interval >= 1) return $localize`${interval} min ago`
|
||||||
|
if (interval <= -1) return $localize`in ${ -interval } min`
|
||||||
|
|
||||||
return $localize`just now`
|
return $localize`just now`
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,6 +55,7 @@ export class Video implements VideoServerModel {
|
||||||
aspectRatio: number
|
aspectRatio: number
|
||||||
|
|
||||||
isLive: boolean
|
isLive: boolean
|
||||||
|
liveSchedules: { startAt: Date | string }[]
|
||||||
|
|
||||||
previewPath: string
|
previewPath: string
|
||||||
previewUrl: string
|
previewUrl: string
|
||||||
|
@ -148,6 +149,9 @@ export class Video implements VideoServerModel {
|
||||||
this.description = hash.description
|
this.description = hash.description
|
||||||
|
|
||||||
this.isLive = hash.isLive
|
this.isLive = hash.isLive
|
||||||
|
this.liveSchedules = hash.liveSchedules
|
||||||
|
? hash.liveSchedules.map(schedule => ({ startAt: new Date(schedule.startAt.toString()) }))
|
||||||
|
: null
|
||||||
|
|
||||||
this.duration = hash.duration
|
this.duration = hash.duration
|
||||||
this.durationLabel = Video.buildDurationLabel(this)
|
this.durationLabel = Video.buildDurationLabel(this)
|
||||||
|
@ -195,7 +199,9 @@ export class Video implements VideoServerModel {
|
||||||
this.privacy.label = peertubeTranslate(this.privacy.label, translations)
|
this.privacy.label = peertubeTranslate(this.privacy.label, translations)
|
||||||
|
|
||||||
this.scheduledUpdate = hash.scheduledUpdate
|
this.scheduledUpdate = hash.scheduledUpdate
|
||||||
this.originallyPublishedAt = hash.originallyPublishedAt ? new Date(hash.originallyPublishedAt.toString()) : null
|
this.originallyPublishedAt = hash.originallyPublishedAt
|
||||||
|
? new Date(hash.originallyPublishedAt.toString())
|
||||||
|
: null
|
||||||
|
|
||||||
if (this.state) this.state.label = peertubeTranslate(this.state.label, translations)
|
if (this.state) this.state.label = peertubeTranslate(this.state.label, translations)
|
||||||
|
|
||||||
|
|
|
@ -34,6 +34,9 @@
|
||||||
<ng-container i18n>LIVE</ng-container>
|
<ng-container i18n>LIVE</ng-container>
|
||||||
} @else if (isEndedLive()) {
|
} @else if (isEndedLive()) {
|
||||||
<ng-container i18n>LIVE ENDED</ng-container>
|
<ng-container i18n>LIVE ENDED</ng-container>
|
||||||
|
} @else if (isScheduledLive()) {
|
||||||
|
<ng-container i18n>SCHEDULED LIVE</ng-container>
|
||||||
|
<span [title]="scheduledLiveDate().toLocaleString()"> {{ scheduledLiveDate() | myFromNow | uppercase }}</span>
|
||||||
} @else {
|
} @else {
|
||||||
<ng-container i18n>WAIT LIVE</ng-container>
|
<ng-container i18n>WAIT LIVE</ng-container>
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { Video as VideoServerModel, VideoState } from '@peertube/peertube-models'
|
import { Video as VideoServerModel, VideoState } from '@peertube/peertube-models'
|
||||||
import { GlobalIconComponent } from '../shared-icons/global-icon.component'
|
import { GlobalIconComponent } from '../shared-icons/global-icon.component'
|
||||||
import { Video } from '../shared-main/video/video.model'
|
import { Video } from '../shared-main/video/video.model'
|
||||||
|
import { FromNowPipe } from '../shared-main/date/from-now.pipe'
|
||||||
|
|
||||||
export type VideoThumbnailInput = Pick<
|
export type VideoThumbnailInput = Pick<
|
||||||
VideoServerModel,
|
VideoServerModel,
|
||||||
|
@ -21,13 +22,15 @@ export type VideoThumbnailInput = Pick<
|
||||||
| 'thumbnailPath'
|
| 'thumbnailPath'
|
||||||
| 'thumbnailUrl'
|
| 'thumbnailUrl'
|
||||||
| 'userHistory'
|
| 'userHistory'
|
||||||
|
| 'originallyPublishedAt'
|
||||||
|
| 'liveSchedules'
|
||||||
>
|
>
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-video-thumbnail',
|
selector: 'my-video-thumbnail',
|
||||||
styleUrls: [ './video-thumbnail.component.scss' ],
|
styleUrls: [ './video-thumbnail.component.scss' ],
|
||||||
templateUrl: './video-thumbnail.component.html',
|
templateUrl: './video-thumbnail.component.html',
|
||||||
imports: [ CommonModule, RouterLink, NgbTooltip, GlobalIconComponent ]
|
imports: [ CommonModule, RouterLink, NgbTooltip, GlobalIconComponent, FromNowPipe ]
|
||||||
})
|
})
|
||||||
export class VideoThumbnailComponent implements OnChanges {
|
export class VideoThumbnailComponent implements OnChanges {
|
||||||
private screenService = inject(ScreenService)
|
private screenService = inject(ScreenService)
|
||||||
|
@ -86,6 +89,16 @@ export class VideoThumbnailComponent implements OnChanges {
|
||||||
return this.video().state?.id === VideoState.LIVE_ENDED
|
return this.video().state?.id === VideoState.LIVE_ENDED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isScheduledLive () {
|
||||||
|
return this.video().state?.id === VideoState.WAITING_FOR_LIVE &&
|
||||||
|
this.video().liveSchedules !== null &&
|
||||||
|
this.video().liveSchedules.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduledLiveDate () {
|
||||||
|
return new Date(this.video().liveSchedules[0].startAt)
|
||||||
|
}
|
||||||
|
|
||||||
getImageUrl () {
|
getImageUrl () {
|
||||||
const video = this.video()
|
const video = this.video()
|
||||||
if (!video) return ''
|
if (!video) return ''
|
||||||
|
|
|
@ -23,7 +23,7 @@
|
||||||
<ng-container *ngIf="highlightedLives.length !== 0">
|
<ng-container *ngIf="highlightedLives.length !== 0">
|
||||||
<h2 class="date-title">
|
<h2 class="date-title">
|
||||||
<my-global-icon class="pt-icon me-1 top--1px" iconName="live"></my-global-icon>
|
<my-global-icon class="pt-icon me-1 top--1px" iconName="live"></my-global-icon>
|
||||||
<ng-container i18n>Current lives</ng-container>
|
<ng-container i18n>Lives</ng-container>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<ng-container *ngFor="let live of highlightedLives; trackBy: videoById;">
|
<ng-container *ngFor="let live of highlightedLives; trackBy: videoById;">
|
||||||
|
|
|
@ -44,6 +44,10 @@ export interface VideoObject {
|
||||||
updated: string
|
updated: string
|
||||||
uploadDate: string
|
uploadDate: string
|
||||||
|
|
||||||
|
schedules?: {
|
||||||
|
startDate: Date
|
||||||
|
}[]
|
||||||
|
|
||||||
mediaType: 'text/markdown'
|
mediaType: 'text/markdown'
|
||||||
content: string
|
content: string
|
||||||
|
|
||||||
|
|
|
@ -40,6 +40,10 @@ export interface VideoExportJSON {
|
||||||
replaySettings?: {
|
replaySettings?: {
|
||||||
privacy: VideoPrivacyType
|
privacy: VideoPrivacyType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
schedules?: {
|
||||||
|
startAt: string
|
||||||
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
url: string
|
url: string
|
||||||
|
|
|
@ -13,6 +13,7 @@ export interface VideosCommonQuery {
|
||||||
nsfwFlagsExcluded?: number
|
nsfwFlagsExcluded?: number
|
||||||
|
|
||||||
isLive?: boolean
|
isLive?: boolean
|
||||||
|
includeScheduledLive?: boolean
|
||||||
|
|
||||||
isLocal?: boolean
|
isLocal?: boolean
|
||||||
include?: VideoIncludeType
|
include?: VideoIncludeType
|
||||||
|
|
|
@ -3,6 +3,7 @@ export * from './live-video-error.enum.js'
|
||||||
export * from './live-video-event-payload.model.js'
|
export * from './live-video-event-payload.model.js'
|
||||||
export * from './live-video-event.type.js'
|
export * from './live-video-event.type.js'
|
||||||
export * from './live-video-latency-mode.enum.js'
|
export * from './live-video-latency-mode.enum.js'
|
||||||
|
export * from './live-video-schedule.model.js'
|
||||||
export * from './live-video-session.model.js'
|
export * from './live-video-session.model.js'
|
||||||
export * from './live-video-update.model.js'
|
export * from './live-video-update.model.js'
|
||||||
export * from './live-video.model.js'
|
export * from './live-video.model.js'
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { VideoCreate } from '../video-create.model.js'
|
import { VideoCreate } from '../video-create.model.js'
|
||||||
import { VideoPrivacyType } from '../video-privacy.enum.js'
|
import { VideoPrivacyType } from '../video-privacy.enum.js'
|
||||||
import { LiveVideoLatencyModeType } from './live-video-latency-mode.enum.js'
|
import { LiveVideoLatencyModeType } from './live-video-latency-mode.enum.js'
|
||||||
|
import { LiveVideoScheduleEdit } from './live-video-schedule.model.js'
|
||||||
|
|
||||||
export interface LiveVideoCreate extends VideoCreate {
|
export interface LiveVideoCreate extends VideoCreate {
|
||||||
permanentLive?: boolean
|
permanentLive?: boolean
|
||||||
|
@ -8,4 +9,6 @@ export interface LiveVideoCreate extends VideoCreate {
|
||||||
|
|
||||||
saveReplay?: boolean
|
saveReplay?: boolean
|
||||||
replaySettings?: { privacy: VideoPrivacyType }
|
replaySettings?: { privacy: VideoPrivacyType }
|
||||||
|
|
||||||
|
schedules?: LiveVideoScheduleEdit[]
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
export interface LiveVideoScheduleEdit {
|
||||||
|
startAt?: Date | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveVideoSchedule {
|
||||||
|
startAt: Date | string
|
||||||
|
}
|
|
@ -1,9 +1,12 @@
|
||||||
import { VideoPrivacyType } from '../video-privacy.enum.js'
|
import { VideoPrivacyType } from '../video-privacy.enum.js'
|
||||||
import { LiveVideoLatencyModeType } from './live-video-latency-mode.enum.js'
|
import { LiveVideoLatencyModeType } from './live-video-latency-mode.enum.js'
|
||||||
|
import { LiveVideoScheduleEdit } from './live-video-schedule.model.js'
|
||||||
|
|
||||||
export interface LiveVideoUpdate {
|
export interface LiveVideoUpdate {
|
||||||
permanentLive?: boolean
|
permanentLive?: boolean
|
||||||
saveReplay?: boolean
|
saveReplay?: boolean
|
||||||
replaySettings?: { privacy: VideoPrivacyType }
|
replaySettings?: { privacy: VideoPrivacyType }
|
||||||
latencyMode?: LiveVideoLatencyModeType
|
latencyMode?: LiveVideoLatencyModeType
|
||||||
|
|
||||||
|
schedules?: LiveVideoScheduleEdit[]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,18 @@
|
||||||
import { VideoPrivacyType } from '../video-privacy.enum.js'
|
import { VideoPrivacyType } from '../video-privacy.enum.js'
|
||||||
import { LiveVideoLatencyModeType } from './live-video-latency-mode.enum.js'
|
import { LiveVideoLatencyModeType } from './live-video-latency-mode.enum.js'
|
||||||
|
import { LiveVideoScheduleEdit } from './live-video-schedule.model.js'
|
||||||
|
|
||||||
export interface LiveVideo {
|
export interface LiveVideo {
|
||||||
// If owner
|
// If owner
|
||||||
rtmpUrl?: string
|
rtmpUrl?: string
|
||||||
rtmpsUrl?: string
|
rtmpsUrl?: string
|
||||||
streamKey?: string
|
streamKey?: string
|
||||||
|
// End if owner
|
||||||
|
|
||||||
saveReplay: boolean
|
saveReplay: boolean
|
||||||
replaySettings?: { privacy: VideoPrivacyType }
|
replaySettings?: { privacy: VideoPrivacyType }
|
||||||
permanentLive: boolean
|
permanentLive: boolean
|
||||||
latencyMode: LiveVideoLatencyModeType
|
latencyMode: LiveVideoLatencyModeType
|
||||||
|
|
||||||
|
schedules: LiveVideoScheduleEdit[]
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { Account, AccountSummary } from '../actors/index.js'
|
||||||
import { VideoChannel, VideoChannelSummary } from './channel/video-channel.model.js'
|
import { VideoChannel, VideoChannelSummary } from './channel/video-channel.model.js'
|
||||||
import { VideoFile } from './file/index.js'
|
import { VideoFile } from './file/index.js'
|
||||||
import { VideoCommentPolicyType } from './index.js'
|
import { VideoCommentPolicyType } from './index.js'
|
||||||
|
import { LiveVideoScheduleEdit } from './live/live-video-schedule.model.js'
|
||||||
import { VideoConstant } from './video-constant.model.js'
|
import { VideoConstant } from './video-constant.model.js'
|
||||||
import { VideoPrivacyType } from './video-privacy.enum.js'
|
import { VideoPrivacyType } from './video-privacy.enum.js'
|
||||||
import { VideoScheduleUpdate } from './video-schedule-update.model.js'
|
import { VideoScheduleUpdate } from './video-schedule-update.model.js'
|
||||||
|
@ -34,6 +35,7 @@ export interface Video extends Partial<VideoAdditionalAttributes> {
|
||||||
aspectRatio: number | null
|
aspectRatio: number | null
|
||||||
|
|
||||||
isLive: boolean
|
isLive: boolean
|
||||||
|
liveSchedules?: LiveVideoScheduleEdit[]
|
||||||
|
|
||||||
thumbnailPath: string
|
thumbnailPath: string
|
||||||
thumbnailUrl?: string
|
thumbnailUrl?: string
|
||||||
|
@ -85,6 +87,8 @@ export interface VideoAdditionalAttributes {
|
||||||
videoSource: VideoSource
|
videoSource: VideoSource
|
||||||
|
|
||||||
automaticTags: string[]
|
automaticTags: string[]
|
||||||
|
|
||||||
|
liveSchedules: LiveVideoScheduleEdit[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoDetails extends Video {
|
export interface VideoDetails extends Video {
|
||||||
|
|
|
@ -663,7 +663,8 @@ export class VideosCommand extends AbstractCommand {
|
||||||
'include',
|
'include',
|
||||||
'skipCount',
|
'skipCount',
|
||||||
'autoTagOneOf',
|
'autoTagOneOf',
|
||||||
'search'
|
'search',
|
||||||
|
'includeScheduledLive'
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -95,7 +95,12 @@ describe('Test video lives API validator', function () {
|
||||||
saveReplay: false,
|
saveReplay: false,
|
||||||
replaySettings: undefined,
|
replaySettings: undefined,
|
||||||
permanentLive: true,
|
permanentLive: true,
|
||||||
latencyMode: LiveVideoLatencyMode.DEFAULT
|
latencyMode: LiveVideoLatencyMode.DEFAULT,
|
||||||
|
schedules: [
|
||||||
|
{
|
||||||
|
startAt: new Date(Date.now() + 1000 * 60 * 60) // 1 hour later
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -272,6 +277,22 @@ describe('Test video lives API validator', function () {
|
||||||
await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should fail with an invalid schedules', async function () {
|
||||||
|
const toTests = [ 'toto', 42, [ 'toto' ], { toto: 'toto' }, { startAt: 'toto' } ]
|
||||||
|
|
||||||
|
for (const schedules of toTests) {
|
||||||
|
const fields = { ...baseCorrectParams, schedules }
|
||||||
|
|
||||||
|
await makePostBodyRequest({
|
||||||
|
url: server.url,
|
||||||
|
path,
|
||||||
|
token: server.accessToken,
|
||||||
|
fields,
|
||||||
|
expectedStatus: HttpStatusCode.BAD_REQUEST_400
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('Should succeed with the correct parameters', async function () {
|
it('Should succeed with the correct parameters', async function () {
|
||||||
this.timeout(30000)
|
this.timeout(30000)
|
||||||
|
|
||||||
|
@ -527,6 +548,20 @@ describe('Test video lives API validator', function () {
|
||||||
await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should fail with an invalid schedules', async function () {
|
||||||
|
const toTests = [ 'toto', 42, [ 'toto' ], { toto: 'toto' }, { startAt: 'toto' } ]
|
||||||
|
|
||||||
|
for (const schedules of toTests) {
|
||||||
|
const fields = { schedules } as any
|
||||||
|
|
||||||
|
await command.update({
|
||||||
|
videoId: video.id,
|
||||||
|
fields,
|
||||||
|
expectedStatus: HttpStatusCode.BAD_REQUEST_400
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('Should succeed with the correct params', async function () {
|
it('Should succeed with the correct params', async function () {
|
||||||
await command.update({ videoId: video.id, fields: { saveReplay: false } })
|
await command.update({ videoId: video.id, fields: { saveReplay: false } })
|
||||||
await command.update({ videoId: video.uuid, fields: { saveReplay: false } })
|
await command.update({ videoId: video.uuid, fields: { saveReplay: false } })
|
||||||
|
|
|
@ -223,6 +223,149 @@ describe('Test live', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Scheduled live', function () {
|
||||||
|
let liveVideoUUID: string
|
||||||
|
const scheduledForDate = (new Date(Date.now() + 3600000)).toISOString()
|
||||||
|
|
||||||
|
it('Should create a live with the appropriate parameters', async function () {
|
||||||
|
this.timeout(20000)
|
||||||
|
|
||||||
|
const { uuid } = await commands[0].create({
|
||||||
|
fields: {
|
||||||
|
name: 'live scheduled',
|
||||||
|
channelId: servers[0].store.channel.id,
|
||||||
|
privacy: VideoPrivacy.PUBLIC,
|
||||||
|
schedules: [ { startAt: scheduledForDate } ]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
liveVideoUUID = uuid
|
||||||
|
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const video = await server.videos.get({ id: liveVideoUUID })
|
||||||
|
|
||||||
|
expect(video.liveSchedules).to.have.lengthOf(1)
|
||||||
|
expect(video.liveSchedules[0].startAt).to.equal(scheduledForDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
const live = await servers[0].live.get({ videoId: liveVideoUUID })
|
||||||
|
expect(live.schedules[0].startAt).to.equal(scheduledForDate)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should not have the live listed globally since nobody streams into', async function () {
|
||||||
|
for (const server of servers) {
|
||||||
|
const { total, data } = await server.videos.list()
|
||||||
|
|
||||||
|
expect(total).to.equal(0)
|
||||||
|
expect(data).to.have.lengthOf(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should have the live listed on the channel since it is scheduled', async function () {
|
||||||
|
const handle = servers[0].store.channel.name + '@' + servers[0].store.channel.host
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const { total, data } = await server.videos.listByChannel({ handle, includeScheduledLive: true })
|
||||||
|
|
||||||
|
expect(total).to.equal(1)
|
||||||
|
expect(data).to.have.lengthOf(1)
|
||||||
|
expect(data[0].liveSchedules[0].startAt).to.equal(scheduledForDate)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should not list lives according to includeScheduledLive query param', async function () {
|
||||||
|
for (const server of servers) {
|
||||||
|
const { total, data } = await server.videos.list({ includeScheduledLive: false })
|
||||||
|
|
||||||
|
expect(total).to.equal(0)
|
||||||
|
expect(data).to.have.lengthOf(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const { total, data } = await server.videos.list({ includeScheduledLive: true })
|
||||||
|
|
||||||
|
expect(total).to.equal(1)
|
||||||
|
expect(data).to.have.lengthOf(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const { total, data } = await server.videos.list({ includeScheduledLive: true, isLive: false })
|
||||||
|
|
||||||
|
expect(total).to.equal(0)
|
||||||
|
expect(data).to.have.lengthOf(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should update the live schedule', async function () {
|
||||||
|
const newSchedule = new Date(Date.now() + 7200000).toISOString()
|
||||||
|
|
||||||
|
await servers[0].live.update({
|
||||||
|
videoId: liveVideoUUID,
|
||||||
|
fields: {
|
||||||
|
schedules: [ { startAt: newSchedule } ]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
const handle = servers[0].store.channel.name + '@' + servers[0].store.channel.host
|
||||||
|
for (const server of servers) {
|
||||||
|
const { total, data } = await server.videos.listByChannel({ handle, includeScheduledLive: true })
|
||||||
|
|
||||||
|
expect(total).to.equal(1)
|
||||||
|
expect(data).to.have.lengthOf(1)
|
||||||
|
expect(data[0].liveSchedules[0].startAt).to.equal(newSchedule)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should not list scheduled lives of the past', async function () {
|
||||||
|
const newSchedule = new Date(Date.now() - 1).toISOString()
|
||||||
|
|
||||||
|
await servers[0].live.update({
|
||||||
|
videoId: liveVideoUUID,
|
||||||
|
fields: {
|
||||||
|
schedules: [ { startAt: newSchedule } ]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
const handle = servers[0].store.channel.name + '@' + servers[0].store.channel.host
|
||||||
|
for (const server of servers) {
|
||||||
|
const { total, data } = await server.videos.listByChannel({ handle, includeScheduledLive: true })
|
||||||
|
|
||||||
|
expect(total).to.equal(0)
|
||||||
|
expect(data).to.have.lengthOf(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should delete the live schedule', async function () {
|
||||||
|
await servers[0].live.update({
|
||||||
|
videoId: liveVideoUUID,
|
||||||
|
fields: {
|
||||||
|
schedules: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const video = await server.videos.get({ id: liveVideoUUID })
|
||||||
|
|
||||||
|
expect(video.liveSchedules).to.have.lengthOf(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const live = await servers[0].live.get({ videoId: liveVideoUUID })
|
||||||
|
expect(live.schedules).to.have.lengthOf(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Delete the live', async function () {
|
||||||
|
await servers[0].videos.remove({ id: liveVideoUUID })
|
||||||
|
await waitJobs(servers)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('Live filters', function () {
|
describe('Live filters', function () {
|
||||||
let ffmpegCommand: any
|
let ffmpegCommand: any
|
||||||
let liveVideoId: string
|
let liveVideoId: string
|
||||||
|
|
|
@ -566,6 +566,8 @@ function runTest (withObjectStorage: boolean) {
|
||||||
expect(liveVideo.live.permanentLive).to.be.true
|
expect(liveVideo.live.permanentLive).to.be.true
|
||||||
expect(liveVideo.live.streamKey).to.exist
|
expect(liveVideo.live.streamKey).to.exist
|
||||||
expect(liveVideo.live.replaySettings.privacy).to.equal(VideoPrivacy.PUBLIC)
|
expect(liveVideo.live.replaySettings.privacy).to.equal(VideoPrivacy.PUBLIC)
|
||||||
|
expect(liveVideo.live.schedules).to.have.lengthOf(1)
|
||||||
|
expect(liveVideo.live.schedules[0].startAt).to.exist
|
||||||
|
|
||||||
expect(liveVideo.channel.name).to.equal('noah_second_channel')
|
expect(liveVideo.channel.name).to.equal('noah_second_channel')
|
||||||
expect(liveVideo.privacy).to.equal(VideoPrivacy.PASSWORD_PROTECTED)
|
expect(liveVideo.privacy).to.equal(VideoPrivacy.PASSWORD_PROTECTED)
|
||||||
|
|
|
@ -492,6 +492,7 @@ function runTest (withObjectStorage: boolean) {
|
||||||
expect(live.permanentLive).to.be.true
|
expect(live.permanentLive).to.be.true
|
||||||
expect(live.streamKey).to.exist
|
expect(live.streamKey).to.exist
|
||||||
expect(live.replaySettings.privacy).to.equal(VideoPrivacy.PUBLIC)
|
expect(live.replaySettings.privacy).to.equal(VideoPrivacy.PUBLIC)
|
||||||
|
expect(live.schedules[0].startAt).to.exist
|
||||||
|
|
||||||
expect(video.channel.name).to.equal('noah_second_channel')
|
expect(video.channel.name).to.equal('noah_second_channel')
|
||||||
expect(video.privacy.id).to.equal(VideoPrivacy.PASSWORD_PROTECTED)
|
expect(video.privacy.id).to.equal(VideoPrivacy.PASSWORD_PROTECTED)
|
||||||
|
|
|
@ -337,7 +337,10 @@ export async function prepareImportExportTests (options: {
|
||||||
videoPasswords: [ 'password1' ],
|
videoPasswords: [ 'password1' ],
|
||||||
channelId: noahSecondChannelId,
|
channelId: noahSecondChannelId,
|
||||||
name: 'noah live video',
|
name: 'noah live video',
|
||||||
privacy: VideoPrivacy.PASSWORD_PROTECTED
|
privacy: VideoPrivacy.PASSWORD_PROTECTED,
|
||||||
|
schedules: [
|
||||||
|
{ startAt: new Date(Date.now() + 1000 * 60 * 60).toISOString() }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
token: noahToken
|
token: noahToken
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,10 +1,14 @@
|
||||||
import express from 'express'
|
import { pick } from '@peertube/peertube-core-utils'
|
||||||
import { HttpStatusCode, LiveVideoCreate, LiveVideoUpdate, ThumbnailType, UserRight, VideoState } from '@peertube/peertube-models'
|
import { HttpStatusCode, LiveVideoCreate, LiveVideoUpdate, ThumbnailType, UserRight, VideoState } from '@peertube/peertube-models'
|
||||||
import { exists } from '@server/helpers/custom-validators/misc.js'
|
import { uuidToShort } from '@peertube/peertube-node-utils'
|
||||||
|
import { exists, isArray } from '@server/helpers/custom-validators/misc.js'
|
||||||
|
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
||||||
import { createReqFiles } from '@server/helpers/express-utils.js'
|
import { createReqFiles } from '@server/helpers/express-utils.js'
|
||||||
import { getFormattedObjects } from '@server/helpers/utils.js'
|
import { getFormattedObjects } from '@server/helpers/utils.js'
|
||||||
import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants.js'
|
import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants.js'
|
||||||
|
import { sequelizeTypescript } from '@server/initializers/database.js'
|
||||||
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/index.js'
|
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/index.js'
|
||||||
|
import { LocalVideoCreator } from '@server/lib/local-video-creator.js'
|
||||||
import { Hooks } from '@server/lib/plugins/hooks.js'
|
import { Hooks } from '@server/lib/plugins/hooks.js'
|
||||||
import {
|
import {
|
||||||
videoLiveAddValidator,
|
videoLiveAddValidator,
|
||||||
|
@ -14,13 +18,13 @@ import {
|
||||||
videoLiveUpdateValidator
|
videoLiveUpdateValidator
|
||||||
} from '@server/middlewares/validators/videos/video-live.js'
|
} from '@server/middlewares/validators/videos/video-live.js'
|
||||||
import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting.js'
|
import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting.js'
|
||||||
|
import { VideoLiveScheduleModel } from '@server/models/video/video-live-schedule.js'
|
||||||
import { VideoLiveSessionModel } from '@server/models/video/video-live-session.js'
|
import { VideoLiveSessionModel } from '@server/models/video/video-live-session.js'
|
||||||
import { MVideoLive } from '@server/types/models/index.js'
|
import { MVideoLive } from '@server/types/models/index.js'
|
||||||
import { uuidToShort } from '@peertube/peertube-node-utils'
|
import express from 'express'
|
||||||
|
import { Transaction } from 'sequelize'
|
||||||
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
|
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
|
||||||
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, optionalAuthenticate } from '../../../middlewares/index.js'
|
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, optionalAuthenticate } from '../../../middlewares/index.js'
|
||||||
import { LocalVideoCreator } from '@server/lib/local-video-creator.js'
|
|
||||||
import { pick } from '@peertube/peertube-core-utils'
|
|
||||||
|
|
||||||
const lTags = loggerTagsFactory('api', 'live')
|
const lTags = loggerTagsFactory('api', 'live')
|
||||||
|
|
||||||
|
@ -100,13 +104,27 @@ async function updateLiveVideo (req: express.Request, res: express.Response) {
|
||||||
const video = res.locals.videoAll
|
const video = res.locals.videoAll
|
||||||
const videoLive = res.locals.videoLive
|
const videoLive = res.locals.videoLive
|
||||||
|
|
||||||
const newReplaySettingModel = await updateReplaySettings(videoLive, body)
|
await retryTransactionWrapper(() => {
|
||||||
|
return sequelizeTypescript.transaction(async t => {
|
||||||
|
const newReplaySettingModel = await updateReplaySettings(videoLive, body, t)
|
||||||
|
|
||||||
if (newReplaySettingModel) videoLive.replaySettingId = newReplaySettingModel.id
|
if (newReplaySettingModel) videoLive.replaySettingId = newReplaySettingModel.id
|
||||||
else videoLive.replaySettingId = null
|
else videoLive.replaySettingId = null
|
||||||
|
|
||||||
if (exists(body.permanentLive)) videoLive.permanentLive = body.permanentLive
|
if (exists(body.permanentLive)) videoLive.permanentLive = body.permanentLive
|
||||||
if (exists(body.latencyMode)) videoLive.latencyMode = body.latencyMode
|
if (exists(body.latencyMode)) videoLive.latencyMode = body.latencyMode
|
||||||
|
|
||||||
|
if (body.schedules !== undefined) {
|
||||||
|
await VideoLiveScheduleModel.deleteAllOfLiveId(videoLive.id, t)
|
||||||
|
videoLive.LiveSchedules = []
|
||||||
|
|
||||||
|
if (isArray(body.schedules)) {
|
||||||
|
videoLive.LiveSchedules = await VideoLiveScheduleModel.addToLiveId(videoLive.id, body.schedules.map(s => s.startAt), t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
video.VideoLive = await videoLive.save()
|
video.VideoLive = await videoLive.save()
|
||||||
|
|
||||||
await federateVideoIfNeeded(video, false)
|
await federateVideoIfNeeded(video, false)
|
||||||
|
@ -114,25 +132,25 @@ async function updateLiveVideo (req: express.Request, res: express.Response) {
|
||||||
return res.status(HttpStatusCode.NO_CONTENT_204).end()
|
return res.status(HttpStatusCode.NO_CONTENT_204).end()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateReplaySettings (videoLive: MVideoLive, body: LiveVideoUpdate) {
|
async function updateReplaySettings (videoLive: MVideoLive, body: LiveVideoUpdate, t: Transaction) {
|
||||||
if (exists(body.saveReplay)) videoLive.saveReplay = body.saveReplay
|
if (exists(body.saveReplay)) videoLive.saveReplay = body.saveReplay
|
||||||
|
|
||||||
// The live replay is not saved anymore, destroy the old model if it existed
|
// The live replay is not saved anymore, destroy the old model if it existed
|
||||||
if (!videoLive.saveReplay) {
|
if (!videoLive.saveReplay) {
|
||||||
if (videoLive.replaySettingId) {
|
if (videoLive.replaySettingId) {
|
||||||
await VideoLiveReplaySettingModel.removeSettings(videoLive.replaySettingId)
|
await VideoLiveReplaySettingModel.removeSettings(videoLive.replaySettingId, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const settingModel = videoLive.replaySettingId
|
const settingModel = videoLive.replaySettingId
|
||||||
? await VideoLiveReplaySettingModel.load(videoLive.replaySettingId)
|
? await VideoLiveReplaySettingModel.load(videoLive.replaySettingId, t)
|
||||||
: new VideoLiveReplaySettingModel()
|
: new VideoLiveReplaySettingModel()
|
||||||
|
|
||||||
if (exists(body.replaySettings.privacy)) settingModel.privacy = body.replaySettings.privacy
|
if (exists(body.replaySettings.privacy)) settingModel.privacy = body.replaySettings.privacy
|
||||||
|
|
||||||
return settingModel.save()
|
return settingModel.save({ transaction: t })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addLiveVideo (req: express.Request, res: express.Response) {
|
async function addLiveVideo (req: express.Request, res: express.Response) {
|
||||||
|
@ -164,7 +182,7 @@ async function addLiveVideo (req: express.Request, res: express.Response) {
|
||||||
fromDescription: false,
|
fromDescription: false,
|
||||||
finalFallback: undefined
|
finalFallback: undefined
|
||||||
},
|
},
|
||||||
liveAttributes: pick(videoInfo, [ 'saveReplay', 'permanentLive', 'latencyMode', 'replaySettings' ]),
|
liveAttributes: pick(videoInfo, [ 'saveReplay', 'permanentLive', 'latencyMode', 'replaySettings', 'schedules' ]),
|
||||||
videoAttributeResultHook: 'filter:api.video.live.video-attribute.result',
|
videoAttributeResultHook: 'filter:api.video.live.video-attribute.result',
|
||||||
lTags,
|
lTags,
|
||||||
videoAttributes: {
|
videoAttributes: {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { isArray } from './custom-validators/misc.js'
|
||||||
import { buildDigest } from './peertube-crypto.js'
|
import { buildDigest } from './peertube-crypto.js'
|
||||||
import type { signJsonLDObject } from './peertube-jsonld.js'
|
import type { signJsonLDObject } from './peertube-jsonld.js'
|
||||||
import { doJSONRequest } from './requests.js'
|
import { doJSONRequest } from './requests.js'
|
||||||
|
import { logger } from './logger.js'
|
||||||
|
|
||||||
export type ContextFilter = <T>(arg: T) => Promise<T>
|
export type ContextFilter = <T>(arg: T) => Promise<T>
|
||||||
|
|
||||||
|
@ -36,7 +37,13 @@ export async function signAndContextify<T> (options: {
|
||||||
? await activityPubContextify(data, contextType, contextFilter)
|
? await activityPubContextify(data, contextType, contextFilter)
|
||||||
: data
|
: data
|
||||||
|
|
||||||
return signerFunction({ byActor, data: activity })
|
try {
|
||||||
|
return await signerFunction({ byActor, data: activity })
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug('Cannot sign activity', { activity, err })
|
||||||
|
|
||||||
|
throw err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getApplicationActorOfHost (host: string) {
|
export async function getApplicationActorOfHost (host: string) {
|
||||||
|
@ -117,6 +124,8 @@ const contextStore: { [id in ContextType]: (string | { [id: string]: string })[]
|
||||||
},
|
},
|
||||||
|
|
||||||
originallyPublishedAt: 'sc:datePublished',
|
originallyPublishedAt: 'sc:datePublished',
|
||||||
|
schedules: 'sc:eventSchedule',
|
||||||
|
startDate: 'sc:startDate',
|
||||||
|
|
||||||
uploadDate: 'sc:uploadDate',
|
uploadDate: 'sc:uploadDate',
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,18 @@
|
||||||
import { LiveVideoLatencyMode } from '@peertube/peertube-models'
|
import { LiveVideoLatencyMode } from '@peertube/peertube-models'
|
||||||
|
import { isDateValid } from './misc.js'
|
||||||
|
|
||||||
function isLiveLatencyModeValid (value: any) {
|
export function isLiveLatencyModeValid (value: any) {
|
||||||
return [ LiveVideoLatencyMode.DEFAULT, LiveVideoLatencyMode.SMALL_LATENCY, LiveVideoLatencyMode.HIGH_LATENCY ].includes(value)
|
return [ LiveVideoLatencyMode.DEFAULT, LiveVideoLatencyMode.SMALL_LATENCY, LiveVideoLatencyMode.HIGH_LATENCY ].includes(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
export function isLiveScheduleValid (schedule: any) {
|
||||||
|
return isDateValid(schedule?.startAt)
|
||||||
export {
|
}
|
||||||
isLiveLatencyModeValid
|
|
||||||
|
export function areLiveSchedulesValid (schedules: any[]) {
|
||||||
|
if (!schedules) return true
|
||||||
|
|
||||||
|
if (!Array.isArray(schedules)) return false
|
||||||
|
|
||||||
|
return schedules.every(schedule => isLiveScheduleValid(schedule))
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ function pickCommonVideoQuery (query: VideosCommonQueryAfterSanitize) {
|
||||||
'nsfwFlagsIncluded',
|
'nsfwFlagsIncluded',
|
||||||
'nsfwFlagsExcluded',
|
'nsfwFlagsExcluded',
|
||||||
'isLive',
|
'isLive',
|
||||||
|
'includeScheduledLive',
|
||||||
'categoryOneOf',
|
'categoryOneOf',
|
||||||
'licenceOneOf',
|
'licenceOneOf',
|
||||||
'languageOneOf',
|
'languageOneOf',
|
||||||
|
|
|
@ -48,7 +48,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const LAST_MIGRATION_VERSION = 920
|
export const LAST_MIGRATION_VERSION = 925
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
|
@ -21,6 +21,7 @@ import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.j
|
||||||
import { VideoChapterModel } from '@server/models/video/video-chapter.js'
|
import { VideoChapterModel } from '@server/models/video/video-chapter.js'
|
||||||
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
|
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
|
||||||
import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting.js'
|
import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting.js'
|
||||||
|
import { VideoLiveScheduleModel } from '@server/models/video/video-live-schedule.js'
|
||||||
import { VideoLiveSessionModel } from '@server/models/video/video-live-session.js'
|
import { VideoLiveSessionModel } from '@server/models/video/video-live-session.js'
|
||||||
import { VideoPasswordModel } from '@server/models/video/video-password.js'
|
import { VideoPasswordModel } from '@server/models/video/video-password.js'
|
||||||
import { VideoSourceModel } from '@server/models/video/video-source.js'
|
import { VideoSourceModel } from '@server/models/video/video-source.js'
|
||||||
|
@ -187,7 +188,8 @@ export async function initDatabaseModels (silent: boolean) {
|
||||||
AutomaticTagModel,
|
AutomaticTagModel,
|
||||||
WatchedWordsListModel,
|
WatchedWordsListModel,
|
||||||
AccountAutomaticTagPolicyModel,
|
AccountAutomaticTagPolicyModel,
|
||||||
UploadImageModel
|
UploadImageModel,
|
||||||
|
VideoLiveScheduleModel
|
||||||
])
|
])
|
||||||
|
|
||||||
// Check extensions exist in the database
|
// Check extensions exist in the database
|
||||||
|
|
24
server/core/initializers/migrations/0925-live-schedule-at.ts
Normal file
24
server/core/initializers/migrations/0925-live-schedule-at.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import * as Sequelize from 'sequelize'
|
||||||
|
|
||||||
|
async function up (utils: {
|
||||||
|
transaction: Sequelize.Transaction
|
||||||
|
queryInterface: Sequelize.QueryInterface
|
||||||
|
sequelize: Sequelize.Sequelize
|
||||||
|
}): Promise<void> {
|
||||||
|
const { transaction } = utils
|
||||||
|
|
||||||
|
const query =
|
||||||
|
// eslint-disable-next-line max-len
|
||||||
|
`CREATE TABLE IF NOT EXISTS "videoLiveSchedule" ("id" SERIAL , "startAt" TIMESTAMP WITH TIME ZONE NOT NULL, "liveVideoId" INTEGER REFERENCES "videoLive" ("id") ON DELETE CASCADE ON UPDATE CASCADE, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, PRIMARY KEY ("id"));`
|
||||||
|
|
||||||
|
await utils.sequelize.query(query, { transaction })
|
||||||
|
}
|
||||||
|
|
||||||
|
function down (options) {
|
||||||
|
throw new Error('Not implemented.')
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
up,
|
||||||
|
down
|
||||||
|
}
|
|
@ -6,7 +6,7 @@ import { ActorModel } from '@server/models/actor/actor.js'
|
||||||
import { MActorFull } from '@server/types/models/index.js'
|
import { MActorFull } from '@server/types/models/index.js'
|
||||||
import WebFinger from 'webfinger.js'
|
import WebFinger from 'webfinger.js'
|
||||||
|
|
||||||
// eslint-disable-next-line new-cap
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||||
const webfinger = new WebFinger({
|
const webfinger = new WebFinger({
|
||||||
tls_only: isProdInstance(),
|
tls_only: isProdInstance(),
|
||||||
uri_fallback: false,
|
uri_fallback: false,
|
||||||
|
|
|
@ -17,6 +17,7 @@ import { setVideoTags } from '@server/lib/video.js'
|
||||||
import { StoryboardModel } from '@server/models/video/storyboard.js'
|
import { StoryboardModel } from '@server/models/video/storyboard.js'
|
||||||
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
|
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
|
||||||
import { VideoFileModel } from '@server/models/video/video-file.js'
|
import { VideoFileModel } from '@server/models/video/video-file.js'
|
||||||
|
import { VideoLiveScheduleModel } from '@server/models/video/video-live-schedule.js'
|
||||||
import { VideoLiveModel } from '@server/models/video/video-live.js'
|
import { VideoLiveModel } from '@server/models/video/video-live.js'
|
||||||
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js'
|
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js'
|
||||||
import {
|
import {
|
||||||
|
@ -35,6 +36,7 @@ import {
|
||||||
getCaptionAttributesFromObject,
|
getCaptionAttributesFromObject,
|
||||||
getFileAttributesFromUrl,
|
getFileAttributesFromUrl,
|
||||||
getLiveAttributesFromObject,
|
getLiveAttributesFromObject,
|
||||||
|
getLiveSchedulesAttributesFromObject,
|
||||||
getPreviewFromIcons,
|
getPreviewFromIcons,
|
||||||
getStoryboardAttributeFromObject,
|
getStoryboardAttributeFromObject,
|
||||||
getStreamingPlaylistAttributesFromObject,
|
getStreamingPlaylistAttributesFromObject,
|
||||||
|
@ -136,7 +138,14 @@ export abstract class APVideoAbstractBuilder {
|
||||||
const attributes = getLiveAttributesFromObject(video, this.videoObject)
|
const attributes = getLiveAttributesFromObject(video, this.videoObject)
|
||||||
const [ videoLive ] = await VideoLiveModel.upsert(attributes, { transaction, returning: true })
|
const [ videoLive ] = await VideoLiveModel.upsert(attributes, { transaction, returning: true })
|
||||||
|
|
||||||
video.VideoLive = videoLive
|
await VideoLiveScheduleModel.deleteAllOfLiveId(videoLive.id, transaction)
|
||||||
|
videoLive.LiveSchedules = []
|
||||||
|
|
||||||
|
for (const scheduleAttributes of getLiveSchedulesAttributesFromObject(videoLive, this.videoObject)) {
|
||||||
|
const scheduleModel = new VideoLiveScheduleModel(scheduleAttributes)
|
||||||
|
|
||||||
|
videoLive.LiveSchedules.push(await scheduleModel.save({ transaction }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async setWebVideoFiles (video: MVideoFullLight, t: Transaction) {
|
protected async setWebVideoFiles (video: MVideoFullLight, t: Transaction) {
|
||||||
|
|
|
@ -31,7 +31,15 @@ import { VideoCaptionModel } from '@server/models/video/video-caption.js'
|
||||||
import { VideoFileModel } from '@server/models/video/video-file.js'
|
import { VideoFileModel } from '@server/models/video/video-file.js'
|
||||||
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js'
|
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js'
|
||||||
import { FilteredModelAttributes } from '@server/types/index.js'
|
import { FilteredModelAttributes } from '@server/types/index.js'
|
||||||
import { isStreamingPlaylist, MChannelId, MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoId } from '@server/types/models/index.js'
|
import {
|
||||||
|
isStreamingPlaylist,
|
||||||
|
MChannelId,
|
||||||
|
MStreamingPlaylistVideo,
|
||||||
|
MVideo,
|
||||||
|
MVideoFile,
|
||||||
|
MVideoId,
|
||||||
|
MVideoLive
|
||||||
|
} from '@server/types/models/index.js'
|
||||||
import { decode as magnetUriDecode } from 'magnet-uri'
|
import { decode as magnetUriDecode } from 'magnet-uri'
|
||||||
import { basename, extname } from 'path'
|
import { basename, extname } from 'path'
|
||||||
import { getDurationFromActivityStream } from '../../activity.js'
|
import { getDurationFromActivityStream } from '../../activity.js'
|
||||||
|
@ -206,6 +214,14 @@ export function getLiveAttributesFromObject (video: MVideoId, videoObject: Video
|
||||||
videoId: video.id
|
videoId: video.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export function getLiveSchedulesAttributesFromObject (live: MVideoLive, videoObject: VideoObject) {
|
||||||
|
const schedules = videoObject.schedules || []
|
||||||
|
|
||||||
|
return schedules.map(s => ({
|
||||||
|
liveVideoId: live.id,
|
||||||
|
startAt: s.startDate
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export function getCaptionAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
|
export function getCaptionAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
|
||||||
return videoObject.subtitleLanguage.map(c => {
|
return videoObject.subtitleLanguage.map(c => {
|
||||||
|
|
|
@ -17,6 +17,7 @@ import { CONFIG } from '@server/initializers/config.js'
|
||||||
import { sequelizeTypescript } from '@server/initializers/database.js'
|
import { sequelizeTypescript } from '@server/initializers/database.js'
|
||||||
import { ScheduleVideoUpdateModel } from '@server/models/video/schedule-video-update.js'
|
import { ScheduleVideoUpdateModel } from '@server/models/video/schedule-video-update.js'
|
||||||
import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting.js'
|
import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting.js'
|
||||||
|
import { VideoLiveScheduleModel } from '@server/models/video/video-live-schedule.js'
|
||||||
import { VideoLiveModel } from '@server/models/video/video-live.js'
|
import { VideoLiveModel } from '@server/models/video/video-live.js'
|
||||||
import { VideoPasswordModel } from '@server/models/video/video-password.js'
|
import { VideoPasswordModel } from '@server/models/video/video-password.js'
|
||||||
import { VideoModel } from '@server/models/video/video.js'
|
import { VideoModel } from '@server/models/video/video.js'
|
||||||
|
@ -44,7 +45,7 @@ type VideoAttributes = Omit<VideoCreate, 'channelId'> & {
|
||||||
inputFilename: string
|
inputFilename: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type LiveAttributes = Pick<LiveVideoCreate, 'permanentLive' | 'latencyMode' | 'saveReplay' | 'replaySettings'> & {
|
type LiveAttributes = Pick<LiveVideoCreate, 'permanentLive' | 'latencyMode' | 'saveReplay' | 'replaySettings' | 'schedules'> & {
|
||||||
streamKey?: string
|
streamKey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -203,6 +204,14 @@ export class LocalVideoCreator {
|
||||||
|
|
||||||
videoLive.videoId = this.video.id
|
videoLive.videoId = this.video.id
|
||||||
this.video.VideoLive = await videoLive.save({ transaction })
|
this.video.VideoLive = await videoLive.save({ transaction })
|
||||||
|
|
||||||
|
if (this.liveAttributes.schedules) {
|
||||||
|
this.video.VideoLive.LiveSchedules = await VideoLiveScheduleModel.addToLiveId(
|
||||||
|
this.video.VideoLive.id,
|
||||||
|
this.liveAttributes.schedules.map(s => s.startAt),
|
||||||
|
transaction
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.videoFile) {
|
if (this.videoFile) {
|
||||||
|
|
|
@ -30,7 +30,7 @@ import {
|
||||||
MVideoChapter,
|
MVideoChapter,
|
||||||
MVideoFile,
|
MVideoFile,
|
||||||
MVideoFullLight,
|
MVideoFullLight,
|
||||||
MVideoLiveWithSetting,
|
MVideoLiveWithSettingSchedules,
|
||||||
MVideoPassword
|
MVideoPassword
|
||||||
} from '@server/types/models/index.js'
|
} from '@server/types/models/index.js'
|
||||||
import { MVideoSource } from '@server/types/models/video/video-source.js'
|
import { MVideoSource } from '@server/types/models/video/video-source.js'
|
||||||
|
@ -92,7 +92,7 @@ export class VideosExporter extends AbstractUserExporter<VideoExportJSON> {
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const live = video.isLive
|
const live = video.isLive
|
||||||
? await VideoLiveModel.loadByVideoIdWithSettings(videoId)
|
? await VideoLiveModel.loadByVideoIdFull(videoId)
|
||||||
: undefined // We already have captions, so we can set it to the video object
|
: undefined // We already have captions, so we can set it to the video object
|
||||||
;(video as any).VideoCaptions = captions
|
;(video as any).VideoCaptions = captions
|
||||||
// Then fetch more attributes for AP serialization
|
// Then fetch more attributes for AP serialization
|
||||||
|
@ -113,7 +113,7 @@ export class VideosExporter extends AbstractUserExporter<VideoExportJSON> {
|
||||||
private exportVideoJSON (options: {
|
private exportVideoJSON (options: {
|
||||||
video: MVideoFullLight
|
video: MVideoFullLight
|
||||||
captions: MVideoCaption[]
|
captions: MVideoCaption[]
|
||||||
live: MVideoLiveWithSetting
|
live: MVideoLiveWithSettingSchedules
|
||||||
passwords: MVideoPassword[]
|
passwords: MVideoPassword[]
|
||||||
source: MVideoSource
|
source: MVideoSource
|
||||||
chapters: MVideoChapter[]
|
chapters: MVideoChapter[]
|
||||||
|
@ -186,7 +186,7 @@ export class VideosExporter extends AbstractUserExporter<VideoExportJSON> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private exportLiveJSON (video: MVideo, live: MVideoLiveWithSetting) {
|
private exportLiveJSON (video: MVideo, live: MVideoLiveWithSettingSchedules) {
|
||||||
if (!video.isLive) return undefined
|
if (!video.isLive) return undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -197,7 +197,11 @@ export class VideosExporter extends AbstractUserExporter<VideoExportJSON> {
|
||||||
|
|
||||||
replaySettings: live.ReplaySetting
|
replaySettings: live.ReplaySetting
|
||||||
? { privacy: live.ReplaySetting.privacy }
|
? { privacy: live.ReplaySetting.privacy }
|
||||||
: undefined
|
: undefined,
|
||||||
|
|
||||||
|
schedules: live.LiveSchedules?.map(s => ({
|
||||||
|
startAt: s.startAt.toISOString()
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ import { isArray, isBooleanValid, isUUIDValid } from '@server/helpers/custom-val
|
||||||
import { isVideoCaptionLanguageValid } from '@server/helpers/custom-validators/video-captions.js'
|
import { isVideoCaptionLanguageValid } from '@server/helpers/custom-validators/video-captions.js'
|
||||||
import { isVideoChannelUsernameValid } from '@server/helpers/custom-validators/video-channels.js'
|
import { isVideoChannelUsernameValid } from '@server/helpers/custom-validators/video-channels.js'
|
||||||
import { isVideoChapterTimecodeValid, isVideoChapterTitleValid } from '@server/helpers/custom-validators/video-chapters.js'
|
import { isVideoChapterTimecodeValid, isVideoChapterTitleValid } from '@server/helpers/custom-validators/video-chapters.js'
|
||||||
import { isLiveLatencyModeValid } from '@server/helpers/custom-validators/video-lives.js'
|
import { isLiveLatencyModeValid, isLiveScheduleValid } from '@server/helpers/custom-validators/video-lives.js'
|
||||||
import {
|
import {
|
||||||
isPasswordValid,
|
isPasswordValid,
|
||||||
isVideoCategoryValid,
|
isVideoCategoryValid,
|
||||||
|
@ -133,6 +133,10 @@ export class VideosImporter extends AbstractUserImporter<VideoExportJSON, Import
|
||||||
|
|
||||||
if (!o.live.streamKey) o.live.streamKey = buildUUID()
|
if (!o.live.streamKey) o.live.streamKey = buildUUID()
|
||||||
else if (!isUUIDValid(o.live.streamKey)) return undefined
|
else if (!isUUIDValid(o.live.streamKey)) return undefined
|
||||||
|
|
||||||
|
if (!isArray(o.live.schedules)) o.live.schedules = []
|
||||||
|
|
||||||
|
o.live.schedules = o.live.schedules.filter(s => isLiveScheduleValid(s))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (o.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
|
if (o.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
|
||||||
|
|
|
@ -1,12 +1,3 @@
|
||||||
import express from 'express'
|
|
||||||
import { body } from 'express-validator'
|
|
||||||
import { isLiveLatencyModeValid } from '@server/helpers/custom-validators/video-lives.js'
|
|
||||||
import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js'
|
|
||||||
import { isLocalLiveVideoAccepted } from '@server/lib/moderation.js'
|
|
||||||
import { Hooks } from '@server/lib/plugins/hooks.js'
|
|
||||||
import { VideoModel } from '@server/models/video/video.js'
|
|
||||||
import { VideoLiveModel } from '@server/models/video/video-live.js'
|
|
||||||
import { VideoLiveSessionModel } from '@server/models/video/video-live-session.js'
|
|
||||||
import {
|
import {
|
||||||
HttpStatusCode,
|
HttpStatusCode,
|
||||||
LiveVideoCreate,
|
LiveVideoCreate,
|
||||||
|
@ -16,6 +7,15 @@ import {
|
||||||
UserRight,
|
UserRight,
|
||||||
VideoState
|
VideoState
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
|
import { isLiveLatencyModeValid, areLiveSchedulesValid } from '@server/helpers/custom-validators/video-lives.js'
|
||||||
|
import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js'
|
||||||
|
import { isLocalLiveVideoAccepted } from '@server/lib/moderation.js'
|
||||||
|
import { Hooks } from '@server/lib/plugins/hooks.js'
|
||||||
|
import { VideoLiveSessionModel } from '@server/models/video/video-live-session.js'
|
||||||
|
import { VideoLiveModel } from '@server/models/video/video-live.js'
|
||||||
|
import { VideoModel } from '@server/models/video/video.js'
|
||||||
|
import express from 'express'
|
||||||
|
import { body } from 'express-validator'
|
||||||
import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc.js'
|
import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc.js'
|
||||||
import { isValidPasswordProtectedPrivacy, isVideoNameValid, isVideoReplayPrivacyValid } from '../../../helpers/custom-validators/videos.js'
|
import { isValidPasswordProtectedPrivacy, isVideoNameValid, isVideoReplayPrivacyValid } from '../../../helpers/custom-validators/videos.js'
|
||||||
import { cleanUpReqFiles } from '../../../helpers/express-utils.js'
|
import { cleanUpReqFiles } from '../../../helpers/express-utils.js'
|
||||||
|
@ -37,7 +37,7 @@ const videoLiveGetValidator = [
|
||||||
if (areValidationErrors(req, res)) return
|
if (areValidationErrors(req, res)) return
|
||||||
if (!await doesVideoExist(req.params.videoId, res, 'all')) return
|
if (!await doesVideoExist(req.params.videoId, res, 'all')) return
|
||||||
|
|
||||||
const videoLive = await VideoLiveModel.loadByVideoId(res.locals.videoAll.id)
|
const videoLive = await VideoLiveModel.loadByVideoIdFull(res.locals.videoAll.id)
|
||||||
if (!videoLive) {
|
if (!videoLive) {
|
||||||
return res.fail({
|
return res.fail({
|
||||||
status: HttpStatusCode.NOT_FOUND_404,
|
status: HttpStatusCode.NOT_FOUND_404,
|
||||||
|
@ -86,6 +86,10 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
|
||||||
.isArray()
|
.isArray()
|
||||||
.withMessage('Video passwords should be an array.'),
|
.withMessage('Video passwords should be an array.'),
|
||||||
|
|
||||||
|
body('schedules')
|
||||||
|
.optional()
|
||||||
|
.custom(areLiveSchedulesValid).withMessage('Should have a valid schedules array'),
|
||||||
|
|
||||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
|
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
|
||||||
if (areErrorsInNSFW(req, res)) return cleanUpReqFiles(req)
|
if (areErrorsInNSFW(req, res)) return cleanUpReqFiles(req)
|
||||||
|
@ -176,6 +180,10 @@ const videoLiveUpdateValidator = [
|
||||||
.customSanitizer(toIntOrNull)
|
.customSanitizer(toIntOrNull)
|
||||||
.custom(isLiveLatencyModeValid),
|
.custom(isLiveLatencyModeValid),
|
||||||
|
|
||||||
|
body('schedules')
|
||||||
|
.optional()
|
||||||
|
.custom(areLiveSchedulesValid).withMessage('Should have a valid schedules array'),
|
||||||
|
|
||||||
(req: express.Request, res: express.Response, next: express.NextFunction) => {
|
(req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
if (areValidationErrors(req, res)) return
|
if (areValidationErrors(req, res)) return
|
||||||
|
|
||||||
|
@ -244,10 +252,10 @@ const videoLiveFindReplaySessionValidator = [
|
||||||
|
|
||||||
export {
|
export {
|
||||||
videoLiveAddValidator,
|
videoLiveAddValidator,
|
||||||
videoLiveUpdateValidator,
|
|
||||||
videoLiveListSessionsValidator,
|
|
||||||
videoLiveFindReplaySessionValidator,
|
videoLiveFindReplaySessionValidator,
|
||||||
videoLiveGetValidator
|
videoLiveGetValidator,
|
||||||
|
videoLiveListSessionsValidator,
|
||||||
|
videoLiveUpdateValidator
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
@ -492,6 +492,10 @@ export const commonVideosFiltersValidator = [
|
||||||
.optional()
|
.optional()
|
||||||
.customSanitizer(toBooleanOrNull)
|
.customSanitizer(toBooleanOrNull)
|
||||||
.custom(isBooleanValid).withMessage('Should have a valid isLive boolean'),
|
.custom(isBooleanValid).withMessage('Should have a valid isLive boolean'),
|
||||||
|
query('includeScheduledLive')
|
||||||
|
.optional()
|
||||||
|
.customSanitizer(toBooleanOrNull)
|
||||||
|
.custom(isBooleanValid).withMessage('Should have a valid includeScheduledLive boolean'),
|
||||||
query('include')
|
query('include')
|
||||||
.optional()
|
.optional()
|
||||||
.custom(isVideoIncludeValid),
|
.custom(isVideoIncludeValid),
|
||||||
|
|
|
@ -94,6 +94,10 @@ export function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
|
||||||
? video.originallyPublishedAt.toISOString()
|
? video.originallyPublishedAt.toISOString()
|
||||||
: null,
|
: null,
|
||||||
|
|
||||||
|
schedules: (video.VideoLive?.LiveSchedules || []).map(s => ({
|
||||||
|
startDate: s.startAt
|
||||||
|
})),
|
||||||
|
|
||||||
updated: video.updatedAt.toISOString(),
|
updated: video.updatedAt.toISOString(),
|
||||||
|
|
||||||
uploadDate: video.inputFileUpdatedAt?.toISOString(),
|
uploadDate: video.inputFileUpdatedAt?.toISOString(),
|
||||||
|
|
|
@ -40,22 +40,24 @@ export type VideoFormattingJSONOptions = {
|
||||||
source?: boolean
|
source?: boolean
|
||||||
blockedOwner?: boolean
|
blockedOwner?: boolean
|
||||||
automaticTags?: boolean
|
automaticTags?: boolean
|
||||||
|
liveSchedules?: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function guessAdditionalAttributesFromQuery (query: Pick<VideosCommonQueryAfterSanitize, 'include'>): VideoFormattingJSONOptions {
|
export function guessAdditionalAttributesFromQuery (
|
||||||
if (!query?.include) return {}
|
query: Pick<VideosCommonQueryAfterSanitize, 'include' | 'includeScheduledLive'>
|
||||||
|
): VideoFormattingJSONOptions {
|
||||||
return {
|
return {
|
||||||
additionalAttributes: {
|
additionalAttributes: {
|
||||||
state: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
|
state: query.includeScheduledLive || !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
|
||||||
waitTranscoding: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
|
waitTranscoding: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
|
||||||
scheduledUpdate: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
|
scheduledUpdate: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
|
||||||
blacklistInfo: !!(query.include & VideoInclude.BLACKLISTED),
|
blacklistInfo: !!(query.include & VideoInclude.BLACKLISTED),
|
||||||
files: !!(query.include & VideoInclude.FILES),
|
files: !!(query.include & VideoInclude.FILES),
|
||||||
source: !!(query.include & VideoInclude.SOURCE),
|
source: !!(query.include & VideoInclude.SOURCE),
|
||||||
blockedOwner: !!(query.include & VideoInclude.BLOCKED_OWNER),
|
blockedOwner: !!(query.include & VideoInclude.BLOCKED_OWNER),
|
||||||
automaticTags: !!(query.include & VideoInclude.AUTOMATIC_TAGS)
|
automaticTags: !!(query.include & VideoInclude.AUTOMATIC_TAGS),
|
||||||
|
liveSchedules: query.includeScheduledLive
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -149,6 +151,7 @@ export function videoModelToFormattedDetailsJSON (video: MVideoFormattableDetail
|
||||||
const videoJSON = video.toFormattedJSON({
|
const videoJSON = video.toFormattedJSON({
|
||||||
completeDescription: true,
|
completeDescription: true,
|
||||||
additionalAttributes: {
|
additionalAttributes: {
|
||||||
|
liveSchedules: true,
|
||||||
scheduledUpdate: true,
|
scheduledUpdate: true,
|
||||||
blacklistInfo: true,
|
blacklistInfo: true,
|
||||||
files: true
|
files: true
|
||||||
|
@ -366,5 +369,9 @@ function buildAdditionalAttributes (video: MVideoFormattable, options: VideoForm
|
||||||
result.automaticTags = (video.VideoAutomaticTags || []).map(t => t.AutomaticTag.name)
|
result.automaticTags = (video.VideoAutomaticTags || []).map(t => t.AutomaticTag.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (add?.liveSchedules === true) {
|
||||||
|
result.liveSchedules = (video.VideoLive?.LiveSchedules || []).map(s => s.toFormattedJSON())
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,13 @@
|
||||||
|
import { ActorImageType } from '@peertube/peertube-models'
|
||||||
|
import { MUserAccountId } from '@server/types/models/index.js'
|
||||||
import { Sequelize } from 'sequelize'
|
import { Sequelize } from 'sequelize'
|
||||||
import validator from 'validator'
|
import validator from 'validator'
|
||||||
import { MUserAccountId } from '@server/types/models/index.js'
|
|
||||||
import { ActorImageType } from '@peertube/peertube-models'
|
|
||||||
import { AbstractRunQuery } from '../../../../shared/abstract-run-query.js'
|
import { AbstractRunQuery } from '../../../../shared/abstract-run-query.js'
|
||||||
import { createSafeIn } from '../../../../shared/index.js'
|
import { createSafeIn } from '../../../../shared/index.js'
|
||||||
import { VideoTableAttributes } from './video-table-attributes.js'
|
import { VideoTableAttributes } from './video-table-attributes.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
|
||||||
* Abstract builder to create SQL query and fetch video models
|
* Abstract builder to create SQL query and fetch video models
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class AbstractVideoQueryBuilder extends AbstractRunQuery {
|
export class AbstractVideoQueryBuilder extends AbstractRunQuery {
|
||||||
|
@ -247,6 +245,19 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected includeLiveSchedules () {
|
||||||
|
this.addJoin(
|
||||||
|
'LEFT OUTER JOIN "videoLiveSchedule" AS "VideoLive->VideoLiveSchedules" ' +
|
||||||
|
'ON "VideoLive->VideoLiveSchedules"."liveVideoId" = "VideoLive"."id"'
|
||||||
|
)
|
||||||
|
|
||||||
|
this.attributes = {
|
||||||
|
...this.attributes,
|
||||||
|
|
||||||
|
...this.buildAttributesObject('VideoLive->VideoLiveSchedules', this.tables.getLiveScheduleAttributes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected includeVideoSource () {
|
protected includeVideoSource () {
|
||||||
this.addJoin(
|
this.addJoin(
|
||||||
'LEFT OUTER JOIN "videoSource" AS "VideoSource" ON "video"."id" = "VideoSource"."videoId"'
|
'LEFT OUTER JOIN "videoSource" AS "VideoSource" ON "video"."id" = "VideoSource"."videoId"'
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { ServerBlocklistModel } from '@server/models/server/server-blocklist.js'
|
||||||
import { ServerModel } from '@server/models/server/server.js'
|
import { ServerModel } from '@server/models/server/server.js'
|
||||||
import { TrackerModel } from '@server/models/server/tracker.js'
|
import { TrackerModel } from '@server/models/server/tracker.js'
|
||||||
import { UserVideoHistoryModel } from '@server/models/user/user-video-history.js'
|
import { UserVideoHistoryModel } from '@server/models/user/user-video-history.js'
|
||||||
|
import { VideoLiveScheduleModel } from '@server/models/video/video-live-schedule.js'
|
||||||
import { VideoSourceModel } from '@server/models/video/video-source.js'
|
import { VideoSourceModel } from '@server/models/video/video-source.js'
|
||||||
import { ScheduleVideoUpdateModel } from '../../../schedule-video-update.js'
|
import { ScheduleVideoUpdateModel } from '../../../schedule-video-update.js'
|
||||||
import { TagModel } from '../../../tag.js'
|
import { TagModel } from '../../../tag.js'
|
||||||
|
@ -41,6 +42,7 @@ export class VideoModelBuilder {
|
||||||
private serverBlocklistDone: Set<any>
|
private serverBlocklistDone: Set<any>
|
||||||
private liveDone: Set<any>
|
private liveDone: Set<any>
|
||||||
private sourceDone: Set<any>
|
private sourceDone: Set<any>
|
||||||
|
private liveScheduleDone: Set<any>
|
||||||
private redundancyDone: Set<any>
|
private redundancyDone: Set<any>
|
||||||
private scheduleVideoUpdateDone: Set<any>
|
private scheduleVideoUpdateDone: Set<any>
|
||||||
|
|
||||||
|
@ -75,6 +77,8 @@ export class VideoModelBuilder {
|
||||||
|
|
||||||
this.setUserHistory(row, videoModel)
|
this.setUserHistory(row, videoModel)
|
||||||
this.addThumbnail(row, videoModel)
|
this.addThumbnail(row, videoModel)
|
||||||
|
this.setLive(row, videoModel)
|
||||||
|
this.addLiveSchedule(row, videoModel)
|
||||||
|
|
||||||
const channelActor = videoModel.VideoChannel?.Actor
|
const channelActor = videoModel.VideoChannel?.Actor
|
||||||
if (channelActor) {
|
if (channelActor) {
|
||||||
|
@ -100,7 +104,6 @@ export class VideoModelBuilder {
|
||||||
this.addTracker(row, videoModel)
|
this.addTracker(row, videoModel)
|
||||||
this.setBlacklisted(row, videoModel)
|
this.setBlacklisted(row, videoModel)
|
||||||
this.setScheduleVideoUpdate(row, videoModel)
|
this.setScheduleVideoUpdate(row, videoModel)
|
||||||
this.setLive(row, videoModel)
|
|
||||||
} else {
|
} else {
|
||||||
if (include & VideoInclude.BLACKLISTED) {
|
if (include & VideoInclude.BLACKLISTED) {
|
||||||
this.setBlacklisted(row, videoModel)
|
this.setBlacklisted(row, videoModel)
|
||||||
|
@ -148,6 +151,7 @@ export class VideoModelBuilder {
|
||||||
this.sourceDone = new Set()
|
this.sourceDone = new Set()
|
||||||
this.redundancyDone = new Set()
|
this.redundancyDone = new Set()
|
||||||
this.scheduleVideoUpdateDone = new Set()
|
this.scheduleVideoUpdateDone = new Set()
|
||||||
|
this.liveScheduleDone = new Set()
|
||||||
|
|
||||||
this.accountBlocklistDone = new Set()
|
this.accountBlocklistDone = new Set()
|
||||||
this.serverBlocklistDone = new Set()
|
this.serverBlocklistDone = new Set()
|
||||||
|
@ -428,6 +432,24 @@ export class VideoModelBuilder {
|
||||||
this.liveDone.add(id)
|
this.liveDone.add(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private addLiveSchedule (row: SQLRow, videoModel: VideoModel) {
|
||||||
|
const id = row['VideoLive.VideoLiveSchedules.id']
|
||||||
|
if (!id) return
|
||||||
|
if (this.liveScheduleDone.has(id)) return
|
||||||
|
|
||||||
|
const videoLiveScheduleAttributes = this.grab(row, this.tables.getLiveScheduleAttributes(), 'VideoLive.VideoLiveSchedules')
|
||||||
|
|
||||||
|
const liveScheduleModel = new VideoLiveScheduleModel(videoLiveScheduleAttributes, this.buildOpts)
|
||||||
|
|
||||||
|
if (!videoModel.VideoLive.LiveSchedules) {
|
||||||
|
videoModel.VideoLive.LiveSchedules = []
|
||||||
|
}
|
||||||
|
|
||||||
|
videoModel.VideoLive.LiveSchedules.push(liveScheduleModel)
|
||||||
|
|
||||||
|
this.liveScheduleDone.add(id)
|
||||||
|
}
|
||||||
|
|
||||||
private setSource (row: SQLRow, videoModel: VideoModel) {
|
private setSource (row: SQLRow, videoModel: VideoModel) {
|
||||||
const id = row['VideoSource.id']
|
const id = row['VideoSource.id']
|
||||||
if (!id || this.sourceDone.has(id)) return
|
if (!id || this.sourceDone.has(id)) return
|
||||||
|
|
|
@ -167,6 +167,15 @@ export class VideoTableAttributes {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLiveScheduleAttributes () {
|
||||||
|
return [
|
||||||
|
'id',
|
||||||
|
'startAt',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
getVideoSourceAttributes () {
|
getVideoSourceAttributes () {
|
||||||
return [
|
return [
|
||||||
'id',
|
'id',
|
||||||
|
|
|
@ -155,6 +155,7 @@ export class VideosModelGetQuerySubBuilder extends AbstractVideoQueryBuilder {
|
||||||
|
|
||||||
if (VideosModelGetQuerySubBuilder.liveInclude.has(options.type)) {
|
if (VideosModelGetQuerySubBuilder.liveInclude.has(options.type)) {
|
||||||
this.includeLive()
|
this.includeLive()
|
||||||
|
this.includeLiveSchedules()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.userId && VideosModelGetQuerySubBuilder.userHistoryInclude.has(options.type)) {
|
if (options.userId && VideosModelGetQuerySubBuilder.userHistoryInclude.has(options.type)) {
|
||||||
|
|
|
@ -37,6 +37,7 @@ export type BuildVideosListQueryOptions = {
|
||||||
isLive?: boolean
|
isLive?: boolean
|
||||||
isLocal?: boolean
|
isLocal?: boolean
|
||||||
include?: VideoIncludeType
|
include?: VideoIncludeType
|
||||||
|
includeScheduledLive?: boolean
|
||||||
|
|
||||||
categoryOneOf?: number[]
|
categoryOneOf?: number[]
|
||||||
licenceOneOf?: number[]
|
licenceOneOf?: number[]
|
||||||
|
@ -158,7 +159,9 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
|
||||||
|
|
||||||
// Only list published videos
|
// Only list published videos
|
||||||
if (!(options.include & VideoInclude.NOT_PUBLISHED_STATE)) {
|
if (!(options.include & VideoInclude.NOT_PUBLISHED_STATE)) {
|
||||||
this.whereStateAvailable()
|
if (options.includeScheduledLive) this.joinLiveSchedules()
|
||||||
|
|
||||||
|
this.whereStateAvailable({ includeScheduledLive: options.includeScheduledLive ?? false })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.videoPlaylistId) {
|
if (options.videoPlaylistId) {
|
||||||
|
@ -349,13 +352,28 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
|
||||||
this.replacements.videoPlaylistId = playlistId
|
this.replacements.videoPlaylistId = playlistId
|
||||||
}
|
}
|
||||||
|
|
||||||
private whereStateAvailable () {
|
private joinLiveSchedules () {
|
||||||
this.and.push(
|
this.joins.push(
|
||||||
`("video"."state" = ${VideoState.PUBLISHED} OR ` +
|
'LEFT JOIN "videoLive" ON "video"."id" = "videoLive"."videoId"',
|
||||||
`("video"."state" = ${VideoState.TO_TRANSCODE} AND "video"."waitTranscoding" IS false))`
|
'LEFT JOIN "videoLiveSchedule" ON "videoLiveSchedule"."liveVideoId" = "videoLive"."id"'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private whereStateAvailable (options: {
|
||||||
|
includeScheduledLive: boolean
|
||||||
|
}) {
|
||||||
|
const or: string[] = []
|
||||||
|
|
||||||
|
or.push(`"video"."state" = ${VideoState.PUBLISHED}`)
|
||||||
|
or.push(`("video"."state" = ${VideoState.TO_TRANSCODE} AND "video"."waitTranscoding" IS false)`)
|
||||||
|
|
||||||
|
if (options.includeScheduledLive) {
|
||||||
|
or.push(`("video"."state" = ${VideoState.WAITING_FOR_LIVE} AND "videoLiveSchedule"."startAt" > NOW())`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.and.push(`(${or.join(' OR ')})`)
|
||||||
|
}
|
||||||
|
|
||||||
private wherePrivacyAvailable (user?: MUserAccountId) {
|
private wherePrivacyAvailable (user?: MUserAccountId) {
|
||||||
if (user) {
|
if (user) {
|
||||||
this.and.push(
|
this.and.push(
|
||||||
|
|
|
@ -89,6 +89,14 @@ export class VideosModelListQueryBuilder extends AbstractVideoQueryBuilder {
|
||||||
this.includePlaylist(options.videoPlaylistId)
|
this.includePlaylist(options.videoPlaylistId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.isLive || options.includeScheduledLive) {
|
||||||
|
this.includeLive()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.includeScheduledLive) {
|
||||||
|
this.includeLiveSchedules()
|
||||||
|
}
|
||||||
|
|
||||||
if (options.include & VideoInclude.BLACKLISTED) {
|
if (options.include & VideoInclude.BLACKLISTED) {
|
||||||
this.includeBlacklisted()
|
this.includeBlacklisted()
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,9 +28,10 @@ export class VideoLiveReplaySettingModel extends SequelizeModel<VideoLiveReplayS
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
static removeSettings (id: number) {
|
static removeSettings (id: number, transaction?: Transaction) {
|
||||||
return VideoLiveReplaySettingModel.destroy({
|
return VideoLiveReplaySettingModel.destroy({
|
||||||
where: { id }
|
where: { id },
|
||||||
|
transaction
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
66
server/core/models/video/video-live-schedule.ts
Normal file
66
server/core/models/video/video-live-schedule.ts
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import { LiveVideoSchedule } from '@peertube/peertube-models'
|
||||||
|
import { Transaction } from 'sequelize'
|
||||||
|
import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||||
|
import { SequelizeModel } from '../shared/index.js'
|
||||||
|
import { VideoLiveModel } from './video-live.js'
|
||||||
|
|
||||||
|
@Table({
|
||||||
|
tableName: 'videoLiveSchedule',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: [ 'liveVideoId' ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: [ 'startAt' ]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class VideoLiveScheduleModel extends SequelizeModel<VideoLiveScheduleModel> {
|
||||||
|
@AllowNull(false)
|
||||||
|
@Column
|
||||||
|
declare startAt: Date
|
||||||
|
|
||||||
|
@CreatedAt
|
||||||
|
declare createdAt: Date
|
||||||
|
|
||||||
|
@UpdatedAt
|
||||||
|
declare updatedAt: Date
|
||||||
|
|
||||||
|
@ForeignKey(() => VideoLiveModel)
|
||||||
|
@Column
|
||||||
|
declare liveVideoId: number
|
||||||
|
|
||||||
|
@BelongsTo(() => VideoLiveModel, {
|
||||||
|
foreignKey: {
|
||||||
|
allowNull: true,
|
||||||
|
name: 'liveVideoId'
|
||||||
|
},
|
||||||
|
as: 'LiveVideo',
|
||||||
|
onDelete: 'cascade'
|
||||||
|
})
|
||||||
|
declare LiveVideo: Awaited<VideoLiveModel>
|
||||||
|
|
||||||
|
static deleteAllOfLiveId (id: number, t?: Transaction) {
|
||||||
|
return VideoLiveScheduleModel.destroy({
|
||||||
|
where: {
|
||||||
|
liveVideoId: id
|
||||||
|
},
|
||||||
|
transaction: t
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static addToLiveId (id: number, schedules: (Date | string)[], t?: Transaction): Promise<VideoLiveScheduleModel[]> {
|
||||||
|
return Promise.all(schedules.map(startAt => {
|
||||||
|
return VideoLiveScheduleModel.create({
|
||||||
|
liveVideoId: id,
|
||||||
|
startAt: new Date(startAt)
|
||||||
|
}, { transaction: t })
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
toFormattedJSON (): LiveVideoSchedule {
|
||||||
|
return {
|
||||||
|
startAt: this.startAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +1,11 @@
|
||||||
import { LiveVideo, VideoState, type LiveVideoLatencyModeType } from '@peertube/peertube-models'
|
import { LiveVideo, VideoState, type LiveVideoLatencyModeType } from '@peertube/peertube-models'
|
||||||
import { CONFIG } from '@server/initializers/config.js'
|
import { CONFIG } from '@server/initializers/config.js'
|
||||||
import { WEBSERVER } from '@server/initializers/constants.js'
|
import { WEBSERVER } from '@server/initializers/constants.js'
|
||||||
import { MVideoLive, MVideoLiveVideoWithSetting, MVideoLiveWithSetting } from '@server/types/models/index.js'
|
import {
|
||||||
|
MVideoLiveVideoWithSetting,
|
||||||
|
MVideoLiveVideoWithSettingSchedules,
|
||||||
|
MVideoLiveWithSettingSchedules
|
||||||
|
} from '@server/types/models/index.js'
|
||||||
import { Transaction } from 'sequelize'
|
import { Transaction } from 'sequelize'
|
||||||
import {
|
import {
|
||||||
AllowNull,
|
AllowNull,
|
||||||
|
@ -10,34 +14,17 @@ import {
|
||||||
Column,
|
Column,
|
||||||
CreatedAt,
|
CreatedAt,
|
||||||
DataType,
|
DataType,
|
||||||
DefaultScope,
|
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
|
HasMany,
|
||||||
Table,
|
Table,
|
||||||
UpdatedAt
|
UpdatedAt
|
||||||
} from 'sequelize-typescript'
|
} from 'sequelize-typescript'
|
||||||
|
import { SequelizeModel } from '../shared/index.js'
|
||||||
import { VideoBlacklistModel } from './video-blacklist.js'
|
import { VideoBlacklistModel } from './video-blacklist.js'
|
||||||
import { VideoLiveReplaySettingModel } from './video-live-replay-setting.js'
|
import { VideoLiveReplaySettingModel } from './video-live-replay-setting.js'
|
||||||
|
import { VideoLiveScheduleModel } from './video-live-schedule.js'
|
||||||
import { VideoModel } from './video.js'
|
import { VideoModel } from './video.js'
|
||||||
import { SequelizeModel } from '../shared/index.js'
|
|
||||||
|
|
||||||
@DefaultScope(() => ({
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: VideoModel,
|
|
||||||
required: true,
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: VideoBlacklistModel,
|
|
||||||
required: false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: VideoLiveReplaySettingModel,
|
|
||||||
required: false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}))
|
|
||||||
@Table({
|
@Table({
|
||||||
tableName: 'videoLive',
|
tableName: 'videoLive',
|
||||||
indexes: [
|
indexes: [
|
||||||
|
@ -98,6 +85,15 @@ export class VideoLiveModel extends SequelizeModel<VideoLiveModel> {
|
||||||
})
|
})
|
||||||
declare ReplaySetting: Awaited<VideoLiveReplaySettingModel>
|
declare ReplaySetting: Awaited<VideoLiveReplaySettingModel>
|
||||||
|
|
||||||
|
@HasMany(() => VideoLiveScheduleModel, {
|
||||||
|
foreignKey: {
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
onDelete: 'cascade',
|
||||||
|
hooks: true
|
||||||
|
})
|
||||||
|
declare LiveSchedules: Awaited<VideoLiveScheduleModel>[]
|
||||||
|
|
||||||
@BeforeDestroy
|
@BeforeDestroy
|
||||||
static deleteReplaySetting (instance: VideoLiveModel, options: { transaction: Transaction }) {
|
static deleteReplaySetting (instance: VideoLiveModel, options: { transaction: Transaction }) {
|
||||||
return VideoLiveReplaySettingModel.destroy({
|
return VideoLiveReplaySettingModel.destroy({
|
||||||
|
@ -144,10 +140,10 @@ export class VideoLiveModel extends SequelizeModel<VideoLiveModel> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return VideoLiveModel.findOne<MVideoLive>(query)
|
return VideoLiveModel.findOne<MVideoLiveWithSettingSchedules>(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
static loadByVideoIdWithSettings (videoId: number) {
|
static loadByVideoIdFull (videoId: number) {
|
||||||
const query = {
|
const query = {
|
||||||
where: {
|
where: {
|
||||||
videoId
|
videoId
|
||||||
|
@ -156,14 +152,18 @@ export class VideoLiveModel extends SequelizeModel<VideoLiveModel> {
|
||||||
{
|
{
|
||||||
model: VideoLiveReplaySettingModel.unscoped(),
|
model: VideoLiveReplaySettingModel.unscoped(),
|
||||||
required: false
|
required: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: VideoLiveScheduleModel.unscoped(),
|
||||||
|
required: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return VideoLiveModel.findOne<MVideoLiveWithSetting>(query)
|
return VideoLiveModel.findOne<MVideoLiveVideoWithSettingSchedules>(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
toFormattedJSON (canSeePrivateInformation: boolean): LiveVideo {
|
toFormattedJSON (this: MVideoLiveWithSettingSchedules, canSeePrivateInformation: boolean): LiveVideo {
|
||||||
let privateInformation: Pick<LiveVideo, 'rtmpUrl' | 'rtmpsUrl' | 'streamKey'> | {} = {}
|
let privateInformation: Pick<LiveVideo, 'rtmpUrl' | 'rtmpsUrl' | 'streamKey'> | {} = {}
|
||||||
|
|
||||||
// If we don't have a stream key, it means this is a remote live so we don't specify the rtmp URL
|
// If we don't have a stream key, it means this is a remote live so we don't specify the rtmp URL
|
||||||
|
@ -192,7 +192,8 @@ export class VideoLiveModel extends SequelizeModel<VideoLiveModel> {
|
||||||
permanentLive: this.permanentLive,
|
permanentLive: this.permanentLive,
|
||||||
saveReplay: this.saveReplay,
|
saveReplay: this.saveReplay,
|
||||||
replaySettings,
|
replaySettings,
|
||||||
latencyMode: this.latencyMode
|
latencyMode: this.latencyMode,
|
||||||
|
schedules: (this.LiveSchedules || []).map(schedule => schedule.toFormattedJSON())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1064,6 +1064,7 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
||||||
isLive?: boolean
|
isLive?: boolean
|
||||||
isLocal?: boolean
|
isLocal?: boolean
|
||||||
include?: VideoIncludeType
|
include?: VideoIncludeType
|
||||||
|
includeScheduledLive?: boolean
|
||||||
|
|
||||||
hasFiles?: boolean // default false
|
hasFiles?: boolean // default false
|
||||||
|
|
||||||
|
@ -1126,6 +1127,7 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
||||||
'privacyOneOf',
|
'privacyOneOf',
|
||||||
'isLocal',
|
'isLocal',
|
||||||
'include',
|
'include',
|
||||||
|
'includeScheduledLive',
|
||||||
'displayOnlyForFollower',
|
'displayOnlyForFollower',
|
||||||
'hasFiles',
|
'hasFiles',
|
||||||
'accountId',
|
'accountId',
|
||||||
|
@ -1166,6 +1168,7 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
||||||
tagsOneOf?: string[]
|
tagsOneOf?: string[]
|
||||||
tagsAllOf?: string[]
|
tagsAllOf?: string[]
|
||||||
privacyOneOf?: VideoPrivacyType[]
|
privacyOneOf?: VideoPrivacyType[]
|
||||||
|
includeScheduledLive?: boolean
|
||||||
|
|
||||||
displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
|
displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
|
||||||
|
|
||||||
|
|
6
server/core/types/express.d.ts
vendored
6
server/core/types/express.d.ts
vendored
|
@ -24,7 +24,7 @@ import {
|
||||||
MVideoFormattableDetails,
|
MVideoFormattableDetails,
|
||||||
MVideoId,
|
MVideoId,
|
||||||
MVideoImmutable,
|
MVideoImmutable,
|
||||||
MVideoLiveFormattable,
|
MVideoLiveSessionReplay,
|
||||||
MVideoPassword,
|
MVideoPassword,
|
||||||
MVideoPlaylistFull,
|
MVideoPlaylistFull,
|
||||||
MVideoPlaylistFullSummary,
|
MVideoPlaylistFullSummary,
|
||||||
|
@ -148,8 +148,8 @@ declare module 'express' {
|
||||||
onlyVideo?: MVideoThumbnailBlacklist
|
onlyVideo?: MVideoThumbnailBlacklist
|
||||||
videoId?: MVideoId
|
videoId?: MVideoId
|
||||||
|
|
||||||
videoLive?: MVideoLiveFormattable
|
videoLive?: MVideoLiveWithSettingSchedules
|
||||||
videoLiveSession?: MVideoLiveSession
|
videoLiveSession?: MVideoLiveSessionReplay
|
||||||
|
|
||||||
videoShare?: MVideoShareActor
|
videoShare?: MVideoShareActor
|
||||||
|
|
||||||
|
|
3
server/core/types/models/video/video-live-schedule.ts
Normal file
3
server/core/types/models/video/video-live-schedule.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import { VideoLiveScheduleModel } from '@server/models/video/video-live-schedule.js'
|
||||||
|
|
||||||
|
export type MLiveSchedule = Omit<VideoLiveScheduleModel, 'VideoLive'>
|
|
@ -2,12 +2,13 @@ import { VideoLiveModel } from '@server/models/video/video-live.js'
|
||||||
import { PickWith } from '@peertube/peertube-typescript-utils'
|
import { PickWith } from '@peertube/peertube-typescript-utils'
|
||||||
import { MVideo } from './video.js'
|
import { MVideo } from './video.js'
|
||||||
import { MLiveReplaySetting } from './video-live-replay-setting.js'
|
import { MLiveReplaySetting } from './video-live-replay-setting.js'
|
||||||
|
import { MLiveSchedule } from './video-live-schedule.js'
|
||||||
|
|
||||||
type Use<K extends keyof VideoLiveModel, M> = PickWith<VideoLiveModel, K, M>
|
type Use<K extends keyof VideoLiveModel, M> = PickWith<VideoLiveModel, K, M>
|
||||||
|
|
||||||
// ############################################################################
|
// ############################################################################
|
||||||
|
|
||||||
export type MVideoLive = Omit<VideoLiveModel, 'Video' | 'ReplaySetting'>
|
export type MVideoLive = Omit<VideoLiveModel, 'Video' | 'ReplaySetting' | 'LiveSchedules'>
|
||||||
|
|
||||||
// ############################################################################
|
// ############################################################################
|
||||||
|
|
||||||
|
@ -21,6 +22,20 @@ export type MVideoLiveWithSetting =
|
||||||
& MVideoLive
|
& MVideoLive
|
||||||
& Use<'ReplaySetting', MLiveReplaySetting>
|
& Use<'ReplaySetting', MLiveReplaySetting>
|
||||||
|
|
||||||
|
export type MVideoLiveWithSettingSchedules =
|
||||||
|
& MVideoLive
|
||||||
|
& Use<'ReplaySetting', MLiveReplaySetting>
|
||||||
|
& Use<'LiveSchedules', MLiveSchedule[]>
|
||||||
|
|
||||||
|
export type MVideoLiveWithSchedules =
|
||||||
|
& MVideoLive
|
||||||
|
& Use<'LiveSchedules', MLiveSchedule[]>
|
||||||
|
|
||||||
export type MVideoLiveVideoWithSetting =
|
export type MVideoLiveVideoWithSetting =
|
||||||
& MVideoLiveVideo
|
& MVideoLiveVideo
|
||||||
& Use<'ReplaySetting', MLiveReplaySetting>
|
& Use<'ReplaySetting', MLiveReplaySetting>
|
||||||
|
|
||||||
|
export type MVideoLiveVideoWithSettingSchedules =
|
||||||
|
& MVideoLiveVideo
|
||||||
|
& Use<'ReplaySetting', MLiveReplaySetting>
|
||||||
|
& Use<'LiveSchedules', MLiveSchedule[]>
|
||||||
|
|
|
@ -19,7 +19,7 @@ import {
|
||||||
MChannelUserId
|
MChannelUserId
|
||||||
} from './video-channel.js'
|
} from './video-channel.js'
|
||||||
import { MVideoFile } from './video-file.js'
|
import { MVideoFile } from './video-file.js'
|
||||||
import { MVideoLive } from './video-live.js'
|
import { MVideoLiveWithSchedules } from './video-live.js'
|
||||||
import {
|
import {
|
||||||
MStreamingPlaylistFiles,
|
MStreamingPlaylistFiles,
|
||||||
MStreamingPlaylistRedundancies,
|
MStreamingPlaylistRedundancies,
|
||||||
|
@ -189,7 +189,7 @@ export type MVideoFullLight =
|
||||||
& Use<'VideoFiles', MVideoFile[]>
|
& Use<'VideoFiles', MVideoFile[]>
|
||||||
& Use<'ScheduleVideoUpdate', MScheduleVideoUpdate>
|
& Use<'ScheduleVideoUpdate', MScheduleVideoUpdate>
|
||||||
& Use<'VideoStreamingPlaylists', MStreamingPlaylistFiles[]>
|
& Use<'VideoStreamingPlaylists', MStreamingPlaylistFiles[]>
|
||||||
& Use<'VideoLive', MVideoLive>
|
& Use<'VideoLive', MVideoLiveWithSchedules>
|
||||||
|
|
||||||
// ############################################################################
|
// ############################################################################
|
||||||
|
|
||||||
|
@ -204,7 +204,7 @@ export type MVideoAP =
|
||||||
& Use<'VideoBlacklist', MVideoBlacklistUnfederated>
|
& Use<'VideoBlacklist', MVideoBlacklistUnfederated>
|
||||||
& Use<'VideoFiles', MVideoFile[]>
|
& Use<'VideoFiles', MVideoFile[]>
|
||||||
& Use<'Thumbnails', MThumbnail[]>
|
& Use<'Thumbnails', MThumbnail[]>
|
||||||
& Use<'VideoLive', MVideoLive>
|
& Use<'VideoLive', MVideoLiveWithSchedules>
|
||||||
& Use<'Storyboard', MStoryboard>
|
& Use<'Storyboard', MStoryboard>
|
||||||
|
|
||||||
export type MVideoAPLight = Omit<MVideoAP, 'VideoCaptions' | 'Storyboard'>
|
export type MVideoAPLight = Omit<MVideoAP, 'VideoCaptions' | 'Storyboard'>
|
||||||
|
@ -244,6 +244,7 @@ export type MVideoFormattable =
|
||||||
& PickWithOpt<VideoModel, 'VideoBlacklist', Pick<MVideoBlacklist, 'reason'>>
|
& PickWithOpt<VideoModel, 'VideoBlacklist', Pick<MVideoBlacklist, 'reason'>>
|
||||||
& PickWithOpt<VideoModel, 'VideoStreamingPlaylists', MStreamingPlaylistFiles[]>
|
& PickWithOpt<VideoModel, 'VideoStreamingPlaylists', MStreamingPlaylistFiles[]>
|
||||||
& PickWithOpt<VideoModel, 'VideoFiles', MVideoFile[]>
|
& PickWithOpt<VideoModel, 'VideoFiles', MVideoFile[]>
|
||||||
|
& PickWithOpt<VideoModel, 'VideoLive', MVideoLiveWithSchedules>
|
||||||
|
|
||||||
export type MVideoFormattableDetails =
|
export type MVideoFormattableDetails =
|
||||||
& MVideoFormattable
|
& MVideoFormattable
|
||||||
|
|
|
@ -792,6 +792,7 @@ paths:
|
||||||
- $ref: '#/components/parameters/nsfwFlagsIncluded'
|
- $ref: '#/components/parameters/nsfwFlagsIncluded'
|
||||||
- $ref: '#/components/parameters/nsfwFlagsExcluded'
|
- $ref: '#/components/parameters/nsfwFlagsExcluded'
|
||||||
- $ref: '#/components/parameters/isLive'
|
- $ref: '#/components/parameters/isLive'
|
||||||
|
- $ref: '#/components/parameters/includeScheduledLive'
|
||||||
- $ref: '#/components/parameters/categoryOneOf'
|
- $ref: '#/components/parameters/categoryOneOf'
|
||||||
- $ref: '#/components/parameters/licenceOneOf'
|
- $ref: '#/components/parameters/licenceOneOf'
|
||||||
- $ref: '#/components/parameters/languageOneOf'
|
- $ref: '#/components/parameters/languageOneOf'
|
||||||
|
@ -2324,6 +2325,7 @@ paths:
|
||||||
- $ref: '#/components/parameters/nsfwFlagsIncluded'
|
- $ref: '#/components/parameters/nsfwFlagsIncluded'
|
||||||
- $ref: '#/components/parameters/nsfwFlagsExcluded'
|
- $ref: '#/components/parameters/nsfwFlagsExcluded'
|
||||||
- $ref: '#/components/parameters/isLive'
|
- $ref: '#/components/parameters/isLive'
|
||||||
|
- $ref: '#/components/parameters/includeScheduledLive'
|
||||||
- $ref: '#/components/parameters/categoryOneOf'
|
- $ref: '#/components/parameters/categoryOneOf'
|
||||||
- $ref: '#/components/parameters/licenceOneOf'
|
- $ref: '#/components/parameters/licenceOneOf'
|
||||||
- $ref: '#/components/parameters/languageOneOf'
|
- $ref: '#/components/parameters/languageOneOf'
|
||||||
|
@ -2429,6 +2431,7 @@ paths:
|
||||||
- $ref: '#/components/parameters/nsfwFlagsIncluded'
|
- $ref: '#/components/parameters/nsfwFlagsIncluded'
|
||||||
- $ref: '#/components/parameters/nsfwFlagsExcluded'
|
- $ref: '#/components/parameters/nsfwFlagsExcluded'
|
||||||
- $ref: '#/components/parameters/isLive'
|
- $ref: '#/components/parameters/isLive'
|
||||||
|
- $ref: '#/components/parameters/includeScheduledLive'
|
||||||
- $ref: '#/components/parameters/categoryOneOf'
|
- $ref: '#/components/parameters/categoryOneOf'
|
||||||
- $ref: '#/components/parameters/licenceOneOf'
|
- $ref: '#/components/parameters/licenceOneOf'
|
||||||
- $ref: '#/components/parameters/languageOneOf'
|
- $ref: '#/components/parameters/languageOneOf'
|
||||||
|
@ -3007,6 +3010,7 @@ paths:
|
||||||
- $ref: '#/components/parameters/nsfwFlagsIncluded'
|
- $ref: '#/components/parameters/nsfwFlagsIncluded'
|
||||||
- $ref: '#/components/parameters/nsfwFlagsExcluded'
|
- $ref: '#/components/parameters/nsfwFlagsExcluded'
|
||||||
- $ref: '#/components/parameters/isLive'
|
- $ref: '#/components/parameters/isLive'
|
||||||
|
- $ref: '#/components/parameters/includeScheduledLive'
|
||||||
- $ref: '#/components/parameters/categoryOneOf'
|
- $ref: '#/components/parameters/categoryOneOf'
|
||||||
- $ref: '#/components/parameters/licenceOneOf'
|
- $ref: '#/components/parameters/licenceOneOf'
|
||||||
- $ref: '#/components/parameters/languageOneOf'
|
- $ref: '#/components/parameters/languageOneOf'
|
||||||
|
@ -3776,6 +3780,10 @@ paths:
|
||||||
downloadEnabled:
|
downloadEnabled:
|
||||||
description: Enable or disable downloading for the replay of this live video
|
description: Enable or disable downloading for the replay of this live video
|
||||||
type: boolean
|
type: boolean
|
||||||
|
schedules:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/LiveSchedule'
|
||||||
required:
|
required:
|
||||||
- channelId
|
- channelId
|
||||||
- name
|
- name
|
||||||
|
@ -4753,6 +4761,7 @@ paths:
|
||||||
- $ref: '#/components/parameters/nsfwFlagsIncluded'
|
- $ref: '#/components/parameters/nsfwFlagsIncluded'
|
||||||
- $ref: '#/components/parameters/nsfwFlagsExcluded'
|
- $ref: '#/components/parameters/nsfwFlagsExcluded'
|
||||||
- $ref: '#/components/parameters/isLive'
|
- $ref: '#/components/parameters/isLive'
|
||||||
|
- $ref: '#/components/parameters/includeScheduledLive'
|
||||||
- $ref: '#/components/parameters/categoryOneOf'
|
- $ref: '#/components/parameters/categoryOneOf'
|
||||||
- $ref: '#/components/parameters/licenceOneOf'
|
- $ref: '#/components/parameters/licenceOneOf'
|
||||||
- $ref: '#/components/parameters/languageOneOf'
|
- $ref: '#/components/parameters/languageOneOf'
|
||||||
|
@ -5831,6 +5840,7 @@ paths:
|
||||||
- $ref: '#/components/parameters/nsfwFlagsIncluded'
|
- $ref: '#/components/parameters/nsfwFlagsIncluded'
|
||||||
- $ref: '#/components/parameters/nsfwFlagsExcluded'
|
- $ref: '#/components/parameters/nsfwFlagsExcluded'
|
||||||
- $ref: '#/components/parameters/isLive'
|
- $ref: '#/components/parameters/isLive'
|
||||||
|
- $ref: '#/components/parameters/includeScheduledLive'
|
||||||
- $ref: '#/components/parameters/categoryOneOf'
|
- $ref: '#/components/parameters/categoryOneOf'
|
||||||
- $ref: '#/components/parameters/licenceOneOf'
|
- $ref: '#/components/parameters/licenceOneOf'
|
||||||
- $ref: '#/components/parameters/languageOneOf'
|
- $ref: '#/components/parameters/languageOneOf'
|
||||||
|
@ -7735,6 +7745,13 @@ components:
|
||||||
description: whether or not the video is a live
|
description: whether or not the video is a live
|
||||||
schema:
|
schema:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
includeScheduledLive:
|
||||||
|
name: includeScheduledLive
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: whether or not include live that are scheduled for later
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
categoryOneOf:
|
categoryOneOf:
|
||||||
name: categoryOneOf
|
name: categoryOneOf
|
||||||
in: query
|
in: query
|
||||||
|
@ -8693,6 +8710,10 @@ components:
|
||||||
- $ref: '#/components/schemas/shortUUID'
|
- $ref: '#/components/schemas/shortUUID'
|
||||||
isLive:
|
isLive:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
liveSchedules:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/LiveSchedule'
|
||||||
createdAt:
|
createdAt:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
@ -11525,6 +11546,10 @@ components:
|
||||||
latencyMode:
|
latencyMode:
|
||||||
description: User can select live latency mode if enabled by the instance
|
description: User can select live latency mode if enabled by the instance
|
||||||
$ref: '#/components/schemas/LiveVideoLatencyMode'
|
$ref: '#/components/schemas/LiveVideoLatencyMode'
|
||||||
|
schedules:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/LiveSchedule'
|
||||||
|
|
||||||
LiveVideoResponse:
|
LiveVideoResponse:
|
||||||
properties:
|
properties:
|
||||||
|
@ -11547,6 +11572,17 @@ components:
|
||||||
latencyMode:
|
latencyMode:
|
||||||
description: User can select live latency mode if enabled by the instance
|
description: User can select live latency mode if enabled by the instance
|
||||||
$ref: '#/components/schemas/LiveVideoLatencyMode'
|
$ref: '#/components/schemas/LiveVideoLatencyMode'
|
||||||
|
schedules:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/LiveSchedule'
|
||||||
|
|
||||||
|
LiveSchedule:
|
||||||
|
properties:
|
||||||
|
startAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Date when the stream is scheduled to air at
|
||||||
|
|
||||||
TokenSession:
|
TokenSession:
|
||||||
properties:
|
properties:
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue