"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",
</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">
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 {
} 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'
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: [
FollowersListComponent,
FollowingListComponent,
RedundancyCheckboxComponent,
+ VideoRedundanciesListComponent,
+ VideoRedundancyInformationComponent,
UsersComponent,
UserCreateComponent,
],
providers: [
- RedundancyService,
JobService,
LogsService,
DebugService,
<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>
<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>
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 = [
{
title: 'Add follow'
}
}
+ },
+ {
+ path: 'video-redundancies-list',
+ component: VideoRedundanciesListComponent
}
]
}
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'
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',
+++ /dev/null
-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))
- )
- }
-
-}
--- /dev/null
+export * from './video-redundancies-list.component'
--- /dev/null
+<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>
--- /dev/null
+@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;
+ }
+ }
+}
--- /dev/null
+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)
+ }
+}
--- /dev/null
+<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>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+.label {
+ display: inline-block;
+ min-width: 100px;
+ font-weight: $font-semibold;
+}
--- /dev/null
+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
+}
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' ]
'video-file-import',
'video-import',
'videos-views',
- 'activitypub-refresher'
+ 'activitypub-refresher',
+ 'video-redundancy'
]
jobs: Job[] = []
}
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)
}
}
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 {
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>()
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',
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'),
-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',
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)
}
}
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: [
UserNotificationService,
FollowService,
+ RedundancyService,
I18n
]
--- /dev/null
+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))
+ )
+ }
+}
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
blacklist?: boolean
delete?: boolean
report?: boolean
+ duplicate?: boolean
}
@Component({
update: true,
blacklist: true,
delete: true,
- report: true
+ report: true,
+ duplicate: true
}
@Input() placement = 'left'
private screenService: ScreenService,
private videoService: VideoService,
private blocklistService: BlocklistService,
+ private redundancyService: RedundancyService,
private i18n: I18n
) { }
return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled
}
+ canVideoBeDuplicated () {
+ return this.video.canBeDuplicatedBy(this.user)
+ }
+
/* Action handlers */
async unblacklistVideo () {
)
}
+ 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()
}
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(),
update: true,
blacklist: true,
delete: true,
- report: true
+ report: true,
+ duplicate: false
}
showActions = false
serverConfig: ServerConfig
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)
+ }
}
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"
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==
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"
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"
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
"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",
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"
} 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'
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 })
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()
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 {
// ---------------------------------------------------------------------------
+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
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)
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()
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 }))
})
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))
}
--- /dev/null
+import { exists } from './misc'
+
+function isVideoRedundancyTarget (value: any) {
+ return exists(value) &&
+ (value === 'my-videos' || value === 'remote-videos')
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+ isVideoRedundancyTarget
+}
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)
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'
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
// ---------------------------------------------------------------------------
-const LAST_MIGRATION_VERSION = 470
+const LAST_MIGRATION_VERSION = 475
// ---------------------------------------------------------------------------
PLUGINS: [ 'name', 'createdAt', 'updatedAt' ],
- AVAILABLE_PLUGINS: [ 'npmName', 'popularity' ]
+ AVAILABLE_PLUGINS: [ 'npmName', 'popularity' ],
+
+ VIDEO_REDUNDANCIES: [ 'name' ]
}
const OAUTH_LIFETIME = {
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': {
--- /dev/null
+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
+}
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,
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,
--- /dev/null
+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
+}
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 } |
{ 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[] = [
'video-file-import',
'video-import',
'videos-views',
- 'activitypub-refresher'
+ 'activitypub-refresher',
+ 'video-redundancy'
]
class JobQueue {
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)
}
}
// ---------------------------------------------------------------------------
export {
- removeRedundancyOf,
+ removeRedundanciesOfServer,
removeVideoRedundancy
}
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'
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'
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[]
export class VideosRedundancyScheduler extends AbstractScheduler {
- private static instance: AbstractScheduler
+ private static instance: VideosRedundancyScheduler
protected schedulerIntervalMs = CONFIG.REDUNDANCY.VIDEOS.CHECK_INTERVAL
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)
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: [],
}
}
- private findVideoToDuplicate (cache: VideosRedundancy) {
+ private findVideoToDuplicate (cache: VideosRedundancyStrategy) {
if (cache.strategy === 'most-views') {
return VideoRedundancyModel.findMostViewToDuplicate(REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR)
}
}
}
- 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)
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
})
}
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
})
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: '' }
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()
+ }
+}
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'),
}
]
+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
}
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)
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)
// ---------------------------------------------------------------------------
serversBlocklistSortValidator,
userNotificationsSortValidator,
videoPlaylistsSortValidator,
+ videoRedundanciesSortValidator,
pluginsSortValidator
}
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'
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'
@UpdatedAt
updatedAt: Date
- @AllowNull(false)
+ @AllowNull(true)
@Column
expiresOn: Date
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: {
[Op.ne]: actor.id
},
expiresOn: {
- [ Op.lt ]: new Date()
+ [ Op.lt ]: new Date(),
+ [ Op.ne ]: null
}
}
}
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 = {
}))
}
+ 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
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',
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,
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
// ---------------------------------------------------------------
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 () {
import './redundancy'
+import './manage-redundancy'
--- /dev/null
+/* 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)
+ })
+})
import { VideoDetails } from '../../../../shared/models/videos'
import {
checkSegmentHash,
- checkVideoFilesWereRemoved, cleanupTests,
+ checkVideoFilesWereRemoved,
+ cleanupTests,
doubleFollow,
flushAndRunMultipleServers,
getFollowingListPaginationAndSort,
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'
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)
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: {
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
{
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)
}
}
}
-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)
})
})
+ 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'
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>
export type MVideoFileVideoUUID = MVideoFile &
Use<'Video', MVideoUUID>
+export type MVideoFileRedundanciesAll = MVideoFile &
+ PickWithOpt<VideoFileModel, 'RedundancyVideos', MVideoRedundancy[]>
+
export type MVideoFileRedundanciesOpt = MVideoFile &
PickWithOpt<VideoFileModel, 'RedundancyVideos', MVideoRedundancyFileUrl[]>
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'
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[]>
} 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'
Use<'VideoBlacklist', MVideoBlacklistLight> &
Use<'Thumbnails', MThumbnail[]>
+export type MVideoForRedundancyAPI = MVideo &
+ Use<'VideoFiles', MVideoFileRedundanciesAll[]> &
+ Use<'VideoStreamingPlaylists', MStreamingPlaylistRedundanciesAll[]>
+
// ############################################################################
// Format for API or AP object
-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({
})
}
+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
}
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 {
completeVideoCheck,
checkVideoFilesWereRemoved,
getPlaylistVideos,
- uploadVideoAndGetId
+ uploadVideoAndGetId,
+ getLocalIdByUUID
}
-export * from './videos-redundancy.model'
+export * from './videos-redundancy-strategy.model'
+export * from './video-redundancies-filters.model'
+export * from './video-redundancy.model'
--- /dev/null
+export type VideoRedundanciesTarget = 'my-videos' | 'remote-videos'
--- /dev/null
+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 {
+
+}
--- /dev/null
+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
+++ /dev/null
-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
'email' |
'video-import' |
'videos-views' |
- 'activitypub-refresher'
+ 'activitypub-refresher' |
+ 'video-redundancy'
export interface Job {
id: number
-import { VideoRedundancyStrategy } from '../redundancy'
+import { VideoRedundancyStrategyWithManual } from '../redundancy'
export interface ServerStats {
totalUsers: number
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
}
SEE_ALL_VIDEOS,
CHANGE_VIDEO_OWNERSHIP,
- MANAGE_PLUGINS
+ MANAGE_PLUGINS,
+
+ MANAGE_VIDEOS_REDUNDANCIES
}
-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'