aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md3
-rw-r--r--client/src/app/+admin/overview/users/user-list/user-list.component.html2
-rw-r--r--client/src/app/+admin/overview/users/user-list/user-list.component.ts44
-rw-r--r--client/src/app/+my-library/+my-video-channels/my-video-channels.component.html4
-rw-r--r--client/src/app/core/core.module.ts4
-rw-r--r--client/src/app/core/rest/rest-table.ts8
-rw-r--r--client/src/app/shared/shared-forms/reactive-file.component.ts2
-rw-r--r--client/src/app/shared/shared-main/video-channel/video-channel.model.ts5
-rw-r--r--client/src/app/shared/shared-video-miniature/video-filters-header.component.html1
-rw-r--r--client/src/app/shared/shared-video-miniature/video-filters-header.component.scss2
-rw-r--r--client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts5
-rw-r--r--client/src/root-helpers/index.ts1
-rw-r--r--client/src/root-helpers/url.ts26
-rw-r--r--client/src/root-helpers/utils.ts10
-rw-r--r--client/src/sass/player/peertube-skin.scss9
-rw-r--r--client/src/standalone/videos/embed-api.ts10
-rw-r--r--client/src/standalone/videos/embed.scss11
-rw-r--r--client/src/standalone/videos/embed.ts872
-rw-r--r--client/src/standalone/videos/shared/auth-http.ts105
-rw-r--r--client/src/standalone/videos/shared/index.ts9
-rw-r--r--client/src/standalone/videos/shared/live-manager.ts69
-rw-r--r--client/src/standalone/videos/shared/peertube-plugin.ts85
-rw-r--r--client/src/standalone/videos/shared/player-html.ts91
-rw-r--r--client/src/standalone/videos/shared/player-manager-options.ts323
-rw-r--r--client/src/standalone/videos/shared/playlist-fetcher.ts72
-rw-r--r--client/src/standalone/videos/shared/playlist-tracker.ts93
-rw-r--r--client/src/standalone/videos/shared/translations.ts5
-rw-r--r--client/src/standalone/videos/shared/video-fetcher.ts63
-rw-r--r--config/default.yaml13
-rw-r--r--config/production.yaml.example19
-rwxr-xr-xscripts/i18n/create-custom-files.ts2
-rw-r--r--server/controllers/api/index.ts4
-rw-r--r--server/controllers/api/users/index.ts12
-rw-r--r--server/controllers/api/users/token.ts5
-rw-r--r--server/controllers/feeds.ts10
-rw-r--r--server/initializers/checker-before-init.ts5
-rw-r--r--server/initializers/config.ts9
-rw-r--r--server/initializers/constants.ts11
-rw-r--r--server/lib/client-html.ts6
-rw-r--r--server/lib/schedulers/geo-ip-update-scheduler.ts2
-rw-r--r--server/middlewares/index.ts1
-rw-r--r--server/middlewares/rate-limiter.ts31
-rw-r--r--server/middlewares/validators/sort.ts4
-rw-r--r--server/models/user/user.ts6
-rw-r--r--server/models/utils.ts22
-rw-r--r--server/models/video/video-channel.ts13
-rw-r--r--server/tests/api/live/live.ts2
-rw-r--r--server/tests/api/server/reverse-proxy.ts11
-rw-r--r--server/tests/api/videos/video-channels.ts19
-rw-r--r--shared/models/videos/channel/video-channel.model.ts1
-rw-r--r--shared/models/videos/video-sort-field.type.ts1
-rw-r--r--support/doc/api/openapi.yaml2
-rw-r--r--support/doc/production.md6
53 files changed, 1375 insertions, 776 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8aeeb7b7e..4638cd6d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,10 +4,11 @@
4 4
5### IMPORTANT NOTES 5### IMPORTANT NOTES
6 6
7 * **Important** SQL migrations (in particular `0685-multiple-actor-images`) can take several minutes to complete
8 * **Important** You need to execute manually a migration script (can be executed after your upgrade, while your PeerTube instance is running) to generate smaller avatar miniatures: 7 * **Important** You need to execute manually a migration script (can be executed after your upgrade, while your PeerTube instance is running) to generate smaller avatar miniatures:
9 * Classic installation: `cd /var/www/peertube/peertube-latest && sudo -u peertube NODE_CONFIG_DIR=/var/www/peertube/config NODE_ENV=production node dist/scripts/migrations/peertube-4.2.js` 8 * Classic installation: `cd /var/www/peertube/peertube-latest && sudo -u peertube NODE_CONFIG_DIR=/var/www/peertube/config NODE_ENV=production node dist/scripts/migrations/peertube-4.2.js`
10 * Docker installation: `cd /var/www/peertube-docker && docker-compose exec -u peertube peertube node dist/scripts/migrations/peertube-4.2.js` 9 * Docker installation: `cd /var/www/peertube-docker && docker-compose exec -u peertube peertube node dist/scripts/migrations/peertube-4.2.js`
10 * **Important** SQL migrations (in particular `0685-multiple-actor-images`) can take several minutes to complete
11 * **Important** You must update your nginx configuration to support video web editor: https://docs.joinpeertube.org/install-any-os?id=nginx
11 * REST API: 12 * REST API:
12 * `PUT /api/v1/videos/{id}/watching` is deprecated, use `POST /api/v1/videos/videos/{id}/views` instead: https://docs.joinpeertube.org/api-rest-reference.html#operation/addView 13 * `PUT /api/v1/videos/{id}/watching` is deprecated, use `POST /api/v1/videos/videos/{id}/views` instead: https://docs.joinpeertube.org/api-rest-reference.html#operation/addView
13 14
diff --git a/client/src/app/+admin/overview/users/user-list/user-list.component.html b/client/src/app/+admin/overview/users/user-list/user-list.component.html
index 30d10e3cf..62eeef8fe 100644
--- a/client/src/app/+admin/overview/users/user-list/user-list.component.html
+++ b/client/src/app/+admin/overview/users/user-list/user-list.component.html
@@ -5,7 +5,7 @@
5 5
6<p-table 6<p-table
7 [value]="users" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions" 7 [value]="users" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
8 [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" [resizableColumns]="true" [(selection)]="selectedUsers" 8 [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" [resizableColumns]="true" [(selection)]="selectedUsers"
9 [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true" 9 [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
10 [showCurrentPageReport]="true" i18n-currentPageReportTemplate 10 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} users" 11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} users"
diff --git a/client/src/app/+admin/overview/users/user-list/user-list.component.ts b/client/src/app/+admin/overview/users/user-list/user-list.component.ts
index d22e1355e..9d11bd02e 100644
--- a/client/src/app/+admin/overview/users/user-list/user-list.component.ts
+++ b/client/src/app/+admin/overview/users/user-list/user-list.component.ts
@@ -1,7 +1,7 @@
1import { SortMeta } from 'primeng/api' 1import { SortMeta } from 'primeng/api'
2import { Component, OnInit, ViewChild } from '@angular/core' 2import { Component, OnInit, ViewChild } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
4import { AuthService, ConfirmService, Notifier, RestPagination, RestTable, ServerService } from '@app/core' 4import { AuthService, ConfirmService, LocalStorageService, Notifier, RestPagination, RestTable, ServerService } from '@app/core'
5import { getAPIHost } from '@app/helpers' 5import { getAPIHost } from '@app/helpers'
6import { AdvancedInputFilter } from '@app/shared/shared-forms' 6import { AdvancedInputFilter } from '@app/shared/shared-forms'
7import { Actor, DropdownAction } from '@app/shared/shared-main' 7import { Actor, DropdownAction } from '@app/shared/shared-main'
@@ -22,6 +22,8 @@ type UserForList = User & {
22 styleUrls: [ './user-list.component.scss' ] 22 styleUrls: [ './user-list.component.scss' ]
23}) 23})
24export class UserListComponent extends RestTable implements OnInit { 24export class UserListComponent extends RestTable implements OnInit {
25 private static readonly LOCAL_STORAGE_SELECTED_COLUMNS_KEY = 'admin-user-list-selected-columns'
26
25 @ViewChild('userBanModal', { static: true }) userBanModal: UserBanModalComponent 27 @ViewChild('userBanModal', { static: true }) userBanModal: UserBanModalComponent
26 28
27 users: (User & { accountMutedStatus: AccountMutedStatus })[] = [] 29 users: (User & { accountMutedStatus: AccountMutedStatus })[] = []
@@ -56,7 +58,7 @@ export class UserListComponent extends RestTable implements OnInit {
56 58
57 requiresEmailVerification = false 59 requiresEmailVerification = false
58 60
59 private _selectedColumns: string[] 61 private _selectedColumns: string[] = []
60 62
61 constructor ( 63 constructor (
62 protected route: ActivatedRoute, 64 protected route: ActivatedRoute,
@@ -66,7 +68,8 @@ export class UserListComponent extends RestTable implements OnInit {
66 private serverService: ServerService, 68 private serverService: ServerService,
67 private auth: AuthService, 69 private auth: AuthService,
68 private blocklist: BlocklistService, 70 private blocklist: BlocklistService,
69 private userAdminService: UserAdminService 71 private userAdminService: UserAdminService,
72 private peertubeLocalStorage: LocalStorageService
70 ) { 73 ) {
71 super() 74 super()
72 } 75 }
@@ -76,11 +79,13 @@ export class UserListComponent extends RestTable implements OnInit {
76 } 79 }
77 80
78 get selectedColumns () { 81 get selectedColumns () {
79 return this._selectedColumns 82 return this._selectedColumns || []
80 } 83 }
81 84
82 set selectedColumns (val: string[]) { 85 set selectedColumns (val: string[]) {
83 this._selectedColumns = val 86 this._selectedColumns = val
87
88 this.saveSelectedColumns()
84 } 89 }
85 90
86 ngOnInit () { 91 ngOnInit () {
@@ -126,14 +131,35 @@ export class UserListComponent extends RestTable implements OnInit {
126 { id: 'role', label: $localize`Role` }, 131 { id: 'role', label: $localize`Role` },
127 { id: 'email', label: $localize`Email` }, 132 { id: 'email', label: $localize`Email` },
128 { id: 'quota', label: $localize`Video quota` }, 133 { id: 'quota', label: $localize`Video quota` },
129 { id: 'createdAt', label: $localize`Created` } 134 { id: 'createdAt', label: $localize`Created` },
135 { id: 'lastLoginDate', label: $localize`Last login` },
136
137 { id: 'quotaDaily', label: $localize`Daily quota` },
138 { id: 'pluginAuth', label: $localize`Auth plugin` }
130 ] 139 ]
131 140
132 this.selectedColumns = this.columns.map(c => c.id) 141 this.loadSelectedColumns()
142 }
143
144 loadSelectedColumns () {
145 const result = this.peertubeLocalStorage.getItem(UserListComponent.LOCAL_STORAGE_SELECTED_COLUMNS_KEY)
146
147 if (result) {
148 try {
149 this.selectedColumns = JSON.parse(result)
150 return
151 } catch (err) {
152 console.error('Cannot load selected columns.', err)
153 }
154 }
155
156 // Default behaviour
157 this.selectedColumns = [ 'username', 'role', 'email', 'quota', 'createdAt', 'lastLoginDate' ]
158 return
159 }
133 160
134 this.columns.push({ id: 'quotaDaily', label: $localize`Daily quota` }) 161 saveSelectedColumns () {
135 this.columns.push({ id: 'pluginAuth', label: $localize`Auth plugin` }) 162 this.peertubeLocalStorage.setItem(UserListComponent.LOCAL_STORAGE_SELECTED_COLUMNS_KEY, JSON.stringify(this.selectedColumns))
136 this.columns.push({ id: 'lastLoginDate', label: $localize`Last login` })
137 } 163 }
138 164
139 getIdentifier () { 165 getIdentifier () {
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html
index 89327b065..f17f62bba 100644
--- a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html
+++ b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html
@@ -31,11 +31,13 @@
31 i18n class="video-channel-followers" 31 i18n class="video-channel-followers"
32 [routerLink]="[ '/my-library', 'followers' ]" [queryParams]="{ search: 'channel:' + videoChannel.name }" 32 [routerLink]="[ '/my-library', 'followers' ]" [queryParams]="{ search: 'channel:' + videoChannel.name }"
33 > 33 >
34 {videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}} 34 {videoChannel.followersCount, plural, =0 {No subscribers} =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}
35 </a> 35 </a>
36 36
37 <div i18n class="video-channel-videos">{videoChannel.videosCount, plural, =0 {No videos} =1 {1 video} other {{{ videoChannel.videosCount }} videos}}</div> 37 <div i18n class="video-channel-videos">{videoChannel.videosCount, plural, =0 {No videos} =1 {1 video} other {{{ videoChannel.videosCount }} videos}}</div>
38 38
39 <div i18n class="video-channel-views">{videoChannel.totalViews, plural, =0 {No views} =1 {1 view} other {{{ videoChannel.totalViews }} views}}</div>
40
39 <div class="video-channel-buttons"> 41 <div class="video-channel-buttons">
40 <my-edit-button label [routerLink]="[ '/manage/update', videoChannel.nameWithHost ]"></my-edit-button> 42 <my-edit-button label [routerLink]="[ '/manage/update', videoChannel.nameWithHost ]"></my-edit-button>
41 <my-delete-button label (click)="deleteVideoChannel(videoChannel)"></my-delete-button> 43 <my-delete-button label (click)="deleteVideoChannel(videoChannel)"></my-delete-button>
diff --git a/client/src/app/core/core.module.ts b/client/src/app/core/core.module.ts
index d80f95ed6..4a4c2321b 100644
--- a/client/src/app/core/core.module.ts
+++ b/client/src/app/core/core.module.ts
@@ -41,7 +41,9 @@ import { LocalStorageService, ScreenService, SessionStorageService } from './wra
41 ToastModule, 41 ToastModule,
42 42
43 HotkeyModule.forRoot({ 43 HotkeyModule.forRoot({
44 cheatSheetCloseEsc: true 44 cheatSheetCloseEsc: true,
45 cheatSheetDescription: $localize`Show/hide this help menu`,
46 cheatSheetCloseEscDescription: $localize`Hide this help menu`
45 }) 47 })
46 ], 48 ],
47 49
diff --git a/client/src/app/core/rest/rest-table.ts b/client/src/app/core/rest/rest-table.ts
index d8b039187..7b765f7fc 100644
--- a/client/src/app/core/rest/rest-table.ts
+++ b/client/src/app/core/rest/rest-table.ts
@@ -39,6 +39,10 @@ export abstract class RestTable {
39 } 39 }
40 } 40 }
41 41
42 saveSort () {
43 peertubeLocalStorage.setItem(this.getSortLocalStorageKey(), JSON.stringify(this.sort))
44 }
45
42 loadLazy (event: LazyLoadEvent) { 46 loadLazy (event: LazyLoadEvent) {
43 logger('Load lazy %o.', event) 47 logger('Load lazy %o.', event)
44 48
@@ -60,10 +64,6 @@ export abstract class RestTable {
60 this.saveSort() 64 this.saveSort()
61 } 65 }
62 66
63 saveSort () {
64 peertubeLocalStorage.setItem(this.getSortLocalStorageKey(), JSON.stringify(this.sort))
65 }
66
67 onSearch (search: string) { 67 onSearch (search: string) {
68 this.search = search 68 this.search = search
69 this.reloadData() 69 this.reloadData()
diff --git a/client/src/app/shared/shared-forms/reactive-file.component.ts b/client/src/app/shared/shared-forms/reactive-file.component.ts
index 50b7d4c3e..48055a51c 100644
--- a/client/src/app/shared/shared-forms/reactive-file.component.ts
+++ b/client/src/app/shared/shared-forms/reactive-file.component.ts
@@ -57,7 +57,7 @@ export class ReactiveFileComponent implements OnInit, ControlValueAccessor {
57 57
58 const extension = '.' + file.name.split('.').pop() 58 const extension = '.' + file.name.split('.').pop()
59 if (this.extensions.includes(extension.toLowerCase()) === false) { 59 if (this.extensions.includes(extension.toLowerCase()) === false) {
60 const message = $localize`PeerTube cannot handle this kind of file. Accepted extensions are ${this.allowedExtensionsMessage}}.` 60 const message = $localize`PeerTube cannot handle this kind of file. Accepted extensions are ${this.allowedExtensionsMessage}.`
61 this.notifier.error(message) 61 this.notifier.error(message)
62 62
63 return 63 return
diff --git a/client/src/app/shared/shared-main/video-channel/video-channel.model.ts b/client/src/app/shared/shared-main/video-channel/video-channel.model.ts
index 32376bf62..62bd94349 100644
--- a/client/src/app/shared/shared-main/video-channel/video-channel.model.ts
+++ b/client/src/app/shared/shared-main/video-channel/video-channel.model.ts
@@ -27,6 +27,7 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
27 videosCount?: number 27 videosCount?: number
28 28
29 viewsPerDay?: ViewsPerDate[] 29 viewsPerDay?: ViewsPerDate[]
30 totalViews?: number
30 31
31 static GET_ACTOR_AVATAR_URL (actor: { avatars: { width: number, url?: string, path: string }[] }, size: number) { 32 static GET_ACTOR_AVATAR_URL (actor: { avatars: { width: number, url?: string, path: string }[] }, size: number) {
32 return Actor.GET_ACTOR_AVATAR_URL(actor, size) 33 return Actor.GET_ACTOR_AVATAR_URL(actor, size)
@@ -74,6 +75,10 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
74 this.viewsPerDay = hash.viewsPerDay.map(v => ({ ...v, date: new Date(v.date) })) 75 this.viewsPerDay = hash.viewsPerDay.map(v => ({ ...v, date: new Date(v.date) }))
75 } 76 }
76 77
78 if (hash.totalViews !== null && hash.totalViews !== undefined) {
79 this.totalViews = hash.totalViews
80 }
81
77 if (hash.ownerAccount) { 82 if (hash.ownerAccount) {
78 this.ownerAccount = hash.ownerAccount 83 this.ownerAccount = hash.ownerAccount
79 this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host) 84 this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
index a07b8b5ee..fe7a59bdb 100644
--- a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
+++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
@@ -44,6 +44,7 @@
44 [searchable]="false" 44 [searchable]="false"
45 > 45 >
46 <ng-option i18n value="-publishedAt">Sort by <strong>"Recently Added"</strong></ng-option> 46 <ng-option i18n value="-publishedAt">Sort by <strong>"Recently Added"</strong></ng-option>
47 <ng-option i18n value="-originallyPublishedAt">Sort by <strong>"Original Publication Date"</strong></ng-option>
47 48
48 <ng-option i18n *ngIf="isTrendingSortEnabled('most-viewed')" value="-trending">Sort by <strong>"Recent Views"</strong></ng-option> 49 <ng-option i18n *ngIf="isTrendingSortEnabled('most-viewed')" value="-trending">Sort by <strong>"Recent Views"</strong></ng-option>
49 <ng-option i18n *ngIf="isTrendingSortEnabled('hot')" value="-hot">Sort by <strong>"Hot"</strong></ng-option> 50 <ng-option i18n *ngIf="isTrendingSortEnabled('hot')" value="-hot">Sort by <strong>"Hot"</strong></ng-option>
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss b/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss
index 8cb1ff5b8..6a968ed5c 100644
--- a/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss
+++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss
@@ -101,7 +101,7 @@
101} 101}
102 102
103.sort { 103.sort {
104 min-width: 200px; 104 min-width: 250px;
105 max-width: 300px; 105 max-width: 300px;
106 height: min-content; 106 height: min-content;
107 107
diff --git a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts
index b48203148..83b483d87 100644
--- a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts
+++ b/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts
@@ -430,6 +430,11 @@ class WebTorrentPlugin extends Plugin {
430 private initializePlayer () { 430 private initializePlayer () {
431 this.buildQualities() 431 this.buildQualities()
432 432
433 if (this.videoFiles.length === 0) {
434 this.player.addClass('disabled')
435 return
436 }
437
433 if (this.autoplay) { 438 if (this.autoplay) {
434 this.player.posterImage.hide() 439 this.player.posterImage.hide()
435 440
diff --git a/client/src/root-helpers/index.ts b/client/src/root-helpers/index.ts
index 0492924fd..a19855761 100644
--- a/client/src/root-helpers/index.ts
+++ b/client/src/root-helpers/index.ts
@@ -5,5 +5,6 @@ export * from './local-storage-utils'
5export * from './peertube-web-storage' 5export * from './peertube-web-storage'
6export * from './plugins-manager' 6export * from './plugins-manager'
7export * from './string' 7export * from './string'
8export * from './url'
8export * from './utils' 9export * from './utils'
9export * from './video' 10export * from './video'
diff --git a/client/src/root-helpers/url.ts b/client/src/root-helpers/url.ts
new file mode 100644
index 000000000..b2f0c8b85
--- /dev/null
+++ b/client/src/root-helpers/url.ts
@@ -0,0 +1,26 @@
1function getParamToggle (params: URLSearchParams, name: string, defaultValue?: boolean) {
2 return params.has(name)
3 ? (params.get(name) === '1' || params.get(name) === 'true')
4 : defaultValue
5}
6
7function getParamString (params: URLSearchParams, name: string, defaultValue?: string) {
8 return params.has(name)
9 ? params.get(name)
10 : defaultValue
11}
12
13function objectToUrlEncoded (obj: any) {
14 const str: string[] = []
15 for (const key of Object.keys(obj)) {
16 str.push(encodeURIComponent(key) + '=' + encodeURIComponent(obj[key]))
17 }
18
19 return str.join('&')
20}
21
22export {
23 getParamToggle,
24 getParamString,
25 objectToUrlEncoded
26}
diff --git a/client/src/root-helpers/utils.ts b/client/src/root-helpers/utils.ts
index 00bd92411..af94ed6ca 100644
--- a/client/src/root-helpers/utils.ts
+++ b/client/src/root-helpers/utils.ts
@@ -1,12 +1,3 @@
1function objectToUrlEncoded (obj: any) {
2 const str: string[] = []
3 for (const key of Object.keys(obj)) {
4 str.push(encodeURIComponent(key) + '=' + encodeURIComponent(obj[key]))
5 }
6
7 return str.join('&')
8}
9
10function copyToClipboard (text: string) { 1function copyToClipboard (text: string) {
11 const el = document.createElement('textarea') 2 const el = document.createElement('textarea')
12 el.value = text 3 el.value = text
@@ -27,6 +18,5 @@ function wait (ms: number) {
27 18
28export { 19export {
29 copyToClipboard, 20 copyToClipboard,
30 objectToUrlEncoded,
31 wait 21 wait
32} 22}
diff --git a/client/src/sass/player/peertube-skin.scss b/client/src/sass/player/peertube-skin.scss
index c420e825e..43c144624 100644
--- a/client/src/sass/player/peertube-skin.scss
+++ b/client/src/sass/player/peertube-skin.scss
@@ -20,6 +20,15 @@ body {
20 font-size: $font-size; 20 font-size: $font-size;
21 color: pvar(--embedForegroundColor); 21 color: pvar(--embedForegroundColor);
22 22
23 &.disabled {
24 cursor: default;
25 pointer-events: none;
26
27 .vjs-big-play-button {
28 display: none !important;
29 }
30 }
31
23 .vjs-audio-button { 32 .vjs-audio-button {
24 display: none; 33 display: none;
25 } 34 }
diff --git a/client/src/standalone/videos/embed-api.ts b/client/src/standalone/videos/embed-api.ts
index a28aeeaef..84d664654 100644
--- a/client/src/standalone/videos/embed-api.ts
+++ b/client/src/standalone/videos/embed-api.ts
@@ -27,11 +27,11 @@ export class PeerTubeEmbedApi {
27 } 27 }
28 28
29 private get element () { 29 private get element () {
30 return this.embed.playerElement 30 return this.embed.getPlayerElement()
31 } 31 }
32 32
33 private constructChannel () { 33 private constructChannel () {
34 const channel = Channel.build({ window: window.parent, origin: '*', scope: this.embed.scope }) 34 const channel = Channel.build({ window: window.parent, origin: '*', scope: this.embed.getScope() })
35 35
36 channel.bind('play', (txn, params) => this.embed.player.play()) 36 channel.bind('play', (txn, params) => this.embed.player.play())
37 channel.bind('pause', (txn, params) => this.embed.player.pause()) 37 channel.bind('pause', (txn, params) => this.embed.player.pause())
@@ -52,9 +52,9 @@ export class PeerTubeEmbedApi {
52 channel.bind('getPlaybackRate', (txn, params) => this.embed.player.playbackRate()) 52 channel.bind('getPlaybackRate', (txn, params) => this.embed.player.playbackRate())
53 channel.bind('getPlaybackRates', (txn, params) => this.embed.player.options_.playbackRates) 53 channel.bind('getPlaybackRates', (txn, params) => this.embed.player.options_.playbackRates)
54 54
55 channel.bind('playNextVideo', (txn, params) => this.embed.playNextVideo()) 55 channel.bind('playNextVideo', (txn, params) => this.embed.playNextPlaylistVideo())
56 channel.bind('playPreviousVideo', (txn, params) => this.embed.playPreviousVideo()) 56 channel.bind('playPreviousVideo', (txn, params) => this.embed.playPreviousPlaylistVideo())
57 channel.bind('getCurrentPosition', (txn, params) => this.embed.getCurrentPosition()) 57 channel.bind('getCurrentPosition', (txn, params) => this.embed.getCurrentPlaylistPosition())
58 this.channel = channel 58 this.channel = channel
59 } 59 }
60 60
diff --git a/client/src/standalone/videos/embed.scss b/client/src/standalone/videos/embed.scss
index 91ab822c8..8c20bae79 100644
--- a/client/src/standalone/videos/embed.scss
+++ b/client/src/standalone/videos/embed.scss
@@ -92,6 +92,17 @@ body {
92 width: 100%; 92 width: 100%;
93 height: 100%; 93 height: 100%;
94 background-position: 50% 50%; 94 background-position: 50% 50%;
95 background-repeat: no-repeat;
96}
97
98.player-information {
99 width: 100%;
100 color: #fff;
101 background: rgba(0, 0, 0, 0.6);
102 padding: 20px 0;
103 position: absolute;
104 bottom: 0;
105 text-align: center;
95} 106}
96 107
97@media screen and (max-width: 300px) { 108@media screen and (max-width: 300px) {
diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts
index 1fc8e229b..0a2b0ccbd 100644
--- a/client/src/standalone/videos/embed.ts
+++ b/client/src/standalone/videos/embed.ts
@@ -3,83 +3,42 @@ import '../../assets/player/shared/dock/peertube-dock-component'
3import '../../assets/player/shared/dock/peertube-dock-plugin' 3import '../../assets/player/shared/dock/peertube-dock-plugin'
4import videojs from 'video.js' 4import videojs from 'video.js'
5import { peertubeTranslate } from '../../../../shared/core-utils/i18n' 5import { peertubeTranslate } from '../../../../shared/core-utils/i18n'
6import { 6import { HTMLServerConfig, LiveVideo, ResultList, VideoDetails, VideoPlaylist, VideoPlaylistElement } from '../../../../shared/models'
7 HTMLServerConfig, 7import { PeertubePlayerManager } from '../../assets/player'
8 HttpStatusCode,
9 LiveVideo,
10 OAuth2ErrorCode,
11 PublicServerSetting,
12 ResultList,
13 UserRefreshToken,
14 Video,
15 VideoCaption,
16 VideoDetails,
17 VideoPlaylist,
18 VideoPlaylistElement,
19 VideoStreamingPlaylistType
20} from '../../../../shared/models'
21import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode, VideoJSCaption } from '../../assets/player'
22import { TranslationsManager } from '../../assets/player/translations-manager' 8import { TranslationsManager } from '../../assets/player/translations-manager'
23import { getBoolOrDefault } from '../../root-helpers/local-storage-utils' 9import { getParamString } from '../../root-helpers'
24import { peertubeLocalStorage } from '../../root-helpers/peertube-web-storage'
25import { PluginInfo, PluginsManager } from '../../root-helpers/plugins-manager'
26import { UserLocalStorageKeys, UserTokens } from '../../root-helpers/users'
27import { objectToUrlEncoded } from '../../root-helpers/utils'
28import { isP2PEnabled } from '../../root-helpers/video'
29import { RegisterClientHelpers } from '../../types/register-client-option.model'
30import { PeerTubeEmbedApi } from './embed-api' 10import { PeerTubeEmbedApi } from './embed-api'
31 11import { AuthHTTP, LiveManager, PeerTubePlugin, PlayerManagerOptions, PlaylistFetcher, PlaylistTracker, VideoFetcher } from './shared'
32type Translations = { [ id: string ]: string } 12import { PlayerHTML } from './shared/player-html'
33 13
34export class PeerTubeEmbed { 14export class PeerTubeEmbed {
35 playerElement: HTMLVideoElement
36 player: videojs.Player 15 player: videojs.Player
37 api: PeerTubeEmbedApi = null 16 api: PeerTubeEmbedApi = null
38 17
39 autoplay: boolean
40
41 controls: boolean
42 controlBar: boolean
43
44 muted: boolean
45 loop: boolean
46 subtitle: string
47 enableApi = false
48 startTime: number | string = 0
49 stopTime: number | string
50
51 title: boolean
52 warningTitle: boolean
53 peertubeLink: boolean
54 p2pEnabled: boolean
55 bigPlayBackgroundColor: string
56 foregroundColor: string
57
58 mode: PlayerMode
59 scope = 'peertube'
60
61 userTokens: UserTokens
62 headers = new Headers()
63 LOCAL_STORAGE_OAUTH_CLIENT_KEYS = {
64 CLIENT_ID: 'client_id',
65 CLIENT_SECRET: 'client_secret'
66 }
67
68 config: HTMLServerConfig 18 config: HTMLServerConfig
69 19
70 private translationsPromise: Promise<{ [id: string]: string }> 20 private translationsPromise: Promise<{ [id: string]: string }>
71 private PeertubePlayerManagerModulePromise: Promise<any> 21 private PeertubePlayerManagerModulePromise: Promise<any>
72 22
73 private playlist: VideoPlaylist 23 private readonly http: AuthHTTP
74 private playlistElements: VideoPlaylistElement[] 24 private readonly videoFetcher: VideoFetcher
75 private currentPlaylistElement: VideoPlaylistElement 25 private readonly playlistFetcher: PlaylistFetcher
26 private readonly peertubePlugin: PeerTubePlugin
27 private readonly playerHTML: PlayerHTML
28 private readonly playerManagerOptions: PlayerManagerOptions
29 private readonly liveManager: LiveManager
76 30
77 private readonly wrapperElement: HTMLElement 31 private playlistTracker: PlaylistTracker
78 32
79 private pluginsManager: PluginsManager 33 constructor (videoWrapperId: string) {
34 this.http = new AuthHTTP()
80 35
81 constructor (private readonly videoWrapperId: string) { 36 this.videoFetcher = new VideoFetcher(this.http)
82 this.wrapperElement = document.getElementById(this.videoWrapperId) 37 this.playlistFetcher = new PlaylistFetcher(this.http)
38 this.peertubePlugin = new PeerTubePlugin(this.http)
39 this.playerHTML = new PlayerHTML(videoWrapperId)
40 this.playerManagerOptions = new PlayerManagerOptions(this.playerHTML, this.videoFetcher, this.peertubePlugin)
41 this.liveManager = new LiveManager(this.playerHTML)
83 42
84 try { 43 try {
85 this.config = JSON.parse(window['PeerTubeServerConfig']) 44 this.config = JSON.parse(window['PeerTubeServerConfig'])
@@ -94,697 +53,279 @@ export class PeerTubeEmbed {
94 await embed.init() 53 await embed.init()
95 } 54 }
96 55
97 getVideoUrl (id: string) { 56 getPlayerElement () {
98 return window.location.origin + '/api/v1/videos/' + id 57 return this.playerHTML.getPlayerElement()
99 }
100
101 getLiveUrl (videoId: string) {
102 return window.location.origin + '/api/v1/videos/live/' + videoId
103 }
104
105 getPluginUrl () {
106 return window.location.origin + '/api/v1/plugins'
107 }
108
109 refreshFetch (url: string, options?: RequestInit) {
110 return fetch(url, options)
111 .then((res: Response) => {
112 if (res.status !== HttpStatusCode.UNAUTHORIZED_401) return res
113
114 const refreshingTokenPromise = new Promise<void>((resolve, reject) => {
115 const clientId: string = peertubeLocalStorage.getItem(this.LOCAL_STORAGE_OAUTH_CLIENT_KEYS.CLIENT_ID)
116 const clientSecret: string = peertubeLocalStorage.getItem(this.LOCAL_STORAGE_OAUTH_CLIENT_KEYS.CLIENT_SECRET)
117
118 const headers = new Headers()
119 headers.set('Content-Type', 'application/x-www-form-urlencoded')
120
121 const data = {
122 refresh_token: this.userTokens.refreshToken,
123 client_id: clientId,
124 client_secret: clientSecret,
125 response_type: 'code',
126 grant_type: 'refresh_token'
127 }
128
129 fetch('/api/v1/users/token', {
130 headers,
131 method: 'POST',
132 body: objectToUrlEncoded(data)
133 }).then(res => {
134 if (res.status === HttpStatusCode.UNAUTHORIZED_401) return undefined
135
136 return res.json()
137 }).then((obj: UserRefreshToken & { code?: OAuth2ErrorCode }) => {
138 if (!obj || obj.code === OAuth2ErrorCode.INVALID_GRANT) {
139 UserTokens.flushLocalStorage(peertubeLocalStorage)
140 this.removeTokensFromHeaders()
141
142 return resolve()
143 }
144
145 this.userTokens.accessToken = obj.access_token
146 this.userTokens.refreshToken = obj.refresh_token
147 UserTokens.saveToLocalStorage(peertubeLocalStorage, this.userTokens)
148
149 this.setHeadersFromTokens()
150
151 resolve()
152 }).catch((refreshTokenError: any) => {
153 reject(refreshTokenError)
154 })
155 })
156
157 return refreshingTokenPromise
158 .catch(() => {
159 UserTokens.flushLocalStorage(peertubeLocalStorage)
160
161 this.removeTokensFromHeaders()
162 }).then(() => fetch(url, {
163 ...options,
164 headers: this.headers
165 }))
166 })
167 }
168
169 getPlaylistUrl (id: string) {
170 return window.location.origin + '/api/v1/video-playlists/' + id
171 } 58 }
172 59
173 loadVideoInfo (videoId: string): Promise<Response> { 60 getScope () {
174 return this.refreshFetch(this.getVideoUrl(videoId), { headers: this.headers }) 61 return this.playerManagerOptions.getScope()
175 } 62 }
176 63
177 loadVideoCaptions (videoId: string): Promise<Response> { 64 // ---------------------------------------------------------------------------
178 return this.refreshFetch(this.getVideoUrl(videoId) + '/captions', { headers: this.headers })
179 }
180 65
181 loadWithLive (video: VideoDetails) { 66 async init () {
182 return this.refreshFetch(this.getLiveUrl(video.uuid), { headers: this.headers }) 67 this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language)
183 .then(res => res.json()) 68 this.PeertubePlayerManagerModulePromise = import('../../assets/player/peertube-player-manager')
184 .then((live: LiveVideo) => ({ video, live }))
185 }
186 69
187 loadPlaylistInfo (playlistId: string): Promise<Response> { 70 // Issue when we parsed config from HTML, fallback to API
188 return this.refreshFetch(this.getPlaylistUrl(playlistId), { headers: this.headers }) 71 if (!this.config) {
189 } 72 this.config = await this.http.fetch('/api/v1/config', { optionalAuth: false })
73 .then(res => res.json())
74 }
190 75
191 loadPlaylistElements (playlistId: string, start = 0): Promise<Response> { 76 const videoId = this.isPlaylistEmbed()
192 const url = new URL(this.getPlaylistUrl(playlistId) + '/videos') 77 ? await this.initPlaylist()
193 url.search = new URLSearchParams({ start: '' + start, count: '100' }).toString() 78 : this.getResourceId()
194 79
195 return this.refreshFetch(url.toString(), { headers: this.headers }) 80 if (!videoId) return
196 }
197 81
198 removeElement (element: HTMLElement) { 82 return this.loadVideoAndBuildPlayer(videoId)
199 element.parentElement.removeChild(element)
200 } 83 }
201 84
202 displayError (text: string, translations?: Translations) { 85 private async initPlaylist () {
203 // Remove video element 86 const playlistId = this.getResourceId()
204 if (this.playerElement) {
205 this.removeElement(this.playerElement)
206 this.playerElement = undefined
207 }
208
209 const translatedText = peertubeTranslate(text, translations)
210 const translatedSorry = peertubeTranslate('Sorry', translations)
211 87
212 document.title = translatedSorry + ' - ' + translatedText 88 try {
213 89 const res = await this.playlistFetcher.loadPlaylist(playlistId)
214 const errorBlock = document.getElementById('error-block')
215 errorBlock.style.display = 'flex'
216 90
217 const errorTitle = document.getElementById('error-title') 91 const [ playlist, playlistElementResult ] = await Promise.all([
218 errorTitle.innerHTML = peertubeTranslate('Sorry', translations) 92 res.playlistResponse.json() as Promise<VideoPlaylist>,
93 res.videosResponse.json() as Promise<ResultList<VideoPlaylistElement>>
94 ])
219 95
220 const errorText = document.getElementById('error-content') 96 const allPlaylistElements = await this.playlistFetcher.loadAllPlaylistVideos(playlistId, playlistElementResult)
221 errorText.innerHTML = translatedText
222 97
223 this.wrapperElement.style.display = 'none' 98 this.playlistTracker = new PlaylistTracker(playlist, allPlaylistElements)
224 }
225 99
226 videoNotFound (translations?: Translations) { 100 const params = new URL(window.location.toString()).searchParams
227 const text = 'This video does not exist.' 101 const playlistPositionParam = getParamString(params, 'playlistPosition')
228 this.displayError(text, translations)
229 }
230 102
231 videoFetchError (translations?: Translations) { 103 const position = playlistPositionParam
232 const text = 'We cannot fetch the video. Please try again later.' 104 ? parseInt(playlistPositionParam + '', 10)
233 this.displayError(text, translations) 105 : 1
234 }
235 106
236 playlistNotFound (translations?: Translations) { 107 this.playlistTracker.setPosition(position)
237 const text = 'This playlist does not exist.' 108 } catch (err) {
238 this.displayError(text, translations) 109 this.playerHTML.displayError(err.message, await this.translationsPromise)
239 } 110 return undefined
111 }
240 112
241 playlistFetchError (translations?: Translations) { 113 return this.playlistTracker.getCurrentElement().video.uuid
242 const text = 'We cannot fetch the playlist. Please try again later.'
243 this.displayError(text, translations)
244 } 114 }
245 115
246 getParamToggle (params: URLSearchParams, name: string, defaultValue?: boolean) { 116 private initializeApi () {
247 return params.has(name) ? (params.get(name) === '1' || params.get(name) === 'true') : defaultValue 117 if (this.playerManagerOptions.hasAPIEnabled()) {
118 this.api = new PeerTubeEmbedApi(this)
119 this.api.initialize()
120 }
248 } 121 }
249 122
250 getParamString (params: URLSearchParams, name: string, defaultValue?: string) { 123 // ---------------------------------------------------------------------------
251 return params.has(name) ? params.get(name) : defaultValue
252 }
253 124
254 async playNextVideo () { 125 async playNextPlaylistVideo () {
255 const next = this.getNextPlaylistElement() 126 const next = this.playlistTracker.getNextPlaylistElement()
256 if (!next) { 127 if (!next) {
257 console.log('Next element not found in playlist.') 128 console.log('Next element not found in playlist.')
258 return 129 return
259 } 130 }
260 131
261 this.currentPlaylistElement = next 132 this.playlistTracker.setCurrentElement(next)
262 133
263 return this.loadVideoAndBuildPlayer(this.currentPlaylistElement.video.uuid) 134 return this.loadVideoAndBuildPlayer(next.video.uuid)
264 } 135 }
265 136
266 async playPreviousVideo () { 137 async playPreviousPlaylistVideo () {
267 const previous = this.getPreviousPlaylistElement() 138 const previous = this.playlistTracker.getPreviousPlaylistElement()
268 if (!previous) { 139 if (!previous) {
269 console.log('Previous element not found in playlist.') 140 console.log('Previous element not found in playlist.')
270 return 141 return
271 } 142 }
272 143
273 this.currentPlaylistElement = previous 144 this.playlistTracker.setCurrentElement(previous)
274
275 await this.loadVideoAndBuildPlayer(this.currentPlaylistElement.video.uuid)
276 }
277
278 getCurrentPosition () {
279 if (!this.currentPlaylistElement) return -1
280 145
281 return this.currentPlaylistElement.position 146 await this.loadVideoAndBuildPlayer(previous.video.uuid)
282 } 147 }
283 148
284 async init () { 149 getCurrentPlaylistPosition () {
285 this.userTokens = UserTokens.getUserTokens(peertubeLocalStorage) 150 return this.playlistTracker.getCurrentPosition()
286 await this.initCore()
287 } 151 }
288 152
289 private initializeApi () { 153 // ---------------------------------------------------------------------------
290 if (!this.enableApi) return
291
292 this.api = new PeerTubeEmbedApi(this)
293 this.api.initialize()
294 }
295
296 private loadParams (video: VideoDetails) {
297 try {
298 const params = new URL(window.location.toString()).searchParams
299
300 this.autoplay = this.getParamToggle(params, 'autoplay', false)
301
302 this.controls = this.getParamToggle(params, 'controls', true)
303 this.controlBar = this.getParamToggle(params, 'controlBar', true)
304
305 this.muted = this.getParamToggle(params, 'muted', undefined)
306 this.loop = this.getParamToggle(params, 'loop', false)
307 this.title = this.getParamToggle(params, 'title', true)
308 this.enableApi = this.getParamToggle(params, 'api', this.enableApi)
309 this.warningTitle = this.getParamToggle(params, 'warningTitle', true)
310 this.peertubeLink = this.getParamToggle(params, 'peertubeLink', true)
311 this.p2pEnabled = this.getParamToggle(params, 'p2p', this.isP2PEnabled(video))
312
313 this.scope = this.getParamString(params, 'scope', this.scope)
314 this.subtitle = this.getParamString(params, 'subtitle')
315 this.startTime = this.getParamString(params, 'start')
316 this.stopTime = this.getParamString(params, 'stop')
317
318 this.bigPlayBackgroundColor = this.getParamString(params, 'bigPlayBackgroundColor')
319 this.foregroundColor = this.getParamString(params, 'foregroundColor')
320
321 const modeParam = this.getParamString(params, 'mode')
322
323 if (modeParam) {
324 if (modeParam === 'p2p-media-loader') this.mode = 'p2p-media-loader'
325 else this.mode = 'webtorrent'
326 } else {
327 if (Array.isArray(video.streamingPlaylists) && video.streamingPlaylists.length !== 0) this.mode = 'p2p-media-loader'
328 else this.mode = 'webtorrent'
329 }
330 } catch (err) {
331 console.error('Cannot get params from URL.', err)
332 }
333 }
334
335 private async loadAllPlaylistVideos (playlistId: string, baseResult: ResultList<VideoPlaylistElement>) {
336 let elements = baseResult.data
337 let total = baseResult.total
338 let i = 0
339
340 while (total > elements.length && i < 10) {
341 const result = await this.loadPlaylistElements(playlistId, elements.length)
342
343 const json = await result.json()
344 total = json.total
345
346 elements = elements.concat(json.data)
347 i++
348 }
349
350 if (i === 10) {
351 console.error('Cannot fetch all playlists elements, there are too many!')
352 }
353
354 return elements
355 }
356
357 private async loadPlaylist (playlistId: string) {
358 const playlistPromise = this.loadPlaylistInfo(playlistId)
359 const playlistElementsPromise = this.loadPlaylistElements(playlistId)
360
361 let playlistResponse: Response
362 let isResponseOk: boolean
363 154
155 private async loadVideoAndBuildPlayer (uuid: string) {
364 try { 156 try {
365 playlistResponse = await playlistPromise 157 const { videoResponse, captionsPromise } = await this.videoFetcher.loadVideo(uuid)
366 isResponseOk = playlistResponse.status === HttpStatusCode.OK_200
367 } catch (err) {
368 console.error(err)
369 isResponseOk = false
370 }
371
372 if (!isResponseOk) {
373 const serverTranslations = await this.translationsPromise
374
375 if (playlistResponse?.status === HttpStatusCode.NOT_FOUND_404) {
376 this.playlistNotFound(serverTranslations)
377 return undefined
378 }
379 158
380 this.playlistFetchError(serverTranslations) 159 return this.buildVideoPlayer(videoResponse, captionsPromise)
381 return undefined
382 }
383
384 return { playlistResponse, videosResponse: await playlistElementsPromise }
385 }
386
387 private async loadVideo (videoId: string) {
388 const videoPromise = this.loadVideoInfo(videoId)
389
390 let videoResponse: Response
391 let isResponseOk: boolean
392
393 try {
394 videoResponse = await videoPromise
395 isResponseOk = videoResponse.status === HttpStatusCode.OK_200
396 } catch (err) { 160 } catch (err) {
397 console.error(err) 161 this.playerHTML.displayError(err.message, await this.translationsPromise)
398
399 isResponseOk = false
400 }
401
402 if (!isResponseOk) {
403 const serverTranslations = await this.translationsPromise
404
405 if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) {
406 this.videoNotFound(serverTranslations)
407 return undefined
408 }
409
410 this.videoFetchError(serverTranslations)
411 return undefined
412 }
413
414 const captionsPromise = this.loadVideoCaptions(videoId)
415
416 return { captionsPromise, videoResponse }
417 }
418
419 private async buildPlaylistManager () {
420 const translations = await this.translationsPromise
421
422 this.player.upnext({
423 timeout: 10000, // 10s
424 headText: peertubeTranslate('Up Next', translations),
425 cancelText: peertubeTranslate('Cancel', translations),
426 suspendedText: peertubeTranslate('Autoplay is suspended', translations),
427 getTitle: () => this.nextVideoTitle(),
428 next: () => this.playNextVideo(),
429 condition: () => !!this.getNextPlaylistElement(),
430 suspended: () => false
431 })
432 }
433
434 private async loadVideoAndBuildPlayer (uuid: string) {
435 const res = await this.loadVideo(uuid)
436 if (res === undefined) return
437
438 return this.buildVideoPlayer(res.videoResponse, res.captionsPromise)
439 }
440
441 private nextVideoTitle () {
442 const next = this.getNextPlaylistElement()
443 if (!next) return ''
444
445 return next.video.name
446 }
447
448 private getNextPlaylistElement (position?: number): VideoPlaylistElement {
449 if (!position) position = this.currentPlaylistElement.position + 1
450
451 if (position > this.playlist.videosLength) {
452 return undefined
453 } 162 }
454
455 const next = this.playlistElements.find(e => e.position === position)
456
457 if (!next || !next.video) {
458 return this.getNextPlaylistElement(position + 1)
459 }
460
461 return next
462 }
463
464 private getPreviousPlaylistElement (position?: number): VideoPlaylistElement {
465 if (!position) position = this.currentPlaylistElement.position - 1
466
467 if (position < 1) {
468 return undefined
469 }
470
471 const prev = this.playlistElements.find(e => e.position === position)
472
473 if (!prev || !prev.video) {
474 return this.getNextPlaylistElement(position - 1)
475 }
476
477 return prev
478 } 163 }
479 164
480 private async buildVideoPlayer (videoResponse: Response, captionsPromise: Promise<Response>) { 165 private async buildVideoPlayer (videoResponse: Response, captionsPromise: Promise<Response>) {
481 let alreadyHadPlayer = false 166 const alreadyHadPlayer = this.resetPlayerElement()
482
483 if (this.player) {
484 this.player.dispose()
485 alreadyHadPlayer = true
486 }
487
488 this.playerElement = document.createElement('video')
489 this.playerElement.className = 'video-js vjs-peertube-skin'
490 this.playerElement.setAttribute('playsinline', 'true')
491 this.wrapperElement.appendChild(this.playerElement)
492
493 // Issue when we parsed config from HTML, fallback to API
494 if (!this.config) {
495 this.config = await this.refreshFetch('/api/v1/config')
496 .then(res => res.json())
497 }
498 167
499 const videoInfoPromise: Promise<{ video: VideoDetails, live?: LiveVideo }> = videoResponse.json() 168 const videoInfoPromise: Promise<{ video: VideoDetails, live?: LiveVideo }> = videoResponse.json()
500 .then((videoInfo: VideoDetails) => { 169 .then((videoInfo: VideoDetails) => {
501 this.loadParams(videoInfo) 170 this.playerManagerOptions.loadParams(this.config, videoInfo)
502 171
503 if (!alreadyHadPlayer && !this.autoplay) this.buildPlaceholder(videoInfo) 172 if (!alreadyHadPlayer && !this.playerManagerOptions.hasAutoplay()) {
173 this.playerHTML.buildPlaceholder(videoInfo)
174 }
504 175
505 if (!videoInfo.isLive) return { video: videoInfo } 176 if (!videoInfo.isLive) {
177 return { video: videoInfo }
178 }
506 179
507 return this.loadWithLive(videoInfo) 180 return this.videoFetcher.loadVideoWithLive(videoInfo)
508 }) 181 })
509 182
510 const [ videoInfoTmp, serverTranslations, captionsResponse, PeertubePlayerManagerModule ] = await Promise.all([ 183 const [ { video, live }, translations, captionsResponse, PeertubePlayerManagerModule ] = await Promise.all([
511 videoInfoPromise, 184 videoInfoPromise,
512 this.translationsPromise, 185 this.translationsPromise,
513 captionsPromise, 186 captionsPromise,
514 this.PeertubePlayerManagerModulePromise 187 this.PeertubePlayerManagerModulePromise
515 ]) 188 ])
516 189
517 await this.loadPlugins(serverTranslations) 190 await this.peertubePlugin.loadPlugins(this.config, translations)
518
519 const { video: videoInfo, live } = videoInfoTmp
520
521 const PeertubePlayerManager = PeertubePlayerManagerModule.PeertubePlayerManager
522 const videoCaptions = await this.buildCaptions(serverTranslations, captionsResponse)
523
524 const liveOptions = videoInfo.isLive
525 ? { latencyMode: live.latencyMode }
526 : undefined
527
528 const playlistPlugin = this.currentPlaylistElement
529 ? {
530 elements: this.playlistElements,
531 playlist: this.playlist,
532
533 getCurrentPosition: () => this.currentPlaylistElement.position,
534
535 onItemClicked: (videoPlaylistElement: VideoPlaylistElement) => {
536 this.currentPlaylistElement = videoPlaylistElement
537
538 this.loadVideoAndBuildPlayer(this.currentPlaylistElement.video.uuid)
539 .catch(err => console.error(err))
540 }
541 }
542 : undefined
543
544 const options: PeertubePlayerManagerOptions = {
545 common: {
546 // Autoplay in playlist mode
547 autoplay: alreadyHadPlayer ? true : this.autoplay,
548
549 controls: this.controls,
550 controlBar: this.controlBar,
551
552 muted: this.muted,
553 loop: this.loop,
554
555 p2pEnabled: this.p2pEnabled,
556
557 captions: videoCaptions.length !== 0,
558 subtitle: this.subtitle,
559 191
560 startTime: this.playlist ? this.currentPlaylistElement.startTimestamp : this.startTime, 192 const PlayerManager: typeof PeertubePlayerManager = PeertubePlayerManagerModule.PeertubePlayerManager
561 stopTime: this.playlist ? this.currentPlaylistElement.stopTimestamp : this.stopTime,
562 193
563 nextVideo: this.playlist ? () => this.playNextVideo() : undefined, 194 const options = await this.playerManagerOptions.getPlayerOptions({
564 hasNextVideo: this.playlist ? () => !!this.getNextPlaylistElement() : undefined, 195 video,
196 captionsResponse,
197 alreadyHadPlayer,
198 translations,
199 onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer(uuid),
565 200
566 previousVideo: this.playlist ? () => this.playPreviousVideo() : undefined, 201 playlistTracker: this.playlistTracker,
567 hasPreviousVideo: this.playlist ? () => !!this.getPreviousPlaylistElement() : undefined, 202 playNextPlaylistVideo: () => this.playNextPlaylistVideo(),
203 playPreviousPlaylistVideo: () => this.playPreviousPlaylistVideo(),
568 204
569 playlist: playlistPlugin, 205 live
570 206 })
571 videoCaptions,
572 inactivityTimeout: 2500,
573 videoViewUrl: this.getVideoUrl(videoInfo.uuid) + '/views',
574 videoShortUUID: videoInfo.shortUUID,
575 videoUUID: videoInfo.uuid,
576
577 isLive: videoInfo.isLive,
578 liveOptions,
579
580 playerElement: this.playerElement,
581 onPlayerElementChange: (element: HTMLVideoElement) => {
582 this.playerElement = element
583 },
584
585 videoDuration: videoInfo.duration,
586 enableHotkeys: true,
587 peertubeLink: this.peertubeLink,
588 poster: window.location.origin + videoInfo.previewPath,
589 theaterButton: false,
590
591 serverUrl: window.location.origin,
592 language: navigator.language,
593 embedUrl: window.location.origin + videoInfo.embedPath,
594 embedTitle: videoInfo.name,
595
596 errorNotifier: () => {
597 // Empty, we don't have a notifier in the embed
598 }
599 },
600
601 webtorrent: {
602 videoFiles: videoInfo.files
603 },
604
605 pluginsManager: this.pluginsManager
606 }
607
608 if (this.mode === 'p2p-media-loader') {
609 const hlsPlaylist = videoInfo.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
610
611 Object.assign(options, {
612 p2pMediaLoader: {
613 playlistUrl: hlsPlaylist.playlistUrl,
614 segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
615 redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
616 trackerAnnounce: videoInfo.trackerUrls,
617 videoFiles: hlsPlaylist.files
618 } as P2PMediaLoaderOptions
619 })
620 }
621 207
622 this.player = await PeertubePlayerManager.initialize(this.mode, options, (player: videojs.Player) => { 208 this.player = await PlayerManager.initialize(this.playerManagerOptions.getMode(), options, (player: videojs.Player) => {
623 this.player = player 209 this.player = player
624 }) 210 })
625 211
626 this.player.on('customError', (event: any, data: any) => this.handleError(data.err, serverTranslations)) 212 this.player.on('customError', (event: any, data: any) => {
213 const message = data?.err?.message || ''
214 if (!message.includes('from xs param')) return
215
216 this.player.dispose()
217 this.playerHTML.removePlayerElement()
218 this.playerHTML.displayError('This video is not available because the remote instance is not responding.', translations)
219 })
627 220
628 window['videojsPlayer'] = this.player 221 window['videojsPlayer'] = this.player
629 222
630 this.buildCSS() 223 this.buildCSS()
631 224 this.buildPlayerDock(video)
632 this.buildDock(videoInfo)
633
634 this.initializeApi() 225 this.initializeApi()
635 226
636 this.removePlaceholder() 227 this.playerHTML.removePlaceholder()
637 228
638 if (this.isPlaylistEmbed()) { 229 if (this.isPlaylistEmbed()) {
639 await this.buildPlaylistManager() 230 await this.buildPlayerPlaylistUpnext()
640 231
641 this.player.playlist().updateSelected() 232 this.player.playlist().updateSelected()
642 233
643 this.player.on('stopped', () => { 234 this.player.on('stopped', () => {
644 this.playNextVideo() 235 this.playNextPlaylistVideo()
645 }) 236 })
646 } 237 }
647 238
648 this.pluginsManager.runHook('action:embed.player.loaded', undefined, { player: this.player, videojs, video: videoInfo }) 239 this.peertubePlugin.getPluginsManager().runHook('action:embed.player.loaded', undefined, { player: this.player, videojs, video })
649 }
650
651 private async initCore () {
652 if (this.userTokens) this.setHeadersFromTokens()
653
654 this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language)
655 this.PeertubePlayerManagerModulePromise = import('../../assets/player/peertube-player-manager')
656 240
657 let videoId: string 241 if (video.isLive) {
242 this.liveManager.displayInfoAndListenForChanges({
243 video,
244 translations,
245 onPublishedVideo: () => {
246 this.liveManager.stopListeningForChanges(video)
247 this.loadVideoAndBuildPlayer(video.uuid)
248 }
249 })
250 }
251 }
658 252
659 if (this.isPlaylistEmbed()) { 253 private resetPlayerElement () {
660 const playlistId = this.getResourceId() 254 let alreadyHadPlayer = false
661 const res = await this.loadPlaylist(playlistId)
662 if (!res) return undefined
663 255
664 this.playlist = await res.playlistResponse.json() 256 if (this.player) {
257 this.player.dispose()
258 alreadyHadPlayer = true
259 }
665 260
666 const playlistElementResult = await res.videosResponse.json() 261 const playerElement = document.createElement('video')
667 this.playlistElements = await this.loadAllPlaylistVideos(playlistId, playlistElementResult) 262 playerElement.className = 'video-js vjs-peertube-skin'
263 playerElement.setAttribute('playsinline', 'true')
668 264
669 const params = new URL(window.location.toString()).searchParams 265 this.playerHTML.setPlayerElement(playerElement)
670 const playlistPositionParam = this.getParamString(params, 'playlistPosition') 266 this.playerHTML.addPlayerElementToDOM()
671
672 let position = 1
673
674 if (playlistPositionParam) {
675 position = parseInt(playlistPositionParam + '', 10)
676 }
677
678 this.currentPlaylistElement = this.playlistElements.find(e => e.position === position)
679 if (!this.currentPlaylistElement || !this.currentPlaylistElement.video) {
680 console.error('Current playlist element is not valid.', this.currentPlaylistElement)
681 this.currentPlaylistElement = this.getNextPlaylistElement()
682 }
683
684 if (!this.currentPlaylistElement) {
685 console.error('This playlist does not have any valid element.')
686 const serverTranslations = await this.translationsPromise
687 this.playlistFetchError(serverTranslations)
688 return
689 }
690
691 videoId = this.currentPlaylistElement.video.uuid
692 } else {
693 videoId = this.getResourceId()
694 }
695 267
696 return this.loadVideoAndBuildPlayer(videoId) 268 return alreadyHadPlayer
697 } 269 }
698 270
699 private handleError (err: Error, translations?: { [ id: string ]: string }) { 271 private async buildPlayerPlaylistUpnext () {
700 if (err.message.includes('from xs param')) { 272 const translations = await this.translationsPromise
701 this.player.dispose() 273
702 this.playerElement = null 274 this.player.upnext({
703 this.displayError('This video is not available because the remote instance is not responding.', translations) 275 timeout: 10000, // 10s
704 } 276 headText: peertubeTranslate('Up Next', translations),
277 cancelText: peertubeTranslate('Cancel', translations),
278 suspendedText: peertubeTranslate('Autoplay is suspended', translations),
279 getTitle: () => this.playlistTracker.nextVideoTitle(),
280 next: () => this.playNextPlaylistVideo(),
281 condition: () => !!this.playlistTracker.getNextPlaylistElement(),
282 suspended: () => false
283 })
705 } 284 }
706 285
707 private buildDock (videoInfo: VideoDetails) { 286 private buildPlayerDock (videoInfo: VideoDetails) {
708 if (!this.controls) return 287 if (!this.playerManagerOptions.hasControls()) return
709 288
710 // On webtorrent fallback, player may have been disposed 289 // On webtorrent fallback, player may have been disposed
711 if (!this.player.player_) return 290 if (!this.player.player_) return
712 291
713 const title = this.title ? videoInfo.name : undefined 292 const title = this.playerManagerOptions.hasTitle()
714 const description = this.warningTitle && this.p2pEnabled 293 ? videoInfo.name
294 : undefined
295
296 const description = this.playerManagerOptions.hasWarningTitle() && this.playerManagerOptions.hasP2PEnabled()
715 ? '<span class="text">' + peertubeTranslate('Watching this video may reveal your IP address to others.') + '</span>' 297 ? '<span class="text">' + peertubeTranslate('Watching this video may reveal your IP address to others.') + '</span>'
716 : undefined 298 : undefined
717 299
300 if (!title && !description) return
301
718 const availableAvatars = videoInfo.channel.avatars.filter(a => a.width < 50) 302 const availableAvatars = videoInfo.channel.avatars.filter(a => a.width < 50)
719 const avatar = availableAvatars.length !== 0 303 const avatar = availableAvatars.length !== 0
720 ? availableAvatars[0] 304 ? availableAvatars[0]
721 : undefined 305 : undefined
722 306
723 if (title || description) { 307 this.player.peertubeDock({
724 this.player.peertubeDock({ 308 title,
725 title, 309 description,
726 description, 310 avatarUrl: title && avatar
727 avatarUrl: title && avatar 311 ? avatar.path
728 ? avatar.path 312 : undefined
729 : undefined 313 })
730 })
731 }
732 } 314 }
733 315
734 private buildCSS () { 316 private buildCSS () {
735 const body = document.getElementById('custom-css') 317 const body = document.getElementById('custom-css')
736 318
737 if (this.bigPlayBackgroundColor) { 319 if (this.playerManagerOptions.hasBigPlayBackgroundColor()) {
738 body.style.setProperty('--embedBigPlayBackgroundColor', this.bigPlayBackgroundColor) 320 body.style.setProperty('--embedBigPlayBackgroundColor', this.playerManagerOptions.getBigPlayBackgroundColor())
739 } 321 }
740 322
741 if (this.foregroundColor) { 323 if (this.playerManagerOptions.hasForegroundColor()) {
742 body.style.setProperty('--embedForegroundColor', this.foregroundColor) 324 body.style.setProperty('--embedForegroundColor', this.playerManagerOptions.getForegroundColor())
743 } 325 }
744 } 326 }
745 327
746 private async buildCaptions (serverTranslations: any, captionsResponse: Response): Promise<VideoJSCaption[]> { 328 // ---------------------------------------------------------------------------
747 if (captionsResponse.ok) {
748 const { data } = await captionsResponse.json()
749
750 return data.map((c: VideoCaption) => ({
751 label: peertubeTranslate(c.language.label, serverTranslations),
752 language: c.language.id,
753 src: window.location.origin + c.captionPath
754 }))
755 }
756
757 return []
758 }
759
760 private buildPlaceholder (video: VideoDetails) {
761 const placeholder = this.getPlaceholderElement()
762
763 const url = window.location.origin + video.previewPath
764 placeholder.style.backgroundImage = `url("${url}")`
765 placeholder.style.display = 'block'
766 }
767
768 private removePlaceholder () {
769 const placeholder = this.getPlaceholderElement()
770 placeholder.style.display = 'none'
771 }
772
773 private getPlaceholderElement () {
774 return document.getElementById('placeholder-preview')
775 }
776
777 private getHeaderTokenValue () {
778 return `${this.userTokens.tokenType} ${this.userTokens.accessToken}`
779 }
780
781 private setHeadersFromTokens () {
782 this.headers.set('Authorization', this.getHeaderTokenValue())
783 }
784
785 private removeTokensFromHeaders () {
786 this.headers.delete('Authorization')
787 }
788 329
789 private getResourceId () { 330 private getResourceId () {
790 const urlParts = window.location.pathname.split('/') 331 const urlParts = window.location.pathname.split('/')
@@ -794,69 +335,6 @@ export class PeerTubeEmbed {
794 private isPlaylistEmbed () { 335 private isPlaylistEmbed () {
795 return window.location.pathname.split('/')[1] === 'video-playlists' 336 return window.location.pathname.split('/')[1] === 'video-playlists'
796 } 337 }
797
798 private loadPlugins (translations?: { [ id: string ]: string }) {
799 this.pluginsManager = new PluginsManager({
800 peertubeHelpersFactory: pluginInfo => this.buildPeerTubeHelpers(pluginInfo, translations)
801 })
802
803 this.pluginsManager.loadPluginsList(this.config)
804
805 return this.pluginsManager.ensurePluginsAreLoaded('embed')
806 }
807
808 private buildPeerTubeHelpers (pluginInfo: PluginInfo, translations?: { [ id: string ]: string }): RegisterClientHelpers {
809 const unimplemented = () => {
810 throw new Error('This helper is not implemented in embed.')
811 }
812
813 return {
814 getBaseStaticRoute: unimplemented,
815 getBaseRouterRoute: unimplemented,
816 getBasePluginClientPath: unimplemented,
817
818 getSettings: () => {
819 const url = this.getPluginUrl() + '/' + pluginInfo.plugin.npmName + '/public-settings'
820
821 return this.refreshFetch(url, { headers: this.headers })
822 .then(res => res.json())
823 .then((obj: PublicServerSetting) => obj.publicSettings)
824 },
825
826 isLoggedIn: () => !!this.userTokens,
827 getAuthHeader: () => {
828 if (!this.userTokens) return undefined
829
830 return { Authorization: this.getHeaderTokenValue() }
831 },
832
833 notifier: {
834 info: unimplemented,
835 error: unimplemented,
836 success: unimplemented
837 },
838
839 showModal: unimplemented,
840
841 getServerConfig: unimplemented,
842
843 markdownRenderer: {
844 textMarkdownToHTML: unimplemented,
845 enhancedMarkdownToHTML: unimplemented
846 },
847
848 translate: (value: string) => Promise.resolve(peertubeTranslate(value, translations))
849 }
850 }
851
852 private isP2PEnabled (video: Video) {
853 const userP2PEnabled = getBoolOrDefault(
854 peertubeLocalStorage.getItem(UserLocalStorageKeys.P2P_ENABLED),
855 this.config.defaults.p2p.embed.enabled
856 )
857
858 return isP2PEnabled(video, this.config, userP2PEnabled)
859 }
860} 338}
861 339
862PeerTubeEmbed.main() 340PeerTubeEmbed.main()
diff --git a/client/src/standalone/videos/shared/auth-http.ts b/client/src/standalone/videos/shared/auth-http.ts
new file mode 100644
index 000000000..0356ab8a6
--- /dev/null
+++ b/client/src/standalone/videos/shared/auth-http.ts
@@ -0,0 +1,105 @@
1import { HttpStatusCode, OAuth2ErrorCode, UserRefreshToken } from '../../../../../shared/models'
2import { objectToUrlEncoded, UserTokens } from '../../../root-helpers'
3import { peertubeLocalStorage } from '../../../root-helpers/peertube-web-storage'
4
5export class AuthHTTP {
6 private readonly LOCAL_STORAGE_OAUTH_CLIENT_KEYS = {
7 CLIENT_ID: 'client_id',
8 CLIENT_SECRET: 'client_secret'
9 }
10
11 private userTokens: UserTokens
12
13 private headers = new Headers()
14
15 constructor () {
16 this.userTokens = UserTokens.getUserTokens(peertubeLocalStorage)
17
18 if (this.userTokens) this.setHeadersFromTokens()
19 }
20
21 fetch (url: string, { optionalAuth }: { optionalAuth: boolean }) {
22 const refreshFetchOptions = optionalAuth
23 ? { headers: this.headers }
24 : {}
25
26 return this.refreshFetch(url.toString(), refreshFetchOptions)
27 }
28
29 getHeaderTokenValue () {
30 return `${this.userTokens.tokenType} ${this.userTokens.accessToken}`
31 }
32
33 isLoggedIn () {
34 return !!this.userTokens
35 }
36
37 private refreshFetch (url: string, options?: RequestInit) {
38 return fetch(url, options)
39 .then((res: Response) => {
40 if (res.status !== HttpStatusCode.UNAUTHORIZED_401) return res
41
42 const refreshingTokenPromise = new Promise<void>((resolve, reject) => {
43 const clientId: string = peertubeLocalStorage.getItem(this.LOCAL_STORAGE_OAUTH_CLIENT_KEYS.CLIENT_ID)
44 const clientSecret: string = peertubeLocalStorage.getItem(this.LOCAL_STORAGE_OAUTH_CLIENT_KEYS.CLIENT_SECRET)
45
46 const headers = new Headers()
47 headers.set('Content-Type', 'application/x-www-form-urlencoded')
48
49 const data = {
50 refresh_token: this.userTokens.refreshToken,
51 client_id: clientId,
52 client_secret: clientSecret,
53 response_type: 'code',
54 grant_type: 'refresh_token'
55 }
56
57 fetch('/api/v1/users/token', {
58 headers,
59 method: 'POST',
60 body: objectToUrlEncoded(data)
61 }).then(res => {
62 if (res.status === HttpStatusCode.UNAUTHORIZED_401) return undefined
63
64 return res.json()
65 }).then((obj: UserRefreshToken & { code?: OAuth2ErrorCode }) => {
66 if (!obj || obj.code === OAuth2ErrorCode.INVALID_GRANT) {
67 UserTokens.flushLocalStorage(peertubeLocalStorage)
68 this.removeTokensFromHeaders()
69
70 return resolve()
71 }
72
73 this.userTokens.accessToken = obj.access_token
74 this.userTokens.refreshToken = obj.refresh_token
75 UserTokens.saveToLocalStorage(peertubeLocalStorage, this.userTokens)
76
77 this.setHeadersFromTokens()
78
79 resolve()
80 }).catch((refreshTokenError: any) => {
81 reject(refreshTokenError)
82 })
83 })
84
85 return refreshingTokenPromise
86 .catch(() => {
87 UserTokens.flushLocalStorage(peertubeLocalStorage)
88
89 this.removeTokensFromHeaders()
90 }).then(() => fetch(url, {
91 ...options,
92
93 headers: this.headers
94 }))
95 })
96 }
97
98 private setHeadersFromTokens () {
99 this.headers.set('Authorization', this.getHeaderTokenValue())
100 }
101
102 private removeTokensFromHeaders () {
103 this.headers.delete('Authorization')
104 }
105}
diff --git a/client/src/standalone/videos/shared/index.ts b/client/src/standalone/videos/shared/index.ts
new file mode 100644
index 000000000..928b8e270
--- /dev/null
+++ b/client/src/standalone/videos/shared/index.ts
@@ -0,0 +1,9 @@
1export * from './auth-http'
2export * from './peertube-plugin'
3export * from './live-manager'
4export * from './player-html'
5export * from './player-manager-options'
6export * from './playlist-fetcher'
7export * from './playlist-tracker'
8export * from './translations'
9export * from './video-fetcher'
diff --git a/client/src/standalone/videos/shared/live-manager.ts b/client/src/standalone/videos/shared/live-manager.ts
new file mode 100644
index 000000000..422d39793
--- /dev/null
+++ b/client/src/standalone/videos/shared/live-manager.ts
@@ -0,0 +1,69 @@
1import { Socket } from 'socket.io-client'
2import { LiveVideoEventPayload, VideoDetails, VideoState } from '../../../../../shared/models'
3import { PlayerHTML } from './player-html'
4import { Translations } from './translations'
5
6export class LiveManager {
7 private liveSocket: Socket
8
9 constructor (
10 private readonly playerHTML: PlayerHTML
11 ) {
12
13 }
14
15 async displayInfoAndListenForChanges (options: {
16 video: VideoDetails
17 translations: Translations
18 onPublishedVideo: () => any
19 }) {
20 const { video, onPublishedVideo } = options
21
22 this.displayAppropriateInfo(options)
23
24 if (!this.liveSocket) {
25 const io = (await import('socket.io-client')).io
26 this.liveSocket = io(window.location.origin + '/live-videos')
27 }
28
29 this.liveSocket.on('state-change', (payload: LiveVideoEventPayload) => {
30 if (payload.state === VideoState.PUBLISHED) {
31 this.playerHTML.removeInformation()
32 onPublishedVideo()
33 return
34 }
35 })
36
37 this.liveSocket.emit('subscribe', { videoId: video.id })
38 }
39
40 stopListeningForChanges (video: VideoDetails) {
41 this.liveSocket.emit('unsubscribe', { videoId: video.id })
42 }
43
44 private displayAppropriateInfo (options: {
45 video: VideoDetails
46 translations: Translations
47 }) {
48 const { video, translations } = options
49
50 if (video.state.id === VideoState.WAITING_FOR_LIVE) {
51 this.displayWaitingForLiveInfo(translations)
52 return
53 }
54
55 if (video.state.id === VideoState.LIVE_ENDED) {
56 this.displayEndedLiveInfo(translations)
57 return
58 }
59 }
60
61 private displayWaitingForLiveInfo (translations: Translations) {
62 this.playerHTML.displayInformation('This live has not started yet.', translations)
63 }
64
65 private displayEndedLiveInfo (translations: Translations) {
66 this.playerHTML.displayInformation('This live has ended.', translations)
67
68 }
69}
diff --git a/client/src/standalone/videos/shared/peertube-plugin.ts b/client/src/standalone/videos/shared/peertube-plugin.ts
new file mode 100644
index 000000000..968854ce8
--- /dev/null
+++ b/client/src/standalone/videos/shared/peertube-plugin.ts
@@ -0,0 +1,85 @@
1import { peertubeTranslate } from '../../../../../shared/core-utils/i18n'
2import { HTMLServerConfig, PublicServerSetting } from '../../../../../shared/models'
3import { PluginInfo, PluginsManager } from '../../../root-helpers'
4import { RegisterClientHelpers } from '../../../types'
5import { AuthHTTP } from './auth-http'
6import { Translations } from './translations'
7
8export class PeerTubePlugin {
9
10 private pluginsManager: PluginsManager
11
12 constructor (private readonly http: AuthHTTP) {
13
14 }
15
16 loadPlugins (config: HTMLServerConfig, translations?: Translations) {
17 this.pluginsManager = new PluginsManager({
18 peertubeHelpersFactory: pluginInfo => this.buildPeerTubeHelpers({
19 pluginInfo,
20 translations
21 })
22 })
23
24 this.pluginsManager.loadPluginsList(config)
25
26 return this.pluginsManager.ensurePluginsAreLoaded('embed')
27 }
28
29 getPluginsManager () {
30 return this.pluginsManager
31 }
32
33 private buildPeerTubeHelpers (options: {
34 pluginInfo: PluginInfo
35 translations?: Translations
36 }): RegisterClientHelpers {
37 const { pluginInfo, translations } = options
38
39 const unimplemented = () => {
40 throw new Error('This helper is not implemented in embed.')
41 }
42
43 return {
44 getBaseStaticRoute: unimplemented,
45 getBaseRouterRoute: unimplemented,
46 getBasePluginClientPath: unimplemented,
47
48 getSettings: () => {
49 const url = this.getPluginUrl() + '/' + pluginInfo.plugin.npmName + '/public-settings'
50
51 return this.http.fetch(url, { optionalAuth: true })
52 .then(res => res.json())
53 .then((obj: PublicServerSetting) => obj.publicSettings)
54 },
55
56 isLoggedIn: () => this.http.isLoggedIn(),
57 getAuthHeader: () => {
58 if (!this.http.isLoggedIn()) return undefined
59
60 return { Authorization: this.http.getHeaderTokenValue() }
61 },
62
63 notifier: {
64 info: unimplemented,
65 error: unimplemented,
66 success: unimplemented
67 },
68
69 showModal: unimplemented,
70
71 getServerConfig: unimplemented,
72
73 markdownRenderer: {
74 textMarkdownToHTML: unimplemented,
75 enhancedMarkdownToHTML: unimplemented
76 },
77
78 translate: (value: string) => Promise.resolve(peertubeTranslate(value, translations))
79 }
80 }
81
82 private getPluginUrl () {
83 return window.location.origin + '/api/v1/plugins'
84 }
85}
diff --git a/client/src/standalone/videos/shared/player-html.ts b/client/src/standalone/videos/shared/player-html.ts
new file mode 100644
index 000000000..eb6324ac7
--- /dev/null
+++ b/client/src/standalone/videos/shared/player-html.ts
@@ -0,0 +1,91 @@
1import { peertubeTranslate } from '../../../../../shared/core-utils/i18n'
2import { VideoDetails } from '../../../../../shared/models'
3import { Translations } from './translations'
4
5export class PlayerHTML {
6 private readonly wrapperElement: HTMLElement
7
8 private playerElement: HTMLVideoElement
9 private informationElement: HTMLDivElement
10
11 constructor (private readonly videoWrapperId: string) {
12 this.wrapperElement = document.getElementById(this.videoWrapperId)
13 }
14
15 getPlayerElement () {
16 return this.playerElement
17 }
18
19 setPlayerElement (playerElement: HTMLVideoElement) {
20 this.playerElement = playerElement
21 }
22
23 removePlayerElement () {
24 this.playerElement = null
25 }
26
27 addPlayerElementToDOM () {
28 this.wrapperElement.appendChild(this.playerElement)
29 }
30
31 displayError (text: string, translations: Translations) {
32 console.error(text)
33
34 // Remove video element
35 if (this.playerElement) {
36 this.removeElement(this.playerElement)
37 this.playerElement = undefined
38 }
39
40 const translatedText = peertubeTranslate(text, translations)
41 const translatedSorry = peertubeTranslate('Sorry', translations)
42
43 document.title = translatedSorry + ' - ' + translatedText
44
45 const errorBlock = document.getElementById('error-block')
46 errorBlock.style.display = 'flex'
47
48 const errorTitle = document.getElementById('error-title')
49 errorTitle.innerHTML = peertubeTranslate('Sorry', translations)
50
51 const errorText = document.getElementById('error-content')
52 errorText.innerHTML = translatedText
53
54 this.wrapperElement.style.display = 'none'
55 }
56
57 buildPlaceholder (video: VideoDetails) {
58 const placeholder = this.getPlaceholderElement()
59
60 const url = window.location.origin + video.previewPath
61 placeholder.style.backgroundImage = `url("${url}")`
62 placeholder.style.display = 'block'
63 }
64
65 removePlaceholder () {
66 const placeholder = this.getPlaceholderElement()
67 placeholder.style.display = 'none'
68 }
69
70 displayInformation (text: string, translations: Translations) {
71 if (this.informationElement) this.removeInformation()
72
73 this.informationElement = document.createElement('div')
74 this.informationElement.className = 'player-information'
75 this.informationElement.innerText = peertubeTranslate(text, translations)
76
77 document.body.appendChild(this.informationElement)
78 }
79
80 removeInformation () {
81 this.removeElement(this.informationElement)
82 }
83
84 private getPlaceholderElement () {
85 return document.getElementById('placeholder-preview')
86 }
87
88 private removeElement (element: HTMLElement) {
89 element.parentElement.removeChild(element)
90 }
91}
diff --git a/client/src/standalone/videos/shared/player-manager-options.ts b/client/src/standalone/videos/shared/player-manager-options.ts
new file mode 100644
index 000000000..144d74319
--- /dev/null
+++ b/client/src/standalone/videos/shared/player-manager-options.ts
@@ -0,0 +1,323 @@
1import { peertubeTranslate } from '../../../../../shared/core-utils/i18n'
2import {
3 HTMLServerConfig,
4 LiveVideo,
5 Video,
6 VideoCaption,
7 VideoDetails,
8 VideoPlaylistElement,
9 VideoStreamingPlaylistType
10} from '../../../../../shared/models'
11import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode, VideoJSCaption } from '../../../assets/player'
12import {
13 getBoolOrDefault,
14 getParamString,
15 getParamToggle,
16 isP2PEnabled,
17 peertubeLocalStorage,
18 UserLocalStorageKeys
19} from '../../../root-helpers'
20import { PeerTubePlugin } from './peertube-plugin'
21import { PlayerHTML } from './player-html'
22import { PlaylistTracker } from './playlist-tracker'
23import { Translations } from './translations'
24import { VideoFetcher } from './video-fetcher'
25
26export class PlayerManagerOptions {
27 private autoplay: boolean
28
29 private controls: boolean
30 private controlBar: boolean
31
32 private muted: boolean
33 private loop: boolean
34 private subtitle: string
35 private enableApi = false
36 private startTime: number | string = 0
37 private stopTime: number | string
38
39 private title: boolean
40 private warningTitle: boolean
41 private peertubeLink: boolean
42 private p2pEnabled: boolean
43 private bigPlayBackgroundColor: string
44 private foregroundColor: string
45
46 private mode: PlayerMode
47 private scope = 'peertube'
48
49 constructor (
50 private readonly playerHTML: PlayerHTML,
51 private readonly videoFetcher: VideoFetcher,
52 private readonly peertubePlugin: PeerTubePlugin
53 ) {}
54
55 hasAPIEnabled () {
56 return this.enableApi
57 }
58
59 hasAutoplay () {
60 return this.autoplay
61 }
62
63 hasControls () {
64 return this.controls
65 }
66
67 hasTitle () {
68 return this.title
69 }
70
71 hasWarningTitle () {
72 return this.warningTitle
73 }
74
75 hasP2PEnabled () {
76 return !!this.p2pEnabled
77 }
78
79 hasBigPlayBackgroundColor () {
80 return !!this.bigPlayBackgroundColor
81 }
82
83 getBigPlayBackgroundColor () {
84 return this.bigPlayBackgroundColor
85 }
86
87 hasForegroundColor () {
88 return !!this.foregroundColor
89 }
90
91 getForegroundColor () {
92 return this.foregroundColor
93 }
94
95 getMode () {
96 return this.mode
97 }
98
99 getScope () {
100 return this.scope
101 }
102
103 // ---------------------------------------------------------------------------
104
105 loadParams (config: HTMLServerConfig, video: VideoDetails) {
106 try {
107 const params = new URL(window.location.toString()).searchParams
108
109 this.autoplay = getParamToggle(params, 'autoplay', false)
110
111 this.controls = getParamToggle(params, 'controls', true)
112 this.controlBar = getParamToggle(params, 'controlBar', true)
113
114 this.muted = getParamToggle(params, 'muted', undefined)
115 this.loop = getParamToggle(params, 'loop', false)
116 this.title = getParamToggle(params, 'title', true)
117 this.enableApi = getParamToggle(params, 'api', this.enableApi)
118 this.warningTitle = getParamToggle(params, 'warningTitle', true)
119 this.peertubeLink = getParamToggle(params, 'peertubeLink', true)
120 this.p2pEnabled = getParamToggle(params, 'p2p', this.isP2PEnabled(config, video))
121
122 this.scope = getParamString(params, 'scope', this.scope)
123 this.subtitle = getParamString(params, 'subtitle')
124 this.startTime = getParamString(params, 'start')
125 this.stopTime = getParamString(params, 'stop')
126
127 this.bigPlayBackgroundColor = getParamString(params, 'bigPlayBackgroundColor')
128 this.foregroundColor = getParamString(params, 'foregroundColor')
129
130 const modeParam = getParamString(params, 'mode')
131
132 if (modeParam) {
133 if (modeParam === 'p2p-media-loader') this.mode = 'p2p-media-loader'
134 else this.mode = 'webtorrent'
135 } else {
136 if (Array.isArray(video.streamingPlaylists) && video.streamingPlaylists.length !== 0) this.mode = 'p2p-media-loader'
137 else this.mode = 'webtorrent'
138 }
139 } catch (err) {
140 console.error('Cannot get params from URL.', err)
141 }
142 }
143
144 // ---------------------------------------------------------------------------
145
146 async getPlayerOptions (options: {
147 video: VideoDetails
148 captionsResponse: Response
149 live?: LiveVideo
150
151 alreadyHadPlayer: boolean
152
153 translations: Translations
154
155 playlistTracker?: PlaylistTracker
156 playNextPlaylistVideo?: () => any
157 playPreviousPlaylistVideo?: () => any
158 onVideoUpdate?: (uuid: string) => any
159 }) {
160 const {
161 video,
162 captionsResponse,
163 alreadyHadPlayer,
164 translations,
165 playlistTracker,
166 live
167 } = options
168
169 const videoCaptions = await this.buildCaptions(captionsResponse, translations)
170
171 const playerOptions: PeertubePlayerManagerOptions = {
172 common: {
173 // Autoplay in playlist mode
174 autoplay: alreadyHadPlayer ? true : this.autoplay,
175
176 controls: this.controls,
177 controlBar: this.controlBar,
178
179 muted: this.muted,
180 loop: this.loop,
181
182 p2pEnabled: this.p2pEnabled,
183
184 captions: videoCaptions.length !== 0,
185 subtitle: this.subtitle,
186
187 startTime: playlistTracker
188 ? playlistTracker.getCurrentElement().startTimestamp
189 : this.startTime,
190 stopTime: playlistTracker
191 ? playlistTracker.getCurrentElement().stopTimestamp
192 : this.stopTime,
193
194 videoCaptions,
195 inactivityTimeout: 2500,
196 videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid),
197
198 videoShortUUID: video.shortUUID,
199 videoUUID: video.uuid,
200
201 playerElement: this.playerHTML.getPlayerElement(),
202 onPlayerElementChange: (element: HTMLVideoElement) => {
203 this.playerHTML.setPlayerElement(element)
204 },
205
206 videoDuration: video.duration,
207 enableHotkeys: true,
208 peertubeLink: this.peertubeLink,
209 poster: window.location.origin + video.previewPath,
210 theaterButton: false,
211
212 serverUrl: window.location.origin,
213 language: navigator.language,
214 embedUrl: window.location.origin + video.embedPath,
215 embedTitle: video.name,
216
217 errorNotifier: () => {
218 // Empty, we don't have a notifier in the embed
219 },
220
221 ...this.buildLiveOptions(video, live),
222
223 ...this.buildPlaylistOptions(options)
224 },
225
226 webtorrent: {
227 videoFiles: video.files
228 },
229
230 ...this.buildP2PMediaLoaderOptions(video),
231
232 pluginsManager: this.peertubePlugin.getPluginsManager()
233 }
234
235 return playerOptions
236 }
237
238 private buildLiveOptions (video: VideoDetails, live: LiveVideo) {
239 if (!video.isLive) return { isLive: false }
240
241 return {
242 isLive: true,
243 liveOptions: {
244 latencyMode: live.latencyMode
245 }
246 }
247 }
248
249 private buildPlaylistOptions (options: {
250 playlistTracker?: PlaylistTracker
251 playNextPlaylistVideo?: () => any
252 playPreviousPlaylistVideo?: () => any
253 onVideoUpdate?: (uuid: string) => any
254 }) {
255 const { playlistTracker, playNextPlaylistVideo, playPreviousPlaylistVideo, onVideoUpdate } = options
256
257 if (!playlistTracker) return {}
258
259 return {
260 playlist: {
261 elements: playlistTracker.getPlaylistElements(),
262 playlist: playlistTracker.getPlaylist(),
263
264 getCurrentPosition: () => playlistTracker.getCurrentPosition(),
265
266 onItemClicked: (videoPlaylistElement: VideoPlaylistElement) => {
267 playlistTracker.setCurrentElement(videoPlaylistElement)
268
269 onVideoUpdate(videoPlaylistElement.video.uuid)
270 }
271 },
272
273 nextVideo: () => playNextPlaylistVideo(),
274 hasNextVideo: () => playlistTracker.hasNextPlaylistElement(),
275
276 previousVideo: () => playPreviousPlaylistVideo(),
277 hasPreviousVideo: () => playlistTracker.hasPreviousPlaylistElement()
278 }
279 }
280
281 private buildP2PMediaLoaderOptions (video: VideoDetails) {
282 if (this.mode !== 'p2p-media-loader') return {}
283
284 const hlsPlaylist = video.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
285
286 return {
287 p2pMediaLoader: {
288 playlistUrl: hlsPlaylist.playlistUrl,
289 segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
290 redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
291 trackerAnnounce: video.trackerUrls,
292 videoFiles: hlsPlaylist.files
293 } as P2PMediaLoaderOptions
294 }
295 }
296
297 // ---------------------------------------------------------------------------
298
299 private async buildCaptions (captionsResponse: Response, translations: Translations): Promise<VideoJSCaption[]> {
300 if (captionsResponse.ok) {
301 const { data } = await captionsResponse.json()
302
303 return data.map((c: VideoCaption) => ({
304 label: peertubeTranslate(c.language.label, translations),
305 language: c.language.id,
306 src: window.location.origin + c.captionPath
307 }))
308 }
309
310 return []
311 }
312
313 // ---------------------------------------------------------------------------
314
315 private isP2PEnabled (config: HTMLServerConfig, video: Video) {
316 const userP2PEnabled = getBoolOrDefault(
317 peertubeLocalStorage.getItem(UserLocalStorageKeys.P2P_ENABLED),
318 config.defaults.p2p.embed.enabled
319 )
320
321 return isP2PEnabled(video, config, userP2PEnabled)
322 }
323}
diff --git a/client/src/standalone/videos/shared/playlist-fetcher.ts b/client/src/standalone/videos/shared/playlist-fetcher.ts
new file mode 100644
index 000000000..a7e72c177
--- /dev/null
+++ b/client/src/standalone/videos/shared/playlist-fetcher.ts
@@ -0,0 +1,72 @@
1import { HttpStatusCode, ResultList, VideoPlaylistElement } from '../../../../../shared/models'
2import { AuthHTTP } from './auth-http'
3
4export class PlaylistFetcher {
5
6 constructor (private readonly http: AuthHTTP) {
7
8 }
9
10 async loadPlaylist (playlistId: string) {
11 const playlistPromise = this.loadPlaylistInfo(playlistId)
12 const playlistElementsPromise = this.loadPlaylistElements(playlistId)
13
14 let playlistResponse: Response
15 let isResponseOk: boolean
16
17 try {
18 playlistResponse = await playlistPromise
19 isResponseOk = playlistResponse.status === HttpStatusCode.OK_200
20 } catch (err) {
21 console.error(err)
22 isResponseOk = false
23 }
24
25 if (!isResponseOk) {
26 if (playlistResponse?.status === HttpStatusCode.NOT_FOUND_404) {
27 throw new Error('This playlist does not exist.')
28 }
29
30 throw new Error('We cannot fetch the playlist. Please try again later.')
31 }
32
33 return { playlistResponse, videosResponse: await playlistElementsPromise }
34 }
35
36 async loadAllPlaylistVideos (playlistId: string, baseResult: ResultList<VideoPlaylistElement>) {
37 let elements = baseResult.data
38 let total = baseResult.total
39 let i = 0
40
41 while (total > elements.length && i < 10) {
42 const result = await this.loadPlaylistElements(playlistId, elements.length)
43
44 const json = await result.json()
45 total = json.total
46
47 elements = elements.concat(json.data)
48 i++
49 }
50
51 if (i === 10) {
52 console.error('Cannot fetch all playlists elements, there are too many!')
53 }
54
55 return elements
56 }
57
58 private loadPlaylistInfo (playlistId: string): Promise<Response> {
59 return this.http.fetch(this.getPlaylistUrl(playlistId), { optionalAuth: true })
60 }
61
62 private loadPlaylistElements (playlistId: string, start = 0): Promise<Response> {
63 const url = new URL(this.getPlaylistUrl(playlistId) + '/videos')
64 url.search = new URLSearchParams({ start: '' + start, count: '100' }).toString()
65
66 return this.http.fetch(url.toString(), { optionalAuth: true })
67 }
68
69 private getPlaylistUrl (id: string) {
70 return window.location.origin + '/api/v1/video-playlists/' + id
71 }
72}
diff --git a/client/src/standalone/videos/shared/playlist-tracker.ts b/client/src/standalone/videos/shared/playlist-tracker.ts
new file mode 100644
index 000000000..75d10b4e2
--- /dev/null
+++ b/client/src/standalone/videos/shared/playlist-tracker.ts
@@ -0,0 +1,93 @@
1import { VideoPlaylist, VideoPlaylistElement } from '../../../../../shared/models'
2
3export class PlaylistTracker {
4 private currentPlaylistElement: VideoPlaylistElement
5
6 constructor (
7 private readonly playlist: VideoPlaylist,
8 private readonly playlistElements: VideoPlaylistElement[]
9 ) {
10
11 }
12
13 getPlaylist () {
14 return this.playlist
15 }
16
17 getPlaylistElements () {
18 return this.playlistElements
19 }
20
21 hasNextPlaylistElement (position?: number) {
22 return !!this.getNextPlaylistElement(position)
23 }
24
25 getNextPlaylistElement (position?: number): VideoPlaylistElement {
26 if (!position) position = this.currentPlaylistElement.position + 1
27
28 if (position > this.playlist.videosLength) {
29 return undefined
30 }
31
32 const next = this.playlistElements.find(e => e.position === position)
33
34 if (!next || !next.video) {
35 return this.getNextPlaylistElement(position + 1)
36 }
37
38 return next
39 }
40
41 hasPreviousPlaylistElement (position?: number) {
42 return !!this.getPreviousPlaylistElement(position)
43 }
44
45 getPreviousPlaylistElement (position?: number): VideoPlaylistElement {
46 if (!position) position = this.currentPlaylistElement.position - 1
47
48 if (position < 1) {
49 return undefined
50 }
51
52 const prev = this.playlistElements.find(e => e.position === position)
53
54 if (!prev || !prev.video) {
55 return this.getNextPlaylistElement(position - 1)
56 }
57
58 return prev
59 }
60
61 nextVideoTitle () {
62 const next = this.getNextPlaylistElement()
63 if (!next) return ''
64
65 return next.video.name
66 }
67
68 setPosition (position: number) {
69 this.currentPlaylistElement = this.playlistElements.find(e => e.position === position)
70 if (!this.currentPlaylistElement || !this.currentPlaylistElement.video) {
71 console.error('Current playlist element is not valid.', this.currentPlaylistElement)
72 this.currentPlaylistElement = this.getNextPlaylistElement()
73 }
74
75 if (!this.currentPlaylistElement) {
76 throw new Error('This playlist does not have any valid element')
77 }
78 }
79
80 setCurrentElement (playlistElement: VideoPlaylistElement) {
81 this.currentPlaylistElement = playlistElement
82 }
83
84 getCurrentElement () {
85 return this.currentPlaylistElement
86 }
87
88 getCurrentPosition () {
89 if (!this.currentPlaylistElement) return -1
90
91 return this.currentPlaylistElement.position
92 }
93}
diff --git a/client/src/standalone/videos/shared/translations.ts b/client/src/standalone/videos/shared/translations.ts
new file mode 100644
index 000000000..146732495
--- /dev/null
+++ b/client/src/standalone/videos/shared/translations.ts
@@ -0,0 +1,5 @@
1type Translations = { [ id: string ]: string }
2
3export {
4 Translations
5}
diff --git a/client/src/standalone/videos/shared/video-fetcher.ts b/client/src/standalone/videos/shared/video-fetcher.ts
new file mode 100644
index 000000000..e78d38536
--- /dev/null
+++ b/client/src/standalone/videos/shared/video-fetcher.ts
@@ -0,0 +1,63 @@
1import { HttpStatusCode, LiveVideo, VideoDetails } from '../../../../../shared/models'
2import { AuthHTTP } from './auth-http'
3
4export class VideoFetcher {
5
6 constructor (private readonly http: AuthHTTP) {
7
8 }
9
10 async loadVideo (videoId: string) {
11 const videoPromise = this.loadVideoInfo(videoId)
12
13 let videoResponse: Response
14 let isResponseOk: boolean
15
16 try {
17 videoResponse = await videoPromise
18 isResponseOk = videoResponse.status === HttpStatusCode.OK_200
19 } catch (err) {
20 console.error(err)
21
22 isResponseOk = false
23 }
24
25 if (!isResponseOk) {
26 if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) {
27 throw new Error('This video does not exist.')
28 }
29
30 throw new Error('We cannot fetch the video. Please try again later.')
31 }
32
33 const captionsPromise = this.loadVideoCaptions(videoId)
34
35 return { captionsPromise, videoResponse }
36 }
37
38 loadVideoWithLive (video: VideoDetails) {
39 return this.http.fetch(this.getLiveUrl(video.uuid), { optionalAuth: true })
40 .then(res => res.json())
41 .then((live: LiveVideo) => ({ video, live }))
42 }
43
44 getVideoViewsUrl (videoUUID: string) {
45 return this.getVideoUrl(videoUUID) + '/views'
46 }
47
48 private loadVideoInfo (videoId: string): Promise<Response> {
49 return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true })
50 }
51
52 private loadVideoCaptions (videoId: string): Promise<Response> {
53 return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true })
54 }
55
56 private getVideoUrl (id: string) {
57 return window.location.origin + '/api/v1/videos/' + id
58 }
59
60 private getLiveUrl (videoId: string) {
61 return window.location.origin + '/api/v1/videos/live/' + videoId
62 }
63}
diff --git a/config/default.yaml b/config/default.yaml
index 54452d5e2..f7c9b620c 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -296,6 +296,16 @@ webadmin:
296 # Set this to false if you don't want to allow config edition in the web interface by instance admins 296 # Set this to false if you don't want to allow config edition in the web interface by instance admins
297 allowed: true 297 allowed: true
298 298
299# XML, Atom or JSON feeds
300feeds:
301 videos:
302 # Default number of videos displayed in feeds
303 count: 20
304
305 comments:
306 # Default number of comments displayed in feeds
307 count: 20
308
299cache: 309cache:
300 previews: 310 previews:
301 size: 500 # Max number of previews you want to cache 311 size: 500 # Max number of previews you want to cache
@@ -469,6 +479,9 @@ import:
469 # Amount of import jobs to execute in parallel 479 # Amount of import jobs to execute in parallel
470 concurrency: 1 480 concurrency: 1
471 481
482 # Set a custom video import timeout to not block import queue
483 timeout: '2 hours'
484
472 # Classic HTTP or all sites supported by youtube-dl https://rg3.github.io/youtube-dl/supportedsites.html 485 # Classic HTTP or all sites supported by youtube-dl https://rg3.github.io/youtube-dl/supportedsites.html
473 http: 486 http:
474 # We recommend to use a HTTP proxy if you enable HTTP import to prevent private URL access from this server 487 # We recommend to use a HTTP proxy if you enable HTTP import to prevent private URL access from this server
diff --git a/config/production.yaml.example b/config/production.yaml.example
index 89b7fe966..a36f4979b 100644
--- a/config/production.yaml.example
+++ b/config/production.yaml.example
@@ -292,15 +292,25 @@ webadmin:
292 # Set this to false if you don't want to allow config edition in the web interface by instance admins 292 # Set this to false if you don't want to allow config edition in the web interface by instance admins
293 allowed: true 293 allowed: true
294 294
295# XML, Atom or JSON feeds
296feeds:
297 videos:
298 # Default number of videos displayed in feeds
299 count: 20
300
301 comments:
302 # Default number of comments displayed in feeds
303 count: 20
304
295############################################################################### 305###############################################################################
296# 306#
297# From this point, all the following keys can be overridden by the web interface 307# From this point, almost all following keys can be overridden by the web interface
298# (local-production.json file). If you need to change some values, prefer to 308# (local-production.json file). If you need to change some values, prefer to
299# use the web interface because the configuration will be automatically 309# use the web interface because the configuration will be automatically
300# reloaded without any need to restart PeerTube 310# reloaded without any need to restart PeerTube
301# 311#
302# /!\ If you already have a local-production.json file, the modification of the 312# /!\ If you already have a local-production.json file, modification of some of
303# following keys will have no effect /!\ 313# the following keys will have no effect /!\
304# 314#
305############################################################################### 315###############################################################################
306 316
@@ -477,6 +487,9 @@ import:
477 # Amount of import jobs to execute in parallel 487 # Amount of import jobs to execute in parallel
478 concurrency: 1 488 concurrency: 1
479 489
490 # Set a custom video import timeout to not block import queue
491 timeout: '2 hours'
492
480 # Classic HTTP or all sites supported by youtube-dl https://rg3.github.io/youtube-dl/supportedsites.html 493 # Classic HTTP or all sites supported by youtube-dl https://rg3.github.io/youtube-dl/supportedsites.html
481 http: 494 http:
482 # We recommend to use a HTTP proxy if you enable HTTP import to prevent private URL access from this server 495 # We recommend to use a HTTP proxy if you enable HTTP import to prevent private URL access from this server
diff --git a/scripts/i18n/create-custom-files.ts b/scripts/i18n/create-custom-files.ts
index 650b0aecd..e52909c43 100755
--- a/scripts/i18n/create-custom-files.ts
+++ b/scripts/i18n/create-custom-files.ts
@@ -57,6 +57,8 @@ const playerKeys = {
57 ' off': ' off', 57 ' off': ' off',
58 'Player mode': 'Player mode', 58 'Player mode': 'Player mode',
59 'Play in loop': 'Play in loop', 59 'Play in loop': 'Play in loop',
60 'This live has not started yet.': 'This live has not started yet.',
61 'This live has ended.': 'This live has ended.',
60 'The video failed to play, will try to fast forward.': 'The video failed to play, will try to fast forward.' 62 'The video failed to play, will try to fast forward.': 'The video failed to play, will try to fast forward.'
61} 63}
62Object.assign(playerKeys, videojs) 64Object.assign(playerKeys, videojs)
diff --git a/server/controllers/api/index.ts b/server/controllers/api/index.ts
index 5f49336b1..d1d4ef765 100644
--- a/server/controllers/api/index.ts
+++ b/server/controllers/api/index.ts
@@ -1,6 +1,6 @@
1import cors from 'cors' 1import cors from 'cors'
2import express from 'express' 2import express from 'express'
3import RateLimit from 'express-rate-limit' 3import { buildRateLimiter } from '@server/middlewares'
4import { HttpStatusCode } from '../../../shared/models' 4import { HttpStatusCode } from '../../../shared/models'
5import { badRequest } from '../../helpers/express-utils' 5import { badRequest } from '../../helpers/express-utils'
6import { CONFIG } from '../../initializers/config' 6import { CONFIG } from '../../initializers/config'
@@ -29,7 +29,7 @@ apiRouter.use(cors({
29 credentials: true 29 credentials: true
30})) 30}))
31 31
32const apiRateLimiter = RateLimit({ 32const apiRateLimiter = buildRateLimiter({
33 windowMs: CONFIG.RATES_LIMIT.API.WINDOW_MS, 33 windowMs: CONFIG.RATES_LIMIT.API.WINDOW_MS,
34 max: CONFIG.RATES_LIMIT.API.MAX 34 max: CONFIG.RATES_LIMIT.API.MAX
35}) 35})
diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts
index 8a06bfe93..46e80d56d 100644
--- a/server/controllers/api/users/index.ts
+++ b/server/controllers/api/users/index.ts
@@ -1,5 +1,4 @@
1import express from 'express' 1import express from 'express'
2import RateLimit from 'express-rate-limit'
3import { tokensRouter } from '@server/controllers/api/users/token' 2import { tokensRouter } from '@server/controllers/api/users/token'
4import { Hooks } from '@server/lib/plugins/hooks' 3import { Hooks } from '@server/lib/plugins/hooks'
5import { OAuthTokenModel } from '@server/models/oauth/oauth-token' 4import { OAuthTokenModel } from '@server/models/oauth/oauth-token'
@@ -17,9 +16,11 @@ import { Notifier } from '../../../lib/notifier'
17import { Redis } from '../../../lib/redis' 16import { Redis } from '../../../lib/redis'
18import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyUserEmail } from '../../../lib/user' 17import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyUserEmail } from '../../../lib/user'
19import { 18import {
19 adminUsersSortValidator,
20 asyncMiddleware, 20 asyncMiddleware,
21 asyncRetryTransactionMiddleware, 21 asyncRetryTransactionMiddleware,
22 authenticate, 22 authenticate,
23 buildRateLimiter,
23 ensureUserHasRight, 24 ensureUserHasRight,
24 ensureUserRegistrationAllowed, 25 ensureUserRegistrationAllowed,
25 ensureUserRegistrationAllowedForIP, 26 ensureUserRegistrationAllowedForIP,
@@ -32,7 +33,6 @@ import {
32 usersListValidator, 33 usersListValidator,
33 usersRegisterValidator, 34 usersRegisterValidator,
34 usersRemoveValidator, 35 usersRemoveValidator,
35 usersSortValidator,
36 usersUpdateValidator 36 usersUpdateValidator
37} from '../../../middlewares' 37} from '../../../middlewares'
38import { 38import {
@@ -54,13 +54,13 @@ import { myVideoPlaylistsRouter } from './my-video-playlists'
54 54
55const auditLogger = auditLoggerFactory('users') 55const auditLogger = auditLoggerFactory('users')
56 56
57const signupRateLimiter = RateLimit({ 57const signupRateLimiter = buildRateLimiter({
58 windowMs: CONFIG.RATES_LIMIT.SIGNUP.WINDOW_MS, 58 windowMs: CONFIG.RATES_LIMIT.SIGNUP.WINDOW_MS,
59 max: CONFIG.RATES_LIMIT.SIGNUP.MAX, 59 max: CONFIG.RATES_LIMIT.SIGNUP.MAX,
60 skipFailedRequests: true 60 skipFailedRequests: true
61}) 61})
62 62
63const askSendEmailLimiter = RateLimit({ 63const askSendEmailLimiter = buildRateLimiter({
64 windowMs: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.WINDOW_MS, 64 windowMs: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.WINDOW_MS,
65 max: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.MAX 65 max: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.MAX
66}) 66})
@@ -84,7 +84,7 @@ usersRouter.get('/',
84 authenticate, 84 authenticate,
85 ensureUserHasRight(UserRight.MANAGE_USERS), 85 ensureUserHasRight(UserRight.MANAGE_USERS),
86 paginationValidator, 86 paginationValidator,
87 usersSortValidator, 87 adminUsersSortValidator,
88 setDefaultSort, 88 setDefaultSort,
89 setDefaultPagination, 89 setDefaultPagination,
90 usersListValidator, 90 usersListValidator,
@@ -277,7 +277,7 @@ async function autocompleteUsers (req: express.Request, res: express.Response) {
277} 277}
278 278
279async function listUsers (req: express.Request, res: express.Response) { 279async function listUsers (req: express.Request, res: express.Response) {
280 const resultList = await UserModel.listForApi({ 280 const resultList = await UserModel.listForAdminApi({
281 start: req.query.start, 281 start: req.query.start,
282 count: req.query.count, 282 count: req.query.count,
283 sort: req.query.sort, 283 sort: req.query.sort,
diff --git a/server/controllers/api/users/token.ts b/server/controllers/api/users/token.ts
index 258b50fe9..012a49791 100644
--- a/server/controllers/api/users/token.ts
+++ b/server/controllers/api/users/token.ts
@@ -1,18 +1,17 @@
1import express from 'express' 1import express from 'express'
2import RateLimit from 'express-rate-limit'
3import { logger } from '@server/helpers/logger' 2import { logger } from '@server/helpers/logger'
4import { CONFIG } from '@server/initializers/config' 3import { CONFIG } from '@server/initializers/config'
5import { getAuthNameFromRefreshGrant, getBypassFromExternalAuth, getBypassFromPasswordGrant } from '@server/lib/auth/external-auth' 4import { getAuthNameFromRefreshGrant, getBypassFromExternalAuth, getBypassFromPasswordGrant } from '@server/lib/auth/external-auth'
6import { handleOAuthToken } from '@server/lib/auth/oauth' 5import { handleOAuthToken } from '@server/lib/auth/oauth'
7import { BypassLogin, revokeToken } from '@server/lib/auth/oauth-model' 6import { BypassLogin, revokeToken } from '@server/lib/auth/oauth-model'
8import { Hooks } from '@server/lib/plugins/hooks' 7import { Hooks } from '@server/lib/plugins/hooks'
9import { asyncMiddleware, authenticate, openapiOperationDoc } from '@server/middlewares' 8import { asyncMiddleware, authenticate, buildRateLimiter, openapiOperationDoc } from '@server/middlewares'
10import { buildUUID } from '@shared/extra-utils' 9import { buildUUID } from '@shared/extra-utils'
11import { ScopedToken } from '@shared/models/users/user-scoped-token' 10import { ScopedToken } from '@shared/models/users/user-scoped-token'
12 11
13const tokensRouter = express.Router() 12const tokensRouter = express.Router()
14 13
15const loginRateLimiter = RateLimit({ 14const loginRateLimiter = buildRateLimiter({
16 windowMs: CONFIG.RATES_LIMIT.LOGIN.WINDOW_MS, 15 windowMs: CONFIG.RATES_LIMIT.LOGIN.WINDOW_MS,
17 max: CONFIG.RATES_LIMIT.LOGIN.MAX 16 max: CONFIG.RATES_LIMIT.LOGIN.MAX
18}) 17})
diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts
index c929a6726..9eb31ed93 100644
--- a/server/controllers/feeds.ts
+++ b/server/controllers/feeds.ts
@@ -1,13 +1,13 @@
1import express from 'express' 1import express from 'express'
2import { Feed } from '@peertube/feed'
3import { extname } from 'path' 2import { extname } from 'path'
3import { Feed } from '@peertube/feed'
4import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown' 4import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown'
5import { getServerActor } from '@server/models/application/application' 5import { getServerActor } from '@server/models/application/application'
6import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils' 6import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils'
7import { VideoInclude } from '@shared/models' 7import { VideoInclude } from '@shared/models'
8import { buildNSFWFilter } from '../helpers/express-utils' 8import { buildNSFWFilter } from '../helpers/express-utils'
9import { CONFIG } from '../initializers/config' 9import { CONFIG } from '../initializers/config'
10import { FEEDS, MIMETYPES, PREVIEWS_SIZE, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../initializers/constants' 10import { MIMETYPES, PREVIEWS_SIZE, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../initializers/constants'
11import { 11import {
12 asyncMiddleware, 12 asyncMiddleware,
13 commonVideosFiltersValidator, 13 commonVideosFiltersValidator,
@@ -76,7 +76,7 @@ async function generateVideoCommentsFeed (req: express.Request, res: express.Res
76 76
77 const comments = await VideoCommentModel.listForFeed({ 77 const comments = await VideoCommentModel.listForFeed({
78 start, 78 start,
79 count: FEEDS.COUNT, 79 count: CONFIG.FEEDS.COMMENTS.COUNT,
80 videoId: video ? video.id : undefined, 80 videoId: video ? video.id : undefined,
81 accountId: account ? account.id : undefined, 81 accountId: account ? account.id : undefined,
82 videoChannelId: videoChannel ? videoChannel.id : undefined 82 videoChannelId: videoChannel ? videoChannel.id : undefined
@@ -166,7 +166,7 @@ async function generateVideoFeed (req: express.Request, res: express.Response) {
166 const server = await getServerActor() 166 const server = await getServerActor()
167 const { data } = await VideoModel.listForApi({ 167 const { data } = await VideoModel.listForApi({
168 start, 168 start,
169 count: FEEDS.COUNT, 169 count: CONFIG.FEEDS.VIDEOS.COUNT,
170 sort: req.query.sort, 170 sort: req.query.sort,
171 displayOnlyForFollower: { 171 displayOnlyForFollower: {
172 actorId: server.id, 172 actorId: server.id,
@@ -202,7 +202,7 @@ async function generateVideoFeedForSubscriptions (req: express.Request, res: exp
202 202
203 const { data } = await VideoModel.listForApi({ 203 const { data } = await VideoModel.listForApi({
204 start, 204 start,
205 count: FEEDS.COUNT, 205 count: CONFIG.FEEDS.VIDEOS.COUNT,
206 sort: req.query.sort, 206 sort: req.query.sort,
207 nsfw, 207 nsfw,
208 208
diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts
index 794303743..359f0c31d 100644
--- a/server/initializers/checker-before-init.ts
+++ b/server/initializers/checker-before-init.ts
@@ -31,8 +31,8 @@ function checkMissedConfig () {
31 'transcoding.resolutions.0p', 'transcoding.resolutions.144p', 'transcoding.resolutions.240p', 'transcoding.resolutions.360p', 31 'transcoding.resolutions.0p', 'transcoding.resolutions.144p', 'transcoding.resolutions.240p', 'transcoding.resolutions.360p',
32 'transcoding.resolutions.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p', 32 'transcoding.resolutions.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p',
33 'transcoding.resolutions.2160p', 'video_studio.enabled', 33 'transcoding.resolutions.2160p', 'video_studio.enabled',
34 'import.videos.http.enabled', 'import.videos.torrent.enabled', 'import.videos.concurrency', 'auto_blacklist.videos.of_users.enabled', 34 'import.videos.http.enabled', 'import.videos.torrent.enabled', 'import.videos.concurrency', 'import.videos.timeout',
35 'trending.videos.interval_days', 35 'auto_blacklist.videos.of_users.enabled', 'trending.videos.interval_days',
36 'client.videos.miniature.display_author_avatar', 36 'client.videos.miniature.display_author_avatar',
37 'client.videos.miniature.prefer_author_display_name', 'client.menu.login.redirect_on_single_external_auth', 37 'client.videos.miniature.prefer_author_display_name', 'client.menu.login.redirect_on_single_external_auth',
38 'defaults.publish.download_enabled', 'defaults.publish.comments_enabled', 'defaults.publish.privacy', 'defaults.publish.licence', 38 'defaults.publish.download_enabled', 'defaults.publish.comments_enabled', 'defaults.publish.privacy', 'defaults.publish.licence',
@@ -44,6 +44,7 @@ function checkMissedConfig () {
44 'history.videos.max_age', 'views.videos.remote.max_age', 'views.videos.local_buffer_update_interval', 'views.videos.ip_view_expiration', 44 'history.videos.max_age', 'views.videos.remote.max_age', 'views.videos.local_buffer_update_interval', 'views.videos.ip_view_expiration',
45 'rates_limit.login.window', 'rates_limit.login.max', 'rates_limit.ask_send_email.window', 'rates_limit.ask_send_email.max', 45 'rates_limit.login.window', 'rates_limit.login.max', 'rates_limit.ask_send_email.window', 'rates_limit.ask_send_email.max',
46 'theme.default', 46 'theme.default',
47 'feeds.videos.count', 'feeds.comments.count',
47 'geo_ip.enabled', 'geo_ip.country.database_url', 48 'geo_ip.enabled', 'geo_ip.country.database_url',
48 'remote_redundancy.videos.accept_from', 49 'remote_redundancy.videos.accept_from',
49 'federation.videos.federate_unlisted', 'federation.videos.cleanup_remote_interactions', 50 'federation.videos.federate_unlisted', 'federation.videos.cleanup_remote_interactions',
diff --git a/server/initializers/config.ts b/server/initializers/config.ts
index 59a65d6a5..c76a839bc 100644
--- a/server/initializers/config.ts
+++ b/server/initializers/config.ts
@@ -247,6 +247,14 @@ const CONFIG = {
247 } 247 }
248 } 248 }
249 }, 249 },
250 FEEDS: {
251 VIDEOS: {
252 COUNT: config.get<number>('feeds.videos.count')
253 },
254 COMMENTS: {
255 COUNT: config.get<number>('feeds.comments.count')
256 }
257 },
250 ADMIN: { 258 ADMIN: {
251 get EMAIL () { return config.get<string>('admin.email') } 259 get EMAIL () { return config.get<string>('admin.email') }
252 }, 260 },
@@ -349,6 +357,7 @@ const CONFIG = {
349 IMPORT: { 357 IMPORT: {
350 VIDEOS: { 358 VIDEOS: {
351 get CONCURRENCY () { return config.get<number>('import.videos.concurrency') }, 359 get CONCURRENCY () { return config.get<number>('import.videos.concurrency') },
360 get TIMEOUT () { return parseDurationToMs(config.get<string>('import.videos.timeout')) },
352 361
353 HTTP: { 362 HTTP: {
354 get ENABLED () { return config.get<boolean>('import.videos.http.enabled') }, 363 get ENABLED () { return config.get<boolean>('import.videos.http.enabled') },
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 909fffdb6..824a30bd2 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -58,7 +58,7 @@ const WEBSERVER = {
58 58
59// Sortable columns per schema 59// Sortable columns per schema
60const SORTABLE_COLUMNS = { 60const SORTABLE_COLUMNS = {
61 USERS: [ 'id', 'username', 'videoQuotaUsed', 'createdAt', 'lastLoginDate', 'role' ], 61 ADMIN_USERS: [ 'id', 'username', 'videoQuotaUsed', 'createdAt', 'lastLoginDate', 'role' ],
62 USER_SUBSCRIPTIONS: [ 'id', 'createdAt' ], 62 USER_SUBSCRIPTIONS: [ 'id', 'createdAt' ],
63 ACCOUNTS: [ 'createdAt' ], 63 ACCOUNTS: [ 'createdAt' ],
64 JOBS: [ 'createdAt' ], 64 JOBS: [ 'createdAt' ],
@@ -183,7 +183,7 @@ const JOB_TTL: { [id in JobType]: number } = {
183 'video-file-import': 1000 * 3600, // 1 hour 183 'video-file-import': 1000 * 3600, // 1 hour
184 'video-transcoding': 1000 * 3600 * 48, // 2 days, transcoding could be long 184 'video-transcoding': 1000 * 3600 * 48, // 2 days, transcoding could be long
185 'video-studio-edition': 1000 * 3600 * 10, // 10 hours 185 'video-studio-edition': 1000 * 3600 * 10, // 10 hours
186 'video-import': 1000 * 3600 * 2, // 2 hours 186 'video-import': CONFIG.IMPORT.VIDEOS.TIMEOUT,
187 'email': 60000 * 10, // 10 minutes 187 'email': 60000 * 10, // 10 minutes
188 'actor-keys': 60000 * 20, // 20 minutes 188 'actor-keys': 60000 * 20, // 20 minutes
189 'videos-views-stats': undefined, // Unlimited 189 'videos-views-stats': undefined, // Unlimited
@@ -766,12 +766,6 @@ const CUSTOM_HTML_TAG_COMMENTS = {
766 SERVER_CONFIG: '<!-- server config -->' 766 SERVER_CONFIG: '<!-- server config -->'
767} 767}
768 768
769// ---------------------------------------------------------------------------
770
771const FEEDS = {
772 COUNT: 20
773}
774
775const MAX_LOGS_OUTPUT_CHARACTERS = 10 * 1000 * 1000 769const MAX_LOGS_OUTPUT_CHARACTERS = 10 * 1000 * 1000
776const LOG_FILENAME = 'peertube.log' 770const LOG_FILENAME = 'peertube.log'
777const AUDIT_LOG_FILENAME = 'peertube-audit.log' 771const AUDIT_LOG_FILENAME = 'peertube-audit.log'
@@ -939,7 +933,6 @@ export {
939 ROUTE_CACHE_LIFETIME, 933 ROUTE_CACHE_LIFETIME,
940 SORTABLE_COLUMNS, 934 SORTABLE_COLUMNS,
941 HLS_STREAMING_PLAYLIST_DIRECTORY, 935 HLS_STREAMING_PLAYLIST_DIRECTORY,
942 FEEDS,
943 JOB_TTL, 936 JOB_TTL,
944 DEFAULT_THEME_NAME, 937 DEFAULT_THEME_NAME,
945 NSFW_POLICY_TYPES, 938 NSFW_POLICY_TYPES,
diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts
index 337364ac9..1e8d03023 100644
--- a/server/lib/client-html.ts
+++ b/server/lib/client-html.ts
@@ -30,6 +30,7 @@ import { MAccountActor, MChannelActor } from '../types/models'
30import { getActivityStreamDuration } from './activitypub/activity' 30import { getActivityStreamDuration } from './activitypub/activity'
31import { getBiggestActorImage } from './actor-image' 31import { getBiggestActorImage } from './actor-image'
32import { ServerConfigManager } from './server-config-manager' 32import { ServerConfigManager } from './server-config-manager'
33import { isTestInstance } from '@server/helpers/core-utils'
33 34
34type Tags = { 35type Tags = {
35 ogType: string 36 ogType: string
@@ -232,7 +233,10 @@ class ClientHtml {
232 static async getEmbedHTML () { 233 static async getEmbedHTML () {
233 const path = ClientHtml.getEmbedPath() 234 const path = ClientHtml.getEmbedPath()
234 235
235 if (ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path] 236 // Disable HTML cache in dev mode because webpack can regenerate JS files
237 if (!isTestInstance() && ClientHtml.htmlCache[path]) {
238 return ClientHtml.htmlCache[path]
239 }
236 240
237 const buffer = await readFile(path) 241 const buffer = await readFile(path)
238 const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig() 242 const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
diff --git a/server/lib/schedulers/geo-ip-update-scheduler.ts b/server/lib/schedulers/geo-ip-update-scheduler.ts
index 9dda6d76c..b06f5a9b5 100644
--- a/server/lib/schedulers/geo-ip-update-scheduler.ts
+++ b/server/lib/schedulers/geo-ip-update-scheduler.ts
@@ -6,7 +6,7 @@ export class GeoIPUpdateScheduler extends AbstractScheduler {
6 6
7 private static instance: AbstractScheduler 7 private static instance: AbstractScheduler
8 8
9 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.YOUTUBE_DL_UPDATE 9 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.GEO_IP_UPDATE
10 10
11 private constructor () { 11 private constructor () {
12 super() 12 super()
diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts
index d2ed079b6..b40f864ce 100644
--- a/server/middlewares/index.ts
+++ b/server/middlewares/index.ts
@@ -4,6 +4,7 @@ export * from './activitypub'
4export * from './async' 4export * from './async'
5export * from './auth' 5export * from './auth'
6export * from './pagination' 6export * from './pagination'
7export * from './rate-limiter'
7export * from './robots' 8export * from './robots'
8export * from './servers' 9export * from './servers'
9export * from './sort' 10export * from './sort'
diff --git a/server/middlewares/rate-limiter.ts b/server/middlewares/rate-limiter.ts
new file mode 100644
index 000000000..bc9513969
--- /dev/null
+++ b/server/middlewares/rate-limiter.ts
@@ -0,0 +1,31 @@
1import { UserRole } from '@shared/models'
2import RateLimit from 'express-rate-limit'
3import { optionalAuthenticate } from './auth'
4
5const whitelistRoles = new Set([ UserRole.ADMINISTRATOR, UserRole.MODERATOR ])
6
7function buildRateLimiter (options: {
8 windowMs: number
9 max: number
10 skipFailedRequests?: boolean
11}) {
12 return RateLimit({
13 windowMs: options.windowMs,
14 max: options.max,
15 skipFailedRequests: options.skipFailedRequests,
16
17 handler: (req, res, next, options) => {
18 return optionalAuthenticate(req, res, () => {
19 if (res.locals.authenticated === true && whitelistRoles.has(res.locals.oauth.token.User.role)) {
20 return next()
21 }
22
23 return res.status(options.statusCode).send(options.message)
24 })
25 }
26 })
27}
28
29export {
30 buildRateLimiter
31}
diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts
index 3ba668460..c9978e3b4 100644
--- a/server/middlewares/validators/sort.ts
+++ b/server/middlewares/validators/sort.ts
@@ -28,7 +28,7 @@ function createSortableColumns (sortableColumns: string[]) {
28 return sortableColumns.concat(sortableColumnDesc) 28 return sortableColumns.concat(sortableColumnDesc)
29} 29}
30 30
31const usersSortValidator = checkSortFactory(SORTABLE_COLUMNS.USERS) 31const adminUsersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ADMIN_USERS)
32const accountsSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS) 32const accountsSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS)
33const jobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.JOBS, [ 'jobs' ]) 33const jobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.JOBS, [ 'jobs' ])
34const abusesSortValidator = checkSortFactory(SORTABLE_COLUMNS.ABUSES) 34const abusesSortValidator = checkSortFactory(SORTABLE_COLUMNS.ABUSES)
@@ -59,7 +59,7 @@ const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CH
59// --------------------------------------------------------------------------- 59// ---------------------------------------------------------------------------
60 60
61export { 61export {
62 usersSortValidator, 62 adminUsersSortValidator,
63 abusesSortValidator, 63 abusesSortValidator,
64 videoChannelsSortValidator, 64 videoChannelsSortValidator,
65 videoImportsSortValidator, 65 videoImportsSortValidator,
diff --git a/server/models/user/user.ts b/server/models/user/user.ts
index 326b2e789..20c2222a7 100644
--- a/server/models/user/user.ts
+++ b/server/models/user/user.ts
@@ -66,7 +66,7 @@ import { ActorModel } from '../actor/actor'
66import { ActorFollowModel } from '../actor/actor-follow' 66import { ActorFollowModel } from '../actor/actor-follow'
67import { ActorImageModel } from '../actor/actor-image' 67import { ActorImageModel } from '../actor/actor-image'
68import { OAuthTokenModel } from '../oauth/oauth-token' 68import { OAuthTokenModel } from '../oauth/oauth-token'
69import { getSort, throwIfNotValid } from '../utils' 69import { getAdminUsersSort, throwIfNotValid } from '../utils'
70import { VideoModel } from '../video/video' 70import { VideoModel } from '../video/video'
71import { VideoChannelModel } from '../video/video-channel' 71import { VideoChannelModel } from '../video/video-channel'
72import { VideoImportModel } from '../video/video-import' 72import { VideoImportModel } from '../video/video-import'
@@ -461,7 +461,7 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
461 return this.count() 461 return this.count()
462 } 462 }
463 463
464 static listForApi (parameters: { 464 static listForAdminApi (parameters: {
465 start: number 465 start: number
466 count: number 466 count: number
467 sort: string 467 sort: string
@@ -497,7 +497,7 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
497 const query: FindOptions = { 497 const query: FindOptions = {
498 offset: start, 498 offset: start,
499 limit: count, 499 limit: count,
500 order: getSort(sort), 500 order: getAdminUsersSort(sort),
501 where 501 where
502 } 502 }
503 503
diff --git a/server/models/utils.ts b/server/models/utils.ts
index b57290aff..88e31f22e 100644
--- a/server/models/utils.ts
+++ b/server/models/utils.ts
@@ -11,8 +11,6 @@ function getSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderIt
11 11
12 if (field.toLowerCase() === 'match') { // Search 12 if (field.toLowerCase() === 'match') { // Search
13 finalField = Sequelize.col('similarity') 13 finalField = Sequelize.col('similarity')
14 } else if (field === 'videoQuotaUsed') { // Users list
15 finalField = Sequelize.col('videoQuotaUsed')
16 } else { 14 } else {
17 finalField = field 15 finalField = field
18 } 16 }
@@ -20,6 +18,25 @@ function getSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderIt
20 return [ [ finalField, direction ], lastSort ] 18 return [ [ finalField, direction ], lastSort ]
21} 19}
22 20
21function getAdminUsersSort (value: string): OrderItem[] {
22 const { direction, field } = buildDirectionAndField(value)
23
24 let finalField: string | ReturnType<typeof Sequelize.col>
25
26 if (field === 'videoQuotaUsed') { // Users list
27 finalField = Sequelize.col('videoQuotaUsed')
28 } else {
29 finalField = field
30 }
31
32 const nullPolicy = direction === 'ASC'
33 ? 'NULLS FIRST'
34 : 'NULLS LAST'
35
36 // FIXME: typings
37 return [ [ finalField as any, direction, nullPolicy ], [ 'id', 'ASC' ] ]
38}
39
23function getPlaylistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] { 40function getPlaylistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
24 const { direction, field } = buildDirectionAndField(value) 41 const { direction, field } = buildDirectionAndField(value)
25 42
@@ -260,6 +277,7 @@ export {
260 buildLocalAccountIdsIn, 277 buildLocalAccountIdsIn,
261 getSort, 278 getSort,
262 getCommentSort, 279 getCommentSort,
280 getAdminUsersSort,
263 getVideoSort, 281 getVideoSort,
264 getBlacklistSort, 282 getBlacklistSort,
265 createSimilarityAttribute, 283 createSimilarityAttribute,
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index d6dd1b8bb..91dafbcf1 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -311,6 +311,16 @@ export type SummaryOptions = {
311 ')' 311 ')'
312 ), 312 ),
313 'viewsPerDay' 313 'viewsPerDay'
314 ],
315 [
316 literal(
317 '(' +
318 'SELECT COALESCE(SUM("video".views), 0) AS totalViews ' +
319 'FROM "video" ' +
320 'WHERE "video"."channelId" = "VideoChannelModel"."id"' +
321 ')'
322 ),
323 'totalViews'
314 ] 324 ]
315 ] 325 ]
316 } 326 }
@@ -766,6 +776,8 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
766 }) 776 })
767 } 777 }
768 778
779 const totalViews = this.get('totalViews') as number
780
769 const actor = this.Actor.toFormattedJSON() 781 const actor = this.Actor.toFormattedJSON()
770 const videoChannel = { 782 const videoChannel = {
771 id: this.id, 783 id: this.id,
@@ -779,6 +791,7 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
779 791
780 videosCount, 792 videosCount,
781 viewsPerDay, 793 viewsPerDay,
794 totalViews,
782 795
783 avatars: actor.avatars, 796 avatars: actor.avatars,
784 797
diff --git a/server/tests/api/live/live.ts b/server/tests/api/live/live.ts
index 9b8fbe3e2..c497f7840 100644
--- a/server/tests/api/live/live.ts
+++ b/server/tests/api/live/live.ts
@@ -654,7 +654,7 @@ describe('Test live', function () {
654 }) 654 })
655 655
656 it('Should save a non permanent live replay', async function () { 656 it('Should save a non permanent live replay', async function () {
657 this.timeout(120000) 657 this.timeout(240000)
658 658
659 await commands[0].waitUntilPublished({ videoId: liveVideoReplayId }) 659 await commands[0].waitUntilPublished({ videoId: liveVideoReplayId })
660 660
diff --git a/server/tests/api/server/reverse-proxy.ts b/server/tests/api/server/reverse-proxy.ts
index fa2063536..0a1565faf 100644
--- a/server/tests/api/server/reverse-proxy.ts
+++ b/server/tests/api/server/reverse-proxy.ts
@@ -7,6 +7,7 @@ import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServ
7 7
8describe('Test application behind a reverse proxy', function () { 8describe('Test application behind a reverse proxy', function () {
9 let server: PeerTubeServer 9 let server: PeerTubeServer
10 let userAccessToken: string
10 let videoId: string 11 let videoId: string
11 12
12 before(async function () { 13 before(async function () {
@@ -34,6 +35,8 @@ describe('Test application behind a reverse proxy', function () {
34 server = await createSingleServer(1, config) 35 server = await createSingleServer(1, config)
35 await setAccessTokensToServers([ server ]) 36 await setAccessTokensToServers([ server ])
36 37
38 userAccessToken = await server.users.generateUserAndToken('user')
39
37 const { uuid } = await server.videos.upload() 40 const { uuid } = await server.videos.upload()
38 videoId = uuid 41 videoId = uuid
39 }) 42 })
@@ -93,7 +96,7 @@ describe('Test application behind a reverse proxy', function () {
93 it('Should rate limit logins', async function () { 96 it('Should rate limit logins', async function () {
94 const user = { username: 'root', password: 'fail' } 97 const user = { username: 'root', password: 'fail' }
95 98
96 for (let i = 0; i < 19; i++) { 99 for (let i = 0; i < 18; i++) {
97 await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 100 await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
98 } 101 }
99 102
@@ -141,6 +144,12 @@ describe('Test application behind a reverse proxy', function () {
141 await server.videos.get({ id: videoId, expectedStatus: HttpStatusCode.TOO_MANY_REQUESTS_429 }) 144 await server.videos.get({ id: videoId, expectedStatus: HttpStatusCode.TOO_MANY_REQUESTS_429 })
142 }) 145 })
143 146
147 it('Should rate limit API calls with a user but not with an admin', async function () {
148 await server.videos.get({ id: videoId, token: userAccessToken, expectedStatus: HttpStatusCode.TOO_MANY_REQUESTS_429 })
149
150 await server.videos.get({ id: videoId, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
151 })
152
144 after(async function () { 153 after(async function () {
145 await cleanupTests([ server ]) 154 await cleanupTests([ server ])
146 }) 155 })
diff --git a/server/tests/api/videos/video-channels.ts b/server/tests/api/videos/video-channels.ts
index 6f495c42d..42e0cf431 100644
--- a/server/tests/api/videos/video-channels.ts
+++ b/server/tests/api/videos/video-channels.ts
@@ -478,6 +478,25 @@ describe('Test video channels', function () {
478 } 478 }
479 }) 479 })
480 480
481 it('Should report correct total views count', async function () {
482 // check if there's the property
483 {
484 const { data } = await servers[0].channels.listByAccount({ accountName, withStats: true })
485
486 for (const channel of data) {
487 expect(channel).to.haveOwnProperty('totalViews')
488 expect(channel.totalViews).to.be.a('number')
489 }
490 }
491
492 // Check if the totalViews count can be updated
493 {
494 const { data } = await servers[0].channels.listByAccount({ accountName, withStats: true })
495 const channelWithView = data.find(channel => channel.id === servers[0].store.channel.id)
496 expect(channelWithView.totalViews).to.equal(2)
497 }
498 })
499
481 it('Should report correct videos count', async function () { 500 it('Should report correct videos count', async function () {
482 const { data } = await servers[0].channels.listByAccount({ accountName, withStats: true }) 501 const { data } = await servers[0].channels.listByAccount({ accountName, withStats: true })
483 502
diff --git a/shared/models/videos/channel/video-channel.model.ts b/shared/models/videos/channel/video-channel.model.ts
index 58b60c177..68e2f9c4c 100644
--- a/shared/models/videos/channel/video-channel.model.ts
+++ b/shared/models/videos/channel/video-channel.model.ts
@@ -18,6 +18,7 @@ export interface VideoChannel extends Actor {
18 18
19 videosCount?: number 19 videosCount?: number
20 viewsPerDay?: ViewsPerDate[] // chronologically ordered 20 viewsPerDay?: ViewsPerDate[] // chronologically ordered
21 totalViews?: number
21 22
22 banners: ActorImage[] 23 banners: ActorImage[]
23 24
diff --git a/shared/models/videos/video-sort-field.type.ts b/shared/models/videos/video-sort-field.type.ts
index 5073848b8..7fa07fa73 100644
--- a/shared/models/videos/video-sort-field.type.ts
+++ b/shared/models/videos/video-sort-field.type.ts
@@ -2,6 +2,7 @@ export type VideoSortField =
2 'name' | '-name' | 2 'name' | '-name' |
3 'duration' | '-duration' | 3 'duration' | '-duration' |
4 'publishedAt' | '-publishedAt' | 4 'publishedAt' | '-publishedAt' |
5 'originallyPublishedAt' | '-originallyPublishedAt' |
5 'createdAt' | '-createdAt' | 6 'createdAt' | '-createdAt' |
6 'views' | '-views' | 7 'views' | '-views' |
7 'likes' | '-likes' | 8 'likes' | '-likes' |
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index 294aa50ab..8521f684e 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -3602,7 +3602,7 @@ paths:
3602 - $ref: '#/components/parameters/name' 3602 - $ref: '#/components/parameters/name'
3603 - name: withStats 3603 - name: withStats
3604 in: query 3604 in: query
3605 description: include view statistics for the last 30 days (only if authentified as the account user) 3605 description: include daily view statistics for the last 30 days and total views (only if authentified as the account user)
3606 schema: 3606 schema:
3607 type: boolean 3607 type: boolean
3608 - $ref: '#/components/parameters/start' 3608 - $ref: '#/components/parameters/start'
diff --git a/support/doc/production.md b/support/doc/production.md
index 3c75b6cab..6d7744b1f 100644
--- a/support/doc/production.md
+++ b/support/doc/production.md
@@ -328,7 +328,7 @@ Copy new configuration defaults values and update your configuration file:
328 328
329```bash 329```bash
330$ sudo -u peertube cp /var/www/peertube/versions/peertube-${VERSION}/config/default.yaml /var/www/peertube/config/default.yaml 330$ sudo -u peertube cp /var/www/peertube/versions/peertube-${VERSION}/config/default.yaml /var/www/peertube/config/default.yaml
331$ diff /var/www/peertube/versions/peertube-${VERSION}/config/production.yaml.example /var/www/peertube/config/production.yaml 331$ diff -u /var/www/peertube/versions/peertube-${VERSION}/config/production.yaml.example /var/www/peertube/config/production.yaml
332``` 332```
333 333
334Change the link to point to the latest version: 334Change the link to point to the latest version:
@@ -345,7 +345,7 @@ Check changes in nginx configuration:
345 345
346```bash 346```bash
347$ cd /var/www/peertube/versions 347$ cd /var/www/peertube/versions
348$ diff "$(ls --sort=t | head -2 | tail -1)/support/nginx/peertube" "$(ls --sort=t | head -1)/support/nginx/peertube" 348$ diff -u "$(ls --sort=t | head -2 | tail -1)/support/nginx/peertube" "$(ls --sort=t | head -1)/support/nginx/peertube"
349``` 349```
350 350
351### systemd 351### systemd
@@ -354,7 +354,7 @@ Check changes in systemd configuration:
354 354
355```bash 355```bash
356$ cd /var/www/peertube/versions 356$ cd /var/www/peertube/versions
357$ diff "$(ls --sort=t | head -2 | tail -1)/support/systemd/peertube.service" "$(ls --sort=t | head -1)/support/systemd/peertube.service" 357$ diff -u "$(ls --sort=t | head -2 | tail -1)/support/systemd/peertube.service" "$(ls --sort=t | head -1)/support/systemd/peertube.service"
358``` 358```
359 359
360### Restart PeerTube 360### Restart PeerTube