From f9c89b98f73dd938c894901724a797ca4ac2f424 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 12 Feb 2024 10:50:29 +0100 Subject: [PATCH] Add user import/export in client --- client/e2e/src/po/admin-config.po.ts | 2 +- ...edit-advanced-configuration.component.html | 4 +- .../edit-basic-configuration.component.html | 96 ++++++++- .../edit-basic-configuration.component.ts | 20 ++ .../edit-custom-config.component.scss | 10 - .../edit-custom-config.component.ts | 12 ++ .../edit-homepage.component.html | 2 +- .../edit-instance-information.component.html | 8 +- .../edit-live-configuration.component.html | 4 +- .../edit-vod-transcoding.component.html | 4 +- .../users/user-edit/user-edit.component.html | 6 +- .../users/user-edit/user-edit.component.scss | 15 -- .../user-real-quota-info.component.html | 15 +- .../shared/user-real-quota-info.component.ts | 4 +- .../app/+admin/system/jobs/jobs.component.ts | 1 + .../+admin/system/runners/runner.service.ts | 2 - .../video-channel-edit.component.html | 4 +- .../video-channel-edit.component.scss | 4 - .../my-account-applications.component.html | 8 +- .../my-account-applications.component.scss | 4 - .../my-account-import-export/index.ts | 4 + .../my-account-export.component.html | 120 +++++++++++ .../my-account-export.component.scss | 9 + .../my-account-export.component.ts | 121 +++++++++++ .../my-account-import-export.component.html | 8 + .../my-account-import-export.component.scss | 2 + .../my-account-import-export.component.ts | 33 +++ .../my-account-import.component.html | 82 ++++++++ .../my-account-import.component.scss | 11 + .../my-account-import.component.ts | 194 ++++++++++++++++++ .../user-import-export.service.ts | 83 ++++++++ .../+my-account/my-account-routing.module.ts | 13 +- .../my-account-settings.component.html | 16 +- .../my-account-settings.component.scss | 8 - .../app/+my-account/my-account.component.ts | 10 +- .../src/app/+my-account/my-account.module.ts | 20 +- .../video-channel-sync-edit.component.html | 2 +- .../video-channel-sync-edit.component.scss | 4 - .../my-video-playlist-edit.component.html | 4 +- .../my-video-playlist-edit.component.scss | 4 - .../+video-edit/shared/video-edit.module.ts | 7 +- .../shared/video-upload.service.ts | 31 +-- .../video-upload.component.html | 2 +- .../video-upload.component.ts | 4 +- .../+videos/+video-edit/video-add.module.ts | 5 +- .../+video-edit/video-update.component.html | 2 +- .../+video-edit/video-update.component.ts | 4 +- .../+video-edit/video-update.module.ts | 5 +- .../routing/can-deactivate-guard.service.ts | 6 + client/src/app/helpers/utils/upload.ts | 31 ++- .../custom-config-validators.ts | 29 ++- .../shared-forms/timestamp-input.component.ts | 1 - .../instance-features-table.component.html | 11 + .../shared/shared-main/angular/bytes.pipe.ts | 2 +- .../src/app/shared/standalone-upload/index.ts | 1 + .../upload-progress.component.html | 19 +- .../upload-progress.component.scss | 0 .../upload-progress.component.ts | 11 +- client/src/root-helpers/bytes.ts | 3 +- client/src/sass/class-helpers/_text.scss | 16 ++ client/src/sass/include/_mixins.scss | 8 - 61 files changed, 991 insertions(+), 180 deletions(-) create mode 100644 client/src/app/+my-account/my-account-import-export/index.ts create mode 100644 client/src/app/+my-account/my-account-import-export/my-account-export.component.html create mode 100644 client/src/app/+my-account/my-account-import-export/my-account-export.component.scss create mode 100644 client/src/app/+my-account/my-account-import-export/my-account-export.component.ts create mode 100644 client/src/app/+my-account/my-account-import-export/my-account-import-export.component.html create mode 100644 client/src/app/+my-account/my-account-import-export/my-account-import-export.component.scss create mode 100644 client/src/app/+my-account/my-account-import-export/my-account-import-export.component.ts create mode 100644 client/src/app/+my-account/my-account-import-export/my-account-import.component.html create mode 100644 client/src/app/+my-account/my-account-import-export/my-account-import.component.scss create mode 100644 client/src/app/+my-account/my-account-import-export/my-account-import.component.ts create mode 100644 client/src/app/+my-account/my-account-import-export/user-import-export.service.ts create mode 100644 client/src/app/shared/standalone-upload/index.ts rename client/src/app/{+videos/+video-edit/shared => shared/standalone-upload}/upload-progress.component.html (51%) rename client/src/app/{+videos/+video-edit/shared => shared/standalone-upload}/upload-progress.component.scss (100%) rename client/src/app/{+videos/+video-edit/shared => shared/standalone-upload}/upload-progress.component.ts (59%) diff --git a/client/e2e/src/po/admin-config.po.ts b/client/e2e/src/po/admin-config.po.ts index 3cf9b17d9..6632894bb 100644 --- a/client/e2e/src/po/admin-config.po.ts +++ b/client/e2e/src/po/admin-config.po.ts @@ -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') { diff --git a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html b/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html index ddfaaa50e..8695b922b 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html @@ -3,7 +3,7 @@
-

CACHE

+

CACHE

Some files are not federated, and fetched when necessary. Define their caching policies.
@@ -74,7 +74,7 @@
-

CUSTOMIZATIONS

+

CUSTOMIZATIONS

Slight modifications to your PeerTube instance for when creating a plugin or theme is overkill.
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html index c08bf97d5..3de23dcf5 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html @@ -1,7 +1,7 @@
-

APPEARANCE

+

APPEARANCE

Use plugins & themes for more involved changes, or add slight customizations.
@@ -91,7 +91,7 @@
-

BROADCAST MESSAGE

+

BROADCAST MESSAGE

Display a message on your instance
@@ -147,7 +147,7 @@
-

NEW USERS

+

NEW USERS

Manage users to set their quota individually.
@@ -228,7 +228,7 @@ [clearable]="false" > - +
@@ -264,7 +264,7 @@
-

VIDEOS

+

VIDEOS

@@ -376,7 +376,7 @@
-

VIDEO CHANNELS

+

VIDEO CHANNELS

@@ -398,7 +398,7 @@
-

SEARCH

+

SEARCH

@@ -485,9 +485,85 @@
+
+
+

USER IMPORT/EXPORT

+
+ +
+ + + +
+ + +
Video quota is checked on import so the user doesn't upload a too big archive file
+
Video quota (daily quota is not taken into account) is also checked for each video when PeerTube is processing the import
+
+
+
+
+
+ + + + + +
+ + + Users can export their PeerTube data in a .zip for backup or re-import. Only one export at a time is allowed per user + + + + +
+ + + If the user decides to include the video files in the archive + + + + +
+ +
+ + + + +
The archive file is deleted after this period.
+ + +
+ +
+
+
+ +
+
+
+
+
-

FEDERATION

+

FEDERATION

Manage relations with other instances.
@@ -566,7 +642,7 @@
-

ADMINISTRATORS

+

ADMINISTRATORS

@@ -594,7 +670,7 @@
-

TWITTER

+

TWITTER

Provide the Twitter account representing your instance to improve link previews. If you don't have a Twitter account, just leave the default value. diff --git a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts index 953c7d540..d55248555 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts @@ -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 } diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss index 09b816129..9215ab0de 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss @@ -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; } diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts index c1cbe1b7d..8f10e1158 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts @@ -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: { diff --git a/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html b/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html index f8e49b385..89568a319 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html @@ -4,7 +4,7 @@
-

INSTANCE HOMEPAGE

+

INSTANCE HOMEPAGE

diff --git a/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html b/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html index fefe0a608..290968486 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html @@ -4,7 +4,7 @@
-

INSTANCE

+

INSTANCE

@@ -81,7 +81,7 @@
-

MODERATION & NSFW

+

MODERATION & NSFW

Manage users to build a moderation team.
@@ -159,7 +159,7 @@
-

YOU AND YOUR INSTANCE

+

YOU AND YOUR INSTANCE

@@ -209,7 +209,7 @@
-

OTHER INFORMATION

+

OTHER INFORMATION

diff --git a/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.html b/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.html index 5f73b18c3..cfd4068a3 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.html @@ -2,7 +2,7 @@
-

LIVE

+

LIVE

Enable users of your instance to stream live.
@@ -89,7 +89,7 @@
-

TRANSCODING

+

TRANSCODING

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.
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html index f52a85884..6d3ebccc4 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html @@ -18,7 +18,7 @@
-

TRANSCODING

+

TRANSCODING

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 @@
-

VIDEO STUDIO

+

VIDEO STUDIO

Allows your users to edit their video (cut, add intro/outro, add a watermark etc)
diff --git a/client/src/app/+admin/overview/users/user-edit/user-edit.component.html b/client/src/app/+admin/overview/users/user-edit/user-edit.component.html index ad44589a6..6464f0050 100644 --- a/client/src/app/+admin/overview/users/user-edit/user-edit.component.html +++ b/client/src/app/+admin/overview/users/user-edit/user-edit.component.html @@ -70,8 +70,8 @@
- - @@ -212,7 +212,7 @@
- +
DANGER ZONE
diff --git a/client/src/app/+admin/overview/users/user-edit/user-edit.component.scss b/client/src/app/+admin/overview/users/user-edit/user-edit.component.scss index 73a23eb7f..079a249d8 100644 --- a/client/src/app/+admin/overview/users/user-edit/user-edit.component.scss +++ b/client/src/app/+admin/overview/users/user-edit/user-edit.component.scss @@ -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; diff --git a/client/src/app/+admin/shared/user-real-quota-info.component.html b/client/src/app/+admin/shared/user-real-quota-info.component.html index b975ab17f..8451e8e8d 100644 --- a/client/src/app/+admin/shared/user-real-quota-info.component.html +++ b/client/src/app/+admin/shared/user-real-quota-info.component.html @@ -1,4 +1,13 @@ -
- The video quota only takes into account original video size.
- Since transcoding is enabled, videos size can be at most ~ {{ computeQuotaWithTranscoding() | bytes: 0 }}. +
+ + + The video quota only takes into account the size of uploaded videos, not transcoded files or user export archives (which may contain video files). + + +
+ + + Transcoding is enabled so videos size can be at most ~ {{ computeQuotaWithTranscoding() | bytes: 0 }}. +
+ diff --git a/client/src/app/+admin/shared/user-real-quota-info.component.ts b/client/src/app/+admin/shared/user-real-quota-info.component.ts index dd78fa9f0..6951b742e 100644 --- a/client/src/app/+admin/shared/user-real-quota-info.component.ts +++ b/client/src/app/+admin/shared/user-real-quota-info.component.ts @@ -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) } } diff --git a/client/src/app/+admin/system/jobs/jobs.component.ts b/client/src/app/+admin/system/jobs/jobs.component.ts index 75c41d1bf..5aca2cf39 100644 --- a/client/src/app/+admin/system/jobs/jobs.component.ts +++ b/client/src/app/+admin/system/jobs/jobs.component.ts @@ -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', diff --git a/client/src/app/+admin/system/runners/runner.service.ts b/client/src/app/+admin/system/runners/runner.service.ts index dede35083..cc2308047 100644 --- a/client/src/app/+admin/system/runners/runner.service.ts +++ b/client/src/app/+admin/system/runners/runner.service.ts @@ -108,8 +108,6 @@ export class RunnerService { } }) - console.log(filters) - return this.restService.addObjectParams(params, filters) } diff --git a/client/src/app/+manage/video-channel-edit/video-channel-edit.component.html b/client/src/app/+manage/video-channel-edit/video-channel-edit.component.html index 0fca0deff..ecf5c6a28 100644 --- a/client/src/app/+manage/video-channel-edit/video-channel-edit.component.html +++ b/client/src/app/+manage/video-channel-edit/video-channel-edit.component.html @@ -5,8 +5,8 @@
-
NEW CHANNEL
-
CHANNEL
+
NEW CHANNEL
+
CHANNEL
diff --git a/client/src/app/+manage/video-channel-edit/video-channel-edit.component.scss b/client/src/app/+manage/video-channel-edit/video-channel-edit.component.scss index 4e21af2a8..e2f428942 100644 --- a/client/src/app/+manage/video-channel-edit/video-channel-edit.component.scss +++ b/client/src/app/+manage/video-channel-edit/video-channel-edit.component.scss @@ -1,10 +1,6 @@ @use '_variables' as *; @use '_mixins' as *; -.video-channel-title { - @include settings-big-title; -} - my-actor-banner-edit { max-width: 500px; } diff --git a/client/src/app/+my-account/my-account-applications/my-account-applications.component.html b/client/src/app/+my-account/my-account-applications/my-account-applications.component.html index 2fc691707..bb0f463d6 100644 --- a/client/src/app/+my-account/my-account-applications/my-account-applications.component.html +++ b/client/src/app/+my-account/my-account-applications/my-account-applications.component.html @@ -5,11 +5,11 @@
-
-

SUBSCRIPTION FEED

+
+

SUBSCRIPTION FEED

+
- 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.
diff --git a/client/src/app/+my-account/my-account-applications/my-account-applications.component.scss b/client/src/app/+my-account/my-account-applications/my-account-applications.component.scss index b3fedd2f6..c1966d5cc 100644 --- a/client/src/app/+my-account/my-account-applications/my-account-applications.component.scss +++ b/client/src/app/+my-account/my-account-applications/my-account-applications.component.scss @@ -1,10 +1,6 @@ @use '_variables' as *; @use '_mixins' as *; -.applications-title { - @include settings-big-title; -} - .form-group { max-width: 500px; } diff --git a/client/src/app/+my-account/my-account-import-export/index.ts b/client/src/app/+my-account/my-account-import-export/index.ts new file mode 100644 index 000000000..53fba0b95 --- /dev/null +++ b/client/src/app/+my-account/my-account-import-export/index.ts @@ -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' diff --git a/client/src/app/+my-account/my-account-import-export/my-account-export.component.html b/client/src/app/+my-account/my-account-import-export/my-account-export.component.html new file mode 100644 index 000000000..e3bf6b5b7 --- /dev/null +++ b/client/src/app/+my-account/my-account-import-export/my-account-export.component.html @@ -0,0 +1,120 @@ +
+ +
+

EXPORT

+
+ +
+ + @if (isExportEnabled()) { + +

You can request an archive of your account containing:

+ +
    +
  • Your account settings with avatar file
  • +
  • Your channels with banner and avatar files
  • +
  • Your muted accounts and servers
  • +
  • Your comments
  • +
  • Your likes and dislikes
  • +
  • Your subscriptions and followers
  • +
  • Your video playlists with thumbnail files
  • +
  • Your videos with thumbnail, caption files. Video files can also be included in the archive
  • +
+ +

The exported data will contain multiple directories:

+ +
    +
  • A directory containing an export in ActivityPub format, readable by any compliant software
  • +
  • A directory containing an export in custom PeerTube JSON format that can be used to re-import your account on another PeerTube instance
  • +
  • A directory containing static files (thumbnails, avatars, video files etc.)
  • +
+ +

You can only request one archive at a time.

+ + @if (isEmailEnabled()) { +

An email will be sent when the export archive is available.

+ } + + + + + + + + + + + + + + + + + + + + +
DateStateSizeExpires on
{{ export.createdAt | date: 'medium' }}{{ export.state.label }} + {{ export.size | bytes }} + + {{ export.expiresOn | date: 'medium' }} + + Download your archive +
+ +
+ +
+ } @else { +

User export is not enabled by your administrator.

+ } + +
+
+ + + + + + + + diff --git a/client/src/app/+my-account/my-account-import-export/my-account-export.component.scss b/client/src/app/+my-account/my-account-import-export/my-account-export.component.scss new file mode 100644 index 000000000..0c7e3428e --- /dev/null +++ b/client/src/app/+my-account/my-account-import-export/my-account-export.component.scss @@ -0,0 +1,9 @@ +@use '_variables' as *; +@use '_mixins' as *; + +table { + td, + th { + @include padding-right(1rem); + } +} diff --git a/client/src/app/+my-account/my-account-import-export/my-account-export.component.ts b/client/src/app/+my-account/my-account-import-export/my-account-export.component.ts new file mode 100644 index 000000000..aef439edc --- /dev/null +++ b/client/src/app/+my-account/my-account-import-export/my-account-export.component.ts @@ -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(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 + } +} diff --git a/client/src/app/+my-account/my-account-import-export/my-account-import-export.component.html b/client/src/app/+my-account/my-account-import-export/my-account-import-export.component.html new file mode 100644 index 000000000..c4c0c9c53 --- /dev/null +++ b/client/src/app/+my-account/my-account-import-export/my-account-import-export.component.html @@ -0,0 +1,8 @@ +

+ + Import/Export +

+ + + + diff --git a/client/src/app/+my-account/my-account-import-export/my-account-import-export.component.scss b/client/src/app/+my-account/my-account-import-export/my-account-import-export.component.scss new file mode 100644 index 000000000..40083bed3 --- /dev/null +++ b/client/src/app/+my-account/my-account-import-export/my-account-import-export.component.scss @@ -0,0 +1,2 @@ +@use '_variables' as *; +@use '_mixins' as *; diff --git a/client/src/app/+my-account/my-account-import-export/my-account-import-export.component.ts b/client/src/app/+my-account/my-account-import-export/my-account-import-export.component.ts new file mode 100644 index 000000000..9b571f1b1 --- /dev/null +++ b/client/src/app/+my-account/my-account-import-export/my-account-import-export.component.ts @@ -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 } + } +} diff --git a/client/src/app/+my-account/my-account-import-export/my-account-import.component.html b/client/src/app/+my-account/my-account-import-export/my-account-import.component.html new file mode 100644 index 000000000..4efae70e5 --- /dev/null +++ b/client/src/app/+my-account/my-account-import-export/my-account-import.component.html @@ -0,0 +1,82 @@ +
+ +
+

IMPORT

+
+ +
+ + @if (isImportEnabled()) { +

+ You can import an archive created by another PeerTube website. + +

+ This is an import 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. +

+ +

The import process will automatically:

+ +
    +
  • Update your account metadata (display name, description, avatar...)
  • +
  • Update your user settings (autoplay or P2P policy, notification settings...). It does not update your user email, username or password.
  • +
  • Add accounts/server in your mute list
  • +
  • Add likes/dislikes
  • +
  • Send a follow request to your subscriptions
  • +
  • Create channels if they do not already exist
  • +
  • Create playlists if they do not already exist
  • +
  • If the archive contains video files, create videos if they do not already exist
  • +
+ +

The following data objects are not imported:

+ +
    +
  • Comments
  • +
  • Followers (accounts will need to re-follow your channels)
  • +
+ +

An email will be sent when the import process is complete.

+ +
+
+ Latest import on: {{ latestImport.createdAt | date: 'medium' }} +
+ +
+ Latest import state: {{ latestImport.state.label }} +
+
+ + @if (hasPendingImport()) { +
+ You can't re-import an archive because you already have an import that is currently being processed by PeerTube. +
+ } @else { + + + +
+ Upload completed. Your archive import will be processed as soon as possible. +
+ +
+ Select the archive file to import + +
+ } + } @else { +

User import is not enabled by your administrator.

+ } + +
+
diff --git a/client/src/app/+my-account/my-account-import-export/my-account-import.component.scss b/client/src/app/+my-account/my-account-import-export/my-account-import.component.scss new file mode 100644 index 000000000..78bd06b74 --- /dev/null +++ b/client/src/app/+my-account/my-account-import-export/my-account-import.component.scss @@ -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; +} diff --git a/client/src/app/+my-account/my-account-import-export/my-account-import.component.ts b/client/src/app/+my-account/my-account-import-export/my-account-import.component.ts new file mode 100644 index 000000000..879cdc9c3 --- /dev/null +++ b/client/src/app/+my-account/my-account-import-export/my-account-import.component.ts @@ -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()) + } +} diff --git a/client/src/app/+my-account/my-account-import-export/user-import-export.service.ts b/client/src/app/+my-account/my-account-import-export/user-import-export.service.ts new file mode 100644 index 000000000..e20ba2a4e --- /dev/null +++ b/client/src/app/+my-account/my-account-import-export/user-import-export.service.ts @@ -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>(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(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) + }) + ) + } +} diff --git a/client/src/app/+my-account/my-account-routing.module.ts b/client/src/app/+my-account/my-account-routing.module.ts index b39b1f6b4..edd3e8ffd 100644 --- a/client/src/app/+my-account/my-account-routing.module.ts +++ b/client/src/app/+my-account/my-account-routing.module.ts @@ -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` + } + } } ] } diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html index 3986354c1..11ea7e326 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html +++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html @@ -10,7 +10,7 @@
- +

PROFILE SETTINGS

@@ -22,7 +22,7 @@
- +

INTERFACE

@@ -33,7 +33,7 @@
- +

VIDEO SETTINGS

@@ -44,7 +44,7 @@
- +

NOTIFICATIONS

@@ -54,7 +54,7 @@
- +

PASSWORD

@@ -64,7 +64,7 @@
- +

Two-factor authentication

@@ -74,7 +74,7 @@
- +

EMAIL

@@ -86,7 +86,7 @@
- +

DANGER ZONE

diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.scss b/client/src/app/+my-account/my-account-settings/my-account-settings.component.scss index 5d406ed5d..3440fc614 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.scss +++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.scss @@ -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; } diff --git a/client/src/app/+my-account/my-account.component.ts b/client/src/app/+my-account/my-account.component.ts index 450454ca2..205e02eed 100644 --- a/client/src/app/+my-account/my-account.component.ts +++ b/client/src/app/+my-account/my-account.component.ts @@ -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' diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts index 673bd2837..266478aaf 100644 --- a/client/src/app/+my-account/my-account.module.ts +++ b/client/src/app/+my-account/my-account.module.ts @@ -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 { } diff --git a/client/src/app/+my-library/my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component.html b/client/src/app/+my-library/my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component.html index 099d37fe2..cc57a2c0d 100644 --- a/client/src/app/+my-library/my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component.html +++ b/client/src/app/+my-library/my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component.html @@ -5,7 +5,7 @@
-
NEW SYNCHRONIZATION
+
NEW SYNCHRONIZATION
diff --git a/client/src/app/+my-library/my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component.scss b/client/src/app/+my-library/my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component.scss index d0d8c2a68..9e382abe8 100644 --- a/client/src/app/+my-library/my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component.scss +++ b/client/src/app/+my-library/my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component.scss @@ -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; diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.html b/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.html index 67ce7ea48..46a2a9919 100644 --- a/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.html +++ b/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.html @@ -22,8 +22,8 @@
-
NEW PLAYLIST
-
PLAYLIST
+
NEW PLAYLIST
+
PLAYLIST
diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.scss b/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.scss index 93bc18fe2..aac9a99ff 100644 --- a/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.scss +++ b/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.scss @@ -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); diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.module.ts b/client/src/app/+videos/+video-edit/shared/video-edit.module.ts index cf9742b84..455fe74e6 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.module.ts +++ b/client/src/app/+videos/+video-edit/shared/video-edit.module.ts @@ -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: [ diff --git a/client/src/app/+videos/+video-edit/shared/video-upload.service.ts b/client/src/app/+videos/+video-edit/shared/video-upload.service.ts index c3f8936a9..82680174e 100644 --- a/client/src/app/+videos/+video-edit/shared/video-upload.service.ts +++ b/client/src/app/+videos/+video-edit/shared/video-upload.service.ts @@ -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() } } } diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html index dcbb358fa..02eac90b9 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html +++ b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html @@ -59,7 +59,7 @@
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts index cc0dcc1ae..7a542794d 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts +++ b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts @@ -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 } diff --git a/client/src/app/+videos/+video-edit/video-add.module.ts b/client/src/app/+videos/+video-edit/video-add.module.ts index f5bfc925a..30150ff04 100644 --- a/client/src/app/+videos/+video-edit/video-add.module.ts +++ b/client/src/app/+videos/+video-edit/video-add.module.ts @@ -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: [ diff --git a/client/src/app/+videos/+video-edit/video-update.component.html b/client/src/app/+videos/+video-edit/video-update.component.html index 2f667658c..f77af4dd0 100644 --- a/client/src/app/+videos/+video-edit/video-update.component.html +++ b/client/src/app/+videos/+video-edit/video-update.component.html @@ -5,7 +5,7 @@
diff --git a/client/src/app/+videos/+video-edit/video-update.component.ts b/client/src/app/+videos/+video-edit/video-update.component.ts index e2bed35e8..b23c12c8e 100644 --- a/client/src/app/+videos/+video-edit/video-update.component.ts +++ b/client/src/app/+videos/+video-edit/video-update.component.ts @@ -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 } diff --git a/client/src/app/+videos/+video-edit/video-update.module.ts b/client/src/app/+videos/+video-edit/video-update.module.ts index 92d32ffc8..98ea647a8 100644 --- a/client/src/app/+videos/+video-edit/video-update.module.ts +++ b/client/src/app/+videos/+video-edit/video-update.module.ts @@ -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: [ diff --git a/client/src/app/core/routing/can-deactivate-guard.service.ts b/client/src/app/core/routing/can-deactivate-guard.service.ts index 638e8e699..d88ffab00 100644 --- a/client/src/app/core/routing/can-deactivate-guard.service.ts +++ b/client/src/app/core/routing/can-deactivate-guard.service.ts @@ -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 } +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( diff --git a/client/src/app/helpers/utils/upload.ts b/client/src/app/helpers/utils/upload.ts index b55415064..ef4eb5533 100644 --- a/client/src/app/helpers/utils/upload.ts +++ b/client/src/app/helpers/utils/upload.ts @@ -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 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 + } } // --------------------------------------------------------------------------- diff --git a/client/src/app/shared/form-validators/custom-config-validators.ts b/client/src/app/shared/form-validators/custom-config-validators.ts index 3672e5610..8579cfaa4 100644 --- a/client/src/app/shared/form-validators/custom-config-validators.ts +++ b/client/src/app/shared/form-validators/custom-config-validators.ts @@ -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.` } } diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.ts b/client/src/app/shared/shared-forms/timestamp-input.component.ts index d3ae91875..15a5675a3 100644 --- a/client/src/app/shared/shared-forms/timestamp-input.component.ts +++ b/client/src/app/shared/shared-forms/timestamp-input.component.ts @@ -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) { diff --git a/client/src/app/shared/shared-instance/instance-features-table.component.html b/client/src/app/shared/shared-instance/instance-features-table.component.html index 4c355c392..94fb7f227 100644 --- a/client/src/app/shared/shared-instance/instance-features-table.component.html +++ b/client/src/app/shared/shared-instance/instance-features-table.component.html @@ -112,6 +112,17 @@ + + Export + + + + Users can export their data + + + + + Search diff --git a/client/src/app/shared/shared-main/angular/bytes.pipe.ts b/client/src/app/shared/shared-main/angular/bytes.pipe.ts index f3f57b825..1e10799b7 100644 --- a/client/src/app/shared/shared-main/angular/bytes.pipe.ts +++ b/client/src/app/shared/shared-main/angular/bytes.pipe.ts @@ -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) } } diff --git a/client/src/app/shared/standalone-upload/index.ts b/client/src/app/shared/standalone-upload/index.ts new file mode 100644 index 000000000..b9fbbd517 --- /dev/null +++ b/client/src/app/shared/standalone-upload/index.ts @@ -0,0 +1 @@ +export * from './upload-progress.component' diff --git a/client/src/app/+videos/+video-edit/shared/upload-progress.component.html b/client/src/app/shared/standalone-upload/upload-progress.component.html similarity index 51% rename from client/src/app/+videos/+video-edit/shared/upload-progress.component.html rename to client/src/app/shared/standalone-upload/upload-progress.component.html index f1626b8f0..06960a8a8 100644 --- a/client/src/app/+videos/+video-edit/shared/upload-progress.component.html +++ b/client/src/app/shared/standalone-upload/upload-progress.component.html @@ -1,17 +1,18 @@ -
-
+
+
- Processing… - {{ videoUploadPercents }}% + Processing… + {{ uploadPercents }}%
+
@@ -22,8 +23,8 @@
- - + +
diff --git a/client/src/app/+videos/+video-edit/shared/upload-progress.component.scss b/client/src/app/shared/standalone-upload/upload-progress.component.scss similarity index 100% rename from client/src/app/+videos/+video-edit/shared/upload-progress.component.scss rename to client/src/app/shared/standalone-upload/upload-progress.component.scss diff --git a/client/src/app/+videos/+video-edit/shared/upload-progress.component.ts b/client/src/app/shared/standalone-upload/upload-progress.component.ts similarity index 59% rename from client/src/app/+videos/+video-edit/shared/upload-progress.component.ts rename to client/src/app/shared/standalone-upload/upload-progress.component.ts index 9ce3a2cb2..4240d3137 100644 --- a/client/src/app/+videos/+video-edit/shared/upload-progress.component.ts +++ b/client/src/app/shared/standalone-upload/upload-progress.component.ts @@ -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() diff --git a/client/src/root-helpers/bytes.ts b/client/src/root-helpers/bytes.ts index 7b1920a09..57987c60e 100644 --- a/client/src/root-helpers/bytes.ts +++ b/client/src/root-helpers/bytes.ts @@ -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 { diff --git a/client/src/sass/class-helpers/_text.scss b/client/src/sass/class-helpers/_text.scss index 2231219fd..50a9eb61a 100644 --- a/client/src/sass/class-helpers/_text.scss +++ b/client/src/sass/class-helpers/_text.scss @@ -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; } diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss index 60eb547f4..249ea0697 100644 --- a/client/src/sass/include/_mixins.scss +++ b/client/src/sass/include/_mixins.scss @@ -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;