diff options
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 @@ | |||
1 | import { SortMeta } from 'primeng/api' | 1 | import { SortMeta } from 'primeng/api' |
2 | import { Component, OnInit, ViewChild } from '@angular/core' | 2 | import { Component, OnInit, ViewChild } from '@angular/core' |
3 | import { ActivatedRoute, Router } from '@angular/router' | 3 | import { ActivatedRoute, Router } from '@angular/router' |
4 | import { AuthService, ConfirmService, Notifier, RestPagination, RestTable, ServerService } from '@app/core' | 4 | import { AuthService, ConfirmService, LocalStorageService, Notifier, RestPagination, RestTable, ServerService } from '@app/core' |
5 | import { getAPIHost } from '@app/helpers' | 5 | import { getAPIHost } from '@app/helpers' |
6 | import { AdvancedInputFilter } from '@app/shared/shared-forms' | 6 | import { AdvancedInputFilter } from '@app/shared/shared-forms' |
7 | import { Actor, DropdownAction } from '@app/shared/shared-main' | 7 | import { 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 | }) |
24 | export class UserListComponent extends RestTable implements OnInit { | 24 | export 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' | |||
5 | export * from './peertube-web-storage' | 5 | export * from './peertube-web-storage' |
6 | export * from './plugins-manager' | 6 | export * from './plugins-manager' |
7 | export * from './string' | 7 | export * from './string' |
8 | export * from './url' | ||
8 | export * from './utils' | 9 | export * from './utils' |
9 | export * from './video' | 10 | export * 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 @@ | |||
1 | function 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 | |||
7 | function getParamString (params: URLSearchParams, name: string, defaultValue?: string) { | ||
8 | return params.has(name) | ||
9 | ? params.get(name) | ||
10 | : defaultValue | ||
11 | } | ||
12 | |||
13 | function 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 | |||
22 | export { | ||
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 @@ | |||
1 | function 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 | |||
10 | function copyToClipboard (text: string) { | 1 | function 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 | ||
28 | export { | 19 | export { |
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' | |||
3 | import '../../assets/player/shared/dock/peertube-dock-plugin' | 3 | import '../../assets/player/shared/dock/peertube-dock-plugin' |
4 | import videojs from 'video.js' | 4 | import videojs from 'video.js' |
5 | import { peertubeTranslate } from '../../../../shared/core-utils/i18n' | 5 | import { peertubeTranslate } from '../../../../shared/core-utils/i18n' |
6 | import { | 6 | import { HTMLServerConfig, LiveVideo, ResultList, VideoDetails, VideoPlaylist, VideoPlaylistElement } from '../../../../shared/models' |
7 | HTMLServerConfig, | 7 | import { 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' | ||
21 | import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode, VideoJSCaption } from '../../assets/player' | ||
22 | import { TranslationsManager } from '../../assets/player/translations-manager' | 8 | import { TranslationsManager } from '../../assets/player/translations-manager' |
23 | import { getBoolOrDefault } from '../../root-helpers/local-storage-utils' | 9 | import { getParamString } from '../../root-helpers' |
24 | import { peertubeLocalStorage } from '../../root-helpers/peertube-web-storage' | ||
25 | import { PluginInfo, PluginsManager } from '../../root-helpers/plugins-manager' | ||
26 | import { UserLocalStorageKeys, UserTokens } from '../../root-helpers/users' | ||
27 | import { objectToUrlEncoded } from '../../root-helpers/utils' | ||
28 | import { isP2PEnabled } from '../../root-helpers/video' | ||
29 | import { RegisterClientHelpers } from '../../types/register-client-option.model' | ||
30 | import { PeerTubeEmbedApi } from './embed-api' | 10 | import { PeerTubeEmbedApi } from './embed-api' |
31 | 11 | import { AuthHTTP, LiveManager, PeerTubePlugin, PlayerManagerOptions, PlaylistFetcher, PlaylistTracker, VideoFetcher } from './shared' | |
32 | type Translations = { [ id: string ]: string } | 12 | import { PlayerHTML } from './shared/player-html' |
33 | 13 | ||
34 | export class PeerTubeEmbed { | 14 | export 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 | ||
862 | PeerTubeEmbed.main() | 340 | PeerTubeEmbed.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 @@ | |||
1 | import { HttpStatusCode, OAuth2ErrorCode, UserRefreshToken } from '../../../../../shared/models' | ||
2 | import { objectToUrlEncoded, UserTokens } from '../../../root-helpers' | ||
3 | import { peertubeLocalStorage } from '../../../root-helpers/peertube-web-storage' | ||
4 | |||
5 | export 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 @@ | |||
1 | export * from './auth-http' | ||
2 | export * from './peertube-plugin' | ||
3 | export * from './live-manager' | ||
4 | export * from './player-html' | ||
5 | export * from './player-manager-options' | ||
6 | export * from './playlist-fetcher' | ||
7 | export * from './playlist-tracker' | ||
8 | export * from './translations' | ||
9 | export * 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 @@ | |||
1 | import { Socket } from 'socket.io-client' | ||
2 | import { LiveVideoEventPayload, VideoDetails, VideoState } from '../../../../../shared/models' | ||
3 | import { PlayerHTML } from './player-html' | ||
4 | import { Translations } from './translations' | ||
5 | |||
6 | export 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 @@ | |||
1 | import { peertubeTranslate } from '../../../../../shared/core-utils/i18n' | ||
2 | import { HTMLServerConfig, PublicServerSetting } from '../../../../../shared/models' | ||
3 | import { PluginInfo, PluginsManager } from '../../../root-helpers' | ||
4 | import { RegisterClientHelpers } from '../../../types' | ||
5 | import { AuthHTTP } from './auth-http' | ||
6 | import { Translations } from './translations' | ||
7 | |||
8 | export 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 @@ | |||
1 | import { peertubeTranslate } from '../../../../../shared/core-utils/i18n' | ||
2 | import { VideoDetails } from '../../../../../shared/models' | ||
3 | import { Translations } from './translations' | ||
4 | |||
5 | export 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 @@ | |||
1 | import { peertubeTranslate } from '../../../../../shared/core-utils/i18n' | ||
2 | import { | ||
3 | HTMLServerConfig, | ||
4 | LiveVideo, | ||
5 | Video, | ||
6 | VideoCaption, | ||
7 | VideoDetails, | ||
8 | VideoPlaylistElement, | ||
9 | VideoStreamingPlaylistType | ||
10 | } from '../../../../../shared/models' | ||
11 | import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode, VideoJSCaption } from '../../../assets/player' | ||
12 | import { | ||
13 | getBoolOrDefault, | ||
14 | getParamString, | ||
15 | getParamToggle, | ||
16 | isP2PEnabled, | ||
17 | peertubeLocalStorage, | ||
18 | UserLocalStorageKeys | ||
19 | } from '../../../root-helpers' | ||
20 | import { PeerTubePlugin } from './peertube-plugin' | ||
21 | import { PlayerHTML } from './player-html' | ||
22 | import { PlaylistTracker } from './playlist-tracker' | ||
23 | import { Translations } from './translations' | ||
24 | import { VideoFetcher } from './video-fetcher' | ||
25 | |||
26 | export 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 @@ | |||
1 | import { HttpStatusCode, ResultList, VideoPlaylistElement } from '../../../../../shared/models' | ||
2 | import { AuthHTTP } from './auth-http' | ||
3 | |||
4 | export 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 @@ | |||
1 | import { VideoPlaylist, VideoPlaylistElement } from '../../../../../shared/models' | ||
2 | |||
3 | export 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 @@ | |||
1 | type Translations = { [ id: string ]: string } | ||
2 | |||
3 | export { | ||
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 @@ | |||
1 | import { HttpStatusCode, LiveVideo, VideoDetails } from '../../../../../shared/models' | ||
2 | import { AuthHTTP } from './auth-http' | ||
3 | |||
4 | export 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 | ||
300 | feeds: | ||
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 | |||
299 | cache: | 309 | cache: |
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 | ||
296 | feeds: | ||
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 | } |
62 | Object.assign(playerKeys, videojs) | 64 | Object.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 @@ | |||
1 | import cors from 'cors' | 1 | import cors from 'cors' |
2 | import express from 'express' | 2 | import express from 'express' |
3 | import RateLimit from 'express-rate-limit' | 3 | import { buildRateLimiter } from '@server/middlewares' |
4 | import { HttpStatusCode } from '../../../shared/models' | 4 | import { HttpStatusCode } from '../../../shared/models' |
5 | import { badRequest } from '../../helpers/express-utils' | 5 | import { badRequest } from '../../helpers/express-utils' |
6 | import { CONFIG } from '../../initializers/config' | 6 | import { CONFIG } from '../../initializers/config' |
@@ -29,7 +29,7 @@ apiRouter.use(cors({ | |||
29 | credentials: true | 29 | credentials: true |
30 | })) | 30 | })) |
31 | 31 | ||
32 | const apiRateLimiter = RateLimit({ | 32 | const 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 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import RateLimit from 'express-rate-limit' | ||
3 | import { tokensRouter } from '@server/controllers/api/users/token' | 2 | import { tokensRouter } from '@server/controllers/api/users/token' |
4 | import { Hooks } from '@server/lib/plugins/hooks' | 3 | import { Hooks } from '@server/lib/plugins/hooks' |
5 | import { OAuthTokenModel } from '@server/models/oauth/oauth-token' | 4 | import { OAuthTokenModel } from '@server/models/oauth/oauth-token' |
@@ -17,9 +16,11 @@ import { Notifier } from '../../../lib/notifier' | |||
17 | import { Redis } from '../../../lib/redis' | 16 | import { Redis } from '../../../lib/redis' |
18 | import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyUserEmail } from '../../../lib/user' | 17 | import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyUserEmail } from '../../../lib/user' |
19 | import { | 18 | import { |
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' |
38 | import { | 38 | import { |
@@ -54,13 +54,13 @@ import { myVideoPlaylistsRouter } from './my-video-playlists' | |||
54 | 54 | ||
55 | const auditLogger = auditLoggerFactory('users') | 55 | const auditLogger = auditLoggerFactory('users') |
56 | 56 | ||
57 | const signupRateLimiter = RateLimit({ | 57 | const 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 | ||
63 | const askSendEmailLimiter = RateLimit({ | 63 | const 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 | ||
279 | async function listUsers (req: express.Request, res: express.Response) { | 279 | async 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 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import RateLimit from 'express-rate-limit' | ||
3 | import { logger } from '@server/helpers/logger' | 2 | import { logger } from '@server/helpers/logger' |
4 | import { CONFIG } from '@server/initializers/config' | 3 | import { CONFIG } from '@server/initializers/config' |
5 | import { getAuthNameFromRefreshGrant, getBypassFromExternalAuth, getBypassFromPasswordGrant } from '@server/lib/auth/external-auth' | 4 | import { getAuthNameFromRefreshGrant, getBypassFromExternalAuth, getBypassFromPasswordGrant } from '@server/lib/auth/external-auth' |
6 | import { handleOAuthToken } from '@server/lib/auth/oauth' | 5 | import { handleOAuthToken } from '@server/lib/auth/oauth' |
7 | import { BypassLogin, revokeToken } from '@server/lib/auth/oauth-model' | 6 | import { BypassLogin, revokeToken } from '@server/lib/auth/oauth-model' |
8 | import { Hooks } from '@server/lib/plugins/hooks' | 7 | import { Hooks } from '@server/lib/plugins/hooks' |
9 | import { asyncMiddleware, authenticate, openapiOperationDoc } from '@server/middlewares' | 8 | import { asyncMiddleware, authenticate, buildRateLimiter, openapiOperationDoc } from '@server/middlewares' |
10 | import { buildUUID } from '@shared/extra-utils' | 9 | import { buildUUID } from '@shared/extra-utils' |
11 | import { ScopedToken } from '@shared/models/users/user-scoped-token' | 10 | import { ScopedToken } from '@shared/models/users/user-scoped-token' |
12 | 11 | ||
13 | const tokensRouter = express.Router() | 12 | const tokensRouter = express.Router() |
14 | 13 | ||
15 | const loginRateLimiter = RateLimit({ | 14 | const 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 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import { Feed } from '@peertube/feed' | ||
3 | import { extname } from 'path' | 2 | import { extname } from 'path' |
3 | import { Feed } from '@peertube/feed' | ||
4 | import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown' | 4 | import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown' |
5 | import { getServerActor } from '@server/models/application/application' | 5 | import { getServerActor } from '@server/models/application/application' |
6 | import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils' | 6 | import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils' |
7 | import { VideoInclude } from '@shared/models' | 7 | import { VideoInclude } from '@shared/models' |
8 | import { buildNSFWFilter } from '../helpers/express-utils' | 8 | import { buildNSFWFilter } from '../helpers/express-utils' |
9 | import { CONFIG } from '../initializers/config' | 9 | import { CONFIG } from '../initializers/config' |
10 | import { FEEDS, MIMETYPES, PREVIEWS_SIZE, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../initializers/constants' | 10 | import { MIMETYPES, PREVIEWS_SIZE, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../initializers/constants' |
11 | import { | 11 | import { |
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 |
60 | const SORTABLE_COLUMNS = { | 60 | const 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 | |||
771 | const FEEDS = { | ||
772 | COUNT: 20 | ||
773 | } | ||
774 | |||
775 | const MAX_LOGS_OUTPUT_CHARACTERS = 10 * 1000 * 1000 | 769 | const MAX_LOGS_OUTPUT_CHARACTERS = 10 * 1000 * 1000 |
776 | const LOG_FILENAME = 'peertube.log' | 770 | const LOG_FILENAME = 'peertube.log' |
777 | const AUDIT_LOG_FILENAME = 'peertube-audit.log' | 771 | const 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' | |||
30 | import { getActivityStreamDuration } from './activitypub/activity' | 30 | import { getActivityStreamDuration } from './activitypub/activity' |
31 | import { getBiggestActorImage } from './actor-image' | 31 | import { getBiggestActorImage } from './actor-image' |
32 | import { ServerConfigManager } from './server-config-manager' | 32 | import { ServerConfigManager } from './server-config-manager' |
33 | import { isTestInstance } from '@server/helpers/core-utils' | ||
33 | 34 | ||
34 | type Tags = { | 35 | type 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' | |||
4 | export * from './async' | 4 | export * from './async' |
5 | export * from './auth' | 5 | export * from './auth' |
6 | export * from './pagination' | 6 | export * from './pagination' |
7 | export * from './rate-limiter' | ||
7 | export * from './robots' | 8 | export * from './robots' |
8 | export * from './servers' | 9 | export * from './servers' |
9 | export * from './sort' | 10 | export * 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 @@ | |||
1 | import { UserRole } from '@shared/models' | ||
2 | import RateLimit from 'express-rate-limit' | ||
3 | import { optionalAuthenticate } from './auth' | ||
4 | |||
5 | const whitelistRoles = new Set([ UserRole.ADMINISTRATOR, UserRole.MODERATOR ]) | ||
6 | |||
7 | function 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 | |||
29 | export { | ||
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 | ||
31 | const usersSortValidator = checkSortFactory(SORTABLE_COLUMNS.USERS) | 31 | const adminUsersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ADMIN_USERS) |
32 | const accountsSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS) | 32 | const accountsSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS) |
33 | const jobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.JOBS, [ 'jobs' ]) | 33 | const jobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.JOBS, [ 'jobs' ]) |
34 | const abusesSortValidator = checkSortFactory(SORTABLE_COLUMNS.ABUSES) | 34 | const abusesSortValidator = checkSortFactory(SORTABLE_COLUMNS.ABUSES) |
@@ -59,7 +59,7 @@ const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CH | |||
59 | // --------------------------------------------------------------------------- | 59 | // --------------------------------------------------------------------------- |
60 | 60 | ||
61 | export { | 61 | export { |
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' | |||
66 | import { ActorFollowModel } from '../actor/actor-follow' | 66 | import { ActorFollowModel } from '../actor/actor-follow' |
67 | import { ActorImageModel } from '../actor/actor-image' | 67 | import { ActorImageModel } from '../actor/actor-image' |
68 | import { OAuthTokenModel } from '../oauth/oauth-token' | 68 | import { OAuthTokenModel } from '../oauth/oauth-token' |
69 | import { getSort, throwIfNotValid } from '../utils' | 69 | import { getAdminUsersSort, throwIfNotValid } from '../utils' |
70 | import { VideoModel } from '../video/video' | 70 | import { VideoModel } from '../video/video' |
71 | import { VideoChannelModel } from '../video/video-channel' | 71 | import { VideoChannelModel } from '../video/video-channel' |
72 | import { VideoImportModel } from '../video/video-import' | 72 | import { 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 | ||
21 | function 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 | |||
23 | function getPlaylistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] { | 40 | function 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 | ||
8 | describe('Test application behind a reverse proxy', function () { | 8 | describe('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 | ||
334 | Change the link to point to the latest version: | 334 | Change 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 |