aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/package.json4
-rw-r--r--client/src/app/+admin/admin.component.html2
-rw-r--r--client/src/app/+admin/admin.module.ts13
-rw-r--r--client/src/app/+admin/follows/follows.component.html6
-rw-r--r--client/src/app/+admin/follows/follows.routes.ts5
-rw-r--r--client/src/app/+admin/follows/index.ts1
-rw-r--r--client/src/app/+admin/follows/shared/redundancy-checkbox.component.ts2
-rw-r--r--client/src/app/+admin/follows/shared/redundancy.service.ts28
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/index.ts1
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.html82
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.scss37
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts178
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.html24
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.scss8
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.ts11
-rw-r--r--client/src/app/+admin/system/jobs/jobs.component.ts15
-rw-r--r--client/src/app/core/server/server.service.ts7
-rw-r--r--client/src/app/shared/images/global-icon.component.ts1
-rw-r--r--client/src/app/shared/instance/instance-statistics.component.ts23
-rw-r--r--client/src/app/shared/shared.module.ts2
-rw-r--r--client/src/app/shared/video/redundancy.service.ts73
-rw-r--r--client/src/app/shared/video/video-actions-dropdown.component.ts28
-rw-r--r--client/src/app/shared/video/video-miniature.component.ts3
-rw-r--r--client/src/app/shared/video/video.model.ts19
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.html5
-rw-r--r--client/src/assets/player/bezels/bezels-plugin.ts86
-rw-r--r--client/src/assets/player/bezels/pause-bezel.ts72
-rw-r--r--client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts23
-rw-r--r--client/src/assets/player/peertube-player-manager.ts64
-rw-r--r--client/src/assets/player/peertube-plugin.ts22
-rw-r--r--client/src/assets/player/peertube-videojs-typings.ts51
-rw-r--r--client/src/assets/player/upnext/end-card.ts155
-rw-r--r--client/src/assets/player/upnext/upnext-plugin.ts155
-rw-r--r--client/src/assets/player/videojs-components/next-video-button.ts29
-rw-r--r--client/src/assets/player/videojs-components/p2p-info-button.ts48
-rw-r--r--client/src/assets/player/videojs-components/peertube-link-button.ts20
-rw-r--r--client/src/assets/player/videojs-components/peertube-load-progress-bar.ts17
-rw-r--r--client/src/assets/player/videojs-components/resolution-menu-button.ts32
-rw-r--r--client/src/assets/player/videojs-components/resolution-menu-item.ts36
-rw-r--r--client/src/assets/player/videojs-components/settings-dialog.ts37
-rw-r--r--client/src/assets/player/videojs-components/settings-menu-button.ts191
-rw-r--r--client/src/assets/player/videojs-components/settings-menu-item.ts159
-rw-r--r--client/src/assets/player/videojs-components/settings-panel-child.ts22
-rw-r--r--client/src/assets/player/videojs-components/settings-panel.ts22
-rw-r--r--client/src/assets/player/videojs-components/theater-button.ts20
-rw-r--r--client/src/assets/player/webtorrent/webtorrent-plugin.ts36
-rw-r--r--client/tsconfig.json2
-rw-r--r--client/webpack/webpack.video-embed.js2
-rw-r--r--client/yarn.lock48
-rw-r--r--config/test.yaml8
-rw-r--r--package.json4
-rwxr-xr-xscripts/ci.sh2
-rwxr-xr-xscripts/dev/index.sh4
-rw-r--r--server.ts4
-rw-r--r--server/controllers/api/server/follows.ts4
-rw-r--r--server/controllers/api/server/redundancy.ts84
-rw-r--r--server/controllers/api/server/stats.ts10
-rw-r--r--server/controllers/api/videos/index.ts3
-rw-r--r--server/controllers/webfinger.ts4
-rw-r--r--server/helpers/custom-validators/activitypub/cache-file.ts2
-rw-r--r--server/helpers/custom-validators/video-redundancies.ts12
-rw-r--r--server/helpers/ffmpeg-utils.ts15
-rw-r--r--server/helpers/logger.ts2
-rw-r--r--server/helpers/webtorrent.ts4
-rw-r--r--server/initializers/config.ts4
-rw-r--r--server/initializers/constants.ts27
-rw-r--r--server/initializers/migrations/0475-redundancy-expires-on.ts27
-rw-r--r--server/lib/activitypub/cache-file.ts4
-rw-r--r--server/lib/activitypub/videos.ts1
-rw-r--r--server/lib/job-queue/handlers/video-redundancy.ts20
-rw-r--r--server/lib/job-queue/job-queue.ts16
-rw-r--r--server/lib/redundancy.ts8
-rw-r--r--server/lib/schedulers/update-videos-scheduler.ts1
-rw-r--r--server/lib/schedulers/videos-redundancy-scheduler.ts59
-rw-r--r--server/middlewares/sort.ts23
-rw-r--r--server/middlewares/validators/redundancy.ts74
-rw-r--r--server/middlewares/validators/sort.ts3
-rw-r--r--server/middlewares/validators/videos/videos.ts2
-rw-r--r--server/middlewares/validators/webfinger.ts5
-rw-r--r--server/models/account/account-blocklist.ts2
-rw-r--r--server/models/account/user-notification.ts2
-rw-r--r--server/models/activitypub/actor.ts37
-rw-r--r--server/models/redundancy/video-redundancy.ts186
-rw-r--r--server/models/server/server-blocklist.ts2
-rw-r--r--server/models/video/video-caption.ts2
-rw-r--r--server/models/video/video-channel.ts23
-rw-r--r--server/models/video/video-playlist.ts2
-rw-r--r--server/models/video/video.ts153
-rw-r--r--server/tests/api/check-params/redundancy.ts141
-rw-r--r--server/tests/api/redundancy/index.ts1
-rw-r--r--server/tests/api/redundancy/manage-redundancy.ts373
-rw-r--r--server/tests/api/redundancy/redundancy.ts138
-rw-r--r--server/tests/api/videos/video-transcoder.ts34
-rw-r--r--server/tests/cli/peertube.ts81
-rw-r--r--server/tools/cli.ts20
-rw-r--r--server/tools/package.json5
-rw-r--r--server/tools/peertube-auth.ts7
-rw-r--r--server/tools/peertube-import-videos.ts4
-rw-r--r--server/tools/peertube-plugins.ts25
-rw-r--r--server/tools/peertube-redundancy.ts194
-rw-r--r--server/tools/peertube.ts1
-rw-r--r--server/tools/yarn.lock23
-rw-r--r--server/typings/express.ts3
-rw-r--r--server/typings/models/video/video-file.ts5
-rw-r--r--server/typings/models/video/video-streaming-playlist.ts6
-rw-r--r--server/typings/models/video/video.ts13
-rw-r--r--shared/extra-utils/miscs/miscs.ts25
-rw-r--r--shared/extra-utils/miscs/sql.ts1
-rw-r--r--shared/extra-utils/server/redundancy.ts70
-rw-r--r--shared/extra-utils/videos/videos.ts18
-rw-r--r--shared/models/redundancy/index.ts4
-rw-r--r--shared/models/redundancy/video-redundancies-filters.model.ts1
-rw-r--r--shared/models/redundancy/video-redundancy.model.ts33
-rw-r--r--shared/models/redundancy/videos-redundancy-strategy.model.ts (renamed from shared/models/redundancy/videos-redundancy.model.ts)3
-rw-r--r--shared/models/server/job.model.ts3
-rw-r--r--shared/models/server/server-stats.model.ts18
-rw-r--r--shared/models/users/user-right.enum.ts4
-rw-r--r--shared/models/videos/video-transcoding-fps.model.ts8
-rw-r--r--shared/models/videos/video.model.ts2
-rw-r--r--support/doc/api/openapi.yaml6
-rw-r--r--support/doc/production.md2
-rw-r--r--support/doc/tools.md32
122 files changed, 3082 insertions, 994 deletions
diff --git a/client/package.json b/client/package.json
index cd0a82aa4..e9c74787c 100644
--- a/client/package.json
+++ b/client/package.json
@@ -69,7 +69,7 @@
69 "@types/node": "^10.9.2", 69 "@types/node": "^10.9.2",
70 "@types/sanitize-html": "1.18.0", 70 "@types/sanitize-html": "1.18.0",
71 "@types/socket.io-client": "^1.4.32", 71 "@types/socket.io-client": "^1.4.32",
72 "@types/video.js": "^7.2.5", 72 "@types/video.js": "^7.3.3",
73 "@types/webtorrent": "^0.107.0", 73 "@types/webtorrent": "^0.107.0",
74 "angular2-hotkeys": "^2.1.2", 74 "angular2-hotkeys": "^2.1.2",
75 "angularx-qrcode": "1.6.4", 75 "angularx-qrcode": "1.6.4",
@@ -77,6 +77,7 @@
77 "bootstrap": "^4.1.3", 77 "bootstrap": "^4.1.3",
78 "buffer": "^5.1.0", 78 "buffer": "^5.1.0",
79 "cache-chunk-store": "^3.0.0", 79 "cache-chunk-store": "^3.0.0",
80 "chart.js": "^2.9.3",
80 "codelyzer": "^5.0.1", 81 "codelyzer": "^5.0.1",
81 "core-js": "^3.1.4", 82 "core-js": "^3.1.4",
82 "css-loader": "^3.1.0", 83 "css-loader": "^3.1.0",
@@ -132,6 +133,7 @@
132 "videojs-dock": "^2.0.2", 133 "videojs-dock": "^2.0.2",
133 "videojs-hotkeys": "^0.2.21", 134 "videojs-hotkeys": "^0.2.21",
134 "videostream": "~3.2.1", 135 "videostream": "~3.2.1",
136 "vtt.js": "^0.13.0",
135 "webpack-bundle-analyzer": "^3.0.2", 137 "webpack-bundle-analyzer": "^3.0.2",
136 "webpack-cli": "^3.0.8", 138 "webpack-cli": "^3.0.8",
137 "webtorrent": "^0.107.16", 139 "webtorrent": "^0.107.16",
diff --git a/client/src/app/+admin/admin.component.html b/client/src/app/+admin/admin.component.html
index 9a3d90c18..0d06aaedc 100644
--- a/client/src/app/+admin/admin.component.html
+++ b/client/src/app/+admin/admin.component.html
@@ -5,7 +5,7 @@
5 </a> 5 </a>
6 6
7 <a i18n *ngIf="hasServerFollowRight()" routerLink="/admin/follows" routerLinkActive="active" class="title-page"> 7 <a i18n *ngIf="hasServerFollowRight()" routerLink="/admin/follows" routerLinkActive="active" class="title-page">
8 Manage follows 8 Follows & redundancies
9 </a> 9 </a>
10 10
11 <a i18n *ngIf="hasVideoAbusesRight() || hasVideoBlacklistRight()" routerLink="/admin/moderation" routerLinkActive="active" class="title-page"> 11 <a i18n *ngIf="hasVideoAbusesRight() || hasVideoBlacklistRight()" routerLink="/admin/moderation" routerLinkActive="active" class="title-page">
diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts
index 9c56b5750..fdbe70314 100644
--- a/client/src/app/+admin/admin.module.ts
+++ b/client/src/app/+admin/admin.module.ts
@@ -5,7 +5,7 @@ import { TableModule } from 'primeng/table'
5import { SharedModule } from '../shared' 5import { SharedModule } from '../shared'
6import { AdminRoutingModule } from './admin-routing.module' 6import { AdminRoutingModule } from './admin-routing.module'
7import { AdminComponent } from './admin.component' 7import { AdminComponent } from './admin.component'
8import { FollowersListComponent, FollowingAddComponent, FollowsComponent } from './follows' 8import { FollowersListComponent, FollowingAddComponent, FollowsComponent, VideoRedundanciesListComponent } from './follows'
9import { FollowingListComponent } from './follows/following-list/following-list.component' 9import { FollowingListComponent } from './follows/following-list/following-list.component'
10import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersComponent, UserUpdateComponent } from './users' 10import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersComponent, UserUpdateComponent } from './users'
11import { 11import {
@@ -16,7 +16,6 @@ import {
16} from './moderation' 16} from './moderation'
17import { ModerationComponent } from '@app/+admin/moderation/moderation.component' 17import { ModerationComponent } from '@app/+admin/moderation/moderation.component'
18import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component' 18import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component'
19import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service'
20import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist' 19import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist'
21import { JobsComponent } from '@app/+admin/system/jobs/jobs.component' 20import { JobsComponent } from '@app/+admin/system/jobs/jobs.component'
22import { JobService, LogsComponent, LogsService, SystemComponent } from '@app/+admin/system' 21import { JobService, LogsComponent, LogsService, SystemComponent } from '@app/+admin/system'
@@ -27,13 +26,18 @@ import { PluginSearchComponent } from '@app/+admin/plugins/plugin-search/plugin-
27import { PluginShowInstalledComponent } from '@app/+admin/plugins/plugin-show-installed/plugin-show-installed.component' 26import { PluginShowInstalledComponent } from '@app/+admin/plugins/plugin-show-installed/plugin-show-installed.component'
28import { SelectButtonModule } from 'primeng/selectbutton' 27import { SelectButtonModule } from 'primeng/selectbutton'
29import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service' 28import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
29import { VideoRedundancyInformationComponent } from '@app/+admin/follows/video-redundancies-list/video-redundancy-information.component'
30import { ChartModule } from 'primeng/chart'
30 31
31@NgModule({ 32@NgModule({
32 imports: [ 33 imports: [
33 AdminRoutingModule, 34 AdminRoutingModule,
35
36 SharedModule,
37
34 TableModule, 38 TableModule,
35 SelectButtonModule, 39 SelectButtonModule,
36 SharedModule 40 ChartModule
37 ], 41 ],
38 42
39 declarations: [ 43 declarations: [
@@ -44,6 +48,8 @@ import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
44 FollowersListComponent, 48 FollowersListComponent,
45 FollowingListComponent, 49 FollowingListComponent,
46 RedundancyCheckboxComponent, 50 RedundancyCheckboxComponent,
51 VideoRedundanciesListComponent,
52 VideoRedundancyInformationComponent,
47 53
48 UsersComponent, 54 UsersComponent,
49 UserCreateComponent, 55 UserCreateComponent,
@@ -78,7 +84,6 @@ import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
78 ], 84 ],
79 85
80 providers: [ 86 providers: [
81 RedundancyService,
82 JobService, 87 JobService,
83 LogsService, 88 LogsService,
84 DebugService, 89 DebugService,
diff --git a/client/src/app/+admin/follows/follows.component.html b/client/src/app/+admin/follows/follows.component.html
index 21d477132..46581daf9 100644
--- a/client/src/app/+admin/follows/follows.component.html
+++ b/client/src/app/+admin/follows/follows.component.html
@@ -1,5 +1,5 @@
1<div class="admin-sub-header"> 1<div class="admin-sub-header">
2 <div i18n class="form-sub-title">Manage follows</div> 2 <div i18n class="form-sub-title">Follows & redundancies</div>
3 3
4 <div class="admin-sub-nav"> 4 <div class="admin-sub-nav">
5 <a i18n routerLink="following-list" routerLinkActive="active">Following</a> 5 <a i18n routerLink="following-list" routerLinkActive="active">Following</a>
@@ -7,7 +7,9 @@
7 <a i18n routerLink="following-add" routerLinkActive="active">Follow</a> 7 <a i18n routerLink="following-add" routerLinkActive="active">Follow</a>
8 8
9 <a i18n routerLink="followers-list" routerLinkActive="active">Followers</a> 9 <a i18n routerLink="followers-list" routerLinkActive="active">Followers</a>
10
11 <a i18n routerLink="video-redundancies-list" routerLinkActive="active">Video redundancies</a>
10 </div> 12 </div>
11</div> 13</div>
12 14
13<router-outlet></router-outlet> \ No newline at end of file 15<router-outlet></router-outlet>
diff --git a/client/src/app/+admin/follows/follows.routes.ts b/client/src/app/+admin/follows/follows.routes.ts
index e84c79e82..298733eb0 100644
--- a/client/src/app/+admin/follows/follows.routes.ts
+++ b/client/src/app/+admin/follows/follows.routes.ts
@@ -6,6 +6,7 @@ import { FollowingAddComponent } from './following-add'
6import { FollowersListComponent } from './followers-list' 6import { FollowersListComponent } from './followers-list'
7import { UserRight } from '../../../../../shared' 7import { UserRight } from '../../../../../shared'
8import { FollowingListComponent } from './following-list/following-list.component' 8import { FollowingListComponent } from './following-list/following-list.component'
9import { VideoRedundanciesListComponent } from '@app/+admin/follows/video-redundancies-list'
9 10
10export const FollowsRoutes: Routes = [ 11export const FollowsRoutes: Routes = [
11 { 12 {
@@ -47,6 +48,10 @@ export const FollowsRoutes: Routes = [
47 title: 'Add follow' 48 title: 'Add follow'
48 } 49 }
49 } 50 }
51 },
52 {
53 path: 'video-redundancies-list',
54 component: VideoRedundanciesListComponent
50 } 55 }
51 ] 56 ]
52 } 57 }
diff --git a/client/src/app/+admin/follows/index.ts b/client/src/app/+admin/follows/index.ts
index e94f33710..4fcb35cb1 100644
--- a/client/src/app/+admin/follows/index.ts
+++ b/client/src/app/+admin/follows/index.ts
@@ -1,5 +1,6 @@
1export * from './following-add' 1export * from './following-add'
2export * from './followers-list' 2export * from './followers-list'
3export * from './following-list' 3export * from './following-list'
4export * from './video-redundancies-list'
4export * from './follows.component' 5export * from './follows.component'
5export * from './follows.routes' 6export * from './follows.routes'
diff --git a/client/src/app/+admin/follows/shared/redundancy-checkbox.component.ts b/client/src/app/+admin/follows/shared/redundancy-checkbox.component.ts
index fa1da26bf..9d7883d97 100644
--- a/client/src/app/+admin/follows/shared/redundancy-checkbox.component.ts
+++ b/client/src/app/+admin/follows/shared/redundancy-checkbox.component.ts
@@ -1,7 +1,7 @@
1import { Component, Input } from '@angular/core' 1import { Component, Input } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { I18n } from '@ngx-translate/i18n-polyfill' 3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service' 4import { RedundancyService } from '@app/shared/video/redundancy.service'
5 5
6@Component({ 6@Component({
7 selector: 'my-redundancy-checkbox', 7 selector: 'my-redundancy-checkbox',
diff --git a/client/src/app/+admin/follows/shared/redundancy.service.ts b/client/src/app/+admin/follows/shared/redundancy.service.ts
deleted file mode 100644
index 87ae01c04..000000000
--- a/client/src/app/+admin/follows/shared/redundancy.service.ts
+++ /dev/null
@@ -1,28 +0,0 @@
1import { catchError, map } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { RestExtractor } from '@app/shared'
5import { environment } from '../../../../environments/environment'
6
7@Injectable()
8export class RedundancyService {
9 static BASE_USER_SUBSCRIPTIONS_URL = environment.apiUrl + '/api/v1/server/redundancy'
10
11 constructor (
12 private authHttp: HttpClient,
13 private restExtractor: RestExtractor
14 ) { }
15
16 updateRedundancy (host: string, redundancyAllowed: boolean) {
17 const url = RedundancyService.BASE_USER_SUBSCRIPTIONS_URL + '/' + host
18
19 const body = { redundancyAllowed }
20
21 return this.authHttp.put(url, body)
22 .pipe(
23 map(this.restExtractor.extractDataBool),
24 catchError(err => this.restExtractor.handleError(err))
25 )
26 }
27
28}
diff --git a/client/src/app/+admin/follows/video-redundancies-list/index.ts b/client/src/app/+admin/follows/video-redundancies-list/index.ts
new file mode 100644
index 000000000..6a7c7f483
--- /dev/null
+++ b/client/src/app/+admin/follows/video-redundancies-list/index.ts
@@ -0,0 +1 @@
export * from './video-redundancies-list.component'
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.html b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.html
new file mode 100644
index 000000000..80c66ec60
--- /dev/null
+++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.html
@@ -0,0 +1,82 @@
1<div class="admin-sub-header">
2 <div i18n class="form-sub-title">Video redundancies list</div>
3
4 <div class="select-filter-block">
5 <label for="displayType" i18n>Display</label>
6
7 <div class="peertube-select-container">
8 <select id="displayType" name="displayType" [(ngModel)]="displayType" (ngModelChange)="onDisplayTypeChanged()">
9 <option value="my-videos">My videos duplicated by remote instances</option>
10 <option value="remote-videos">Remote videos duplicated by my instance</option>
11 </select>
12 </div>
13 </div>
14</div>
15
16<p-table
17 [value]="videoRedundancies" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage"
18 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id"
19>
20 <ng-template pTemplate="header">
21 <tr>
22 <th i18n *ngIf="isDisplayingRemoteVideos()">Strategy</th>
23 <th i18n pSortableColumn="name">Video name <p-sortIcon field="name"></p-sortIcon></th>
24 <th i18n>Video URL</th>
25 <th i18n *ngIf="isDisplayingRemoteVideos()">Total size</th>
26 <th></th>
27 </tr>
28 </ng-template>
29
30 <ng-template pTemplate="body" let-redundancy>
31 <tr class="expander" [pRowToggler]="redundancy">
32 <td *ngIf="isDisplayingRemoteVideos()">{{ getRedundancyStrategy(redundancy) }}</td>
33
34 <td>{{ redundancy.name }}</td>
35
36 <td>
37 <a target="_blank" rel="noopener noreferrer" [href]="redundancy.url">{{ redundancy.url }}</a>
38 </td>
39
40 <td *ngIf="isDisplayingRemoteVideos()">{{ getTotalSize(redundancy) | bytes: 1 }}</td>
41
42 <td class="action-cell">
43 <my-delete-button (click)="removeRedundancy(redundancy)"></my-delete-button>
44 </td>
45 </tr>
46 </ng-template>
47
48 <ng-template pTemplate="rowexpansion" let-redundancy>
49 <tr>
50 <td colspan="2">
51 <div *ngFor="let file of redundancy.redundancies.files" class="expansion-block">
52 <my-video-redundancy-information [redundancyElement]="file"></my-video-redundancy-information>
53 </div>
54 </td>
55 </tr>
56
57 <tr>
58 <td colspan="2">
59 <div *ngFor="let playlist of redundancy.redundancies.streamingPlaylists">
60 <my-video-redundancy-information [redundancyElement]="playlist"></my-video-redundancy-information>
61 </div>
62 </td>
63 </tr>
64 </ng-template>
65</p-table>
66
67
68<div class="redundancies-charts" *ngIf="isDisplayingRemoteVideos()">
69 <div class="form-sub-title" i18n>Enabled strategies stats</div>
70
71 <div class="chart-blocks">
72
73 <div *ngIf="noRedundancies" i18n class="no-results">
74 No redundancy strategy is enabled on your instance.
75 </div>
76
77 <div class="chart-block" *ngFor="let r of redundanciesGraphsData">
78 <p-chart type="pie" [data]="r.graphData" [options]="r.options" width="300px" height="300px"></p-chart>
79 </div>
80
81 </div>
82</div>
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.scss b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.scss
new file mode 100644
index 000000000..05018c281
--- /dev/null
+++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.scss
@@ -0,0 +1,37 @@
1@import '_variables';
2@import '_mixins';
3
4.expansion-block {
5 margin-bottom: 20px;
6}
7
8.admin-sub-header {
9 align-items: flex-end;
10
11 .select-filter-block {
12 &:not(:last-child) {
13 margin-right: 10px;
14 }
15
16 label {
17 margin-bottom: 2px;
18 }
19
20 .peertube-select-container {
21 @include peertube-select-container(auto);
22 }
23 }
24}
25
26.redundancies-charts {
27 margin-top: 50px;
28
29 .chart-blocks {
30 display: flex;
31 justify-content: center;
32
33 .chart-block {
34 margin: 0 20px;
35 }
36 }
37}
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts
new file mode 100644
index 000000000..4b41d1d86
--- /dev/null
+++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts
@@ -0,0 +1,178 @@
1import { Component, OnInit } from '@angular/core'
2import { Notifier, ServerService } from '@app/core'
3import { SortMeta } from 'primeng/api'
4import { ConfirmService } from '../../../core/confirm/confirm.service'
5import { RestPagination, RestTable } from '../../../shared'
6import { I18n } from '@ngx-translate/i18n-polyfill'
7import { VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
8import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
9import { VideosRedundancyStats } from '@shared/models/server'
10import { BytesPipe } from 'ngx-pipes'
11import { RedundancyService } from '@app/shared/video/redundancy.service'
12
13@Component({
14 selector: 'my-video-redundancies-list',
15 templateUrl: './video-redundancies-list.component.html',
16 styleUrls: [ './video-redundancies-list.component.scss' ]
17})
18export class VideoRedundanciesListComponent extends RestTable implements OnInit {
19 private static LOCAL_STORAGE_DISPLAY_TYPE = 'video-redundancies-list-display-type'
20
21 videoRedundancies: VideoRedundancy[] = []
22 totalRecords = 0
23 rowsPerPage = 10
24
25 sort: SortMeta = { field: 'name', order: 1 }
26 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
27 displayType: VideoRedundanciesTarget = 'my-videos'
28
29 redundanciesGraphsData: { stats: VideosRedundancyStats, graphData: object, options: object }[] = []
30
31 noRedundancies = false
32
33 private bytesPipe: BytesPipe
34
35 constructor (
36 private notifier: Notifier,
37 private confirmService: ConfirmService,
38 private redundancyService: RedundancyService,
39 private serverService: ServerService,
40 private i18n: I18n
41 ) {
42 super()
43
44 this.bytesPipe = new BytesPipe()
45 }
46
47 ngOnInit () {
48 this.loadSelectLocalStorage()
49
50 this.initialize()
51
52 this.serverService.getServerStats()
53 .subscribe(res => {
54 const redundancies = res.videosRedundancy
55
56 if (redundancies.length === 0) this.noRedundancies = true
57
58 for (const r of redundancies) {
59 this.buildPieData(r)
60 }
61 })
62 }
63
64 isDisplayingRemoteVideos () {
65 return this.displayType === 'remote-videos'
66 }
67
68 getTotalSize (redundancy: VideoRedundancy) {
69 return redundancy.redundancies.files.reduce((a, b) => a + b.size, 0) +
70 redundancy.redundancies.streamingPlaylists.reduce((a, b) => a + b.size, 0)
71 }
72
73 onDisplayTypeChanged () {
74 this.pagination.start = 0
75 this.saveSelectLocalStorage()
76
77 this.loadData()
78 }
79
80 getRedundancyStrategy (redundancy: VideoRedundancy) {
81 if (redundancy.redundancies.files.length !== 0) return redundancy.redundancies.files[0].strategy
82 if (redundancy.redundancies.streamingPlaylists.length !== 0) return redundancy.redundancies.streamingPlaylists[0].strategy
83
84 return ''
85 }
86
87 buildPieData (stats: VideosRedundancyStats) {
88 const totalSize = stats.totalSize
89 ? stats.totalSize - stats.totalUsed
90 : stats.totalUsed
91
92 if (totalSize === 0) return
93
94 this.redundanciesGraphsData.push({
95 stats,
96 graphData: {
97 labels: [ this.i18n('Used'), this.i18n('Available') ],
98 datasets: [
99 {
100 data: [ stats.totalUsed, totalSize ],
101 backgroundColor: [
102 '#FF6384',
103 '#36A2EB'
104 ],
105 hoverBackgroundColor: [
106 '#FF6384',
107 '#36A2EB'
108 ]
109 }
110 ]
111 },
112 options: {
113 title: {
114 display: true,
115 text: stats.strategy
116 },
117
118 tooltips: {
119 callbacks: {
120 label: (tooltipItem: any, data: any) => {
121 const dataset = data.datasets[tooltipItem.datasetIndex]
122 let label = data.labels[tooltipItem.index]
123 if (label) label += ': '
124 else label = ''
125
126 label += this.bytesPipe.transform(dataset.data[tooltipItem.index], 1)
127 return label
128 }
129 }
130 }
131 }
132 })
133 }
134
135 async removeRedundancy (redundancy: VideoRedundancy) {
136 const message = this.i18n('Do you really want to remove this video redundancy?')
137 const res = await this.confirmService.confirm(message, this.i18n('Remove redundancy'))
138 if (res === false) return
139
140 this.redundancyService.removeVideoRedundancies(redundancy)
141 .subscribe(
142 () => {
143 this.notifier.success(this.i18n('Video redundancies removed!'))
144 this.loadData()
145 },
146
147 err => this.notifier.error(err.message)
148 )
149
150 }
151
152 protected loadData () {
153 const options = {
154 pagination: this.pagination,
155 sort: this.sort,
156 target: this.displayType
157 }
158
159 this.redundancyService.listVideoRedundancies(options)
160 .subscribe(
161 resultList => {
162 this.videoRedundancies = resultList.data
163 this.totalRecords = resultList.total
164 },
165
166 err => this.notifier.error(err.message)
167 )
168 }
169
170 private loadSelectLocalStorage () {
171 const displayType = peertubeLocalStorage.getItem(VideoRedundanciesListComponent.LOCAL_STORAGE_DISPLAY_TYPE)
172 if (displayType) this.displayType = displayType as VideoRedundanciesTarget
173 }
174
175 private saveSelectLocalStorage () {
176 peertubeLocalStorage.setItem(VideoRedundanciesListComponent.LOCAL_STORAGE_DISPLAY_TYPE, this.displayType)
177 }
178}
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.html b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.html
new file mode 100644
index 000000000..a379520e3
--- /dev/null
+++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.html
@@ -0,0 +1,24 @@
1<div>
2 <span class="label">Url</span>
3 <a target="_blank" rel="noopener noreferrer" [href]="redundancyElement.fileUrl">{{ redundancyElement.fileUrl }}</a>
4</div>
5
6<div>
7 <span class="label">Created on</span>
8 <span>{{ redundancyElement.createdAt | date: 'medium' }}</span>
9</div>
10
11<div>
12 <span class="label">Expires on</span>
13 <span>{{ redundancyElement.expiresOn | date: 'medium' }}</span>
14</div>
15
16<div>
17 <span class="label">Size</span>
18 <span>{{ redundancyElement.size | bytes: 1 }}</span>
19</div>
20
21<div *ngIf="redundancyElement.strategy">
22 <span class="label">Strategy</span>
23 <span>{{ redundancyElement.strategy }}</span>
24</div>
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.scss b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.scss
new file mode 100644
index 000000000..6b09fbb01
--- /dev/null
+++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.scss
@@ -0,0 +1,8 @@
1@import '_variables';
2@import '_mixins';
3
4.label {
5 display: inline-block;
6 min-width: 100px;
7 font-weight: $font-semibold;
8}
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.ts b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.ts
new file mode 100644
index 000000000..6f3090c08
--- /dev/null
+++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.ts
@@ -0,0 +1,11 @@
1import { Component, Input } from '@angular/core'
2import { FileRedundancyInformation, StreamingPlaylistRedundancyInformation } from '@shared/models'
3
4@Component({
5 selector: 'my-video-redundancy-information',
6 templateUrl: './video-redundancy-information.component.html',
7 styleUrls: [ './video-redundancy-information.component.scss' ]
8})
9export class VideoRedundancyInformationComponent {
10 @Input() redundancyElement: FileRedundancyInformation | StreamingPlaylistRedundancyInformation
11}
diff --git a/client/src/app/+admin/system/jobs/jobs.component.ts b/client/src/app/+admin/system/jobs/jobs.component.ts
index 20c8ea71a..bc40452cf 100644
--- a/client/src/app/+admin/system/jobs/jobs.component.ts
+++ b/client/src/app/+admin/system/jobs/jobs.component.ts
@@ -16,8 +16,8 @@ import { JobTypeClient } from '../../../../types/job-type-client.type'
16 styleUrls: [ './jobs.component.scss' ] 16 styleUrls: [ './jobs.component.scss' ]
17}) 17})
18export class JobsComponent extends RestTable implements OnInit { 18export class JobsComponent extends RestTable implements OnInit {
19 private static JOB_STATE_LOCAL_STORAGE_STATE = 'jobs-list-state' 19 private static LOCAL_STORAGE_STATE = 'jobs-list-state'
20 private static JOB_STATE_LOCAL_STORAGE_TYPE = 'jobs-list-type' 20 private static LOCAL_STORAGE_TYPE = 'jobs-list-type'
21 21
22 jobState: JobStateClient = 'waiting' 22 jobState: JobStateClient = 'waiting'
23 jobStates: JobStateClient[] = [ 'active', 'completed', 'failed', 'waiting', 'delayed' ] 23 jobStates: JobStateClient[] = [ 'active', 'completed', 'failed', 'waiting', 'delayed' ]
@@ -34,7 +34,8 @@ export class JobsComponent extends RestTable implements OnInit {
34 'video-file-import', 34 'video-file-import',
35 'video-import', 35 'video-import',
36 'videos-views', 36 'videos-views',
37 'activitypub-refresher' 37 'activitypub-refresher',
38 'video-redundancy'
38 ] 39 ]
39 40
40 jobs: Job[] = [] 41 jobs: Job[] = []
@@ -77,15 +78,15 @@ export class JobsComponent extends RestTable implements OnInit {
77 } 78 }
78 79
79 private loadJobStateAndType () { 80 private loadJobStateAndType () {
80 const state = peertubeLocalStorage.getItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_STATE) 81 const state = peertubeLocalStorage.getItem(JobsComponent.LOCAL_STORAGE_STATE)
81 if (state) this.jobState = state as JobState 82 if (state) this.jobState = state as JobState
82 83
83 const type = peertubeLocalStorage.getItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_TYPE) 84 const type = peertubeLocalStorage.getItem(JobsComponent.LOCAL_STORAGE_TYPE)
84 if (type) this.jobType = type as JobType 85 if (type) this.jobType = type as JobType
85 } 86 }
86 87
87 private saveJobStateAndType () { 88 private saveJobStateAndType () {
88 peertubeLocalStorage.setItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_STATE, this.jobState) 89 peertubeLocalStorage.setItem(JobsComponent.LOCAL_STORAGE_STATE, this.jobState)
89 peertubeLocalStorage.setItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_TYPE, this.jobType) 90 peertubeLocalStorage.setItem(JobsComponent.LOCAL_STORAGE_TYPE, this.jobType)
90 } 91 }
91} 92}
diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts
index cdcbcb528..1f6cfb596 100644
--- a/client/src/app/core/server/server.service.ts
+++ b/client/src/app/core/server/server.service.ts
@@ -9,6 +9,7 @@ import { VideoConstant } from '../../../../../shared/models/videos'
9import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n' 9import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n'
10import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' 10import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
11import { sortBy } from '@app/shared/misc/utils' 11import { sortBy } from '@app/shared/misc/utils'
12import { ServerStats } from '@shared/models/server'
12 13
13@Injectable() 14@Injectable()
14export class ServerService { 15export class ServerService {
@@ -16,6 +17,8 @@ export class ServerService {
16 private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' 17 private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
17 private static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/' 18 private static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/'
18 private static BASE_LOCALE_URL = environment.apiUrl + '/client/locales/' 19 private static BASE_LOCALE_URL = environment.apiUrl + '/client/locales/'
20 private static BASE_STATS_URL = environment.apiUrl + '/api/v1/server/stats'
21
19 private static CONFIG_LOCAL_STORAGE_KEY = 'server-config' 22 private static CONFIG_LOCAL_STORAGE_KEY = 'server-config'
20 23
21 configReloaded = new Subject<void>() 24 configReloaded = new Subject<void>()
@@ -235,6 +238,10 @@ export class ServerService {
235 return this.localeObservable.pipe(first()) 238 return this.localeObservable.pipe(first())
236 } 239 }
237 240
241 getServerStats () {
242 return this.http.get<ServerStats>(ServerService.BASE_STATS_URL)
243 }
244
238 private loadAttributeEnum <T extends string | number> ( 245 private loadAttributeEnum <T extends string | number> (
239 baseUrl: string, 246 baseUrl: string,
240 attributeName: 'categories' | 'licences' | 'languages' | 'privacies', 247 attributeName: 'categories' | 'licences' | 'languages' | 'privacies',
diff --git a/client/src/app/shared/images/global-icon.component.ts b/client/src/app/shared/images/global-icon.component.ts
index 806aca347..b6e641228 100644
--- a/client/src/app/shared/images/global-icon.component.ts
+++ b/client/src/app/shared/images/global-icon.component.ts
@@ -1,6 +1,5 @@
1import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit } from '@angular/core' 1import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit } from '@angular/core'
2import { HooksService } from '@app/core/plugins/hooks.service' 2import { HooksService } from '@app/core/plugins/hooks.service'
3import { I18n } from '@ngx-translate/i18n-polyfill'
4 3
5const icons = { 4const icons = {
6 'add': require('!!raw-loader?!../../../assets/images/global/add.svg'), 5 'add': require('!!raw-loader?!../../../assets/images/global/add.svg'),
diff --git a/client/src/app/shared/instance/instance-statistics.component.ts b/client/src/app/shared/instance/instance-statistics.component.ts
index 8ec728f05..40aa8a4c0 100644
--- a/client/src/app/shared/instance/instance-statistics.component.ts
+++ b/client/src/app/shared/instance/instance-statistics.component.ts
@@ -1,9 +1,6 @@
1import { map } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http'
3import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { ServerStats } from '@shared/models/server' 2import { ServerStats } from '@shared/models/server'
6import { environment } from '../../../environments/environment' 3import { ServerService } from '@app/core'
7 4
8@Component({ 5@Component({
9 selector: 'my-instance-statistics', 6 selector: 'my-instance-statistics',
@@ -11,27 +8,15 @@ import { environment } from '../../../environments/environment'
11 styleUrls: [ './instance-statistics.component.scss' ] 8 styleUrls: [ './instance-statistics.component.scss' ]
12}) 9})
13export class InstanceStatisticsComponent implements OnInit { 10export class InstanceStatisticsComponent implements OnInit {
14 private static BASE_STATS_URL = environment.apiUrl + '/api/v1/server/stats'
15
16 serverStats: ServerStats = null 11 serverStats: ServerStats = null
17 12
18 constructor ( 13 constructor (
19 private http: HttpClient, 14 private serverService: ServerService
20 private i18n: I18n
21 ) { 15 ) {
22 } 16 }
23 17
24 ngOnInit () { 18 ngOnInit () {
25 this.getStats() 19 this.serverService.getServerStats()
26 .subscribe( 20 .subscribe(res => this.serverStats = res)
27 res => {
28 this.serverStats = res
29 }
30 )
31 }
32
33 getStats () {
34 return this.http
35 .get<ServerStats>(InstanceStatisticsComponent.BASE_STATS_URL)
36 } 21 }
37} 22}
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index b2eb13f73..d06d37d8c 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -98,6 +98,7 @@ import { FollowService } from '@app/shared/instance/follow.service'
98import { MultiSelectModule } from 'primeng/multiselect' 98import { MultiSelectModule } from 'primeng/multiselect'
99import { FeatureBooleanComponent } from '@app/shared/instance/feature-boolean.component' 99import { FeatureBooleanComponent } from '@app/shared/instance/feature-boolean.component'
100import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-copy.component' 100import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-copy.component'
101import { RedundancyService } from '@app/shared/video/redundancy.service'
101 102
102@NgModule({ 103@NgModule({
103 imports: [ 104 imports: [
@@ -300,6 +301,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
300 UserNotificationService, 301 UserNotificationService,
301 302
302 FollowService, 303 FollowService,
304 RedundancyService,
303 305
304 I18n 306 I18n
305 ] 307 ]
diff --git a/client/src/app/shared/video/redundancy.service.ts b/client/src/app/shared/video/redundancy.service.ts
new file mode 100644
index 000000000..fb918d73b
--- /dev/null
+++ b/client/src/app/shared/video/redundancy.service.ts
@@ -0,0 +1,73 @@
1import { catchError, map, toArray } from 'rxjs/operators'
2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { RestExtractor, RestPagination, RestService } from '@app/shared/rest'
5import { SortMeta } from 'primeng/api'
6import { ResultList, Video, VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
7import { concat, Observable } from 'rxjs'
8import { environment } from '../../../environments/environment'
9
10@Injectable()
11export class RedundancyService {
12 static BASE_REDUNDANCY_URL = environment.apiUrl + '/api/v1/server/redundancy'
13
14 constructor (
15 private authHttp: HttpClient,
16 private restService: RestService,
17 private restExtractor: RestExtractor
18 ) { }
19
20 updateRedundancy (host: string, redundancyAllowed: boolean) {
21 const url = RedundancyService.BASE_REDUNDANCY_URL + '/' + host
22
23 const body = { redundancyAllowed }
24
25 return this.authHttp.put(url, body)
26 .pipe(
27 map(this.restExtractor.extractDataBool),
28 catchError(err => this.restExtractor.handleError(err))
29 )
30 }
31
32 listVideoRedundancies (options: {
33 pagination: RestPagination,
34 sort: SortMeta,
35 target?: VideoRedundanciesTarget
36 }): Observable<ResultList<VideoRedundancy>> {
37 const { pagination, sort, target } = options
38
39 let params = new HttpParams()
40 params = this.restService.addRestGetParams(params, pagination, sort)
41
42 if (target) params = params.append('target', target)
43
44 return this.authHttp.get<ResultList<VideoRedundancy>>(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { params })
45 .pipe(
46 catchError(res => this.restExtractor.handleError(res))
47 )
48 }
49
50 addVideoRedundancy (video: Video) {
51 return this.authHttp.post(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { videoId: video.id })
52 .pipe(
53 catchError(res => this.restExtractor.handleError(res))
54 )
55 }
56
57 removeVideoRedundancies (redundancy: VideoRedundancy) {
58 const observables = redundancy.redundancies.streamingPlaylists.map(r => r.id)
59 .concat(redundancy.redundancies.files.map(r => r.id))
60 .map(id => this.removeRedundancy(id))
61
62 return concat(...observables)
63 .pipe(toArray())
64 }
65
66 private removeRedundancy (redundancyId: number) {
67 return this.authHttp.delete(RedundancyService.BASE_REDUNDANCY_URL + '/videos/' + redundancyId)
68 .pipe(
69 map(this.restExtractor.extractDataBool),
70 catchError(res => this.restExtractor.handleError(res))
71 )
72 }
73}
diff --git a/client/src/app/shared/video/video-actions-dropdown.component.ts b/client/src/app/shared/video/video-actions-dropdown.component.ts
index afdeab18d..390d74c52 100644
--- a/client/src/app/shared/video/video-actions-dropdown.component.ts
+++ b/client/src/app/shared/video/video-actions-dropdown.component.ts
@@ -14,6 +14,7 @@ import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklis
14import { VideoBlacklistService } from '@app/shared/video-blacklist' 14import { VideoBlacklistService } from '@app/shared/video-blacklist'
15import { ScreenService } from '@app/shared/misc/screen.service' 15import { ScreenService } from '@app/shared/misc/screen.service'
16import { VideoCaption } from '@shared/models' 16import { VideoCaption } from '@shared/models'
17import { RedundancyService } from '@app/shared/video/redundancy.service'
17 18
18export type VideoActionsDisplayType = { 19export type VideoActionsDisplayType = {
19 playlist?: boolean 20 playlist?: boolean
@@ -22,6 +23,7 @@ export type VideoActionsDisplayType = {
22 blacklist?: boolean 23 blacklist?: boolean
23 delete?: boolean 24 delete?: boolean
24 report?: boolean 25 report?: boolean
26 duplicate?: boolean
25} 27}
26 28
27@Component({ 29@Component({
@@ -46,7 +48,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
46 update: true, 48 update: true,
47 blacklist: true, 49 blacklist: true,
48 delete: true, 50 delete: true,
49 report: true 51 report: true,
52 duplicate: true
50 } 53 }
51 @Input() placement = 'left' 54 @Input() placement = 'left'
52 55
@@ -74,6 +77,7 @@ export class VideoActionsDropdownComponent implements OnChanges {
74 private screenService: ScreenService, 77 private screenService: ScreenService,
75 private videoService: VideoService, 78 private videoService: VideoService,
76 private blocklistService: BlocklistService, 79 private blocklistService: BlocklistService,
80 private redundancyService: RedundancyService,
77 private i18n: I18n 81 private i18n: I18n
78 ) { } 82 ) { }
79 83
@@ -144,6 +148,10 @@ export class VideoActionsDropdownComponent implements OnChanges {
144 return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled 148 return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled
145 } 149 }
146 150
151 canVideoBeDuplicated () {
152 return this.video.canBeDuplicatedBy(this.user)
153 }
154
147 /* Action handlers */ 155 /* Action handlers */
148 156
149 async unblacklistVideo () { 157 async unblacklistVideo () {
@@ -186,6 +194,18 @@ export class VideoActionsDropdownComponent implements OnChanges {
186 ) 194 )
187 } 195 }
188 196
197 duplicateVideo () {
198 this.redundancyService.addVideoRedundancy(this.video)
199 .subscribe(
200 () => {
201 const message = this.i18n('This video will be duplicated by your instance.')
202 this.notifier.success(message)
203 },
204
205 err => this.notifier.error(err.message)
206 )
207 }
208
189 onVideoBlacklisted () { 209 onVideoBlacklisted () {
190 this.videoBlacklisted.emit() 210 this.videoBlacklisted.emit()
191 } 211 }
@@ -234,6 +254,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
234 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoUnblacklistable() 254 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoUnblacklistable()
235 }, 255 },
236 { 256 {
257 label: this.i18n('Duplicate (redundancy)'),
258 handler: () => this.duplicateVideo(),
259 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.duplicate && this.canVideoBeDuplicated(),
260 iconName: 'cloud-download'
261 },
262 {
237 label: this.i18n('Delete'), 263 label: this.i18n('Delete'),
238 handler: () => this.removeVideo(), 264 handler: () => this.removeVideo(),
239 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.delete && this.isVideoRemovable(), 265 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.delete && this.isVideoRemovable(),
diff --git a/client/src/app/shared/video/video-miniature.component.ts b/client/src/app/shared/video/video-miniature.component.ts
index 598a7a983..1dfb3eec7 100644
--- a/client/src/app/shared/video/video-miniature.component.ts
+++ b/client/src/app/shared/video/video-miniature.component.ts
@@ -64,7 +64,8 @@ export class VideoMiniatureComponent implements OnInit {
64 update: true, 64 update: true,
65 blacklist: true, 65 blacklist: true,
66 delete: true, 66 delete: true,
67 report: true 67 report: true,
68 duplicate: false
68 } 69 }
69 showActions = false 70 showActions = false
70 serverConfig: ServerConfig 71 serverConfig: ServerConfig
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts
index fb98d5382..546518cca 100644
--- a/client/src/app/shared/video/video.model.ts
+++ b/client/src/app/shared/video/video.model.ts
@@ -42,6 +42,9 @@ export class Video implements VideoServerModel {
42 dislikes: number 42 dislikes: number
43 nsfw: boolean 43 nsfw: boolean
44 44
45 originInstanceUrl: string
46 originInstanceHost: string
47
45 waitTranscoding?: boolean 48 waitTranscoding?: boolean
46 state?: VideoConstant<VideoState> 49 state?: VideoConstant<VideoState>
47 scheduledUpdate?: VideoScheduleUpdate 50 scheduledUpdate?: VideoScheduleUpdate
@@ -86,22 +89,31 @@ export class Video implements VideoServerModel {
86 this.waitTranscoding = hash.waitTranscoding 89 this.waitTranscoding = hash.waitTranscoding
87 this.state = hash.state 90 this.state = hash.state
88 this.description = hash.description 91 this.description = hash.description
92
89 this.duration = hash.duration 93 this.duration = hash.duration
90 this.durationLabel = durationToString(hash.duration) 94 this.durationLabel = durationToString(hash.duration)
95
91 this.id = hash.id 96 this.id = hash.id
92 this.uuid = hash.uuid 97 this.uuid = hash.uuid
98
93 this.isLocal = hash.isLocal 99 this.isLocal = hash.isLocal
94 this.name = hash.name 100 this.name = hash.name
101
95 this.thumbnailPath = hash.thumbnailPath 102 this.thumbnailPath = hash.thumbnailPath
96 this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath 103 this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath
104
97 this.previewPath = hash.previewPath 105 this.previewPath = hash.previewPath
98 this.previewUrl = absoluteAPIUrl + hash.previewPath 106 this.previewUrl = absoluteAPIUrl + hash.previewPath
107
99 this.embedPath = hash.embedPath 108 this.embedPath = hash.embedPath
100 this.embedUrl = absoluteAPIUrl + hash.embedPath 109 this.embedUrl = absoluteAPIUrl + hash.embedPath
110
101 this.views = hash.views 111 this.views = hash.views
102 this.likes = hash.likes 112 this.likes = hash.likes
103 this.dislikes = hash.dislikes 113 this.dislikes = hash.dislikes
114
104 this.nsfw = hash.nsfw 115 this.nsfw = hash.nsfw
116
105 this.account = hash.account 117 this.account = hash.account
106 this.channel = hash.channel 118 this.channel = hash.channel
107 119
@@ -124,6 +136,9 @@ export class Video implements VideoServerModel {
124 this.blacklistedReason = hash.blacklistedReason 136 this.blacklistedReason = hash.blacklistedReason
125 137
126 this.userHistory = hash.userHistory 138 this.userHistory = hash.userHistory
139
140 this.originInstanceHost = this.account.host
141 this.originInstanceUrl = 'https://' + this.originInstanceHost
127 } 142 }
128 143
129 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) { 144 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) {
@@ -152,4 +167,8 @@ export class Video implements VideoServerModel {
152 isUpdatableBy (user: AuthUser) { 167 isUpdatableBy (user: AuthUser) {
153 return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO)) 168 return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO))
154 } 169 }
170
171 canBeDuplicatedBy (user: AuthUser) {
172 return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES)
173 }
155} 174}
diff --git a/client/src/app/videos/+video-watch/video-watch.component.html b/client/src/app/videos/+video-watch/video-watch.component.html
index bc3a3ffdd..a382777f5 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.html
+++ b/client/src/app/videos/+video-watch/video-watch.component.html
@@ -188,6 +188,11 @@
188 <span class="video-attribute-value">{{ video.privacy.label }}</span> 188 <span class="video-attribute-value">{{ video.privacy.label }}</span>
189 </div> 189 </div>
190 190
191 <div *ngIf="video.isLocal === false" class="video-attribute">
192 <span i18n class="video-attribute-label">Origin instance</span>
193 <a class="video-attribute-value" target="_blank" rel="noopener noreferrer" [href]="video.originInstanceUrl">{{ video.originInstanceHost }}</a>
194 </div>
195
191 <div *ngIf="!!video.originallyPublishedAt" class="video-attribute"> 196 <div *ngIf="!!video.originallyPublishedAt" class="video-attribute">
192 <span i18n class="video-attribute-label">Originally published</span> 197 <span i18n class="video-attribute-label">Originally published</span>
193 <span class="video-attribute-value">{{ video.originallyPublishedAt | date: 'dd MMMM yyyy' }}</span> 198 <span class="video-attribute-value">{{ video.originallyPublishedAt | date: 'dd MMMM yyyy' }}</span>
diff --git a/client/src/assets/player/bezels/bezels-plugin.ts b/client/src/assets/player/bezels/bezels-plugin.ts
index c2c251961..499177526 100644
--- a/client/src/assets/player/bezels/bezels-plugin.ts
+++ b/client/src/assets/player/bezels/bezels-plugin.ts
@@ -1,85 +1,12 @@
1// @ts-ignore 1import videojs, { VideoJsPlayer } from 'video.js'
2import * as videojs from 'video.js' 2import './pause-bezel'
3import { VideoJSComponentInterface } from '../peertube-videojs-typings'
4 3
5function getPauseBezel () { 4const Plugin = videojs.getPlugin('plugin')
6 return `
7 <div class="vjs-bezels-pause">
8 <div class="vjs-bezel" role="status" aria-label="Pause">
9 <div class="vjs-bezel-icon">
10 <svg height="100%" version="1.1" viewBox="0 0 36 36" width="100%">
11 <use class="vjs-svg-shadow" xlink:href="#vjs-id-1"></use>
12 <path class="vjs-svg-fill" d="M 12,26 16,26 16,10 12,10 z M 21,26 25,26 25,10 21,10 z" id="vjs-id-1"></path>
13 </svg>
14 </div>
15 </div>
16 </div>
17 `
18}
19
20function getPlayBezel () {
21 return `
22 <div class="vjs-bezels-play">
23 <div class="vjs-bezel" role="status" aria-label="Play">
24 <div class="vjs-bezel-icon">
25 <svg height="100%" version="1.1" viewBox="0 0 36 36" width="100%">
26 <use class="vjs-svg-shadow" xlink:href="#vjs-id-2"></use>
27 <path class="vjs-svg-fill" d="M 12,26 18.5,22 18.5,14 12,10 z M 18.5,22 25,18 25,18 18.5,14 z" id="ytp-id-2"></path>
28 </svg>
29 </div>
30 </div>
31 </div>
32 `
33}
34
35// @ts-ignore-start
36const Component = videojs.getComponent('Component')
37class PauseBezel extends Component {
38 options_: any
39 container: HTMLBodyElement
40
41 constructor (player: videojs.Player, options: any) {
42 super(player, options)
43 this.options_ = options
44
45 player.on('pause', (_: any) => {
46 if (player.seeking() || player.ended()) return
47 this.container.innerHTML = getPauseBezel()
48 this.showBezel()
49 })
50
51 player.on('play', (_: any) => {
52 if (player.seeking()) return
53 this.container.innerHTML = getPlayBezel()
54 this.showBezel()
55 })
56 }
57 5
58 createEl () {
59 const container = super.createEl('div', {
60 className: 'vjs-bezels-content'
61 })
62 this.container = container
63 container.style.display = 'none'
64
65 return container
66 }
67
68 showBezel () {
69 this.container.style.display = 'inherit'
70 setTimeout(() => {
71 this.container.style.display = 'none'
72 }, 500) // matching the animation duration
73 }
74}
75// @ts-ignore-end
76
77videojs.registerComponent('PauseBezel', PauseBezel)
78
79const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
80class BezelsPlugin extends Plugin { 6class BezelsPlugin extends Plugin {
81 constructor (player: videojs.Player, options: any = {}) { 7
82 super(player, options) 8 constructor (player: VideoJsPlayer, options?: videojs.ComponentOptions) {
9 super(player)
83 10
84 this.player.ready(() => { 11 this.player.ready(() => {
85 player.addClass('vjs-bezels') 12 player.addClass('vjs-bezels')
@@ -90,4 +17,5 @@ class BezelsPlugin extends Plugin {
90} 17}
91 18
92videojs.registerPlugin('bezels', BezelsPlugin) 19videojs.registerPlugin('bezels', BezelsPlugin)
20
93export { BezelsPlugin } 21export { BezelsPlugin }
diff --git a/client/src/assets/player/bezels/pause-bezel.ts b/client/src/assets/player/bezels/pause-bezel.ts
new file mode 100644
index 000000000..98eb12099
--- /dev/null
+++ b/client/src/assets/player/bezels/pause-bezel.ts
@@ -0,0 +1,72 @@
1import videojs, { VideoJsPlayer } from 'video.js'
2
3function getPauseBezel () {
4 return `
5 <div class="vjs-bezels-pause">
6 <div class="vjs-bezel" role="status" aria-label="Pause">
7 <div class="vjs-bezel-icon">
8 <svg height="100%" version="1.1" viewBox="0 0 36 36" width="100%">
9 <use class="vjs-svg-shadow" xlink:href="#vjs-id-1"></use>
10 <path class="vjs-svg-fill" d="M 12,26 16,26 16,10 12,10 z M 21,26 25,26 25,10 21,10 z" id="vjs-id-1"></path>
11 </svg>
12 </div>
13 </div>
14 </div>
15 `
16}
17
18function getPlayBezel () {
19 return `
20 <div class="vjs-bezels-play">
21 <div class="vjs-bezel" role="status" aria-label="Play">
22 <div class="vjs-bezel-icon">
23 <svg height="100%" version="1.1" viewBox="0 0 36 36" width="100%">
24 <use class="vjs-svg-shadow" xlink:href="#vjs-id-2"></use>
25 <path class="vjs-svg-fill" d="M 12,26 18.5,22 18.5,14 12,10 z M 18.5,22 25,18 25,18 18.5,14 z" id="ytp-id-2"></path>
26 </svg>
27 </div>
28 </div>
29 </div>
30 `
31}
32
33const Component = videojs.getComponent('Component')
34class PauseBezel extends Component {
35 container: HTMLDivElement
36
37 constructor (player: VideoJsPlayer, options?: videojs.ComponentOptions) {
38 super(player, options)
39
40 player.on('pause', (_: any) => {
41 if (player.seeking() || player.ended()) return
42 this.container.innerHTML = getPauseBezel()
43 this.showBezel()
44 })
45
46 player.on('play', (_: any) => {
47 if (player.seeking()) return
48 this.container.innerHTML = getPlayBezel()
49 this.showBezel()
50 })
51 }
52
53 createEl () {
54 this.container = super.createEl('div', {
55 className: 'vjs-bezels-content'
56 }) as HTMLDivElement
57
58 this.container.style.display = 'none'
59
60 return this.container
61 }
62
63 showBezel () {
64 this.container.style.display = 'inherit'
65
66 setTimeout(() => {
67 this.container.style.display = 'none'
68 }, 500) // matching the animation duration
69 }
70}
71
72videojs.registerComponent('PauseBezel', PauseBezel)
diff --git a/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts b/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts
index c3f863f72..512054ae6 100644
--- a/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts
+++ b/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts
@@ -1,7 +1,5 @@
1// FIXME: something weird with our path definition in tsconfig and typings 1import videojs, { VideoJsPlayer } from 'video.js'
2// @ts-ignore 2import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../peertube-videojs-typings'
3import * as videojs from 'video.js'
4import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo, VideoJSComponentInterface } from '../peertube-videojs-typings'
5import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs' 3import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs'
6import { Events, Segment } from 'p2p-media-loader-core' 4import { Events, Segment } from 'p2p-media-loader-core'
7import { timeToInt } from '../utils' 5import { timeToInt } from '../utils'
@@ -10,7 +8,7 @@ import { timeToInt } from '../utils'
10window['videojs'] = videojs 8window['videojs'] = videojs
11require('@streamroot/videojs-hlsjs-plugin') 9require('@streamroot/videojs-hlsjs-plugin')
12 10
13const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') 11const Plugin = videojs.getPlugin('plugin')
14class P2pMediaLoaderPlugin extends Plugin { 12class P2pMediaLoaderPlugin extends Plugin {
15 13
16 private readonly CONSTANTS = { 14 private readonly CONSTANTS = {
@@ -37,12 +35,13 @@ class P2pMediaLoaderPlugin extends Plugin {
37 35
38 private networkInfoInterval: any 36 private networkInfoInterval: any
39 37
40 constructor (player: videojs.Player, options: P2PMediaLoaderPluginOptions) { 38 constructor (player: VideoJsPlayer, options?: P2PMediaLoaderPluginOptions) {
41 super(player, options) 39 super(player)
42 40
43 this.options = options 41 this.options = options
44 42
45 if (!videojs.Html5Hlsjs) { 43 // FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080
44 if (!(videojs as any).Html5Hlsjs) {
46 const message = 'HLS.js does not seem to be supported.' 45 const message = 'HLS.js does not seem to be supported.'
47 console.warn(message) 46 console.warn(message)
48 47
@@ -50,7 +49,8 @@ class P2pMediaLoaderPlugin extends Plugin {
50 return 49 return
51 } 50 }
52 51
53 videojs.Html5Hlsjs.addHook('beforeinitialize', (videojsPlayer: any, hlsjs: any) => { 52 // FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080
53 (videojs as any).Html5Hlsjs.addHook('beforeinitialize', (videojsPlayer: any, hlsjs: any) => {
54 this.hlsjs = hlsjs 54 this.hlsjs = hlsjs
55 }) 55 })
56 56
@@ -84,8 +84,9 @@ class P2pMediaLoaderPlugin extends Plugin {
84 private initialize () { 84 private initialize () {
85 initHlsJsPlayer(this.hlsjs) 85 initHlsJsPlayer(this.hlsjs)
86 86
87 const tech = this.player.tech_ 87 // FIXME: typings
88 this.p2pEngine = tech.options_.hlsjsConfig.loader.getEngine() 88 const options = this.player.tech(true).options_ as any
89 this.p2pEngine = options.hlsjsConfig.loader.getEngine()
89 90
90 // Avoid using constants to not import hls.hs 91 // Avoid using constants to not import hls.hs
91 // https://github.com/video-dev/hls.js/blob/master/src/events.js#L37 92 // https://github.com/video-dev/hls.js/blob/master/src/events.js#L37
diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts
index d9e02cd7d..4e6387a53 100644
--- a/client/src/assets/player/peertube-player-manager.ts
+++ b/client/src/assets/player/peertube-player-manager.ts
@@ -1,21 +1,26 @@
1import { VideoFile } from '../../../../shared/models/videos' 1import { VideoFile } from '../../../../shared/models/videos'
2// @ts-ignore 2import videojs, { VideoJsPlayer, VideoJsPlayerOptions } from 'video.js'
3import * as videojs from 'video.js'
4import 'videojs-hotkeys' 3import 'videojs-hotkeys'
5import 'videojs-dock' 4import 'videojs-dock'
6import 'videojs-contextmenu-ui' 5import 'videojs-contextmenu-ui'
7import 'videojs-contrib-quality-levels' 6import 'videojs-contrib-quality-levels'
7import './upnext/end-card'
8import './upnext/upnext-plugin' 8import './upnext/upnext-plugin'
9import './bezels/bezels-plugin' 9import './bezels/bezels-plugin'
10import './peertube-plugin' 10import './peertube-plugin'
11import './videojs-components/next-video-button' 11import './videojs-components/next-video-button'
12import './videojs-components/p2p-info-button'
12import './videojs-components/peertube-link-button' 13import './videojs-components/peertube-link-button'
14import './videojs-components/peertube-load-progress-bar'
13import './videojs-components/resolution-menu-button' 15import './videojs-components/resolution-menu-button'
16import './videojs-components/resolution-menu-item'
17import './videojs-components/settings-dialog'
14import './videojs-components/settings-menu-button' 18import './videojs-components/settings-menu-button'
15import './videojs-components/p2p-info-button' 19import './videojs-components/settings-menu-item'
16import './videojs-components/peertube-load-progress-bar' 20import './videojs-components/settings-panel'
21import './videojs-components/settings-panel-child'
17import './videojs-components/theater-button' 22import './videojs-components/theater-button'
18import { P2PMediaLoaderPluginOptions, UserWatching, VideoJSCaption, VideoJSPluginOptions, videojsUntyped } from './peertube-videojs-typings' 23import { P2PMediaLoaderPluginOptions, UserWatching, VideoJSCaption, VideoJSPluginOptions } from './peertube-videojs-typings'
19import { buildVideoEmbed, buildVideoLink, copyToClipboard, getRtcConfig } from './utils' 24import { buildVideoEmbed, buildVideoLink, copyToClipboard, getRtcConfig } from './utils'
20import { isDefaultLocale } from '../../../../shared/models/i18n/i18n' 25import { isDefaultLocale } from '../../../../shared/models/i18n/i18n'
21import { segmentValidatorFactory } from './p2p-media-loader/segment-validator' 26import { segmentValidatorFactory } from './p2p-media-loader/segment-validator'
@@ -24,12 +29,17 @@ import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager'
24import { getStoredP2PEnabled } from './peertube-player-local-storage' 29import { getStoredP2PEnabled } from './peertube-player-local-storage'
25import { TranslationsManager } from './translations-manager' 30import { TranslationsManager } from './translations-manager'
26 31
32// For VideoJS
33(window as any).WebVTT = require('vtt.js/lib/vtt.js').WebVTT;
34
27// Change 'Playback Rate' to 'Speed' (smaller for our settings menu) 35// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
28videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed' 36(videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed'
37
38const CaptionsButton = videojs.getComponent('CaptionsButton') as any
29// Change Captions to Subtitles/CC 39// Change Captions to Subtitles/CC
30videojsUntyped.getComponent('CaptionsButton').prototype.controlText_ = 'Subtitles/CC' 40CaptionsButton.prototype.controlText_ = 'Subtitles/CC'
31// We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know) 41// We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know)
32videojsUntyped.getComponent('CaptionsButton').prototype.label_ = ' ' 42CaptionsButton.prototype.label_ = ' '
33 43
34export type PlayerMode = 'webtorrent' | 'p2p-media-loader' 44export type PlayerMode = 'webtorrent' | 'p2p-media-loader'
35 45
@@ -92,9 +102,9 @@ export type PeertubePlayerManagerOptions = {
92 102
93export class PeertubePlayerManager { 103export class PeertubePlayerManager {
94 private static playerElementClassName: string 104 private static playerElementClassName: string
95 private static onPlayerChange: (player: any) => void 105 private static onPlayerChange: (player: VideoJsPlayer) => void
96 106
97 static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions, onPlayerChange: (player: any) => void) { 107 static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions, onPlayerChange: (player: VideoJsPlayer) => void) {
98 let p2pMediaLoader: any 108 let p2pMediaLoader: any
99 109
100 this.onPlayerChange = onPlayerChange 110 this.onPlayerChange = onPlayerChange
@@ -114,12 +124,12 @@ export class PeertubePlayerManager {
114 124
115 const self = this 125 const self = this
116 return new Promise(res => { 126 return new Promise(res => {
117 videojs(options.common.playerElement, videojsOptions, function (this: any) { 127 videojs(options.common.playerElement, videojsOptions, function (this: VideoJsPlayer) {
118 const player = this 128 const player = this
119 129
120 let alreadyFallback = false 130 let alreadyFallback = false
121 131
122 player.tech_.one('error', () => { 132 player.tech(true).one('error', () => {
123 if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options) 133 if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options)
124 alreadyFallback = true 134 alreadyFallback = true
125 }) 135 })
@@ -164,7 +174,7 @@ export class PeertubePlayerManager {
164 const videojsOptions = this.getVideojsOptions(mode, options) 174 const videojsOptions = this.getVideojsOptions(mode, options)
165 175
166 const self = this 176 const self = this
167 videojs(newVideoElement, videojsOptions, function (this: any) { 177 videojs(newVideoElement, videojsOptions, function (this: VideoJsPlayer) {
168 const player = this 178 const player = this
169 179
170 self.addContextMenu(mode, player, options.common.embedUrl) 180 self.addContextMenu(mode, player, options.common.embedUrl)
@@ -173,7 +183,11 @@ export class PeertubePlayerManager {
173 }) 183 })
174 } 184 }
175 185
176 private static getVideojsOptions (mode: PlayerMode, options: PeertubePlayerManagerOptions, p2pMediaLoaderModule?: any) { 186 private static getVideojsOptions (
187 mode: PlayerMode,
188 options: PeertubePlayerManagerOptions,
189 p2pMediaLoaderModule?: any
190 ): VideoJsPlayerOptions {
177 const commonOptions = options.common 191 const commonOptions = options.common
178 192
179 let autoplay = commonOptions.autoplay 193 let autoplay = commonOptions.autoplay
@@ -213,7 +227,7 @@ export class PeertubePlayerManager {
213 html5, 227 html5,
214 228
215 // We don't use text track settings for now 229 // We don't use text track settings for now
216 textTrackSettings: false, 230 textTrackSettings: false as any, // FIXME: typings
217 controls: commonOptions.controls !== undefined ? commonOptions.controls : true, 231 controls: commonOptions.controls !== undefined ? commonOptions.controls : true,
218 loop: commonOptions.loop !== undefined ? commonOptions.loop : false, 232 loop: commonOptions.loop !== undefined ? commonOptions.loop : false,
219 233
@@ -237,7 +251,7 @@ export class PeertubePlayerManager {
237 peertubeLink: commonOptions.peertubeLink, 251 peertubeLink: commonOptions.peertubeLink,
238 theaterButton: commonOptions.theaterButton, 252 theaterButton: commonOptions.theaterButton,
239 nextVideo: commonOptions.nextVideo 253 nextVideo: commonOptions.nextVideo
240 }) 254 }) as any // FIXME: typings
241 } 255 }
242 } 256 }
243 257
@@ -406,7 +420,7 @@ export class PeertubePlayerManager {
406 return children 420 return children
407 } 421 }
408 422
409 private static addContextMenu (mode: PlayerMode, player: any, videoEmbedUrl: string) { 423 private static addContextMenu (mode: PlayerMode, player: VideoJsPlayer, videoEmbedUrl: string) {
410 const content = [ 424 const content = [
411 { 425 {
412 label: player.localize('Copy the video URL'), 426 label: player.localize('Copy the video URL'),
@@ -416,9 +430,8 @@ export class PeertubePlayerManager {
416 }, 430 },
417 { 431 {
418 label: player.localize('Copy the video URL at the current time'), 432 label: player.localize('Copy the video URL at the current time'),
419 listener: function () { 433 listener: function (this: VideoJsPlayer) {
420 const player = this as videojs.Player 434 copyToClipboard(buildVideoLink({ startTime: this.currentTime() }))
421 copyToClipboard(buildVideoLink({ startTime: player.currentTime() }))
422 } 435 }
423 }, 436 },
424 { 437 {
@@ -432,9 +445,8 @@ export class PeertubePlayerManager {
432 if (mode === 'webtorrent') { 445 if (mode === 'webtorrent') {
433 content.push({ 446 content.push({
434 label: player.localize('Copy magnet URI'), 447 label: player.localize('Copy magnet URI'),
435 listener: function () { 448 listener: function (this: VideoJsPlayer) {
436 const player = this as videojs.Player 449 copyToClipboard(this.webtorrent().getCurrentVideoFile().magnetUri)
437 copyToClipboard(player.webtorrent().getCurrentVideoFile().magnetUri)
438 } 450 }
439 }) 451 })
440 } 452 }
@@ -472,7 +484,8 @@ export class PeertubePlayerManager {
472 return event.key === '>' 484 return event.key === '>'
473 }, 485 },
474 handler: function (player: videojs.Player) { 486 handler: function (player: videojs.Player) {
475 player.playbackRate((player.playbackRate() + 0.1).toFixed(2)) 487 const newValue = Math.min(player.playbackRate() + 0.1, 5)
488 player.playbackRate(parseFloat(newValue.toFixed(2)))
476 } 489 }
477 }, 490 },
478 decreasePlaybackRateKey: { 491 decreasePlaybackRateKey: {
@@ -480,7 +493,8 @@ export class PeertubePlayerManager {
480 return event.key === '<' 493 return event.key === '<'
481 }, 494 },
482 handler: function (player: videojs.Player) { 495 handler: function (player: videojs.Player) {
483 player.playbackRate((player.playbackRate() - 0.1).toFixed(2)) 496 const newValue = Math.max(player.playbackRate() - 0.1, 0.10)
497 player.playbackRate(parseFloat(newValue.toFixed(2)))
484 } 498 }
485 }, 499 },
486 frameByFrame: { 500 frameByFrame: {
diff --git a/client/src/assets/player/peertube-plugin.ts b/client/src/assets/player/peertube-plugin.ts
index 9824c43b5..19d104676 100644
--- a/client/src/assets/player/peertube-plugin.ts
+++ b/client/src/assets/player/peertube-plugin.ts
@@ -1,14 +1,10 @@
1// FIXME: something weird with our path definition in tsconfig and typings 1import videojs, { VideoJsPlayer } from 'video.js'
2// @ts-ignore
3import * as videojs from 'video.js'
4import './videojs-components/settings-menu-button' 2import './videojs-components/settings-menu-button'
5import { 3import {
6 PeerTubePluginOptions, 4 PeerTubePluginOptions,
7 ResolutionUpdateData, 5 ResolutionUpdateData,
8 UserWatching, 6 UserWatching,
9 VideoJSCaption, 7 VideoJSCaption
10 VideoJSComponentInterface,
11 videojsUntyped
12} from './peertube-videojs-typings' 8} from './peertube-videojs-typings'
13import { isMobile, timeToInt } from './utils' 9import { isMobile, timeToInt } from './utils'
14import { 10import {
@@ -20,7 +16,8 @@ import {
20 saveVolumeInStore 16 saveVolumeInStore
21} from './peertube-player-local-storage' 17} from './peertube-player-local-storage'
22 18
23const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') 19const Plugin = videojs.getPlugin('plugin')
20
24class PeerTubePlugin extends Plugin { 21class PeerTubePlugin extends Plugin {
25 private readonly videoViewUrl: string 22 private readonly videoViewUrl: string
26 private readonly videoDuration: number 23 private readonly videoDuration: number
@@ -28,7 +25,6 @@ class PeerTubePlugin extends Plugin {
28 USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video 25 USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video
29 } 26 }
30 27
31 private player: any
32 private videoCaptions: VideoJSCaption[] 28 private videoCaptions: VideoJSCaption[]
33 private defaultSubtitle: string 29 private defaultSubtitle: string
34 30
@@ -40,8 +36,8 @@ class PeerTubePlugin extends Plugin {
40 private mouseInControlBar = false 36 private mouseInControlBar = false
41 private readonly savedInactivityTimeout: number 37 private readonly savedInactivityTimeout: number
42 38
43 constructor (player: videojs.Player, options: PeerTubePluginOptions) { 39 constructor (player: VideoJsPlayer, options?: PeerTubePluginOptions) {
44 super(player, options) 40 super(player)
45 41
46 this.videoViewUrl = options.videoViewUrl 42 this.videoViewUrl = options.videoViewUrl
47 this.videoDuration = options.videoDuration 43 this.videoDuration = options.videoDuration
@@ -67,7 +63,7 @@ class PeerTubePlugin extends Plugin {
67 this.player.p2pMediaLoader().on('resolutionChange', (_: any, d: any) => this.handleResolutionChange(d)) 63 this.player.p2pMediaLoader().on('resolutionChange', (_: any, d: any) => this.handleResolutionChange(d))
68 } 64 }
69 65
70 this.player.tech_.on('loadedqualitydata', () => { 66 this.player.tech(true).on('loadedqualitydata', () => {
71 setTimeout(() => { 67 setTimeout(() => {
72 // Replay a resolution change, now we loaded all quality data 68 // Replay a resolution change, now we loaded all quality data
73 if (this.lastResolutionChange) this.handleResolutionChange(this.lastResolutionChange) 69 if (this.lastResolutionChange) this.handleResolutionChange(this.lastResolutionChange)
@@ -102,7 +98,7 @@ class PeerTubePlugin extends Plugin {
102 } 98 }
103 99
104 this.player.textTracks().on('change', () => { 100 this.player.textTracks().on('change', () => {
105 const showing = this.player.textTracks().tracks_.find((t: { kind: string, mode: string }) => { 101 const showing = this.player.textTracks().tracks_.find(t => {
106 return t.kind === 'captions' && t.mode === 'showing' 102 return t.kind === 'captions' && t.mode === 'showing'
107 }) 103 })
108 104
@@ -262,7 +258,7 @@ class PeerTubePlugin extends Plugin {
262 258
263 // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657 259 // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657
264 private initSmoothProgressBar () { 260 private initSmoothProgressBar () {
265 const SeekBar = videojsUntyped.getComponent('SeekBar') 261 const SeekBar = videojs.getComponent('SeekBar') as any
266 SeekBar.prototype.getPercent = function getPercent () { 262 SeekBar.prototype.getPercent = function getPercent () {
267 // Allows for smooth scrubbing, when player can't keep up. 263 // Allows for smooth scrubbing, when player can't keep up.
268 // const time = (this.player_.scrubbing()) ? 264 // const time = (this.player_.scrubbing()) ?
diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts
index aad4dbb4f..e45722661 100644
--- a/client/src/assets/player/peertube-videojs-typings.ts
+++ b/client/src/assets/player/peertube-videojs-typings.ts
@@ -1,7 +1,4 @@
1// FIXME: something weird with our path definition in tsconfig and typings 1import videojs from 'video.js'
2// @ts-ignore
3import * as videojs from 'video.js'
4
5import { PeerTubePlugin } from './peertube-plugin' 2import { PeerTubePlugin } from './peertube-plugin'
6import { WebTorrentPlugin } from './webtorrent/webtorrent-plugin' 3import { WebTorrentPlugin } from './webtorrent/webtorrent-plugin'
7import { P2pMediaLoaderPlugin } from './p2p-media-loader/p2p-media-loader-plugin' 4import { P2pMediaLoaderPlugin } from './p2p-media-loader/p2p-media-loader-plugin'
@@ -9,20 +6,45 @@ import { PlayerMode } from './peertube-player-manager'
9import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager' 6import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager'
10import { VideoFile } from '@shared/models' 7import { VideoFile } from '@shared/models'
11 8
12declare namespace videojs { 9declare module 'video.js' {
13 interface Player { 10 export interface VideoJsPlayer {
11 theaterEnabled: boolean
12
13 // FIXME: add it to upstream typings
14 posterImage: {
15 show (): void
16 hide (): void
17 }
18
19 handleTechSeeked_ (): void
20
21 // Plugins
22
14 peertube (): PeerTubePlugin 23 peertube (): PeerTubePlugin
15 webtorrent (): WebTorrentPlugin 24 webtorrent (): WebTorrentPlugin
16 p2pMediaLoader (): P2pMediaLoaderPlugin 25 p2pMediaLoader (): P2pMediaLoaderPlugin
17 }
18}
19 26
20interface VideoJSComponentInterface { 27 contextmenuUI (options: any): any
21 _player: videojs.Player 28
29 bezels (): void
30
31 qualityLevels (): { height: number, id: number }[] & {
32 selectedIndex: number
33 selectedIndex_: number
22 34
23 new (player: videojs.Player, options?: any): any 35 addQualityLevel (representation: {
36 id: number
37 label: string
38 height: number
39 _enabled: boolean
40 }): void
41 }
24 42
25 registerComponent (name: string, obj: any): any 43 textTracks (): TextTrackList & {
44 on: Function
45 tracks_: { kind: string, mode: string, language: string }[]
46 }
47 }
26} 48}
27 49
28type VideoJSCaption = { 50type VideoJSCaption = {
@@ -78,9 +100,6 @@ type VideoJSPluginOptions = {
78 p2pMediaLoader?: P2PMediaLoaderPluginOptions 100 p2pMediaLoader?: P2PMediaLoaderPluginOptions
79} 101}
80 102
81// videojs typings don't have some method we need
82const videojsUntyped = videojs as any
83
84type LoadedQualityData = { 103type LoadedQualityData = {
85 qualitySwitchCallback: Function, 104 qualitySwitchCallback: Function,
86 qualityData: { 105 qualityData: {
@@ -123,8 +142,6 @@ export {
123 PlayerNetworkInfo, 142 PlayerNetworkInfo,
124 ResolutionUpdateData, 143 ResolutionUpdateData,
125 AutoResolutionUpdateData, 144 AutoResolutionUpdateData,
126 VideoJSComponentInterface,
127 videojsUntyped,
128 VideoJSCaption, 145 VideoJSCaption,
129 UserWatching, 146 UserWatching,
130 PeerTubePluginOptions, 147 PeerTubePluginOptions,
diff --git a/client/src/assets/player/upnext/end-card.ts b/client/src/assets/player/upnext/end-card.ts
new file mode 100644
index 000000000..d121a83a9
--- /dev/null
+++ b/client/src/assets/player/upnext/end-card.ts
@@ -0,0 +1,155 @@
1import videojs, { VideoJsPlayer } from 'video.js'
2
3function getMainTemplate (options: any) {
4 return `
5 <div class="vjs-upnext-top">
6 <span class="vjs-upnext-headtext">${options.headText}</span>
7 <div class="vjs-upnext-title"></div>
8 </div>
9 <div class="vjs-upnext-autoplay-icon">
10 <svg height="100%" version="1.1" viewbox="0 0 98 98" width="100%">
11 <circle class="vjs-upnext-svg-autoplay-circle" cx="49" cy="49" fill="#000" fill-opacity="0.8" r="48"></circle>
12 <circle class="vjs-upnext-svg-autoplay-ring" cx="-49" cy="49" fill-opacity="0" r="46.5" stroke="#FFFFFF" stroke-width="4" transform="rotate(-90)"></circle>
13 <polygon class="vjs-upnext-svg-autoplay-triangle" fill="#fff" points="32,27 72,49 32,71"></polygon></svg>
14 </div>
15 <span class="vjs-upnext-bottom">
16 <span class="vjs-upnext-cancel">
17 <button class="vjs-upnext-cancel-button" tabindex="0" aria-label="Cancel autoplay">${options.cancelText}</button>
18 </span>
19 <span class="vjs-upnext-suspended">${options.suspendedText}</span>
20 </span>
21 `
22}
23
24export interface EndCardOptions extends videojs.ComponentOptions {
25 next: Function,
26 getTitle: () => string
27 timeout: number
28 cancelText: string
29 headText: string
30 suspendedText: string
31 condition: () => boolean
32 suspended: () => boolean
33}
34
35const Component = videojs.getComponent('Component')
36class EndCard extends Component {
37 options_: EndCardOptions
38
39 dashOffsetTotal = 586
40 dashOffsetStart = 293
41 interval = 50
42 upNextEvents = new videojs.EventTarget()
43 ticks = 0
44 totalTicks: number
45
46 container: HTMLDivElement
47 title: HTMLElement
48 autoplayRing: HTMLElement
49 cancelButton: HTMLElement
50 suspendedMessage: HTMLElement
51 nextButton: HTMLElement
52
53 constructor (player: VideoJsPlayer, options: EndCardOptions) {
54 super(player, options)
55
56 this.totalTicks = this.options_.timeout / this.interval
57
58 player.on('ended', (_: any) => {
59 if (!this.options_.condition()) return
60
61 player.addClass('vjs-upnext--showing')
62 this.showCard((canceled: boolean) => {
63 player.removeClass('vjs-upnext--showing')
64 this.container.style.display = 'none'
65 if (!canceled) {
66 this.options_.next()
67 }
68 })
69 })
70
71 player.on('playing', () => {
72 this.upNextEvents.trigger('playing')
73 })
74 }
75
76 createEl () {
77 const container = super.createEl('div', {
78 className: 'vjs-upnext-content',
79 innerHTML: getMainTemplate(this.options_)
80 }) as HTMLDivElement
81
82 this.container = container
83 container.style.display = 'none'
84
85 this.autoplayRing = container.getElementsByClassName('vjs-upnext-svg-autoplay-ring')[0] as HTMLElement
86 this.title = container.getElementsByClassName('vjs-upnext-title')[0] as HTMLElement
87 this.cancelButton = container.getElementsByClassName('vjs-upnext-cancel-button')[0] as HTMLElement
88 this.suspendedMessage = container.getElementsByClassName('vjs-upnext-suspended')[0] as HTMLElement
89 this.nextButton = container.getElementsByClassName('vjs-upnext-autoplay-icon')[0] as HTMLElement
90
91 this.cancelButton.onclick = () => {
92 this.upNextEvents.trigger('cancel')
93 }
94
95 this.nextButton.onclick = () => {
96 this.upNextEvents.trigger('next')
97 }
98
99 return container
100 }
101
102 showCard (cb: Function) {
103 let timeout: any
104
105 this.autoplayRing.setAttribute('stroke-dasharray', '' + this.dashOffsetStart)
106 this.autoplayRing.setAttribute('stroke-dashoffset', '' + -this.dashOffsetStart)
107
108 this.title.innerHTML = this.options_.getTitle()
109
110 this.upNextEvents.one('cancel', () => {
111 clearTimeout(timeout)
112 cb(true)
113 })
114
115 this.upNextEvents.one('playing', () => {
116 clearTimeout(timeout)
117 cb(true)
118 })
119
120 this.upNextEvents.one('next', () => {
121 clearTimeout(timeout)
122 cb(false)
123 })
124
125 const goToPercent = (percent: number) => {
126 const newOffset = Math.max(-this.dashOffsetTotal, - this.dashOffsetStart - percent * this.dashOffsetTotal / 2 / 100)
127 this.autoplayRing.setAttribute('stroke-dashoffset', '' + newOffset)
128 }
129
130 const tick = () => {
131 goToPercent((this.ticks++) * 100 / this.totalTicks)
132 }
133
134 const update = () => {
135 if (this.options_.suspended()) {
136 this.suspendedMessage.innerText = this.options_.suspendedText
137 goToPercent(0)
138 this.ticks = 0
139 timeout = setTimeout(update.bind(this), 300) // checks once supsended can be a bit longer
140 } else if (this.ticks >= this.totalTicks) {
141 clearTimeout(timeout)
142 cb(false)
143 } else {
144 this.suspendedMessage.innerText = ''
145 tick()
146 timeout = setTimeout(update.bind(this), this.interval)
147 }
148 }
149
150 this.container.style.display = 'block'
151 timeout = setTimeout(update.bind(this), this.interval)
152 }
153}
154
155videojs.registerComponent('EndCard', EndCard)
diff --git a/client/src/assets/player/upnext/upnext-plugin.ts b/client/src/assets/player/upnext/upnext-plugin.ts
index a3747b25f..6512fec2c 100644
--- a/client/src/assets/player/upnext/upnext-plugin.ts
+++ b/client/src/assets/player/upnext/upnext-plugin.ts
@@ -1,154 +1,11 @@
1// @ts-ignore 1import videojs, { VideoJsPlayer } from 'video.js'
2import * as videojs from 'video.js' 2import { EndCardOptions } from './end-card'
3import { VideoJSComponentInterface } from '../peertube-videojs-typings'
4 3
5function getMainTemplate (options: any) { 4const Plugin = videojs.getPlugin('plugin')
6 return `
7 <div class="vjs-upnext-top">
8 <span class="vjs-upnext-headtext">${options.headText}</span>
9 <div class="vjs-upnext-title"></div>
10 </div>
11 <div class="vjs-upnext-autoplay-icon">
12 <svg height="100%" version="1.1" viewbox="0 0 98 98" width="100%">
13 <circle class="vjs-upnext-svg-autoplay-circle" cx="49" cy="49" fill="#000" fill-opacity="0.8" r="48"></circle>
14 <circle class="vjs-upnext-svg-autoplay-ring" cx="-49" cy="49" fill-opacity="0" r="46.5" stroke="#FFFFFF" stroke-width="4" transform="rotate(-90)"></circle>
15 <polygon class="vjs-upnext-svg-autoplay-triangle" fill="#fff" points="32,27 72,49 32,71"></polygon></svg>
16 </div>
17 <span class="vjs-upnext-bottom">
18 <span class="vjs-upnext-cancel">
19 <button class="vjs-upnext-cancel-button" tabindex="0" aria-label="Cancel autoplay">${options.cancelText}</button>
20 </span>
21 <span class="vjs-upnext-suspended">${options.suspendedText}</span>
22 </span>
23 `
24}
25
26// @ts-ignore-start
27const Component = videojs.getComponent('Component')
28class EndCard extends Component {
29 options_: any
30 dashOffsetTotal = 586
31 dashOffsetStart = 293
32 interval = 50
33 upNextEvents = new videojs.EventTarget()
34 ticks = 0
35 totalTicks: number
36
37 container: HTMLElement
38 title: HTMLElement
39 autoplayRing: HTMLElement
40 cancelButton: HTMLElement
41 suspendedMessage: HTMLElement
42 nextButton: HTMLElement
43
44 constructor (player: videojs.Player, options: any) {
45 super(player, options)
46
47 this.totalTicks = this.options_.timeout / this.interval
48
49 player.on('ended', (_: any) => {
50 if (!this.options_.condition()) return
51
52 player.addClass('vjs-upnext--showing')
53 this.showCard((canceled: boolean) => {
54 player.removeClass('vjs-upnext--showing')
55 this.container.style.display = 'none'
56 if (!canceled) {
57 this.options_.next()
58 }
59 })
60 })
61
62 player.on('playing', () => {
63 this.upNextEvents.trigger('playing')
64 })
65 }
66
67 createEl () {
68 const container = super.createEl('div', {
69 className: 'vjs-upnext-content',
70 innerHTML: getMainTemplate(this.options_)
71 })
72
73 this.container = container
74 container.style.display = 'none'
75
76 this.autoplayRing = container.getElementsByClassName('vjs-upnext-svg-autoplay-ring')[0]
77 this.title = container.getElementsByClassName('vjs-upnext-title')[0]
78 this.cancelButton = container.getElementsByClassName('vjs-upnext-cancel-button')[0]
79 this.suspendedMessage = container.getElementsByClassName('vjs-upnext-suspended')[0]
80 this.nextButton = container.getElementsByClassName('vjs-upnext-autoplay-icon')[0]
81
82 this.cancelButton.onclick = () => {
83 this.upNextEvents.trigger('cancel')
84 }
85
86 this.nextButton.onclick = () => {
87 this.upNextEvents.trigger('next')
88 }
89
90 return container
91 }
92 5
93 showCard (cb: Function) {
94 let timeout: any
95
96 this.autoplayRing.setAttribute('stroke-dasharray', '' + this.dashOffsetStart)
97 this.autoplayRing.setAttribute('stroke-dashoffset', '' + -this.dashOffsetStart)
98
99 this.title.innerHTML = this.options_.getTitle()
100
101 this.upNextEvents.one('cancel', () => {
102 clearTimeout(timeout)
103 cb(true)
104 })
105
106 this.upNextEvents.one('playing', () => {
107 clearTimeout(timeout)
108 cb(true)
109 })
110
111 this.upNextEvents.one('next', () => {
112 clearTimeout(timeout)
113 cb(false)
114 })
115
116 const goToPercent = (percent: number) => {
117 const newOffset = Math.max(-this.dashOffsetTotal, - this.dashOffsetStart - percent * this.dashOffsetTotal / 2 / 100)
118 this.autoplayRing.setAttribute('stroke-dashoffset', '' + newOffset)
119 }
120
121 const tick = () => {
122 goToPercent((this.ticks++) * 100 / this.totalTicks)
123 }
124
125 const update = () => {
126 if (this.options_.suspended()) {
127 this.suspendedMessage.innerText = this.options_.suspendedText
128 goToPercent(0)
129 this.ticks = 0
130 timeout = setTimeout(update.bind(this), 300) // checks once supsended can be a bit longer
131 } else if (this.ticks >= this.totalTicks) {
132 clearTimeout(timeout)
133 cb(false)
134 } else {
135 this.suspendedMessage.innerText = ''
136 tick()
137 timeout = setTimeout(update.bind(this), this.interval)
138 }
139 }
140
141 this.container.style.display = 'block'
142 timeout = setTimeout(update.bind(this), this.interval)
143 }
144}
145// @ts-ignore-end
146
147videojs.registerComponent('EndCard', EndCard)
148
149const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
150class UpNextPlugin extends Plugin { 6class UpNextPlugin extends Plugin {
151 constructor (player: videojs.Player, options: any = {}) { 7
8 constructor (player: VideoJsPlayer, options: Partial<EndCardOptions> = {}) {
152 const settings = { 9 const settings = {
153 next: options.next, 10 next: options.next,
154 getTitle: options.getTitle, 11 getTitle: options.getTitle,
@@ -160,7 +17,7 @@ class UpNextPlugin extends Plugin {
160 suspended: options.suspended 17 suspended: options.suspended
161 } 18 }
162 19
163 super(player, settings) 20 super(player)
164 21
165 this.player.ready(() => { 22 this.player.ready(() => {
166 player.addClass('vjs-upnext') 23 player.addClass('vjs-upnext')
diff --git a/client/src/assets/player/videojs-components/next-video-button.ts b/client/src/assets/player/videojs-components/next-video-button.ts
index bf5c1aba4..bdb245dcc 100644
--- a/client/src/assets/player/videojs-components/next-video-button.ts
+++ b/client/src/assets/player/videojs-components/next-video-button.ts
@@ -1,21 +1,25 @@
1import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' 1import videojs, { VideoJsPlayer } from 'video.js'
2// FIXME: something weird with our path definition in tsconfig and typings
3// @ts-ignore
4import { Player } from 'video.js'
5 2
6const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') 3const Button = videojs.getComponent('Button')
4
5export interface NextVideoButtonOptions extends videojs.ComponentOptions {
6 handler: Function
7}
7 8
8class NextVideoButton extends Button { 9class NextVideoButton extends Button {
10 private readonly nextVideoButtonOptions: NextVideoButtonOptions
9 11
10 constructor (player: Player, options: any) { 12 constructor (player: VideoJsPlayer, options?: NextVideoButtonOptions) {
11 super(player, options) 13 super(player, options)
14
15 this.nextVideoButtonOptions = options
12 } 16 }
13 17
14 createEl () { 18 createEl () {
15 const button = videojsUntyped.dom.createEl('button', { 19 const button = videojs.dom.createEl('button', {
16 className: 'vjs-next-video' 20 className: 'vjs-next-video'
17 }) 21 }) as HTMLButtonElement
18 const nextIcon = videojsUntyped.dom.createEl('span', { 22 const nextIcon = videojs.dom.createEl('span', {
19 className: 'icon icon-next' 23 className: 'icon icon-next'
20 }) 24 })
21 button.appendChild(nextIcon) 25 button.appendChild(nextIcon)
@@ -26,11 +30,8 @@ class NextVideoButton extends Button {
26 } 30 }
27 31
28 handleClick () { 32 handleClick () {
29 this.options_.handler() 33 this.nextVideoButtonOptions.handler()
30 } 34 }
31
32} 35}
33 36
34NextVideoButton.prototype.controlText_ = 'Next video' 37videojs.registerComponent('NextVideoButton', NextVideoButton)
35
36NextVideoButton.registerComponent('NextVideoButton', NextVideoButton)
diff --git a/client/src/assets/player/videojs-components/p2p-info-button.ts b/client/src/assets/player/videojs-components/p2p-info-button.ts
index 6424787b2..db6806fed 100644
--- a/client/src/assets/player/videojs-components/p2p-info-button.ts
+++ b/client/src/assets/player/videojs-components/p2p-info-button.ts
@@ -1,63 +1,64 @@
1import { PlayerNetworkInfo, VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' 1import { PlayerNetworkInfo } from '../peertube-videojs-typings'
2import videojs from 'video.js'
2import { bytes } from '../utils' 3import { bytes } from '../utils'
3 4
4const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') 5const Button = videojs.getComponent('Button')
5class P2pInfoButton extends Button { 6class P2pInfoButton extends Button {
6 7
7 createEl () { 8 createEl () {
8 const div = videojsUntyped.dom.createEl('div', { 9 const div = videojs.dom.createEl('div', {
9 className: 'vjs-peertube' 10 className: 'vjs-peertube'
10 }) 11 })
11 const subDivWebtorrent = videojsUntyped.dom.createEl('div', { 12 const subDivWebtorrent = videojs.dom.createEl('div', {
12 className: 'vjs-peertube-hidden' // Hide the stats before we get the info 13 className: 'vjs-peertube-hidden' // Hide the stats before we get the info
13 }) 14 }) as HTMLDivElement
14 div.appendChild(subDivWebtorrent) 15 div.appendChild(subDivWebtorrent)
15 16
16 const downloadIcon = videojsUntyped.dom.createEl('span', { 17 const downloadIcon = videojs.dom.createEl('span', {
17 className: 'icon icon-download' 18 className: 'icon icon-download'
18 }) 19 })
19 subDivWebtorrent.appendChild(downloadIcon) 20 subDivWebtorrent.appendChild(downloadIcon)
20 21
21 const downloadSpeedText = videojsUntyped.dom.createEl('span', { 22 const downloadSpeedText = videojs.dom.createEl('span', {
22 className: 'download-speed-text' 23 className: 'download-speed-text'
23 }) 24 })
24 const downloadSpeedNumber = videojsUntyped.dom.createEl('span', { 25 const downloadSpeedNumber = videojs.dom.createEl('span', {
25 className: 'download-speed-number' 26 className: 'download-speed-number'
26 }) 27 })
27 const downloadSpeedUnit = videojsUntyped.dom.createEl('span') 28 const downloadSpeedUnit = videojs.dom.createEl('span')
28 downloadSpeedText.appendChild(downloadSpeedNumber) 29 downloadSpeedText.appendChild(downloadSpeedNumber)
29 downloadSpeedText.appendChild(downloadSpeedUnit) 30 downloadSpeedText.appendChild(downloadSpeedUnit)
30 subDivWebtorrent.appendChild(downloadSpeedText) 31 subDivWebtorrent.appendChild(downloadSpeedText)
31 32
32 const uploadIcon = videojsUntyped.dom.createEl('span', { 33 const uploadIcon = videojs.dom.createEl('span', {
33 className: 'icon icon-upload' 34 className: 'icon icon-upload'
34 }) 35 })
35 subDivWebtorrent.appendChild(uploadIcon) 36 subDivWebtorrent.appendChild(uploadIcon)
36 37
37 const uploadSpeedText = videojsUntyped.dom.createEl('span', { 38 const uploadSpeedText = videojs.dom.createEl('span', {
38 className: 'upload-speed-text' 39 className: 'upload-speed-text'
39 }) 40 })
40 const uploadSpeedNumber = videojsUntyped.dom.createEl('span', { 41 const uploadSpeedNumber = videojs.dom.createEl('span', {
41 className: 'upload-speed-number' 42 className: 'upload-speed-number'
42 }) 43 })
43 const uploadSpeedUnit = videojsUntyped.dom.createEl('span') 44 const uploadSpeedUnit = videojs.dom.createEl('span')
44 uploadSpeedText.appendChild(uploadSpeedNumber) 45 uploadSpeedText.appendChild(uploadSpeedNumber)
45 uploadSpeedText.appendChild(uploadSpeedUnit) 46 uploadSpeedText.appendChild(uploadSpeedUnit)
46 subDivWebtorrent.appendChild(uploadSpeedText) 47 subDivWebtorrent.appendChild(uploadSpeedText)
47 48
48 const peersText = videojsUntyped.dom.createEl('span', { 49 const peersText = videojs.dom.createEl('span', {
49 className: 'peers-text' 50 className: 'peers-text'
50 }) 51 })
51 const peersNumber = videojsUntyped.dom.createEl('span', { 52 const peersNumber = videojs.dom.createEl('span', {
52 className: 'peers-number' 53 className: 'peers-number'
53 }) 54 })
54 subDivWebtorrent.appendChild(peersNumber) 55 subDivWebtorrent.appendChild(peersNumber)
55 subDivWebtorrent.appendChild(peersText) 56 subDivWebtorrent.appendChild(peersText)
56 57
57 const subDivHttp = videojsUntyped.dom.createEl('div', { 58 const subDivHttp = videojs.dom.createEl('div', {
58 className: 'vjs-peertube-hidden' 59 className: 'vjs-peertube-hidden'
59 }) 60 })
60 const subDivHttpText = videojsUntyped.dom.createEl('span', { 61 const subDivHttpText = videojs.dom.createEl('span', {
61 className: 'http-fallback', 62 className: 'http-fallback',
62 textContent: 'HTTP' 63 textContent: 'HTTP'
63 }) 64 })
@@ -83,8 +84,8 @@ class P2pInfoButton extends Button {
83 const totalUploaded = bytes(p2pStats.uploaded + httpStats.uploaded) 84 const totalUploaded = bytes(p2pStats.uploaded + httpStats.uploaded)
84 const numPeers = p2pStats.numPeers 85 const numPeers = p2pStats.numPeers
85 86
86 subDivWebtorrent.title = this.player_.localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' + 87 subDivWebtorrent.title = this.player().localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' +
87 this.player_.localize('Total uploaded: ' + totalUploaded.join(' ')) 88 this.player().localize('Total uploaded: ' + totalUploaded.join(' '))
88 89
89 downloadSpeedNumber.textContent = downloadSpeed[ 0 ] 90 downloadSpeedNumber.textContent = downloadSpeed[ 0 ]
90 downloadSpeedUnit.textContent = ' ' + downloadSpeed[ 1 ] 91 downloadSpeedUnit.textContent = ' ' + downloadSpeed[ 1 ]
@@ -92,14 +93,15 @@ class P2pInfoButton extends Button {
92 uploadSpeedNumber.textContent = uploadSpeed[ 0 ] 93 uploadSpeedNumber.textContent = uploadSpeed[ 0 ]
93 uploadSpeedUnit.textContent = ' ' + uploadSpeed[ 1 ] 94 uploadSpeedUnit.textContent = ' ' + uploadSpeed[ 1 ]
94 95
95 peersNumber.textContent = numPeers 96 peersNumber.textContent = numPeers.toString()
96 peersText.textContent = ' ' + (numPeers > 1 ? this.player_.localize('peers') : this.player_.localize('peer')) 97 peersText.textContent = ' ' + (numPeers > 1 ? this.player().localize('peers') : this.player_.localize('peer'))
97 98
98 subDivHttp.className = 'vjs-peertube-hidden' 99 subDivHttp.className = 'vjs-peertube-hidden'
99 subDivWebtorrent.className = 'vjs-peertube-displayed' 100 subDivWebtorrent.className = 'vjs-peertube-displayed'
100 }) 101 })
101 102
102 return div 103 return div as HTMLButtonElement
103 } 104 }
104} 105}
105Button.registerComponent('P2PInfoButton', P2pInfoButton) 106
107videojs.registerComponent('P2PInfoButton', P2pInfoButton)
diff --git a/client/src/assets/player/videojs-components/peertube-link-button.ts b/client/src/assets/player/videojs-components/peertube-link-button.ts
index 4d0ea37f5..0db9762a5 100644
--- a/client/src/assets/player/videojs-components/peertube-link-button.ts
+++ b/client/src/assets/player/videojs-components/peertube-link-button.ts
@@ -1,13 +1,10 @@
1import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
2import { buildVideoLink } from '../utils' 1import { buildVideoLink } from '../utils'
3// FIXME: something weird with our path definition in tsconfig and typings 2import videojs, { VideoJsPlayer } from 'video.js'
4// @ts-ignore
5import { Player } from 'video.js'
6 3
7const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') 4const Button = videojs.getComponent('Button')
8class PeerTubeLinkButton extends Button { 5class PeerTubeLinkButton extends Button {
9 6
10 constructor (player: Player, options: any) { 7 constructor (player: VideoJsPlayer, options?: videojs.ComponentOptions) {
11 super(player, options) 8 super(player, options)
12 } 9 }
13 10
@@ -20,21 +17,22 @@ class PeerTubeLinkButton extends Button {
20 } 17 }
21 18
22 handleClick () { 19 handleClick () {
23 this.player_.pause() 20 this.player().pause()
24 } 21 }
25 22
26 private buildElement () { 23 private buildElement () {
27 const el = videojsUntyped.dom.createEl('a', { 24 const el = videojs.dom.createEl('a', {
28 href: buildVideoLink(), 25 href: buildVideoLink(),
29 innerHTML: 'PeerTube', 26 innerHTML: 'PeerTube',
30 title: this.player_.localize('Go to the video page'), 27 title: this.player().localize('Go to the video page'),
31 className: 'vjs-peertube-link', 28 className: 'vjs-peertube-link',
32 target: '_blank' 29 target: '_blank'
33 }) 30 })
34 31
35 el.addEventListener('mouseenter', () => this.updateHref()) 32 el.addEventListener('mouseenter', () => this.updateHref())
36 33
37 return el 34 return el as HTMLButtonElement
38 } 35 }
39} 36}
40Button.registerComponent('PeerTubeLinkButton', PeerTubeLinkButton) 37
38videojs.registerComponent('PeerTubeLinkButton', PeerTubeLinkButton)
diff --git a/client/src/assets/player/videojs-components/peertube-load-progress-bar.ts b/client/src/assets/player/videojs-components/peertube-load-progress-bar.ts
index b594fc1c5..8168e8f2d 100644
--- a/client/src/assets/player/videojs-components/peertube-load-progress-bar.ts
+++ b/client/src/assets/player/videojs-components/peertube-load-progress-bar.ts
@@ -1,16 +1,12 @@
1import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' 1import videojs, { VideoJsPlayer } from 'video.js'
2// FIXME: something weird with our path definition in tsconfig and typings
3// @ts-ignore
4import { Player } from 'video.js'
5 2
6const Component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') 3const Component = videojs.getComponent('Component')
7 4
8class PeerTubeLoadProgressBar extends Component { 5class PeerTubeLoadProgressBar extends Component {
9 partEls_: any[]
10 6
11 constructor (player: Player, options: any) { 7 constructor (player: VideoJsPlayer, options?: videojs.ComponentOptions) {
12 super(player, options) 8 super(player, options)
13 this.partEls_ = [] 9
14 this.on(player, 'progress', this.update) 10 this.on(player, 'progress', this.update)
15 } 11 }
16 12
@@ -22,8 +18,6 @@ class PeerTubeLoadProgressBar extends Component {
22 } 18 }
23 19
24 dispose () { 20 dispose () {
25 this.partEls_ = null
26
27 super.dispose() 21 super.dispose()
28 } 22 }
29 23
@@ -31,7 +25,8 @@ class PeerTubeLoadProgressBar extends Component {
31 const torrent = this.player().webtorrent().getTorrent() 25 const torrent = this.player().webtorrent().getTorrent()
32 if (!torrent) return 26 if (!torrent) return
33 27
34 this.el_.style.width = (torrent.progress * 100) + '%' 28 // FIXME: typings
29 (this.el() as HTMLElement).style.width = (torrent.progress * 100) + '%'
35 } 30 }
36 31
37} 32}
diff --git a/client/src/assets/player/videojs-components/resolution-menu-button.ts b/client/src/assets/player/videojs-components/resolution-menu-button.ts
index 2de3ece19..0fa6272e7 100644
--- a/client/src/assets/player/videojs-components/resolution-menu-button.ts
+++ b/client/src/assets/player/videojs-components/resolution-menu-button.ts
@@ -1,22 +1,19 @@
1// FIXME: something weird with our path definition in tsconfig and typings 1import videojs, { VideoJsPlayer } from 'video.js'
2// @ts-ignore
3import { Player } from 'video.js'
4 2
5import { LoadedQualityData, VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' 3import { LoadedQualityData } from '../peertube-videojs-typings'
6import { ResolutionMenuItem } from './resolution-menu-item' 4import { ResolutionMenuItem } from './resolution-menu-item'
7 5
8const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu') 6const Menu = videojs.getComponent('Menu')
9const MenuButton: VideoJSComponentInterface = videojsUntyped.getComponent('MenuButton') 7const MenuButton = videojs.getComponent('MenuButton')
10class ResolutionMenuButton extends MenuButton { 8class ResolutionMenuButton extends MenuButton {
11 label: HTMLElement 9 labelEl_: HTMLElement
12 labelEl_: any
13 player: Player
14 10
15 constructor (player: Player, options: any) { 11 constructor (player: VideoJsPlayer, options?: videojs.MenuButtonOptions) {
16 super(player, options) 12 super(player, options)
17 this.player = player
18 13
19 player.tech_.on('loadedqualitydata', (e: any, data: any) => this.buildQualities(data)) 14 this.controlText('Quality')
15
16 player.tech(true).on('loadedqualitydata', (e: any, data: any) => this.buildQualities(data))
20 17
21 player.peertube().on('resolutionChange', () => setTimeout(() => this.trigger('updateLabel'), 0)) 18 player.peertube().on('resolutionChange', () => setTimeout(() => this.trigger('updateLabel'), 0))
22 } 19 }
@@ -24,9 +21,9 @@ class ResolutionMenuButton extends MenuButton {
24 createEl () { 21 createEl () {
25 const el = super.createEl() 22 const el = super.createEl()
26 23
27 this.labelEl_ = videojsUntyped.dom.createEl('div', { 24 this.labelEl_ = videojs.dom.createEl('div', {
28 className: 'vjs-resolution-value' 25 className: 'vjs-resolution-value'
29 }) 26 }) as HTMLElement
30 27
31 el.appendChild(this.labelEl_) 28 el.appendChild(this.labelEl_)
32 29
@@ -55,7 +52,7 @@ class ResolutionMenuButton extends MenuButton {
55 52
56 for (const child of children) { 53 for (const child of children) {
57 if (component !== child) { 54 if (component !== child) {
58 child.selected(false) 55 (child as videojs.MenuItem).selected(false)
59 } 56 }
60 } 57 }
61 }) 58 })
@@ -76,7 +73,7 @@ class ResolutionMenuButton extends MenuButton {
76 if (d.id === -1) continue 73 if (d.id === -1) continue
77 74
78 const label = d.label === '0p' 75 const label = d.label === '0p'
79 ? this.player.localize('Audio-only') 76 ? this.player().localize('Audio-only')
80 : d.label 77 : d.label
81 78
82 this.menu.addChild(new ResolutionMenuItem( 79 this.menu.addChild(new ResolutionMenuItem(
@@ -110,6 +107,5 @@ class ResolutionMenuButton extends MenuButton {
110 this.trigger('menuChanged') 107 this.trigger('menuChanged')
111 } 108 }
112} 109}
113ResolutionMenuButton.prototype.controlText_ = 'Quality'
114 110
115MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton) 111videojs.registerComponent('ResolutionMenuButton', ResolutionMenuButton)
diff --git a/client/src/assets/player/videojs-components/resolution-menu-item.ts b/client/src/assets/player/videojs-components/resolution-menu-item.ts
index 6c42fefd2..b039c4572 100644
--- a/client/src/assets/player/videojs-components/resolution-menu-item.ts
+++ b/client/src/assets/player/videojs-components/resolution-menu-item.ts
@@ -1,12 +1,16 @@
1// FIXME: something weird with our path definition in tsconfig and typings 1import videojs, { VideoJsPlayer } from 'video.js'
2// @ts-ignore 2import { AutoResolutionUpdateData, ResolutionUpdateData } from '../peertube-videojs-typings'
3import { Player } from 'video.js'
4 3
5import { AutoResolutionUpdateData, ResolutionUpdateData, VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' 4const MenuItem = videojs.getComponent('MenuItem')
5
6export interface ResolutionMenuItemOptions extends videojs.MenuItemOptions {
7 labels?: { [id: number]: string }
8 id: number
9 callback: Function
10}
6 11
7const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem')
8class ResolutionMenuItem extends MenuItem { 12class ResolutionMenuItem extends MenuItem {
9 private readonly id: number 13 private readonly resolutionId: number
10 private readonly label: string 14 private readonly label: string
11 // Only used for the automatic item 15 // Only used for the automatic item
12 private readonly labels: { [id: number]: string } 16 private readonly labels: { [id: number]: string }
@@ -15,7 +19,7 @@ class ResolutionMenuItem extends MenuItem {
15 private autoResolutionPossible: boolean 19 private autoResolutionPossible: boolean
16 private currentResolutionLabel: string 20 private currentResolutionLabel: string
17 21
18 constructor (player: Player, options: any) { 22 constructor (player: VideoJsPlayer, options?: ResolutionMenuItemOptions) {
19 options.selectable = true 23 options.selectable = true
20 24
21 super(player, options) 25 super(player, options)
@@ -23,40 +27,40 @@ class ResolutionMenuItem extends MenuItem {
23 this.autoResolutionPossible = true 27 this.autoResolutionPossible = true
24 this.currentResolutionLabel = '' 28 this.currentResolutionLabel = ''
25 29
30 this.resolutionId = options.id
26 this.label = options.label 31 this.label = options.label
27 this.labels = options.labels 32 this.labels = options.labels
28 this.id = options.id
29 this.callback = options.callback 33 this.callback = options.callback
30 34
31 player.peertube().on('resolutionChange', (_: any, data: ResolutionUpdateData) => this.updateSelection(data)) 35 player.peertube().on('resolutionChange', (_: any, data: ResolutionUpdateData) => this.updateSelection(data))
32 36
33 // We only want to disable the "Auto" item 37 // We only want to disable the "Auto" item
34 if (this.id === -1) { 38 if (this.resolutionId === -1) {
35 player.peertube().on('autoResolutionChange', (_: any, data: AutoResolutionUpdateData) => this.updateAutoResolution(data)) 39 player.peertube().on('autoResolutionChange', (_: any, data: AutoResolutionUpdateData) => this.updateAutoResolution(data))
36 } 40 }
37 } 41 }
38 42
39 handleClick (event: any) { 43 handleClick (event: any) {
40 // Auto button disabled? 44 // Auto button disabled?
41 if (this.autoResolutionPossible === false && this.id === -1) return 45 if (this.autoResolutionPossible === false && this.resolutionId === -1) return
42 46
43 super.handleClick(event) 47 super.handleClick(event)
44 48
45 this.callback(this.id, 'video') 49 this.callback(this.resolutionId, 'video')
46 } 50 }
47 51
48 updateSelection (data: ResolutionUpdateData) { 52 updateSelection (data: ResolutionUpdateData) {
49 if (this.id === -1) { 53 if (this.resolutionId === -1) {
50 this.currentResolutionLabel = this.labels[data.id] 54 this.currentResolutionLabel = this.labels[data.id]
51 } 55 }
52 56
53 // Automatic resolution only 57 // Automatic resolution only
54 if (data.auto === true) { 58 if (data.auto === true) {
55 this.selected(this.id === -1) 59 this.selected(this.resolutionId === -1)
56 return 60 return
57 } 61 }
58 62
59 this.selected(this.id === data.id) 63 this.selected(this.resolutionId === data.id)
60 } 64 }
61 65
62 updateAutoResolution (data: AutoResolutionUpdateData) { 66 updateAutoResolution (data: AutoResolutionUpdateData) {
@@ -71,13 +75,13 @@ class ResolutionMenuItem extends MenuItem {
71 } 75 }
72 76
73 getLabel () { 77 getLabel () {
74 if (this.id === -1) { 78 if (this.resolutionId === -1) {
75 return this.label + ' <small>' + this.currentResolutionLabel + '</small>' 79 return this.label + ' <small>' + this.currentResolutionLabel + '</small>'
76 } 80 }
77 81
78 return this.label 82 return this.label
79 } 83 }
80} 84}
81MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem) 85videojs.registerComponent('ResolutionMenuItem', ResolutionMenuItem)
82 86
83export { ResolutionMenuItem } 87export { ResolutionMenuItem }
diff --git a/client/src/assets/player/videojs-components/settings-dialog.ts b/client/src/assets/player/videojs-components/settings-dialog.ts
new file mode 100644
index 000000000..dd0b1e472
--- /dev/null
+++ b/client/src/assets/player/videojs-components/settings-dialog.ts
@@ -0,0 +1,37 @@
1import videojs, { VideoJsPlayer } from 'video.js'
2
3const Component = videojs.getComponent('Component')
4
5class SettingsDialog extends Component {
6 constructor (player: VideoJsPlayer) {
7 super(player)
8
9 this.hide()
10 }
11
12 /**
13 * Create the component's DOM element
14 *
15 * @return {Element}
16 * @method createEl
17 */
18 createEl () {
19 const uniqueId = this.id()
20 const dialogLabelId = 'TTsettingsDialogLabel-' + uniqueId
21 const dialogDescriptionId = 'TTsettingsDialogDescription-' + uniqueId
22
23 return super.createEl('div', {
24 className: 'vjs-settings-dialog vjs-modal-overlay',
25 innerHTML: '',
26 tabIndex: -1
27 }, {
28 'role': 'dialog',
29 'aria-labelledby': dialogLabelId,
30 'aria-describedby': dialogDescriptionId
31 })
32 }
33}
34
35Component.registerComponent('SettingsDialog', SettingsDialog)
36
37export { SettingsDialog }
diff --git a/client/src/assets/player/videojs-components/settings-menu-button.ts b/client/src/assets/player/videojs-components/settings-menu-button.ts
index b700f4be6..eae628e7d 100644
--- a/client/src/assets/player/videojs-components/settings-menu-button.ts
+++ b/client/src/assets/player/videojs-components/settings-menu-button.ts
@@ -1,43 +1,52 @@
1// Author: Yanko Shterev 1// Thanks to Yanko Shterev: https://github.com/yshterev/videojs-settings-menu
2// Thanks https://github.com/yshterev/videojs-settings-menu
3
4// FIXME: something weird with our path definition in tsconfig and typings
5// @ts-ignore
6import * as videojs from 'video.js'
7
8import { SettingsMenuItem } from './settings-menu-item' 2import { SettingsMenuItem } from './settings-menu-item'
9import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
10import { toTitleCase } from '../utils' 3import { toTitleCase } from '../utils'
4import videojs, { VideoJsPlayer } from 'video.js'
5
6import { SettingsDialog } from './settings-dialog'
7import { SettingsPanel } from './settings-panel'
8import { SettingsPanelChild } from './settings-panel-child'
11 9
12const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') 10const Button = videojs.getComponent('Button')
13const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu') 11const Menu = videojs.getComponent('Menu')
14const Component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') 12const Component = videojs.getComponent('Component')
13
14export interface SettingsButtonOptions extends videojs.ComponentOptions {
15 entries: any[]
16 setup?: {
17 maxHeightOffset: number
18 }
19}
15 20
16class SettingsButton extends Button { 21class SettingsButton extends Button {
17 playerComponent = videojs.Player 22 dialog: SettingsDialog
18 dialog: any 23 dialogEl: HTMLElement
19 dialogEl: any 24 menu: videojs.Menu
20 menu: any 25 panel: SettingsPanel
21 panel: any 26 panelChild: SettingsPanelChild
22 panelChild: any 27
23 28 addSettingsItemHandler: typeof SettingsButton.prototype.onAddSettingsItem
24 addSettingsItemHandler: Function 29 disposeSettingsItemHandler: typeof SettingsButton.prototype.onDisposeSettingsItem
25 disposeSettingsItemHandler: Function 30 playerClickHandler: typeof SettingsButton.prototype.onPlayerClick
26 playerClickHandler: Function 31 userInactiveHandler: typeof SettingsButton.prototype.onUserInactive
27 userInactiveHandler: Function 32
28 33 private settingsButtonOptions: SettingsButtonOptions
29 constructor (player: videojs.Player, options: any) { 34
35 constructor (player: VideoJsPlayer, options?: SettingsButtonOptions) {
30 super(player, options) 36 super(player, options)
31 37
32 this.playerComponent = player 38 this.settingsButtonOptions = options
33 this.dialog = this.playerComponent.addChild('settingsDialog') 39
34 this.dialogEl = this.dialog.el_ 40 this.controlText('Settings')
41
42 this.dialog = this.player().addChild('settingsDialog')
43 this.dialogEl = this.dialog.el() as HTMLElement
35 this.menu = null 44 this.menu = null
36 this.panel = this.dialog.addChild('settingsPanel') 45 this.panel = this.dialog.addChild('settingsPanel')
37 this.panelChild = this.panel.addChild('settingsPanelChild') 46 this.panelChild = this.panel.addChild('settingsPanelChild')
38 47
39 this.addClass('vjs-settings') 48 this.addClass('vjs-settings')
40 this.el_.setAttribute('aria-label', 'Settings Button') 49 this.el().setAttribute('aria-label', 'Settings Button')
41 50
42 // Event handlers 51 // Event handlers
43 this.addSettingsItemHandler = this.onAddSettingsItem.bind(this) 52 this.addSettingsItemHandler = this.onAddSettingsItem.bind(this)
@@ -84,7 +93,7 @@ class SettingsButton extends Button {
84 93
85 this.hideDialog() 94 this.hideDialog()
86 95
87 if (this.options_.entries.length === 0) { 96 if (this.settingsButtonOptions.entries.length === 0) {
88 this.addClass('vjs-hidden') 97 this.addClass('vjs-hidden')
89 } 98 }
90 } 99 }
@@ -103,10 +112,10 @@ class SettingsButton extends Button {
103 } 112 }
104 113
105 bindEvents () { 114 bindEvents () {
106 this.playerComponent.on('click', this.playerClickHandler) 115 this.player().on('click', this.playerClickHandler)
107 this.playerComponent.on('addsettingsitem', this.addSettingsItemHandler) 116 this.player().on('addsettingsitem', this.addSettingsItemHandler)
108 this.playerComponent.on('disposesettingsitem', this.disposeSettingsItemHandler) 117 this.player().on('disposesettingsitem', this.disposeSettingsItemHandler)
109 this.playerComponent.on('userinactive', this.userInactiveHandler) 118 this.player().on('userinactive', this.userInactiveHandler)
110 } 119 }
111 120
112 buildCSSClass () { 121 buildCSSClass () {
@@ -122,9 +131,9 @@ class SettingsButton extends Button {
122 } 131 }
123 132
124 showDialog () { 133 showDialog () {
125 this.player_.peertube().onMenuOpen() 134 this.player().peertube().onMenuOpen();
126 135
127 this.menu.el_.style.opacity = '1' 136 (this.menu.el() as HTMLElement).style.opacity = '1'
128 this.dialog.show() 137 this.dialog.show()
129 138
130 this.setDialogSize(this.getComponentSize(this.menu)) 139 this.setDialogSize(this.getComponentSize(this.menu))
@@ -134,23 +143,24 @@ class SettingsButton extends Button {
134 this.player_.peertube().onMenuClosed() 143 this.player_.peertube().onMenuClosed()
135 144
136 this.dialog.hide() 145 this.dialog.hide()
137 this.setDialogSize(this.getComponentSize(this.menu)) 146 this.setDialogSize(this.getComponentSize(this.menu));
138 this.menu.el_.style.opacity = '1' 147 (this.menu.el() as HTMLElement).style.opacity = '1'
139 this.resetChildren() 148 this.resetChildren()
140 } 149 }
141 150
142 getComponentSize (element: any) { 151 getComponentSize (element: videojs.Component | HTMLElement) {
143 let width: number = null 152 let width: number = null
144 let height: number = null 153 let height: number = null
145 154
146 // Could be component or just DOM element 155 // Could be component or just DOM element
147 if (element instanceof Component) { 156 if (element instanceof Component) {
148 width = element.el_.offsetWidth 157 const el = element.el() as HTMLElement
149 height = element.el_.offsetHeight 158
159 width = el.offsetWidth
160 height = el.offsetHeight;
150 161
151 // keep width/height as properties for direct use 162 (element as any).width = width;
152 element.width = width 163 (element as any).height = height
153 element.height = height
154 } else { 164 } else {
155 width = element.offsetWidth 165 width = element.offsetWidth
156 height = element.offsetHeight 166 height = element.offsetHeight
@@ -164,15 +174,17 @@ class SettingsButton extends Button {
164 return 174 return
165 } 175 }
166 176
167 const offset = this.options_.setup.maxHeightOffset 177 const offset = this.settingsButtonOptions.setup.maxHeightOffset
168 const maxHeight = this.playerComponent.el_.offsetHeight - offset 178 const maxHeight = (this.player().el() as HTMLElement).offsetHeight - offset // FIXME: typings
179
180 const panelEl = this.panel.el() as HTMLElement
169 181
170 if (height > maxHeight) { 182 if (height > maxHeight) {
171 height = maxHeight 183 height = maxHeight
172 width += 17 184 width += 17
173 this.panel.el_.style.maxHeight = `${height}px` 185 panelEl.style.maxHeight = `${height}px`
174 } else if (this.panel.el_.style.maxHeight !== '') { 186 } else if (panelEl.style.maxHeight !== '') {
175 this.panel.el_.style.maxHeight = '' 187 panelEl.style.maxHeight = ''
176 } 188 }
177 189
178 this.dialogEl.style.width = `${width}px` 190 this.dialogEl.style.width = `${width}px`
@@ -182,7 +194,7 @@ class SettingsButton extends Button {
182 buildMenu () { 194 buildMenu () {
183 this.menu = new Menu(this.player()) 195 this.menu = new Menu(this.player())
184 this.menu.addClass('vjs-main-menu') 196 this.menu.addClass('vjs-main-menu')
185 const entries = this.options_.entries 197 const entries = this.settingsButtonOptions.entries
186 198
187 if (entries.length === 0) { 199 if (entries.length === 0) {
188 this.addClass('vjs-hidden') 200 this.addClass('vjs-hidden')
@@ -191,7 +203,7 @@ class SettingsButton extends Button {
191 } 203 }
192 204
193 for (const entry of entries) { 205 for (const entry of entries) {
194 this.addMenuItem(entry, this.options_) 206 this.addMenuItem(entry, this.settingsButtonOptions)
195 } 207 }
196 208
197 this.panelChild.addChild(this.menu) 209 this.panelChild.addChild(this.menu)
@@ -199,15 +211,17 @@ class SettingsButton extends Button {
199 211
200 addMenuItem (entry: any, options: any) { 212 addMenuItem (entry: any, options: any) {
201 const openSubMenu = function (this: any) { 213 const openSubMenu = function (this: any) {
202 if (videojsUntyped.dom.hasClass(this.el_, 'open')) { 214 if (videojs.dom.hasClass(this.el_, 'open')) {
203 videojsUntyped.dom.removeClass(this.el_, 'open') 215 videojs.dom.removeClass(this.el_, 'open')
204 } else { 216 } else {
205 videojsUntyped.dom.addClass(this.el_, 'open') 217 videojs.dom.addClass(this.el_, 'open')
206 } 218 }
207 } 219 }
208 220
209 options.name = toTitleCase(entry) 221 options.name = toTitleCase(entry)
210 const settingsMenuItem = new SettingsMenuItem(this.player(), options, entry, this as any) 222
223 const newOptions = Object.assign({}, options, { entry, menuButton: this })
224 const settingsMenuItem = new SettingsMenuItem(this.player(), newOptions)
211 225
212 this.menu.addChild(settingsMenuItem) 226 this.menu.addChild(settingsMenuItem)
213 227
@@ -221,7 +235,7 @@ class SettingsButton extends Button {
221 235
222 resetChildren () { 236 resetChildren () {
223 for (const menuChild of this.menu.children()) { 237 for (const menuChild of this.menu.children()) {
224 menuChild.reset() 238 (menuChild as SettingsMenuItem).reset()
225 } 239 }
226 } 240 }
227 241
@@ -230,75 +244,12 @@ class SettingsButton extends Button {
230 */ 244 */
231 hideChildren () { 245 hideChildren () {
232 for (const menuChild of this.menu.children()) { 246 for (const menuChild of this.menu.children()) {
233 menuChild.hideSubMenu() 247 (menuChild as SettingsMenuItem).hideSubMenu()
234 } 248 }
235 } 249 }
236 250
237} 251}
238 252
239class SettingsPanel extends Component {
240 constructor (player: videojs.Player, options: any) {
241 super(player, options)
242 }
243
244 createEl () {
245 return super.createEl('div', {
246 className: 'vjs-settings-panel',
247 innerHTML: '',
248 tabIndex: -1
249 })
250 }
251}
252
253class SettingsPanelChild extends Component {
254 constructor (player: videojs.Player, options: any) {
255 super(player, options)
256 }
257
258 createEl () {
259 return super.createEl('div', {
260 className: 'vjs-settings-panel-child',
261 innerHTML: '',
262 tabIndex: -1
263 })
264 }
265}
266
267class SettingsDialog extends Component {
268 constructor (player: videojs.Player, options: any) {
269 super(player, options)
270 this.hide()
271 }
272
273 /**
274 * Create the component's DOM element
275 *
276 * @return {Element}
277 * @method createEl
278 */
279 createEl () {
280 const uniqueId = this.id_
281 const dialogLabelId = 'TTsettingsDialogLabel-' + uniqueId
282 const dialogDescriptionId = 'TTsettingsDialogDescription-' + uniqueId
283
284 return super.createEl('div', {
285 className: 'vjs-settings-dialog vjs-modal-overlay',
286 innerHTML: '',
287 tabIndex: -1
288 }, {
289 'role': 'dialog',
290 'aria-labelledby': dialogLabelId,
291 'aria-describedby': dialogDescriptionId
292 })
293 }
294
295}
296
297SettingsButton.prototype.controlText_ = 'Settings'
298
299Component.registerComponent('SettingsButton', SettingsButton) 253Component.registerComponent('SettingsButton', SettingsButton)
300Component.registerComponent('SettingsDialog', SettingsDialog)
301Component.registerComponent('SettingsPanel', SettingsPanel)
302Component.registerComponent('SettingsPanelChild', SettingsPanelChild)
303 254
304export { SettingsButton, SettingsDialog, SettingsPanel, SettingsPanelChild } 255export { SettingsButton }
diff --git a/client/src/assets/player/videojs-components/settings-menu-item.ts b/client/src/assets/player/videojs-components/settings-menu-item.ts
index 84d394c0e..f5671f49d 100644
--- a/client/src/assets/player/videojs-components/settings-menu-item.ts
+++ b/client/src/assets/player/videojs-components/settings-menu-item.ts
@@ -1,57 +1,63 @@
1// Author: Yanko Shterev 1// Thanks to Yanko Shterev: https://github.com/yshterev/videojs-settings-menu
2// Thanks https://github.com/yshterev/videojs-settings-menu
3
4// FIXME: something weird with our path definition in tsconfig and typings
5// @ts-ignore
6import * as videojs from 'video.js'
7
8import { toTitleCase } from '../utils' 2import { toTitleCase } from '../utils'
9import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' 3import videojs, { VideoJsPlayer } from 'video.js'
10 4import { SettingsButton } from './settings-menu-button'
11const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') 5import { SettingsDialog } from './settings-dialog'
12const component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') 6import { SettingsPanel } from './settings-panel'
7import { SettingsPanelChild } from './settings-panel-child'
8
9const MenuItem = videojs.getComponent('MenuItem')
10const component = videojs.getComponent('Component')
11
12export interface SettingsMenuItemOptions extends videojs.MenuItemOptions {
13 entry: string
14 menuButton: SettingsButton
15}
13 16
14class SettingsMenuItem extends MenuItem { 17class SettingsMenuItem extends MenuItem {
15 settingsButton: any 18 settingsButton: SettingsButton
16 dialog: any 19 dialog: SettingsDialog
17 mainMenu: any 20 mainMenu: videojs.Menu
18 panel: any 21 panel: SettingsPanel
19 panelChild: any 22 panelChild: SettingsPanelChild
20 panelChildEl: any 23 panelChildEl: HTMLElement
21 size: any 24 size: number[]
22 menuToLoad: string 25 menuToLoad: string
23 subMenu: any 26 subMenu: SettingsButton
24 27
25 submenuClickHandler: Function 28 submenuClickHandler: typeof SettingsMenuItem.prototype.onSubmenuClick
26 transitionEndHandler: Function 29 transitionEndHandler: typeof SettingsMenuItem.prototype.onTransitionEnd
27 30
28 settingsSubMenuTitleEl_: any 31 settingsSubMenuTitleEl_: HTMLElement
29 settingsSubMenuValueEl_: any 32 settingsSubMenuValueEl_: HTMLElement
30 settingsSubMenuEl_: any 33 settingsSubMenuEl_: HTMLElement
31 34
32 constructor (player: videojs.Player, options: any, entry: string, menuButton: VideoJSComponentInterface) { 35 constructor (player: VideoJsPlayer, options?: SettingsMenuItemOptions) {
33 super(player, options) 36 super(player, options)
34 37
35 this.settingsButton = menuButton 38 this.settingsButton = options.menuButton
36 this.dialog = this.settingsButton.dialog 39 this.dialog = this.settingsButton.dialog
37 this.mainMenu = this.settingsButton.menu 40 this.mainMenu = this.settingsButton.menu
38 this.panel = this.dialog.getChild('settingsPanel') 41 this.panel = this.dialog.getChild('settingsPanel')
39 this.panelChild = this.panel.getChild('settingsPanelChild') 42 this.panelChild = this.panel.getChild('settingsPanelChild')
40 this.panelChildEl = this.panelChild.el_ 43 this.panelChildEl = this.panelChild.el() as HTMLElement
41 44
42 this.size = null 45 this.size = null
43 46
44 // keep state of what menu type is loading next 47 // keep state of what menu type is loading next
45 this.menuToLoad = 'mainmenu' 48 this.menuToLoad = 'mainmenu'
46 49
47 const subMenuName = toTitleCase(entry) 50 const subMenuName = toTitleCase(options.entry)
48 const SubMenuComponent = videojsUntyped.getComponent(subMenuName) 51 const SubMenuComponent = videojs.getComponent(subMenuName)
49 52
50 if (!SubMenuComponent) { 53 if (!SubMenuComponent) {
51 throw new Error(`Component ${subMenuName} does not exist`) 54 throw new Error(`Component ${subMenuName} does not exist`)
52 } 55 }
53 this.subMenu = new SubMenuComponent(this.player(), options, menuButton, this) 56
54 const subMenuClass = this.subMenu.buildCSSClass().split(' ')[0] 57 const newOptions = Object.assign({}, options, { entry: options.menuButton, menuButton: this })
58
59 this.subMenu = new SubMenuComponent(this.player(), newOptions) as any // FIXME: typings
60 const subMenuClass = this.subMenu.buildCSSClass().split(' ')[ 0 ]
55 this.settingsSubMenuEl_.className += ' ' + subMenuClass 61 this.settingsSubMenuEl_.className += ' ' + subMenuClass
56 62
57 this.eventHandlers() 63 this.eventHandlers()
@@ -72,7 +78,7 @@ class SettingsMenuItem extends MenuItem {
72 player.on('captionsChanged', () => { 78 player.on('captionsChanged', () => {
73 setTimeout(() => { 79 setTimeout(() => {
74 this.settingsSubMenuEl_.innerHTML = '' 80 this.settingsSubMenuEl_.innerHTML = ''
75 this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_) 81 this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el())
76 this.update() 82 this.update()
77 this.bindClickEvents() 83 this.bindClickEvents()
78 }, 0) 84 }, 0)
@@ -119,27 +125,27 @@ class SettingsMenuItem extends MenuItem {
119 * @method createEl 125 * @method createEl
120 */ 126 */
121 createEl () { 127 createEl () {
122 const el = videojsUntyped.dom.createEl('li', { 128 const el = videojs.dom.createEl('li', {
123 className: 'vjs-menu-item' 129 className: 'vjs-menu-item'
124 }) 130 })
125 131
126 this.settingsSubMenuTitleEl_ = videojsUntyped.dom.createEl('div', { 132 this.settingsSubMenuTitleEl_ = videojs.dom.createEl('div', {
127 className: 'vjs-settings-sub-menu-title' 133 className: 'vjs-settings-sub-menu-title'
128 }) 134 }) as HTMLElement
129 135
130 el.appendChild(this.settingsSubMenuTitleEl_) 136 el.appendChild(this.settingsSubMenuTitleEl_)
131 137
132 this.settingsSubMenuValueEl_ = videojsUntyped.dom.createEl('div', { 138 this.settingsSubMenuValueEl_ = videojs.dom.createEl('div', {
133 className: 'vjs-settings-sub-menu-value' 139 className: 'vjs-settings-sub-menu-value'
134 }) 140 }) as HTMLElement
135 141
136 el.appendChild(this.settingsSubMenuValueEl_) 142 el.appendChild(this.settingsSubMenuValueEl_)
137 143
138 this.settingsSubMenuEl_ = videojsUntyped.dom.createEl('div', { 144 this.settingsSubMenuEl_ = videojs.dom.createEl('div', {
139 className: 'vjs-settings-sub-menu' 145 className: 'vjs-settings-sub-menu'
140 }) 146 }) as HTMLElement
141 147
142 return el 148 return el as HTMLLIElement
143 } 149 }
144 150
145 /** 151 /**
@@ -147,17 +153,17 @@ class SettingsMenuItem extends MenuItem {
147 * 153 *
148 * @method handleClick 154 * @method handleClick
149 */ 155 */
150 handleClick () { 156 handleClick (event: videojs.EventTarget.Event) {
151 this.menuToLoad = 'submenu' 157 this.menuToLoad = 'submenu'
152 // Remove open class to ensure only the open submenu gets this class 158 // Remove open class to ensure only the open submenu gets this class
153 videojsUntyped.dom.removeClass(this.el_, 'open') 159 videojs.dom.removeClass(this.el(), 'open')
154 160
155 super.handleClick() 161 super.handleClick(event);
156 162
157 this.mainMenu.el_.style.opacity = '0' 163 (this.mainMenu.el() as HTMLElement).style.opacity = '0'
158 // Whether to add or remove vjs-hidden class on the settingsSubMenuEl element 164 // Whether to add or remove vjs-hidden class on the settingsSubMenuEl element
159 if (videojsUntyped.dom.hasClass(this.settingsSubMenuEl_, 'vjs-hidden')) { 165 if (videojs.dom.hasClass(this.settingsSubMenuEl_, 'vjs-hidden')) {
160 videojsUntyped.dom.removeClass(this.settingsSubMenuEl_, 'vjs-hidden') 166 videojs.dom.removeClass(this.settingsSubMenuEl_, 'vjs-hidden')
161 167
162 // animation not played without timeout 168 // animation not played without timeout
163 setTimeout(() => { 169 setTimeout(() => {
@@ -167,7 +173,7 @@ class SettingsMenuItem extends MenuItem {
167 173
168 this.settingsButton.setDialogSize(this.size) 174 this.settingsButton.setDialogSize(this.size)
169 } else { 175 } else {
170 videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') 176 videojs.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden')
171 } 177 }
172 } 178 }
173 179
@@ -178,9 +184,9 @@ class SettingsMenuItem extends MenuItem {
178 */ 184 */
179 createBackButton () { 185 createBackButton () {
180 const button = this.subMenu.menu.addChild('MenuItem', {}, 0) 186 const button = this.subMenu.menu.addChild('MenuItem', {}, 0)
181 button.name_ = 'BackButton' 187
182 button.addClass('vjs-back-button') 188 button.addClass('vjs-back-button');
183 button.el_.innerHTML = this.player_.localize(this.subMenu.controlText_) 189 (button.el() as HTMLElement).innerHTML = this.player().localize(this.subMenu.controlText())
184 } 190 }
185 191
186 /** 192 /**
@@ -189,17 +195,17 @@ class SettingsMenuItem extends MenuItem {
189 * @method PrefixedEvent 195 * @method PrefixedEvent
190 */ 196 */
191 PrefixedEvent (element: any, type: any, callback: any, action = 'addEvent') { 197 PrefixedEvent (element: any, type: any, callback: any, action = 'addEvent') {
192 const prefix = ['webkit', 'moz', 'MS', 'o', ''] 198 const prefix = [ 'webkit', 'moz', 'MS', 'o', '' ]
193 199
194 for (let p = 0; p < prefix.length; p++) { 200 for (let p = 0; p < prefix.length; p++) {
195 if (!prefix[p]) { 201 if (!prefix[ p ]) {
196 type = type.toLowerCase() 202 type = type.toLowerCase()
197 } 203 }
198 204
199 if (action === 'addEvent') { 205 if (action === 'addEvent') {
200 element.addEventListener(prefix[p] + type, callback, false) 206 element.addEventListener(prefix[ p ] + type, callback, false)
201 } else if (action === 'removeEvent') { 207 } else if (action === 'removeEvent') {
202 element.removeEventListener(prefix[p] + type, callback, false) 208 element.removeEventListener(prefix[ p ] + type, callback, false)
203 } 209 }
204 } 210 }
205 } 211 }
@@ -211,7 +217,7 @@ class SettingsMenuItem extends MenuItem {
211 217
212 if (this.menuToLoad === 'mainmenu') { 218 if (this.menuToLoad === 'mainmenu') {
213 // hide submenu 219 // hide submenu
214 videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') 220 videojs.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden')
215 221
216 // reset opacity to 0 222 // reset opacity to 0
217 this.settingsSubMenuEl_.style.opacity = '0' 223 this.settingsSubMenuEl_.style.opacity = '0'
@@ -219,25 +225,27 @@ class SettingsMenuItem extends MenuItem {
219 } 225 }
220 226
221 reset () { 227 reset () {
222 videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') 228 videojs.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden')
223 this.settingsSubMenuEl_.style.opacity = '0' 229 this.settingsSubMenuEl_.style.opacity = '0'
224 this.setMargin() 230 this.setMargin()
225 } 231 }
226 232
227 loadMainMenu () { 233 loadMainMenu () {
234 const mainMenuEl = this.mainMenu.el() as HTMLElement
228 this.menuToLoad = 'mainmenu' 235 this.menuToLoad = 'mainmenu'
229 this.mainMenu.show() 236 this.mainMenu.show()
230 this.mainMenu.el_.style.opacity = '0' 237 mainMenuEl.style.opacity = '0'
231 238
232 // back button will always take you to main menu, so set dialog sizes 239 // back button will always take you to main menu, so set dialog sizes
233 this.settingsButton.setDialogSize([this.mainMenu.width, this.mainMenu.height]) 240 const mainMenuAny = this.mainMenu as any
241 this.settingsButton.setDialogSize([ mainMenuAny.width, mainMenuAny.height ])
234 242
235 // animation not triggered without timeout (some async stuff ?!?) 243 // animation not triggered without timeout (some async stuff ?!?)
236 setTimeout(() => { 244 setTimeout(() => {
237 // animate margin and opacity before hiding the submenu 245 // animate margin and opacity before hiding the submenu
238 // this triggers CSS Transition event 246 // this triggers CSS Transition event
239 this.setMargin() 247 this.setMargin()
240 this.mainMenu.el_.style.opacity = '1' 248 mainMenuEl.style.opacity = '1'
241 }, 0) 249 }, 0)
242 } 250 }
243 251
@@ -251,8 +259,8 @@ class SettingsMenuItem extends MenuItem {
251 this.update() 259 this.update()
252 }) 260 })
253 261
254 this.settingsSubMenuTitleEl_.innerHTML = this.player_.localize(this.subMenu.controlText_) 262 this.settingsSubMenuTitleEl_.innerHTML = this.player().localize(this.subMenu.controlText())
255 this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_) 263 this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el())
256 this.panelChildEl.appendChild(this.settingsSubMenuEl_) 264 this.panelChildEl.appendChild(this.settingsSubMenuEl_)
257 this.update() 265 this.update()
258 266
@@ -283,7 +291,8 @@ class SettingsMenuItem extends MenuItem {
283 // or sets options_['selected'] on the selected playback rate. 291 // or sets options_['selected'] on the selected playback rate.
284 // Thus we get the submenu value based on the labelEl of playbackRateMenuButton 292 // Thus we get the submenu value based on the labelEl of playbackRateMenuButton
285 if (subMenu === 'PlaybackRateMenuButton') { 293 if (subMenu === 'PlaybackRateMenuButton') {
286 setTimeout(() => this.settingsSubMenuValueEl_.innerHTML = this.subMenu.labelEl_.innerHTML, 250) 294 const html = (this.subMenu as any).labelEl_.innerHTML
295 setTimeout(() => this.settingsSubMenuValueEl_.innerHTML = html, 250)
287 } else { 296 } else {
288 // Loop trough the submenu items to find the selected child 297 // Loop trough the submenu items to find the selected child
289 for (const subMenuItem of this.subMenu.menu.children_) { 298 for (const subMenuItem of this.subMenu.menu.children_) {
@@ -292,13 +301,15 @@ class SettingsMenuItem extends MenuItem {
292 } 301 }
293 302
294 if (subMenuItem.hasClass('vjs-selected')) { 303 if (subMenuItem.hasClass('vjs-selected')) {
304 const subMenuItemUntyped = subMenuItem as any
305
295 // Prefer to use the function 306 // Prefer to use the function
296 if (typeof subMenuItem.getLabel === 'function') { 307 if (typeof subMenuItemUntyped.getLabel === 'function') {
297 this.settingsSubMenuValueEl_.innerHTML = subMenuItem.getLabel() 308 this.settingsSubMenuValueEl_.innerHTML = subMenuItemUntyped.getLabel()
298 break 309 break
299 } 310 }
300 311
301 this.settingsSubMenuValueEl_.innerHTML = subMenuItem.options_.label 312 this.settingsSubMenuValueEl_.innerHTML = subMenuItemUntyped.options_.label
302 } 313 }
303 } 314 }
304 } 315 }
@@ -313,7 +324,7 @@ class SettingsMenuItem extends MenuItem {
313 if (!(item instanceof component)) { 324 if (!(item instanceof component)) {
314 continue 325 continue
315 } 326 }
316 item.on(['tap', 'click'], this.submenuClickHandler) 327 item.on([ 'tap', 'click' ], this.submenuClickHandler)
317 } 328 }
318 } 329 }
319 330
@@ -321,11 +332,11 @@ class SettingsMenuItem extends MenuItem {
321 // if number of submenu items change dynamically more logic will be needed 332 // if number of submenu items change dynamically more logic will be needed
322 setSize () { 333 setSize () {
323 this.dialog.removeClass('vjs-hidden') 334 this.dialog.removeClass('vjs-hidden')
324 videojsUntyped.dom.removeClass(this.settingsSubMenuEl_, 'vjs-hidden') 335 videojs.dom.removeClass(this.settingsSubMenuEl_, 'vjs-hidden')
325 this.size = this.settingsButton.getComponentSize(this.settingsSubMenuEl_) 336 this.size = this.settingsButton.getComponentSize(this.settingsSubMenuEl_)
326 this.setMargin() 337 this.setMargin()
327 this.dialog.addClass('vjs-hidden') 338 this.dialog.addClass('vjs-hidden')
328 videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') 339 videojs.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden')
329 } 340 }
330 341
331 setMargin () { 342 setMargin () {
@@ -341,19 +352,19 @@ class SettingsMenuItem extends MenuItem {
341 */ 352 */
342 hideSubMenu () { 353 hideSubMenu () {
343 // after removing settings item this.el_ === null 354 // after removing settings item this.el_ === null
344 if (!this.el_) { 355 if (!this.el()) {
345 return 356 return
346 } 357 }
347 358
348 if (videojsUntyped.dom.hasClass(this.el_, 'open')) { 359 if (videojs.dom.hasClass(this.el(), 'open')) {
349 videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') 360 videojs.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden')
350 videojsUntyped.dom.removeClass(this.el_, 'open') 361 videojs.dom.removeClass(this.el(), 'open')
351 } 362 }
352 } 363 }
353 364
354} 365}
355 366
356SettingsMenuItem.prototype.contentElType = 'button' 367(SettingsMenuItem as any).prototype.contentElType = 'button'
357videojsUntyped.registerComponent('SettingsMenuItem', SettingsMenuItem) 368videojs.registerComponent('SettingsMenuItem', SettingsMenuItem)
358 369
359export { SettingsMenuItem } 370export { SettingsMenuItem }
diff --git a/client/src/assets/player/videojs-components/settings-panel-child.ts b/client/src/assets/player/videojs-components/settings-panel-child.ts
new file mode 100644
index 000000000..d12e8218a
--- /dev/null
+++ b/client/src/assets/player/videojs-components/settings-panel-child.ts
@@ -0,0 +1,22 @@
1import videojs, { VideoJsPlayer } from 'video.js'
2
3const Component = videojs.getComponent('Component')
4
5class SettingsPanelChild extends Component {
6
7 constructor (player: VideoJsPlayer, options?: videojs.ComponentOptions) {
8 super(player, options)
9 }
10
11 createEl () {
12 return super.createEl('div', {
13 className: 'vjs-settings-panel-child',
14 innerHTML: '',
15 tabIndex: -1
16 })
17 }
18}
19
20Component.registerComponent('SettingsPanelChild', SettingsPanelChild)
21
22export { SettingsPanelChild }
diff --git a/client/src/assets/player/videojs-components/settings-panel.ts b/client/src/assets/player/videojs-components/settings-panel.ts
new file mode 100644
index 000000000..2090abf45
--- /dev/null
+++ b/client/src/assets/player/videojs-components/settings-panel.ts
@@ -0,0 +1,22 @@
1import videojs, { VideoJsPlayer } from 'video.js'
2
3const Component = videojs.getComponent('Component')
4
5class SettingsPanel extends Component {
6
7 constructor (player: VideoJsPlayer, options?: videojs.ComponentOptions) {
8 super(player, options)
9 }
10
11 createEl () {
12 return super.createEl('div', {
13 className: 'vjs-settings-panel',
14 innerHTML: '',
15 tabIndex: -1
16 })
17 }
18}
19
20Component.registerComponent('SettingsPanel', SettingsPanel)
21
22export { SettingsPanel }
diff --git a/client/src/assets/player/videojs-components/theater-button.ts b/client/src/assets/player/videojs-components/theater-button.ts
index bf383cf34..1c8c9f154 100644
--- a/client/src/assets/player/videojs-components/theater-button.ts
+++ b/client/src/assets/player/videojs-components/theater-button.ts
@@ -1,26 +1,24 @@
1// FIXME: something weird with our path definition in tsconfig and typings 1import videojs, { VideoJsPlayer } from 'video.js'
2// @ts-ignore
3import * as videojs from 'video.js'
4
5import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
6import { saveTheaterInStore, getStoredTheater } from '../peertube-player-local-storage' 2import { saveTheaterInStore, getStoredTheater } from '../peertube-player-local-storage'
7 3
8const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') 4const Button = videojs.getComponent('Button')
9class TheaterButton extends Button { 5class TheaterButton extends Button {
10 6
11 private static readonly THEATER_MODE_CLASS = 'vjs-theater-enabled' 7 private static readonly THEATER_MODE_CLASS = 'vjs-theater-enabled'
12 8
13 constructor (player: videojs.Player, options: any) { 9 constructor (player: VideoJsPlayer, options: videojs.ComponentOptions) {
14 super(player, options) 10 super(player, options)
15 11
16 const enabled = getStoredTheater() 12 const enabled = getStoredTheater()
17 if (enabled === true) { 13 if (enabled === true) {
18 this.player_.addClass(TheaterButton.THEATER_MODE_CLASS) 14 this.player().addClass(TheaterButton.THEATER_MODE_CLASS)
19 15
20 this.handleTheaterChange() 16 this.handleTheaterChange()
21 } 17 }
22 18
23 this.player_.theaterEnabled = enabled 19 this.controlText('Theater mode')
20
21 this.player().theaterEnabled = enabled
24 } 22 }
25 23
26 buildCSSClass () { 24 buildCSSClass () {
@@ -52,6 +50,4 @@ class TheaterButton extends Button {
52 } 50 }
53} 51}
54 52
55TheaterButton.prototype.controlText_ = 'Theater mode' 53videojs.registerComponent('TheaterButton', TheaterButton)
56
57TheaterButton.registerComponent('TheaterButton', TheaterButton)
diff --git a/client/src/assets/player/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/webtorrent/webtorrent-plugin.ts
index 35cf85c99..d26fc38fa 100644
--- a/client/src/assets/player/webtorrent/webtorrent-plugin.ts
+++ b/client/src/assets/player/webtorrent/webtorrent-plugin.ts
@@ -1,17 +1,15 @@
1// FIXME: something weird with our path definition in tsconfig and typings 1import videojs, { VideoJsPlayer } from 'video.js'
2// @ts-ignore
3import * as videojs from 'video.js'
4 2
5import * as WebTorrent from 'webtorrent' 3import * as WebTorrent from 'webtorrent'
6import { renderVideo } from './video-renderer' 4import { renderVideo } from './video-renderer'
7import { LoadedQualityData, PlayerNetworkInfo, VideoJSComponentInterface, WebtorrentPluginOptions } from '../peertube-videojs-typings' 5import { LoadedQualityData, PlayerNetworkInfo, WebtorrentPluginOptions } from '../peertube-videojs-typings'
8import { getRtcConfig, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from '../utils' 6import { getRtcConfig, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from '../utils'
9import { PeertubeChunkStore } from './peertube-chunk-store' 7import { PeertubeChunkStore } from './peertube-chunk-store'
10import { 8import {
11 getAverageBandwidthInStore, 9 getAverageBandwidthInStore,
12 getStoredMute, 10 getStoredMute,
13 getStoredVolume,
14 getStoredP2PEnabled, 11 getStoredP2PEnabled,
12 getStoredVolume,
15 saveAverageBandwidth 13 saveAverageBandwidth
16} from '../peertube-player-local-storage' 14} from '../peertube-player-local-storage'
17import { VideoFile } from '@shared/models' 15import { VideoFile } from '@shared/models'
@@ -24,13 +22,14 @@ type PlayOptions = {
24 delay?: number 22 delay?: number
25} 23}
26 24
27const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') 25const Plugin = videojs.getPlugin('plugin')
26
28class WebTorrentPlugin extends Plugin { 27class WebTorrentPlugin extends Plugin {
29 private readonly playerElement: HTMLVideoElement 28 private readonly playerElement: HTMLVideoElement
30 29
31 private readonly autoplay: boolean = false 30 private readonly autoplay: boolean = false
32 private readonly startTime: number = 0 31 private readonly startTime: number = 0
33 private readonly savePlayerSrcFunction: Function 32 private readonly savePlayerSrcFunction: VideoJsPlayer['src']
34 private readonly videoFiles: VideoFile[] 33 private readonly videoFiles: VideoFile[]
35 private readonly videoDuration: number 34 private readonly videoDuration: number
36 private readonly CONSTANTS = { 35 private readonly CONSTANTS = {
@@ -49,7 +48,6 @@ class WebTorrentPlugin extends Plugin {
49 dht: false 48 dht: false
50 }) 49 })
51 50
52 private player: any
53 private currentVideoFile: VideoFile 51 private currentVideoFile: VideoFile
54 private torrent: WebTorrent.Torrent 52 private torrent: WebTorrent.Torrent
55 53
@@ -70,8 +68,8 @@ class WebTorrentPlugin extends Plugin {
70 68
71 private downloadSpeeds: number[] = [] 69 private downloadSpeeds: number[] = []
72 70
73 constructor (player: videojs.Player, options: WebtorrentPluginOptions) { 71 constructor (player: VideoJsPlayer, options?: WebtorrentPluginOptions) {
74 super(player, options) 72 super(player)
75 73
76 this.startTime = timeToInt(options.startTime) 74 this.startTime = timeToInt(options.startTime)
77 75
@@ -147,12 +145,12 @@ class WebTorrentPlugin extends Plugin {
147 } 145 }
148 146
149 // Do not display error to user because we will have multiple fallback 147 // Do not display error to user because we will have multiple fallback
150 this.disableErrorDisplay() 148 this.disableErrorDisplay();
151 149
152 // Hack to "simulate" src link in video.js >= 6 150 // Hack to "simulate" src link in video.js >= 6
153 // Without this, we can't play the video after pausing it 151 // Without this, we can't play the video after pausing it
154 // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633 152 // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633
155 this.player.src = () => true 153 (this.player as any).src = () => true
156 const oldPlaybackRate = this.player.playbackRate() 154 const oldPlaybackRate = this.player.playbackRate()
157 155
158 const previousVideoFile = this.currentVideoFile 156 const previousVideoFile = this.currentVideoFile
@@ -333,7 +331,7 @@ class WebTorrentPlugin extends Plugin {
333 331
334 const playPromise = this.player.play() 332 const playPromise = this.player.play()
335 if (playPromise !== undefined) { 333 if (playPromise !== undefined) {
336 return playPromise.then(done) 334 return playPromise.then(() => done())
337 .catch((err: Error) => { 335 .catch((err: Error) => {
338 if (err.message.indexOf('The play() request was interrupted by a call to pause()') !== -1) { 336 if (err.message.indexOf('The play() request was interrupted by a call to pause()') !== -1) {
339 return 337 return
@@ -426,8 +424,8 @@ class WebTorrentPlugin extends Plugin {
426 } 424 }
427 425
428 // Proxy first play 426 // Proxy first play
429 const oldPlay = this.player.play.bind(this.player) 427 const oldPlay = this.player.play.bind(this.player);
430 this.player.play = () => { 428 (this.player as any).play = () => {
431 this.player.addClass('vjs-has-big-play-button-clicked') 429 this.player.addClass('vjs-has-big-play-button-clicked')
432 this.player.play = oldPlay 430 this.player.play = oldPlay
433 431
@@ -619,7 +617,7 @@ class WebTorrentPlugin extends Plugin {
619 video: qualityLevelsPayload 617 video: qualityLevelsPayload
620 } 618 }
621 } 619 }
622 this.player.tech_.trigger('loadedqualitydata', payload) 620 this.player.tech(true).trigger('loadedqualitydata', payload)
623 } 621 }
624 622
625 private buildQualityLabel (file: VideoFile) { 623 private buildQualityLabel (file: VideoFile) {
@@ -651,9 +649,9 @@ class WebTorrentPlugin extends Plugin {
651 return 649 return
652 } 650 }
653 651
654 for (let i = 0; i < qualityLevels; i++) { 652 for (let i = 0; i < qualityLevels.length; i++) {
655 const q = this.player.qualityLevels[i] 653 const q = qualityLevels[i]
656 if (q.height === resolutionId) qualityLevels.selectedIndex = i 654 if (q.height === resolutionId) qualityLevels.selectedIndex_ = i
657 } 655 }
658 } 656 }
659} 657}
diff --git a/client/tsconfig.json b/client/tsconfig.json
index 8824c4f7c..c4f2d6a6a 100644
--- a/client/tsconfig.json
+++ b/client/tsconfig.json
@@ -26,7 +26,7 @@
26 "paths": { 26 "paths": {
27 "@app/*": [ "src/app/*" ], 27 "@app/*": [ "src/app/*" ],
28 "@shared/*": [ "../shared/*" ], 28 "@shared/*": [ "../shared/*" ],
29 "video.js": [ "node_modules/video.js/dist/alt/video.core.js" ], 29 "video.js": [ "node_modules/video.js/dist/alt/video.core.novtt" ],
30 "fs": [ "src/shims/noop" ], 30 "fs": [ "src/shims/noop" ],
31 "http": [ "src/shims/http" ], 31 "http": [ "src/shims/http" ],
32 "https": [ "src/shims/https" ], 32 "https": [ "src/shims/https" ],
diff --git a/client/webpack/webpack.video-embed.js b/client/webpack/webpack.video-embed.js
index 909048cca..f6d532556 100644
--- a/client/webpack/webpack.video-embed.js
+++ b/client/webpack/webpack.video-embed.js
@@ -27,7 +27,7 @@ module.exports = function () {
27 modules: [ helpers.root('src'), helpers.root('node_modules') ], 27 modules: [ helpers.root('src'), helpers.root('node_modules') ],
28 28
29 alias: { 29 alias: {
30 'video.js$': path.resolve('node_modules/video.js/dist/alt/video.core.js') 30 'video.js$': path.resolve('node_modules/video.js/dist/alt/video.core.novtt.js')
31 } 31 }
32 }, 32 },
33 33
diff --git a/client/yarn.lock b/client/yarn.lock
index 0855a2570..20ff5c3c8 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -1157,10 +1157,10 @@
1157 resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9" 1157 resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9"
1158 integrity sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA== 1158 integrity sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==
1159 1159
1160"@types/video.js@^7.2.5": 1160"@types/video.js@^7.3.3":
1161 version "7.2.15" 1161 version "7.3.3"
1162 resolved "https://registry.yarnpkg.com/@types/video.js/-/video.js-7.2.15.tgz#03d950f01c985a5082ead4d1b73064455a1c8c6f" 1162 resolved "https://registry.yarnpkg.com/@types/video.js/-/video.js-7.3.3.tgz#b6870d954473dfd694e10b55a90c0f3be8522da3"
1163 integrity sha512-NsojVfvTwdVqDe0+vJaoHOO2iuLm0sp/u8jEsZeLGsM3gNfg5WIOFd6NC0cQR9JHUuDPPSPF70jxdklGWm5jhQ== 1163 integrity sha512-yAb46+4A0dKFxOQRVLoLyfC/S/BmHLE10MxPXt/t88+7R4GWLHosHelVtYpKBRykjptdkqfQXNRXoQzDeKm6MA==
1164 1164
1165"@types/webpack-sources@^0.1.5": 1165"@types/webpack-sources@^0.1.5":
1166 version "0.1.5" 1166 version "0.1.5"
@@ -2586,6 +2586,29 @@ chardet@^0.7.0:
2586 resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" 2586 resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
2587 integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== 2587 integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
2588 2588
2589chart.js@^2.9.3:
2590 version "2.9.3"
2591 resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-2.9.3.tgz#ae3884114dafd381bc600f5b35a189138aac1ef7"
2592 integrity sha512-+2jlOobSk52c1VU6fzkh3UwqHMdSlgH1xFv9FKMqHiNCpXsGPQa/+81AFa+i3jZ253Mq9aAycPwDjnn1XbRNNw==
2593 dependencies:
2594 chartjs-color "^2.1.0"
2595 moment "^2.10.2"
2596
2597chartjs-color-string@^0.6.0:
2598 version "0.6.0"
2599 resolved "https://registry.yarnpkg.com/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz#1df096621c0e70720a64f4135ea171d051402f71"
2600 integrity sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==
2601 dependencies:
2602 color-name "^1.0.0"
2603
2604chartjs-color@^2.1.0:
2605 version "2.4.1"
2606 resolved "https://registry.yarnpkg.com/chartjs-color/-/chartjs-color-2.4.1.tgz#6118bba202fe1ea79dd7f7c0f9da93467296c3b0"
2607 integrity sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==
2608 dependencies:
2609 chartjs-color-string "^0.6.0"
2610 color-convert "^1.9.3"
2611
2589check-types@^8.0.3: 2612check-types@^8.0.3:
2590 version "8.0.3" 2613 version "8.0.3"
2591 resolved "https://registry.yarnpkg.com/check-types/-/check-types-8.0.3.tgz#3356cca19c889544f2d7a95ed49ce508a0ecf552" 2614 resolved "https://registry.yarnpkg.com/check-types/-/check-types-8.0.3.tgz#3356cca19c889544f2d7a95ed49ce508a0ecf552"
@@ -2800,7 +2823,7 @@ collection-visit@^1.0.0:
2800 map-visit "^1.0.0" 2823 map-visit "^1.0.0"
2801 object-visit "^1.0.0" 2824 object-visit "^1.0.0"
2802 2825
2803color-convert@^1.9.0: 2826color-convert@^1.9.0, color-convert@^1.9.3:
2804 version "1.9.3" 2827 version "1.9.3"
2805 resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" 2828 resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
2806 integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== 2829 integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
@@ -2812,6 +2835,11 @@ color-name@1.1.3:
2812 resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" 2835 resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
2813 integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= 2836 integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
2814 2837
2838color-name@^1.0.0:
2839 version "1.1.4"
2840 resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
2841 integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
2842
2815colors@1.1.2: 2843colors@1.1.2:
2816 version "1.1.2" 2844 version "1.1.2"
2817 resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63" 2845 resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63"
@@ -6941,6 +6969,11 @@ mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1:
6941 dependencies: 6969 dependencies:
6942 minimist "0.0.8" 6970 minimist "0.0.8"
6943 6971
6972moment@^2.10.2:
6973 version "2.24.0"
6974 resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
6975 integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==
6976
6944mousetrap@^1.6.0: 6977mousetrap@^1.6.0:
6945 version "1.6.3" 6978 version "1.6.3"
6946 resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.3.tgz#80fee49665fd478bccf072c9d46bdf1bfed3558a" 6979 resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.3.tgz#80fee49665fd478bccf072c9d46bdf1bfed3558a"
@@ -10809,6 +10842,11 @@ void-elements@^2.0.0:
10809 resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec" 10842 resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec"
10810 integrity sha1-wGavtYK7HLQSjWDqkjkulNXp2+w= 10843 integrity sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=
10811 10844
10845vtt.js@^0.13.0:
10846 version "0.13.0"
10847 resolved "https://registry.yarnpkg.com/vtt.js/-/vtt.js-0.13.0.tgz#955c667b34d5325b2012cb9e8ba9bad6e0b11ff8"
10848 integrity sha1-lVxmezTVMlsgEsuei6m61uCxH/g=
10849
10812watchpack@^1.6.0: 10850watchpack@^1.6.0:
10813 version "1.6.0" 10851 version "1.6.0"
10814 resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00" 10852 resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00"
diff --git a/config/test.yaml b/config/test.yaml
index 5758c1887..74979f3a7 100644
--- a/config/test.yaml
+++ b/config/test.yaml
@@ -40,18 +40,18 @@ contact_form:
40 40
41redundancy: 41redundancy:
42 videos: 42 videos:
43 check_interval: '10 minutes' 43 check_interval: '1 minute'
44 strategies: 44 strategies:
45 - 45 -
46 size: '10MB' 46 size: '1000MB'
47 min_lifetime: '10 minutes' 47 min_lifetime: '10 minutes'
48 strategy: 'most-views' 48 strategy: 'most-views'
49 - 49 -
50 size: '10MB' 50 size: '1000MB'
51 min_lifetime: '10 minutes' 51 min_lifetime: '10 minutes'
52 strategy: 'trending' 52 strategy: 'trending'
53 - 53 -
54 size: '10MB' 54 size: '1000MB'
55 min_lifetime: '10 minutes' 55 min_lifetime: '10 minutes'
56 strategy: 'recently-added' 56 strategy: 'recently-added'
57 min_views: 1 57 min_views: 1
diff --git a/package.json b/package.json
index d4858725a..b416e9501 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
5 "private": true, 5 "private": true,
6 "licence": "AGPL-3.0", 6 "licence": "AGPL-3.0",
7 "engines": { 7 "engines": {
8 "node": ">=8.x" 8 "node": ">=10.x"
9 }, 9 },
10 "bin": { 10 "bin": {
11 "peertube": "dist/server/tools/peertube.js" 11 "peertube": "dist/server/tools/peertube.js"
@@ -41,7 +41,7 @@
41 "i18n:create-custom-files": "node ./dist/scripts/i18n/create-custom-files.js", 41 "i18n:create-custom-files": "node ./dist/scripts/i18n/create-custom-files.js",
42 "reset-password": "node ./dist/scripts/reset-password.js", 42 "reset-password": "node ./dist/scripts/reset-password.js",
43 "play": "scripty", 43 "play": "scripty",
44 "dev": "scripty", 44 "dev": "sh ./scripts/dev/index.sh",
45 "dev:server": "sh ./scripts/dev/server.sh", 45 "dev:server": "sh ./scripts/dev/server.sh",
46 "dev:embed": "scripty", 46 "dev:embed": "scripty",
47 "dev:client": "sh ./scripts/dev/client.sh", 47 "dev:client": "sh ./scripts/dev/client.sh",
diff --git a/scripts/ci.sh b/scripts/ci.sh
index d111b7447..4063a6241 100755
--- a/scripts/ci.sh
+++ b/scripts/ci.sh
@@ -12,7 +12,7 @@ killall -q peertube || true
12perl -0777 -i -pe 's#proxy:(\n\s+)enabled: false\n\s+url: ""#proxy:$1enabled: true$1url: "http://188.165.225.149:7899"#' config/test.yaml 12perl -0777 -i -pe 's#proxy:(\n\s+)enabled: false\n\s+url: ""#proxy:$1enabled: true$1url: "http://188.165.225.149:7899"#' config/test.yaml
13 13
14if [ "$1" = "misc" ]; then 14if [ "$1" = "misc" ]; then
15 npm run build -- --light-fr 15 npm run build -- --light
16 mocha --timeout 5000 --exit --require ts-node/register --require tsconfig-paths/register --bail server/tests/client.ts \ 16 mocha --timeout 5000 --exit --require ts-node/register --require tsconfig-paths/register --bail server/tests/client.ts \
17 server/tests/feeds/index.ts \ 17 server/tests/feeds/index.ts \
18 server/tests/misc-endpoints.ts \ 18 server/tests/misc-endpoints.ts \
diff --git a/scripts/dev/index.sh b/scripts/dev/index.sh
index d221d2fc8..9e89516b8 100755
--- a/scripts/dev/index.sh
+++ b/scripts/dev/index.sh
@@ -3,5 +3,5 @@
3set -eu 3set -eu
4 4
5NODE_ENV=test npm run concurrently -- -k \ 5NODE_ENV=test npm run concurrently -- -k \
6 "npm run dev:client -- --skip-server" \ 6 "sh scripts/dev/client.sh --skip-server" \
7 "npm run dev:server" 7 "sh scripts/dev/server.sh"
diff --git a/server.ts b/server.ts
index 8df3add2c..b14ebf623 100644
--- a/server.ts
+++ b/server.ts
@@ -1,10 +1,6 @@
1import { registerTSPaths } from './server/helpers/register-ts-paths' 1import { registerTSPaths } from './server/helpers/register-ts-paths'
2
3registerTSPaths() 2registerTSPaths()
4 3
5// FIXME: https://github.com/nodejs/node/pull/16853
6require('tls').DEFAULT_ECDH_CURVE = 'auto'
7
8import { isTestInstance } from './server/helpers/core-utils' 4import { isTestInstance } from './server/helpers/core-utils'
9if (isTestInstance()) { 5if (isTestInstance()) {
10 require('source-map-support').install() 6 require('source-map-support').install()
diff --git a/server/controllers/api/server/follows.ts b/server/controllers/api/server/follows.ts
index 29a403a43..c69de21fb 100644
--- a/server/controllers/api/server/follows.ts
+++ b/server/controllers/api/server/follows.ts
@@ -24,7 +24,7 @@ import {
24} from '../../../middlewares/validators' 24} from '../../../middlewares/validators'
25import { ActorFollowModel } from '../../../models/activitypub/actor-follow' 25import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
26import { JobQueue } from '../../../lib/job-queue' 26import { JobQueue } from '../../../lib/job-queue'
27import { removeRedundancyOf } from '../../../lib/redundancy' 27import { removeRedundanciesOfServer } from '../../../lib/redundancy'
28import { sequelizeTypescript } from '../../../initializers/database' 28import { sequelizeTypescript } from '../../../initializers/database'
29import { autoFollowBackIfNeeded } from '../../../lib/activitypub/follow' 29import { autoFollowBackIfNeeded } from '../../../lib/activitypub/follow'
30 30
@@ -153,7 +153,7 @@ async function removeFollowing (req: express.Request, res: express.Response) {
153 await server.save({ transaction: t }) 153 await server.save({ transaction: t })
154 154
155 // Async, could be long 155 // Async, could be long
156 removeRedundancyOf(server.id) 156 removeRedundanciesOfServer(server.id)
157 .catch(err => logger.error('Cannot remove redundancy of %s.', server.host, err)) 157 .catch(err => logger.error('Cannot remove redundancy of %s.', server.host, err))
158 158
159 await follow.destroy({ transaction: t }) 159 await follow.destroy({ transaction: t })
diff --git a/server/controllers/api/server/redundancy.ts b/server/controllers/api/server/redundancy.ts
index 4ea6164a3..a11c1a74f 100644
--- a/server/controllers/api/server/redundancy.ts
+++ b/server/controllers/api/server/redundancy.ts
@@ -1,9 +1,24 @@
1import * as express from 'express' 1import * as express from 'express'
2import { UserRight } from '../../../../shared/models/users' 2import { UserRight } from '../../../../shared/models/users'
3import { asyncMiddleware, authenticate, ensureUserHasRight } from '../../../middlewares' 3import {
4import { updateServerRedundancyValidator } from '../../../middlewares/validators/redundancy' 4 asyncMiddleware,
5import { removeRedundancyOf } from '../../../lib/redundancy' 5 authenticate,
6 ensureUserHasRight,
7 paginationValidator,
8 setDefaultPagination,
9 setDefaultVideoRedundanciesSort,
10 videoRedundanciesSortValidator
11} from '../../../middlewares'
12import {
13 listVideoRedundanciesValidator,
14 updateServerRedundancyValidator,
15 addVideoRedundancyValidator,
16 removeVideoRedundancyValidator
17} from '../../../middlewares/validators/redundancy'
18import { removeRedundanciesOfServer, removeVideoRedundancy } from '../../../lib/redundancy'
6import { logger } from '../../../helpers/logger' 19import { logger } from '../../../helpers/logger'
20import { VideoRedundancyModel } from '@server/models/redundancy/video-redundancy'
21import { JobQueue } from '@server/lib/job-queue'
7 22
8const serverRedundancyRouter = express.Router() 23const serverRedundancyRouter = express.Router()
9 24
@@ -14,6 +29,31 @@ serverRedundancyRouter.put('/redundancy/:host',
14 asyncMiddleware(updateRedundancy) 29 asyncMiddleware(updateRedundancy)
15) 30)
16 31
32serverRedundancyRouter.get('/redundancy/videos',
33 authenticate,
34 ensureUserHasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES),
35 listVideoRedundanciesValidator,
36 paginationValidator,
37 videoRedundanciesSortValidator,
38 setDefaultVideoRedundanciesSort,
39 setDefaultPagination,
40 asyncMiddleware(listVideoRedundancies)
41)
42
43serverRedundancyRouter.post('/redundancy/videos',
44 authenticate,
45 ensureUserHasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES),
46 addVideoRedundancyValidator,
47 asyncMiddleware(addVideoRedundancy)
48)
49
50serverRedundancyRouter.delete('/redundancy/videos/:redundancyId',
51 authenticate,
52 ensureUserHasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES),
53 removeVideoRedundancyValidator,
54 asyncMiddleware(removeVideoRedundancyController)
55)
56
17// --------------------------------------------------------------------------- 57// ---------------------------------------------------------------------------
18 58
19export { 59export {
@@ -22,6 +62,42 @@ export {
22 62
23// --------------------------------------------------------------------------- 63// ---------------------------------------------------------------------------
24 64
65async function listVideoRedundancies (req: express.Request, res: express.Response) {
66 const resultList = await VideoRedundancyModel.listForApi({
67 start: req.query.start,
68 count: req.query.count,
69 sort: req.query.sort,
70 target: req.query.target,
71 strategy: req.query.strategy
72 })
73
74 const result = {
75 total: resultList.total,
76 data: resultList.data.map(r => VideoRedundancyModel.toFormattedJSONStatic(r))
77 }
78
79 return res.json(result)
80}
81
82async function addVideoRedundancy (req: express.Request, res: express.Response) {
83 const payload = {
84 videoId: res.locals.onlyVideo.id
85 }
86
87 await JobQueue.Instance.createJob({
88 type: 'video-redundancy',
89 payload
90 })
91
92 return res.sendStatus(204)
93}
94
95async function removeVideoRedundancyController (req: express.Request, res: express.Response) {
96 await removeVideoRedundancy(res.locals.videoRedundancy)
97
98 return res.sendStatus(204)
99}
100
25async function updateRedundancy (req: express.Request, res: express.Response) { 101async function updateRedundancy (req: express.Request, res: express.Response) {
26 const server = res.locals.server 102 const server = res.locals.server
27 103
@@ -30,7 +106,7 @@ async function updateRedundancy (req: express.Request, res: express.Response) {
30 await server.save() 106 await server.save()
31 107
32 // Async, could be long 108 // Async, could be long
33 removeRedundancyOf(server.id) 109 removeRedundanciesOfServer(server.id)
34 .catch(err => logger.error('Cannot remove redundancy of %s.', server.host, { err })) 110 .catch(err => logger.error('Cannot remove redundancy of %s.', server.host, { err }))
35 111
36 return res.sendStatus(204) 112 return res.sendStatus(204)
diff --git a/server/controllers/api/server/stats.ts b/server/controllers/api/server/stats.ts
index 3616c074d..6d508a481 100644
--- a/server/controllers/api/server/stats.ts
+++ b/server/controllers/api/server/stats.ts
@@ -10,6 +10,7 @@ import { ROUTE_CACHE_LIFETIME } from '../../../initializers/constants'
10import { cacheRoute } from '../../../middlewares/cache' 10import { cacheRoute } from '../../../middlewares/cache'
11import { VideoFileModel } from '../../../models/video/video-file' 11import { VideoFileModel } from '../../../models/video/video-file'
12import { CONFIG } from '../../../initializers/config' 12import { CONFIG } from '../../../initializers/config'
13import { VideoRedundancyStrategyWithManual } from '@shared/models'
13 14
14const statsRouter = express.Router() 15const statsRouter = express.Router()
15 16
@@ -25,8 +26,15 @@ async function getStats (req: express.Request, res: express.Response) {
25 const { totalInstanceFollowers, totalInstanceFollowing } = await ActorFollowModel.getStats() 26 const { totalInstanceFollowers, totalInstanceFollowing } = await ActorFollowModel.getStats()
26 const { totalLocalVideoFilesSize } = await VideoFileModel.getStats() 27 const { totalLocalVideoFilesSize } = await VideoFileModel.getStats()
27 28
29 const strategies: { strategy: VideoRedundancyStrategyWithManual, size: number }[] = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES
30 .map(r => ({
31 strategy: r.strategy,
32 size: r.size
33 }))
34 strategies.push({ strategy: 'manual', size: null })
35
28 const videosRedundancyStats = await Promise.all( 36 const videosRedundancyStats = await Promise.all(
29 CONFIG.REDUNDANCY.VIDEOS.STRATEGIES.map(r => { 37 strategies.map(r => {
30 return VideoRedundancyModel.getStats(r.strategy) 38 return VideoRedundancyModel.getStats(r.strategy)
31 .then(stats => Object.assign(stats, { strategy: r.strategy, totalSize: r.size })) 39 .then(stats => Object.assign(stats, { strategy: r.strategy, totalSize: r.size }))
32 }) 40 })
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index 8d4ff07eb..a593f7076 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -12,7 +12,8 @@ import {
12 VIDEO_CATEGORIES, 12 VIDEO_CATEGORIES,
13 VIDEO_LANGUAGES, 13 VIDEO_LANGUAGES,
14 VIDEO_LICENCES, 14 VIDEO_LICENCES,
15 VIDEO_PRIVACIES 15 VIDEO_PRIVACIES,
16 VIDEO_TRANSCODING_FPS
16} from '../../../initializers/constants' 17} from '../../../initializers/constants'
17import { 18import {
18 changeVideoChannelShare, 19 changeVideoChannelShare,
diff --git a/server/controllers/webfinger.ts b/server/controllers/webfinger.ts
index fc9575160..77c851880 100644
--- a/server/controllers/webfinger.ts
+++ b/server/controllers/webfinger.ts
@@ -18,7 +18,7 @@ export {
18// --------------------------------------------------------------------------- 18// ---------------------------------------------------------------------------
19 19
20function webfingerController (req: express.Request, res: express.Response) { 20function webfingerController (req: express.Request, res: express.Response) {
21 const actor = res.locals.actorFull 21 const actor = res.locals.actorUrl
22 22
23 const json = { 23 const json = {
24 subject: req.query.resource, 24 subject: req.query.resource,
@@ -32,5 +32,5 @@ function webfingerController (req: express.Request, res: express.Response) {
32 ] 32 ]
33 } 33 }
34 34
35 return res.json(json).end() 35 return res.json(json)
36} 36}
diff --git a/server/helpers/custom-validators/activitypub/cache-file.ts b/server/helpers/custom-validators/activitypub/cache-file.ts
index 21d5c53ca..c5b3b4d9f 100644
--- a/server/helpers/custom-validators/activitypub/cache-file.ts
+++ b/server/helpers/custom-validators/activitypub/cache-file.ts
@@ -6,7 +6,7 @@ import { CacheFileObject } from '../../../../shared/models/activitypub/objects'
6function isCacheFileObjectValid (object: CacheFileObject) { 6function isCacheFileObjectValid (object: CacheFileObject) {
7 return exists(object) && 7 return exists(object) &&
8 object.type === 'CacheFile' && 8 object.type === 'CacheFile' &&
9 isDateValid(object.expires) && 9 (object.expires === null || isDateValid(object.expires)) &&
10 isActivityPubUrlValid(object.object) && 10 isActivityPubUrlValid(object.object) &&
11 (isRemoteVideoUrlValid(object.url) || isPlaylistRedundancyUrlValid(object.url)) 11 (isRemoteVideoUrlValid(object.url) || isPlaylistRedundancyUrlValid(object.url))
12} 12}
diff --git a/server/helpers/custom-validators/video-redundancies.ts b/server/helpers/custom-validators/video-redundancies.ts
new file mode 100644
index 000000000..50a559c4f
--- /dev/null
+++ b/server/helpers/custom-validators/video-redundancies.ts
@@ -0,0 +1,12 @@
1import { exists } from './misc'
2
3function isVideoRedundancyTarget (value: any) {
4 return exists(value) &&
5 (value === 'my-videos' || value === 'remote-videos')
6}
7
8// ---------------------------------------------------------------------------
9
10export {
11 isVideoRedundancyTarget
12}
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts
index 00c32e99a..63dc5b6a3 100644
--- a/server/helpers/ffmpeg-utils.ts
+++ b/server/helpers/ffmpeg-utils.ts
@@ -263,6 +263,10 @@ async function canDoQuickTranscode (path: string): Promise<boolean> {
263 return true 263 return true
264} 264}
265 265
266function getClosestFramerateStandard (fps: number, hd = false): number {
267 return VIDEO_TRANSCODING_FPS[hd ? 'HD_STANDARD' : 'STANDARD'].slice(0).sort((a, b) => fps % a - fps % b)[0]
268}
269
266// --------------------------------------------------------------------------- 270// ---------------------------------------------------------------------------
267 271
268export { 272export {
@@ -286,13 +290,16 @@ export {
286 290
287async function buildx264Command (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) { 291async function buildx264Command (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) {
288 let fps = await getVideoFileFPS(options.inputPath) 292 let fps = await getVideoFileFPS(options.inputPath)
289 // On small/medium resolutions, limit FPS
290 if ( 293 if (
294 // On small/medium resolutions, limit FPS
291 options.resolution !== undefined && 295 options.resolution !== undefined &&
292 options.resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN && 296 options.resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN &&
293 fps > VIDEO_TRANSCODING_FPS.AVERAGE 297 fps > VIDEO_TRANSCODING_FPS.AVERAGE ||
298 // If the video is doesn't match hd standard
299 !VIDEO_TRANSCODING_FPS.HD_STANDARD.some(value => fps % value === 0)
294 ) { 300 ) {
295 fps = VIDEO_TRANSCODING_FPS.AVERAGE 301 // Get closest standard framerate by modulo: downsampling has to be done to a divisor of the nominal fps value
302 fps = getClosestFramerateStandard(fps)
296 } 303 }
297 304
298 command = await presetH264(command, options.inputPath, options.resolution, fps) 305 command = await presetH264(command, options.inputPath, options.resolution, fps)
@@ -305,7 +312,7 @@ async function buildx264Command (command: ffmpeg.FfmpegCommand, options: Transco
305 312
306 if (fps) { 313 if (fps) {
307 // Hard FPS limits 314 // Hard FPS limits
308 if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = VIDEO_TRANSCODING_FPS.MAX 315 if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = getClosestFramerateStandard(fps, true)
309 else if (fps < VIDEO_TRANSCODING_FPS.MIN) fps = VIDEO_TRANSCODING_FPS.MIN 316 else if (fps < VIDEO_TRANSCODING_FPS.MIN) fps = VIDEO_TRANSCODING_FPS.MIN
310 317
311 command = command.withFPS(fps) 318 command = command.withFPS(fps)
diff --git a/server/helpers/logger.ts b/server/helpers/logger.ts
index 395417612..fd2988ad0 100644
--- a/server/helpers/logger.ts
+++ b/server/helpers/logger.ts
@@ -5,7 +5,7 @@ import * as winston from 'winston'
5import { FileTransportOptions } from 'winston/lib/winston/transports' 5import { FileTransportOptions } from 'winston/lib/winston/transports'
6import { CONFIG } from '../initializers/config' 6import { CONFIG } from '../initializers/config'
7import { omit } from 'lodash' 7import { omit } from 'lodash'
8import { LOG_FILENAME } from '@server/initializers/constants' 8import { LOG_FILENAME } from '../initializers/constants'
9 9
10const label = CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT 10const label = CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
11 11
diff --git a/server/helpers/webtorrent.ts b/server/helpers/webtorrent.ts
index 3a99518c6..8a5d030df 100644
--- a/server/helpers/webtorrent.ts
+++ b/server/helpers/webtorrent.ts
@@ -9,12 +9,12 @@ import { promisify2 } from './core-utils'
9import { MVideo } from '@server/typings/models/video/video' 9import { MVideo } from '@server/typings/models/video/video'
10import { MVideoFile, MVideoFileRedundanciesOpt } from '@server/typings/models/video/video-file' 10import { MVideoFile, MVideoFileRedundanciesOpt } from '@server/typings/models/video/video-file'
11import { isStreamingPlaylist, MStreamingPlaylistVideo } from '@server/typings/models/video/video-streaming-playlist' 11import { isStreamingPlaylist, MStreamingPlaylistVideo } from '@server/typings/models/video/video-streaming-playlist'
12import { STATIC_PATHS, WEBSERVER } from '@server/initializers/constants' 12import { WEBSERVER } from '@server/initializers/constants'
13import * as parseTorrent from 'parse-torrent' 13import * as parseTorrent from 'parse-torrent'
14import * as magnetUtil from 'magnet-uri' 14import * as magnetUtil from 'magnet-uri'
15import { isArray } from '@server/helpers/custom-validators/misc' 15import { isArray } from '@server/helpers/custom-validators/misc'
16import { extractVideo } from '@server/lib/videos' 16import { extractVideo } from '@server/lib/videos'
17import { getTorrentFileName, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths' 17import { getTorrentFileName, getVideoFilePath } from '@server/lib/video-paths'
18 18
19const createTorrentPromise = promisify2<string, any, any>(createTorrent) 19const createTorrentPromise = promisify2<string, any, any>(createTorrent)
20 20
diff --git a/server/initializers/config.ts b/server/initializers/config.ts
index 7fd77f3e8..fd8bf09fc 100644
--- a/server/initializers/config.ts
+++ b/server/initializers/config.ts
@@ -1,6 +1,6 @@
1import { IConfig } from 'config' 1import { IConfig } from 'config'
2import { dirname, join } from 'path' 2import { dirname, join } from 'path'
3import { VideosRedundancy } from '../../shared/models' 3import { VideosRedundancyStrategy } from '../../shared/models'
4// Do not use barrels, remain constants as independent as possible 4// Do not use barrels, remain constants as independent as possible
5import { buildPath, parseBytes, parseDurationToMs, root } from '../helpers/core-utils' 5import { buildPath, parseBytes, parseDurationToMs, root } from '../helpers/core-utils'
6import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type' 6import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
@@ -304,7 +304,7 @@ function getLocalConfigFilePath () {
304 return join(dirname(configSources[ 0 ].name), filename + '.json') 304 return join(dirname(configSources[ 0 ].name), filename + '.json')
305} 305}
306 306
307function buildVideosRedundancy (objs: any[]): VideosRedundancy[] { 307function buildVideosRedundancy (objs: any[]): VideosRedundancyStrategy[] {
308 if (!objs) return [] 308 if (!objs) return []
309 309
310 if (!Array.isArray(objs)) return objs 310 if (!Array.isArray(objs)) return objs
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 032f63c8f..64803b1db 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -14,7 +14,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
14 14
15// --------------------------------------------------------------------------- 15// ---------------------------------------------------------------------------
16 16
17const LAST_MIGRATION_VERSION = 470 17const LAST_MIGRATION_VERSION = 475
18 18
19// --------------------------------------------------------------------------- 19// ---------------------------------------------------------------------------
20 20
@@ -73,7 +73,9 @@ const SORTABLE_COLUMNS = {
73 73
74 PLUGINS: [ 'name', 'createdAt', 'updatedAt' ], 74 PLUGINS: [ 'name', 'createdAt', 'updatedAt' ],
75 75
76 AVAILABLE_PLUGINS: [ 'npmName', 'popularity' ] 76 AVAILABLE_PLUGINS: [ 'npmName', 'popularity' ],
77
78 VIDEO_REDUNDANCIES: [ 'name' ]
77} 79}
78 80
79const OAUTH_LIFETIME = { 81const OAUTH_LIFETIME = {
@@ -117,45 +119,44 @@ const REMOTE_SCHEME = {
117 WS: 'wss' 119 WS: 'wss'
118} 120}
119 121
120// TODO: remove 'video-file' 122const JOB_ATTEMPTS: { [id in JobType]: number } = {
121const JOB_ATTEMPTS: { [id in (JobType | 'video-file')]: number } = {
122 'activitypub-http-broadcast': 5, 123 'activitypub-http-broadcast': 5,
123 'activitypub-http-unicast': 5, 124 'activitypub-http-unicast': 5,
124 'activitypub-http-fetcher': 5, 125 'activitypub-http-fetcher': 5,
125 'activitypub-follow': 5, 126 'activitypub-follow': 5,
126 'video-file-import': 1, 127 'video-file-import': 1,
127 'video-transcoding': 1, 128 'video-transcoding': 1,
128 'video-file': 1,
129 'video-import': 1, 129 'video-import': 1,
130 'email': 5, 130 'email': 5,
131 'videos-views': 1, 131 'videos-views': 1,
132 'activitypub-refresher': 1 132 'activitypub-refresher': 1,
133 'video-redundancy': 1
133} 134}
134const JOB_CONCURRENCY: { [id in (JobType | 'video-file')]: number } = { 135const JOB_CONCURRENCY: { [id in JobType]: number } = {
135 'activitypub-http-broadcast': 1, 136 'activitypub-http-broadcast': 1,
136 'activitypub-http-unicast': 5, 137 'activitypub-http-unicast': 5,
137 'activitypub-http-fetcher': 1, 138 'activitypub-http-fetcher': 1,
138 'activitypub-follow': 1, 139 'activitypub-follow': 1,
139 'video-file-import': 1, 140 'video-file-import': 1,
140 'video-transcoding': 1, 141 'video-transcoding': 1,
141 'video-file': 1,
142 'video-import': 1, 142 'video-import': 1,
143 'email': 5, 143 'email': 5,
144 'videos-views': 1, 144 'videos-views': 1,
145 'activitypub-refresher': 1 145 'activitypub-refresher': 1,
146 'video-redundancy': 1
146} 147}
147const JOB_TTL: { [id in (JobType | 'video-file')]: number } = { 148const JOB_TTL: { [id in JobType]: number } = {
148 'activitypub-http-broadcast': 60000 * 10, // 10 minutes 149 'activitypub-http-broadcast': 60000 * 10, // 10 minutes
149 'activitypub-http-unicast': 60000 * 10, // 10 minutes 150 'activitypub-http-unicast': 60000 * 10, // 10 minutes
150 'activitypub-http-fetcher': 60000 * 10, // 10 minutes 151 'activitypub-http-fetcher': 60000 * 10, // 10 minutes
151 'activitypub-follow': 60000 * 10, // 10 minutes 152 'activitypub-follow': 60000 * 10, // 10 minutes
152 'video-file-import': 1000 * 3600, // 1 hour 153 'video-file-import': 1000 * 3600, // 1 hour
153 'video-transcoding': 1000 * 3600 * 48, // 2 days, transcoding could be long 154 'video-transcoding': 1000 * 3600 * 48, // 2 days, transcoding could be long
154 'video-file': 1000 * 3600 * 48, // 2 days, transcoding could be long
155 'video-import': 1000 * 3600 * 2, // hours 155 'video-import': 1000 * 3600 * 2, // hours
156 'email': 60000 * 10, // 10 minutes 156 'email': 60000 * 10, // 10 minutes
157 'videos-views': undefined, // Unlimited 157 'videos-views': undefined, // Unlimited
158 'activitypub-refresher': 60000 * 10 // 10 minutes 158 'activitypub-refresher': 60000 * 10, // 10 minutes
159 'video-redundancy': 1000 * 3600 * 3 // 3 hours
159} 160}
160const REPEAT_JOBS: { [ id: string ]: EveryRepeatOptions | CronRepeatOptions } = { 161const REPEAT_JOBS: { [ id: string ]: EveryRepeatOptions | CronRepeatOptions } = {
161 'videos-views': { 162 'videos-views': {
@@ -309,6 +310,8 @@ let CONTACT_FORM_LIFETIME = 60000 * 60 // 1 hour
309 310
310const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = { 311const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = {
311 MIN: 10, 312 MIN: 10,
313 STANDARD: [24, 25, 30],
314 HD_STANDARD: [50, 60],
312 AVERAGE: 30, 315 AVERAGE: 30,
313 MAX: 60, 316 MAX: 60,
314 KEEP_ORIGIN_FPS_RESOLUTION_MIN: 720 // We keep the original FPS on high resolutions (720 minimum) 317 KEEP_ORIGIN_FPS_RESOLUTION_MIN: 720 // We keep the original FPS on high resolutions (720 minimum)
diff --git a/server/initializers/migrations/0475-redundancy-expires-on.ts b/server/initializers/migrations/0475-redundancy-expires-on.ts
new file mode 100644
index 000000000..7e392c8c0
--- /dev/null
+++ b/server/initializers/migrations/0475-redundancy-expires-on.ts
@@ -0,0 +1,27 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction,
5 queryInterface: Sequelize.QueryInterface,
6 sequelize: Sequelize.Sequelize,
7 db: any
8}): Promise<void> {
9 {
10 const data = {
11 type: Sequelize.DATE,
12 allowNull: true,
13 defaultValue: null
14 }
15
16 await utils.queryInterface.changeColumn('videoRedundancy', 'expiresOn', data)
17 }
18}
19
20function down (options) {
21 throw new Error('Not implemented.')
22}
23
24export {
25 up,
26 down
27}
diff --git a/server/lib/activitypub/cache-file.ts b/server/lib/activitypub/cache-file.ts
index 65b2dcb49..8252e95e9 100644
--- a/server/lib/activitypub/cache-file.ts
+++ b/server/lib/activitypub/cache-file.ts
@@ -13,7 +13,7 @@ function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject
13 if (!playlist) throw new Error('Cannot find HLS playlist of video ' + video.url) 13 if (!playlist) throw new Error('Cannot find HLS playlist of video ' + video.url)
14 14
15 return { 15 return {
16 expiresOn: new Date(cacheFileObject.expires), 16 expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null,
17 url: cacheFileObject.id, 17 url: cacheFileObject.id,
18 fileUrl: url.href, 18 fileUrl: url.href,
19 strategy: null, 19 strategy: null,
@@ -30,7 +30,7 @@ function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject
30 if (!videoFile) throw new Error(`Cannot find video file ${url.height} ${url.fps} of video ${video.url}`) 30 if (!videoFile) throw new Error(`Cannot find video file ${url.height} ${url.fps} of video ${video.url}`)
31 31
32 return { 32 return {
33 expiresOn: new Date(cacheFileObject.expires), 33 expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null,
34 url: cacheFileObject.id, 34 url: cacheFileObject.id,
35 fileUrl: url.href, 35 fileUrl: url.href,
36 strategy: null, 36 strategy: null,
diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts
index ade93150f..7a9d5168b 100644
--- a/server/lib/activitypub/videos.ts
+++ b/server/lib/activitypub/videos.ts
@@ -639,7 +639,6 @@ async function videoActivityObjectToDBAttributes (videoChannel: MChannelId, vide
639 createdAt: new Date(videoObject.published), 639 createdAt: new Date(videoObject.published),
640 publishedAt: new Date(videoObject.published), 640 publishedAt: new Date(videoObject.published),
641 originallyPublishedAt: videoObject.originallyPublishedAt ? new Date(videoObject.originallyPublishedAt) : null, 641 originallyPublishedAt: videoObject.originallyPublishedAt ? new Date(videoObject.originallyPublishedAt) : null,
642 // FIXME: updatedAt does not seems to be considered by Sequelize
643 updatedAt: new Date(videoObject.updated), 642 updatedAt: new Date(videoObject.updated),
644 views: videoObject.views, 643 views: videoObject.views,
645 likes: 0, 644 likes: 0,
diff --git a/server/lib/job-queue/handlers/video-redundancy.ts b/server/lib/job-queue/handlers/video-redundancy.ts
new file mode 100644
index 000000000..319d7090e
--- /dev/null
+++ b/server/lib/job-queue/handlers/video-redundancy.ts
@@ -0,0 +1,20 @@
1import * as Bull from 'bull'
2import { logger } from '../../../helpers/logger'
3import { VideosRedundancyScheduler } from '@server/lib/schedulers/videos-redundancy-scheduler'
4
5export type VideoRedundancyPayload = {
6 videoId: number
7}
8
9async function processVideoRedundancy (job: Bull.Job) {
10 const payload = job.data as VideoRedundancyPayload
11 logger.info('Processing video redundancy in job %d.', job.id)
12
13 return VideosRedundancyScheduler.Instance.createManualRedundancy(payload.videoId)
14}
15
16// ---------------------------------------------------------------------------
17
18export {
19 processVideoRedundancy
20}
diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts
index ec601e9ea..a1c623b25 100644
--- a/server/lib/job-queue/job-queue.ts
+++ b/server/lib/job-queue/job-queue.ts
@@ -13,6 +13,7 @@ import { processVideoImport, VideoImportPayload } from './handlers/video-import'
13import { processVideosViews } from './handlers/video-views' 13import { processVideosViews } from './handlers/video-views'
14import { refreshAPObject, RefreshPayload } from './handlers/activitypub-refresher' 14import { refreshAPObject, RefreshPayload } from './handlers/activitypub-refresher'
15import { processVideoFileImport, VideoFileImportPayload } from './handlers/video-file-import' 15import { processVideoFileImport, VideoFileImportPayload } from './handlers/video-file-import'
16import { processVideoRedundancy, VideoRedundancyPayload } from '@server/lib/job-queue/handlers/video-redundancy'
16 17
17type CreateJobArgument = 18type CreateJobArgument =
18 { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } | 19 { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } |
@@ -24,20 +25,21 @@ type CreateJobArgument =
24 { type: 'email', payload: EmailPayload } | 25 { type: 'email', payload: EmailPayload } |
25 { type: 'video-import', payload: VideoImportPayload } | 26 { type: 'video-import', payload: VideoImportPayload } |
26 { type: 'activitypub-refresher', payload: RefreshPayload } | 27 { type: 'activitypub-refresher', payload: RefreshPayload } |
27 { type: 'videos-views', payload: {} } 28 { type: 'videos-views', payload: {} } |
29 { type: 'video-redundancy', payload: VideoRedundancyPayload }
28 30
29const handlers: { [ id in (JobType | 'video-file') ]: (job: Bull.Job) => Promise<any>} = { 31const handlers: { [ id in JobType ]: (job: Bull.Job) => Promise<any>} = {
30 'activitypub-http-broadcast': processActivityPubHttpBroadcast, 32 'activitypub-http-broadcast': processActivityPubHttpBroadcast,
31 'activitypub-http-unicast': processActivityPubHttpUnicast, 33 'activitypub-http-unicast': processActivityPubHttpUnicast,
32 'activitypub-http-fetcher': processActivityPubHttpFetcher, 34 'activitypub-http-fetcher': processActivityPubHttpFetcher,
33 'activitypub-follow': processActivityPubFollow, 35 'activitypub-follow': processActivityPubFollow,
34 'video-file-import': processVideoFileImport, 36 'video-file-import': processVideoFileImport,
35 'video-transcoding': processVideoTranscoding, 37 'video-transcoding': processVideoTranscoding,
36 'video-file': processVideoTranscoding, // TODO: remove it (changed in 1.3)
37 'email': processEmail, 38 'email': processEmail,
38 'video-import': processVideoImport, 39 'video-import': processVideoImport,
39 'videos-views': processVideosViews, 40 'videos-views': processVideosViews,
40 'activitypub-refresher': refreshAPObject 41 'activitypub-refresher': refreshAPObject,
42 'video-redundancy': processVideoRedundancy
41} 43}
42 44
43const jobTypes: JobType[] = [ 45const jobTypes: JobType[] = [
@@ -50,7 +52,8 @@ const jobTypes: JobType[] = [
50 'video-file-import', 52 'video-file-import',
51 'video-import', 53 'video-import',
52 'videos-views', 54 'videos-views',
53 'activitypub-refresher' 55 'activitypub-refresher',
56 'video-redundancy'
54] 57]
55 58
56class JobQueue { 59class JobQueue {
@@ -141,8 +144,7 @@ class JobQueue {
141 continue 144 continue
142 } 145 }
143 146
144 // FIXME: Bull queue typings does not have getJobs method 147 const jobs = await queue.getJobs([ state ], 0, start + count, asc)
145 const jobs = await (queue as any).getJobs(state, 0, start + count, asc)
146 results = results.concat(jobs) 148 results = results.concat(jobs)
147 } 149 }
148 150
diff --git a/server/lib/redundancy.ts b/server/lib/redundancy.ts
index 1b4ecd7c0..78d84e02e 100644
--- a/server/lib/redundancy.ts
+++ b/server/lib/redundancy.ts
@@ -13,10 +13,10 @@ async function removeVideoRedundancy (videoRedundancy: MVideoRedundancyVideo, t?
13 await videoRedundancy.destroy({ transaction: t }) 13 await videoRedundancy.destroy({ transaction: t })
14} 14}
15 15
16async function removeRedundancyOf (serverId: number) { 16async function removeRedundanciesOfServer (serverId: number) {
17 const videosRedundancy = await VideoRedundancyModel.listLocalOfServer(serverId) 17 const redundancies = await VideoRedundancyModel.listLocalOfServer(serverId)
18 18
19 for (const redundancy of videosRedundancy) { 19 for (const redundancy of redundancies) {
20 await removeVideoRedundancy(redundancy) 20 await removeVideoRedundancy(redundancy)
21 } 21 }
22} 22}
@@ -24,6 +24,6 @@ async function removeRedundancyOf (serverId: number) {
24// --------------------------------------------------------------------------- 24// ---------------------------------------------------------------------------
25 25
26export { 26export {
27 removeRedundancyOf, 27 removeRedundanciesOfServer,
28 removeVideoRedundancy 28 removeVideoRedundancy
29} 29}
diff --git a/server/lib/schedulers/update-videos-scheduler.ts b/server/lib/schedulers/update-videos-scheduler.ts
index 350a335d3..956780a77 100644
--- a/server/lib/schedulers/update-videos-scheduler.ts
+++ b/server/lib/schedulers/update-videos-scheduler.ts
@@ -4,7 +4,6 @@ import { ScheduleVideoUpdateModel } from '../../models/video/schedule-video-upda
4import { retryTransactionWrapper } from '../../helpers/database-utils' 4import { retryTransactionWrapper } from '../../helpers/database-utils'
5import { federateVideoIfNeeded } from '../activitypub' 5import { federateVideoIfNeeded } from '../activitypub'
6import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants' 6import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
7import { VideoPrivacy } from '../../../shared/models/videos'
8import { Notifier } from '../notifier' 7import { Notifier } from '../notifier'
9import { sequelizeTypescript } from '../../initializers/database' 8import { sequelizeTypescript } from '../../initializers/database'
10import { MVideoFullLight } from '@server/typings/models' 9import { MVideoFullLight } from '@server/typings/models'
diff --git a/server/lib/schedulers/videos-redundancy-scheduler.ts b/server/lib/schedulers/videos-redundancy-scheduler.ts
index c1c91b656..6e61cbe7d 100644
--- a/server/lib/schedulers/videos-redundancy-scheduler.ts
+++ b/server/lib/schedulers/videos-redundancy-scheduler.ts
@@ -1,7 +1,7 @@
1import { AbstractScheduler } from './abstract-scheduler' 1import { AbstractScheduler } from './abstract-scheduler'
2import { HLS_REDUNDANCY_DIRECTORY, REDUNDANCY, VIDEO_IMPORT_TIMEOUT, WEBSERVER } from '../../initializers/constants' 2import { HLS_REDUNDANCY_DIRECTORY, REDUNDANCY, VIDEO_IMPORT_TIMEOUT, WEBSERVER } from '../../initializers/constants'
3import { logger } from '../../helpers/logger' 3import { logger } from '../../helpers/logger'
4import { VideosRedundancy } from '../../../shared/models/redundancy' 4import { VideosRedundancyStrategy } from '../../../shared/models/redundancy'
5import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy' 5import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
6import { downloadWebTorrentVideo, generateMagnetUri } from '../../helpers/webtorrent' 6import { downloadWebTorrentVideo, generateMagnetUri } from '../../helpers/webtorrent'
7import { join } from 'path' 7import { join } from 'path'
@@ -25,9 +25,10 @@ import {
25 MVideoWithAllFiles 25 MVideoWithAllFiles
26} from '@server/typings/models' 26} from '@server/typings/models'
27import { getVideoFilename } from '../video-paths' 27import { getVideoFilename } from '../video-paths'
28import { VideoModel } from '@server/models/video/video'
28 29
29type CandidateToDuplicate = { 30type CandidateToDuplicate = {
30 redundancy: VideosRedundancy, 31 redundancy: VideosRedundancyStrategy,
31 video: MVideoWithAllFiles, 32 video: MVideoWithAllFiles,
32 files: MVideoFile[], 33 files: MVideoFile[],
33 streamingPlaylists: MStreamingPlaylistFiles[] 34 streamingPlaylists: MStreamingPlaylistFiles[]
@@ -41,7 +42,7 @@ function isMVideoRedundancyFileVideo (
41 42
42export class VideosRedundancyScheduler extends AbstractScheduler { 43export class VideosRedundancyScheduler extends AbstractScheduler {
43 44
44 private static instance: AbstractScheduler 45 private static instance: VideosRedundancyScheduler
45 46
46 protected schedulerIntervalMs = CONFIG.REDUNDANCY.VIDEOS.CHECK_INTERVAL 47 protected schedulerIntervalMs = CONFIG.REDUNDANCY.VIDEOS.CHECK_INTERVAL
47 48
@@ -49,6 +50,22 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
49 super() 50 super()
50 } 51 }
51 52
53 async createManualRedundancy (videoId: number) {
54 const videoToDuplicate = await VideoModel.loadWithFiles(videoId)
55
56 if (!videoToDuplicate) {
57 logger.warn('Video to manually duplicate %d does not exist anymore.', videoId)
58 return
59 }
60
61 return this.createVideoRedundancies({
62 video: videoToDuplicate,
63 redundancy: null,
64 files: videoToDuplicate.VideoFiles,
65 streamingPlaylists: videoToDuplicate.VideoStreamingPlaylists
66 })
67 }
68
52 protected async internalExecute () { 69 protected async internalExecute () {
53 for (const redundancyConfig of CONFIG.REDUNDANCY.VIDEOS.STRATEGIES) { 70 for (const redundancyConfig of CONFIG.REDUNDANCY.VIDEOS.STRATEGIES) {
54 logger.info('Running redundancy scheduler for strategy %s.', redundancyConfig.strategy) 71 logger.info('Running redundancy scheduler for strategy %s.', redundancyConfig.strategy)
@@ -94,7 +111,7 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
94 for (const redundancyModel of expired) { 111 for (const redundancyModel of expired) {
95 try { 112 try {
96 const redundancyConfig = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES.find(s => s.strategy === redundancyModel.strategy) 113 const redundancyConfig = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES.find(s => s.strategy === redundancyModel.strategy)
97 const candidate = { 114 const candidate: CandidateToDuplicate = {
98 redundancy: redundancyConfig, 115 redundancy: redundancyConfig,
99 video: null, 116 video: null,
100 files: [], 117 files: [],
@@ -140,7 +157,7 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
140 } 157 }
141 } 158 }
142 159
143 private findVideoToDuplicate (cache: VideosRedundancy) { 160 private findVideoToDuplicate (cache: VideosRedundancyStrategy) {
144 if (cache.strategy === 'most-views') { 161 if (cache.strategy === 'most-views') {
145 return VideoRedundancyModel.findMostViewToDuplicate(REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR) 162 return VideoRedundancyModel.findMostViewToDuplicate(REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR)
146 } 163 }
@@ -187,13 +204,21 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
187 } 204 }
188 } 205 }
189 206
190 private async createVideoFileRedundancy (redundancy: VideosRedundancy, video: MVideoAccountLight, fileArg: MVideoFile) { 207 private async createVideoFileRedundancy (redundancy: VideosRedundancyStrategy | null, video: MVideoAccountLight, fileArg: MVideoFile) {
208 let strategy = 'manual'
209 let expiresOn: Date = null
210
211 if (redundancy) {
212 strategy = redundancy.strategy
213 expiresOn = this.buildNewExpiration(redundancy.minLifetime)
214 }
215
191 const file = fileArg as MVideoFileVideo 216 const file = fileArg as MVideoFileVideo
192 file.Video = video 217 file.Video = video
193 218
194 const serverActor = await getServerActor() 219 const serverActor = await getServerActor()
195 220
196 logger.info('Duplicating %s - %d in videos redundancy with "%s" strategy.', video.url, file.resolution, redundancy.strategy) 221 logger.info('Duplicating %s - %d in videos redundancy with "%s" strategy.', video.url, file.resolution, strategy)
197 222
198 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls() 223 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
199 const magnetUri = generateMagnetUri(video, file, baseUrlHttp, baseUrlWs) 224 const magnetUri = generateMagnetUri(video, file, baseUrlHttp, baseUrlWs)
@@ -204,10 +229,10 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
204 await move(tmpPath, destPath, { overwrite: true }) 229 await move(tmpPath, destPath, { overwrite: true })
205 230
206 const createdModel: MVideoRedundancyFileVideo = await VideoRedundancyModel.create({ 231 const createdModel: MVideoRedundancyFileVideo = await VideoRedundancyModel.create({
207 expiresOn: this.buildNewExpiration(redundancy.minLifetime), 232 expiresOn,
208 url: getVideoCacheFileActivityPubUrl(file), 233 url: getVideoCacheFileActivityPubUrl(file),
209 fileUrl: video.getVideoRedundancyUrl(file, WEBSERVER.URL), 234 fileUrl: video.getVideoRedundancyUrl(file, WEBSERVER.URL),
210 strategy: redundancy.strategy, 235 strategy,
211 videoFileId: file.id, 236 videoFileId: file.id,
212 actorId: serverActor.id 237 actorId: serverActor.id
213 }) 238 })
@@ -220,25 +245,33 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
220 } 245 }
221 246
222 private async createStreamingPlaylistRedundancy ( 247 private async createStreamingPlaylistRedundancy (
223 redundancy: VideosRedundancy, 248 redundancy: VideosRedundancyStrategy,
224 video: MVideoAccountLight, 249 video: MVideoAccountLight,
225 playlistArg: MStreamingPlaylist 250 playlistArg: MStreamingPlaylist
226 ) { 251 ) {
252 let strategy = 'manual'
253 let expiresOn: Date = null
254
255 if (redundancy) {
256 strategy = redundancy.strategy
257 expiresOn = this.buildNewExpiration(redundancy.minLifetime)
258 }
259
227 const playlist = playlistArg as MStreamingPlaylistVideo 260 const playlist = playlistArg as MStreamingPlaylistVideo
228 playlist.Video = video 261 playlist.Video = video
229 262
230 const serverActor = await getServerActor() 263 const serverActor = await getServerActor()
231 264
232 logger.info('Duplicating %s streaming playlist in videos redundancy with "%s" strategy.', video.url, redundancy.strategy) 265 logger.info('Duplicating %s streaming playlist in videos redundancy with "%s" strategy.', video.url, strategy)
233 266
234 const destDirectory = join(HLS_REDUNDANCY_DIRECTORY, video.uuid) 267 const destDirectory = join(HLS_REDUNDANCY_DIRECTORY, video.uuid)
235 await downloadPlaylistSegments(playlist.playlistUrl, destDirectory, VIDEO_IMPORT_TIMEOUT) 268 await downloadPlaylistSegments(playlist.playlistUrl, destDirectory, VIDEO_IMPORT_TIMEOUT)
236 269
237 const createdModel: MVideoRedundancyStreamingPlaylistVideo = await VideoRedundancyModel.create({ 270 const createdModel: MVideoRedundancyStreamingPlaylistVideo = await VideoRedundancyModel.create({
238 expiresOn: this.buildNewExpiration(redundancy.minLifetime), 271 expiresOn,
239 url: getVideoCacheStreamingPlaylistActivityPubUrl(video, playlist), 272 url: getVideoCacheStreamingPlaylistActivityPubUrl(video, playlist),
240 fileUrl: playlist.getVideoRedundancyUrl(WEBSERVER.URL), 273 fileUrl: playlist.getVideoRedundancyUrl(WEBSERVER.URL),
241 strategy: redundancy.strategy, 274 strategy,
242 videoStreamingPlaylistId: playlist.id, 275 videoStreamingPlaylistId: playlist.id,
243 actorId: serverActor.id 276 actorId: serverActor.id
244 }) 277 })
diff --git a/server/middlewares/sort.ts b/server/middlewares/sort.ts
index 8c27e8237..75238228f 100644
--- a/server/middlewares/sort.ts
+++ b/server/middlewares/sort.ts
@@ -1,17 +1,11 @@
1import * as express from 'express' 1import * as express from 'express'
2import { SortType } from '../models/utils' 2import { SortType } from '../models/utils'
3 3
4function setDefaultSort (req: express.Request, res: express.Response, next: express.NextFunction) { 4const setDefaultSort = setDefaultSortFactory('-createdAt')
5 if (!req.query.sort) req.query.sort = '-createdAt'
6
7 return next()
8}
9 5
10function setDefaultSearchSort (req: express.Request, res: express.Response, next: express.NextFunction) { 6const setDefaultVideoRedundanciesSort = setDefaultSortFactory('name')
11 if (!req.query.sort) req.query.sort = '-match'
12 7
13 return next() 8const setDefaultSearchSort = setDefaultSortFactory('-match')
14}
15 9
16function setBlacklistSort (req: express.Request, res: express.Response, next: express.NextFunction) { 10function setBlacklistSort (req: express.Request, res: express.Response, next: express.NextFunction) {
17 let newSort: SortType = { sortModel: undefined, sortValue: '' } 11 let newSort: SortType = { sortModel: undefined, sortValue: '' }
@@ -39,5 +33,16 @@ function setBlacklistSort (req: express.Request, res: express.Response, next: ex
39export { 33export {
40 setDefaultSort, 34 setDefaultSort,
41 setDefaultSearchSort, 35 setDefaultSearchSort,
36 setDefaultVideoRedundanciesSort,
42 setBlacklistSort 37 setBlacklistSort
43} 38}
39
40// ---------------------------------------------------------------------------
41
42function setDefaultSortFactory (sort: string) {
43 return (req: express.Request, res: express.Response, next: express.NextFunction) => {
44 if (!req.query.sort) req.query.sort = sort
45
46 return next()
47 }
48}
diff --git a/server/middlewares/validators/redundancy.ts b/server/middlewares/validators/redundancy.ts
index 8098e3a44..16b42fc0d 100644
--- a/server/middlewares/validators/redundancy.ts
+++ b/server/middlewares/validators/redundancy.ts
@@ -1,12 +1,13 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body, param } from 'express-validator' 2import { body, param, query } from 'express-validator'
3import { exists, isBooleanValid, isIdOrUUIDValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc' 3import { exists, isBooleanValid, isIdOrUUIDValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
4import { logger } from '../../helpers/logger' 4import { logger } from '../../helpers/logger'
5import { areValidationErrors } from './utils' 5import { areValidationErrors } from './utils'
6import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy' 6import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
7import { isHostValid } from '../../helpers/custom-validators/servers' 7import { isHostValid } from '../../helpers/custom-validators/servers'
8import { ServerModel } from '../../models/server/server' 8import { ServerModel } from '../../models/server/server'
9import { doesVideoExist } from '../../helpers/middlewares' 9import { doesVideoExist } from '../../helpers/middlewares'
10import { isVideoRedundancyTarget } from '@server/helpers/custom-validators/video-redundancies'
10 11
11const videoFileRedundancyGetValidator = [ 12const videoFileRedundancyGetValidator = [
12 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'), 13 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
@@ -101,10 +102,77 @@ const updateServerRedundancyValidator = [
101 } 102 }
102] 103]
103 104
105const listVideoRedundanciesValidator = [
106 query('target')
107 .custom(isVideoRedundancyTarget).withMessage('Should have a valid video redundancies target'),
108
109 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
110 logger.debug('Checking listVideoRedundanciesValidator parameters', { parameters: req.query })
111
112 if (areValidationErrors(req, res)) return
113
114 return next()
115 }
116]
117
118const addVideoRedundancyValidator = [
119 body('videoId')
120 .custom(isIdValid)
121 .withMessage('Should have a valid video id'),
122
123 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
124 logger.debug('Checking addVideoRedundancyValidator parameters', { parameters: req.query })
125
126 if (areValidationErrors(req, res)) return
127
128 if (!await doesVideoExist(req.body.videoId, res, 'only-video')) return
129
130 if (res.locals.onlyVideo.remote === false) {
131 return res.status(400)
132 .json({ error: 'Cannot create a redundancy on a local video' })
133 .end()
134 }
135
136 const alreadyExists = await VideoRedundancyModel.isLocalByVideoUUIDExists(res.locals.onlyVideo.uuid)
137 if (alreadyExists) {
138 return res.status(409)
139 .json({ error: 'This video is already duplicated by your instance.' })
140 }
141
142 return next()
143 }
144]
145
146const removeVideoRedundancyValidator = [
147 param('redundancyId')
148 .custom(isIdValid)
149 .withMessage('Should have a valid redundancy id'),
150
151 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
152 logger.debug('Checking removeVideoRedundancyValidator parameters', { parameters: req.query })
153
154 if (areValidationErrors(req, res)) return
155
156 const redundancy = await VideoRedundancyModel.loadByIdWithVideo(parseInt(req.params.redundancyId, 10))
157 if (!redundancy) {
158 return res.status(404)
159 .json({ error: 'Video redundancy not found' })
160 .end()
161 }
162
163 res.locals.videoRedundancy = redundancy
164
165 return next()
166 }
167]
168
104// --------------------------------------------------------------------------- 169// ---------------------------------------------------------------------------
105 170
106export { 171export {
107 videoFileRedundancyGetValidator, 172 videoFileRedundancyGetValidator,
108 videoPlaylistRedundancyGetValidator, 173 videoPlaylistRedundancyGetValidator,
109 updateServerRedundancyValidator 174 updateServerRedundancyValidator,
175 listVideoRedundanciesValidator,
176 addVideoRedundancyValidator,
177 removeVideoRedundancyValidator
110} 178}
diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts
index c75e701d6..b76dab722 100644
--- a/server/middlewares/validators/sort.ts
+++ b/server/middlewares/validators/sort.ts
@@ -23,6 +23,7 @@ const SORTABLE_USER_NOTIFICATIONS_COLUMNS = createSortableColumns(SORTABLE_COLUM
23const SORTABLE_VIDEO_PLAYLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_PLAYLISTS) 23const SORTABLE_VIDEO_PLAYLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_PLAYLISTS)
24const SORTABLE_PLUGINS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.PLUGINS) 24const SORTABLE_PLUGINS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.PLUGINS)
25const SORTABLE_AVAILABLE_PLUGINS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.AVAILABLE_PLUGINS) 25const SORTABLE_AVAILABLE_PLUGINS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
26const SORTABLE_VIDEO_REDUNDANCIES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
26 27
27const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS) 28const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS)
28const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS) 29const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS)
@@ -45,6 +46,7 @@ const userNotificationsSortValidator = checkSort(SORTABLE_USER_NOTIFICATIONS_COL
45const videoPlaylistsSortValidator = checkSort(SORTABLE_VIDEO_PLAYLISTS_COLUMNS) 46const videoPlaylistsSortValidator = checkSort(SORTABLE_VIDEO_PLAYLISTS_COLUMNS)
46const pluginsSortValidator = checkSort(SORTABLE_PLUGINS_COLUMNS) 47const pluginsSortValidator = checkSort(SORTABLE_PLUGINS_COLUMNS)
47const availablePluginsSortValidator = checkSort(SORTABLE_AVAILABLE_PLUGINS_COLUMNS) 48const availablePluginsSortValidator = checkSort(SORTABLE_AVAILABLE_PLUGINS_COLUMNS)
49const videoRedundanciesSortValidator = checkSort(SORTABLE_VIDEO_REDUNDANCIES_COLUMNS)
48 50
49// --------------------------------------------------------------------------- 51// ---------------------------------------------------------------------------
50 52
@@ -69,5 +71,6 @@ export {
69 serversBlocklistSortValidator, 71 serversBlocklistSortValidator,
70 userNotificationsSortValidator, 72 userNotificationsSortValidator,
71 videoPlaylistsSortValidator, 73 videoPlaylistsSortValidator,
74 videoRedundanciesSortValidator,
72 pluginsSortValidator 75 pluginsSortValidator
73} 76}
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts
index 6733d9dec..00ee375cb 100644
--- a/server/middlewares/validators/videos/videos.ts
+++ b/server/middlewares/validators/videos/videos.ts
@@ -81,7 +81,7 @@ const videosAddValidator = getCommonVideoEditAttributes().concat([
81 duration = await getDurationFromVideoFile(videoFile.path) 81 duration = await getDurationFromVideoFile(videoFile.path)
82 } catch (err) { 82 } catch (err) {
83 logger.error('Invalid input file in videosAddValidator.', { err }) 83 logger.error('Invalid input file in videosAddValidator.', { err })
84 res.status(400) 84 res.status(422)
85 .json({ error: 'Invalid input file.' }) 85 .json({ error: 'Invalid input file.' })
86 86
87 return cleanUpReqFiles(req) 87 return cleanUpReqFiles(req)
diff --git a/server/middlewares/validators/webfinger.ts b/server/middlewares/validators/webfinger.ts
index d50e6527f..5fe864f8b 100644
--- a/server/middlewares/validators/webfinger.ts
+++ b/server/middlewares/validators/webfinger.ts
@@ -18,15 +18,14 @@ const webfingerValidator = [
18 const nameWithHost = getHostWithPort(req.query.resource.substr(5)) 18 const nameWithHost = getHostWithPort(req.query.resource.substr(5))
19 const [ name ] = nameWithHost.split('@') 19 const [ name ] = nameWithHost.split('@')
20 20
21 // FIXME: we don't need the full actor 21 const actor = await ActorModel.loadLocalUrlByName(name)
22 const actor = await ActorModel.loadLocalByName(name)
23 if (!actor) { 22 if (!actor) {
24 return res.status(404) 23 return res.status(404)
25 .send({ error: 'Actor not found' }) 24 .send({ error: 'Actor not found' })
26 .end() 25 .end()
27 } 26 }
28 27
29 res.locals.actorFull = actor 28 res.locals.actorUrl = actor
30 return next() 29 return next()
31 } 30 }
32] 31]
diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts
index 6ebe32556..e2f66d733 100644
--- a/server/models/account/account-blocklist.ts
+++ b/server/models/account/account-blocklist.ts
@@ -80,7 +80,7 @@ export class AccountBlocklistModel extends Model<AccountBlocklistModel> {
80 attributes: [ 'accountId', 'id' ], 80 attributes: [ 'accountId', 'id' ],
81 where: { 81 where: {
82 accountId: { 82 accountId: {
83 [Op.in]: accountIds // FIXME: sequelize ANY seems broken 83 [Op.in]: accountIds
84 }, 84 },
85 targetAccountId 85 targetAccountId
86 }, 86 },
diff --git a/server/models/account/user-notification.ts b/server/models/account/user-notification.ts
index a05f30175..f649b8f95 100644
--- a/server/models/account/user-notification.ts
+++ b/server/models/account/user-notification.ts
@@ -363,7 +363,7 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
363 where: { 363 where: {
364 userId, 364 userId,
365 id: { 365 id: {
366 [Op.in]: notificationIds // FIXME: sequelize ANY seems broken 366 [Op.in]: notificationIds
367 } 367 }
368 } 368 }
369 } 369 }
diff --git a/server/models/activitypub/actor.ts b/server/models/activitypub/actor.ts
index 007647ced..d651a281a 100644
--- a/server/models/activitypub/actor.ts
+++ b/server/models/activitypub/actor.ts
@@ -43,7 +43,7 @@ import {
43 MActorFull, 43 MActorFull,
44 MActorHost, 44 MActorHost,
45 MActorServer, 45 MActorServer,
46 MActorSummaryFormattable, 46 MActorSummaryFormattable, MActorUrl,
47 MActorWithInboxes 47 MActorWithInboxes
48} from '../../typings/models' 48} from '../../typings/models'
49import * as Bluebird from 'bluebird' 49import * as Bluebird from 'bluebird'
@@ -276,7 +276,8 @@ export class ActorModel extends Model<ActorModel> {
276 }) 276 })
277 VideoChannel: VideoChannelModel 277 VideoChannel: VideoChannelModel
278 278
279 private static cache: { [ id: string ]: any } = {} 279 private static localNameCache: { [ id: string ]: any } = {}
280 private static localUrlCache: { [ id: string ]: any } = {}
280 281
281 static load (id: number): Bluebird<MActor> { 282 static load (id: number): Bluebird<MActor> {
282 return ActorModel.unscoped().findByPk(id) 283 return ActorModel.unscoped().findByPk(id)
@@ -345,8 +346,8 @@ export class ActorModel extends Model<ActorModel> {
345 346
346 static loadLocalByName (preferredUsername: string, transaction?: Transaction): Bluebird<MActorFull> { 347 static loadLocalByName (preferredUsername: string, transaction?: Transaction): Bluebird<MActorFull> {
347 // The server actor never change, so we can easily cache it 348 // The server actor never change, so we can easily cache it
348 if (preferredUsername === SERVER_ACTOR_NAME && ActorModel.cache[preferredUsername]) { 349 if (preferredUsername === SERVER_ACTOR_NAME && ActorModel.localNameCache[preferredUsername]) {
349 return Bluebird.resolve(ActorModel.cache[preferredUsername]) 350 return Bluebird.resolve(ActorModel.localNameCache[preferredUsername])
350 } 351 }
351 352
352 const query = { 353 const query = {
@@ -361,7 +362,33 @@ export class ActorModel extends Model<ActorModel> {
361 .findOne(query) 362 .findOne(query)
362 .then(actor => { 363 .then(actor => {
363 if (preferredUsername === SERVER_ACTOR_NAME) { 364 if (preferredUsername === SERVER_ACTOR_NAME) {
364 ActorModel.cache[ preferredUsername ] = actor 365 ActorModel.localNameCache[ preferredUsername ] = actor
366 }
367
368 return actor
369 })
370 }
371
372 static loadLocalUrlByName (preferredUsername: string, transaction?: Transaction): Bluebird<MActorUrl> {
373 // The server actor never change, so we can easily cache it
374 if (preferredUsername === SERVER_ACTOR_NAME && ActorModel.localUrlCache[preferredUsername]) {
375 return Bluebird.resolve(ActorModel.localUrlCache[preferredUsername])
376 }
377
378 const query = {
379 attributes: [ 'url' ],
380 where: {
381 preferredUsername,
382 serverId: null
383 },
384 transaction
385 }
386
387 return ActorModel.unscoped()
388 .findOne(query)
389 .then(actor => {
390 if (preferredUsername === SERVER_ACTOR_NAME) {
391 ActorModel.localUrlCache[ preferredUsername ] = actor
365 } 392 }
366 393
367 return actor 394 return actor
diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts
index 8c9a7eabf..4e66d72e3 100644
--- a/server/models/redundancy/video-redundancy.ts
+++ b/server/models/redundancy/video-redundancy.ts
@@ -13,13 +13,13 @@ import {
13 UpdatedAt 13 UpdatedAt
14} from 'sequelize-typescript' 14} from 'sequelize-typescript'
15import { ActorModel } from '../activitypub/actor' 15import { ActorModel } from '../activitypub/actor'
16import { getVideoSort, parseAggregateResult, throwIfNotValid } from '../utils' 16import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../utils'
17import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validators/activitypub/misc' 17import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validators/activitypub/misc'
18import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants' 18import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants'
19import { VideoFileModel } from '../video/video-file' 19import { VideoFileModel } from '../video/video-file'
20import { getServerActor } from '../../helpers/utils' 20import { getServerActor } from '../../helpers/utils'
21import { VideoModel } from '../video/video' 21import { VideoModel } from '../video/video'
22import { VideoRedundancyStrategy } from '../../../shared/models/redundancy' 22import { VideoRedundancyStrategy, VideoRedundancyStrategyWithManual } from '../../../shared/models/redundancy'
23import { logger } from '../../helpers/logger' 23import { logger } from '../../helpers/logger'
24import { CacheFileObject, VideoPrivacy } from '../../../shared' 24import { CacheFileObject, VideoPrivacy } from '../../../shared'
25import { VideoChannelModel } from '../video/video-channel' 25import { VideoChannelModel } from '../video/video-channel'
@@ -27,10 +27,16 @@ import { ServerModel } from '../server/server'
27import { sample } from 'lodash' 27import { sample } from 'lodash'
28import { isTestInstance } from '../../helpers/core-utils' 28import { isTestInstance } from '../../helpers/core-utils'
29import * as Bluebird from 'bluebird' 29import * as Bluebird from 'bluebird'
30import { col, FindOptions, fn, literal, Op, Transaction } from 'sequelize' 30import { col, FindOptions, fn, literal, Op, Transaction, WhereOptions } from 'sequelize'
31import { VideoStreamingPlaylistModel } from '../video/video-streaming-playlist' 31import { VideoStreamingPlaylistModel } from '../video/video-streaming-playlist'
32import { CONFIG } from '../../initializers/config' 32import { CONFIG } from '../../initializers/config'
33import { MVideoRedundancy, MVideoRedundancyAP, MVideoRedundancyVideo } from '@server/typings/models' 33import { MVideoForRedundancyAPI, MVideoRedundancy, MVideoRedundancyAP, MVideoRedundancyVideo } from '@server/typings/models'
34import { VideoRedundanciesTarget } from '@shared/models/redundancy/video-redundancies-filters.model'
35import {
36 FileRedundancyInformation,
37 StreamingPlaylistRedundancyInformation,
38 VideoRedundancy
39} from '@shared/models/redundancy/video-redundancy.model'
34 40
35export enum ScopeNames { 41export enum ScopeNames {
36 WITH_VIDEO = 'WITH_VIDEO' 42 WITH_VIDEO = 'WITH_VIDEO'
@@ -86,7 +92,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
86 @UpdatedAt 92 @UpdatedAt
87 updatedAt: Date 93 updatedAt: Date
88 94
89 @AllowNull(false) 95 @AllowNull(true)
90 @Column 96 @Column
91 expiresOn: Date 97 expiresOn: Date
92 98
@@ -193,6 +199,15 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
193 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query) 199 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
194 } 200 }
195 201
202 static loadByIdWithVideo (id: number, transaction?: Transaction): Bluebird<MVideoRedundancyVideo> {
203 const query = {
204 where: { id },
205 transaction
206 }
207
208 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
209 }
210
196 static loadByUrl (url: string, transaction?: Transaction): Bluebird<MVideoRedundancy> { 211 static loadByUrl (url: string, transaction?: Transaction): Bluebird<MVideoRedundancy> {
197 const query = { 212 const query = {
198 where: { 213 where: {
@@ -394,7 +409,8 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
394 [Op.ne]: actor.id 409 [Op.ne]: actor.id
395 }, 410 },
396 expiresOn: { 411 expiresOn: {
397 [ Op.lt ]: new Date() 412 [ Op.lt ]: new Date(),
413 [ Op.ne ]: null
398 } 414 }
399 } 415 }
400 } 416 }
@@ -447,7 +463,112 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
447 return VideoRedundancyModel.findAll(query) 463 return VideoRedundancyModel.findAll(query)
448 } 464 }
449 465
450 static async getStats (strategy: VideoRedundancyStrategy) { 466 static listForApi (options: {
467 start: number,
468 count: number,
469 sort: string,
470 target: VideoRedundanciesTarget,
471 strategy?: string
472 }) {
473 const { start, count, sort, target, strategy } = options
474 let redundancyWhere: WhereOptions = {}
475 let videosWhere: WhereOptions = {}
476 let redundancySqlSuffix = ''
477
478 if (target === 'my-videos') {
479 Object.assign(videosWhere, { remote: false })
480 } else if (target === 'remote-videos') {
481 Object.assign(videosWhere, { remote: true })
482 Object.assign(redundancyWhere, { strategy: { [Op.ne]: null } })
483 redundancySqlSuffix = ' AND "videoRedundancy"."strategy" IS NOT NULL'
484 }
485
486 if (strategy) {
487 Object.assign(redundancyWhere, { strategy: strategy })
488 }
489
490 const videoFilterWhere = {
491 [Op.and]: [
492 {
493 [ Op.or ]: [
494 {
495 id: {
496 [ Op.in ]: literal(
497 '(' +
498 'SELECT "videoId" FROM "videoFile" ' +
499 'INNER JOIN "videoRedundancy" ON "videoRedundancy"."videoFileId" = "videoFile".id' +
500 redundancySqlSuffix +
501 ')'
502 )
503 }
504 },
505 {
506 id: {
507 [ Op.in ]: literal(
508 '(' +
509 'select "videoId" FROM "videoStreamingPlaylist" ' +
510 'INNER JOIN "videoRedundancy" ON "videoRedundancy"."videoStreamingPlaylistId" = "videoStreamingPlaylist".id' +
511 redundancySqlSuffix +
512 ')'
513 )
514 }
515 }
516 ]
517 },
518
519 videosWhere
520 ]
521 }
522
523 // /!\ On video model /!\
524 const findOptions = {
525 offset: start,
526 limit: count,
527 order: getSort(sort),
528 include: [
529 {
530 required: false,
531 model: VideoFileModel.unscoped(),
532 include: [
533 {
534 model: VideoRedundancyModel.unscoped(),
535 required: false,
536 where: redundancyWhere
537 }
538 ]
539 },
540 {
541 required: false,
542 model: VideoStreamingPlaylistModel.unscoped(),
543 include: [
544 {
545 model: VideoRedundancyModel.unscoped(),
546 required: false,
547 where: redundancyWhere
548 },
549 {
550 model: VideoFileModel.unscoped(),
551 required: false
552 }
553 ]
554 }
555 ],
556 where: videoFilterWhere
557 }
558
559 // /!\ On video model /!\
560 const countOptions = {
561 where: videoFilterWhere
562 }
563
564 return Promise.all([
565 VideoModel.findAll(findOptions),
566
567 VideoModel.count(countOptions)
568 ]).then(([ data, total ]) => ({ total, data }))
569 }
570
571 static async getStats (strategy: VideoRedundancyStrategyWithManual) {
451 const actor = await getServerActor() 572 const actor = await getServerActor()
452 573
453 const query: FindOptions = { 574 const query: FindOptions = {
@@ -478,6 +599,53 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
478 })) 599 }))
479 } 600 }
480 601
602 static toFormattedJSONStatic (video: MVideoForRedundancyAPI): VideoRedundancy {
603 let filesRedundancies: FileRedundancyInformation[] = []
604 let streamingPlaylistsRedundancies: StreamingPlaylistRedundancyInformation[] = []
605
606 for (const file of video.VideoFiles) {
607 for (const redundancy of file.RedundancyVideos) {
608 filesRedundancies.push({
609 id: redundancy.id,
610 fileUrl: redundancy.fileUrl,
611 strategy: redundancy.strategy,
612 createdAt: redundancy.createdAt,
613 updatedAt: redundancy.updatedAt,
614 expiresOn: redundancy.expiresOn,
615 size: file.size
616 })
617 }
618 }
619
620 for (const playlist of video.VideoStreamingPlaylists) {
621 const size = playlist.VideoFiles.reduce((a, b) => a + b.size, 0)
622
623 for (const redundancy of playlist.RedundancyVideos) {
624 streamingPlaylistsRedundancies.push({
625 id: redundancy.id,
626 fileUrl: redundancy.fileUrl,
627 strategy: redundancy.strategy,
628 createdAt: redundancy.createdAt,
629 updatedAt: redundancy.updatedAt,
630 expiresOn: redundancy.expiresOn,
631 size
632 })
633 }
634 }
635
636 return {
637 id: video.id,
638 name: video.name,
639 url: video.url,
640 uuid: video.uuid,
641
642 redundancies: {
643 files: filesRedundancies,
644 streamingPlaylists: streamingPlaylistsRedundancies
645 }
646 }
647 }
648
481 getVideo () { 649 getVideo () {
482 if (this.VideoFile) return this.VideoFile.Video 650 if (this.VideoFile) return this.VideoFile.Video
483 651
@@ -494,7 +662,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
494 id: this.url, 662 id: this.url,
495 type: 'CacheFile' as 'CacheFile', 663 type: 'CacheFile' as 'CacheFile',
496 object: this.VideoStreamingPlaylist.Video.url, 664 object: this.VideoStreamingPlaylist.Video.url,
497 expires: this.expiresOn.toISOString(), 665 expires: this.expiresOn ? this.expiresOn.toISOString() : null,
498 url: { 666 url: {
499 type: 'Link', 667 type: 'Link',
500 mediaType: 'application/x-mpegURL', 668 mediaType: 'application/x-mpegURL',
@@ -507,7 +675,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
507 id: this.url, 675 id: this.url,
508 type: 'CacheFile' as 'CacheFile', 676 type: 'CacheFile' as 'CacheFile',
509 object: this.VideoFile.Video.url, 677 object: this.VideoFile.Video.url,
510 expires: this.expiresOn.toISOString(), 678 expires: this.expiresOn ? this.expiresOn.toISOString() : null,
511 url: { 679 url: {
512 type: 'Link', 680 type: 'Link',
513 mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[ this.VideoFile.extname ] as any, 681 mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[ this.VideoFile.extname ] as any,
diff --git a/server/models/server/server-blocklist.ts b/server/models/server/server-blocklist.ts
index b88df4fd5..883ae47ab 100644
--- a/server/models/server/server-blocklist.ts
+++ b/server/models/server/server-blocklist.ts
@@ -81,7 +81,7 @@ export class ServerBlocklistModel extends Model<ServerBlocklistModel> {
81 attributes: [ 'accountId', 'id' ], 81 attributes: [ 'accountId', 'id' ],
82 where: { 82 where: {
83 accountId: { 83 accountId: {
84 [Op.in]: accountIds // FIXME: sequelize ANY seems broken 84 [Op.in]: accountIds
85 }, 85 },
86 targetServerId 86 targetServerId
87 }, 87 },
diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts
index eeb2a4afd..6335d44e4 100644
--- a/server/models/video/video-caption.ts
+++ b/server/models/video/video-caption.ts
@@ -120,7 +120,7 @@ export class VideoCaptionModel extends Model<VideoCaptionModel> {
120 language 120 language
121 } 121 }
122 122
123 return (VideoCaptionModel.upsert<VideoCaptionModel>(values, { transaction, returning: true }) as any) // FIXME: typings 123 return VideoCaptionModel.upsert(values, { transaction, returning: true })
124 .then(([ caption ]) => caption) 124 .then(([ caption ]) => caption)
125 } 125 }
126 126
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index e10adcb3a..4e98e7ba3 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -43,18 +43,6 @@ import {
43 MChannelSummaryFormattable 43 MChannelSummaryFormattable
44} from '../../typings/models/video' 44} from '../../typings/models/video'
45 45
46// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
47const indexes: ModelIndexesOptions[] = [
48 buildTrigramSearchIndex('video_channel_name_trigram', 'name'),
49
50 {
51 fields: [ 'accountId' ]
52 },
53 {
54 fields: [ 'actorId' ]
55 }
56]
57
58export enum ScopeNames { 46export enum ScopeNames {
59 FOR_API = 'FOR_API', 47 FOR_API = 'FOR_API',
60 WITH_ACCOUNT = 'WITH_ACCOUNT', 48 WITH_ACCOUNT = 'WITH_ACCOUNT',
@@ -176,7 +164,16 @@ export type SummaryOptions = {
176})) 164}))
177@Table({ 165@Table({
178 tableName: 'videoChannel', 166 tableName: 'videoChannel',
179 indexes 167 indexes: [
168 buildTrigramSearchIndex('video_channel_name_trigram', 'name'),
169
170 {
171 fields: [ 'accountId' ]
172 },
173 {
174 fields: [ 'actorId' ]
175 }
176 ]
180}) 177})
181export class VideoChannelModel extends Model<VideoChannelModel> { 178export class VideoChannelModel extends Model<VideoChannelModel> {
182 179
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
index bcdda36e5..ba1fc23ea 100644
--- a/server/models/video/video-playlist.ts
+++ b/server/models/video/video-playlist.ts
@@ -369,7 +369,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
369 model: VideoPlaylistElementModel.unscoped(), 369 model: VideoPlaylistElementModel.unscoped(),
370 where: { 370 where: {
371 videoId: { 371 videoId: {
372 [Op.in]: videoIds // FIXME: sequelize ANY seems broken 372 [Op.in]: videoIds
373 } 373 }
374 }, 374 },
375 required: true 375 required: true
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index eacffe186..20e1f1c4a 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -1,18 +1,7 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import { maxBy, minBy } from 'lodash' 2import { maxBy, minBy } from 'lodash'
3import { join } from 'path' 3import { join } from 'path'
4import { 4import { CountOptions, FindOptions, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
5 CountOptions,
6 FindOptions,
7 IncludeOptions,
8 ModelIndexesOptions,
9 Op,
10 QueryTypes,
11 ScopeOptions,
12 Sequelize,
13 Transaction,
14 WhereOptions
15} from 'sequelize'
16import { 5import {
17 AllowNull, 6 AllowNull,
18 BeforeDestroy, 7 BeforeDestroy,
@@ -136,8 +125,7 @@ import {
136 MVideoThumbnailBlacklist, 125 MVideoThumbnailBlacklist,
137 MVideoWithAllFiles, 126 MVideoWithAllFiles,
138 MVideoWithFile, 127 MVideoWithFile,
139 MVideoWithRights, 128 MVideoWithRights
140 MStreamingPlaylistFiles
141} from '../../typings/models' 129} from '../../typings/models'
142import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../typings/models/video/video-file' 130import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../typings/models/video/video-file'
143import { MThumbnail } from '../../typings/models/video/thumbnail' 131import { MThumbnail } from '../../typings/models/video/thumbnail'
@@ -145,74 +133,6 @@ import { VideoFile } from '@shared/models/videos/video-file.model'
145import { getHLSDirectory, getTorrentFileName, getTorrentFilePath, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths' 133import { getHLSDirectory, getTorrentFileName, getTorrentFilePath, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
146import validator from 'validator' 134import validator from 'validator'
147 135
148// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
149const indexes: (ModelIndexesOptions & { where?: WhereOptions })[] = [
150 buildTrigramSearchIndex('video_name_trigram', 'name'),
151
152 { fields: [ 'createdAt' ] },
153 {
154 fields: [
155 { name: 'publishedAt', order: 'DESC' },
156 { name: 'id', order: 'ASC' }
157 ]
158 },
159 { fields: [ 'duration' ] },
160 { fields: [ 'views' ] },
161 { fields: [ 'channelId' ] },
162 {
163 fields: [ 'originallyPublishedAt' ],
164 where: {
165 originallyPublishedAt: {
166 [Op.ne]: null
167 }
168 }
169 },
170 {
171 fields: [ 'category' ], // We don't care videos with an unknown category
172 where: {
173 category: {
174 [Op.ne]: null
175 }
176 }
177 },
178 {
179 fields: [ 'licence' ], // We don't care videos with an unknown licence
180 where: {
181 licence: {
182 [Op.ne]: null
183 }
184 }
185 },
186 {
187 fields: [ 'language' ], // We don't care videos with an unknown language
188 where: {
189 language: {
190 [Op.ne]: null
191 }
192 }
193 },
194 {
195 fields: [ 'nsfw' ], // Most of the videos are not NSFW
196 where: {
197 nsfw: true
198 }
199 },
200 {
201 fields: [ 'remote' ], // Only index local videos
202 where: {
203 remote: false
204 }
205 },
206 {
207 fields: [ 'uuid' ],
208 unique: true
209 },
210 {
211 fields: [ 'url' ],
212 unique: true
213 }
214]
215
216export enum ScopeNames { 136export enum ScopeNames {
217 AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS', 137 AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS',
218 FOR_API = 'FOR_API', 138 FOR_API = 'FOR_API',
@@ -292,7 +212,7 @@ export type AvailableForListIDsOptions = {
292 if (options.ids) { 212 if (options.ids) {
293 query.where = { 213 query.where = {
294 id: { 214 id: {
295 [ Op.in ]: options.ids // FIXME: sequelize ANY seems broken 215 [ Op.in ]: options.ids
296 } 216 }
297 } 217 }
298 } 218 }
@@ -760,7 +680,72 @@ export type AvailableForListIDsOptions = {
760})) 680}))
761@Table({ 681@Table({
762 tableName: 'video', 682 tableName: 'video',
763 indexes 683 indexes: [
684 buildTrigramSearchIndex('video_name_trigram', 'name'),
685
686 { fields: [ 'createdAt' ] },
687 {
688 fields: [
689 { name: 'publishedAt', order: 'DESC' },
690 { name: 'id', order: 'ASC' }
691 ]
692 },
693 { fields: [ 'duration' ] },
694 { fields: [ 'views' ] },
695 { fields: [ 'channelId' ] },
696 {
697 fields: [ 'originallyPublishedAt' ],
698 where: {
699 originallyPublishedAt: {
700 [Op.ne]: null
701 }
702 }
703 },
704 {
705 fields: [ 'category' ], // We don't care videos with an unknown category
706 where: {
707 category: {
708 [Op.ne]: null
709 }
710 }
711 },
712 {
713 fields: [ 'licence' ], // We don't care videos with an unknown licence
714 where: {
715 licence: {
716 [Op.ne]: null
717 }
718 }
719 },
720 {
721 fields: [ 'language' ], // We don't care videos with an unknown language
722 where: {
723 language: {
724 [Op.ne]: null
725 }
726 }
727 },
728 {
729 fields: [ 'nsfw' ], // Most of the videos are not NSFW
730 where: {
731 nsfw: true
732 }
733 },
734 {
735 fields: [ 'remote' ], // Only index local videos
736 where: {
737 remote: false
738 }
739 },
740 {
741 fields: [ 'uuid' ],
742 unique: true
743 },
744 {
745 fields: [ 'url' ],
746 unique: true
747 }
748 ]
764}) 749})
765export class VideoModel extends Model<VideoModel> { 750export class VideoModel extends Model<VideoModel> {
766 751
diff --git a/server/tests/api/check-params/redundancy.ts b/server/tests/api/check-params/redundancy.ts
index 6471da840..7012a39ee 100644
--- a/server/tests/api/check-params/redundancy.ts
+++ b/server/tests/api/check-params/redundancy.ts
@@ -3,21 +3,25 @@
3import 'mocha' 3import 'mocha'
4 4
5import { 5import {
6 checkBadCountPagination,
7 checkBadSortPagination,
8 checkBadStartPagination,
6 cleanupTests, 9 cleanupTests,
7 createUser, 10 createUser,
8 doubleFollow, 11 doubleFollow,
9 flushAndRunMultipleServers, 12 flushAndRunMultipleServers, makeDeleteRequest,
10 flushTests, 13 makeGetRequest, makePostBodyRequest,
11 killallServers,
12 makePutBodyRequest, 14 makePutBodyRequest,
13 ServerInfo, 15 ServerInfo,
14 setAccessTokensToServers, 16 setAccessTokensToServers, uploadVideoAndGetId,
15 userLogin 17 userLogin, waitJobs
16} from '../../../../shared/extra-utils' 18} from '../../../../shared/extra-utils'
17 19
18describe('Test server redundancy API validators', function () { 20describe('Test server redundancy API validators', function () {
19 let servers: ServerInfo[] 21 let servers: ServerInfo[]
20 let userAccessToken = null 22 let userAccessToken = null
23 let videoIdLocal: number
24 let videoIdRemote: number
21 25
22 // --------------------------------------------------------------- 26 // ---------------------------------------------------------------
23 27
@@ -36,9 +40,134 @@ describe('Test server redundancy API validators', function () {
36 40
37 await createUser({ url: servers[ 0 ].url, accessToken: servers[ 0 ].accessToken, username: user.username, password: user.password }) 41 await createUser({ url: servers[ 0 ].url, accessToken: servers[ 0 ].accessToken, username: user.username, password: user.password })
38 userAccessToken = await userLogin(servers[0], user) 42 userAccessToken = await userLogin(servers[0], user)
43
44 videoIdLocal = (await uploadVideoAndGetId({ server: servers[0], videoName: 'video' })).id
45 videoIdRemote = (await uploadVideoAndGetId({ server: servers[1], videoName: 'video' })).id
46
47 await waitJobs(servers)
48 })
49
50 describe('When listing redundancies', function () {
51 const path = '/api/v1/server/redundancy/videos'
52
53 let url: string
54 let token: string
55
56 before(function () {
57 url = servers[0].url
58 token = servers[0].accessToken
59 })
60
61 it('Should fail with an invalid token', async function () {
62 await makeGetRequest({ url, path, token: 'fake_token', statusCodeExpected: 401 })
63 })
64
65 it('Should fail if the user is not an administrator', async function () {
66 await makeGetRequest({ url, path, token: userAccessToken, statusCodeExpected: 403 })
67 })
68
69 it('Should fail with a bad start pagination', async function () {
70 await checkBadStartPagination(url, path, servers[0].accessToken)
71 })
72
73 it('Should fail with a bad count pagination', async function () {
74 await checkBadCountPagination(url, path, servers[0].accessToken)
75 })
76
77 it('Should fail with an incorrect sort', async function () {
78 await checkBadSortPagination(url, path, servers[0].accessToken)
79 })
80
81 it('Should fail with a bad target', async function () {
82 await makeGetRequest({ url, path, token, query: { target: 'bad target' } })
83 })
84
85 it('Should fail without target', async function () {
86 await makeGetRequest({ url, path, token })
87 })
88
89 it('Should succeed with the correct params', async function () {
90 await makeGetRequest({ url, path, token, query: { target: 'my-videos' }, statusCodeExpected: 200 })
91 })
92 })
93
94 describe('When manually adding a redundancy', function () {
95 const path = '/api/v1/server/redundancy/videos'
96
97 let url: string
98 let token: string
99
100 before(function () {
101 url = servers[0].url
102 token = servers[0].accessToken
103 })
104
105 it('Should fail with an invalid token', async function () {
106 await makePostBodyRequest({ url, path, token: 'fake_token', statusCodeExpected: 401 })
107 })
108
109 it('Should fail if the user is not an administrator', async function () {
110 await makePostBodyRequest({ url, path, token: userAccessToken, statusCodeExpected: 403 })
111 })
112
113 it('Should fail without a video id', async function () {
114 await makePostBodyRequest({ url, path, token })
115 })
116
117 it('Should fail with an incorrect video id', async function () {
118 await makePostBodyRequest({ url, path, token, fields: { videoId: 'peertube' } })
119 })
120
121 it('Should fail with a not found video id', async function () {
122 await makePostBodyRequest({ url, path, token, fields: { videoId: 6565 }, statusCodeExpected: 404 })
123 })
124
125 it('Should fail with a local a video id', async function () {
126 await makePostBodyRequest({ url, path, token, fields: { videoId: videoIdLocal } })
127 })
128
129 it('Should succeed with the correct params', async function () {
130 await makePostBodyRequest({ url, path, token, fields: { videoId: videoIdRemote }, statusCodeExpected: 204 })
131 })
132
133 it('Should fail if the video is already duplicated', async function () {
134 this.timeout(30000)
135
136 await waitJobs(servers)
137
138 await makePostBodyRequest({ url, path, token, fields: { videoId: videoIdRemote }, statusCodeExpected: 409 })
139 })
140 })
141
142 describe('When manually removing a redundancy', function () {
143 const path = '/api/v1/server/redundancy/videos/'
144
145 let url: string
146 let token: string
147
148 before(function () {
149 url = servers[0].url
150 token = servers[0].accessToken
151 })
152
153 it('Should fail with an invalid token', async function () {
154 await makeDeleteRequest({ url, path: path + '1', token: 'fake_token', statusCodeExpected: 401 })
155 })
156
157 it('Should fail if the user is not an administrator', async function () {
158 await makeDeleteRequest({ url, path: path + '1', token: userAccessToken, statusCodeExpected: 403 })
159 })
160
161 it('Should fail with an incorrect video id', async function () {
162 await makeDeleteRequest({ url, path: path + 'toto', token })
163 })
164
165 it('Should fail with a not found video redundancy', async function () {
166 await makeDeleteRequest({ url, path: path + '454545', token, statusCodeExpected: 404 })
167 })
39 }) 168 })
40 169
41 describe('When updating redundancy', function () { 170 describe('When updating server redundancy', function () {
42 const path = '/api/v1/server/redundancy' 171 const path = '/api/v1/server/redundancy'
43 172
44 it('Should fail with an invalid token', async function () { 173 it('Should fail with an invalid token', async function () {
diff --git a/server/tests/api/redundancy/index.ts b/server/tests/api/redundancy/index.ts
index 8e69b95a6..5359055b0 100644
--- a/server/tests/api/redundancy/index.ts
+++ b/server/tests/api/redundancy/index.ts
@@ -1 +1,2 @@
1import './redundancy' 1import './redundancy'
2import './manage-redundancy'
diff --git a/server/tests/api/redundancy/manage-redundancy.ts b/server/tests/api/redundancy/manage-redundancy.ts
new file mode 100644
index 000000000..6a8937f24
--- /dev/null
+++ b/server/tests/api/redundancy/manage-redundancy.ts
@@ -0,0 +1,373 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import {
6 cleanupTests,
7 doubleFollow,
8 flushAndRunMultipleServers,
9 getLocalIdByUUID,
10 ServerInfo,
11 setAccessTokensToServers,
12 uploadVideo,
13 uploadVideoAndGetId,
14 waitUntilLog
15} from '../../../../shared/extra-utils'
16import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
17import { addVideoRedundancy, listVideoRedundancies, removeVideoRedundancy, updateRedundancy } from '@shared/extra-utils/server/redundancy'
18import { VideoPrivacy, VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
19
20const expect = chai.expect
21
22describe('Test manage videos redundancy', function () {
23 const targets: VideoRedundanciesTarget[] = [ 'my-videos', 'remote-videos' ]
24
25 let servers: ServerInfo[]
26 let video1Server2UUID: string
27 let video2Server2UUID: string
28 let redundanciesToRemove: number[] = []
29
30 before(async function () {
31 this.timeout(120000)
32
33 const config = {
34 transcoding: {
35 hls: {
36 enabled: true
37 }
38 },
39 redundancy: {
40 videos: {
41 check_interval: '1 second',
42 strategies: [
43 {
44 strategy: 'recently-added',
45 min_lifetime: '1 hour',
46 size: '10MB',
47 min_views: 0
48 }
49 ]
50 }
51 }
52 }
53 servers = await flushAndRunMultipleServers(3, config)
54
55 // Get the access tokens
56 await setAccessTokensToServers(servers)
57
58 {
59 const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 1 server 2' })
60 video1Server2UUID = res.body.video.uuid
61 }
62
63 {
64 const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 2 server 2' })
65 video2Server2UUID = res.body.video.uuid
66 }
67
68 await waitJobs(servers)
69
70 // Server 1 and server 2 follow each other
71 await doubleFollow(servers[ 0 ], servers[ 1 ])
72 await updateRedundancy(servers[ 0 ].url, servers[ 0 ].accessToken, servers[ 1 ].host, true)
73
74 await waitJobs(servers)
75 })
76
77 it('Should not have redundancies on server 3', async function () {
78 for (const target of targets) {
79 const res = await listVideoRedundancies({
80 url: servers[2].url,
81 accessToken: servers[2].accessToken,
82 target
83 })
84
85 expect(res.body.total).to.equal(0)
86 expect(res.body.data).to.have.lengthOf(0)
87 }
88 })
89
90 it('Should not have "remote-videos" redundancies on server 2', async function () {
91 this.timeout(120000)
92
93 await waitJobs(servers)
94 await waitUntilLog(servers[0], 'Duplicated ', 10)
95 await waitJobs(servers)
96
97 const res = await listVideoRedundancies({
98 url: servers[1].url,
99 accessToken: servers[1].accessToken,
100 target: 'remote-videos'
101 })
102
103 expect(res.body.total).to.equal(0)
104 expect(res.body.data).to.have.lengthOf(0)
105 })
106
107 it('Should have "my-videos" redundancies on server 2', async function () {
108 this.timeout(120000)
109
110 const res = await listVideoRedundancies({
111 url: servers[1].url,
112 accessToken: servers[1].accessToken,
113 target: 'my-videos'
114 })
115
116 expect(res.body.total).to.equal(2)
117
118 const videos = res.body.data as VideoRedundancy[]
119 expect(videos).to.have.lengthOf(2)
120
121 const videos1 = videos.find(v => v.uuid === video1Server2UUID)
122 const videos2 = videos.find(v => v.uuid === video2Server2UUID)
123
124 expect(videos1.name).to.equal('video 1 server 2')
125 expect(videos2.name).to.equal('video 2 server 2')
126
127 expect(videos1.redundancies.files).to.have.lengthOf(4)
128 expect(videos1.redundancies.streamingPlaylists).to.have.lengthOf(1)
129
130 const redundancies = videos1.redundancies.files.concat(videos1.redundancies.streamingPlaylists)
131
132 for (const r of redundancies) {
133 expect(r.strategy).to.be.null
134 expect(r.fileUrl).to.exist
135 expect(r.createdAt).to.exist
136 expect(r.updatedAt).to.exist
137 expect(r.expiresOn).to.exist
138 }
139 })
140
141 it('Should not have "my-videos" redundancies on server 1', async function () {
142 const res = await listVideoRedundancies({
143 url: servers[0].url,
144 accessToken: servers[0].accessToken,
145 target: 'my-videos'
146 })
147
148 expect(res.body.total).to.equal(0)
149 expect(res.body.data).to.have.lengthOf(0)
150 })
151
152 it('Should have "remote-videos" redundancies on server 1', async function () {
153 this.timeout(120000)
154
155 const res = await listVideoRedundancies({
156 url: servers[0].url,
157 accessToken: servers[0].accessToken,
158 target: 'remote-videos'
159 })
160
161 expect(res.body.total).to.equal(2)
162
163 const videos = res.body.data as VideoRedundancy[]
164 expect(videos).to.have.lengthOf(2)
165
166 const videos1 = videos.find(v => v.uuid === video1Server2UUID)
167 const videos2 = videos.find(v => v.uuid === video2Server2UUID)
168
169 expect(videos1.name).to.equal('video 1 server 2')
170 expect(videos2.name).to.equal('video 2 server 2')
171
172 expect(videos1.redundancies.files).to.have.lengthOf(4)
173 expect(videos1.redundancies.streamingPlaylists).to.have.lengthOf(1)
174
175 const redundancies = videos1.redundancies.files.concat(videos1.redundancies.streamingPlaylists)
176
177 for (const r of redundancies) {
178 expect(r.strategy).to.equal('recently-added')
179 expect(r.fileUrl).to.exist
180 expect(r.createdAt).to.exist
181 expect(r.updatedAt).to.exist
182 expect(r.expiresOn).to.exist
183 }
184 })
185
186 it('Should correctly paginate and sort results', async function () {
187 {
188 const res = await listVideoRedundancies({
189 url: servers[0].url,
190 accessToken: servers[0].accessToken,
191 target: 'remote-videos',
192 sort: 'name',
193 start: 0,
194 count: 2
195 })
196
197 const videos = res.body.data
198 expect(videos[ 0 ].name).to.equal('video 1 server 2')
199 expect(videos[ 1 ].name).to.equal('video 2 server 2')
200 }
201
202 {
203 const res = await listVideoRedundancies({
204 url: servers[0].url,
205 accessToken: servers[0].accessToken,
206 target: 'remote-videos',
207 sort: '-name',
208 start: 0,
209 count: 2
210 })
211
212 const videos = res.body.data
213 expect(videos[ 0 ].name).to.equal('video 2 server 2')
214 expect(videos[ 1 ].name).to.equal('video 1 server 2')
215 }
216
217 {
218 const res = await listVideoRedundancies({
219 url: servers[0].url,
220 accessToken: servers[0].accessToken,
221 target: 'remote-videos',
222 sort: '-name',
223 start: 1,
224 count: 1
225 })
226
227 const videos = res.body.data
228 expect(videos[ 0 ].name).to.equal('video 1 server 2')
229 }
230 })
231
232 it('Should manually add a redundancy and list it', async function () {
233 this.timeout(120000)
234
235 const uuid = (await uploadVideoAndGetId({ server: servers[ 1 ], videoName: 'video 3 server 2', privacy: VideoPrivacy.UNLISTED })).uuid
236 await waitJobs(servers)
237 const videoId = await getLocalIdByUUID(servers[0].url, uuid)
238
239 await addVideoRedundancy({
240 url: servers[0].url,
241 accessToken: servers[0].accessToken,
242 videoId
243 })
244
245 await waitJobs(servers)
246 await waitUntilLog(servers[0], 'Duplicated ', 15)
247 await waitJobs(servers)
248
249 {
250 const res = await listVideoRedundancies({
251 url: servers[0].url,
252 accessToken: servers[0].accessToken,
253 target: 'remote-videos',
254 sort: '-name',
255 start: 0,
256 count: 5
257 })
258
259 const videos = res.body.data
260 expect(videos[ 0 ].name).to.equal('video 3 server 2')
261
262 const video = videos[ 0 ]
263 expect(video.redundancies.files).to.have.lengthOf(4)
264 expect(video.redundancies.streamingPlaylists).to.have.lengthOf(1)
265
266 const redundancies = video.redundancies.files.concat(video.redundancies.streamingPlaylists)
267
268 for (const r of redundancies) {
269 redundanciesToRemove.push(r.id)
270
271 expect(r.strategy).to.equal('manual')
272 expect(r.fileUrl).to.exist
273 expect(r.createdAt).to.exist
274 expect(r.updatedAt).to.exist
275 expect(r.expiresOn).to.be.null
276 }
277 }
278
279 const res = await listVideoRedundancies({
280 url: servers[1].url,
281 accessToken: servers[1].accessToken,
282 target: 'my-videos',
283 sort: '-name',
284 start: 0,
285 count: 5
286 })
287
288 const videos = res.body.data
289 expect(videos[ 0 ].name).to.equal('video 3 server 2')
290
291 const video = videos[ 0 ]
292 expect(video.redundancies.files).to.have.lengthOf(4)
293 expect(video.redundancies.streamingPlaylists).to.have.lengthOf(1)
294
295 const redundancies = video.redundancies.files.concat(video.redundancies.streamingPlaylists)
296
297 for (const r of redundancies) {
298 expect(r.strategy).to.be.null
299 expect(r.fileUrl).to.exist
300 expect(r.createdAt).to.exist
301 expect(r.updatedAt).to.exist
302 expect(r.expiresOn).to.be.null
303 }
304 })
305
306 it('Should manually remove a redundancy and remove it from the list', async function () {
307 this.timeout(120000)
308
309 for (const redundancyId of redundanciesToRemove) {
310 await removeVideoRedundancy({
311 url: servers[ 0 ].url,
312 accessToken: servers[ 0 ].accessToken,
313 redundancyId
314 })
315 }
316
317 {
318 const res = await listVideoRedundancies({
319 url: servers[0].url,
320 accessToken: servers[0].accessToken,
321 target: 'remote-videos',
322 sort: '-name',
323 start: 0,
324 count: 5
325 })
326
327 const videos = res.body.data
328 expect(videos).to.have.lengthOf(2)
329
330 expect(videos[ 0 ].name).to.equal('video 2 server 2')
331
332 redundanciesToRemove = []
333 const video = videos[ 0 ]
334 expect(video.redundancies.files).to.have.lengthOf(4)
335 expect(video.redundancies.streamingPlaylists).to.have.lengthOf(1)
336
337 const redundancies = video.redundancies.files.concat(video.redundancies.streamingPlaylists)
338
339 for (const r of redundancies) {
340 redundanciesToRemove.push(r.id)
341 }
342 }
343 })
344
345 it('Should remove another (auto) redundancy', async function () {
346 {
347 for (const redundancyId of redundanciesToRemove) {
348 await removeVideoRedundancy({
349 url: servers[ 0 ].url,
350 accessToken: servers[ 0 ].accessToken,
351 redundancyId
352 })
353 }
354
355 const res = await listVideoRedundancies({
356 url: servers[0].url,
357 accessToken: servers[0].accessToken,
358 target: 'remote-videos',
359 sort: '-name',
360 start: 0,
361 count: 5
362 })
363
364 const videos = res.body.data
365 expect(videos[ 0 ].name).to.equal('video 1 server 2')
366 expect(videos).to.have.lengthOf(1)
367 }
368 })
369
370 after(async function () {
371 await cleanupTests(servers)
372 })
373})
diff --git a/server/tests/api/redundancy/redundancy.ts b/server/tests/api/redundancy/redundancy.ts
index 1cdf93aa1..f5bf130d5 100644
--- a/server/tests/api/redundancy/redundancy.ts
+++ b/server/tests/api/redundancy/redundancy.ts
@@ -5,7 +5,8 @@ import 'mocha'
5import { VideoDetails } from '../../../../shared/models/videos' 5import { VideoDetails } from '../../../../shared/models/videos'
6import { 6import {
7 checkSegmentHash, 7 checkSegmentHash,
8 checkVideoFilesWereRemoved, cleanupTests, 8 checkVideoFilesWereRemoved,
9 cleanupTests,
9 doubleFollow, 10 doubleFollow,
10 flushAndRunMultipleServers, 11 flushAndRunMultipleServers,
11 getFollowingListPaginationAndSort, 12 getFollowingListPaginationAndSort,
@@ -28,11 +29,16 @@ import {
28import { waitJobs } from '../../../../shared/extra-utils/server/jobs' 29import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
29 30
30import * as magnetUtil from 'magnet-uri' 31import * as magnetUtil from 'magnet-uri'
31import { updateRedundancy } from '../../../../shared/extra-utils/server/redundancy' 32import {
33 addVideoRedundancy,
34 listVideoRedundancies,
35 removeVideoRedundancy,
36 updateRedundancy
37} from '../../../../shared/extra-utils/server/redundancy'
32import { ActorFollow } from '../../../../shared/models/actors' 38import { ActorFollow } from '../../../../shared/models/actors'
33import { readdir } from 'fs-extra' 39import { readdir } from 'fs-extra'
34import { join } from 'path' 40import { join } from 'path'
35import { VideoRedundancyStrategy } from '../../../../shared/models/redundancy' 41import { VideoRedundancy, VideoRedundancyStrategy, VideoRedundancyStrategyWithManual } from '../../../../shared/models/redundancy'
36import { getStats } from '../../../../shared/extra-utils/server/stats' 42import { getStats } from '../../../../shared/extra-utils/server/stats'
37import { ServerStats } from '../../../../shared/models/server/server-stats.model' 43import { ServerStats } from '../../../../shared/models/server/server-stats.model'
38 44
@@ -40,6 +46,7 @@ const expect = chai.expect
40 46
41let servers: ServerInfo[] = [] 47let servers: ServerInfo[] = []
42let video1Server2UUID: string 48let video1Server2UUID: string
49let video1Server2Id: number
43 50
44function checkMagnetWebseeds (file: { magnetUri: string, resolution: { id: number } }, baseWebseeds: string[], server: ServerInfo) { 51function checkMagnetWebseeds (file: { magnetUri: string, resolution: { id: number } }, baseWebseeds: string[], server: ServerInfo) {
45 const parsed = magnetUtil.decode(file.magnetUri) 52 const parsed = magnetUtil.decode(file.magnetUri)
@@ -52,7 +59,19 @@ function checkMagnetWebseeds (file: { magnetUri: string, resolution: { id: numbe
52 expect(parsed.urlList).to.have.lengthOf(baseWebseeds.length) 59 expect(parsed.urlList).to.have.lengthOf(baseWebseeds.length)
53} 60}
54 61
55async function flushAndRunServers (strategy: VideoRedundancyStrategy, additionalParams: any = {}) { 62async function flushAndRunServers (strategy: VideoRedundancyStrategy | null, additionalParams: any = {}) {
63 const strategies: any[] = []
64
65 if (strategy !== null) {
66 strategies.push(
67 immutableAssign({
68 min_lifetime: '1 hour',
69 strategy: strategy,
70 size: '400KB'
71 }, additionalParams)
72 )
73 }
74
56 const config = { 75 const config = {
57 transcoding: { 76 transcoding: {
58 hls: { 77 hls: {
@@ -62,16 +81,11 @@ async function flushAndRunServers (strategy: VideoRedundancyStrategy, additional
62 redundancy: { 81 redundancy: {
63 videos: { 82 videos: {
64 check_interval: '5 seconds', 83 check_interval: '5 seconds',
65 strategies: [ 84 strategies
66 immutableAssign({
67 min_lifetime: '1 hour',
68 strategy: strategy,
69 size: '400KB'
70 }, additionalParams)
71 ]
72 } 85 }
73 } 86 }
74 } 87 }
88
75 servers = await flushAndRunMultipleServers(3, config) 89 servers = await flushAndRunMultipleServers(3, config)
76 90
77 // Get the access tokens 91 // Get the access tokens
@@ -80,6 +94,7 @@ async function flushAndRunServers (strategy: VideoRedundancyStrategy, additional
80 { 94 {
81 const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 1 server 2' }) 95 const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 1 server 2' })
82 video1Server2UUID = res.body.video.uuid 96 video1Server2UUID = res.body.video.uuid
97 video1Server2Id = res.body.video.id
83 98
84 await viewVideo(servers[ 1 ].url, video1Server2UUID) 99 await viewVideo(servers[ 1 ].url, video1Server2UUID)
85 } 100 }
@@ -216,29 +231,38 @@ async function check1PlaylistRedundancies (videoUUID?: string) {
216 } 231 }
217} 232}
218 233
219async function checkStatsWith2Webseed (strategy: VideoRedundancyStrategy) { 234async function checkStatsGlobal (strategy: VideoRedundancyStrategyWithManual) {
235 let totalSize: number = null
236 let statsLength = 1
237
238 if (strategy !== 'manual') {
239 totalSize = 409600
240 statsLength = 2
241 }
242
220 const res = await getStats(servers[0].url) 243 const res = await getStats(servers[0].url)
221 const data: ServerStats = res.body 244 const data: ServerStats = res.body
222 245
223 expect(data.videosRedundancy).to.have.lengthOf(1) 246 expect(data.videosRedundancy).to.have.lengthOf(statsLength)
224 const stat = data.videosRedundancy[0]
225 247
248 const stat = data.videosRedundancy[0]
226 expect(stat.strategy).to.equal(strategy) 249 expect(stat.strategy).to.equal(strategy)
227 expect(stat.totalSize).to.equal(409600) 250 expect(stat.totalSize).to.equal(totalSize)
251
252 return stat
253}
254
255async function checkStatsWith2Webseed (strategy: VideoRedundancyStrategyWithManual) {
256 const stat = await checkStatsGlobal(strategy)
257
228 expect(stat.totalUsed).to.be.at.least(1).and.below(409601) 258 expect(stat.totalUsed).to.be.at.least(1).and.below(409601)
229 expect(stat.totalVideoFiles).to.equal(4) 259 expect(stat.totalVideoFiles).to.equal(4)
230 expect(stat.totalVideos).to.equal(1) 260 expect(stat.totalVideos).to.equal(1)
231} 261}
232 262
233async function checkStatsWith1Webseed (strategy: VideoRedundancyStrategy) { 263async function checkStatsWith1Webseed (strategy: VideoRedundancyStrategyWithManual) {
234 const res = await getStats(servers[0].url) 264 const stat = await checkStatsGlobal(strategy)
235 const data: ServerStats = res.body
236
237 expect(data.videosRedundancy).to.have.lengthOf(1)
238 265
239 const stat = data.videosRedundancy[0]
240 expect(stat.strategy).to.equal(strategy)
241 expect(stat.totalSize).to.equal(409600)
242 expect(stat.totalUsed).to.equal(0) 266 expect(stat.totalUsed).to.equal(0)
243 expect(stat.totalVideoFiles).to.equal(0) 267 expect(stat.totalVideoFiles).to.equal(0)
244 expect(stat.totalVideos).to.equal(0) 268 expect(stat.totalVideos).to.equal(0)
@@ -446,6 +470,74 @@ describe('Test videos redundancy', function () {
446 }) 470 })
447 }) 471 })
448 472
473 describe('With manual strategy', function () {
474 before(function () {
475 this.timeout(120000)
476
477 return flushAndRunServers(null)
478 })
479
480 it('Should have 1 webseed on the first video', async function () {
481 await check1WebSeed()
482 await check0PlaylistRedundancies()
483 await checkStatsWith1Webseed('manual')
484 })
485
486 it('Should create a redundancy on first video', async function () {
487 await addVideoRedundancy({
488 url: servers[0].url,
489 accessToken: servers[0].accessToken,
490 videoId: video1Server2Id
491 })
492 })
493
494 it('Should have 2 webseeds on the first video', async function () {
495 this.timeout(80000)
496
497 await waitJobs(servers)
498 await waitUntilLog(servers[0], 'Duplicated ', 5)
499 await waitJobs(servers)
500
501 await check2Webseeds()
502 await check1PlaylistRedundancies()
503 await checkStatsWith2Webseed('manual')
504 })
505
506 it('Should manually remove redundancies on server 1 and remove duplicated videos', async function () {
507 this.timeout(80000)
508
509 const res = await listVideoRedundancies({
510 url: servers[0].url,
511 accessToken: servers[0].accessToken,
512 target: 'remote-videos'
513 })
514
515 const videos = res.body.data as VideoRedundancy[]
516 expect(videos).to.have.lengthOf(1)
517
518 const video = videos[0]
519 for (const r of video.redundancies.files.concat(video.redundancies.streamingPlaylists)) {
520 await removeVideoRedundancy({
521 url: servers[0].url,
522 accessToken: servers[0].accessToken,
523 redundancyId: r.id
524 })
525 }
526
527 await waitJobs(servers)
528 await wait(5000)
529
530 await check1WebSeed()
531 await check0PlaylistRedundancies()
532
533 await checkVideoFilesWereRemoved(video1Server2UUID, servers[0].serverNumber, [ 'videos' ])
534 })
535
536 after(async function () {
537 await cleanupTests(servers)
538 })
539 })
540
449 describe('Test expiration', function () { 541 describe('Test expiration', function () {
450 const strategy = 'recently-added' 542 const strategy = 'recently-added'
451 543
diff --git a/server/tests/api/videos/video-transcoder.ts b/server/tests/api/videos/video-transcoder.ts
index 4be74901a..0104c94fc 100644
--- a/server/tests/api/videos/video-transcoder.ts
+++ b/server/tests/api/videos/video-transcoder.ts
@@ -11,6 +11,7 @@ import {
11 doubleFollow, 11 doubleFollow,
12 flushAndRunMultipleServers, 12 flushAndRunMultipleServers,
13 generateHighBitrateVideo, 13 generateHighBitrateVideo,
14 generateVideoWithFramerate,
14 getMyVideos, 15 getMyVideos,
15 getVideo, 16 getVideo,
16 getVideosList, 17 getVideosList,
@@ -416,6 +417,39 @@ describe('Test video transcoding', function () {
416 } 417 }
417 }) 418 })
418 419
420 it('Should downscale to the closest divisor standard framerate', async function () {
421 this.timeout(160000)
422
423 let tempFixturePath: string
424
425 {
426 tempFixturePath = await generateVideoWithFramerate()
427
428 const fps = await getVideoFileFPS(tempFixturePath)
429 expect(fps).to.be.equal(59)
430 }
431
432 const videoAttributes = {
433 name: '59fps video',
434 description: '59fps video',
435 fixture: tempFixturePath
436 }
437
438 await uploadVideo(servers[1].url, servers[1].accessToken, videoAttributes)
439
440 await waitJobs(servers)
441
442 for (const server of servers) {
443 const res = await getVideosList(server.url)
444
445 const video = res.body.data.find(v => v.name === videoAttributes.name)
446 const path = join(root(), 'test' + servers[1].internalServerNumber, 'videos', video.uuid + '-240.mp4')
447 const fps = await getVideoFileFPS(path)
448
449 expect(fps).to.be.equal(25)
450 }
451 })
452
419 after(async function () { 453 after(async function () {
420 await cleanupTests(servers) 454 await cleanupTests(servers)
421 }) 455 })
diff --git a/server/tests/cli/peertube.ts b/server/tests/cli/peertube.ts
index b8c0b1f79..15b6755f2 100644
--- a/server/tests/cli/peertube.ts
+++ b/server/tests/cli/peertube.ts
@@ -6,15 +6,15 @@ import {
6 addVideoChannel, 6 addVideoChannel,
7 buildAbsoluteFixturePath, 7 buildAbsoluteFixturePath,
8 cleanupTests, 8 cleanupTests,
9 createUser, 9 createUser, doubleFollow,
10 execCLI, 10 execCLI,
11 flushAndRunServer, 11 flushAndRunServer,
12 getEnvCli, 12 getEnvCli, getLocalIdByUUID,
13 getVideo, 13 getVideo,
14 getVideosList, 14 getVideosList,
15 getVideosListWithToken, removeVideo, 15 getVideosListWithToken, removeVideo,
16 ServerInfo, 16 ServerInfo,
17 setAccessTokensToServers, 17 setAccessTokensToServers, uploadVideo, uploadVideoAndGetId,
18 userLogin, 18 userLogin,
19 waitJobs 19 waitJobs
20} from '../../../shared/extra-utils' 20} from '../../../shared/extra-utils'
@@ -210,6 +210,81 @@ describe('Test CLI wrapper', function () {
210 }) 210 })
211 }) 211 })
212 212
213 describe('Manage video redundancies', function () {
214 let anotherServer: ServerInfo
215 let video1Server2: number
216 let servers: ServerInfo[]
217
218 before(async function () {
219 this.timeout(120000)
220
221 anotherServer = await flushAndRunServer(2)
222 await setAccessTokensToServers([ anotherServer ])
223
224 await doubleFollow(server, anotherServer)
225
226 servers = [ server, anotherServer ]
227 await waitJobs(servers)
228
229 const uuid = (await uploadVideoAndGetId({ server: anotherServer, videoName: 'super video' })).uuid
230 await waitJobs(servers)
231
232 video1Server2 = await getLocalIdByUUID(server.url, uuid)
233 })
234
235 it('Should add a redundancy', async function () {
236 this.timeout(60000)
237
238 const env = getEnvCli(server)
239
240 const params = `add --video ${video1Server2}`
241
242 await execCLI(`${env} ${cmd} redundancy ${params}`)
243
244 await waitJobs(servers)
245 })
246
247 it('Should list redundancies', async function () {
248 this.timeout(60000)
249
250 {
251 const env = getEnvCli(server)
252
253 const params = `list-my-redundancies`
254 const stdout = await execCLI(`${env} ${cmd} redundancy ${params}`)
255
256 expect(stdout).to.contain('super video')
257 expect(stdout).to.contain(`localhost:${server.port}`)
258 }
259 })
260
261 it('Should remove a redundancy', async function () {
262 this.timeout(60000)
263
264 const env = getEnvCli(server)
265
266 const params = `remove --video ${video1Server2}`
267
268 await execCLI(`${env} ${cmd} redundancy ${params}`)
269
270 await waitJobs(servers)
271
272 {
273 const env = getEnvCli(server)
274 const params = `list-my-redundancies`
275 const stdout = await execCLI(`${env} ${cmd} redundancy ${params}`)
276
277 expect(stdout).to.not.contain('super video')
278 }
279 })
280
281 after(async function () {
282 this.timeout(10000)
283
284 await cleanupTests([ anotherServer ])
285 })
286 })
287
213 after(async function () { 288 after(async function () {
214 this.timeout(10000) 289 this.timeout(10000)
215 290
diff --git a/server/tools/cli.ts b/server/tools/cli.ts
index 58e2445ac..15ac6c6a8 100644
--- a/server/tools/cli.ts
+++ b/server/tools/cli.ts
@@ -6,6 +6,9 @@ import { getVideoChannel } from '../../shared/extra-utils/videos/video-channels'
6import { Command } from 'commander' 6import { Command } from 'commander'
7import { VideoChannel, VideoPrivacy } from '../../shared/models/videos' 7import { VideoChannel, VideoPrivacy } from '../../shared/models/videos'
8import { createLogger, format, transports } from 'winston' 8import { createLogger, format, transports } from 'winston'
9import { getMyUserInformation } from '@shared/extra-utils/users/users'
10import { User, UserRole } from '@shared/models'
11import { getAccessToken } from '@shared/extra-utils/users/login'
9 12
10let configName = 'PeerTube/CLI' 13let configName = 'PeerTube/CLI'
11if (isTestInstance()) configName += `-${getAppNumber()}` 14if (isTestInstance()) configName += `-${getAppNumber()}`
@@ -14,6 +17,19 @@ const config = require('application-config')(configName)
14 17
15const version = require('../../../package.json').version 18const version = require('../../../package.json').version
16 19
20async function getAdminTokenOrDie (url: string, username: string, password: string) {
21 const accessToken = await getAccessToken(url, username, password)
22 const resMe = await getMyUserInformation(url, accessToken)
23 const me: User = resMe.body
24
25 if (me.role !== UserRole.ADMINISTRATOR) {
26 console.error('You must be an administrator.')
27 process.exit(-1)
28 }
29
30 return accessToken
31}
32
17interface Settings { 33interface Settings {
18 remotes: any[], 34 remotes: any[],
19 default: number 35 default: number
@@ -222,5 +238,7 @@ export {
222 getServerCredentials, 238 getServerCredentials,
223 239
224 buildCommonVideoOptions, 240 buildCommonVideoOptions,
225 buildVideoAttributesFromCommander 241 buildVideoAttributesFromCommander,
242
243 getAdminTokenOrDie
226} 244}
diff --git a/server/tools/package.json b/server/tools/package.json
index 40959d76e..06ad31cab 100644
--- a/server/tools/package.json
+++ b/server/tools/package.json
@@ -4,11 +4,12 @@
4 "private": true, 4 "private": true,
5 "dependencies": { 5 "dependencies": {
6 "application-config": "^1.0.1", 6 "application-config": "^1.0.1",
7 "cli-table": "^0.3.1", 7 "cli-table3": "^0.5.1",
8 "netrc-parser": "^3.1.6", 8 "netrc-parser": "^3.1.6",
9 "webtorrent-hybrid": "^4.0.1" 9 "webtorrent-hybrid": "^4.0.1"
10 }, 10 },
11 "summon": { 11 "summon": {
12 "silent": true 12 "silent": true
13 } 13 },
14 "devDependencies": {}
14} 15}
diff --git a/server/tools/peertube-auth.ts b/server/tools/peertube-auth.ts
index 6597a5c36..acac75034 100644
--- a/server/tools/peertube-auth.ts
+++ b/server/tools/peertube-auth.ts
@@ -6,8 +6,7 @@ import * as prompt from 'prompt'
6import { getNetrc, getSettings, writeSettings } from './cli' 6import { getNetrc, getSettings, writeSettings } from './cli'
7import { isUserUsernameValid } from '../helpers/custom-validators/users' 7import { isUserUsernameValid } from '../helpers/custom-validators/users'
8import { getAccessToken, login } from '../../shared/extra-utils' 8import { getAccessToken, login } from '../../shared/extra-utils'
9 9import * as CliTable3 from 'cli-table3'
10const Table = require('cli-table')
11 10
12async function delInstance (url: string) { 11async function delInstance (url: string) {
13 const [ settings, netrc ] = await Promise.all([ getSettings(), getNetrc() ]) 12 const [ settings, netrc ] = await Promise.all([ getSettings(), getNetrc() ])
@@ -108,10 +107,10 @@ program
108 .action(async () => { 107 .action(async () => {
109 const [ settings, netrc ] = await Promise.all([ getSettings(), getNetrc() ]) 108 const [ settings, netrc ] = await Promise.all([ getSettings(), getNetrc() ])
110 109
111 const table = new Table({ 110 const table = new CliTable3({
112 head: ['instance', 'login'], 111 head: ['instance', 'login'],
113 colWidths: [30, 30] 112 colWidths: [30, 30]
114 }) 113 }) as CliTable3.HorizontalTable
115 114
116 settings.remotes.forEach(element => { 115 settings.remotes.forEach(element => {
117 if (!netrc.machines[element]) return 116 if (!netrc.machines[element]) return
diff --git a/server/tools/peertube-import-videos.ts b/server/tools/peertube-import-videos.ts
index eaa792763..c7e85b570 100644
--- a/server/tools/peertube-import-videos.ts
+++ b/server/tools/peertube-import-videos.ts
@@ -1,10 +1,6 @@
1import { registerTSPaths } from '../helpers/register-ts-paths' 1import { registerTSPaths } from '../helpers/register-ts-paths'
2
3registerTSPaths() 2registerTSPaths()
4 3
5// FIXME: https://github.com/nodejs/node/pull/16853
6require('tls').DEFAULT_ECDH_CURVE = 'auto'
7
8import * as program from 'commander' 4import * as program from 'commander'
9import { join } from 'path' 5import { join } from 'path'
10import { doRequestAndSaveToFile } from '../helpers/requests' 6import { doRequestAndSaveToFile } from '../helpers/requests'
diff --git a/server/tools/peertube-plugins.ts b/server/tools/peertube-plugins.ts
index e40606107..b341c14c1 100644
--- a/server/tools/peertube-plugins.ts
+++ b/server/tools/peertube-plugins.ts
@@ -3,15 +3,11 @@ registerTSPaths()
3 3
4import * as program from 'commander' 4import * as program from 'commander'
5import { PluginType } from '../../shared/models/plugins/plugin.type' 5import { PluginType } from '../../shared/models/plugins/plugin.type'
6import { getAccessToken } from '../../shared/extra-utils/users/login'
7import { getMyUserInformation } from '../../shared/extra-utils/users/users'
8import { installPlugin, listPlugins, uninstallPlugin, updatePlugin } from '../../shared/extra-utils/server/plugins' 6import { installPlugin, listPlugins, uninstallPlugin, updatePlugin } from '../../shared/extra-utils/server/plugins'
9import { getServerCredentials } from './cli' 7import { getAdminTokenOrDie, getServerCredentials } from './cli'
10import { User, UserRole } from '../../shared/models/users'
11import { PeerTubePlugin } from '../../shared/models/plugins/peertube-plugin.model' 8import { PeerTubePlugin } from '../../shared/models/plugins/peertube-plugin.model'
12import { isAbsolute } from 'path' 9import { isAbsolute } from 'path'
13 10import * as CliTable3 from 'cli-table3'
14const Table = require('cli-table')
15 11
16program 12program
17 .name('plugins') 13 .name('plugins')
@@ -82,10 +78,10 @@ async function pluginsListCLI () {
82 }) 78 })
83 const plugins: PeerTubePlugin[] = res.body.data 79 const plugins: PeerTubePlugin[] = res.body.data
84 80
85 const table = new Table({ 81 const table = new CliTable3({
86 head: ['name', 'version', 'homepage'], 82 head: ['name', 'version', 'homepage'],
87 colWidths: [ 50, 10, 50 ] 83 colWidths: [ 50, 10, 50 ]
88 }) 84 }) as CliTable3.HorizontalTable
89 85
90 for (const plugin of plugins) { 86 for (const plugin of plugins) {
91 const npmName = plugin.type === PluginType.PLUGIN 87 const npmName = plugin.type === PluginType.PLUGIN
@@ -192,16 +188,3 @@ async function uninstallPluginCLI (options: any) {
192 console.log('Plugin uninstalled.') 188 console.log('Plugin uninstalled.')
193 process.exit(0) 189 process.exit(0)
194} 190}
195
196async function getAdminTokenOrDie (url: string, username: string, password: string) {
197 const accessToken = await getAccessToken(url, username, password)
198 const resMe = await getMyUserInformation(url, accessToken)
199 const me: User = resMe.body
200
201 if (me.role !== UserRole.ADMINISTRATOR) {
202 console.error('Cannot list plugins if you are not administrator.')
203 process.exit(-1)
204 }
205
206 return accessToken
207}
diff --git a/server/tools/peertube-redundancy.ts b/server/tools/peertube-redundancy.ts
new file mode 100644
index 000000000..a71f48104
--- /dev/null
+++ b/server/tools/peertube-redundancy.ts
@@ -0,0 +1,194 @@
1import { registerTSPaths } from '../helpers/register-ts-paths'
2registerTSPaths()
3
4import * as program from 'commander'
5import { getAdminTokenOrDie, getServerCredentials } from './cli'
6import { VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
7import { addVideoRedundancy, listVideoRedundancies, removeVideoRedundancy } from '@shared/extra-utils/server/redundancy'
8import validator from 'validator'
9import bytes = require('bytes')
10import * as CliTable3 from 'cli-table3'
11import { parse } from 'url'
12import { uniq } from 'lodash'
13
14program
15 .name('plugins')
16 .usage('[command] [options]')
17
18program
19 .command('list-remote-redundancies')
20 .description('List remote redundancies on your videos')
21 .option('-u, --url <url>', 'Server url')
22 .option('-U, --username <username>', 'Username')
23 .option('-p, --password <token>', 'Password')
24 .action(() => listRedundanciesCLI('my-videos'))
25
26program
27 .command('list-my-redundancies')
28 .description('List your redundancies of remote videos')
29 .option('-u, --url <url>', 'Server url')
30 .option('-U, --username <username>', 'Username')
31 .option('-p, --password <token>', 'Password')
32 .action(() => listRedundanciesCLI('remote-videos'))
33
34program
35 .command('add')
36 .description('Duplicate a video in your redundancy system')
37 .option('-u, --url <url>', 'Server url')
38 .option('-U, --username <username>', 'Username')
39 .option('-p, --password <token>', 'Password')
40 .option('-v, --video <videoId>', 'Video id to duplicate')
41 .action((options) => addRedundancyCLI(options))
42
43program
44 .command('remove')
45 .description('Remove a video from your redundancies')
46 .option('-u, --url <url>', 'Server url')
47 .option('-U, --username <username>', 'Username')
48 .option('-p, --password <token>', 'Password')
49 .option('-v, --video <videoId>', 'Video id to remove from redundancies')
50 .action((options) => removeRedundancyCLI(options))
51
52if (!process.argv.slice(2).length) {
53 program.outputHelp()
54}
55
56program.parse(process.argv)
57
58// ----------------------------------------------------------------------------
59
60async function listRedundanciesCLI (target: VideoRedundanciesTarget) {
61 const { url, username, password } = await getServerCredentials(program)
62 const accessToken = await getAdminTokenOrDie(url, username, password)
63
64 const redundancies = await listVideoRedundanciesData(url, accessToken, target)
65
66 const table = new CliTable3({
67 head: [ 'video id', 'video name', 'video url', 'files', 'playlists', 'by instances', 'total size' ]
68 }) as CliTable3.HorizontalTable
69
70 for (const redundancy of redundancies) {
71 const webtorrentFiles = redundancy.redundancies.files
72 const streamingPlaylists = redundancy.redundancies.streamingPlaylists
73
74 let totalSize = ''
75 if (target === 'remote-videos') {
76 const tmp = webtorrentFiles.concat(streamingPlaylists)
77 .reduce((a, b) => a + b.size, 0)
78
79 totalSize = bytes(tmp)
80 }
81
82 const instances = uniq(
83 webtorrentFiles.concat(streamingPlaylists)
84 .map(r => r.fileUrl)
85 .map(u => parse(u).host)
86 )
87
88 table.push([
89 redundancy.id.toString(),
90 redundancy.name,
91 redundancy.url,
92 webtorrentFiles.length,
93 streamingPlaylists.length,
94 instances.join('\n'),
95 totalSize
96 ])
97 }
98
99 console.log(table.toString())
100 process.exit(0)
101}
102
103async function addRedundancyCLI (options: { videoId: number }) {
104 const { url, username, password } = await getServerCredentials(program)
105 const accessToken = await getAdminTokenOrDie(url, username, password)
106
107 if (!options[ 'video' ] || validator.isInt('' + options[ 'video' ]) === false) {
108 console.error('You need to specify the video id to duplicate and it should be a number.\n')
109 program.outputHelp()
110 process.exit(-1)
111 }
112
113 try {
114 await addVideoRedundancy({
115 url,
116 accessToken,
117 videoId: options[ 'video' ]
118 })
119
120 console.log('Video will be duplicated by your instance!')
121
122 process.exit(0)
123 } catch (err) {
124 if (err.message.includes(409)) {
125 console.error('This video is already duplicated by your instance.')
126 } else if (err.message.includes(404)) {
127 console.error('This video id does not exist.')
128 } else {
129 console.error(err)
130 }
131
132 process.exit(-1)
133 }
134}
135
136async function removeRedundancyCLI (options: { videoId: number }) {
137 const { url, username, password } = await getServerCredentials(program)
138 const accessToken = await getAdminTokenOrDie(url, username, password)
139
140 if (!options[ 'video' ] || validator.isInt('' + options[ 'video' ]) === false) {
141 console.error('You need to specify the video id to remove from your redundancies.\n')
142 program.outputHelp()
143 process.exit(-1)
144 }
145
146 const videoId = parseInt(options[ 'video' ] + '', 10)
147
148 let redundancies = await listVideoRedundanciesData(url, accessToken, 'my-videos')
149 let videoRedundancy = redundancies.find(r => videoId === r.id)
150
151 if (!videoRedundancy) {
152 redundancies = await listVideoRedundanciesData(url, accessToken, 'remote-videos')
153 videoRedundancy = redundancies.find(r => videoId === r.id)
154 }
155
156 if (!videoRedundancy) {
157 console.error('Video redundancy not found.')
158 process.exit(-1)
159 }
160
161 try {
162 const ids = videoRedundancy.redundancies.files
163 .concat(videoRedundancy.redundancies.streamingPlaylists)
164 .map(r => r.id)
165
166 for (const id of ids) {
167 await removeVideoRedundancy({
168 url,
169 accessToken,
170 redundancyId: id
171 })
172 }
173
174 console.log('Video redundancy removed!')
175
176 process.exit(0)
177 } catch (err) {
178 console.error(err)
179 process.exit(-1)
180 }
181}
182
183async function listVideoRedundanciesData (url: string, accessToken: string, target: VideoRedundanciesTarget) {
184 const res = await listVideoRedundancies({
185 url,
186 accessToken,
187 start: 0,
188 count: 100,
189 sort: 'name',
190 target
191 })
192
193 return res.body.data as VideoRedundancy[]
194}
diff --git a/server/tools/peertube.ts b/server/tools/peertube.ts
index fc85c4210..9883bbf05 100644
--- a/server/tools/peertube.ts
+++ b/server/tools/peertube.ts
@@ -22,6 +22,7 @@ program
22 .command('watch', 'watch a video in the terminal ✩°。⋆').alias('w') 22 .command('watch', 'watch a video in the terminal ✩°。⋆').alias('w')
23 .command('repl', 'initiate a REPL to access internals') 23 .command('repl', 'initiate a REPL to access internals')
24 .command('plugins [action]', 'manage instance plugins/themes').alias('p') 24 .command('plugins [action]', 'manage instance plugins/themes').alias('p')
25 .command('redundancy [action]', 'manage instance redundancies').alias('r')
25 26
26/* Not Yet Implemented */ 27/* Not Yet Implemented */
27program 28program
diff --git a/server/tools/yarn.lock b/server/tools/yarn.lock
index 28756cbc2..ccd716a51 100644
--- a/server/tools/yarn.lock
+++ b/server/tools/yarn.lock
@@ -347,12 +347,15 @@ chunk-store-stream@^4.0.0:
347 block-stream2 "^2.0.0" 347 block-stream2 "^2.0.0"
348 readable-stream "^3.4.0" 348 readable-stream "^3.4.0"
349 349
350cli-table@^0.3.1: 350cli-table3@^0.5.1:
351 version "0.3.1" 351 version "0.5.1"
352 resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.1.tgz#f53b05266a8b1a0b934b3d0821e6e2dc5914ae23" 352 resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.5.1.tgz#0252372d94dfc40dbd8df06005f48f31f656f202"
353 integrity sha1-9TsFJmqLGguTSz0IIebi3FkUriM= 353 integrity sha512-7Qg2Jrep1S/+Q3EceiZtQcDPWxhAvBw+ERf1162v4sikJrvojMHFqXt8QIVha8UlH9rgU0BeWPytZ9/TzYqlUw==
354 dependencies: 354 dependencies:
355 colors "1.0.3" 355 object-assign "^4.1.0"
356 string-width "^2.1.1"
357 optionalDependencies:
358 colors "^1.1.2"
356 359
357clivas@^0.2.0: 360clivas@^0.2.0:
358 version "0.2.0" 361 version "0.2.0"
@@ -364,10 +367,10 @@ code-point-at@^1.0.0:
364 resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" 367 resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
365 integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= 368 integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
366 369
367colors@1.0.3: 370colors@^1.1.2:
368 version "1.0.3" 371 version "1.4.0"
369 resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" 372 resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"
370 integrity sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs= 373 integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==
371 374
372common-tags@^1.8.0: 375common-tags@^1.8.0:
373 version "1.8.0" 376 version "1.8.0"
@@ -1609,7 +1612,7 @@ string-width@^1.0.1:
1609 is-fullwidth-code-point "^1.0.0" 1612 is-fullwidth-code-point "^1.0.0"
1610 strip-ansi "^3.0.0" 1613 strip-ansi "^3.0.0"
1611 1614
1612"string-width@^1.0.2 || 2": 1615"string-width@^1.0.2 || 2", string-width@^2.1.1:
1613 version "2.1.1" 1616 version "2.1.1"
1614 resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" 1617 resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
1615 integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== 1618 integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==
diff --git a/server/typings/express.ts b/server/typings/express.ts
index 3cc7c7632..43a9b2c99 100644
--- a/server/typings/express.ts
+++ b/server/typings/express.ts
@@ -21,7 +21,7 @@ import {
21} from './models' 21} from './models'
22import { MVideoPlaylistFull, MVideoPlaylistFullSummary } from './models/video/video-playlist' 22import { MVideoPlaylistFull, MVideoPlaylistFullSummary } from './models/video/video-playlist'
23import { MVideoImportDefault } from '@server/typings/models/video/video-import' 23import { MVideoImportDefault } from '@server/typings/models/video/video-import'
24import { MAccountBlocklist, MStreamingPlaylist, MVideoFile } from '@server/typings/models' 24import { MAccountBlocklist, MActorUrl, MStreamingPlaylist, MVideoFile } from '@server/typings/models'
25import { MVideoPlaylistElement, MVideoPlaylistElementVideoUrlPlaylistPrivacy } from '@server/typings/models/video/video-playlist-element' 25import { MVideoPlaylistElement, MVideoPlaylistElementVideoUrlPlaylistPrivacy } from '@server/typings/models/video/video-playlist-element'
26import { MAccountVideoRateAccountVideo } from '@server/typings/models/video/video-rate' 26import { MAccountVideoRateAccountVideo } from '@server/typings/models/video/video-rate'
27import { MVideoChangeOwnershipFull } from './models/video/video-change-ownership' 27import { MVideoChangeOwnershipFull } from './models/video/video-change-ownership'
@@ -74,6 +74,7 @@ declare module 'express' {
74 74
75 account?: MAccountDefault 75 account?: MAccountDefault
76 76
77 actorUrl?: MActorUrl
77 actorFull?: MActorFull 78 actorFull?: MActorFull
78 79
79 user?: MUserDefault 80 user?: MUserDefault
diff --git a/server/typings/models/video/video-file.ts b/server/typings/models/video/video-file.ts
index 352fe3d32..139b22b2c 100644
--- a/server/typings/models/video/video-file.ts
+++ b/server/typings/models/video/video-file.ts
@@ -1,7 +1,7 @@
1import { VideoFileModel } from '../../../models/video/video-file' 1import { VideoFileModel } from '../../../models/video/video-file'
2import { PickWith, PickWithOpt } from '../../utils' 2import { PickWith, PickWithOpt } from '../../utils'
3import { MVideo, MVideoUUID } from './video' 3import { MVideo, MVideoUUID } from './video'
4import { MVideoRedundancyFileUrl } from './video-redundancy' 4import { MVideoRedundancy, MVideoRedundancyFileUrl } from './video-redundancy'
5import { MStreamingPlaylistVideo, MStreamingPlaylist } from './video-streaming-playlist' 5import { MStreamingPlaylistVideo, MStreamingPlaylist } from './video-streaming-playlist'
6 6
7type Use<K extends keyof VideoFileModel, M> = PickWith<VideoFileModel, K, M> 7type Use<K extends keyof VideoFileModel, M> = PickWith<VideoFileModel, K, M>
@@ -22,6 +22,9 @@ export type MVideoFileStreamingPlaylistVideo = MVideoFile &
22export type MVideoFileVideoUUID = MVideoFile & 22export type MVideoFileVideoUUID = MVideoFile &
23 Use<'Video', MVideoUUID> 23 Use<'Video', MVideoUUID>
24 24
25export type MVideoFileRedundanciesAll = MVideoFile &
26 PickWithOpt<VideoFileModel, 'RedundancyVideos', MVideoRedundancy[]>
27
25export type MVideoFileRedundanciesOpt = MVideoFile & 28export type MVideoFileRedundanciesOpt = MVideoFile &
26 PickWithOpt<VideoFileModel, 'RedundancyVideos', MVideoRedundancyFileUrl[]> 29 PickWithOpt<VideoFileModel, 'RedundancyVideos', MVideoRedundancyFileUrl[]>
27 30
diff --git a/server/typings/models/video/video-streaming-playlist.ts b/server/typings/models/video/video-streaming-playlist.ts
index 436c0c072..6fd489945 100644
--- a/server/typings/models/video/video-streaming-playlist.ts
+++ b/server/typings/models/video/video-streaming-playlist.ts
@@ -1,6 +1,6 @@
1import { VideoStreamingPlaylistModel } from '../../../models/video/video-streaming-playlist' 1import { VideoStreamingPlaylistModel } from '../../../models/video/video-streaming-playlist'
2import { PickWith, PickWithOpt } from '../../utils' 2import { PickWith, PickWithOpt } from '../../utils'
3import { MVideoRedundancyFileUrl } from './video-redundancy' 3import { MVideoRedundancyFileUrl, MVideoRedundancy } from './video-redundancy'
4import { MVideo } from './video' 4import { MVideo } from './video'
5import { MVideoFile } from './video-file' 5import { MVideoFile } from './video-file'
6 6
@@ -20,6 +20,10 @@ export type MStreamingPlaylistFilesVideo = MStreamingPlaylist &
20 Use<'VideoFiles', MVideoFile[]> & 20 Use<'VideoFiles', MVideoFile[]> &
21 Use<'Video', MVideo> 21 Use<'Video', MVideo>
22 22
23export type MStreamingPlaylistRedundanciesAll = MStreamingPlaylist &
24 Use<'VideoFiles', MVideoFile[]> &
25 Use<'RedundancyVideos', MVideoRedundancy[]>
26
23export type MStreamingPlaylistRedundancies = MStreamingPlaylist & 27export type MStreamingPlaylistRedundancies = MStreamingPlaylist &
24 Use<'VideoFiles', MVideoFile[]> & 28 Use<'VideoFiles', MVideoFile[]> &
25 Use<'RedundancyVideos', MVideoRedundancyFileUrl[]> 29 Use<'RedundancyVideos', MVideoRedundancyFileUrl[]>
diff --git a/server/typings/models/video/video.ts b/server/typings/models/video/video.ts
index 7f69a91de..bcc5e5028 100644
--- a/server/typings/models/video/video.ts
+++ b/server/typings/models/video/video.ts
@@ -10,8 +10,13 @@ import {
10} from './video-channels' 10} from './video-channels'
11import { MTag } from './tag' 11import { MTag } from './tag'
12import { MVideoCaptionLanguage } from './video-caption' 12import { MVideoCaptionLanguage } from './video-caption'
13import { MStreamingPlaylistFiles, MStreamingPlaylistRedundancies, MStreamingPlaylistRedundanciesOpt } from './video-streaming-playlist' 13import {
14import { MVideoFile, MVideoFileRedundanciesOpt } from './video-file' 14 MStreamingPlaylistFiles,
15 MStreamingPlaylistRedundancies,
16 MStreamingPlaylistRedundanciesAll,
17 MStreamingPlaylistRedundanciesOpt
18} from './video-streaming-playlist'
19import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file'
15import { MThumbnail } from './thumbnail' 20import { MThumbnail } from './thumbnail'
16import { MVideoBlacklist, MVideoBlacklistLight, MVideoBlacklistUnfederated } from './video-blacklist' 21import { MVideoBlacklist, MVideoBlacklistLight, MVideoBlacklistUnfederated } from './video-blacklist'
17import { MScheduleVideoUpdate } from './schedule-video-update' 22import { MScheduleVideoUpdate } from './schedule-video-update'
@@ -158,6 +163,10 @@ export type MVideoForUser = MVideo &
158 Use<'VideoBlacklist', MVideoBlacklistLight> & 163 Use<'VideoBlacklist', MVideoBlacklistLight> &
159 Use<'Thumbnails', MThumbnail[]> 164 Use<'Thumbnails', MThumbnail[]>
160 165
166export type MVideoForRedundancyAPI = MVideo &
167 Use<'VideoFiles', MVideoFileRedundanciesAll[]> &
168 Use<'VideoStreamingPlaylists', MStreamingPlaylistRedundanciesAll[]>
169
161// ############################################################################ 170// ############################################################################
162 171
163// Format for API or AP object 172// Format for API or AP object
diff --git a/shared/extra-utils/miscs/miscs.ts b/shared/extra-utils/miscs/miscs.ts
index 6b0f6d990..c957a6abe 100644
--- a/shared/extra-utils/miscs/miscs.ts
+++ b/shared/extra-utils/miscs/miscs.ts
@@ -104,6 +104,28 @@ async function generateHighBitrateVideo () {
104 return tempFixturePath 104 return tempFixturePath
105} 105}
106 106
107async function generateVideoWithFramerate (fps = 60) {
108 const tempFixturePath = buildAbsoluteFixturePath(`video_${fps}fps.mp4`, true)
109
110 await ensureDir(dirname(tempFixturePath))
111
112 const exists = await pathExists(tempFixturePath)
113 if (!exists) {
114 return new Promise<string>(async (res, rej) => {
115 ffmpeg()
116 .outputOptions([ '-f rawvideo', '-video_size 320x240', '-i /dev/urandom' ])
117 .outputOptions([ '-ac 2', '-f s16le', '-i /dev/urandom', '-t 10' ])
118 .outputOptions([ `-r ${fps}` ])
119 .output(tempFixturePath)
120 .on('error', rej)
121 .on('end', () => res(tempFixturePath))
122 .run()
123 })
124 }
125
126 return tempFixturePath
127}
128
107// --------------------------------------------------------------------------- 129// ---------------------------------------------------------------------------
108 130
109export { 131export {
@@ -115,5 +137,6 @@ export {
115 testImage, 137 testImage,
116 buildAbsoluteFixturePath, 138 buildAbsoluteFixturePath,
117 root, 139 root,
118 generateHighBitrateVideo 140 generateHighBitrateVideo,
141 generateVideoWithFramerate
119} 142}
diff --git a/shared/extra-utils/miscs/sql.ts b/shared/extra-utils/miscs/sql.ts
index 167649c6d..42599c20e 100644
--- a/shared/extra-utils/miscs/sql.ts
+++ b/shared/extra-utils/miscs/sql.ts
@@ -59,7 +59,6 @@ async function countVideoViewsOf (internalServerNumber: number, uuid: string) {
59 59
60 if (!total) return 0 60 if (!total) return 0
61 61
62 // FIXME: check if we really need parseInt
63 return parseInt(total + '', 10) 62 return parseInt(total + '', 10)
64} 63}
65 64
diff --git a/shared/extra-utils/server/redundancy.ts b/shared/extra-utils/server/redundancy.ts
index c39ff2c8b..7b488e23e 100644
--- a/shared/extra-utils/server/redundancy.ts
+++ b/shared/extra-utils/server/redundancy.ts
@@ -1,6 +1,7 @@
1import { makePutBodyRequest } from '../requests/requests' 1import { makeDeleteRequest, makeGetRequest, makePostBodyRequest, makePutBodyRequest } from '../requests/requests'
2import { VideoRedundanciesTarget } from '@shared/models'
2 3
3async function updateRedundancy (url: string, accessToken: string, host: string, redundancyAllowed: boolean, expectedStatus = 204) { 4function updateRedundancy (url: string, accessToken: string, host: string, redundancyAllowed: boolean, expectedStatus = 204) {
4 const path = '/api/v1/server/redundancy/' + host 5 const path = '/api/v1/server/redundancy/' + host
5 6
6 return makePutBodyRequest({ 7 return makePutBodyRequest({
@@ -12,6 +13,69 @@ async function updateRedundancy (url: string, accessToken: string, host: string,
12 }) 13 })
13} 14}
14 15
16function listVideoRedundancies (options: {
17 url: string
18 accessToken: string,
19 target: VideoRedundanciesTarget,
20 start?: number,
21 count?: number,
22 sort?: string,
23 statusCodeExpected?: number
24}) {
25 const path = '/api/v1/server/redundancy/videos'
26
27 const { url, accessToken, target, statusCodeExpected, start, count, sort } = options
28
29 return makeGetRequest({
30 url,
31 token: accessToken,
32 path,
33 query: {
34 start: start ?? 0,
35 count: count ?? 5,
36 sort: sort ?? 'name',
37 target
38 },
39 statusCodeExpected: statusCodeExpected || 200
40 })
41}
42
43function addVideoRedundancy (options: {
44 url: string,
45 accessToken: string,
46 videoId: number
47}) {
48 const path = '/api/v1/server/redundancy/videos'
49 const { url, accessToken, videoId } = options
50
51 return makePostBodyRequest({
52 url,
53 token: accessToken,
54 path,
55 fields: { videoId },
56 statusCodeExpected: 204
57 })
58}
59
60function removeVideoRedundancy (options: {
61 url: string,
62 accessToken: string,
63 redundancyId: number
64}) {
65 const { url, accessToken, redundancyId } = options
66 const path = '/api/v1/server/redundancy/videos/' + redundancyId
67
68 return makeDeleteRequest({
69 url,
70 token: accessToken,
71 path,
72 statusCodeExpected: 204
73 })
74}
75
15export { 76export {
16 updateRedundancy 77 updateRedundancy,
78 listVideoRedundancies,
79 addVideoRedundancy,
80 removeVideoRedundancy
17} 81}
diff --git a/shared/extra-utils/videos/videos.ts b/shared/extra-utils/videos/videos.ts
index 7a77a03ad..aa13273ae 100644
--- a/shared/extra-utils/videos/videos.ts
+++ b/shared/extra-utils/videos/videos.ts
@@ -607,15 +607,28 @@ async function videoUUIDToId (url: string, id: number | string) {
607 return res.body.id 607 return res.body.id
608} 608}
609 609
610async function uploadVideoAndGetId (options: { server: ServerInfo, videoName: string, nsfw?: boolean, token?: string }) { 610async function uploadVideoAndGetId (options: {
611 server: ServerInfo,
612 videoName: string,
613 nsfw?: boolean,
614 privacy?: VideoPrivacy,
615 token?: string
616}) {
611 const videoAttrs: any = { name: options.videoName } 617 const videoAttrs: any = { name: options.videoName }
612 if (options.nsfw) videoAttrs.nsfw = options.nsfw 618 if (options.nsfw) videoAttrs.nsfw = options.nsfw
619 if (options.privacy) videoAttrs.privacy = options.privacy
613 620
614 const res = await uploadVideo(options.server.url, options.token || options.server.accessToken, videoAttrs) 621 const res = await uploadVideo(options.server.url, options.token || options.server.accessToken, videoAttrs)
615 622
616 return { id: res.body.video.id, uuid: res.body.video.uuid } 623 return { id: res.body.video.id, uuid: res.body.video.uuid }
617} 624}
618 625
626async function getLocalIdByUUID (url: string, uuid: string) {
627 const res = await getVideo(url, uuid)
628
629 return res.body.id
630}
631
619// --------------------------------------------------------------------------- 632// ---------------------------------------------------------------------------
620 633
621export { 634export {
@@ -645,5 +658,6 @@ export {
645 completeVideoCheck, 658 completeVideoCheck,
646 checkVideoFilesWereRemoved, 659 checkVideoFilesWereRemoved,
647 getPlaylistVideos, 660 getPlaylistVideos,
648 uploadVideoAndGetId 661 uploadVideoAndGetId,
662 getLocalIdByUUID
649} 663}
diff --git a/shared/models/redundancy/index.ts b/shared/models/redundancy/index.ts
index 61bf0fca7..649cc489f 100644
--- a/shared/models/redundancy/index.ts
+++ b/shared/models/redundancy/index.ts
@@ -1 +1,3 @@
1export * from './videos-redundancy.model' 1export * from './videos-redundancy-strategy.model'
2export * from './video-redundancies-filters.model'
3export * from './video-redundancy.model'
diff --git a/shared/models/redundancy/video-redundancies-filters.model.ts b/shared/models/redundancy/video-redundancies-filters.model.ts
new file mode 100644
index 000000000..05ba7dfd3
--- /dev/null
+++ b/shared/models/redundancy/video-redundancies-filters.model.ts
@@ -0,0 +1 @@
export type VideoRedundanciesTarget = 'my-videos' | 'remote-videos'
diff --git a/shared/models/redundancy/video-redundancy.model.ts b/shared/models/redundancy/video-redundancy.model.ts
new file mode 100644
index 000000000..014f69634
--- /dev/null
+++ b/shared/models/redundancy/video-redundancy.model.ts
@@ -0,0 +1,33 @@
1export interface VideoRedundancy {
2 id: number
3 name: string
4 url: string
5 uuid: string
6
7 redundancies: {
8 files: FileRedundancyInformation[]
9
10 streamingPlaylists: StreamingPlaylistRedundancyInformation[]
11 }
12}
13
14interface RedundancyInformation {
15 id: number
16 fileUrl: string
17 strategy: string
18
19 createdAt: Date | string
20 updatedAt: Date | string
21
22 expiresOn: Date | string
23
24 size: number
25}
26
27export interface FileRedundancyInformation extends RedundancyInformation {
28
29}
30
31export interface StreamingPlaylistRedundancyInformation extends RedundancyInformation {
32
33}
diff --git a/shared/models/redundancy/videos-redundancy.model.ts b/shared/models/redundancy/videos-redundancy-strategy.model.ts
index a8c2743c1..15409abf0 100644
--- a/shared/models/redundancy/videos-redundancy.model.ts
+++ b/shared/models/redundancy/videos-redundancy-strategy.model.ts
@@ -1,4 +1,5 @@
1export type VideoRedundancyStrategy = 'most-views' | 'trending' | 'recently-added' 1export type VideoRedundancyStrategy = 'most-views' | 'trending' | 'recently-added'
2export type VideoRedundancyStrategyWithManual = VideoRedundancyStrategy | 'manual'
2 3
3export type MostViewsRedundancyStrategy = { 4export type MostViewsRedundancyStrategy = {
4 strategy: 'most-views' 5 strategy: 'most-views'
@@ -19,4 +20,4 @@ export type RecentlyAddedStrategy = {
19 minLifetime: number 20 minLifetime: number
20} 21}
21 22
22export type VideosRedundancy = MostViewsRedundancyStrategy | TrendingRedundancyStrategy | RecentlyAddedStrategy 23export type VideosRedundancyStrategy = MostViewsRedundancyStrategy | TrendingRedundancyStrategy | RecentlyAddedStrategy
diff --git a/shared/models/server/job.model.ts b/shared/models/server/job.model.ts
index b82a633b2..19fd4c659 100644
--- a/shared/models/server/job.model.ts
+++ b/shared/models/server/job.model.ts
@@ -9,7 +9,8 @@ export type JobType = 'activitypub-http-unicast' |
9 'email' | 9 'email' |
10 'video-import' | 10 'video-import' |
11 'videos-views' | 11 'videos-views' |
12 'activitypub-refresher' 12 'activitypub-refresher' |
13 'video-redundancy'
13 14
14export interface Job { 15export interface Job {
15 id: number 16 id: number
diff --git a/shared/models/server/server-stats.model.ts b/shared/models/server/server-stats.model.ts
index 74f3de5d3..11778e6ed 100644
--- a/shared/models/server/server-stats.model.ts
+++ b/shared/models/server/server-stats.model.ts
@@ -1,4 +1,4 @@
1import { VideoRedundancyStrategy } from '../redundancy' 1import { VideoRedundancyStrategyWithManual } from '../redundancy'
2 2
3export interface ServerStats { 3export interface ServerStats {
4 totalUsers: number 4 totalUsers: number
@@ -13,11 +13,13 @@ export interface ServerStats {
13 totalInstanceFollowers: number 13 totalInstanceFollowers: number
14 totalInstanceFollowing: number 14 totalInstanceFollowing: number
15 15
16 videosRedundancy: { 16 videosRedundancy: VideosRedundancyStats[]
17 strategy: VideoRedundancyStrategy 17}
18 totalSize: number 18
19 totalUsed: number 19export interface VideosRedundancyStats {
20 totalVideoFiles: number 20 strategy: VideoRedundancyStrategyWithManual
21 totalVideos: number 21 totalSize: number
22 }[] 22 totalUsed: number
23 totalVideoFiles: number
24 totalVideos: number
23} 25}
diff --git a/shared/models/users/user-right.enum.ts b/shared/models/users/user-right.enum.ts
index 4a28a229d..2f88a65de 100644
--- a/shared/models/users/user-right.enum.ts
+++ b/shared/models/users/user-right.enum.ts
@@ -33,5 +33,7 @@ export enum UserRight {
33 SEE_ALL_VIDEOS, 33 SEE_ALL_VIDEOS,
34 CHANGE_VIDEO_OWNERSHIP, 34 CHANGE_VIDEO_OWNERSHIP,
35 35
36 MANAGE_PLUGINS 36 MANAGE_PLUGINS,
37
38 MANAGE_VIDEOS_REDUNDANCIES
37} 39}
diff --git a/shared/models/videos/video-transcoding-fps.model.ts b/shared/models/videos/video-transcoding-fps.model.ts
index 82022d2f1..25fc1c2da 100644
--- a/shared/models/videos/video-transcoding-fps.model.ts
+++ b/shared/models/videos/video-transcoding-fps.model.ts
@@ -1,6 +1,8 @@
1export type VideoTranscodingFPS = { 1export type VideoTranscodingFPS = {
2 MIN: number, 2 MIN: number
3 AVERAGE: number, 3 STANDARD: number[]
4 MAX: number, 4 HD_STANDARD: number[]
5 AVERAGE: number
6 MAX: number
5 KEEP_ORIGIN_FPS_RESOLUTION_MIN: number 7 KEEP_ORIGIN_FPS_RESOLUTION_MIN: number
6} 8}
diff --git a/shared/models/videos/video.model.ts b/shared/models/videos/video.model.ts
index 7576439fe..a69152759 100644
--- a/shared/models/videos/video.model.ts
+++ b/shared/models/videos/video.model.ts
@@ -1,4 +1,4 @@
1import { AccountSummary, VideoChannelSummary, VideoResolution, VideoState } from '../../index' 1import { AccountSummary, VideoChannelSummary, VideoState } from '../../index'
2import { Account } from '../actors' 2import { Account } from '../actors'
3import { VideoChannel } from './channel/video-channel.model' 3import { VideoChannel } from './channel/video-channel.model'
4import { VideoPrivacy } from './video-privacy.enum' 4import { VideoPrivacy } from './video-privacy.enum'
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index 43718e2a1..907187e4c 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -977,6 +977,12 @@ paths:
977 application/json: 977 application/json:
978 schema: 978 schema:
979 $ref: '#/components/schemas/VideoUploadResponse' 979 $ref: '#/components/schemas/VideoUploadResponse'
980 '403':
981 description: 'The user video quota is exceeded with this video.'
982 '408':
983 description: 'Upload has timed out'
984 '422':
985 description: 'Invalid input file.'
980 requestBody: 986 requestBody:
981 content: 987 content:
982 multipart/form-data: 988 multipart/form-data:
diff --git a/support/doc/production.md b/support/doc/production.md
index 8f061f868..6febaba5d 100644
--- a/support/doc/production.md
+++ b/support/doc/production.md
@@ -39,7 +39,7 @@ Create the production database and a peertube user inside PostgreSQL:
39 39
40``` 40```
41$ sudo -u postgres createuser -P peertube 41$ sudo -u postgres createuser -P peertube
42$ sudo -u postgres createdb -O peertube peertube_prod 42$ sudo -u postgres createdb -O peertube -E UTF8 -T template0 peertube_prod
43``` 43```
44 44
45Then enable extensions PeerTube needs: 45Then enable extensions PeerTube needs:
diff --git a/support/doc/tools.md b/support/doc/tools.md
index d5427b5b7..1f1e52c36 100644
--- a/support/doc/tools.md
+++ b/support/doc/tools.md
@@ -12,6 +12,7 @@
12 - [peertube-upload.js](#peertube-uploadjs) 12 - [peertube-upload.js](#peertube-uploadjs)
13 - [peertube-watch.js](#peertube-watchjs) 13 - [peertube-watch.js](#peertube-watchjs)
14 - [peertube-plugins.js](#peertube-pluginsjs) 14 - [peertube-plugins.js](#peertube-pluginsjs)
15 - [peertube-redundancy.js](#peertube-redundancyjs)
15- [Server tools](#server-tools) 16- [Server tools](#server-tools)
16 - [parse-log](#parse-log) 17 - [parse-log](#parse-log)
17 - [create-transcoding-job.js](#create-transcoding-jobjs) 18 - [create-transcoding-job.js](#create-transcoding-jobjs)
@@ -77,7 +78,8 @@ You can access it as `peertube` via an alias in your `.bashrc` like `alias peert
77 import-videos|import import a video from a streaming platform 78 import-videos|import import a video from a streaming platform
78 watch|w watch a video in the terminal ✩°。⋆ 79 watch|w watch a video in the terminal ✩°。⋆
79 repl initiate a REPL to access internals 80 repl initiate a REPL to access internals
80 plugins|p [action] manag instance plugins 81 plugins|p [action] manage instance plugins
82 redundancy|r [action] manage video redundancies
81 help [cmd] display help for [cmd] 83 help [cmd] display help for [cmd]
82``` 84```
83 85
@@ -200,6 +202,34 @@ $ node dist/server/tools/peertube-plugins.js install --path /my/plugin/path
200$ node dist/server/tools/peertube-plugins.js install --npm-name peertube-theme-example 202$ node dist/server/tools/peertube-plugins.js install --npm-name peertube-theme-example
201``` 203```
202 204
205#### peertube-redundancy.js
206
207Manage (list/add/remove) video redundancies:
208
209To list your videos that are duplicated by remote instances:
210
211```
212$ node dist/server/tools/peertube.js redundancy list-remote-redundancies
213```
214
215To list remote videos that your instance duplicated:
216
217```
218$ node dist/server/tools/peertube.js redundancy list-my-redundancies
219```
220
221To duplicate a specific video in your redundancy system:
222
223```
224$ node dist/server/tools/peertube.js redundancy add --video 823
225```
226
227To remove a video redundancy:
228
229```
230$ node dist/server/tools/peertube.js redundancy remove --video 823
231```
232
203## Server tools 233## Server tools
204 234
205These scripts should be run on the server, in `peertube-latest` directory. 235These scripts should be run on the server, in `peertube-latest` directory.