From 74e97347bb204832d91142921e0d9005d10ae327 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 28 Aug 2025 14:59:18 +0200 Subject: [PATCH] Add ability to customize player settings --- client/angular.json | 2 +- .../config/pages/admin-config-common.scss | 1 + .../admin-config-customization.component.html | 38 +- .../admin-config-customization.component.ts | 23 +- .../pages/admin-config-general.component.html | 365 ++++++++++-------- .../pages/admin-config-general.component.ts | 9 +- client/src/app/+video-watch/routes.ts | 8 +- .../app/+video-watch/video-watch.component.ts | 24 +- .../+video-manage/routes.ts | 6 +- .../+video-manage/video-manage.component.html | 1 - .../+video-manage/video-manage.component.ts | 19 +- .../+video-manage/video-manage.resolver.ts | 52 ++- .../+video-publish/routes.ts | 8 +- .../+video-publish/video-publish.component.ts | 3 +- .../shared-manage/common/video-edit.model.ts | 63 ++- .../video-customization.component.html | 24 +- .../video-customization.component.ts | 42 +- .../video-manage-controller.service.ts | 21 +- .../actor-avatar-edit.component.html | 2 +- .../actor-avatar-edit.component.ts | 17 +- .../actor-banner-edit.component.html | 44 ++- .../actor-banner-edit.component.ts | 26 +- .../select/select-player-theme.component.ts | 129 +++++++ .../common/infinite-scroller.directive.ts | 9 +- .../shared-video/player-settings.service.ts | 71 ++++ .../video-channel-create.component.ts | 144 +++---- .../video-channel-edit.component.html | 45 ++- .../video-channel-edit.component.scss | 16 +- .../video-channel-edit.component.ts | 178 +++++++++ .../standalone-channels/video-channel-edit.ts | 18 - .../video-channel-update.component.ts | 237 +++++------- .../standalone/player/src/peertube-player.ts | 2 +- .../player/src/sass/shared/control-bar.scss | 6 +- .../player/src/sass/shared/peertube-skin.scss | 8 +- .../src/shared/settings/settings-menu-item.ts | 3 +- .../src/types/peertube-player-options.ts | 7 +- .../src/types/peertube-videojs-typings.ts | 5 +- client/src/standalone/videos/embed.ts | 22 +- .../videos/shared/player-options-builder.ts | 31 +- .../standalone/videos/shared/video-fetcher.ts | 13 +- config/default.yaml | 2 + config/production.yaml.example | 2 + packages/models/src/activitypub/activity.ts | 3 +- .../src/activitypub/activitypub-actor.ts | 3 + packages/models/src/activitypub/context.ts | 37 +- .../activitypub/objects/activitypub-object.ts | 16 +- .../models/src/activitypub/objects/index.ts | 1 + .../objects/player-settings-object.ts | 8 + .../src/activitypub/objects/video-object.ts | 1 + .../channel-export.model.ts | 5 + .../video-export.model.ts | 5 + packages/models/src/index.ts | 1 + packages/models/src/player/index.ts | 2 + .../models/src/player/player-mode.type.ts | 1 + .../models/src/player/player-theme.type.ts | 3 + .../models/src/server/custom-config.model.ts | 2 + .../models/src/server/server-config.model.ts | 3 +- packages/models/src/videos/index.ts | 2 + .../src/videos/player-settings-update.ts | 9 + packages/models/src/videos/player-settings.ts | 9 + packages/server-commands/src/server/server.ts | 6 +- packages/server-commands/src/videos/index.ts | 1 + .../src/videos/player-settings-command.ts | 89 +++++ packages/tests/src/api/check-params/index.ts | 1 + .../src/api/check-params/player-settings.ts | 138 +++++++ .../src/api/check-params/video-passwords.ts | 34 +- .../tests/src/api/server/config-defaults.ts | 15 +- packages/tests/src/api/server/config.ts | 4 +- packages/tests/src/api/users/user-export.ts | 3 + packages/tests/src/api/users/user-import.ts | 28 ++ packages/tests/src/api/videos/index.ts | 1 + .../tests/src/api/videos/player-settings.ts | 283 ++++++++++++++ packages/tests/src/shared/import-export.ts | 4 + server/core/controllers/activitypub/client.ts | 39 ++ server/core/controllers/api/config.ts | 1 + server/core/controllers/api/index.ts | 2 + .../core/controllers/api/player-settings.ts | 112 ++++++ server/core/helpers/activity-pub-utils.ts | 24 +- .../custom-validators/activitypub/activity.ts | 10 +- .../activitypub/player-settings.ts | 13 + .../custom-validators/player-settings.ts | 16 + .../core/initializers/checker-before-init.ts | 1 + server/core/initializers/config.ts | 4 + server/core/initializers/constants.ts | 8 +- server/core/initializers/database.ts | 4 +- .../migrations/0930-player-settings.ts | 28 ++ .../lib/activitypub/actors/shared/creator.ts | 20 +- server/core/lib/activitypub/actors/updater.ts | 10 + .../core/lib/activitypub/player-settings.ts | 44 +++ .../lib/activitypub/process/process-update.ts | 42 +- .../core/lib/activitypub/send/send-update.ts | 80 +++- server/core/lib/activitypub/url.ts | 11 +- .../videos/shared/abstract-builder.ts | 14 +- .../lib/activitypub/videos/shared/creator.ts | 3 +- server/core/lib/activitypub/videos/updater.ts | 3 +- server/core/lib/player-settings.ts | 31 ++ server/core/lib/plugins/theme-utils.ts | 4 +- server/core/lib/server-config-manager.ts | 1 + .../exporters/channels-exporter.ts | 26 +- .../exporters/videos-exporter.ts | 31 +- .../importers/channels-importer.ts | 45 ++- .../importers/videos-importer.ts | 21 +- server/core/middlewares/validators/abuse.ts | 2 +- server/core/middlewares/validators/account.ts | 4 +- .../middlewares/validators/automatic-tags.ts | 2 +- .../core/middlewares/validators/blocklist.ts | 6 +- server/core/middlewares/validators/bulk.ts | 2 +- server/core/middlewares/validators/config.ts | 2 + server/core/middlewares/validators/feeds.ts | 6 +- .../middlewares/validators/player-settings.ts | 86 +++++ .../middlewares/validators/shared/accounts.ts | 17 +- .../middlewares/validators/shared/users.ts | 15 +- .../validators/shared/video-channels.ts | 19 +- .../middlewares/validators/shared/videos.ts | 30 +- server/core/middlewares/validators/token.ts | 2 +- .../middlewares/validators/users/users.ts | 4 +- .../validators/videos/video-captions.ts | 14 +- .../validators/videos/video-channel-sync.ts | 6 +- .../validators/videos/video-channels.ts | 2 +- .../validators/videos/video-chapters.ts | 7 +- .../validators/videos/video-comments.ts | 12 +- .../validators/videos/video-live.ts | 4 +- .../videos/video-ownership-changes.ts | 11 +- .../validators/videos/video-passwords.ts | 4 +- .../validators/videos/video-source.ts | 4 +- .../validators/videos/video-stats.ts | 6 +- .../validators/videos/video-studio.ts | 2 +- .../middlewares/validators/videos/videos.ts | 14 +- .../middlewares/validators/watched-words.ts | 2 +- server/core/models/user/user.ts | 6 +- .../formatter/video-activity-pub-format.ts | 2 + server/core/models/video/player-setting.ts | 184 +++++++++ .../core/types/models/video/player-setting.ts | 3 + 133 files changed, 2809 insertions(+), 783 deletions(-) create mode 100644 client/src/app/shared/shared-forms/select/select-player-theme.component.ts create mode 100644 client/src/app/shared/shared-video/player-settings.service.ts create mode 100644 client/src/app/shared/standalone-channels/video-channel-edit.component.ts delete mode 100644 client/src/app/shared/standalone-channels/video-channel-edit.ts create mode 100644 packages/models/src/activitypub/objects/player-settings-object.ts create mode 100644 packages/models/src/player/index.ts create mode 100644 packages/models/src/player/player-mode.type.ts create mode 100644 packages/models/src/player/player-theme.type.ts create mode 100644 packages/models/src/videos/player-settings-update.ts create mode 100644 packages/models/src/videos/player-settings.ts create mode 100644 packages/server-commands/src/videos/player-settings-command.ts create mode 100644 packages/tests/src/api/check-params/player-settings.ts create mode 100644 packages/tests/src/api/videos/player-settings.ts create mode 100644 server/core/controllers/api/player-settings.ts create mode 100644 server/core/helpers/custom-validators/activitypub/player-settings.ts create mode 100644 server/core/helpers/custom-validators/player-settings.ts create mode 100644 server/core/initializers/migrations/0930-player-settings.ts create mode 100644 server/core/lib/activitypub/player-settings.ts create mode 100644 server/core/lib/player-settings.ts create mode 100644 server/core/middlewares/validators/player-settings.ts create mode 100644 server/core/models/video/player-setting.ts create mode 100644 server/core/types/models/video/player-setting.ts diff --git a/client/angular.json b/client/angular.json index 9e0795154..cb97e0789 100644 --- a/client/angular.json +++ b/client/angular.json @@ -244,7 +244,7 @@ { "type": "anyComponentStyle", "maximumWarning": "6kb", - "maximumError": "120kb" + "maximumError": "140kb" } ], "fileReplacements": [ diff --git a/client/src/app/+admin/config/pages/admin-config-common.scss b/client/src/app/+admin/config/pages/admin-config-common.scss index 7741b950d..774a8a682 100644 --- a/client/src/app/+admin/config/pages/admin-config-common.scss +++ b/client/src/app/+admin/config/pages/admin-config-common.scss @@ -40,6 +40,7 @@ my-select-videos-sort, my-select-videos-scope, my-select-checkbox, my-select-options, +my-select-player-theme, my-select-custom-value { display: block; diff --git a/client/src/app/+admin/config/pages/admin-config-customization.component.html b/client/src/app/+admin/config/pages/admin-config-customization.component.html index e94f7b6ab..9bd5adeed 100644 --- a/client/src/app/+admin/config/pages/admin-config-customization.component.html +++ b/client/src/app/+admin/config/pages/admin-config-customization.component.html @@ -7,26 +7,32 @@
- -
- +
+ - -
- + +
- -
- -
-
+
+ +
+
+
+ + + +
+ + + +
diff --git a/client/src/app/+admin/config/pages/admin-config-customization.component.ts b/client/src/app/+admin/config/pages/admin-config-customization.component.ts index 0e4ccb9a5..0d5d9b4cd 100644 --- a/client/src/app/+admin/config/pages/admin-config-customization.component.ts +++ b/client/src/app/+admin/config/pages/admin-config-customization.component.ts @@ -9,7 +9,7 @@ import { PeertubeCheckboxComponent } from '@app/shared/shared-forms/peertube-che import { SelectCustomValueComponent } from '@app/shared/shared-forms/select/select-custom-value.component' import { SelectOptionsComponent } from '@app/shared/shared-forms/select/select-options.component' import { objectKeysTyped } from '@peertube/peertube-core-utils' -import { CustomConfig } from '@peertube/peertube-models' +import { CustomConfig, PlayerTheme } from '@peertube/peertube-models' import { capitalizeFirstLetter } from '@root-helpers/string' import { ColorPaletteThemeConfig, ThemeCustomizationKey } from '@root-helpers/theme-manager' import debug from 'debug' @@ -20,6 +20,7 @@ import { AdminConfigService } from '../../../shared/shared-admin/admin-config.se import { HelpComponent } from '../../../shared/shared-main/buttons/help.component' import { AlertComponent } from '../../../shared/shared-main/common/alert.component' import { AdminSaveBarComponent } from '../shared/admin-save-bar.component' +import { SelectPlayerThemeComponent } from '@app/shared/shared-forms/select/select-player-theme.component' const debugLogger = debug('peertube:config') @@ -65,6 +66,12 @@ type Form = { inputBorderRadius: FormControl }> }> + + defaults: FormGroup<{ + player: FormGroup<{ + theme: FormControl + }> + }> } type FieldType = 'color' | 'radius' @@ -84,7 +91,8 @@ type FieldType = 'color' | 'radius' SelectOptionsComponent, HelpComponent, PeertubeCheckboxComponent, - SelectCustomValueComponent + SelectCustomValueComponent, + SelectPlayerThemeComponent ] }) export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, CanComponentDeactivate { @@ -108,6 +116,7 @@ export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, Can }[] = [] availableThemes: SelectOptionsItem[] + availablePlayerThemes: SelectOptionsItem[] = [] private customizationResetFields = new Set() private customConfig: CustomConfig @@ -164,6 +173,11 @@ export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, Can ...this.themeService.buildAvailableThemes() ] + this.availablePlayerThemes = [ + { id: 'galaxy', label: $localize`Galaxy`, description: $localize`Original theme` }, + { id: 'lucide', label: $localize`Lucide`, description: $localize`A clean and modern theme` } + ] + this.buildForm() this.subscribeToCustomizationChanges() @@ -265,6 +279,11 @@ export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, Can headerBackgroundColor: null, inputBorderRadius: null } + }, + defaults: { + player: { + theme: null + } } } diff --git a/client/src/app/+admin/config/pages/admin-config-general.component.html b/client/src/app/+admin/config/pages/admin-config-general.component.html index a3e37c955..0ce83a89d 100644 --- a/client/src/app/+admin/config/pages/admin-config-general.component.html +++ b/client/src/app/+admin/config/pages/admin-config-general.component.html @@ -7,7 +7,6 @@
-
@@ -43,13 +42,14 @@
-
@if (countExternalAuth() === 0) { @@ -58,12 +58,11 @@ ⚠️ You have multiple external auth plugins enabled } - +
-
@@ -76,20 +75,22 @@
- -
@@ -111,31 +112,28 @@
- - -
+
+

NEW USERS

-
- + ⚠️ This functionality requires a lot of attention and extra moderation @@ -144,16 +142,22 @@
-
-
@@ -163,8 +167,12 @@
{form.value.signup.limit, plural, =1 {user} other {users}}
@@ -179,8 +187,12 @@
{form.value.signup.minimumAge, plural, =1 {year old} other {years old}}
@@ -201,7 +213,9 @@ inputId="userVideoQuota" [items]="getVideoQuotaOptions()" formControlName="videoQuota" - i18n-inputSuffix inputSuffix="bytes" inputType="number" + i18n-inputSuffix + inputSuffix="bytes" + inputType="number" [clearable]="false" > @@ -218,7 +232,9 @@ inputId="userVideoQuotaDaily" [items]="getVideoQuotaDailyOptions()" formControlName="videoQuotaDaily" - i18n-inputSuffix inputSuffix="bytes" inputType="number" + i18n-inputSuffix + inputSuffix="bytes" + inputType="number" [clearable]="false" > @@ -228,15 +244,16 @@
-
@@ -246,11 +263,8 @@
- - -
allows to import multiple videos in parallel. ⚠️ Requires a PeerTube restart @@ -265,39 +279,46 @@
- ⚠️ 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
- - ⚠️ We don't recommend to enable this feature if you don't trust your users - - + + ⚠️ We don't recommend to enable this feature if you don't trust your users + +
-
- - - ⛔ You need to allow import with HTTP URL to be able to activate this feature. - - + + + ⛔ You need to allow import with HTTP URL to be able to activate this feature. + +
@@ -306,16 +327,21 @@
{form.value.import.videoChannelSynchronization.maxPerUser, plural, =1 {sync} other {syncs}}
- +
-
@@ -354,22 +380,21 @@
- -
- - Unless a user is marked as trusted, their videos will stay private until a moderator reviews them. - - + + Unless a user is marked as trusted, their videos will stay private until a moderator reviews them. + +
-
@@ -378,8 +403,10 @@
@@ -388,10 +415,7 @@
- + Generate storyboards of local videos using ffmpeg so users can see the video preview in the player while scrubbing the video @@ -415,19 +439,19 @@
- + - Automatically create subtitles for uploaded/imported VOD videos + Automatically create subtitles + for uploaded/imported VOD videos
Use remote runners to process transcription tasks.
@@ -442,7 +466,6 @@ -
@@ -482,8 +505,12 @@
{form.value.videoChannels.maxPerUser, plural, =1 {channel} other {channels}}
@@ -502,12 +529,13 @@
-
This setting is not retroactive: current comments from remote platforms will not be deleted @@ -520,22 +548,25 @@
This setting is not retroactive: current followers of channels of your platform will not be affected - +
-
This setting is not retroactive: current followers of your platform will not be affected @@ -545,8 +576,10 @@
@@ -554,12 +587,13 @@ -
⚠️ This functionality requires a lot of attention and extra moderation @@ -571,14 +605,21 @@
⚠️ This functionality requires a lot of attention and extra moderation.
- See the documentation for more information about the expected URL + See the documentation for more information about the expected URL
@@ -586,19 +627,22 @@
- +
-
-
@@ -609,27 +653,34 @@
+ -
- -
+
+ +
+
-
@@ -643,14 +694,14 @@
- -
Allow your users to look up remote videos/actors that may not be federated with your platform @@ -660,23 +711,21 @@
Allow anonymous users to look up remote videos/actors that may not be federated with your platform
-
- +
⚠️ This functionality depends heavily on the moderation of platforms followed by the search index you select
@@ -690,39 +739,44 @@
-
- Otherwise, the local search will be used by default
-
-
-
-
@@ -732,14 +786,10 @@
-
- +
Video quota is checked on import so the user doesn't upload a too big archive file
Video quota (daily quota is not taken into account) is also checked for each video when PeerTube is processing the import
@@ -750,20 +800,14 @@
- -
- + Users can export their PeerTube data in a .zip for backup or re-import. Only one export at a time is allowed per user -
@@ -774,7 +818,9 @@ inputId="exportUsersMaxUserVideoQuota" [items]="exportMaxUserVideoQuotaOptions" formControlName="maxUserVideoQuota" - i18n-inputSuffix inputSuffix="bytes" inputType="number" + i18n-inputSuffix + inputSuffix="bytes" + inputType="number" [clearable]="false" > @@ -784,20 +830,21 @@
- +
The archive file is deleted after this period
-
-
- diff --git a/client/src/app/+admin/config/pages/admin-config-general.component.ts b/client/src/app/+admin/config/pages/admin-config-general.component.ts index 48136ab23..f8d75d01b 100644 --- a/client/src/app/+admin/config/pages/admin-config-general.component.ts +++ b/client/src/app/+admin/config/pages/admin-config-general.component.ts @@ -24,7 +24,14 @@ import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@a import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' import { AlertComponent } from '@app/shared/shared-main/common/alert.component' import { VideoService } from '@app/shared/shared-main/video/video.service' -import { BroadcastMessageLevel, CustomConfig, VideoCommentPolicyType, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models' +import { + BroadcastMessageLevel, + CustomConfig, + PlayerTheme, + VideoCommentPolicyType, + VideoConstant, + VideoPrivacyType +} from '@peertube/peertube-models' import { Subscription } from 'rxjs' import { pairwise } from 'rxjs/operators' import { SelectOptionsItem } from 'src/types/select-options-item.model' diff --git a/client/src/app/+video-watch/routes.ts b/client/src/app/+video-watch/routes.ts index 8fd233927..46fc604b3 100644 --- a/client/src/app/+video-watch/routes.ts +++ b/client/src/app/+video-watch/routes.ts @@ -1,6 +1,7 @@ import { Routes } from '@angular/router' import { AbuseService } from '@app/shared/shared-moderation/abuse.service' import { BlocklistService } from '@app/shared/shared-moderation/blocklist.service' +import { BulkService } from '@app/shared/shared-moderation/bulk.service' import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service' import { SearchService } from '@app/shared/shared-search/search.service' import { UserSubscriptionService } from '@app/shared/shared-user-subscription/user-subscription.service' @@ -8,11 +9,11 @@ import { UserAdminService } from '@app/shared/shared-users/user-admin.service' import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service' import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service' import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service' +import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service' +import { VideoStateMessageService } from '@app/shared/shared-video/video-state-message.service' import { OverviewService } from '../+video-list' import { VideoRecommendationService } from './shared' import { VideoWatchComponent } from './video-watch.component' -import { BulkService } from '@app/shared/shared-moderation/bulk.service' -import { VideoStateMessageService } from '@app/shared/shared-video/video-state-message.service' export default [ { @@ -30,7 +31,8 @@ export default [ AbuseService, UserAdminService, BulkService, - VideoStateMessageService + VideoStateMessageService, + PlayerSettingsService ], children: [ { diff --git a/client/src/app/+video-watch/video-watch.component.ts b/client/src/app/+video-watch/video-watch.component.ts index 60a0c2bf4..0317985e7 100644 --- a/client/src/app/+video-watch/video-watch.component.ts +++ b/client/src/app/+video-watch/video-watch.component.ts @@ -29,12 +29,16 @@ import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription/s import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service' import { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.model' import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service' +import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service' import { getVideoWatchRSSFeeds, timeToInt } from '@peertube/peertube-core-utils' import { HTMLServerConfig, HttpStatusCode, LiveVideo, PeerTubeProblemDocument, + PlayerMode, + PlayerTheme, + PlayerVideoSettings, ServerErrorCode, Storyboard, VideoCaption, @@ -51,8 +55,6 @@ import { PeerTubePlayer, PeerTubePlayerConstructorOptions, PeerTubePlayerLoadOptions, - PeerTubePlayerTheme, - PlayerMode, videojs, VideojsPlayer } from '@peertube/player' @@ -80,7 +82,7 @@ const debugLogger = debug('peertube:watch:VideoWatchComponent') type URLOptions = { playerMode: PlayerMode - playerTheme?: PeerTubePlayerTheme + playerTheme?: PlayerTheme startTime: number | string stopTime: number | string @@ -140,6 +142,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { private zone = inject(NgZone) private videoCaptionService = inject(VideoCaptionService) private videoChapterService = inject(VideoChapterService) + private playerSettingsService = inject(PlayerSettingsService) private hotkeysService = inject(HotkeysService) private hooks = inject(HooksService) private pluginService = inject(PluginService) @@ -163,6 +166,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { liveVideo: LiveVideo videoPassword: string storyboards: Storyboard[] = [] + playerSettings: PlayerVideoSettings playlistPosition: number playlist: VideoPlaylist = null @@ -374,9 +378,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.videoCaptionService.listCaptions(videoId, videoPassword), this.videoChapterService.getChapters({ videoId, videoPassword }), this.videoService.getStoryboards(videoId, videoPassword), + this.playerSettingsService.getVideoSettings({ videoId, videoPassword, raw: false }), this.userService.getAnonymousOrLoggedUser() ]).subscribe({ - next: ([ { video, live, videoFileToken }, captionsResult, chaptersResult, storyboards, loggedInOrAnonymousUser ]) => { + next: ([ { video, live, videoFileToken }, captionsResult, chaptersResult, storyboards, playerSettings, loggedInOrAnonymousUser ]) => { this.onVideoFetched({ video, live, @@ -385,6 +390,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { storyboards, videoFileToken, videoPassword, + playerSettings, loggedInOrAnonymousUser, forceAutoplay }).catch(err => { @@ -491,6 +497,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { storyboards: Storyboard[] videoFileToken: string videoPassword: string + playerSettings: PlayerVideoSettings loggedInOrAnonymousUser: User forceAutoplay: boolean @@ -503,6 +510,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { storyboards, videoFileToken, videoPassword, + playerSettings, loggedInOrAnonymousUser, forceAutoplay } = options @@ -516,6 +524,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.videoFileToken = videoFileToken this.videoPassword = videoPassword this.storyboards = storyboards + this.playerSettings = playerSettings // Re init attributes this.remoteServerDown = false @@ -579,6 +588,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { liveVideo: this.liveVideo, videoFileToken: this.videoFileToken, videoPassword: this.videoPassword, + playerSettings: this.playerSettings, urlOptions: this.getUrlOptions(), loggedInOrAnonymousUser, forceAutoplay, @@ -727,6 +737,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { videoCaptions: VideoCaption[] videoChapters: VideoChapter[] storyboards: Storyboard[] + playerSettings: PlayerVideoSettings videoFileToken: string videoPassword: string @@ -747,7 +758,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy { videoPassword, urlOptions, loggedInOrAnonymousUser, - forceAutoplay + forceAutoplay, + playerSettings } = options let mode: PlayerMode @@ -816,7 +828,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { return { mode, - theme: urlOptions.playerTheme || 'default', + theme: urlOptions.playerTheme || playerSettings.theme as PlayerTheme, autoplay: this.isAutoplay(video, loggedInOrAnonymousUser), forceAutoplay, diff --git a/client/src/app/+videos-publish-manage/+video-manage/routes.ts b/client/src/app/+videos-publish-manage/+video-manage/routes.ts index f86b51ec9..3cbaa5ad5 100644 --- a/client/src/app/+videos-publish-manage/+video-manage/routes.ts +++ b/client/src/app/+videos-publish-manage/+video-manage/routes.ts @@ -8,6 +8,8 @@ import { manageRoutes } from '../shared-manage/routes' import { VideoStudioService } from '../shared-manage/studio/video-studio.service' import { VideoManageComponent } from './video-manage.component' import { VideoManageResolver } from './video-manage.resolver' +import { VideoManageController } from '../shared-manage/video-manage-controller.service' +import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service' export default [ { @@ -16,12 +18,14 @@ export default [ canActivate: [ LoginGuard ], canDeactivate: [ CanDeactivateGuard ], providers: [ + VideoManageController, VideoManageResolver, LiveVideoService, I18nPrimengCalendarService, VideoUploadService, VideoStudioService, - VideoStateMessageService + VideoStateMessageService, + PlayerSettingsService ], resolve: { resolverData: VideoManageResolver diff --git a/client/src/app/+videos-publish-manage/+video-manage/video-manage.component.html b/client/src/app/+videos-publish-manage/+video-manage/video-manage.component.html index 23ae5bec7..65e5e3031 100644 --- a/client/src/app/+videos-publish-manage/+video-manage/video-manage.component.html +++ b/client/src/app/+videos-publish-manage/+video-manage/video-manage.component.html @@ -1,6 +1,5 @@
diff --git a/client/src/app/+videos-publish-manage/+video-manage/video-manage.component.ts b/client/src/app/+videos-publish-manage/+video-manage/video-manage.component.ts index 275ecec33..2bb8a9f97 100644 --- a/client/src/app/+videos-publish-manage/+video-manage/video-manage.component.ts +++ b/client/src/app/+videos-publish-manage/+video-manage/video-manage.component.ts @@ -3,7 +3,6 @@ import { Component, HostListener, inject, OnDestroy, OnInit } from '@angular/cor import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { ActivatedRoute } from '@angular/router' import { CanComponentDeactivate, Notifier, ServerService } from '@app/core' -import { VideoEdit } from '../shared-manage/common/video-edit.model' import { VideoManageContainerComponent } from '../shared-manage/video-manage-container.component' import { VideoManageController } from '../shared-manage/video-manage-controller.service' import { VideoManageResolverData } from './video-manage.resolver' @@ -16,8 +15,7 @@ import { VideoManageResolverData } from './video-manage.resolver' FormsModule, ReactiveFormsModule, VideoManageContainerComponent - ], - providers: [ VideoManageController ] + ] }) export class VideoManageComponent implements OnInit, OnDestroy, CanComponentDeactivate { private route = inject(ActivatedRoute) @@ -29,18 +27,9 @@ export class VideoManageComponent implements OnInit, OnDestroy, CanComponentDeac isUpdatingVideo = false loaded = false - async ngOnInit () { + ngOnInit () { const data = this.route.snapshot.data.resolverData as VideoManageResolverData - const { video, userChannels, captions, chapters, videoSource, live, videoPasswords, userQuota, privacies } = data - - const videoEdit = await VideoEdit.createFromAPI(this.serverService.getHTMLConfig(), { - video, - captions, - chapters, - live, - videoSource, - videoPasswords: videoPasswords.map(p => p.password) - }) + const { userChannels, userQuota, privacies, videoEdit } = data this.manageController.setStore({ videoEdit, @@ -50,8 +39,6 @@ export class VideoManageComponent implements OnInit, OnDestroy, CanComponentDeac }) this.manageController.setConfig({ manageType: 'update', serverConfig: this.serverService.getHTMLConfig() }) - - this.loaded = true } ngOnDestroy () { diff --git a/client/src/app/+videos-publish-manage/+video-manage/video-manage.resolver.ts b/client/src/app/+videos-publish-manage/+video-manage/video-manage.resolver.ts index c3a2a0415..45bf1c4c5 100644 --- a/client/src/app/+videos-publish-manage/+video-manage/video-manage.resolver.ts +++ b/client/src/app/+videos-publish-manage/+video-manage/video-manage.resolver.ts @@ -10,6 +10,7 @@ import { VideoService } from '@app/shared/shared-main/video/video.service' import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service' import { LiveVideo, + PlayerVideoSettings, UserVideoQuota, VideoCaption, VideoChapter, @@ -22,6 +23,8 @@ import { import { forkJoin, of } from 'rxjs' import { map, switchMap } from 'rxjs/operators' import { SelectChannelItem } from '../../../types' +import { VideoEdit } from '../shared-manage/common/video-edit.model' +import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service' export type VideoManageResolverData = { video: VideoDetails @@ -33,6 +36,8 @@ export type VideoManageResolverData = { videoPasswords: VideoPassword[] userQuota: UserVideoQuota privacies: VideoConstant[] + videoEdit: VideoEdit + playerSettings: PlayerVideoSettings } @Injectable() @@ -45,6 +50,7 @@ export class VideoManageResolver { private videoPasswordService = inject(VideoPasswordService) private userService = inject(UserService) private serverService = inject(ServerService) + private playerSettingsService = inject(PlayerSettingsService) resolve (route: ActivatedRouteSnapshot) { const uuid: string = route.params['uuid'] @@ -52,18 +58,32 @@ export class VideoManageResolver { return this.videoService.getVideo({ videoId: uuid }) .pipe( switchMap(video => forkJoin(this.buildObservables(video))), - map(([ video, videoSource, userChannels, captions, chapters, live, videoPasswords, userQuota, privacies ]) => - ({ - video, - userChannels, - captions, - chapters, - videoSource, - live, - videoPasswords, - userQuota, - privacies - }) as VideoManageResolverData + switchMap( + async ([ video, videoSource, userChannels, captions, chapters, live, videoPasswords, userQuota, privacies, playerSettings ]) => { + const videoEdit = await VideoEdit.createFromAPI(this.serverService.getHTMLConfig(), { + video, + captions, + chapters, + live, + videoSource, + playerSettings, + videoPasswords: videoPasswords.map(p => p.password) + }) + + return { + video, + userChannels, + captions, + chapters, + videoSource, + live, + videoPasswords, + userQuota, + privacies, + videoEdit, + playerSettings + } satisfies VideoManageResolverData + } ) ) } @@ -94,11 +114,13 @@ export class VideoManageResolver { video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED ? this.videoPasswordService.getVideoPasswords({ videoUUID: video.uuid }) - : of([]), + : of([] as VideoPassword[]), this.userService.getMyVideoQuotaUsed(), - this.serverService.getVideoPrivacies() - ] + this.serverService.getVideoPrivacies(), + + this.playerSettingsService.getVideoSettings({ videoId: video.uuid, raw: true }) + ] as const } } diff --git a/client/src/app/+videos-publish-manage/+video-publish/routes.ts b/client/src/app/+videos-publish-manage/+video-publish/routes.ts index 748def032..f9ceb0c5d 100644 --- a/client/src/app/+videos-publish-manage/+video-publish/routes.ts +++ b/client/src/app/+videos-publish-manage/+video-publish/routes.ts @@ -1,6 +1,10 @@ +import { inject } from '@angular/core' import { RedirectCommand, Router, Routes } from '@angular/router' import { CanDeactivateGuard, LoginGuard } from '@app/core' import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service' +import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service' +import { VideoStateMessageService } from '@app/shared/shared-video/video-state-message.service' +import debug from 'debug' import { I18nPrimengCalendarService } from '../shared-manage/common/i18n-primeng-calendar.service' import { VideoUploadService } from '../shared-manage/common/video-upload.service' import { manageRoutes } from '../shared-manage/routes' @@ -8,9 +12,6 @@ import { VideoStudioService } from '../shared-manage/studio/video-studio.service import { VideoManageController } from '../shared-manage/video-manage-controller.service' import { VideoPublishComponent } from './video-publish.component' import { VideoPublishResolver } from './video-publish.resolver' -import { inject } from '@angular/core' -import debug from 'debug' -import { VideoStateMessageService } from '@app/shared/shared-video/video-state-message.service' const debugLogger = debug('peertube:video-publish') @@ -43,6 +44,7 @@ export default [ providers: [ VideoPublishResolver, VideoManageController, + PlayerSettingsService, VideoStateMessageService, LiveVideoService, I18nPrimengCalendarService, diff --git a/client/src/app/+videos-publish-manage/+video-publish/video-publish.component.ts b/client/src/app/+videos-publish-manage/+video-publish/video-publish.component.ts index 8289cac0c..75b40b749 100644 --- a/client/src/app/+videos-publish-manage/+video-publish/video-publish.component.ts +++ b/client/src/app/+videos-publish-manage/+video-publish/video-publish.component.ts @@ -41,8 +41,7 @@ import { VideoPublishResolverData } from './video-publish.resolver' VideoImportUrlComponent, VideoUploadComponent, HelpComponent - ], - providers: [ VideoManageController ] + ] }) export class VideoPublishComponent implements OnInit, CanComponentDeactivate { private auth = inject(AuthService) diff --git a/client/src/app/+videos-publish-manage/shared-manage/common/video-edit.model.ts b/client/src/app/+videos-publish-manage/shared-manage/common/video-edit.model.ts index edce9c255..6860dc98f 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/common/video-edit.model.ts +++ b/client/src/app/+videos-publish-manage/shared-manage/common/video-edit.model.ts @@ -6,6 +6,8 @@ import { LiveVideoCreate, LiveVideoUpdate, NSFWFlag, + PlayerVideoSettings, + PlayerVideoSettingsUpdate, VideoCaption, VideoChapter, VideoCreate, @@ -65,6 +67,8 @@ type StudioForm = { 'add-watermark'?: { file?: File } } +type PlayerSettingsForm = PlayerVideoSettingsUpdate + // --------------------------------------------------------------------------- type LoadFromPublishOptions = Required> & Partial> @@ -115,6 +119,7 @@ type UpdateFromAPIOptions = { captions?: VideoCaption[] videoPasswords?: string[] videoSource?: VideoSource + playerSettings?: PlayerVideoSettings } // --------------------------------------------------------------------------- @@ -143,6 +148,7 @@ export class VideoEdit { private live: LiveUpdate private replaceFile: File private studioTasks: VideoStudioTask[] = [] + private playerSettings: PlayerVideoSettings private videoImport: Pick @@ -185,6 +191,7 @@ export class VideoEdit { previewfile?: { size: number } live?: LiveUpdate + playerSettings?: PlayerVideoSettings pluginData?: any pluginDefaults?: Record @@ -294,12 +301,13 @@ export class VideoEdit { } async loadFromAPI (options: UpdateFromAPIOptions & { loadPrivacy?: boolean }) { - const { video, videoPasswords, live, chapters, captions, videoSource, loadPrivacy = true } = options + const { video, videoPasswords, live, chapters, captions, videoSource, playerSettings, loadPrivacy = true } = options debugLogger('Load from API', options) this.loadVideo({ video, videoPasswords, saveInStore: true, loadPrivacy }) this.loadLive(live) + this.loadPlayerSettings(playerSettings) if (captions !== undefined) { this.captions = captions @@ -449,6 +457,17 @@ export class VideoEdit { this.metadata.live = pick(live, [ 'rtmpUrl', 'rtmpsUrl', 'streamKey' ]) } + private loadPlayerSettings (playerSettings: UpdateFromAPIOptions['playerSettings']) { + const buildObj = () => { + return { + theme: playerSettings.theme + } + } + + this.playerSettings = buildObj() + this.saveStore.playerSettings = buildObj() + } + loadAfterPublish (options: { video: Pick }) { @@ -797,6 +816,26 @@ export class VideoEdit { // --------------------------------------------------------------------------- + loadFromPlayerSettingsForm (values: PlayerSettingsForm) { + this.playerSettings = values + } + + toPlayerSettingsFormPatch (): Required { + return { + theme: this.playerSettings?.theme ?? 'channel-default' + } + } + + toPlayerSettingsUpdate (): PlayerVideoSettingsUpdate { + if (!this.playerSettings) return undefined + + return { + theme: this.playerSettings.theme + } + } + + // --------------------------------------------------------------------------- + getVideoSource () { return this.metadata.videoSource } @@ -825,6 +864,10 @@ export class VideoEdit { return this.studioTasks } + getPlayerSettings () { + return this.playerSettings + } + getStudioTasksSummary () { return this.getStudioTasks().map(t => { if (t.name === 'add-intro') { @@ -941,6 +984,21 @@ export class VideoEdit { return changes } + hasPlayerSettingsChanges () { + if (!this.playerSettings) return false + if (!this.saveStore.playerSettings) return true + + const changes = !this.areSameObjects(this.playerSettings, this.saveStore.playerSettings) + + debugLogger('Check if player settings has changes', { + playerSettings: this.playerSettings, + savePlayerSettings: this.saveStore.playerSettings, + changes + }) + + return changes + } + // --------------------------------------------------------------------------- hasPendingChanges () { @@ -950,7 +1008,8 @@ export class VideoEdit { this.hasStudioTasks() || this.hasChaptersChanges() || this.hasCommonChanges() || - this.hasPluginDataChanges() + this.hasPluginDataChanges() || + this.hasPlayerSettingsChanges() } // --------------------------------------------------------------------------- diff --git a/client/src/app/+videos-publish-manage/shared-manage/customization/video-customization.component.html b/client/src/app/+videos-publish-manage/shared-manage/customization/video-customization.component.html index d305a9ed4..9024b71b8 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/customization/video-customization.component.html +++ b/client/src/app/+videos-publish-manage/shared-manage/customization/video-customization.component.html @@ -31,8 +31,15 @@
@@ -42,10 +49,15 @@
- + + +
+ + + + +
+ diff --git a/client/src/app/+videos-publish-manage/shared-manage/customization/video-customization.component.ts b/client/src/app/+videos-publish-manage/shared-manage/customization/video-customization.component.ts index 22a2e542c..077153445 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/customization/video-customization.component.ts +++ b/client/src/app/+videos-publish-manage/shared-manage/customization/video-customization.component.ts @@ -1,11 +1,12 @@ -import { NgIf } from '@angular/common' +import { CommonModule } from '@angular/common' import { Component, OnDestroy, OnInit, inject } from '@angular/core' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' import { ServerService } from '@app/core' -import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model' +import { BuildFormArgumentTyped } from '@app/shared/form-validators/form-validator.model' import { VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR } from '@app/shared/form-validators/video-validators' -import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service' -import { HTMLServerConfig } from '@peertube/peertube-models' +import { FormReactiveErrors, FormReactiveMessages, FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' +import { SelectPlayerThemeComponent } from '@app/shared/shared-forms/select/select-player-theme.component' +import { HTMLServerConfig, PlayerVideoSettings, VideoChannel } from '@peertube/peertube-models' import debug from 'debug' import { DatePickerModule } from 'primeng/datepicker' import { Subscription } from 'rxjs' @@ -19,6 +20,10 @@ const debugLogger = debug('peertube:video-manage') type Form = { downloadEnabled: FormControl originallyPublishedAt: FormControl + + playerSettings: FormGroup<{ + theme: FormControl + }> } @Component({ @@ -28,12 +33,13 @@ type Form = { ], templateUrl: './video-customization.component.html', imports: [ + CommonModule, FormsModule, ReactiveFormsModule, - NgIf, DatePickerModule, PeertubeCheckboxComponent, - GlobalIconComponent + GlobalIconComponent, + SelectPlayerThemeComponent ] }) export class VideoCustomizationComponent implements OnInit, OnDestroy { @@ -47,6 +53,7 @@ export class VideoCustomizationComponent implements OnInit, OnDestroy { validationMessages: FormReactiveMessages = {} videoEdit: VideoEdit + videoChannel: Pick calendarDateFormat: string myYearRange: string @@ -63,17 +70,24 @@ export class VideoCustomizationComponent implements OnInit, OnDestroy { ngOnInit () { this.serverConfig = this.serverService.getHTMLConfig() - const { videoEdit } = this.manageController.getStore() + const { videoEdit, userChannels } = this.manageController.getStore() this.videoEdit = videoEdit + const channelItem = userChannels.find(c => c.id === videoEdit.toCommonFormPatch().channelId) + this.videoChannel = { name: channelItem.name, displayName: channelItem.label } + this.buildForm() } private buildForm () { - const defaultValues = this.videoEdit.toCommonFormPatch() - const obj: BuildFormArgument = { + const defaultValues = { ...this.videoEdit.toCommonFormPatch(), playerSettings: this.videoEdit.toPlayerSettingsFormPatch() } + + const obj: BuildFormArgumentTyped
= { downloadEnabled: null, - originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR + originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR, + playerSettings: { + theme: null + } } const { @@ -93,12 +107,18 @@ export class VideoCustomizationComponent implements OnInit, OnDestroy { debugLogger('Updating form values', formValues) this.videoEdit.loadFromCommonForm(formValues) + this.videoEdit.loadFromPlayerSettingsForm({ + theme: formValues.playerSettings.theme + }) }) this.formReactiveService.markAllAsDirty(this.form.controls) this.updatedSub = this.manageController.getUpdatedObs().subscribe(() => { - this.form.patchValue(this.videoEdit.toCommonFormPatch()) + this.form.patchValue({ + ...this.videoEdit.toCommonFormPatch(), + ...this.videoEdit.toPlayerSettingsFormPatch() + }) }) } diff --git a/client/src/app/+videos-publish-manage/shared-manage/video-manage-controller.service.ts b/client/src/app/+videos-publish-manage/shared-manage/video-manage-controller.service.ts index db9599947..4dcef4e15 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/video-manage-controller.service.ts +++ b/client/src/app/+videos-publish-manage/shared-manage/video-manage-controller.service.ts @@ -9,6 +9,7 @@ import { VideoChapterService } from '@app/shared/shared-main/video/video-chapter import { VideoPasswordService } from '@app/shared/shared-main/video/video-password.service' import { VideoService } from '@app/shared/shared-main/video/video.service' import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service' +import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service' import { LoadingBarService } from '@ngx-loading-bar/core' import { HTMLServerConfig, @@ -47,6 +48,7 @@ export class VideoManageController implements OnDestroy { private formReactiveService = inject(FormReactiveService) private videoStudio = inject(VideoStudioService) private peertubeRouter = inject(PeerTubeRouterService) + private playerSettingsService = inject(PlayerSettingsService) private videoEdit: VideoEdit private userChannels: SelectChannelItem[] @@ -245,6 +247,16 @@ export class VideoManageController implements OnDestroy { return this.videoChapterService.updateChapters(videoAttributes.uuid, this.videoEdit.getChaptersEdit()) }), + switchMap(() => { + if (!this.videoEdit.hasPlayerSettingsChanges()) return of(true) + + debugLogger('Update player settings') + + return this.playerSettingsService.updateVideoSettings({ + videoId: videoAttributes.uuid, + settings: this.videoEdit.getPlayerSettings() + }) + }), switchMap(() => { if (!isLive || !this.videoEdit.hasLiveChanges()) return of(true) @@ -283,16 +295,19 @@ export class VideoManageController implements OnDestroy { !isLive ? this.videoCaptionService.listCaptions(videoAttributes.uuid) - : of(undefined) + : of(undefined), + + this.playerSettingsService.getVideoSettings({ videoId: videoAttributes.uuid, raw: true }) ]) }), - switchMap(([ video, videoPasswords, live, chaptersRes, captionsRes ]) => { + switchMap(([ video, videoPasswords, live, chaptersRes, captionsRes, playerSettings ]) => { return this.videoEdit.loadFromAPI({ video, videoPasswords: videoPasswords.map(p => p.password), live, chapters: chaptersRes?.chapters, - captions: captionsRes?.data + captions: captionsRes?.data, + playerSettings }) }), first(), // To complete diff --git a/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.html b/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.html index 1517fe7aa..b83b78f8d 100644 --- a/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.html +++ b/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.html @@ -1,6 +1,6 @@
- + @if (editable()) { @if (hasAvatar()) { diff --git a/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.ts b/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.ts index d0bcbf572..cd2275022 100644 --- a/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.ts +++ b/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.ts @@ -41,7 +41,7 @@ export class ActorAvatarEditComponent implements OnInit, OnChanges { maxAvatarSize = 0 avatarExtensions = '' - preview: string + previewUrl: string actor: ActorAvatarInput @@ -55,6 +55,8 @@ export class ActorAvatarEditComponent implements OnInit, OnChanges { } ngOnChanges () { + this.previewUrl = undefined + this.actor = { avatars: this.avatars(), name: this.username() @@ -73,16 +75,23 @@ export class ActorAvatarEditComponent implements OnInit, OnChanges { this.avatarChange.emit(formData) if (this.previewImage()) { - imageToDataURL(avatarfile).then(result => this.preview = result) + imageToDataURL(avatarfile).then(result => this.previewUrl = result) } } deleteAvatar () { - this.preview = undefined + if (this.previewImage()) { + this.previewUrl = null + this.actor.avatars = [] + } + this.avatarDelete.emit() } hasAvatar () { - return !!this.preview || this.avatars().length !== 0 + // User deleted the avatar + if (this.previewUrl === null) return false + + return !!this.previewUrl || this.avatars().length !== 0 } } diff --git a/client/src/app/shared/shared-actor-image-edit/actor-banner-edit.component.html b/client/src/app/shared/shared-actor-image-edit/actor-banner-edit.component.html index 4f0d23808..f009768a1 100644 --- a/client/src/app/shared/shared-actor-image-edit/actor-banner-edit.component.html +++ b/client/src/app/shared/shared-actor-image-edit/actor-banner-edit.component.html @@ -1,30 +1,32 @@
-
- -
- -
- - -
- - - + @if (!hasBanner()) { +
+
-
+ } @else { +
+ + +
+ + + +
+
+ }
diff --git a/client/src/app/shared/shared-actor-image-edit/actor-banner-edit.component.ts b/client/src/app/shared/shared-actor-image-edit/actor-banner-edit.component.ts index cbcdeebe9..f481a71a4 100644 --- a/client/src/app/shared/shared-actor-image-edit/actor-banner-edit.component.ts +++ b/client/src/app/shared/shared-actor-image-edit/actor-banner-edit.component.ts @@ -1,8 +1,8 @@ -import { NgIf, NgTemplateOutlet } from '@angular/common' -import { Component, ElementRef, OnInit, inject, input, output, viewChild } from '@angular/core' +import { CommonModule, NgTemplateOutlet } from '@angular/common' +import { Component, ElementRef, OnInit, booleanAttribute, inject, input, output, viewChild } from '@angular/core' import { SafeResourceUrl } from '@angular/platform-browser' import { Notifier, ServerService } from '@app/core' -import { NgbDropdown, NgbDropdownMenu, NgbDropdownToggle, NgbPopover, NgbTooltip } from '@ng-bootstrap/ng-bootstrap' +import { NgbDropdownModule, NgbPopover, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' import { getBytes } from '@root-helpers/bytes' import { imageToDataURL } from '@root-helpers/images' import { GlobalIconComponent } from '../shared-icons/global-icon.component' @@ -14,7 +14,7 @@ import { GlobalIconComponent } from '../shared-icons/global-icon.component' './actor-image-edit.scss', './actor-banner-edit.component.scss' ], - imports: [ NgIf, NgbTooltip, NgTemplateOutlet, NgbDropdown, NgbDropdownToggle, GlobalIconComponent, NgbDropdownMenu ] + imports: [ CommonModule, NgbTooltipModule, NgTemplateOutlet, NgbDropdownModule, GlobalIconComponent ] }) export class ActorBannerEditComponent implements OnInit { private serverService = inject(ServerService) @@ -23,8 +23,8 @@ export class ActorBannerEditComponent implements OnInit { readonly bannerfileInput = viewChild>('bannerfileInput') readonly bannerPopover = viewChild('bannerPopover') - readonly bannerUrl = input(undefined) - readonly previewImage = input(false) + readonly bannerUrl = input() + readonly previewImage = input(false, { transform: booleanAttribute }) readonly bannerChange = output() readonly bannerDelete = output() @@ -63,11 +63,23 @@ export class ActorBannerEditComponent implements OnInit { } deleteBanner () { - this.preview = undefined + if (this.previewImage()) { + this.preview = null + } + this.bannerDelete.emit() } hasBanner () { + // User deleted the avatar + if (this.preview === null) return false + return !!this.preview || !!this.bannerUrl() } + + getBannerUrl () { + if (this.preview === null) return '' + + return this.preview || this.bannerUrl() + } } diff --git a/client/src/app/shared/shared-forms/select/select-player-theme.component.ts b/client/src/app/shared/shared-forms/select/select-player-theme.component.ts new file mode 100644 index 000000000..3b051a105 --- /dev/null +++ b/client/src/app/shared/shared-forms/select/select-player-theme.component.ts @@ -0,0 +1,129 @@ +import { CommonModule } from '@angular/common' +import { Component, forwardRef, inject, input, OnInit } from '@angular/core' +import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms' +import { ServerService } from '@app/core' +import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service' +import { PlayerChannelSettings, PlayerTheme, PlayerVideoSettings, VideoChannel } from '@peertube/peertube-models' +import { of } from 'rxjs' +import { SelectOptionsItem } from '../../../../types/select-options-item.model' +import { SelectOptionsComponent } from './select-options.component' + +@Component({ + selector: 'my-select-player-theme', + template: ` + + `, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SelectPlayerThemeComponent), + multi: true + } + ], + imports: [ FormsModule, CommonModule, SelectOptionsComponent ] +}) +export class SelectPlayerThemeComponent implements ControlValueAccessor, OnInit { + private serverService = inject(ServerService) + private playerSettingsService = inject(PlayerSettingsService) + + readonly inputId = input.required() + readonly mode = input.required<'instance' | 'video' | 'channel'>() + + readonly channel = input>() + + themes: SelectOptionsItem[] + selectedId: PlayerTheme + + ngOnInit () { + if (this.mode() === 'video' && !this.channel()) { + throw new Error('Channel must be specified in video mode') + } + + this.buildOptions() + } + + propagateChange = (_: any) => { + // empty + } + + writeValue (id: PlayerTheme) { + this.selectedId = id + } + + registerOnChange (fn: (_: any) => void) { + this.propagateChange = fn + } + + registerOnTouched () { + // Unused + } + + onModelChange () { + this.propagateChange(this.selectedId) + } + + private buildOptions () { + const config = this.serverService.getHTMLConfig() + const instanceName = config.instance.name + const instancePlayerTheme = this.getLabelOf(config.defaults.player.theme) + + this.themes = [] + + if (this.mode() === 'channel' || this.mode() === 'video') { + this.themes.push( + { id: 'instance-default', label: $localize`${instanceName} setting (${instancePlayerTheme})` } + ) + } + + if (this.mode() === 'video') { + this.themes.push( + { id: 'channel-default', label: $localize`${this.channel().displayName} setting` } + ) + + this.scheduleChannelUpdate() + } + + this.themes = this.themes.concat(this.getPlayerThemes()) + } + + private scheduleChannelUpdate () { + this.playerSettingsService.getChannelSettings({ channelHandle: this.channel().name, raw: true }).subscribe({ + next: settings => { + this.themes.find(t => t.id === 'channel-default').label = this.buildChannelLabel(settings) + } + }) + } + + private buildChannelLabel (channelRawPlayerSettings: PlayerChannelSettings) { + const config = this.serverService.getHTMLConfig() + const instanceName = config.instance.name + const instancePlayerTheme = this.getLabelOf(config.defaults.player.theme) + + const channelRawTheme = channelRawPlayerSettings.theme + + const channelPlayerTheme = channelRawTheme === 'instance-default' + ? $localize`from ${instanceName} setting\: ${instancePlayerTheme}` + : this.getLabelOf(channelRawTheme) + + return $localize`${this.channel().displayName} channel setting (${channelPlayerTheme})` + } + + private getLabelOf (playerTheme: PlayerTheme) { + return this.getPlayerThemes().find(t => t.id === playerTheme)?.label + } + + private getPlayerThemes (): SelectOptionsItem[] { + return [ + { id: 'galaxy', label: $localize`Galaxy`, description: $localize`Original theme` }, + { id: 'lucide', label: $localize`Lucide`, description: $localize`A clean and modern theme` } + ] + } +} diff --git a/client/src/app/shared/shared-main/common/infinite-scroller.directive.ts b/client/src/app/shared/shared-main/common/infinite-scroller.directive.ts index bc04381ae..8bef37deb 100644 --- a/client/src/app/shared/shared-main/common/infinite-scroller.directive.ts +++ b/client/src/app/shared/shared-main/common/infinite-scroller.directive.ts @@ -1,5 +1,4 @@ import { AfterViewChecked, booleanAttribute, Directive, ElementRef, inject, input, OnDestroy, OnInit, output } from '@angular/core' -import { PeerTubeRouterService } from '@app/core' import { fromEvent, Observable, Subscription } from 'rxjs' import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' @@ -8,7 +7,6 @@ import { distinctUntilChanged, filter, map, share, startWith, throttleTime } fro standalone: true }) export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewChecked { - private peertubeRouter = inject(PeerTubeRouterService) private el = inject(ElementRef) readonly percentLimit = input(70) @@ -18,7 +16,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh readonly nearOfBottom = output() private decimalLimit = 0 - private lastCurrentBottom = -1 + private lastCurrentBottom: number private scrollDownSub: Subscription private container: HTMLElement @@ -98,6 +96,11 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh } private isScrollingDown (current: number) { + if (this.lastCurrentBottom === undefined) { + this.lastCurrentBottom = current + return false + } + const result = this.lastCurrentBottom < current this.lastCurrentBottom = current diff --git a/client/src/app/shared/shared-video/player-settings.service.ts b/client/src/app/shared/shared-video/player-settings.service.ts new file mode 100644 index 000000000..7a0e38747 --- /dev/null +++ b/client/src/app/shared/shared-video/player-settings.service.ts @@ -0,0 +1,71 @@ +import { HttpClient, HttpParams } from '@angular/common/http' +import { inject, Injectable } from '@angular/core' +import { RestExtractor } from '@app/core' +import { + PlayerChannelSettings, + PlayerChannelSettingsUpdate, + PlayerVideoSettings, + PlayerVideoSettingsUpdate +} from '@peertube/peertube-models' +import { catchError } from 'rxjs' +import { environment } from 'src/environments/environment' +import { VideoPasswordService } from '../shared-main/video/video-password.service' + +@Injectable() +export class PlayerSettingsService { + static BASE_PLAYER_SETTINGS_URL = environment.apiUrl + '/api/v1/player-settings/' + + private authHttp = inject(HttpClient) + private restExtractor = inject(RestExtractor) + + getVideoSettings (options: { + videoId: string + videoPassword?: string + raw: boolean + }) { + const headers = VideoPasswordService.buildVideoPasswordHeader(options.videoPassword) + + const path = PlayerSettingsService.BASE_PLAYER_SETTINGS_URL + 'videos/' + options.videoId + + let params = new HttpParams() + if (options.raw) params = params.set('raw', 'true') + + return this.authHttp.get(path, { params, headers }) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + updateVideoSettings (options: { + videoId: string + settings: PlayerVideoSettingsUpdate + }) { + const path = PlayerSettingsService.BASE_PLAYER_SETTINGS_URL + 'videos/' + options.videoId + + return this.authHttp.put(path, options.settings) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + // --------------------------------------------------------------------------- + + getChannelSettings (options: { + channelHandle: string + raw: boolean + }) { + const path = PlayerSettingsService.BASE_PLAYER_SETTINGS_URL + 'video-channels/' + options.channelHandle + + let params = new HttpParams() + if (options.raw) params = params.set('raw', 'true') + + return this.authHttp.get(path, { params }) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + updateChannelSettings (options: { + channelHandle: string + settings: PlayerChannelSettingsUpdate + }) { + const path = PlayerSettingsService.BASE_PLAYER_SETTINGS_URL + 'video-channels/' + options.channelHandle + + return this.authHttp.put(path, options.settings) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } +} diff --git a/client/src/app/shared/standalone-channels/video-channel-create.component.ts b/client/src/app/shared/standalone-channels/video-channel-create.component.ts index 3bec54467..9ce6178e6 100644 --- a/client/src/app/shared/standalone-channels/video-channel-create.component.ts +++ b/client/src/app/shared/standalone-channels/video-channel-create.component.ts @@ -1,144 +1,98 @@ -import { NgClass, NgIf } from '@angular/common' -import { AfterViewInit, Component, OnInit, inject } from '@angular/core' -import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { AfterViewInit, Component, inject } from '@angular/core' import { Router } from '@angular/router' import { AuthService, HooksService, Notifier } from '@app/core' -import { - VIDEO_CHANNEL_DESCRIPTION_VALIDATOR, - VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, - VIDEO_CHANNEL_NAME_VALIDATOR, - VIDEO_CHANNEL_SUPPORT_VALIDATOR -} from '@app/shared/form-validators/video-channel-validators' -import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' import { VideoChannel } from '@app/shared/shared-main/channel/video-channel.model' import { VideoChannelService } from '@app/shared/shared-main/channel/video-channel.service' -import { AlertComponent } from '@app/shared/shared-main/common/alert.component' -import { HttpStatusCode, VideoChannelCreate } from '@peertube/peertube-models' +import { HttpStatusCode, PlayerChannelSettings, VideoChannelCreate } from '@peertube/peertube-models' import { of } from 'rxjs' import { switchMap } from 'rxjs/operators' -import { ActorAvatarEditComponent } from '../shared-actor-image-edit/actor-avatar-edit.component' -import { ActorBannerEditComponent } from '../shared-actor-image-edit/actor-banner-edit.component' -import { MarkdownTextareaComponent } from '../shared-forms/markdown-textarea.component' -import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component' -import { HelpComponent } from '../shared-main/buttons/help.component' -import { MarkdownHintComponent } from '../shared-main/text/markdown-hint.component' -import { VideoChannelEdit } from './video-channel-edit' +import { PlayerSettingsService } from '../shared-video/player-settings.service' +import { FormValidatedOutput, VideoChannelEditComponent } from './video-channel-edit.component' @Component({ - templateUrl: './video-channel-edit.component.html', - styleUrls: [ './video-channel-edit.component.scss' ], + template: ` + + + `, imports: [ - NgIf, - FormsModule, - ReactiveFormsModule, - ActorBannerEditComponent, - ActorAvatarEditComponent, - NgClass, - HelpComponent, - MarkdownTextareaComponent, - PeertubeCheckboxComponent, - AlertComponent, - MarkdownHintComponent + VideoChannelEditComponent + ], + providers: [ + PlayerSettingsService ] }) -export class VideoChannelCreateComponent extends VideoChannelEdit implements OnInit, AfterViewInit { - protected formReactiveService = inject(FormReactiveService) +export class VideoChannelCreateComponent implements AfterViewInit { private authService = inject(AuthService) private notifier = inject(Notifier) private router = inject(Router) private videoChannelService = inject(VideoChannelService) private hooks = inject(HooksService) + private playerSettingsService = inject(PlayerSettingsService) error: string - videoChannel = new VideoChannel({}) - - private avatar: FormData - private banner: FormData - - ngOnInit () { - this.buildForm({ - 'name': VIDEO_CHANNEL_NAME_VALIDATOR, - 'display-name': VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, - 'description': VIDEO_CHANNEL_DESCRIPTION_VALIDATOR, - 'support': VIDEO_CHANNEL_SUPPORT_VALIDATOR - }) + channel = new VideoChannel({}) + rawPlayerSettings: PlayerChannelSettings = { + theme: 'instance-default' } ngAfterViewInit () { this.hooks.runAction('action:video-channel-create.init', 'video-channel') } - formValidated () { + onFormValidated (output: FormValidatedOutput) { this.error = undefined - const body = this.form.value - const videoChannelCreate: VideoChannelCreate = { - name: body.name, - displayName: body['display-name'], - description: body.description || null, - support: body.support || null + const channelCreate: VideoChannelCreate = { + name: output.channel.name, + displayName: output.channel.displayName, + description: output.channel.description, + support: output.channel.support } - this.videoChannelService.createVideoChannel(videoChannelCreate) + this.videoChannelService.createVideoChannel(channelCreate) .pipe( - switchMap(() => this.uploadAvatar()), - switchMap(() => this.uploadBanner()) + switchMap(() => { + return this.playerSettingsService.updateChannelSettings({ + channelHandle: output.channel.name, + settings: { + theme: output.playerSettings.theme + } + }) + }), + switchMap(() => this.uploadAvatar(output.channel.name, output.avatar)), + switchMap(() => this.uploadBanner(output.channel.name, output.banner)) ).subscribe({ next: () => { this.authService.refreshUserInformation() - this.notifier.success($localize`Video channel ${videoChannelCreate.displayName} created.`) + this.notifier.success($localize`Video channel ${channelCreate.displayName} created.`) this.router.navigate([ '/my-library', 'video-channels' ]) }, error: err => { + let message = err.message + if (err.status === HttpStatusCode.CONFLICT_409) { - this.error = $localize`This name already exists on this platform.` - return + message = $localize`Channel name "${channelCreate.name}" already exists on this platform.` } - this.error = err.message + this.notifier.error(message) } }) } - onAvatarChange (formData: FormData) { - this.avatar = formData + private uploadAvatar (username: string, avatar?: FormData) { + if (!avatar) return of(undefined) + + return this.videoChannelService.changeVideoChannelImage(username, avatar, 'avatar') } - onAvatarDelete () { - this.avatar = null - } + private uploadBanner (username: string, banner?: FormData) { + if (!banner) return of(undefined) - onBannerChange (formData: FormData) { - this.banner = formData - } - - onBannerDelete () { - this.banner = null - } - - isCreation () { - return true - } - - getFormButtonTitle () { - return $localize`Create your channel` - } - - getUsername () { - return this.form.value.name - } - - private uploadAvatar () { - if (!this.avatar) return of(undefined) - - return this.videoChannelService.changeVideoChannelImage(this.getUsername(), this.avatar, 'avatar') - } - - private uploadBanner () { - if (!this.banner) return of(undefined) - - return this.videoChannelService.changeVideoChannelImage(this.getUsername(), this.banner, 'banner') + return this.videoChannelService.changeVideoChannelImage(username, banner, 'banner') } } diff --git a/client/src/app/shared/standalone-channels/video-channel-edit.component.html b/client/src/app/shared/standalone-channels/video-channel-edit.component.html index 1f3c72963..5582c43f9 100644 --- a/client/src/app/shared/standalone-channels/video-channel-edit.component.html +++ b/client/src/app/shared/standalone-channels/video-channel-edit.component.html @@ -1,11 +1,11 @@ -{{ error }} +{{ error() }}
- +
- @if (isCreation()) { + @if (mode() === 'create') {

NEW CHANNEL

} @else {

UPDATE CHANNEL

@@ -14,40 +14,40 @@
-
+
@{{ instanceHost }}
-
- + - @@ -58,7 +58,7 @@ @@ -86,6 +86,13 @@ >
+
+ + + + +
+
diff --git a/client/src/app/shared/standalone-channels/video-channel-edit.component.scss b/client/src/app/shared/standalone-channels/video-channel-edit.component.scss index 732b98508..5a7181301 100644 --- a/client/src/app/shared/standalone-channels/video-channel-edit.component.scss +++ b/client/src/app/shared/standalone-channels/video-channel-edit.component.scss @@ -1,16 +1,16 @@ -@use '_variables' as *; -@use '_mixins' as *; -@use '_form-mixins' as *; +@use "_variables" as *; +@use "_mixins" as *; +@use "_form-mixins" as *; my-actor-banner-edit { max-width: 500px; } -input[type=text] { +input[type="text"] { @include peertube-input-text(340px); } -input[type=submit] { +input[type="submit"] { @include margin-left(auto); } @@ -18,6 +18,8 @@ input[type=submit] { max-width: 500px; } -.peertube-select-container { - @include peertube-select-container(340px); +my-select-player-theme { + display: block; + + @include responsive-width(340px); } diff --git a/client/src/app/shared/standalone-channels/video-channel-edit.component.ts b/client/src/app/shared/standalone-channels/video-channel-edit.component.ts new file mode 100644 index 000000000..687d55556 --- /dev/null +++ b/client/src/app/shared/standalone-channels/video-channel-edit.component.ts @@ -0,0 +1,178 @@ +import { CommonModule } from '@angular/common' +import { Component, inject, input, OnInit, output } from '@angular/core' +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' +import { VideoChannel } from '@app/shared/shared-main/channel/video-channel.model' +import { PlayerChannelSettings } from '@peertube/peertube-models' +import { BuildFormArgumentTyped, FormReactiveErrorsTyped, FormReactiveMessagesTyped } from '../form-validators/form-validator.model' +import { + VIDEO_CHANNEL_DESCRIPTION_VALIDATOR, + VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, + VIDEO_CHANNEL_NAME_VALIDATOR, + VIDEO_CHANNEL_SUPPORT_VALIDATOR +} from '../form-validators/video-channel-validators' +import { ActorAvatarEditComponent } from '../shared-actor-image-edit/actor-avatar-edit.component' +import { ActorBannerEditComponent } from '../shared-actor-image-edit/actor-banner-edit.component' +import { FormReactiveService } from '../shared-forms/form-reactive.service' +import { MarkdownTextareaComponent } from '../shared-forms/markdown-textarea.component' +import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component' +import { SelectPlayerThemeComponent } from '../shared-forms/select/select-player-theme.component' +import { HelpComponent } from '../shared-main/buttons/help.component' +import { AlertComponent } from '../shared-main/common/alert.component' +import { MarkdownHintComponent } from '../shared-main/text/markdown-hint.component' + +type Form = { + name: FormControl + displayName: FormControl + description: FormControl + support: FormControl + playerTheme: FormControl + bulkVideosSupportUpdate: FormControl +} + +export type FormValidatedOutput = { + avatar: FormData + banner: FormData + + playerSettings: { + theme: PlayerChannelSettings['theme'] + } + + channel: { + name: string + displayName: string + description: string + support: string + bulkVideosSupportUpdate: boolean + } +} + +@Component({ + selector: 'my-video-channel-edit', + templateUrl: './video-channel-edit.component.html', + styleUrls: [ './video-channel-edit.component.scss' ], + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + ActorBannerEditComponent, + ActorAvatarEditComponent, + HelpComponent, + MarkdownTextareaComponent, + PeertubeCheckboxComponent, + AlertComponent, + MarkdownHintComponent, + SelectPlayerThemeComponent + ] +}) +export class VideoChannelEditComponent implements OnInit { + private formReactiveService = inject(FormReactiveService) + + readonly mode = input.required<'create' | 'update'>() + readonly channel = input.required() + readonly rawPlayerSettings = input.required() + readonly error = input() + + readonly formValidated = output() + + form: FormGroup + formErrors: FormReactiveErrorsTyped = {} + validationMessages: FormReactiveMessagesTyped = {} + + private avatar: FormData + private banner: FormData + private oldSupportField: string + + ngOnInit () { + this.buildForm() + + this.oldSupportField = this.channel().support + } + + private buildForm () { + const obj: BuildFormArgumentTyped = { + name: this.mode() === 'create' + ? VIDEO_CHANNEL_NAME_VALIDATOR + : null, + displayName: VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, + description: VIDEO_CHANNEL_DESCRIPTION_VALIDATOR, + support: VIDEO_CHANNEL_SUPPORT_VALIDATOR, + bulkVideosSupportUpdate: null, + playerTheme: null + } + + const defaultValues = { + displayName: this.channel().displayName, + description: this.channel().description, + support: this.channel().support, + playerTheme: this.rawPlayerSettings().theme + } + + const { + form, + formErrors, + validationMessages + } = this.formReactiveService.buildForm(obj, defaultValues) + + this.form = form + this.formErrors = formErrors + this.validationMessages = validationMessages + } + + getFormButtonTitle () { + if (this.mode() === 'update') { + return $localize`Update ${this.channel().name}` + } + + return $localize`Create your channel` + } + + onAvatarChange (formData: FormData) { + this.avatar = formData + } + + onAvatarDelete () { + this.avatar = null + } + + onBannerChange (formData: FormData) { + this.banner = formData + } + + onBannerDelete () { + this.banner = null + } + + get instanceHost () { + return window.location.host + } + + isBulkUpdateVideosDisplayed () { + if (this.mode() === 'create') return false + + if (this.oldSupportField === undefined) return false + + return this.oldSupportField !== this.form.value.support + } + + onFormValidated () { + const body = this.form.value + + this.formValidated.emit({ + avatar: this.avatar, + banner: this.banner, + playerSettings: { + theme: body.playerTheme + }, + channel: { + name: body.name, + displayName: body.displayName, + description: body.description || null, + support: body.support || null, + + bulkVideosSupportUpdate: this.mode() === 'update' + ? body.bulkVideosSupportUpdate || false + : undefined + } + }) + } +} diff --git a/client/src/app/shared/standalone-channels/video-channel-edit.ts b/client/src/app/shared/standalone-channels/video-channel-edit.ts deleted file mode 100644 index 4c97d3265..000000000 --- a/client/src/app/shared/standalone-channels/video-channel-edit.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { FormReactive } from '@app/shared/shared-forms/form-reactive' -import { VideoChannel } from '@app/shared/shared-main/channel/video-channel.model' - -export abstract class VideoChannelEdit extends FormReactive { - videoChannel: VideoChannel - - abstract isCreation (): boolean - abstract getFormButtonTitle (): string - - get instanceHost () { - return window.location.host - } - - // Should be implemented by the child - isBulkUpdateVideosDisplayed () { - return false - } -} diff --git a/client/src/app/shared/standalone-channels/video-channel-update.component.ts b/client/src/app/shared/standalone-channels/video-channel-update.component.ts index d8cc8f0a3..f61033d18 100644 --- a/client/src/app/shared/standalone-channels/video-channel-update.component.ts +++ b/client/src/app/shared/standalone-channels/video-channel-update.component.ts @@ -1,92 +1,65 @@ -import { NgClass, NgIf } from '@angular/common' -import { HttpErrorResponse } from '@angular/common/http' -import { AfterViewInit, Component, OnDestroy, OnInit, inject } from '@angular/core' -import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { AfterViewInit, Component, inject, OnDestroy, OnInit } from '@angular/core' import { ActivatedRoute } from '@angular/router' import { AuthService, HooksService, Notifier, RedirectService } from '@app/core' import { genericUploadErrorHandler } from '@app/helpers' -import { - VIDEO_CHANNEL_DESCRIPTION_VALIDATOR, - VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, - VIDEO_CHANNEL_SUPPORT_VALIDATOR -} from '@app/shared/form-validators/video-channel-validators' -import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' import { VideoChannelService } from '@app/shared/shared-main/channel/video-channel.service' -import { AlertComponent } from '@app/shared/shared-main/common/alert.component' import { shallowCopy } from '@peertube/peertube-core-utils' -import { VideoChannelUpdate } from '@peertube/peertube-models' -import { Subscription } from 'rxjs' -import { ActorAvatarEditComponent } from '../shared-actor-image-edit/actor-avatar-edit.component' -import { ActorBannerEditComponent } from '../shared-actor-image-edit/actor-banner-edit.component' -import { MarkdownTextareaComponent } from '../shared-forms/markdown-textarea.component' -import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component' -import { HelpComponent } from '../shared-main/buttons/help.component' -import { MarkdownHintComponent } from '../shared-main/text/markdown-hint.component' -import { VideoChannelEdit } from './video-channel-edit' +import { PlayerChannelSettings, VideoChannelUpdate } from '@peertube/peertube-models' +import { catchError, forkJoin, Subscription, switchMap, tap, throwError } from 'rxjs' +import { VideoChannel } from '../shared-main/channel/video-channel.model' +import { PlayerSettingsService } from '../shared-video/player-settings.service' +import { FormValidatedOutput, VideoChannelEditComponent } from './video-channel-edit.component' @Component({ selector: 'my-video-channel-update', - templateUrl: './video-channel-edit.component.html', - styleUrls: [ './video-channel-edit.component.scss' ], + template: ` + @if (channel && rawPlayerSettings) { + + + } + `, imports: [ - NgIf, - FormsModule, - ReactiveFormsModule, - ActorBannerEditComponent, - ActorAvatarEditComponent, - NgClass, - HelpComponent, - MarkdownTextareaComponent, - PeertubeCheckboxComponent, - AlertComponent, - MarkdownHintComponent + VideoChannelEditComponent + ], + providers: [ + PlayerSettingsService ] }) -export class VideoChannelUpdateComponent extends VideoChannelEdit implements OnInit, AfterViewInit, OnDestroy { - protected formReactiveService = inject(FormReactiveService) +export class VideoChannelUpdateComponent implements OnInit, AfterViewInit, OnDestroy { private authService = inject(AuthService) private notifier = inject(Notifier) private route = inject(ActivatedRoute) private videoChannelService = inject(VideoChannelService) + private playerSettingsService = inject(PlayerSettingsService) private redirectService = inject(RedirectService) private hooks = inject(HooksService) + channel: VideoChannel + rawPlayerSettings: PlayerChannelSettings error: string private paramsSub: Subscription - private oldSupportField: string ngOnInit () { - this.buildForm({ - 'display-name': VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, - 'description': VIDEO_CHANNEL_DESCRIPTION_VALIDATOR, - 'support': VIDEO_CHANNEL_SUPPORT_VALIDATOR, - 'bulkVideosSupportUpdate': null - }) - this.paramsSub = this.route.params.subscribe(routeParams => { const videoChannelName = routeParams['videoChannelName'] - this.videoChannelService.getVideoChannel(videoChannelName) - .subscribe({ - next: videoChannelToUpdate => { - this.videoChannel = videoChannelToUpdate + forkJoin([ + this.videoChannelService.getVideoChannel(videoChannelName), + this.playerSettingsService.getChannelSettings({ channelHandle: videoChannelName, raw: true }) + ]).subscribe({ + next: ([ channel, rawPlayerSettings ]) => { + this.channel = channel + this.rawPlayerSettings = rawPlayerSettings - this.hooks.runAction('action:video-channel-update.video-channel.loaded', 'video-channel', { videoChannel: this.videoChannel }) + this.hooks.runAction('action:video-channel-update.video-channel.loaded', 'video-channel', { videoChannel: this.channel }) + }, - this.oldSupportField = videoChannelToUpdate.support - - this.form.patchValue({ - 'display-name': videoChannelToUpdate.displayName, - 'description': videoChannelToUpdate.description, - 'support': videoChannelToUpdate.support - }) - }, - - error: err => { - this.error = err.message - } - }) + error: err => this.notifier.error(err.message) + }) }) } @@ -98,112 +71,84 @@ export class VideoChannelUpdateComponent extends VideoChannelEdit implements OnI if (this.paramsSub) this.paramsSub.unsubscribe() } - formValidated () { + onFormValidated (output: FormValidatedOutput) { this.error = undefined - const body = this.form.value const videoChannelUpdate: VideoChannelUpdate = { - displayName: body['display-name'], - description: body.description || null, - support: body.support || null, - bulkVideosSupportUpdate: body.bulkVideosSupportUpdate || false + displayName: output.channel.displayName, + description: output.channel.description, + support: output.channel.support, + bulkVideosSupportUpdate: output.channel.bulkVideosSupportUpdate } - this.videoChannelService.updateVideoChannel(this.videoChannel.name, videoChannelUpdate) + this.videoChannelService.updateVideoChannel(this.channel.name, videoChannelUpdate) + .pipe( + switchMap(() => { + return this.playerSettingsService.updateChannelSettings({ + channelHandle: this.channel.name, + settings: { + theme: output.playerSettings.theme + } + }) + }), + switchMap(() => this.updateOrDeleteAvatar(output.avatar)), + switchMap(() => this.updateOrDeleteBanner(output.banner)) + ) .subscribe({ next: () => { + // So my-actor-avatar component detects changes + this.channel = shallowCopy(this.channel) + this.authService.refreshUserInformation() this.notifier.success($localize`Video channel ${videoChannelUpdate.displayName} updated.`) - this.redirectService.redirectToPreviousRoute('/c/' + this.videoChannel.name) - }, - - error: err => { - this.error = err.message - } - }) - } - - onAvatarChange (formData: FormData) { - this.videoChannelService.changeVideoChannelImage(this.videoChannel.name, formData, 'avatar') - .subscribe({ - next: data => { - this.notifier.success($localize`Avatar changed.`) - - this.videoChannel.updateAvatar(data.avatars) - - // So my-actor-avatar component detects changes - this.videoChannel = shallowCopy(this.videoChannel) - }, - - error: (err: HttpErrorResponse) => - genericUploadErrorHandler({ - err, - name: $localize`avatar`, - notifier: this.notifier - }) - }) - } - - onAvatarDelete () { - this.videoChannelService.deleteVideoChannelImage(this.videoChannel.name, 'avatar') - .subscribe({ - next: () => { - this.notifier.success($localize`Avatar deleted.`) - - this.videoChannel.resetAvatar() - - // So my-actor-avatar component detects changes - this.videoChannel = shallowCopy(this.videoChannel) + this.redirectService.redirectToPreviousRoute('/c/' + this.channel.name) }, error: err => this.notifier.error(err.message) }) } - onBannerChange (formData: FormData) { - this.videoChannelService.changeVideoChannelImage(this.videoChannel.name, formData, 'banner') - .subscribe({ - next: data => { - this.notifier.success($localize`Banner changed.`) + private updateOrDeleteAvatar (avatar: FormData) { + if (!avatar) { + return this.videoChannelService.deleteVideoChannelImage(this.channel.name, 'avatar') + .pipe(tap(() => this.channel.resetAvatar())) + } - this.videoChannel.updateBanner(data.banners) - }, - - error: (err: HttpErrorResponse) => - genericUploadErrorHandler({ - err, - name: $localize`banner`, - notifier: this.notifier + return this.videoChannelService.changeVideoChannelImage(this.channel.name, avatar, 'avatar') + .pipe( + tap(data => this.channel.updateAvatar(data.avatars)), + catchError(err => + throwError(() => { + return new Error(genericUploadErrorHandler({ + err, + name: $localize`avatar`, + notifier: this.notifier + })) }) - }) + ) + ) } - onBannerDelete () { - this.videoChannelService.deleteVideoChannelImage(this.videoChannel.name, 'banner') - .subscribe({ - next: () => { - this.notifier.success($localize`Banner deleted.`) + private updateOrDeleteBanner (banner: FormData) { + if (!banner) { + return this.videoChannelService.deleteVideoChannelImage(this.channel.name, 'banner') + .pipe(tap(() => this.channel.resetBanner())) + } - this.videoChannel.resetBanner() - }, - - error: err => this.notifier.error(err.message) - }) - } - - isCreation () { - return false - } - - getFormButtonTitle () { - return $localize`Update ${this.videoChannel?.name}` - } - - isBulkUpdateVideosDisplayed () { - if (this.oldSupportField === undefined) return false - - return this.oldSupportField !== this.form.value['support'] + return this.videoChannelService.changeVideoChannelImage(this.channel.name, banner, 'banner') + .pipe( + tap(data => this.channel.updateBanner(data.banners)), + catchError(err => + throwError(() => { + return new Error(genericUploadErrorHandler({ + err, + name: $localize`banner`, + notifier: this.notifier + })) + }) + ) + ) } } diff --git a/client/src/standalone/player/src/peertube-player.ts b/client/src/standalone/player/src/peertube-player.ts index b037dd934..ae067bb0c 100644 --- a/client/src/standalone/player/src/peertube-player.ts +++ b/client/src/standalone/player/src/peertube-player.ts @@ -103,7 +103,7 @@ export class PeerTubePlayer { await this.buildPlayerIfNeeded() - for (const theme of [ 'default', 'lucide' ]) { + for (const theme of [ 'galaxy', 'lucide' ]) { this.player.removeClass('vjs-peertube-theme-' + theme) } diff --git a/client/src/standalone/player/src/sass/shared/control-bar.scss b/client/src/standalone/player/src/sass/shared/control-bar.scss index d88b43a9f..3d6e597a0 100644 --- a/client/src/standalone/player/src/sass/shared/control-bar.scss +++ b/client/src/standalone/player/src/sass/shared/control-bar.scss @@ -313,11 +313,11 @@ $chapter-marker-size: 9px; } // --------------------------------------------------------------------------- -// PeerTube Default Theme +// PeerTube Galaxy (original) Theme // --------------------------------------------------------------------------- // Play/pause animations -.video-js.vjs-peertube-theme-default.vjs-has-started .vjs-play-control { +.video-js.vjs-peertube-theme-galaxy.vjs-has-started .vjs-play-control { &.vjs-playing { animation: remove-pause-button 0.25s ease; } @@ -345,7 +345,7 @@ $chapter-marker-size: 9px; } } -.video-js.vjs-peertube-theme-default .vjs-control-bar { +.video-js.vjs-peertube-theme-galaxy .vjs-control-bar { background: linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.6)); box-shadow: 0 -15px 40px 10px rgba(0, 0, 0, 0.2); diff --git a/client/src/standalone/player/src/sass/shared/peertube-skin.scss b/client/src/standalone/player/src/sass/shared/peertube-skin.scss index 297af3606..ddc80393d 100644 --- a/client/src/standalone/player/src/sass/shared/peertube-skin.scss +++ b/client/src/standalone/player/src/sass/shared/peertube-skin.scss @@ -166,16 +166,16 @@ body { } // --------------------------------------------------------------------------- -// Default theme +// Galaxy (original) theme // --------------------------------------------------------------------------- -.video-js.vjs-peertube-theme-default { +.video-js.vjs-peertube-theme-galaxy { .vjs-big-play-button { border: 2px solid #fff; } } -.video-js.vjs-peertube-theme-default.vjs-size-570 { +.video-js.vjs-peertube-theme-galaxy.vjs-size-570 { .vjs-big-play-button { --big-play-button-size: 78px; --big-play-button-icon-size: 32px; @@ -184,7 +184,7 @@ body { } } -.video-js.vjs-peertube-theme-default.vjs-size-350 { +.video-js.vjs-peertube-theme-galaxy.vjs-size-350 { .vjs-big-play-button { --big-play-button-size: 46px; --big-play-button-icon-size: 20px; diff --git a/client/src/standalone/player/src/shared/settings/settings-menu-item.ts b/client/src/standalone/player/src/shared/settings/settings-menu-item.ts index 989cc2fe8..5cea805bc 100644 --- a/client/src/standalone/player/src/shared/settings/settings-menu-item.ts +++ b/client/src/standalone/player/src/shared/settings/settings-menu-item.ts @@ -1,13 +1,12 @@ import debug from 'debug' import videojs from 'video.js' +import MenuButton from 'video.js/dist/types/menu/menu-button' import { VideojsComponent, VideojsMenu, VideojsMenuItem, VideojsMenuItemOptions, VideojsPlayer } from '../../types' import { toTitleCase } from '../common' import { SettingsDialog } from './settings-dialog' import { SettingsButton } from './settings-menu-button' import { SettingsPanel } from './settings-panel' import { SettingsPanelChild } from './settings-panel-child' -import Button from 'video.js/dist/types/button' -import MenuButton from 'video.js/dist/types/menu/menu-button' const debugLogger = debug('peertube:player:settings') diff --git a/client/src/standalone/player/src/types/peertube-player-options.ts b/client/src/standalone/player/src/types/peertube-player-options.ts index a4dbf0718..ba2c7263b 100644 --- a/client/src/standalone/player/src/types/peertube-player-options.ts +++ b/client/src/standalone/player/src/types/peertube-player-options.ts @@ -1,11 +1,8 @@ -import { LiveVideoLatencyModeType, VideoChapter, VideoFile } from '@peertube/peertube-models' +import { LiveVideoLatencyModeType, PlayerMode, PlayerTheme, VideoChapter, VideoFile } from '@peertube/peertube-models' import { PluginsManager } from '@root-helpers/plugins-manager' import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' import { PlaylistPluginOptions, VideoJSCaption, VideojsPlayer, VideoJSStoryboard } from './peertube-videojs-typings' -export type PlayerMode = 'web-video' | 'p2p-media-loader' -export type PeerTubePlayerTheme = 'default' | 'lucide' - export type PeerTubePlayerConstructorOptions = { playerElement: () => HTMLVideoElement @@ -53,7 +50,7 @@ export type PeerTubePlayerConstructorOptions = { export type PeerTubePlayerLoadOptions = { mode: PlayerMode - theme: PeerTubePlayerTheme + theme: PlayerTheme startTime?: number | string stopTime?: number | string diff --git a/client/src/standalone/player/src/types/peertube-videojs-typings.ts b/client/src/standalone/player/src/types/peertube-videojs-typings.ts index 34e08fc64..02aa92ce3 100644 --- a/client/src/standalone/player/src/types/peertube-videojs-typings.ts +++ b/client/src/standalone/player/src/types/peertube-videojs-typings.ts @@ -1,4 +1,4 @@ -import { VideoChapter, VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models' +import { PlayerMode, VideoChapter, VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models' import type { HlsConfig, Level, Loader, LoaderContext } from 'hls.js' import type { CoreConfig } from 'p2p-media-loader-core' import type { HlsJsP2PEngine } from 'p2p-media-loader-hlsjs' @@ -34,12 +34,11 @@ import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator' import { PeerTubePlugin } from '../shared/peertube/peertube-plugin' import { PlaylistPlugin } from '../shared/playlist/playlist-plugin' import { PeerTubeResolutionsPlugin } from '../shared/resolutions/peertube-resolutions-plugin' +import { SettingsButton } from '../shared/settings/settings-menu-button' import { StatsCardOptions } from '../shared/stats/stats-card' import { StatsForNerdsPlugin } from '../shared/stats/stats-plugin' import { UpNextPlugin } from '../shared/upnext/upnext-plugin' import { WebVideoPlugin } from '../shared/web-video/web-video-plugin' -import { PlayerMode } from './peertube-player-options' -import { SettingsButton } from '../shared/settings/settings-menu-button' declare module 'video.js' { export interface VideoJsPlayer { diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts index 28d453aa4..5dd86fa65 100644 --- a/client/src/standalone/videos/embed.ts +++ b/client/src/standalone/videos/embed.ts @@ -69,7 +69,7 @@ export class PeerTubeEmbed { this.peertubePlugin = new PeerTubePlugin(this.http) this.peertubeTheme = new PeerTubeTheme(this.peertubePlugin) this.playerHTML = new PlayerHTML(videoWrapperId) - this.playerOptionsBuilder = new PlayerOptionsBuilder(this.playerHTML, this.videoFetcher, this.peertubePlugin) + this.playerOptionsBuilder = new PlayerOptionsBuilder(this.playerHTML, this.videoFetcher, this.peertubePlugin, this.config) this.liveManager = new LiveManager(this.playerHTML) this.requiresPassword = false @@ -220,10 +220,18 @@ export class PeerTubeEmbed { videoResponse, captionsPromise, chaptersPromise, - storyboardsPromise + storyboardsPromise, + playerSettingsPromise } = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword }) - return this.buildVideoPlayer({ videoResponse, captionsPromise, chaptersPromise, storyboardsPromise, forceAutoplay }) + return this.buildVideoPlayer({ + videoResponse, + captionsPromise, + chaptersPromise, + storyboardsPromise, + playerSettingsPromise, + forceAutoplay + }) } catch (err) { if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options }) else this.playerHTML.displayError(err.message, await this.translationsPromise) @@ -235,9 +243,10 @@ export class PeerTubeEmbed { storyboardsPromise: Promise captionsPromise: Promise chaptersPromise: Promise + playerSettingsPromise: Promise forceAutoplay: boolean }) { - const { videoResponse, captionsPromise, chaptersPromise, storyboardsPromise, forceAutoplay } = options + const { videoResponse, captionsPromise, chaptersPromise, storyboardsPromise, playerSettingsPromise, forceAutoplay } = options const videoInfoPromise = videoResponse.json() .then(async (videoInfo: VideoDetails) => { @@ -259,13 +268,15 @@ export class PeerTubeEmbed { translations, captionsResponse, chaptersResponse, - storyboardsResponse + storyboardsResponse, + playerSettingsResponse ] = await Promise.all([ videoInfoPromise, this.translationsPromise, captionsPromise, chaptersPromise, storyboardsPromise, + playerSettingsPromise, this.buildPlayerIfNeeded() ]) @@ -283,6 +294,7 @@ export class PeerTubeEmbed { video, captionsResponse, chaptersResponse, + playerSettingsResponse, config: this.config, translations, diff --git a/client/src/standalone/videos/shared/player-options-builder.ts b/client/src/standalone/videos/shared/player-options-builder.ts index 762628007..d11cd83ee 100644 --- a/client/src/standalone/videos/shared/player-options-builder.ts +++ b/client/src/standalone/videos/shared/player-options-builder.ts @@ -2,6 +2,9 @@ import { peertubeTranslate } from '@peertube/peertube-core-utils' import { HTMLServerConfig, LiveVideo, + PlayerMode, + PlayerTheme, + PlayerVideoSettings, Storyboard, Video, VideoCaption, @@ -24,14 +27,7 @@ import { UserLocalStorageKeys, videoRequiresUserAuth } from '../../../root-helpers' -import { - HLSOptions, - PeerTubePlayerConstructorOptions, - PeerTubePlayerLoadOptions, - PeerTubePlayerTheme, - PlayerMode, - VideoJSCaption -} from '../../player' +import { HLSOptions, PeerTubePlayerConstructorOptions, PeerTubePlayerLoadOptions, VideoJSCaption } from '../../player' import { PeerTubePlugin } from './peertube-plugin' import { PlayerHTML } from './player-html' import { PlaylistTracker } from './playlist-tracker' @@ -59,7 +55,7 @@ export class PlayerOptionsBuilder { private p2pEnabled: boolean private bigPlayBackgroundColor: string private foregroundColor: string - private playerTheme: PeerTubePlayerTheme + private playerTheme: PlayerTheme private waitPasswordFromEmbedAPI = false @@ -69,7 +65,8 @@ export class PlayerOptionsBuilder { constructor ( private readonly playerHTML: PlayerHTML, private readonly videoFetcher: VideoFetcher, - private readonly peertubePlugin: PeerTubePlugin + private readonly peertubePlugin: PeerTubePlugin, + private readonly serverConfig: HTMLServerConfig ) {} hasAPIEnabled () { @@ -150,7 +147,7 @@ export class PlayerOptionsBuilder { this.bigPlayBackgroundColor = getParamString(params, 'bigPlayBackgroundColor') this.foregroundColor = getParamString(params, 'foregroundColor') - this.playerTheme = getParamString(params, 'playerTheme', 'default') as PeerTubePlayerTheme + this.playerTheme = getParamString(params, 'playerTheme') as PlayerTheme } catch (err) { logger.error('Cannot get params from URL.', err) } @@ -238,6 +235,8 @@ export class PlayerOptionsBuilder { chaptersResponse: Response + playerSettingsResponse: Response + live?: LiveVideo alreadyPlayed: boolean @@ -271,13 +270,15 @@ export class PlayerOptionsBuilder { live, storyboardsResponse, chaptersResponse, - config + config, + playerSettingsResponse } = options - const [ videoCaptions, storyboard, chapters ] = await Promise.all([ + const [ videoCaptions, storyboard, chapters, playerSettings ] = await Promise.all([ this.buildCaptions(captionsResponse, translations), this.buildStoryboard(storyboardsResponse), - this.buildChapters(chaptersResponse) + this.buildChapters(chaptersResponse), + playerSettingsResponse.json() as Promise ]) const nsfwWarn = isVideoNSFWWarnedForUser(video, config, null) || isVideoNSFWHiddenForUser(video, config, null) @@ -285,7 +286,7 @@ export class PlayerOptionsBuilder { return { mode: this.mode, - theme: this.playerTheme, + theme: this.playerTheme || playerSettings.theme as PlayerTheme, autoplay: !nsfwWarn && (forceAutoplay || alreadyPlayed || this.autoplay), forceAutoplay, diff --git a/client/src/standalone/videos/shared/video-fetcher.ts b/client/src/standalone/videos/shared/video-fetcher.ts index ec2cc3380..862a55ebc 100644 --- a/client/src/standalone/videos/shared/video-fetcher.ts +++ b/client/src/standalone/videos/shared/video-fetcher.ts @@ -5,9 +5,7 @@ import { AuthHTTP } from './auth-http' import { getBackendUrl } from './url' export class VideoFetcher { - constructor (private readonly http: AuthHTTP) { - } async loadVideo ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }) { @@ -39,8 +37,9 @@ export class VideoFetcher { const captionsPromise = this.loadVideoCaptions({ videoId, videoPassword }) const chaptersPromise = this.loadVideoChapters({ videoId, videoPassword }) const storyboardsPromise = this.loadStoryboards(videoId) + const playerSettingsPromise = this.loadPlayerSettings({ videoId, videoPassword }) - return { captionsPromise, chaptersPromise, storyboardsPromise, videoResponse } + return { captionsPromise, chaptersPromise, storyboardsPromise, videoResponse, playerSettingsPromise } } loadLive (video: VideoDetails) { @@ -70,10 +69,18 @@ export class VideoFetcher { return this.http.fetch(this.getVideoUrl(videoId) + '/chapters', { optionalAuth: true }, videoPassword) } + private loadPlayerSettings ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise { + return this.http.fetch(this.getPlayerSettingsUrl(videoId), { optionalAuth: true }, videoPassword) + } + private getVideoUrl (id: string) { return getBackendUrl() + '/api/v1/videos/' + id } + private getPlayerSettingsUrl (id: string) { + return getBackendUrl() + '/api/v1/player-settings/videos/' + id + } + private getLiveUrl (videoId: string) { return getBackendUrl() + '/api/v1/videos/live/' + videoId } diff --git a/config/default.yaml b/config/default.yaml index c8dedbcd0..e0c22a964 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -1176,6 +1176,8 @@ defaults: enabled: true player: + theme: 'galaxy' # 'galaxy' | 'lucide' + # By default, playback starts automatically when opening a video auto_play: true diff --git a/config/production.yaml.example b/config/production.yaml.example index 9e950c003..52300cc60 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example @@ -1186,6 +1186,8 @@ defaults: enabled: true player: + theme: 'galaxy' # 'galaxy' | 'lucide' + # By default, playback starts automatically when opening a video auto_play: true diff --git a/packages/models/src/activitypub/activity.ts b/packages/models/src/activitypub/activity.ts index a81b4b422..e2a4d964b 100644 --- a/packages/models/src/activitypub/activity.ts +++ b/packages/models/src/activitypub/activity.ts @@ -5,6 +5,7 @@ import { ActivityObject, APObjectId, CacheFileObject, + PlayerSettingsObject, PlaylistObject, VideoCommentObject, VideoObject, @@ -12,7 +13,7 @@ import { } from './objects/index.js' export type ActivityUpdateObject = - | Extract + | Extract | ActivityPubActor // Cannot Extract from Activity because of circular reference diff --git a/packages/models/src/activitypub/activitypub-actor.ts b/packages/models/src/activitypub/activitypub-actor.ts index cf8487b0b..18c524fea 100644 --- a/packages/models/src/activitypub/activitypub-actor.ts +++ b/packages/models/src/activitypub/activitypub-actor.ts @@ -38,4 +38,7 @@ export interface ActivityPubActor { // Used by the user export feature likes?: string dislikes?: string + + // On channels only + playerSettings?: string } diff --git a/packages/models/src/activitypub/context.ts b/packages/models/src/activitypub/context.ts index f0989698d..e85a1677c 100644 --- a/packages/models/src/activitypub/context.ts +++ b/packages/models/src/activitypub/context.ts @@ -1,19 +1,20 @@ export type ContextType = - 'Video' | - 'Comment' | - 'Playlist' | - 'Follow' | - 'Reject' | - 'Accept' | - 'View' | - 'Announce' | - 'CacheFile' | - 'Delete' | - 'Rate' | - 'Flag' | - 'Actor' | - 'Collection' | - 'WatchAction' | - 'Chapters' | - 'ApproveReply' | - 'RejectReply' + | 'Video' + | 'Comment' + | 'Playlist' + | 'Follow' + | 'Reject' + | 'Accept' + | 'View' + | 'Announce' + | 'CacheFile' + | 'Delete' + | 'Rate' + | 'Flag' + | 'Actor' + | 'Collection' + | 'WatchAction' + | 'Chapters' + | 'ApproveReply' + | 'RejectReply' + | 'PlayerSettings' diff --git a/packages/models/src/activitypub/objects/activitypub-object.ts b/packages/models/src/activitypub/objects/activitypub-object.ts index 93c925ae0..5937dad4d 100644 --- a/packages/models/src/activitypub/objects/activitypub-object.ts +++ b/packages/models/src/activitypub/objects/activitypub-object.ts @@ -1,17 +1,19 @@ import { AbuseObject } from './abuse-object.js' import { CacheFileObject } from './cache-file-object.js' +import { PlayerSettingsObject } from './player-settings-object.js' import { PlaylistObject } from './playlist-object.js' import { VideoCommentObject } from './video-comment-object.js' import { VideoObject } from './video-object.js' import { WatchActionObject } from './watch-action-object.js' export type ActivityObject = - VideoObject | - AbuseObject | - VideoCommentObject | - CacheFileObject | - PlaylistObject | - WatchActionObject | - string + | VideoObject + | AbuseObject + | VideoCommentObject + | CacheFileObject + | PlaylistObject + | WatchActionObject + | PlayerSettingsObject + | string export type APObjectId = string | { id: string } diff --git a/packages/models/src/activitypub/objects/index.ts b/packages/models/src/activitypub/objects/index.ts index bd8351d30..a1767e7bd 100644 --- a/packages/models/src/activitypub/objects/index.ts +++ b/packages/models/src/activitypub/objects/index.ts @@ -8,4 +8,5 @@ export * from './video-caption-object.js' export * from './video-chapters-object.js' export * from './video-comment-object.js' export * from './video-object.js' +export * from './player-settings-object.js' export * from './watch-action-object.js' diff --git a/packages/models/src/activitypub/objects/player-settings-object.ts b/packages/models/src/activitypub/objects/player-settings-object.ts new file mode 100644 index 000000000..735c4e824 --- /dev/null +++ b/packages/models/src/activitypub/objects/player-settings-object.ts @@ -0,0 +1,8 @@ +import { PlayerThemeChannelSetting, PlayerThemeVideoSetting } from '../../player/player-theme.type.js' + +export interface PlayerSettingsObject { + type: 'PlayerSettings' + id: string + object: string + theme: PlayerThemeVideoSetting | PlayerThemeChannelSetting +} diff --git a/packages/models/src/activitypub/objects/video-object.ts b/packages/models/src/activitypub/objects/video-object.ts index 5f9c57aaf..05ed22672 100644 --- a/packages/models/src/activitypub/objects/video-object.ts +++ b/packages/models/src/activitypub/objects/video-object.ts @@ -64,6 +64,7 @@ export interface VideoObject { shares: string comments: string hasParts: string | VideoChapterObject[] + playerSettings: string attributedTo: ActivityPubAttributedTo[] diff --git a/packages/models/src/import-export/peertube-export-format/channel-export.model.ts b/packages/models/src/import-export/peertube-export-format/channel-export.model.ts index 99aa36f75..d5a3ebfc0 100644 --- a/packages/models/src/import-export/peertube-export-format/channel-export.model.ts +++ b/packages/models/src/import-export/peertube-export-format/channel-export.model.ts @@ -1,3 +1,4 @@ +import { PlayerThemeChannelSetting } from '../../player/player-theme.type.js' import { UserActorImageJSON } from './actor-export.model.js' export interface ChannelExportJSON { @@ -15,6 +16,10 @@ export interface ChannelExportJSON { avatars: UserActorImageJSON[] banners: UserActorImageJSON[] + playerSettings?: { + theme: PlayerThemeChannelSetting + } + archiveFiles: { avatar: string | null banner: string | null diff --git a/packages/models/src/import-export/peertube-export-format/video-export.model.ts b/packages/models/src/import-export/peertube-export-format/video-export.model.ts index f3762e0f9..3b3585cc3 100644 --- a/packages/models/src/import-export/peertube-export-format/video-export.model.ts +++ b/packages/models/src/import-export/peertube-export-format/video-export.model.ts @@ -1,3 +1,4 @@ +import { PlayerThemeVideoSetting } from '../../player/player-theme.type.js' import { LiveVideoLatencyModeType, VideoCommentPolicyType, @@ -108,6 +109,10 @@ export interface VideoExportJSON { metadata: VideoFileMetadata } + playerSettings?: { + theme: PlayerThemeVideoSetting + } + archiveFiles: { videoFile: string | null thumbnail: string | null diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 8a1ec8dcb..0cee85b19 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -11,6 +11,7 @@ export * from './metrics/index.js' export * from './moderation/index.js' export * from './nodeinfo/index.js' export * from './overviews/index.js' +export * from './player/index.js' export * from './plugins/index.js' export * from './redundancy/index.js' export * from './runners/index.js' diff --git a/packages/models/src/player/index.ts b/packages/models/src/player/index.ts new file mode 100644 index 000000000..98537c80e --- /dev/null +++ b/packages/models/src/player/index.ts @@ -0,0 +1,2 @@ +export * from './player-mode.type.js' +export * from './player-theme.type.js' diff --git a/packages/models/src/player/player-mode.type.ts b/packages/models/src/player/player-mode.type.ts new file mode 100644 index 000000000..577c828d8 --- /dev/null +++ b/packages/models/src/player/player-mode.type.ts @@ -0,0 +1 @@ +export type PlayerMode = 'web-video' | 'p2p-media-loader' diff --git a/packages/models/src/player/player-theme.type.ts b/packages/models/src/player/player-theme.type.ts new file mode 100644 index 000000000..3a383f7db --- /dev/null +++ b/packages/models/src/player/player-theme.type.ts @@ -0,0 +1,3 @@ +export type PlayerTheme = 'galaxy' | 'lucide' +export type PlayerThemeChannelSetting = 'instance-default' | PlayerTheme +export type PlayerThemeVideoSetting = 'channel-default' | PlayerThemeChannelSetting diff --git a/packages/models/src/server/custom-config.model.ts b/packages/models/src/server/custom-config.model.ts index 1d05cb758..1d4696641 100644 --- a/packages/models/src/server/custom-config.model.ts +++ b/packages/models/src/server/custom-config.model.ts @@ -1,3 +1,4 @@ +import { PlayerTheme } from '../player/player-theme.type.js' import { VideoCommentPolicyType, VideoPrivacyType } from '../videos/index.js' import { NSFWPolicyType } from '../videos/nsfw-policy.type.js' import { BroadcastMessageLevel } from './broadcast-message-level.type.js' @@ -370,6 +371,7 @@ export interface CustomConfig { } player: { + theme: PlayerTheme autoPlay: boolean } } diff --git a/packages/models/src/server/server-config.model.ts b/packages/models/src/server/server-config.model.ts index 4bf483b25..0a3478c9c 100644 --- a/packages/models/src/server/server-config.model.ts +++ b/packages/models/src/server/server-config.model.ts @@ -1,4 +1,4 @@ -import { ActorImage, LogoType, VideoCommentPolicyType } from '../index.js' +import { ActorImage, LogoType, PlayerTheme, VideoCommentPolicyType } from '../index.js' import { ClientScriptJSON } from '../plugins/plugin-package-json.model.js' import { NSFWPolicyType } from '../videos/nsfw-policy.type.js' import { VideoPrivacyType } from '../videos/video-privacy.enum.js' @@ -103,6 +103,7 @@ export interface ServerConfig { } player: { + theme: PlayerTheme autoPlay: boolean } } diff --git a/packages/models/src/videos/index.ts b/packages/models/src/videos/index.ts index dd581d797..e71d90bd0 100644 --- a/packages/models/src/videos/index.ts +++ b/packages/models/src/videos/index.ts @@ -16,6 +16,8 @@ export * from './chapter/index.js' export * from './nsfw-flag.enum.js' export * from './nsfw-policy.type.js' +export * from './player-settings.js' +export * from './player-settings-update.js' export * from './storyboard.model.js' export * from './thumbnail.type.js' diff --git a/packages/models/src/videos/player-settings-update.ts b/packages/models/src/videos/player-settings-update.ts new file mode 100644 index 000000000..96128f412 --- /dev/null +++ b/packages/models/src/videos/player-settings-update.ts @@ -0,0 +1,9 @@ +import { PlayerThemeChannelSetting, PlayerThemeVideoSetting } from '../player/player-theme.type.js' + +export interface PlayerVideoSettingsUpdate { + theme: PlayerThemeVideoSetting +} + +export interface PlayerChannelSettingsUpdate { + theme: PlayerThemeChannelSetting +} diff --git a/packages/models/src/videos/player-settings.ts b/packages/models/src/videos/player-settings.ts new file mode 100644 index 000000000..41b7b291b --- /dev/null +++ b/packages/models/src/videos/player-settings.ts @@ -0,0 +1,9 @@ +import { PlayerThemeChannelSetting, PlayerThemeVideoSetting } from '../player/player-theme.type.js' + +export interface PlayerVideoSettings { + theme: PlayerThemeVideoSetting +} + +export interface PlayerChannelSettings { + theme: PlayerThemeChannelSetting +} diff --git a/packages/server-commands/src/server/server.ts b/packages/server-commands/src/server/server.ts index a8ebdf682..c3dec3490 100644 --- a/packages/server-commands/src/server/server.ts +++ b/packages/server-commands/src/server/server.ts @@ -3,6 +3,7 @@ import { Video, VideoChannel, VideoChannelSync, VideoCreateResult, VideoDetails import { parallelTests, root } from '@peertube/peertube-node-utils' import { ChildProcess, fork } from 'child_process' import { copy } from 'fs-extra/esm' +import merge from 'lodash-es/merge.js' import { join } from 'path' import { BulkCommand } from '../bulk/index.js' import { CLICommand } from '../cli/index.js' @@ -36,6 +37,7 @@ import { CommentsCommand, HistoryCommand, LiveCommand, + PlayerSettingsCommand, PlaylistsCommand, ServicesCommand, StoryboardCommand, @@ -58,7 +60,6 @@ import { PluginsCommand } from './plugins-command.js' import { RedundancyCommand } from './redundancy-command.js' import { ServersCommand } from './servers-command.js' import { StatsCommand } from './stats-command.js' -import merge from 'lodash-es/merge.js' export type RunServerOptions = { autoEnableImportProxy?: boolean @@ -154,6 +155,7 @@ export class PeerTubeServer { videoToken?: VideoTokenCommand registrations?: RegistrationsCommand videoPasswords?: VideoPasswordsCommand + playerSettings?: PlayerSettingsCommand storyboard?: StoryboardCommand chapters?: ChaptersCommand @@ -460,6 +462,8 @@ export class PeerTubeServer { this.videoToken = new VideoTokenCommand(this) this.registrations = new RegistrationsCommand(this) + this.playerSettings = new PlayerSettingsCommand(this) + this.storyboard = new StoryboardCommand(this) this.chapters = new ChaptersCommand(this) diff --git a/packages/server-commands/src/videos/index.ts b/packages/server-commands/src/videos/index.ts index 7835ba242..d704b0446 100644 --- a/packages/server-commands/src/videos/index.ts +++ b/packages/server-commands/src/videos/index.ts @@ -6,6 +6,7 @@ export * from './channels-command.js' export * from './chapters-command.js' export * from './channel-syncs-command.js' export * from './comments-command.js' +export * from './player-settings-command.js' export * from './history-command.js' export * from './video-imports-command.js' export * from './live-command.js' diff --git a/packages/server-commands/src/videos/player-settings-command.ts b/packages/server-commands/src/videos/player-settings-command.ts new file mode 100644 index 000000000..68b1515f3 --- /dev/null +++ b/packages/server-commands/src/videos/player-settings-command.ts @@ -0,0 +1,89 @@ +import { pick } from '@peertube/peertube-core-utils' +import { HttpStatusCode, PlayerChannelSettings, PlayerVideoSettings, PlayerVideoSettingsUpdate } from '@peertube/peertube-models' +import { unwrapBody } from '../requests/requests.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class PlayerSettingsCommand extends AbstractCommand { + getForVideo ( + options: OverrideCommandOptions & { + videoId: number | string + raw?: boolean + videoPassword?: string + } + ) { + const path = '/api/v1/player-settings/videos/' + options.videoId + + return this.get({ ...options, path }) + } + + getForChannel ( + options: OverrideCommandOptions & { + channelHandle: string + raw?: boolean + } + ) { + const path = '/api/v1/player-settings/video-channels/' + options.channelHandle + + return this.get({ ...options, path }) + } + + private get ( + options: OverrideCommandOptions & { + path: string + videoPassword?: string + + raw?: boolean + } + ) { + const headers = this.buildVideoPasswordHeader(options.videoPassword) + + return this.getRequestBody({ + ...options, + + headers, + + query: options.raw + ? { raw: options.raw } + : undefined, + + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + updateForVideo ( + options: OverrideCommandOptions & PlayerVideoSettingsUpdate & { + videoId: number | string + } + ) { + const path = '/api/v1/player-settings/videos/' + options.videoId + + return this.update({ ...options, path }) + } + + updateForChannel ( + options: OverrideCommandOptions & PlayerVideoSettingsUpdate & { + channelHandle: string + } + ) { + const path = '/api/v1/player-settings/video-channels/' + options.channelHandle + + return this.update({ ...options, path }) + } + + private update ( + options: OverrideCommandOptions & PlayerVideoSettingsUpdate & { + path: string + } + ) { + return unwrapBody(this.putBodyRequest({ + ...options, + + fields: pick(options, [ 'theme' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } +} diff --git a/packages/tests/src/api/check-params/index.ts b/packages/tests/src/api/check-params/index.ts index 5b9db62a9..74e687f14 100644 --- a/packages/tests/src/api/check-params/index.ts +++ b/packages/tests/src/api/check-params/index.ts @@ -15,6 +15,7 @@ import './live.js' import './logs.js' import './metrics.js' import './my-user.js' +import './player-settings.js' import './plugins.js' import './redundancy.js' import './registrations.js' diff --git a/packages/tests/src/api/check-params/player-settings.ts b/packages/tests/src/api/check-params/player-settings.ts new file mode 100644 index 000000000..399f02b6d --- /dev/null +++ b/packages/tests/src/api/check-params/player-settings.ts @@ -0,0 +1,138 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { HttpStatusCode, PlayerSettings, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models' +import { + PeerTubeServer, + cleanupTests, + createSingleServer, + setAccessTokensToServers, + setDefaultVideoChannel +} from '@peertube/peertube-server-commands' + +describe('Test player settings API validator', function () { + let server: PeerTubeServer + let ownerAccessToken: string + let userAccessToken: string + let video: VideoCreateResult + let privateVideo: VideoCreateResult + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(60000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + ownerAccessToken = await server.users.generateUserAndToken('owner') + userAccessToken = await server.users.generateUserAndToken('user1') + + video = await server.videos.upload({ token: ownerAccessToken }) + privateVideo = await server.videos.upload({ token: ownerAccessToken, attributes: { privacy: VideoPrivacy.PRIVATE } }) + }) + + it('Should fail to get video player settings if the video does not exist', async function () { + await server.playerSettings.getForVideo({ videoId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should check video privacy before getting player settings of a video', async function () { + const videoId = privateVideo.uuid + + await server.playerSettings.getForVideo({ token: server.accessToken, videoId }) + await server.playerSettings.getForVideo({ token: ownerAccessToken, videoId }) + await server.playerSettings.getForVideo({ token: userAccessToken, videoId, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await server.playerSettings.getForVideo({ token: null, videoId, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail to get channel player settings if the channel does not exist', async function () { + await server.playerSettings.getForChannel({ + token: ownerAccessToken, + channelHandle: 'unknown', + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should only allow to get raw player settings of a channel by owner/moderators', async function () { + const channelHandle = 'owner_channel' + const videoId = video.uuid + + await server.playerSettings.getForChannel({ token: server.accessToken, channelHandle, raw: true }) + await server.playerSettings.getForChannel({ token: ownerAccessToken, channelHandle, raw: true }) + await server.playerSettings.getForChannel({ + token: userAccessToken, + channelHandle, + expectedStatus: HttpStatusCode.FORBIDDEN_403, + raw: true + }) + await server.playerSettings.getForChannel({ token: null, channelHandle, expectedStatus: HttpStatusCode.UNAUTHORIZED_401, raw: true }) + + await server.playerSettings.getForVideo({ token: server.accessToken, videoId, raw: true }) + await server.playerSettings.getForVideo({ token: ownerAccessToken, videoId, raw: true }) + await server.playerSettings.getForVideo({ token: userAccessToken, videoId, expectedStatus: HttpStatusCode.FORBIDDEN_403, raw: true }) + await server.playerSettings.getForVideo({ token: null, videoId, expectedStatus: HttpStatusCode.UNAUTHORIZED_401, raw: true }) + }) + + it('Should only allow to update player settings of a video by owner/moderators', async function () { + const videoId = video.uuid + const playerSettings: PlayerSettings = { theme: 'lucide' } + + await server.playerSettings.updateForVideo({ token: server.accessToken, videoId, ...playerSettings }) + await server.playerSettings.updateForVideo({ token: ownerAccessToken, videoId, ...playerSettings }) + + await server.playerSettings.updateForVideo({ + token: userAccessToken, + videoId, + ...playerSettings, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + + await server.playerSettings.updateForVideo({ token: null, videoId, ...playerSettings, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should only allow to update player settings of a channel by owner/moderators', async function () { + const channelHandle = 'owner_channel' + const playerSettings: PlayerSettings = { theme: 'lucide' } + + await server.playerSettings.updateForChannel({ token: server.accessToken, channelHandle, ...playerSettings }) + await server.playerSettings.updateForChannel({ token: ownerAccessToken, channelHandle, ...playerSettings }) + + await server.playerSettings.updateForChannel({ + token: userAccessToken, + channelHandle, + ...playerSettings, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + + await server.playerSettings.updateForChannel({ + token: null, + channelHandle, + ...playerSettings, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail to update player settings with invalid settings', async function () { + const videoId = video.uuid + const channelHandle = 'owner_channel' + + { + const playerSettings = { theme: 'invalid' } as any + + await server.playerSettings.updateForVideo({ videoId, ...playerSettings, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await server.playerSettings.updateForChannel({ channelHandle, ...playerSettings, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + } + + { + const playerSettings = { theme: 'channel-default' } as any + + await server.playerSettings.updateForVideo({ videoId, ...playerSettings }) + await server.playerSettings.updateForChannel({ channelHandle, ...playerSettings, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + } + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/video-passwords.ts b/packages/tests/src/api/check-params/video-passwords.ts index e1108962c..d9829e0e1 100644 --- a/packages/tests/src/api/check-params/video-passwords.ts +++ b/packages/tests/src/api/check-params/video-passwords.ts @@ -48,7 +48,7 @@ describe('Test video passwords validator', function () { }, import: { videos: { - http:{ + http: { enabled: true } } @@ -132,7 +132,6 @@ describe('Test video passwords validator', function () { } function validateVideoPasswordList (mode: 'uploadLegacy' | 'uploadResumable' | 'import' | 'updateVideo' | 'updatePasswords' | 'live') { - it('Should fail with a password protected privacy without providing a password', async function () { await checkVideoPasswordOptions({ server, @@ -268,7 +267,17 @@ describe('Test video passwords validator', function () { token?: string videoPassword?: string expectedStatus: HttpStatusCodeType - mode: 'get' | 'getWithPassword' | 'getWithToken' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'rate' | 'token' + mode: + | 'get' + | 'getWithPassword' + | 'getWithToken' + | 'listCaptions' + | 'createThread' + | 'listThreads' + | 'replyThread' + | 'rate' + | 'token' + | 'getPlayerSettings' }) { const { server, token = null, videoPassword, expectedStatus, mode } = options @@ -351,6 +360,15 @@ describe('Test video passwords validator', function () { }) } + if (mode === 'getPlayerSettings') { + return server.playerSettings.getForVideo({ + videoId: video.id, + token, + expectedStatus, + videoPassword + }) + } + if (mode === 'token') { return server.videoToken.create({ videoId: video.id, @@ -380,9 +398,12 @@ describe('Test video passwords validator', function () { expect(error.status).to.equal(HttpStatusCode.FORBIDDEN_403) } - function validateVideoAccess (mode: 'get' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'rate' | 'token') { + function validateVideoAccess ( + mode: 'get' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'getPlayerSettings' | 'rate' | 'token' + ) { const requiresUserAuth = [ 'createThread', 'replyThread', 'rate' ].includes(mode) let tokens: string[] + if (!requiresUserAuth) { it('Should fail without providing a password for an unlogged user', async function () { const body = await checkVideoAccessOptions({ server, expectedStatus: HttpStatusCode.FORBIDDEN_403, mode }) @@ -482,7 +503,6 @@ describe('Test video passwords validator', function () { } describe('When accessing password protected video', function () { - describe('For getting a password protected video', function () { validateVideoAccess('get') }) @@ -507,6 +527,10 @@ describe('Test video passwords validator', function () { validateVideoAccess('listCaptions') }) + describe('For getting player settings', function () { + validateVideoAccess('getPlayerSettings') + }) + describe('For creating video file token', function () { validateVideoAccess('token') }) diff --git a/packages/tests/src/api/server/config-defaults.ts b/packages/tests/src/api/server/config-defaults.ts index 419a7a11f..03f8bf276 100644 --- a/packages/tests/src/api/server/config-defaults.ts +++ b/packages/tests/src/api/server/config-defaults.ts @@ -26,7 +26,6 @@ describe('Test config defaults', function () { }) describe('Default publish values', function () { - before(async function () { const overrideConfig = { defaults: { @@ -123,9 +122,7 @@ describe('Test config defaults', function () { }) describe('Default P2P values', function () { - describe('Webapp default value', function () { - before(async function () { const overrideConfig = { defaults: { @@ -167,7 +164,6 @@ describe('Test config defaults', function () { }) describe('Embed default value', function () { - before(async function () { const overrideConfig = { defaults: { @@ -213,11 +209,11 @@ describe('Test config defaults', function () { }) describe('Default player value', function () { - before(async function () { const overrideConfig = { defaults: { player: { + theme: 'lucide', auto_play: false } }, @@ -230,9 +226,10 @@ describe('Test config defaults', function () { await server.run(overrideConfig) }) - it('Should have appropriate autoplay config', async function () { + it('Should have appropriate player config', async function () { const config = await server.config.getConfig() + expect(config.defaults.player.theme).to.equal('lucide') expect(config.defaults.player.autoPlay).to.be.false }) @@ -255,7 +252,6 @@ describe('Test config defaults', function () { }) describe('Default user attributes', function () { - it('Should create a user and register a user with the default config', async function () { await server.config.updateExistingConfig({ newConfig: { @@ -265,7 +261,7 @@ describe('Test config defaults', function () { enabled: true } }, - videoQuota : -1, + videoQuota: -1, videoQuotaDaily: -1 }, signup: { @@ -305,7 +301,7 @@ describe('Test config defaults', function () { enabled: false } }, - videoQuota : 5242881, + videoQuota: 5242881, videoQuotaDaily: 318742 }, signup: { @@ -330,7 +326,6 @@ describe('Test config defaults', function () { expect(user.videoQuotaDaily).to.equal(318742) } }) - }) after(async function () { diff --git a/packages/tests/src/api/server/config.ts b/packages/tests/src/api/server/config.ts index ea2af9c6e..47d27c734 100644 --- a/packages/tests/src/api/server/config.ts +++ b/packages/tests/src/api/server/config.ts @@ -159,6 +159,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) { expect(data.defaults.publish.privacy).to.equal(VideoPrivacy.PUBLIC) expect(data.defaults.p2p.embed.enabled).to.be.true expect(data.defaults.p2p.webapp.enabled).to.be.true + expect(data.defaults.player.theme).to.equal('galaxy') expect(data.defaults.player.autoPlay).to.be.true expect(data.email.body.signature).to.equal('') @@ -473,7 +474,8 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig { } }, player: { - autoPlay: false + autoPlay: false, + theme: 'lucide' } }, email: { diff --git a/packages/tests/src/api/users/user-export.ts b/packages/tests/src/api/users/user-export.ts index 769738328..81d8c7da2 100644 --- a/packages/tests/src/api/users/user-export.ts +++ b/packages/tests/src/api/users/user-export.ts @@ -455,6 +455,7 @@ function runTest (withObjectStorage: boolean) { expect(secondaryChannel.displayName).to.equal('noah display name') expect(secondaryChannel.description).to.equal('noah description') expect(secondaryChannel.support).to.equal('noah support') + expect(secondaryChannel.playerSettings.theme).to.equal('galaxy') expect(secondaryChannel.avatars).to.have.lengthOf(4) expect(secondaryChannel.banners).to.have.lengthOf(2) @@ -554,6 +555,8 @@ function runTest (withObjectStorage: boolean) { expect(publicVideo.source.metadata?.streams).to.exist expect(publicVideo.source.resolution).to.equal(720) expect(publicVideo.source.size).to.equal(218910) + + expect(publicVideo.playerSettings.theme).to.equal('lucide') } { diff --git a/packages/tests/src/api/users/user-import.ts b/packages/tests/src/api/users/user-import.ts index 3ef6e82d0..1445f0519 100644 --- a/packages/tests/src/api/users/user-import.ts +++ b/packages/tests/src/api/users/user-import.ts @@ -193,11 +193,25 @@ function runTest (withObjectStorage: boolean) { expect(importedMain.avatars).to.have.lengthOf(0) expect(importedMain.banners).to.have.lengthOf(0) + const playerSettingMain = await remoteServer.playerSettings.getForChannel({ + channelHandle: 'noah_remote_channel', + token: remoteServer.accessToken, + raw: true + }) + expect(playerSettingMain.theme).to.equal('instance-default') + const importedSecond = await remoteServer.channels.get({ token: remoteNoahToken, channelName: 'noah_second_channel' }) expect(importedSecond.displayName).to.equal('noah display name') expect(importedSecond.description).to.equal('noah description') expect(importedSecond.support).to.equal('noah support') + const playerSettingSecond = await remoteServer.playerSettings.getForChannel({ + channelHandle: 'noah_second_channel', + token: remoteServer.accessToken, + raw: true + }) + expect(playerSettingSecond.theme).to.equal('galaxy') + for (const banner of importedSecond.banners) { await testImage({ url: banner.fileUrl, name: `banner-user-import-resized-${banner.width}.jpg` }) } @@ -376,6 +390,13 @@ function runTest (withObjectStorage: boolean) { expect(publicVideo).to.exist expect(publicVideo.privacy.id).to.equal(VideoPrivacy.PUBLIC) + const playerSetting = await remoteServer.playerSettings.getForVideo({ + videoId: publicVideo.uuid, + token: remoteServer.accessToken, + raw: true + }) + expect(playerSetting.theme).to.equal('lucide') + // Federated await server.videos.get({ id: publicVideo.uuid }) } @@ -385,6 +406,13 @@ function runTest (withObjectStorage: boolean) { expect(passwordVideo).to.exist expect(passwordVideo.privacy.id).to.equal(VideoPrivacy.PASSWORD_PROTECTED) + const playerSetting = await remoteServer.playerSettings.getForVideo({ + videoId: passwordVideo.uuid, + token: remoteServer.accessToken, + raw: true + }) + expect(playerSetting.theme).to.equal('channel-default') + const { data: passwords } = await remoteServer.videoPasswords.list({ videoId: passwordVideo.uuid }) expect(passwords.map(p => p.password).sort()).to.deep.equal([ 'password1', 'password2' ]) diff --git a/packages/tests/src/api/videos/index.ts b/packages/tests/src/api/videos/index.ts index 6e1348548..4b542619f 100644 --- a/packages/tests/src/api/videos/index.ts +++ b/packages/tests/src/api/videos/index.ts @@ -1,6 +1,7 @@ import './channel-import-videos.js' import './generate-download.js' import './multiple-servers.js' +import './player-settings.js' import './resumable-upload.js' import './single-server.js' import './video-captions.js' diff --git a/packages/tests/src/api/videos/player-settings.ts b/packages/tests/src/api/videos/player-settings.ts new file mode 100644 index 000000000..8dbe56de7 --- /dev/null +++ b/packages/tests/src/api/videos/player-settings.ts @@ -0,0 +1,283 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { + PeerTubeServer, + cleanupTests, + createMultipleServers, + doubleFollow, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' +import { expect } from 'chai' +import { VideoCreateResult } from '../../../../models/src/videos/video-create-result.model.js' + +describe('Test player settings', function () { + let servers: PeerTubeServer[] + let video: VideoCreateResult + let otherVideo: VideoCreateResult + let otherVideoAndChannel: VideoCreateResult + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(60000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + video = await servers[0].videos.upload() + otherVideo = await servers[0].videos.upload() + + const otherChannel = await servers[0].channels.create({ attributes: { name: 'other_channel' } }) + otherVideoAndChannel = await servers[0].videos.upload({ attributes: { channelId: otherChannel.id } }) + + await doubleFollow(servers[0], servers[1]) + }) + + async function check (options: { + server: PeerTubeServer + videoId: number | string + channelHandle: string + expectedVideo: string + expectedChannel: string + expectedRawVideo?: string + expectedRawChannel?: string + }) { + const { server, expectedRawVideo, expectedRawChannel, expectedVideo, expectedChannel } = options + + // Raw mode + { + if (expectedRawVideo) { + const { theme } = await server.playerSettings.getForVideo({ + token: server.accessToken, + videoId: options.videoId, + raw: true + }) + expect(theme).to.equal(expectedRawVideo) + } + + if (expectedRawChannel) { + const { theme } = await server.playerSettings.getForChannel({ + token: server.accessToken, + channelHandle: options.channelHandle, + raw: true + }) + expect(theme).to.equal(expectedRawChannel) + } + } + + // Interpreted settings mode + { + { + const { theme } = await server.playerSettings.getForVideo({ videoId: options.videoId }) + expect(theme).to.equal(expectedVideo) + } + + { + const { theme } = await server.playerSettings.getForChannel({ channelHandle: options.channelHandle, raw: false }) + expect(theme).to.equal(expectedChannel) + } + } + } + + it('Should return default player settings for the instance', async function () { + await check({ + server: servers[0], + videoId: video.uuid, + channelHandle: 'root_channel', + expectedRawVideo: 'channel-default', + expectedRawChannel: 'instance-default', + expectedVideo: 'galaxy', + expectedChannel: 'galaxy' + }) + + await check({ + server: servers[1], + videoId: video.uuid, + channelHandle: 'root_channel@' + servers[0].host, + expectedVideo: 'galaxy', + expectedChannel: 'galaxy' + }) + }) + + it('Should update instance settings and return the updated settings', async function () { + await servers[0].config.updateExistingConfig({ newConfig: { defaults: { player: { theme: 'lucide' } } } }) + + await check({ + server: servers[0], + videoId: video.uuid, + channelHandle: 'root_channel', + expectedRawVideo: 'channel-default', + expectedRawChannel: 'instance-default', + expectedVideo: 'lucide', + expectedChannel: 'lucide' + }) + + // Instance 2 keeps its own instance default + await check({ + server: servers[1], + videoId: video.uuid, + channelHandle: 'root_channel@' + servers[0].host, + expectedVideo: 'galaxy', + expectedChannel: 'galaxy' + }) + + // Update instance 2 default theme to observe changes + await servers[1].config.updateExistingConfig({ newConfig: { defaults: { player: { theme: 'lucide' } } } }) + + await check({ + server: servers[1], + videoId: video.uuid, + channelHandle: 'root_channel@' + servers[0].host, + expectedVideo: 'lucide', + expectedChannel: 'lucide' + }) + }) + + it('Should update player settings of the channel and return the updated settings', async function () { + const { theme } = await servers[0].playerSettings.updateForChannel({ channelHandle: 'root_channel', theme: 'galaxy' }) + expect(theme).to.equal('galaxy') + + await waitJobs(servers) + + await check({ + server: servers[0], + videoId: video.uuid, + channelHandle: 'root_channel', + expectedRawVideo: 'channel-default', + expectedRawChannel: 'galaxy', + expectedVideo: 'galaxy', + expectedChannel: 'galaxy' + }) + + await check({ + server: servers[1], + videoId: video.uuid, + channelHandle: 'root_channel@' + servers[0].host, + expectedVideo: 'galaxy', + expectedChannel: 'galaxy' + }) + }) + + it('Should update player settings of the video and return the updated settings', async function () { + const { theme } = await servers[0].playerSettings.updateForVideo({ videoId: video.id, theme: 'lucide' }) + expect(theme).to.equal('lucide') + + await waitJobs(servers) + + await check({ + server: servers[0], + videoId: video.uuid, + channelHandle: 'root_channel', + expectedRawVideo: 'lucide', + expectedRawChannel: 'galaxy', + expectedVideo: 'lucide', + expectedChannel: 'galaxy' + }) + + await check({ + server: servers[1], + videoId: video.uuid, + channelHandle: 'root_channel@' + servers[0].host, + expectedVideo: 'lucide', + expectedChannel: 'galaxy' + }) + }) + + it('Should choose the default instance player theme', async function () { + const { theme } = await servers[0].playerSettings.updateForVideo({ videoId: video.id, theme: 'instance-default' }) + expect(theme).to.equal('instance-default') + + await waitJobs(servers) + + await check({ + server: servers[0], + videoId: video.uuid, + channelHandle: 'root_channel', + expectedRawVideo: 'instance-default', + expectedRawChannel: 'galaxy', + expectedVideo: 'lucide', + expectedChannel: 'galaxy' + }) + + await check({ + server: servers[1], + videoId: video.uuid, + channelHandle: 'root_channel@' + servers[0].host, + expectedVideo: 'lucide', + expectedChannel: 'galaxy' + }) + }) + + it('Should choose the default channel player theme', async function () { + const { theme } = await servers[0].playerSettings.updateForVideo({ videoId: video.id, theme: 'channel-default' }) + expect(theme).to.equal('channel-default') + + await waitJobs(servers) + + await check({ + server: servers[0], + videoId: video.uuid, + channelHandle: 'root_channel', + expectedRawVideo: 'channel-default', + expectedRawChannel: 'galaxy', + expectedVideo: 'galaxy', + expectedChannel: 'galaxy' + }) + + await check({ + server: servers[1], + videoId: video.uuid, + channelHandle: 'root_channel@' + servers[0].host, + expectedVideo: 'galaxy', + expectedChannel: 'galaxy' + }) + }) + + it('Should keep default settings for the other video', async function () { + await check({ + server: servers[0], + videoId: otherVideo.uuid, + channelHandle: 'root_channel', + expectedRawVideo: 'channel-default', + expectedRawChannel: 'galaxy', + expectedVideo: 'galaxy', + expectedChannel: 'galaxy' + }) + + await check({ + server: servers[1], + videoId: otherVideo.uuid, + channelHandle: 'root_channel@' + servers[0].host, + expectedVideo: 'galaxy', + expectedChannel: 'galaxy' + }) + }) + + it('Should keep default settings for the other channel', async function () { + await check({ + server: servers[0], + videoId: otherVideoAndChannel.uuid, + channelHandle: 'other_channel', + expectedRawVideo: 'channel-default', + expectedRawChannel: 'instance-default', + expectedVideo: 'lucide', + expectedChannel: 'lucide' + }) + + await check({ + server: servers[1], + videoId: otherVideoAndChannel.uuid, + channelHandle: 'other_channel@' + servers[0].host, + expectedVideo: 'lucide', + expectedChannel: 'lucide' + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/shared/import-export.ts b/packages/tests/src/shared/import-export.ts index 60a4d1344..c6ab6e97a 100644 --- a/packages/tests/src/shared/import-export.ts +++ b/packages/tests/src/shared/import-export.ts @@ -211,13 +211,17 @@ export async function prepareImportExportTests (options: { fixture: 'avatar.png', type: 'avatar' }) + await server.playerSettings.updateForChannel({ channelHandle: 'noah_second_channel', theme: 'galaxy' }) // Videos const externalVideo = await remoteServer.videos.quickUpload({ name: 'external video', privacy: VideoPrivacy.PUBLIC }) // eslint-disable-next-line max-len const noahPrivateVideo = await server.videos.quickUpload({ name: 'noah private video', token: noahToken, privacy: VideoPrivacy.PRIVATE }) + const noahVideo = await server.videos.quickUpload({ name: 'noah public video', token: noahToken, privacy: VideoPrivacy.PUBLIC }) + await server.playerSettings.updateForVideo({ videoId: noahVideo.uuid, theme: 'lucide' }) + // eslint-disable-next-line max-len const noahVideo2 = await server.videos.upload({ token: noahToken, diff --git a/server/core/controllers/activitypub/client.ts b/server/core/controllers/activitypub/client.ts index a83c0a911..3391f96ed 100644 --- a/server/core/controllers/activitypub/client.ts +++ b/server/core/controllers/activitypub/client.ts @@ -4,6 +4,7 @@ import { getContextFilter } from '@server/lib/activitypub/context.js' import { buildChaptersAPHasPart } from '@server/lib/activitypub/video-chapters.js' import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js' import { getServerActor } from '@server/models/application/application.js' +import { PlayerSettingModel } from '@server/models/video/player-setting.js' import { VideoChapterModel } from '@server/models/video/video-chapter.js' import { MAccountId, MActorId, MChannelId, MVideoId } from '@server/types/models/index.js' import cors from 'cors' @@ -177,6 +178,13 @@ activityPubClientRouter.get( asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')), asyncMiddleware(videoDislikesController) ) +activityPubClientRouter.get( + '/videos/watch/:id/player-settings', + executeIfActivityPub, + activityPubRateLimiter, + asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')), + asyncMiddleware(videoPlayerSettingsController) +) // --------------------------------------------------------------------------- @@ -228,6 +236,13 @@ activityPubClientRouter.get( asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: false })), asyncMiddleware(videoChannelPlaylistsController) ) +activityPubClientRouter.get( + '/video-channels/:handle/player-settings', + executeIfActivityPub, + activityPubRateLimiter, + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: false })), + asyncMiddleware(channelPlayerSettingsController) +) activityPubClientRouter.get( '/redundancy/streaming-playlists/:streamingPlaylistType/:videoId', @@ -399,6 +414,30 @@ async function videoCommentsController (req: express.Request, res: express.Respo return activityPubResponse(activityPubContextify(json, 'Collection', getContextFilter()), res) } +// --------------------------------------------------------------------------- + +async function videoPlayerSettingsController (req: express.Request, res: express.Response) { + const video = res.locals.onlyVideo + + if (redirectIfNotOwned(video.url, res)) return + + const settings = await PlayerSettingModel.loadByVideoId(video.id) + const json = PlayerSettingModel.formatAPPlayerSetting({ channel: undefined, video, settings }) + + return activityPubResponse(activityPubContextify(json, 'PlayerSettings', getContextFilter()), res) +} + +async function channelPlayerSettingsController (req: express.Request, res: express.Response) { + const channel = res.locals.videoChannel + + const settings = await PlayerSettingModel.loadByChannelId(channel.id) + const json = PlayerSettingModel.formatAPPlayerSetting({ channel, video: undefined, settings }) + + return activityPubResponse(activityPubContextify(json, 'PlayerSettings', getContextFilter()), res) +} + +// --------------------------------------------------------------------------- + async function videoChannelController (req: express.Request, res: express.Response) { const videoChannel = res.locals.videoChannel diff --git a/server/core/controllers/api/config.ts b/server/core/controllers/api/config.ts index 761cd8ca0..0d8df4451 100644 --- a/server/core/controllers/api/config.ts +++ b/server/core/controllers/api/config.ts @@ -605,6 +605,7 @@ function customConfig (): CustomConfig { } }, player: { + theme: CONFIG.DEFAULTS.PLAYER.THEME, autoPlay: CONFIG.DEFAULTS.PLAYER.AUTO_PLAY } }, diff --git a/server/core/controllers/api/index.ts b/server/core/controllers/api/index.ts index 437151f27..a0a35b951 100644 --- a/server/core/controllers/api/index.ts +++ b/server/core/controllers/api/index.ts @@ -14,6 +14,7 @@ import { jobsRouter } from './jobs.js' import { metricsRouter } from './metrics.js' import { oauthClientsRouter } from './oauth-clients.js' import { overviewsRouter } from './overviews.js' +import { playerSettingsRouter } from './player-settings.js' import { pluginRouter } from './plugins.js' import { runnersRouter } from './runners/index.js' import { searchRouter } from './search/index.js' @@ -48,6 +49,7 @@ apiRouter.use('/jobs', jobsRouter) apiRouter.use('/metrics', metricsRouter) apiRouter.use('/search', searchRouter) apiRouter.use('/overviews', overviewsRouter) +apiRouter.use('/player-settings', playerSettingsRouter) apiRouter.use('/plugins', pluginRouter) apiRouter.use('/custom-pages', customPageRouter) apiRouter.use('/blocklist', blocklistRouter) diff --git a/server/core/controllers/api/player-settings.ts b/server/core/controllers/api/player-settings.ts new file mode 100644 index 000000000..1475dfef2 --- /dev/null +++ b/server/core/controllers/api/player-settings.ts @@ -0,0 +1,112 @@ +import { PlayerChannelSettingsUpdate, PlayerVideoSettingsUpdate } from '@peertube/peertube-models' +import { upsertPlayerSettings } from '@server/lib/player-settings.js' +import { + getChannelPlayerSettingsValidator, + getVideoPlayerSettingsValidator, + updatePlayerSettingsValidatorFactory, + updateVideoPlayerSettingsValidator +} from '@server/middlewares/validators/player-settings.js' +import { PlayerSettingModel } from '@server/models/video/player-setting.js' +import express from 'express' +import { + apiRateLimiter, + asyncMiddleware, + authenticate, + optionalAuthenticate, + videoChannelsHandleValidatorFactory +} from '../../middlewares/index.js' +import { sendUpdateChannelPlayerSettings, sendUpdateVideoPlayerSettings } from '@server/lib/activitypub/send/send-update.js' + +const playerSettingsRouter = express.Router() + +playerSettingsRouter.use(apiRateLimiter) + +playerSettingsRouter.get( + '/videos/:videoId', + optionalAuthenticate, + asyncMiddleware(getVideoPlayerSettingsValidator), + asyncMiddleware(getVideoPlayerSettings) +) + +playerSettingsRouter.put( + '/videos/:videoId', + authenticate, + asyncMiddleware(updateVideoPlayerSettingsValidator), + updatePlayerSettingsValidatorFactory('video'), + asyncMiddleware(updateVideoPlayerSettings) +) + +playerSettingsRouter.get( + '/video-channels/:handle', + optionalAuthenticate, + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: false, checkManage: false })), + getChannelPlayerSettingsValidator, + asyncMiddleware(getChannelPlayerSettings) +) + +playerSettingsRouter.put( + '/video-channels/:handle', + authenticate, + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: true })), + updatePlayerSettingsValidatorFactory('channel'), + asyncMiddleware(updateChannelPlayerSettings) +) + +// --------------------------------------------------------------------------- + +export { + playerSettingsRouter +} + +// --------------------------------------------------------------------------- + +async function getVideoPlayerSettings (req: express.Request, res: express.Response) { + const video = res.locals.onlyVideo || res.locals.videoAll + + const { videoSetting, channelSetting } = await PlayerSettingModel.loadByVideoIdOrChannelId({ + channelId: video.channelId, + videoId: video.id + }) + + if (req.query.raw === true) { + return res.json(PlayerSettingModel.formatVideoPlayerRawSetting(videoSetting)) + } + + return res.json(PlayerSettingModel.formatVideoPlayerSetting({ videoSetting, channelSetting })) +} + +async function getChannelPlayerSettings (req: express.Request, res: express.Response) { + const channel = res.locals.videoChannel + + const channelSetting = await PlayerSettingModel.loadByChannelId(channel.id) + + if (req.query.raw === true) { + return res.json(PlayerSettingModel.formatChannelPlayerRawSetting(channelSetting)) + } + + return res.json(PlayerSettingModel.formatChannelPlayerSetting({ channelSetting })) +} + +// --------------------------------------------------------------------------- + +async function updateVideoPlayerSettings (req: express.Request, res: express.Response) { + const body: PlayerVideoSettingsUpdate = req.body + const video = res.locals.videoAll + + const setting = await upsertPlayerSettings({ settings: body, channel: undefined, video }) + + await sendUpdateVideoPlayerSettings(video, setting, undefined) + + return res.json(PlayerSettingModel.formatVideoPlayerRawSetting(setting)) +} + +async function updateChannelPlayerSettings (req: express.Request, res: express.Response) { + const body: PlayerChannelSettingsUpdate = req.body + const channel = res.locals.videoChannel + + const settings = await upsertPlayerSettings({ settings: body, channel, video: undefined }) + + await sendUpdateChannelPlayerSettings(channel, settings, undefined) + + return res.json(PlayerSettingModel.formatChannelPlayerRawSetting(settings)) +} diff --git a/server/core/helpers/activity-pub-utils.ts b/server/core/helpers/activity-pub-utils.ts index b17e6cdd9..efd02ec9b 100644 --- a/server/core/helpers/activity-pub-utils.ts +++ b/server/core/helpers/activity-pub-utils.ts @@ -2,10 +2,10 @@ import { arrayify } from '@peertube/peertube-core-utils' import { ContextType } from '@peertube/peertube-models' import { ACTIVITY_PUB, REMOTE_SCHEME } from '@server/initializers/constants.js' import { isArray } from './custom-validators/misc.js' +import { logger } from './logger.js' import { buildDigest } from './peertube-crypto.js' import type { signJsonLDObject } from './peertube-jsonld.js' import { doJSONRequest } from './requests.js' -import { logger } from './logger.js' export type ContextFilter = (arg: T) => Promise @@ -75,6 +75,8 @@ type ContextValue = { [id: string]: string | { '@type': string, '@id': string } const contextStore: { [id in ContextType]: (string | { [id: string]: string })[] } = { Video: buildContext({ + ...getPlayerSettingsTypeContext(), + Hashtag: 'as:Hashtag', category: 'sc:category', licence: 'sc:license', @@ -99,6 +101,7 @@ const contextStore: { [id in ContextType]: (string | { [id: string]: string })[] }, Infohash: 'pt:Infohash', + SensitiveTag: 'pt:SensitiveTag', tileWidth: { @@ -131,6 +134,8 @@ const contextStore: { [id in ContextType]: (string | { [id: string]: string })[] hasParts: 'sc:hasParts', + playerSettings: 'pt:playerSettings', + views: { '@type': 'sc:Number', '@id': 'pt:views' @@ -236,6 +241,8 @@ const contextStore: { [id in ContextType]: (string | { [id: string]: string })[] }), Actor: buildContext({ + ...getPlayerSettingsTypeContext(), + playlists: { '@id': 'pt:playlists', '@type': '@id' @@ -303,9 +310,24 @@ const contextStore: { [id in ContextType]: (string | { [id: string]: string })[] hasPart: 'sc:hasPart', endOffset: 'sc:endOffset', startOffset: 'sc:startOffset' + }), + + PlayerSettings: buildContext({ + ...getPlayerSettingsTypeContext(), + + theme: 'pt:theme' }) } +function getPlayerSettingsTypeContext () { + return { + PlayerSettings: { + '@type': '@id', + '@id': 'pt:PlayerSettings' + } + } +} + let allContext: (string | ContextValue)[] export function getAllContext () { if (allContext) return allContext diff --git a/server/core/helpers/custom-validators/activitypub/activity.ts b/server/core/helpers/custom-validators/activitypub/activity.ts index 83cb06062..4e9365d62 100644 --- a/server/core/helpers/custom-validators/activitypub/activity.ts +++ b/server/core/helpers/custom-validators/activitypub/activity.ts @@ -1,10 +1,11 @@ -import validator from 'validator' import { Activity, ActivityType } from '@peertube/peertube-models' +import validator from 'validator' import { isAbuseReasonValid } from '../abuses.js' import { exists } from '../misc.js' import { sanitizeAndCheckActorObject } from './actor.js' import { isCacheFileObjectValid } from './cache-file.js' import { isActivityPubUrlValid, isBaseActivityValid, isObjectValid } from './misc.js' +import { sanitizeAndCheckPlayerSettingsObject } from './player-settings.js' import { isPlaylistObjectValid } from './playlist.js' import { sanitizeAndCheckVideoCommentObject } from './video-comments.js' import { sanitizeAndCheckVideoTorrentObject } from './videos.js' @@ -28,7 +29,7 @@ function isActivity (activity: any) { // --------------------------------------------------------------------------- -const activityCheckers: { [ P in ActivityType ]: (activity: Activity) => boolean } = { +const activityCheckers: { [P in ActivityType]: (activity: Activity) => boolean } = { Create: isCreateActivityValid, Update: isUpdateActivityValid, Delete: isDeleteActivityValid, @@ -88,7 +89,6 @@ export function isCreateActivityValid (activity: any) { isFlagActivityValid(activity.object) || isPlaylistObjectValid(activity.object) || isWatchActionObjectValid(activity.object) || - isCacheFileObjectValid(activity.object) || sanitizeAndCheckVideoCommentObject(activity.object) || sanitizeAndCheckVideoTorrentObject(activity.object) @@ -101,7 +101,9 @@ export function isUpdateActivityValid (activity: any) { isCacheFileObjectValid(activity.object) || isPlaylistObjectValid(activity.object) || sanitizeAndCheckVideoTorrentObject(activity.object) || - sanitizeAndCheckActorObject(activity.object) + sanitizeAndCheckActorObject(activity.object) || + sanitizeAndCheckPlayerSettingsObject(activity.object, 'video') || + sanitizeAndCheckPlayerSettingsObject(activity.object, 'channel') ) } diff --git a/server/core/helpers/custom-validators/activitypub/player-settings.ts b/server/core/helpers/custom-validators/activitypub/player-settings.ts new file mode 100644 index 000000000..a6c4d31bb --- /dev/null +++ b/server/core/helpers/custom-validators/activitypub/player-settings.ts @@ -0,0 +1,13 @@ +import { PlayerSettingsObject } from '@peertube/peertube-models' +import { isPlayerChannelThemeSettingValid, isPlayerVideoThemeSettingValid } from '../player-settings.js' +import { isActivityPubUrlValid } from './misc.js' + +export function sanitizeAndCheckPlayerSettingsObject (settings: PlayerSettingsObject, target: 'video' | 'channel') { + if (!settings) return false + + if (settings.type !== 'PlayerSettings') return false + if (target === 'video' && !isPlayerVideoThemeSettingValid(settings.theme)) return false + if (target === 'channel' && !isPlayerChannelThemeSettingValid(settings.theme)) return false + + return isActivityPubUrlValid(settings.id) +} diff --git a/server/core/helpers/custom-validators/player-settings.ts b/server/core/helpers/custom-validators/player-settings.ts new file mode 100644 index 000000000..364ac10c0 --- /dev/null +++ b/server/core/helpers/custom-validators/player-settings.ts @@ -0,0 +1,16 @@ +import { PlayerTheme, PlayerThemeVideoSetting } from '@peertube/peertube-models' +import { DEFAULT_CHANNEL_PLAYER_SETTING_VALUE, DEFAULT_INSTANCE_PLAYER_SETTING_VALUE } from '@server/initializers/constants.js' + +export function isPlayerVideoThemeSettingValid (name: PlayerThemeVideoSetting) { + return isPlayerChannelThemeSettingValid(name) || name === DEFAULT_CHANNEL_PLAYER_SETTING_VALUE +} + +export function isPlayerChannelThemeSettingValid (name: PlayerThemeVideoSetting) { + return name === DEFAULT_INSTANCE_PLAYER_SETTING_VALUE || isPlayerThemeValid(name as PlayerTheme) +} + +const availableThemes = new Set([ 'galaxy', 'lucide' ]) + +export function isPlayerThemeValid (name: PlayerTheme) { + return availableThemes.has(name) +} diff --git a/server/core/initializers/checker-before-init.ts b/server/core/initializers/checker-before-init.ts index b1f7bad7b..81b773c10 100644 --- a/server/core/initializers/checker-before-init.ts +++ b/server/core/initializers/checker-before-init.ts @@ -135,6 +135,7 @@ export function checkMissedConfig () { 'defaults.publish.privacy', 'defaults.publish.licence', 'defaults.player.auto_play', + 'defaults.player.theme', 'instance.name', 'instance.short_description', 'instance.default_language', diff --git a/server/core/initializers/config.ts b/server/core/initializers/config.ts index d6306019f..84d7cddc5 100644 --- a/server/core/initializers/config.ts +++ b/server/core/initializers/config.ts @@ -1,6 +1,7 @@ import { BroadcastMessageLevel, NSFWPolicyType, + PlayerTheme, VideoCommentPolicyType, VideoPrivacyType, VideoRedundancyConfigFilter, @@ -172,6 +173,9 @@ const CONFIG = { } }, PLAYER: { + get THEME () { + return config.get('defaults.player.theme') + }, get AUTO_PLAY () { return config.get('defaults.player.auto_play') } diff --git a/server/core/initializers/constants.ts b/server/core/initializers/constants.ts index 416cc285b..faf76160e 100644 --- a/server/core/initializers/constants.ts +++ b/server/core/initializers/constants.ts @@ -8,6 +8,8 @@ import { FollowState, JobType, NSFWPolicyType, + PlayerThemeChannelSetting, + PlayerThemeVideoSetting, RunnerJobState, RunnerJobStateType, UploadImageType, @@ -50,7 +52,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js' // --------------------------------------------------------------------------- -export const LAST_MIGRATION_VERSION = 925 +export const LAST_MIGRATION_VERSION = 930 // --------------------------------------------------------------------------- @@ -1177,7 +1179,9 @@ export const PLUGIN_GLOBAL_CSS_PATH = join(CONFIG.STORAGE.TMP_DIR, PLUGIN_GLOBAL export let PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME = 1000 * 60 * 5 // 5 minutes export const DEFAULT_THEME_NAME = 'default' -export const DEFAULT_USER_THEME_NAME = 'instance-default' +export const DEFAULT_INSTANCE_THEME_NAME = 'instance-default' +export const DEFAULT_CHANNEL_PLAYER_SETTING_VALUE: PlayerThemeVideoSetting = 'channel-default' +export const DEFAULT_INSTANCE_PLAYER_SETTING_VALUE: PlayerThemeVideoSetting | PlayerThemeChannelSetting = 'instance-default' // --------------------------------------------------------------------------- diff --git a/server/core/initializers/database.ts b/server/core/initializers/database.ts index 3d56a42ef..8f1340097 100644 --- a/server/core/initializers/database.ts +++ b/server/core/initializers/database.ts @@ -69,6 +69,7 @@ import { VideoTagModel } from '../models/video/video-tag.js' import { VideoModel } from '../models/video/video.js' import { VideoViewModel } from '../models/view/video-view.js' import { CONFIG } from './config.js' +import { PlayerSettingModel } from '@server/models/video/player-setting.js' pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string @@ -189,7 +190,8 @@ export async function initDatabaseModels (silent: boolean) { WatchedWordsListModel, AccountAutomaticTagPolicyModel, UploadImageModel, - VideoLiveScheduleModel + VideoLiveScheduleModel, + PlayerSettingModel ]) // Check extensions exist in the database diff --git a/server/core/initializers/migrations/0930-player-settings.ts b/server/core/initializers/migrations/0930-player-settings.ts new file mode 100644 index 000000000..8ca669d55 --- /dev/null +++ b/server/core/initializers/migrations/0930-player-settings.ts @@ -0,0 +1,28 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize +}): Promise { + const query = `CREATE TABLE IF NOT EXISTS "playerSetting" ( + "id" SERIAL, + "theme" VARCHAR(255) NOT NULL DEFAULT 'instance-default', + "videoId" INTEGER REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + "channelId" INTEGER REFERENCES "videoChannel" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, + PRIMARY KEY ("id") +);` + + await utils.sequelize.query(query, { transaction: utils.transaction }) +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { + up, + down +} diff --git a/server/core/lib/activitypub/actors/shared/creator.ts b/server/core/lib/activitypub/actors/shared/creator.ts index a7e3f7f01..279966366 100644 --- a/server/core/lib/activitypub/actors/shared/creator.ts +++ b/server/core/lib/activitypub/actors/shared/creator.ts @@ -1,5 +1,5 @@ -import { Op, Transaction } from 'sequelize' import { ActivityPubActor, ActorImageType, ActorImageType_Type } from '@peertube/peertube-models' +import { isAccountActor, isChannelActor } from '@server/helpers/actors.js' import { sequelizeTypescript } from '@server/initializers/database.js' import { AccountModel } from '@server/models/account/account.js' import { ActorModel } from '@server/models/actor/actor.js' @@ -15,18 +15,17 @@ import { MChannel, MServer } from '@server/types/models/index.js' +import { Op, Transaction } from 'sequelize' +import { upsertAPPlayerSettings } from '../../player-settings.js' import { updateActorImages } from '../image.js' import { getActorAttributesFromObject, getActorDisplayNameFromObject, getImagesInfoFromObject } from './object-to-model-attributes.js' import { fetchActorFollowsCount } from './url-to-object.js' -import { isAccountActor, isChannelActor } from '@server/helpers/actors.js' export class APActorCreator { - constructor ( private readonly actorObject: ActivityPubActor, private readonly ownerActor?: MActorFullActor ) { - } async create (): Promise { @@ -34,7 +33,7 @@ export class APActorCreator { const actorInstance = new ActorModel(getActorAttributesFromObject(this.actorObject, followersCount, followingCount)) - return sequelizeTypescript.transaction(async t => { + const actor = await sequelizeTypescript.transaction(async t => { const server = await this.setServer(actorInstance, t) const { actorCreated, created } = await this.saveActor(actorInstance, t) @@ -58,6 +57,17 @@ export class APActorCreator { return actorCreated }) + + if (isChannelActor(actor.type) && typeof this.actorObject.playerSettings === 'string') { + await upsertAPPlayerSettings({ + settingsObject: this.actorObject.playerSettings, + video: undefined, + channel: actor.VideoChannel, + contextUrl: actor.url + }) + } + + return actor } private async setServer (actor: MActor, t: Transaction) { diff --git a/server/core/lib/activitypub/actors/updater.ts b/server/core/lib/activitypub/actors/updater.ts index d4652d51c..6076e567d 100644 --- a/server/core/lib/activitypub/actors/updater.ts +++ b/server/core/lib/activitypub/actors/updater.ts @@ -4,6 +4,7 @@ import { logger } from '@server/helpers/logger.js' import { AccountModel } from '@server/models/account/account.js' import { VideoChannelModel } from '@server/models/video/video-channel.js' import { MAccount, MActor, MActorFull, MChannel } from '@server/types/models/index.js' +import { upsertAPPlayerSettings } from '../player-settings.js' import { getOrCreateAPOwner } from './get.js' import { updateActorImages } from './image.js' import { fetchActorFollowsCount } from './shared/index.js' @@ -36,6 +37,15 @@ export class APActorUpdater { this.accountOrChannel.Account = owner.Account as AccountModel this.accountOrChannel.support = this.actorObject.support + + if (typeof this.actorObject.playerSettings === 'string') { + await upsertAPPlayerSettings({ + settingsObject: this.actorObject.playerSettings, + video: undefined, + channel: this.accountOrChannel, + contextUrl: this.actor.url + }) + } } await runInReadCommittedTransaction(async t => { diff --git a/server/core/lib/activitypub/player-settings.ts b/server/core/lib/activitypub/player-settings.ts new file mode 100644 index 000000000..7cf5b855e --- /dev/null +++ b/server/core/lib/activitypub/player-settings.ts @@ -0,0 +1,44 @@ +import { PlayerSettingsObject } from '@peertube/peertube-models' +import { sanitizeAndCheckPlayerSettingsObject } from '@server/helpers/custom-validators/activitypub/player-settings.js' +import { MChannelId, MChannelUrl, MVideoIdUrl } from '../../types/models/index.js' +import { upsertPlayerSettings } from '../player-settings.js' +import { fetchAPObjectIfNeeded } from './activity.js' +import { checkUrlsSameHost } from './url.js' + +export async function upsertAPPlayerSettings (options: { + video: MVideoIdUrl + channel: MChannelUrl & MChannelId + settingsObject: PlayerSettingsObject | string + contextUrl: string +}) { + const { video, channel, contextUrl } = options + + if (!video && !channel) throw new Error('Video or channel must be specified') + + const settingsObject = await fetchAPObjectIfNeeded(options.settingsObject) + + if (!sanitizeAndCheckPlayerSettingsObject(settingsObject, video ? 'video' : 'channel')) { + throw new Error(`Player settings ${settingsObject.id} object is not valid`) + } + + if (!checkUrlsSameHost(settingsObject.id, contextUrl)) { + throw new Error(`Player settings ${settingsObject.id} object is not on the same host as context URL ${contextUrl}`) + } + + const objectUrl = video?.url || channel?.Actor.url + if (!checkUrlsSameHost(settingsObject.id, objectUrl)) { + throw new Error(`Player settings ${settingsObject.id} object is not on the same host as context URL ${contextUrl}`) + } + + await upsertPlayerSettings({ settings: getPlayerSettingsAttributesFromObject(settingsObject), channel, video }) +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +function getPlayerSettingsAttributesFromObject (settingsObject: PlayerSettingsObject) { + return { + theme: settingsObject.theme + } +} diff --git a/server/core/lib/activitypub/process/process-update.ts b/server/core/lib/activitypub/process/process-update.ts index fa80a88f2..857d47717 100644 --- a/server/core/lib/activitypub/process/process-update.ts +++ b/server/core/lib/activitypub/process/process-update.ts @@ -5,6 +5,7 @@ import { ActivityUpdate, ActivityUpdateObject, CacheFileObject, + PlayerSettingsObject, PlaylistObject, VideoObject } from '@peertube/peertube-models' @@ -17,13 +18,15 @@ import { logger } from '../../../helpers/logger.js' import { sequelizeTypescript } from '../../../initializers/database.js' import { ActorModel } from '../../../models/actor/actor.js' import { APProcessorOptions } from '../../../types/activitypub-processor.model.js' -import { MActorFull, MActorSignature } from '../../../types/models/index.js' +import { MActorAccountChannelId, MActorFull, MActorSignature } from '../../../types/models/index.js' import { fetchAPObjectIfNeeded } from '../activity.js' +import { getOrCreateAPActor } from '../actors/get.js' import { APActorUpdater } from '../actors/updater.js' import { createOrUpdateCacheFile } from '../cache-file.js' +import { upsertAPPlayerSettings } from '../player-settings.js' import { createOrUpdateVideoPlaylist } from '../playlists/index.js' import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js' -import { APVideoUpdater, canVideoBeFederated, getOrCreateAPVideo } from '../videos/index.js' +import { APVideoUpdater, canVideoBeFederated, getOrCreateAPVideo, maybeGetOrCreateAPVideo } from '../videos/index.js' async function processUpdateActivity (options: APProcessorOptions>) { const { activity, byActor } = options @@ -51,6 +54,10 @@ async function processUpdateActivity (options: APProcessorOptions(this.videoObject.hasParts) @@ -180,6 +181,17 @@ export abstract class APVideoAbstractBuilder { }) } + protected async upsertPlayerSettings (video: MVideoFullLight) { + if (typeof this.videoObject.playerSettings !== 'string') return + + await upsertAPPlayerSettings({ + settingsObject: this.videoObject.playerSettings, + video, + channel: undefined, + contextUrl: video.url + }) + } + protected async setStreamingPlaylists (video: MVideoFullLight, t: Transaction) { const streamingPlaylistAttributes = getStreamingPlaylistAttributesFromObject(video, this.videoObject) const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a)) diff --git a/server/core/lib/activitypub/videos/shared/creator.ts b/server/core/lib/activitypub/videos/shared/creator.ts index f53ba0379..c73ed96c7 100644 --- a/server/core/lib/activitypub/videos/shared/creator.ts +++ b/server/core/lib/activitypub/videos/shared/creator.ts @@ -62,7 +62,8 @@ export class APVideoCreator extends APVideoAbstractBuilder { return { autoBlacklisted, videoCreated } }) - await this.updateChaptersOutsideTransaction(videoCreated) + await this.updateChapters(videoCreated) + await this.upsertPlayerSettings(videoCreated) return { autoBlacklisted, videoCreated } } diff --git a/server/core/lib/activitypub/videos/updater.ts b/server/core/lib/activitypub/videos/updater.ts index 40356d3d5..8f22114cc 100644 --- a/server/core/lib/activitypub/videos/updater.ts +++ b/server/core/lib/activitypub/videos/updater.ts @@ -79,7 +79,8 @@ export class APVideoUpdater extends APVideoAbstractBuilder { await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t)) - await this.updateChaptersOutsideTransaction(videoUpdated) + await this.updateChapters(videoUpdated) + await this.upsertPlayerSettings(videoUpdated) await autoBlacklistVideoIfNeeded({ video: videoUpdated, diff --git a/server/core/lib/player-settings.ts b/server/core/lib/player-settings.ts new file mode 100644 index 000000000..4fc868f8f --- /dev/null +++ b/server/core/lib/player-settings.ts @@ -0,0 +1,31 @@ +import { retryTransactionWrapper } from '@server/helpers/database-utils.js' +import { sequelizeTypescript } from '@server/initializers/database.js' +import { PlayerSettingModel } from '@server/models/video/player-setting.js' +import { PlayerChannelSettings, PlayerVideoSettings } from '../../../packages/models/src/videos/player-settings.js' +import { MChannelId, MVideoId } from '@server/types/models/index.js' + +export async function upsertPlayerSettings (options: { + settings: PlayerVideoSettings | PlayerChannelSettings + channel: MChannelId + video: MVideoId +}) { + const { settings, channel, video } = options + + if (!channel && !video) throw new Error('channel or video must be specified') + + return retryTransactionWrapper(() => { + return sequelizeTypescript.transaction(async transaction => { + const setting = channel + ? await PlayerSettingModel.loadByChannelId(channel.id, transaction) + : await PlayerSettingModel.loadByVideoId(video.id, transaction) + + if (setting) await setting.destroy({ transaction }) + + return PlayerSettingModel.create({ + theme: settings.theme, + channelId: channel?.id, + videoId: video?.id + }, { transaction }) + }) + }) +} diff --git a/server/core/lib/plugins/theme-utils.ts b/server/core/lib/plugins/theme-utils.ts index 651ddd195..757313b85 100644 --- a/server/core/lib/plugins/theme-utils.ts +++ b/server/core/lib/plugins/theme-utils.ts @@ -1,4 +1,4 @@ -import { DEFAULT_THEME_NAME, DEFAULT_USER_THEME_NAME } from '../../initializers/constants.js' +import { DEFAULT_THEME_NAME, DEFAULT_INSTANCE_THEME_NAME } from '../../initializers/constants.js' import { PluginManager } from './plugin-manager.js' import { CONFIG } from '../../initializers/config.js' import { ServerConfigManager } from '../server-config-manager.js' @@ -13,7 +13,7 @@ export function getThemeOrDefault (name: string, defaultTheme: string) { } export function isThemeRegistered (name: string) { - if (name === DEFAULT_THEME_NAME || name === DEFAULT_USER_THEME_NAME) return true + if (name === DEFAULT_THEME_NAME || name === DEFAULT_INSTANCE_THEME_NAME) return true return PluginManager.Instance.getRegisteredThemes().some(r => r.name === name) || ServerConfigManager.Instance.getBuiltInThemes().some(r => r.name === name) diff --git a/server/core/lib/server-config-manager.ts b/server/core/lib/server-config-manager.ts index e15d5badf..d646843da 100644 --- a/server/core/lib/server-config-manager.ts +++ b/server/core/lib/server-config-manager.ts @@ -116,6 +116,7 @@ class ServerConfigManager { } }, player: { + theme: CONFIG.DEFAULTS.PLAYER.THEME, autoPlay: CONFIG.DEFAULTS.PLAYER.AUTO_PLAY } }, diff --git a/server/core/lib/user-import-export/exporters/channels-exporter.ts b/server/core/lib/user-import-export/exporters/channels-exporter.ts index 0cfd7bc1a..af416a2a6 100644 --- a/server/core/lib/user-import-export/exporters/channels-exporter.ts +++ b/server/core/lib/user-import-export/exporters/channels-exporter.ts @@ -1,12 +1,13 @@ +import { ChannelExportJSON, PlayerChannelSettings } from '@peertube/peertube-models' import { logger } from '@server/helpers/logger.js' +import { PlayerSettingModel } from '@server/models/video/player-setting.js' import { VideoChannelModel } from '@server/models/video/video-channel.js' -import { ExportResult } from './abstract-user-exporter.js' -import { ChannelExportJSON } from '@peertube/peertube-models' import { MChannelBannerAccountDefault } from '@server/types/models/index.js' +import { MPlayerSetting } from '@server/types/models/video/player-setting.js' +import { ExportResult } from './abstract-user-exporter.js' import { ActorExporter } from './actor-exporter.js' -export class ChannelsExporter extends ActorExporter { - +export class ChannelsExporter extends ActorExporter { async export () { const channelsJSON: ChannelExportJSON['channels'] = [] let staticFiles: ExportResult['staticFiles'] = [] @@ -31,12 +32,15 @@ export class ChannelsExporter extends ActorExporter { } private async exportChannel (channelId: number) { - const channel = await VideoChannelModel.loadAndPopulateAccount(channelId) + const [ channel, playerSettings ] = await Promise.all([ + VideoChannelModel.loadAndPopulateAccount(channelId), + PlayerSettingModel.loadByChannelId(channelId) + ]) const { relativePathsFromJSON, staticFiles } = this.exportActorFiles(channel.Actor) return { - json: this.exportChannelJSON(channel, relativePathsFromJSON), + json: this.exportChannelJSON(channel, playerSettings, relativePathsFromJSON), staticFiles } } @@ -45,6 +49,7 @@ export class ChannelsExporter extends ActorExporter { private exportChannelJSON ( channel: MChannelBannerAccountDefault, + playerSettings: MPlayerSetting, archiveFiles: { avatar: string, banner: string } ): ChannelExportJSON['channels'][0] { return { @@ -54,6 +59,8 @@ export class ChannelsExporter extends ActorExporter { description: channel.description, support: channel.support, + playerSettings: this.exportPlayerSettingsJSON(playerSettings), + updatedAt: channel.updatedAt.toISOString(), createdAt: channel.createdAt.toISOString(), @@ -61,4 +68,11 @@ export class ChannelsExporter extends ActorExporter { } } + private exportPlayerSettingsJSON (playerSettings: MPlayerSetting) { + if (!playerSettings) return null + + return { + theme: playerSettings.theme as PlayerChannelSettings['theme'] + } + } } diff --git a/server/core/lib/user-import-export/exporters/videos-exporter.ts b/server/core/lib/user-import-export/exporters/videos-exporter.ts index 13faec72d..b4394cb4d 100644 --- a/server/core/lib/user-import-export/exporters/videos-exporter.ts +++ b/server/core/lib/user-import-export/exporters/videos-exporter.ts @@ -13,6 +13,7 @@ import { } from '@server/lib/object-storage/videos.js' import { VideoDownload } from '@server/lib/video-download.js' import { VideoPathManager } from '@server/lib/video-path-manager.js' +import { PlayerSettingModel } from '@server/models/video/player-setting.js' import { VideoCaptionModel } from '@server/models/video/video-caption.js' import { VideoChannelModel } from '@server/models/video/video-channel.js' import { VideoChapterModel } from '@server/models/video/video-chapter.js' @@ -33,6 +34,7 @@ import { MVideoLiveWithSettingSchedules, MVideoPassword } from '@server/types/models/index.js' +import { MPlayerSetting } from '@server/types/models/video/player-setting.js' import { MVideoSource } from '@server/types/models/video/video-source.js' import Bluebird from 'bluebird' import { createReadStream } from 'fs' @@ -80,11 +82,12 @@ export class VideosExporter extends AbstractUserExporter { } private async exportVideo (videoId: number) { - const [ video, captions, source, chapters ] = await Promise.all([ + const [ video, captions, source, chapters, playerSettings ] = await Promise.all([ VideoModel.loadFull(videoId), VideoCaptionModel.listVideoCaptions(videoId), VideoSourceModel.loadLatest(videoId), - VideoChapterModel.listChaptersOfVideo(videoId) + VideoChapterModel.listChaptersOfVideo(videoId), + PlayerSettingModel.loadByVideoId(videoId) ]) const passwords = video.privacy === VideoPrivacy.PASSWORD_PROTECTED @@ -101,7 +104,16 @@ export class VideosExporter extends AbstractUserExporter { const { relativePathsFromJSON, staticFiles, exportedVideoFileOrSource } = await this.exportVideoFiles({ video, captions }) return { - json: this.exportVideoJSON({ video, captions, live, passwords, source, chapters, archiveFiles: relativePathsFromJSON }), + json: this.exportVideoJSON({ + video, + captions, + live, + passwords, + source, + chapters, + playerSettings, + archiveFiles: relativePathsFromJSON + }), staticFiles, relativePathsFromJSON, activityPubOutbox: await this.exportVideoAP(videoAP, chapters, exportedVideoFileOrSource) @@ -116,10 +128,11 @@ export class VideosExporter extends AbstractUserExporter { live: MVideoLiveWithSettingSchedules passwords: MVideoPassword[] source: MVideoSource + playerSettings: MPlayerSetting chapters: MVideoChapter[] archiveFiles: VideoExportJSON['videos'][0]['archiveFiles'] }): VideoExportJSON['videos'][0] { - const { video, captions, live, passwords, source, chapters, archiveFiles } = options + const { video, captions, live, passwords, source, chapters, playerSettings, archiveFiles } = options return { uuid: video.uuid, @@ -182,6 +195,8 @@ export class VideosExporter extends AbstractUserExporter { source: this.exportVideoSourceJSON(source), + playerSettings: this.exportPlayerSettingsJSON(playerSettings), + archiveFiles } } @@ -261,6 +276,14 @@ export class VideosExporter extends AbstractUserExporter { } } + private exportPlayerSettingsJSON (playerSettings: MPlayerSetting) { + if (!playerSettings) return null + + return { + theme: playerSettings.theme + } + } + // --------------------------------------------------------------------------- private async exportVideoAP ( diff --git a/server/core/lib/user-import-export/importers/channels-importer.ts b/server/core/lib/user-import-export/importers/channels-importer.ts index fac235f8c..8db2a528f 100644 --- a/server/core/lib/user-import-export/importers/channels-importer.ts +++ b/server/core/lib/user-import-export/importers/channels-importer.ts @@ -1,26 +1,31 @@ -import { ActorImageType, ChannelExportJSON } from '@peertube/peertube-models' -import { logger, loggerTagsFactory } from '@server/helpers/logger.js' import { pick } from '@peertube/peertube-core-utils' -import { AbstractUserImporter } from './abstract-user-importer.js' -import { sequelizeTypescript } from '@server/initializers/database.js' -import { createLocalVideoChannelWithoutKeys } from '@server/lib/video-channel.js' -import { JobQueue } from '@server/lib/job-queue/job-queue.js' -import { updateLocalActorImageFiles } from '@server/lib/local-actor.js' -import { VideoChannelModel } from '@server/models/video/video-channel.js' +import { ActorImageType, ChannelExportJSON } from '@peertube/peertube-models' +import { isPlayerChannelThemeSettingValid } from '@server/helpers/custom-validators/player-settings.js' import { isVideoChannelDescriptionValid, isVideoChannelDisplayNameValid, isVideoChannelSupportValid, isVideoChannelUsernameValid } from '@server/helpers/custom-validators/video-channels.js' +import { logger, loggerTagsFactory } from '@server/helpers/logger.js' import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js' +import { sequelizeTypescript } from '@server/initializers/database.js' +import { JobQueue } from '@server/lib/job-queue/job-queue.js' +import { updateLocalActorImageFiles } from '@server/lib/local-actor.js' +import { createLocalVideoChannelWithoutKeys } from '@server/lib/video-channel.js' +import { PlayerSettingModel } from '@server/models/video/player-setting.js' +import { VideoChannelModel } from '@server/models/video/video-channel.js' +import { MChannelId } from '@server/types/models/index.js' +import { AbstractUserImporter } from './abstract-user-importer.js' const lTags = loggerTagsFactory('user-import') -type SanitizedObject = Pick - -export class ChannelsImporter extends AbstractUserImporter { +type SanitizedObject = Pick< + ChannelExportJSON['channels'][0], + 'name' | 'displayName' | 'description' | 'support' | 'playerSettings' | 'archiveFiles' +> +export class ChannelsImporter extends AbstractUserImporter { protected getImportObjects (json: ChannelExportJSON) { return json.channels } @@ -32,7 +37,11 @@ export class ChannelsImporter extends AbstractUserImporter export class VideosImporter extends AbstractUserImporter { @@ -139,6 +142,10 @@ export class VideosImporter extends AbstractUserImporter isLiveScheduleValid(s)) } + if (o.playerSettings) { + if (!isPlayerVideoThemeSettingValid(o.playerSettings.theme)) o.playerSettings.theme = undefined + } + if (o.privacy === VideoPrivacy.PASSWORD_PROTECTED) { if (!isArray(o.passwords)) return undefined // Refuse the import rather than handle only a portion of the passwords, which can be difficult for video owners to debug @@ -167,7 +174,8 @@ export class VideosImporter extends AbstractUserImporter { if (areValidationErrors(req, res)) return - if (!await doesAccountHandleExist({ handle: req.params.handle, res, checkIsLocal, checkManage })) return + if (!await doesAccountHandleExist({ handle: req.params.handle, req, res, checkIsLocal, checkManage })) return if (options.checkManage) { const user = res.locals.oauth.token.User - if (!checkUserCanManageAccount({ account: res.locals.account, user, res, specialRight: UserRight.MANAGE_USERS })) { + if (!checkUserCanManageAccount({ account: res.locals.account, user, req, res, specialRight: UserRight.MANAGE_USERS })) { return false } } diff --git a/server/core/middlewares/validators/automatic-tags.ts b/server/core/middlewares/validators/automatic-tags.ts index 9510ae0f2..fdd8fa371 100644 --- a/server/core/middlewares/validators/automatic-tags.ts +++ b/server/core/middlewares/validators/automatic-tags.ts @@ -12,7 +12,7 @@ export const manageAccountAutomaticTagsValidator = [ async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - if (!await doesAccountHandleExist({ handle: req.params.accountName, res, checkIsLocal: true, checkManage: true })) return + if (!await doesAccountHandleExist({ handle: req.params.accountName, req, res, checkIsLocal: true, checkManage: true })) return return next() } diff --git a/server/core/middlewares/validators/blocklist.ts b/server/core/middlewares/validators/blocklist.ts index e3a8ae204..01e9712ef 100644 --- a/server/core/middlewares/validators/blocklist.ts +++ b/server/core/middlewares/validators/blocklist.ts @@ -17,7 +17,7 @@ const blockAccountValidator = [ async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - if (!await doesAccountHandleExist({ handle: req.body.accountName, res, checkIsLocal: false, checkManage: false })) return + if (!await doesAccountHandleExist({ handle: req.body.accountName, req, res, checkIsLocal: false, checkManage: false })) return const user = res.locals.oauth.token.User const accountToBlock = res.locals.account @@ -40,7 +40,7 @@ const unblockAccountByAccountValidator = [ async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - if (!await doesAccountHandleExist({ handle: req.params.accountName, res, checkIsLocal: false, checkManage: false })) return + if (!await doesAccountHandleExist({ handle: req.params.accountName, req, res, checkIsLocal: false, checkManage: false })) return const user = res.locals.oauth.token.User const targetAccount = res.locals.account @@ -56,7 +56,7 @@ const unblockAccountByServerValidator = [ async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - if (!await doesAccountHandleExist({ handle: req.params.accountName, res, checkIsLocal: false, checkManage: false })) return + if (!await doesAccountHandleExist({ handle: req.params.accountName, req, res, checkIsLocal: false, checkManage: false })) return const serverActor = await getServerActor() const targetAccount = res.locals.account diff --git a/server/core/middlewares/validators/bulk.ts b/server/core/middlewares/validators/bulk.ts index f888b6587..c9162ebd5 100644 --- a/server/core/middlewares/validators/bulk.ts +++ b/server/core/middlewares/validators/bulk.ts @@ -12,7 +12,7 @@ export const bulkRemoveCommentsOfValidator = [ async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - if (!await doesAccountHandleExist({ handle: req.body.accountName, res, checkIsLocal: false, checkManage: false })) return + if (!await doesAccountHandleExist({ handle: req.body.accountName, req, res, checkIsLocal: false, checkManage: false })) return const user = res.locals.oauth.token.User const body = req.body as BulkRemoveCommentsOfBody diff --git a/server/core/middlewares/validators/config.ts b/server/core/middlewares/validators/config.ts index e91f33eee..40f1f7f25 100644 --- a/server/core/middlewares/validators/config.ts +++ b/server/core/middlewares/validators/config.ts @@ -1,6 +1,7 @@ import { CustomConfig, HttpStatusCode } from '@peertube/peertube-models' import { isConfigLogoTypeValid } from '@server/helpers/custom-validators/config.js' import { isIntOrNull } from '@server/helpers/custom-validators/misc.js' +import { isPlayerThemeValid } from '@server/helpers/custom-validators/player-settings.js' import { isNumberArray, isStringArray } from '@server/helpers/custom-validators/search.js' import { isVideoCommentsPolicyValid, isVideoLicenceValid, isVideoPrivacyValid } from '@server/helpers/custom-validators/videos.js' import { guessLanguageFromReq } from '@server/helpers/i18n.js' @@ -148,6 +149,7 @@ export const customConfigUpdateValidator = [ body('defaults.p2p.webapp.enabled').isBoolean(), body('defaults.p2p.embed.enabled').isBoolean(), body('defaults.player.autoPlay').isBoolean(), + body('defaults.player.theme').custom(isPlayerThemeValid), body('email.body.signature').exists(), body('email.subject.prefix').exists(), diff --git a/server/core/middlewares/validators/feeds.ts b/server/core/middlewares/validators/feeds.ts index f770049c4..9b260fafa 100644 --- a/server/core/middlewares/validators/feeds.ts +++ b/server/core/middlewares/validators/feeds.ts @@ -91,7 +91,7 @@ const feedsAccountOrChannelFiltersValidator = [ if (areValidationErrors(req, res)) return const { accountId, videoChannelId, accountName, videoChannelName } = req.query - const commonOptions = { res, checkManage: false, checkIsLocal: false } + const commonOptions = { req, res, checkManage: false, checkIsLocal: false } if (accountId && !await doesAccountIdExist({ id: accountId, ...commonOptions })) return if (videoChannelId && !await doesChannelIdExist({ id: videoChannelId, ...commonOptions })) return @@ -111,7 +111,7 @@ const videoFeedsPodcastValidator = [ async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - if (!await doesChannelIdExist({ id: req.query.videoChannelId, checkManage: false, checkIsLocal: false, res })) return + if (!await doesChannelIdExist({ id: req.query.videoChannelId, checkManage: false, checkIsLocal: false, req, res })) return return next() } @@ -129,7 +129,7 @@ const videoSubscriptionFeedsValidator = [ async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - if (!await doesAccountIdExist({ id: req.query.accountId, res, checkIsLocal: true, checkManage: false })) return + if (!await doesAccountIdExist({ id: req.query.accountId, req, res, checkIsLocal: true, checkManage: false })) return if (!await doesUserFeedTokenCorrespond(res.locals.account.userId, req.query.token, res)) return return next() diff --git a/server/core/middlewares/validators/player-settings.ts b/server/core/middlewares/validators/player-settings.ts new file mode 100644 index 000000000..e39cdd8a7 --- /dev/null +++ b/server/core/middlewares/validators/player-settings.ts @@ -0,0 +1,86 @@ +import { UserRight } from '@peertube/peertube-models' +import { isBooleanValid, toBooleanOrNull } from '@server/helpers/custom-validators/misc.js' +import { isPlayerChannelThemeSettingValid, isPlayerVideoThemeSettingValid } from '@server/helpers/custom-validators/player-settings.js' +import express from 'express' +import { body, query } from 'express-validator' +import { checkUserCanManageAccount } from './shared/users.js' +import { areValidationErrors, isValidVideoIdParam } from './shared/utils.js' +import { checkCanSeeVideo, checkUserCanManageVideo, doesVideoExist } from './shared/videos.js' + +export const getVideoPlayerSettingsValidator = [ + isValidVideoIdParam('videoId'), + + query('raw') + .optional() + .customSanitizer(toBooleanOrNull) + .custom(isBooleanValid), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + const raw = req.query.raw === true + + if (!await doesVideoExist(req.params.videoId, res, raw ? 'all' : 'only-video-and-blacklist')) return + + const video = res.locals.onlyVideo || res.locals.videoAll + if (!await checkCanSeeVideo({ req, res, video, paramId: req.params.videoId })) return + + if (raw === true) { + const user = res.locals.oauth?.token.User + + if (!checkUserCanManageVideo({ user, video: res.locals.videoAll, right: UserRight.UPDATE_ANY_VIDEO, req, res })) return + } + + return next() + } +] + +export const getChannelPlayerSettingsValidator = [ + query('raw') + .optional() + .customSanitizer(toBooleanOrNull) + .custom(isBooleanValid), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + if (req.query.raw === true) { + const account = res.locals.videoChannel.Account + const user = res.locals.oauth?.token.User + + if (!checkUserCanManageAccount({ account, user, req, res, specialRight: UserRight.MANAGE_ANY_VIDEO_CHANNEL })) return false + } + + return next() + } +] + +export const updateVideoPlayerSettingsValidator = [ + isValidVideoIdParam('videoId'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + if (!await doesVideoExist(req.params.videoId, res, 'all')) return + + const user = res.locals.oauth.token.User + if (!checkUserCanManageVideo({ user, video: res.locals.videoAll, right: UserRight.UPDATE_ANY_VIDEO, req, res })) return + + return next() + } +] + +export const updatePlayerSettingsValidatorFactory = (type: 'video' | 'channel') => [ + body('theme') + .custom(v => { + return type === 'video' + ? isPlayerVideoThemeSettingValid(v) + : isPlayerChannelThemeSettingValid(v) + }), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + return next() + } +] diff --git a/server/core/middlewares/validators/shared/accounts.ts b/server/core/middlewares/validators/shared/accounts.ts index 17e5a8be6..14f0503e6 100644 --- a/server/core/middlewares/validators/shared/accounts.ts +++ b/server/core/middlewares/validators/shared/accounts.ts @@ -2,33 +2,35 @@ import { forceNumber } from '@peertube/peertube-core-utils' import { HttpStatusCode, UserRight } from '@peertube/peertube-models' import { AccountModel } from '@server/models/account/account.js' import { MAccountDefault } from '@server/types/models/index.js' -import { Response } from 'express' +import { Request, Response } from 'express' import { checkUserCanManageAccount } from './users.js' export async function doesAccountIdExist (options: { id: string | number + req: Request res: Response checkManage: boolean // Also check the user can manage the account checkIsLocal: boolean // Also check this is a local channel }) { - const { id, res, checkIsLocal, checkManage } = options + const { id, req, res, checkIsLocal, checkManage } = options const account = await AccountModel.load(forceNumber(id)) - return doesAccountExist({ account, res, checkIsLocal, checkManage }) + return doesAccountExist({ account, req, res, checkIsLocal, checkManage }) } export async function doesAccountHandleExist (options: { handle: string + req: Request res: Response checkManage: boolean // Also check the user can manage the account checkIsLocal: boolean // Also check this is a local channel }) { - const { handle, res, checkIsLocal, checkManage } = options + const { handle, req, res, checkIsLocal, checkManage } = options const account = await AccountModel.loadByHandle(handle) - return doesAccountExist({ account, res, checkIsLocal, checkManage }) + return doesAccountExist({ account, req, res, checkIsLocal, checkManage }) } // --------------------------------------------------------------------------- @@ -37,11 +39,12 @@ export async function doesAccountHandleExist (options: { function doesAccountExist (options: { account: MAccountDefault + req: Request res: Response checkManage: boolean checkIsLocal: boolean }) { - const { account, res, checkIsLocal, checkManage } = options + const { account, req, res, checkIsLocal, checkManage } = options if (!account) { res.fail({ @@ -54,7 +57,7 @@ function doesAccountExist (options: { if (checkManage) { const user = res.locals.oauth.token.User - if (!checkUserCanManageAccount({ account, user, res, specialRight: UserRight.MANAGE_USERS })) { + if (!checkUserCanManageAccount({ account, user, req, res, specialRight: UserRight.MANAGE_USERS })) { return false } } diff --git a/server/core/middlewares/validators/shared/users.ts b/server/core/middlewares/validators/shared/users.ts index 54a156230..20c1ff9d2 100644 --- a/server/core/middlewares/validators/shared/users.ts +++ b/server/core/middlewares/validators/shared/users.ts @@ -95,9 +95,18 @@ export function checkUserCanManageAccount (options: { user: MUserAccountId account: MAccountId specialRight: UserRightType + req: express.Request res: express.Response }) { - const { user, account, specialRight, res } = options + const { user, account, specialRight, res, req } = options + + if (!user) { + res.fail({ + status: HttpStatusCode.UNAUTHORIZED_401, + message: req.t('Authentication is required') + }) + return false + } if (account.id === user.Account.id) return true if (specialRight && user.hasRight(specialRight) === true) return true @@ -105,7 +114,7 @@ export function checkUserCanManageAccount (options: { if (!specialRight) { res.fail({ status: HttpStatusCode.FORBIDDEN_403, - message: 'Only the owner of this account can manage this account resource.' + message: req.t('Only the owner of this account can manage this account resource.') }) return false @@ -113,7 +122,7 @@ export function checkUserCanManageAccount (options: { res.fail({ status: HttpStatusCode.FORBIDDEN_403, - message: 'Only a user with sufficient right can access this account resource.' + message: req.t('Only a user with sufficient right can access this account resource.') }) return false diff --git a/server/core/middlewares/validators/shared/video-channels.ts b/server/core/middlewares/validators/shared/video-channels.ts index a1b589db0..c5e5d3b48 100644 --- a/server/core/middlewares/validators/shared/video-channels.ts +++ b/server/core/middlewares/validators/shared/video-channels.ts @@ -8,26 +8,28 @@ export async function doesChannelIdExist (options: { id: number checkManage: boolean // Also check the user can manage the account checkIsLocal: boolean // Also check this is a local channel + req: express.Request res: express.Response }) { - const { id, checkManage, checkIsLocal, res } = options + const { id, checkManage, checkIsLocal, req, res } = options const channel = await VideoChannelModel.loadAndPopulateAccount(+id) - return processVideoChannelExist({ channel, checkManage, checkIsLocal, res }) + return processVideoChannelExist({ channel, checkManage, checkIsLocal, req, res }) } export async function doesChannelHandleExist (options: { handle: string checkManage: boolean // Also check the user can manage the account checkIsLocal: boolean // Also check this is a local channel + req: express.Request res: express.Response }) { - const { handle, checkManage, checkIsLocal, res } = options + const { handle, checkManage, checkIsLocal, req, res } = options const channel = await VideoChannelModel.loadByHandleAndPopulateAccount(handle) - return processVideoChannelExist({ channel, checkManage, checkIsLocal, res }) + return processVideoChannelExist({ channel, checkManage, checkIsLocal, req, res }) } // --------------------------------------------------------------------------- @@ -36,16 +38,17 @@ export async function doesChannelHandleExist (options: { function processVideoChannelExist (options: { channel: MChannelBannerAccountDefault + req: express.Request res: express.Response checkManage: boolean checkIsLocal: boolean }) { - const { channel, res, checkManage, checkIsLocal } = options + const { channel, req, res, checkManage, checkIsLocal } = options if (!channel) { res.fail({ status: HttpStatusCode.NOT_FOUND_404, - message: 'Video channel not found' + message: req.t('Video channel not found') }) return false } @@ -53,7 +56,7 @@ function processVideoChannelExist (options: { if (checkManage) { const user = res.locals.oauth.token.User - if (!checkUserCanManageAccount({ account: channel.Account, user, res, specialRight: UserRight.MANAGE_ANY_VIDEO_CHANNEL })) { + if (!checkUserCanManageAccount({ account: channel.Account, user, req, res, specialRight: UserRight.MANAGE_ANY_VIDEO_CHANNEL })) { return false } } @@ -61,7 +64,7 @@ function processVideoChannelExist (options: { if (checkIsLocal && channel.Actor.isOwned() === false) { res.fail({ status: HttpStatusCode.FORBIDDEN_403, - message: 'This channel is not owned.' + message: req.t('This channel is not owned.') }) return false diff --git a/server/core/middlewares/validators/shared/videos.ts b/server/core/middlewares/validators/shared/videos.ts index e1ff1ad90..4f6af5e4f 100644 --- a/server/core/middlewares/validators/shared/videos.ts +++ b/server/core/middlewares/validators/shared/videos.ts @@ -127,7 +127,7 @@ export async function checkCanSeeVideo (options: { throw new Error('Unknown video privacy when checking video right ' + video.url) } -export async function checkCanSeeUserAuthVideo (options: { +async function checkCanSeeUserAuthVideo (options: { req: Request res: Response video: MVideoId | MVideoWithRights @@ -173,7 +173,7 @@ export async function checkCanSeeUserAuthVideo (options: { return fail() } -export async function checkCanSeePasswordProtectedVideo (options: { +async function checkCanSeePasswordProtectedVideo (options: { req: Request res: Response video: MVideo @@ -214,13 +214,13 @@ export async function checkCanSeePasswordProtectedVideo (options: { return false } -export function canUserAccessVideo (user: MUser, video: MVideoWithRights | MVideoAccountLight, right: UserRightType) { +function canUserAccessVideo (user: MUser, video: MVideoWithRights | MVideoAccountLight, right: UserRightType) { const isOwnedByUser = video.VideoChannel.Account.userId === user.id return isOwnedByUser || user.hasRight(right) } -export async function getVideoWithRights (video: MVideoWithRights): Promise { +async function getVideoWithRights (video: MVideoWithRights): Promise { return video.VideoChannel?.Account?.userId ? video : VideoModel.loadFull(video.id) @@ -284,11 +284,27 @@ function assignVideoTokenIfNeeded (req: Request, res: Response, video: MVideoUUI // --------------------------------------------------------------------------- -export function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right: UserRightType, res: Response, onlyOwned = true) { +export function checkUserCanManageVideo (options: { + user: MUser + video: MVideoAccountLight + right: UserRightType + req: Request + res: Response + onlyOwned?: boolean +}) { + const { user, video, right, req, res, onlyOwned = true } = options + + if (!user) { + res.fail({ + status: HttpStatusCode.UNAUTHORIZED_401, + message: req.t('Authentication is required.') + }) + } + if (onlyOwned && video.isOwned() === false) { res.fail({ status: HttpStatusCode.FORBIDDEN_403, - message: 'Cannot manage a video of another server.' + message: req.t('Cannot manage a video of another server.') }) return false } @@ -297,7 +313,7 @@ export function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, if (user.hasRight(right) === false && account.userId !== user.id) { res.fail({ status: HttpStatusCode.FORBIDDEN_403, - message: 'Cannot manage a video of another user.' + message: req.t('Cannot manage a video of another user.') }) return false } diff --git a/server/core/middlewares/validators/token.ts b/server/core/middlewares/validators/token.ts index 815684794..02b0fe732 100644 --- a/server/core/middlewares/validators/token.ts +++ b/server/core/middlewares/validators/token.ts @@ -17,7 +17,7 @@ export const manageTokenSessionsValidator = [ const authUser = res.locals.oauth.token.User const targetUser = res.locals.user - if (!checkUserCanManageAccount({ account: targetUser.Account, user: authUser, res, specialRight: UserRight.MANAGE_USERS })) return + if (!checkUserCanManageAccount({ account: targetUser.Account, user: authUser, req, res, specialRight: UserRight.MANAGE_USERS })) return return next() } diff --git a/server/core/middlewares/validators/users/users.ts b/server/core/middlewares/validators/users/users.ts index 8cc8b3261..d0145d58a 100644 --- a/server/core/middlewares/validators/users/users.ts +++ b/server/core/middlewares/validators/users/users.ts @@ -369,7 +369,9 @@ export const usersVideosValidator = [ async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - if (req.query.channelId && !await doesChannelIdExist({ id: req.query.channelId, checkManage: true, checkIsLocal: true, res })) return + if (req.query.channelId && !await doesChannelIdExist({ id: req.query.channelId, checkManage: true, checkIsLocal: true, req, res })) { + return + } return next() } diff --git a/server/core/middlewares/validators/videos/video-captions.ts b/server/core/middlewares/validators/videos/video-captions.ts index 4dc874a6f..10f27397a 100644 --- a/server/core/middlewares/validators/videos/video-captions.ts +++ b/server/core/middlewares/validators/videos/video-captions.ts @@ -29,9 +29,9 @@ export const addVideoCaptionValidator = [ .custom((_, { req }) => isVideoCaptionFile(req.files, 'captionfile')) .withMessage( 'This caption file is not supported or too large. ' + - `Please, make sure it is under ${CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max} bytes ` + - 'and one of the following mimetypes: ' + - Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT).map(key => `${key} (${MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT[key]})`).join(', ') + `Please, make sure it is under ${CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max} bytes ` + + 'and one of the following mimetypes: ' + + Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT).map(key => `${key} (${MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT[key]})`).join(', ') ), async (req: express.Request, res: express.Response, next: express.NextFunction) => { @@ -40,7 +40,9 @@ export const addVideoCaptionValidator = [ // Check if the user who did the request is able to update the video const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req) + if (!checkUserCanManageVideo({ user, video: res.locals.videoAll, right: UserRight.UPDATE_ANY_VIDEO, req, res })) { + return cleanUpReqFiles(req) + } return next() } @@ -72,7 +74,7 @@ export const generateVideoCaptionValidator = [ // Check if the user who did the request is able to update the video const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return + if (!checkUserCanManageVideo({ user, video, right: UserRight.UPDATE_ANY_VIDEO, req, res })) return // Check the video has not already a caption const captions = await VideoCaptionModel.listVideoCaptions(video.id) @@ -123,7 +125,7 @@ export const deleteVideoCaptionValidator = [ // Check if the user who did the request is able to update the video const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return + if (!checkUserCanManageVideo({ user, video: res.locals.videoAll, right: UserRight.UPDATE_ANY_VIDEO, req, res })) return return next() } diff --git a/server/core/middlewares/validators/videos/video-channel-sync.ts b/server/core/middlewares/validators/videos/video-channel-sync.ts index 47f43199b..8aa461985 100644 --- a/server/core/middlewares/validators/videos/video-channel-sync.ts +++ b/server/core/middlewares/validators/videos/video-channel-sync.ts @@ -29,7 +29,7 @@ export const videoChannelSyncValidator = [ if (areValidationErrors(req, res)) return const body: VideoChannelSyncCreate = req.body - if (!await doesChannelIdExist({ id: body.videoChannelId, checkManage: true, checkIsLocal: true, res })) return + if (!await doesChannelIdExist({ id: body.videoChannelId, checkManage: true, checkIsLocal: true, req, res })) return const count = await VideoChannelSyncModel.countByAccount(res.locals.videoChannel.accountId) if (count >= CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.MAX_PER_USER) { @@ -49,7 +49,9 @@ export const ensureSyncExists = [ if (areValidationErrors(req, res)) return if (!await doesVideoChannelSyncIdExist(+req.params.id, res)) return - if (!await doesChannelIdExist({ id: res.locals.videoChannelSync.videoChannelId, checkManage: true, checkIsLocal: true, res })) return + if (!await doesChannelIdExist({ id: res.locals.videoChannelSync.videoChannelId, checkManage: true, checkIsLocal: true, req, res })) { + return + } return next() } diff --git a/server/core/middlewares/validators/videos/video-channels.ts b/server/core/middlewares/validators/videos/video-channels.ts index 1c4c96abb..43bbb7d96 100644 --- a/server/core/middlewares/validators/videos/video-channels.ts +++ b/server/core/middlewares/validators/videos/video-channels.ts @@ -92,7 +92,7 @@ export const videoChannelsHandleValidatorFactory = (options: { async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - if (!await doesChannelHandleExist({ handle: req.params.handle, checkManage, checkIsLocal, res })) return + if (!await doesChannelHandleExist({ handle: req.params.handle, checkManage, checkIsLocal, req, res })) return return next() } diff --git a/server/core/middlewares/validators/videos/video-chapters.ts b/server/core/middlewares/validators/videos/video-chapters.ts index 5097e6380..d6e8acf62 100644 --- a/server/core/middlewares/validators/videos/video-chapters.ts +++ b/server/core/middlewares/validators/videos/video-chapters.ts @@ -1,10 +1,7 @@ import express from 'express' import { body } from 'express-validator' import { HttpStatusCode, UserRight } from '@peertube/peertube-models' -import { - areValidationErrors, checkUserCanManageVideo, doesVideoExist, - isValidVideoIdParam -} from '../shared/index.js' +import { areValidationErrors, checkUserCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared/index.js' import { areVideoChaptersValid } from '@server/helpers/custom-validators/video-chapters.js' export const updateVideoChaptersValidator = [ @@ -27,7 +24,7 @@ export const updateVideoChaptersValidator = [ // Check if the user who did the request is able to update video chapters (same right as updating the video) const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return + if (!checkUserCanManageVideo({ user, video: res.locals.videoAll, right: UserRight.UPDATE_ANY_VIDEO, req, res })) return return next() } diff --git a/server/core/middlewares/validators/videos/video-comments.ts b/server/core/middlewares/validators/videos/video-comments.ts index 61207bb2e..a74b81aa6 100644 --- a/server/core/middlewares/validators/videos/video-comments.ts +++ b/server/core/middlewares/validators/videos/video-comments.ts @@ -52,7 +52,8 @@ export const listAllVideoCommentsForAdminValidator = [ if (req.query.videoId && !await doesVideoExist(req.query.videoId, res, 'unsafe-only-immutable-attributes')) return if ( - req.query.videoChannelId && !await doesChannelIdExist({ id: req.query.videoChannelId, checkManage: true, checkIsLocal: true, res }) + req.query.videoChannelId && + !await doesChannelIdExist({ id: req.query.videoChannelId, checkManage: true, checkIsLocal: true, req, res }) ) return return next() @@ -73,16 +74,19 @@ export const listCommentsOnUserVideosValidator = [ if (req.query.videoId && !await doesVideoExist(req.query.videoId, res, 'all')) return if ( - req.query.videoChannelId && !await doesChannelIdExist({ id: req.query.videoChannelId, checkManage: true, checkIsLocal: true, res }) + req.query.videoChannelId && + !await doesChannelIdExist({ id: req.query.videoChannelId, checkManage: true, checkIsLocal: true, req, res }) ) return const user = res.locals.oauth.token.User const video = res.locals.videoAll - if (video && !checkUserCanManageVideo(user, video, UserRight.SEE_ALL_COMMENTS, res)) return + if (video && !checkUserCanManageVideo({ user, video, right: UserRight.SEE_ALL_COMMENTS, req, res })) return const channel = res.locals.videoChannel - if (channel && !checkUserCanManageAccount({ account: channel.Account, user, res, specialRight: UserRight.SEE_ALL_COMMENTS })) return + if (channel && !checkUserCanManageAccount({ account: channel.Account, user, req, res, specialRight: UserRight.SEE_ALL_COMMENTS })) { + return + } return next() } diff --git a/server/core/middlewares/validators/videos/video-live.ts b/server/core/middlewares/validators/videos/video-live.ts index 0cb1d5b79..633ea392e 100644 --- a/server/core/middlewares/validators/videos/video-live.ts +++ b/server/core/middlewares/validators/videos/video-live.ts @@ -211,7 +211,7 @@ const videoLiveUpdateValidator = [ // Check the user can manage the live const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.GET_ANY_LIVE, res)) return + if (!checkUserCanManageVideo({ user, video: res.locals.videoAll, right: UserRight.GET_ANY_LIVE, req, res })) return return next() } @@ -221,7 +221,7 @@ const videoLiveListSessionsValidator = [ (req: express.Request, res: express.Response, next: express.NextFunction) => { // Check the user can manage the live const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.GET_ANY_LIVE, res)) return + if (!checkUserCanManageVideo({ user, video: res.locals.videoAll, right: UserRight.GET_ANY_LIVE, req, res })) return return next() } diff --git a/server/core/middlewares/validators/videos/video-ownership-changes.ts b/server/core/middlewares/validators/videos/video-ownership-changes.ts index b540675ff..8c9e1d857 100644 --- a/server/core/middlewares/validators/videos/video-ownership-changes.ts +++ b/server/core/middlewares/validators/videos/video-ownership-changes.ts @@ -23,7 +23,15 @@ export const videosChangeOwnershipValidator = [ if (!await doesVideoExist(req.params.videoId, res)) return // Check if the user who did the request is able to change the ownership of the video - if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.CHANGE_VIDEO_OWNERSHIP, res)) return + if ( + !checkUserCanManageVideo({ + user: res.locals.oauth.token.User, + video: res.locals.videoAll, + right: UserRight.CHANGE_VIDEO_OWNERSHIP, + req, + res + }) + ) return const nextOwner = await AccountModel.loadLocalByName(req.body.username) if (!nextOwner) { @@ -82,7 +90,6 @@ export const videosAcceptChangeOwnershipValidator = [ async function checkCanAccept (video: MVideoWithAllFiles, res: express.Response): Promise { if (video.isLive) { - if (video.state !== VideoState.WAITING_FOR_LIVE) { res.fail({ status: HttpStatusCode.BAD_REQUEST_400, diff --git a/server/core/middlewares/validators/videos/video-passwords.ts b/server/core/middlewares/validators/videos/video-passwords.ts index 58ca224f4..8d7e56629 100644 --- a/server/core/middlewares/validators/videos/video-passwords.ts +++ b/server/core/middlewares/validators/videos/video-passwords.ts @@ -24,7 +24,7 @@ const listVideoPasswordValidator = [ // Check if the user who did the request is able to access video password list const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.SEE_ALL_VIDEOS, res)) return + if (!checkUserCanManageVideo({ user, video: res.locals.videoAll, right: UserRight.SEE_ALL_VIDEOS, req, res })) return return next() } @@ -44,7 +44,7 @@ const updateVideoPasswordListValidator = [ // Check if the user who did the request is able to update video passwords const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return + if (!checkUserCanManageVideo({ user, video: res.locals.videoAll, right: UserRight.UPDATE_ANY_VIDEO, req, res })) return return next() } diff --git a/server/core/middlewares/validators/videos/video-source.ts b/server/core/middlewares/validators/videos/video-source.ts index 09aac489b..6d958ab58 100644 --- a/server/core/middlewares/validators/videos/video-source.ts +++ b/server/core/middlewares/validators/videos/video-source.ts @@ -26,7 +26,7 @@ export const videoSourceGetLatestValidator = [ const video = getVideoWithAttributes(res) as MVideoFullLight const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return + if (!checkUserCanManageVideo({ user, video, right: UserRight.UPDATE_ANY_VIDEO, req, res })) return res.locals.videoSource = await VideoSourceModel.loadLatest(video.id) @@ -123,7 +123,7 @@ async function checkCanUpdateVideoFile (options: { const user = res.locals.oauth.token.User const video = res.locals.videoAll - if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return false + if (!checkUserCanManageVideo({ user, video, right: UserRight.UPDATE_ANY_VIDEO, req, res })) return false if (!checkVideoFileCanBeEdited(video, res)) return false diff --git a/server/core/middlewares/validators/videos/video-stats.ts b/server/core/middlewares/validators/videos/video-stats.ts index 632a15975..d9b3d9ccc 100644 --- a/server/core/middlewares/validators/videos/video-stats.ts +++ b/server/core/middlewares/validators/videos/video-stats.ts @@ -89,7 +89,11 @@ export const videoTimeseriesStatsValidator = [ async function commonStatsCheck (req: express.Request, res: express.Response) { if (!await doesVideoExist(req.params.videoId, res, 'all')) return false - if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.SEE_ALL_VIDEOS, res)) return false + if ( + !checkUserCanManageVideo({ user: res.locals.oauth.token.User, video: res.locals.videoAll, right: UserRight.SEE_ALL_VIDEOS, req, res }) + ) { + return false + } return true } diff --git a/server/core/middlewares/validators/videos/video-studio.ts b/server/core/middlewares/validators/videos/video-studio.ts index 7723b15ae..91ee10b51 100644 --- a/server/core/middlewares/validators/videos/video-studio.ts +++ b/server/core/middlewares/validators/videos/video-studio.ts @@ -82,7 +82,7 @@ const videoStudioAddEditionValidator = [ if (!checkVideoFileCanBeEdited(video, res)) return cleanUpReqFiles(req) const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req) + if (!checkUserCanManageVideo({ user, video, right: UserRight.UPDATE_ANY_VIDEO, req, res })) return cleanUpReqFiles(req) // Try to make an approximation of bytes added by the intro/outro const additionalBytes = await approximateIntroOutroAdditionalSize(video, body.tasks, i => getTaskFileFromReq(files, i).path) diff --git a/server/core/middlewares/validators/videos/videos.ts b/server/core/middlewares/validators/videos/videos.ts index 2cc719786..be0add2a0 100644 --- a/server/core/middlewares/validators/videos/videos.ts +++ b/server/core/middlewares/validators/videos/videos.ts @@ -231,7 +231,9 @@ export const videosUpdateValidator = getCommonVideoEditAttributes().concat([ // Check if the user who did the request is able to update the video const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req) + if (!checkUserCanManageVideo({ user, video: res.locals.videoAll, right: UserRight.UPDATE_ANY_VIDEO, req, res })) { + return cleanUpReqFiles(req) + } if (req.body.channelId && !await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) @@ -343,7 +345,15 @@ export const videosRemoveValidator = [ if (!await doesVideoExist(req.params.id, res)) return // Check if the user who did the request is able to delete the video - if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.REMOVE_ANY_VIDEO, res)) return + if ( + !checkUserCanManageVideo({ + user: res.locals.oauth.token.User, + video: res.locals.videoAll, + right: UserRight.REMOVE_ANY_VIDEO, + req, + res + }) + ) return return next() } diff --git a/server/core/middlewares/validators/watched-words.ts b/server/core/middlewares/validators/watched-words.ts index 63b43c2cd..774d30c62 100644 --- a/server/core/middlewares/validators/watched-words.ts +++ b/server/core/middlewares/validators/watched-words.ts @@ -16,7 +16,7 @@ export const manageAccountWatchedWordsListValidator = [ async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - if (!await doesAccountHandleExist({ handle: req.params.accountName, res, checkIsLocal: true, checkManage: true })) return + if (!await doesAccountHandleExist({ handle: req.params.accountName, req, res, checkIsLocal: true, checkManage: true })) return return next() } diff --git a/server/core/models/user/user.ts b/server/core/models/user/user.ts index 4559dbc6e..105f2cfe6 100644 --- a/server/core/models/user/user.ts +++ b/server/core/models/user/user.ts @@ -64,7 +64,7 @@ import { isUserVideosHistoryEnabledValid } from '../../helpers/custom-validators/users.js' import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto.js' -import { DEFAULT_USER_THEME_NAME, NSFW_POLICY_TYPES } from '../../initializers/constants.js' +import { DEFAULT_INSTANCE_THEME_NAME, NSFW_POLICY_TYPES } from '../../initializers/constants.js' import { getThemeOrDefault } from '../../lib/plugins/theme-utils.js' import { AccountModel } from '../account/account.js' import { ActorFollowModel } from '../actor/actor-follow.js' @@ -417,7 +417,7 @@ export class UserModel extends SequelizeModel { declare videoQuotaDaily: number @AllowNull(false) - @Default(DEFAULT_USER_THEME_NAME) + @Default(DEFAULT_INSTANCE_THEME_NAME) @Is('UserTheme', value => throwIfNotValid(value, isThemeNameValid, 'theme')) @Column declare theme: string @@ -1010,7 +1010,7 @@ export class UserModel extends SequelizeModel { id: this.id, username: this.username, email: this.email, - theme: getThemeOrDefault(this.theme, DEFAULT_USER_THEME_NAME), + theme: getThemeOrDefault(this.theme, DEFAULT_INSTANCE_THEME_NAME), pendingEmail: this.pendingEmail, emailPublic: this.emailPublic, diff --git a/server/core/models/video/formatter/video-activity-pub-format.ts b/server/core/models/video/formatter/video-activity-pub-format.ts index 3a565e361..3794f946e 100644 --- a/server/core/models/video/formatter/video-activity-pub-format.ts +++ b/server/core/models/video/formatter/video-activity-pub-format.ts @@ -22,6 +22,7 @@ import { getLocalVideoCommentsActivityPubUrl, getLocalVideoDislikesActivityPubUrl, getLocalVideoLikesActivityPubUrl, + getLocalVideoPlayerSettingsActivityPubUrl, getLocalVideoSharesActivityPubUrl } from '../../../lib/activitypub/url.js' import { MStreamingPlaylistFiles, MUserId, MVideo, MVideoAP, MVideoFile } from '../../../types/models/index.js' @@ -123,6 +124,7 @@ export function videoModelToActivityPubObject (video: MVideoAP): VideoObject { shares: getLocalVideoSharesActivityPubUrl(video), comments: getLocalVideoCommentsActivityPubUrl(video), hasParts: getLocalVideoChaptersActivityPubUrl(video), + playerSettings: getLocalVideoPlayerSettingsActivityPubUrl(video), attributedTo: [ video.VideoChannel.Account.Actor.url, diff --git a/server/core/models/video/player-setting.ts b/server/core/models/video/player-setting.ts new file mode 100644 index 000000000..6cc93dc74 --- /dev/null +++ b/server/core/models/video/player-setting.ts @@ -0,0 +1,184 @@ +import type { + PlayerChannelSettings, + PlayerSettingsObject, + PlayerThemeChannelSetting, + PlayerThemeVideoSetting, + PlayerVideoSettings +} from '@peertube/peertube-models' +import { CONFIG } from '@server/initializers/config.js' +import { DEFAULT_CHANNEL_PLAYER_SETTING_VALUE, DEFAULT_INSTANCE_PLAYER_SETTING_VALUE } from '@server/initializers/constants.js' +import { getLocalChannelPlayerSettingsActivityPubUrl, getLocalVideoPlayerSettingsActivityPubUrl } from '@server/lib/activitypub/url.js' +import { MChannel, MChannelActorLight, MVideoUrl } from '@server/types/models/index.js' +import { MPlayerSetting } from '@server/types/models/video/player-setting.js' +import { Op, Transaction } from 'sequelize' +import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript' +import { SequelizeModel } from '../shared/index.js' +import { VideoChannelModel } from './video-channel.js' +import { VideoModel } from './video.js' + +@Table({ + tableName: 'playerSetting', + indexes: [ + { + fields: [ 'videoId' ], + unique: true + }, + { + fields: [ 'channelId' ], + unique: true + } + ] +}) +export class PlayerSettingModel extends SequelizeModel { + @CreatedAt + declare createdAt: Date + + @UpdatedAt + declare updatedAt: Date + + @AllowNull(false) + @Default(DEFAULT_INSTANCE_PLAYER_SETTING_VALUE) + @Column + declare theme: PlayerThemeVideoSetting | PlayerThemeChannelSetting + + @ForeignKey(() => VideoModel) + @Column + declare videoId: number + + @BelongsTo(() => VideoModel, { + foreignKey: { + allowNull: true + }, + onDelete: 'cascade' + }) + declare Video: Awaited + + @ForeignKey(() => VideoChannelModel) + @Column + declare channelId: number + + @BelongsTo(() => VideoChannelModel, { + foreignKey: { + allowNull: true + }, + onDelete: 'cascade' + }) + declare VideoChannel: Awaited + + static loadByVideoId (videoId: number, transaction?: Transaction) { + return PlayerSettingModel.findOne({ + where: { videoId }, + transaction + }) + } + + static loadByChannelId (channelId: number, transaction?: Transaction) { + return PlayerSettingModel.findOne({ + where: { channelId }, + transaction + }) + } + + static async loadByVideoIdOrChannelId (options: { + videoId: number + channelId: number + transaction?: Transaction + }) { + const { videoId, channelId, transaction } = options + + const results = await PlayerSettingModel.findAll({ + where: { + [Op.or]: [ + { videoId }, + { channelId } + ] + }, + transaction + }) + + const videoSetting = results.find(s => s.videoId === videoId) + const channelSetting = results.find(s => s.channelId === channelId) + + return { videoSetting, channelSetting } + } + + // --------------------------------------------------------------------------- + + static formatVideoPlayerSetting (options: { + videoSetting: MPlayerSetting + channelSetting: MPlayerSetting + }): PlayerVideoSettings { + const { videoSetting, channelSetting } = options + + const channelFormattedSetting = this.formatChannelPlayerSetting({ channelSetting }) + if (!videoSetting) return channelFormattedSetting + + let theme: PlayerThemeVideoSetting + if (videoSetting.theme === DEFAULT_CHANNEL_PLAYER_SETTING_VALUE) { + theme = channelFormattedSetting.theme + } else if (videoSetting.theme === DEFAULT_INSTANCE_PLAYER_SETTING_VALUE) { + theme = CONFIG.DEFAULTS.PLAYER.THEME + } else { + theme = videoSetting.theme + } + + return { + theme + } + } + + static formatVideoPlayerRawSetting (videoSetting: MPlayerSetting) { + return { + theme: videoSetting?.theme ?? DEFAULT_CHANNEL_PLAYER_SETTING_VALUE + } + } + + static formatChannelPlayerSetting (options: { + channelSetting: MPlayerSetting + }): PlayerChannelSettings { + const { channelSetting } = options + + const instanceSetting = { + theme: CONFIG.DEFAULTS.PLAYER.THEME + } + + if (!channelSetting) return instanceSetting + + return { + theme: channelSetting.theme === DEFAULT_INSTANCE_PLAYER_SETTING_VALUE + ? instanceSetting.theme + : channelSetting.theme as PlayerThemeChannelSetting + } + } + + static formatChannelPlayerRawSetting (channelSetting: MPlayerSetting) { + return { + theme: channelSetting?.theme ?? DEFAULT_INSTANCE_PLAYER_SETTING_VALUE + } + } + + // --------------------------------------------------------------------------- + + static formatAPPlayerSetting (options: { + settings: MPlayerSetting + channel: MChannel & MChannelActorLight + video: MVideoUrl + }): PlayerSettingsObject { + const { channel, settings, video } = options + + const json = video + ? this.formatVideoPlayerRawSetting(settings) + : this.formatChannelPlayerRawSetting(settings) + + return { + id: video + ? getLocalVideoPlayerSettingsActivityPubUrl(video) + : getLocalChannelPlayerSettingsActivityPubUrl(channel.Actor.preferredUsername), + + object: video?.url || channel?.Actor.url, + type: 'PlayerSettings', + + theme: json.theme + } + } +} diff --git a/server/core/types/models/video/player-setting.ts b/server/core/types/models/video/player-setting.ts new file mode 100644 index 000000000..b0d62a217 --- /dev/null +++ b/server/core/types/models/video/player-setting.ts @@ -0,0 +1,3 @@ +import { PlayerSettingModel } from '@server/models/video/player-setting.js' + +export type MPlayerSetting = Omit