1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-03 09:49:20 +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:
Bojidar Marinov 2025-08-01 16:06:27 +03:00 committed by GitHub
parent a5c087d3d4
commit 8c9b4abe45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 858 additions and 148 deletions

View file

@ -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 () {

View file

@ -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 () {

View file

@ -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()">

View file

@ -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
}
} }

View file

@ -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)

View file

@ -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
} }
} }

View file

@ -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>

View file

@ -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
}
]
})
}
} }

View file

@ -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 {

View file

@ -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>>

View file

@ -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`
} }

View file

@ -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)

View file

@ -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>
} }

View file

@ -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 ''

View file

@ -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;">

View file

@ -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

View file

@ -40,6 +40,10 @@ export interface VideoExportJSON {
replaySettings?: { replaySettings?: {
privacy: VideoPrivacyType privacy: VideoPrivacyType
} }
schedules?: {
startAt: string
}[]
} }
url: string url: string

View file

@ -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

View file

@ -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'

View file

@ -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[]
} }

View file

@ -0,0 +1,7 @@
export interface LiveVideoScheduleEdit {
startAt?: Date | string
}
export interface LiveVideoSchedule {
startAt: Date | string
}

View file

@ -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[]
} }

View file

@ -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[]
} }

View file

@ -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 {

View file

@ -663,7 +663,8 @@ export class VideosCommand extends AbstractCommand {
'include', 'include',
'skipCount', 'skipCount',
'autoTagOneOf', 'autoTagOneOf',
'search' 'search',
'includeScheduledLive'
]) ])
} }

View file

@ -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 } })

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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
}) })

View file

@ -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,12 +104,26 @@ 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(() => {
if (newReplaySettingModel) videoLive.replaySettingId = newReplaySettingModel.id return sequelizeTypescript.transaction(async t => {
else videoLive.replaySettingId = null const newReplaySettingModel = await updateReplaySettings(videoLive, body, t)
if (exists(body.permanentLive)) videoLive.permanentLive = body.permanentLive if (newReplaySettingModel) videoLive.replaySettingId = newReplaySettingModel.id
if (exists(body.latencyMode)) videoLive.latencyMode = body.latencyMode else videoLive.replaySettingId = null
if (exists(body.permanentLive)) videoLive.permanentLive = body.permanentLive
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()
@ -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: {

View file

@ -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',

View file

@ -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))
} }

View file

@ -15,6 +15,7 @@ function pickCommonVideoQuery (query: VideosCommonQueryAfterSanitize) {
'nsfwFlagsIncluded', 'nsfwFlagsIncluded',
'nsfwFlagsExcluded', 'nsfwFlagsExcluded',
'isLive', 'isLive',
'includeScheduledLive',
'categoryOneOf', 'categoryOneOf',
'licenceOneOf', 'licenceOneOf',
'languageOneOf', 'languageOneOf',

View file

@ -48,7 +48,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const LAST_MIGRATION_VERSION = 920 export const LAST_MIGRATION_VERSION = 925
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -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

View 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
}

View file

@ -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,

View file

@ -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,
@ -101,7 +103,7 @@ export abstract class APVideoAbstractBuilder {
const existingCaptions = await VideoCaptionModel.listVideoCaptions(video.id, t) const existingCaptions = await VideoCaptionModel.listVideoCaptions(video.id, t)
let captionsToCreate = getCaptionAttributesFromObject(video, this.videoObject) let captionsToCreate = getCaptionAttributesFromObject(video, this.videoObject)
.map(a => new VideoCaptionModel(a) as MVideoCaption) .map(a => new VideoCaptionModel(a) as MVideoCaption)
for (const existingCaption of existingCaptions) { for (const existingCaption of existingCaptions) {
// Only keep captions that do not already exist // Only keep captions that do not already exist
@ -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) {

View file

@ -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 => {

View file

@ -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) {

View file

@ -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()
}))
} }
} }

View file

@ -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) {

View file

@ -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
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -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),

View file

@ -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(),

View file

@ -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
} }

View file

@ -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 {
@ -173,8 +171,8 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
this.addJoin( this.addJoin(
'LEFT OUTER JOIN (' + 'LEFT OUTER JOIN (' +
'"videoTag" AS "Tags->VideoTagModel" INNER JOIN "tag" AS "Tags" ON "Tags"."id" = "Tags->VideoTagModel"."tagId"' + '"videoTag" AS "Tags->VideoTagModel" INNER JOIN "tag" AS "Tags" ON "Tags"."id" = "Tags->VideoTagModel"."tagId"' +
') ' + ') ' +
'ON "video"."id" = "Tags->VideoTagModel"."videoId"' 'ON "video"."id" = "Tags->VideoTagModel"."videoId"'
) )
this.attributes = { this.attributes = {
@ -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"'
@ -263,8 +274,8 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
this.addJoin( this.addJoin(
'LEFT JOIN (' + 'LEFT JOIN (' +
'"videoAutomaticTag" AS "VideoAutomaticTags" INNER JOIN "automaticTag" AS "VideoAutomaticTags->AutomaticTag" ' + '"videoAutomaticTag" AS "VideoAutomaticTags" INNER JOIN "automaticTag" AS "VideoAutomaticTags->AutomaticTag" ' +
'ON "VideoAutomaticTags->AutomaticTag"."id" = "VideoAutomaticTags"."automaticTagId" ' + 'ON "VideoAutomaticTags->AutomaticTag"."id" = "VideoAutomaticTags"."automaticTagId" ' +
') ON "video"."id" = "VideoAutomaticTags"."videoId" AND "VideoAutomaticTags"."accountId" = :autoTagOfAccountId' ') ON "video"."id" = "VideoAutomaticTags"."videoId" AND "VideoAutomaticTags"."accountId" = :autoTagOfAccountId'
) )
this.replacements.autoTagOfAccountId = autoTagOfAccountId this.replacements.autoTagOfAccountId = autoTagOfAccountId
@ -281,8 +292,8 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
this.addJoin( this.addJoin(
'LEFT OUTER JOIN (' + 'LEFT OUTER JOIN (' +
'"videoTracker" AS "Trackers->VideoTrackerModel" ' + '"videoTracker" AS "Trackers->VideoTrackerModel" ' +
'INNER JOIN "tracker" AS "Trackers" ON "Trackers"."id" = "Trackers->VideoTrackerModel"."trackerId"' + 'INNER JOIN "tracker" AS "Trackers" ON "Trackers"."id" = "Trackers->VideoTrackerModel"."trackerId"' +
') ON "video"."id" = "Trackers->VideoTrackerModel"."videoId"' ') ON "video"."id" = "Trackers->VideoTrackerModel"."videoId"'
) )
this.attributes = { this.attributes = {

View file

@ -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

View file

@ -167,6 +167,15 @@ export class VideoTableAttributes {
] ]
} }
getLiveScheduleAttributes () {
return [
'id',
'startAt',
'createdAt',
'updatedAt'
]
}
getVideoSourceAttributes () { getVideoSourceAttributes () {
return [ return [
'id', 'id',

View file

@ -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)) {

View file

@ -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(

View file

@ -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()
} }

View file

@ -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
}) })
} }

View 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
}
}
}

View file

@ -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())
} }
} }
} }

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,3 @@
import { VideoLiveScheduleModel } from '@server/models/video/video-live-schedule.js'
export type MLiveSchedule = Omit<VideoLiveScheduleModel, 'VideoLive'>

View file

@ -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[]>

View file

@ -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

View file

@ -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: