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:
parent
a5c087d3d4
commit
8c9b4abe45
62 changed files with 858 additions and 148 deletions
|
@ -51,15 +51,14 @@ export class AccountVideosComponent implements OnInit, OnDestroy, DisableForReus
|
|||
}
|
||||
|
||||
getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
|
||||
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 () {
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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()">
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>>
|
||||
|
||||
|
|
|
@ -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`
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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 ''
|
||||
|
|
|
@ -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;">
|
||||
|
|
|
@ -44,6 +44,10 @@ export interface VideoObject {
|
|||
updated: string
|
||||
uploadDate: string
|
||||
|
||||
schedules?: {
|
||||
startDate: Date
|
||||
}[]
|
||||
|
||||
mediaType: 'text/markdown'
|
||||
content: string
|
||||
|
||||
|
|
|
@ -40,6 +40,10 @@ export interface VideoExportJSON {
|
|||
replaySettings?: {
|
||||
privacy: VideoPrivacyType
|
||||
}
|
||||
|
||||
schedules?: {
|
||||
startAt: string
|
||||
}[]
|
||||
}
|
||||
|
||||
url: string
|
||||
|
|
|
@ -13,6 +13,7 @@ export interface VideosCommonQuery {
|
|||
nsfwFlagsExcluded?: number
|
||||
|
||||
isLive?: boolean
|
||||
includeScheduledLive?: boolean
|
||||
|
||||
isLocal?: boolean
|
||||
include?: VideoIncludeType
|
||||
|
|
|
@ -3,6 +3,7 @@ export * from './live-video-error.enum.js'
|
|||
export * from './live-video-event-payload.model.js'
|
||||
export * from './live-video-event.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'
|
||||
|
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
export interface LiveVideoScheduleEdit {
|
||||
startAt?: Date | string
|
||||
}
|
||||
|
||||
export interface LiveVideoSchedule {
|
||||
startAt: Date | string
|
||||
}
|
|
@ -1,9 +1,12 @@
|
|||
import { VideoPrivacyType } from '../video-privacy.enum.js'
|
||||
import { 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[]
|
||||
}
|
||||
|
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -663,7 +663,8 @@ export class VideosCommand extends AbstractCommand {
|
|||
'include',
|
||||
'skipCount',
|
||||
'autoTagOneOf',
|
||||
'search'
|
||||
'search',
|
||||
'includeScheduledLive'
|
||||
])
|
||||
}
|
||||
|
||||
|
|
|
@ -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 } })
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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,12 +104,26 @@ 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)
|
||||
if (newReplaySettingModel) videoLive.replaySettingId = newReplaySettingModel.id
|
||||
else videoLive.replaySettingId = null
|
||||
await retryTransactionWrapper(() => {
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
const newReplaySettingModel = await updateReplaySettings(videoLive, body, t)
|
||||
|
||||
if (exists(body.permanentLive)) videoLive.permanentLive = body.permanentLive
|
||||
if (exists(body.latencyMode)) videoLive.latencyMode = body.latencyMode
|
||||
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()
|
||||
|
||||
|
@ -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: {
|
||||
|
|
|
@ -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',
|
||||
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ function pickCommonVideoQuery (query: VideosCommonQueryAfterSanitize) {
|
|||
'nsfwFlagsIncluded',
|
||||
'nsfwFlagsExcluded',
|
||||
'isLive',
|
||||
'includeScheduledLive',
|
||||
'categoryOneOf',
|
||||
'licenceOneOf',
|
||||
'languageOneOf',
|
||||
|
|
|
@ -48,7 +48,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js'
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const LAST_MIGRATION_VERSION = 920
|
||||
export const LAST_MIGRATION_VERSION = 925
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.j
|
|||
import { VideoChapterModel } from '@server/models/video/video-chapter.js'
|
||||
import { 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
|
||||
|
|
24
server/core/initializers/migrations/0925-live-schedule-at.ts
Normal file
24
server/core/initializers/migrations/0925-live-schedule-at.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import * as Sequelize from 'sequelize'
|
||||
|
||||
async function up (utils: {
|
||||
transaction: Sequelize.Transaction
|
||||
queryInterface: Sequelize.QueryInterface
|
||||
sequelize: Sequelize.Sequelize
|
||||
}): Promise<void> {
|
||||
const { transaction } = utils
|
||||
|
||||
const query =
|
||||
// eslint-disable-next-line max-len
|
||||
`CREATE TABLE IF NOT EXISTS "videoLiveSchedule" ("id" SERIAL , "startAt" TIMESTAMP WITH TIME ZONE NOT NULL, "liveVideoId" INTEGER REFERENCES "videoLive" ("id") ON DELETE CASCADE ON UPDATE CASCADE, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, PRIMARY KEY ("id"));`
|
||||
|
||||
await utils.sequelize.query(query, { transaction })
|
||||
}
|
||||
|
||||
function down (options) {
|
||||
throw new Error('Not implemented.')
|
||||
}
|
||||
|
||||
export {
|
||||
up,
|
||||
down
|
||||
}
|
|
@ -6,7 +6,7 @@ import { ActorModel } from '@server/models/actor/actor.js'
|
|||
import { MActorFull } from '@server/types/models/index.js'
|
||||
import 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,
|
||||
|
|
|
@ -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,
|
||||
|
@ -101,7 +103,7 @@ export abstract class APVideoAbstractBuilder {
|
|||
const existingCaptions = await VideoCaptionModel.listVideoCaptions(video.id, t)
|
||||
|
||||
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) {
|
||||
// Only keep captions that do not already exist
|
||||
|
@ -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) {
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
@ -173,8 +171,8 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
|
|||
this.addJoin(
|
||||
'LEFT OUTER JOIN (' +
|
||||
'"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 = {
|
||||
|
@ -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"'
|
||||
|
@ -263,8 +274,8 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
|
|||
this.addJoin(
|
||||
'LEFT JOIN (' +
|
||||
'"videoAutomaticTag" AS "VideoAutomaticTags" INNER JOIN "automaticTag" AS "VideoAutomaticTags->AutomaticTag" ' +
|
||||
'ON "VideoAutomaticTags->AutomaticTag"."id" = "VideoAutomaticTags"."automaticTagId" ' +
|
||||
') ON "video"."id" = "VideoAutomaticTags"."videoId" AND "VideoAutomaticTags"."accountId" = :autoTagOfAccountId'
|
||||
'ON "VideoAutomaticTags->AutomaticTag"."id" = "VideoAutomaticTags"."automaticTagId" ' +
|
||||
') ON "video"."id" = "VideoAutomaticTags"."videoId" AND "VideoAutomaticTags"."accountId" = :autoTagOfAccountId'
|
||||
)
|
||||
|
||||
this.replacements.autoTagOfAccountId = autoTagOfAccountId
|
||||
|
@ -281,8 +292,8 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
|
|||
this.addJoin(
|
||||
'LEFT OUTER JOIN (' +
|
||||
'"videoTracker" AS "Trackers->VideoTrackerModel" ' +
|
||||
'INNER JOIN "tracker" AS "Trackers" ON "Trackers"."id" = "Trackers->VideoTrackerModel"."trackerId"' +
|
||||
') ON "video"."id" = "Trackers->VideoTrackerModel"."videoId"'
|
||||
'INNER JOIN "tracker" AS "Trackers" ON "Trackers"."id" = "Trackers->VideoTrackerModel"."trackerId"' +
|
||||
') ON "video"."id" = "Trackers->VideoTrackerModel"."videoId"'
|
||||
)
|
||||
|
||||
this.attributes = {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -167,6 +167,15 @@ export class VideoTableAttributes {
|
|||
]
|
||||
}
|
||||
|
||||
getLiveScheduleAttributes () {
|
||||
return [
|
||||
'id',
|
||||
'startAt',
|
||||
'createdAt',
|
||||
'updatedAt'
|
||||
]
|
||||
}
|
||||
|
||||
getVideoSourceAttributes () {
|
||||
return [
|
||||
'id',
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
66
server/core/models/video/video-live-schedule.ts
Normal file
66
server/core/models/video/video-live-schedule.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
import { LiveVideoSchedule } from '@peertube/peertube-models'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
import { SequelizeModel } from '../shared/index.js'
|
||||
import { VideoLiveModel } from './video-live.js'
|
||||
|
||||
@Table({
|
||||
tableName: 'videoLiveSchedule',
|
||||
indexes: [
|
||||
{
|
||||
fields: [ 'liveVideoId' ]
|
||||
},
|
||||
{
|
||||
fields: [ 'startAt' ]
|
||||
}
|
||||
]
|
||||
})
|
||||
export class VideoLiveScheduleModel extends SequelizeModel<VideoLiveScheduleModel> {
|
||||
@AllowNull(false)
|
||||
@Column
|
||||
declare startAt: Date
|
||||
|
||||
@CreatedAt
|
||||
declare createdAt: Date
|
||||
|
||||
@UpdatedAt
|
||||
declare updatedAt: Date
|
||||
|
||||
@ForeignKey(() => VideoLiveModel)
|
||||
@Column
|
||||
declare liveVideoId: number
|
||||
|
||||
@BelongsTo(() => VideoLiveModel, {
|
||||
foreignKey: {
|
||||
allowNull: true,
|
||||
name: 'liveVideoId'
|
||||
},
|
||||
as: 'LiveVideo',
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
declare LiveVideo: Awaited<VideoLiveModel>
|
||||
|
||||
static deleteAllOfLiveId (id: number, t?: Transaction) {
|
||||
return VideoLiveScheduleModel.destroy({
|
||||
where: {
|
||||
liveVideoId: id
|
||||
},
|
||||
transaction: t
|
||||
})
|
||||
}
|
||||
|
||||
static addToLiveId (id: number, schedules: (Date | string)[], t?: Transaction): Promise<VideoLiveScheduleModel[]> {
|
||||
return Promise.all(schedules.map(startAt => {
|
||||
return VideoLiveScheduleModel.create({
|
||||
liveVideoId: id,
|
||||
startAt: new Date(startAt)
|
||||
}, { transaction: t })
|
||||
}))
|
||||
}
|
||||
|
||||
toFormattedJSON (): LiveVideoSchedule {
|
||||
return {
|
||||
startAt: this.startAt
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +1,11 @@
|
|||
import { LiveVideo, VideoState, type LiveVideoLatencyModeType } from '@peertube/peertube-models'
|
||||
import { 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
6
server/core/types/express.d.ts
vendored
6
server/core/types/express.d.ts
vendored
|
@ -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
|
||||
|
||||
|
|
3
server/core/types/models/video/video-live-schedule.ts
Normal file
3
server/core/types/models/video/video-live-schedule.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { VideoLiveScheduleModel } from '@server/models/video/video-live-schedule.js'
|
||||
|
||||
export type MLiveSchedule = Omit<VideoLiveScheduleModel, 'VideoLive'>
|
|
@ -2,12 +2,13 @@ import { VideoLiveModel } from '@server/models/video/video-live.js'
|
|||
import { PickWith } from '@peertube/peertube-typescript-utils'
|
||||
import { 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[]>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue