Add user import/export in client

pull/6266/head
Chocobozzz 2024-02-12 10:50:29 +01:00 committed by Chocobozzz
parent 35f0bb14be
commit f9c89b98f7
61 changed files with 991 additions and 180 deletions

View File

@ -10,7 +10,7 @@ export class AdminConfigPage {
}
await go('/admin/config/edit-custom#' + tab)
await $('.inner-form-title=' + waitTitles[tab]).waitForDisplayed()
await $('.section-left-column-title=' + waitTitles[tab]).waitForDisplayed()
}
async updateNSFWSetting (newValue: 'do_not_list' | 'blur' | 'display') {

View File

@ -3,7 +3,7 @@
<div class="row mt-5"> <!-- cache grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="inner-form-title">CACHE</h2>
<h2 i18n class="section-left-column-title">CACHE</h2>
<div i18n class="inner-form-description">
Some files are not federated, and fetched when necessary. Define their caching policies.
</div>
@ -74,7 +74,7 @@
<div class="row mt-4"> <!-- cache grid -->
<div class="col-12 col-lg-4 col-xl-3">
<div class="anchor" id="customizations"></div> <!-- customizations anchor -->
<h2 i18n class="inner-form-title">CUSTOMIZATIONS</h2>
<h2 i18n class="section-left-column-title">CUSTOMIZATIONS</h2>
<div i18n class="inner-form-description">
Slight modifications to your PeerTube instance for when creating a plugin or theme is overkill.
</div>

View File

@ -1,7 +1,7 @@
<ng-container [formGroup]="form">
<div class="row mt-5"> <!-- appearance grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="inner-form-title">APPEARANCE</h2>
<h2 i18n class="section-left-column-title">APPEARANCE</h2>
<div i18n class="inner-form-description">
Use <a class="link-orange" routerLink="/admin/plugins">plugins & themes</a> for more involved changes, or add slight <a class="link-orange" routerLink="/admin/config/edit-custom" fragment="advanced-configuration">customizations</a>.
</div>
@ -91,7 +91,7 @@
<div class="row mt-4"> <!-- broadcast grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="inner-form-title">BROADCAST MESSAGE</h2>
<h2 i18n class="section-left-column-title">BROADCAST MESSAGE</h2>
<div i18n class="inner-form-description">
Display a message on your instance
</div>
@ -147,7 +147,7 @@
<div class="row mt-4"> <!-- new users grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="inner-form-title">NEW USERS</h2>
<h2 i18n class="section-left-column-title">NEW USERS</h2>
<div i18n class="inner-form-description">
Manage <a class="link-orange" routerLink="/admin/users">users</a> to set their quota individually.
</div>
@ -228,7 +228,7 @@
[clearable]="false"
></my-select-custom-value>
<my-user-real-quota-info [videoQuota]="getUserVideoQuota()"></my-user-real-quota-info>
<my-user-real-quota-info class="mt-2 d-block small muted" [videoQuota]="getUserVideoQuota()"></my-user-real-quota-info>
<div *ngIf="formErrors.user.videoQuota" class="form-error" role="alert">{{ formErrors.user.videoQuota }}</div>
</div>
@ -264,7 +264,7 @@
<div class="row mt-4"> <!-- videos grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="inner-form-title">VIDEOS</h2>
<h2 i18n class="section-left-column-title">VIDEOS</h2>
</div>
<div class="col-12 col-lg-8 col-xl-9">
@ -376,7 +376,7 @@
<div class="row mt-4"> <!-- video channels grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="inner-form-title">VIDEO CHANNELS</h2>
<h2 i18n class="section-left-column-title">VIDEO CHANNELS</h2>
</div>
<div class="col-12 col-lg-8 col-xl-9">
@ -398,7 +398,7 @@
<div class="row mt-4"> <!-- search grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="inner-form-title">SEARCH</h2>
<h2 i18n class="section-left-column-title">SEARCH</h2>
</div>
<div class="col-12 col-lg-8 col-xl-9">
@ -485,9 +485,85 @@
</div>
</div>
<div class="row mt-4"> <!-- import/export grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="section-left-column-title">USER IMPORT/EXPORT</h2>
</div>
<div class="col-12 col-lg-8 col-xl-9">
<ng-container formGroupName="import">
<ng-container formGroupName="users">
<div class="form-group">
<my-peertube-checkbox
inputName="importUsersEnabled" formControlName="enabled"
i18n-labelText labelText="Allow your users to import a data archive"
>
<ng-container ngProjectAs="description">
<div i18n>Video quota is checked on import so the user doesn't upload a too big archive file</div>
<div i18n>Video quota (daily quota is not taken into account) is also checked for each video when PeerTube is processing the import</div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
<ng-container formGroupName="export">
<ng-container formGroupName="users">
<div class="form-group">
<my-peertube-checkbox
inputName="exportUsersEnabled" formControlName="enabled"
i18n-labelText labelText="Allow your users to export their data"
>
<ng-container ngProjectAs="description">
<span i18n>Users can export their PeerTube data in a .zip for backup or re-import. Only one export at a time is allowed per user</span>
</ng-container>
<ng-container ngProjectAs="extra">
<div class="form-group" [ngClass]="getDisabledExportUsersClass()">
<label i18n for="exportUsersMaxUserVideoQuota">Max user video quota allowed to generate the export</label>
<span i18n class="ms-2 small muted">If the user decides to include the video files in the archive</span>
<my-select-custom-value
id="exportUsersMaxUserVideoQuota"
[items]="exportMaxUserVideoQuotaOptions"
formControlName="maxUserVideoQuota"
i18n-inputSuffix inputSuffix="bytes" inputType="number"
[clearable]="false"
></my-select-custom-value>
<div *ngIf="formErrors.export.users.maxUserVideoQuota" class="form-error" role="alert">{{ formErrors.export.users.maxUserVideoQuota }}</div>
</div>
<div class="form-group" [ngClass]="getDisabledExportUsersClass()">
<label i18n for="exportUsersExportExpiration">User export expiration</label>
<my-select-options
labelForId="exportUsersExportExpiration" [items]="exportExpirationOptions" formControlName="exportExpiration"
bindLabel="label" bindValue="value" [clearable]="false" [searchable]="false"
></my-select-options>
<div i18n class="mt-1 small muted">The archive file is deleted after this period.</div>
<div *ngIf="formErrors.export.users.exportExpiration" class="form-error" role="alert">{{ formErrors.export.users.exportExpiration }}</div>
</div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</div>
</div>
<div class="row mt-4"> <!-- federation grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="inner-form-title">FEDERATION</h2>
<h2 i18n class="section-left-column-title">FEDERATION</h2>
<div i18n class="inner-form-description">
Manage <a class="link-orange" routerLink="/admin/follows">relations</a> with other instances.
</div>
@ -566,7 +642,7 @@
<div class="row mt-4"> <!-- administrators grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="inner-form-title">ADMINISTRATORS</h2>
<h2 i18n class="section-left-column-title">ADMINISTRATORS</h2>
</div>
<div class="col-12 col-lg-8 col-xl-9">
@ -594,7 +670,7 @@
<div class="row mt-4"> <!-- Twitter grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="inner-form-title">TWITTER</h2>
<h2 i18n class="section-left-column-title">TWITTER</h2>
<div i18n class="inner-form-description">
Provide the Twitter account representing your instance to improve link previews.
If you don't have a Twitter account, just leave the default value.

View File

@ -21,6 +21,9 @@ export class EditBasicConfigurationComponent implements OnInit, OnChanges {
defaultLandingPageOptions: SelectOptionsItem[] = []
availableThemes: SelectOptionsItem[]
exportExpirationOptions: SelectOptionsItem[] = []
exportMaxUserVideoQuotaOptions: SelectOptionsItem[] = []
constructor (
private configService: ConfigService,
private menuService: MenuService,
@ -33,6 +36,15 @@ export class EditBasicConfigurationComponent implements OnInit, OnChanges {
this.checkImportSyncField()
this.availableThemes = this.themeService.buildAvailableThemes()
this.exportExpirationOptions = [
{ id: 1000 * 3600 * 24, label: $localize`1 day` },
{ id: 1000 * 3600 * 24 * 2, label: $localize`2 days` },
{ id: 1000 * 3600 * 24 * 7, label: $localize`7 days` },
{ id: 1000 * 3600 * 24 * 30, label: $localize`30 days` }
]
this.exportMaxUserVideoQuotaOptions = this.configService.videoQuotaOptions.filter(o => (o.id as number) >= 1)
}
ngOnChanges (changes: SimpleChanges) {
@ -64,6 +76,14 @@ export class EditBasicConfigurationComponent implements OnInit, OnChanges {
return this.form.value['user']['videoQuota']
}
isExportUsersEnabled () {
return this.form.value['export']['users']['enabled'] === true
}
getDisabledExportUsersClass () {
return { 'disabled-checkbox-extra': !this.isExportUsersEnabled() }
}
isSignupEnabled () {
return this.form.value['signup']['enabled'] === true
}

View File

@ -79,10 +79,6 @@ input[type=submit] {
}
}
.inner-form-title {
@include settings-big-title;
}
.inner-form-description {
font-size: 15px;
margin-bottom: 15px;
@ -152,12 +148,6 @@ ngb-tabset:not(.previews) ::ng-deep {
}
}
my-user-real-quota-info {
display: block;
margin-top: 5px;
font-size: 11px;
}
my-actor-banner-edit {
max-width: $form-max-width;
}

View File

@ -10,6 +10,8 @@ import {
ADMIN_EMAIL_VALIDATOR,
CACHE_SIZE_VALIDATOR,
CONCURRENCY_VALIDATOR,
EXPORT_EXPIRATION_VALIDATOR,
EXPORT_MAX_USER_VIDEO_QUOTA_VALIDATOR,
INDEX_URL_VALIDATOR,
INSTANCE_NAME_VALIDATOR,
INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
@ -149,6 +151,16 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
},
videoChannelSynchronization: {
enabled: null
},
users: {
enabled: null
}
},
export: {
users: {
enabled: null,
maxUserVideoQuota: EXPORT_MAX_USER_VIDEO_QUOTA_VALIDATOR,
exportExpiration: EXPORT_EXPIRATION_VALIDATOR
}
},
trending: {

View File

@ -4,7 +4,7 @@
<div class="homepage row mt-5"> <!-- homepage grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="inner-form-title">INSTANCE HOMEPAGE</h2>
<h2 i18n class="section-left-column-title">INSTANCE HOMEPAGE</h2>
</div>
<div class="col-12 col-lg-8 col-xl-9">

View File

@ -4,7 +4,7 @@
<div class="row mt-5"> <!-- instance grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="inner-form-title">INSTANCE</h2>
<h2 i18n class="section-left-column-title">INSTANCE</h2>
</div>
<div class="col-12 col-lg-8 col-xl-9">
@ -81,7 +81,7 @@
<div class="row mt-4"> <!-- moderation & nsfw grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="inner-form-title">MODERATION & NSFW</h2>
<h2 i18n class="section-left-column-title">MODERATION & NSFW</h2>
<div i18n class="inner-form-description">
Manage <a class="link-orange" routerLink="/admin/users">users</a> to build a moderation team.
</div>
@ -159,7 +159,7 @@
<div class="row mt-4"> <!-- you and your instance grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="inner-form-title">YOU AND YOUR INSTANCE</h2>
<h2 i18n class="section-left-column-title">YOU AND YOUR INSTANCE</h2>
</div>
<div class="col-12 col-lg-8 col-xl-9">
@ -209,7 +209,7 @@
<div class="row mt-4"> <!-- other information grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="inner-form-title">OTHER INFORMATION</h2>
<h2 i18n class="section-left-column-title">OTHER INFORMATION</h2>
</div>
<div class="col-12 col-lg-8 col-xl-9">

View File

@ -2,7 +2,7 @@
<div class="row mt-5">
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="inner-form-title">LIVE</h2>
<h2 i18n class="section-left-column-title">LIVE</h2>
<div i18n class="inner-form-description">
Enable users of your instance to stream live.
</div>
@ -89,7 +89,7 @@
<div class="row"> <!-- transcoding live streams grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="inner-form-title">TRANSCODING</h2>
<h2 i18n class="section-left-column-title">TRANSCODING</h2>
<div i18n class="inner-form-description">
Same as VOD transcoding, transcoding live streams so that they are in a streamable form that any device can play. Requires a beefy CPU, and then some.
</div>

View File

@ -18,7 +18,7 @@
<div class="row mt-4"> <!-- transcoding grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="inner-form-title">TRANSCODING</h2>
<h2 i18n class="section-left-column-title">TRANSCODING</h2>
<div i18n class="inner-form-description">
Process uploaded videos so that they are in a streamable form that any device can play. Though costly in
resources, this is a critical part of PeerTube, so tread carefully.
@ -211,7 +211,7 @@
<div class="row mt-2"> <!-- video studio grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="inner-form-title">VIDEO STUDIO</h2>
<h2 i18n class="section-left-column-title">VIDEO STUDIO</h2>
<div i18n class="inner-form-description">
Allows your users to edit their video (cut, add intro/outro, add a watermark etc)
</div>

View File

@ -70,8 +70,8 @@
<div class="row mt-4"> <!-- user grid -->
<div class="col-12 col-lg-4 col-xl-3">
<div class="anchor" id="user"></div> <!-- user anchor -->
<div *ngIf="isCreation()" class="account-title" i18n>NEW USER</div>
<div *ngIf="!isCreation() && user" class="account-title">
<div *ngIf="isCreation()" class="section-left-column-title" i18n>NEW USER</div>
<div *ngIf="!isCreation() && user" class="section-left-column-title">
<my-actor-avatar-edit [actor]="user.account" [editable]="false" [displaySubscribers]="false" [displayUsername]="false"></my-actor-avatar-edit>
</div>
</div>
@ -212,7 +212,7 @@
<div *ngIf="displayDangerZone()" class="row mt-4"> <!-- danger zone grid -->
<div class="col-12 col-lg-4 col-xl-3">
<div class="anchor" id="danger"></div> <!-- danger zone anchor -->
<div i18n class="account-title account-title-danger">DANGER ZONE</div>
<div i18n class="section-left-column-title section-left-column-title-danger">DANGER ZONE</div>
</div>
<div class="col-12 col-lg-8 col-xl-9">

View File

@ -1,19 +1,10 @@
@use 'sass:math';
@use 'sass:color';
@use '_variables' as *;
@use '_mixins' as *;
$form-base-input-width: 340px;
.account-title {
@include settings-big-title;
&.account-title-danger {
color: color.adjust($color: #c54130, $lightness: 10%);
}
}
input:not([type=submit]) {
@include peertube-input-text($form-base-input-width);
display: block;
@ -43,12 +34,6 @@ button {
margin-top: 10px;
}
my-user-real-quota-info {
display: block;
margin-top: 5px;
font-size: 11px;
}
.danger-zone {
button {
@include peertube-button;

View File

@ -1,4 +1,13 @@
<div i18n class="transcoding-information" *ngIf="isTranscodingInformationDisplayed()">
The video quota only takes into account <strong>original</strong> video size. <br />
Since transcoding is enabled, videos size can be at most ~ {{ computeQuotaWithTranscoding() | bytes: 0 }}.
<div *ngIf="getQuotaAsNumber() > 0">
<ng-container i18n>
The video quota only takes into account the size of <strong>uploaded</strong> videos, not transcoded files or user export archives (which may contain video files).
</ng-container>
<br />
<ng-container i18n *ngIf="isTranscodingInformationDisplayed()">
Transcoding is enabled so videos size can be at most ~ {{ computeQuotaWithTranscoding() | bytes: 0 }}.
</ng-container>
</div>

View File

@ -18,7 +18,7 @@ export class UserRealQuotaInfoComponent implements OnInit {
}
isTranscodingInformationDisplayed () {
return this.serverConfig.transcoding.enabledResolutions.length !== 0 && this.getQuotaAsNumber() > 0
return this.serverConfig.transcoding.enabledResolutions.length !== 0
}
computeQuotaWithTranscoding () {
@ -37,7 +37,7 @@ export class UserRealQuotaInfoComponent implements OnInit {
return multiplier * this.getQuotaAsNumber()
}
private getQuotaAsNumber () {
getQuotaAsNumber () {
return parseInt(this.videoQuota + '', 10)
}
}

View File

@ -33,6 +33,7 @@ export class JobsComponent extends RestTable implements OnInit {
'activitypub-refresher',
'actor-keys',
'after-video-channel-import',
'create-user-export',
'email',
'federate-video',
'generate-video-storyboard',

View File

@ -108,8 +108,6 @@ export class RunnerService {
}
})
console.log(filters)
return this.restService.addObjectParams(params, filters)
}

View File

@ -5,8 +5,8 @@
<div class="row"> <!-- channel grid -->
<div class="col-12 col-lg-4 col-xl-3">
<div *ngIf="isCreation()" class="video-channel-title" i18n>NEW CHANNEL</div>
<div *ngIf="!isCreation() && videoChannel" class="video-channel-title" i18n>CHANNEL</div>
<div *ngIf="isCreation()" class="section-left-column-title" i18n>NEW CHANNEL</div>
<div *ngIf="!isCreation() && videoChannel" class="section-left-column-title" i18n>CHANNEL</div>
</div>
<div class="col-12 col-lg-8 col-xl-9">

View File

@ -1,10 +1,6 @@
@use '_variables' as *;
@use '_mixins' as *;
.video-channel-title {
@include settings-big-title;
}
my-actor-banner-edit {
max-width: 500px;
}

View File

@ -5,11 +5,11 @@
<div class="row"> <!-- built-in token grid -->
<div class="group col-12 col-lg-4 col-xl-3">
<h2 i18n class="applications-title">SUBSCRIPTION FEED</h2>
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="section-left-column-title">SUBSCRIPTION FEED</h2>
<div i18n class="applications-description">
Use third-party feed aggregators to retrieve the list of videos from
channels you subscribed to.
Use third-party feed aggregators to retrieve the list of videos from channels you subscribed to.
</div>
</div>

View File

@ -1,10 +1,6 @@
@use '_variables' as *;
@use '_mixins' as *;
.applications-title {
@include settings-big-title;
}
.form-group {
max-width: 500px;
}

View File

@ -0,0 +1,4 @@
export * from './user-import-export.service'
export * from './my-account-import.component'
export * from './my-account-export.component'
export * from './my-account-import-export.component'

View File

@ -0,0 +1,120 @@
<div class="row">
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="section-left-column-title">EXPORT</h2>
</div>
<div class="col-12 col-lg-8 col-xl-9">
@if (isExportEnabled()) {
<p i18n>You can request an archive of your account containing:</p>
<ul>
<li i18n>Your account settings with avatar file</li>
<li i18n>Your channels with banner and avatar files</li>
<li i18n>Your muted accounts and servers</li>
<li i18n>Your comments</li>
<li i18n>Your likes and dislikes</li>
<li i18n>Your subscriptions and followers</li>
<li i18n>Your video playlists with thumbnail files</li>
<li i18n>Your videos with thumbnail, caption files. Video files can also be included in the archive</li>
</ul>
<p i18n>The exported data will contain multiple directories:</p>
<ul>
<li i18n>A directory containing an export in ActivityPub format, readable by any compliant software</li>
<li i18n>A directory containing an export in custom PeerTube JSON format that can be used to re-import your account on another PeerTube instance</li>
<li i18n>A directory containing static files (thumbnails, avatars, video files etc.)</li>
</ul>
<p i18n>You can only request one archive at a time.</p>
@if (isEmailEnabled()) {
<p i18n>An email will be sent when the export archive is available.</p>
}
<table *ngIf="userExports && userExports.length !== 0">
<tr>
<th i18n scope="column">Date</th>
<th i18n scope="column">State</th>
<th i18n scope="column">Size</th>
<th i18n scope="column">Expires on</th>
<th></th>
</tr>
<tr *ngFor="let export of userExports">
<td>{{ export.createdAt | date: 'medium' }}</td>
<td>{{ export.state.label }}</td>
<td>
<ng-container *ngIf="export.size">{{ export.size | bytes }}</ng-container>
</td>
<td>
<ng-container *ngIf="export.expiresOn">{{ export.expiresOn | date: 'medium' }}</ng-container>
</td>
<td>
<a i18n *ngIf="export.privateDownloadUrl" [href]="export.privateDownloadUrl" class="peertube-button-link grey-button">Download your archive</a>
</td>
</tr>
</table>
<div class="mt-3">
<input
class="peertube-button orange-button"
[disabled]="isRequestArchiveDisabled()"
(click)="openNewArchiveModal()"
type="submit" i18n-value value="Request a new archive"
>
</div>
} @else {
<p i18n>User export is not enabled by your administrator.</p>
}
</div>
</div>
<ng-template #exportModal let-hide="close">
<div class="modal-header">
<h4 class="modal-title" i18n>Archive settings</h4>
<button class="border-0 p-0" title="Close this modal" i18n-title (click)="hide()">
<my-global-icon iconName="cross"></my-global-icon>
</button>
</div>
<div class="modal-body">
<div i18n class="alert alert-warning" *ngIf="hasAlreadyACompletedArchive()">
You already have an active archive. Requesting a new export archive will remove the current one.
</div>
<div *ngIf="errorInModal" class="alert alert-danger">{{ errorInModal }}</div>
<my-peertube-checkbox
inputName="exportWithVideos" [(ngModel)]="exportWithVideosFiles"
i18n-labelText labelText="Include video files in archive file"
>
<ng-container ngProjectAs="description">
<div i18n>Including video files is required if you want to re-import your videos on another PeerTube website</div>
<div *ngIf="archiveWeightEstimation" i18n>If you include video files, the archive file will weigh <strong>approximately {{ archiveWeightEstimation | bytes }}</strong></div>
</ng-container>
</my-peertube-checkbox>
</div>
<div class="modal-footer inputs">
<input
type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
(click)="hide()" (key.enter)="hide()"
>
<input
type="submit" i18n-value value="Request an archive" class="peertube-button orange-button"
(click)="requestNewArchive()"
/>
</div>
</ng-template>

View File

@ -0,0 +1,9 @@
@use '_variables' as *;
@use '_mixins' as *;
table {
td,
th {
@include padding-right(1rem);
}
}

View File

@ -0,0 +1,121 @@
import { Component, Input, OnInit, ViewChild } from '@angular/core'
import { AuthService, ServerService } from '@app/core'
import { PeerTubeProblemDocument, ServerErrorCode, UserExport, UserExportState } from '@peertube/peertube-models'
import { UserImportExportService } from './user-import-export.service'
import { concatMap, first, from, of, switchMap, toArray } from 'rxjs'
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
@Component({
selector: 'my-account-export',
templateUrl: './my-account-export.component.html',
styleUrls: [ './my-account-export.component.scss' ]
})
export class MyAccountExportComponent implements OnInit {
@ViewChild('exportModal', { static: true }) exportModal: NgbModal
@Input() videoQuotaUsed: number
userExports: UserExport[] = []
exportWithVideosFiles: boolean
errorInModal: string
archiveWeightEstimation: number
private exportModalOpened: NgbModalRef
private requestingArchive = false
constructor (
private authService: AuthService,
private server: ServerService,
private userImportExportService: UserImportExportService,
private modalService: NgbModal
) {}
ngOnInit () {
this.archiveWeightEstimation = this.videoQuotaUsed
this.authService.userInformationLoaded
.pipe(first())
.subscribe(() => this.reloadUserExports())
}
isExportEnabled () {
return this.server.getHTMLConfig().export.users.enabled
}
isEmailEnabled () {
return this.server.getHTMLConfig().email.enabled
}
isRequestArchiveDisabled () {
return this.userExports.some(e => {
const id = e.state.id
return id === UserExportState.PENDING || id === UserExportState.PROCESSING
})
}
hasAlreadyACompletedArchive () {
return this.userExports.some(e => e.state.id === UserExportState.COMPLETED)
}
openNewArchiveModal () {
this.exportWithVideosFiles = false
this.errorInModal = undefined
this.exportModalOpened = this.modalService.open(this.exportModal, { centered: true })
}
requestNewArchive () {
if (this.requestingArchive) return
this.requestingArchive = true
let baseObs = of<any>(true)
if (this.userExports.length !== 0) {
baseObs = from(this.userExports.map(e => e.id))
.pipe(
concatMap(id => this.userImportExportService.deleteUserExport({ userId: this.getUserId(), userExportId: id })),
toArray()
)
}
baseObs.pipe(
switchMap(() => {
return this.userImportExportService.requestNewUserExport({ withVideoFiles: this.exportWithVideosFiles, userId: this.getUserId() })
})
).subscribe({
next: () => {
this.reloadUserExports()
this.exportModalOpened.close()
this.requestingArchive = false
},
error: err => {
this.requestingArchive = false
const error = err.body as PeerTubeProblemDocument
if (error.code === ServerErrorCode.MAX_USER_VIDEO_QUOTA_EXCEEDED_FOR_USER_EXPORT) {
// eslint-disable-next-line max-len
this.errorInModal = $localize`Video files cannot be included in the export because you have exceeded the maximum video quota allowed by your administrator to export this archive.`
return
}
this.errorInModal = err.message
}
})
}
private reloadUserExports () {
if (!this.isExportEnabled()) return
this.userImportExportService.listUserExports({ userId: this.authService.getUser().id })
.subscribe(({ data }) => this.userExports = data)
}
private getUserId () {
return this.authService.getUser().id
}
}

View File

@ -0,0 +1,8 @@
<h1>
<my-global-icon iconName="cloud-download" aria-hidden="true"></my-global-icon>
<ng-container i18n>Import/Export</ng-container>
</h1>
<my-account-import #accountImport [videoQuotaUsed]="videoQuotaUsed" class="d-block mb-5"></my-account-import>
<my-account-export [videoQuotaUsed]="videoQuotaUsed"></my-account-export>

View File

@ -0,0 +1,2 @@
@use '_variables' as *;
@use '_mixins' as *;

View File

@ -0,0 +1,33 @@
import { Component, OnInit, ViewChild } from '@angular/core'
import { AuthService, CanComponentDeactivate, UserService } from '@app/core'
import { MyAccountImportComponent } from './my-account-import.component'
import { first } from 'rxjs'
@Component({
selector: 'my-account-import-export',
templateUrl: './my-account-import-export.component.html',
styleUrls: [ './my-account-import-export.component.scss' ]
})
export class MyAccountImportExportComponent implements OnInit, CanComponentDeactivate {
@ViewChild('accountImport') accountImport: MyAccountImportComponent
videoQuotaUsed: number
constructor (
private authService: AuthService,
private userService: UserService
) {}
ngOnInit () {
this.authService.userInformationLoaded
.pipe(first())
.subscribe(() => {
this.userService.getMyVideoQuotaUsed()
.subscribe(res => this.videoQuotaUsed = res.videoQuotaUsed)
})
}
canDeactivate () {
return this.accountImport?.canDeactivate() || { canDeactivate: true }
}
}

View File

@ -0,0 +1,82 @@
<div class="row">
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="section-left-column-title">IMPORT</h2>
</div>
<div class="col-12 col-lg-8 col-xl-9">
@if (isImportEnabled()) {
<p i18n>
You can import an archive created by another PeerTube website.
<p i18n>
This is an <strong>import</strong> tool and not a migration tool.
It's the reason why data (like channels or videos) is duplicated and not moved from your previous PeerTube website.
</p>
<p i18n>The import process will automatically:</p>
<ul>
<li i18n>Update your account metadata (display name, description, avatar...)</li>
<li i18n>Update your user settings (autoplay or P2P policy, notification settings...). It does not update your user email, username or password.</li>
<li i18n>Add accounts/server in your mute list</li>
<li i18n>Add likes/dislikes</li>
<li i18n>Send a follow request to your subscriptions</li>
<li i18n>Create channels if they do not already exist</li>
<li i18n>Create playlists if they do not already exist</li>
<li i18n><strong>If the archive contains video files</strong>, create videos if they do not already exist</li>
</ul>
<p i18n>The following data objects are not imported:</p>
<ul>
<li i18n>Comments</li>
<li i18n>Followers (accounts will need to re-follow your channels)</li>
</ul>
<p *ngIf="isEmailEnabled()" i18n>An email will be sent when the import process is complete.</p>
<div class="mb-3" *ngIf="latestImport">
<div>
<strong>Latest import on:</strong> {{ latestImport.createdAt | date: 'medium' }}
</div>
<div>
<strong>Latest import state:</strong> {{ latestImport.state.label }}
</div>
</div>
@if (hasPendingImport()) {
<div i18n class="alert pt-alert-primary">
You can't re-import an archive because you already have an import that is currently being processed by PeerTube.
</div>
} @else {
<my-upload-progress
[isUploading]="uploadingArchive" [uploadPercents]="uploadPercents" [error]="error" [uploaded]="archiveUploadFinished"
[enableRetryAfterError]="enableRetryAfterError" (cancel)="cancelUpload()" (retry)="retryUpload()"
>
</my-upload-progress>
<div *ngIf="archiveUploadFinished && !error" class="alert pt-alert-primary" i18n>
Upload completed. Your archive import will be processed as soon as possible.
</div>
<div [hidden]="uploadingArchive || archiveUploadFinished" class="button-file form-control" i18n-ngbTooltip ngbTooltip="(extension: .zip)">
<span i18n>Select the archive file to import</span>
<input
aria-label="Select the file to import"
i18n-aria-label
accept=".zip"
(change)="onFileChange($event)"
id="importfile"
type="file"
/>
</div>
}
} @else {
<p i18n>User import is not enabled by your administrator.</p>
}
</div>
</div>

View File

@ -0,0 +1,11 @@
@use '_variables' as *;
@use '_mixins' as *;
.button-file {
@include peertube-button-file(max-content);
@include orange-button;
}
.pt-alert-primary {
width: fit-content;
}

View File

@ -0,0 +1,194 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core'
import { AuthService, ServerService, CanComponentDeactivate, Notifier } from '@app/core'
import { Subscription, first, switchMap } from 'rxjs'
import { UserImportExportService } from './user-import-export.service'
import { HttpErrorResponse } from '@angular/common/http'
import { buildHTTPErrorResponse, genericUploadErrorHandler, getUploadXRetryConfig } from '@app/helpers'
import { HttpStatusCode, UserImport, UserImportState } from '@peertube/peertube-models'
import { UploadxService, UploadState, UploaderX } from 'ngx-uploadx'
import { BytesPipe } from '@app/shared/shared-main'
@Component({
selector: 'my-account-import',
templateUrl: './my-account-import.component.html',
styleUrls: [ './my-account-import.component.scss' ]
})
export class MyAccountImportComponent implements OnInit, OnDestroy, CanComponentDeactivate {
@Input() videoQuotaUsed: number
uploadingArchive = false
archiveUploadFinished = false
error: string
enableRetryAfterError: boolean
uploadPercents = 0
latestImport: UserImport
private fileToUpload: File
private uploadServiceSubscription: Subscription
private alreadyRefreshedToken = false
constructor (
private authService: AuthService,
private server: ServerService,
private userImportExportService: UserImportExportService,
private resumableUploadService: UploadxService,
private notifier: Notifier
) {}
ngOnInit () {
this.authService.userInformationLoaded
.pipe(
first(),
switchMap(() => this.userImportExportService.getLatestImport({ userId: this.authService.getUser().id }))
)
.subscribe(res => this.latestImport = res)
this.uploadServiceSubscription = this.resumableUploadService.events
.subscribe(state => this.onUploadOngoing(state))
}
ngOnDestroy () {
this.resumableUploadService.disconnect()
if (this.uploadServiceSubscription) this.uploadServiceSubscription.unsubscribe()
}
canDeactivate () {
return {
canDeactivate: !this.uploadingArchive,
text: $localize`Your archive file is not uploaded yet, are you sure you want to leave this page?`
}
}
isImportEnabled () {
return this.server.getHTMLConfig().import.users.enabled
}
isEmailEnabled () {
return this.server.getHTMLConfig().email.enabled
}
onUploadOngoing (state: UploadState) {
switch (state.status) {
case 'error': {
if (!this.alreadyRefreshedToken && state.responseStatus === HttpStatusCode.UNAUTHORIZED_401) {
this.alreadyRefreshedToken = true
return this.refreshTokenAndRetryUpload()
}
this.handleUploadError(buildHTTPErrorResponse(state))
break
}
case 'cancelled':
this.uploadingArchive = false
this.uploadPercents = 0
this.enableRetryAfterError = false
this.error = ''
break
case 'uploading':
this.uploadPercents = state.progress
break
case 'complete':
this.archiveUploadFinished = true
this.uploadPercents = 100
this.uploadingArchive = false
break
}
}
onFileChange (event: Event | { target: HTMLInputElement }) {
const inputEl = event.target as HTMLInputElement
const file = inputEl.files[0]
if (!file) return
const user = this.authService.getUser()
if (user.videoQuota !== -1 && this.videoQuotaUsed + file.size > user.videoQuota) {
const bytePipes = new BytesPipe()
const fileSizeBytes = bytePipes.transform(file.size, 0)
const videoQuotaUsedBytes = bytePipes.transform(this.videoQuotaUsed, 0)
const videoQuotaBytes = bytePipes.transform(user.videoQuota, 0)
this.notifier.error(
// eslint-disable-next-line max-len
$localize`Cannot import this file as your video quota would be exceeded (import size: ${fileSizeBytes}, used: ${videoQuotaUsedBytes}, quota: ${videoQuotaBytes})`
)
inputEl.value = ''
return
}
this.fileToUpload = file
this.uploadFile(file)
}
cancelUpload () {
this.resumableUploadService.control({ action: 'cancel' })
}
retryUpload () {
this.enableRetryAfterError = false
this.error = ''
this.uploadFile(this.fileToUpload)
}
hasPendingImport () {
if (!this.latestImport) return false
const state = this.latestImport.state.id
return state === UserImportState.PENDING || state === UserImportState.PROCESSING
}
private uploadFile (file: File) {
this.resumableUploadService.handleFiles(file, {
endpoint: `${UserImportExportService.BASE_USER_IMPORTS_URL}${this.authService.getUser().id}/imports/import-resumable`,
multiple: false,
maxChunkSize: this.server.getHTMLConfig().client.videos.resumableUpload.maxChunkSize,
token: this.authService.getAccessToken(),
uploaderClass: UploaderX,
retryConfig: getUploadXRetryConfig(),
metadata: {
filename: file.name
}
})
this.uploadingArchive = true
}
private handleUploadError (err: HttpErrorResponse) {
// Reset progress
this.uploadPercents = 0
this.enableRetryAfterError = true
this.error = genericUploadErrorHandler({
err,
name: $localize`archive`,
notifier: this.notifier,
sticky: false
})
if (err.status === HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415) {
this.cancelUpload()
}
}
private refreshTokenAndRetryUpload () {
this.authService.refreshAccessToken()
.subscribe(() => this.retryUpload())
}
}

View File

@ -0,0 +1,83 @@
import { catchError, map } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { RestExtractor, ServerService } from '@app/core'
import { environment } from 'src/environments/environment'
import { HttpStatusCode, ResultList, UserExport, UserImport } from '@peertube/peertube-models'
import { forkJoin, of } from 'rxjs'
import { peertubeTranslate } from '@peertube/peertube-core-utils'
@Injectable()
export class UserImportExportService {
static BASE_USER_EXPORTS_URL = environment.apiUrl + '/api/v1/users/'
static BASE_USER_IMPORTS_URL = environment.apiUrl + '/api/v1/users/'
constructor (
private authHttp: HttpClient,
private restExtractor: RestExtractor,
private server: ServerService
) { }
// ---------------------------------------------------------------------------
listUserExports (options: {
userId: number
}) {
const { userId } = options
const url = UserImportExportService.BASE_USER_EXPORTS_URL + userId + '/exports'
return this.authHttp.get<ResultList<UserExport>>(url)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
requestNewUserExport (options: {
userId: number
withVideoFiles: boolean
}) {
const { userId, withVideoFiles } = options
const url = UserImportExportService.BASE_USER_EXPORTS_URL + userId + '/exports/request'
return this.authHttp.post(url, { withVideoFiles })
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
deleteUserExport (options: {
userId: number
userExportId: number
}) {
const { userId, userExportId } = options
const url = UserImportExportService.BASE_USER_EXPORTS_URL + userId + '/exports/' + userExportId
return this.authHttp.delete(url)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
// ---------------------------------------------------------------------------
getLatestImport (options: {
userId: number
}) {
const { userId } = options
const url = UserImportExportService.BASE_USER_IMPORTS_URL + userId + '/imports/latest'
return forkJoin([
this.authHttp.get<UserImport>(url),
this.server.getServerLocale()
]).pipe(
map(([ latestImport, translations ]) => {
latestImport.state.label = peertubeTranslate(latestImport.state.label, translations)
return latestImport
}),
catchError(err => {
if (err.status === HttpStatusCode.NOT_FOUND_404) return of(undefined)
return this.restExtractor.handleError(err)
})
)
}
}

View File

@ -1,6 +1,6 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { LoginGuard } from '../core'
import { CanDeactivateGuard, LoginGuard } from '../core'
import { MyAccountAbusesListComponent } from './my-account-abuses/my-account-abuses-list.component'
import { MyAccountApplicationsComponent } from './my-account-applications/my-account-applications.component'
import { MyAccountBlocklistComponent } from './my-account-blocklist/my-account-blocklist.component'
@ -8,6 +8,7 @@ import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-acc
import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component'
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
import { MyAccountTwoFactorComponent } from './my-account-settings/my-account-two-factor'
import { MyAccountImportExportComponent } from './my-account-import-export'
import { MyAccountComponent } from './my-account.component'
const myAccountRoutes: Routes = [
@ -137,6 +138,16 @@ const myAccountRoutes: Routes = [
title: $localize`Applications`
}
}
},
{
path: 'import-export',
component: MyAccountImportExportComponent,
canDeactivate: [ CanDeactivateGuard ],
data: {
meta: {
title: $localize`Import/Export`
}
}
}
]
}

View File

@ -10,7 +10,7 @@
<div class="row mt-3"> <!-- profile settings grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="account-title">PROFILE SETTINGS</h2>
<h2 i18n class="section-left-column-title">PROFILE SETTINGS</h2>
</div>
<div class="col-12 col-lg-8 col-xl-9">
@ -22,7 +22,7 @@
<div class="row mt-5"> <!-- interface grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="account-title">INTERFACE</h2>
<h2 i18n class="section-left-column-title">INTERFACE</h2>
</div>
<div class="col-12 col-lg-8 col-xl-9">
@ -33,7 +33,7 @@
<div class="row mt-5"> <!-- video settings grid -->
<div class="col-12 col-lg-4 col-xl-3">
<div class="anchor" id="video-settings"></div> <!-- video settings anchor -->
<h2 i18n class="account-title">VIDEO SETTINGS</h2>
<h2 i18n class="section-left-column-title">VIDEO SETTINGS</h2>
</div>
<div class="col-12 col-lg-8 col-xl-9">
@ -44,7 +44,7 @@
<div class="row mt-5"> <!-- notifications grid -->
<div class="col-12 col-lg-4 col-xl-3">
<div class="anchor" id="notifications"></div> <!-- notifications anchor -->
<h2 i18n class="account-title">NOTIFICATIONS</h2>
<h2 i18n class="section-left-column-title">NOTIFICATIONS</h2>
</div>
<div class="col-12 col-lg-8 col-xl-9">
@ -54,7 +54,7 @@
<div class="row mt-5" *ngIf="user.pluginAuth === null"> <!-- password grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="account-title">PASSWORD</h2>
<h2 i18n class="section-left-column-title">PASSWORD</h2>
</div>
<div class="col-12 col-lg-8 col-xl-9">
@ -64,7 +64,7 @@
<div class="row mt-5" *ngIf="user.pluginAuth === null"> <!-- two factor auth grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="account-title">Two-factor authentication</h2>
<h2 i18n class="section-left-column-title">Two-factor authentication</h2>
</div>
<div class="col-12 col-lg-8 col-xl-9">
@ -74,7 +74,7 @@
<div class="row mt-5" *ngIf="user.pluginAuth === null"> <!-- email grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="account-title">EMAIL</h2>
<h2 i18n class="section-left-column-title">EMAIL</h2>
</div>
<div class="col-12 col-lg-8 col-xl-9">
@ -86,7 +86,7 @@
<div class="row mt-5"> <!-- danger zone grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="account-title account-title-danger">DANGER ZONE</h2>
<h2 i18n class="section-left-column-title section-left-column-title-danger">DANGER ZONE</h2>
</div>
<div class="col-12 col-lg-8 col-xl-9">

View File

@ -4,14 +4,6 @@
@use '_mixins' as *;
@use 'bootstrap/scss/functions' as *;
.account-title {
@include settings-big-title;
&.account-title-danger {
color: color.adjust($color: #c54130, $lightness: 10%);
}
}
.row > div {
max-width: 500px;
}

View File

@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core'
import { AuthUser, ScreenService } from '@app/core'
import { AuthUser, ScreenService, ServerService } from '@app/core'
import { TopMenuDropdownParam } from '../shared/shared-main/misc/top-menu-dropdown.component'
@Component({
@ -12,7 +12,8 @@ export class MyAccountComponent implements OnInit {
user: AuthUser
constructor (
private screenService: ScreenService
private screenService: ScreenService,
private server: ServerService
) { }
get isBroadcastMessageDisplayed () {
@ -56,6 +57,11 @@ export class MyAccountComponent implements OnInit {
routerLink: '/my-account/notifications'
},
{
label: $localize`Import/Export`,
routerLink: '/my-account/import-export'
},
{
label: $localize`Applications`,
routerLink: '/my-account/applications'

View File

@ -28,6 +28,13 @@ import { MyAccountProfileComponent } from './my-account-settings/my-account-prof
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
import { MyAccountTwoFactorButtonComponent, MyAccountTwoFactorComponent } from './my-account-settings/my-account-two-factor'
import { MyAccountComponent } from './my-account.component'
import {
MyAccountImportExportComponent,
MyAccountExportComponent,
MyAccountImportComponent,
UserImportExportService
} from './my-account-import-export'
import { UploadProgressComponent } from '@app/shared/standalone-upload'
@NgModule({
imports: [
@ -47,7 +54,9 @@ import { MyAccountComponent } from './my-account.component'
SharedAbuseListModule,
SharedShareModal,
SharedActorImageModule,
SharedActorImageEditModule
SharedActorImageEditModule,
UploadProgressComponent
],
declarations: [
@ -68,14 +77,19 @@ import { MyAccountComponent } from './my-account.component'
MyAccountNotificationsComponent,
MyAccountNotificationPreferencesComponent,
MyAccountEmailPreferencesComponent
MyAccountEmailPreferencesComponent,
MyAccountImportExportComponent,
MyAccountExportComponent,
MyAccountImportComponent
],
exports: [
MyAccountComponent
],
providers: []
providers: [
UserImportExportService
]
})
export class MyAccountModule {
}

View File

@ -5,7 +5,7 @@
<div class="row">
<div class="col-12 col-lg-4 col-xl-3">
<div class="video-channel-sync-title" i18n>NEW SYNCHRONIZATION</div>
<div class="section-left-column-title" i18n>NEW SYNCHRONIZATION</div>
</div>
<div class="col-12 col-lg-8 col-xl-9">

View File

@ -7,10 +7,6 @@ input[type=text] {
@include peertube-input-text($form-base-input-width);
}
.video-channel-sync-title {
@include settings-big-title;
}
my-select-channel {
display: block;
max-width: $form-base-input-width;

View File

@ -22,8 +22,8 @@
<div class="row"> <!-- playlist grid -->
<div class="col-12 col-lg-4 col-xl-3">
<div *ngIf="isCreation()" class="video-playlist-title" i18n>NEW PLAYLIST</div>
<div *ngIf="!isCreation() && videoPlaylistToUpdate" class="video-playlist-title" i18n>PLAYLIST</div>
<div *ngIf="isCreation()" class="section-left-column-title" i18n>NEW PLAYLIST</div>
<div *ngIf="!isCreation() && videoPlaylistToUpdate" class="section-left-column-title" i18n>PLAYLIST</div>
</div>
<div class="col-12 col-lg-8 col-xl-9">

View File

@ -1,10 +1,6 @@
@use '_variables' as *;
@use '_mixins' as *;
.video-playlist-title {
@include settings-big-title;
}
input[type=text] {
@include peertube-input-text(340px);

View File

@ -5,7 +5,6 @@ import { SharedGlobalIconModule } from '@app/shared/shared-icons'
import { SharedMainModule } from '@app/shared/shared-main'
import { SharedVideoLiveModule } from '@app/shared/shared-video-live'
import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
import { UploadProgressComponent } from './upload-progress.component'
import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
import { VideoCaptionEditModalContentComponent } from './video-caption-edit-modal-content/video-caption-edit-modal-content.component'
import { VideoEditComponent } from './video-edit.component'
@ -24,8 +23,7 @@ import { VideoUploadService } from './video-upload.service'
declarations: [
VideoEditComponent,
VideoCaptionAddModalComponent,
VideoCaptionEditModalContentComponent,
UploadProgressComponent
VideoCaptionEditModalContentComponent
],
exports: [
@ -35,8 +33,7 @@ import { VideoUploadService } from './video-upload.service'
SharedFormModule,
SharedGlobalIconModule,
VideoEditComponent,
UploadProgressComponent
VideoEditComponent
],
providers: [

View File

@ -1,10 +1,9 @@
import { UploaderX, UploadState, UploadxOptions } from 'ngx-uploadx'
import { HttpErrorResponse, HttpEventType, HttpHeaders } from '@angular/common/http'
import { UploaderX, UploadxOptions } from 'ngx-uploadx'
import { Injectable } from '@angular/core'
import { AuthService, Notifier, ServerService } from '@app/core'
import { BytesPipe, VideoService } from '@app/shared/shared-main'
import { HttpStatusCode } from '@peertube/peertube-models'
import { UploaderXFormData } from './uploaderx-form-data'
import { getUploadXRetryConfig } from '@app/helpers'
@Injectable()
export class VideoUploadService {
@ -73,31 +72,7 @@ export class VideoUploadService {
uploaderClass,
retryConfig: {
maxAttempts: 30, // maximum attempts for 503 codes, otherwise set to 6, see below
maxDelay: 120_000, // 2 min
shouldRetry: (code: number, attempts: number) => {
return code === HttpStatusCode.SERVICE_UNAVAILABLE_503 || ((code < 400 || code > 500) && attempts < 6)
}
}
}
}
// ---------------------------------------------------------------------------
buildHTTPErrorResponse (state: UploadState): HttpErrorResponse {
const error = state.response?.error?.message || state.response?.error || 'Unknown error'
return {
error: new Error(error),
name: 'HttpErrorResponse',
message: error,
ok: false,
headers: new HttpHeaders(state.responseHeaders),
status: +state.responseStatus,
statusText: error,
type: HttpEventType.Response,
url: state.url
retryConfig: getUploadXRetryConfig()
}
}
}

View File

@ -59,7 +59,7 @@
</div>
<my-upload-progress
[isUploadingVideo]="isUploadingVideo" [videoUploadPercents]="videoUploadPercents" [error]="error" [videoUploaded]="videoUploaded"
[isUploading]="isUploadingVideo" [uploadPercents]="videoUploadPercents" [error]="error" [uploaded]="videoUploaded"
[enableRetryAfterError]="enableRetryAfterError" (cancel)="cancelUpload()" (retry)="retryUpload()"
>
</my-upload-progress>

View File

@ -5,7 +5,7 @@ import { HttpErrorResponse } from '@angular/common/http'
import { AfterViewInit, Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core'
import { genericUploadErrorHandler, scrollToTop } from '@app/helpers'
import { buildHTTPErrorResponse, genericUploadErrorHandler, scrollToTop } from '@app/helpers'
import { FormReactiveService } from '@app/shared/shared-forms'
import { Video, VideoCaptionService, VideoChapterService, VideoEdit, VideoService } from '@app/shared/shared-main'
import { LoadingBarService } from '@ngx-loading-bar/core'
@ -140,7 +140,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
return this.refreshTokenAndRetryUpload()
}
this.handleUploadError(this.videoUploadService.buildHTTPErrorResponse(state))
this.handleUploadError(buildHTTPErrorResponse(state))
break
}

View File

@ -8,6 +8,7 @@ import { VideoImportUrlComponent } from './video-add-components/video-import-url
import { VideoUploadComponent } from './video-add-components/video-upload.component'
import { VideoAddRoutingModule } from './video-add-routing.module'
import { VideoAddComponent } from './video-add.component'
import { UploadProgressComponent } from '@app/shared/standalone-upload'
@NgModule({
imports: [
@ -15,7 +16,9 @@ import { VideoAddComponent } from './video-add.component'
VideoEditModule,
UploadxModule
UploadxModule,
UploadProgressComponent
],
declarations: [

View File

@ -5,7 +5,7 @@
</div>
<my-upload-progress
[isUploadingVideo]="isReplacingVideoFile" [videoUploadPercents]="videoUploadPercents" [error]="uploadError" [videoUploaded]="updateDone"
[isUploading]="isReplacingVideoFile" [uploadPercents]="videoUploadPercents" [error]="uploadError" [uploaded]="updateDone"
[enableRetryAfterError]="false" (cancel)="cancelUpload()"
>
</my-upload-progress>

View File

@ -7,7 +7,7 @@ import { HttpErrorResponse } from '@angular/common/http'
import { Component, HostListener, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, CanComponentDeactivate, ConfirmService, Notifier, ServerService, UserService } from '@app/core'
import { genericUploadErrorHandler } from '@app/helpers'
import { buildHTTPErrorResponse, genericUploadErrorHandler } from '@app/helpers'
import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import {
Video,
@ -329,7 +329,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest
return this.refreshTokenAndRetryUpload()
}
this.handleUploadError(this.videoUploadService.buildHTTPErrorResponse(state))
this.handleUploadError(buildHTTPErrorResponse(state))
break
}

View File

@ -3,12 +3,15 @@ import { VideoEditModule } from './shared/video-edit.module'
import { VideoUpdateRoutingModule } from './video-update-routing.module'
import { VideoUpdateComponent } from './video-update.component'
import { VideoUpdateResolver } from './video-update.resolver'
import { UploadProgressComponent } from '@app/shared/standalone-upload'
@NgModule({
imports: [
VideoUpdateRoutingModule,
VideoEditModule
VideoEditModule,
UploadProgressComponent
],
declarations: [

View File

@ -1,9 +1,12 @@
import * as debug from 'debug'
import { Observable } from 'rxjs'
import { Injectable } from '@angular/core'
import { ConfirmService } from '@app/core/confirm'
export type CanComponentDeactivateResult = { text?: string, canDeactivate: Observable<boolean> | boolean }
const debugLogger = debug('peertube:routing:CanComponentDeactivate')
export interface CanComponentDeactivate {
canDeactivate: () => CanComponentDeactivateResult
}
@ -15,6 +18,9 @@ export class CanDeactivateGuard {
canDeactivate (component: CanComponentDeactivate) {
const result = component.canDeactivate()
debugLogger('Checking if component can deactivate', result)
const text = result.text || $localize`All unsaved data will be lost, are you sure you want to leave this page?`
return result.canDeactivate || this.confirmService.confirm(

View File

@ -1,8 +1,9 @@
import { HttpErrorResponse } from '@angular/common/http'
import { HttpErrorResponse, HttpEventType, HttpHeaders } from '@angular/common/http'
import { Notifier } from '@app/core'
import { HttpStatusCode } from '@peertube/peertube-models'
import { UploadState } from 'ngx-uploadx'
function genericUploadErrorHandler (options: {
export function genericUploadErrorHandler (options: {
err: Pick<HttpErrorResponse, 'message' | 'status' | 'headers'>
name: string
notifier?: Notifier
@ -17,8 +18,30 @@ function genericUploadErrorHandler (options: {
return message
}
export {
genericUploadErrorHandler
export function getUploadXRetryConfig () {
return {
maxAttempts: 30, // maximum attempts for 503 codes, otherwise set to 6, see below
maxDelay: 120_000, // 2 min
shouldRetry: (code: number, attempts: number) => {
return code === HttpStatusCode.SERVICE_UNAVAILABLE_503 || ((code < 400 || code > 500) && attempts < 6)
}
}
}
export function buildHTTPErrorResponse (state: UploadState): HttpErrorResponse {
const error = state.response?.error?.message || state.response?.error || 'Unknown error'
return {
error: new Error(error),
name: 'HttpErrorResponse',
message: error,
ok: false,
headers: new HttpHeaders(state.responseHeaders),
status: +state.responseStatus,
statusText: error,
type: HttpEventType.Response,
url: state.url
}
}
// ---------------------------------------------------------------------------

View File

@ -11,7 +11,7 @@ export const INSTANCE_NAME_VALIDATOR: BuildFormValidator = {
export const INSTANCE_SHORT_DESCRIPTION_VALIDATOR: BuildFormValidator = {
VALIDATORS: [ Validators.maxLength(250) ],
MESSAGES: {
maxlength: $localize`Short description should not be longer than 250 characters.`
maxlength: $localize`Short description must not be longer than 250 characters.`
}
}
@ -69,7 +69,7 @@ export const MAX_LIVE_DURATION_VALIDATOR: BuildFormValidator = {
VALIDATORS: [ Validators.required, Validators.min(-1) ],
MESSAGES: {
required: $localize`Max live duration is required.`,
min: $localize`Max live duration should be greater or equal to -1.`
min: $localize`Max live duration must be greater or equal to -1.`
}
}
@ -77,7 +77,7 @@ export const MAX_INSTANCE_LIVES_VALIDATOR: BuildFormValidator = {
VALIDATORS: [ Validators.required, Validators.min(-1) ],
MESSAGES: {
required: $localize`Max instance lives is required.`,
min: $localize`Max instance lives should be greater or equal to -1.`
min: $localize`Max instance lives must be greater or equal to -1.`
}
}
@ -85,7 +85,7 @@ export const MAX_USER_LIVES_VALIDATOR: BuildFormValidator = {
VALIDATORS: [ Validators.required, Validators.min(-1) ],
MESSAGES: {
required: $localize`Max user lives is required.`,
min: $localize`Max user lives should be greater or equal to -1.`
min: $localize`Max user lives must be greater or equal to -1.`
}
}
@ -102,20 +102,35 @@ export const CONCURRENCY_VALIDATOR: BuildFormValidator = {
VALIDATORS: [ Validators.required, Validators.min(1) ],
MESSAGES: {
required: $localize`Concurrency is required.`,
min: $localize`Concurrency should be greater or equal to 1.`
min: $localize`Concurrency must be greater or equal to 1.`
}
}
export const INDEX_URL_VALIDATOR: BuildFormValidator = {
VALIDATORS: [ Validators.pattern(/^https:\/\//) ],
MESSAGES: {
pattern: $localize`Index URL should be a URL`
pattern: $localize`Index URL must be a URL`
}
}
export const SEARCH_INDEX_URL_VALIDATOR: BuildFormValidator = {
VALIDATORS: [ Validators.pattern(/^https?:\/\//) ],
MESSAGES: {
pattern: $localize`Search index URL should be a URL`
pattern: $localize`Search index URL must be a URL`
}
}
export const EXPORT_EXPIRATION_VALIDATOR: BuildFormValidator = {
VALIDATORS: [ Validators.required, Validators.min(1) ],
MESSAGES: {
required: $localize`Export expiration is required.`,
min: $localize`Export expiration must be greater or equal to 1.`
}
}
export const EXPORT_MAX_USER_VIDEO_QUOTA_VALIDATOR: BuildFormValidator = {
VALIDATORS: [ Validators.required, Validators.min(1) ],
MESSAGES: {
required: $localize`Max user video quota is required.`,
min: $localize`Max user video video quota must be greater or equal to 1.`
}
}

View File

@ -37,7 +37,6 @@ export class TimestampInputComponent implements ControlValueAccessor, OnInit {
this.timestamp = timestamp
this.timestampString = secondsToTime({ seconds: this.timestamp, fullFormat: true, symbol: ':' })
console.log(this.timestampString)
}
registerOnChange (fn: (_: any) => void) {

View File

@ -112,6 +112,17 @@
</td>
</tr>
<tr>
<th i18n class="label" colspan="2">Export</th>
</tr>
<tr>
<th i18n class="sub-label" scope="row">Users can export their data</th>
<td>
<my-feature-boolean [value]="serverConfig.export.users.enabled"></my-feature-boolean>
</td>
</tr>
<tr>
<th i18n class="label" colspan="2">Search</th>
</tr>

View File

@ -6,7 +6,7 @@ import { getBytes } from '@root-helpers/bytes'
@Pipe({ name: 'bytes' })
export class BytesPipe implements PipeTransform {
transform (value: number, precision?: number | undefined): string | number {
transform (value: number, precision = 0): string | number {
return getBytes(value, precision)
}
}

View File

@ -0,0 +1 @@
export * from './upload-progress.component'

View File

@ -1,17 +1,18 @@
<!-- Upload progress/cancel/error/success header -->
<div *ngIf="isUploadingVideo && !error" class="upload-progress-cancel">
<div class="progress" i18n-title title="Total video uploaded">
<div *ngIf="isUploading && !error" class="upload-progress-cancel">
<div class="progress" i18n-title title="Total uploaded">
<div
class="progress-bar" role="progressbar"
[style]="{ width: videoUploadPercents + '%' }" [attr.aria-valuenow]="videoUploadPercents" aria-valuemin="0" [attr.aria-valuemax]="100"
[style]="{ width: uploadPercents + '%' }" [attr.aria-valuenow]="uploadPercents" aria-valuemin="0" [attr.aria-valuemax]="100"
>
<span *ngIf="videoUploadPercents === 100 && videoUploaded === false" i18n>Processing…</span>
<span *ngIf="videoUploadPercents !== 100 || videoUploaded">{{ videoUploadPercents }}%</span>
<span *ngIf="uploadPercents === 100 && uploaded === false" i18n>Processing…</span>
<span *ngIf="uploadPercents !== 100 || uploaded">{{ uploadPercents }}%</span>
</div>
</div>
<input
*ngIf="videoUploaded === false"
type="button" class="peertube-button grey-button ms-1" i18n-value="Cancel ongoing upload of a video" value="Cancel" (click)="cancel.emit()"
*ngIf="uploaded === false"
type="button" class="peertube-button grey-button ms-1" i18n-value="Cancel ongoing upload" value="Cancel" (click)="cancel.emit()"
/>
</div>
@ -22,8 +23,8 @@
</div>
</div>
<input type="button" class="peertube-button grey-button ms-1" i18n-value="Retry failed upload of a video" value="Retry" (click)="retry.emit()" />
<input type="button" class="peertube-button grey-button ms-1" i18n-value="Cancel ongoing upload of a video" value="Cancel" (click)="cancel.emit()" />
<input type="button" class="peertube-button grey-button ms-1" i18n-value="Retry failed upload" value="Retry" (click)="retry.emit()" />
<input type="button" class="peertube-button grey-button ms-1" i18n-value="Cancel ongoing upload" value="Cancel" (click)="cancel.emit()" />
</div>
<div *ngIf="error && !enableRetryAfterError" class="alert alert-danger">

View File

@ -1,15 +1,18 @@
import { CommonModule } from '@angular/common'
import { Component, EventEmitter, Input, Output } from '@angular/core'
@Component({
selector: 'my-upload-progress',
templateUrl: './upload-progress.component.html',
styleUrls: [ './upload-progress.component.scss' ]
styleUrls: [ './upload-progress.component.scss' ],
imports: [ CommonModule ],
standalone: true
})
export class UploadProgressComponent {
@Input() isUploadingVideo: boolean
@Input() videoUploadPercents: number
@Input() isUploading: boolean
@Input() uploadPercents: number
@Input() error: string
@Input() videoUploaded: boolean
@Input() uploaded: boolean
@Input() enableRetryAfterError: boolean
@Output() cancel = new EventEmitter()

View File

@ -2,7 +2,8 @@ const dictionary: { max: number, type: string }[] = [
{ max: 1024, type: 'B' },
{ max: 1048576, type: 'KB' },
{ max: 1073741824, type: 'MB' },
{ max: 1.0995116e12, type: 'GB' }
{ max: 1.0995116e12, type: 'GB' },
{ max: 1.125899906842624e15, type: 'TB' }
]
function getBytes (value: number, precision?: number | undefined): string | number {

View File

@ -1,3 +1,5 @@
@use 'sass:color';
@use '_badges' as *;
@use '_icons' as *;
@use '_variables' as *;
@ -23,6 +25,20 @@
// ---------------------------------------------------------------------------
.section-left-column-title {
text-transform: uppercase;
color: pvar(--mainColor);
font-weight: $font-bold;
font-size: 1rem;
margin-bottom: 10px;
&.section-left-column-title-danger {
color: color.adjust($color: #c54130, $lightness: 10%);
}
}
// ---------------------------------------------------------------------------
.muted {
@include muted;
}

View File

@ -598,14 +598,6 @@
font-size: 13px;
}
@mixin settings-big-title {
text-transform: uppercase;
color: pvar(--mainColor);
font-weight: $font-bold;
font-size: 1rem;
margin-bottom: 10px;
}
@mixin row-blocks ($column-responsive: true, $min-height: 130px, $separator: true) {
display: flex;
min-height: $min-height;