aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+admin/system/jobs/jobs.component.ts1
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts2
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.html10
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.ts4
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.html2
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts16
-rw-r--r--client/src/app/+videos/+video-edit/video-update.component.html2
-rw-r--r--client/src/app/+videos/+video-edit/video-update.component.ts8
-rw-r--r--client/src/app/+videos/+video-edit/video-update.resolver.ts6
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.html10
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.scss5
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.ts59
-rw-r--r--client/src/app/core/core.module.ts4
-rw-r--r--client/src/app/core/notification/index.ts2
-rw-r--r--client/src/app/core/notification/peertube-socket.service.ts86
-rw-r--r--client/src/app/core/notification/user-notification-socket.service.ts44
-rw-r--r--client/src/app/menu/avatar-notification.component.ts6
-rw-r--r--client/src/app/shared/shared-main/shared-main.module.ts4
-rw-r--r--client/src/app/shared/shared-main/users/user-notification.service.ts10
-rw-r--r--client/src/app/shared/shared-main/video/index.ts2
-rw-r--r--client/src/app/shared/shared-main/video/live-video.service.ts (renamed from client/src/app/shared/shared-main/video/video-live.service.ts)8
-rw-r--r--client/src/app/shared/shared-share-modal/video-share.component.html6
-rw-r--r--client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts5
-rw-r--r--config/default.yaml11
-rw-r--r--config/test.yaml2
-rw-r--r--server/initializers/constants.ts10
-rw-r--r--server/lib/activitypub/videos.ts3
-rw-r--r--server/lib/job-queue/handlers/video-live-ending.ts47
-rw-r--r--server/lib/job-queue/job-queue.ts20
-rw-r--r--server/lib/live-manager.ts129
-rw-r--r--server/lib/peertube-socket.ts30
-rw-r--r--server/lib/video-blacklist.ts5
-rw-r--r--server/models/video/video-live.ts30
-rw-r--r--server/models/video/video-streaming-playlist.ts11
-rw-r--r--server/models/video/video.ts8
-rw-r--r--shared/models/server/job.model.ts5
-rw-r--r--shared/models/videos/index.ts3
-rw-r--r--shared/models/videos/live/index.ts3
-rw-r--r--shared/models/videos/live/live-video-event-payload.model.ts5
-rw-r--r--shared/models/videos/live/live-video-event.type.ts1
-rw-r--r--shared/models/videos/live/live-video.model.ts (renamed from shared/models/videos/video-live.model.ts)2
41 files changed, 468 insertions, 159 deletions
diff --git a/client/src/app/+admin/system/jobs/jobs.component.ts b/client/src/app/+admin/system/jobs/jobs.component.ts
index 25d75aed2..602362fe9 100644
--- a/client/src/app/+admin/system/jobs/jobs.component.ts
+++ b/client/src/app/+admin/system/jobs/jobs.component.ts
@@ -32,6 +32,7 @@ export class JobsComponent extends RestTable implements OnInit {
32 'video-import', 32 'video-import',
33 'videos-views', 33 'videos-views',
34 'activitypub-refresher', 34 'activitypub-refresher',
35 'video-live-ending',
35 'video-redundancy' 36 'video-redundancy'
36 ] 37 ]
37 38
diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts
index bcbea7fad..ad7497f45 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts
@@ -86,7 +86,7 @@ export class MyAccountNotificationPreferencesComponent implements OnInit {
86 } 86 }
87 87
88 private savePreferencesImpl () { 88 private savePreferencesImpl () {
89 this.userNotificationService.updateNotificationSettings(this.user, this.user.notificationSettings) 89 this.userNotificationService.updateNotificationSettings(this.user.notificationSettings)
90 .subscribe( 90 .subscribe(
91 () => { 91 () => {
92 this.notifier.success($localize`Preferences saved`, undefined, 2000) 92 this.notifier.success($localize`Preferences saved`, undefined, 2000)
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html
index c444dd8d3..0802e906d 100644
--- a/client/src/app/+videos/+video-edit/shared/video-edit.component.html
+++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html
@@ -195,7 +195,7 @@
195 </ng-template> 195 </ng-template>
196 </ng-container> 196 </ng-container>
197 197
198 <ng-container ngbNavItem *ngIf="videoLive"> 198 <ng-container ngbNavItem *ngIf="liveVideo">
199 <a ngbNavLink i18n>Live settings</a> 199 <a ngbNavLink i18n>Live settings</a>
200 200
201 <ng-template ngbNavContent> 201 <ng-template ngbNavContent>
@@ -203,13 +203,13 @@
203 <div class="col-md-12"> 203 <div class="col-md-12">
204 204
205 <div class="form-group"> 205 <div class="form-group">
206 <label for="videoLiveRTMPUrl" i18n>Live RTMP Url</label> 206 <label for="liveVideoRTMPUrl" i18n>Live RTMP Url</label>
207 <my-input-readonly-copy id="videoLiveRTMPUrl" [value]="videoLive.rtmpUrl"></my-input-readonly-copy> 207 <my-input-readonly-copy id="liveVideoRTMPUrl" [value]="liveVideo.rtmpUrl"></my-input-readonly-copy>
208 </div> 208 </div>
209 209
210 <div class="form-group"> 210 <div class="form-group">
211 <label for="videoLiveStreamKey" i18n>Live stream key</label> 211 <label for="liveVideoStreamKey" i18n>Live stream key</label>
212 <my-input-readonly-copy id="videoLiveStreamKey" [value]="videoLive.streamKey"></my-input-readonly-copy> 212 <my-input-readonly-copy id="liveVideoStreamKey" [value]="liveVideo.streamKey"></my-input-readonly-copy>
213 </div> 213 </div>
214 </div> 214 </div>
215 </div> 215 </div>
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
index bee65184b..304bf7ed0 100644
--- a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
+++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
@@ -20,7 +20,7 @@ import {
20import { FormReactiveValidationMessages, FormValidatorService, SelectChannelItem } from '@app/shared/shared-forms' 20import { FormReactiveValidationMessages, FormValidatorService, SelectChannelItem } from '@app/shared/shared-forms'
21import { InstanceService } from '@app/shared/shared-instance' 21import { InstanceService } from '@app/shared/shared-instance'
22import { VideoCaptionEdit, VideoEdit, VideoService } from '@app/shared/shared-main' 22import { VideoCaptionEdit, VideoEdit, VideoService } from '@app/shared/shared-main'
23import { ServerConfig, VideoConstant, VideoLive, VideoPrivacy } from '@shared/models' 23import { ServerConfig, VideoConstant, LiveVideo, VideoPrivacy } from '@shared/models'
24import { RegisterClientFormFieldOptions, RegisterClientVideoFieldOptions } from '@shared/models/plugins/register-client-form-field.model' 24import { RegisterClientFormFieldOptions, RegisterClientVideoFieldOptions } from '@shared/models/plugins/register-client-form-field.model'
25import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service' 25import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
26import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' 26import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
@@ -42,7 +42,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
42 @Input() videoCaptions: (VideoCaptionEdit & { captionPath?: string })[] = [] 42 @Input() videoCaptions: (VideoCaptionEdit & { captionPath?: string })[] = []
43 @Input() waitTranscodingEnabled = true 43 @Input() waitTranscodingEnabled = true
44 @Input() type: VideoEditType 44 @Input() type: VideoEditType
45 @Input() videoLive: VideoLive 45 @Input() liveVideo: LiveVideo
46 46
47 @ViewChild('videoCaptionAddModal', { static: true }) videoCaptionAddModal: VideoCaptionAddModalComponent 47 @ViewChild('videoCaptionAddModal', { static: true }) videoCaptionAddModal: VideoCaptionAddModalComponent
48 48
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.html b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.html
index 6997f5388..8fae4044a 100644
--- a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.html
+++ b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.html
@@ -31,7 +31,7 @@
31<form [hidden]="!isInUpdateForm" novalidate [formGroup]="form"> 31<form [hidden]="!isInUpdateForm" novalidate [formGroup]="form">
32 <my-video-edit 32 <my-video-edit
33 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false" 33 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false"
34 [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels" [videoLive]="videoLive" 34 [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels" [liveVideo]="liveVideo"
35 type="go-live" 35 type="go-live"
36 ></my-video-edit> 36 ></my-video-edit>
37 37
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts
index 64fd4c4d4..0a9efc693 100644
--- a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts
+++ b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts
@@ -4,9 +4,9 @@ import { Router } from '@angular/router'
4import { AuthService, CanComponentDeactivate, Notifier, ServerService } from '@app/core' 4import { AuthService, CanComponentDeactivate, Notifier, ServerService } from '@app/core'
5import { scrollToTop } from '@app/helpers' 5import { scrollToTop } from '@app/helpers'
6import { FormValidatorService } from '@app/shared/shared-forms' 6import { FormValidatorService } from '@app/shared/shared-forms'
7import { VideoCaptionService, VideoEdit, VideoService, VideoLiveService } from '@app/shared/shared-main' 7import { LiveVideoService, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
8import { LoadingBarService } from '@ngx-loading-bar/core' 8import { LoadingBarService } from '@ngx-loading-bar/core'
9import { VideoCreate, VideoLive, VideoPrivacy } from '@shared/models' 9import { LiveVideo, VideoCreate, VideoPrivacy } from '@shared/models'
10import { VideoSend } from './video-send' 10import { VideoSend } from './video-send'
11 11
12@Component({ 12@Component({
@@ -23,7 +23,7 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, CanCompon
23 23
24 isInUpdateForm = false 24 isInUpdateForm = false
25 25
26 videoLive: VideoLive 26 liveVideo: LiveVideo
27 videoId: number 27 videoId: number
28 videoUUID: string 28 videoUUID: string
29 error: string 29 error: string
@@ -38,7 +38,7 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, CanCompon
38 protected serverService: ServerService, 38 protected serverService: ServerService,
39 protected videoService: VideoService, 39 protected videoService: VideoService,
40 protected videoCaptionService: VideoCaptionService, 40 protected videoCaptionService: VideoCaptionService,
41 private videoLiveService: VideoLiveService, 41 private liveVideoService: LiveVideoService,
42 private router: Router 42 private router: Router
43 ) { 43 ) {
44 super() 44 super()
@@ -69,7 +69,7 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, CanCompon
69 const toPatch = Object.assign({}, video, { privacy: this.firstStepPrivacyId }) 69 const toPatch = Object.assign({}, video, { privacy: this.firstStepPrivacyId })
70 this.form.patchValue(toPatch) 70 this.form.patchValue(toPatch)
71 71
72 this.videoLiveService.goLive(video).subscribe( 72 this.liveVideoService.goLive(video).subscribe(
73 res => { 73 res => {
74 this.videoId = res.video.id 74 this.videoId = res.video.id
75 this.videoUUID = res.video.uuid 75 this.videoUUID = res.video.uuid
@@ -114,10 +114,10 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, CanCompon
114 } 114 }
115 115
116 private fetchVideoLive () { 116 private fetchVideoLive () {
117 this.videoLiveService.getVideoLive(this.videoId) 117 this.liveVideoService.getVideoLive(this.videoId)
118 .subscribe( 118 .subscribe(
119 videoLive => { 119 liveVideo => {
120 this.videoLive = videoLive 120 this.liveVideo = liveVideo
121 }, 121 },
122 122
123 err => { 123 err => {
diff --git a/client/src/app/+videos/+video-edit/video-update.component.html b/client/src/app/+videos/+video-edit/video-update.component.html
index 5f50ddc74..f290fd136 100644
--- a/client/src/app/+videos/+video-edit/video-update.component.html
+++ b/client/src/app/+videos/+video-edit/video-update.component.html
@@ -11,7 +11,7 @@
11 [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels" 11 [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
12 [videoCaptions]="videoCaptions" [waitTranscodingEnabled]="waitTranscodingEnabled" 12 [videoCaptions]="videoCaptions" [waitTranscodingEnabled]="waitTranscodingEnabled"
13 type="update" (pluginFieldsAdded)="hydratePluginFieldsFromVideo()" 13 type="update" (pluginFieldsAdded)="hydratePluginFieldsFromVideo()"
14 [videoLive]="videoLive" 14 [liveVideo]="liveVideo"
15 ></my-video-edit> 15 ></my-video-edit>
16 16
17 <div class="submit-container"> 17 <div class="submit-container">
diff --git a/client/src/app/+videos/+video-edit/video-update.component.ts b/client/src/app/+videos/+video-edit/video-update.component.ts
index c0f46acd2..ec1305a33 100644
--- a/client/src/app/+videos/+video-edit/video-update.component.ts
+++ b/client/src/app/+videos/+video-edit/video-update.component.ts
@@ -5,7 +5,7 @@ import { Notifier } from '@app/core'
5import { FormReactive, FormValidatorService, SelectChannelItem } from '@app/shared/shared-forms' 5import { FormReactive, FormValidatorService, SelectChannelItem } from '@app/shared/shared-forms'
6import { VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main' 6import { VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main'
7import { LoadingBarService } from '@ngx-loading-bar/core' 7import { LoadingBarService } from '@ngx-loading-bar/core'
8import { VideoPrivacy, VideoLive } from '@shared/models' 8import { LiveVideo, VideoPrivacy } from '@shared/models'
9import { hydrateFormFromVideo } from './shared/video-edit-utils' 9import { hydrateFormFromVideo } from './shared/video-edit-utils'
10 10
11@Component({ 11@Component({
@@ -17,7 +17,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
17 video: VideoEdit 17 video: VideoEdit
18 userVideoChannels: SelectChannelItem[] = [] 18 userVideoChannels: SelectChannelItem[] = []
19 videoCaptions: VideoCaptionEdit[] = [] 19 videoCaptions: VideoCaptionEdit[] = []
20 videoLive: VideoLive 20 liveVideo: LiveVideo
21 21
22 isUpdatingVideo = false 22 isUpdatingVideo = false
23 schedulePublicationPossible = false 23 schedulePublicationPossible = false
@@ -42,11 +42,11 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
42 42
43 this.route.data 43 this.route.data
44 .pipe(map(data => data.videoData)) 44 .pipe(map(data => data.videoData))
45 .subscribe(({ video, videoChannels, videoCaptions, videoLive }) => { 45 .subscribe(({ video, videoChannels, videoCaptions, liveVideo }) => {
46 this.video = new VideoEdit(video) 46 this.video = new VideoEdit(video)
47 this.userVideoChannels = videoChannels 47 this.userVideoChannels = videoChannels
48 this.videoCaptions = videoCaptions 48 this.videoCaptions = videoCaptions
49 this.videoLive = videoLive 49 this.liveVideo = liveVideo
50 50
51 this.schedulePublicationPossible = this.video.privacy === VideoPrivacy.PRIVATE 51 this.schedulePublicationPossible = this.video.privacy === VideoPrivacy.PRIVATE
52 52
diff --git a/client/src/app/+videos/+video-edit/video-update.resolver.ts b/client/src/app/+videos/+video-edit/video-update.resolver.ts
index 3a82324c3..b7ec22dd5 100644
--- a/client/src/app/+videos/+video-edit/video-update.resolver.ts
+++ b/client/src/app/+videos/+video-edit/video-update.resolver.ts
@@ -2,13 +2,13 @@ import { forkJoin, of } from 'rxjs'
2import { map, switchMap } from 'rxjs/operators' 2import { map, switchMap } from 'rxjs/operators'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { ActivatedRouteSnapshot, Resolve } from '@angular/router' 4import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
5import { VideoCaptionService, VideoChannelService, VideoDetails, VideoLiveService, VideoService } from '@app/shared/shared-main' 5import { VideoCaptionService, VideoChannelService, VideoDetails, LiveVideoService, VideoService } from '@app/shared/shared-main'
6 6
7@Injectable() 7@Injectable()
8export class VideoUpdateResolver implements Resolve<any> { 8export class VideoUpdateResolver implements Resolve<any> {
9 constructor ( 9 constructor (
10 private videoService: VideoService, 10 private videoService: VideoService,
11 private videoLiveService: VideoLiveService, 11 private liveVideoService: LiveVideoService,
12 private videoChannelService: VideoChannelService, 12 private videoChannelService: VideoChannelService,
13 private videoCaptionService: VideoCaptionService 13 private videoCaptionService: VideoCaptionService
14 ) { 14 ) {
@@ -49,7 +49,7 @@ export class VideoUpdateResolver implements Resolve<any> {
49 ), 49 ),
50 50
51 video.isLive 51 video.isLive
52 ? this.videoLiveService.getVideoLive(video.id) 52 ? this.liveVideoService.getVideoLive(video.id)
53 : of(undefined) 53 : of(undefined)
54 ] 54 ]
55 } 55 }
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.html b/client/src/app/+videos/+video-watch/video-watch.component.html
index 0d1768aa9..13242a2bc 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.html
+++ b/client/src/app/+videos/+video-watch/video-watch.component.html
@@ -29,6 +29,14 @@
29 This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}. 29 This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}.
30 </div> 30 </div>
31 31
32 <div i18n class="col-md-12 alert alert-info" *ngIf="isWaitingForLive()">
33 This live has not started yet.
34 </div>
35
36 <div i18n class="col-md-12 alert alert-info" *ngIf="isLiveEnded()">
37 This live is finished.
38 </div>
39
32 <div class="col-md-12 alert alert-danger" *ngIf="video?.blacklisted"> 40 <div class="col-md-12 alert alert-danger" *ngIf="video?.blacklisted">
33 <div class="blocked-label" i18n>This video is blocked.</div> 41 <div class="blocked-label" i18n>This video is blocked.</div>
34 {{ video.blockedReason }} 42 {{ video.blockedReason }}
@@ -113,7 +121,7 @@
113 </div> 121 </div>
114 </div> 122 </div>
115 123
116 <ng-container *ngIf="!isUserLoggedIn()"> 124 <ng-container *ngIf="!isUserLoggedIn() && !isLive()">
117 <button 125 <button
118 *ngIf="isVideoDownloadable()" class="action-button action-button-save" 126 *ngIf="isVideoDownloadable()" class="action-button action-button-save"
119 (click)="showDownloadModal()" (keydown.enter)="showDownloadModal()" 127 (click)="showDownloadModal()" (keydown.enter)="showDownloadModal()"
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.scss b/client/src/app/+videos/+video-watch/video-watch.component.scss
index b2bd04cf3..4bf5ff808 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.scss
+++ b/client/src/app/+videos/+video-watch/video-watch.component.scss
@@ -50,6 +50,8 @@ $video-info-margin-left: 44px;
50} 50}
51 51
52#video-wrapper { 52#video-wrapper {
53 $video-height: 66vh;
54
53 background-color: #000; 55 background-color: #000;
54 display: flex; 56 display: flex;
55 justify-content: center; 57 justify-content: center;
@@ -58,6 +60,7 @@ $video-info-margin-left: 44px;
58 display: flex; 60 display: flex;
59 justify-content: center; 61 justify-content: center;
60 flex-grow: 1; 62 flex-grow: 1;
63 height: $video-height;
61 } 64 }
62 65
63 .remote-server-down { 66 .remote-server-down {
@@ -84,7 +87,7 @@ $video-info-margin-left: 44px;
84 ::ng-deep .video-js { 87 ::ng-deep .video-js {
85 width: 100%; 88 width: 100%;
86 max-width: getPlayerWidth(66vh); 89 max-width: getPlayerWidth(66vh);
87 height: 66vh; 90 height: $video-height;
88 91
89 // VideoJS create an inner video player 92 // VideoJS create an inner video player
90 video { 93 video {
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts
index fde32dc74..e4edb42fb 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/+videos/+video-watch/video-watch.component.ts
@@ -4,7 +4,17 @@ import { catchError } from 'rxjs/operators'
4import { PlatformLocation } from '@angular/common' 4import { PlatformLocation } from '@angular/common'
5import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' 5import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
6import { ActivatedRoute, Router } from '@angular/router' 6import { ActivatedRoute, Router } from '@angular/router'
7import { AuthService, AuthUser, ConfirmService, MarkdownService, Notifier, RestExtractor, ServerService, UserService } from '@app/core' 7import {
8 AuthService,
9 AuthUser,
10 ConfirmService,
11 MarkdownService,
12 Notifier,
13 PeerTubeSocket,
14 RestExtractor,
15 ServerService,
16 UserService
17} from '@app/core'
8import { HooksService } from '@app/core/plugins/hooks.service' 18import { HooksService } from '@app/core/plugins/hooks.service'
9import { RedirectService } from '@app/core/routing/redirect.service' 19import { RedirectService } from '@app/core/routing/redirect.service'
10import { isXPercentInViewport, scrollToTop } from '@app/helpers' 20import { isXPercentInViewport, scrollToTop } from '@app/helpers'
@@ -30,6 +40,8 @@ import { environment } from '../../../environments/environment'
30import { VideoSupportComponent } from './modal/video-support.component' 40import { VideoSupportComponent } from './modal/video-support.component'
31import { VideoWatchPlaylistComponent } from './video-watch-playlist.component' 41import { VideoWatchPlaylistComponent } from './video-watch-playlist.component'
32 42
43type URLOptions = CustomizationOptions & { playerMode: PlayerMode }
44
33@Component({ 45@Component({
34 selector: 'my-video-watch', 46 selector: 'my-video-watch',
35 templateUrl: './video-watch.component.html', 47 templateUrl: './video-watch.component.html',
@@ -76,6 +88,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
76 private paramsSub: Subscription 88 private paramsSub: Subscription
77 private queryParamsSub: Subscription 89 private queryParamsSub: Subscription
78 private configSub: Subscription 90 private configSub: Subscription
91 private liveVideosSub: Subscription
79 92
80 private serverConfig: ServerConfig 93 private serverConfig: ServerConfig
81 94
@@ -99,6 +112,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
99 private videoCaptionService: VideoCaptionService, 112 private videoCaptionService: VideoCaptionService,
100 private hotkeysService: HotkeysService, 113 private hotkeysService: HotkeysService,
101 private hooks: HooksService, 114 private hooks: HooksService,
115 private peertubeSocket: PeerTubeSocket,
102 private location: PlatformLocation, 116 private location: PlatformLocation,
103 @Inject(LOCALE_ID) private localeId: string 117 @Inject(LOCALE_ID) private localeId: string
104 ) { 118 ) {
@@ -165,6 +179,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
165 if (this.paramsSub) this.paramsSub.unsubscribe() 179 if (this.paramsSub) this.paramsSub.unsubscribe()
166 if (this.queryParamsSub) this.queryParamsSub.unsubscribe() 180 if (this.queryParamsSub) this.queryParamsSub.unsubscribe()
167 if (this.configSub) this.configSub.unsubscribe() 181 if (this.configSub) this.configSub.unsubscribe()
182 if (this.liveVideosSub) this.liveVideosSub.unsubscribe()
168 183
169 // Unbind hotkeys 184 // Unbind hotkeys
170 this.hotkeysService.remove(this.hotkeys) 185 this.hotkeysService.remove(this.hotkeys)
@@ -306,6 +321,18 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
306 return this.video && this.video.scheduledUpdate !== undefined 321 return this.video && this.video.scheduledUpdate !== undefined
307 } 322 }
308 323
324 isLive () {
325 return !!(this.video?.isLive)
326 }
327
328 isWaitingForLive () {
329 return this.video?.state.id === VideoState.WAITING_FOR_LIVE
330 }
331
332 isLiveEnded () {
333 return this.video?.state.id === VideoState.LIVE_ENDED
334 }
335
309 isVideoBlur (video: Video) { 336 isVideoBlur (video: Video) {
310 return video.isVideoNSFWForUser(this.user, this.serverConfig) 337 return video.isVideoNSFWForUser(this.user, this.serverConfig)
311 } 338 }
@@ -470,8 +497,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
470 private async onVideoFetched ( 497 private async onVideoFetched (
471 video: VideoDetails, 498 video: VideoDetails,
472 videoCaptions: VideoCaption[], 499 videoCaptions: VideoCaption[],
473 urlOptions: CustomizationOptions & { playerMode: PlayerMode } 500 urlOptions: URLOptions
474 ) { 501 ) {
502 this.subscribeToLiveEventsIfNeeded(this.video, video)
503
475 this.video = video 504 this.video = video
476 this.videoCaptions = videoCaptions 505 this.videoCaptions = videoCaptions
477 506
@@ -489,6 +518,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
489 if (res === false) return this.location.back() 518 if (res === false) return this.location.back()
490 } 519 }
491 520
521 const videoState = this.video.state.id
522 if (videoState === VideoState.LIVE_ENDED || videoState === VideoState.WAITING_FOR_LIVE) return
523
492 // Flush old player if needed 524 // Flush old player if needed
493 this.flushPlayer() 525 this.flushPlayer()
494 526
@@ -794,6 +826,29 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
794 return !this.player.paused() 826 return !this.player.paused()
795 } 827 }
796 828
829 private async subscribeToLiveEventsIfNeeded (oldVideo: VideoDetails, newVideo: VideoDetails) {
830 if (!this.liveVideosSub) {
831 this.liveVideosSub = this.peertubeSocket.getLiveVideosObservable()
832 .subscribe(({ payload }) => {
833 if (payload.state !== VideoState.PUBLISHED || this.video.state.id !== VideoState.WAITING_FOR_LIVE) return
834
835 const videoUUID = this.video.uuid
836
837 // Reset to refetch the video
838 this.video = undefined
839 this.loadVideo(videoUUID)
840 })
841 }
842
843 if (oldVideo && oldVideo.id !== newVideo.id) {
844 await this.peertubeSocket.unsubscribeLiveVideos(oldVideo.id)
845 }
846
847 if (!newVideo.isLive) return
848
849 await this.peertubeSocket.subscribeToLiveVideosSocket(newVideo.id)
850 }
851
797 private initHotkeys () { 852 private initHotkeys () {
798 this.hotkeys = [ 853 this.hotkeys = [
799 // These hotkeys are managed by the player 854 // These hotkeys are managed by the player
diff --git a/client/src/app/core/core.module.ts b/client/src/app/core/core.module.ts
index 22896e2e9..6c0a2245d 100644
--- a/client/src/app/core/core.module.ts
+++ b/client/src/app/core/core.module.ts
@@ -4,7 +4,7 @@ import { ToastModule } from 'primeng/toast'
4import { CommonModule } from '@angular/common' 4import { CommonModule } from '@angular/common'
5import { NgModule, Optional, SkipSelf } from '@angular/core' 5import { NgModule, Optional, SkipSelf } from '@angular/core'
6import { BrowserAnimationsModule } from '@angular/platform-browser/animations' 6import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
7import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service' 7import { PeerTubeSocket } from '@app/core/notification/peertube-socket.service'
8import { HooksService } from '@app/core/plugins/hooks.service' 8import { HooksService } from '@app/core/plugins/hooks.service'
9import { PluginService } from '@app/core/plugins/plugin.service' 9import { PluginService } from '@app/core/plugins/plugin.service'
10import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service' 10import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service'
@@ -84,7 +84,7 @@ import { LocalStorageService, ScreenService, SessionStorageService } from './wra
84 RedirectService, 84 RedirectService,
85 Notifier, 85 Notifier,
86 MessageService, 86 MessageService,
87 UserNotificationSocket, 87 PeerTubeSocket,
88 ServerConfigResolver, 88 ServerConfigResolver,
89 CanDeactivateGuard 89 CanDeactivateGuard
90 ] 90 ]
diff --git a/client/src/app/core/notification/index.ts b/client/src/app/core/notification/index.ts
index 3e8d9ea65..cd9634c8e 100644
--- a/client/src/app/core/notification/index.ts
+++ b/client/src/app/core/notification/index.ts
@@ -1,2 +1,2 @@
1export * from './notifier.service' 1export * from './notifier.service'
2export * from './user-notification-socket.service' 2export * from './peertube-socket.service'
diff --git a/client/src/app/core/notification/peertube-socket.service.ts b/client/src/app/core/notification/peertube-socket.service.ts
new file mode 100644
index 000000000..8668c44a8
--- /dev/null
+++ b/client/src/app/core/notification/peertube-socket.service.ts
@@ -0,0 +1,86 @@
1import { Subject } from 'rxjs'
2import { Injectable, NgZone } from '@angular/core'
3import { LiveVideoEventPayload, LiveVideoEventType, UserNotification as UserNotificationServer } from '@shared/models'
4import { environment } from '../../../environments/environment'
5import { AuthService } from '../auth'
6
7export type NotificationEvent = 'new' | 'read' | 'read-all'
8
9@Injectable()
10export class PeerTubeSocket {
11 private io: typeof import ('socket.io-client')
12
13 private notificationSubject = new Subject<{ type: NotificationEvent, notification?: UserNotificationServer }>()
14 private liveVideosSubject = new Subject<{ type: LiveVideoEventType, payload: LiveVideoEventPayload }>()
15
16 private notificationSocket: SocketIOClient.Socket
17 private liveVideosSocket: SocketIOClient.Socket
18
19 constructor (
20 private auth: AuthService,
21 private ngZone: NgZone
22 ) {}
23
24 async getMyNotificationsSocket () {
25 await this.initNotificationSocket()
26
27 return this.notificationSubject.asObservable()
28 }
29
30 getLiveVideosObservable () {
31 return this.liveVideosSubject.asObservable()
32 }
33
34 async subscribeToLiveVideosSocket (videoId: number) {
35 await this.initLiveVideosSocket()
36
37 this.liveVideosSocket.emit('subscribe', { videoId })
38 }
39
40 async unsubscribeLiveVideos (videoId: number) {
41 if (!this.liveVideosSocket) return
42
43 this.liveVideosSocket.emit('unsubscribe', { videoId })
44 }
45
46 dispatchNotificationEvent (type: NotificationEvent, notification?: UserNotificationServer) {
47 this.notificationSubject.next({ type, notification })
48 }
49
50 private async initNotificationSocket () {
51 if (this.notificationSocket) return
52
53 await this.importIOIfNeeded()
54
55 this.ngZone.runOutsideAngular(() => {
56 this.notificationSocket = this.io(environment.apiUrl + '/user-notifications', {
57 query: { accessToken: this.auth.getAccessToken() }
58 })
59
60 this.notificationSocket.on('new-notification', (n: UserNotificationServer) => this.dispatchNotificationEvent('new', n))
61 })
62 }
63
64 private async initLiveVideosSocket () {
65 if (this.liveVideosSocket) return
66
67 await this.importIOIfNeeded()
68
69 this.ngZone.runOutsideAngular(() => {
70 this.liveVideosSocket = this.io(environment.apiUrl + '/live-videos')
71
72 const type: LiveVideoEventType = 'state-change'
73 this.liveVideosSocket.on(type, (payload: LiveVideoEventPayload) => this.dispatchLiveVideoEvent(type, payload))
74 })
75 }
76
77 private async importIOIfNeeded () {
78 if (this.io) return
79
80 this.io = (await import('socket.io-client') as any).default
81 }
82
83 private dispatchLiveVideoEvent (type: LiveVideoEventType, payload: LiveVideoEventPayload) {
84 this.liveVideosSubject.next({ type, payload })
85 }
86}
diff --git a/client/src/app/core/notification/user-notification-socket.service.ts b/client/src/app/core/notification/user-notification-socket.service.ts
deleted file mode 100644
index 37f0bc32c..000000000
--- a/client/src/app/core/notification/user-notification-socket.service.ts
+++ /dev/null
@@ -1,44 +0,0 @@
1import { Subject } from 'rxjs'
2import { Injectable, NgZone } from '@angular/core'
3import { UserNotification as UserNotificationServer } from '@shared/models'
4import { environment } from '../../../environments/environment'
5import { AuthService } from '../auth'
6
7export type NotificationEvent = 'new' | 'read' | 'read-all'
8
9@Injectable()
10export class UserNotificationSocket {
11 private notificationSubject = new Subject<{ type: NotificationEvent, notification?: UserNotificationServer }>()
12
13 private socket: SocketIOClient.Socket
14
15 constructor (
16 private auth: AuthService,
17 private ngZone: NgZone
18 ) {}
19
20 dispatch (type: NotificationEvent, notification?: UserNotificationServer) {
21 this.notificationSubject.next({ type, notification })
22 }
23
24 async getMyNotificationsSocket () {
25 await this.initSocket()
26
27 return this.notificationSubject.asObservable()
28 }
29
30 private async initSocket () {
31 if (this.socket) return
32
33 // FIXME: import('..') returns a struct module, containing a "default" field corresponding to our sanitizeHtml function
34 const io: typeof import ('socket.io-client') = (await import('socket.io-client') as any).default
35
36 this.ngZone.runOutsideAngular(() => {
37 this.socket = io(environment.apiUrl + '/user-notifications', {
38 query: { accessToken: this.auth.getAccessToken() }
39 })
40
41 this.socket.on('new-notification', (n: UserNotificationServer) => this.dispatch('new', n))
42 })
43 }
44}
diff --git a/client/src/app/menu/avatar-notification.component.ts b/client/src/app/menu/avatar-notification.component.ts
index 8b9955069..ed3ffc2d8 100644
--- a/client/src/app/menu/avatar-notification.component.ts
+++ b/client/src/app/menu/avatar-notification.component.ts
@@ -2,7 +2,7 @@ import { Subject, Subscription } from 'rxjs'
2import { filter } from 'rxjs/operators' 2import { filter } from 'rxjs/operators'
3import { Component, EventEmitter, Input, Output, OnDestroy, OnInit, ViewChild } from '@angular/core' 3import { Component, EventEmitter, Input, Output, OnDestroy, OnInit, ViewChild } from '@angular/core'
4import { NavigationEnd, Router } from '@angular/router' 4import { NavigationEnd, Router } from '@angular/router'
5import { Notifier, User, UserNotificationSocket } from '@app/core' 5import { Notifier, User, PeerTubeSocket } from '@app/core'
6import { UserNotificationService } from '@app/shared/shared-main' 6import { UserNotificationService } from '@app/shared/shared-main'
7import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' 7import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
8 8
@@ -27,7 +27,7 @@ export class AvatarNotificationComponent implements OnInit, OnDestroy {
27 27
28 constructor ( 28 constructor (
29 private userNotificationService: UserNotificationService, 29 private userNotificationService: UserNotificationService,
30 private userNotificationSocket: UserNotificationSocket, 30 private peertubeSocket: PeerTubeSocket,
31 private notifier: Notifier, 31 private notifier: Notifier,
32 private router: Router 32 private router: Router
33 ) { 33 ) {
@@ -75,7 +75,7 @@ export class AvatarNotificationComponent implements OnInit, OnDestroy {
75 } 75 }
76 76
77 private async subscribeToNotifications () { 77 private async subscribeToNotifications () {
78 const obs = await this.userNotificationSocket.getMyNotificationsSocket() 78 const obs = await this.peertubeSocket.getMyNotificationsSocket()
79 79
80 this.notificationSub = obs.subscribe(data => { 80 this.notificationSub = obs.subscribe(data => {
81 if (data.type === 'new') return this.unreadNotifications++ 81 if (data.type === 'new') return this.unreadNotifications++
diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts
index bca67b193..0580872f4 100644
--- a/client/src/app/shared/shared-main/shared-main.module.ts
+++ b/client/src/app/shared/shared-main/shared-main.module.ts
@@ -23,7 +23,7 @@ import { FeedComponent } from './feeds'
23import { LoaderComponent, SmallLoaderComponent } from './loaders' 23import { LoaderComponent, SmallLoaderComponent } from './loaders'
24import { HelpComponent, ListOverflowComponent, TopMenuDropdownComponent } from './misc' 24import { HelpComponent, ListOverflowComponent, TopMenuDropdownComponent } from './misc'
25import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users' 25import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users'
26import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService, VideoLiveService } from './video' 26import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService, LiveVideoService } from './video'
27import { VideoCaptionService } from './video-caption' 27import { VideoCaptionService } from './video-caption'
28import { VideoChannelService } from './video-channel' 28import { VideoChannelService } from './video-channel'
29 29
@@ -142,7 +142,7 @@ import { VideoChannelService } from './video-channel'
142 RedundancyService, 142 RedundancyService,
143 VideoImportService, 143 VideoImportService,
144 VideoOwnershipService, 144 VideoOwnershipService,
145 VideoLiveService, 145 LiveVideoService,
146 VideoService, 146 VideoService,
147 147
148 VideoCaptionService, 148 VideoCaptionService,
diff --git a/client/src/app/shared/shared-main/users/user-notification.service.ts b/client/src/app/shared/shared-main/users/user-notification.service.ts
index 7b9dc34be..9014b48a8 100644
--- a/client/src/app/shared/shared-main/users/user-notification.service.ts
+++ b/client/src/app/shared/shared-main/users/user-notification.service.ts
@@ -1,7 +1,7 @@
1import { catchError, map, tap } from 'rxjs/operators' 1import { catchError, map, tap } from 'rxjs/operators'
2import { HttpClient, HttpParams } from '@angular/common/http' 2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { ComponentPaginationLight, RestExtractor, RestService, User, UserNotificationSocket, AuthService } from '@app/core' 4import { ComponentPaginationLight, RestExtractor, RestService, User, PeerTubeSocket, AuthService } from '@app/core'
5import { ResultList, UserNotification as UserNotificationServer, UserNotificationSetting } from '@shared/models' 5import { ResultList, UserNotification as UserNotificationServer, UserNotificationSetting } from '@shared/models'
6import { environment } from '../../../../environments/environment' 6import { environment } from '../../../../environments/environment'
7import { UserNotification } from './user-notification.model' 7import { UserNotification } from './user-notification.model'
@@ -17,7 +17,7 @@ export class UserNotificationService {
17 private auth: AuthService, 17 private auth: AuthService,
18 private restExtractor: RestExtractor, 18 private restExtractor: RestExtractor,
19 private restService: RestService, 19 private restService: RestService,
20 private userNotificationSocket: UserNotificationSocket 20 private peertubeSocket: PeerTubeSocket
21 ) {} 21 ) {}
22 22
23 listMyNotifications (parameters: { 23 listMyNotifications (parameters: {
@@ -57,7 +57,7 @@ export class UserNotificationService {
57 return this.authHttp.post(url, body, { headers }) 57 return this.authHttp.post(url, body, { headers })
58 .pipe( 58 .pipe(
59 map(this.restExtractor.extractDataBool), 59 map(this.restExtractor.extractDataBool),
60 tap(() => this.userNotificationSocket.dispatch('read')), 60 tap(() => this.peertubeSocket.dispatchNotificationEvent('read')),
61 catchError(res => this.restExtractor.handleError(res)) 61 catchError(res => this.restExtractor.handleError(res))
62 ) 62 )
63 } 63 }
@@ -69,12 +69,12 @@ export class UserNotificationService {
69 return this.authHttp.post(url, {}, { headers }) 69 return this.authHttp.post(url, {}, { headers })
70 .pipe( 70 .pipe(
71 map(this.restExtractor.extractDataBool), 71 map(this.restExtractor.extractDataBool),
72 tap(() => this.userNotificationSocket.dispatch('read-all')), 72 tap(() => this.peertubeSocket.dispatchNotificationEvent('read-all')),
73 catchError(res => this.restExtractor.handleError(res)) 73 catchError(res => this.restExtractor.handleError(res))
74 ) 74 )
75 } 75 }
76 76
77 updateNotificationSettings (user: User, settings: UserNotificationSetting) { 77 updateNotificationSettings (settings: UserNotificationSetting) {
78 const url = UserNotificationService.BASE_NOTIFICATION_SETTINGS 78 const url = UserNotificationService.BASE_NOTIFICATION_SETTINGS
79 79
80 return this.authHttp.put(url, settings) 80 return this.authHttp.put(url, settings)
diff --git a/client/src/app/shared/shared-main/video/index.ts b/client/src/app/shared/shared-main/video/index.ts
index 121635a30..f69089517 100644
--- a/client/src/app/shared/shared-main/video/index.ts
+++ b/client/src/app/shared/shared-main/video/index.ts
@@ -1,8 +1,8 @@
1export * from './live-video.service'
1export * from './redundancy.service' 2export * from './redundancy.service'
2export * from './video-details.model' 3export * from './video-details.model'
3export * from './video-edit.model' 4export * from './video-edit.model'
4export * from './video-import.service' 5export * from './video-import.service'
5export * from './video-live.service'
6export * from './video-ownership.service' 6export * from './video-ownership.service'
7export * from './video.model' 7export * from './video.model'
8export * from './video.service' 8export * from './video.service'
diff --git a/client/src/app/shared/shared-main/video/video-live.service.ts b/client/src/app/shared/shared-main/video/live-video.service.ts
index 12daff756..2cd1c66a5 100644
--- a/client/src/app/shared/shared-main/video/video-live.service.ts
+++ b/client/src/app/shared/shared-main/video/live-video.service.ts
@@ -2,11 +2,11 @@ import { catchError } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http' 2import { HttpClient } from '@angular/common/http'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { RestExtractor } from '@app/core' 4import { RestExtractor } from '@app/core'
5import { VideoCreate, VideoLive } from '@shared/models' 5import { VideoCreate, LiveVideo } from '@shared/models'
6import { environment } from '../../../../environments/environment' 6import { environment } from '../../../../environments/environment'
7 7
8@Injectable() 8@Injectable()
9export class VideoLiveService { 9export class LiveVideoService {
10 static BASE_VIDEO_LIVE_URL = environment.apiUrl + '/api/v1/videos/live/' 10 static BASE_VIDEO_LIVE_URL = environment.apiUrl + '/api/v1/videos/live/'
11 11
12 constructor ( 12 constructor (
@@ -16,13 +16,13 @@ export class VideoLiveService {
16 16
17 goLive (video: VideoCreate) { 17 goLive (video: VideoCreate) {
18 return this.authHttp 18 return this.authHttp
19 .post<{ video: { id: number, uuid: string } }>(VideoLiveService.BASE_VIDEO_LIVE_URL, video) 19 .post<{ video: { id: number, uuid: string } }>(LiveVideoService.BASE_VIDEO_LIVE_URL, video)
20 .pipe(catchError(err => this.restExtractor.handleError(err))) 20 .pipe(catchError(err => this.restExtractor.handleError(err)))
21 } 21 }
22 22
23 getVideoLive (videoId: number | string) { 23 getVideoLive (videoId: number | string) {
24 return this.authHttp 24 return this.authHttp
25 .get<VideoLive>(VideoLiveService.BASE_VIDEO_LIVE_URL + videoId) 25 .get<LiveVideo>(LiveVideoService.BASE_VIDEO_LIVE_URL + videoId)
26 .pipe(catchError(err => this.restExtractor.handleError(err))) 26 .pipe(catchError(err => this.restExtractor.handleError(err)))
27 } 27 }
28} 28}
diff --git a/client/src/app/shared/shared-share-modal/video-share.component.html b/client/src/app/shared/shared-share-modal/video-share.component.html
index 3222dc5a6..80b4e446a 100644
--- a/client/src/app/shared/shared-share-modal/video-share.component.html
+++ b/client/src/app/shared/shared-share-modal/video-share.component.html
@@ -107,7 +107,7 @@
107 107
108 <div class="filters"> 108 <div class="filters">
109 <div> 109 <div>
110 <div class="form-group start-at"> 110 <div class="form-group start-at" *ngIf="!video.isLive">
111 <my-peertube-checkbox 111 <my-peertube-checkbox
112 inputName="startAt" [(ngModel)]="customizations.startAtCheckbox" 112 inputName="startAt" [(ngModel)]="customizations.startAtCheckbox"
113 i18n-labelText labelText="Start at" 113 i18n-labelText labelText="Start at"
@@ -138,7 +138,7 @@
138 138
139 <div class="advanced-filters collapse-transition" [ngbCollapse]="isAdvancedCustomizationCollapsed"> 139 <div class="advanced-filters collapse-transition" [ngbCollapse]="isAdvancedCustomizationCollapsed">
140 <div> 140 <div>
141 <div class="form-group stop-at"> 141 <div class="form-group stop-at" *ngIf="!video.isLive">
142 <my-peertube-checkbox 142 <my-peertube-checkbox
143 inputName="stopAt" [(ngModel)]="customizations.stopAtCheckbox" 143 inputName="stopAt" [(ngModel)]="customizations.stopAtCheckbox"
144 i18n-labelText labelText="Stop at" 144 i18n-labelText labelText="Stop at"
@@ -167,7 +167,7 @@
167 ></my-peertube-checkbox> 167 ></my-peertube-checkbox>
168 </div> 168 </div>
169 169
170 <div class="form-group"> 170 <div class="form-group" *ngIf="!video.isLive">
171 <my-peertube-checkbox 171 <my-peertube-checkbox
172 inputName="loop" [(ngModel)]="customizations.loop" 172 inputName="loop" [(ngModel)]="customizations.loop"
173 i18n-labelText labelText="Loop" 173 i18n-labelText labelText="Loop"
diff --git a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
index 39358e08b..4ef17bfe3 100644
--- a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
@@ -146,7 +146,10 @@ export class VideoActionsDropdownComponent implements OnChanges {
146 } 146 }
147 147
148 isVideoDownloadable () { 148 isVideoDownloadable () {
149 return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled 149 return this.video &&
150 this.video.isLive !== true &&
151 this.video instanceof VideoDetails &&
152 this.video.downloadEnabled
150 } 153 }
151 154
152 canVideoBeDuplicated () { 155 canVideoBeDuplicated () {
diff --git a/config/default.yaml b/config/default.yaml
index 7efaeb5a2..d0937bfc8 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -246,9 +246,20 @@ transcoding:
246live: 246live:
247 enabled: false 247 enabled: false
248 248
249 # Limit lives duration
250 # Set null to disable duration limit
251 max_duration: 5 hours
252
253 # Allow your users to save a replay of their live
254 # PeerTube will transcode segments in a video file
255 # If the user daily/total quota is reached, PeerTube will stop the live
256 # /!\ transcoding.enabled (and not live.transcoding.enabled) has to be true to create a replay
257 allow_replay: true
258
249 rtmp: 259 rtmp:
250 port: 1935 260 port: 1935
251 261
262 # Allow to transcode the live streaming in multiple live resolutions
252 transcoding: 263 transcoding:
253 enabled: false 264 enabled: false
254 threads: 2 265 threads: 2
diff --git a/config/test.yaml b/config/test.yaml
index b9279b5e6..865ed5400 100644
--- a/config/test.yaml
+++ b/config/test.yaml
@@ -89,7 +89,7 @@ live:
89 port: 1935 89 port: 1935
90 90
91 transcoding: 91 transcoding:
92 enabled: false 92 enabled: true
93 threads: 2 93 threads: 2
94 94
95 resolutions: 95 resolutions:
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 606eeba2d..82d04a94e 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -139,7 +139,8 @@ const JOB_ATTEMPTS: { [id in JobType]: number } = {
139 'email': 5, 139 'email': 5,
140 'videos-views': 1, 140 'videos-views': 1,
141 'activitypub-refresher': 1, 141 'activitypub-refresher': 1,
142 'video-redundancy': 1 142 'video-redundancy': 1,
143 'video-live-ending': 1
143} 144}
144const JOB_CONCURRENCY: { [id in JobType]: number } = { 145const JOB_CONCURRENCY: { [id in JobType]: number } = {
145 'activitypub-http-broadcast': 1, 146 'activitypub-http-broadcast': 1,
@@ -152,7 +153,8 @@ const JOB_CONCURRENCY: { [id in JobType]: number } = {
152 'email': 5, 153 'email': 5,
153 'videos-views': 1, 154 'videos-views': 1,
154 'activitypub-refresher': 1, 155 'activitypub-refresher': 1,
155 'video-redundancy': 1 156 'video-redundancy': 1,
157 'video-live-ending': 1
156} 158}
157const JOB_TTL: { [id in JobType]: number } = { 159const JOB_TTL: { [id in JobType]: number } = {
158 'activitypub-http-broadcast': 60000 * 10, // 10 minutes 160 'activitypub-http-broadcast': 60000 * 10, // 10 minutes
@@ -165,7 +167,8 @@ const JOB_TTL: { [id in JobType]: number } = {
165 'email': 60000 * 10, // 10 minutes 167 'email': 60000 * 10, // 10 minutes
166 'videos-views': undefined, // Unlimited 168 'videos-views': undefined, // Unlimited
167 'activitypub-refresher': 60000 * 10, // 10 minutes 169 'activitypub-refresher': 60000 * 10, // 10 minutes
168 'video-redundancy': 1000 * 3600 * 3 // 3 hours 170 'video-redundancy': 1000 * 3600 * 3, // 3 hours
171 'video-live-ending': 1000 * 60 * 10 // 10 minutes
169} 172}
170const REPEAT_JOBS: { [ id: string ]: EveryRepeatOptions | CronRepeatOptions } = { 173const REPEAT_JOBS: { [ id: string ]: EveryRepeatOptions | CronRepeatOptions } = {
171 'videos-views': { 174 'videos-views': {
@@ -605,6 +608,7 @@ const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls')
605 608
606const VIDEO_LIVE = { 609const VIDEO_LIVE = {
607 EXTENSION: '.ts', 610 EXTENSION: '.ts',
611 CLEANUP_DELAY: 1000 * 60 * 5, // 5 mintues
608 RTMP: { 612 RTMP: {
609 CHUNK_SIZE: 60000, 613 CHUNK_SIZE: 60000,
610 GOP_CACHE: true, 614 GOP_CACHE: true,
diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts
index 049e06cff..ab23ff507 100644
--- a/server/lib/activitypub/videos.ts
+++ b/server/lib/activitypub/videos.ts
@@ -66,6 +66,7 @@ import { FilteredModelAttributes } from '../../types/sequelize'
66import { ActorFollowScoreCache } from '../files-cache' 66import { ActorFollowScoreCache } from '../files-cache'
67import { JobQueue } from '../job-queue' 67import { JobQueue } from '../job-queue'
68import { Notifier } from '../notifier' 68import { Notifier } from '../notifier'
69import { PeerTubeSocket } from '../peertube-socket'
69import { createPlaceholderThumbnail, createVideoMiniatureFromUrl } from '../thumbnail' 70import { createPlaceholderThumbnail, createVideoMiniatureFromUrl } from '../thumbnail'
70import { setVideoTags } from '../video' 71import { setVideoTags } from '../video'
71import { autoBlacklistVideoIfNeeded } from '../video-blacklist' 72import { autoBlacklistVideoIfNeeded } from '../video-blacklist'
@@ -348,6 +349,7 @@ async function updateVideoFromAP (options: {
348 video.privacy = videoData.privacy 349 video.privacy = videoData.privacy
349 video.channelId = videoData.channelId 350 video.channelId = videoData.channelId
350 video.views = videoData.views 351 video.views = videoData.views
352 video.isLive = videoData.isLive
351 353
352 const videoUpdated = await video.save(sequelizeOptions) as MVideoFullLight 354 const videoUpdated = await video.save(sequelizeOptions) as MVideoFullLight
353 355
@@ -434,6 +436,7 @@ async function updateVideoFromAP (options: {
434 }) 436 })
435 437
436 if (wasPrivateVideo || wasUnlistedVideo) Notifier.Instance.notifyOnNewVideoIfNeeded(videoUpdated) // Notify our users? 438 if (wasPrivateVideo || wasUnlistedVideo) Notifier.Instance.notifyOnNewVideoIfNeeded(videoUpdated) // Notify our users?
439 if (videoUpdated.isLive) PeerTubeSocket.Instance.sendVideoLiveNewState(video)
437 440
438 logger.info('Remote video with uuid %s updated', videoObject.uuid) 441 logger.info('Remote video with uuid %s updated', videoObject.uuid)
439 442
diff --git a/server/lib/job-queue/handlers/video-live-ending.ts b/server/lib/job-queue/handlers/video-live-ending.ts
new file mode 100644
index 000000000..1a58a9f7e
--- /dev/null
+++ b/server/lib/job-queue/handlers/video-live-ending.ts
@@ -0,0 +1,47 @@
1import * as Bull from 'bull'
2import { readdir, remove } from 'fs-extra'
3import { join } from 'path'
4import { getHLSDirectory } from '@server/lib/video-paths'
5import { VideoModel } from '@server/models/video/video'
6import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
7import { VideoLiveEndingPayload } from '@shared/models'
8import { logger } from '../../../helpers/logger'
9
10async function processVideoLiveEnding (job: Bull.Job) {
11 const payload = job.data as VideoLiveEndingPayload
12
13 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId)
14 if (!video) {
15 logger.warn('Video live %d does not exist anymore. Cannot cleanup.', payload.videoId)
16 return
17 }
18
19 const streamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id)
20 const hlsDirectory = getHLSDirectory(video, false)
21
22 const files = await readdir(hlsDirectory)
23
24 for (const filename of files) {
25 if (
26 filename.endsWith('.ts') ||
27 filename.endsWith('.m3u8') ||
28 filename.endsWith('.mpd') ||
29 filename.endsWith('.m4s') ||
30 filename.endsWith('.tmp')
31 ) {
32 const p = join(hlsDirectory, filename)
33
34 remove(p)
35 .catch(err => logger.error('Cannot remove %s.', p, { err }))
36 }
37 }
38
39 streamingPlaylist.destroy()
40 .catch(err => logger.error('Cannot remove live streaming playlist.', { err }))
41}
42
43// ---------------------------------------------------------------------------
44
45export {
46 processVideoLiveEnding
47}
diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts
index 14e181835..8d97434ac 100644
--- a/server/lib/job-queue/job-queue.ts
+++ b/server/lib/job-queue/job-queue.ts
@@ -10,6 +10,7 @@ import {
10 RefreshPayload, 10 RefreshPayload,
11 VideoFileImportPayload, 11 VideoFileImportPayload,
12 VideoImportPayload, 12 VideoImportPayload,
13 VideoLiveEndingPayload,
13 VideoRedundancyPayload, 14 VideoRedundancyPayload,
14 VideoTranscodingPayload 15 VideoTranscodingPayload
15} from '../../../shared/models' 16} from '../../../shared/models'
@@ -27,6 +28,7 @@ import { processVideosViews } from './handlers/video-views'
27import { refreshAPObject } from './handlers/activitypub-refresher' 28import { refreshAPObject } from './handlers/activitypub-refresher'
28import { processVideoFileImport } from './handlers/video-file-import' 29import { processVideoFileImport } from './handlers/video-file-import'
29import { processVideoRedundancy } from '@server/lib/job-queue/handlers/video-redundancy' 30import { processVideoRedundancy } from '@server/lib/job-queue/handlers/video-redundancy'
31import { processVideoLiveEnding } from './handlers/video-live-ending'
30 32
31type CreateJobArgument = 33type CreateJobArgument =
32 { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } | 34 { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } |
@@ -39,8 +41,13 @@ type CreateJobArgument =
39 { type: 'video-import', payload: VideoImportPayload } | 41 { type: 'video-import', payload: VideoImportPayload } |
40 { type: 'activitypub-refresher', payload: RefreshPayload } | 42 { type: 'activitypub-refresher', payload: RefreshPayload } |
41 { type: 'videos-views', payload: {} } | 43 { type: 'videos-views', payload: {} } |
44 { type: 'video-live-ending', payload: VideoLiveEndingPayload } |
42 { type: 'video-redundancy', payload: VideoRedundancyPayload } 45 { type: 'video-redundancy', payload: VideoRedundancyPayload }
43 46
47type CreateJobOptions = {
48 delay?: number
49}
50
44const handlers: { [id in JobType]: (job: Bull.Job) => Promise<any> } = { 51const handlers: { [id in JobType]: (job: Bull.Job) => Promise<any> } = {
45 'activitypub-http-broadcast': processActivityPubHttpBroadcast, 52 'activitypub-http-broadcast': processActivityPubHttpBroadcast,
46 'activitypub-http-unicast': processActivityPubHttpUnicast, 53 'activitypub-http-unicast': processActivityPubHttpUnicast,
@@ -52,6 +59,7 @@ const handlers: { [id in JobType]: (job: Bull.Job) => Promise<any> } = {
52 'video-import': processVideoImport, 59 'video-import': processVideoImport,
53 'videos-views': processVideosViews, 60 'videos-views': processVideosViews,
54 'activitypub-refresher': refreshAPObject, 61 'activitypub-refresher': refreshAPObject,
62 'video-live-ending': processVideoLiveEnding,
55 'video-redundancy': processVideoRedundancy 63 'video-redundancy': processVideoRedundancy
56} 64}
57 65
@@ -66,7 +74,8 @@ const jobTypes: JobType[] = [
66 'video-import', 74 'video-import',
67 'videos-views', 75 'videos-views',
68 'activitypub-refresher', 76 'activitypub-refresher',
69 'video-redundancy' 77 'video-redundancy',
78 'video-live-ending'
70] 79]
71 80
72class JobQueue { 81class JobQueue {
@@ -122,12 +131,12 @@ class JobQueue {
122 } 131 }
123 } 132 }
124 133
125 createJob (obj: CreateJobArgument): void { 134 createJob (obj: CreateJobArgument, options: CreateJobOptions = {}): void {
126 this.createJobWithPromise(obj) 135 this.createJobWithPromise(obj, options)
127 .catch(err => logger.error('Cannot create job.', { err, obj })) 136 .catch(err => logger.error('Cannot create job.', { err, obj }))
128 } 137 }
129 138
130 createJobWithPromise (obj: CreateJobArgument) { 139 createJobWithPromise (obj: CreateJobArgument, options: CreateJobOptions = {}) {
131 const queue = this.queues[obj.type] 140 const queue = this.queues[obj.type]
132 if (queue === undefined) { 141 if (queue === undefined) {
133 logger.error('Unknown queue %s: cannot create job.', obj.type) 142 logger.error('Unknown queue %s: cannot create job.', obj.type)
@@ -137,7 +146,8 @@ class JobQueue {
137 const jobArgs: Bull.JobOptions = { 146 const jobArgs: Bull.JobOptions = {
138 backoff: { delay: 60 * 1000, type: 'exponential' }, 147 backoff: { delay: 60 * 1000, type: 'exponential' },
139 attempts: JOB_ATTEMPTS[obj.type], 148 attempts: JOB_ATTEMPTS[obj.type],
140 timeout: JOB_TTL[obj.type] 149 timeout: JOB_TTL[obj.type],
150 delay: options.delay
141 } 151 }
142 152
143 return queue.add(obj.payload, jobArgs) 153 return queue.add(obj.payload, jobArgs)
diff --git a/server/lib/live-manager.ts b/server/lib/live-manager.ts
index f602bfb6d..41176d197 100644
--- a/server/lib/live-manager.ts
+++ b/server/lib/live-manager.ts
@@ -2,18 +2,22 @@
2import { AsyncQueue, queue } from 'async' 2import { AsyncQueue, queue } from 'async'
3import * as chokidar from 'chokidar' 3import * as chokidar from 'chokidar'
4import { FfmpegCommand } from 'fluent-ffmpeg' 4import { FfmpegCommand } from 'fluent-ffmpeg'
5import { ensureDir, readdir, remove } from 'fs-extra' 5import { ensureDir } from 'fs-extra'
6import { basename, join } from 'path' 6import { basename } from 'path'
7import { computeResolutionsToTranscode, runLiveMuxing, runLiveTranscoding } from '@server/helpers/ffmpeg-utils' 7import { computeResolutionsToTranscode, runLiveMuxing, runLiveTranscoding } from '@server/helpers/ffmpeg-utils'
8import { logger } from '@server/helpers/logger' 8import { logger } from '@server/helpers/logger'
9import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config' 9import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config'
10import { P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE, WEBSERVER } from '@server/initializers/constants' 10import { P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE, WEBSERVER } from '@server/initializers/constants'
11import { VideoModel } from '@server/models/video/video'
11import { VideoFileModel } from '@server/models/video/video-file' 12import { VideoFileModel } from '@server/models/video/video-file'
12import { VideoLiveModel } from '@server/models/video/video-live' 13import { VideoLiveModel } from '@server/models/video/video-live'
13import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' 14import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
14import { MStreamingPlaylist, MVideo, MVideoLiveVideo } from '@server/types/models' 15import { MStreamingPlaylist, MVideoLiveVideo } from '@server/types/models'
15import { VideoState, VideoStreamingPlaylistType } from '@shared/models' 16import { VideoState, VideoStreamingPlaylistType } from '@shared/models'
17import { federateVideoIfNeeded } from './activitypub/videos'
16import { buildSha256Segment } from './hls' 18import { buildSha256Segment } from './hls'
19import { JobQueue } from './job-queue'
20import { PeerTubeSocket } from './peertube-socket'
17import { getHLSDirectory } from './video-paths' 21import { getHLSDirectory } from './video-paths'
18 22
19const NodeRtmpServer = require('node-media-server/node_rtmp_server') 23const NodeRtmpServer = require('node-media-server/node_rtmp_server')
@@ -47,6 +51,7 @@ class LiveManager {
47 private static instance: LiveManager 51 private static instance: LiveManager
48 52
49 private readonly transSessions = new Map<string, FfmpegCommand>() 53 private readonly transSessions = new Map<string, FfmpegCommand>()
54 private readonly videoSessions = new Map<number, string>()
50 private readonly segmentsSha256 = new Map<string, Map<string, string>>() 55 private readonly segmentsSha256 = new Map<string, Map<string, string>>()
51 56
52 private segmentsSha256Queue: AsyncQueue<SegmentSha256QueueParam> 57 private segmentsSha256Queue: AsyncQueue<SegmentSha256QueueParam>
@@ -56,7 +61,8 @@ class LiveManager {
56 } 61 }
57 62
58 init () { 63 init () {
59 this.getContext().nodeEvent.on('postPublish', (sessionId: string, streamPath: string) => { 64 const events = this.getContext().nodeEvent
65 events.on('postPublish', (sessionId: string, streamPath: string) => {
60 logger.debug('RTMP received stream', { id: sessionId, streamPath }) 66 logger.debug('RTMP received stream', { id: sessionId, streamPath })
61 67
62 const splittedPath = streamPath.split('/') 68 const splittedPath = streamPath.split('/')
@@ -69,7 +75,7 @@ class LiveManager {
69 .catch(err => logger.error('Cannot handle sessions.', { err })) 75 .catch(err => logger.error('Cannot handle sessions.', { err }))
70 }) 76 })
71 77
72 this.getContext().nodeEvent.on('donePublish', sessionId => { 78 events.on('donePublish', sessionId => {
73 this.abortSession(sessionId) 79 this.abortSession(sessionId)
74 }) 80 })
75 81
@@ -115,6 +121,16 @@ class LiveManager {
115 return this.segmentsSha256.get(videoUUID) 121 return this.segmentsSha256.get(videoUUID)
116 } 122 }
117 123
124 stopSessionOf (videoId: number) {
125 const sessionId = this.videoSessions.get(videoId)
126 if (!sessionId) return
127
128 this.abortSession(sessionId)
129
130 this.onEndTransmuxing(videoId)
131 .catch(err => logger.error('Cannot end transmuxing of video %d.', videoId, { err }))
132 }
133
118 private getContext () { 134 private getContext () {
119 return context 135 return context
120 } 136 }
@@ -135,6 +151,13 @@ class LiveManager {
135 } 151 }
136 152
137 const video = videoLive.Video 153 const video = videoLive.Video
154 if (video.isBlacklisted()) {
155 logger.warn('Video is blacklisted. Refusing stream %s.', streamKey)
156 return this.abortSession(sessionId)
157 }
158
159 this.videoSessions.set(video.id, sessionId)
160
138 const playlistUrl = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid) 161 const playlistUrl = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid)
139 162
140 const session = this.getContext().sessions.get(sessionId) 163 const session = this.getContext().sessions.get(sessionId)
@@ -154,11 +177,6 @@ class LiveManager {
154 type: VideoStreamingPlaylistType.HLS 177 type: VideoStreamingPlaylistType.HLS
155 }, { returning: true }) as [ MStreamingPlaylist, boolean ] 178 }, { returning: true }) as [ MStreamingPlaylist, boolean ]
156 179
157 video.state = VideoState.PUBLISHED
158 await video.save()
159
160 // FIXME: federation?
161
162 return this.runMuxing({ 180 return this.runMuxing({
163 sessionId, 181 sessionId,
164 videoLive, 182 videoLive,
@@ -207,11 +225,46 @@ class LiveManager {
207 225
208 this.transSessions.set(sessionId, ffmpegExec) 226 this.transSessions.set(sessionId, ffmpegExec)
209 227
228 const videoUUID = videoLive.Video.uuid
229 const tsWatcher = chokidar.watch(outPath + '/*.ts')
230
231 const updateHandler = segmentPath => {
232 this.segmentsSha256Queue.push({ operation: 'update', segmentPath, videoUUID })
233 }
234
235 const deleteHandler = segmentPath => this.segmentsSha256Queue.push({ operation: 'delete', segmentPath, videoUUID })
236
237 tsWatcher.on('add', p => updateHandler(p))
238 tsWatcher.on('change', p => updateHandler(p))
239 tsWatcher.on('unlink', p => deleteHandler(p))
240
241 const masterWatcher = chokidar.watch(outPath + '/master.m3u8')
242 masterWatcher.on('add', async () => {
243 try {
244 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoLive.videoId)
245
246 video.state = VideoState.PUBLISHED
247 await video.save()
248 videoLive.Video = video
249
250 await federateVideoIfNeeded(video, false)
251
252 PeerTubeSocket.Instance.sendVideoLiveNewState(video)
253 } catch (err) {
254 logger.error('Cannot federate video %d.', videoLive.videoId, { err })
255 } finally {
256 masterWatcher.close()
257 .catch(err => logger.error('Cannot close master watcher of %s.', outPath, { err }))
258 }
259 })
260
210 const onFFmpegEnded = () => { 261 const onFFmpegEnded = () => {
211 watcher.close() 262 logger.info('RTMP transmuxing for video %s ended. Scheduling cleanup', streamPath)
212 .catch(err => logger.error('Cannot close watcher of %s.', outPath, { err }))
213 263
214 this.onEndTransmuxing(videoLive.Video, playlist, streamPath, outPath) 264 Promise.all([ tsWatcher.close(), masterWatcher.close() ])
265 .catch(err => logger.error('Cannot close watchers of %s.', outPath, { err }))
266
267 this.onEndTransmuxing(videoLive.Video.id)
215 .catch(err => logger.error('Error in closed transmuxing.', { err })) 268 .catch(err => logger.error('Error in closed transmuxing.', { err }))
216 } 269 }
217 270
@@ -225,44 +278,30 @@ class LiveManager {
225 }) 278 })
226 279
227 ffmpegExec.on('end', () => onFFmpegEnded()) 280 ffmpegExec.on('end', () => onFFmpegEnded())
228
229 const videoUUID = videoLive.Video.uuid
230 const watcher = chokidar.watch(outPath + '/*.ts')
231
232 const updateHandler = segmentPath => this.segmentsSha256Queue.push({ operation: 'update', segmentPath, videoUUID })
233 const deleteHandler = segmentPath => this.segmentsSha256Queue.push({ operation: 'delete', segmentPath, videoUUID })
234
235 watcher.on('add', p => updateHandler(p))
236 watcher.on('change', p => updateHandler(p))
237 watcher.on('unlink', p => deleteHandler(p))
238 } 281 }
239 282
240 private async onEndTransmuxing (video: MVideo, playlist: MStreamingPlaylist, streamPath: string, outPath: string) { 283 private async onEndTransmuxing (videoId: number) {
241 logger.info('RTMP transmuxing for %s ended.', streamPath) 284 try {
285 const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
286 if (!fullVideo) return
242 287
243 const files = await readdir(outPath) 288 JobQueue.Instance.createJob({
289 type: 'video-live-ending',
290 payload: {
291 videoId: fullVideo.id
292 }
293 }, { delay: VIDEO_LIVE.CLEANUP_DELAY })
244 294
245 for (const filename of files) { 295 // FIXME: use end
246 if ( 296 fullVideo.state = VideoState.WAITING_FOR_LIVE
247 filename.endsWith('.ts') || 297 await fullVideo.save()
248 filename.endsWith('.m3u8') ||
249 filename.endsWith('.mpd') ||
250 filename.endsWith('.m4s') ||
251 filename.endsWith('.tmp')
252 ) {
253 const p = join(outPath, filename)
254 298
255 remove(p) 299 PeerTubeSocket.Instance.sendVideoLiveNewState(fullVideo)
256 .catch(err => logger.error('Cannot remove %s.', p, { err }))
257 }
258 }
259 300
260 playlist.destroy() 301 await federateVideoIfNeeded(fullVideo, false)
261 .catch(err => logger.error('Cannot remove live streaming playlist.', { err })) 302 } catch (err) {
262 303 logger.error('Cannot save/federate new video state of live streaming.', { err })
263 video.state = VideoState.LIVE_ENDED 304 }
264 video.save()
265 .catch(err => logger.error('Cannot save new video state of live streaming.', { err }))
266 } 305 }
267 306
268 private async addSegmentSha (options: SegmentSha256QueueParam) { 307 private async addSegmentSha (options: SegmentSha256QueueParam) {
diff --git a/server/lib/peertube-socket.ts b/server/lib/peertube-socket.ts
index 2e4b15b38..c918a8685 100644
--- a/server/lib/peertube-socket.ts
+++ b/server/lib/peertube-socket.ts
@@ -1,14 +1,18 @@
1import * as SocketIO from 'socket.io' 1import { Socket } from 'dgram'
2import { authenticateSocket } from '../middlewares'
3import { logger } from '../helpers/logger'
4import { Server } from 'http' 2import { Server } from 'http'
3import * as SocketIO from 'socket.io'
4import { MVideo } from '@server/types/models'
5import { UserNotificationModelForApi } from '@server/types/models/user' 5import { UserNotificationModelForApi } from '@server/types/models/user'
6import { LiveVideoEventPayload, LiveVideoEventType } from '@shared/models'
7import { logger } from '../helpers/logger'
8import { authenticateSocket } from '../middlewares'
6 9
7class PeerTubeSocket { 10class PeerTubeSocket {
8 11
9 private static instance: PeerTubeSocket 12 private static instance: PeerTubeSocket
10 13
11 private userNotificationSockets: { [ userId: number ]: SocketIO.Socket[] } = {} 14 private userNotificationSockets: { [ userId: number ]: SocketIO.Socket[] } = {}
15 private liveVideosNamespace: SocketIO.Namespace
12 16
13 private constructor () {} 17 private constructor () {}
14 18
@@ -32,19 +36,37 @@ class PeerTubeSocket {
32 this.userNotificationSockets[userId] = this.userNotificationSockets[userId].filter(s => s !== socket) 36 this.userNotificationSockets[userId] = this.userNotificationSockets[userId].filter(s => s !== socket)
33 }) 37 })
34 }) 38 })
39
40 this.liveVideosNamespace = io.of('/live-videos')
41 .on('connection', socket => {
42 socket.on('subscribe', ({ videoId }) => socket.join(videoId))
43 socket.on('unsubscribe', ({ videoId }) => socket.leave(videoId))
44 })
35 } 45 }
36 46
37 sendNotification (userId: number, notification: UserNotificationModelForApi) { 47 sendNotification (userId: number, notification: UserNotificationModelForApi) {
38 const sockets = this.userNotificationSockets[userId] 48 const sockets = this.userNotificationSockets[userId]
39
40 if (!sockets) return 49 if (!sockets) return
41 50
51 logger.debug('Sending user notification to user %d.', userId)
52
42 const notificationMessage = notification.toFormattedJSON() 53 const notificationMessage = notification.toFormattedJSON()
43 for (const socket of sockets) { 54 for (const socket of sockets) {
44 socket.emit('new-notification', notificationMessage) 55 socket.emit('new-notification', notificationMessage)
45 } 56 }
46 } 57 }
47 58
59 sendVideoLiveNewState (video: MVideo) {
60 const data: LiveVideoEventPayload = { state: video.state }
61 const type: LiveVideoEventType = 'state-change'
62
63 logger.debug('Sending video live new state notification of %s.', video.url)
64
65 this.liveVideosNamespace
66 .in(video.id)
67 .emit(type, data)
68 }
69
48 static get Instance () { 70 static get Instance () {
49 return this.instance || (this.instance = new this()) 71 return this.instance || (this.instance = new this())
50 } 72 }
diff --git a/server/lib/video-blacklist.ts b/server/lib/video-blacklist.ts
index bdbcffda6..f6c66b6dd 100644
--- a/server/lib/video-blacklist.ts
+++ b/server/lib/video-blacklist.ts
@@ -17,6 +17,7 @@ import { sendDeleteVideo } from './activitypub/send'
17import { federateVideoIfNeeded } from './activitypub/videos' 17import { federateVideoIfNeeded } from './activitypub/videos'
18import { Notifier } from './notifier' 18import { Notifier } from './notifier'
19import { Hooks } from './plugins/hooks' 19import { Hooks } from './plugins/hooks'
20import { LiveManager } from './live-manager'
20 21
21async function autoBlacklistVideoIfNeeded (parameters: { 22async function autoBlacklistVideoIfNeeded (parameters: {
22 video: MVideoWithBlacklistLight 23 video: MVideoWithBlacklistLight
@@ -73,6 +74,10 @@ async function blacklistVideo (videoInstance: MVideoAccountLight, options: Video
73 await sendDeleteVideo(videoInstance, undefined) 74 await sendDeleteVideo(videoInstance, undefined)
74 } 75 }
75 76
77 if (videoInstance.isLive) {
78 LiveManager.Instance.stopSessionOf(videoInstance.id)
79 }
80
76 Notifier.Instance.notifyOnVideoBlacklist(blacklist) 81 Notifier.Instance.notifyOnVideoBlacklist(blacklist)
77} 82}
78 83
diff --git a/server/models/video/video-live.ts b/server/models/video/video-live.ts
index 6929b9688..8608bc84c 100644
--- a/server/models/video/video-live.ts
+++ b/server/models/video/video-live.ts
@@ -1,14 +1,21 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, DefaultScope, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, DefaultScope, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { WEBSERVER } from '@server/initializers/constants' 2import { WEBSERVER } from '@server/initializers/constants'
3import { MVideoLive, MVideoLiveVideo } from '@server/types/models' 3import { MVideoLive, MVideoLiveVideo } from '@server/types/models'
4import { VideoLive } from '@shared/models/videos/video-live.model' 4import { LiveVideo, VideoState } from '@shared/models'
5import { VideoModel } from './video' 5import { VideoModel } from './video'
6import { VideoBlacklistModel } from './video-blacklist'
6 7
7@DefaultScope(() => ({ 8@DefaultScope(() => ({
8 include: [ 9 include: [
9 { 10 {
10 model: VideoModel, 11 model: VideoModel,
11 required: true 12 required: true,
13 include: [
14 {
15 model: VideoBlacklistModel,
16 required: false
17 }
18 ]
12 } 19 }
13 ] 20 ]
14})) 21}))
@@ -49,7 +56,22 @@ export class VideoLiveModel extends Model<VideoLiveModel> {
49 const query = { 56 const query = {
50 where: { 57 where: {
51 streamKey 58 streamKey
52 } 59 },
60 include: [
61 {
62 model: VideoModel.unscoped(),
63 required: true,
64 where: {
65 state: VideoState.WAITING_FOR_LIVE
66 },
67 include: [
68 {
69 model: VideoBlacklistModel.unscoped(),
70 required: false
71 }
72 ]
73 }
74 ]
53 } 75 }
54 76
55 return VideoLiveModel.findOne<MVideoLiveVideo>(query) 77 return VideoLiveModel.findOne<MVideoLiveVideo>(query)
@@ -65,7 +87,7 @@ export class VideoLiveModel extends Model<VideoLiveModel> {
65 return VideoLiveModel.findOne<MVideoLive>(query) 87 return VideoLiveModel.findOne<MVideoLive>(query)
66 } 88 }
67 89
68 toFormattedJSON (): VideoLive { 90 toFormattedJSON (): LiveVideo {
69 return { 91 return {
70 rtmpUrl: WEBSERVER.RTMP_URL, 92 rtmpUrl: WEBSERVER.RTMP_URL,
71 streamKey: this.streamKey 93 streamKey: this.streamKey
diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts
index b8dc7c450..73bd89844 100644
--- a/server/models/video/video-streaming-playlist.ts
+++ b/server/models/video/video-streaming-playlist.ts
@@ -153,6 +153,17 @@ export class VideoStreamingPlaylistModel extends Model<VideoStreamingPlaylistMod
153 return VideoStreamingPlaylistModel.findByPk(id, options) 153 return VideoStreamingPlaylistModel.findByPk(id, options)
154 } 154 }
155 155
156 static loadHLSPlaylistByVideo (videoId: number) {
157 const options = {
158 where: {
159 type: VideoStreamingPlaylistType.HLS,
160 videoId
161 }
162 }
163
164 return VideoStreamingPlaylistModel.findOne(options)
165 }
166
156 static getHlsPlaylistFilename (resolution: number) { 167 static getHlsPlaylistFilename (resolution: number) {
157 return resolution + '.m3u8' 168 return resolution + '.m3u8'
158 } 169 }
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index a3e3b6cfe..8493ab802 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -127,6 +127,7 @@ import { VideoShareModel } from './video-share'
127import { VideoStreamingPlaylistModel } from './video-streaming-playlist' 127import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
128import { VideoTagModel } from './video-tag' 128import { VideoTagModel } from './video-tag'
129import { VideoViewModel } from './video-view' 129import { VideoViewModel } from './video-view'
130import { LiveManager } from '@server/lib/live-manager'
130 131
131export enum ScopeNames { 132export enum ScopeNames {
132 AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS', 133 AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS',
@@ -800,6 +801,13 @@ export class VideoModel extends Model<VideoModel> {
800 } 801 }
801 802
802 @BeforeDestroy 803 @BeforeDestroy
804 static stopLiveIfNeeded (instance: VideoModel) {
805 if (!instance.isLive) return
806
807 return LiveManager.Instance.stopSessionOf(instance.id)
808 }
809
810 @BeforeDestroy
803 static invalidateCache (instance: VideoModel) { 811 static invalidateCache (instance: VideoModel) {
804 ModelCache.Instance.invalidateCache('video', instance.id) 812 ModelCache.Instance.invalidateCache('video', instance.id)
805 } 813 }
diff --git a/shared/models/server/job.model.ts b/shared/models/server/job.model.ts
index c643e6017..346b25607 100644
--- a/shared/models/server/job.model.ts
+++ b/shared/models/server/job.model.ts
@@ -16,6 +16,7 @@ export type JobType =
16 | 'videos-views' 16 | 'videos-views'
17 | 'activitypub-refresher' 17 | 'activitypub-refresher'
18 | 'video-redundancy' 18 | 'video-redundancy'
19 | 'video-live-ending'
19 20
20export interface Job { 21export interface Job {
21 id: number 22 id: number
@@ -126,3 +127,7 @@ export type VideoTranscodingPayload =
126 | NewResolutionTranscodingPayload 127 | NewResolutionTranscodingPayload
127 | OptimizeTranscodingPayload 128 | OptimizeTranscodingPayload
128 | MergeAudioTranscodingPayload 129 | MergeAudioTranscodingPayload
130
131export interface VideoLiveEndingPayload {
132 videoId: number
133}
diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts
index 2a032a456..abf144f23 100644
--- a/shared/models/videos/index.ts
+++ b/shared/models/videos/index.ts
@@ -1,6 +1,7 @@
1export * from './blacklist' 1export * from './blacklist'
2export * from './caption' 2export * from './caption'
3export * from './channel' 3export * from './channel'
4export * from './live'
4export * from './import' 5export * from './import'
5export * from './playlist' 6export * from './playlist'
6export * from './rate' 7export * from './rate'
@@ -19,7 +20,7 @@ export * from './video-create.model'
19export * from './video-file-metadata' 20export * from './video-file-metadata'
20export * from './video-file.model' 21export * from './video-file.model'
21 22
22export * from './video-live.model' 23export * from './live/live-video.model'
23 24
24export * from './video-privacy.enum' 25export * from './video-privacy.enum'
25export * from './video-query.type' 26export * from './video-query.type'
diff --git a/shared/models/videos/live/index.ts b/shared/models/videos/live/index.ts
new file mode 100644
index 000000000..4f331738b
--- /dev/null
+++ b/shared/models/videos/live/index.ts
@@ -0,0 +1,3 @@
1export * from './live-video-event-payload.model'
2export * from './live-video-event.type'
3export * from './live-video.model'
diff --git a/shared/models/videos/live/live-video-event-payload.model.ts b/shared/models/videos/live/live-video-event-payload.model.ts
new file mode 100644
index 000000000..f9038f4de
--- /dev/null
+++ b/shared/models/videos/live/live-video-event-payload.model.ts
@@ -0,0 +1,5 @@
1import { VideoState } from '../video-state.enum'
2
3export interface LiveVideoEventPayload {
4 state: VideoState
5}
diff --git a/shared/models/videos/live/live-video-event.type.ts b/shared/models/videos/live/live-video-event.type.ts
new file mode 100644
index 000000000..4d15899da
--- /dev/null
+++ b/shared/models/videos/live/live-video-event.type.ts
@@ -0,0 +1 @@
export type LiveVideoEventType = 'state-change'
diff --git a/shared/models/videos/video-live.model.ts b/shared/models/videos/live/live-video.model.ts
index 2a834dc91..74abee96e 100644
--- a/shared/models/videos/video-live.model.ts
+++ b/shared/models/videos/live/live-video.model.ts
@@ -1,4 +1,4 @@
1export interface VideoLive { 1export interface LiveVideo {
2 rtmpUrl: string 2 rtmpUrl: string
3 streamKey: string 3 streamKey: string
4} 4}