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) {
const options = {
return this.videoService.listAccountVideos({
...filters.toVideosAPIObject(),
videoPagination: pagination,
account: this.account,
skipCount: true
}
return this.videoService.listAccountVideos(options)
skipCount: true,
includeScheduledLive: true
})
}
getSyndicationItems () {

View file

@ -57,15 +57,14 @@ export class VideoChannelVideosComponent implements OnInit, AfterViewInit, OnDes
}
getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
const params = {
return this.videoService.listChannelVideos({
...filters.toVideosAPIObject(),
videoPagination: pagination,
videoChannel: this.videoChannel,
includeScheduledLive: true,
skipCount: true
}
return this.videoService.listChannelVideos(params)
})
}
getSyndicationItems () {

View file

@ -2,8 +2,14 @@
This video will be published on {{ video().scheduledUpdate.updateAt | ptDate: 'full' }}.
</my-alert>
<my-alert rounded="false" type="primary" i18n *ngIf="isWaitingForLive()">
This live is not currently streaming.
<my-alert rounded="false" type="primary" *ngIf="isWaitingForLive()">
<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 rounded="false" type="primary" i18n *ngIf="isLiveEnded()">

View file

@ -46,4 +46,11 @@ export class VideoAlertComponent {
isVideoPasswordProtected () {
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,
latencyMode: LiveVideoLatencyMode.DEFAULT,
saveReplay: this.isReplayAllowed(),
replaySettings: { privacy: this.highestPrivacy() }
replaySettings: { privacy: this.highestPrivacy() },
schedules: []
})
this.manageController.setConfig({ manageType: 'go-live', serverConfig: this.serverService.getHTMLConfig() })
this.manageController.setVideoEdit(videoEdit)

View file

@ -46,9 +46,12 @@ type CommonUpdateForm =
nsfwFlagSex?: boolean
}
type LiveUpdateForm = Omit<LiveVideoUpdate, 'replaySettings'> & {
type LiveUpdateForm = Omit<LiveVideoUpdate, 'replaySettings' | 'schedules'> & {
replayPrivacy?: VideoPrivacyType
liveStreamKey?: string
schedules?: {
startAt?: Date
}[]
}
type ReplaceFileForm = {
@ -72,7 +75,7 @@ type CreateFromImportOptions = LoadFromPublishOptions & Pick<VideoImportCreate,
type CreateFromLiveOptions =
& CreateFromUploadOptions
& Required<Pick<LiveVideoCreate, 'permanentLive' | 'latencyMode' | 'saveReplay' | 'replaySettings'>>
& Required<Pick<LiveVideoCreate, 'permanentLive' | 'latencyMode' | 'saveReplay' | 'replaySettings' | 'schedules'>>
type UpdateFromAPIOptions = {
video?: Pick<
@ -124,6 +127,12 @@ type CommonUpdate = Omit<VideoUpdate, 'thumbnailfile' | 'originallyPublishedAt'
}
}
type LiveUpdate = Omit<LiveVideoUpdate, 'schedules'> & {
schedules?: {
startAt: string
}[]
}
export class VideoEdit {
static readonly SPECIAL_SCHEDULED_PRIVACY = -1
@ -131,7 +140,7 @@ export class VideoEdit {
private common: CommonUpdate = {}
private captions: VideoCaptionWithPathEdit[] = []
private chapters: VideoChaptersEdit = new VideoChaptersEdit()
private live: LiveVideoUpdate
private live: LiveUpdate
private replaceFile: File
private studioTasks: VideoStudioTask[] = []
@ -175,7 +184,7 @@ export class VideoEdit {
common?: Omit<CommonUpdate, 'pluginData' | 'previewfile'>
previewfile?: { size: number }
live?: LiveVideoUpdate
live?: LiveUpdate
pluginData?: any
pluginDefaults?: Record<string, string | boolean>
@ -236,8 +245,13 @@ export class VideoEdit {
permanentLive: options.permanentLive,
saveReplay: options.saveReplay,
replaySettings: options.replaySettings
? { privacy: options.replaySettings.privacy }
: undefined,
schedules: options.schedules
? options.schedules.map(s => ({ startAt: new Date(s.startAt).toISOString() }))
: undefined
}
@ -411,6 +425,10 @@ export class VideoEdit {
replaySettings: live.replaySettings
? { privacy: live.replaySettings.privacy }
: undefined,
schedules: live.schedules
? live.schedules.map(s => ({ startAt: new Date(s.startAt).toISOString() }))
: undefined
}
}
@ -620,6 +638,16 @@ export class VideoEdit {
: 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()
}
@ -632,7 +660,11 @@ export class VideoEdit {
replayPrivacy: this.live.replaySettings
? 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
? this.live.replaySettings
: undefined,
latencyMode: this.live.latencyMode
latencyMode: this.live.latencyMode,
schedules: this.live.schedules
}
}
@ -654,7 +688,8 @@ export class VideoEdit {
permanentLive: this.live.permanentLive,
latencyMode: this.live.latencyMode,
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>
} @else {
<div [formGroup]="form">
<my-alert class="d-block mb-4" type="primary">
@if (isStreaming()) {
<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>
<div class="mt-3" i18n *ngIf="getMaxLiveDuration() >= 0">
Max live duration configured on {{ getInstanceName() }} is {{ getMaxLiveDuration() | myTimeDurationFormatter }}.
If your live reaches this limit, it will be automatically terminated.
Max live duration configured on {{ getInstanceName() }} is {{ getMaxLiveDuration() | myTimeDurationFormatter }}. If your live reaches this limit, it
will be automatically terminated.
</div>
}
</my-alert>
@ -28,12 +27,26 @@
<div>
<div *ngIf="getLive().rtmpUrl" class="form-group">
<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 *ngIf="getLive().rtmpsUrl" class="form-group">
<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 class="form-group">
@ -79,7 +92,7 @@
</my-peertube-checkbox>
</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>
<my-select-options inputId="replayPrivacy" [items]="replayPrivacies" formControlName="replayPrivacy"></my-select-options>
</div>
@ -92,6 +105,37 @@
{{ formErrors.latencyMode }}
</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>

View file

@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common'
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 { BuildFormArgument } from '@app/shared/form-validators/form-validator.model'
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 { LiveDocumentationLinkComponent } from './live-documentation-link.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')
@ -37,6 +39,12 @@ type Form = {
latencyMode: FormControl<LiveVideoLatencyModeType>
saveReplay: FormControl<boolean>
replayPrivacy: FormControl<VideoPrivacyType>
schedules: FormArray<
FormGroup<{
startAt: FormControl<Date>
}>
>
}
@Component({
@ -52,6 +60,7 @@ type Form = {
PeerTubeTemplateDirective,
SelectOptionsComponent,
InputTextComponent,
DatePickerModule,
PeertubeCheckboxComponent,
LiveDocumentationLinkComponent,
AlertComponent,
@ -64,6 +73,7 @@ export class VideoLiveSettingsComponent implements OnInit, OnDestroy {
private formReactiveService = inject(FormReactiveService)
private videoService = inject(VideoService)
private serverService = inject(ServerService)
private i18nPrimengCalendarService = inject(I18nPrimengCalendarService)
private manageController = inject(VideoManageController)
form: FormGroup<Form>
@ -72,6 +82,9 @@ export class VideoLiveSettingsComponent implements OnInit, OnDestroy {
videoEdit: VideoEdit
calendarDateFormat: string
myYearRange: string
replayPrivacies: VideoConstant<VideoPrivacyType>[] = []
latencyModes: SelectOptionsItem[] = [
@ -96,6 +109,11 @@ export class VideoLiveSettingsComponent implements OnInit, OnDestroy {
private updatedSub: Subscription
constructor () {
this.calendarDateFormat = this.i18nPrimengCalendarService.getDateFormat()
this.myYearRange = this.i18nPrimengCalendarService.getVideoPublicationYearRange()
}
ngOnInit () {
this.serverConfig = this.serverService.getHTMLConfig()
@ -137,6 +155,15 @@ export class VideoLiveSettingsComponent implements OnInit, OnDestroy {
this.formErrors = formErrors
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.manageController.setFormError($localize`Live settings`, 'live-settings', this.formErrors)
@ -196,4 +223,18 @@ export class VideoLiveSettingsComponent implements OnInit, OnDestroy {
getInstanceName () {
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 '_mixins' as *;
@use '_form-mixins' as *;
@import 'bootstrap/scss/mixins';
@use "_variables" as *;
@use "_mixins" as *;
@use "_form-mixins" as *;
@import "bootstrap/scss/mixins";
.root {
display: flex;
@ -22,7 +22,8 @@
position: sticky;
top: pvar(--header-height);
background-color: pvar(--bg);
z-index: 1;
// On top of input group addons
z-index: 3;
}
.video-actions {

View file

@ -16,7 +16,7 @@ export type BuildFormArgumentTyped<Form> = ReplaceForm<Form, BuildFormValidator>
// ---------------------------------------------------------------------------
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>>

View file

@ -10,43 +10,61 @@ export class FromNowPipe implements PipeTransform {
transform (arg: number | Date | string) {
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) {
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
// Display "1 year ago" rather than "12 months ago"
if (interval >= 12) return $localize`1 year ago`
if (interval <= -12) return $localize`in 1 year`
if (interval >= 1) {
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
// Display "1 month ago" rather than "4 weeks ago"
if (interval >= 4) return $localize`1 month ago`
if (interval <= -4) return $localize`1 month from now`
if (interval >= 1) {
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) {
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) {
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`in ${ -interval } min`
return $localize`just now`
}

View file

@ -55,6 +55,7 @@ export class Video implements VideoServerModel {
aspectRatio: number
isLive: boolean
liveSchedules: { startAt: Date | string }[]
previewPath: string
previewUrl: string
@ -148,6 +149,9 @@ export class Video implements VideoServerModel {
this.description = hash.description
this.isLive = hash.isLive
this.liveSchedules = hash.liveSchedules
? hash.liveSchedules.map(schedule => ({ startAt: new Date(schedule.startAt.toString()) }))
: null
this.duration = hash.duration
this.durationLabel = Video.buildDurationLabel(this)
@ -195,7 +199,9 @@ export class Video implements VideoServerModel {
this.privacy.label = peertubeTranslate(this.privacy.label, translations)
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)

View file

@ -34,6 +34,9 @@
<ng-container i18n>LIVE</ng-container>
} @else if (isEndedLive()) {
<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 {
<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 { GlobalIconComponent } from '../shared-icons/global-icon.component'
import { Video } from '../shared-main/video/video.model'
import { FromNowPipe } from '../shared-main/date/from-now.pipe'
export type VideoThumbnailInput = Pick<
VideoServerModel,
@ -21,13 +22,15 @@ export type VideoThumbnailInput = Pick<
| 'thumbnailPath'
| 'thumbnailUrl'
| 'userHistory'
| 'originallyPublishedAt'
| 'liveSchedules'
>
@Component({
selector: 'my-video-thumbnail',
styleUrls: [ './video-thumbnail.component.scss' ],
templateUrl: './video-thumbnail.component.html',
imports: [ CommonModule, RouterLink, NgbTooltip, GlobalIconComponent ]
imports: [ CommonModule, RouterLink, NgbTooltip, GlobalIconComponent, FromNowPipe ]
})
export class VideoThumbnailComponent implements OnChanges {
private screenService = inject(ScreenService)
@ -86,6 +89,16 @@ export class VideoThumbnailComponent implements OnChanges {
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 () {
const video = this.video()
if (!video) return ''

View file

@ -23,7 +23,7 @@
<ng-container *ngIf="highlightedLives.length !== 0">
<h2 class="date-title">
<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>
<ng-container *ngFor="let live of highlightedLives; trackBy: videoById;">

View file

@ -44,6 +44,10 @@ export interface VideoObject {
updated: string
uploadDate: string
schedules?: {
startDate: Date
}[]
mediaType: 'text/markdown'
content: string

View file

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

View file

@ -13,6 +13,7 @@ export interface VideosCommonQuery {
nsfwFlagsExcluded?: number
isLive?: boolean
includeScheduledLive?: boolean
isLocal?: boolean
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.type.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-update.model.js'
export * from './live-video.model.js'

View file

@ -1,6 +1,7 @@
import { VideoCreate } from '../video-create.model.js'
import { VideoPrivacyType } from '../video-privacy.enum.js'
import { LiveVideoLatencyModeType } from './live-video-latency-mode.enum.js'
import { LiveVideoScheduleEdit } from './live-video-schedule.model.js'
export interface LiveVideoCreate extends VideoCreate {
permanentLive?: boolean
@ -8,4 +9,6 @@ export interface LiveVideoCreate extends VideoCreate {
saveReplay?: boolean
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 { LiveVideoLatencyModeType } from './live-video-latency-mode.enum.js'
import { LiveVideoScheduleEdit } from './live-video-schedule.model.js'
export interface LiveVideoUpdate {
permanentLive?: boolean
saveReplay?: boolean
replaySettings?: { privacy: VideoPrivacyType }
latencyMode?: LiveVideoLatencyModeType
schedules?: LiveVideoScheduleEdit[]
}

View file

@ -1,14 +1,18 @@
import { VideoPrivacyType } from '../video-privacy.enum.js'
import { LiveVideoLatencyModeType } from './live-video-latency-mode.enum.js'
import { LiveVideoScheduleEdit } from './live-video-schedule.model.js'
export interface LiveVideo {
// If owner
rtmpUrl?: string
rtmpsUrl?: string
streamKey?: string
// End if owner
saveReplay: boolean
replaySettings?: { privacy: VideoPrivacyType }
permanentLive: boolean
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 { VideoFile } from './file/index.js'
import { VideoCommentPolicyType } from './index.js'
import { LiveVideoScheduleEdit } from './live/live-video-schedule.model.js'
import { VideoConstant } from './video-constant.model.js'
import { VideoPrivacyType } from './video-privacy.enum.js'
import { VideoScheduleUpdate } from './video-schedule-update.model.js'
@ -34,6 +35,7 @@ export interface Video extends Partial<VideoAdditionalAttributes> {
aspectRatio: number | null
isLive: boolean
liveSchedules?: LiveVideoScheduleEdit[]
thumbnailPath: string
thumbnailUrl?: string
@ -85,6 +87,8 @@ export interface VideoAdditionalAttributes {
videoSource: VideoSource
automaticTags: string[]
liveSchedules: LiveVideoScheduleEdit[]
}
export interface VideoDetails extends Video {

View file

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

View file

@ -95,7 +95,12 @@ describe('Test video lives API validator', function () {
saveReplay: false,
replaySettings: undefined,
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 })
})
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 () {
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 })
})
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 () {
await command.update({ videoId: video.id, 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 () {
let ffmpegCommand: any
let liveVideoId: string

View file

@ -566,6 +566,8 @@ function runTest (withObjectStorage: boolean) {
expect(liveVideo.live.permanentLive).to.be.true
expect(liveVideo.live.streamKey).to.exist
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.privacy).to.equal(VideoPrivacy.PASSWORD_PROTECTED)

View file

@ -492,6 +492,7 @@ function runTest (withObjectStorage: boolean) {
expect(live.permanentLive).to.be.true
expect(live.streamKey).to.exist
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.privacy.id).to.equal(VideoPrivacy.PASSWORD_PROTECTED)

View file

@ -337,7 +337,10 @@ export async function prepareImportExportTests (options: {
videoPasswords: [ 'password1' ],
channelId: noahSecondChannelId,
name: 'noah live video',
privacy: VideoPrivacy.PASSWORD_PROTECTED
privacy: VideoPrivacy.PASSWORD_PROTECTED,
schedules: [
{ startAt: new Date(Date.now() + 1000 * 60 * 60).toISOString() }
]
},
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 { 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 { getFormattedObjects } from '@server/helpers/utils.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 { LocalVideoCreator } from '@server/lib/local-video-creator.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import {
videoLiveAddValidator,
@ -14,13 +18,13 @@ import {
videoLiveUpdateValidator
} from '@server/middlewares/validators/videos/video-live.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 { 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 { 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')
@ -100,13 +104,27 @@ async function updateLiveVideo (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
const videoLive = res.locals.videoLive
const newReplaySettingModel = await updateReplaySettings(videoLive, body)
await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async t => {
const newReplaySettingModel = await updateReplaySettings(videoLive, body, t)
if (newReplaySettingModel) videoLive.replaySettingId = newReplaySettingModel.id
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()
await federateVideoIfNeeded(video, false)
@ -114,25 +132,25 @@ async function updateLiveVideo (req: express.Request, res: express.Response) {
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function updateReplaySettings (videoLive: MVideoLive, body: LiveVideoUpdate) {
async function updateReplaySettings (videoLive: MVideoLive, body: LiveVideoUpdate, t: Transaction) {
if (exists(body.saveReplay)) videoLive.saveReplay = body.saveReplay
// The live replay is not saved anymore, destroy the old model if it existed
if (!videoLive.saveReplay) {
if (videoLive.replaySettingId) {
await VideoLiveReplaySettingModel.removeSettings(videoLive.replaySettingId)
await VideoLiveReplaySettingModel.removeSettings(videoLive.replaySettingId, t)
}
return undefined
}
const settingModel = videoLive.replaySettingId
? await VideoLiveReplaySettingModel.load(videoLive.replaySettingId)
? await VideoLiveReplaySettingModel.load(videoLive.replaySettingId, t)
: new VideoLiveReplaySettingModel()
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) {
@ -164,7 +182,7 @@ async function addLiveVideo (req: express.Request, res: express.Response) {
fromDescription: false,
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',
lTags,
videoAttributes: {

View file

@ -5,6 +5,7 @@ import { isArray } from './custom-validators/misc.js'
import { buildDigest } from './peertube-crypto.js'
import type { signJsonLDObject } from './peertube-jsonld.js'
import { doJSONRequest } from './requests.js'
import { logger } from './logger.js'
export type ContextFilter = <T>(arg: T) => Promise<T>
@ -36,7 +37,13 @@ export async function signAndContextify<T> (options: {
? await activityPubContextify(data, contextType, contextFilter)
: 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) {
@ -117,6 +124,8 @@ const contextStore: { [id in ContextType]: (string | { [id: string]: string })[]
},
originallyPublishedAt: 'sc:datePublished',
schedules: 'sc:eventSchedule',
startDate: 'sc:startDate',
uploadDate: 'sc:uploadDate',

View file

@ -1,11 +1,18 @@
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)
}
// ---------------------------------------------------------------------------
export {
isLiveLatencyModeValid
export function isLiveScheduleValid (schedule: any) {
return isDateValid(schedule?.startAt)
}
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',
'nsfwFlagsExcluded',
'isLive',
'includeScheduledLive',
'categoryOneOf',
'licenceOneOf',
'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 { VideoJobInfoModel } from '@server/models/video/video-job-info.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 { VideoPasswordModel } from '@server/models/video/video-password.js'
import { VideoSourceModel } from '@server/models/video/video-source.js'
@ -187,7 +188,8 @@ export async function initDatabaseModels (silent: boolean) {
AutomaticTagModel,
WatchedWordsListModel,
AccountAutomaticTagPolicyModel,
UploadImageModel
UploadImageModel,
VideoLiveScheduleModel
])
// 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 WebFinger from 'webfinger.js'
// eslint-disable-next-line new-cap
// eslint-disable-next-line @typescript-eslint/no-deprecated
const webfinger = new WebFinger({
tls_only: isProdInstance(),
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 { VideoCaptionModel } from '@server/models/video/video-caption.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 { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js'
import {
@ -35,6 +36,7 @@ import {
getCaptionAttributesFromObject,
getFileAttributesFromUrl,
getLiveAttributesFromObject,
getLiveSchedulesAttributesFromObject,
getPreviewFromIcons,
getStoryboardAttributeFromObject,
getStreamingPlaylistAttributesFromObject,
@ -136,7 +138,14 @@ export abstract class APVideoAbstractBuilder {
const attributes = getLiveAttributesFromObject(video, this.videoObject)
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) {

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 { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.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 { basename, extname } from 'path'
import { getDurationFromActivityStream } from '../../activity.js'
@ -206,6 +214,14 @@ export function getLiveAttributesFromObject (video: MVideoId, videoObject: Video
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) {
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 { ScheduleVideoUpdateModel } from '@server/models/video/schedule-video-update.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 { VideoPasswordModel } from '@server/models/video/video-password.js'
import { VideoModel } from '@server/models/video/video.js'
@ -44,7 +45,7 @@ type VideoAttributes = Omit<VideoCreate, 'channelId'> & {
inputFilename: string
}
type LiveAttributes = Pick<LiveVideoCreate, 'permanentLive' | 'latencyMode' | 'saveReplay' | 'replaySettings'> & {
type LiveAttributes = Pick<LiveVideoCreate, 'permanentLive' | 'latencyMode' | 'saveReplay' | 'replaySettings' | 'schedules'> & {
streamKey?: string
}
@ -203,6 +204,14 @@ export class LocalVideoCreator {
videoLive.videoId = this.video.id
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) {

View file

@ -30,7 +30,7 @@ import {
MVideoChapter,
MVideoFile,
MVideoFullLight,
MVideoLiveWithSetting,
MVideoLiveWithSettingSchedules,
MVideoPassword
} from '@server/types/models/index.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
? await VideoLiveModel.loadByVideoIdWithSettings(videoId)
? await VideoLiveModel.loadByVideoIdFull(videoId)
: undefined // We already have captions, so we can set it to the video object
;(video as any).VideoCaptions = captions
// Then fetch more attributes for AP serialization
@ -113,7 +113,7 @@ export class VideosExporter extends AbstractUserExporter<VideoExportJSON> {
private exportVideoJSON (options: {
video: MVideoFullLight
captions: MVideoCaption[]
live: MVideoLiveWithSetting
live: MVideoLiveWithSettingSchedules
passwords: MVideoPassword[]
source: MVideoSource
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
return {
@ -197,7 +197,11 @@ export class VideosExporter extends AbstractUserExporter<VideoExportJSON> {
replaySettings: live.ReplaySetting
? { 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 { isVideoChannelUsernameValid } from '@server/helpers/custom-validators/video-channels.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 {
isPasswordValid,
isVideoCategoryValid,
@ -133,6 +133,10 @@ export class VideosImporter extends AbstractUserImporter<VideoExportJSON, Import
if (!o.live.streamKey) o.live.streamKey = buildUUID()
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) {

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 {
HttpStatusCode,
LiveVideoCreate,
@ -16,6 +7,15 @@ import {
UserRight,
VideoState
} 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 { isValidPasswordProtectedPrivacy, isVideoNameValid, isVideoReplayPrivacyValid } from '../../../helpers/custom-validators/videos.js'
import { cleanUpReqFiles } from '../../../helpers/express-utils.js'
@ -37,7 +37,7 @@ const videoLiveGetValidator = [
if (areValidationErrors(req, res)) 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) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
@ -86,6 +86,10 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
.isArray()
.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) => {
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
if (areErrorsInNSFW(req, res)) return cleanUpReqFiles(req)
@ -176,6 +180,10 @@ const videoLiveUpdateValidator = [
.customSanitizer(toIntOrNull)
.custom(isLiveLatencyModeValid),
body('schedules')
.optional()
.custom(areLiveSchedulesValid).withMessage('Should have a valid schedules array'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
@ -244,10 +252,10 @@ const videoLiveFindReplaySessionValidator = [
export {
videoLiveAddValidator,
videoLiveUpdateValidator,
videoLiveListSessionsValidator,
videoLiveFindReplaySessionValidator,
videoLiveGetValidator
videoLiveGetValidator,
videoLiveListSessionsValidator,
videoLiveUpdateValidator
}
// ---------------------------------------------------------------------------

View file

@ -492,6 +492,10 @@ export const commonVideosFiltersValidator = [
.optional()
.customSanitizer(toBooleanOrNull)
.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')
.optional()
.custom(isVideoIncludeValid),

View file

@ -94,6 +94,10 @@ export function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
? video.originallyPublishedAt.toISOString()
: null,
schedules: (video.VideoLive?.LiveSchedules || []).map(s => ({
startDate: s.startAt
})),
updated: video.updatedAt.toISOString(),
uploadDate: video.inputFileUpdatedAt?.toISOString(),

View file

@ -40,22 +40,24 @@ export type VideoFormattingJSONOptions = {
source?: boolean
blockedOwner?: boolean
automaticTags?: boolean
liveSchedules?: boolean
}
}
export function guessAdditionalAttributesFromQuery (query: Pick<VideosCommonQueryAfterSanitize, 'include'>): VideoFormattingJSONOptions {
if (!query?.include) return {}
export function guessAdditionalAttributesFromQuery (
query: Pick<VideosCommonQueryAfterSanitize, 'include' | 'includeScheduledLive'>
): VideoFormattingJSONOptions {
return {
additionalAttributes: {
state: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
state: query.includeScheduledLive || !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
waitTranscoding: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
scheduledUpdate: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
blacklistInfo: !!(query.include & VideoInclude.BLACKLISTED),
files: !!(query.include & VideoInclude.FILES),
source: !!(query.include & VideoInclude.SOURCE),
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({
completeDescription: true,
additionalAttributes: {
liveSchedules: true,
scheduledUpdate: true,
blacklistInfo: true,
files: true
@ -366,5 +369,9 @@ function buildAdditionalAttributes (video: MVideoFormattable, options: VideoForm
result.automaticTags = (video.VideoAutomaticTags || []).map(t => t.AutomaticTag.name)
}
if (add?.liveSchedules === true) {
result.liveSchedules = (video.VideoLive?.LiveSchedules || []).map(s => s.toFormattedJSON())
}
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 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 { createSafeIn } from '../../../../shared/index.js'
import { VideoTableAttributes } from './video-table-attributes.js'
/**
*
* Abstract builder to create SQL query and fetch video models
*
*/
export class AbstractVideoQueryBuilder extends AbstractRunQuery {
@ -247,6 +245,19 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
}
}
protected includeLiveSchedules () {
this.addJoin(
'LEFT OUTER JOIN "videoLiveSchedule" AS "VideoLive->VideoLiveSchedules" ' +
'ON "VideoLive->VideoLiveSchedules"."liveVideoId" = "VideoLive"."id"'
)
this.attributes = {
...this.attributes,
...this.buildAttributesObject('VideoLive->VideoLiveSchedules', this.tables.getLiveScheduleAttributes())
}
}
protected includeVideoSource () {
this.addJoin(
'LEFT OUTER JOIN "videoSource" AS "VideoSource" ON "video"."id" = "VideoSource"."videoId"'

View file

@ -10,6 +10,7 @@ import { ServerBlocklistModel } from '@server/models/server/server-blocklist.js'
import { ServerModel } from '@server/models/server/server.js'
import { TrackerModel } from '@server/models/server/tracker.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 { ScheduleVideoUpdateModel } from '../../../schedule-video-update.js'
import { TagModel } from '../../../tag.js'
@ -41,6 +42,7 @@ export class VideoModelBuilder {
private serverBlocklistDone: Set<any>
private liveDone: Set<any>
private sourceDone: Set<any>
private liveScheduleDone: Set<any>
private redundancyDone: Set<any>
private scheduleVideoUpdateDone: Set<any>
@ -75,6 +77,8 @@ export class VideoModelBuilder {
this.setUserHistory(row, videoModel)
this.addThumbnail(row, videoModel)
this.setLive(row, videoModel)
this.addLiveSchedule(row, videoModel)
const channelActor = videoModel.VideoChannel?.Actor
if (channelActor) {
@ -100,7 +104,6 @@ export class VideoModelBuilder {
this.addTracker(row, videoModel)
this.setBlacklisted(row, videoModel)
this.setScheduleVideoUpdate(row, videoModel)
this.setLive(row, videoModel)
} else {
if (include & VideoInclude.BLACKLISTED) {
this.setBlacklisted(row, videoModel)
@ -148,6 +151,7 @@ export class VideoModelBuilder {
this.sourceDone = new Set()
this.redundancyDone = new Set()
this.scheduleVideoUpdateDone = new Set()
this.liveScheduleDone = new Set()
this.accountBlocklistDone = new Set()
this.serverBlocklistDone = new Set()
@ -428,6 +432,24 @@ export class VideoModelBuilder {
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) {
const id = row['VideoSource.id']
if (!id || this.sourceDone.has(id)) return

View file

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

View file

@ -155,6 +155,7 @@ export class VideosModelGetQuerySubBuilder extends AbstractVideoQueryBuilder {
if (VideosModelGetQuerySubBuilder.liveInclude.has(options.type)) {
this.includeLive()
this.includeLiveSchedules()
}
if (options.userId && VideosModelGetQuerySubBuilder.userHistoryInclude.has(options.type)) {

View file

@ -37,6 +37,7 @@ export type BuildVideosListQueryOptions = {
isLive?: boolean
isLocal?: boolean
include?: VideoIncludeType
includeScheduledLive?: boolean
categoryOneOf?: number[]
licenceOneOf?: number[]
@ -158,7 +159,9 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
// Only list published videos
if (!(options.include & VideoInclude.NOT_PUBLISHED_STATE)) {
this.whereStateAvailable()
if (options.includeScheduledLive) this.joinLiveSchedules()
this.whereStateAvailable({ includeScheduledLive: options.includeScheduledLive ?? false })
}
if (options.videoPlaylistId) {
@ -349,13 +352,28 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
this.replacements.videoPlaylistId = playlistId
}
private whereStateAvailable () {
this.and.push(
`("video"."state" = ${VideoState.PUBLISHED} OR ` +
`("video"."state" = ${VideoState.TO_TRANSCODE} AND "video"."waitTranscoding" IS false))`
private joinLiveSchedules () {
this.joins.push(
'LEFT JOIN "videoLive" ON "video"."id" = "videoLive"."videoId"',
'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) {
if (user) {
this.and.push(

View file

@ -89,6 +89,14 @@ export class VideosModelListQueryBuilder extends AbstractVideoQueryBuilder {
this.includePlaylist(options.videoPlaylistId)
}
if (options.isLive || options.includeScheduledLive) {
this.includeLive()
}
if (options.includeScheduledLive) {
this.includeLiveSchedules()
}
if (options.include & VideoInclude.BLACKLISTED) {
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({
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 { CONFIG } from '@server/initializers/config.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 {
AllowNull,
@ -10,34 +14,17 @@ import {
Column,
CreatedAt,
DataType,
DefaultScope,
ForeignKey,
HasMany,
Table,
UpdatedAt
} from 'sequelize-typescript'
import { SequelizeModel } from '../shared/index.js'
import { VideoBlacklistModel } from './video-blacklist.js'
import { VideoLiveReplaySettingModel } from './video-live-replay-setting.js'
import { VideoLiveScheduleModel } from './video-live-schedule.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({
tableName: 'videoLive',
indexes: [
@ -98,6 +85,15 @@ export class VideoLiveModel extends SequelizeModel<VideoLiveModel> {
})
declare ReplaySetting: Awaited<VideoLiveReplaySettingModel>
@HasMany(() => VideoLiveScheduleModel, {
foreignKey: {
allowNull: true
},
onDelete: 'cascade',
hooks: true
})
declare LiveSchedules: Awaited<VideoLiveScheduleModel>[]
@BeforeDestroy
static deleteReplaySetting (instance: VideoLiveModel, options: { transaction: Transaction }) {
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 = {
where: {
videoId
@ -156,14 +152,18 @@ export class VideoLiveModel extends SequelizeModel<VideoLiveModel> {
{
model: VideoLiveReplaySettingModel.unscoped(),
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'> | {} = {}
// 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,
saveReplay: this.saveReplay,
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
isLocal?: boolean
include?: VideoIncludeType
includeScheduledLive?: boolean
hasFiles?: boolean // default false
@ -1126,6 +1127,7 @@ export class VideoModel extends SequelizeModel<VideoModel> {
'privacyOneOf',
'isLocal',
'include',
'includeScheduledLive',
'displayOnlyForFollower',
'hasFiles',
'accountId',
@ -1166,6 +1168,7 @@ export class VideoModel extends SequelizeModel<VideoModel> {
tagsOneOf?: string[]
tagsAllOf?: string[]
privacyOneOf?: VideoPrivacyType[]
includeScheduledLive?: boolean
displayOnlyForFollower: DisplayOnlyForFollowerOptions | null

View file

@ -24,7 +24,7 @@ import {
MVideoFormattableDetails,
MVideoId,
MVideoImmutable,
MVideoLiveFormattable,
MVideoLiveSessionReplay,
MVideoPassword,
MVideoPlaylistFull,
MVideoPlaylistFullSummary,
@ -148,8 +148,8 @@ declare module 'express' {
onlyVideo?: MVideoThumbnailBlacklist
videoId?: MVideoId
videoLive?: MVideoLiveFormattable
videoLiveSession?: MVideoLiveSession
videoLive?: MVideoLiveWithSettingSchedules
videoLiveSession?: MVideoLiveSessionReplay
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 { MVideo } from './video.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>
// ############################################################################
export type MVideoLive = Omit<VideoLiveModel, 'Video' | 'ReplaySetting'>
export type MVideoLive = Omit<VideoLiveModel, 'Video' | 'ReplaySetting' | 'LiveSchedules'>
// ############################################################################
@ -21,6 +22,20 @@ export type MVideoLiveWithSetting =
& MVideoLive
& Use<'ReplaySetting', MLiveReplaySetting>
export type MVideoLiveWithSettingSchedules =
& MVideoLive
& Use<'ReplaySetting', MLiveReplaySetting>
& Use<'LiveSchedules', MLiveSchedule[]>
export type MVideoLiveWithSchedules =
& MVideoLive
& Use<'LiveSchedules', MLiveSchedule[]>
export type MVideoLiveVideoWithSetting =
& MVideoLiveVideo
& Use<'ReplaySetting', MLiveReplaySetting>
export type MVideoLiveVideoWithSettingSchedules =
& MVideoLiveVideo
& Use<'ReplaySetting', MLiveReplaySetting>
& Use<'LiveSchedules', MLiveSchedule[]>

View file

@ -19,7 +19,7 @@ import {
MChannelUserId
} from './video-channel.js'
import { MVideoFile } from './video-file.js'
import { MVideoLive } from './video-live.js'
import { MVideoLiveWithSchedules } from './video-live.js'
import {
MStreamingPlaylistFiles,
MStreamingPlaylistRedundancies,
@ -189,7 +189,7 @@ export type MVideoFullLight =
& Use<'VideoFiles', MVideoFile[]>
& Use<'ScheduleVideoUpdate', MScheduleVideoUpdate>
& Use<'VideoStreamingPlaylists', MStreamingPlaylistFiles[]>
& Use<'VideoLive', MVideoLive>
& Use<'VideoLive', MVideoLiveWithSchedules>
// ############################################################################
@ -204,7 +204,7 @@ export type MVideoAP =
& Use<'VideoBlacklist', MVideoBlacklistUnfederated>
& Use<'VideoFiles', MVideoFile[]>
& Use<'Thumbnails', MThumbnail[]>
& Use<'VideoLive', MVideoLive>
& Use<'VideoLive', MVideoLiveWithSchedules>
& Use<'Storyboard', MStoryboard>
export type MVideoAPLight = Omit<MVideoAP, 'VideoCaptions' | 'Storyboard'>
@ -244,6 +244,7 @@ export type MVideoFormattable =
& PickWithOpt<VideoModel, 'VideoBlacklist', Pick<MVideoBlacklist, 'reason'>>
& PickWithOpt<VideoModel, 'VideoStreamingPlaylists', MStreamingPlaylistFiles[]>
& PickWithOpt<VideoModel, 'VideoFiles', MVideoFile[]>
& PickWithOpt<VideoModel, 'VideoLive', MVideoLiveWithSchedules>
export type MVideoFormattableDetails =
& MVideoFormattable

View file

@ -792,6 +792,7 @@ paths:
- $ref: '#/components/parameters/nsfwFlagsIncluded'
- $ref: '#/components/parameters/nsfwFlagsExcluded'
- $ref: '#/components/parameters/isLive'
- $ref: '#/components/parameters/includeScheduledLive'
- $ref: '#/components/parameters/categoryOneOf'
- $ref: '#/components/parameters/licenceOneOf'
- $ref: '#/components/parameters/languageOneOf'
@ -2324,6 +2325,7 @@ paths:
- $ref: '#/components/parameters/nsfwFlagsIncluded'
- $ref: '#/components/parameters/nsfwFlagsExcluded'
- $ref: '#/components/parameters/isLive'
- $ref: '#/components/parameters/includeScheduledLive'
- $ref: '#/components/parameters/categoryOneOf'
- $ref: '#/components/parameters/licenceOneOf'
- $ref: '#/components/parameters/languageOneOf'
@ -2429,6 +2431,7 @@ paths:
- $ref: '#/components/parameters/nsfwFlagsIncluded'
- $ref: '#/components/parameters/nsfwFlagsExcluded'
- $ref: '#/components/parameters/isLive'
- $ref: '#/components/parameters/includeScheduledLive'
- $ref: '#/components/parameters/categoryOneOf'
- $ref: '#/components/parameters/licenceOneOf'
- $ref: '#/components/parameters/languageOneOf'
@ -3007,6 +3010,7 @@ paths:
- $ref: '#/components/parameters/nsfwFlagsIncluded'
- $ref: '#/components/parameters/nsfwFlagsExcluded'
- $ref: '#/components/parameters/isLive'
- $ref: '#/components/parameters/includeScheduledLive'
- $ref: '#/components/parameters/categoryOneOf'
- $ref: '#/components/parameters/licenceOneOf'
- $ref: '#/components/parameters/languageOneOf'
@ -3776,6 +3780,10 @@ paths:
downloadEnabled:
description: Enable or disable downloading for the replay of this live video
type: boolean
schedules:
type: array
items:
$ref: '#/components/schemas/LiveSchedule'
required:
- channelId
- name
@ -4753,6 +4761,7 @@ paths:
- $ref: '#/components/parameters/nsfwFlagsIncluded'
- $ref: '#/components/parameters/nsfwFlagsExcluded'
- $ref: '#/components/parameters/isLive'
- $ref: '#/components/parameters/includeScheduledLive'
- $ref: '#/components/parameters/categoryOneOf'
- $ref: '#/components/parameters/licenceOneOf'
- $ref: '#/components/parameters/languageOneOf'
@ -5831,6 +5840,7 @@ paths:
- $ref: '#/components/parameters/nsfwFlagsIncluded'
- $ref: '#/components/parameters/nsfwFlagsExcluded'
- $ref: '#/components/parameters/isLive'
- $ref: '#/components/parameters/includeScheduledLive'
- $ref: '#/components/parameters/categoryOneOf'
- $ref: '#/components/parameters/licenceOneOf'
- $ref: '#/components/parameters/languageOneOf'
@ -7735,6 +7745,13 @@ components:
description: whether or not the video is a live
schema:
type: boolean
includeScheduledLive:
name: includeScheduledLive
in: query
required: false
description: whether or not include live that are scheduled for later
schema:
type: boolean
categoryOneOf:
name: categoryOneOf
in: query
@ -8693,6 +8710,10 @@ components:
- $ref: '#/components/schemas/shortUUID'
isLive:
type: boolean
liveSchedules:
type: array
items:
$ref: '#/components/schemas/LiveSchedule'
createdAt:
type: string
format: date-time
@ -11525,6 +11546,10 @@ components:
latencyMode:
description: User can select live latency mode if enabled by the instance
$ref: '#/components/schemas/LiveVideoLatencyMode'
schedules:
type: array
items:
$ref: '#/components/schemas/LiveSchedule'
LiveVideoResponse:
properties:
@ -11547,6 +11572,17 @@ components:
latencyMode:
description: User can select live latency mode if enabled by the instance
$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:
properties: