Add video embed domain privacy

This commit is contained in:
Chocobozzz
2026-02-23 09:39:55 +01:00
parent 0234ece61c
commit 4710ffb0ec
132 changed files with 2543 additions and 902 deletions
+13 -12
View File
@@ -136,6 +136,16 @@ More detailed documentation is available:
* [Server code/architecture](https://docs.joinpeertube.org/contribute/architecture#server)
* [Server development (adding a new feature...)](/support/doc/development/server.md)
### Embed only
The embed is a standalone application built using Vite.
The generated files (HTML entrypoint and multiple JS and CSS files) are served by the Vite server (behind `localhost:5173/videos/embed/:videoUUID` or `localhost:5173/video-playlists/embed/:playlistUUID`).
The following command will compile embed files and run the PeerTube server:
```sh
npm run dev:embed
```
### Client side
To develop on the client side:
@@ -145,16 +155,17 @@ npm run dev:client
```
The API will listen on `localhost:9000` and the frontend on `localhost:3000`.
Embed is also available on `localhost:5173`.
Client files are automatically compiled on change, and the web browser will
reload them automatically thanks to hot module replacement.
More detailed documentation is available:
* [Client code/architecture](https://docs.joinpeertube.org/contribute/architecture#client)
### Client and server side
### Client with embed and server side
The API will listen on `localhost:9000` and the frontend on `localhost:3000`.
Embed is also available on `localhost:5173`.
File changes are automatically recompiled, injected in the web browser (no need to refresh manually)
and the web server is automatically restarted.
@@ -162,16 +173,6 @@ and the web server is automatically restarted.
npm run dev
```
### Embed
The embed is a standalone application built using Vite.
The generated files (HTML entrypoint and multiple JS and CSS files) are served by the Vite server (behind `localhost:5173/videos/embed/:videoUUID` or `localhost:5173/video-playlists/embed/:playlistUUID`).
The following command will compile embed files and run the PeerTube server:
```sh
npm run dev:embed
```
### RTL layout
To test RTL (right-to-left) layout using `ar` locale:
@@ -2,7 +2,7 @@ import { inject } from '@angular/core'
import { ActivatedRouteSnapshot, ResolveFn, RouterStateSnapshot, Routes } from '@angular/router'
import { CanDeactivateGuard, ServerService, UserRightGuard } from '@app/core'
import { CustomPageService } from '@app/shared/shared-main/custom-page/custom-page.service'
import { CustomConfig, UserRight, VideoCommentPolicyType, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models'
import { CustomConfig, UserRight, VideoCommentPolicyType, ConstantLabel, VideoPrivacyType } from '@peertube/peertube-models'
import { map } from 'rxjs'
import { AdminConfigComponent } from './admin-config.component'
import {
@@ -28,26 +28,26 @@ export const homepageResolver: ResolveFn<string> = (_route: ActivatedRouteSnapsh
.pipe(map(({ content }) => content))
}
export const categoriesResolver: ResolveFn<VideoConstant<number>[]> = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
export const categoriesResolver: ResolveFn<ConstantLabel<number>[]> = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
return inject(ServerService).getVideoCategories()
}
export const languagesResolver: ResolveFn<VideoConstant<string>[]> = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
export const languagesResolver: ResolveFn<ConstantLabel<string>[]> = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
return inject(ServerService).getVideoLanguages()
}
export const licencesResolver: ResolveFn<VideoConstant<number>[]> = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
export const licencesResolver: ResolveFn<ConstantLabel<number>[]> = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
return inject(ServerService).getVideoLicences()
}
export const privaciesResolver: ResolveFn<VideoConstant<VideoPrivacyType>[]> = (
export const privaciesResolver: ResolveFn<ConstantLabel<VideoPrivacyType>[]> = (
_route: ActivatedRouteSnapshot,
_state: RouterStateSnapshot
) => {
return inject(ServerService).getVideoPrivacies()
}
export const commentPoliciesResolver: ResolveFn<VideoConstant<VideoCommentPolicyType>[]> = (
export const commentPoliciesResolver: ResolveFn<ConstantLabel<VideoCommentPolicyType>[]> = (
_route: ActivatedRouteSnapshot,
_state: RouterStateSnapshot
) => {
@@ -24,7 +24,7 @@ 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, VideoCommentPolicyType, ConstantLabel, VideoPrivacyType } from '@peertube/peertube-models'
import { Subscription } from 'rxjs'
import { pairwise } from 'rxjs/operators'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
@@ -264,9 +264,9 @@ export class AdminConfigGeneralComponent implements OnInit, OnDestroy, CanCompon
this.customConfig = this.route.parent.snapshot.data['customConfig']
const data = this.route.snapshot.data as {
licences: VideoConstant<number>[]
privacies: VideoConstant<VideoPrivacyType>[]
commentPolicies: VideoConstant<VideoCommentPolicyType>[]
licences: ConstantLabel<number>[]
privacies: ConstantLabel<VideoPrivacyType>[]
commentPolicies: ConstantLabel<VideoCommentPolicyType>[]
}
this.privacyOptions = this.videoService.explainedPrivacyLabels(data.privacies).videoPrivacies
@@ -20,7 +20,7 @@ import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.serv
import { SelectOptionsComponent } from '@app/shared/shared-forms/select/select-options.component'
import { SelectRadioComponent } from '@app/shared/shared-forms/select/select-radio.component'
import { getCompleteLocale, I18N_LOCALES } from '@peertube/peertube-core-utils'
import { ActorImage, CustomConfig, NSFWPolicyType, VideoConstant } from '@peertube/peertube-models'
import { ActorImage, CustomConfig, NSFWPolicyType, ConstantLabel } from '@peertube/peertube-models'
import merge from 'lodash-es/merge'
import { Subscription } from 'rxjs'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
@@ -145,8 +145,8 @@ export class AdminConfigInformationComponent implements OnInit, OnDestroy, CanCo
this.customConfig = this.route.parent.snapshot.data['customConfig']
const data = this.route.snapshot.data as {
languages: VideoConstant<string>[]
categories: VideoConstant<number>[]
languages: ConstantLabel<string>[]
categories: ConstantLabel<number>[]
}
this.languageItems = data.languages.map(l => ({ label: l.label, id: l.id }))
@@ -10,7 +10,7 @@
<div class="modal-body">
<form novalidate [formGroup]="form" (ngSubmit)="submit()">
<div class="form-group">
<label i18n for="hostsOrHandles">1 host (without "http://"), account handle or channel handle per line</label>
<label i18n for="hostsOrHandles">1 domain (without "http://"), account handle or channel handle per line</label>
<textarea
[placeholder]="placeholder" formControlName="hostsOrHandles" type="text" id="hostsOrHandles" name="hostsOrHandles"
@@ -176,6 +176,30 @@
}
}
}
@case (12) { <!-- VideoChannelActivityAction.SEND_OWNERSHIP_REQUEST) -->
<ng-container i18n>
<strong>{{ account }}</strong> sent an ownership request to {{ a.targetAccount.displayName }} for video <a [routerLink]="[ '/w', a.video.shortUUID ]">{{ a.video.name }}</a>
</ng-container>
}
@case (13) { <!-- VideoChannelActivityAction.ACCEPT_OWNERSHIP_REQUEST) -->
<ng-container i18n>
<strong>{{ account }}</strong> accepted an ownership request for video <a [routerLink]="[ '/w', a.video.shortUUID ]">{{ a.video.name }}</a>
</ng-container>
}
@case (14) { <!-- VideoChannelActivityAction.REFUSE_OWNERSHIP_REQUEST) -->
<ng-container i18n>
<strong>{{ account }}</strong> refused an ownership request for video <a [routerLink]="[ '/w', a.video.shortUUID ]">{{ a.video.name }}</a>
</ng-container>
}
@case (15) { <!-- VideoChannelActivityAction.UPDATE_EMBED_POLICY) -->
<ng-container i18n>
<strong>{{ account }}</strong> updated embed policy of video <a [routerLink]="[ '/w', a.video.shortUUID ]">{{ a.video.name }}</a>
</ng-container>
}
}
</div>
@@ -1,13 +1,13 @@
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.model'
import { VideoConstant, VideoPlaylistPrivacyType } from '@peertube/peertube-models'
import { ConstantLabel, VideoPlaylistPrivacyType } from '@peertube/peertube-models'
import { SelectChannelItem } from '../../../types/select-options-item.model'
export abstract class MyVideoPlaylistEdit extends FormReactive {
// Declare it here to avoid errors in create template
videoPlaylistToUpdate: VideoPlaylist
userVideoChannels: SelectChannelItem[] = []
videoPlaylistPrivacies: VideoConstant<VideoPlaylistPrivacyType>[] = []
videoPlaylistPrivacies: ConstantLabel<VideoPlaylistPrivacyType>[] = []
abstract isCreation (): boolean
abstract getFormButtonTitle (): string
@@ -2,7 +2,7 @@ import { Component, OnInit, inject, input, output } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { ServerService } from '@app/core'
import { AdvancedSearch } from '@app/shared/shared-search/advanced-search.model'
import { HTMLServerConfig, VideoConstant } from '@peertube/peertube-models'
import { HTMLServerConfig, ConstantLabel } from '@peertube/peertube-models'
import { SelectTagsComponent } from '../shared/shared-forms/select/select-tags.component'
type FormOption = { id: string, label: string }
@@ -19,9 +19,9 @@ export class SearchFiltersComponent implements OnInit {
advancedSearch = input<AdvancedSearch>(new AdvancedSearch())
filtered = output<AdvancedSearch>()
videoCategories: VideoConstant<number>[] = []
videoLicences: VideoConstant<number>[] = []
videoLanguages: VideoConstant<string>[] = []
videoCategories: ConstantLabel<number>[] = []
videoLicences: ConstantLabel<number>[] = []
videoLanguages: ConstantLabel<string>[] = []
publishedDateRanges: FormOption[] = []
sorts: FormOption[] = []
@@ -1,5 +1,5 @@
import { Video } from '@app/shared/shared-main/video/video.model'
import { VideoChannelSummary, VideoConstant, VideosOverview as VideosOverviewServer } from '@peertube/peertube-models'
import { VideoChannelSummary, ConstantLabel, VideosOverview as VideosOverviewServer } from '@peertube/peertube-models'
export class VideosOverview implements VideosOverviewServer {
channels: {
@@ -8,7 +8,7 @@ export class VideosOverview implements VideosOverviewServer {
}[]
categories: {
category: VideoConstant<number>
category: ConstantLabel<number>
videos: Video[]
}[]
@@ -1,15 +1,16 @@
import { 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 { VideoEmbedPrivacyService } from '@app/shared/shared-video/video-embed-privacy.service'
import { VideoStateMessageService } from '@app/shared/shared-video/video-state-message.service'
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'
import { VideoStudioService } from '../shared-manage/studio/video-studio.service'
import { VideoManageController } from '../shared-manage/video-manage-controller.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 [
{
@@ -25,7 +26,8 @@ export default [
VideoUploadService,
VideoStudioService,
VideoStateMessageService,
PlayerSettingsService
PlayerSettingsService,
VideoEmbedPrivacyService
],
resolve: {
resolverData: VideoManageResolver
@@ -9,13 +9,15 @@ import { VideoPasswordService } from '@app/shared/shared-main/video/video-passwo
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 { VideoEmbedPrivacyService } from '@app/shared/shared-video/video-embed-privacy.service'
import {
ConstantLabel,
LiveVideo,
PlayerVideoSettings,
UserVideoQuota,
VideoCaption,
VideoChapter,
VideoConstant,
VideoEmbedPrivacy,
VideoPassword,
VideoPrivacy,
VideoPrivacyType,
@@ -36,9 +38,10 @@ export type VideoManageResolverData = {
live: LiveVideo
videoPasswords: VideoPassword[]
userQuota: UserVideoQuota
privacies: VideoConstant<VideoPrivacyType>[]
privacies: ConstantLabel<VideoPrivacyType>[]
videoEdit: VideoEdit
playerSettings: PlayerVideoSettings
embedPrivacy: VideoEmbedPrivacy
}
@Injectable()
@@ -53,6 +56,7 @@ export class VideoManageResolver {
private userService = inject(UserService)
private serverService = inject(ServerService)
private playerSettingsService = inject(PlayerSettingsService)
private videoEmbedPrivacyService = inject(VideoEmbedPrivacyService)
resolve (route: ActivatedRouteSnapshot) {
const uuid: string = route.params['uuid']
@@ -71,7 +75,8 @@ export class VideoManageResolver {
videoPasswords,
userQuota,
privacies,
playerSettings
playerSettings,
embedPrivacy
]) => {
const videoEdit = await VideoEdit.createFromAPI(this.serverService.getHTMLConfig(), {
video,
@@ -80,7 +85,8 @@ export class VideoManageResolver {
live,
videoSource,
playerSettings,
videoPasswords: videoPasswords.map(p => p.password)
videoPasswords: videoPasswords.map(p => p.password),
embedPrivacy
})
return {
@@ -94,7 +100,8 @@ export class VideoManageResolver {
userQuota,
privacies,
videoEdit,
playerSettings
playerSettings,
embedPrivacy
} satisfies VideoManageResolverData
}
),
@@ -140,7 +147,9 @@ export class VideoManageResolver {
this.serverService.getVideoPrivacies(),
this.playerSettingsService.getVideoSettings({ videoId: video.uuid, raw: true })
this.playerSettingsService.getVideoSettings({ videoId: video.uuid, raw: true }),
this.videoEmbedPrivacyService.getPrivacy({ videoId: video.uuid })
] as const
}
}
@@ -7,6 +7,8 @@ import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerServ
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { VideoImportService } from '@app/shared/shared-main/video/video-import.service'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service'
import { VideoEmbedPrivacyService } from '@app/shared/shared-video/video-embed-privacy.service'
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
import { LoadingBarService } from '@ngx-loading-bar/core'
import { PeerTubeProblemDocument, ServerErrorCode, UserVideoQuota, VideoPrivacyType } from '@peertube/peertube-models'
@@ -18,7 +20,6 @@ import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.co
import { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
import { VideoManageContainerComponent } from '../../shared-manage/video-manage-container.component'
import { DragDropDirective } from '../shared/drag-drop.directive'
import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service'
const debugLogger = debug('peertube:video-publish')
@@ -52,6 +53,7 @@ export class VideoImportTorrentComponent implements OnInit, AfterViewInit, CanCo
private serverService = inject(ServerService)
private manageController = inject(VideoManageController)
private route = inject(ActivatedRoute)
private videoEmbedPrivacyService = inject(VideoEmbedPrivacyService)
readonly userChannels = input.required<SelectChannelItem[]>()
readonly userQuota = input.required<UserVideoQuota>()
@@ -136,12 +138,13 @@ export class VideoImportTorrentComponent implements OnInit, AfterViewInit, CanCo
.pipe(switchMap(({ video }) => {
return forkJoin([
this.videoService.getVideo({ videoId: video.uuid }),
this.playerSettingsService.getVideoSettings({ videoId: video.uuid, raw: true })
this.playerSettingsService.getVideoSettings({ videoId: video.uuid, raw: true }),
this.videoEmbedPrivacyService.getPrivacy({ videoId: video.uuid })
])
}))
.subscribe({
next: async ([ video, playerSettings ]) => {
await videoEdit.loadFromAPI({ video, playerSettings, loadPrivacy: false })
next: async ([ video, playerSettings, embedPrivacy ]) => {
await videoEdit.loadFromAPI({ video, playerSettings, embedPrivacy, loadPrivacy: false })
this.loadingBar.useRef('import-video').complete()
@@ -9,6 +9,8 @@ import { VideoCaptionService } from '@app/shared/shared-main/video-caption/video
import { VideoChapterService } from '@app/shared/shared-main/video/video-chapter.service'
import { VideoImportService } from '@app/shared/shared-main/video/video-import.service'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service'
import { VideoEmbedPrivacyService } from '@app/shared/shared-video/video-embed-privacy.service'
import { LoadingBarService } from '@ngx-loading-bar/core'
import { UserVideoQuota, VideoPrivacyType } from '@peertube/peertube-models'
import debug from 'debug'
@@ -19,7 +21,6 @@ import { SelectChannelComponent } from '../../../shared/shared-forms/select/sele
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
import { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
import { VideoManageContainerComponent } from '../../shared-manage/video-manage-container.component'
import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service'
const debugLogger = debug('peertube:video-publish')
@@ -51,6 +52,7 @@ export class VideoImportUrlComponent implements OnInit, AfterViewInit, CanCompon
private chapterService = inject(VideoChapterService)
private captionService = inject(VideoCaptionService)
private playerSettingsService = inject(PlayerSettingsService)
private videoEmbedPrivacyService = inject(VideoEmbedPrivacyService)
readonly userChannels = input.required<SelectChannelItem[]>()
readonly userQuota = input.required<UserVideoQuota>()
@@ -127,13 +129,22 @@ export class VideoImportUrlComponent implements OnInit, AfterViewInit, CanCompon
this.captionService.listCaptions(video.uuid),
this.chapterService.getChapters({ videoId: video.uuid }),
this.playerSettingsService.getVideoSettings({ videoId: video.uuid, raw: true }),
this.videoService.getVideo({ videoId: video.uuid })
]).pipe(map(([ { data: captions }, { chapters }, playerSettings, video ]) => ({ captions, chapters, playerSettings, video })))
this.videoService.getVideo({ videoId: video.uuid }),
this.videoEmbedPrivacyService.getPrivacy({ videoId: video.uuid })
]).pipe(
map(([ { data: captions }, { chapters }, playerSettings, video, embedPrivacy ]) => ({
captions,
chapters,
playerSettings,
video,
embedPrivacy
}))
)
})
)
.subscribe({
next: async ({ video, playerSettings, captions, chapters }) => {
await videoEdit.loadFromAPI({ video, captions, playerSettings, chapters, loadPrivacy: false })
next: async ({ video, playerSettings, captions, chapters, embedPrivacy }) => {
await videoEdit.loadFromAPI({ video, captions, playerSettings, chapters, embedPrivacy, loadPrivacy: false })
this.loadingBar.useRef('import-video').complete()
@@ -6,6 +6,7 @@ import { VideoManageController } from '@app/+videos-publish-manage/shared-manage
import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } 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 { VideoEmbedPrivacyService } from '@app/shared/shared-video/video-embed-privacy.service'
import { LiveVideoLatencyMode, PeerTubeProblemDocument, ServerErrorCode, UserVideoQuota, VideoPrivacyType } from '@peertube/peertube-models'
import debug from 'debug'
import { forkJoin, map, switchMap } from 'rxjs'
@@ -40,6 +41,7 @@ export class VideoGoLiveComponent implements OnInit, AfterViewInit, CanComponent
private manageController = inject(VideoManageController)
private route = inject(ActivatedRoute)
private playerSettingsService = inject(PlayerSettingsService)
private videoEmbedPrivacyService = inject(VideoEmbedPrivacyService)
readonly userChannels = input.required<SelectChannelItem[]>()
readonly userQuota = input.required<UserVideoQuota>()
@@ -104,14 +106,15 @@ export class VideoGoLiveComponent implements OnInit, AfterViewInit, CanComponent
switchMap(({ video }) => {
return forkJoin([
this.liveVideoService.getVideoLive(video.uuid),
this.playerSettingsService.getVideoSettings({ videoId: video.uuid, raw: true })
]).pipe(map(([ live, playerSettings ]) => ({ live, playerSettings, video })))
this.playerSettingsService.getVideoSettings({ videoId: video.uuid, raw: true }),
this.videoEmbedPrivacyService.getPrivacy({ videoId: video.uuid })
]).pipe(map(([ live, playerSettings, embedPrivacy ]) => ({ live, playerSettings, embedPrivacy, video })))
})
)
.subscribe({
next: async ({ video: { id, uuid, shortUUID }, live, playerSettings }) => {
next: async ({ video: { id, uuid, shortUUID }, live, playerSettings, embedPrivacy }) => {
videoEdit.loadAfterPublish({ video: { id, uuid, shortUUID } })
await videoEdit.loadFromAPI({ live, playerSettings, loadPrivacy: false })
await videoEdit.loadFromAPI({ live, playerSettings, embedPrivacy, loadPrivacy: false })
debugLogger(`Live published`)
@@ -12,6 +12,7 @@ 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 { VideoEmbedPrivacyService } from '@app/shared/shared-video/video-embed-privacy.service'
const debugLogger = debug('peertube:video-publish')
@@ -49,7 +50,8 @@ export default [
LiveVideoService,
I18nPrimengCalendarService,
VideoUploadService,
VideoStudioService
VideoStudioService,
VideoEmbedPrivacyService
],
resolve: {
resolverData: VideoPublishResolver
@@ -5,7 +5,7 @@ import { AuthService, AuthUser, CanComponentDeactivate, CanDeactivateGuard, Hook
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import { NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'
import { HTMLServerConfig, UserVideoQuota, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models'
import { HTMLServerConfig, UserVideoQuota, ConstantLabel, VideoPrivacyType } from '@peertube/peertube-models'
import { SelectChannelItem } from 'src/types'
import { HelpComponent } from '../../shared/shared-main/buttons/help.component'
import { ChannelsSetupMessageComponent } from '../../shared/shared-main/channel/channels-setup-message.component'
@@ -79,7 +79,7 @@ export class VideoPublishComponent implements OnInit, CanComponentDeactivate {
userChannels: SelectChannelItem[]
userQuota: UserVideoQuota
privacies: VideoConstant<VideoPrivacyType>[]
privacies: ConstantLabel<VideoPrivacyType>[]
private publishedIdQuery: string
private uploadingQuery: string
@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core'
import { AuthService, ServerService, UserService } from '@app/core'
import { listUserChannelsForSelect } from '@app/helpers'
import { UserVideoQuota, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models'
import { UserVideoQuota, ConstantLabel, VideoPrivacyType } from '@peertube/peertube-models'
import { forkJoin } from 'rxjs'
import { map } from 'rxjs/operators'
import { SelectChannelItem } from '../../../types'
@@ -9,7 +9,7 @@ import { SelectChannelItem } from '../../../types'
export type VideoPublishResolverData = {
videoChannels: SelectChannelItem[]
userQuota: UserVideoQuota
privacies: VideoConstant<VideoPrivacyType>[]
privacies: ConstantLabel<VideoPrivacyType>[]
}
@Injectable()
@@ -7,7 +7,7 @@ import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.serv
import { SelectOptionsComponent } from '@app/shared/shared-forms/select/select-options.component'
import { VideoCaptionEdit } from '@app/+videos-publish-manage/shared-manage/common/video-caption-edit.model'
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { HTMLServerConfig, VideoConstant } from '@peertube/peertube-models'
import { HTMLServerConfig, ConstantLabel } from '@peertube/peertube-models'
import { ReactiveFileComponent } from '../../../shared/shared-forms/reactive-file.component'
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
@@ -29,7 +29,7 @@ export class VideoCaptionAddModalComponent extends FormReactive implements OnIni
readonly modal = viewChild<ElementRef>('modal')
videoCaptionLanguages: VideoConstant<string>[] = []
videoCaptionLanguages: ConstantLabel<string>[] = []
private openedModal: NgbModalRef
@@ -11,7 +11,7 @@ import { VideoCaptionService } from '@app/shared/shared-main/video-caption/video
import { EmbedComponent } from '@app/shared/shared-main/video/embed.component'
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { millisecondsToVttTime, sortBy, timeToInt } from '@peertube/peertube-core-utils'
import { HTMLServerConfig, VideoConstant } from '@peertube/peertube-models'
import { HTMLServerConfig, ConstantLabel } from '@peertube/peertube-models'
import { parse } from '@plussub/srt-vtt-parser'
import { PeerTubePlayer } from '../../../../standalone/embed-player-api/player'
import { ConfirmService, Notifier, ServerService } from '../../../core'
@@ -74,7 +74,7 @@ export class VideoCaptionEditModalComponent extends FormReactive implements OnIn
activeSegment: Segment
videoCaptionLanguages: VideoConstant<string>[] = []
videoCaptionLanguages: ConstantLabel<string>[] = []
timestampParser = this.webvttToMS.bind(this)
timestampFormatter = millisecondsToVttTime
@@ -1,4 +1,3 @@
import { Component, OnInit, inject, viewChild } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { VideoCaptionEdit, VideoCaptionWithPathEdit } from '@app/+videos-publish-manage/shared-manage/common/video-caption-edit.model'
@@ -6,7 +5,7 @@ import { ServerService } from '@app/core'
import { removeElementFromArray } from '@app/helpers'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { PTDatePipe } from '@app/shared/shared-main/common/date.pipe'
import { HTMLServerConfig, VideoConstant } from '@peertube/peertube-models'
import { HTMLServerConfig, ConstantLabel } from '@peertube/peertube-models'
import debug from 'debug'
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component'
@@ -51,7 +50,7 @@ export class VideoCaptionsComponent implements OnInit {
displayTranscriptionInfo: boolean
videoEdit: VideoEdit
videoLanguages: VideoConstant<string>[] = []
videoLanguages: ConstantLabel<string>[] = []
private initialVideoCaptions: string[] = []
@@ -12,6 +12,9 @@ import {
VideoChapter,
VideoCreate,
VideoDetails,
VideoEmbedPrivacy,
VideoEmbedPrivacyPolicy,
VideoEmbedPrivacyUpdate,
VideoImportCreate,
VideoPrivacy,
VideoPrivacyType,
@@ -28,6 +31,7 @@ import debug from 'debug'
import { Jsonify, SharedUnionFieldsDeep } from 'type-fest'
import { VideoCaptionWithPathEdit } from './video-caption-edit.model'
import { VideoChaptersEdit } from './video-chapters-edit.model'
import { splitAndGetNotEmpty } from '@root-helpers/string'
const debugLogger = debug('peertube:video-manage:video-edit')
@@ -69,6 +73,11 @@ type StudioForm = {
type PlayerSettingsForm = PlayerVideoSettingsUpdate
type EmbedPrivacyForm = {
videoPrivacyEmbedEnableAllowlist?: boolean
videoPrivacyEmbedAllowlistDomains?: string
}
// ---------------------------------------------------------------------------
type LoadFromPublishOptions = Required<Pick<VideoCreate, 'channelId' | 'support'>> & Partial<Pick<VideoCreate, 'name'>> & {
@@ -123,6 +132,7 @@ type UpdateFromAPIOptions = {
videoPasswords?: string[]
videoSource?: VideoSource
playerSettings: PlayerVideoSettings
embedPrivacy: VideoEmbedPrivacy
}
// ---------------------------------------------------------------------------
@@ -151,7 +161,8 @@ export class VideoEdit {
private live: LiveUpdate
private replaceFile: File
private studioTasks: VideoStudioTask[] = []
private playerSettings: PlayerVideoSettings
private playerSettings: PlayerVideoSettingsUpdate
private embedPrivacy: VideoEmbedPrivacyUpdate
private videoImport: Pick<VideoImportCreate, 'magnetUri' | 'torrentfile' | 'targetUrl'>
@@ -202,7 +213,9 @@ export class VideoEdit {
thumbnailfile?: { size: number }
live?: LiveUpdate
playerSettings?: PlayerVideoSettings
playerSettings?: PlayerVideoSettingsUpdate
embedPrivacy?: VideoEmbedPrivacyUpdate
pluginData?: any
pluginDefaults?: Record<string, string | boolean>
@@ -307,6 +320,7 @@ export class VideoEdit {
// ---------------------------------------------------------------------------
// Build a new VideoEdit model based on data coming from the API
static async createFromAPI (serverConfig: HTMLServerConfig, options: UpdateFromAPIOptions) {
const videoEdit = new VideoEdit(serverConfig)
await videoEdit.loadFromAPI(options)
@@ -315,13 +329,14 @@ export class VideoEdit {
}
async loadFromAPI (options: UpdateFromAPIOptions & { loadPrivacy?: boolean }) {
const { video, videoPasswords, live, chapters, captions, videoSource, playerSettings, loadPrivacy = true } = options
const { video, videoPasswords, live, chapters, captions, videoSource, playerSettings, embedPrivacy, loadPrivacy = true } = options
debugLogger('Load from API', options)
this.loadVideo({ video, videoPasswords, saveInStore: true, loadPrivacy })
this.loadLive(live)
this.loadPlayerSettings(playerSettings)
this.loadEmbedPrivacy(embedPrivacy)
if (captions !== undefined) {
this.captions = captions
@@ -488,6 +503,18 @@ export class VideoEdit {
this.saveStore.playerSettings = buildObj()
}
private loadEmbedPrivacy (embedPrivacy: UpdateFromAPIOptions['embedPrivacy']) {
const buildObj = () => {
return {
policy: embedPrivacy.policy.id,
domains: embedPrivacy.domains ?? []
}
}
this.embedPrivacy = buildObj()
this.saveStore.embedPrivacy = buildObj()
}
loadAfterPublish (options: {
video: Pick<VideoDetails, 'id' | 'uuid' | 'shortUUID'>
}) {
@@ -845,16 +872,36 @@ export class VideoEdit {
}
}
toPlayerSettingsUpdate (): PlayerVideoSettingsUpdate {
if (!this.playerSettings) return undefined
// ---------------------------------------------------------------------------
loadFromEmbedPrivacyForm (value: EmbedPrivacyForm) {
this.embedPrivacy = {
policy: value.videoPrivacyEmbedEnableAllowlist
? VideoEmbedPrivacyPolicy.ALLOWLIST
: VideoEmbedPrivacyPolicy.ALL_ALLOWED,
domains: splitAndGetNotEmpty(value.videoPrivacyEmbedAllowlistDomains)
}
}
toEmbedPrivacyFormPatch (): Required<EmbedPrivacyForm> {
if (!this.embedPrivacy) {
return {
videoPrivacyEmbedEnableAllowlist: false,
videoPrivacyEmbedAllowlistDomains: ''
}
}
return {
theme: this.playerSettings.theme
videoPrivacyEmbedEnableAllowlist: this.embedPrivacy.policy === VideoEmbedPrivacyPolicy.ALLOWLIST,
videoPrivacyEmbedAllowlistDomains: this.embedPrivacy.domains.join('\n')
}
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
getVideoSource () {
return this.metadata.videoSource
}
@@ -887,6 +934,10 @@ export class VideoEdit {
return this.playerSettings
}
getEmbedPrivacy () {
return this.embedPrivacy
}
getStudioTasksSummary () {
return this.getStudioTasks().map(t => {
if (t.name === 'add-intro') {
@@ -1018,6 +1069,21 @@ export class VideoEdit {
return changes
}
hasEmbedPrivacyChanges () {
if (!this.embedPrivacy) return false
if (!this.saveStore.embedPrivacy) return true
const changes = !this.areSameObjects(this.embedPrivacy, this.saveStore.embedPrivacy)
debugLogger('Check if embed privacy has changes', {
embedPrivacy: this.embedPrivacy,
saveEmbedPrivacy: this.saveStore.embedPrivacy,
changes
})
return changes
}
// ---------------------------------------------------------------------------
hasPendingChanges () {
@@ -1028,7 +1094,8 @@ export class VideoEdit {
this.hasChaptersChanges() ||
this.hasCommonChanges() ||
this.hasPluginDataChanges() ||
this.hasPlayerSettingsChanges()
this.hasPlayerSettingsChanges() ||
this.hasEmbedPrivacyChanges()
}
// ---------------------------------------------------------------------------
@@ -23,6 +23,10 @@ input[type="text"] {
@include peertube-input-text(100%);
}
textarea {
@include peertube-textarea(100%, 150px);
}
p-calendar {
display: block;
@@ -53,8 +53,6 @@
}
</div>
<my-peertube-checkbox inputName="downloadEnabled" formControlName="downloadEnabled" i18n-labelText labelText="Enable download"></my-peertube-checkbox>
<div class="form-group" formGroupName="playerSettings">
<label i18n for="playerSettingsTheme">Player Theme</label>
<div class="form-group-description" i18n>Only used by the web player</div>
@@ -62,6 +60,8 @@
<my-select-player-theme formControlName="theme" inputId="playerSettingsTheme" mode="video" [channel]="videoChannel">
</my-select-player-theme>
</div>
<my-peertube-checkbox inputName="downloadEnabled" formControlName="downloadEnabled" i18n-labelText labelText="Enable download"></my-peertube-checkbox>
</div>
</div>
@@ -10,7 +10,7 @@ import {
HTMLServerConfig,
LiveVideoLatencyMode,
LiveVideoLatencyModeType,
VideoConstant,
ConstantLabel,
VideoPrivacy,
VideoPrivacyType,
VideoState
@@ -85,7 +85,7 @@ export class VideoLiveSettingsComponent implements OnInit, OnDestroy {
calendarDateFormat: string
myYearRange: string
replayPrivacies: VideoConstant<VideoPrivacyType>[] = []
replayPrivacies: ConstantLabel<VideoPrivacyType>[] = []
latencyModes: SelectOptionsItem[] = [
{
@@ -35,7 +35,7 @@ import {
HTMLServerConfig,
RegisterClientFormFieldOptions,
RegisterClientVideoFieldOptions,
VideoConstant,
ConstantLabel,
VideoPrivacy,
VideoPrivacyType
} from '@peertube/peertube-models'
@@ -132,10 +132,10 @@ export class VideoMainInfoComponent implements OnInit, OnDestroy {
forbidScheduledPublication: boolean
hideWaitTranscoding: boolean
videoPrivacies: VideoConstant<VideoEditPrivacyType>[] = []
videoCategories: VideoConstant<number>[] = []
videoLicences: VideoConstant<number>[] = []
videoLanguages: VideoConstant<string>[] = []
videoPrivacies: ConstantLabel<VideoEditPrivacyType>[] = []
videoCategories: ConstantLabel<number>[] = []
videoLicences: ConstantLabel<number>[] = []
videoLanguages: ConstantLabel<string>[] = []
pluginDataFormGroup: FormGroup
@@ -5,22 +5,8 @@
</h2>
<form [formGroup]="form" id="video-manage-form">
<div class="form-columns">
<div>
<div class="form-group">
<my-select-radio
i18n-label label="Comments policy"
[items]="commentPolicies"
inputId="commentsPolicy"
formControlName="commentsPolicy"
>
<div class="form-group-description" i18n>
You can require comments to be approved depending on <a routerLink="/my-account/auto-tag-policies" target="_blank">your auto-tags policies</a>
</div>
</my-select-radio>
</div>
<my-peertube-checkbox inputName="nsfw" formControlName="nsfw">
<ng-template ptTemplate="label">
<ng-container i18n>Your video contains sensitive content</ng-container>
@@ -49,11 +35,53 @@
<ng-container i18n>Potentially sexually explicit content</ng-container>
</ng-template>
</my-peertube-checkbox>
</ng-container>
</my-peertube-checkbox>
<div class="form-group">
<my-select-radio i18n-label label="Comments policy" [items]="commentPolicies" inputId="commentsPolicy" formControlName="commentsPolicy">
<div class="form-group-description" i18n>
You can require comments to be approved depending on <a routerLink="/my-account/auto-tag-policies" target="_blank">your auto-tags policies</a>
</div>
</my-select-radio>
</div>
</div>
<div>
<div class="form-group">
<my-peertube-checkbox inputName="videoPrivacyEmbedEnableAllowlist" formControlName="videoPrivacyEmbedEnableAllowlist">
<ng-template ptTemplate="label">
<ng-container i18n>Restrict embed to the following domains</ng-container>
</ng-template>
<ng-container ngProjectAs="extra">
<div class="form-group">
<label i18n for="videoPrivacyEmbedAllowlistDomains">Allowed domains</label>
<div class="form-group-description">1 domain (without "https://") per line</div>
<textarea
placeholder="example.com"
formControlName="videoPrivacyEmbedAllowlistDomains"
type="text"
id="videoPrivacyEmbedAllowlistDomains"
name="videoPrivacyEmbedAllowlistDomains"
class="form-control"
[ngClass]="{ 'input-error': formErrors['videoPrivacyEmbedAllowlistDomains'] }"
></textarea>
@if (formErrors.videoPrivacyEmbedAllowlistDomains) {
<div class="form-error" role="alert">
{{ formErrors.videoPrivacyEmbedAllowlistDomains }}
@if (form.controls['videoPrivacyEmbedAllowlistDomains'].errors.validHosts) {
<div>{{ form.controls['videoPrivacyEmbedAllowlistDomains'].errors.validHosts.value }}</div>
}
</div>
}
</div>
</ng-container>
</my-peertube-checkbox>
</div>
</div>
</div>
</form>
@@ -6,7 +6,7 @@ import { ServerService } from '@app/core'
import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model'
import { VIDEO_NSFW_SUMMARY_VALIDATOR } from '@app/shared/form-validators/video-validators'
import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service'
import { HTMLServerConfig, VideoCommentPolicyType, VideoConstant } from '@peertube/peertube-models'
import { HTMLServerConfig, VideoCommentPolicyType, ConstantLabel } from '@peertube/peertube-models'
import debug from 'debug'
import { Subscription } from 'rxjs'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
@@ -14,6 +14,7 @@ import { SelectRadioComponent } from '../../../shared/shared-forms/select/select
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive'
import { VideoManageController } from '../video-manage-controller.service'
import { UNIQUE_HOSTS_VALIDATOR } from '@app/shared/form-validators/host-validators'
const debugLogger = debug('peertube:video-manage')
@@ -25,6 +26,9 @@ type Form = {
nsfwSummary: FormControl<string>
commentPolicies: FormControl<VideoCommentPolicyType>
videoPrivacyEmbedEnableAllowlist: FormControl<boolean>
videoPrivacyEmbedAllowlistDomains: FormControl<string>
}
@Component({
@@ -53,7 +57,7 @@ export class VideoModerationComponent implements OnInit, OnDestroy {
formErrors: FormReactiveErrors = {}
validationMessages: FormReactiveMessages = {}
commentPolicies: VideoConstant<VideoCommentPolicyType>[] = []
commentPolicies: ConstantLabel<VideoCommentPolicyType>[] = []
serverConfig: HTMLServerConfig
private updatedSub: Subscription
@@ -74,13 +78,15 @@ export class VideoModerationComponent implements OnInit, OnDestroy {
private buildForm () {
const videoEdit = this.manageController.getStore().videoEdit
const defaultValues = videoEdit.toCommonFormPatch()
const defaultValues = { ...videoEdit.toCommonFormPatch(), ...videoEdit.toEmbedPrivacyFormPatch() }
const obj: BuildFormArgument = {
commentsPolicy: null,
nsfw: null,
nsfwFlagViolent: null,
nsfwFlagSex: null,
nsfwSummary: VIDEO_NSFW_SUMMARY_VALIDATOR
nsfwSummary: VIDEO_NSFW_SUMMARY_VALIDATOR,
videoPrivacyEmbedEnableAllowlist: null,
videoPrivacyEmbedAllowlistDomains: UNIQUE_HOSTS_VALIDATOR
}
const {
@@ -100,22 +106,29 @@ export class VideoModerationComponent implements OnInit, OnDestroy {
debugLogger('Updating form values', formValues)
videoEdit.loadFromCommonForm(formValues)
videoEdit.loadFromEmbedPrivacyForm(formValues)
})
this.formReactiveService.markAllAsDirty(this.form.controls)
this.updatedSub = this.manageController.getUpdatedObs().subscribe(() => {
this.form.patchValue(videoEdit.toCommonFormPatch())
this.form.patchValue({ ...videoEdit.toCommonFormPatch(), ...videoEdit.toEmbedPrivacyFormPatch() })
})
this.updateNSFWControls(videoEdit.toCommonFormPatch().nsfw)
this.trackNSFWChange()
this.updateAllowedDomainsControls(videoEdit.toEmbedPrivacyFormPatch().videoPrivacyEmbedEnableAllowlist)
this.trackControlsChange()
}
private trackNSFWChange () {
private trackControlsChange () {
this.form.controls.nsfw
.valueChanges
.subscribe(newNSFW => this.updateNSFWControls(newNSFW))
this.form.controls.videoPrivacyEmbedEnableAllowlist
.valueChanges
.subscribe(newEnableAllowlist => this.updateAllowedDomainsControls(newEnableAllowlist))
}
private updateNSFWControls (nsfw: boolean) {
@@ -137,4 +150,22 @@ export class VideoModerationComponent implements OnInit, OnDestroy {
control.enable()
}
}
private updateAllowedDomainsControls (enableAllowlist: boolean) {
const controls = [
this.form.controls.videoPrivacyEmbedAllowlistDomains
]
if (!enableAllowlist) {
for (const control of controls) {
control.disable()
}
return
}
for (const control of controls) {
control.enable()
}
}
}
@@ -10,12 +10,14 @@ import { VideoPasswordService } from '@app/shared/shared-main/video/video-passwo
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 { VideoEmbedPrivacyService } from '@app/shared/shared-video/video-embed-privacy.service'
import { LoadingBarService } from '@ngx-loading-bar/core'
import {
ConstantLabel,
HTMLServerConfig,
HttpStatusCode,
LiveVideo,
UserVideoQuota,
VideoConstant,
VideoPassword,
VideoPrivacy,
VideoPrivacyType,
@@ -49,11 +51,12 @@ export class VideoManageController implements OnDestroy {
private videoStudio = inject(VideoStudioService)
private peertubeRouter = inject(PeerTubeRouterService)
private playerSettingsService = inject(PlayerSettingsService)
private videoEmbedPrivacyService = inject(VideoEmbedPrivacyService)
private videoEdit: VideoEdit
private userChannels: SelectChannelItem[]
private userQuota: UserVideoQuota
private privacies: VideoConstant<VideoPrivacyType>[]
private privacies: ConstantLabel<VideoPrivacyType>[]
private manageType: VideoManageType
private serverConfig: HTMLServerConfig
@@ -138,7 +141,7 @@ export class VideoManageController implements OnDestroy {
videoEdit: VideoEdit
userChannels: SelectChannelItem[]
userQuota: UserVideoQuota
privacies: VideoConstant<VideoPrivacyType>[]
privacies: ConstantLabel<VideoPrivacyType>[]
}) {
this.videoEdit = store.videoEdit
this.userChannels = store.userChannels
@@ -264,6 +267,13 @@ export class VideoManageController implements OnDestroy {
return this.liveVideoService.updateLive(videoAttributes.uuid, this.videoEdit.toLiveUpdate())
}),
switchMap(() => {
if (!this.videoEdit.hasEmbedPrivacyChanges()) return of(true)
debugLogger('Update embed privacy')
return this.videoEmbedPrivacyService.updatePrivacy({ videoId: videoAttributes.uuid, settings: this.videoEdit.getEmbedPrivacy() })
}),
switchMap(() => {
debugLogger('Update video')
@@ -276,38 +286,42 @@ export class VideoManageController implements OnDestroy {
return this.videoStudio.editVideo(videoAttributes.uuid, this.videoEdit.getStudioTasks())
.pipe(tap(() => this.videoEdit.resetStudio()))
}),
})
).pipe( // https://stackoverflow.com/questions/69260751/angular-observable-pipe-limit
switchMap(() => {
return forkJoin([
this.videoService.getVideo({ videoId: videoAttributes.uuid }),
return forkJoin({
video: this.videoService.getVideo({ videoId: videoAttributes.uuid }),
this.videoEdit.getVideoAttributes().privacy === VideoPrivacy.PASSWORD_PROTECTED
videoPasswords: this.videoEdit.getVideoAttributes().privacy === VideoPrivacy.PASSWORD_PROTECTED
? this.videoPasswordService.getVideoPasswords({ videoUUID: videoAttributes.uuid })
: of([] as VideoPassword[]),
isLive
live: isLive
? this.liveVideoService.getVideoLive(videoAttributes.uuid)
: of(undefined),
: of(undefined as LiveVideo),
!isLive
chaptersRes: !isLive
? this.videoChapterService.getChapters({ videoId: videoAttributes.uuid })
: of(undefined),
!isLive
captionsRes: !isLive
? this.videoCaptionService.listCaptions(videoAttributes.uuid)
: of(undefined),
this.playerSettingsService.getVideoSettings({ videoId: videoAttributes.uuid, raw: true })
])
playerSettings: this.playerSettingsService.getVideoSettings({ videoId: videoAttributes.uuid, raw: true }),
embedPrivacy: this.videoEmbedPrivacyService.getPrivacy({ videoId: videoAttributes.uuid })
})
}),
switchMap(([ video, videoPasswords, live, chaptersRes, captionsRes, playerSettings ]) => {
switchMap(({ video, videoPasswords, live, chaptersRes, captionsRes, playerSettings, embedPrivacy }) => {
return this.videoEdit.loadFromAPI({
video,
videoPasswords: videoPasswords.map(p => p.password),
live,
chapters: chaptersRes?.chapters,
captions: captionsRes?.data,
playerSettings
playerSettings,
embedPrivacy
})
}),
first(), // To complete
+2 -2
View File
@@ -57,13 +57,13 @@ export class AuthUser extends User implements ServerMyUserModel {
return this.videoChannelCollaborations.length !== 0
}
isEditorOfChannel (channel: Pick<VideoChannel, 'id'>) {
isEditorOfChannel (channel?: Pick<VideoChannel, 'id'>) {
if (!channel) return false
return this.videoChannelCollaborations.some(c => c.id === channel.id)
}
isOwnerOfChannel (channel: Pick<VideoChannel, 'id'>) {
isOwnerOfChannel (channel?: Pick<VideoChannel, 'id'>) {
if (!channel) return true
return this.videoChannels.some(c => c.id === channel.id)
+7 -7
View File
@@ -7,7 +7,7 @@ import {
ServerConfig,
ServerStats,
VideoCommentPolicy,
VideoConstant,
ConstantLabel,
VideoLicenceType,
VideoPlaylistPrivacyType,
VideoPrivacyType
@@ -31,11 +31,11 @@ export class ServerService {
configReloaded = new Subject<ServerConfig>()
private localeObservable: Observable<any>
private videoLicensesObservable: Observable<VideoConstant<VideoLicenceType>[]>
private videoCategoriesObservable: Observable<VideoConstant<number>[]>
private videoPrivaciesObservable: Observable<VideoConstant<VideoPrivacyType>[]>
private videoPlaylistPrivaciesObservable: Observable<VideoConstant<VideoPlaylistPrivacyType>[]>
private videoLanguagesObservable: Observable<VideoConstant<string>[]>
private videoLicensesObservable: Observable<ConstantLabel<VideoLicenceType>[]>
private videoCategoriesObservable: Observable<ConstantLabel<number>[]>
private videoPrivaciesObservable: Observable<ConstantLabel<VideoPrivacyType>[]>
private videoPlaylistPrivaciesObservable: Observable<ConstantLabel<VideoPlaylistPrivacyType>[]>
private videoLanguagesObservable: Observable<ConstantLabel<string>[]>
private configObservable: Observable<ServerConfig>
private configLoaded = false
@@ -196,7 +196,7 @@ export class ServerService {
.pipe(map(data => ({ data, translations })))
}),
map(({ data, translations }) => {
const hashToPopulate: VideoConstant<T>[] = Object.keys(data)
const hashToPopulate: ConstantLabel<T>[] = Object.keys(data)
.map(dataKey => {
const label = data[dataKey]
+4
View File
@@ -12,6 +12,10 @@ export function getBackendUrl () {
return environment.apiUrl || environment.originServerUrl || window.location.origin
}
export function getEmbedUrl () {
return environment.embedUrl || getOriginUrl()
}
export function getBackendHost () {
return new URL(getBackendUrl()).host
}
@@ -70,8 +70,8 @@ export const UNIQUE_HOSTS_VALIDATOR: BuildFormValidator = {
VALIDATORS: [ Validators.required, validHosts, unique ],
MESSAGES: {
required: $localize`Domain is required.`,
validHosts: $localize`Hosts entered are invalid.`,
unique: $localize`Hosts entered contain duplicates.`
validHosts: $localize`Domains entered are invalid.`,
unique: $localize`Domains entered contain duplicates.`
}
}
@@ -79,7 +79,7 @@ export const UNIQUE_HOSTS_OR_HANDLE_VALIDATOR: BuildFormValidator = {
VALIDATORS: [ Validators.required, validHostsOrHandles, unique ],
MESSAGES: {
required: $localize`Domain is required.`,
validHostsOrHandles: $localize`Hosts or handles are invalid.`,
unique: $localize`Hosts or handles contain duplicates.`
validHostsOrHandles: $localize`Domains or handles are invalid.`,
unique: $localize`Domains or handles contain duplicates.`
}
}
@@ -1,9 +1,11 @@
import { Account } from '@app/shared/shared-main/account/account.model'
import { VideoChannel } from '@app/shared/shared-main/channel/video-channel.model'
import {
ConstantLabel,
VideoCommentPolicyType,
VideoConstant,
VideoDetails as VideoDetailsServerModel,
VideoEmbedPrivacyPolicy,
VideoEmbedPrivacyPolicyType,
VideoFile,
VideoStateType,
VideoStreamingPlaylist,
@@ -19,7 +21,7 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
tags: string[]
downloadEnabled: boolean
commentsPolicy: VideoConstant<VideoCommentPolicyType>
commentsPolicy: ConstantLabel<VideoCommentPolicyType>
likesPercent: number
dislikesPercent: number
@@ -28,11 +30,13 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
inputFileUpdatedAt: Date | string
embedPrivacyPolicy: ConstantLabel<VideoEmbedPrivacyPolicyType>
// These fields are not optional
declare files: VideoFile[]
declare streamingPlaylists: VideoStreamingPlaylist[]
declare waitTranscoding: boolean
declare state: VideoConstant<VideoStateType>
declare state: ConstantLabel<VideoStateType>
constructor (hash: VideoDetailsServerModel, translations = {}) {
super(hash, translations)
@@ -48,6 +52,8 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
this.trackerUrls = hash.trackerUrls
this.embedPrivacyPolicy = hash.embedPrivacyPolicy
this.buildLikeAndDislikePercents()
}
@@ -63,4 +69,8 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
hasHlsPlaylist () {
return !!this.getHlsPlaylist()
}
hasEmbedRestrictions () {
return this.embedPrivacyPolicy.id !== VideoEmbedPrivacyPolicy.ALL_ALLOWED
}
}
@@ -1,14 +1,14 @@
import { AuthUser } from '@app/core'
import { User } from '@app/core/users/user.model'
import { durationToString, getOriginUrl } from '@app/helpers'
import { durationToString, getEmbedUrl } from '@app/helpers'
import { Actor } from '@app/shared/shared-main/account/actor.model'
import { buildVideoWatchPath, getAllFiles, peertubeTranslate } from '@peertube/peertube-core-utils'
import {
ActorImage,
ConstantLabel,
HTMLServerConfig,
Thumbnail,
UserRight,
VideoConstant,
VideoFile,
VideoPrivacy,
VideoPrivacyType,
@@ -31,10 +31,10 @@ export class Video implements VideoServerModel {
publishedAt: Date
originallyPublishedAt: Date | string
category: VideoConstant<number>
licence: VideoConstant<number>
language: VideoConstant<string>
privacy: VideoConstant<VideoPrivacyType>
category: ConstantLabel<number>
licence: ConstantLabel<number>
language: ConstantLabel<string>
privacy: ConstantLabel<VideoPrivacyType>
truncatedDescription: string
description: string
@@ -81,7 +81,7 @@ export class Video implements VideoServerModel {
originInstanceHost: string
waitTranscoding?: boolean
state?: VideoConstant<VideoStateType>
state?: ConstantLabel<VideoStateType>
scheduledUpdate?: VideoScheduleUpdate
blacklisted?: boolean
@@ -168,7 +168,7 @@ export class Video implements VideoServerModel {
this.name = hash.name
this.embedPath = hash.embedPath
this.embedUrl = hash.embedUrl || (getOriginUrl() + hash.embedPath)
this.embedUrl = hash.embedUrl || (getEmbedUrl() + hash.embedPath)
this.url = hash.url
@@ -255,7 +255,11 @@ export class Video implements VideoServerModel {
}
isUpdatableBy (user: AuthUser) {
return user && this.isLocal === true && (user.isEditorOfChannel(this.channel) || user.hasRight(UserRight.UPDATE_ANY_VIDEO))
return user && this.isLocal === true && (
user.isOwnerOfChannel(this.channel) ||
user.isEditorOfChannel(this.channel) ||
user.hasRight(UserRight.UPDATE_ANY_VIDEO)
)
}
isStudioEditableBy (options: {
@@ -268,23 +272,15 @@ export class Video implements VideoServerModel {
}
isRemovableBy (user: AuthUser) {
return user && this.isLocal === true && (user.isEditorOfChannel(this.channel) || user.hasRight(UserRight.REMOVE_ANY_VIDEO))
return user && this.isLocal === true && (
user.isOwnerOfChannel(this.channel) ||
user.isEditorOfChannel(this.channel) ||
user.hasRight(UserRight.REMOVE_ANY_VIDEO)
)
}
// ---------------------------------------------------------------------------
isOwner (user: AuthUser) {
return user && this.isLocal === true && this.account.name === user.username
}
hasSeeAllVideosRight (user: AuthUser) {
return user?.hasRight(UserRight.SEE_ALL_VIDEOS)
}
isOwnerOrHasSeeAllVideosRight (user: AuthUser) {
return this.isOwner(user) || this.hasSeeAllVideosRight(user)
}
canRemoveOneFile (user: AuthUser) {
return this.isLocal &&
user && user.hasRight(UserRight.MANAGE_VIDEO_FILES) &&
@@ -27,7 +27,7 @@ import {
UserVideoRateType,
UserVideoRateUpdate,
VideoChannel as VideoChannelServerModel,
VideoConstant,
ConstantLabel,
VideoDetails as VideoDetailsServerModel,
VideoFile,
VideoFileMetadata,
@@ -534,7 +534,7 @@ export class VideoService {
// ---------------------------------------------------------------------------
explainedPrivacyLabels (serverPrivacies: VideoConstant<VideoPrivacyType>[], defaultPrivacyId: VideoPrivacyType = VideoPrivacy.PUBLIC) {
explainedPrivacyLabels (serverPrivacies: ConstantLabel<VideoPrivacyType>[], defaultPrivacyId: VideoPrivacyType = VideoPrivacy.PUBLIC) {
const descriptions = {
[VideoPrivacy.PRIVATE]: $localize`Only I can see this video`,
[VideoPrivacy.UNLISTED]: $localize`Only shareable via a private link`,
@@ -557,7 +557,7 @@ export class VideoService {
}
}
explainedLicenceLabels (serverLicences: VideoConstant<VideoLicenceType>[]) {
explainedLicenceLabels (serverLicences: ConstantLabel<VideoLicenceType>[]) {
const descriptions = {
[VideoLicence['CC-BY']]: $localize`CC-BY`,
[VideoLicence['CC-BY-SA']]: $localize`CC-BY-SA`,
@@ -599,7 +599,7 @@ export class VideoService {
return $localize`This video contains sensitive content: ${flags.join(' - ')}`
}
getMostPrivatePrivacy (serverPrivacies: VideoConstant<VideoPrivacyType>[]) {
getMostPrivatePrivacy (serverPrivacies: ConstantLabel<VideoPrivacyType>[]) {
// We do not add a password as this requires additional configuration.
const order = [
VideoPrivacy.PRIVATE,
@@ -611,7 +611,7 @@ export class VideoService {
return this.getPrivacyFromOrder(serverPrivacies, order)
}
private getPrivacyFromOrder (serverPrivacies: VideoConstant<VideoPrivacyType>[], order: VideoPrivacyType[]) {
private getPrivacyFromOrder (serverPrivacies: ConstantLabel<VideoPrivacyType>[], order: VideoPrivacyType[]) {
for (const privacy of order) {
if (serverPrivacies.find(p => p.id === privacy)) {
return privacy
@@ -10,7 +10,7 @@
<div class="modal-body">
<form novalidate [formGroup]="form" (ngSubmit)="submit()">
<div class="form-group">
<label i18n for="hosts">1 host (without "http://") per line</label>
<label i18n for="hosts">1 domain (without "http://") per line</label>
<textarea
[placeholder]="placeholder()" formControlName="hosts" type="text" id="hosts" name="hosts"
@@ -2,10 +2,6 @@
@use "_mixins" as *;
@use "_form-mixins" as *;
my-timestamp-input {
width: 100px;
}
my-input-text {
width: 100%;
}
@@ -35,6 +31,14 @@ my-input-text {
margin-top: 20px;
}
h5 {
font-size: 1.15rem;
}
my-timestamp-input {
width: 100px;
}
.filters {
margin-top: 30px;
@@ -66,14 +70,3 @@ my-input-text {
}
}
}
.alert-private {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
h5 {
font-size: 1.15rem;
}
@@ -0,0 +1,116 @@
<div class="playlist">
@if (video()) {
<h5 i18n class="text-center mb-4">Share the playlist</h5>
}
@if (isPrivatePlaylist()) {
<my-alert type="primary">
<div i18n>This playlist is private so you won't be able to share it with external users</div>
<a
i18n
class="peertube-button-link primary-button"
[routerLink]="[ '/my-library/video-playlists/update', playlist().shortUUID ]"
target="_blank"
rel="noopener noreferrer"
>Update playlist privacy</a>
</my-alert>
}
<div ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activePlaylistId">
<ng-container ngbNavItem="url">
<a ngbNavLink i18n>URL</a>
<ng-template ngbNavContent>
<div class="nav-content">
<my-input-text
inputId="playlist-url"
i18n-ariaLabel
ariaLabel="Playlist URL"
[value]="playlistUrl"
[withToggle]="false"
[withCopy]="true"
[show]="true"
[readonly]="true"
></my-input-text>
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem="qrcode">
<a ngbNavLink i18n>QR-Code</a>
<ng-template ngbNavContent>
<div class="nav-content">
<qrcode [qrdata]="playlistUrl" [width]="256" level="Q"></qrcode>
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem="embed">
<a ngbNavLink i18n>Embed</a>
<ng-template ngbNavContent>
<div class="nav-content">
<my-input-text
inputId="playlist-embed-url"
i18n-ariaLabel
ariaLabel="Playlist embed URL"
[value]="customizations().onlyEmbedUrl ? playlistEmbedUrl : playlistEmbedHTML"
(change)="onUpdate()"
[withToggle]="false"
[withCopy]="true"
[show]="true"
[readonly]="true"
></my-input-text>
@if (notSecure()) {
<my-alert i18n type="warning" class="mt-3">
The url is not secured (no HTTPS), so the embed video won't work on HTTPS websites (web browsers block non secured HTTP requests on HTTPS
websites).
</my-alert>
}
<div class="embed" [innerHTML]="playlistEmbedSafeHTML"></div>
</div>
</ng-template>
</ng-container>
</div>
<div [ngbNavOutlet]="nav"></div>
<div class="filters">
@if (video()) {
<div class="form-group">
<my-peertube-checkbox
inputName="includeVideoInPlaylist"
[(ngModel)]="customizations().includeVideoInPlaylist"
i18n-labelText
labelText="Share the playlist at this video position"
></my-peertube-checkbox>
</div>
}
@if (isInEmbedTab()) {
<div class="form-group">
<my-peertube-checkbox
inputName="onlyEmbedUrl"
[(ngModel)]="customizations().onlyEmbedUrl"
i18n-labelText
labelText="Only display embed URL"
></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="responsive"
[(ngModel)]="customizations().responsive"
i18n-labelText
labelText="Responsive embed"
></my-peertube-checkbox>
</div>
}
<my-plugin-placeholder pluginId="share-modal-playlist-settings"></my-plugin-placeholder>
</div>
</div>
@@ -0,0 +1,136 @@
import { Component, inject, input, OnInit } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import { HooksService } from '@app/core'
import { NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'
import { buildPlaylistLink, decoratePlaylistLink } from '@peertube/peertube-core-utils'
import { VideoPlaylistPrivacy } from '@peertube/peertube-models'
import { buildVideoOrPlaylistEmbed } from '@root-helpers/video'
import { QRCodeComponent } from 'angularx-qrcode'
import { InputTextComponent } from '../shared-forms/input-text.component'
import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component'
import { AlertComponent } from '../shared-main/common/alert.component'
import { PluginPlaceholderComponent } from '../shared-main/plugins/plugin-placeholder.component'
import { VideoDetails } from '../shared-main/video/video-details.model'
import { VideoPlaylist } from '../shared-video-playlist/video-playlist.model'
import { Customizations, TabId } from './video-share.model'
@Component({
selector: 'my-share-playlist',
templateUrl: './share-playlist.component.html',
styleUrls: [ './share-common.component.scss' ],
imports: [
RouterLink,
NgbNav,
NgbNavItem,
NgbNavLink,
NgbNavLinkBase,
NgbNavContent,
NgbNavOutlet,
InputTextComponent,
QRCodeComponent,
PeertubeCheckboxComponent,
FormsModule,
PluginPlaceholderComponent,
AlertComponent
]
})
export class SharePlaylistComponent implements OnInit {
private sanitizer = inject(DomSanitizer)
private hooks = inject(HooksService)
readonly playlist = input<VideoPlaylist>()
readonly playlistPosition = input<number>()
readonly video = input<VideoDetails>()
readonly customizations = input<Customizations>()
activePlaylistId: TabId = 'url'
playlistUrl: string
playlistEmbedUrl: string
playlistEmbedHTML: string
playlistEmbedSafeHTML: SafeHtml
ngOnInit () {
this.onUpdate()
}
async onUpdate () {
const playlist = this.playlist()
const customizations = this.customizations()
if (!playlist || !customizations) return
this.playlistUrl = await this.getPlaylistUrl()
this.playlistEmbedUrl = await this.getPlaylistEmbedUrl()
this.playlistEmbedHTML = await this.getPlaylistEmbedCode({ responsive: customizations.responsive })
this.playlistEmbedSafeHTML = this.sanitizer.bypassSecurityTrustHtml(await this.getPlaylistEmbedCode({ responsive: false }))
}
notSecure () {
return window.location.protocol === 'http:'
}
isInEmbedTab () {
return this.activePlaylistId === 'embed'
}
isPrivatePlaylist () {
return this.playlist().privacy.id === VideoPlaylistPrivacy.PRIVATE
}
private getPlaylistUrl () {
const url = buildPlaylistLink(this.playlist())
return this.hooks.wrapFun(
decoratePlaylistLink,
{ url, ...this.getPlaylistOptions() },
'video-watch',
'filter:share.video-playlist-url.build.params',
'filter:share.video-playlist-url.build.result'
)
}
private getPlaylistEmbedUrl () {
return this.hooks.wrapFun(
decoratePlaylistLink,
{ url: this.playlist().embedUrl, ...this.getPlaylistOptions() },
'video-watch',
'filter:share.video-playlist-embed-url.build.params',
'filter:share.video-playlist-embed-url.build.result'
)
}
private async getPlaylistEmbedCode (options: { responsive: boolean }) {
const { responsive } = options
return this.hooks.wrapFun(
buildVideoOrPlaylistEmbed,
{
embedUrl: await this.getPlaylistEmbedUrl(),
embedTitle: this.playlist().displayName,
responsive,
aspectRatio: this.video()?.aspectRatio
},
'video-watch',
'filter:share.video-playlist-embed-code.build.params',
'filter:share.video-playlist-embed-code.build.result'
)
}
private getPlaylistOptions (baseUrl?: string) {
const customizations = this.customizations()
if (!customizations) return { baseUrl }
const playlistPosition = this.playlistPosition()
return {
baseUrl,
playlistPosition: playlistPosition && customizations.includeVideoInPlaylist
? playlistPosition
: undefined
}
}
}
@@ -0,0 +1,248 @@
<div class="video">
@if (playlist()) {
<h5 i18n class="text-center mb-4">Share the video</h5>
}
@if (isPrivateVideo()) {
<my-alert type="primary">
<div i18n>This video is private so you won't be able to share it with external users</div>
<a
i18n
class="peertube-button-link secondary-button mt-3"
[routerLink]="[ '/videos/', 'manage', video().shortUUID ]"
target="_blank"
rel="noopener noreferrer"
>Update video privacy</a>
</my-alert>
} @else if (isPasswordProtectedVideo()) {
<my-alert i18n type="primary">
This video is password protected, please note that recipients will require the corresponding password to access the content.
</my-alert>
} @else if (video().hasEmbedRestrictions() && isInEmbedTab()) {
<my-alert type="primary">
<div i18n>
This video has embed restrictions and may not be shareable on external websites depending on the chosen restrictions.
</div>
<a
i18n
class="peertube-button-link secondary-button mt-3"
[routerLink]="[ '/videos/', 'manage', video().shortUUID, 'moderation' ]"
target="_blank"
rel="noopener noreferrer"
>Update embed privacy</a>
</my-alert>
}
<div ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeVideoId">
<ng-container ngbNavItem="url">
<a ngbNavLink i18n>URL</a>
<ng-template ngbNavContent>
<div class="nav-content">
<my-input-text
inputId="video-url"
i18n-ariaLabel
ariaLabel="Video URL"
[value]="videoUrl"
[withToggle]="false"
[withCopy]="true"
[show]="true"
[readonly]="true"
></my-input-text>
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem="qrcode">
<a ngbNavLink i18n>QR-Code</a>
<ng-template ngbNavContent>
<div class="nav-content">
<qrcode [qrdata]="videoUrl" [width]="256" level="Q"></qrcode>
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem="embed">
<a ngbNavLink i18n>Embed</a>
<ng-template ngbNavContent>
<div class="nav-content">
<my-input-text
inputId="video-embed-url"
i18n-ariaLabel
ariaLabel="Video embed URL"
[value]="customizations().onlyEmbedUrl ? videoEmbedUrl : videoEmbedHTML"
(ngModelChange)="onUpdate()"
[withToggle]="false"
[withCopy]="true"
[show]="true"
[readonly]="true"
></my-input-text>
@if (notSecure()) {
<my-alert i18n type="warning" class="mt-3">
The url is not secured (no HTTPS), so the embed video won't work on HTTPS websites (web browsers block non secured HTTP requests on HTTPS
websites).
</my-alert>
}
<div class="embed" [innerHTML]="videoEmbedSafeHTML"></div>
</div>
</ng-template>
</ng-container>
</div>
<div [ngbNavOutlet]="nav"></div>
<div class="filters">
@if (!video().isLive) {
<div class="form-group start-at">
<my-peertube-checkbox inputName="startAt" [(ngModel)]="customizations().startAtCheckbox" i18n-labelText labelText="Start at"></my-peertube-checkbox>
<my-timestamp-input
[timestamp]="customizations().startAt"
[maxTimestamp]="video().duration"
[disabled]="!customizations().startAtCheckbox"
[(ngModel)]="customizations().startAt"
>
</my-timestamp-input>
</div>
}
@if (videoCaptions().length !== 0) {
<div class="form-group video-caption-block">
<my-peertube-checkbox
inputName="subtitleCheckbox"
[(ngModel)]="customizations().subtitleCheckbox"
i18n-labelText
labelText="Auto select subtitle"
></my-peertube-checkbox>
<div class="peertube-select-container">
<select [(ngModel)]="customizations().subtitle" [disabled]="!customizations().subtitleCheckbox" class="form-control">
@for (caption of videoCaptions(); track caption) {
<option [value]="caption.language.id">{{ caption.language.label }}</option>
}
</select>
</div>
</div>
}
@if (isInEmbedTab()) {
<div class="form-group">
<my-peertube-checkbox
inputName="onlyEmbedUrl"
[(ngModel)]="customizations().onlyEmbedUrl"
i18n-labelText
labelText="Only display embed URL"
></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="responsive"
[(ngModel)]="customizations().responsive"
i18n-labelText
labelText="Responsive embed"
></my-peertube-checkbox>
</div>
}
<my-plugin-placeholder pluginId="share-modal-video-settings"></my-plugin-placeholder>
<div class="advanced-filters" [ngbCollapse]="isAdvancedCustomizationCollapsed" [animation]="true">
@if (!video().isLive) {
<div class="form-group stop-at">
<my-peertube-checkbox inputName="stopAt" [(ngModel)]="customizations().stopAtCheckbox" i18n-labelText labelText="Stop at"></my-peertube-checkbox>
<my-timestamp-input
[timestamp]="customizations().stopAt"
[maxTimestamp]="video().duration"
[disabled]="!customizations().stopAtCheckbox"
[(ngModel)]="customizations().stopAt"
>
</my-timestamp-input>
</div>
}
<div class="form-group">
<my-peertube-checkbox inputName="autoplay" [(ngModel)]="customizations().autoplay" i18n-labelText labelText="Autoplay"></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox inputName="muted" [(ngModel)]="customizations().muted" i18n-labelText labelText="Muted"></my-peertube-checkbox>
</div>
@if (!video().isLive) {
<div class="form-group">
<my-peertube-checkbox inputName="loop" [(ngModel)]="customizations().loop" i18n-labelText labelText="Loop"></my-peertube-checkbox>
</div>
}
@if (!isLocalVideo() && !isInEmbedTab()) {
<div class="form-group">
<my-peertube-checkbox
inputName="originUrl"
[(ngModel)]="customizations().originUrl"
i18n-labelText
labelText="Use origin instance URL"
></my-peertube-checkbox>
</div>
}
@if (isInEmbedTab()) {
<div class="form-group">
<my-peertube-checkbox inputName="title" [(ngModel)]="customizations().title" i18n-labelText labelText="Display video title"></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox inputName="embedP2P" [(ngModel)]="customizations().embedP2P" i18n-labelText labelText="P2P"></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="warningTitle"
[(ngModel)]="customizations().warningTitle"
i18n-labelText
labelText="Display privacy warning"
[disabled]="!customizations().embedP2P"
></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="controlBar"
[(ngModel)]="customizations().controlBar"
i18n-labelText
labelText="Display player control bar"
></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="peertubeLink"
[(ngModel)]="customizations().peertubeLink"
i18n-labelText
labelText="Display PeerTube button link"
></my-peertube-checkbox>
</div>
}
</div>
<button
class="border-0 p-0 mt-4 mx-auto fw-semibold d-block"
(click)="isAdvancedCustomizationCollapsed = !isAdvancedCustomizationCollapsed"
[attr.aria-expanded]="!isAdvancedCustomizationCollapsed"
aria-controls="collapseBasic"
>
@if (isAdvancedCustomizationCollapsed) {
<span class="chevron-down"></span>
<ng-container i18n>More customization</ng-container>
} @else {
<span class="chevron-up"></span>
<ng-container i18n>Less customization</ng-container>
}
</button>
</div>
</div>
@@ -0,0 +1,170 @@
import { Component, inject, input, OnInit } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import { AuthService, HooksService, ServerService } from '@app/core'
import { NgbCollapse, NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'
import { buildVideoLink, decorateVideoLink } from '@peertube/peertube-core-utils'
import { VideoCaption, VideoPrivacy } from '@peertube/peertube-models'
import { buildVideoOrPlaylistEmbed } from '@root-helpers/video'
import { QRCodeComponent } from 'angularx-qrcode'
import { InputTextComponent } from '../shared-forms/input-text.component'
import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component'
import { TimestampInputComponent } from '../shared-forms/timestamp-input.component'
import { AlertComponent } from '../shared-main/common/alert.component'
import { PluginPlaceholderComponent } from '../shared-main/plugins/plugin-placeholder.component'
import { VideoDetails } from '../shared-main/video/video-details.model'
import { VideoPlaylist } from '../shared-video-playlist/video-playlist.model'
import { Customizations, TabId } from './video-share.model'
@Component({
selector: 'my-share-video',
templateUrl: './share-video.component.html',
styleUrls: [ './share-common.component.scss' ],
imports: [
RouterLink,
NgbNav,
NgbNavItem,
NgbNavLink,
NgbNavLinkBase,
NgbNavContent,
NgbNavOutlet,
InputTextComponent,
QRCodeComponent,
PeertubeCheckboxComponent,
FormsModule,
PluginPlaceholderComponent,
TimestampInputComponent,
NgbCollapse,
AlertComponent
]
})
export class ShareVideoComponent implements OnInit {
private sanitizer = inject(DomSanitizer)
private server = inject(ServerService)
private hooks = inject(HooksService)
private authService = inject(AuthService)
readonly video = input<VideoDetails>(null)
readonly videoCaptions = input<VideoCaption[]>([])
readonly playlist = input<VideoPlaylist>(null)
readonly customizations = input<Customizations>(null)
activeVideoId: TabId = 'url'
isAdvancedCustomizationCollapsed = true
videoUrl: string
videoEmbedUrl: string
videoEmbedHTML: string
videoEmbedSafeHTML: SafeHtml
ngOnInit () {
this.onUpdate()
}
async onUpdate () {
const video = this.video()
const customizations = this.customizations()
if (!video || !customizations) return
this.videoUrl = await this.getVideoUrl()
this.videoEmbedUrl = await this.getVideoEmbedUrl()
this.videoEmbedHTML = await this.getVideoEmbedCode({ responsive: customizations.responsive })
this.videoEmbedSafeHTML = this.sanitizer.bypassSecurityTrustHtml(await this.getVideoEmbedCode({ responsive: false }))
}
notSecure () {
return window.location.protocol === 'http:'
}
isInEmbedTab () {
return this.activeVideoId === 'embed'
}
isLocalVideo () {
return this.video().isLocal
}
isPrivateVideo () {
return this.video().privacy.id === VideoPrivacy.PRIVATE
}
isPasswordProtectedVideo () {
return this.video().privacy.id === VideoPrivacy.PASSWORD_PROTECTED
}
private getVideoUrl () {
const customizations = this.customizations()
const url = customizations?.originUrl
? this.video().url
: buildVideoLink(this.video(), window.location.origin)
return this.hooks.wrapFun(
decorateVideoLink,
{ url, ...this.getVideoOptions(false) },
'video-watch',
'filter:share.video-url.build.params',
'filter:share.video-url.build.result'
)
}
private getVideoEmbedUrl () {
return this.hooks.wrapFun(
decorateVideoLink,
{ url: this.video().embedUrl, ...this.getVideoOptions(true) },
'video-watch',
'filter:share.video-embed-url.build.params',
'filter:share.video-embed-url.build.result'
)
}
private async getVideoEmbedCode (options: { responsive: boolean }) {
const { responsive } = options
return this.hooks.wrapFun(
buildVideoOrPlaylistEmbed,
{
embedUrl: await this.getVideoEmbedUrl(),
embedTitle: this.video().name,
responsive,
aspectRatio: this.video().aspectRatio
},
'video-watch',
'filter:share.video-embed-code.build.params',
'filter:share.video-embed-code.build.result'
)
}
private getVideoOptions (forEmbed: boolean) {
const customizations = this.customizations()
if (!customizations) return {}
const embedOptions = forEmbed
? {
title: customizations.title,
warningTitle: customizations.warningTitle,
controlBar: customizations.controlBar,
peertubeLink: customizations.peertubeLink,
p2p: customizations.embedP2P === this.server.getHTMLConfig().defaults.p2p.embed.enabled
? undefined
: customizations.embedP2P
}
: {}
return {
startTime: customizations.startAtCheckbox ? customizations.startAt : undefined,
stopTime: customizations.stopAtCheckbox ? customizations.stopAt : undefined,
subtitle: customizations.subtitleCheckbox ? customizations.subtitle : undefined,
loop: customizations.loop,
autoplay: customizations.autoplay,
muted: customizations.muted,
...embedOptions
}
}
}
@@ -9,366 +9,21 @@
<div class="modal-body">
@if (playlist()) {
<div class="playlist">
@if (video()) {
<h5 i18n class="text-center mb-4">Share the playlist</h5>
}
@if (isPrivatePlaylist()) {
<my-alert class="alert-private" type="warning">
<div i18n>This playlist is private so you won't be able to share it with external users</div>
<a
i18n
class="peertube-button-link primary-button"
[routerLink]="[ '/my-library/video-playlists/update', playlist().shortUUID ]"
target="_blank"
rel="noopener noreferrer"
>Update playlist privacy</a>
</my-alert>
}
<div ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activePlaylistId">
<ng-container ngbNavItem="url">
<a ngbNavLink i18n>URL</a>
<ng-template ngbNavContent>
<div class="nav-content">
<my-input-text
inputId="playlist-url"
i18n-ariaLabel
ariaLabel="Playlist URL"
[value]="playlistUrl"
[withToggle]="false"
[withCopy]="true"
[show]="true"
[readonly]="true"
></my-input-text>
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem="qrcode">
<a ngbNavLink i18n>QR-Code</a>
<ng-template ngbNavContent>
<div class="nav-content">
<qrcode [qrdata]="playlistUrl" [width]="256" level="Q"></qrcode>
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem="embed">
<a ngbNavLink i18n>Embed</a>
<ng-template ngbNavContent>
<div class="nav-content">
<my-input-text
inputId="playlist-embed-url"
i18n-ariaLabel
ariaLabel="Playlist embed URL"
[value]="customizations.onlyEmbedUrl ? playlistEmbedUrl : playlistEmbedHTML"
(change)="onUpdate()"
[withToggle]="false"
[withCopy]="true"
[show]="true"
[readonly]="true"
></my-input-text>
@if (notSecure()) {
<my-alert i18n type="warning" class="mt-3">
The url is not secured (no HTTPS), so the embed video won't work on HTTPS websites (web browsers block non secured HTTP requests on HTTPS
websites).
</my-alert>
}
<div class="embed" [innerHTML]="playlistEmbedSafeHTML"></div>
</div>
</ng-template>
</ng-container>
</div>
<div [ngbNavOutlet]="nav"></div>
<div class="filters">
@if (video()) {
<div class="form-group">
<my-peertube-checkbox
inputName="includeVideoInPlaylist"
[(ngModel)]="customizations.includeVideoInPlaylist"
i18n-labelText
labelText="Share the playlist at this video position"
></my-peertube-checkbox>
</div>
}
@if (isInPlaylistEmbedTab()) {
<div class="form-group">
<my-peertube-checkbox
inputName="onlyEmbedUrl"
[(ngModel)]="customizations.onlyEmbedUrl"
i18n-labelText
labelText="Only display embed URL"
></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="responsive"
[(ngModel)]="customizations.responsive"
i18n-labelText
labelText="Responsive embed"
></my-peertube-checkbox>
</div>
}
<my-plugin-placeholder pluginId="share-modal-playlist-settings"></my-plugin-placeholder>
</div>
</div>
<my-share-playlist
[playlist]="playlist()"
[playlistPosition]="playlistPosition()"
[video]="video()"
[customizations]="customizations"
></my-share-playlist>
}
@if (video()) {
<div class="video">
@if (playlist()) {
<h5 i18n class="text-center mb-4">Share the video</h5>
}
@if (isPrivateVideo()) {
<my-alert class="alert-private" type="warning">
<div i18n>This video is private so you won't be able to share it with external users</div>
<a
i18n
class="peertube-button-link primary-button mt-3"
[routerLink]="[ '/videos/', 'manage', video().shortUUID ]"
target="_blank"
rel="noopener noreferrer"
>Update video privacy</a>
</my-alert>
}
@if (isPasswordProtectedVideo()) {
<my-alert i18n class="alert-private" type="warning">
This video is password protected, please note that recipients will require the corresponding password to access the content.
</my-alert>
}
<div ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeVideoId">
<ng-container ngbNavItem="url">
<a ngbNavLink i18n>URL</a>
<ng-template ngbNavContent>
<div class="nav-content">
<my-input-text
inputId="video-url"
i18n-ariaLabel
ariaLabel="Video URL"
[value]="videoUrl"
[withToggle]="false"
[withCopy]="true"
[show]="true"
[readonly]="true"
></my-input-text>
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem="qrcode">
<a ngbNavLink i18n>QR-Code</a>
<ng-template ngbNavContent>
<div class="nav-content">
<qrcode [qrdata]="videoUrl" [width]="256" level="Q"></qrcode>
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem="embed">
<a ngbNavLink i18n>Embed</a>
<ng-template ngbNavContent>
<div class="nav-content">
<my-input-text
inputId="video-embed-url"
i18n-ariaLabel
ariaLabel="Video embed URL"
[value]="customizations.onlyEmbedUrl ? videoEmbedUrl : videoEmbedHTML"
(ngModelChange)="onUpdate()"
[withToggle]="false"
[withCopy]="true"
[show]="true"
[readonly]="true"
></my-input-text>
@if (notSecure()) {
<my-alert i18n type="warning" class="mt-3">
The url is not secured (no HTTPS), so the embed video won't work on HTTPS websites (web browsers block non secured HTTP requests on HTTPS
websites).
</my-alert>
}
<div class="embed" [innerHTML]="videoEmbedSafeHTML"></div>
</div>
</ng-template>
</ng-container>
</div>
<div [ngbNavOutlet]="nav"></div>
<div class="filters">
@if (!video().isLive) {
<div class="form-group start-at">
<my-peertube-checkbox inputName="startAt" [(ngModel)]="customizations.startAtCheckbox" i18n-labelText labelText="Start at"></my-peertube-checkbox>
<my-timestamp-input
[timestamp]="customizations.startAt"
[maxTimestamp]="video().duration"
[disabled]="!customizations.startAtCheckbox"
[(ngModel)]="customizations.startAt"
>
</my-timestamp-input>
</div>
}
@if (videoCaptions().length !== 0) {
<div class="form-group video-caption-block">
<my-peertube-checkbox
inputName="subtitleCheckbox"
[(ngModel)]="customizations.subtitleCheckbox"
i18n-labelText
labelText="Auto select subtitle"
></my-peertube-checkbox>
<div class="peertube-select-container">
<select [(ngModel)]="customizations.subtitle" [disabled]="!customizations.subtitleCheckbox" class="form-control">
@for (caption of videoCaptions(); track caption) {
<option [value]="caption.language.id">{{ caption.language.label }}</option>
}
</select>
</div>
</div>
}
@if (isInVideoEmbedTab()) {
<div class="form-group">
<my-peertube-checkbox
inputName="onlyEmbedUrl"
[(ngModel)]="customizations.onlyEmbedUrl"
i18n-labelText
labelText="Only display embed URL"
></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="responsive"
[(ngModel)]="customizations.responsive"
i18n-labelText
labelText="Responsive embed"
></my-peertube-checkbox>
</div>
}
<my-plugin-placeholder pluginId="share-modal-video-settings"></my-plugin-placeholder>
<div class="advanced-filters" [ngbCollapse]="isAdvancedCustomizationCollapsed" [animation]="true">
@if (!video().isLive) {
<div class="form-group stop-at">
<my-peertube-checkbox inputName="stopAt" [(ngModel)]="customizations.stopAtCheckbox" i18n-labelText labelText="Stop at"></my-peertube-checkbox>
<my-timestamp-input
[timestamp]="customizations.stopAt"
[maxTimestamp]="video().duration"
[disabled]="!customizations.stopAtCheckbox"
[(ngModel)]="customizations.stopAt"
>
</my-timestamp-input>
</div>
}
<div class="form-group">
<my-peertube-checkbox inputName="autoplay" [(ngModel)]="customizations.autoplay" i18n-labelText labelText="Autoplay"></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox inputName="muted" [(ngModel)]="customizations.muted" i18n-labelText labelText="Muted"></my-peertube-checkbox>
</div>
@if (!video().isLive) {
<div class="form-group">
<my-peertube-checkbox inputName="loop" [(ngModel)]="customizations.loop" i18n-labelText labelText="Loop"></my-peertube-checkbox>
</div>
}
@if (!isLocalVideo() && !isInVideoEmbedTab()) {
<div class="form-group">
<my-peertube-checkbox
inputName="originUrl"
[(ngModel)]="customizations.originUrl"
i18n-labelText
labelText="Use origin instance URL"
></my-peertube-checkbox>
</div>
}
@if (isInVideoEmbedTab()) {
<div class="form-group">
<my-peertube-checkbox
inputName="title"
[(ngModel)]="customizations.title"
i18n-labelText
labelText="Display video title"
></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox inputName="embedP2P" [(ngModel)]="customizations.embedP2P" i18n-labelText labelText="P2P"></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="warningTitle"
[(ngModel)]="customizations.warningTitle"
i18n-labelText
labelText="Display privacy warning"
[disabled]="!customizations.embedP2P"
></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="controlBar"
[(ngModel)]="customizations.controlBar"
i18n-labelText
labelText="Display player control bar"
></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="peertubeLink"
[(ngModel)]="customizations.peertubeLink"
i18n-labelText
labelText="Display PeerTube button link"
></my-peertube-checkbox>
</div>
}
</div>
<button
class="border-0 p-0 mt-4 mx-auto fw-semibold d-block"
(click)="isAdvancedCustomizationCollapsed = !isAdvancedCustomizationCollapsed"
[attr.aria-expanded]="!isAdvancedCustomizationCollapsed"
aria-controls="collapseBasic"
>
@if (isAdvancedCustomizationCollapsed) {
<span class="chevron-down"></span>
<ng-container i18n>More customization</ng-container>
} @else{
<span class="chevron-up"></span>
<ng-container i18n>Less customization</ng-container>
}
</button>
</div>
</div>
<my-share-video
[video]="video()"
[videoCaptions]="videoCaptions()"
[playlist]="playlist()"
[customizations]="customizations"
></my-share-video>
}
</div>
</ng-template>
@@ -1,111 +1,38 @@
import { Component, ElementRef, inject, input, model, viewChild } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import { HooksService, ServerService } from '@app/core'
import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
import {
NgbCollapse,
NgbModal,
NgbNav,
NgbNavContent,
NgbNavItem,
NgbNavLink,
NgbNavLinkBase,
NgbNavOutlet
} from '@ng-bootstrap/ng-bootstrap'
import { buildPlaylistLink, buildVideoLink, decoratePlaylistLink, decorateVideoLink } from '@peertube/peertube-core-utils'
import { VideoCaption, VideoPlaylistPrivacy, VideoPrivacy } from '@peertube/peertube-models'
import { buildVideoOrPlaylistEmbed } from '@root-helpers/video'
import { QRCodeComponent } from 'angularx-qrcode'
import { InputTextComponent } from '../shared-forms/input-text.component'
import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component'
import { TimestampInputComponent } from '../shared-forms/timestamp-input.component'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { VideoCaption } from '@peertube/peertube-models'
import { GlobalIconComponent } from '../shared-icons/global-icon.component'
import { AlertComponent } from '../shared-main/common/alert.component'
import { PluginPlaceholderComponent } from '../shared-main/plugins/plugin-placeholder.component'
import { VideoPlaylist } from '../shared-video-playlist/video-playlist.model'
type Customizations = {
startAtCheckbox: boolean
startAt: number
stopAtCheckbox: boolean
stopAt: number
subtitleCheckbox: boolean
subtitle: string
loop: boolean
originUrl: boolean
autoplay: boolean
muted: boolean
embedP2P: boolean
onlyEmbedUrl: boolean
title: boolean
warningTitle: boolean
controlBar: boolean
peertubeLink: boolean
responsive: boolean
includeVideoInPlaylist: boolean
}
type TabId = 'url' | 'qrcode' | 'embed'
import { SharePlaylistComponent } from './share-playlist.component'
import { ShareVideoComponent } from './share-video.component'
import { Customizations } from './video-share.model'
@Component({
selector: 'my-video-share',
templateUrl: './video-share.component.html',
styleUrls: [ './video-share.component.scss' ],
imports: [
GlobalIconComponent,
RouterLink,
NgbNav,
NgbNavItem,
NgbNavLink,
NgbNavLinkBase,
NgbNavContent,
InputTextComponent,
QRCodeComponent,
NgbNavOutlet,
PeertubeCheckboxComponent,
FormsModule,
PluginPlaceholderComponent,
TimestampInputComponent,
NgbCollapse,
AlertComponent
SharePlaylistComponent,
ShareVideoComponent
]
})
export class VideoShareComponent {
private modalService = inject(NgbModal)
private sanitizer = inject(DomSanitizer)
private server = inject(ServerService)
private hooks = inject(HooksService)
readonly modal = viewChild<ElementRef>('modal')
readonly playlistShare = viewChild(SharePlaylistComponent)
readonly videoShare = viewChild(ShareVideoComponent)
readonly video = input<VideoDetails>(null)
readonly videoCaptions = input<VideoCaption[]>([])
readonly playlist = input<VideoPlaylist>(null)
readonly playlistPosition = model<number>(null)
activeVideoId: TabId = 'url'
activePlaylistId: TabId = 'url'
customizations: Customizations
isAdvancedCustomizationCollapsed = true
videoUrl: string
playlistUrl: string
videoEmbedUrl: string
playlistEmbedUrl: string
videoEmbedHTML: string
videoEmbedSafeHTML: SafeHtml
playlistEmbedHTML: string
playlistEmbedSafeHTML: SafeHtml
show (currentVideoTimestamp?: number, currentPlaylistPosition?: number) {
let subtitle: string
@@ -163,166 +90,8 @@ export class VideoShareComponent {
})
}
// ---------------------------------------------------------------------------
getVideoUrl () {
const url = this.customizations.originUrl
? this.video().url
: buildVideoLink(this.video(), window.location.origin)
return this.hooks.wrapFun(
decorateVideoLink,
{ url, ...this.getVideoOptions(false) },
'video-watch',
'filter:share.video-url.build.params',
'filter:share.video-url.build.result'
)
}
getVideoEmbedUrl () {
return this.hooks.wrapFun(
decorateVideoLink,
{ url: this.video().embedUrl, ...this.getVideoOptions(true) },
'video-watch',
'filter:share.video-embed-url.build.params',
'filter:share.video-embed-url.build.result'
)
}
async getVideoEmbedCode (options: { responsive: boolean }) {
const { responsive } = options
return this.hooks.wrapFun(
buildVideoOrPlaylistEmbed,
{ embedUrl: await this.getVideoEmbedUrl(), embedTitle: this.video().name, responsive, aspectRatio: this.video().aspectRatio },
'video-watch',
'filter:share.video-embed-code.build.params',
'filter:share.video-embed-code.build.result'
)
}
// ---------------------------------------------------------------------------
getPlaylistUrl () {
const url = buildPlaylistLink(this.playlist())
return this.hooks.wrapFun(
decoratePlaylistLink,
{ url, ...this.getPlaylistOptions() },
'video-watch',
'filter:share.video-playlist-url.build.params',
'filter:share.video-playlist-url.build.result'
)
}
getPlaylistEmbedUrl () {
return this.hooks.wrapFun(
decoratePlaylistLink,
{ url: this.playlist().embedUrl, ...this.getPlaylistOptions() },
'video-watch',
'filter:share.video-playlist-embed-url.build.params',
'filter:share.video-playlist-embed-url.build.result'
)
}
async getPlaylistEmbedCode (options: { responsive: boolean }) {
const { responsive } = options
return this.hooks.wrapFun(
buildVideoOrPlaylistEmbed,
{
embedUrl: await this.getPlaylistEmbedUrl(),
embedTitle: this.playlist().displayName,
responsive,
aspectRatio: this.video()?.aspectRatio
},
'video-watch',
'filter:share.video-playlist-embed-code.build.params',
'filter:share.video-playlist-embed-code.build.result'
)
}
// ---------------------------------------------------------------------------
async onUpdate () {
if (this.playlist()) {
this.playlistUrl = await this.getPlaylistUrl()
this.playlistEmbedUrl = await this.getPlaylistEmbedUrl()
this.playlistEmbedHTML = await this.getPlaylistEmbedCode({ responsive: this.customizations.responsive })
this.playlistEmbedSafeHTML = this.sanitizer.bypassSecurityTrustHtml(await this.getPlaylistEmbedCode({ responsive: false }))
}
if (this.video()) {
this.videoUrl = await this.getVideoUrl()
this.videoEmbedUrl = await this.getVideoEmbedUrl()
this.videoEmbedHTML = await this.getVideoEmbedCode({ responsive: this.customizations.responsive })
this.videoEmbedSafeHTML = this.sanitizer.bypassSecurityTrustHtml(await this.getVideoEmbedCode({ responsive: false }))
}
}
notSecure () {
return window.location.protocol === 'http:'
}
isInVideoEmbedTab () {
return this.activeVideoId === 'embed'
}
isInPlaylistEmbedTab () {
return this.activePlaylistId === 'embed'
}
isLocalVideo () {
return this.video().isLocal
}
isPrivateVideo () {
return this.video().privacy.id === VideoPrivacy.PRIVATE
}
isPrivatePlaylist () {
return this.playlist().privacy.id === VideoPlaylistPrivacy.PRIVATE
}
isPasswordProtectedVideo () {
return this.video().privacy.id === VideoPrivacy.PASSWORD_PROTECTED
}
private getPlaylistOptions (baseUrl?: string) {
const playlistPosition = this.playlistPosition()
return {
baseUrl,
playlistPosition: playlistPosition && this.customizations.includeVideoInPlaylist
? playlistPosition
: undefined
}
}
private getVideoOptions (forEmbed: boolean) {
const embedOptions = forEmbed
? {
title: this.customizations.title,
warningTitle: this.customizations.warningTitle,
controlBar: this.customizations.controlBar,
peertubeLink: this.customizations.peertubeLink,
// If using default value, we don't need to specify it
p2p: this.customizations.embedP2P === this.server.getHTMLConfig().defaults.p2p.embed.enabled
? undefined
: this.customizations.embedP2P
}
: {}
return {
startTime: this.customizations.startAtCheckbox ? this.customizations.startAt : undefined,
stopTime: this.customizations.stopAtCheckbox ? this.customizations.stopAt : undefined,
subtitle: this.customizations.subtitleCheckbox ? this.customizations.subtitle : undefined,
loop: this.customizations.loop,
autoplay: this.customizations.autoplay,
muted: this.customizations.muted,
...embedOptions
}
onUpdate () {
this.playlistShare()?.onUpdate()
this.videoShare()?.onUpdate()
}
}
@@ -0,0 +1,27 @@
export type Customizations = {
startAtCheckbox: boolean
startAt: number
stopAtCheckbox: boolean
stopAt: number
subtitleCheckbox: boolean
subtitle: string
loop: boolean
originUrl: boolean
autoplay: boolean
muted: boolean
embedP2P: boolean
onlyEmbedUrl: boolean
title: boolean
warningTitle: boolean
controlBar: boolean
peertubeLink: boolean
responsive: boolean
includeVideoInPlaylist: boolean
}
export type TabId = 'url' | 'qrcode' | 'embed'
@@ -98,7 +98,8 @@ export class VideoDownloadComponent {
if (!this.video.isLocal || !this.authService.isLoggedIn()) return of(undefined)
const user = this.authService.getUser()
if (!this.video.isOwnerOrHasSeeAllVideosRight(user)) return of(undefined)
// User that can update the video can also get the original video file
if (!this.video.isUpdatableBy(user)) return of(undefined)
return this.videoService.getSource(this.video.id)
.pipe(catchError(err => {
@@ -188,8 +188,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
isVideoStatsAvailable () {
if (!this.user) return false
const video = this.video()
return video.isLocal && video.isOwnerOrHasSeeAllVideosRight(this.user)
// Users that can update the video can also see its stats
return this.video().isUpdatableBy(this.user)
}
isVideoRemovable () {
@@ -226,6 +226,7 @@ export class VideoActionsDropdownComponent implements OnChanges {
isVideoDownloadableByAnonymous () {
const video = this.video()
return (
video &&
video.isLive !== true &&
@@ -238,10 +239,11 @@ export class VideoActionsDropdownComponent implements OnChanges {
if (!this.user) return false
const video = this.video()
return (
video &&
video.isLive !== true &&
video.isOwnerOrHasSeeAllVideosRight(this.user)
video.isUpdatableBy(this.user)
)
}
@@ -22,8 +22,7 @@
<ng-container i18n>Videos on <strong>{{ instanceName }}</strong></ng-container>
} @else {
<ng-container i18n>
Videos on <strong>{{ instanceName }}</strong> and <strong>{{ totalFollowing }}</strong> {totalFollowing, plural, =1 {other platform} other {other
platforms}}
Videos on <strong>{{ instanceName }}</strong> and <strong>{{ totalFollowing }}</strong> {totalFollowing, plural, =1 {other platform} other {other platforms}}
</ng-container>
}
</div>
@@ -110,8 +109,8 @@
<div class="with-description">
<div i18n>
{{ instanceName }} platform subscribes to content from <a routerLink="/about/follows" target="_blank">{{ totalFollowing }} {totalFollowing,
plural, =1 {other platform} other {other platforms}}</a>.
{{ instanceName }} platform subscribes to content from
<a routerLink="/about/follows" target="_blank">{{ totalFollowing }} {totalFollowing, plural, =1 {other platform} other {other platforms}}</a>.
</div>
<div i18n>Set your display preferences here.</div>
</div>
@@ -5,7 +5,7 @@ import { RouterLink } from '@angular/router'
import { AuthService, RedirectService } from '@app/core'
import { ServerService } from '@app/core/server/server.service'
import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap'
import { UserRight, VideoConstant } from '@peertube/peertube-models'
import { UserRight, ConstantLabel } from '@peertube/peertube-models'
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
import debug from 'debug'
import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component'
@@ -70,8 +70,8 @@ export class VideoFiltersHeaderComponent implements OnInit {
instanceName: string
totalFollowing: number
private videoCategories: VideoConstant<number>[] = []
private videoLanguages: VideoConstant<string>[] = []
private videoCategories: ConstantLabel<number>[] = []
private videoLanguages: ConstantLabel<string>[] = []
ngOnInit () {
this.instanceName = this.server.getHTMLConfig().instance.name
@@ -1,11 +1,11 @@
import { getAPIUrl, getOriginUrl } from '@app/helpers'
import { getAPIUrl, getEmbedUrl } from '@app/helpers'
import { buildPlaylistWatchPath, peertubeTranslate } from '@peertube/peertube-core-utils'
import {
AccountSummary,
ConstantLabel,
VideoPlaylist as ServerVideoPlaylist,
Thumbnail,
VideoChannelSummary,
VideoConstant,
VideoPlaylistPrivacyType,
VideoPlaylistType,
VideoPlaylistType_Type
@@ -24,11 +24,11 @@ export class VideoPlaylist implements ServerVideoPlaylist {
displayName: string
description: string
privacy: VideoConstant<VideoPlaylistPrivacyType>
privacy: ConstantLabel<VideoPlaylistPrivacyType>
videosLength: number
type: VideoConstant<VideoPlaylistType_Type>
type: ConstantLabel<VideoPlaylistType_Type>
createdAt: Date | string
updatedAt: Date | string
@@ -68,7 +68,7 @@ export class VideoPlaylist implements ServerVideoPlaylist {
this.privacy = hash.privacy
this.embedPath = hash.embedPath
this.embedUrl = hash.embedUrl || (getOriginUrl() + hash.embedPath)
this.embedUrl = hash.embedUrl || (getEmbedUrl() + hash.embedPath)
this.videosLength = hash.videosLength
@@ -0,0 +1,31 @@
import { HttpClient } from '@angular/common/http'
import { inject, Injectable } from '@angular/core'
import { RestExtractor } from '@app/core'
import { VideoEmbedPrivacy, VideoEmbedPrivacyUpdate } from '@peertube/peertube-models'
import { catchError } from 'rxjs'
import { VideoService } from '../shared-main/video/video.service'
@Injectable()
export class VideoEmbedPrivacyService {
private authHttp = inject(HttpClient)
private restExtractor = inject(RestExtractor)
getPrivacy (options: {
videoId: string
}) {
const path = `${VideoService.BASE_VIDEO_URL}/${options.videoId}/embed-privacy`
return this.authHttp.get<VideoEmbedPrivacy>(path)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
updatePrivacy (options: {
videoId: string
settings: VideoEmbedPrivacyUpdate
}) {
const path = `${VideoService.BASE_VIDEO_URL}/${options.videoId}/embed-privacy`
return this.authHttp.put(path, options.settings)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
}
+2 -1
View File
@@ -2,5 +2,6 @@ export const environment = {
production: false,
hmr: true,
apiUrl: '',
originServerUrl: 'http://localhost:9000'
originServerUrl: 'http://localhost:9000',
embedUrl: 'http://localhost:5173'
}
+2 -1
View File
@@ -2,5 +2,6 @@ export const environment = {
production: true,
hmr: false,
apiUrl: '',
originServerUrl: ''
originServerUrl: '',
embedUrl: ''
}
+2 -1
View File
@@ -12,5 +12,6 @@ export const environment = {
production: true,
hmr: false,
apiUrl: '',
originServerUrl: ''
originServerUrl: '',
embedUrl: ''
}
+3 -1
View File
@@ -16,7 +16,9 @@ export function randomString (length: number) {
return result
}
export function splitAndGetNotEmpty (value: string) {
export function splitAndGetNotEmpty (value: string): string[] {
if (!value) return []
return value
.split('\n')
.filter(line => line && line.length !== 0) // Eject empty lines
@@ -14,6 +14,10 @@
@include peertube-input-text(100%);
}
.pt-textarea {
@include peertube-textarea(100%, 150px);
}
.form-group {
margin-bottom: 1.5rem;
}
@@ -147,13 +147,14 @@ body {
font-family: $main-fonts;
.error-details {
margin-top: 40px;
font-size: 80%;
margin-top: 0.5rem;
}
}
.vjs-modal-dialog-content {
padding-top: 40px !important;
padding-top: 0 !important;
display: flex;
align-items: center;
}
// Error display disabled
@@ -189,7 +189,13 @@ class PeerTubePlugin extends Plugin {
this.alterInactivity()
}
displayFatalError () {
displayFatalError (options: {
log?: boolean // default true
error?: Error | MediaError
isTechnicalError?: boolean // default true
} = {}) {
const { log = true, error = this.player.error(), isTechnicalError = true } = options
// Already displayed an error
if (this.errorModal) return
@@ -197,16 +203,20 @@ class PeerTubePlugin extends Plugin {
this.player.loadingSpinner.hide()
const buildModal = (error: MediaError) => {
const buildModal = () => {
const localize = this.player.localize.bind(this.player)
const wrapper = document.createElement('div')
const header = document.createElement('h1')
header.innerText = localize('Failed to play video')
wrapper.appendChild(header)
if (isTechnicalError) {
const desc = document.createElement('div')
desc.innerText = localize('The video failed to play due to technical issues.')
wrapper.appendChild(desc)
}
const details = document.createElement('p')
details.classList.add('error-details')
details.innerText = error.message
@@ -215,7 +225,7 @@ class PeerTubePlugin extends Plugin {
return wrapper
}
this.errorModal = this.player.createModal(buildModal(this.player.error()), {
this.errorModal = this.player.createModal(buildModal(), {
temporary: true,
uncloseable: true
})
@@ -223,11 +233,13 @@ class PeerTubePlugin extends Plugin {
this.player.addClass('vjs-error-display-enabled')
if (log) {
// Google Bot may throw codecs, but it should not prevent indexing
if (/googlebot/i.test(navigator.userAgent)) {
console.error(this.player.error())
console.error(error)
} else {
logger.error('Fatal error in player', this.player.error())
logger.error('Fatal error in player', error)
}
}
}
@@ -84,7 +84,7 @@ class PlaylistMenuItem extends Component {
const thumbnail = super.createEl('img', {
src: videoElement.video.thumbnails.length !== 0
? window.location.origin + findAppropriateThumbnail(videoElement.video.thumbnails, 80, '16:9').fileUrl // Keep 80 in sync with CSS
? findAppropriateThumbnail(videoElement.video.thumbnails, 80, '16:9').fileUrl // Keep 80 in sync with CSS
: ''
})
+1
View File
@@ -63,6 +63,7 @@ body {
#error-content {
font-size: 24px;
padding: 0 20px;
}
#error-details {
+23 -5
View File
@@ -3,6 +3,7 @@ import {
ResultList,
ServerErrorCode,
VideoDetails,
VideoEmbedPrivacyPolicy,
VideoPlaylist,
VideoPlaylistElement,
VideoState
@@ -224,7 +225,7 @@ export class PeerTubeEmbed {
playerSettingsPromise
} = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword })
return this.buildVideoPlayer({
return await this.buildVideoPlayer({
videoResponse,
captionsPromise,
chaptersPromise,
@@ -233,8 +234,17 @@ export class PeerTubeEmbed {
forceAutoplay
})
} catch (err) {
if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options })
else this.playerHTML.displayError(err.message, await this.translationsPromise)
if (await this.handlePasswordError(err)) {
this.loadVideoAndBuildPlayer({ ...options })
return
}
if (this.player?.usingPlugin('peertube')) {
this.player.peertube().displayFatalError({ error: err, log: false, isTechnicalError: false })
return
}
this.playerHTML.displayError(err.message, await this.translationsPromise)
}
}
@@ -260,11 +270,15 @@ export class PeerTubeEmbed {
? await this.videoFetcher.loadVideoToken(videoInfo, this.videoPassword)
: undefined
return { live, video: videoInfo, videoFileToken }
const allowed = videoInfo.embedPrivacyPolicy.id !== VideoEmbedPrivacyPolicy.ALL_ALLOWED
? await this.videoFetcher.loadEmbedAllowed(videoInfo)
: true
return { live, video: videoInfo, videoFileToken, allowed }
})
const [
{ video, live, videoFileToken },
{ video, live, videoFileToken, allowed },
translations,
captionsResponse,
chaptersResponse,
@@ -280,6 +294,10 @@ export class PeerTubeEmbed {
this.buildPlayerIfNeeded()
])
if (!allowed) {
throw new Error('This video is not allowed to be embedded on this domain.')
}
const playlist = this.playlistTracker
? {
onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer({ uuid, forceAutoplay: false }),
@@ -1,4 +1,4 @@
import { HttpStatusCode, LiveVideo, VideoDetails, VideoToken } from '@peertube/peertube-models'
import { HttpStatusCode, LiveVideo, VideoDetails, VideoEmbedPrivacyAllowed, VideoToken } from '@peertube/peertube-models'
import { logger } from '../../../root-helpers'
import { PeerTubeServerError } from '../../../types'
import { AuthHTTP } from './auth-http'
@@ -57,6 +57,17 @@ export class VideoFetcher {
.then(token => token.files.token)
}
loadEmbedAllowed (video: VideoDetails) {
if (!document.referrer) return Promise.resolve({ allowed: false })
const params = new URLSearchParams()
params.append('domain', new URL(document.referrer).host)
return this.http.fetch(this.getVideoUrl(video.uuid) + '/embed-privacy/allowed?' + params.toString(), { optionalAuth: false })
.then(res => res.json() as Promise<VideoEmbedPrivacyAllowed>)
.then(({ domainAllowed }) => domainAllowed)
}
getVideoViewsUrl (videoUUID: string) {
return this.getVideoUrl(videoUUID) + '/views'
}
@@ -38,6 +38,9 @@ export interface VideoObject {
waitTranscoding: boolean
state: VideoStateType
// If null, the embed has restrictions
embedUrl: string | null
published: string
originallyPublishedAt: string
updated: string
@@ -1,4 +1,4 @@
export interface VideoConstant<T> {
export interface ConstantLabel<T> {
id: T
label: string
description?: string
+1
View File
@@ -1,4 +1,5 @@
export * from './file-storage.enum.js'
export * from './constant-label.model.js'
export * from './peertube-error.model.js'
export * from './result-list.model.js'
export * from './simple-logger.model.js'
@@ -2,6 +2,7 @@ import { PlayerThemeVideoSetting } from '../../player/player-theme.type.js'
import {
LiveVideoLatencyModeType,
VideoCommentPolicyType,
VideoEmbedPrivacyPolicyType,
VideoFileMetadata,
VideoPrivacyType,
VideoStateType,
@@ -113,6 +114,11 @@ export interface VideoExportJSON {
theme: PlayerThemeVideoSetting
}
videoEmbedPrivacy: {
policy: VideoEmbedPrivacyPolicyType
domains: string[]
}
archiveFiles: {
videoFile: string | null
thumbnail: string | null
@@ -1,7 +1,7 @@
import { Account } from '../../actors/account.model.js'
import { VideoChannel } from '../../videos/channel/video-channel.model.js'
import { Thumbnail } from '../../videos/index.js'
import { VideoConstant } from '../../videos/video-constant.model.js'
import { ConstantLabel } from '../../common/constant-label.model.js'
import { AbusePredefinedReasonsString } from './abuse-reason.model.js'
import { AbuseStateType } from './abuse-state.model.js'
@@ -55,7 +55,7 @@ export interface AdminAbuse {
reporterAccount: Account
flaggedAccount: Account
state: VideoConstant<AbuseStateType>
state: ConstantLabel<AbuseStateType>
moderationComment?: string
video?: AdminVideoAbuse
@@ -1,4 +1,5 @@
import { Video, VideoChannelSummary, VideoConstant } from '../videos/index.js'
import { ConstantLabel } from '../common/constant-label.model.js'
import { Video, VideoChannelSummary } from '../videos/index.js'
export interface ChannelOverview {
channel: VideoChannelSummary
@@ -6,7 +7,7 @@ export interface ChannelOverview {
}
export interface CategoryOverview {
category: VideoConstant<number>
category: ConstantLabel<number>
videos: Video[]
}
@@ -1,4 +1,4 @@
import { VideoConstant } from '../../videos/index.js'
import { ConstantLabel } from '../../common/constant-label.model.js'
import { RunnerJobPayload } from './runner-job-payload.model.js'
import { RunnerJobPrivatePayload } from './runner-job-private-payload.model.js'
import { RunnerJobStateType } from './runner-job-state.model.js'
@@ -9,7 +9,7 @@ export interface RunnerJob <T extends RunnerJobPayload = RunnerJobPayload> {
type: RunnerJobType
state: VideoConstant<RunnerJobStateType>
state: ConstantLabel<RunnerJobStateType>
payload: T
@@ -26,7 +26,7 @@ export interface RunnerJob <T extends RunnerJobPayload = RunnerJobPayload> {
parent?: {
type: RunnerJobType
state: VideoConstant<RunnerJobStateType>
state: ConstantLabel<RunnerJobStateType>
uuid: string
}
@@ -40,6 +40,8 @@ export interface RunnerJob <T extends RunnerJobPayload = RunnerJobPayload> {
}
// eslint-disable-next-line max-len
export interface RunnerJobAdmin <T extends RunnerJobPayload = RunnerJobPayload, U extends RunnerJobPrivatePayload = RunnerJobPrivatePayload> extends RunnerJob<T> {
export interface RunnerJobAdmin<T extends RunnerJobPayload = RunnerJobPayload, U extends RunnerJobPrivatePayload = RunnerJobPrivatePayload>
extends RunnerJob<T>
{
privatePayload: U
}
@@ -2,7 +2,7 @@ import { FollowState } from '../actors/index.js'
import { AbuseStateType } from '../moderation/index.js'
import { PluginType_Type } from '../plugins/index.js'
import { VideoChannelCollaboratorStateType } from '../videos/index.js'
import { VideoConstant } from '../videos/video-constant.model.js'
import { ConstantLabel } from '../common/constant-label.model.js'
import { VideoStateType } from '../videos/video-state.enum.js'
import { UserNotificationData } from './user-notification-data.model.js'
@@ -156,14 +156,14 @@ export interface UserNotification {
videoCaption?: {
id: number
language: VideoConstant<string>
language: ConstantLabel<string>
video: VideoInfo
}
videoChannelCollaborator?: {
id: number
state: VideoConstant<VideoChannelCollaboratorStateType>
state: ConstantLabel<VideoChannelCollaboratorStateType>
channel: ActorInfo
channelOwner: ActorInfo
@@ -1,7 +1,7 @@
import { VideoConstant } from '../video-constant.model.js'
import { ConstantLabel } from '../../common/constant-label.model.js'
export interface VideoCaption {
language: VideoConstant<string>
language: ConstantLabel<string>
// TODO: remove, deprecated in 8.0
captionPath: string
@@ -1,5 +1,5 @@
import { VideoChannelSummary } from '../channel/video-channel.model.js'
import { VideoConstant } from '../video-constant.model.js'
import { ConstantLabel } from '../../common/constant-label.model.js'
import { VideoChannelSyncStateType } from './video-channel-sync-state.enum.js'
export interface VideoChannelSync {
@@ -9,6 +9,6 @@ export interface VideoChannelSync {
createdAt: string
channel: VideoChannelSummary
state: VideoConstant<VideoChannelSyncStateType>
state: ConstantLabel<VideoChannelSyncStateType>
lastSyncAt: string
}
@@ -14,7 +14,8 @@ export const VideoChannelActivityAction = {
CREATE_CHANNEL_OWNERSHIP: 11,
SEND_OWNERSHIP_REQUEST: 12,
ACCEPT_OWNERSHIP_REQUEST: 13,
REFUSE_OWNERSHIP_REQUEST: 14
REFUSE_OWNERSHIP_REQUEST: 14,
UPDATE_EMBED_POLICY: 15
} as const
export type VideoChannelActivityActionType = typeof VideoChannelActivityAction[keyof typeof VideoChannelActivityAction]
@@ -0,0 +1,4 @@
export * from './video-embed-privacy-update.model.js'
export * from './video-embed-privacy-allowed.model.js'
export * from './video-embed-privacy-policy.enum.js'
export * from './video-embed-privacy.model.js'
@@ -0,0 +1,5 @@
export interface VideoEmbedPrivacyAllowed {
domainAllowed: boolean
userBypassAllowed: boolean
}
@@ -0,0 +1,9 @@
export const VideoEmbedPrivacyPolicy = {
ALL_ALLOWED: 1,
ALLOWLIST: 2,
// Federated server imposes restrictions on the embed
REMOTE_RESTRICTIONS: 3
} as const
export type VideoEmbedPrivacyPolicyType = typeof VideoEmbedPrivacyPolicy[keyof typeof VideoEmbedPrivacyPolicy]
@@ -0,0 +1,7 @@
import { VideoEmbedPrivacyPolicyType } from './video-embed-privacy-policy.enum.js'
export interface VideoEmbedPrivacyUpdate {
policy: VideoEmbedPrivacyPolicyType
domains: string[]
}
@@ -0,0 +1,8 @@
import { ConstantLabel } from '../../common/constant-label.model.js'
import { VideoEmbedPrivacyPolicyType } from './video-embed-privacy-policy.enum.js'
export interface VideoEmbedPrivacy {
policy: ConstantLabel<VideoEmbedPrivacyPolicyType>
domains: string[]
}
@@ -1,11 +1,11 @@
import { FileStorageType } from '../../common/file-storage.enum.js'
import { VideoConstant } from '../video-constant.model.js'
import { ConstantLabel } from '../../common/constant-label.model.js'
import { VideoFileMetadata } from './video-file-metadata.model.js'
export interface VideoFile {
id: number
resolution: VideoConstant<number>
resolution: ConstantLabel<number>
size: number // Bytes
width?: number
@@ -1,4 +1,4 @@
import { VideoConstant } from '../video-constant.model.js'
import { ConstantLabel } from '../../common/constant-label.model.js'
import { Video } from '../video.model.js'
import { VideoImportStateType } from './video-import-state.enum.js'
@@ -14,7 +14,7 @@ export interface VideoImport {
createdAt: string
updatedAt: string
originallyPublishedAt?: string
state: VideoConstant<VideoImportStateType>
state: ConstantLabel<VideoImportStateType>
error?: string
video?: Video & { tags: string[] }
+2 -2
View File
@@ -13,6 +13,7 @@ export * from './stats/index.js'
export * from './transcoding/index.js'
export * from './channel-sync/index.js'
export * from './chapter/index.js'
export * from './embed-privacy/index.js'
export * from './nsfw-flag.enum.js'
export * from './nsfw-policy.type.js'
@@ -20,9 +21,8 @@ export * from './nsfw-policy.type.js'
export * from './storyboard.model.js'
export * from './thumbnail/index.js'
export * from './video-constant.model.js'
export * from './video-create.model.js'
export * from './video-create-update-common.model.js'
export * from './video-create.model.js'
export * from './video-licence.enum.js'
export * from './video-privacy.enum.js'
@@ -1,7 +1,7 @@
import { AccountSummary } from '../../actors/index.js'
import { VideoChannelSummary } from '../channel/index.js'
import { Thumbnail } from '../thumbnail/thumbnail.model.js'
import { VideoConstant } from '../video-constant.model.js'
import { ConstantLabel } from '../../common/constant-label.model.js'
import { VideoPlaylistPrivacyType } from './video-playlist-privacy.model.js'
import { VideoPlaylistType_Type } from './video-playlist-type.model.js'
@@ -16,7 +16,7 @@ export interface VideoPlaylist {
displayName: string
description: string
privacy: VideoConstant<VideoPlaylistPrivacyType>
privacy: ConstantLabel<VideoPlaylistPrivacyType>
/**
* @deprecated in 8.1, use thumbnails array instead
@@ -31,7 +31,7 @@ export interface VideoPlaylist {
videosLength: number
type: VideoConstant<VideoPlaylistType_Type>
type: ConstantLabel<VideoPlaylistType_Type>
embedPath: string
embedUrl?: string
@@ -22,9 +22,11 @@ export interface VideoCreateUpdateCommon {
waitTranscoding?: boolean
channelId?: number
thumbnailfile?: Blob
// TODO: remove in v10, deprecated in 8.1
previewfile?: Blob
scheduleUpdate?: VideoScheduleUpdate
originallyPublishedAt?: Date | string
videoPasswords?: string[]
@@ -1,10 +1,10 @@
import { VideoFileMetadata } from './file/index.js'
import { VideoConstant } from './video-constant.model.js'
import { ConstantLabel } from '../common/constant-label.model.js'
export interface VideoSource {
inputFilename: string
resolution?: VideoConstant<number>
resolution?: ConstantLabel<number>
size?: number // Bytes
width?: number
+10 -7
View File
@@ -1,10 +1,11 @@
import { Account, AccountSummary } from '../actors/index.js'
import { VideoChannel, VideoChannelSummary } from './channel/video-channel.model.js'
import { VideoCommentPolicyType } from './comment/video-comment-policy.enum.js'
import { VideoEmbedPrivacyPolicyType } from './embed-privacy/video-embed-privacy-policy.enum.js'
import { VideoFile } from './file/index.js'
import { LiveVideoScheduleEdit } from './live/live-video-schedule.model.js'
import { Thumbnail } from './thumbnail/thumbnail.model.js'
import { VideoConstant } from './video-constant.model.js'
import { ConstantLabel } from '../common/constant-label.model.js'
import { VideoPrivacyType } from './video-privacy.enum.js'
import { VideoScheduleUpdate } from './video-schedule-update.model.js'
import { VideoSource } from './video-source.model.js'
@@ -20,10 +21,10 @@ export interface Video extends Partial<VideoAdditionalAttributes> {
updatedAt: Date | string
publishedAt: Date | string
originallyPublishedAt: Date | string
category: VideoConstant<number>
licence: VideoConstant<number>
language: VideoConstant<string>
privacy: VideoConstant<VideoPrivacyType>
category: ConstantLabel<number>
licence: ConstantLabel<number>
language: ConstantLabel<string>
privacy: ConstantLabel<VideoPrivacyType>
// Deprecated in 5.0 in favour of truncatedDescription
description: string
@@ -89,7 +90,7 @@ export interface Video extends Partial<VideoAdditionalAttributes> {
// Not included by default, needs query params
export interface VideoAdditionalAttributes {
waitTranscoding: boolean
state: VideoConstant<VideoStateType>
state: ConstantLabel<VideoStateType>
scheduledUpdate: VideoScheduleUpdate
blacklisted: boolean
@@ -123,7 +124,7 @@ export interface VideoDetails extends Video {
// Not optional in details (unlike in parent Video)
waitTranscoding: boolean
state: VideoConstant<VideoStateType>
state: ConstantLabel<VideoStateType>
trackerUrls: string[]
@@ -131,4 +132,6 @@ export interface VideoDetails extends Video {
streamingPlaylists: VideoStreamingPlaylist[]
inputFileUpdatedAt: string | Date
embedPrivacyPolicy: ConstantLabel<VideoEmbedPrivacyPolicyType>
}
@@ -43,6 +43,7 @@ import {
ServicesCommand,
StoryboardCommand,
StreamingPlaylistsCommand,
VideoEmbedPrivacyCommand,
VideoImportsCommand,
VideoPasswordsCommand,
VideoStatsCommand,
@@ -152,6 +153,7 @@ export class PeerTubeServer {
videoStudio?: VideoStudioCommand
videos?: VideosCommand
videoStats?: VideoStatsCommand
videoEmbedPrivacy?: VideoEmbedPrivacyCommand
views?: ViewsCommand
twoFactor?: TwoFactorCommand
videoToken?: VideoTokenCommand
@@ -462,6 +464,7 @@ export class PeerTubeServer {
this.videos = new VideosCommand(this)
this.videoStudio = new VideoStudioCommand(this)
this.videoStats = new VideoStatsCommand(this)
this.videoEmbedPrivacy = new VideoEmbedPrivacyCommand(this)
this.views = new ViewsCommand(this)
this.twoFactor = new TwoFactorCommand(this)
this.videoToken = new VideoTokenCommand(this)
@@ -16,6 +16,7 @@ export * from './playlists-command.js'
export * from './services-command.js'
export * from './storyboard-command.js'
export * from './streaming-playlists-command.js'
export * from './video-embed-privacy-command.js'
export * from './comments-command.js'
export * from './video-studio-command.js'
export * from './video-token-command.js'
@@ -0,0 +1,57 @@
import { HttpStatusCode, VideoEmbedPrivacy, VideoEmbedPrivacyAllowed, VideoEmbedPrivacyUpdate } from '@peertube/peertube-models'
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
export class VideoEmbedPrivacyCommand extends AbstractCommand {
get (
options: OverrideCommandOptions & {
videoId: number | string
}
) {
const { videoId } = options
const path = '/api/v1/videos/' + videoId + '/embed-privacy'
return this.getRequestBody<VideoEmbedPrivacy>({
...options,
path,
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
isDomainAllowed (
options: OverrideCommandOptions & {
videoId: number | string
domain: string
}
) {
const { videoId } = options
const path = '/api/v1/videos/' + videoId + '/embed-privacy/allowed'
return this.getRequestBody<VideoEmbedPrivacyAllowed>({
...options,
path,
query: { domain: options.domain },
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
update (
options: OverrideCommandOptions & VideoEmbedPrivacyUpdate & {
videoId: number | string
}
) {
const { videoId, policy, domains } = options
const path = '/api/v1/videos/' + videoId + '/embed-privacy'
return this.putBodyRequest({
...options,
path,
fields: { policy, domains },
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
})
}
}
@@ -41,6 +41,7 @@ import './channel-syncs.js'
import './channels.js'
import './video-chapters.js'
import './video-comments.js'
import './video-embed-privacy.js'
import './video-files.js'
import './video-imports.js'
import './video-playlists.js'
@@ -0,0 +1,249 @@
import { HttpStatusCode, VideoCreateResult, VideoEmbedPrivacyPolicy } from '@peertube/peertube-models'
import {
PeerTubeServer,
cleanupTests,
createSingleServer,
setAccessTokensToServers,
setDefaultVideoChannel
} from '@peertube/peertube-server-commands'
describe('Test video embed privacy validator', function () {
let server: PeerTubeServer
let video: VideoCreateResult
let ownerAccessToken: string
let userAccessToken: string
let editorAccessToken: string
let invitedEditorAccessToken: string
// ---------------------------------------------------------------
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')
editorAccessToken = await server.channelCollaborators.createEditor('accepted_editor', 'owner_channel')
invitedEditorAccessToken = await server.channelCollaborators.createInvited('invited_editor', 'owner_channel')
video = await server.videos.upload({ token: ownerAccessToken })
})
describe('When getting embed privacy', function () {
it('Should fail without a valid uuid', async function () {
await server.videoEmbedPrivacy.get({ videoId: '4da6fd', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
})
it('Should fail with an unknown id', async function () {
await server.videoEmbedPrivacy.get({
videoId: 'ce0801ef-7124-48df-9b22-b473ace78797',
expectedStatus: HttpStatusCode.NOT_FOUND_404
})
})
it('Should fail without access token', async function () {
await server.videoEmbedPrivacy.get({
videoId: video.id,
token: null,
expectedStatus: HttpStatusCode.UNAUTHORIZED_401
})
})
it('Should fail with a bad access token', async function () {
await server.videoEmbedPrivacy.get({
videoId: video.id,
token: 'toto',
expectedStatus: HttpStatusCode.UNAUTHORIZED_401
})
})
it('Should fail with another user access token', async function () {
await server.videoEmbedPrivacy.get({
videoId: video.id,
token: userAccessToken,
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
})
it('Should fail with an invited editor access token', async function () {
await server.videoEmbedPrivacy.get({
videoId: video.id,
token: invitedEditorAccessToken,
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
})
it('Should succeed with correct params', async function () {
for (const token of [ server.accessToken, ownerAccessToken, editorAccessToken ]) {
await server.videoEmbedPrivacy.get({ videoId: video.id, token })
}
})
})
describe('When checking if embed is allowed on a domain', function () {
it('Should fail without a valid uuid', async function () {
await server.videoEmbedPrivacy.isDomainAllowed({
videoId: '4da6fd',
domain: 'example.com',
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should fail with an unknown id', async function () {
await server.videoEmbedPrivacy.isDomainAllowed({
videoId: 'ce0801ef-7124-48df-9b22-b473ace78797',
domain: 'example.com',
expectedStatus: HttpStatusCode.NOT_FOUND_404
})
})
it('Should fail with an invalid domain', async function () {
await server.videoEmbedPrivacy.isDomainAllowed({
videoId: video.id,
domain: '',
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should succeed with correct params', async function () {
await server.videoEmbedPrivacy.isDomainAllowed({ videoId: video.id, domain: 'example.com' })
})
})
describe('When updating embed privacy', function () {
it('Should fail without a valid uuid', async function () {
await server.videoEmbedPrivacy.update({
videoId: '4da6fd',
policy: VideoEmbedPrivacyPolicy.ALL_ALLOWED,
domains: [],
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should fail with an unknown id', async function () {
await server.videoEmbedPrivacy.update({
videoId: 'ce0801ef-7124-48df-9b22-b473ace78797',
policy: VideoEmbedPrivacyPolicy.ALL_ALLOWED,
domains: [],
expectedStatus: HttpStatusCode.NOT_FOUND_404
})
})
it('Should fail without access token', async function () {
await server.videoEmbedPrivacy.update({
videoId: video.id,
policy: VideoEmbedPrivacyPolicy.ALL_ALLOWED,
domains: [],
token: null,
expectedStatus: HttpStatusCode.UNAUTHORIZED_401
})
})
it('Should fail with a bad access token', async function () {
await server.videoEmbedPrivacy.update({
videoId: video.id,
policy: VideoEmbedPrivacyPolicy.ALL_ALLOWED,
domains: [],
token: 'toto',
expectedStatus: HttpStatusCode.UNAUTHORIZED_401
})
})
it('Should fail with another user access token', async function () {
await server.videoEmbedPrivacy.update({
videoId: video.id,
policy: VideoEmbedPrivacyPolicy.ALL_ALLOWED,
domains: [],
token: userAccessToken,
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
})
it('Should fail with an invited editor access token', async function () {
await server.videoEmbedPrivacy.update({
videoId: video.id,
policy: VideoEmbedPrivacyPolicy.ALL_ALLOWED,
domains: [],
token: invitedEditorAccessToken,
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
})
it('Should fail with an invalid policy', async function () {
await server.videoEmbedPrivacy.update({
videoId: video.id,
policy: 999 as any,
domains: [],
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
await server.videoEmbedPrivacy.update({
videoId: video.id,
policy: VideoEmbedPrivacyPolicy.REMOTE_RESTRICTIONS,
domains: [],
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should fail with missing policy', async function () {
await server.videoEmbedPrivacy.update({
videoId: video.id,
policy: undefined as any,
domains: [],
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should fail with invalid domains', async function () {
await server.videoEmbedPrivacy.update({
videoId: video.id,
policy: VideoEmbedPrivacyPolicy.ALL_ALLOWED,
domains: 'example.com' as any,
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
await server.videoEmbedPrivacy.update({
videoId: video.id,
policy: VideoEmbedPrivacyPolicy.ALL_ALLOWED,
domains: [ 'http://example.com' ],
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should fail with missing domains', async function () {
await server.videoEmbedPrivacy.update({
videoId: video.id,
policy: VideoEmbedPrivacyPolicy.ALL_ALLOWED,
domains: undefined as any,
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should fail with inconsistent policy', async function () {
await server.videoEmbedPrivacy.update({
videoId: video.id,
policy: VideoEmbedPrivacyPolicy.ALL_ALLOWED,
domains: [ 'example.com' ],
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should succeed with correct params', async function () {
const policy = VideoEmbedPrivacyPolicy.ALLOWLIST
const domains = [ 'example.com' ]
for (const token of [ server.accessToken, ownerAccessToken, editorAccessToken ]) {
await server.videoEmbedPrivacy.update({ videoId: video.id, token, policy, domains })
}
})
})
after(async function () {
await cleanupTests([ server ])
})
})
@@ -5,6 +5,7 @@ import {
HttpStatusCode,
HttpStatusCodeType,
LiveVideoError,
VideoEmbedPrivacyPolicy,
VideoPrivacy,
VideoPrivacyType,
VideoState,
@@ -61,6 +62,7 @@ describe('Save replay setting', function () {
thumbnailfile: options.thumbnailfile
}
})
return uuid
}
@@ -293,6 +295,13 @@ describe('Save replay setting', function () {
liveVideoUUID = await createLiveWrapper({ permanent: false, replay: true, replaySettings: { privacy: VideoPrivacy.UNLISTED } })
await servers[0].playerSettings.updateForVideo({ theme: 'lucide', videoId: liveVideoUUID })
await servers[0].videoEmbedPrivacy.update({
videoId: liveVideoUUID,
domains: [ 'example.com' ],
policy: VideoEmbedPrivacyPolicy.ALLOWLIST
})
await waitJobs(servers)
await checkVideosExist(liveVideoUUID, 0, HttpStatusCode.OK_200)
@@ -335,6 +344,13 @@ describe('Save replay setting', function () {
await checkVideoState(liveVideoUUID, VideoState.PUBLISHED)
await checkVideoPrivacy(liveVideoUUID, VideoPrivacy.UNLISTED)
await checkVideoTags(liveVideoUUID, [ 'tag1', 'tag2' ])
const playerSettings = await servers[0].playerSettings.getForVideo({ videoId: liveVideoUUID })
expect(playerSettings.theme).to.equal('lucide')
const videoEmbedPrivacy = await servers[0].videoEmbedPrivacy.get({ videoId: liveVideoUUID })
expect(videoEmbedPrivacy.policy.id).to.equal(VideoEmbedPrivacyPolicy.ALLOWLIST)
expect(videoEmbedPrivacy.domains).to.deep.equal([ 'example.com' ])
})
it('Should find the replay live session', async function () {
@@ -436,6 +452,13 @@ describe('Save replay setting', function () {
thumbnailfile: 'custom-thumbnail-input.jpg'
})
await servers[0].playerSettings.updateForVideo({ theme: 'lucide', videoId: liveVideoUUID })
await servers[0].videoEmbedPrivacy.update({
videoId: liveVideoUUID,
domains: [ 'example.com' ],
policy: VideoEmbedPrivacyPolicy.ALLOWLIST
})
await waitJobs(servers)
await checkVideosExist(liveVideoUUID, 0, HttpStatusCode.OK_200)
@@ -483,6 +506,13 @@ describe('Save replay setting', function () {
await servers[1].videos.get({ id: lastReplayUUID, expectedStatus: HttpStatusCode.OK_200 })
await checkVideoTags(lastReplayUUID, [ 'tag1', 'tag2' ])
const playerSettings = await servers[0].playerSettings.getForVideo({ videoId: lastReplayUUID })
expect(playerSettings.theme).to.equal('lucide')
const videoEmbedPrivacy = await servers[0].videoEmbedPrivacy.get({ videoId: lastReplayUUID })
expect(videoEmbedPrivacy.policy.id).to.equal(VideoEmbedPrivacyPolicy.ALLOWLIST)
expect(videoEmbedPrivacy.domains).to.deep.equal([ 'example.com' ])
})
it('Should have appropriate ended session and replay live session', async function () {
@@ -155,9 +155,6 @@ function runTest (withObjectStorage: boolean) {
await waitJobs([ server ])
})
it('Should not export collaborations', async function () {
})
it('Should have received an email on archive creation', async function () {
const email = emails.find(e => {
return e['to'][0]['address'] === 'admin' + server.internalServerNumber + '@example.com' &&
@@ -7,6 +7,7 @@ import {
UserNotificationSettingValue,
VideoCommentPolicy,
VideoCreateResult,
VideoEmbedPrivacyPolicy,
VideoPlaylistPrivacy,
VideoPlaylistType,
VideoPrivacy,
@@ -510,6 +511,7 @@ function runTest (withObjectStorage: boolean) {
await remoteServer.videos.get({ id: liveVideo.uuid, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
const video = await remoteServer.videos.getWithPassword({ id: liveVideo.uuid, password: 'password1' })
const live = await remoteServer.live.get({ videoId: liveVideo.uuid, token: remoteNoahToken })
const liveEmbedPrivacy = await remoteServer.videoEmbedPrivacy.get({ videoId: liveVideo.uuid, token: remoteNoahToken })
expect(video.isLive).to.be.true
expect(live.latencyMode).to.equal(LiveVideoLatencyMode.SMALL_LATENCY)
@@ -527,6 +529,9 @@ function runTest (withObjectStorage: boolean) {
expect(video.streamingPlaylists).to.have.lengthOf(0)
expect(video.state.id).to.equal(VideoState.WAITING_FOR_LIVE)
expect(liveEmbedPrivacy.policy.id).to.equal(VideoEmbedPrivacyPolicy.ALLOWLIST)
expect(liveEmbedPrivacy.domains).to.deep.equal([ 'example.com' ])
}
})
})
@@ -5,6 +5,7 @@ import {
VideoChannelActivityAction,
VideoChannelActivityTarget,
VideoCreateResult,
VideoEmbedPrivacyPolicy,
VideoImport,
VideoPlaylistCreateResult,
VideoPlaylistElementCreateResult,
@@ -350,6 +351,21 @@ describe('Test channel activities', function () {
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
})
it('Should update embed privacy', async function () {
const a = await getActivityAfterAction(async () => {
await server.videoEmbedPrivacy.update({
videoId: video.id,
policy: VideoEmbedPrivacyPolicy.ALLOWLIST,
domains: [ 'example.com' ],
token: editorToken
})
})
expect(a.account.name).to.equal('editor')
expect(a.action.id).to.equal(VideoChannelActivityAction.UPDATE_EMBED_POLICY)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
})
it('Should delete the video', async function () {
const a = await getActivityAfterAction(() => {
return server.videos.remove({ id: video.id })
@@ -390,6 +406,88 @@ describe('Test channel activities', function () {
})
})
describe('Ownership changes', function () {
let video: VideoCreateResult
let receiverToken: string
before(async function () {
video = await server.videos.quickUpload({ name: 'video for ownership change', channelId })
receiverToken = await server.users.generateUserAndToken('receiver')
})
it('Should send an ownership change request', async function () {
const a = await getActivityAfterAction(() => {
return server.changeOwnership.create({ username: 'receiver', videoId: video.id })
})
expect(a.action.id).to.equal(VideoChannelActivityAction.SEND_OWNERSHIP_REQUEST)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
expect(a.account.name).to.equal('root')
expect(a.video.name).to.equal('video for ownership change')
expect(a.targetAccount.username).to.equal('receiver')
expect(a.targetAccount.url).to.equal(server.url + '/accounts/receiver')
expect(a.targetAccount.displayName).to.equal('receiver')
})
it('Should refuse an ownership change request', async function () {
const { data } = await server.changeOwnership.list({ token: receiverToken })
const a = await getActivityAfterAction(() => {
return server.changeOwnership.refuse({ ownershipId: data[0].id, token: receiverToken })
})
expect(a.action.id).to.equal(VideoChannelActivityAction.REFUSE_OWNERSHIP_REQUEST)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
expect(a.account.name).to.equal('receiver')
expect(a.video.name).to.equal('video for ownership change')
expect(a.targetAccount.username).to.equal('receiver')
expect(a.targetAccount.url).to.equal(server.url + '/accounts/receiver')
expect(a.targetAccount.displayName).to.equal('receiver')
})
it('Should accept an ownership change request', async function () {
await server.changeOwnership.create({ username: 'receiver', videoId: video.id })
const { data } = await server.changeOwnership.list({ token: receiverToken })
const a = await getActivityAfterAction(async () => {
return server.changeOwnership.accept({
ownershipId: data[0].id,
channelId: await server.channels.getDefaultId({ token: receiverToken }),
token: receiverToken
})
})
{
expect(a.action.id).to.equal(VideoChannelActivityAction.ACCEPT_OWNERSHIP_REQUEST)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
expect(a.account.name).to.equal('receiver')
expect(a.video.name).to.equal('video for ownership change')
expect(a.targetAccount.username).to.equal('receiver')
expect(a.targetAccount.url).to.equal(server.url + '/accounts/receiver')
expect(a.targetAccount.displayName).to.equal('receiver')
}
{
const { data } = await server.channels.listActivities({ channelName: 'receiver_channel', sort: '-createdAt' })
const a = data[0]
expect(a.action.id).to.equal(VideoChannelActivityAction.ACCEPT_OWNERSHIP_REQUEST)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
expect(a.video.name).to.equal('video for ownership change')
}
})
})
describe('Lives', async function () {
let liveVideo: VideoCreateResult
+1
View File
@@ -13,6 +13,7 @@ import './video-channels.js'
import './video-chapters.js'
import './video-comments.js'
import './video-description.js'
import './video-embed-privacy.js'
import './video-files.js'
import './video-imports.js'
import './video-nsfw.js'
@@ -0,0 +1,149 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { VideoEmbedPrivacy, VideoEmbedPrivacyPolicy } from '@peertube/peertube-models'
import {
cleanupTests,
createMultipleServers,
doubleFollow,
PeerTubeServer,
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
import { expect } from 'chai'
describe('Test video embed privacy', function () {
let servers: PeerTubeServer[]
let videoId: string
let userToken: string
before(async function () {
this.timeout(120000)
servers = await createMultipleServers(2)
await setAccessTokensToServers(servers)
const { uuid } = await servers[0].videos.quickUpload({ name: 'video' })
videoId = uuid
userToken = await servers[0].users.generateUserAndToken('user1')
await doubleFollow(servers[0], servers[1])
})
it('Should get default video embed privacy', async function () {
const policy = { id: VideoEmbedPrivacyPolicy.ALL_ALLOWED, label: 'All allowed' }
{
const body = await servers[0].videoEmbedPrivacy.get({ videoId })
expect(body).to.deep.equal({ policy, domains: [] } satisfies VideoEmbedPrivacy)
}
{
const video = await servers[0].videos.get({ id: videoId })
expect(video.embedPrivacyPolicy).to.deep.equal(policy)
}
{
const result = await servers[0].videoEmbedPrivacy.isDomainAllowed({ videoId, domain: 'toto.example.com' })
expect(result).to.deep.equal({ domainAllowed: true, userBypassAllowed: null })
}
})
it('Should update video embed privacy to allowlist', async function () {
await servers[0].videoEmbedPrivacy.update({
videoId,
policy: VideoEmbedPrivacyPolicy.ALLOWLIST,
domains: [ 'example.com' ]
})
const policy = { id: VideoEmbedPrivacyPolicy.ALLOWLIST, label: 'Allowlist' }
{
const body = await servers[0].videoEmbedPrivacy.get({ videoId })
expect(body).to.deep.equal({ policy, domains: [ 'example.com' ] } satisfies VideoEmbedPrivacy)
}
{
const video = await servers[0].videos.get({ id: videoId })
expect(video.embedPrivacyPolicy).to.deep.equal(policy)
}
})
it('Should have federated video embed privacy', async function () {
await waitJobs(servers)
const video = await servers[1].videos.get({ id: videoId })
expect(video.embedPrivacyPolicy.id).to.equal(VideoEmbedPrivacyPolicy.REMOTE_RESTRICTIONS)
})
it('Should check if embed is allowed on a domain', async function () {
for (const server of servers) {
{
const result = await server.videoEmbedPrivacy.isDomainAllowed({ videoId, domain: 'toto.example.com' })
expect(result).to.deep.equal({ domainAllowed: false, userBypassAllowed: true })
}
{
const result = await server.videoEmbedPrivacy.isDomainAllowed({ videoId, domain: 'toto.example.com', token: null })
expect(result).to.deep.equal({ domainAllowed: false, userBypassAllowed: false })
}
}
{
const result = await servers[0].videoEmbedPrivacy.isDomainAllowed({ videoId, domain: 'toto.example.com', token: userToken })
expect(result).to.deep.equal({ domainAllowed: false, userBypassAllowed: false })
}
{
const result = await servers[0].videoEmbedPrivacy.isDomainAllowed({ videoId, domain: 'example.com' })
expect(result).to.deep.equal({ domainAllowed: true, userBypassAllowed: null })
}
// Only server 1 knows which domain is allowed
{
const result = await servers[1].videoEmbedPrivacy.isDomainAllowed({ videoId, domain: 'example.com' })
expect(result).to.deep.equal({ domainAllowed: false, userBypassAllowed: true })
}
})
it('Should add some domains to video embed privacy', async function () {
await servers[0].videoEmbedPrivacy.update({
videoId,
policy: VideoEmbedPrivacyPolicy.ALLOWLIST,
domains: [ 'example.com', 'example2.com' ]
})
const policy = { id: VideoEmbedPrivacyPolicy.ALLOWLIST, label: 'Allowlist' }
{
const body = await servers[0].videoEmbedPrivacy.get({ videoId })
expect(body).to.deep.equal({ policy, domains: [ 'example.com', 'example2.com' ] } satisfies VideoEmbedPrivacy)
}
})
it('Should remove video embed privacy restriction', async function () {
await servers[0].videoEmbedPrivacy.update({
videoId,
policy: VideoEmbedPrivacyPolicy.ALL_ALLOWED,
domains: []
})
const policy = { id: VideoEmbedPrivacyPolicy.ALL_ALLOWED, label: 'All allowed' }
{
const body = await servers[0].videoEmbedPrivacy.get({ videoId })
expect(body).to.deep.equal({ policy, domains: [] } satisfies VideoEmbedPrivacy)
}
await waitJobs(servers)
{
const video = await servers[1].videos.get({ id: videoId })
expect(video.embedPrivacyPolicy.id).to.equal(VideoEmbedPrivacyPolicy.ALL_ALLOWED)
}
})
after(async function () {
await cleanupTests(servers)
})
})
@@ -9,6 +9,7 @@ import {
UserNotificationSettingValue,
VideoCommentObject,
VideoCommentPolicy,
VideoEmbedPrivacyPolicy,
VideoObject,
VideoPlaylistPrivacy,
VideoPrivacy
@@ -348,6 +349,13 @@ export async function prepareImportExportTests (options: {
token: noahToken
})
await server.videoEmbedPrivacy.update({
videoId: noahLive.uuid,
policy: VideoEmbedPrivacyPolicy.ALLOWLIST,
domains: [ 'example.com' ],
token: noahToken
})
// Views
await server.views.view({ id: noahVideo.uuid, token: noahToken, currentTime: 4 })
await server.views.view({ id: externalVideo.uuid, token: noahToken, currentTime: 2 })
@@ -375,6 +383,8 @@ export async function prepareImportExportTests (options: {
await waitJobs([ server, remoteServer ])
await server.channelCollaborators.addEditor({ channel: 'root_channel', editorToken: noahToken, editor: 'noah' })
const { data: noahVideos } = await server.videos.listMyVideos({ token: noahToken, sort: '-publishedAt' })
const noahVODNames = noahVideos.filter(v => !v.isLive).map(v => v.name)
+2
View File
@@ -503,6 +503,7 @@ export async function checkThumbnails (options: {
// eslint-disable-next-line @typescript-eslint/no-deprecated
await testImageGeneratedByFFmpeg({
name: thumbnails.find(t => t.width === 280 && t.height === 157).filename,
// eslint-disable-next-line @typescript-eslint/no-deprecated
url: server.url + entity.thumbnailPath
})
@@ -512,6 +513,7 @@ export async function checkThumbnails (options: {
// eslint-disable-next-line @typescript-eslint/no-deprecated
await testImageGeneratedByFFmpeg({
name: preview.filename,
// eslint-disable-next-line @typescript-eslint/no-deprecated
url: server.url + video.previewPath
})
}
+7 -4
View File
@@ -10,19 +10,22 @@ if [ ! -z ${2+x} ] && [ "$2" = "--ar-locale" ]; then
clientConfiguration="ar-locale"
fi
embedCommand="cd client/src/standalone/player && npm run dev"
playerCommand="cd client/src/standalone/player && npm run dev"
embedCommand="cd client && ./node_modules/.bin/vite -c ./src/standalone/videos/vite.config.mjs dev"
clientCommand="cd client && NODE_OPTIONS=--max_old_space_size=8192 node_modules/.bin/ng serve --proxy-config proxy.config.json --hmr --configuration $clientConfiguration --host 0.0.0.0 --port 3000"
serverCommand="ANGULAR_CLIENT_ENABLED=true NODE_ENV=dev node dist/server"
if [ ! -z ${1+x} ] && [ "$1" = "--skip-server" ]; then
node_modules/.bin/concurrently -k \
"$embedCommand" \
"$clientCommand"
"$playerCommand" \
"$clientCommand" \
"$embedCommand"
else
npm run build:server
node_modules/.bin/concurrently -k \
"$embedCommand" \
"$playerCommand" \
"$clientCommand" \
"$embedCommand" \
"$serverCommand"
fi
+2 -1
View File
@@ -130,7 +130,8 @@ Object.values(VIDEO_CATEGORIES)
'By {1}',
'Unavailable video',
'Audio only',
'Unknown'
'Unknown',
'This video is not allowed to be embedded on this domain.'
])
.forEach(v => {
serverKeys[v] = v
@@ -0,0 +1,129 @@
import {
HttpStatusCode,
UserRight,
VideoChannelActivityAction,
VideoEmbedPrivacy,
VideoEmbedPrivacyAllowed,
VideoEmbedPrivacyPolicy,
VideoEmbedPrivacyUpdate
} from '@peertube/peertube-models'
import { getAuthUser } from '@server/helpers/express-utils.js'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { VIDEO_EMBED_PRIVACY_POLICIES } from '@server/initializers/constants.js'
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/index.js'
import { checkCanManageVideo } from '@server/middlewares/validators/shared/videos.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import { VideoEmbedPrivacyDomainModel } from '@server/models/video/video-embed-privacy-domain.js'
import { VideoPasswordModel } from '@server/models/video/video-password.js'
import { VideoModel } from '@server/models/video/video.js'
import express from 'express'
import { Transaction } from 'sequelize'
import { asyncMiddleware, authenticate, optionalAuthenticate } from '../../../middlewares/index.js'
import {
getVideoEmbedPrivacyValidator,
isVideoEmbedOnDomainAllowedValidator,
updateVideoEmbedPrivacyValidator
} from '../../../middlewares/validators/index.js'
const lTags = loggerTagsFactory('api', 'video', 'embed-privacy')
const videoEmbedPrivacyRouter = express.Router()
videoEmbedPrivacyRouter.get(
'/:videoId/embed-privacy',
authenticate,
asyncMiddleware(getVideoEmbedPrivacyValidator),
asyncMiddleware(getVideoEmbedPrivacy)
)
videoEmbedPrivacyRouter.put(
'/:videoId/embed-privacy',
authenticate,
asyncMiddleware(updateVideoEmbedPrivacyValidator),
asyncMiddleware(updateVideoEmbedPrivacy)
)
videoEmbedPrivacyRouter.get(
'/:videoId/embed-privacy/allowed',
optionalAuthenticate,
asyncMiddleware(isVideoEmbedOnDomainAllowedValidator),
asyncMiddleware(isVideoEmbedOnDomainAllowed)
)
// ---------------------------------------------------------------------------
export {
videoEmbedPrivacyRouter
}
// ---------------------------------------------------------------------------
async function getVideoEmbedPrivacy (req: express.Request, res: express.Response) {
const domains = await VideoEmbedPrivacyDomainModel.list(res.locals.videoAll.id)
const video = res.locals.videoAll
return res.json(
{
policy: {
id: video.embedPrivacyPolicy,
label: VIDEO_EMBED_PRIVACY_POLICIES[video.embedPrivacyPolicy]
},
domains: domains.map(d => d.domain)
} satisfies VideoEmbedPrivacy
)
}
async function isVideoEmbedOnDomainAllowed (req: express.Request, res: express.Response) {
const video = res.locals.onlyVideo
const domainAllowed = video.embedPrivacyPolicy === VideoEmbedPrivacyPolicy.ALL_ALLOWED
? true
: await VideoEmbedPrivacyDomainModel.isDomainAllowed(video.id, req.query.domain)
const user = getAuthUser(res)
let userBypassAllowed = domainAllowed === true
? null
: false
if (domainAllowed === false && user) {
userBypassAllowed = await checkCanManageVideo({
user,
video: await VideoModel.loadFull(video.id),
right: UserRight.UPDATE_ANY_VIDEO,
checkIsOwner: false,
checkIsLocal: false,
req,
res: null
})
}
return res.json({ domainAllowed, userBypassAllowed } satisfies VideoEmbedPrivacyAllowed)
}
async function updateVideoEmbedPrivacy (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
const body = req.body as VideoEmbedPrivacyUpdate
await VideoPasswordModel.sequelize.transaction(async (t: Transaction) => {
video.embedPrivacyPolicy = body.policy
await video.save({ transaction: t })
await VideoEmbedPrivacyDomainModel.deleteAllDomains(video.id, t)
await VideoEmbedPrivacyDomainModel.addDomains(body.domains, video.id, t)
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.UPDATE_EMBED_POLICY,
user: res.locals.oauth.token.User,
channel: video.VideoChannel,
video,
transaction: t
})
await federateVideoIfNeeded(video, false, t)
})
logger.info(`Video embed policy for video with name ${video.name} and uuid ${video.uuid} have been updated`, lTags(video.uuid))
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

Some files were not shown because too many files have changed in this diff Show More