Add ability to list redundancies
authorChocobozzz <me@florianbigard.com>
Fri, 10 Jan 2020 09:11:28 +0000 (10:11 +0100)
committerChocobozzz <chocobozzz@cpy.re>
Tue, 28 Jan 2020 10:35:26 +0000 (11:35 +0100)
65 files changed:
client/package.json
client/src/app/+admin/admin.component.html
client/src/app/+admin/admin.module.ts
client/src/app/+admin/follows/follows.component.html
client/src/app/+admin/follows/follows.routes.ts
client/src/app/+admin/follows/index.ts
client/src/app/+admin/follows/shared/redundancy-checkbox.component.ts
client/src/app/+admin/follows/shared/redundancy.service.ts [deleted file]
client/src/app/+admin/follows/video-redundancies-list/index.ts [new file with mode: 0644]
client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.html [new file with mode: 0644]
client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.scss [new file with mode: 0644]
client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts [new file with mode: 0644]
client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.html [new file with mode: 0644]
client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.scss [new file with mode: 0644]
client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.ts [new file with mode: 0644]
client/src/app/+admin/system/jobs/jobs.component.ts
client/src/app/core/server/server.service.ts
client/src/app/shared/images/global-icon.component.ts
client/src/app/shared/instance/instance-statistics.component.ts
client/src/app/shared/shared.module.ts
client/src/app/shared/video/redundancy.service.ts [new file with mode: 0644]
client/src/app/shared/video/video-actions-dropdown.component.ts
client/src/app/shared/video/video-miniature.component.ts
client/src/app/shared/video/video.model.ts
client/yarn.lock
config/test.yaml
package.json
scripts/dev/index.sh
server/controllers/api/server/follows.ts
server/controllers/api/server/redundancy.ts
server/controllers/api/server/stats.ts
server/helpers/custom-validators/activitypub/cache-file.ts
server/helpers/custom-validators/video-redundancies.ts [new file with mode: 0644]
server/helpers/webtorrent.ts
server/initializers/config.ts
server/initializers/constants.ts
server/initializers/migrations/0475-redundancy-expires-on.ts [new file with mode: 0644]
server/lib/activitypub/cache-file.ts
server/lib/job-queue/handlers/video-redundancy.ts [new file with mode: 0644]
server/lib/job-queue/job-queue.ts
server/lib/redundancy.ts
server/lib/schedulers/update-videos-scheduler.ts
server/lib/schedulers/videos-redundancy-scheduler.ts
server/middlewares/sort.ts
server/middlewares/validators/redundancy.ts
server/middlewares/validators/sort.ts
server/models/redundancy/video-redundancy.ts
server/tests/api/check-params/redundancy.ts
server/tests/api/redundancy/index.ts
server/tests/api/redundancy/manage-redundancy.ts [new file with mode: 0644]
server/tests/api/redundancy/redundancy.ts
server/typings/models/video/video-file.ts
server/typings/models/video/video-streaming-playlist.ts
server/typings/models/video/video.ts
shared/extra-utils/server/redundancy.ts
shared/extra-utils/videos/videos.ts
shared/models/redundancy/index.ts
shared/models/redundancy/video-redundancies-filters.model.ts [new file with mode: 0644]
shared/models/redundancy/video-redundancy.model.ts [new file with mode: 0644]
shared/models/redundancy/videos-redundancy-strategy.model.ts [new file with mode: 0644]
shared/models/redundancy/videos-redundancy.model.ts [deleted file]
shared/models/server/job.model.ts
shared/models/server/server-stats.model.ts
shared/models/users/user-right.enum.ts
shared/models/videos/video.model.ts

index cd0a82aa4f2cafd2b9560a9a52af0936ab32841a..7205dbe8f4f77f4fdf598dc65c491b63663d439e 100644 (file)
@@ -77,6 +77,7 @@
     "bootstrap": "^4.1.3",
     "buffer": "^5.1.0",
     "cache-chunk-store": "^3.0.0",
+    "chart.js": "^2.9.3",
     "codelyzer": "^5.0.1",
     "core-js": "^3.1.4",
     "css-loader": "^3.1.0",
index 9a3d90c183911df6a0a4d968abdbe951bc9ad46f..0d06aaedc029ace88bc913e414ea42eba9e6f239 100644 (file)
@@ -5,7 +5,7 @@
     </a>
 
     <a i18n *ngIf="hasServerFollowRight()" routerLink="/admin/follows" routerLinkActive="active" class="title-page">
-      Manage follows
+      Follows & redundancies
     </a>
 
     <a i18n *ngIf="hasVideoAbusesRight() || hasVideoBlacklistRight()" routerLink="/admin/moderation" routerLinkActive="active" class="title-page">
index 9c56b575031319a8bf20a2d90891a804e50e2e4d..fdbe70314e0ded871110195eedd92ce696977ace 100644 (file)
@@ -5,7 +5,7 @@ import { TableModule } from 'primeng/table'
 import { SharedModule } from '../shared'
 import { AdminRoutingModule } from './admin-routing.module'
 import { AdminComponent } from './admin.component'
-import { FollowersListComponent, FollowingAddComponent, FollowsComponent } from './follows'
+import { FollowersListComponent, FollowingAddComponent, FollowsComponent, VideoRedundanciesListComponent } from './follows'
 import { FollowingListComponent } from './follows/following-list/following-list.component'
 import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersComponent, UserUpdateComponent } from './users'
 import {
@@ -16,7 +16,6 @@ import {
 } from './moderation'
 import { ModerationComponent } from '@app/+admin/moderation/moderation.component'
 import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component'
-import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service'
 import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist'
 import { JobsComponent } from '@app/+admin/system/jobs/jobs.component'
 import { JobService, LogsComponent, LogsService, SystemComponent } from '@app/+admin/system'
@@ -27,13 +26,18 @@ import { PluginSearchComponent } from '@app/+admin/plugins/plugin-search/plugin-
 import { PluginShowInstalledComponent } from '@app/+admin/plugins/plugin-show-installed/plugin-show-installed.component'
 import { SelectButtonModule } from 'primeng/selectbutton'
 import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
+import { VideoRedundancyInformationComponent } from '@app/+admin/follows/video-redundancies-list/video-redundancy-information.component'
+import { ChartModule } from 'primeng/chart'
 
 @NgModule({
   imports: [
     AdminRoutingModule,
+
+    SharedModule,
+
     TableModule,
     SelectButtonModule,
-    SharedModule
+    ChartModule
   ],
 
   declarations: [
@@ -44,6 +48,8 @@ import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
     FollowersListComponent,
     FollowingListComponent,
     RedundancyCheckboxComponent,
+    VideoRedundanciesListComponent,
+    VideoRedundancyInformationComponent,
 
     UsersComponent,
     UserCreateComponent,
@@ -78,7 +84,6 @@ import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
   ],
 
   providers: [
-    RedundancyService,
     JobService,
     LogsService,
     DebugService,
index 21d47713210c2a53454cba6ee179cb9c71ba1c17..46581daf9832a1f57a729b8ac0873416b9c43f11 100644 (file)
@@ -1,5 +1,5 @@
 <div class="admin-sub-header">
-  <div i18n class="form-sub-title">Manage follows</div>
+  <div i18n class="form-sub-title">Follows & redundancies</div>
 
   <div class="admin-sub-nav">
     <a i18n routerLink="following-list" routerLinkActive="active">Following</a>
@@ -7,7 +7,9 @@
     <a i18n routerLink="following-add" routerLinkActive="active">Follow</a>
 
     <a i18n routerLink="followers-list" routerLinkActive="active">Followers</a>
+
+    <a i18n routerLink="video-redundancies-list" routerLinkActive="active">Video redundancies</a>
   </div>
 </div>
 
-<router-outlet></router-outlet>
\ No newline at end of file
+<router-outlet></router-outlet>
index e84c79e829ad3300f1bfc451492000d00e29c3c6..298733eb03559fad776ef150c6ac11da65c9b19a 100644 (file)
@@ -6,6 +6,7 @@ import { FollowingAddComponent } from './following-add'
 import { FollowersListComponent } from './followers-list'
 import { UserRight } from '../../../../../shared'
 import { FollowingListComponent } from './following-list/following-list.component'
+import { VideoRedundanciesListComponent } from '@app/+admin/follows/video-redundancies-list'
 
 export const FollowsRoutes: Routes = [
   {
@@ -47,6 +48,10 @@ export const FollowsRoutes: Routes = [
             title: 'Add follow'
           }
         }
+      },
+      {
+        path: 'video-redundancies-list',
+        component: VideoRedundanciesListComponent
       }
     ]
   }
index e94f33710513285583899589d824c7d7f9200d7c..4fcb35cb1d89ff08fd9fcf8add611a5b05c934a7 100644 (file)
@@ -1,5 +1,6 @@
 export * from './following-add'
 export * from './followers-list'
 export * from './following-list'
+export * from './video-redundancies-list'
 export * from './follows.component'
 export * from './follows.routes'
index fa1da26bfccdb93e1ede0845250726bf49afdc14..9d7883d97ca8aded1331f73a4a9eef596ac9c432 100644 (file)
@@ -1,7 +1,7 @@
 import { Component, Input } from '@angular/core'
 import { Notifier } from '@app/core'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service'
+import { RedundancyService } from '@app/shared/video/redundancy.service'
 
 @Component({
   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 (file)
index 87ae01c..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-import { catchError, map } from 'rxjs/operators'
-import { HttpClient } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { RestExtractor } from '@app/shared'
-import { environment } from '../../../../environments/environment'
-
-@Injectable()
-export class RedundancyService {
-  static BASE_USER_SUBSCRIPTIONS_URL = environment.apiUrl + '/api/v1/server/redundancy'
-
-  constructor (
-    private authHttp: HttpClient,
-    private restExtractor: RestExtractor
-  ) { }
-
-  updateRedundancy (host: string, redundancyAllowed: boolean) {
-    const url = RedundancyService.BASE_USER_SUBSCRIPTIONS_URL + '/' + host
-
-    const body = { redundancyAllowed }
-
-    return this.authHttp.put(url, body)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-}
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 (file)
index 0000000..6a7c7f4
--- /dev/null
@@ -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 (file)
index 0000000..80c66ec
--- /dev/null
@@ -0,0 +1,82 @@
+<div class="admin-sub-header">
+  <div i18n class="form-sub-title">Video redundancies list</div>
+
+  <div class="select-filter-block">
+    <label for="displayType" i18n>Display</label>
+
+    <div class="peertube-select-container">
+      <select id="displayType" name="displayType" [(ngModel)]="displayType" (ngModelChange)="onDisplayTypeChanged()">
+        <option value="my-videos">My videos duplicated by remote instances</option>
+        <option value="remote-videos">Remote videos duplicated by my instance</option>
+      </select>
+    </div>
+  </div>
+</div>
+
+<p-table
+  [value]="videoRedundancies" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage"
+  [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id"
+>
+  <ng-template pTemplate="header">
+    <tr>
+      <th i18n *ngIf="isDisplayingRemoteVideos()">Strategy</th>
+      <th i18n pSortableColumn="name">Video name <p-sortIcon field="name"></p-sortIcon></th>
+      <th i18n>Video URL</th>
+      <th i18n *ngIf="isDisplayingRemoteVideos()">Total size</th>
+      <th></th>
+    </tr>
+  </ng-template>
+
+  <ng-template pTemplate="body" let-redundancy>
+    <tr class="expander" [pRowToggler]="redundancy">
+      <td *ngIf="isDisplayingRemoteVideos()">{{ getRedundancyStrategy(redundancy) }}</td>
+
+      <td>{{ redundancy.name }}</td>
+
+      <td>
+        <a target="_blank" rel="noopener noreferrer" [href]="redundancy.url">{{ redundancy.url }}</a>
+      </td>
+
+      <td *ngIf="isDisplayingRemoteVideos()">{{ getTotalSize(redundancy) | bytes: 1 }}</td>
+
+      <td class="action-cell">
+        <my-delete-button (click)="removeRedundancy(redundancy)"></my-delete-button>
+      </td>
+    </tr>
+  </ng-template>
+
+  <ng-template pTemplate="rowexpansion" let-redundancy>
+    <tr>
+      <td colspan="2">
+        <div *ngFor="let file of redundancy.redundancies.files" class="expansion-block">
+          <my-video-redundancy-information [redundancyElement]="file"></my-video-redundancy-information>
+        </div>
+      </td>
+    </tr>
+
+    <tr>
+      <td colspan="2">
+        <div *ngFor="let playlist of redundancy.redundancies.streamingPlaylists">
+          <my-video-redundancy-information [redundancyElement]="playlist"></my-video-redundancy-information>
+        </div>
+      </td>
+    </tr>
+  </ng-template>
+</p-table>
+
+
+<div class="redundancies-charts" *ngIf="isDisplayingRemoteVideos()">
+  <div class="form-sub-title" i18n>Enabled strategies stats</div>
+
+  <div class="chart-blocks">
+
+    <div *ngIf="noRedundancies" i18n class="no-results">
+      No redundancy strategy is enabled on your instance.
+    </div>
+
+    <div class="chart-block" *ngFor="let r of redundanciesGraphsData">
+      <p-chart type="pie" [data]="r.graphData" [options]="r.options" width="300px" height="300px"></p-chart>
+    </div>
+
+  </div>
+</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 (file)
index 0000000..05018c2
--- /dev/null
@@ -0,0 +1,37 @@
+@import '_variables';
+@import '_mixins';
+
+.expansion-block {
+  margin-bottom: 20px;
+}
+
+.admin-sub-header {
+  align-items: flex-end;
+
+  .select-filter-block {
+    &:not(:last-child) {
+      margin-right: 10px;
+    }
+
+    label {
+      margin-bottom: 2px;
+    }
+
+    .peertube-select-container {
+      @include peertube-select-container(auto);
+    }
+  }
+}
+
+.redundancies-charts {
+  margin-top: 50px;
+
+  .chart-blocks {
+    display: flex;
+    justify-content: center;
+
+    .chart-block {
+      margin: 0 20px;
+    }
+  }
+}
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 (file)
index 0000000..4b41d1d
--- /dev/null
@@ -0,0 +1,178 @@
+import { Component, OnInit } from '@angular/core'
+import { Notifier, ServerService } from '@app/core'
+import { SortMeta } from 'primeng/api'
+import { ConfirmService } from '../../../core/confirm/confirm.service'
+import { RestPagination, RestTable } from '../../../shared'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
+import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
+import { VideosRedundancyStats } from '@shared/models/server'
+import { BytesPipe } from 'ngx-pipes'
+import { RedundancyService } from '@app/shared/video/redundancy.service'
+
+@Component({
+  selector: 'my-video-redundancies-list',
+  templateUrl: './video-redundancies-list.component.html',
+  styleUrls: [ './video-redundancies-list.component.scss' ]
+})
+export class VideoRedundanciesListComponent extends RestTable implements OnInit {
+  private static LOCAL_STORAGE_DISPLAY_TYPE = 'video-redundancies-list-display-type'
+
+  videoRedundancies: VideoRedundancy[] = []
+  totalRecords = 0
+  rowsPerPage = 10
+
+  sort: SortMeta = { field: 'name', order: 1 }
+  pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
+  displayType: VideoRedundanciesTarget = 'my-videos'
+
+  redundanciesGraphsData: { stats: VideosRedundancyStats, graphData: object, options: object }[] = []
+
+  noRedundancies = false
+
+  private bytesPipe: BytesPipe
+
+  constructor (
+    private notifier: Notifier,
+    private confirmService: ConfirmService,
+    private redundancyService: RedundancyService,
+    private serverService: ServerService,
+    private i18n: I18n
+  ) {
+    super()
+
+    this.bytesPipe = new BytesPipe()
+  }
+
+  ngOnInit () {
+    this.loadSelectLocalStorage()
+
+    this.initialize()
+
+    this.serverService.getServerStats()
+        .subscribe(res => {
+          const redundancies = res.videosRedundancy
+
+          if (redundancies.length === 0) this.noRedundancies = true
+
+          for (const r of redundancies) {
+            this.buildPieData(r)
+          }
+        })
+  }
+
+  isDisplayingRemoteVideos () {
+    return this.displayType === 'remote-videos'
+  }
+
+  getTotalSize (redundancy: VideoRedundancy) {
+    return redundancy.redundancies.files.reduce((a, b) => a + b.size, 0) +
+      redundancy.redundancies.streamingPlaylists.reduce((a, b) => a + b.size, 0)
+  }
+
+  onDisplayTypeChanged () {
+    this.pagination.start = 0
+    this.saveSelectLocalStorage()
+
+    this.loadData()
+  }
+
+  getRedundancyStrategy (redundancy: VideoRedundancy) {
+    if (redundancy.redundancies.files.length !== 0) return redundancy.redundancies.files[0].strategy
+    if (redundancy.redundancies.streamingPlaylists.length !== 0) return redundancy.redundancies.streamingPlaylists[0].strategy
+
+    return ''
+  }
+
+  buildPieData (stats: VideosRedundancyStats) {
+    const totalSize = stats.totalSize
+      ? stats.totalSize - stats.totalUsed
+      : stats.totalUsed
+
+    if (totalSize === 0) return
+
+    this.redundanciesGraphsData.push({
+      stats,
+      graphData: {
+        labels: [ this.i18n('Used'), this.i18n('Available') ],
+        datasets: [
+          {
+            data: [ stats.totalUsed, totalSize ],
+            backgroundColor: [
+              '#FF6384',
+              '#36A2EB'
+            ],
+            hoverBackgroundColor: [
+              '#FF6384',
+              '#36A2EB'
+            ]
+          }
+        ]
+      },
+      options: {
+        title: {
+          display: true,
+          text: stats.strategy
+        },
+
+        tooltips: {
+          callbacks: {
+            label: (tooltipItem: any, data: any) => {
+              const dataset = data.datasets[tooltipItem.datasetIndex]
+              let label = data.labels[tooltipItem.index]
+              if (label) label += ': '
+              else label = ''
+
+              label += this.bytesPipe.transform(dataset.data[tooltipItem.index], 1)
+              return label
+            }
+          }
+        }
+      }
+    })
+  }
+
+  async removeRedundancy (redundancy: VideoRedundancy) {
+    const message = this.i18n('Do you really want to remove this video redundancy?')
+    const res = await this.confirmService.confirm(message, this.i18n('Remove redundancy'))
+    if (res === false) return
+
+    this.redundancyService.removeVideoRedundancies(redundancy)
+      .subscribe(
+        () => {
+          this.notifier.success(this.i18n('Video redundancies removed!'))
+          this.loadData()
+        },
+
+        err => this.notifier.error(err.message)
+      )
+
+  }
+
+  protected loadData () {
+    const options = {
+      pagination: this.pagination,
+      sort: this.sort,
+      target: this.displayType
+    }
+
+    this.redundancyService.listVideoRedundancies(options)
+                      .subscribe(
+                        resultList => {
+                          this.videoRedundancies = resultList.data
+                          this.totalRecords = resultList.total
+                        },
+
+                        err => this.notifier.error(err.message)
+                      )
+  }
+
+  private loadSelectLocalStorage () {
+    const displayType = peertubeLocalStorage.getItem(VideoRedundanciesListComponent.LOCAL_STORAGE_DISPLAY_TYPE)
+    if (displayType) this.displayType = displayType as VideoRedundanciesTarget
+  }
+
+  private saveSelectLocalStorage () {
+    peertubeLocalStorage.setItem(VideoRedundanciesListComponent.LOCAL_STORAGE_DISPLAY_TYPE, this.displayType)
+  }
+}
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 (file)
index 0000000..a379520
--- /dev/null
@@ -0,0 +1,24 @@
+<div>
+  <span class="label">Url</span>
+  <a target="_blank" rel="noopener noreferrer" [href]="redundancyElement.fileUrl">{{ redundancyElement.fileUrl }}</a>
+</div>
+
+<div>
+  <span class="label">Created on</span>
+  <span>{{ redundancyElement.createdAt | date: 'medium' }}</span>
+</div>
+
+<div>
+  <span class="label">Expires on</span>
+  <span>{{ redundancyElement.expiresOn | date: 'medium' }}</span>
+</div>
+
+<div>
+  <span class="label">Size</span>
+  <span>{{ redundancyElement.size | bytes: 1 }}</span>
+</div>
+
+<div *ngIf="redundancyElement.strategy">
+  <span class="label">Strategy</span>
+  <span>{{ redundancyElement.strategy }}</span>
+</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 (file)
index 0000000..6b09fbb
--- /dev/null
@@ -0,0 +1,8 @@
+@import '_variables';
+@import '_mixins';
+
+.label {
+  display: inline-block;
+  min-width: 100px;
+  font-weight: $font-semibold;
+}
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 (file)
index 0000000..6f3090c
--- /dev/null
@@ -0,0 +1,11 @@
+import { Component, Input } from '@angular/core'
+import { FileRedundancyInformation, StreamingPlaylistRedundancyInformation } from '@shared/models'
+
+@Component({
+  selector: 'my-video-redundancy-information',
+  templateUrl: './video-redundancy-information.component.html',
+  styleUrls: [ './video-redundancy-information.component.scss' ]
+})
+export class VideoRedundancyInformationComponent {
+  @Input() redundancyElement: FileRedundancyInformation | StreamingPlaylistRedundancyInformation
+}
index 20c8ea71a0da906af1c99a68e583265e214b959e..bc40452cf8b231b0dcfa6c62d083b56f86f0879e 100644 (file)
@@ -16,8 +16,8 @@ import { JobTypeClient } from '../../../../types/job-type-client.type'
   styleUrls: [ './jobs.component.scss' ]
 })
 export class JobsComponent extends RestTable implements OnInit {
-  private static JOB_STATE_LOCAL_STORAGE_STATE = 'jobs-list-state'
-  private static JOB_STATE_LOCAL_STORAGE_TYPE = 'jobs-list-type'
+  private static LOCAL_STORAGE_STATE = 'jobs-list-state'
+  private static LOCAL_STORAGE_TYPE = 'jobs-list-type'
 
   jobState: JobStateClient = 'waiting'
   jobStates: JobStateClient[] = [ 'active', 'completed', 'failed', 'waiting', 'delayed' ]
@@ -34,7 +34,8 @@ export class JobsComponent extends RestTable implements OnInit {
     'video-file-import',
     'video-import',
     'videos-views',
-    'activitypub-refresher'
+    'activitypub-refresher',
+    'video-redundancy'
   ]
 
   jobs: Job[] = []
@@ -77,15 +78,15 @@ export class JobsComponent extends RestTable implements OnInit {
   }
 
   private loadJobStateAndType () {
-    const state = peertubeLocalStorage.getItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_STATE)
+    const state = peertubeLocalStorage.getItem(JobsComponent.LOCAL_STORAGE_STATE)
     if (state) this.jobState = state as JobState
 
-    const type = peertubeLocalStorage.getItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_TYPE)
+    const type = peertubeLocalStorage.getItem(JobsComponent.LOCAL_STORAGE_TYPE)
     if (type) this.jobType = type as JobType
   }
 
   private saveJobStateAndType () {
-    peertubeLocalStorage.setItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_STATE, this.jobState)
-    peertubeLocalStorage.setItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_TYPE, this.jobType)
+    peertubeLocalStorage.setItem(JobsComponent.LOCAL_STORAGE_STATE, this.jobState)
+    peertubeLocalStorage.setItem(JobsComponent.LOCAL_STORAGE_TYPE, this.jobType)
   }
 }
index cdcbcb528ec8931ffb677e3e6f9b2525b28aadd8..1f6cfb596aa180fa940544c2a5d838b05c582421 100644 (file)
@@ -9,6 +9,7 @@ import { VideoConstant } from '../../../../../shared/models/videos'
 import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n'
 import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
 import { sortBy } from '@app/shared/misc/utils'
+import { ServerStats } from '@shared/models/server'
 
 @Injectable()
 export class ServerService {
@@ -16,6 +17,8 @@ export class ServerService {
   private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
   private static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/'
   private static BASE_LOCALE_URL = environment.apiUrl + '/client/locales/'
+  private static BASE_STATS_URL = environment.apiUrl + '/api/v1/server/stats'
+
   private static CONFIG_LOCAL_STORAGE_KEY = 'server-config'
 
   configReloaded = new Subject<void>()
@@ -235,6 +238,10 @@ export class ServerService {
     return this.localeObservable.pipe(first())
   }
 
+  getServerStats () {
+    return this.http.get<ServerStats>(ServerService.BASE_STATS_URL)
+  }
+
   private loadAttributeEnum <T extends string | number> (
     baseUrl: string,
     attributeName: 'categories' | 'licences' | 'languages' | 'privacies',
index 806aca347237b9ebc8208f7a6b8657f3a6cf5ed4..b6e641228ad8e727f130d0f670196747e5c02685 100644 (file)
@@ -1,6 +1,5 @@
 import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit } from '@angular/core'
 import { HooksService } from '@app/core/plugins/hooks.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
 
 const icons = {
   'add': require('!!raw-loader?!../../../assets/images/global/add.svg'),
index 8ec728f058e66677801cb9a7ed7d1dfdb0785fe7..40aa8a4c086e0cdd1bff440216d36132390e74a6 100644 (file)
@@ -1,9 +1,6 @@
-import { map } from 'rxjs/operators'
-import { HttpClient } from '@angular/common/http'
 import { Component, OnInit } from '@angular/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
 import { ServerStats } from '@shared/models/server'
-import { environment } from '../../../environments/environment'
+import { ServerService } from '@app/core'
 
 @Component({
   selector: 'my-instance-statistics',
@@ -11,27 +8,15 @@ import { environment } from '../../../environments/environment'
   styleUrls: [ './instance-statistics.component.scss' ]
 })
 export class InstanceStatisticsComponent implements OnInit {
-  private static BASE_STATS_URL = environment.apiUrl + '/api/v1/server/stats'
-
   serverStats: ServerStats = null
 
   constructor (
-    private http: HttpClient,
-    private i18n: I18n
+    private serverService: ServerService
   ) {
   }
 
   ngOnInit () {
-    this.getStats()
-      .subscribe(
-        res => {
-          this.serverStats = res
-        }
-      )
-  }
-
-  getStats () {
-    return this.http
-      .get<ServerStats>(InstanceStatisticsComponent.BASE_STATS_URL)
+    this.serverService.getServerStats()
+        .subscribe(res => this.serverStats = res)
   }
 }
index b2eb13f7391c6b530aec4b465e4dfb22de979640..d06d37d8cb54f8dd7e9657b35bae6215e94fdc5d 100644 (file)
@@ -98,6 +98,7 @@ import { FollowService } from '@app/shared/instance/follow.service'
 import { MultiSelectModule } from 'primeng/multiselect'
 import { FeatureBooleanComponent } from '@app/shared/instance/feature-boolean.component'
 import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-copy.component'
+import { RedundancyService } from '@app/shared/video/redundancy.service'
 
 @NgModule({
   imports: [
@@ -300,6 +301,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
     UserNotificationService,
 
     FollowService,
+    RedundancyService,
 
     I18n
   ]
diff --git a/client/src/app/shared/video/redundancy.service.ts b/client/src/app/shared/video/redundancy.service.ts
new file mode 100644 (file)
index 0000000..fb918d7
--- /dev/null
@@ -0,0 +1,73 @@
+import { catchError, map, toArray } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor, RestPagination, RestService } from '@app/shared/rest'
+import { SortMeta } from 'primeng/api'
+import { ResultList, Video, VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
+import { concat, Observable } from 'rxjs'
+import { environment } from '../../../environments/environment'
+
+@Injectable()
+export class RedundancyService {
+  static BASE_REDUNDANCY_URL = environment.apiUrl + '/api/v1/server/redundancy'
+
+  constructor (
+    private authHttp: HttpClient,
+    private restService: RestService,
+    private restExtractor: RestExtractor
+  ) { }
+
+  updateRedundancy (host: string, redundancyAllowed: boolean) {
+    const url = RedundancyService.BASE_REDUNDANCY_URL + '/' + host
+
+    const body = { redundancyAllowed }
+
+    return this.authHttp.put(url, body)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  listVideoRedundancies (options: {
+    pagination: RestPagination,
+    sort: SortMeta,
+    target?: VideoRedundanciesTarget
+  }): Observable<ResultList<VideoRedundancy>> {
+    const { pagination, sort, target } = options
+
+    let params = new HttpParams()
+    params = this.restService.addRestGetParams(params, pagination, sort)
+
+    if (target) params = params.append('target', target)
+
+    return this.authHttp.get<ResultList<VideoRedundancy>>(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { params })
+               .pipe(
+                 catchError(res => this.restExtractor.handleError(res))
+               )
+  }
+
+  addVideoRedundancy (video: Video) {
+    return this.authHttp.post(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { videoId: video.id })
+      .pipe(
+        catchError(res => this.restExtractor.handleError(res))
+      )
+  }
+
+  removeVideoRedundancies (redundancy: VideoRedundancy) {
+    const observables = redundancy.redundancies.streamingPlaylists.map(r => r.id)
+      .concat(redundancy.redundancies.files.map(r => r.id))
+      .map(id => this.removeRedundancy(id))
+
+    return concat(...observables)
+      .pipe(toArray())
+  }
+
+  private removeRedundancy (redundancyId: number) {
+    return this.authHttp.delete(RedundancyService.BASE_REDUNDANCY_URL + '/videos/' + redundancyId)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(res => this.restExtractor.handleError(res))
+               )
+  }
+}
index afdeab18d819e5c3ff9b7cef886044b4a988610c..390d74c527951c604879c39fea4e3975649188e0 100644 (file)
@@ -14,6 +14,7 @@ import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklis
 import { VideoBlacklistService } from '@app/shared/video-blacklist'
 import { ScreenService } from '@app/shared/misc/screen.service'
 import { VideoCaption } from '@shared/models'
+import { RedundancyService } from '@app/shared/video/redundancy.service'
 
 export type VideoActionsDisplayType = {
   playlist?: boolean
@@ -22,6 +23,7 @@ export type VideoActionsDisplayType = {
   blacklist?: boolean
   delete?: boolean
   report?: boolean
+  duplicate?: boolean
 }
 
 @Component({
@@ -46,7 +48,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
     update: true,
     blacklist: true,
     delete: true,
-    report: true
+    report: true,
+    duplicate: true
   }
   @Input() placement = 'left'
 
@@ -74,6 +77,7 @@ export class VideoActionsDropdownComponent implements OnChanges {
     private screenService: ScreenService,
     private videoService: VideoService,
     private blocklistService: BlocklistService,
+    private redundancyService: RedundancyService,
     private i18n: I18n
   ) { }
 
@@ -144,6 +148,10 @@ export class VideoActionsDropdownComponent implements OnChanges {
     return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled
   }
 
+  canVideoBeDuplicated () {
+    return this.video.canBeDuplicatedBy(this.user)
+  }
+
   /* Action handlers */
 
   async unblacklistVideo () {
@@ -186,6 +194,18 @@ export class VideoActionsDropdownComponent implements OnChanges {
         )
   }
 
+  duplicateVideo () {
+    this.redundancyService.addVideoRedundancy(this.video)
+      .subscribe(
+        () => {
+          const message = this.i18n('This video will be duplicated by your instance.')
+          this.notifier.success(message)
+        },
+
+        err => this.notifier.error(err.message)
+      )
+  }
+
   onVideoBlacklisted () {
     this.videoBlacklisted.emit()
   }
@@ -233,6 +253,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
           iconName: 'undo',
           isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoUnblacklistable()
         },
+        {
+          label: this.i18n('Duplicate (redundancy)'),
+          handler: () => this.duplicateVideo(),
+          isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.duplicate && this.canVideoBeDuplicated(),
+          iconName: 'cloud-download'
+        },
         {
           label: this.i18n('Delete'),
           handler: () => this.removeVideo(),
index 598a7a98371e6225441583284f30545239a6f162..1dfb3eec7ad706b53e258b460555bfed06c63fb2 100644 (file)
@@ -64,7 +64,8 @@ export class VideoMiniatureComponent implements OnInit {
     update: true,
     blacklist: true,
     delete: true,
-    report: true
+    report: true,
+    duplicate: false
   }
   showActions = false
   serverConfig: ServerConfig
index fb98d53820929c769eb3986f7647fff3a4bba316..9eeaf41b052de335d5ad070d6e49627472ac21c9 100644 (file)
@@ -152,4 +152,8 @@ export class Video implements VideoServerModel {
   isUpdatableBy (user: AuthUser) {
     return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO))
   }
+
+  canBeDuplicatedBy (user: AuthUser) {
+    return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES)
+  }
 }
index 0855a25700d2b643aa72d2373b2d8efad3b3d9df..c900ae54971b3111e7bf4d26c6cc894b0d461b89 100644 (file)
@@ -2586,6 +2586,29 @@ chardet@^0.7.0:
   resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
   integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
 
+chart.js@^2.9.3:
+  version "2.9.3"
+  resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-2.9.3.tgz#ae3884114dafd381bc600f5b35a189138aac1ef7"
+  integrity sha512-+2jlOobSk52c1VU6fzkh3UwqHMdSlgH1xFv9FKMqHiNCpXsGPQa/+81AFa+i3jZ253Mq9aAycPwDjnn1XbRNNw==
+  dependencies:
+    chartjs-color "^2.1.0"
+    moment "^2.10.2"
+
+chartjs-color-string@^0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz#1df096621c0e70720a64f4135ea171d051402f71"
+  integrity sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==
+  dependencies:
+    color-name "^1.0.0"
+
+chartjs-color@^2.1.0:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/chartjs-color/-/chartjs-color-2.4.1.tgz#6118bba202fe1ea79dd7f7c0f9da93467296c3b0"
+  integrity sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==
+  dependencies:
+    chartjs-color-string "^0.6.0"
+    color-convert "^1.9.3"
+
 check-types@^8.0.3:
   version "8.0.3"
   resolved "https://registry.yarnpkg.com/check-types/-/check-types-8.0.3.tgz#3356cca19c889544f2d7a95ed49ce508a0ecf552"
@@ -2800,7 +2823,7 @@ collection-visit@^1.0.0:
     map-visit "^1.0.0"
     object-visit "^1.0.0"
 
-color-convert@^1.9.0:
+color-convert@^1.9.0, color-convert@^1.9.3:
   version "1.9.3"
   resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
   integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
@@ -2812,6 +2835,11 @@ color-name@1.1.3:
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
   integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
 
+color-name@^1.0.0:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
+  integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
+
 colors@1.1.2:
   version "1.1.2"
   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:
   dependencies:
     minimist "0.0.8"
 
+moment@^2.10.2:
+  version "2.24.0"
+  resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
+  integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==
+
 mousetrap@^1.6.0:
   version "1.6.3"
   resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.3.tgz#80fee49665fd478bccf072c9d46bdf1bfed3558a"
index 5758c1887e41c8e9fd57d8014dc37a465aa221d1..74979f3a76b5e96239441b91b622d29e1258f209 100644 (file)
@@ -40,18 +40,18 @@ contact_form:
 
 redundancy:
   videos:
-    check_interval: '10 minutes'
+    check_interval: '1 minute'
     strategies:
       -
-        size: '10MB'
+        size: '1000MB'
         min_lifetime: '10 minutes'
         strategy: 'most-views'
       -
-        size: '10MB'
+        size: '1000MB'
         min_lifetime: '10 minutes'
         strategy: 'trending'
       -
-        size: '10MB'
+        size: '1000MB'
         min_lifetime: '10 minutes'
         strategy: 'recently-added'
         min_views: 1
index d4858725a2b623bc4428e09d27f874ce929c9e49..fed390c82bd09c78dfef2cf2fc0d21651c3c88f4 100644 (file)
@@ -41,7 +41,7 @@
     "i18n:create-custom-files": "node ./dist/scripts/i18n/create-custom-files.js",
     "reset-password": "node ./dist/scripts/reset-password.js",
     "play": "scripty",
-    "dev": "scripty",
+    "dev": "sh ./scripts/dev/index.sh",
     "dev:server": "sh ./scripts/dev/server.sh",
     "dev:embed": "scripty",
     "dev:client": "sh ./scripts/dev/client.sh",
index d221d2fc8ab189271cc5e848627ad6b49d6e10f0..9e89516b880689a7bc98652a459a22099872e86a 100755 (executable)
@@ -3,5 +3,5 @@
 set -eu
 
 NODE_ENV=test npm run concurrently -- -k \
-  "npm run dev:client -- --skip-server" \
-  "npm run dev:server"
+  "sh scripts/dev/client.sh --skip-server" \
+  "sh scripts/dev/server.sh"
index 29a403a43f607f3a75fbdb2748c336ba012d3240..c69de21fbfd3b33dbfe238180aca6a258668847a 100644 (file)
@@ -24,7 +24,7 @@ import {
 } from '../../../middlewares/validators'
 import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
 import { JobQueue } from '../../../lib/job-queue'
-import { removeRedundancyOf } from '../../../lib/redundancy'
+import { removeRedundanciesOfServer } from '../../../lib/redundancy'
 import { sequelizeTypescript } from '../../../initializers/database'
 import { autoFollowBackIfNeeded } from '../../../lib/activitypub/follow'
 
@@ -153,7 +153,7 @@ async function removeFollowing (req: express.Request, res: express.Response) {
     await server.save({ transaction: t })
 
     // Async, could be long
-    removeRedundancyOf(server.id)
+    removeRedundanciesOfServer(server.id)
       .catch(err => logger.error('Cannot remove redundancy of %s.', server.host, err))
 
     await follow.destroy({ transaction: t })
index 4ea6164a38c0342a839d5207ef9c44c1dd428f6b..a11c1a74fc58ed22652c0e8ea0806725d042b3ee 100644 (file)
@@ -1,9 +1,24 @@
 import * as express from 'express'
 import { UserRight } from '../../../../shared/models/users'
-import { asyncMiddleware, authenticate, ensureUserHasRight } from '../../../middlewares'
-import { updateServerRedundancyValidator } from '../../../middlewares/validators/redundancy'
-import { removeRedundancyOf } from '../../../lib/redundancy'
+import {
+  asyncMiddleware,
+  authenticate,
+  ensureUserHasRight,
+  paginationValidator,
+  setDefaultPagination,
+  setDefaultVideoRedundanciesSort,
+  videoRedundanciesSortValidator
+} from '../../../middlewares'
+import {
+  listVideoRedundanciesValidator,
+  updateServerRedundancyValidator,
+  addVideoRedundancyValidator,
+  removeVideoRedundancyValidator
+} from '../../../middlewares/validators/redundancy'
+import { removeRedundanciesOfServer, removeVideoRedundancy } from '../../../lib/redundancy'
 import { logger } from '../../../helpers/logger'
+import { VideoRedundancyModel } from '@server/models/redundancy/video-redundancy'
+import { JobQueue } from '@server/lib/job-queue'
 
 const serverRedundancyRouter = express.Router()
 
@@ -14,6 +29,31 @@ serverRedundancyRouter.put('/redundancy/:host',
   asyncMiddleware(updateRedundancy)
 )
 
+serverRedundancyRouter.get('/redundancy/videos',
+  authenticate,
+  ensureUserHasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES),
+  listVideoRedundanciesValidator,
+  paginationValidator,
+  videoRedundanciesSortValidator,
+  setDefaultVideoRedundanciesSort,
+  setDefaultPagination,
+  asyncMiddleware(listVideoRedundancies)
+)
+
+serverRedundancyRouter.post('/redundancy/videos',
+  authenticate,
+  ensureUserHasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES),
+  addVideoRedundancyValidator,
+  asyncMiddleware(addVideoRedundancy)
+)
+
+serverRedundancyRouter.delete('/redundancy/videos/:redundancyId',
+  authenticate,
+  ensureUserHasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES),
+  removeVideoRedundancyValidator,
+  asyncMiddleware(removeVideoRedundancyController)
+)
+
 // ---------------------------------------------------------------------------
 
 export {
@@ -22,6 +62,42 @@ export {
 
 // ---------------------------------------------------------------------------
 
+async function listVideoRedundancies (req: express.Request, res: express.Response) {
+  const resultList = await VideoRedundancyModel.listForApi({
+    start: req.query.start,
+    count: req.query.count,
+    sort: req.query.sort,
+    target: req.query.target,
+    strategy: req.query.strategy
+  })
+
+  const result = {
+    total: resultList.total,
+    data: resultList.data.map(r => VideoRedundancyModel.toFormattedJSONStatic(r))
+  }
+
+  return res.json(result)
+}
+
+async function addVideoRedundancy (req: express.Request, res: express.Response) {
+  const payload = {
+    videoId: res.locals.onlyVideo.id
+  }
+
+  await JobQueue.Instance.createJob({
+    type: 'video-redundancy',
+    payload
+  })
+
+  return res.sendStatus(204)
+}
+
+async function removeVideoRedundancyController (req: express.Request, res: express.Response) {
+  await removeVideoRedundancy(res.locals.videoRedundancy)
+
+  return res.sendStatus(204)
+}
+
 async function updateRedundancy (req: express.Request, res: express.Response) {
   const server = res.locals.server
 
@@ -30,7 +106,7 @@ async function updateRedundancy (req: express.Request, res: express.Response) {
   await server.save()
 
   // Async, could be long
-  removeRedundancyOf(server.id)
+  removeRedundanciesOfServer(server.id)
     .catch(err => logger.error('Cannot remove redundancy of %s.', server.host, { err }))
 
   return res.sendStatus(204)
index 3616c074d35c378a7b434aef4e0a7eeb0389a421..6d508a481d7beb67024bb15ce688341309823745 100644 (file)
@@ -10,6 +10,7 @@ import { ROUTE_CACHE_LIFETIME } from '../../../initializers/constants'
 import { cacheRoute } from '../../../middlewares/cache'
 import { VideoFileModel } from '../../../models/video/video-file'
 import { CONFIG } from '../../../initializers/config'
+import { VideoRedundancyStrategyWithManual } from '@shared/models'
 
 const statsRouter = express.Router()
 
@@ -25,8 +26,15 @@ async function getStats (req: express.Request, res: express.Response) {
   const { totalInstanceFollowers, totalInstanceFollowing } = await ActorFollowModel.getStats()
   const { totalLocalVideoFilesSize } = await VideoFileModel.getStats()
 
+  const strategies: { strategy: VideoRedundancyStrategyWithManual, size: number }[] = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES
+                                                                                            .map(r => ({
+                                                                                              strategy: r.strategy,
+                                                                                              size: r.size
+                                                                                            }))
+  strategies.push({ strategy: 'manual', size: null })
+
   const videosRedundancyStats = await Promise.all(
-    CONFIG.REDUNDANCY.VIDEOS.STRATEGIES.map(r => {
+    strategies.map(r => {
       return VideoRedundancyModel.getStats(r.strategy)
         .then(stats => Object.assign(stats, { strategy: r.strategy, totalSize: r.size }))
     })
index 21d5c53ca55f439fe5244ee2f5c55cfc87685137..c5b3b4d9fda11bb86d751e2aa33a9c4e26c604a1 100644 (file)
@@ -6,7 +6,7 @@ import { CacheFileObject } from '../../../../shared/models/activitypub/objects'
 function isCacheFileObjectValid (object: CacheFileObject) {
   return exists(object) &&
     object.type === 'CacheFile' &&
-    isDateValid(object.expires) &&
+    (object.expires === null || isDateValid(object.expires)) &&
     isActivityPubUrlValid(object.object) &&
     (isRemoteVideoUrlValid(object.url) || isPlaylistRedundancyUrlValid(object.url))
 }
diff --git a/server/helpers/custom-validators/video-redundancies.ts b/server/helpers/custom-validators/video-redundancies.ts
new file mode 100644 (file)
index 0000000..50a559c
--- /dev/null
@@ -0,0 +1,12 @@
+import { exists } from './misc'
+
+function isVideoRedundancyTarget (value: any) {
+  return exists(value) &&
+    (value === 'my-videos' || value === 'remote-videos')
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  isVideoRedundancyTarget
+}
index 3a99518c64127fc48d2b38cf7d28a4598ba9f810..8a5d030dfde8b8ccabd032e225eb6524fb07f951 100644 (file)
@@ -9,12 +9,12 @@ import { promisify2 } from './core-utils'
 import { MVideo } from '@server/typings/models/video/video'
 import { MVideoFile, MVideoFileRedundanciesOpt } from '@server/typings/models/video/video-file'
 import { isStreamingPlaylist, MStreamingPlaylistVideo } from '@server/typings/models/video/video-streaming-playlist'
-import { STATIC_PATHS, WEBSERVER } from '@server/initializers/constants'
+import { WEBSERVER } from '@server/initializers/constants'
 import * as parseTorrent from 'parse-torrent'
 import * as magnetUtil from 'magnet-uri'
 import { isArray } from '@server/helpers/custom-validators/misc'
 import { extractVideo } from '@server/lib/videos'
-import { getTorrentFileName, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
+import { getTorrentFileName, getVideoFilePath } from '@server/lib/video-paths'
 
 const createTorrentPromise = promisify2<string, any, any>(createTorrent)
 
index 7fd77f3e83983b7d326f5e0cdb37daff5a213e27..fd8bf09fc489b5d15fd8cec5d705b9a5cf6be614 100644 (file)
@@ -1,6 +1,6 @@
 import { IConfig } from 'config'
 import { dirname, join } from 'path'
-import { VideosRedundancy } from '../../shared/models'
+import { VideosRedundancyStrategy } from '../../shared/models'
 // Do not use barrels, remain constants as independent as possible
 import { buildPath, parseBytes, parseDurationToMs, root } from '../helpers/core-utils'
 import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
@@ -304,7 +304,7 @@ function getLocalConfigFilePath () {
   return join(dirname(configSources[ 0 ].name), filename + '.json')
 }
 
-function buildVideosRedundancy (objs: any[]): VideosRedundancy[] {
+function buildVideosRedundancy (objs: any[]): VideosRedundancyStrategy[] {
   if (!objs) return []
 
   if (!Array.isArray(objs)) return objs
index 032f63c8f7bc5b64f760e2925f6acf6ec163edec..e01ab8943534ffa2cb3086b48ceacf98aaf4e2cf 100644 (file)
@@ -14,7 +14,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
 
 // ---------------------------------------------------------------------------
 
-const LAST_MIGRATION_VERSION = 470
+const LAST_MIGRATION_VERSION = 475
 
 // ---------------------------------------------------------------------------
 
@@ -73,7 +73,9 @@ const SORTABLE_COLUMNS = {
 
   PLUGINS: [ 'name', 'createdAt', 'updatedAt' ],
 
-  AVAILABLE_PLUGINS: [ 'npmName', 'popularity' ]
+  AVAILABLE_PLUGINS: [ 'npmName', 'popularity' ],
+
+  VIDEO_REDUNDANCIES: [ 'name' ]
 }
 
 const OAUTH_LIFETIME = {
@@ -117,45 +119,44 @@ const REMOTE_SCHEME = {
   WS: 'wss'
 }
 
-// TODO: remove 'video-file'
-const JOB_ATTEMPTS: { [id in (JobType | 'video-file')]: number } = {
+const JOB_ATTEMPTS: { [id in JobType]: number } = {
   'activitypub-http-broadcast': 5,
   'activitypub-http-unicast': 5,
   'activitypub-http-fetcher': 5,
   'activitypub-follow': 5,
   'video-file-import': 1,
   'video-transcoding': 1,
-  'video-file': 1,
   'video-import': 1,
   'email': 5,
   'videos-views': 1,
-  'activitypub-refresher': 1
+  'activitypub-refresher': 1,
+  'video-redundancy': 1
 }
-const JOB_CONCURRENCY: { [id in (JobType | 'video-file')]: number } = {
+const JOB_CONCURRENCY: { [id in JobType]: number } = {
   'activitypub-http-broadcast': 1,
   'activitypub-http-unicast': 5,
   'activitypub-http-fetcher': 1,
   'activitypub-follow': 1,
   'video-file-import': 1,
   'video-transcoding': 1,
-  'video-file': 1,
   'video-import': 1,
   'email': 5,
   'videos-views': 1,
-  'activitypub-refresher': 1
+  'activitypub-refresher': 1,
+  'video-redundancy': 1
 }
-const JOB_TTL: { [id in (JobType | 'video-file')]: number } = {
+const JOB_TTL: { [id in JobType]: number } = {
   'activitypub-http-broadcast': 60000 * 10, // 10 minutes
   'activitypub-http-unicast': 60000 * 10, // 10 minutes
   'activitypub-http-fetcher': 60000 * 10, // 10 minutes
   'activitypub-follow': 60000 * 10, // 10 minutes
   'video-file-import': 1000 * 3600, // 1 hour
   'video-transcoding': 1000 * 3600 * 48, // 2 days, transcoding could be long
-  'video-file': 1000 * 3600 * 48, // 2 days, transcoding could be long
   'video-import': 1000 * 3600 * 2, //  hours
   'email': 60000 * 10, // 10 minutes
   'videos-views': undefined, // Unlimited
-  'activitypub-refresher': 60000 * 10 // 10 minutes
+  'activitypub-refresher': 60000 * 10, // 10 minutes
+  'video-redundancy': 1000 * 3600 * 3 // 3 hours
 }
 const REPEAT_JOBS: { [ id: string ]: EveryRepeatOptions | CronRepeatOptions } = {
   'videos-views': {
diff --git a/server/initializers/migrations/0475-redundancy-expires-on.ts b/server/initializers/migrations/0475-redundancy-expires-on.ts
new file mode 100644 (file)
index 0000000..7e392c8
--- /dev/null
@@ -0,0 +1,27 @@
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+  transaction: Sequelize.Transaction,
+  queryInterface: Sequelize.QueryInterface,
+  sequelize: Sequelize.Sequelize,
+  db: any
+}): Promise<void> {
+  {
+    const data = {
+      type: Sequelize.DATE,
+      allowNull: true,
+      defaultValue: null
+    }
+
+    await utils.queryInterface.changeColumn('videoRedundancy', 'expiresOn', data)
+  }
+}
+
+function down (options) {
+  throw new Error('Not implemented.')
+}
+
+export {
+  up,
+  down
+}
index 65b2dcb494d337317fb380276c0cb49eaac4c809..8252e95e92b99eed1b82690f650c8c89d386ba59 100644 (file)
@@ -13,7 +13,7 @@ function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject
     if (!playlist) throw new Error('Cannot find HLS playlist of video ' + video.url)
 
     return {
-      expiresOn: new Date(cacheFileObject.expires),
+      expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null,
       url: cacheFileObject.id,
       fileUrl: url.href,
       strategy: null,
@@ -30,7 +30,7 @@ function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject
   if (!videoFile) throw new Error(`Cannot find video file ${url.height} ${url.fps} of video ${video.url}`)
 
   return {
-    expiresOn: new Date(cacheFileObject.expires),
+    expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null,
     url: cacheFileObject.id,
     fileUrl: url.href,
     strategy: null,
diff --git a/server/lib/job-queue/handlers/video-redundancy.ts b/server/lib/job-queue/handlers/video-redundancy.ts
new file mode 100644 (file)
index 0000000..319d709
--- /dev/null
@@ -0,0 +1,20 @@
+import * as Bull from 'bull'
+import { logger } from '../../../helpers/logger'
+import { VideosRedundancyScheduler } from '@server/lib/schedulers/videos-redundancy-scheduler'
+
+export type VideoRedundancyPayload = {
+  videoId: number
+}
+
+async function processVideoRedundancy (job: Bull.Job) {
+  const payload = job.data as VideoRedundancyPayload
+  logger.info('Processing video redundancy in job %d.', job.id)
+
+  return VideosRedundancyScheduler.Instance.createManualRedundancy(payload.videoId)
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  processVideoRedundancy
+}
index ec601e9eadd3cf761580b4a9dad94c036b25f1eb..8bbf58f2bf08b0df46f810a7c2f3a6ca00f6f9ef 100644 (file)
@@ -13,6 +13,7 @@ import { processVideoImport, VideoImportPayload } from './handlers/video-import'
 import { processVideosViews } from './handlers/video-views'
 import { refreshAPObject, RefreshPayload } from './handlers/activitypub-refresher'
 import { processVideoFileImport, VideoFileImportPayload } from './handlers/video-file-import'
+import { processVideoRedundancy, VideoRedundancyPayload } from '@server/lib/job-queue/handlers/video-redundancy'
 
 type CreateJobArgument =
   { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } |
@@ -24,20 +25,21 @@ type CreateJobArgument =
   { type: 'email', payload: EmailPayload } |
   { type: 'video-import', payload: VideoImportPayload } |
   { type: 'activitypub-refresher', payload: RefreshPayload } |
-  { type: 'videos-views', payload: {} }
+  { type: 'videos-views', payload: {} } |
+  { type: 'video-redundancy', payload: VideoRedundancyPayload }
 
-const handlers: { [ id in (JobType | 'video-file') ]: (job: Bull.Job) => Promise<any>} = {
+const handlers: { [ id in JobType ]: (job: Bull.Job) => Promise<any>} = {
   'activitypub-http-broadcast': processActivityPubHttpBroadcast,
   'activitypub-http-unicast': processActivityPubHttpUnicast,
   'activitypub-http-fetcher': processActivityPubHttpFetcher,
   'activitypub-follow': processActivityPubFollow,
   'video-file-import': processVideoFileImport,
   'video-transcoding': processVideoTranscoding,
-  'video-file': processVideoTranscoding, // TODO: remove it (changed in 1.3)
   'email': processEmail,
   'video-import': processVideoImport,
   'videos-views': processVideosViews,
-  'activitypub-refresher': refreshAPObject
+  'activitypub-refresher': refreshAPObject,
+  'video-redundancy': processVideoRedundancy
 }
 
 const jobTypes: JobType[] = [
@@ -50,7 +52,8 @@ const jobTypes: JobType[] = [
   'video-file-import',
   'video-import',
   'videos-views',
-  'activitypub-refresher'
+  'activitypub-refresher',
+  'video-redundancy'
 ]
 
 class JobQueue {
index 1b4ecd7c04875d6abc3e98475a3fc14a257fc373..78d84e02e97d7d8648ff8f4bed3eac99ea7fc5ed 100644 (file)
@@ -13,10 +13,10 @@ async function removeVideoRedundancy (videoRedundancy: MVideoRedundancyVideo, t?
   await videoRedundancy.destroy({ transaction: t })
 }
 
-async function removeRedundancyOf (serverId: number) {
-  const videosRedundancy = await VideoRedundancyModel.listLocalOfServer(serverId)
+async function removeRedundanciesOfServer (serverId: number) {
+  const redundancies = await VideoRedundancyModel.listLocalOfServer(serverId)
 
-  for (const redundancy of videosRedundancy) {
+  for (const redundancy of redundancies) {
     await removeVideoRedundancy(redundancy)
   }
 }
@@ -24,6 +24,6 @@ async function removeRedundancyOf (serverId: number) {
 // ---------------------------------------------------------------------------
 
 export {
-  removeRedundancyOf,
+  removeRedundanciesOfServer,
   removeVideoRedundancy
 }
index 350a335d379517d1d7f1a630c8037fe36f3059e1..956780a776529ba1b77967f39e577e496d99d9eb 100644 (file)
@@ -4,7 +4,6 @@ import { ScheduleVideoUpdateModel } from '../../models/video/schedule-video-upda
 import { retryTransactionWrapper } from '../../helpers/database-utils'
 import { federateVideoIfNeeded } from '../activitypub'
 import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
-import { VideoPrivacy } from '../../../shared/models/videos'
 import { Notifier } from '../notifier'
 import { sequelizeTypescript } from '../../initializers/database'
 import { MVideoFullLight } from '@server/typings/models'
index c1c91b6563d9488ca1b37d8ea2cce68fc544d119..6e61cbe7d79bc283c24db626492c16255c816291 100644 (file)
@@ -1,7 +1,7 @@
 import { AbstractScheduler } from './abstract-scheduler'
 import { HLS_REDUNDANCY_DIRECTORY, REDUNDANCY, VIDEO_IMPORT_TIMEOUT, WEBSERVER } from '../../initializers/constants'
 import { logger } from '../../helpers/logger'
-import { VideosRedundancy } from '../../../shared/models/redundancy'
+import { VideosRedundancyStrategy } from '../../../shared/models/redundancy'
 import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
 import { downloadWebTorrentVideo, generateMagnetUri } from '../../helpers/webtorrent'
 import { join } from 'path'
@@ -25,9 +25,10 @@ import {
   MVideoWithAllFiles
 } from '@server/typings/models'
 import { getVideoFilename } from '../video-paths'
+import { VideoModel } from '@server/models/video/video'
 
 type CandidateToDuplicate = {
-  redundancy: VideosRedundancy,
+  redundancy: VideosRedundancyStrategy,
   video: MVideoWithAllFiles,
   files: MVideoFile[],
   streamingPlaylists: MStreamingPlaylistFiles[]
@@ -41,7 +42,7 @@ function isMVideoRedundancyFileVideo (
 
 export class VideosRedundancyScheduler extends AbstractScheduler {
 
-  private static instance: AbstractScheduler
+  private static instance: VideosRedundancyScheduler
 
   protected schedulerIntervalMs = CONFIG.REDUNDANCY.VIDEOS.CHECK_INTERVAL
 
@@ -49,6 +50,22 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
     super()
   }
 
+  async createManualRedundancy (videoId: number) {
+    const videoToDuplicate = await VideoModel.loadWithFiles(videoId)
+
+    if (!videoToDuplicate) {
+      logger.warn('Video to manually duplicate %d does not exist anymore.', videoId)
+      return
+    }
+
+    return this.createVideoRedundancies({
+      video: videoToDuplicate,
+      redundancy: null,
+      files: videoToDuplicate.VideoFiles,
+      streamingPlaylists: videoToDuplicate.VideoStreamingPlaylists
+    })
+  }
+
   protected async internalExecute () {
     for (const redundancyConfig of CONFIG.REDUNDANCY.VIDEOS.STRATEGIES) {
       logger.info('Running redundancy scheduler for strategy %s.', redundancyConfig.strategy)
@@ -94,7 +111,7 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
     for (const redundancyModel of expired) {
       try {
         const redundancyConfig = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES.find(s => s.strategy === redundancyModel.strategy)
-        const candidate = {
+        const candidate: CandidateToDuplicate = {
           redundancy: redundancyConfig,
           video: null,
           files: [],
@@ -140,7 +157,7 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
     }
   }
 
-  private findVideoToDuplicate (cache: VideosRedundancy) {
+  private findVideoToDuplicate (cache: VideosRedundancyStrategy) {
     if (cache.strategy === 'most-views') {
       return VideoRedundancyModel.findMostViewToDuplicate(REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR)
     }
@@ -187,13 +204,21 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
     }
   }
 
-  private async createVideoFileRedundancy (redundancy: VideosRedundancy, video: MVideoAccountLight, fileArg: MVideoFile) {
+  private async createVideoFileRedundancy (redundancy: VideosRedundancyStrategy | null, video: MVideoAccountLight, fileArg: MVideoFile) {
+    let strategy = 'manual'
+    let expiresOn: Date = null
+
+    if (redundancy) {
+      strategy = redundancy.strategy
+      expiresOn = this.buildNewExpiration(redundancy.minLifetime)
+    }
+
     const file = fileArg as MVideoFileVideo
     file.Video = video
 
     const serverActor = await getServerActor()
 
-    logger.info('Duplicating %s - %d in videos redundancy with "%s" strategy.', video.url, file.resolution, redundancy.strategy)
+    logger.info('Duplicating %s - %d in videos redundancy with "%s" strategy.', video.url, file.resolution, strategy)
 
     const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
     const magnetUri = generateMagnetUri(video, file, baseUrlHttp, baseUrlWs)
@@ -204,10 +229,10 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
     await move(tmpPath, destPath, { overwrite: true })
 
     const createdModel: MVideoRedundancyFileVideo = await VideoRedundancyModel.create({
-      expiresOn: this.buildNewExpiration(redundancy.minLifetime),
+      expiresOn,
       url: getVideoCacheFileActivityPubUrl(file),
       fileUrl: video.getVideoRedundancyUrl(file, WEBSERVER.URL),
-      strategy: redundancy.strategy,
+      strategy,
       videoFileId: file.id,
       actorId: serverActor.id
     })
@@ -220,25 +245,33 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
   }
 
   private async createStreamingPlaylistRedundancy (
-    redundancy: VideosRedundancy,
+    redundancy: VideosRedundancyStrategy,
     video: MVideoAccountLight,
     playlistArg: MStreamingPlaylist
   ) {
+    let strategy = 'manual'
+    let expiresOn: Date = null
+
+    if (redundancy) {
+      strategy = redundancy.strategy
+      expiresOn = this.buildNewExpiration(redundancy.minLifetime)
+    }
+
     const playlist = playlistArg as MStreamingPlaylistVideo
     playlist.Video = video
 
     const serverActor = await getServerActor()
 
-    logger.info('Duplicating %s streaming playlist in videos redundancy with "%s" strategy.', video.url, redundancy.strategy)
+    logger.info('Duplicating %s streaming playlist in videos redundancy with "%s" strategy.', video.url, strategy)
 
     const destDirectory = join(HLS_REDUNDANCY_DIRECTORY, video.uuid)
     await downloadPlaylistSegments(playlist.playlistUrl, destDirectory, VIDEO_IMPORT_TIMEOUT)
 
     const createdModel: MVideoRedundancyStreamingPlaylistVideo = await VideoRedundancyModel.create({
-      expiresOn: this.buildNewExpiration(redundancy.minLifetime),
+      expiresOn,
       url: getVideoCacheStreamingPlaylistActivityPubUrl(video, playlist),
       fileUrl: playlist.getVideoRedundancyUrl(WEBSERVER.URL),
-      strategy: redundancy.strategy,
+      strategy,
       videoStreamingPlaylistId: playlist.id,
       actorId: serverActor.id
     })
index 8c27e82379bfbb2b09f04b1bd249ea859ab9f2f3..75238228fefb229401a6ef7491beb39f5e7e097d 100644 (file)
@@ -1,17 +1,11 @@
 import * as express from 'express'
 import { SortType } from '../models/utils'
 
-function setDefaultSort (req: express.Request, res: express.Response, next: express.NextFunction) {
-  if (!req.query.sort) req.query.sort = '-createdAt'
-
-  return next()
-}
+const setDefaultSort = setDefaultSortFactory('-createdAt')
 
-function setDefaultSearchSort (req: express.Request, res: express.Response, next: express.NextFunction) {
-  if (!req.query.sort) req.query.sort = '-match'
+const setDefaultVideoRedundanciesSort = setDefaultSortFactory('name')
 
-  return next()
-}
+const setDefaultSearchSort = setDefaultSortFactory('-match')
 
 function setBlacklistSort (req: express.Request, res: express.Response, next: express.NextFunction) {
   let newSort: SortType = { sortModel: undefined, sortValue: '' }
@@ -39,5 +33,16 @@ function setBlacklistSort (req: express.Request, res: express.Response, next: ex
 export {
   setDefaultSort,
   setDefaultSearchSort,
+  setDefaultVideoRedundanciesSort,
   setBlacklistSort
 }
+
+// ---------------------------------------------------------------------------
+
+function setDefaultSortFactory (sort: string) {
+  return (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (!req.query.sort) req.query.sort = sort
+
+    return next()
+  }
+}
index 8098e3a449a94a89f86e132c809de4a9e742ff92..16b42fc0d0b870c8b23433ceaa9cd41b3f0353c5 100644 (file)
@@ -1,12 +1,13 @@
 import * as express from 'express'
-import { body, param } from 'express-validator'
-import { exists, isBooleanValid, isIdOrUUIDValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
+import { body, param, query } from 'express-validator'
+import { exists, isBooleanValid, isIdOrUUIDValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
 import { logger } from '../../helpers/logger'
 import { areValidationErrors } from './utils'
 import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
 import { isHostValid } from '../../helpers/custom-validators/servers'
 import { ServerModel } from '../../models/server/server'
 import { doesVideoExist } from '../../helpers/middlewares'
+import { isVideoRedundancyTarget } from '@server/helpers/custom-validators/video-redundancies'
 
 const videoFileRedundancyGetValidator = [
   param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
@@ -101,10 +102,77 @@ const updateServerRedundancyValidator = [
   }
 ]
 
+const listVideoRedundanciesValidator = [
+  query('target')
+    .custom(isVideoRedundancyTarget).withMessage('Should have a valid video redundancies target'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking listVideoRedundanciesValidator parameters', { parameters: req.query })
+
+    if (areValidationErrors(req, res)) return
+
+    return next()
+  }
+]
+
+const addVideoRedundancyValidator = [
+  body('videoId')
+    .custom(isIdValid)
+    .withMessage('Should have a valid video id'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking addVideoRedundancyValidator parameters', { parameters: req.query })
+
+    if (areValidationErrors(req, res)) return
+
+    if (!await doesVideoExist(req.body.videoId, res, 'only-video')) return
+
+    if (res.locals.onlyVideo.remote === false) {
+      return res.status(400)
+        .json({ error: 'Cannot create a redundancy on a local video' })
+        .end()
+    }
+
+    const alreadyExists = await VideoRedundancyModel.isLocalByVideoUUIDExists(res.locals.onlyVideo.uuid)
+    if (alreadyExists) {
+      return res.status(409)
+        .json({ error: 'This video is already duplicated by your instance.' })
+    }
+
+    return next()
+  }
+]
+
+const removeVideoRedundancyValidator = [
+  param('redundancyId')
+    .custom(isIdValid)
+    .withMessage('Should have a valid redundancy id'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking removeVideoRedundancyValidator parameters', { parameters: req.query })
+
+    if (areValidationErrors(req, res)) return
+
+    const redundancy = await VideoRedundancyModel.loadByIdWithVideo(parseInt(req.params.redundancyId, 10))
+    if (!redundancy) {
+      return res.status(404)
+                .json({ error: 'Video redundancy not found' })
+                .end()
+    }
+
+    res.locals.videoRedundancy = redundancy
+
+    return next()
+  }
+]
+
 // ---------------------------------------------------------------------------
 
 export {
   videoFileRedundancyGetValidator,
   videoPlaylistRedundancyGetValidator,
-  updateServerRedundancyValidator
+  updateServerRedundancyValidator,
+  listVideoRedundanciesValidator,
+  addVideoRedundancyValidator,
+  removeVideoRedundancyValidator
 }
index c75e701d6db69dd9004ace683ad0e3f0a2025d98..b76dab722d34d610430efa549f0c6333bb191ed5 100644 (file)
@@ -23,6 +23,7 @@ const SORTABLE_USER_NOTIFICATIONS_COLUMNS = createSortableColumns(SORTABLE_COLUM
 const SORTABLE_VIDEO_PLAYLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_PLAYLISTS)
 const SORTABLE_PLUGINS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.PLUGINS)
 const SORTABLE_AVAILABLE_PLUGINS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
+const SORTABLE_VIDEO_REDUNDANCIES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
 
 const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS)
 const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS)
@@ -45,6 +46,7 @@ const userNotificationsSortValidator = checkSort(SORTABLE_USER_NOTIFICATIONS_COL
 const videoPlaylistsSortValidator = checkSort(SORTABLE_VIDEO_PLAYLISTS_COLUMNS)
 const pluginsSortValidator = checkSort(SORTABLE_PLUGINS_COLUMNS)
 const availablePluginsSortValidator = checkSort(SORTABLE_AVAILABLE_PLUGINS_COLUMNS)
+const videoRedundanciesSortValidator = checkSort(SORTABLE_VIDEO_REDUNDANCIES_COLUMNS)
 
 // ---------------------------------------------------------------------------
 
@@ -69,5 +71,6 @@ export {
   serversBlocklistSortValidator,
   userNotificationsSortValidator,
   videoPlaylistsSortValidator,
+  videoRedundanciesSortValidator,
   pluginsSortValidator
 }
index 8c9a7eabf8fa1ba3148bf4a87a6ff7e85ba7e444..4e66d72e3161fdb0b5e0262a9de1c1344f321e3f 100644 (file)
@@ -13,13 +13,13 @@ import {
   UpdatedAt
 } from 'sequelize-typescript'
 import { ActorModel } from '../activitypub/actor'
-import { getVideoSort, parseAggregateResult, throwIfNotValid } from '../utils'
+import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../utils'
 import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validators/activitypub/misc'
 import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants'
 import { VideoFileModel } from '../video/video-file'
 import { getServerActor } from '../../helpers/utils'
 import { VideoModel } from '../video/video'
-import { VideoRedundancyStrategy } from '../../../shared/models/redundancy'
+import { VideoRedundancyStrategy, VideoRedundancyStrategyWithManual } from '../../../shared/models/redundancy'
 import { logger } from '../../helpers/logger'
 import { CacheFileObject, VideoPrivacy } from '../../../shared'
 import { VideoChannelModel } from '../video/video-channel'
@@ -27,10 +27,16 @@ import { ServerModel } from '../server/server'
 import { sample } from 'lodash'
 import { isTestInstance } from '../../helpers/core-utils'
 import * as Bluebird from 'bluebird'
-import { col, FindOptions, fn, literal, Op, Transaction } from 'sequelize'
+import { col, FindOptions, fn, literal, Op, Transaction, WhereOptions } from 'sequelize'
 import { VideoStreamingPlaylistModel } from '../video/video-streaming-playlist'
 import { CONFIG } from '../../initializers/config'
-import { MVideoRedundancy, MVideoRedundancyAP, MVideoRedundancyVideo } from '@server/typings/models'
+import { MVideoForRedundancyAPI, MVideoRedundancy, MVideoRedundancyAP, MVideoRedundancyVideo } from '@server/typings/models'
+import { VideoRedundanciesTarget } from '@shared/models/redundancy/video-redundancies-filters.model'
+import {
+  FileRedundancyInformation,
+  StreamingPlaylistRedundancyInformation,
+  VideoRedundancy
+} from '@shared/models/redundancy/video-redundancy.model'
 
 export enum ScopeNames {
   WITH_VIDEO = 'WITH_VIDEO'
@@ -86,7 +92,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
   @UpdatedAt
   updatedAt: Date
 
-  @AllowNull(false)
+  @AllowNull(true)
   @Column
   expiresOn: Date
 
@@ -193,6 +199,15 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
     return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
   }
 
+  static loadByIdWithVideo (id: number, transaction?: Transaction): Bluebird<MVideoRedundancyVideo> {
+    const query = {
+      where: { id },
+      transaction
+    }
+
+    return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
+  }
+
   static loadByUrl (url: string, transaction?: Transaction): Bluebird<MVideoRedundancy> {
     const query = {
       where: {
@@ -394,7 +409,8 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
           [Op.ne]: actor.id
         },
         expiresOn: {
-          [ Op.lt ]: new Date()
+          [ Op.lt ]: new Date(),
+          [ Op.ne ]: null
         }
       }
     }
@@ -447,7 +463,112 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
     return VideoRedundancyModel.findAll(query)
   }
 
-  static async getStats (strategy: VideoRedundancyStrategy) {
+  static listForApi (options: {
+    start: number,
+    count: number,
+    sort: string,
+    target: VideoRedundanciesTarget,
+    strategy?: string
+  }) {
+    const { start, count, sort, target, strategy } = options
+    let redundancyWhere: WhereOptions = {}
+    let videosWhere: WhereOptions = {}
+    let redundancySqlSuffix = ''
+
+    if (target === 'my-videos') {
+      Object.assign(videosWhere, { remote: false })
+    } else if (target === 'remote-videos') {
+      Object.assign(videosWhere, { remote: true })
+      Object.assign(redundancyWhere, { strategy: { [Op.ne]: null } })
+      redundancySqlSuffix = ' AND "videoRedundancy"."strategy" IS NOT NULL'
+    }
+
+    if (strategy) {
+      Object.assign(redundancyWhere, { strategy: strategy })
+    }
+
+    const videoFilterWhere = {
+      [Op.and]: [
+        {
+          [ Op.or ]: [
+            {
+              id: {
+                [ Op.in ]: literal(
+                  '(' +
+                  'SELECT "videoId" FROM "videoFile" ' +
+                  'INNER JOIN "videoRedundancy" ON "videoRedundancy"."videoFileId" = "videoFile".id' +
+                  redundancySqlSuffix +
+                  ')'
+                )
+              }
+            },
+            {
+              id: {
+                [ Op.in ]: literal(
+                  '(' +
+                  'select "videoId" FROM "videoStreamingPlaylist" ' +
+                  'INNER JOIN "videoRedundancy" ON "videoRedundancy"."videoStreamingPlaylistId" = "videoStreamingPlaylist".id' +
+                  redundancySqlSuffix +
+                  ')'
+                )
+              }
+            }
+          ]
+        },
+
+        videosWhere
+      ]
+    }
+
+    // /!\ On video model /!\
+    const findOptions = {
+      offset: start,
+      limit: count,
+      order: getSort(sort),
+      include: [
+        {
+          required: false,
+          model: VideoFileModel.unscoped(),
+          include: [
+            {
+              model: VideoRedundancyModel.unscoped(),
+              required: false,
+              where: redundancyWhere
+            }
+          ]
+        },
+        {
+          required: false,
+          model: VideoStreamingPlaylistModel.unscoped(),
+          include: [
+            {
+              model: VideoRedundancyModel.unscoped(),
+              required: false,
+              where: redundancyWhere
+            },
+            {
+              model: VideoFileModel.unscoped(),
+              required: false
+            }
+          ]
+        }
+      ],
+      where: videoFilterWhere
+    }
+
+    // /!\ On video model /!\
+    const countOptions = {
+      where: videoFilterWhere
+    }
+
+    return Promise.all([
+      VideoModel.findAll(findOptions),
+
+      VideoModel.count(countOptions)
+    ]).then(([ data, total ]) => ({ total, data }))
+  }
+
+  static async getStats (strategy: VideoRedundancyStrategyWithManual) {
     const actor = await getServerActor()
 
     const query: FindOptions = {
@@ -478,6 +599,53 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
       }))
   }
 
+  static toFormattedJSONStatic (video: MVideoForRedundancyAPI): VideoRedundancy {
+    let filesRedundancies: FileRedundancyInformation[] = []
+    let streamingPlaylistsRedundancies: StreamingPlaylistRedundancyInformation[] = []
+
+    for (const file of video.VideoFiles) {
+      for (const redundancy of file.RedundancyVideos) {
+        filesRedundancies.push({
+          id: redundancy.id,
+          fileUrl: redundancy.fileUrl,
+          strategy: redundancy.strategy,
+          createdAt: redundancy.createdAt,
+          updatedAt: redundancy.updatedAt,
+          expiresOn: redundancy.expiresOn,
+          size: file.size
+        })
+      }
+    }
+
+    for (const playlist of video.VideoStreamingPlaylists) {
+      const size = playlist.VideoFiles.reduce((a, b) => a + b.size, 0)
+
+      for (const redundancy of playlist.RedundancyVideos) {
+        streamingPlaylistsRedundancies.push({
+          id: redundancy.id,
+          fileUrl: redundancy.fileUrl,
+          strategy: redundancy.strategy,
+          createdAt: redundancy.createdAt,
+          updatedAt: redundancy.updatedAt,
+          expiresOn: redundancy.expiresOn,
+          size
+        })
+      }
+    }
+
+    return {
+      id: video.id,
+      name: video.name,
+      url: video.url,
+      uuid: video.uuid,
+
+      redundancies: {
+        files: filesRedundancies,
+        streamingPlaylists: streamingPlaylistsRedundancies
+      }
+    }
+  }
+
   getVideo () {
     if (this.VideoFile) return this.VideoFile.Video
 
@@ -494,7 +662,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
         id: this.url,
         type: 'CacheFile' as 'CacheFile',
         object: this.VideoStreamingPlaylist.Video.url,
-        expires: this.expiresOn.toISOString(),
+        expires: this.expiresOn ? this.expiresOn.toISOString() : null,
         url: {
           type: 'Link',
           mediaType: 'application/x-mpegURL',
@@ -507,7 +675,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
       id: this.url,
       type: 'CacheFile' as 'CacheFile',
       object: this.VideoFile.Video.url,
-      expires: this.expiresOn.toISOString(),
+      expires: this.expiresOn ? this.expiresOn.toISOString() : null,
       url: {
         type: 'Link',
         mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[ this.VideoFile.extname ] as any,
index 6471da8404c9016dc4aaba92314b2c8dc57f40e0..7012a39ee46f0ffe283ba76b616de9def7f93abb 100644 (file)
@@ -3,21 +3,25 @@
 import 'mocha'
 
 import {
+  checkBadCountPagination,
+  checkBadSortPagination,
+  checkBadStartPagination,
   cleanupTests,
   createUser,
   doubleFollow,
-  flushAndRunMultipleServers,
-  flushTests,
-  killallServers,
+  flushAndRunMultipleServers, makeDeleteRequest,
+  makeGetRequest, makePostBodyRequest,
   makePutBodyRequest,
   ServerInfo,
-  setAccessTokensToServers,
-  userLogin
+  setAccessTokensToServers, uploadVideoAndGetId,
+  userLogin, waitJobs
 } from '../../../../shared/extra-utils'
 
 describe('Test server redundancy API validators', function () {
   let servers: ServerInfo[]
   let userAccessToken = null
+  let videoIdLocal: number
+  let videoIdRemote: number
 
   // ---------------------------------------------------------------
 
@@ -36,9 +40,134 @@ describe('Test server redundancy API validators', function () {
 
     await createUser({ url: servers[ 0 ].url, accessToken: servers[ 0 ].accessToken, username: user.username, password: user.password })
     userAccessToken = await userLogin(servers[0], user)
+
+    videoIdLocal = (await uploadVideoAndGetId({ server: servers[0], videoName: 'video' })).id
+    videoIdRemote = (await uploadVideoAndGetId({ server: servers[1], videoName: 'video' })).id
+
+    await waitJobs(servers)
+  })
+
+  describe('When listing redundancies', function () {
+    const path = '/api/v1/server/redundancy/videos'
+
+    let url: string
+    let token: string
+
+    before(function () {
+      url = servers[0].url
+      token = servers[0].accessToken
+    })
+
+    it('Should fail with an invalid token', async function () {
+      await makeGetRequest({ url, path, token: 'fake_token', statusCodeExpected: 401 })
+    })
+
+    it('Should fail if the user is not an administrator', async function () {
+      await makeGetRequest({ url, path, token: userAccessToken, statusCodeExpected: 403 })
+    })
+
+    it('Should fail with a bad start pagination', async function () {
+      await checkBadStartPagination(url, path, servers[0].accessToken)
+    })
+
+    it('Should fail with a bad count pagination', async function () {
+      await checkBadCountPagination(url, path, servers[0].accessToken)
+    })
+
+    it('Should fail with an incorrect sort', async function () {
+      await checkBadSortPagination(url, path, servers[0].accessToken)
+    })
+
+    it('Should fail with a bad target', async function () {
+      await makeGetRequest({ url, path, token, query: { target: 'bad target' } })
+    })
+
+    it('Should fail without target', async function () {
+      await makeGetRequest({ url, path, token })
+    })
+
+    it('Should succeed with the correct params', async function () {
+      await makeGetRequest({ url, path, token, query: { target: 'my-videos' }, statusCodeExpected: 200 })
+    })
+  })
+
+  describe('When manually adding a redundancy', function () {
+    const path = '/api/v1/server/redundancy/videos'
+
+    let url: string
+    let token: string
+
+    before(function () {
+      url = servers[0].url
+      token = servers[0].accessToken
+    })
+
+    it('Should fail with an invalid token', async function () {
+      await makePostBodyRequest({ url, path, token: 'fake_token', statusCodeExpected: 401 })
+    })
+
+    it('Should fail if the user is not an administrator', async function () {
+      await makePostBodyRequest({ url, path, token: userAccessToken, statusCodeExpected: 403 })
+    })
+
+    it('Should fail without a video id', async function () {
+      await makePostBodyRequest({ url, path, token })
+    })
+
+    it('Should fail with an incorrect video id', async function () {
+      await makePostBodyRequest({ url, path, token, fields: { videoId: 'peertube' } })
+    })
+
+    it('Should fail with a not found video id', async function () {
+      await makePostBodyRequest({ url, path, token, fields: { videoId: 6565 }, statusCodeExpected: 404 })
+    })
+
+    it('Should fail with a local a video id', async function () {
+      await makePostBodyRequest({ url, path, token, fields: { videoId: videoIdLocal } })
+    })
+
+    it('Should succeed with the correct params', async function () {
+      await makePostBodyRequest({ url, path, token, fields: { videoId: videoIdRemote }, statusCodeExpected: 204 })
+    })
+
+    it('Should fail if the video is already duplicated', async function () {
+      this.timeout(30000)
+
+      await waitJobs(servers)
+
+      await makePostBodyRequest({ url, path, token, fields: { videoId: videoIdRemote }, statusCodeExpected: 409 })
+    })
+  })
+
+  describe('When manually removing a redundancy', function () {
+    const path = '/api/v1/server/redundancy/videos/'
+
+    let url: string
+    let token: string
+
+    before(function () {
+      url = servers[0].url
+      token = servers[0].accessToken
+    })
+
+    it('Should fail with an invalid token', async function () {
+      await makeDeleteRequest({ url, path: path + '1', token: 'fake_token', statusCodeExpected: 401 })
+    })
+
+    it('Should fail if the user is not an administrator', async function () {
+      await makeDeleteRequest({ url, path: path + '1', token: userAccessToken, statusCodeExpected: 403 })
+    })
+
+    it('Should fail with an incorrect video id', async function () {
+      await makeDeleteRequest({ url, path: path + 'toto', token })
+    })
+
+    it('Should fail with a not found video redundancy', async function () {
+      await makeDeleteRequest({ url, path: path + '454545', token, statusCodeExpected: 404 })
+    })
   })
 
-  describe('When updating redundancy', function () {
+  describe('When updating server redundancy', function () {
     const path = '/api/v1/server/redundancy'
 
     it('Should fail with an invalid token', async function () {
index 8e69b95a6ca9ab2ed443d67e34fa43e56bf51c86..5359055b0cfb5357c7638ceb340da9432169ac28 100644 (file)
@@ -1 +1,2 @@
 import './redundancy'
+import './manage-redundancy'
diff --git a/server/tests/api/redundancy/manage-redundancy.ts b/server/tests/api/redundancy/manage-redundancy.ts
new file mode 100644 (file)
index 0000000..6a8937f
--- /dev/null
@@ -0,0 +1,373 @@
+/* tslint:disable:no-unused-expression */
+
+import * as chai from 'chai'
+import 'mocha'
+import {
+  cleanupTests,
+  doubleFollow,
+  flushAndRunMultipleServers,
+  getLocalIdByUUID,
+  ServerInfo,
+  setAccessTokensToServers,
+  uploadVideo,
+  uploadVideoAndGetId,
+  waitUntilLog
+} from '../../../../shared/extra-utils'
+import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
+import { addVideoRedundancy, listVideoRedundancies, removeVideoRedundancy, updateRedundancy } from '@shared/extra-utils/server/redundancy'
+import { VideoPrivacy, VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
+
+const expect = chai.expect
+
+describe('Test manage videos redundancy', function () {
+  const targets: VideoRedundanciesTarget[] = [ 'my-videos', 'remote-videos' ]
+
+  let servers: ServerInfo[]
+  let video1Server2UUID: string
+  let video2Server2UUID: string
+  let redundanciesToRemove: number[] = []
+
+  before(async function () {
+    this.timeout(120000)
+
+    const config = {
+      transcoding: {
+        hls: {
+          enabled: true
+        }
+      },
+      redundancy: {
+        videos: {
+          check_interval: '1 second',
+          strategies: [
+            {
+              strategy: 'recently-added',
+              min_lifetime: '1 hour',
+              size: '10MB',
+              min_views: 0
+            }
+          ]
+        }
+      }
+    }
+    servers = await flushAndRunMultipleServers(3, config)
+
+    // Get the access tokens
+    await setAccessTokensToServers(servers)
+
+    {
+      const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 1 server 2' })
+      video1Server2UUID = res.body.video.uuid
+    }
+
+    {
+      const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 2 server 2' })
+      video2Server2UUID = res.body.video.uuid
+    }
+
+    await waitJobs(servers)
+
+    // Server 1 and server 2 follow each other
+    await doubleFollow(servers[ 0 ], servers[ 1 ])
+    await updateRedundancy(servers[ 0 ].url, servers[ 0 ].accessToken, servers[ 1 ].host, true)
+
+    await waitJobs(servers)
+  })
+
+  it('Should not have redundancies on server 3', async function () {
+    for (const target of targets) {
+      const res = await listVideoRedundancies({
+        url: servers[2].url,
+        accessToken: servers[2].accessToken,
+        target
+      })
+
+      expect(res.body.total).to.equal(0)
+      expect(res.body.data).to.have.lengthOf(0)
+    }
+  })
+
+  it('Should not have "remote-videos" redundancies on server 2', async function () {
+    this.timeout(120000)
+
+    await waitJobs(servers)
+    await waitUntilLog(servers[0], 'Duplicated ', 10)
+    await waitJobs(servers)
+
+    const res = await listVideoRedundancies({
+      url: servers[1].url,
+      accessToken: servers[1].accessToken,
+      target: 'remote-videos'
+    })
+
+    expect(res.body.total).to.equal(0)
+    expect(res.body.data).to.have.lengthOf(0)
+  })
+
+  it('Should have "my-videos" redundancies on server 2', async function () {
+    this.timeout(120000)
+
+    const res = await listVideoRedundancies({
+      url: servers[1].url,
+      accessToken: servers[1].accessToken,
+      target: 'my-videos'
+    })
+
+    expect(res.body.total).to.equal(2)
+
+    const videos = res.body.data as VideoRedundancy[]
+    expect(videos).to.have.lengthOf(2)
+
+    const videos1 = videos.find(v => v.uuid === video1Server2UUID)
+    const videos2 = videos.find(v => v.uuid === video2Server2UUID)
+
+    expect(videos1.name).to.equal('video 1 server 2')
+    expect(videos2.name).to.equal('video 2 server 2')
+
+    expect(videos1.redundancies.files).to.have.lengthOf(4)
+    expect(videos1.redundancies.streamingPlaylists).to.have.lengthOf(1)
+
+    const redundancies = videos1.redundancies.files.concat(videos1.redundancies.streamingPlaylists)
+
+    for (const r of redundancies) {
+      expect(r.strategy).to.be.null
+      expect(r.fileUrl).to.exist
+      expect(r.createdAt).to.exist
+      expect(r.updatedAt).to.exist
+      expect(r.expiresOn).to.exist
+    }
+  })
+
+  it('Should not have "my-videos" redundancies on server 1', async function () {
+    const res = await listVideoRedundancies({
+      url: servers[0].url,
+      accessToken: servers[0].accessToken,
+      target: 'my-videos'
+    })
+
+    expect(res.body.total).to.equal(0)
+    expect(res.body.data).to.have.lengthOf(0)
+  })
+
+  it('Should have "remote-videos" redundancies on server 1', async function () {
+    this.timeout(120000)
+
+    const res = await listVideoRedundancies({
+      url: servers[0].url,
+      accessToken: servers[0].accessToken,
+      target: 'remote-videos'
+    })
+
+    expect(res.body.total).to.equal(2)
+
+    const videos = res.body.data as VideoRedundancy[]
+    expect(videos).to.have.lengthOf(2)
+
+    const videos1 = videos.find(v => v.uuid === video1Server2UUID)
+    const videos2 = videos.find(v => v.uuid === video2Server2UUID)
+
+    expect(videos1.name).to.equal('video 1 server 2')
+    expect(videos2.name).to.equal('video 2 server 2')
+
+    expect(videos1.redundancies.files).to.have.lengthOf(4)
+    expect(videos1.redundancies.streamingPlaylists).to.have.lengthOf(1)
+
+    const redundancies = videos1.redundancies.files.concat(videos1.redundancies.streamingPlaylists)
+
+    for (const r of redundancies) {
+      expect(r.strategy).to.equal('recently-added')
+      expect(r.fileUrl).to.exist
+      expect(r.createdAt).to.exist
+      expect(r.updatedAt).to.exist
+      expect(r.expiresOn).to.exist
+    }
+  })
+
+  it('Should correctly paginate and sort results', async function () {
+    {
+      const res = await listVideoRedundancies({
+        url: servers[0].url,
+        accessToken: servers[0].accessToken,
+        target: 'remote-videos',
+        sort: 'name',
+        start: 0,
+        count: 2
+      })
+
+      const videos = res.body.data
+      expect(videos[ 0 ].name).to.equal('video 1 server 2')
+      expect(videos[ 1 ].name).to.equal('video 2 server 2')
+    }
+
+    {
+      const res = await listVideoRedundancies({
+        url: servers[0].url,
+        accessToken: servers[0].accessToken,
+        target: 'remote-videos',
+        sort: '-name',
+        start: 0,
+        count: 2
+      })
+
+      const videos = res.body.data
+      expect(videos[ 0 ].name).to.equal('video 2 server 2')
+      expect(videos[ 1 ].name).to.equal('video 1 server 2')
+    }
+
+    {
+      const res = await listVideoRedundancies({
+        url: servers[0].url,
+        accessToken: servers[0].accessToken,
+        target: 'remote-videos',
+        sort: '-name',
+        start: 1,
+        count: 1
+      })
+
+      const videos = res.body.data
+      expect(videos[ 0 ].name).to.equal('video 1 server 2')
+    }
+  })
+
+  it('Should manually add a redundancy and list it', async function () {
+    this.timeout(120000)
+
+    const uuid = (await uploadVideoAndGetId({ server: servers[ 1 ], videoName: 'video 3 server 2', privacy: VideoPrivacy.UNLISTED })).uuid
+    await waitJobs(servers)
+    const videoId = await getLocalIdByUUID(servers[0].url, uuid)
+
+    await addVideoRedundancy({
+      url: servers[0].url,
+      accessToken: servers[0].accessToken,
+      videoId
+    })
+
+    await waitJobs(servers)
+    await waitUntilLog(servers[0], 'Duplicated ', 15)
+    await waitJobs(servers)
+
+    {
+      const res = await listVideoRedundancies({
+        url: servers[0].url,
+        accessToken: servers[0].accessToken,
+        target: 'remote-videos',
+        sort: '-name',
+        start: 0,
+        count: 5
+      })
+
+      const videos = res.body.data
+      expect(videos[ 0 ].name).to.equal('video 3 server 2')
+
+      const video = videos[ 0 ]
+      expect(video.redundancies.files).to.have.lengthOf(4)
+      expect(video.redundancies.streamingPlaylists).to.have.lengthOf(1)
+
+      const redundancies = video.redundancies.files.concat(video.redundancies.streamingPlaylists)
+
+      for (const r of redundancies) {
+        redundanciesToRemove.push(r.id)
+
+        expect(r.strategy).to.equal('manual')
+        expect(r.fileUrl).to.exist
+        expect(r.createdAt).to.exist
+        expect(r.updatedAt).to.exist
+        expect(r.expiresOn).to.be.null
+      }
+    }
+
+    const res = await listVideoRedundancies({
+      url: servers[1].url,
+      accessToken: servers[1].accessToken,
+      target: 'my-videos',
+      sort: '-name',
+      start: 0,
+      count: 5
+    })
+
+    const videos = res.body.data
+    expect(videos[ 0 ].name).to.equal('video 3 server 2')
+
+    const video = videos[ 0 ]
+    expect(video.redundancies.files).to.have.lengthOf(4)
+    expect(video.redundancies.streamingPlaylists).to.have.lengthOf(1)
+
+    const redundancies = video.redundancies.files.concat(video.redundancies.streamingPlaylists)
+
+    for (const r of redundancies) {
+      expect(r.strategy).to.be.null
+      expect(r.fileUrl).to.exist
+      expect(r.createdAt).to.exist
+      expect(r.updatedAt).to.exist
+      expect(r.expiresOn).to.be.null
+    }
+  })
+
+  it('Should manually remove a redundancy and remove it from the list', async function () {
+    this.timeout(120000)
+
+    for (const redundancyId of redundanciesToRemove) {
+      await removeVideoRedundancy({
+        url: servers[ 0 ].url,
+        accessToken: servers[ 0 ].accessToken,
+        redundancyId
+      })
+    }
+
+    {
+      const res = await listVideoRedundancies({
+        url: servers[0].url,
+        accessToken: servers[0].accessToken,
+        target: 'remote-videos',
+        sort: '-name',
+        start: 0,
+        count: 5
+      })
+
+      const videos = res.body.data
+      expect(videos).to.have.lengthOf(2)
+
+      expect(videos[ 0 ].name).to.equal('video 2 server 2')
+
+      redundanciesToRemove = []
+      const video = videos[ 0 ]
+      expect(video.redundancies.files).to.have.lengthOf(4)
+      expect(video.redundancies.streamingPlaylists).to.have.lengthOf(1)
+
+      const redundancies = video.redundancies.files.concat(video.redundancies.streamingPlaylists)
+
+      for (const r of redundancies) {
+        redundanciesToRemove.push(r.id)
+      }
+    }
+  })
+
+  it('Should remove another (auto) redundancy', async function () {
+    {
+      for (const redundancyId of redundanciesToRemove) {
+        await removeVideoRedundancy({
+          url: servers[ 0 ].url,
+          accessToken: servers[ 0 ].accessToken,
+          redundancyId
+        })
+      }
+
+      const res = await listVideoRedundancies({
+        url: servers[0].url,
+        accessToken: servers[0].accessToken,
+        target: 'remote-videos',
+        sort: '-name',
+        start: 0,
+        count: 5
+      })
+
+      const videos = res.body.data
+      expect(videos[ 0 ].name).to.equal('video 1 server 2')
+      expect(videos).to.have.lengthOf(1)
+    }
+  })
+
+  after(async function () {
+    await cleanupTests(servers)
+  })
+})
index 1cdf93aa1996accb35dfdf7e39bdd18c59750547..f5bf130d59c361b3f6929b3f1d2c6dbd0f98558b 100644 (file)
@@ -5,7 +5,8 @@ import 'mocha'
 import { VideoDetails } from '../../../../shared/models/videos'
 import {
   checkSegmentHash,
-  checkVideoFilesWereRemoved, cleanupTests,
+  checkVideoFilesWereRemoved,
+  cleanupTests,
   doubleFollow,
   flushAndRunMultipleServers,
   getFollowingListPaginationAndSort,
@@ -28,11 +29,16 @@ import {
 import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
 
 import * as magnetUtil from 'magnet-uri'
-import { updateRedundancy } from '../../../../shared/extra-utils/server/redundancy'
+import {
+  addVideoRedundancy,
+  listVideoRedundancies,
+  removeVideoRedundancy,
+  updateRedundancy
+} from '../../../../shared/extra-utils/server/redundancy'
 import { ActorFollow } from '../../../../shared/models/actors'
 import { readdir } from 'fs-extra'
 import { join } from 'path'
-import { VideoRedundancyStrategy } from '../../../../shared/models/redundancy'
+import { VideoRedundancy, VideoRedundancyStrategy, VideoRedundancyStrategyWithManual } from '../../../../shared/models/redundancy'
 import { getStats } from '../../../../shared/extra-utils/server/stats'
 import { ServerStats } from '../../../../shared/models/server/server-stats.model'
 
@@ -40,6 +46,7 @@ const expect = chai.expect
 
 let servers: ServerInfo[] = []
 let video1Server2UUID: string
+let video1Server2Id: number
 
 function checkMagnetWebseeds (file: { magnetUri: string, resolution: { id: number } }, baseWebseeds: string[], server: ServerInfo) {
   const parsed = magnetUtil.decode(file.magnetUri)
@@ -52,7 +59,19 @@ function checkMagnetWebseeds (file: { magnetUri: string, resolution: { id: numbe
   expect(parsed.urlList).to.have.lengthOf(baseWebseeds.length)
 }
 
-async function flushAndRunServers (strategy: VideoRedundancyStrategy, additionalParams: any = {}) {
+async function flushAndRunServers (strategy: VideoRedundancyStrategy | null, additionalParams: any = {}) {
+  const strategies: any[] = []
+
+  if (strategy !== null) {
+    strategies.push(
+      immutableAssign({
+        min_lifetime: '1 hour',
+        strategy: strategy,
+        size: '400KB'
+      }, additionalParams)
+    )
+  }
+
   const config = {
     transcoding: {
       hls: {
@@ -62,16 +81,11 @@ async function flushAndRunServers (strategy: VideoRedundancyStrategy, additional
     redundancy: {
       videos: {
         check_interval: '5 seconds',
-        strategies: [
-          immutableAssign({
-            min_lifetime: '1 hour',
-            strategy: strategy,
-            size: '400KB'
-          }, additionalParams)
-        ]
+        strategies
       }
     }
   }
+
   servers = await flushAndRunMultipleServers(3, config)
 
   // Get the access tokens
@@ -80,6 +94,7 @@ async function flushAndRunServers (strategy: VideoRedundancyStrategy, additional
   {
     const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 1 server 2' })
     video1Server2UUID = res.body.video.uuid
+    video1Server2Id = res.body.video.id
 
     await viewVideo(servers[ 1 ].url, video1Server2UUID)
   }
@@ -216,29 +231,38 @@ async function check1PlaylistRedundancies (videoUUID?: string) {
   }
 }
 
-async function checkStatsWith2Webseed (strategy: VideoRedundancyStrategy) {
+async function checkStatsGlobal (strategy: VideoRedundancyStrategyWithManual) {
+  let totalSize: number = null
+  let statsLength = 1
+
+  if (strategy !== 'manual') {
+    totalSize = 409600
+    statsLength = 2
+  }
+
   const res = await getStats(servers[0].url)
   const data: ServerStats = res.body
 
-  expect(data.videosRedundancy).to.have.lengthOf(1)
-  const stat = data.videosRedundancy[0]
+  expect(data.videosRedundancy).to.have.lengthOf(statsLength)
 
+  const stat = data.videosRedundancy[0]
   expect(stat.strategy).to.equal(strategy)
-  expect(stat.totalSize).to.equal(409600)
+  expect(stat.totalSize).to.equal(totalSize)
+
+  return stat
+}
+
+async function checkStatsWith2Webseed (strategy: VideoRedundancyStrategyWithManual) {
+  const stat = await checkStatsGlobal(strategy)
+
   expect(stat.totalUsed).to.be.at.least(1).and.below(409601)
   expect(stat.totalVideoFiles).to.equal(4)
   expect(stat.totalVideos).to.equal(1)
 }
 
-async function checkStatsWith1Webseed (strategy: VideoRedundancyStrategy) {
-  const res = await getStats(servers[0].url)
-  const data: ServerStats = res.body
-
-  expect(data.videosRedundancy).to.have.lengthOf(1)
+async function checkStatsWith1Webseed (strategy: VideoRedundancyStrategyWithManual) {
+  const stat = await checkStatsGlobal(strategy)
 
-  const stat = data.videosRedundancy[0]
-  expect(stat.strategy).to.equal(strategy)
-  expect(stat.totalSize).to.equal(409600)
   expect(stat.totalUsed).to.equal(0)
   expect(stat.totalVideoFiles).to.equal(0)
   expect(stat.totalVideos).to.equal(0)
@@ -446,6 +470,74 @@ describe('Test videos redundancy', function () {
     })
   })
 
+  describe('With manual strategy', function () {
+    before(function () {
+      this.timeout(120000)
+
+      return flushAndRunServers(null)
+    })
+
+    it('Should have 1 webseed on the first video', async function () {
+      await check1WebSeed()
+      await check0PlaylistRedundancies()
+      await checkStatsWith1Webseed('manual')
+    })
+
+    it('Should create a redundancy on first video', async function () {
+      await addVideoRedundancy({
+        url: servers[0].url,
+        accessToken: servers[0].accessToken,
+        videoId: video1Server2Id
+      })
+    })
+
+    it('Should have 2 webseeds on the first video', async function () {
+      this.timeout(80000)
+
+      await waitJobs(servers)
+      await waitUntilLog(servers[0], 'Duplicated ', 5)
+      await waitJobs(servers)
+
+      await check2Webseeds()
+      await check1PlaylistRedundancies()
+      await checkStatsWith2Webseed('manual')
+    })
+
+    it('Should manually remove redundancies on server 1 and remove duplicated videos', async function () {
+      this.timeout(80000)
+
+      const res = await listVideoRedundancies({
+        url: servers[0].url,
+        accessToken: servers[0].accessToken,
+        target: 'remote-videos'
+      })
+
+      const videos = res.body.data as VideoRedundancy[]
+      expect(videos).to.have.lengthOf(1)
+
+      const video = videos[0]
+      for (const r of video.redundancies.files.concat(video.redundancies.streamingPlaylists)) {
+        await removeVideoRedundancy({
+          url: servers[0].url,
+          accessToken: servers[0].accessToken,
+          redundancyId: r.id
+        })
+      }
+
+      await waitJobs(servers)
+      await wait(5000)
+
+      await check1WebSeed()
+      await check0PlaylistRedundancies()
+
+      await checkVideoFilesWereRemoved(video1Server2UUID, servers[0].serverNumber, [ 'videos' ])
+    })
+
+    after(async function () {
+      await cleanupTests(servers)
+    })
+  })
+
   describe('Test expiration', function () {
     const strategy = 'recently-added'
 
index 352fe3d3264c480d96205792ab13300a0b0e3db7..139b22b2c931b6ea37ad72333774385934c5ed08 100644 (file)
@@ -1,7 +1,7 @@
 import { VideoFileModel } from '../../../models/video/video-file'
 import { PickWith, PickWithOpt } from '../../utils'
 import { MVideo, MVideoUUID } from './video'
-import { MVideoRedundancyFileUrl } from './video-redundancy'
+import { MVideoRedundancy, MVideoRedundancyFileUrl } from './video-redundancy'
 import { MStreamingPlaylistVideo, MStreamingPlaylist } from './video-streaming-playlist'
 
 type Use<K extends keyof VideoFileModel, M> = PickWith<VideoFileModel, K, M>
@@ -22,6 +22,9 @@ export type MVideoFileStreamingPlaylistVideo = MVideoFile &
 export type MVideoFileVideoUUID = MVideoFile &
   Use<'Video', MVideoUUID>
 
+export type MVideoFileRedundanciesAll = MVideoFile &
+  PickWithOpt<VideoFileModel, 'RedundancyVideos', MVideoRedundancy[]>
+
 export type MVideoFileRedundanciesOpt = MVideoFile &
   PickWithOpt<VideoFileModel, 'RedundancyVideos', MVideoRedundancyFileUrl[]>
 
index 436c0c0726a9db50e9b2c88705a5819b6ea21225..6fd4899452e23d36ad27b36d4a8533f51d931135 100644 (file)
@@ -1,6 +1,6 @@
 import { VideoStreamingPlaylistModel } from '../../../models/video/video-streaming-playlist'
 import { PickWith, PickWithOpt } from '../../utils'
-import { MVideoRedundancyFileUrl } from './video-redundancy'
+import { MVideoRedundancyFileUrl, MVideoRedundancy } from './video-redundancy'
 import { MVideo } from './video'
 import { MVideoFile } from './video-file'
 
@@ -20,6 +20,10 @@ export type MStreamingPlaylistFilesVideo = MStreamingPlaylist &
   Use<'VideoFiles', MVideoFile[]> &
   Use<'Video', MVideo>
 
+export type MStreamingPlaylistRedundanciesAll = MStreamingPlaylist &
+  Use<'VideoFiles', MVideoFile[]> &
+  Use<'RedundancyVideos', MVideoRedundancy[]>
+
 export type MStreamingPlaylistRedundancies = MStreamingPlaylist &
   Use<'VideoFiles', MVideoFile[]> &
   Use<'RedundancyVideos', MVideoRedundancyFileUrl[]>
index 7f69a91dea37b770ee0f7d2c6ecb036af601ecb0..bcc5e5028e326a95e23b627e4dace41dac7aac92 100644 (file)
@@ -10,8 +10,13 @@ import {
 } from './video-channels'
 import { MTag } from './tag'
 import { MVideoCaptionLanguage } from './video-caption'
-import { MStreamingPlaylistFiles, MStreamingPlaylistRedundancies, MStreamingPlaylistRedundanciesOpt } from './video-streaming-playlist'
-import { MVideoFile, MVideoFileRedundanciesOpt } from './video-file'
+import {
+  MStreamingPlaylistFiles,
+  MStreamingPlaylistRedundancies,
+  MStreamingPlaylistRedundanciesAll,
+  MStreamingPlaylistRedundanciesOpt
+} from './video-streaming-playlist'
+import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file'
 import { MThumbnail } from './thumbnail'
 import { MVideoBlacklist, MVideoBlacklistLight, MVideoBlacklistUnfederated } from './video-blacklist'
 import { MScheduleVideoUpdate } from './schedule-video-update'
@@ -158,6 +163,10 @@ export type MVideoForUser = MVideo &
   Use<'VideoBlacklist', MVideoBlacklistLight> &
   Use<'Thumbnails', MThumbnail[]>
 
+export type MVideoForRedundancyAPI = MVideo &
+  Use<'VideoFiles', MVideoFileRedundanciesAll[]> &
+  Use<'VideoStreamingPlaylists', MStreamingPlaylistRedundanciesAll[]>
+
 // ############################################################################
 
 // Format for API or AP object
index c39ff2c8be4baaa477dfa5fa1736c563aa2fc621..7b488e23e15423441b2cf5993c6ded0ff65bf623 100644 (file)
@@ -1,6 +1,7 @@
-import { makePutBodyRequest } from '../requests/requests'
+import { makeDeleteRequest, makeGetRequest, makePostBodyRequest, makePutBodyRequest } from '../requests/requests'
+import { VideoRedundanciesTarget } from '@shared/models'
 
-async function updateRedundancy (url: string, accessToken: string, host: string, redundancyAllowed: boolean, expectedStatus = 204) {
+function updateRedundancy (url: string, accessToken: string, host: string, redundancyAllowed: boolean, expectedStatus = 204) {
   const path = '/api/v1/server/redundancy/' + host
 
   return makePutBodyRequest({
@@ -12,6 +13,69 @@ async function updateRedundancy (url: string, accessToken: string, host: string,
   })
 }
 
+function listVideoRedundancies (options: {
+  url: string
+  accessToken: string,
+  target: VideoRedundanciesTarget,
+  start?: number,
+  count?: number,
+  sort?: string,
+  statusCodeExpected?: number
+}) {
+  const path = '/api/v1/server/redundancy/videos'
+
+  const { url, accessToken, target, statusCodeExpected, start, count, sort } = options
+
+  return makeGetRequest({
+    url,
+    token: accessToken,
+    path,
+    query: {
+      start: start ?? 0,
+      count: count ?? 5,
+      sort: sort ?? 'name',
+      target
+    },
+    statusCodeExpected: statusCodeExpected || 200
+  })
+}
+
+function addVideoRedundancy (options: {
+  url: string,
+  accessToken: string,
+  videoId: number
+}) {
+  const path = '/api/v1/server/redundancy/videos'
+  const { url, accessToken, videoId } = options
+
+  return makePostBodyRequest({
+    url,
+    token: accessToken,
+    path,
+    fields: { videoId },
+    statusCodeExpected: 204
+  })
+}
+
+function removeVideoRedundancy (options: {
+  url: string,
+  accessToken: string,
+  redundancyId: number
+}) {
+  const { url, accessToken, redundancyId } = options
+  const path = '/api/v1/server/redundancy/videos/' + redundancyId
+
+  return makeDeleteRequest({
+    url,
+    token: accessToken,
+    path,
+    statusCodeExpected: 204
+  })
+}
+
 export {
-  updateRedundancy
+  updateRedundancy,
+  listVideoRedundancies,
+  addVideoRedundancy,
+  removeVideoRedundancy
 }
index 7a77a03addcddf3f81de80b3c6914f2161493c13..aa13273aef7f194e38d74f583f317787d32063d3 100644 (file)
@@ -607,15 +607,28 @@ async function videoUUIDToId (url: string, id: number | string) {
   return res.body.id
 }
 
-async function uploadVideoAndGetId (options: { server: ServerInfo, videoName: string, nsfw?: boolean, token?: string }) {
+async function uploadVideoAndGetId (options: {
+  server: ServerInfo,
+  videoName: string,
+  nsfw?: boolean,
+  privacy?: VideoPrivacy,
+  token?: string
+}) {
   const videoAttrs: any = { name: options.videoName }
   if (options.nsfw) videoAttrs.nsfw = options.nsfw
+  if (options.privacy) videoAttrs.privacy = options.privacy
 
   const res = await uploadVideo(options.server.url, options.token || options.server.accessToken, videoAttrs)
 
   return { id: res.body.video.id, uuid: res.body.video.uuid }
 }
 
+async function getLocalIdByUUID (url: string, uuid: string) {
+  const res = await getVideo(url, uuid)
+
+  return res.body.id
+}
+
 // ---------------------------------------------------------------------------
 
 export {
@@ -645,5 +658,6 @@ export {
   completeVideoCheck,
   checkVideoFilesWereRemoved,
   getPlaylistVideos,
-  uploadVideoAndGetId
+  uploadVideoAndGetId,
+  getLocalIdByUUID
 }
index 61bf0fca7d78f10e03ae75b6be0befa46f85647e..649cc489f88b9498c6361ab2e7fb0e27ea3c5ff7 100644 (file)
@@ -1 +1,3 @@
-export * from './videos-redundancy.model'
+export * from './videos-redundancy-strategy.model'
+export * from './video-redundancies-filters.model'
+export * 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 (file)
index 0000000..05ba7df
--- /dev/null
@@ -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 (file)
index 0000000..014f696
--- /dev/null
@@ -0,0 +1,33 @@
+export interface VideoRedundancy {
+  id: number
+  name: string
+  url: string
+  uuid: string
+
+  redundancies: {
+    files: FileRedundancyInformation[]
+
+    streamingPlaylists: StreamingPlaylistRedundancyInformation[]
+  }
+}
+
+interface RedundancyInformation {
+  id: number
+  fileUrl: string
+  strategy: string
+
+  createdAt: Date | string
+  updatedAt: Date | string
+
+  expiresOn: Date | string
+
+  size: number
+}
+
+export interface FileRedundancyInformation extends RedundancyInformation {
+
+}
+
+export interface StreamingPlaylistRedundancyInformation extends RedundancyInformation {
+
+}
diff --git a/shared/models/redundancy/videos-redundancy-strategy.model.ts b/shared/models/redundancy/videos-redundancy-strategy.model.ts
new file mode 100644 (file)
index 0000000..15409ab
--- /dev/null
@@ -0,0 +1,23 @@
+export type VideoRedundancyStrategy = 'most-views' | 'trending' | 'recently-added'
+export type VideoRedundancyStrategyWithManual = VideoRedundancyStrategy | 'manual'
+
+export type MostViewsRedundancyStrategy = {
+  strategy: 'most-views'
+  size: number
+  minLifetime: number
+}
+
+export type TrendingRedundancyStrategy = {
+  strategy: 'trending'
+  size: number
+  minLifetime: number
+}
+
+export type RecentlyAddedStrategy = {
+  strategy: 'recently-added'
+  size: number
+  minViews: number
+  minLifetime: number
+}
+
+export type VideosRedundancyStrategy = MostViewsRedundancyStrategy | TrendingRedundancyStrategy | RecentlyAddedStrategy
diff --git a/shared/models/redundancy/videos-redundancy.model.ts b/shared/models/redundancy/videos-redundancy.model.ts
deleted file mode 100644 (file)
index a8c2743..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-export type VideoRedundancyStrategy = 'most-views' | 'trending' | 'recently-added'
-
-export type MostViewsRedundancyStrategy = {
-  strategy: 'most-views'
-  size: number
-  minLifetime: number
-}
-
-export type TrendingRedundancyStrategy = {
-  strategy: 'trending'
-  size: number
-  minLifetime: number
-}
-
-export type RecentlyAddedStrategy = {
-  strategy: 'recently-added'
-  size: number
-  minViews: number
-  minLifetime: number
-}
-
-export type VideosRedundancy = MostViewsRedundancyStrategy | TrendingRedundancyStrategy | RecentlyAddedStrategy
index b82a633b2917e3767fb812beff7decebfbf15efb..19fd4c65924531b14505bb9b0dd34bef590cdf0e 100644 (file)
@@ -9,7 +9,8 @@ export type JobType = 'activitypub-http-unicast' |
   'email' |
   'video-import' |
   'videos-views' |
-  'activitypub-refresher'
+  'activitypub-refresher' |
+  'video-redundancy'
 
 export interface Job {
   id: number
index 74f3de5d3094724e29d7de55efc6c12e10c6f70d..11778e6ed5da3b93e43770eb5ccd9269bef7b714 100644 (file)
@@ -1,4 +1,4 @@
-import { VideoRedundancyStrategy } from '../redundancy'
+import { VideoRedundancyStrategyWithManual } from '../redundancy'
 
 export interface ServerStats {
   totalUsers: number
@@ -13,11 +13,13 @@ export interface ServerStats {
   totalInstanceFollowers: number
   totalInstanceFollowing: number
 
-  videosRedundancy: {
-    strategy: VideoRedundancyStrategy
-    totalSize: number
-    totalUsed: number
-    totalVideoFiles: number
-    totalVideos: number
-  }[]
+  videosRedundancy: VideosRedundancyStats[]
+}
+
+export interface VideosRedundancyStats {
+  strategy: VideoRedundancyStrategyWithManual
+  totalSize: number
+  totalUsed: number
+  totalVideoFiles: number
+  totalVideos: number
 }
index 4a28a229d21fc429b5ebcf4af2870528df0e2ff3..2f88a65ded827a7c24be60119586bca7f2c9903b 100644 (file)
@@ -33,5 +33,7 @@ export enum UserRight {
   SEE_ALL_VIDEOS,
   CHANGE_VIDEO_OWNERSHIP,
 
-  MANAGE_PLUGINS
+  MANAGE_PLUGINS,
+
+  MANAGE_VIDEOS_REDUNDANCIES
 }
index 7576439fe5f3751439b0fee7b93f629f4178a6a5..a69152759aa0e77d43de607bf0a81ed02b2f1d48 100644 (file)
@@ -1,4 +1,4 @@
-import { AccountSummary, VideoChannelSummary, VideoResolution, VideoState } from '../../index'
+import { AccountSummary, VideoChannelSummary, VideoState } from '../../index'
 import { Account } from '../actors'
 import { VideoChannel } from './channel/video-channel.model'
 import { VideoPrivacy } from './video-privacy.enum'