diff options
8 files changed, 151 insertions, 10 deletions
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 daee4be2a..9e469f0b0 100644 --- a/client/src/app/+videos/+video-watch/video-watch.component.html +++ b/client/src/app/+videos/+video-watch/video-watch.component.html | |||
@@ -34,7 +34,7 @@ | |||
34 | </div> | 34 | </div> |
35 | 35 | ||
36 | <div i18n class="col-md-12 alert alert-info" *ngIf="isLiveEnded()"> | 36 | <div i18n class="col-md-12 alert alert-info" *ngIf="isLiveEnded()"> |
37 | This live is finished. | 37 | This live has ended. |
38 | </div> | 38 | </div> |
39 | 39 | ||
40 | <div class="col-md-12 alert alert-danger" *ngIf="video?.blacklisted"> | 40 | <div class="col-md-12 alert alert-danger" *ngIf="video?.blacklisted"> |
@@ -51,8 +51,14 @@ | |||
51 | <div class="d-block d-md-none"> <!-- only shown on medium devices, has its counterpart for larger viewports below --> | 51 | <div class="d-block d-md-none"> <!-- only shown on medium devices, has its counterpart for larger viewports below --> |
52 | <h1 class="video-info-name">{{ video.name }}</h1> | 52 | <h1 class="video-info-name">{{ video.name }}</h1> |
53 | 53 | ||
54 | <div i18n class="video-info-date-views"> | 54 | <div class="video-info-date-views"> |
55 | Published <my-date-toggle [date]="video.publishedAt"></my-date-toggle> <span class="views"> • {{ video.views | myNumberFormatter }} views</span> | 55 | <ng-container i18n>Published <my-date-toggle [date]="video.publishedAt"></my-date-toggle></ng-container> |
56 | |||
57 | <span i18n class="views"> | ||
58 | • {{ video.views | myNumberFormatter }} | ||
59 | <ng-container *ngIf="!video.isLive">views</ng-container> | ||
60 | <ng-container *ngIf="video.isLive">viewers</ng-container> | ||
61 | </span> | ||
56 | </div> | 62 | </div> |
57 | </div> | 63 | </div> |
58 | 64 | ||
@@ -62,8 +68,14 @@ | |||
62 | </div> | 68 | </div> |
63 | 69 | ||
64 | <div class="video-info-first-row-bottom"> | 70 | <div class="video-info-first-row-bottom"> |
65 | <div i18n class="d-none d-md-block video-info-date-views"> | 71 | <div class="d-none d-md-block video-info-date-views"> |
66 | Published <my-date-toggle [date]="video.publishedAt"></my-date-toggle> <span class="views"> • {{ video.views | myNumberFormatter }} views</span> | 72 | <ng-container i18n>Published <my-date-toggle [date]="video.publishedAt"></my-date-toggle></ng-container> |
73 | |||
74 | <span i18n class="views"> | ||
75 | • {{ video.views | myNumberFormatter }} | ||
76 | <ng-container *ngIf="!video.isLive">views</ng-container> | ||
77 | <ng-container *ngIf="video.isLive">viewers</ng-container> | ||
78 | </span> | ||
67 | </div> | 79 | </div> |
68 | 80 | ||
69 | <div class="video-actions-rates"> | 81 | <div class="video-actions-rates"> |
diff --git a/client/src/app/shared/shared-thumbnail/video-thumbnail.component.html b/client/src/app/shared/shared-thumbnail/video-thumbnail.component.html index be07844ab..4fea0cc1c 100644 --- a/client/src/app/shared/shared-thumbnail/video-thumbnail.component.html +++ b/client/src/app/shared/shared-thumbnail/video-thumbnail.component.html | |||
@@ -27,7 +27,10 @@ | |||
27 | <div class="video-thumbnail-label-overlay danger"><ng-content select="label-danger"></ng-content></div> | 27 | <div class="video-thumbnail-label-overlay danger"><ng-content select="label-danger"></ng-content></div> |
28 | 28 | ||
29 | <div class="video-thumbnail-duration-overlay" *ngIf="!video.isLive">{{ video.durationLabel }}</div> | 29 | <div class="video-thumbnail-duration-overlay" *ngIf="!video.isLive">{{ video.durationLabel }}</div> |
30 | <div i18n class="video-thumbnail-live-overlay" *ngIf="video.isLive">LIVE</div> | 30 | <div class="video-thumbnail-live-overlay" [ngClass]="{ 'live-ended': isLiveEnded() }" *ngIf="video.isLive"> |
31 | <ng-container i18n *ngIf="!isLiveEnded()">LIVE</ng-container> | ||
32 | <ng-container i18n *ngIf="isLiveEnded()">LIVE ENDED</ng-container> | ||
33 | </div> | ||
31 | 34 | ||
32 | <div class="play-overlay"> | 35 | <div class="play-overlay"> |
33 | <div class="icon"></div> | 36 | <div class="icon"></div> |
diff --git a/client/src/app/shared/shared-thumbnail/video-thumbnail.component.scss b/client/src/app/shared/shared-thumbnail/video-thumbnail.component.scss index 1b6151c89..4f53ffaf6 100644 --- a/client/src/app/shared/shared-thumbnail/video-thumbnail.component.scss +++ b/client/src/app/shared/shared-thumbnail/video-thumbnail.component.scss | |||
@@ -51,9 +51,12 @@ | |||
51 | } | 51 | } |
52 | 52 | ||
53 | .video-thumbnail-live-overlay { | 53 | .video-thumbnail-live-overlay { |
54 | background-color: rgba(224, 8, 8, 0.7); | ||
55 | color: #fff; | ||
56 | font-weight: $font-semibold; | 54 | font-weight: $font-semibold; |
55 | color: #fff; | ||
56 | |||
57 | &:not(.live-ended) { | ||
58 | background-color: rgba(224, 8, 8, 0.7); | ||
59 | } | ||
57 | } | 60 | } |
58 | 61 | ||
59 | .video-thumbnail-actions-overlay { | 62 | .video-thumbnail-actions-overlay { |
diff --git a/client/src/app/shared/shared-thumbnail/video-thumbnail.component.ts b/client/src/app/shared/shared-thumbnail/video-thumbnail.component.ts index b2a2cf240..67a9b0028 100644 --- a/client/src/app/shared/shared-thumbnail/video-thumbnail.component.ts +++ b/client/src/app/shared/shared-thumbnail/video-thumbnail.component.ts | |||
@@ -1,5 +1,6 @@ | |||
1 | import { Component, EventEmitter, Input, Output } from '@angular/core' | 1 | import { Component, EventEmitter, Input, Output } from '@angular/core' |
2 | import { ScreenService } from '@app/core' | 2 | import { ScreenService } from '@app/core' |
3 | import { VideoState } from '@shared/models' | ||
3 | import { Video } from '../shared-main' | 4 | import { Video } from '../shared-main' |
4 | 5 | ||
5 | @Component({ | 6 | @Component({ |
@@ -29,6 +30,10 @@ export class VideoThumbnailComponent { | |||
29 | this.addedToWatchLaterText = $localize`Remove from watch later` | 30 | this.addedToWatchLaterText = $localize`Remove from watch later` |
30 | } | 31 | } |
31 | 32 | ||
33 | isLiveEnded () { | ||
34 | return this.video.state.id === VideoState.LIVE_ENDED | ||
35 | } | ||
36 | |||
32 | getImageUrl () { | 37 | getImageUrl () { |
33 | if (!this.video) return '' | 38 | if (!this.video) return '' |
34 | 39 | ||
diff --git a/server/lib/live-manager.ts b/server/lib/live-manager.ts index 2702437c4..fe5b33322 100644 --- a/server/lib/live-manager.ts +++ b/server/lib/live-manager.ts | |||
@@ -99,6 +99,10 @@ class LiveManager { | |||
99 | } | 99 | } |
100 | }) | 100 | }) |
101 | 101 | ||
102 | // Cleanup broken lives, that were terminated by a server restart for example | ||
103 | this.handleBrokenLives() | ||
104 | .catch(err => logger.error('Cannot handle broken lives.', { err })) | ||
105 | |||
102 | setInterval(() => this.updateLiveViews(), VIEW_LIFETIME.LIVE) | 106 | setInterval(() => this.updateLiveViews(), VIEW_LIFETIME.LIVE) |
103 | } | 107 | } |
104 | 108 | ||
@@ -468,6 +472,14 @@ class LiveManager { | |||
468 | } | 472 | } |
469 | } | 473 | } |
470 | 474 | ||
475 | private async handleBrokenLives () { | ||
476 | const videoIds = await VideoModel.listPublishedLiveIds() | ||
477 | |||
478 | for (const id of videoIds) { | ||
479 | await this.onEndTransmuxing(id, true) | ||
480 | } | ||
481 | } | ||
482 | |||
471 | static get Instance () { | 483 | static get Instance () { |
472 | return this.instance || (this.instance = new this()) | 484 | return this.instance || (this.instance = new this()) |
473 | } | 485 | } |
diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 70839aa89..f3055a494 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts | |||
@@ -988,6 +988,19 @@ export class VideoModel extends Model<VideoModel> { | |||
988 | }) | 988 | }) |
989 | } | 989 | } |
990 | 990 | ||
991 | static listPublishedLiveIds () { | ||
992 | const options = { | ||
993 | attributes: [ 'id' ], | ||
994 | where: { | ||
995 | isLive: true, | ||
996 | state: VideoState.PUBLISHED | ||
997 | } | ||
998 | } | ||
999 | |||
1000 | return VideoModel.findAll(options) | ||
1001 | .map(v => v.id) | ||
1002 | } | ||
1003 | |||
991 | static listUserVideosForApi ( | 1004 | static listUserVideosForApi ( |
992 | accountId: number, | 1005 | accountId: number, |
993 | start: number, | 1006 | start: number, |
diff --git a/server/tests/api/live/live.ts b/server/tests/api/live/live.ts index 29081c6cc..aa2e1318a 100644 --- a/server/tests/api/live/live.ts +++ b/server/tests/api/live/live.ts | |||
@@ -2,22 +2,28 @@ | |||
2 | 2 | ||
3 | import 'mocha' | 3 | import 'mocha' |
4 | import * as chai from 'chai' | 4 | import * as chai from 'chai' |
5 | import { FfmpegCommand } from 'fluent-ffmpeg' | ||
5 | import { getLiveNotificationSocket } from '@shared/extra-utils/socket/socket-io' | 6 | import { getLiveNotificationSocket } from '@shared/extra-utils/socket/socket-io' |
6 | import { LiveVideo, LiveVideoCreate, Video, VideoDetails, VideoPrivacy, VideoState, VideoStreamingPlaylistType } from '@shared/models' | 7 | import { LiveVideo, LiveVideoCreate, Video, VideoDetails, VideoPrivacy, VideoState, VideoStreamingPlaylistType } from '@shared/models' |
7 | import { | 8 | import { |
8 | addVideoToBlacklist, | 9 | addVideoToBlacklist, |
9 | checkLiveCleanup, | 10 | checkLiveCleanup, |
11 | checkLiveSegmentHash, | ||
10 | checkResolutionsInMasterPlaylist, | 12 | checkResolutionsInMasterPlaylist, |
13 | checkSegmentHash, | ||
11 | cleanupTests, | 14 | cleanupTests, |
12 | createLive, | 15 | createLive, |
13 | doubleFollow, | 16 | doubleFollow, |
14 | flushAndRunMultipleServers, | 17 | flushAndRunMultipleServers, |
15 | getLive, | 18 | getLive, |
19 | getPlaylist, | ||
16 | getVideo, | 20 | getVideo, |
17 | getVideoIdFromUUID, | 21 | getVideoIdFromUUID, |
18 | getVideosList, | 22 | getVideosList, |
23 | killallServers, | ||
19 | makeRawRequest, | 24 | makeRawRequest, |
20 | removeVideo, | 25 | removeVideo, |
26 | reRunServer, | ||
21 | sendRTMPStream, | 27 | sendRTMPStream, |
22 | sendRTMPStreamInVideo, | 28 | sendRTMPStreamInVideo, |
23 | ServerInfo, | 29 | ServerInfo, |
@@ -31,9 +37,9 @@ import { | |||
31 | viewVideo, | 37 | viewVideo, |
32 | wait, | 38 | wait, |
33 | waitJobs, | 39 | waitJobs, |
34 | waitUntilLiveStarts | 40 | waitUntilLiveStarts, |
41 | waitUntilLog | ||
35 | } from '../../../../shared/extra-utils' | 42 | } from '../../../../shared/extra-utils' |
36 | import { FfmpegCommand } from 'fluent-ffmpeg' | ||
37 | 43 | ||
38 | const expect = chai.expect | 44 | const expect = chai.expect |
39 | 45 | ||
@@ -316,6 +322,19 @@ describe('Test live', function () { | |||
316 | expect(hlsPlaylist.files).to.have.lengthOf(0) | 322 | expect(hlsPlaylist.files).to.have.lengthOf(0) |
317 | 323 | ||
318 | await checkResolutionsInMasterPlaylist(hlsPlaylist.playlistUrl, resolutions) | 324 | await checkResolutionsInMasterPlaylist(hlsPlaylist.playlistUrl, resolutions) |
325 | |||
326 | for (let i = 0; i < resolutions.length; i++) { | ||
327 | const segmentName = `${i}-000001.ts` | ||
328 | await waitUntilLog(servers[0], `${video.uuid}/${segmentName}`, 1, false) | ||
329 | |||
330 | const res = await getPlaylist(`${servers[0].url}/static/streaming-playlists/hls/${video.uuid}/${i}.m3u8`) | ||
331 | const subPlaylist = res.text | ||
332 | |||
333 | expect(subPlaylist).to.contain(segmentName) | ||
334 | |||
335 | const baseUrlAndPath = servers[0].url + '/static/streaming-playlists/hls' | ||
336 | await checkLiveSegmentHash(baseUrlAndPath, video.uuid, segmentName, hlsPlaylist) | ||
337 | } | ||
319 | } | 338 | } |
320 | } | 339 | } |
321 | 340 | ||
@@ -580,6 +599,65 @@ describe('Test live', function () { | |||
580 | }) | 599 | }) |
581 | }) | 600 | }) |
582 | 601 | ||
602 | describe('After a server restart', function () { | ||
603 | let liveVideoId: string | ||
604 | let liveVideoReplayId: string | ||
605 | |||
606 | async function createLiveWrapper (saveReplay: boolean) { | ||
607 | const liveAttributes = { | ||
608 | name: 'live video', | ||
609 | channelId: servers[0].videoChannel.id, | ||
610 | privacy: VideoPrivacy.PUBLIC, | ||
611 | saveReplay | ||
612 | } | ||
613 | |||
614 | const res = await createLive(servers[0].url, servers[0].accessToken, liveAttributes) | ||
615 | return res.body.video.uuid | ||
616 | } | ||
617 | |||
618 | before(async function () { | ||
619 | this.timeout(60000) | ||
620 | |||
621 | liveVideoId = await createLiveWrapper(false) | ||
622 | liveVideoReplayId = await createLiveWrapper(true) | ||
623 | |||
624 | await Promise.all([ | ||
625 | sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoId), | ||
626 | sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoReplayId) | ||
627 | ]) | ||
628 | |||
629 | await Promise.all([ | ||
630 | waitUntilLiveStarts(servers[0].url, servers[0].accessToken, liveVideoId), | ||
631 | waitUntilLiveStarts(servers[0].url, servers[0].accessToken, liveVideoReplayId) | ||
632 | ]) | ||
633 | |||
634 | await killallServers([ servers[0] ]) | ||
635 | await reRunServer(servers[0]) | ||
636 | |||
637 | await wait(5000) | ||
638 | }) | ||
639 | |||
640 | it('Should cleanup lives', async function () { | ||
641 | this.timeout(60000) | ||
642 | |||
643 | const res = await getVideo(servers[0].url, liveVideoId) | ||
644 | const video: VideoDetails = res.body | ||
645 | |||
646 | expect(video.state.id).to.equal(VideoState.LIVE_ENDED) | ||
647 | }) | ||
648 | |||
649 | it('Should save a live replay', async function () { | ||
650 | this.timeout(60000) | ||
651 | |||
652 | await waitJobs(servers) | ||
653 | |||
654 | const res = await getVideo(servers[0].url, liveVideoReplayId) | ||
655 | const video: VideoDetails = res.body | ||
656 | |||
657 | expect(video.state.id).to.equal(VideoState.PUBLISHED) | ||
658 | }) | ||
659 | }) | ||
660 | |||
583 | after(async function () { | 661 | after(async function () { |
584 | await cleanupTests(servers) | 662 | await cleanupTests(servers) |
585 | }) | 663 | }) |
diff --git a/shared/extra-utils/videos/video-streaming-playlists.ts b/shared/extra-utils/videos/video-streaming-playlists.ts index 8cf0e4930..b386e77c3 100644 --- a/shared/extra-utils/videos/video-streaming-playlists.ts +++ b/shared/extra-utils/videos/video-streaming-playlists.ts | |||
@@ -41,6 +41,20 @@ async function checkSegmentHash ( | |||
41 | expect(sha256(res2.body)).to.equal(sha256Server) | 41 | expect(sha256(res2.body)).to.equal(sha256Server) |
42 | } | 42 | } |
43 | 43 | ||
44 | async function checkLiveSegmentHash ( | ||
45 | baseUrlSegment: string, | ||
46 | videoUUID: string, | ||
47 | segmentName: string, | ||
48 | hlsPlaylist: VideoStreamingPlaylist | ||
49 | ) { | ||
50 | const res2 = await getSegment(`${baseUrlSegment}/${videoUUID}/${segmentName}`) | ||
51 | |||
52 | const resSha = await getSegmentSha256(hlsPlaylist.segmentsSha256Url) | ||
53 | |||
54 | const sha256Server = resSha.body[segmentName] | ||
55 | expect(sha256(res2.body)).to.equal(sha256Server) | ||
56 | } | ||
57 | |||
44 | async function checkResolutionsInMasterPlaylist (playlistUrl: string, resolutions: number[]) { | 58 | async function checkResolutionsInMasterPlaylist (playlistUrl: string, resolutions: number[]) { |
45 | const res = await getPlaylist(playlistUrl) | 59 | const res = await getPlaylist(playlistUrl) |
46 | 60 | ||
@@ -62,5 +76,6 @@ export { | |||
62 | getSegment, | 76 | getSegment, |
63 | checkResolutionsInMasterPlaylist, | 77 | checkResolutionsInMasterPlaylist, |
64 | getSegmentSha256, | 78 | getSegmentSha256, |
79 | checkLiveSegmentHash, | ||
65 | checkSegmentHash | 80 | checkSegmentHash |
66 | } | 81 | } |