diff --git a/.editorconfig b/.editorconfig index b7d3033f3..843d5d926 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,11 +7,11 @@ root = true end_of_line = lf charset = utf-8 -[*.yml] +[*.{yml,html}] indent_style = space indent_size = 2 -[{client,server,shared}/**.{ts,json,js}] +[{client,server,shared,scripts}/**.{ts,json,js}] trim_trailing_whitespace = true insert_final_newline = true indent_style = space diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index b95ca367e..75557865e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -116,6 +116,9 @@ $ sudo -u postgres psql -c "CREATE EXTENSION pg_trgm;" peertube_dev $ sudo -u postgres psql -c "CREATE EXTENSION unaccent;" peertube_dev ``` +Peertube also requires a running redis server, no special setup is needed for +this. + In dev mode, administrator username is **root** and password is **test**. ### Online development diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ae19615c5..1c2f8093a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -75,6 +75,8 @@ jobs: - name: Run Test # external-plugins tests only run on schedule if: github.event_name == 'schedule' || matrix.test_suite != 'external-plugins' + env: + AKISMET_KEY: ${{ secrets.AKISMET_KEY }} run: npm run ci -- ${{ matrix.test_suite }} - name: Display errors diff --git a/CHANGELOG.md b/CHANGELOG.md index bb2f981b0..9f057c152 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,137 @@ # Changelog +## v4.3.0 + +### IMPORTANT NOTES + + * Redis **<** 5.x is not supported anymore + * FFmpeg **<** 4.3 is not supported anymore + +### Maintenance + + * Use `yt-dlp` by default instead of `youtube-dl` for new installations (because of much more dev activity) + * Support NodeJS 18 + * Improved PeerTube logs: + * Reduce amount of PeerTube error logs + * Introduce `log.log_tracker_unknown_infohash` setting to disable "Unknown infoHash" warnings + * Web browsers send their error logs to the server that writes them in its own logs. Can be disabled by `log.accept_client_log` setting + * Introduce experimental support of [OpenTelemetry](https://opentelemetry.io/) + * Enable metrics export using a Prometheus exporter + * Enable tracing export using a Jaeger exporter + * Automatically rebuild native plugin modules on NodeJS ABI change + +### Docker + + * Add ability to easily use the docker compose stack on localhost + +### Plugins/Themes/Embed API + + * Theme: + * Removed unused `--secondaryColor` CSS variable + * Add client plugin hooks (https://docs.joinpeertube.org/api-plugins): + * `filter:api.my-library.video-playlist-elements.list.params` & `filter:api.my-library.video-playlist-elements.list.result` [#5098](https://github.com/Chocobozzz/PeerTube/pull/5098) + * `action:video-channel-create.init` + * `action:video-channel-update.init` & `action:video-channel-update.video-channel.loaded` + * `action:video-channel-videos.init` & `action:video-channel-videos.video-channel.loaded` & `action:video-channel-videos.videos.loaded` + * `action:video-channel-playlists.init` & `action:video-channel-playlists.video-channel.loaded` & `action:video-channel-playlists.playlists.loaded` + * `filter:share.video-embed-code.build.params` & `filter:share.video-embed-code.build.result` & `filter:share.video-playlist-embed-code.build.params` & `filter:share.video-playlist-embed-code.build.result` + * `filter:share.video-embed-url.build.params` & `filter:share.video-embed-url.build.result` & `filter:share.video-playlist-embed-url.build.params` & `filter:share.video-playlist-embed-url.build.result` + * `filter:share.video-url.build.params` & `filter:share.video-url.build.result` & `filter:share.video-playlist-url.build.params` & `filter:share.video-playlist-url.build.result` + * `action:modal.share.shown` + * Add server plugin hooks (https://docs.joinpeertube.org/api-plugins): + * `filter:job-queue.process.params` & `filter:job-queue.process.result` + * `filter:transcoding.manual.resolutions-to-transcode.result` & `filter:transcoding.auto.resolutions-to-transcode.result` + * `action:api.video-channel.created` & `action:api.video-channel.updated` & `action:api.video-channel.deleted` + * `action:notifier.notification.created` + * Add HTML placeholder (https://docs.joinpeertube.org/contribute-plugins?id=html-placeholder-elements): + * `share-modal-playlist-settings` & `share-modal-video-settings` + +### Features + + * :tada: Add ability for users to synchronize a remote channel [#5135](https://github.com/Chocobozzz/PeerTube/pull/5135) :tada: + * Automatically import all videos of a remote channel in your PeerTube channel + * PeerTube will watch for new publications and automatically import these new videos + * UI: + * Redesigned *Create an account* steps + * Improved *Login* page + * Use a lighter font color + * Use a bigger font size + * Don't display form errors in red while typing but only when we unfocus the input + * Display an error message when the user is unauthorized to view a page [#5097](https://github.com/Chocobozzz/PeerTube/pull/5097) + * Display latest upload date for captions + * Add an information if the live will be saved as a replay when displaying live sessions + * Move search bar at the center of the header + * Add *Toki Pona* and *Croatian* locales in client + * Embed: + * Display a message and automatically start live streams in embed + * Use the instance name instead of "PeerTube" in embed control bar + * Reuse current watch page query parameters for embed when using OEmbed [#5023](https://github.com/Chocobozzz/PeerTube/pull/5023) + * Instance follows: + * Introduce a *Rejected* state for follow requests to not reprocess already rejected follow requests + * Add bulk actions on instance following/followers () + * Admins: + * Add ability to disable original resolution transcoding of the uploaded video/live stream + * Add ability to delete a specific video file in videos overview + * Display *Last Login* column by default in users overview + * Remember last selected columns in users overview + * Add ability to set a custom video import timeout + * Add ability to set the default feed (Atom, RSS...) items count + * Admins and moderators now bypass API rate limits + * Add ability to list comments on local videos in comments overview + * Limit video import resolution depending on enabled VOD transcoding resolutions + * Store and display the uploaded video original filename [#4885](https://github.com/Chocobozzz/PeerTube/pull/4885) + * Add *Total views* in the my channels list [#5007](https://github.com/Chocobozzz/PeerTube/pull/5007) + * Add *Original Publication Date* video sort option [#4959](https://github.com/Chocobozzz/PeerTube/pull/4959) + * Performance: + * Optimized view/watching endpoint + * Optimized video feed SQL query + * Process images (resize, convert...) in a dedicated worker thread + * Optimized emoji markup list rendering in client + * Use a worker thread to send ActivityPub Broadcast requests + * Suffix external auth username/channel name on conflict instead of throwing an exception + +### Bug fixes + + * Fix users overview *Last login* sort in admin + * More robust *move to object storage* job failure + * Fix comment add avatar with a unauthenticated user + * Fix fetching unlisted video in client + * Fix comments/download enabled attributes when importing a video + * Fix total instance views stats + * Fix HLS player infinite buffering on seek + * Reset table pagination on search + * *Host* search filter can also search into channels and playlists in global search + * Fix *My videos* invalid counter + * Prevent error on highlighted thread + * Fix *Jobs*, *Account blocklist* and *Server blocklist* hidden columns on Safari + * Fix live stream max bitrate + * Fix incompatibility with OpenSSL 3 + * Don't crash on redis connection error + * Transcoding: + * Fix failed transcoding with a mp3 file that contains a cover image + * Prevent duplicated HLS playlist when running transcoding + * Regenerate video file names when running transcoding manually + * Prevent job failures resulting in broken videos on concurrent transcoding + * Fix transcoding of videos with quad audio channels + * ActivityPub + * Fix random invalid HTTP signature generation + * Use unique AP id for *Accept*/*Reject* activities + * Correctly handle remote actors that don't have follow counters + * Correctly handle unknown remote actor image size + * Add years in graph legend when grouping video views stats by month + * Prevent creating multiple lives when clicking multiple times on the "Go Live" button + * Fix *undefined" resolution in player *Stats for nerds* + * Fix not displayed error message in administrator web config + * More robust S3 upload [#5231](https://github.com/Chocobozzz/PeerTube/pull/5231) + * Fix broken saved live stream with only one resolution + * Fix `removeEventListener` player embed api + * Progressively cleanup actor images without width from the database + * Fix broken dates on localized pages + * Prevent job queue to be started before plugins + * Fix old database enum names + * Don't display remove file icon in admin videos overviews if we can't delete the file + + ## v4.2.2 ### IMPORTANT NOTES diff --git a/client/.eslintrc.json b/client/.eslintrc.json index f9326acc8..f7b207b58 100644 --- a/client/.eslintrc.json +++ b/client/.eslintrc.json @@ -2,7 +2,8 @@ "root": true, "ignorePatterns": [ "projects/**/*", - "node_modules/" + "node_modules/", + "src/standalone/player/dist" ], "overrides": [ { diff --git a/client/e2e/wdio.browserstack.conf.ts b/client/e2e/wdio.browserstack.conf.ts index b89cdbc2e..944df8bdd 100644 --- a/client/e2e/wdio.browserstack.conf.ts +++ b/client/e2e/wdio.browserstack.conf.ts @@ -82,7 +82,7 @@ module.exports = { { browserName: 'Chrome', - ...buildBStackMobileOptions('Latest Chrome Android', 'Samsung Galaxy S6', '5.0') + ...buildBStackMobileOptions('Latest Chrome Android', 'Samsung Galaxy S8', '7.0') }, { browserName: 'Safari', diff --git a/client/package.json b/client/package.json index 79e8d25b6..38cead1a1 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "peertube-client", - "version": "4.2.2", + "version": "4.3.0", "private": true, "license": "AGPL-3.0", "author": { @@ -27,11 +27,11 @@ "typings": "*.d.ts", "devDependencies": { "@angular-devkit/build-angular": "^14.0.1", - "@angular-eslint/builder": "14.0.2", - "@angular-eslint/eslint-plugin": "14.0.2", - "@angular-eslint/eslint-plugin-template": "14.0.2", - "@angular-eslint/schematics": "14.0.2", - "@angular-eslint/template-parser": "14.0.2", + "@angular-eslint/builder": "^14.0.2", + "@angular-eslint/eslint-plugin": "^14.0.2", + "@angular-eslint/eslint-plugin-template": "^14.0.2", + "@angular-eslint/schematics": "^14.0.2", + "@angular-eslint/template-parser": "^14.0.2", "@angular/animations": "^14.0.1", "@angular/cdk": "^14.0.1", "@angular/cli": "^14.0.1", @@ -47,7 +47,7 @@ "@angular/service-worker": "^14.0.1", "@babel/core": "^7.18.5", "@babel/preset-env": "^7.18.2", - "@ng-bootstrap/ng-bootstrap": "^12.1.2", + "@ng-bootstrap/ng-bootstrap": "^13.0.0", "@ng-select/ng-select": "^9.0.1", "@ngx-loading-bar/core": "^6.0.0", "@ngx-loading-bar/http-client": "^6.0.0", @@ -69,8 +69,8 @@ "@types/sha.js": "^2.4.0", "@types/video.js": "^7.3.40", "@types/webtorrent": "^0.109.0", - "@typescript-eslint/eslint-plugin": "5.31.0", - "@typescript-eslint/parser": "5.31.0", + "@typescript-eslint/eslint-plugin": "^5.31.0", + "@typescript-eslint/parser": "^5.31.0", "@wdio/browserstack-service": "^7.20.2", "@wdio/cli": "^7.20.2", "@wdio/local-runner": "^7.20.2", @@ -84,7 +84,7 @@ "cache-chunk-store": "^3.0.0", "chart.js": "^3.8.0", "chartjs-plugin-zoom": "^1.2.1", - "chromedriver": "^103.0.0", + "chromedriver": "^105.0.0", "core-js": "^3.22.8", "css-loader": "^6.2.0", "debug": "^4.3.1", @@ -96,7 +96,7 @@ "expect-webdriverio": "^3.4.0", "focus-visible": "^5.0.2", "geckodriver": "^3.0.1", - "hls.js": "1.2.0", + "hls.js": "1.2.2", "html-loader": "^4.1.0", "html-webpack-plugin": "^5.3.1", "https-browserify": "^1.0.0", @@ -128,7 +128,7 @@ "stylelint-config-sass-guidelines": "^9.0.1", "ts-loader": "^9.3.0", "tslib": "^2.4.0", - "typescript": "~4.7.3", + "typescript": "~4.8.3", "url": "^0.11.0", "video.js": "^7.19.2", "videostream": "~3.2.1", @@ -137,7 +137,7 @@ "webpack": "^5.73.0", "webpack-bundle-analyzer": "^4.4.2", "webpack-cli": "^4.10.0", - "webtorrent": "^1.8.22", + "webtorrent": "1.8.26", "whatwg-fetch": "^3.0.0", "zone.js": "~0.11.4" }, diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts index 366e29883..f01967ea6 100644 --- a/client/src/app/+admin/admin.module.ts +++ b/client/src/app/+admin/admin.module.ts @@ -49,6 +49,7 @@ import { PluginSearchComponent, PluginShowInstalledComponent } from './plugins' +import { SharedAdminModule } from './shared' import { JobService, LogsComponent, LogsService } from './system' import { DebugComponent, DebugService } from './system/debug' import { JobsComponent } from './system/jobs/jobs.component' @@ -69,6 +70,7 @@ import { JobsComponent } from './system/jobs/jobs.component' SharedVideoMiniatureModule, SharedTablesModule, SharedUsersModule, + SharedAdminModule, TableModule, ChartModule 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 7dfe5f5f9..43f1438e0 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 @@ -218,6 +218,8 @@ [clearable]="false" > + +
{{ formErrors.user.videoQuota }}
@@ -267,10 +269,10 @@ inputName="importVideosHttpEnabled" formControlName="enabled" i18n-labelText labelText="Allow import with HTTP URL (e.g. YouTube)" > - - ⚠️ If enabled, we recommend to use a HTTP proxy to prevent private URL access from your PeerTube server - - + + ⚠️ If enabled, we recommend to use a HTTP proxy to prevent private URL access from your PeerTube server + +
@@ -285,6 +287,22 @@
+ + +
+ + + + ⛔ You need to allow import with HTTP URL to be able to activate this feature. + + + +
+
+ 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 29910369a..2122e67b2 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 @@ -25,11 +25,12 @@ export class EditBasicConfigurationComponent implements OnInit, OnChanges { private configService: ConfigService, private menuService: MenuService, private themeService: ThemeService - ) { } + ) {} ngOnInit () { this.buildLandingPageOptions() this.checkSignupField() + this.checkImportSyncField() this.availableThemes = this.themeService.buildAvailableThemes() } @@ -59,6 +60,10 @@ export class EditBasicConfigurationComponent implements OnInit, OnChanges { return !!enabled.find((e: string) => e === algorithm) } + getUserVideoQuota () { + return this.form.value['user']['videoQuota'] + } + isSignupEnabled () { return this.form.value['signup']['enabled'] === true } @@ -67,6 +72,14 @@ export class EditBasicConfigurationComponent implements OnInit, OnChanges { return { 'disabled-checkbox-extra': !this.isSignupEnabled() } } + isImportVideosHttpEnabled (): boolean { + return this.form.value['import']['videos']['http']['enabled'] === true + } + + importSynchronizationChecked () { + return this.isImportVideosHttpEnabled() && this.form.value['import']['videoChannelSynchronization']['enabled'] + } + hasUnlimitedSignup () { return this.form.value['signup']['limit'] === -1 } @@ -97,6 +110,21 @@ export class EditBasicConfigurationComponent implements OnInit, OnChanges { return this.themeService.getDefaultThemeLabel() } + private checkImportSyncField () { + const importSyncControl = this.form.get('import.videoChannelSynchronization.enabled') + const importVideosHttpControl = this.form.get('import.videos.http.enabled') + + importVideosHttpControl.valueChanges + .subscribe((httpImportEnabled) => { + importSyncControl.setValue(httpImportEnabled && importSyncControl.value) + if (httpImportEnabled) { + importSyncControl.enable() + } else { + importSyncControl.disable() + } + }) + } + private checkSignupField () { const signupControl = this.form.get('signup.enabled') 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 1bc9aebba..764e626ec 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 @@ -36,6 +36,10 @@ input[type=number] { position: absolute; top: 0.2em; right: 2.5rem; + + @media screen and (max-width: $mobile-view) { + display: none; + } } input[disabled] { @@ -146,3 +150,9 @@ ngb-tabset:not(.previews) ::ng-deep { padding: 0 .3em; } } + +my-user-real-quota-info { + display: block; + margin-top: 5px; + font-size: 11px; +} 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 ce01f8b59..545e37857 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 @@ -144,6 +144,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { torrent: { enabled: null } + }, + videoChannelSynchronization: { + enabled: null } }, trending: { @@ -289,6 +292,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { } formValidated () { + this.forceCheck() + if (!this.form.valid) return + const value: ComponentCustomConfig = this.form.getRawValue() forkJoin([ @@ -378,8 +384,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { this.customConfig = { ...config, instanceCustomHomepage: homepage } this.updateForm() - // Force form validation - this.forceCheck() + this.markAllAsDirty() }, error: err => this.notifier.error(err.message) 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 5a67b8e3b..1e7691f9e 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 @@ -108,7 +108,7 @@
- +
- Your instance subscriptions + Subscriptions of your instance - - + + diff --git a/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts b/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts index 033305a2b..8d67e9beb 100644 --- a/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts +++ b/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts @@ -145,15 +145,15 @@ export class VideoBlockListComponent extends RestTable implements OnInit { } getVideoEmbed (entry: VideoBlacklist) { - return buildVideoOrPlaylistEmbed( - decorateVideoLink({ + return buildVideoOrPlaylistEmbed({ + embedUrl: decorateVideoLink({ url: buildVideoEmbedLink(entry.video, environment.originServerUrl), title: false, warningTitle: false }), - entry.video.name - ) + embedTitle: entry.video.name + }) } protected reloadData () { diff --git a/client/src/app/+admin/overview/comments/video-comment-list.component.ts b/client/src/app/+admin/overview/comments/video-comment-list.component.ts index f01a1629b..cfe40b92a 100644 --- a/client/src/app/+admin/overview/comments/video-comment-list.component.ts +++ b/client/src/app/+admin/overview/comments/video-comment-list.component.ts @@ -54,6 +54,10 @@ export class VideoCommentListComponent extends RestTable implements OnInit { { value: 'local:false', label: $localize`Remote comments` + }, + { + value: 'localVideo:true', + label: $localize`Comments on local videos` } ] } 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 e484ab8b0..da5879a36 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 @@ -152,10 +152,7 @@ [clearable]="false" > -
- Transcoding is enabled. The video quota only takes into account original video size.
- At most, this user could upload ~ {{ computeQuotaWithTranscoding() | bytes: 0 }}. -
+
{{ formErrors.videoQuota }} 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 254286ae3..68fa1215f 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 @@ -41,7 +41,8 @@ button { margin-top: 10px; } -.transcoding-information { +my-user-real-quota-info { + display: block; margin-top: 5px; font-size: 11px; } diff --git a/client/src/app/+admin/overview/users/user-edit/user-edit.ts b/client/src/app/+admin/overview/users/user-edit/user-edit.ts index 395d07423..6dae4110d 100644 --- a/client/src/app/+admin/overview/users/user-edit/user-edit.ts +++ b/client/src/app/+admin/overview/users/user-edit/user-edit.ts @@ -3,7 +3,7 @@ import { ConfigService } from '@app/+admin/config/shared/config.service' import { AuthService, ScreenService, ServerService, User } from '@app/core' import { FormReactive } from '@app/shared/shared-forms' import { USER_ROLE_LABELS } from '@shared/core-utils/users' -import { HTMLServerConfig, UserAdminFlag, UserRole, VideoResolution } from '@shared/models' +import { HTMLServerConfig, UserAdminFlag, UserRole } from '@shared/models' import { SelectOptionsItem } from '../../../../../types/select-options-item.model' @Directive() @@ -60,33 +60,14 @@ export abstract class UserEdit extends FormReactive implements OnInit { ] } - isTranscodingInformationDisplayed () { - const formVideoQuota = parseInt(this.form.value['videoQuota'], 10) - - return this.serverConfig.transcoding.enabledResolutions.length !== 0 && - formVideoQuota > 0 - } - - computeQuotaWithTranscoding () { - const transcodingConfig = this.serverConfig.transcoding - - const resolutions = transcodingConfig.enabledResolutions - const higherResolution = VideoResolution.H_4K - let multiplier = 0 - - for (const resolution of resolutions) { - multiplier += resolution / higherResolution - } - - if (transcodingConfig.hls.enabled) multiplier *= 2 - - return multiplier * parseInt(this.form.value['videoQuota'], 10) - } - resetPassword () { return } + getUserVideoQuota () { + return this.form.value['videoQuota'] + } + protected buildAdminFlags (formValue: any) { return formValue.byPassAutoBlock ? UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST : UserAdminFlag.NONE } diff --git a/client/src/app/+admin/overview/videos/video-list.component.html b/client/src/app/+admin/overview/videos/video-list.component.html index 06b9ab347..14bbb55e9 100644 --- a/client/src/app/+admin/overview/videos/video-list.component.html +++ b/client/src/app/+admin/overview/videos/video-list.component.html @@ -109,6 +109,7 @@ {{ file.resolution.label }}: {{ file.size | bytes: 1 }} @@ -124,6 +125,7 @@ {{ file.resolution.label }}: {{ file.size | bytes: 1 }} diff --git a/client/src/app/+admin/overview/videos/video-list.component.ts b/client/src/app/+admin/overview/videos/video-list.component.ts index ed7ec54a1..cb693ce12 100644 --- a/client/src/app/+admin/overview/videos/video-list.component.ts +++ b/client/src/app/+admin/overview/videos/video-list.component.ts @@ -166,6 +166,10 @@ export class VideoListComponent extends RestTable implements OnInit { return video.files.length !== 0 } + canRemoveOneFile (video: Video) { + return video.canRemoveOneFile(this.authUser) + } + getFilesSize (video: Video) { let files = video.files diff --git a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html index c5d440c8c..374c4d96d 100644 --- a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html +++ b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html @@ -15,10 +15,15 @@ - +
diff --git a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts index 960e711b4..2fdc14d85 100644 --- a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts +++ b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts @@ -24,6 +24,7 @@ export class PluginListInstalledComponent implements OnInit { plugins: PeerTubePlugin[] = [] updating: { [name: string]: boolean } = {} + uninstalling: { [name: string]: boolean } = {} onDataSubject = new Subject() @@ -99,7 +100,11 @@ export class PluginListInstalledComponent implements OnInit { } isUpdating (plugin: PeerTubePlugin) { - return !!this.updating[this.getUpdatingKey(plugin)] + return !!this.updating[this.getPluginKey(plugin)] + } + + isUninstalling (plugin: PeerTubePlugin) { + return !!this.uninstall[this.getPluginKey(plugin)] } isTheme (plugin: PeerTubePlugin) { @@ -107,12 +112,17 @@ export class PluginListInstalledComponent implements OnInit { } async uninstall (plugin: PeerTubePlugin) { + const pluginKey = this.getPluginKey(plugin) + if (this.uninstalling[pluginKey]) return + const res = await this.confirmService.confirm( $localize`Do you really want to uninstall ${plugin.name}?`, $localize`Uninstall` ) if (res === false) return + this.uninstalling[pluginKey] = true + this.pluginApiService.uninstall(plugin.name, plugin.type) .subscribe({ next: () => { @@ -120,15 +130,20 @@ export class PluginListInstalledComponent implements OnInit { this.plugins = this.plugins.filter(p => p.name !== plugin.name) this.pagination.totalItems-- + + this.uninstalling[pluginKey] = false }, - error: err => this.notifier.error(err.message) + error: err => { + this.notifier.error(err.message) + this.uninstalling[pluginKey] = false + } }) } async update (plugin: PeerTubePlugin) { - const updatingKey = this.getUpdatingKey(plugin) - if (this.updating[updatingKey]) return + const pluginKey = this.getPluginKey(plugin) + if (this.updating[pluginKey]) return if (this.isMajorUpgrade(plugin)) { const res = await this.confirmService.confirm( @@ -140,20 +155,23 @@ export class PluginListInstalledComponent implements OnInit { if (res === false) return } - this.updating[updatingKey] = true + this.updating[pluginKey] = true this.pluginApiService.update(plugin.name, plugin.type) .pipe() .subscribe({ next: res => { - this.updating[updatingKey] = false + this.updating[pluginKey] = false this.notifier.success($localize`${plugin.name} updated.`) Object.assign(plugin, res) }, - error: err => this.notifier.error(err.message) + error: err => { + this.notifier.error(err.message) + this.updating[pluginKey] = false + } }) } @@ -165,7 +183,7 @@ export class PluginListInstalledComponent implements OnInit { return this.pluginApiService.getPluginOrThemeHref(this.pluginType, name) } - private getUpdatingKey (plugin: PeerTubePlugin) { + private getPluginKey (plugin: PeerTubePlugin) { return plugin.name + plugin.type } diff --git a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html index c989d2e38..08430913a 100644 --- a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html +++ b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html @@ -46,7 +46,7 @@ > diff --git a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts index b02c054a2..c03e37aa5 100644 --- a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts +++ b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts @@ -145,7 +145,11 @@ export class PluginSearchComponent implements OnInit { plugin.installed = true }, - error: err => this.notifier.error(err.message) + error: err => { + this.installing[plugin.npmName] = false + + this.notifier.error(err.message) + } }) } } diff --git a/client/src/app/+admin/shared/index.ts b/client/src/app/+admin/shared/index.ts new file mode 100644 index 000000000..9e3834aae --- /dev/null +++ b/client/src/app/+admin/shared/index.ts @@ -0,0 +1,3 @@ +export * from './user-real-quota-info.component' + +export * from './shared-admin.module' diff --git a/client/src/app/+admin/shared/shared-admin.module.ts b/client/src/app/+admin/shared/shared-admin.module.ts new file mode 100644 index 000000000..bef7d54ef --- /dev/null +++ b/client/src/app/+admin/shared/shared-admin.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core' +import { SharedMainModule } from '../../shared/shared-main/shared-main.module' +import { UserRealQuotaInfoComponent } from './user-real-quota-info.component' + +@NgModule({ + imports: [ + SharedMainModule + ], + + declarations: [ + UserRealQuotaInfoComponent + ], + + exports: [ + UserRealQuotaInfoComponent + ], + + providers: [] +}) +export class SharedAdminModule { } 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 new file mode 100644 index 000000000..b975ab17f --- /dev/null +++ b/client/src/app/+admin/shared/user-real-quota-info.component.html @@ -0,0 +1,4 @@ +
+ The video quota only takes into account original video size.
+ Since transcoding is enabled, videos size can be at most ~ {{ computeQuotaWithTranscoding() | bytes: 0 }}. +
diff --git a/client/src/app/+admin/shared/user-real-quota-info.component.scss b/client/src/app/+admin/shared/user-real-quota-info.component.scss new file mode 100644 index 000000000..40083bed3 --- /dev/null +++ b/client/src/app/+admin/shared/user-real-quota-info.component.scss @@ -0,0 +1,2 @@ +@use '_variables' as *; +@use '_mixins' as *; 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 new file mode 100644 index 000000000..069eeba12 --- /dev/null +++ b/client/src/app/+admin/shared/user-real-quota-info.component.ts @@ -0,0 +1,44 @@ +import { Component, Input, OnInit } from '@angular/core' +import { ServerService } from '@app/core' +import { HTMLServerConfig, VideoResolution } from '@shared/models/index' + +@Component({ + selector: 'my-user-real-quota-info', + templateUrl: './user-real-quota-info.component.html', + styleUrls: [ './user-real-quota-info.component.scss' ] +}) +export class UserRealQuotaInfoComponent implements OnInit { + @Input() videoQuota: number | string + + private serverConfig: HTMLServerConfig + + constructor (private server: ServerService) { } + + ngOnInit () { + this.serverConfig = this.server.getHTMLConfig() + } + + isTranscodingInformationDisplayed () { + return this.serverConfig.transcoding.enabledResolutions.length !== 0 && this.getQuotaAsNumber() > 0 + } + + computeQuotaWithTranscoding () { + const transcodingConfig = this.serverConfig.transcoding + + const resolutions = transcodingConfig.enabledResolutions + const higherResolution = VideoResolution.H_4K + let multiplier = 0 + + for (const resolution of resolutions) { + multiplier += resolution / higherResolution + } + + if (transcodingConfig.hls.enabled) multiplier *= 2 + + return multiplier * this.getQuotaAsNumber() + } + + private getQuotaAsNumber () { + return parseInt(this.videoQuota + '', 10) + } +} diff --git a/client/src/app/+admin/system/jobs/job.service.ts b/client/src/app/+admin/system/jobs/job.service.ts index 6c4a07469..ef8ddd3b4 100644 --- a/client/src/app/+admin/system/jobs/job.service.ts +++ b/client/src/app/+admin/system/jobs/job.service.ts @@ -34,9 +34,7 @@ export class JobService { return this.authHttp.get>(JobService.BASE_JOB_URL + `/${jobState || ''}`, { params }) .pipe( - map(res => { - return this.restExtractor.convertResultListDateToHuman(res, [ 'createdAt', 'processedOn', 'finishedOn' ]) - }), + map(res => this.restExtractor.convertResultListDateToHuman(res, [ 'createdAt', 'processedOn', 'finishedOn' ], 'precise')), map(res => this.restExtractor.applyToResultListData(res, this.prettyPrintData)), map(res => this.restExtractor.applyToResultListData(res, this.buildUniqId)), catchError(err => this.restExtractor.handleError(err)) diff --git a/client/src/app/+admin/system/jobs/jobs.component.html b/client/src/app/+admin/system/jobs/jobs.component.html index b53fafeba..a5266bde5 100644 --- a/client/src/app/+admin/system/jobs/jobs.component.html +++ b/client/src/app/+admin/system/jobs/jobs.component.html @@ -69,7 +69,7 @@ {{ getProgress(job) }} - {{ job.createdAt | date: 'short' }} + {{ job.createdAt }}
diff --git a/client/src/app/+admin/system/jobs/jobs.component.ts b/client/src/app/+admin/system/jobs/jobs.component.ts index 42f503be6..d5da1b743 100644 --- a/client/src/app/+admin/system/jobs/jobs.component.ts +++ b/client/src/app/+admin/system/jobs/jobs.component.ts @@ -23,22 +23,28 @@ export class JobsComponent extends RestTable implements OnInit { jobTypes: JobTypeClient[] = [ 'all', + 'activitypub-cleaner', 'activitypub-follow', - 'activitypub-http-broadcast', 'activitypub-http-broadcast-parallel', + 'activitypub-http-broadcast', 'activitypub-http-fetcher', 'activitypub-http-unicast', 'activitypub-refresher', - 'activitypub-cleaner', 'actor-keys', + 'after-video-channel-import', 'email', + 'federate-video', + 'manage-video-torrent', + 'move-to-object-storage', + 'notify', + 'video-channel-import', 'video-file-import', 'video-import', 'video-live-ending', 'video-redundancy', + 'video-studio-edition', 'video-transcoding', - 'videos-views-stats', - 'move-to-object-storage' + 'videos-views-stats' ] jobs: Job[] = [] 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 b557fb011..b93dc2b12 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 @@ -61,7 +61,7 @@
- + {{ user.pendingEmail }} is awaiting email verification
-
+
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 d9e833019..42a8d0856 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 @@ -62,7 +62,7 @@
-
+
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html index e942e002b..a48731e7c 100644 --- a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html +++ b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html @@ -1,7 +1,16 @@

- - My channels - {{ totalItems }} + + + My channels + {{ totalItems }} + + +
+ + + My synchronizations + +

diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss index ab80f3d01..6c5be9240 100644 --- a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss +++ b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss @@ -1,9 +1,20 @@ @use '_variables' as *; @use '_mixins' as *; -h1 my-global-icon { - position: relative; - top: -2px; +h1 { + display: flex; + justify-content: space-between; + + my-global-icon { + position: relative; + top: -2px; + } + + .button-link { + @include peertube-button-link; + @include grey-button; + @include button-with-icon(18px, 3px, -1px); + } } .create-button { diff --git a/client/src/app/+my-library/my-library-routing.module.ts b/client/src/app/+my-library/my-library-routing.module.ts index 73858fb82..de3ef4d96 100644 --- a/client/src/app/+my-library/my-library-routing.module.ts +++ b/client/src/app/+my-library/my-library-routing.module.ts @@ -6,6 +6,8 @@ import { MySubscriptionsComponent } from './my-follows/my-subscriptions.componen import { MyHistoryComponent } from './my-history/my-history.component' import { MyLibraryComponent } from './my-library.component' import { MyOwnershipComponent } from './my-ownership/my-ownership.component' +import { MyVideoChannelSyncsComponent } from './my-video-channel-syncs/my-video-channel-syncs.component' +import { VideoChannelSyncEditComponent } from './my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component' import { MyVideoImportsComponent } from './my-video-imports/my-video-imports.component' import { MyVideoPlaylistCreateComponent } from './my-video-playlists/my-video-playlist-create.component' import { MyVideoPlaylistElementsComponent } from './my-video-playlists/my-video-playlist-elements.component' @@ -131,6 +133,26 @@ const myLibraryRoutes: Routes = [ key: 'my-videos-history-list' } } + }, + + { + path: 'video-channel-syncs', + component: MyVideoChannelSyncsComponent, + data: { + meta: { + title: $localize`My synchronizations` + } + } + }, + + { + path: 'video-channel-syncs/create', + component: VideoChannelSyncEditComponent, + data: { + meta: { + title: $localize`Create new synchronization` + } + } } ] } diff --git a/client/src/app/+my-library/my-library.module.ts b/client/src/app/+my-library/my-library.module.ts index bfafcb3e4..4acb3b75e 100644 --- a/client/src/app/+my-library/my-library.module.ts +++ b/client/src/app/+my-library/my-library.module.ts @@ -29,6 +29,8 @@ import { MyVideoPlaylistUpdateComponent } from './my-video-playlists/my-video-pl import { MyVideoPlaylistsComponent } from './my-video-playlists/my-video-playlists.component' import { VideoChangeOwnershipComponent } from './my-videos/modals/video-change-ownership.component' import { MyVideosComponent } from './my-videos/my-videos.component' +import { MyVideoChannelSyncsComponent } from './my-video-channel-syncs/my-video-channel-syncs.component' +import { VideoChannelSyncEditComponent } from './my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component' @NgModule({ imports: [ @@ -63,6 +65,8 @@ import { MyVideosComponent } from './my-videos/my-videos.component' MyOwnershipComponent, MyAcceptOwnershipComponent, MyVideoImportsComponent, + MyVideoChannelSyncsComponent, + VideoChannelSyncEditComponent, MySubscriptionsComponent, MyFollowersComponent, MyHistoryComponent, diff --git a/client/src/app/+my-library/my-video-channel-syncs/my-video-channel-syncs.component.html b/client/src/app/+my-library/my-video-channel-syncs/my-video-channel-syncs.component.html new file mode 100644 index 000000000..5f368d430 --- /dev/null +++ b/client/src/app/+my-library/my-video-channel-syncs/my-video-channel-syncs.component.html @@ -0,0 +1,90 @@ +
{{ error }}
+ +

+ + My synchronizations +

+ +
+

⚠️ The instance doesn't allow channel synchronization

+
+ + + + + + + + + + External Channel + Channel + State + Created + Last synchronization at + + + + + + + + + + + + {{ videoChannelSync.externalChannelUrl }} + + + + + + + + + {{ videoChannelSync.state.label }} + + + + {{ videoChannelSync.createdAt | date: 'short' }} + {{ videoChannelSync.lastSyncAt | date: 'short' }} + + + + List imports + + + + + diff --git a/client/src/app/+my-library/my-video-channel-syncs/my-video-channel-syncs.component.scss b/client/src/app/+my-library/my-video-channel-syncs/my-video-channel-syncs.component.scss new file mode 100644 index 000000000..88738e54d --- /dev/null +++ b/client/src/app/+my-library/my-video-channel-syncs/my-video-channel-syncs.component.scss @@ -0,0 +1,14 @@ +@use '_mixins' as *; +@use '_variables' as *; +@use '_actor' as *; + +.add-sync { + @include create-button; +} + +.actor { + @include actor-row($min-height: auto, $separator: true); + margin-bottom: 0; + padding-bottom: 0; + border: 0; +} diff --git a/client/src/app/+my-library/my-video-channel-syncs/my-video-channel-syncs.component.ts b/client/src/app/+my-library/my-video-channel-syncs/my-video-channel-syncs.component.ts new file mode 100644 index 000000000..290847418 --- /dev/null +++ b/client/src/app/+my-library/my-video-channel-syncs/my-video-channel-syncs.component.ts @@ -0,0 +1,130 @@ +import { Component, OnInit } from '@angular/core' +import { AuthService, Notifier, RestPagination, RestTable, ServerService } from '@app/core' +import { DropdownAction, VideoChannelService, VideoChannelSyncService } from '@app/shared/shared-main' +import { HTMLServerConfig } from '@shared/models/server' +import { VideoChannelSync, VideoChannelSyncState } from '@shared/models/videos' +import { SortMeta } from 'primeng/api' +import { mergeMap } from 'rxjs' + +@Component({ + templateUrl: './my-video-channel-syncs.component.html', + styleUrls: [ './my-video-channel-syncs.component.scss' ] +}) +export class MyVideoChannelSyncsComponent extends RestTable implements OnInit { + error: string + + channelSyncs: VideoChannelSync[] = [] + totalRecords = 0 + + videoChannelSyncActions: DropdownAction[][] = [] + sort: SortMeta = { field: 'createdAt', order: 1 } + pagination: RestPagination = { count: this.rowsPerPage, start: 0 } + + private static STATE_CLASS_BY_ID = { + [VideoChannelSyncState.FAILED]: 'badge-red', + [VideoChannelSyncState.PROCESSING]: 'badge-blue', + [VideoChannelSyncState.SYNCED]: 'badge-green', + [VideoChannelSyncState.WAITING_FIRST_RUN]: 'badge-yellow' + } + + private serverConfig: HTMLServerConfig + + constructor ( + private videoChannelsSyncService: VideoChannelSyncService, + private serverService: ServerService, + private notifier: Notifier, + private authService: AuthService, + private videoChannelService: VideoChannelService + ) { + super() + } + + ngOnInit () { + this.serverConfig = this.serverService.getHTMLConfig() + this.initialize() + + this.videoChannelSyncActions = [ + [ + { + label: $localize`Delete`, + iconName: 'delete', + handler: videoChannelSync => this.deleteSync(videoChannelSync) + }, + { + label: $localize`Fully synchronize the channel`, + description: $localize`This fetches any missing videos on the local channel`, + iconName: 'refresh', + handler: videoChannelSync => this.fullySynchronize(videoChannelSync) + } + ] + ] + } + + protected reloadData () { + this.error = undefined + + this.authService.userInformationLoaded + .pipe(mergeMap(() => { + const user = this.authService.getUser() + return this.videoChannelsSyncService.listAccountVideoChannelsSyncs({ + sort: this.sort, + account: user.account, + pagination: this.pagination + }) + })) + .subscribe({ + next: res => { + this.channelSyncs = res.data + this.totalRecords = res.total + }, + error: err => { + this.error = err.message + } + }) + } + + syncEnabled () { + return this.serverConfig.import.videoChannelSynchronization.enabled + } + + deleteSync (videoChannelSync: VideoChannelSync) { + this.videoChannelsSyncService.deleteSync(videoChannelSync.id) + .subscribe({ + next: () => { + this.notifier.success($localize`Synchronization removed successfully for ${videoChannelSync.channel.displayName}.`) + this.reloadData() + }, + error: err => { + this.error = err.message + } + }) + } + + fullySynchronize (videoChannelSync: VideoChannelSync) { + this.videoChannelService.importVideos(videoChannelSync.channel.name, videoChannelSync.externalChannelUrl, videoChannelSync.id) + .subscribe({ + next: () => { + this.notifier.success($localize`Full synchronization requested successfully for ${videoChannelSync.channel.displayName}.`) + }, + error: err => { + this.error = err.message + } + }) + } + + getSyncCreateLink () { + return '/my-library/video-channel-syncs/create' + } + + getSyncStateClass (stateId: number) { + return [ 'pt-badge', MyVideoChannelSyncsComponent.STATE_CLASS_BY_ID[stateId] ] + } + + getIdentifier () { + return 'MyVideoChannelsSyncComponent' + } + + getChannelUrl (name: string) { + return '/c/' + name + } +} 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 new file mode 100644 index 000000000..611146c1a --- /dev/null +++ b/client/src/app/+my-library/my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component.html @@ -0,0 +1,64 @@ +
{{ error }}
+ +
+ + +
+
+
NEW SYNCHRONIZATION
+
+ +
+
+ + +
+ +
+ +
+ {{ formErrors['externalChannelUrl'] }} +
+
+ +
+ + + +
+ {{ formErrors['videoChannel'] }} +
+
+ +
+ + +
+ + +
+ +
+ + +
+
+
+
+ +
+
+
+ +
+
+ +
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 new file mode 100644 index 000000000..d0d8c2a68 --- /dev/null +++ b/client/src/app/+my-library/my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component.scss @@ -0,0 +1,17 @@ +@use '_variables' as *; +@use '_mixins' as *; + +$form-base-input-width: 480px; + +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-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component.ts b/client/src/app/+my-library/my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component.ts new file mode 100644 index 000000000..9ceb6dfd1 --- /dev/null +++ b/client/src/app/+my-library/my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component.ts @@ -0,0 +1,76 @@ +import { mergeMap } from 'rxjs' +import { SelectChannelItem } from 'src/types' +import { Component, OnInit } from '@angular/core' +import { Router } from '@angular/router' +import { AuthService, Notifier } from '@app/core' +import { listUserChannelsForSelect } from '@app/helpers' +import { VIDEO_CHANNEL_EXTERNAL_URL_VALIDATOR } from '@app/shared/form-validators/video-channel-validators' +import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' +import { VideoChannelService, VideoChannelSyncService } from '@app/shared/shared-main' +import { VideoChannelSyncCreate } from '@shared/models/videos' + +@Component({ + selector: 'my-video-channel-sync-edit', + templateUrl: './video-channel-sync-edit.component.html', + styleUrls: [ './video-channel-sync-edit.component.scss' ] +}) +export class VideoChannelSyncEditComponent extends FormReactive implements OnInit { + error: string + userVideoChannels: SelectChannelItem[] = [] + existingVideosStrategy: string + + constructor ( + protected formValidatorService: FormValidatorService, + private authService: AuthService, + private router: Router, + private notifier: Notifier, + private videoChannelSyncService: VideoChannelSyncService, + private videoChannelService: VideoChannelService + ) { + super() + } + + ngOnInit () { + this.buildForm({ + externalChannelUrl: VIDEO_CHANNEL_EXTERNAL_URL_VALIDATOR, + videoChannel: null, + existingVideoStrategy: null + }) + + listUserChannelsForSelect(this.authService) + .subscribe(channels => this.userVideoChannels = channels) + } + + getFormButtonTitle () { + return $localize`Create` + } + + formValidated () { + this.error = undefined + + const body = this.form.value + const videoChannelSyncCreate: VideoChannelSyncCreate = { + externalChannelUrl: body.externalChannelUrl, + videoChannelId: body.videoChannel + } + + const importExistingVideos = body['existingVideoStrategy'] === 'import' + + this.videoChannelSyncService.createSync(videoChannelSyncCreate) + .pipe(mergeMap(({ videoChannelSync }) => { + return importExistingVideos + ? this.videoChannelService.importVideos(videoChannelSync.channel.name, videoChannelSync.externalChannelUrl, videoChannelSync.id) + : Promise.resolve(null) + })) + .subscribe({ + next: () => { + this.notifier.success($localize`Synchronization created successfully.`) + this.router.navigate([ '/my-library', 'video-channel-syncs' ]) + }, + + error: err => { + this.error = err.message + } + }) + } +} diff --git a/client/src/app/+my-library/my-video-imports/my-video-imports.component.html b/client/src/app/+my-library/my-video-imports/my-video-imports.component.html index fb0f6f5a3..92a4a4a52 100644 --- a/client/src/app/+my-library/my-video-imports/my-video-imports.component.html +++ b/client/src/app/+my-library/my-video-imports/my-video-imports.component.html @@ -1,11 +1,22 @@ -

- - My imports +

+ + + My imports + + + + + My synchronizations +

+
+ +
+ { this.videoImports = resultList.data diff --git a/client/src/app/+my-library/my-videos/my-videos.component.ts b/client/src/app/+my-library/my-videos/my-videos.component.ts index 40650029c..ecd0be14f 100644 --- a/client/src/app/+my-library/my-videos/my-videos.component.ts +++ b/client/src/app/+my-library/my-videos/my-videos.component.ts @@ -43,7 +43,8 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook { privacyLabel: false, privacyText: true, state: true, - blacklistInfo: true + blacklistInfo: true, + forceChannelInBy: true } videoDropdownDisplayOptions: VideoActionsDisplayType = { playlist: false, diff --git a/client/src/app/+search/search.component.ts b/client/src/app/+search/search.component.ts index 62b1c4446..366fbd459 100644 --- a/client/src/app/+search/search.component.ts +++ b/client/src/app/+search/search.component.ts @@ -98,7 +98,7 @@ export class SearchComponent implements OnInit, OnDestroy { this.search() }, - error: err => this.notifier.error(err.text) + error: err => this.notifier.error(err.message) }) this.userService.getAnonymousOrLoggedUser() diff --git a/client/src/app/+signup/+register/register.component.html b/client/src/app/+signup/+register/register.component.html index 4094f24cf..bafb96a49 100644 --- a/client/src/app/+signup/+register/register.component.html +++ b/client/src/app/+signup/+register/register.component.html @@ -55,7 +55,7 @@
- + Setup
your account
diff --git a/client/src/app/+stats/video/video-stats.component.ts b/client/src/app/+stats/video/video-stats.component.ts index 6e03da727..bfad4f823 100644 --- a/client/src/app/+stats/video/video-stats.component.ts +++ b/client/src/app/+stats/video/video-stats.component.ts @@ -528,7 +528,7 @@ export class VideoStatsComponent implements OnInit { const date = new Date(label) if (data.groupInterval.match(/ month?$/)) { - return date.toLocaleDateString([], { month: 'numeric' }) + return date.toLocaleDateString([], { year: '2-digit', month: 'numeric' }) } if (data.groupInterval.match(/ days?$/)) { diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.html b/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.html new file mode 100644 index 000000000..e8079c74e --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.html @@ -0,0 +1,34 @@ + + + + + + + diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal/video-caption-edit-modal.component.scss b/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.scss similarity index 100% rename from client/src/app/+videos/+video-edit/shared/video-caption-edit-modal/video-caption-edit-modal.component.scss rename to client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.scss diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal/video-caption-edit-modal.component.ts b/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.ts similarity index 70% rename from client/src/app/+videos/+video-edit/shared/video-caption-edit-modal/video-caption-edit-modal.component.ts rename to client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.ts index f74f3c5ea..f33353d36 100644 --- a/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal/video-caption-edit-modal.component.ts +++ b/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.ts @@ -2,28 +2,33 @@ import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } import { VIDEO_CAPTION_FILE_CONTENT_VALIDATOR } from '@app/shared/form-validators/video-captions-validators' import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' import { VideoCaptionEdit, VideoCaptionService, VideoCaptionWithPathEdit } from '@app/shared/shared-main' -import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' +import { NgbModal, NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { HTMLServerConfig, VideoConstant } from '@shared/models' import { ServerService } from '../../../../core' +/** + * https://github.com/valor-software/ngx-bootstrap/issues/3825 + * https://stackblitz.com/edit/angular-t5dfp7 + * https://medium.com/@izzatnadiri/how-to-pass-data-to-and-receive-from-ng-bootstrap-modals-916f2ad5d66e + */ @Component({ - selector: 'my-video-caption-edit-modal', - styleUrls: [ './video-caption-edit-modal.component.scss' ], - templateUrl: './video-caption-edit-modal.component.html' + selector: 'my-video-caption-edit-modal-content', + styleUrls: [ './video-caption-edit-modal-content.component.scss' ], + templateUrl: './video-caption-edit-modal-content.component.html' }) -export class VideoCaptionEditModalComponent extends FormReactive implements OnInit { +export class VideoCaptionEditModalContentComponent extends FormReactive implements OnInit { @Input() videoCaption: VideoCaptionWithPathEdit @Input() serverConfig: HTMLServerConfig @Output() captionEdited = new EventEmitter() - @ViewChild('modal', { static: true }) modal: ElementRef + @ViewChild('textarea', { static: true }) textarea!: ElementRef videoCaptionLanguages: VideoConstant[] = [] - private openedModal: NgbModalRef constructor ( + protected openedModal: NgbActiveModal, protected formValidatorService: FormValidatorService, private modalService: NgbModal, private videoCaptionService: VideoCaptionService, @@ -49,11 +54,14 @@ export class VideoCaptionEditModalComponent extends FormReactive implements OnIn this.form.patchValue({ captionFileContent: res }) + this.resetTextarea() }) } - show () { - this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false }) + resetTextarea () { + this.textarea.nativeElement.scrollTop = 0 + this.textarea.nativeElement.selectionStart = 0 + this.textarea.nativeElement.selectionEnd = 0 } hide () { diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal/video-caption-edit-modal.component.html b/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal/video-caption-edit-modal.component.html deleted file mode 100644 index be6f676c2..000000000 --- a/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal/video-caption-edit-modal.component.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html index 2892d603d..7be5a3736 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.html +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html @@ -147,7 +147,7 @@ - If you decide not to wait for transcoding before publishing the video, it could be unplayable until transcoding ends. + The video may be unplayable during the transcoding process. It's the reason why we prefer to publish publicly the video after transcoding. @@ -185,7 +185,7 @@
Already uploaded on {{ videoCaption.updatedAt | date }} ✔
- Edit + Edit Delete @@ -212,13 +212,6 @@ Cancel deletion - -
@@ -314,7 +307,7 @@
- + & { group?: string } type PluginField = { @@ -70,7 +71,6 @@ export class VideoEditComponent implements OnInit, OnDestroy { @Input() liveVideo: LiveVideo @ViewChild('videoCaptionAddModal', { static: true }) videoCaptionAddModal: VideoCaptionAddModalComponent - @ViewChild('videoCaptionEditModal', { static: true }) editCaptionModal: VideoCaptionEditModalComponent @Output() formBuilt = new EventEmitter() @Output() pluginFieldsAdded = new EventEmitter() @@ -128,7 +128,8 @@ export class VideoEditComponent implements OnInit, OnDestroy { private i18nPrimengCalendarService: I18nPrimengCalendarService, private ngZone: NgZone, private hooks: HooksService, - private cd: ChangeDetectorRef + private cd: ChangeDetectorRef, + private modalService: NgbModal ) { this.calendarTimezone = this.i18nPrimengCalendarService.getTimezone() this.calendarDateFormat = this.i18nPrimengCalendarService.getDateFormat() @@ -286,6 +287,13 @@ export class VideoEditComponent implements OnInit, OnDestroy { this.videoCaptionAddModal.show() } + openEditCaptionModal (videoCaption: VideoCaptionWithPathEdit) { + const modalRef = this.modalService.open(VideoCaptionEditModalContentComponent, { centered: true, keyboard: false }) + modalRef.componentInstance.videoCaption = videoCaption + modalRef.componentInstance.serverConfig = this.serverConfig + modalRef.componentInstance.captionEdited.subscribe(this.onCaptionEdited.bind(this)) + } + isSaveReplayEnabled () { return this.serverConfig.live.allowReplay } 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 4e8767364..d463bf633 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 @@ -6,7 +6,7 @@ import { SharedMainModule } from '@app/shared/shared-main' import { SharedVideoLiveModule } from '@app/shared/shared-video-live' import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service' import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' -import { VideoCaptionEditModalComponent } from './video-caption-edit-modal/video-caption-edit-modal.component' +import { VideoCaptionEditModalContentComponent } from './video-caption-edit-modal-content/video-caption-edit-modal-content.component' import { VideoEditComponent } from './video-edit.component' @NgModule({ @@ -22,7 +22,7 @@ import { VideoEditComponent } from './video-edit.component' declarations: [ VideoEditComponent, VideoCaptionAddModalComponent, - VideoCaptionEditModalComponent + VideoCaptionEditModalContentComponent ], exports: [ diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.html b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.html index f537b939f..2fb29303f 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.html +++ b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.html @@ -61,7 +61,7 @@
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.scss b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.scss index 09b71018f..42d74725e 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.scss +++ b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.scss @@ -2,11 +2,3 @@ margin-top: 15px; } -.peertube-radio-container { - width: 250px; - - .form-group-description { - white-space: nowrap; - } -} - diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts index 91eb66931..344b99ea2 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts +++ b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts @@ -27,6 +27,8 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView firstStepPermanentLive: boolean isInUpdateForm = false + isUpdatingVideo = false + isOrHasGoingLive = false liveVideo: LiveVideo @@ -64,6 +66,9 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView } goLive () { + if (this.isOrHasGoingLive) return + this.isOrHasGoingLive = true + const name = 'Live' const video: LiveVideoCreate = { @@ -115,6 +120,8 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView async updateSecondStep () { if (!await this.isFormValid()) return + this.isUpdatingVideo = true + const video = new VideoEdit() video.patch(this.form.value) video.id = this.videoId @@ -134,6 +141,8 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView this.liveVideoService.updateLive(this.videoId, liveVideoUpdate) ]).subscribe({ next: () => { + this.isUpdatingVideo = false + this.notifier.success($localize`Live published.`) this.router.navigateByUrl(Video.buildWatchUrl(video)) diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html index 67e1cb418..a80d31aaf 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html +++ b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html @@ -16,6 +16,10 @@ + +
+ You can also synchronize a remote channel in your library +
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts index 4ef7d1321..422f0c643 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts +++ b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts @@ -64,6 +64,10 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV return this.targetUrl?.match(/https?:\/\//) } + isChannelSyncEnabled () { + return this.serverConfig.import.videoChannelSynchronization.enabled + } + importVideo () { this.isImportingVideo = true diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-send.scss b/client/src/app/+videos/+video-edit/video-add-components/video-send.scss index ed46fefb0..684ab23cc 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-send.scss +++ b/client/src/app/+videos/+video-edit/video-add-components/video-send.scss @@ -1,7 +1,7 @@ @use '_variables' as *; @use '_mixins' as *; -$width-size: 250px; +$width-size: 275px; .alert.alert-danger { text-align: center; @@ -27,7 +27,9 @@ $width-size: 250px; @include peertube-select-container($width-size); } my-select-options ::ng-deep ng-select, - my-select-channel ::ng-deep ng-select { + my-select-channel ::ng-deep ng-select, + .peertube-radio-container, + .form-group-description { width: $width-size; @media screen and (max-width: $width-size) { @@ -35,6 +37,10 @@ $width-size: 250px; } } + .form-group-description { + white-space: nowrap; + } + input[type=text] { @include peertube-input-text($width-size); display: block; 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 66a3967c7..19fba2a83 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 @@ -13,6 +13,7 @@ import { isIOS } from '@root-helpers/web-browser' import { HttpStatusCode, VideoCreateResult } from '@shared/models' import { UploaderXFormData } from './uploaderx-form-data' import { VideoSend } from './video-send' +import { Subscription } from 'rxjs' @Component({ selector: 'my-video-upload', @@ -56,6 +57,8 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy private alreadyRefreshedToken = false + private uploadServiceSubscription: Subscription + constructor ( protected formValidatorService: FormValidatorService, protected loadingBar: LoadingBarService, @@ -87,7 +90,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy this.userVideoQuotaUsedDaily = data.videoQuotaUsedDaily }) - this.resumableUploadService.events + this.uploadServiceSubscription = this.resumableUploadService.events .subscribe(state => this.onUploadVideoOngoing(state)) } @@ -96,7 +99,9 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy } ngOnDestroy () { - this.cancelUpload() + this.resumableUploadService.disconnect() + + if (this.uploadServiceSubscription) this.uploadServiceSubscription.unsubscribe() } canDeactivate () { diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts b/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts index fd3614297..9f4a68736 100644 --- a/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts +++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts @@ -148,7 +148,7 @@ export class VideoCommentAddComponent extends FormReactive implements OnChanges, error: err => { this.addingComment = false - this.notifier.error(err.text) + this.notifier.error(err.message) } }) } diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts index 8d9c08ab3..9ae6f9f12 100644 --- a/client/src/app/+videos/+video-watch/video-watch.component.ts +++ b/client/src/app/+videos/+video-watch/video-watch.component.ts @@ -628,6 +628,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy { : null, authorizationHeader: this.authService.getRequestHeaderValue(), + metricsUrl: environment.apiUrl + '/api/v1/metrics/playback', + embedUrl: video.embedUrl, embedTitle: video.name, instanceName: this.serverConfig.instance.name, diff --git a/client/src/app/core/auth/auth.service.ts b/client/src/app/core/auth/auth.service.ts index ece6bc5d1..ca46866f5 100644 --- a/client/src/app/core/auth/auth.service.ts +++ b/client/src/app/core/auth/auth.service.ts @@ -97,7 +97,7 @@ export class AuthService { let errorMessage = err.message if (err.status === HttpStatusCode.FORBIDDEN_403) { - errorMessage = $localize`Cannot retrieve OAuth Client credentials: ${err.text}. + errorMessage = $localize`Cannot retrieve OAuth Client credentials: ${err.message}. Ensure you have correctly configured PeerTube (config/ directory), in particular the "webserver" section.` } diff --git a/client/src/app/core/menu/menu.service.ts b/client/src/app/core/menu/menu.service.ts index 81837db7e..d865c7da2 100644 --- a/client/src/app/core/menu/menu.service.ts +++ b/client/src/app/core/menu/menu.service.ts @@ -7,6 +7,7 @@ import { ScreenService } from '../wrappers' export type MenuLink = { icon: GlobalIconName + iconClass?: string label: string // Used by the left menu for example @@ -70,6 +71,14 @@ export class MenuService { let links: MenuLink[] = [] if (userCanSeeVideosLink) { + links.push({ + path: '/my-library/video-channels', + icon: 'channel' as GlobalIconName, + iconClass: 'channel-icon', + shortLabel: $localize`Channels`, + label: $localize`My channels` + }) + links.push({ path: '/my-library/videos', icon: 'videos' as GlobalIconName, diff --git a/client/src/app/core/rest/rest-extractor.service.ts b/client/src/app/core/rest/rest-extractor.service.ts index 8a2974563..7eec2eca6 100644 --- a/client/src/app/core/rest/rest-extractor.service.ts +++ b/client/src/app/core/rest/rest-extractor.service.ts @@ -1,14 +1,17 @@ import { throwError as observableThrowError } from 'rxjs' -import { Injectable } from '@angular/core' +import { Inject, Injectable, LOCALE_ID } from '@angular/core' import { Router } from '@angular/router' -import { dateToHuman } from '@app/helpers' -import { HttpStatusCode, ResultList } from '@shared/models' +import { DateFormat, dateToHuman } from '@app/helpers' import { logger } from '@root-helpers/logger' +import { HttpStatusCode, ResultList } from '@shared/models' @Injectable() export class RestExtractor { - constructor (private router: Router) { } + constructor ( + @Inject(LOCALE_ID) private localeId: string, + private router: Router + ) { } applyToResultListData ( result: ResultList, @@ -23,13 +26,17 @@ export class RestExtractor { } } - convertResultListDateToHuman (result: ResultList, fieldsToConvert: string[] = [ 'createdAt' ]): ResultList { - return this.applyToResultListData(result, this.convertDateToHuman, [ fieldsToConvert ]) + convertResultListDateToHuman ( + result: ResultList, + fieldsToConvert: string[] = [ 'createdAt' ], + format?: DateFormat + ): ResultList { + return this.applyToResultListData(result, this.convertDateToHuman, [ fieldsToConvert, format ]) } - convertDateToHuman (target: any, fieldsToConvert: string[]) { + convertDateToHuman (target: any, fieldsToConvert: string[], format?: DateFormat) { fieldsToConvert.forEach(field => { - target[field] = dateToHuman(target[field]) + target[field] = dateToHuman(this.localeId, new Date(target[field]), format) }) return target diff --git a/client/src/app/header/header.component.html b/client/src/app/header/header.component.html index f49f471c6..b5e9e3dd8 100644 --- a/client/src/app/header/header.component.html +++ b/client/src/app/header/header.component.html @@ -1,4 +1,4 @@ - + diff --git a/client/src/app/header/header.component.scss b/client/src/app/header/header.component.scss index 8a4111c5a..cf6e96d07 100644 --- a/client/src/app/header/header.component.scss +++ b/client/src/app/header/header.component.scss @@ -1,14 +1,6 @@ @use '_variables' as *; @use '_mixins' as *; -my-search-typeahead { - @include margin-right(80px); - - @media screen and (max-width: $small-view) { - @include margin-right(15px); - } -} - .publish-button { @include peertube-button-link; @include orange-button; diff --git a/client/src/app/header/search-typeahead.component.scss b/client/src/app/header/search-typeahead.component.scss index 299efba2c..ae0f1547e 100644 --- a/client/src/app/header/search-typeahead.component.scss +++ b/client/src/app/header/search-typeahead.component.scss @@ -75,6 +75,7 @@ li.suggestion { #typeahead-container { font-size: 14px; + margin: 0 10px; input { border: 1px solid pvar(--mainBackgroundColor) !important; @@ -83,15 +84,15 @@ li.suggestion { transition: box-shadow .3s ease, width .2s ease; } - @media screen and (min-width: $mobile-view) { - @include margin-left(10px); + @media screen and (max-width: $small-view) { + input { + width: 200px; + } } - @media screen and (max-width: $small-view) { - flex: 1; - + @media screen and (max-width: $mobile-view) { input { - width: 70px; + width: 150px; } } diff --git a/client/src/app/helpers/utils/date.ts b/client/src/app/helpers/utils/date.ts index 012b959ea..75363cc81 100644 --- a/client/src/app/helpers/utils/date.ts +++ b/client/src/app/helpers/utils/date.ts @@ -1,8 +1,29 @@ import { DatePipe } from '@angular/common' -const datePipe = new DatePipe('en') -function dateToHuman (date: string) { - return datePipe.transform(date, 'medium') +let datePipe: DatePipe +let intl: Intl.DateTimeFormat + +type DateFormat = 'medium' | 'precise' + +function dateToHuman (localeId: string, date: Date, format: 'medium' | 'precise' = 'medium') { + if (!datePipe) { + datePipe = new DatePipe(localeId) + } + + if (!intl) { + intl = new Intl.DateTimeFormat(localeId, { + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + year: '2-digit', + month: 'numeric', + day: 'numeric', + fractionalSecondDigits: 3 + }) + } + + if (format === 'medium') return datePipe.transform(date, format) + if (format === 'precise') return intl.format(date) } function durationToString (duration: number) { @@ -20,6 +41,8 @@ function durationToString (duration: number) { } export { + DateFormat, + durationToString, dateToHuman } diff --git a/client/src/app/helpers/utils/object.ts b/client/src/app/helpers/utils/object.ts index 1ca4a23ac..69b2b18c0 100644 --- a/client/src/app/helpers/utils/object.ts +++ b/client/src/app/helpers/utils/object.ts @@ -18,7 +18,7 @@ function sortBy (obj: any[], key1: string, key2?: string) { }) } -function intoArray (value: any) { +function splitIntoArray (value: any) { if (!value) return undefined if (Array.isArray(value)) return value @@ -42,6 +42,6 @@ export { sortBy, immutableAssign, removeElementFromArray, - intoArray, + splitIntoArray, toBoolean } diff --git a/client/src/app/menu/menu.component.html b/client/src/app/menu/menu.component.html index c1e5f79a6..c5d08ab75 100644 --- a/client/src/app/menu/menu.component.html +++ b/client/src/app/menu/menu.component.html @@ -88,7 +88,7 @@ - + My library @@ -111,7 +111,7 @@
{{ menuSection.title }}
- + {{ link.shortLabel }}
diff --git a/client/src/app/menu/menu.component.scss b/client/src/app/menu/menu.component.scss index a824c69fe..cd57e134e 100644 --- a/client/src/app/menu/menu.component.scss +++ b/client/src/app/menu/menu.component.scss @@ -391,26 +391,17 @@ my-actor-avatar { } my-global-icon { - &[iconName=playlists] { + position: relative; + top: -1px; + + .playlist-icon { @include margin-right(16px); height: 24px; width: 24px; } - &[iconName=videos] { - position: relative; - right: -1px; - } - - &[iconName=channel] { - margin-top: -2px; - } - - &[iconName='sign-out'] { - position: relative; - right: -2px; - height: 20px; - width: 20px; + &.channel-icon { + top: -2px; } } 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 ba8512e95..ff0813f7d 100644 --- a/client/src/app/shared/form-validators/custom-config-validators.ts +++ b/client/src/app/shared/form-validators/custom-config-validators.ts @@ -9,9 +9,9 @@ export const INSTANCE_NAME_VALIDATOR: BuildFormValidator = { } export const INSTANCE_SHORT_DESCRIPTION_VALIDATOR: BuildFormValidator = { - VALIDATORS: [ Validators.max(250) ], + VALIDATORS: [ Validators.maxLength(250) ], MESSAGES: { - max: $localize`Short description should not be longer than 250 characters.` + maxlength: $localize`Short description should not be longer than 250 characters.` } } diff --git a/client/src/app/shared/form-validators/video-channel-validators.ts b/client/src/app/shared/form-validators/video-channel-validators.ts index 163faf270..b12b3caaf 100644 --- a/client/src/app/shared/form-validators/video-channel-validators.ts +++ b/client/src/app/shared/form-validators/video-channel-validators.ts @@ -48,3 +48,16 @@ export const VIDEO_CHANNEL_SUPPORT_VALIDATOR: BuildFormValidator = { maxlength: $localize`Support text cannot be more than 1000 characters long.` } } + +export const VIDEO_CHANNEL_EXTERNAL_URL_VALIDATOR: BuildFormValidator = { + VALIDATORS: [ + Validators.required, + Validators.pattern(/^https?:\/\//), + Validators.maxLength(1000) + ], + MESSAGES: { + required: $localize`Remote channel url is required.`, + pattern: $localize`External channel URL must begin with "https://" or "http://"`, + maxlength: $localize`External channel URL cannot be more than 1000 characters long` + } +} diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/embed-markup.component.ts b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/embed-markup.component.ts index 955b0af18..0baf2428b 100644 --- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/embed-markup.component.ts +++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/embed-markup.component.ts @@ -21,6 +21,6 @@ export class EmbedMarkupComponent implements CustomMarkupComponent, OnInit { ? buildVideoEmbedLink({ uuid: this.uuid }, environment.originServerUrl) : buildPlaylistEmbedLink({ uuid: this.uuid }, environment.originServerUrl) - this.el.nativeElement.innerHTML = buildVideoOrPlaylistEmbed(link, this.uuid) + this.el.nativeElement.innerHTML = buildVideoOrPlaylistEmbed({ embedUrl: link, embedTitle: this.uuid }) } } diff --git a/client/src/app/shared/shared-forms/form-reactive.ts b/client/src/app/shared/shared-forms/form-reactive.ts index 6b3a6c773..a19ffdd82 100644 --- a/client/src/app/shared/shared-forms/form-reactive.ts +++ b/client/src/app/shared/shared-forms/form-reactive.ts @@ -1,5 +1,5 @@ -import { FormGroup } from '@angular/forms' +import { AbstractControl, FormGroup } from '@angular/forms' import { wait } from '@root-helpers/utils' import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' import { FormValidatorService } from './form-validator.service' @@ -44,6 +44,21 @@ export abstract class FormReactive { } while (this.form.status === 'PENDING') } + protected markAllAsDirty (controlsArg?: { [ key: string ]: AbstractControl }) { + const controls = controlsArg || this.form.controls + + for (const key of Object.keys(controls)) { + const control = controls[key] + + if (control instanceof FormGroup) { + this.markAllAsDirty(control.controls) + continue + } + + control.markAsDirty() + } + } + protected forceCheck () { this.onStatusChanged(this.form, this.formErrors, this.validationMessages, false) } @@ -59,7 +74,8 @@ export abstract class FormReactive { this.onStatusChanged( form.controls[field] as FormGroup, formErrors[field] as FormReactiveErrors, - validationMessages[field] as FormReactiveValidationMessages + validationMessages[field] as FormReactiveValidationMessages, + onlyDirty ) continue } 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 761243bfe..6c05764df 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 @@ -106,6 +106,13 @@ + + Channel synchronization with other platforms (YouTube, Vimeo, ...) + + + + + Search diff --git a/client/src/app/shared/shared-instance/instance-follow.service.ts b/client/src/app/shared/shared-instance/instance-follow.service.ts index 5366fd068..7568fbbf4 100644 --- a/client/src/app/shared/shared-instance/instance-follow.service.ts +++ b/client/src/app/shared/shared-instance/instance-follow.service.ts @@ -1,6 +1,6 @@ import { SortMeta } from 'primeng/api' import { from, Observable } from 'rxjs' -import { catchError, concatMap, map, toArray } from 'rxjs/operators' +import { catchError, concatMap, toArray } from 'rxjs/operators' import { HttpClient, HttpParams } from '@angular/common/http' import { Injectable } from '@angular/core' import { RestExtractor, RestPagination, RestService } from '@app/core' @@ -40,10 +40,7 @@ export class InstanceFollowService { if (actorType) params = params.append('actorType', actorType) return this.authHttp.get>(InstanceFollowService.BASE_APPLICATION_URL + '/following', { params }) - .pipe( - map(res => this.restExtractor.convertResultListDateToHuman(res)), - catchError(res => this.restExtractor.handleError(res)) - ) + .pipe(catchError(res => this.restExtractor.handleError(res))) } getFollowers (options: { @@ -66,10 +63,7 @@ export class InstanceFollowService { if (actorType) params = params.append('actorType', actorType) return this.authHttp.get>(InstanceFollowService.BASE_APPLICATION_URL + '/followers', { params }) - .pipe( - map(res => this.restExtractor.convertResultListDateToHuman(res)), - catchError(res => this.restExtractor.handleError(res)) - ) + .pipe(catchError(res => this.restExtractor.handleError(res))) } follow (hostsOrHandles: string[]) { diff --git a/client/src/app/shared/shared-main/buttons/action-dropdown.component.html b/client/src/app/shared/shared-main/buttons/action-dropdown.component.html index 017355bd0..37cf63fcd 100644 --- a/client/src/app/shared/shared-main/buttons/action-dropdown.component.html +++ b/client/src/app/shared/shared-main/buttons/action-dropdown.component.html @@ -18,7 +18,7 @@ -
+
{{ action.label }} {{ action.description }}
diff --git a/client/src/app/shared/shared-main/buttons/action-dropdown.component.scss b/client/src/app/shared/shared-main/buttons/action-dropdown.component.scss index fe65d6e7e..4c8a591aa 100644 --- a/client/src/app/shared/shared-main/buttons/action-dropdown.component.scss +++ b/client/src/app/shared/shared-main/buttons/action-dropdown.component.scss @@ -53,6 +53,8 @@ } .dropdown-menu { + max-width: 75vw; + .dropdown-header { padding: 0.2rem 1rem; } @@ -72,3 +74,13 @@ } } } + +.item-label { + display: flex; + flex-direction: column; + min-width: 1px; + + > * { + @include ellipsis; + } +} diff --git a/client/src/app/shared/shared-main/buttons/delete-button.component.ts b/client/src/app/shared/shared-main/buttons/delete-button.component.ts index 1cab10803..0ee7d3757 100644 --- a/client/src/app/shared/shared-main/buttons/delete-button.component.ts +++ b/client/src/app/shared/shared-main/buttons/delete-button.component.ts @@ -3,13 +3,18 @@ import { Component, Input, OnInit } from '@angular/core' @Component({ selector: 'my-delete-button', template: ` - + ` }) export class DeleteButtonComponent implements OnInit { @Input() label: string @Input() title: string @Input() responsiveLabel = false + @Input() disabled: boolean ngOnInit () { if (this.label === undefined && !this.title) { diff --git a/client/src/app/shared/shared-main/index.ts b/client/src/app/shared/shared-main/index.ts index 3a7fd4c34..9faa28e32 100644 --- a/client/src/app/shared/shared-main/index.ts +++ b/client/src/app/shared/shared-main/index.ts @@ -13,3 +13,4 @@ export * from './video' export * from './video-caption' export * from './video-channel' export * from './shared-main.module' +export * from './video-channel-sync' diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts index 89f43239f..04b223cc5 100644 --- a/client/src/app/shared/shared-main/shared-main.module.ts +++ b/client/src/app/shared/shared-main/shared-main.module.ts @@ -6,7 +6,6 @@ import { NgModule } from '@angular/core' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { RouterModule } from '@angular/router' import { - NgbButtonsModule, NgbCollapseModule, NgbDropdownModule, NgbModalModule, @@ -66,7 +65,6 @@ import { VideoChannelService } from './video-channel' NgbNavModule, NgbTooltipModule, NgbCollapseModule, - NgbButtonsModule, ClipboardModule, @@ -129,7 +127,6 @@ import { VideoChannelService } from './video-channel' NgbNavModule, NgbTooltipModule, NgbCollapseModule, - NgbButtonsModule, ClipboardModule, diff --git a/client/src/app/shared/shared-main/users/user-notification.service.ts b/client/src/app/shared/shared-main/users/user-notification.service.ts index df886ed65..0b3dd9a53 100644 --- a/client/src/app/shared/shared-main/users/user-notification.service.ts +++ b/client/src/app/shared/shared-main/users/user-notification.service.ts @@ -40,7 +40,6 @@ export class UserNotificationService { return this.authHttp.get>(UserNotificationService.BASE_NOTIFICATIONS_URL, { params, context }) .pipe( - map(res => this.restExtractor.convertResultListDateToHuman(res)), map(res => this.restExtractor.applyToResultListData(res, this.formatNotification.bind(this))), catchError(err => this.restExtractor.handleError(err)) ) diff --git a/client/src/app/shared/shared-main/video-channel-sync/index.ts b/client/src/app/shared/shared-main/video-channel-sync/index.ts new file mode 100644 index 000000000..7134bcd18 --- /dev/null +++ b/client/src/app/shared/shared-main/video-channel-sync/index.ts @@ -0,0 +1 @@ +export * from './video-channel-sync.service' diff --git a/client/src/app/shared/shared-main/video-channel-sync/video-channel-sync.service.ts b/client/src/app/shared/shared-main/video-channel-sync/video-channel-sync.service.ts new file mode 100644 index 000000000..a4e216869 --- /dev/null +++ b/client/src/app/shared/shared-main/video-channel-sync/video-channel-sync.service.ts @@ -0,0 +1,50 @@ +import { SortMeta } from 'primeng/api' +import { catchError, Observable } from 'rxjs' +import { environment } from 'src/environments/environment' +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { RestExtractor, RestPagination, RestService } from '@app/core' +import { ResultList } from '@shared/models/common' +import { VideoChannelSync, VideoChannelSyncCreate } from '@shared/models/videos' +import { Account, AccountService } from '../account' + +@Injectable({ + providedIn: 'root' +}) +export class VideoChannelSyncService { + static BASE_VIDEO_CHANNEL_URL = environment.apiUrl + '/api/v1/video-channel-syncs' + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor, + private restService: RestService + ) { } + + listAccountVideoChannelsSyncs (parameters: { + sort: SortMeta + pagination: RestPagination + account: Account + }): Observable> { + const { pagination, sort, account } = parameters + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-channel-syncs' + + return this.authHttp.get>(url, { params }) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + createSync (body: VideoChannelSyncCreate) { + return this.authHttp.post<{ videoChannelSync: VideoChannelSync }>(VideoChannelSyncService.BASE_VIDEO_CHANNEL_URL, body) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + deleteSync (videoChannelsSyncId: number) { + const url = `${VideoChannelSyncService.BASE_VIDEO_CHANNEL_URL}/${videoChannelsSyncId}` + + return this.authHttp.delete(url) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } +} diff --git a/client/src/app/shared/shared-main/video-channel/video-channel.service.ts b/client/src/app/shared/shared-main/video-channel/video-channel.service.ts index 480d250fb..5e3985526 100644 --- a/client/src/app/shared/shared-main/video-channel/video-channel.service.ts +++ b/client/src/app/shared/shared-main/video-channel/video-channel.service.ts @@ -3,7 +3,14 @@ import { catchError, map, tap } from 'rxjs/operators' import { HttpClient, HttpParams } from '@angular/common/http' import { Injectable } from '@angular/core' import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core' -import { ActorImage, ResultList, VideoChannel as VideoChannelServer, VideoChannelCreate, VideoChannelUpdate } from '@shared/models' +import { + ActorImage, + ResultList, + VideoChannel as VideoChannelServer, + VideoChannelCreate, + VideoChannelUpdate, + VideosImportInChannelCreate +} from '@shared/models' import { environment } from '../../../../environments/environment' import { Account } from '../account' import { AccountService } from '../account/account.service' @@ -95,4 +102,16 @@ export class VideoChannelService { return this.authHttp.delete(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost) .pipe(catchError(err => this.restExtractor.handleError(err))) } + + importVideos (videoChannelName: string, externalChannelUrl: string, syncId?: number) { + const path = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName + '/import-videos' + + const body: VideosImportInChannelCreate = { + externalChannelUrl, + videoChannelSyncId: syncId + } + + return this.authHttp.post(path, body) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } } diff --git a/client/src/app/shared/shared-main/video/embed.component.ts b/client/src/app/shared/shared-main/video/embed.component.ts index 123000834..43e350197 100644 --- a/client/src/app/shared/shared-main/video/embed.component.ts +++ b/client/src/app/shared/shared-main/video/embed.component.ts @@ -20,15 +20,15 @@ export class EmbedComponent implements OnInit { } ngOnInit () { - const html = buildVideoOrPlaylistEmbed( - decorateVideoLink({ + const html = buildVideoOrPlaylistEmbed({ + embedUrl: decorateVideoLink({ url: buildVideoEmbedLink(this.video, environment.originServerUrl), title: false, warningTitle: false }), - this.video.name - ) + embedTitle: this.video.name + }) this.embedHTML = this.sanitizer.bypassSecurityTrustHtml(html) } diff --git a/client/src/app/shared/shared-main/video/video-import.service.ts b/client/src/app/shared/shared-main/video/video-import.service.ts index 0a610ab1f..607c08d71 100644 --- a/client/src/app/shared/shared-main/video/video-import.service.ts +++ b/client/src/app/shared/shared-main/video/video-import.service.ts @@ -43,15 +43,27 @@ export class VideoImportService { .pipe(catchError(res => this.restExtractor.handleError(res))) } - getMyVideoImports (pagination: RestPagination, sort: SortMeta): Observable> { + getMyVideoImports (pagination: RestPagination, sort: SortMeta, search?: string): Observable> { let params = new HttpParams() params = this.restService.addRestGetParams(params, pagination, sort) + if (search) { + const filters = this.restService.parseQueryStringFilter(search, { + videoChannelSyncId: { + prefix: 'videoChannelSyncId:' + }, + targetUrl: { + prefix: 'targetUrl:' + } + }) + + params = this.restService.addObjectParams(params, filters) + } + return this.authHttp .get>(UserService.BASE_USERS_URL + '/me/videos/imports', { params }) .pipe( switchMap(res => this.extractVideoImports(res)), - map(res => this.restExtractor.convertResultListDateToHuman(res)), catchError(err => this.restExtractor.handleError(err)) ) } diff --git a/client/src/app/shared/shared-main/video/video-ownership.service.ts b/client/src/app/shared/shared-main/video/video-ownership.service.ts index bc0e1b1d1..1e8f7f68c 100644 --- a/client/src/app/shared/shared-main/video/video-ownership.service.ts +++ b/client/src/app/shared/shared-main/video/video-ownership.service.ts @@ -1,6 +1,6 @@ import { SortMeta } from 'primeng/api' import { Observable } from 'rxjs' -import { catchError, map } from 'rxjs/operators' +import { catchError } from 'rxjs/operators' import { HttpClient, HttpParams } from '@angular/common/http' import { Injectable } from '@angular/core' import { RestExtractor, RestPagination, RestService } from '@app/core' @@ -35,10 +35,7 @@ export class VideoOwnershipService { params = this.restService.addRestGetParams(params, pagination, sort) return this.authHttp.get>(url, { params }) - .pipe( - map(res => this.restExtractor.convertResultListDateToHuman(res)), - catchError(res => this.restExtractor.handleError(res)) - ) + .pipe(catchError(res => this.restExtractor.handleError(res))) } acceptOwnership (id: number, input: VideoChangeOwnershipAccept) { diff --git a/client/src/app/shared/shared-main/video/video.model.ts b/client/src/app/shared/shared-main/video/video.model.ts index 2e4ab87d7..c9c6b979c 100644 --- a/client/src/app/shared/shared-main/video/video.model.ts +++ b/client/src/app/shared/shared-main/video/video.model.ts @@ -1,8 +1,8 @@ import { AuthUser } from '@app/core' import { User } from '@app/core/users/user.model' -import { durationToString, prepareIcu, getAbsoluteAPIUrl, getAbsoluteEmbedUrl } from '@app/helpers' +import { durationToString, getAbsoluteAPIUrl, getAbsoluteEmbedUrl, prepareIcu } from '@app/helpers' import { Actor } from '@app/shared/shared-main/account/actor.model' -import { buildVideoWatchPath } from '@shared/core-utils' +import { buildVideoWatchPath, getAllFiles } from '@shared/core-utils' import { peertubeTranslate } from '@shared/core-utils/i18n' import { ActorImage, @@ -240,6 +240,13 @@ export class Video implements VideoServerModel { return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.SEE_ALL_VIDEOS)) } + canRemoveOneFile (user: AuthUser) { + return this.isLocal && + user && user.hasRight(UserRight.MANAGE_VIDEO_FILES) && + this.state.id !== VideoState.TO_TRANSCODE && + getAllFiles(this).length > 1 + } + canRemoveFiles (user: AuthUser) { return this.isLocal && user && user.hasRight(UserRight.MANAGE_VIDEO_FILES) && diff --git a/client/src/app/shared/shared-moderation/blocklist.service.ts b/client/src/app/shared/shared-moderation/blocklist.service.ts index 1169bf757..0fb7536e5 100644 --- a/client/src/app/shared/shared-moderation/blocklist.service.ts +++ b/client/src/app/shared/shared-moderation/blocklist.service.ts @@ -53,7 +53,6 @@ export class BlocklistService { return this.authHttp.get>(BlocklistService.BASE_USER_BLOCKLIST_URL + '/accounts', { params }) .pipe( - map(res => this.restExtractor.convertResultListDateToHuman(res)), map(res => this.restExtractor.applyToResultListData(res, this.formatAccountBlock.bind(this))), catchError(err => this.restExtractor.handleError(err)) ) @@ -84,10 +83,7 @@ export class BlocklistService { if (search) params = params.append('search', search) return this.authHttp.get>(BlocklistService.BASE_USER_BLOCKLIST_URL + '/servers', { params }) - .pipe( - map(res => this.restExtractor.convertResultListDateToHuman(res)), - catchError(err => this.restExtractor.handleError(err)) - ) + .pipe(catchError(err => this.restExtractor.handleError(err))) } blockServerByUser (host: string) { @@ -116,7 +112,6 @@ export class BlocklistService { return this.authHttp.get>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', { params }) .pipe( - map(res => this.restExtractor.convertResultListDateToHuman(res)), map(res => this.restExtractor.applyToResultListData(res, this.formatAccountBlock.bind(this))), catchError(err => this.restExtractor.handleError(err)) ) @@ -151,10 +146,7 @@ export class BlocklistService { if (search) params = params.append('search', search) return this.authHttp.get>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers', { params }) - .pipe( - map(res => this.restExtractor.convertResultListDateToHuman(res)), - catchError(err => this.restExtractor.handleError(err)) - ) + .pipe(catchError(err => this.restExtractor.handleError(err))) } blockServerByInstance (host: string) { diff --git a/client/src/app/shared/shared-moderation/video-block.service.ts b/client/src/app/shared/shared-moderation/video-block.service.ts index 6272b672f..ab352a2d6 100644 --- a/client/src/app/shared/shared-moderation/video-block.service.ts +++ b/client/src/app/shared/shared-moderation/video-block.service.ts @@ -1,6 +1,6 @@ import { SortMeta } from 'primeng/api' import { from as observableFrom, Observable } from 'rxjs' -import { catchError, concatMap, map, toArray } from 'rxjs/operators' +import { catchError, concatMap, toArray } from 'rxjs/operators' import { HttpClient, HttpParams } from '@angular/common/http' import { Injectable } from '@angular/core' import { RestExtractor, RestPagination, RestService } from '@app/core' @@ -47,10 +47,7 @@ export class VideoBlockService { if (type) params = params.append('type', type.toString()) return this.authHttp.get>(VideoBlockService.BASE_VIDEOS_URL + 'blacklist', { params }) - .pipe( - map(res => this.restExtractor.convertResultListDateToHuman(res)), - catchError(res => this.restExtractor.handleError(res)) - ) + .pipe(catchError(res => this.restExtractor.handleError(res))) } unblockVideo (videoIdArgs: number | number[]) { diff --git a/client/src/app/shared/shared-search/advanced-search.model.ts b/client/src/app/shared/shared-search/advanced-search.model.ts index ea9baa27f..e8bb00fd3 100644 --- a/client/src/app/shared/shared-search/advanced-search.model.ts +++ b/client/src/app/shared/shared-search/advanced-search.model.ts @@ -1,4 +1,4 @@ -import { intoArray } from '@app/helpers' +import { splitIntoArray } from '@app/helpers' import { BooleanBothQuery, BooleanQuery, @@ -76,8 +76,8 @@ export class AdvancedSearch { this.categoryOneOf = options.categoryOneOf || undefined this.licenceOneOf = options.licenceOneOf || undefined this.languageOneOf = options.languageOneOf || undefined - this.tagsOneOf = intoArray(options.tagsOneOf) - this.tagsAllOf = intoArray(options.tagsAllOf) + this.tagsOneOf = splitIntoArray(options.tagsOneOf) + this.tagsAllOf = splitIntoArray(options.tagsAllOf) this.durationMin = options.durationMin ? parseInt(options.durationMin, 10) : undefined this.durationMax = options.durationMax ? parseInt(options.durationMax, 10) : undefined @@ -152,9 +152,9 @@ export class AdvancedSearch { originallyPublishedStartDate: this.originallyPublishedStartDate, originallyPublishedEndDate: this.originallyPublishedEndDate, nsfw: this.nsfw, - categoryOneOf: intoArray(this.categoryOneOf), - licenceOneOf: intoArray(this.licenceOneOf), - languageOneOf: intoArray(this.languageOneOf), + categoryOneOf: splitIntoArray(this.categoryOneOf), + licenceOneOf: splitIntoArray(this.licenceOneOf), + languageOneOf: splitIntoArray(this.languageOneOf), tagsOneOf: this.tagsOneOf, tagsAllOf: this.tagsAllOf, durationMin: this.durationMin, diff --git a/client/src/app/shared/shared-search/find-in-bulk.service.ts b/client/src/app/shared/shared-search/find-in-bulk.service.ts index d2f8c3213..d6ee04379 100644 --- a/client/src/app/shared/shared-search/find-in-bulk.service.ts +++ b/client/src/app/shared/shared-search/find-in-bulk.service.ts @@ -80,13 +80,18 @@ export class FindInBulkService { map(result => result.response.data), map(data => data.find(finder)) ) - .subscribe(result => { - if (!result) { - obs.error(new Error($localize`Element ${param} not found`)) - } else { + .subscribe({ + next: result => { + if (!result) { + obs.error(new Error($localize`Element ${param} not found`)) + return + } + obs.next(result) obs.complete() - } + }, + + error: err => obs.error(err) }) observableObject.notifier.next(param) diff --git a/client/src/app/shared/shared-share-modal/video-share.component.html b/client/src/app/shared/shared-share-modal/video-share.component.html index b163d3581..f4d249b41 100644 --- a/client/src/app/shared/shared-share-modal/video-share.component.html +++ b/client/src/app/shared/shared-share-modal/video-share.component.html @@ -25,7 +25,7 @@ @@ -35,7 +35,7 @@ @@ -46,7 +46,7 @@ -
+
@@ -67,7 +67,7 @@
@@ -80,6 +80,7 @@ >
+ @@ -102,7 +103,7 @@ @@ -112,7 +113,7 @@ @@ -123,7 +124,7 @@ -
+
@@ -176,6 +177,8 @@ i18n-labelText labelText="Only display embed URL" > + +
diff --git a/client/src/app/shared/shared-share-modal/video-share.component.ts b/client/src/app/shared/shared-share-modal/video-share.component.ts index e0c98008c..e1db4a3b8 100644 --- a/client/src/app/shared/shared-share-modal/video-share.component.ts +++ b/client/src/app/shared/shared-share-modal/video-share.component.ts @@ -1,6 +1,6 @@ import { Component, ElementRef, Input, ViewChild } from '@angular/core' import { DomSanitizer, SafeHtml } from '@angular/platform-browser' -import { ServerService } from '@app/core' +import { HooksService, ServerService } from '@app/core' import { VideoDetails } from '@app/shared/shared-main' import { VideoPlaylist } from '@app/shared/shared-video-playlist' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' @@ -29,6 +29,8 @@ type Customizations = { warningTitle: boolean controlBar: boolean peertubeLink: boolean + + includeVideoInPlaylist: boolean } type TabId = 'url' | 'qrcode' | 'embed' @@ -51,15 +53,23 @@ export class VideoShareComponent { customizations: Customizations isAdvancedCustomizationCollapsed = true - includeVideoInPlaylist = false - playlistEmbedHTML: SafeHtml - videoEmbedHTML: SafeHtml + videoUrl: string + playlistUrl: string + + videoEmbedUrl: string + playlistEmbedUrl: string + + videoEmbedHTML: string + videoEmbedSafeHTML: SafeHtml + playlistEmbedHTML: string + playlistEmbedSafeHTML: SafeHtml constructor ( private modalService: NgbModal, private sanitizer: DomSanitizer, - private server: ServerService + private server: ServerService, + private hooks: HooksService ) { } show (currentVideoTimestamp?: number, currentPlaylistPosition?: number) { @@ -89,7 +99,9 @@ export class VideoShareComponent { title: true, warningTitle: true, controlBar: true, - peertubeLink: true + peertubeLink: true, + + includeVideoInPlaylist: false }, { set: (target, prop, value) => { target[prop] = value @@ -99,7 +111,7 @@ export class VideoShareComponent { this.customizations.warningTitle = value } - this.updateEmbedCode() + this.onUpdate() return true } @@ -107,50 +119,101 @@ export class VideoShareComponent { this.playlistPosition = currentPlaylistPosition - this.updateEmbedCode() + this.onUpdate() - this.modalService.open(this.modal, { centered: true }) + this.modalService.open(this.modal, { centered: true }).shown.subscribe(() => { + this.hooks.runAction('action:modal.share.shown', 'video-watch', { video: this.video, playlist: this.playlist }) + }) } - getVideoIframeCode () { - return buildVideoOrPlaylistEmbed(this.getVideoEmbedUrl(), this.video.name) - } - - getVideoEmbedUrl () { - return decorateVideoLink({ url: this.video.embedUrl, ...this.getVideoOptions(true) }) - } - - getPlaylistEmbedUrl () { - return decoratePlaylistLink({ url: this.playlist.embedUrl, ...this.getPlaylistOptions() }) - } - - getPlaylistIframeCode () { - return buildVideoOrPlaylistEmbed(this.getPlaylistEmbedUrl(), this.playlist.displayName) - } + // --------------------------------------------------------------------------- getVideoUrl () { const url = this.customizations.originUrl ? this.video.url : buildVideoLink(this.video, window.location.origin) - return decorateVideoLink({ - url, - - ...this.getVideoOptions(false) - }) + return this.hooks.wrapFun( + decorateVideoLink, + { url, ...this.getVideoOptions(false) }, + 'video-watch', + 'filter:share.video-url.build.params', + 'filter:share.video-url.build.result' + ) } + getVideoEmbedUrl () { + return this.hooks.wrapFun( + decorateVideoLink, + { url: this.video.embedUrl, ...this.getVideoOptions(true) }, + 'video-watch', + 'filter:share.video-embed-url.build.params', + 'filter:share.video-embed-url.build.result' + ) + } + + async getVideoIframeCode () { + return this.hooks.wrapFun( + buildVideoOrPlaylistEmbed, + { embedUrl: await this.getVideoEmbedUrl(), embedTitle: this.video.name }, + 'video-watch', + 'filter:share.video-embed-code.build.params', + 'filter:share.video-embed-code.build.result' + ) + } + + // --------------------------------------------------------------------------- + getPlaylistUrl () { const url = buildPlaylistLink(this.playlist) - if (!this.includeVideoInPlaylist) return url - return decoratePlaylistLink({ url, playlistPosition: this.playlistPosition }) + return this.hooks.wrapFun( + decoratePlaylistLink, + { url, ...this.getPlaylistOptions() }, + 'video-watch', + 'filter:share.video-playlist-url.build.params', + 'filter:share.video-playlist-url.build.result' + ) } - updateEmbedCode () { - if (this.playlist) this.playlistEmbedHTML = this.sanitizer.bypassSecurityTrustHtml(this.getPlaylistIframeCode()) + getPlaylistEmbedUrl () { + return this.hooks.wrapFun( + decoratePlaylistLink, + { url: this.playlist.embedUrl, ...this.getPlaylistOptions() }, + 'video-watch', + 'filter:share.video-playlist-embed-url.build.params', + 'filter:share.video-playlist-embed-url.build.result' + ) + } - if (this.video) this.videoEmbedHTML = this.sanitizer.bypassSecurityTrustHtml(this.getVideoIframeCode()) + async getPlaylistEmbedCode () { + return this.hooks.wrapFun( + buildVideoOrPlaylistEmbed, + { embedUrl: await this.getPlaylistEmbedUrl(), embedTitle: this.playlist.displayName }, + 'video-watch', + 'filter:share.video-playlist-embed-code.build.params', + 'filter:share.video-playlist-embed-code.build.result' + ) + } + + // --------------------------------------------------------------------------- + + async onUpdate () { + console.log('on update') + + if (this.playlist) { + this.playlistUrl = await this.getPlaylistUrl() + this.playlistEmbedUrl = await this.getPlaylistEmbedUrl() + this.playlistEmbedHTML = await this.getPlaylistEmbedCode() + this.playlistEmbedSafeHTML = this.sanitizer.bypassSecurityTrustHtml(this.playlistEmbedHTML) + } + + if (this.video) { + this.videoUrl = await this.getVideoUrl() + this.videoEmbedUrl = await this.getVideoEmbedUrl() + this.videoEmbedHTML = await this.getVideoIframeCode() + this.videoEmbedSafeHTML = this.sanitizer.bypassSecurityTrustHtml(this.videoEmbedHTML) + } } notSecure () { @@ -181,7 +244,9 @@ export class VideoShareComponent { return { baseUrl, - playlistPosition: this.playlistPosition || undefined + playlistPosition: this.playlistPosition && this.customizations.includeVideoInPlaylist + ? this.playlistPosition + : undefined } } diff --git a/client/src/app/shared/shared-user-subscription/subscribe-button.component.html b/client/src/app/shared/shared-user-subscription/subscribe-button.component.html index 0e09c2697..341b83a04 100644 --- a/client/src/app/shared/shared-user-subscription/subscribe-button.component.html +++ b/client/src/app/shared/shared-user-subscription/subscribe-button.component.html @@ -37,7 +37,7 @@ class="btn-group" ngbDropdown autoClose="outside" placement="bottom-right bottom-left bottom auto" role="group" aria-label="Multiple ways to subscribe to the current channel" i18n-aria-label > -