はじまりの大地

このコミットが含まれているのは:
2024-07-15 09:14:04 +09:00
コミット 6632905f32
3501個のファイルの変更1439465行の追加0行の削除
+4
ファイルの表示
@@ -0,0 +1,4 @@
last 1 Chrome version
last 2 Edge major versions
Firefox ESR
ios_saf >= 13.1
+180
ファイルの表示
@@ -0,0 +1,180 @@
{
"root": true,
"ignorePatterns": [
"projects/**/*",
"node_modules/",
"src/standalone/embed-player-api/dist"
],
"overrides": [
{
"files": [
"*.ts"
],
"parserOptions": {
"project": [
"tsconfig.eslint.json"
],
"EXPERIMENTAL_useSourceOfProjectReferenceRedirect": true,
"createDefaultProgram": false
},
"extends": [
"../.eslintrc.json",
"plugin:@angular-eslint/recommended",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
"jsdoc/newline-after-description": "off",
"jsdoc/check-alignment": "off",
"lines-between-class-members": "off",
"@typescript-eslint/lines-between-class-members": [ "off" ],
"arrow-body-style": "off",
"no-underscore-dangle": "off",
"n/no-callback-literal": "off",
"@angular-eslint/component-selector": [
"error",
{
"type": [ "element", "attribute" ],
"prefix": "my",
"style": "kebab-case"
}
],
"@angular-eslint/directive-selector": [
"error",
{
"type": [ "element", "attribute" ],
"prefix": "my",
"style": "camelCase"
}
],
"@typescript-eslint/no-this-alias": [
"error",
{
"allowDestructuring": true,
"allowedNames": ["self", "player"]
}
],
"@typescript-eslint/prefer-readonly": "off",
"@angular-eslint/use-component-view-encapsulation": "error",
"prefer-arrow/prefer-arrow-functions": "off",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/consistent-type-definitions": "off",
"@typescript-eslint/dot-notation": "off",
"@typescript-eslint/explicit-member-accessibility": [
"off",
{
"accessibility": "explicit"
}
],
"@typescript-eslint/member-ordering": [
"off"
],
"@typescript-eslint/member-delimiter-style": [
"error",
{
"multiline": {
"delimiter": "none",
"requireLast": true
},
"singleline": {
"delimiter": "comma",
"requireLast": false
}
}
],
"@typescript-eslint/prefer-for-of": "off",
"@typescript-eslint/no-empty-function": "error",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-inferrable-types": "error",
"@typescript-eslint/no-shadow": [
"off",
{
"hoist": "all"
}
],
"@typescript-eslint/no-unnecessary-qualifier": "error",
"@typescript-eslint/no-unnecessary-type-assertion": "error",
"@typescript-eslint/no-unused-expressions": [
"error",
{
"allowTaggedTemplates": true,
"allowShortCircuit": true
}
],
"@typescript-eslint/quotes": [
"error",
"single",
{
"avoidEscape": true,
"allowTemplateLiterals": true
}
],
"@typescript-eslint/semi": [
"error",
"never"
],
"brace-style": [
"error",
"1tbs"
],
"comma-dangle": "error",
"curly": [
"error",
"multi-line"
],
"dot-notation": "off",
"no-useless-return": "off",
"indent": "off",
"no-bitwise": "off",
"no-console": "off",
"no-return-assign": "off",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-duplicate-imports": "error",
"no-empty": "error",
"no-empty-function": [
"error",
{ "allow": [ "constructors" ] }
],
"no-invalid-regexp": "error",
"no-multiple-empty-lines": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-return-await": "error",
"no-shadow": "off",
"no-unused-expressions": "error",
"semi": "error",
"space-before-function-paren": [
"error",
"always"
],
"space-in-parens": [
"error",
"never"
],
"object-shorthand": [
"error",
"properties"
],
"quote-props": [
"error",
"consistent-as-needed"
],
"no-constant-binary-expression": "error",
"@typescript-eslint/unbound-method": [
"error",
{ "ignoreStatic": true }
]
}
},
{
"files": [
"*.html"
],
"extends": [
"plugin:@angular-eslint/template/recommended",
"plugin:@angular-eslint/template/accessibility"
],
"rules": {}
}
]
}
+16
ファイルの表示
@@ -0,0 +1,16 @@
/.angular/cache
/dist/
/node_modules
/compiled
/stats.json
/dll
/.awcache
/src/locale/pending_target/
/src/locale/target/iso639_*.xml
/src/locale/target/player_*.xml
/src/locale/target/server_*.xml
/e2e/local.log
/e2e/browserstack.err
/e2e/screenshots
/src/standalone/embed-player-api/build
/src/standalone/embed-player-api/dist
+32
ファイルの表示
@@ -0,0 +1,32 @@
{
"extends": "stylelint-config-sass-guidelines",
"rules": {
"scss/at-import-no-partial-leading-underscore": null,
"color-hex-length": null,
"selector-pseudo-element-no-unknown": [
true,
{
"ignorePseudoElements": [ "ng-deep" ]
}
],
"max-nesting-depth": [
8,
{
"ignore": [ "blockless-at-rules", "pseudo-classes" ]
}
],
"selector-max-compound-selectors": 9,
"selector-no-qualifying-type": null,
"scss/at-extend-no-missing-placeholder": null,
"rule-empty-line-before": null,
"selector-max-id": null,
"scss/at-function-pattern": null,
"scss/load-no-partial-leading-underscore": null,
"property-no-vendor-prefix": [
true,
{
"ignoreProperties": [ "mask-image", "mask-size" ]
}
]
}
}
+9
ファイルの表示
@@ -0,0 +1,9 @@
{
"xliffmergeOptions": {
"i18nFormat": "xlf",
"srcDir": "src/locale",
"genDir": "src/locale",
"i18nBaseFile": "angular",
"defaultLanguage": "en-US"
}
}
+339
ファイルの表示
@@ -0,0 +1,339 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"PeerTube": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"i18n": {
"sourceLocale": {
"code": "en",
"baseHref": "/client/en-US/"
},
"locales": {
"ar": {
"translation": "src/locale/angular.ar.xlf",
"baseHref": "/client/ar/"
},
"fa": {
"translation": "src/locale/angular.fa-IR.xlf",
"baseHref": "/client/fa-IR/"
},
"hu": {
"translation": "src/locale/angular.hu-HU.xlf",
"baseHref": "/client/hu-HU/"
},
"th": {
"translation": "src/locale/angular.th-TH.xlf",
"baseHref": "/client/th-TH/"
},
"tr": {
"translation": "src/locale/angular.tr-TR.xlf",
"baseHref": "/client/tr-TR/"
},
"fi": {
"translation": "src/locale/angular.fi-FI.xlf",
"baseHref": "/client/fi-FI/"
},
"nl": {
"translation": "src/locale/angular.nl-NL.xlf",
"baseHref": "/client/nl-NL/"
},
"gd": {
"translation": "src/locale/angular.gd.xlf",
"baseHref": "/client/gd/"
},
"el": {
"translation": "src/locale/angular.el-GR.xlf",
"baseHref": "/client/el-GR/"
},
"es": {
"translation": "src/locale/angular.es-ES.xlf",
"baseHref": "/client/es-ES/"
},
"oc": {
"translation": "src/locale/angular.oc.xlf",
"baseHref": "/client/oc/"
},
"pt": {
"translation": "src/locale/angular.pt-BR.xlf",
"baseHref": "/client/pt-BR/"
},
"pt-PT": {
"translation": "src/locale/angular.pt-PT.xlf",
"baseHref": "/client/pt-PT/"
},
"sv": {
"translation": "src/locale/angular.sv-SE.xlf",
"baseHref": "/client/sv-SE/"
},
"pl": {
"translation": "src/locale/angular.pl-PL.xlf",
"baseHref": "/client/pl-PL/"
},
"ru": {
"translation": "src/locale/angular.ru-RU.xlf",
"baseHref": "/client/ru-RU/"
},
"sq": {
"translation": "src/locale/angular.sq.xlf",
"baseHref": "/client/sq/"
},
"hr": {
"translation": "src/locale/angular.hr.xlf",
"baseHref": "/client/hr/"
},
"zh-Hans": {
"translation": "src/locale/angular.zh-Hans-CN.xlf",
"baseHref": "/client/zh-Hans-CN/"
},
"zh-Hant": {
"translation": "src/locale/angular.zh-Hant-TW.xlf",
"baseHref": "/client/zh-Hant-TW/"
},
"fr": {
"translation": "src/locale/angular.fr-FR.xlf",
"baseHref": "/client/fr-FR/"
},
"ja": {
"translation": "src/locale/angular.ja-JP.xlf",
"baseHref": "/client/ja-JP/"
},
"eu": {
"translation": "src/locale/angular.eu-ES.xlf",
"baseHref": "/client/eu-ES/"
},
"ca": {
"translation": "src/locale/angular.ca-ES.xlf",
"baseHref": "/client/ca-ES/"
},
"gl": {
"translation": "src/locale/angular.gl-ES.xlf",
"baseHref": "/client/gl-ES/"
},
"cs": {
"translation": "src/locale/angular.cs-CZ.xlf",
"baseHref": "/client/cs-CZ/"
},
"eo": {
"translation": "src/locale/angular.eo.xlf",
"baseHref": "/client/eo/"
},
"de": {
"translation": "src/locale/angular.de-DE.xlf",
"baseHref": "/client/de-DE/"
},
"it": {
"translation": "src/locale/angular.it-IT.xlf",
"baseHref": "/client/it-IT/"
},
"vi": {
"translation": "src/locale/angular.vi-VN.xlf",
"baseHref": "/client/vi-VN/"
},
"kab": {
"translation": "src/locale/angular.kab.xlf",
"baseHref": "/client/kab/"
},
"nb": {
"translation": "src/locale/angular.nb-NO.xlf",
"baseHref": "/client/nb-NO/"
},
"tok": {
"translation": "src/locale/angular.tok.xlf",
"baseHref": "/client/tok/"
},
"nn": {
"translation": "src/locale/angular.nn.xlf",
"baseHref": "/client/nn/"
},
"is": {
"translation": "src/locale/angular.is.xlf",
"baseHref": "/client/is/"
},
"uk": {
"translation": "src/locale/angular.uk-UA.xlf",
"baseHref": "/client/uk-UA/"
}
}
},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"i18nMissingTranslation": "ignore",
"localize": true,
"outputPath": {
"base": "dist"
},
"index": "src/index.html",
"tsConfig": "tsconfig.json",
"polyfills": [
"src/polyfills.ts",
"@angular/localize/init"
],
"baseHref": "/",
"stylePreprocessorOptions": {
"includePaths": [
"src/sass/include",
"."
]
},
"assets": [
"src/assets/images",
"src/manifest.webmanifest"
],
"styles": [
"src/sass/application.scss"
],
"allowedCommonJsDependencies": [
"qrcode",
"chart.js",
"htmlparser2",
"markdown-it-emoji/light",
"linkifyjs/lib/linkify-html",
"linkifyjs/lib/plugins/mention",
"sanitize-html",
"debug",
"@peertube/p2p-media-loader-hlsjs",
"video.js",
"sha.js",
"postcss",
"focus-visible",
"path-browserify",
"deep-merge",
"escape-string-regexp",
"is-plain-object",
"parse-srcset",
"deepmerge",
"core-js/features/reflect",
"@formatjs/intl-locale/polyfill",
"@formatjs/intl-locale/should-polyfill",
"@formatjs/intl-pluralrules/polyfill-force",
"@formatjs/intl-pluralrules/should-polyfill"
],
"scripts": [],
"extractLicenses": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true,
"browser": "src/main.ts",
"loader": {
".svg": "text"
}
},
"configurations": {
"production": {
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"extractLicenses": true,
"serviceWorker": "src/ngsw-config.json",
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "100kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
]
},
"ar-locale": {
"localize": [
"ar"
],
"budgets": [
{
"type": "anyComponentStyle",
"maximumWarning": "6kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.hmr.ts"
}
]
},
"hmr": {
"localize": false,
"budgets": [
{
"type": "anyComponentStyle",
"maximumWarning": "6kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.hmr.ts"
}
]
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"proxyConfig": "proxy.config.json",
"buildTarget": "PeerTube:build"
},
"configurations": {
"hmr": {
"buildTarget": "PeerTube:build:hmr"
},
"ar-locale": {
"buildTarget": "PeerTube:build:ar-locale"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"e2e/**/*.ts",
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
}
},
"schematics": {
"@schematics/angular:component": {
"prefix": "my",
"style": "scss",
"skipTests": true,
"flat": true
},
"@schematics/angular:directive": {
"prefix": "my"
},
"@angular-eslint/schematics:application": {
"setParserOptionsProject": true
},
"@angular-eslint/schematics:library": {
"setParserOptionsProject": true
}
},
"cli": {
"analytics": false
}
}
バイナリ
ファイルの表示
バイナリファイルは表示されません.
バイナリ
ファイルの表示
バイナリファイルは表示されません.
バイナリ
ファイルの表示
バイナリファイルは表示されません.
+12
ファイルの表示
@@ -0,0 +1,12 @@
browser.addCommand('chooseFile', async function (this: WebdriverIO.Element, localFilePath: string) {
try {
const remoteFile = await browser.uploadFile(localFilePath)
return this.addValue(remoteFile)
} catch {
console.log('Cannot upload file, fallback to add value.')
// Firefox does not support upload file, but if we're running the test in local we don't really need it
return this.addValue(localFilePath)
}
}, true)
+65
ファイルの表示
@@ -0,0 +1,65 @@
import { browserSleep, getCheckbox, go, isCheckboxSelected } from '../utils'
export class AdminConfigPage {
async navigateTo (tab: 'instance-homepage' | 'basic-configuration' | 'instance-information') {
const waitTitles = {
'instance-homepage': 'INSTANCE HOMEPAGE',
'basic-configuration': 'APPEARANCE',
'instance-information': 'INSTANCE'
}
await go('/admin/config/edit-custom#' + tab)
await $('h2=' + waitTitles[tab]).waitForDisplayed()
}
async updateNSFWSetting (newValue: 'do_not_list' | 'blur' | 'display') {
const elem = $('#instanceDefaultNSFWPolicy')
await elem.waitForDisplayed()
await elem.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
await elem.waitForClickable()
return elem.selectByAttribute('value', newValue)
}
updateHomepage (newValue: string) {
return $('#instanceCustomHomepageContent').setValue(newValue)
}
async toggleSignup (enabled: boolean) {
if (await isCheckboxSelected('signupEnabled') === enabled) return
const checkbox = await getCheckbox('signupEnabled')
await checkbox.waitForClickable()
await checkbox.click()
}
async toggleSignupApproval (required: boolean) {
if (await isCheckboxSelected('signupRequiresApproval') === required) return
const checkbox = await getCheckbox('signupRequiresApproval')
await checkbox.waitForClickable()
await checkbox.click()
}
async toggleSignupEmailVerification (required: boolean) {
if (await isCheckboxSelected('signupRequiresEmailVerification') === required) return
const checkbox = await getCheckbox('signupRequiresEmailVerification')
await checkbox.waitForClickable()
await checkbox.click()
}
async save () {
const button = $('input[type=submit]')
await button.waitForClickable()
await button.click()
await browserSleep(1000)
}
}
+31
ファイルの表示
@@ -0,0 +1,31 @@
import { browserSleep, go } from '../utils'
export class AdminPluginPage {
async navigateToPluginSearch () {
await go('/admin/plugins/search')
await $('my-plugin-search').waitForDisplayed()
}
async search (name: string) {
const input = $('.search-bar input')
await input.waitForDisplayed()
await input.clearValue()
await input.setValue(name)
await browserSleep(1000)
}
async installHelloWorld () {
$('.plugin-name=hello-world').waitForDisplayed()
await $('.card-body my-button[icon=cloud-download]').click()
const submitModalButton = $('.modal-content input[type=submit]')
await submitModalButton.waitForClickable()
await submitModalButton.click()
await $('.card-body my-edit-button').waitForDisplayed()
}
}
+35
ファイルの表示
@@ -0,0 +1,35 @@
import { browserSleep, findParentElement, go } from '../utils'
export class AdminRegistrationPage {
async navigateToRegistratonsList () {
await go('/admin/moderation/registrations/list')
await $('my-registration-list').waitForDisplayed()
}
async accept (username: string, moderationResponse: string) {
const usernameEl = await $('*=' + username)
await usernameEl.waitForDisplayed()
const tr = await findParentElement(usernameEl, async el => await el.getTagName() === 'tr')
await tr.$('.action-cell .dropdown-root').click()
const accept = await $('span*=Accept this request')
await accept.waitForClickable()
await accept.click()
const moderationResponseTextarea = await $('#moderationResponse')
await moderationResponseTextarea.waitForDisplayed()
await moderationResponseTextarea.setValue(moderationResponse)
const submitButton = $('.modal-footer input[type=submit]')
await submitButton.waitForClickable()
await submitButton.click()
await browserSleep(1000)
}
}
+21
ファイルの表示
@@ -0,0 +1,21 @@
import { getCheckbox } from '../utils'
export class AnonymousSettingsPage {
async openSettings () {
const link = await $$('.menu-link').filter(async i => {
return await i.getText() === 'My settings'
}).then(links => links[0])
await link.click()
await $('my-user-video-settings').waitForDisplayed()
}
async clickOnP2PCheckbox () {
const p2p = await getCheckbox('p2pEnabled')
await p2p.waitForClickable()
await p2p.click()
}
}
+109
ファイルの表示
@@ -0,0 +1,109 @@
import { browserSleep, go, isAndroid } from '../utils'
export class LoginPage {
constructor (private isMobileDevice: boolean) {
}
async login (options: {
username: string
password: string
displayName?: string
url?: string
}) {
const { username, password, url = '/login', displayName = username } = options
await go(url)
await browser.execute(`window.localStorage.setItem('no_account_setup_warning_modal', 'true')`)
await browser.execute(`window.localStorage.setItem('no_instance_config_warning_modal', 'true')`)
await browser.execute(`window.localStorage.setItem('no_welcome_modal', 'true')`)
await $('input#username').setValue(username)
await $('input#password').setValue(password)
await browserSleep(1000)
const submit = $('.login-form-and-externals > form input[type=submit]')
await submit.click()
// Have to do this on Android, don't really know why
// I think we need to "escape" from the password input, so click twice on the submit button
if (isAndroid()) {
await browserSleep(2000)
await submit.click()
}
if (this.isMobileDevice) {
const menuToggle = $('.top-left-block button')
await $('h2=Our content selection').waitForDisplayed()
await menuToggle.click()
await this.ensureIsLoggedInAs(displayName)
await menuToggle.click()
} else {
await this.ensureIsLoggedInAs(displayName)
}
}
async getLoginError (username: string, password: string) {
await go('/login')
await $('input#username').setValue(username)
await $('input#password').setValue(password)
await browser.pause(1000)
await $('form input[type=submit]').click()
return $('.alert-danger').getText()
}
async loginAsRootUser () {
return this.login({ username: 'root', password: 'test' + this.getSuffix() })
}
loginOnPeerTube2 () {
if (!process.env.PEERTUBE2_E2E_PASSWORD) {
throw new Error('PEERTUBE2_E2E_PASSWORD env is missing for user e2e on peertube2.cpy.re')
}
return this.login({ username: 'e2e', password: process.env.PEERTUBE2_E2E_PASSWORD, url: 'https://peertube2.cpy.re/login' })
}
async logout () {
const loggedInDropdown = $('.logged-in-more .logged-in-info')
await loggedInDropdown.waitForClickable()
await loggedInDropdown.click()
const logout = $('.dropdown-item*=Log out')
await logout.waitForClickable()
await logout.click()
await browser.waitUntil(() => {
return $$('.login-buttons-block, my-error-page a[href="/login"]').some(e => e.isDisplayed())
})
}
async ensureIsLoggedInAs (displayName: string) {
await this.getLoggedInInfoElem().waitForExist()
await expect(this.getLoggedInInfoElem()).toHaveText(displayName)
}
private getLoggedInInfoElem () {
return $('.logged-in-display-name')
}
private getSuffix () {
return browser.options.baseUrl
? browser.options.baseUrl.slice(-1)
: '1'
}
}
+179
ファイルの表示
@@ -0,0 +1,179 @@
import { getCheckbox, go, selectCustomSelect } from '../utils'
export class MyAccountPage {
navigateToMyVideos () {
return $('a[href="/my-library/videos"]').click()
}
navigateToMyPlaylists () {
return $('a[href="/my-library/video-playlists"]').click()
}
navigateToMyHistory () {
return $('a[href="/my-library/history/videos"]').click()
}
// Settings
navigateToMySettings () {
return $('a[href="/my-account"]').click()
}
async updateNSFW (newValue: 'do_not_list' | 'blur' | 'display') {
const nsfw = $('#nsfwPolicy')
await nsfw.waitForDisplayed()
await nsfw.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
await nsfw.waitForClickable()
await nsfw.selectByAttribute('value', newValue)
await this.submitVideoSettings()
}
async clickOnP2PCheckbox () {
const p2p = await getCheckbox('p2pEnabled')
await p2p.waitForClickable()
await p2p.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
await p2p.click()
await this.submitVideoSettings()
}
private async submitVideoSettings () {
const submit = $('my-user-video-settings input[type=submit]')
await submit.waitForClickable()
await submit.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
await submit.click()
}
// My account Videos
async removeVideo (name: string) {
const container = await this.getVideoElement(name)
await container.$('my-action-dropdown .dropdown-toggle').click()
const deleteItem = () => {
return $$('.dropdown-menu .dropdown-item').find<WebdriverIO.Element>(async v => {
const text = await v.getText()
return text.includes('Delete')
})
}
await (await deleteItem()).waitForClickable()
return (await deleteItem()).click()
}
validRemove () {
return $('input[type=submit]').click()
}
async countVideos (names: string[]) {
const elements = await $$('.video').filter(async e => {
const t = await e.$('.video-miniature-name').getText()
return names.some(n => t.includes(n))
})
return elements.length
}
// My account playlists
async getPlaylistVideosText (name: string) {
const elem = await this.getPlaylist(name)
return elem.$('.miniature-playlist-info-overlay').getText()
}
async clickOnPlaylist (name: string) {
const elem = await this.getPlaylist(name)
return elem.$('.miniature-thumbnail').click()
}
async countTotalPlaylistElements () {
await $('<my-video-playlist-element-miniature>').waitForDisplayed()
return $$('<my-video-playlist-element-miniature>').length
}
playPlaylist () {
return $('.playlist-info .miniature-thumbnail').click()
}
async goOnAssociatedPlaylistEmbed () {
let url = await browser.getUrl()
url = url.replace('/w/p/', '/video-playlists/embed/')
url = url.replace(':3333', ':9001')
return go(url)
}
async updatePlaylistPrivacy (playlistUUID: string, privacy: 'Public' | 'Private' | 'Unlisted') {
go('/my-library/video-playlists/update/' + playlistUUID)
await $('a[href*="/my-library/video-playlists/update/"]').waitForDisplayed()
await selectCustomSelect('videoChannelId', 'Main root channel')
await selectCustomSelect('privacy', privacy)
const submit = await $('form input[type=submit]')
await submit.waitForClickable()
await submit.scrollIntoView()
await submit.click()
return browser.waitUntil(async () => {
return (await browser.getUrl()).includes('my-library/video-playlists')
})
}
// My account Videos
private async getVideoElement (name: string) {
const video = async () => {
const videos = await $$('.video').filter(async e => {
const t = await e.$('.video-miniature-name').getText()
return t.includes(name)
})
return videos[0]
}
await browser.waitUntil(async () => {
return (await video()).isDisplayed()
})
return video()
}
// My account playlists
private async getPlaylist (name: string) {
const playlist = () => {
return $$('my-video-playlist-miniature')
.filter(async e => {
const t = await e.$('.miniature-name').getText()
return t.includes(name)
})
.then(elems => elems[0])
}
await browser.waitUntil(async () => {
const el = await playlist()
return el?.isDisplayed()
})
return playlist()
}
}
+86
ファイルの表示
@@ -0,0 +1,86 @@
import { browserSleep, isIOS, isMobileDevice, isSafari } from '../utils'
export class PlayerPage {
getWatchVideoPlayerCurrentTime () {
const elem = $('video')
const p = isIOS()
? elem.getAttribute('currentTime')
: elem.getProperty('currentTime')
return p.then(t => parseInt(t + '', 10))
.then(t => Math.ceil(t))
}
waitUntilPlaylistInfo (text: string, maxTime: number) {
return browser.waitUntil(async () => {
// Without this we have issues on iphone
await $('.video-js').click()
return (await $('.video-js .vjs-playlist-info').getText()).includes(text)
}, { timeout: maxTime })
}
waitUntilPlayerWrapper () {
return browser.waitUntil(async () => {
return !!(await $('#placeholder-preview'))
})
}
async playAndPauseVideo (isAutoplay: boolean, waitUntilSec: number) {
// Autoplay is disabled on mobile and Safari
if (isIOS() || isSafari() || isMobileDevice() || isAutoplay === false) {
await this.playVideo()
}
await $('div.video-js.vjs-has-started').waitForExist()
await browserSleep(2000)
await browser.waitUntil(async () => {
return (await this.getWatchVideoPlayerCurrentTime()) >= waitUntilSec
}, { timeout: Math.max(waitUntilSec * 2 * 1000, 30000) })
// Pause video
await $('div.video-js').click()
}
async playVideo () {
await $('div.video-js.vjs-paused, div.video-js.vjs-playing').waitForExist()
if (await $('div.video-js.vjs-playing').isExisting()) {
if (!isIOS()) return
// On iOS, the web browser may have aborted player autoplay, so check the video is still autoplayed
await browserSleep(5000)
if (await $('div.video-js.vjs-playing').isExisting()) return
}
// Autoplay is disabled on iOS and Safari
if (isIOS() || isSafari() || isMobileDevice()) {
// We can't play the video if it is not muted
await browser.execute(`document.querySelector('video').muted = true`)
}
return this.clickOnPlayButton()
}
private async clickOnPlayButton () {
const playButton = () => $('.vjs-big-play-button')
await playButton().waitForClickable()
await playButton().click()
}
async fillEmbedVideoPassword (videoPassword: string) {
const videoPasswordInput = $('input#video-password-input')
const confirmButton = await $('button#video-password-submit')
await videoPasswordInput.clearValue()
await videoPasswordInput.setValue(videoPassword)
await confirmButton.waitForClickable()
return confirmButton.click()
}
}
+87
ファイルの表示
@@ -0,0 +1,87 @@
import { getCheckbox } from '../utils'
export class SignupPage {
getRegisterMenuButton () {
return $('.create-account-button')
}
async clickOnRegisterInMenu () {
const button = this.getRegisterMenuButton()
await button.waitForClickable()
await button.click()
}
async validateStep () {
const next = $('button[type=submit]')
await next.waitForClickable()
await next.click()
}
async checkTerms () {
const terms = await getCheckbox('terms')
await terms.waitForClickable()
return terms.click()
}
async getEndMessage () {
const alert = $('.pt-alert-primary')
await alert.waitForDisplayed()
return alert.getText()
}
async fillRegistrationReason (reason: string) {
await $('#registrationReason').setValue(reason)
}
async fillAccountStep (options: {
username: string
password?: string
displayName?: string
email?: string
}) {
await $('#displayName').setValue(options.displayName || `${options.username} display name`)
await $('#username').setValue(options.username)
await $('#password').setValue(options.password || 'password')
// Fix weird bug on firefox that "cannot scroll into view" when using just `setValue`
await $('#email').scrollIntoView({ block: 'center' })
await $('#email').waitForClickable()
await $('#email').setValue(options.email || `${options.username}@example.com`)
}
async fillChannelStep (options: {
name: string
displayName?: string
}) {
await $('#displayName').setValue(options.displayName || `${options.name} channel display name`)
await $('#name').setValue(options.name)
}
async fullSignup ({ accountInfo, channelInfo }: {
accountInfo: {
username: string
password?: string
displayName?: string
email?: string
}
channelInfo: {
name: string
}
}) {
await this.clickOnRegisterInMenu()
await this.validateStep()
await this.checkTerms()
await this.validateStep()
await this.fillAccountStep(accountInfo)
await this.validateStep()
await this.fillChannelStep(channelInfo)
await this.validateStep()
await this.getEndMessage()
}
}
+128
ファイルの表示
@@ -0,0 +1,128 @@
import { browserSleep, go } from '../utils'
export class VideoListPage {
constructor (private isMobileDevice: boolean, private isSafari: boolean) {
}
async goOnVideosList () {
let url: string
// We did not upload a file on a mobile device
if (this.isMobileDevice === true || this.isSafari === true) {
url = 'https://peertube2.cpy.re/videos/local'
} else {
url = '/videos/recently-added'
}
await go(url)
// Waiting the following element does not work on Safari...
if (this.isSafari) return browserSleep(3000)
await this.waitForList()
}
async goOnLocal () {
await $('.menu-link[href="/videos/local"]').click()
await this.waitForTitle('Local videos')
}
async goOnRecentlyAdded () {
await $('.menu-link[href="/videos/recently-added"]').click()
await this.waitForTitle('Recently added')
}
async goOnTrending () {
await $('.menu-link[href="/videos/trending"]').click()
await this.waitForTitle('Trending')
}
async goOnHomepage () {
await go('/home')
await this.waitForList()
}
async goOnRootChannel () {
await go('/c/root_channel/videos')
await this.waitForList()
}
async goOnRootAccount () {
await go('/a/root/videos')
await this.waitForList()
}
async goOnRootAccountChannels () {
await go('/a/root/video-channels')
await this.waitForList()
}
getNSFWFilter () {
return $$('.active-filter').filter(async a => {
return (await a.getText()).includes('Sensitive')
}).then(f => f[0])
}
async getVideosListName () {
const elems = await $$('.videos .video-miniature .video-miniature-name')
const texts = await elems.map(e => e.getText())
return texts.map(t => t.trim())
}
videoExists (name: string) {
return $('.video-miniature-name=' + name).isDisplayed()
}
async videoIsBlurred (name: string) {
const filter = await $('.video-miniature-name=' + name).getCSSProperty('filter')
return filter.value !== 'none'
}
async clickOnVideo (videoName: string) {
const video = async () => {
const videos = await $$('.videos .video-miniature .video-miniature-name').filter(async e => {
const t = await e.getText()
return t === videoName
})
return videos[0]
}
await browser.waitUntil(async () => {
const elem = await video()
return elem?.isClickable()
});
(await video()).click()
await browser.waitUntil(async () => (await browser.getUrl()).includes('/w/'))
}
async clickOnFirstVideo () {
const video = () => $('.videos .video-miniature .video-thumbnail')
const videoName = () => $('.videos .video-miniature .video-miniature-name')
await video().waitForClickable()
const textToReturn = await videoName().getText()
await video().click()
await browser.waitUntil(async () => (await browser.getUrl()).includes('/w/'))
return textToReturn
}
private waitForList () {
return $('.videos .video-miniature .video-miniature-name').waitForDisplayed()
}
private waitForTitle (title: string) {
return $('h1=' + title).waitForDisplayed()
}
}
+11
ファイルの表示
@@ -0,0 +1,11 @@
export class VideoSearchPage {
async search (search: string) {
await $('#search-video').setValue(search)
await $('.search-button').click()
await browser.waitUntil(() => {
return $('my-video-miniature').isDisplayed()
})
}
}
+20
ファイルの表示
@@ -0,0 +1,20 @@
export class VideoUpdatePage {
async updateName (videoName: string) {
const nameInput = $('input#name')
await nameInput.waitForDisplayed()
await nameInput.clearValue()
await nameInput.setValue(videoName)
}
async validUpdate () {
const submitButton = await this.getSubmitButton()
return submitButton.click()
}
private getSubmitButton () {
return $('.submit-container .action-button')
}
}
+80
ファイルの表示
@@ -0,0 +1,80 @@
import { join } from 'path'
import { getCheckbox, selectCustomSelect } from '../utils'
export class VideoUploadPage {
async navigateTo () {
const publishButton = await $('.root-header .publish-button')
await publishButton.waitForClickable()
await publishButton.click()
await $('.upload-video-container').waitForDisplayed()
}
async uploadVideo (fixtureName: 'video.mp4' | 'video2.mp4' | 'video3.mp4') {
const fileToUpload = join(__dirname, '../../fixtures/' + fixtureName)
const fileInputSelector = '.upload-video-container input[type=file]'
const parentFileInput = '.upload-video-container .button-file'
// Avoid sending keys on non visible element
await browser.execute(`document.querySelector('${fileInputSelector}').style.opacity = 1`)
await browser.execute(`document.querySelector('${parentFileInput}').style.overflow = 'initial'`)
await browser.pause(1000)
const elem = await $(fileInputSelector)
await elem.chooseFile(fileToUpload)
// Wait for the upload to finish
await browser.waitUntil(async () => {
const warning = await $('=Publish will be available when upload is finished').isDisplayed()
const progress = await $('.progress-bar=100%').isDisplayed()
return !warning && progress
})
}
async setAsNSFW () {
const checkbox = await getCheckbox('nsfw')
await checkbox.waitForClickable()
return checkbox.click()
}
async validSecondUploadStep (videoName: string) {
const nameInput = $('input#name')
await nameInput.clearValue()
await nameInput.setValue(videoName)
const button = this.getSecondStepSubmitButton()
await button.waitForClickable()
await button.click()
return browser.waitUntil(async () => {
return (await browser.getUrl()).includes('/w/')
})
}
setAsPublic () {
return selectCustomSelect('privacy', 'Public')
}
setAsPrivate () {
return selectCustomSelect('privacy', 'Private')
}
async setAsPasswordProtected (videoPassword: string) {
selectCustomSelect('privacy', 'Password protected')
const videoPasswordInput = $('input#videoPassword')
await videoPasswordInput.waitForClickable()
await videoPasswordInput.clearValue()
return videoPasswordInput.setValue(videoPassword)
}
private getSecondStepSubmitButton () {
return $('.submit-container my-button')
}
}
+229
ファイルの表示
@@ -0,0 +1,229 @@
import { browserSleep, FIXTURE_URLS, go } from '../utils'
export class VideoWatchPage {
constructor (private isMobileDevice: boolean, private isSafari: boolean) {
}
waitWatchVideoName (videoName: string) {
if (this.isSafari) return browserSleep(5000)
// On mobile we display the first node, on desktop the second one
const index = this.isMobileDevice ? 0 : 1
return browser.waitUntil(async () => {
if (!await $('.video-info .video-info-name').isExisting()) return false
const elem = await $$('.video-info .video-info-name')[index]
return (await elem.getText()).includes(videoName) && elem.isDisplayed()
})
}
getVideoName () {
return this.getVideoNameElement().then(e => e.getText())
}
getPrivacy () {
return $('.attribute-privacy .attribute-value').getText()
}
getLicence () {
return $('.attribute-licence .attribute-value').getText()
}
async isDownloadEnabled () {
try {
await this.clickOnMoreDropdownIcon()
return await $('.dropdown-item .icon-download').isExisting()
} catch {
return $('.action-button-download').isDisplayed()
}
}
areCommentsEnabled () {
return $('my-video-comment-add').isExisting()
}
isPrivacyWarningDisplayed () {
return $('my-privacy-concerns').isDisplayed()
}
async goOnAssociatedEmbed (passwordProtected = false) {
let url = await browser.getUrl()
url = url.replace('/w/', '/videos/embed/')
url = url.replace(':3333', ':9001')
await go(url)
if (passwordProtected) await this.waitEmbedForVideoPasswordForm()
else await this.waitEmbedForDisplayed()
}
waitEmbedForDisplayed () {
return $('.vjs-big-play-button').waitForDisplayed()
}
waitEmbedForVideoPasswordForm () {
return $('#video-password-input').waitForDisplayed()
}
isEmbedWarningDisplayed () {
return $('.peertube-dock-description').isDisplayed()
}
goOnP2PMediaLoaderEmbed () {
return go(FIXTURE_URLS.HLS_EMBED)
}
goOnP2PMediaLoaderPlaylistEmbed () {
return go(FIXTURE_URLS.HLS_PLAYLIST_EMBED)
}
async clickOnUpdate () {
await this.clickOnMoreDropdownIcon()
const items = await $$('.dropdown-menu.show .dropdown-item')
for (const item of items) {
const href = await item.getAttribute('href')
if (href?.includes('/update/')) {
await item.click()
return
}
}
}
clickOnSave () {
return $('.action-button-save').click()
}
async createPlaylist (name: string) {
const newPlaylistButton = () => $('.new-playlist-button')
await newPlaylistButton().waitForClickable()
await newPlaylistButton().click()
const displayName = () => $('#displayName')
await displayName().waitForDisplayed()
await displayName().setValue(name)
return $('.new-playlist-block input[type=submit]').click()
}
async saveToPlaylist (name: string) {
const playlist = () => $('my-video-add-to-playlist').$(`.playlist=${name}`)
await playlist().waitForDisplayed()
return playlist().click()
}
waitUntilVideoName (name: string, maxTime: number) {
return browser.waitUntil(async () => {
return (await this.getVideoName()) === name
}, { timeout: maxTime })
}
async clickOnMoreDropdownIcon () {
const dropdown = $('my-video-actions-dropdown .action-button')
await dropdown.click()
await $('.dropdown-menu.show .dropdown-item').waitForDisplayed()
}
private async getVideoNameElement () {
// We have 2 video info name block, pick the first that is not empty
const elem = async () => {
const elems = await $$('.video-info-first-row .video-info-name').filter(e => e.isDisplayed())
return elems[0]
}
await browser.waitUntil(async () => {
const e = await elem()
return e?.isDisplayed()
})
return elem()
}
isPasswordProtected () {
return $('#confirmInput').isExisting()
}
async fillVideoPassword (videoPassword: string) {
const videoPasswordInput = await $('input#confirmInput')
await videoPasswordInput.waitForClickable()
await videoPasswordInput.clearValue()
await videoPasswordInput.setValue(videoPassword)
const confirmButton = await $('input[value="Confirm"]')
await confirmButton.waitForClickable()
return confirmButton.click()
}
async like () {
const likeButton = await $('.action-button-like')
const isActivated = (await likeButton.getAttribute('class')).includes('activated')
let count: number
try {
count = parseInt(await $('.action-button-like > .count').getText())
} catch (error) {
count = 0
}
await likeButton.waitForClickable()
await likeButton.click()
if (isActivated) {
if (count === 1) {
return expect(!await $('.action-button-like > .count').isExisting())
} else {
return expect(parseInt(await $('.action-button-like > .count').getText())).toBe(count - 1)
}
} else {
return expect(parseInt(await $('.action-button-like > .count').getText())).toBe(count + 1)
}
}
async createThread (comment: string) {
const textarea = await $('my-video-comment-add textarea')
await textarea.waitForClickable()
await textarea.setValue(comment)
const confirmButton = await $('.comment-buttons .orange-button')
await confirmButton.waitForClickable()
await confirmButton.click()
const createdComment = await (await $('.comment-html p')).getText()
return expect(createdComment).toBe(comment)
}
async createReply (comment: string) {
const replyButton = await $('button.comment-action-reply')
await replyButton.waitForClickable()
await replyButton.scrollIntoView()
await replyButton.click()
const textarea = await $('my-video-comment my-video-comment-add textarea')
await textarea.waitForClickable()
await textarea.setValue(comment)
const confirmButton = await $('my-video-comment .comment-buttons .orange-button')
await confirmButton.waitForClickable()
await confirmButton.click()
const createdComment = await (await $('.is-child .comment-html p')).getText()
return expect(createdComment).toBe(comment)
}
}
+35
ファイルの表示
@@ -0,0 +1,35 @@
import { PlayerPage } from '../po/player.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { FIXTURE_URLS, go, isMobileDevice, isSafari } from '../utils'
describe('Live all workflow', () => {
let videoWatchPage: VideoWatchPage
let playerPage: PlayerPage
beforeEach(async () => {
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
playerPage = new PlayerPage()
if (!isMobileDevice()) {
await browser.maximizeWindow()
}
})
it('Should go to the live page', async () => {
await go(FIXTURE_URLS.LIVE_VIDEO)
return videoWatchPage.waitWatchVideoName('E2E - Live')
})
it('Should play the live', async () => {
await playerPage.playAndPauseVideo(false, 45)
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(45)
})
it('Should watch the associated live embed', async () => {
await videoWatchPage.goOnAssociatedEmbed()
await playerPage.playAndPauseVideo(false, 45)
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(45)
})
})
+75
ファイルの表示
@@ -0,0 +1,75 @@
import { LoginPage } from '../po/login.po'
import { PlayerPage } from '../po/player.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { FIXTURE_URLS, go, isMobileDevice, isSafari } from '../utils'
async function checkCorrectlyPlay (playerPage: PlayerPage) {
await playerPage.playAndPauseVideo(false, 2)
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(2)
}
describe('Private videos all workflow', () => {
let videoWatchPage: VideoWatchPage
let loginPage: LoginPage
let playerPage: PlayerPage
const internalVideoName = 'Internal E2E test'
const internalHLSOnlyVideoName = 'Internal E2E test - HLS only'
beforeEach(async () => {
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
loginPage = new LoginPage(isMobileDevice())
playerPage = new PlayerPage()
if (!isMobileDevice()) {
await browser.maximizeWindow()
}
})
it('Should log in', async () => {
return loginPage.loginOnPeerTube2()
})
it('Should play an internal web video', async () => {
await go(FIXTURE_URLS.INTERNAL_WEB_VIDEO)
await videoWatchPage.waitWatchVideoName(internalVideoName)
await checkCorrectlyPlay(playerPage)
})
it('Should play an internal HLS video', async () => {
await go(FIXTURE_URLS.INTERNAL_HLS_VIDEO)
await videoWatchPage.waitWatchVideoName(internalVideoName)
await checkCorrectlyPlay(playerPage)
})
it('Should play an internal HLS only video', async () => {
await go(FIXTURE_URLS.INTERNAL_HLS_ONLY_VIDEO)
await videoWatchPage.waitWatchVideoName(internalHLSOnlyVideoName)
await checkCorrectlyPlay(playerPage)
})
it('Should play an internal Web Video in embed', async () => {
await go(FIXTURE_URLS.INTERNAL_EMBED_WEB_VIDEO)
await videoWatchPage.waitEmbedForDisplayed()
await checkCorrectlyPlay(playerPage)
})
it('Should play an internal HLS video in embed', async () => {
await go(FIXTURE_URLS.INTERNAL_EMBED_HLS_VIDEO)
await videoWatchPage.waitEmbedForDisplayed()
await checkCorrectlyPlay(playerPage)
})
it('Should play an internal HLS only video in embed', async () => {
await go(FIXTURE_URLS.INTERNAL_EMBED_HLS_ONLY_VIDEO)
await videoWatchPage.waitEmbedForDisplayed()
await checkCorrectlyPlay(playerPage)
})
})
+234
ファイルの表示
@@ -0,0 +1,234 @@
import { LoginPage } from '../po/login.po'
import { MyAccountPage } from '../po/my-account.po'
import { PlayerPage } from '../po/player.po'
import { VideoListPage } from '../po/video-list.po'
import { VideoUpdatePage } from '../po/video-update.po'
import { VideoUploadPage } from '../po/video-upload.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { FIXTURE_URLS, go, isIOS, isMobileDevice, isSafari, waitServerUp } from '../utils'
function isUploadUnsupported () {
if (isMobileDevice() || isSafari()) {
console.log('Skipping because we are on a real device or Safari and BrowserStack does not support file upload.')
return true
}
return false
}
describe('Videos all workflow', () => {
let videoWatchPage: VideoWatchPage
let videoListPage: VideoListPage
let videoUploadPage: VideoUploadPage
let videoUpdatePage: VideoUpdatePage
let myAccountPage: MyAccountPage
let loginPage: LoginPage
let playerPage: PlayerPage
let videoName = Math.random() + ' video'
const video2Name = Math.random() + ' second video'
const playlistName = Math.random() + ' playlist'
let videoWatchUrl: string
before(async () => {
if (isIOS()) {
console.log('iOS detected')
} else if (isMobileDevice()) {
console.log('Android detected.')
} else if (isSafari()) {
console.log('Safari detected.')
}
if (isUploadUnsupported()) return
await waitServerUp()
})
beforeEach(async () => {
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
videoUploadPage = new VideoUploadPage()
videoUpdatePage = new VideoUpdatePage()
myAccountPage = new MyAccountPage()
loginPage = new LoginPage(isMobileDevice())
playerPage = new PlayerPage()
videoListPage = new VideoListPage(isMobileDevice(), isSafari())
if (!isMobileDevice()) {
await browser.maximizeWindow()
}
})
it('Should log in', async () => {
if (isMobileDevice() || isSafari()) {
console.log('Skipping because we are on a real device or Safari and BrowserStack does not support file upload.')
return
}
return loginPage.loginAsRootUser()
})
it('Should upload a video', async () => {
if (isUploadUnsupported()) return
await videoUploadPage.navigateTo()
await videoUploadPage.uploadVideo('video.mp4')
return videoUploadPage.validSecondUploadStep(videoName)
})
it('Should list videos', async () => {
await videoListPage.goOnVideosList()
if (isUploadUnsupported()) return
const videoNames = await videoListPage.getVideosListName()
expect(videoNames).toContain(videoName)
})
it('Should go on video watch page', async () => {
let videoNameToExcept = videoName
if (isMobileDevice() || isSafari()) {
await go(FIXTURE_URLS.WEB_VIDEO)
videoNameToExcept = 'E2E tests'
} else {
await videoListPage.clickOnVideo(videoName)
}
return videoWatchPage.waitWatchVideoName(videoNameToExcept)
})
it('Should play the video', async () => {
videoWatchUrl = await browser.getUrl()
await playerPage.playAndPauseVideo(true, 2)
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(2)
})
it('Should watch the associated embed video', async () => {
await videoWatchPage.goOnAssociatedEmbed()
await playerPage.playAndPauseVideo(false, 2)
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(2)
})
it('Should watch the p2p media loader embed video', async () => {
await videoWatchPage.goOnP2PMediaLoaderEmbed()
await playerPage.playAndPauseVideo(false, 2)
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(2)
})
it('Should update the video', async () => {
if (isUploadUnsupported()) return
await go(videoWatchUrl)
await videoWatchPage.clickOnUpdate()
videoName += ' updated'
await videoUpdatePage.updateName(videoName)
await videoUpdatePage.validUpdate()
const name = await videoWatchPage.getVideoName()
expect(name).toEqual(videoName)
})
it('Should add the video in my playlist', async () => {
if (isUploadUnsupported()) return
await videoWatchPage.clickOnSave()
await videoWatchPage.createPlaylist(playlistName)
await videoWatchPage.saveToPlaylist(playlistName)
await browser.pause(5000)
await videoUploadPage.navigateTo()
await videoUploadPage.uploadVideo('video2.mp4')
await videoUploadPage.validSecondUploadStep(video2Name)
await videoWatchPage.clickOnSave()
await videoWatchPage.saveToPlaylist(playlistName)
})
it('Should have the playlist in my account', async () => {
if (isUploadUnsupported()) return
await myAccountPage.navigateToMyPlaylists()
const videosNumberText = await myAccountPage.getPlaylistVideosText(playlistName)
expect(videosNumberText).toEqual('2 videos')
await myAccountPage.clickOnPlaylist(playlistName)
const count = await myAccountPage.countTotalPlaylistElements()
expect(count).toEqual(2)
})
it('Should watch the playlist', async () => {
if (isUploadUnsupported()) return
await myAccountPage.playPlaylist()
await videoWatchPage.waitUntilVideoName(video2Name, 40 * 1000)
})
it('Should watch the Web Video playlist in the embed', async () => {
if (isUploadUnsupported()) return
const accessToken = await browser.execute(`return window.localStorage.getItem('access_token');`)
const refreshToken = await browser.execute(`return window.localStorage.getItem('refresh_token');`)
await myAccountPage.goOnAssociatedPlaylistEmbed()
await playerPage.waitUntilPlayerWrapper()
console.log('Will set %s and %s tokens in local storage.', accessToken, refreshToken)
await browser.execute(`window.localStorage.setItem('access_token', '${accessToken}');`)
await browser.execute(`window.localStorage.setItem('refresh_token', '${refreshToken}');`)
await browser.execute(`window.localStorage.setItem('token_type', 'Bearer');`)
await browser.refresh()
await playerPage.playVideo()
await playerPage.waitUntilPlaylistInfo('2/2', 30 * 1000)
})
it('Should watch the HLS playlist in the embed', async () => {
await videoWatchPage.goOnP2PMediaLoaderPlaylistEmbed()
await playerPage.playVideo()
await playerPage.waitUntilPlaylistInfo('2/2', 30 * 1000)
})
it('Should delete the video 2', async () => {
if (isUploadUnsupported()) return
// Go to the dev website
await go(videoWatchUrl)
await myAccountPage.navigateToMyVideos()
await myAccountPage.removeVideo(video2Name)
await myAccountPage.validRemove()
await browser.waitUntil(async () => {
const count = await myAccountPage.countVideos([ videoName, video2Name ])
return count === 1
})
})
it('Should delete the first video', async () => {
if (isUploadUnsupported()) return
await myAccountPage.removeVideo(videoName)
await myAccountPage.validRemove()
})
})
+97
ファイルの表示
@@ -0,0 +1,97 @@
import { LoginPage } from '../po/login.po'
import { VideoUploadPage } from '../po/video-upload.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, go, isMobileDevice, isSafari, waitServerUp } from '../utils'
describe('Custom server defaults', () => {
let videoUploadPage: VideoUploadPage
let loginPage: LoginPage
let videoWatchPage: VideoWatchPage
before(async () => {
await waitServerUp()
loginPage = new LoginPage(isMobileDevice())
videoUploadPage = new VideoUploadPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
await browser.maximizeWindow()
})
describe('Publish default values', function () {
before(async function () {
await loginPage.loginAsRootUser()
})
it('Should upload a video with custom default values', async function () {
await videoUploadPage.navigateTo()
await videoUploadPage.uploadVideo('video.mp4')
await videoUploadPage.validSecondUploadStep('video')
await videoWatchPage.waitWatchVideoName('video')
const videoUrl = await browser.getUrl()
expect(await videoWatchPage.getPrivacy()).toBe('Unlisted')
expect(await videoWatchPage.getLicence()).toBe('Attribution - Non Commercial')
expect(await videoWatchPage.areCommentsEnabled()).toBeFalsy()
// Owners can download their videos
expect(await videoWatchPage.isDownloadEnabled()).toBeTruthy()
// Logout to see if the download enabled is correct for anonymous users
await loginPage.logout()
await browser.url(videoUrl)
await videoWatchPage.waitWatchVideoName('video')
expect(await videoWatchPage.isDownloadEnabled()).toBeFalsy()
})
})
describe('P2P', function () {
let videoUrl: string
async function goOnVideoWatchPage () {
await go(videoUrl)
await videoWatchPage.waitWatchVideoName('video')
}
async function checkP2P (enabled: boolean) {
await goOnVideoWatchPage()
expect(await videoWatchPage.isPrivacyWarningDisplayed()).toEqual(enabled)
await videoWatchPage.goOnAssociatedEmbed()
expect(await videoWatchPage.isEmbedWarningDisplayed()).toEqual(enabled)
}
before(async () => {
await loginPage.loginAsRootUser()
await videoUploadPage.navigateTo()
await videoUploadPage.uploadVideo('video2.mp4')
await videoUploadPage.setAsPublic()
await videoUploadPage.validSecondUploadStep('video')
await videoWatchPage.waitWatchVideoName('video')
videoUrl = await browser.getUrl()
})
beforeEach(async function () {
await goOnVideoWatchPage()
})
it('Should have P2P disabled for a logged in user', async function () {
await checkP2P(false)
})
it('Should have P2P disabled for anonymous users', async function () {
await loginPage.logout()
await checkP2P(false)
})
})
after(async () => {
await browser.saveScreenshot(getScreenshotPath('after-test.png'))
})
})
+82
ファイルの表示
@@ -0,0 +1,82 @@
import { AdminPluginPage } from '../po/admin-plugin.po'
import { LoginPage } from '../po/login.po'
import { VideoUploadPage } from '../po/video-upload.po'
import { getCheckbox, isMobileDevice, waitServerUp } from '../utils'
describe('Plugins', () => {
let videoUploadPage: VideoUploadPage
let loginPage: LoginPage
let adminPluginPage: AdminPluginPage
function getPluginCheckbox () {
return getCheckbox('hello-world-field-4')
}
async function expectSubmitState ({ disabled }: { disabled: boolean }) {
const disabledSubmit = await $('my-button .disabled')
if (disabled) expect(await disabledSubmit.isDisplayed()).toBeTruthy()
else expect(await disabledSubmit.isDisplayed()).toBeFalsy()
}
before(async () => {
await waitServerUp()
})
beforeEach(async () => {
loginPage = new LoginPage(isMobileDevice())
videoUploadPage = new VideoUploadPage()
adminPluginPage = new AdminPluginPage()
await browser.maximizeWindow()
})
it('Should install hello world plugin', async () => {
await loginPage.loginAsRootUser()
await adminPluginPage.navigateToPluginSearch()
await adminPluginPage.search('hello-world')
await adminPluginPage.installHelloWorld()
await browser.refresh()
})
it('Should have checkbox in video edit page', async () => {
await videoUploadPage.navigateTo()
await videoUploadPage.uploadVideo('video.mp4')
await $('span=Super field 4 in main tab').waitForDisplayed()
const checkbox = await getPluginCheckbox()
expect(await checkbox.isDisplayed()).toBeTruthy()
await expectSubmitState({ disabled: true })
})
it('Should check the checkbox and be able to submit the video', async function () {
const checkbox = await getPluginCheckbox()
await checkbox.waitForClickable()
await checkbox.click()
await expectSubmitState({ disabled: false })
})
it('Should uncheck the checkbox and not be able to submit the video', async function () {
const checkbox = await getPluginCheckbox()
await checkbox.waitForClickable()
await checkbox.click()
await expectSubmitState({ disabled: true })
const error = await $('.form-error*=Should be enabled')
expect(await error.isDisplayed()).toBeTruthy()
})
it('Should change the privacy and should hide the checkbox', async function () {
await videoUploadPage.setAsPrivate()
await expectSubmitState({ disabled: false })
})
})
+413
ファイルの表示
@@ -0,0 +1,413 @@
import { AdminConfigPage } from '../po/admin-config.po'
import { AdminRegistrationPage } from '../po/admin-registration.po'
import { LoginPage } from '../po/login.po'
import { SignupPage } from '../po/signup.po'
import {
browserSleep,
findEmailTo,
getScreenshotPath,
getVerificationLink,
go,
isMobileDevice,
MockSMTPServer,
waitServerUp
} from '../utils'
function checkEndMessage (options: {
message: string
requiresEmailVerification: boolean
requiresApproval: boolean
afterEmailVerification: boolean
}) {
const { message, requiresApproval, requiresEmailVerification, afterEmailVerification } = options
{
const created = 'account has been created'
const request = 'account request has been sent'
if (requiresApproval) {
expect(message).toContain(request)
expect(message).not.toContain(created)
} else {
expect(message).not.toContain(request)
expect(message).toContain(created)
}
}
{
const checkEmail = 'Check your email'
if (requiresEmailVerification) {
expect(message).toContain(checkEmail)
} else {
expect(message).not.toContain(checkEmail)
const moderatorsApproval = 'moderator will check your registration request'
if (requiresApproval) {
expect(message).toContain(moderatorsApproval)
} else {
expect(message).not.toContain(moderatorsApproval)
}
}
}
{
const emailVerified = 'email has been verified'
if (afterEmailVerification) {
expect(message).toContain(emailVerified)
} else {
expect(message).not.toContain(emailVerified)
}
}
}
describe('Signup', () => {
let loginPage: LoginPage
let adminConfigPage: AdminConfigPage
let signupPage: SignupPage
let adminRegistrationPage: AdminRegistrationPage
async function prepareSignup (options: {
enabled: boolean
requiresApproval?: boolean
requiresEmailVerification?: boolean
}) {
await loginPage.loginAsRootUser()
await adminConfigPage.navigateTo('basic-configuration')
await adminConfigPage.toggleSignup(options.enabled)
if (options.enabled) {
if (options.requiresApproval !== undefined) {
await adminConfigPage.toggleSignupApproval(options.requiresApproval)
}
if (options.requiresEmailVerification !== undefined) {
await adminConfigPage.toggleSignupEmailVerification(options.requiresEmailVerification)
}
}
await adminConfigPage.save()
await loginPage.logout()
await browser.refresh()
}
before(async () => {
await waitServerUp()
})
beforeEach(async () => {
loginPage = new LoginPage(isMobileDevice())
adminConfigPage = new AdminConfigPage()
signupPage = new SignupPage()
adminRegistrationPage = new AdminRegistrationPage()
await browser.maximizeWindow()
})
describe('Signup disabled', function () {
it('Should disable signup', async () => {
await prepareSignup({ enabled: false })
await expect(signupPage.getRegisterMenuButton()).not.toBeDisplayed()
})
})
describe('Email verification disabled', function () {
describe('Direct registration', function () {
it('Should enable signup without approval', async () => {
await prepareSignup({ enabled: true, requiresApproval: false, requiresEmailVerification: false })
await signupPage.getRegisterMenuButton().waitForDisplayed()
})
it('Should go on signup page', async function () {
await signupPage.clickOnRegisterInMenu()
})
it('Should validate the first step (about page)', async function () {
await signupPage.validateStep()
})
it('Should validate the second step (terms)', async function () {
await signupPage.checkTerms()
await signupPage.validateStep()
})
it('Should validate the third step (account)', async function () {
await signupPage.fillAccountStep({ username: 'user_1', displayName: 'user_1_dn' })
await signupPage.validateStep()
})
it('Should validate the third step (channel)', async function () {
await signupPage.fillChannelStep({ name: 'user_1_channel' })
await signupPage.validateStep()
})
it('Should be logged in', async function () {
await loginPage.ensureIsLoggedInAs('user_1_dn')
})
it('Should have a valid end message', async function () {
const message = await signupPage.getEndMessage()
checkEndMessage({
message,
requiresEmailVerification: false,
requiresApproval: false,
afterEmailVerification: false
})
await browser.saveScreenshot(getScreenshotPath('direct-without-email.png'))
await loginPage.logout()
})
})
describe('Registration with approval', function () {
it('Should enable signup with approval', async () => {
await prepareSignup({ enabled: true, requiresApproval: true, requiresEmailVerification: false })
await signupPage.getRegisterMenuButton().waitForDisplayed()
})
it('Should go on signup page', async function () {
await signupPage.clickOnRegisterInMenu()
})
it('Should validate the first step (about page)', async function () {
await signupPage.validateStep()
})
it('Should validate the second step (terms)', async function () {
await signupPage.checkTerms()
await signupPage.fillRegistrationReason('my super reason')
await signupPage.validateStep()
})
it('Should validate the third step (account)', async function () {
await signupPage.fillAccountStep({ username: 'user_2', displayName: 'user_2 display name', password: 'password' })
await signupPage.validateStep()
})
it('Should validate the third step (channel)', async function () {
await signupPage.fillChannelStep({ name: 'user_2_channel' })
await signupPage.validateStep()
})
it('Should have a valid end message', async function () {
const message = await signupPage.getEndMessage()
checkEndMessage({
message,
requiresEmailVerification: false,
requiresApproval: true,
afterEmailVerification: false
})
await browser.saveScreenshot(getScreenshotPath('request-without-email.png'))
})
it('Should display a message when trying to login with this account', async function () {
const error = await loginPage.getLoginError('user_2', 'password')
expect(error).toContain('awaiting approval')
})
it('Should accept the registration', async function () {
await loginPage.loginAsRootUser()
await adminRegistrationPage.navigateToRegistratonsList()
await adminRegistrationPage.accept('user_2', 'moderation response')
await loginPage.logout()
})
it('Should be able to login with this new account', async function () {
await loginPage.login({ username: 'user_2', password: 'password', displayName: 'user_2 display name' })
await loginPage.logout()
})
})
})
describe('Email verification enabled', function () {
const emails: any[] = []
let emailPort: number
before(async () => {
const key = browser.options.baseUrl + '-emailPort'
// FIXME: typings are wrong, get returns a promise
// FIXME: use * because the key is not properly escaped by the shared store when using get(key)
emailPort = (await (browser.sharedStore.get('*') as unknown as Promise<number>))[key]
await MockSMTPServer.Instance.collectEmails(emailPort, emails)
})
describe('Direct registration', function () {
it('Should enable signup without approval', async () => {
await prepareSignup({ enabled: true, requiresApproval: false, requiresEmailVerification: true })
await signupPage.getRegisterMenuButton().waitForDisplayed()
})
it('Should go on signup page', async function () {
await signupPage.clickOnRegisterInMenu()
})
it('Should validate the first step (about page)', async function () {
await signupPage.validateStep()
})
it('Should validate the second step (terms)', async function () {
await signupPage.checkTerms()
await signupPage.validateStep()
})
it('Should validate the third step (account)', async function () {
await signupPage.fillAccountStep({ username: 'user_3', displayName: 'user_3 display name', email: 'user_3@example.com' })
await signupPage.validateStep()
})
it('Should validate the third step (channel)', async function () {
await signupPage.fillChannelStep({ name: 'user_3_channel' })
await signupPage.validateStep()
})
it('Should have a valid end message', async function () {
const message = await signupPage.getEndMessage()
checkEndMessage({
message,
requiresEmailVerification: true,
requiresApproval: false,
afterEmailVerification: false
})
await browser.saveScreenshot(getScreenshotPath('direct-with-email.png'))
})
it('Should validate the email', async function () {
let email: { text: string }
while (!(email = findEmailTo(emails, 'user_3@example.com'))) {
await browserSleep(100)
}
await go(getVerificationLink(email))
const message = await signupPage.getEndMessage()
checkEndMessage({
message,
requiresEmailVerification: false,
requiresApproval: false,
afterEmailVerification: true
})
await browser.saveScreenshot(getScreenshotPath('direct-after-email.png'))
})
})
describe('Registration with approval', function () {
it('Should enable signup without approval', async () => {
await prepareSignup({ enabled: true, requiresApproval: true, requiresEmailVerification: true })
await signupPage.getRegisterMenuButton().waitForDisplayed()
})
it('Should go on signup page', async function () {
await signupPage.clickOnRegisterInMenu()
})
it('Should validate the first step (about page)', async function () {
await signupPage.validateStep()
})
it('Should validate the second step (terms)', async function () {
await signupPage.checkTerms()
await signupPage.fillRegistrationReason('my super reason 2')
await signupPage.validateStep()
})
it('Should validate the third step (account)', async function () {
await signupPage.fillAccountStep({
username: 'user_4',
displayName: 'user_4 display name',
email: 'user_4@example.com',
password: 'password'
})
await signupPage.validateStep()
})
it('Should validate the third step (channel)', async function () {
await signupPage.fillChannelStep({ name: 'user_4_channel' })
await signupPage.validateStep()
})
it('Should have a valid end message', async function () {
const message = await signupPage.getEndMessage()
checkEndMessage({
message,
requiresEmailVerification: true,
requiresApproval: true,
afterEmailVerification: false
})
await browser.saveScreenshot(getScreenshotPath('request-with-email.png'))
})
it('Should display a message when trying to login with this account', async function () {
const error = await loginPage.getLoginError('user_4', 'password')
expect(error).toContain('awaiting approval')
})
it('Should accept the registration', async function () {
await loginPage.loginAsRootUser()
await adminRegistrationPage.navigateToRegistratonsList()
await adminRegistrationPage.accept('user_4', 'moderation response 2')
await loginPage.logout()
})
it('Should validate the email', async function () {
let email: { text: string }
while (!(email = findEmailTo(emails, 'user_4@example.com'))) {
await browserSleep(100)
}
await go(getVerificationLink(email))
const message = await signupPage.getEndMessage()
checkEndMessage({
message,
requiresEmailVerification: false,
requiresApproval: true,
afterEmailVerification: true
})
await browser.saveScreenshot(getScreenshotPath('request-after-email.png'))
})
})
after(() => {
MockSMTPServer.Instance.kill()
})
})
})
+82
ファイルの表示
@@ -0,0 +1,82 @@
import { AnonymousSettingsPage } from '../po/anonymous-settings.po'
import { LoginPage } from '../po/login.po'
import { MyAccountPage } from '../po/my-account.po'
import { VideoUploadPage } from '../po/video-upload.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { go, isMobileDevice, isSafari, waitServerUp } from '../utils'
describe('User settings', () => {
let videoUploadPage: VideoUploadPage
let loginPage: LoginPage
let videoWatchPage: VideoWatchPage
let myAccountPage: MyAccountPage
let anonymousSettingsPage: AnonymousSettingsPage
before(async () => {
await waitServerUp()
loginPage = new LoginPage(isMobileDevice())
videoUploadPage = new VideoUploadPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
myAccountPage = new MyAccountPage()
anonymousSettingsPage = new AnonymousSettingsPage()
await browser.maximizeWindow()
})
describe('P2P', function () {
let videoUrl: string
async function goOnVideoWatchPage () {
await go(videoUrl)
await videoWatchPage.waitWatchVideoName('video')
}
async function checkP2P (enabled: boolean) {
await goOnVideoWatchPage()
expect(await videoWatchPage.isPrivacyWarningDisplayed()).toEqual(enabled)
await videoWatchPage.goOnAssociatedEmbed()
expect(await videoWatchPage.isEmbedWarningDisplayed()).toEqual(enabled)
}
before(async () => {
await loginPage.loginAsRootUser()
await videoUploadPage.navigateTo()
await videoUploadPage.uploadVideo('video.mp4')
await videoUploadPage.validSecondUploadStep('video')
await videoWatchPage.waitWatchVideoName('video')
videoUrl = await browser.getUrl()
})
beforeEach(async function () {
await goOnVideoWatchPage()
})
it('Should have P2P enabled for a logged in user', async function () {
await checkP2P(true)
})
it('Should disable P2P for a logged in user', async function () {
await myAccountPage.navigateToMySettings()
await myAccountPage.clickOnP2PCheckbox()
await checkP2P(false)
})
it('Should have P2P enabled for anonymous users', async function () {
await loginPage.logout()
await checkP2P(true)
})
it('Should disable P2P for an anonymous user', async function () {
await anonymousSettingsPage.openSettings()
await anonymousSettingsPage.clickOnP2PCheckbox()
await checkP2P(false)
})
})
})
+232
ファイルの表示
@@ -0,0 +1,232 @@
import { LoginPage } from '../po/login.po'
import { MyAccountPage } from '../po/my-account.po'
import { PlayerPage } from '../po/player.po'
import { SignupPage } from '../po/signup.po'
import { VideoUploadPage } from '../po/video-upload.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, go, isMobileDevice, isSafari, waitServerUp } from '../utils'
describe('Password protected videos', () => {
let videoUploadPage: VideoUploadPage
let loginPage: LoginPage
let videoWatchPage: VideoWatchPage
let signupPage: SignupPage
let playerPage: PlayerPage
let myAccountPage: MyAccountPage
let passwordProtectedVideoUrl: string
let playlistUrl: string
const seed = Math.random()
const passwordProtectedVideoName = seed + ' - password protected'
const publicVideoName1 = seed + ' - public 1'
const publicVideoName2 = seed + ' - public 2'
const videoPassword = 'password'
const regularUsername = 'user_1'
const regularUserPassword = 'user password'
const playlistName = seed + ' - playlist'
function testRateAndComment () {
it('Should add and remove like on video', async function () {
await videoWatchPage.like()
await videoWatchPage.like()
})
it('Should create thread on video', async function () {
await videoWatchPage.createThread('My first comment')
})
it('Should reply to thread on video', async function () {
await videoWatchPage.createReply('My first reply')
})
}
before(async () => {
await waitServerUp()
loginPage = new LoginPage(isMobileDevice())
videoUploadPage = new VideoUploadPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
signupPage = new SignupPage()
playerPage = new PlayerPage()
myAccountPage = new MyAccountPage()
await browser.maximizeWindow()
})
describe('Owner', function () {
before(async () => {
await loginPage.loginAsRootUser()
})
it('Should login, upload a public video and save it to a playlist', async () => {
await videoUploadPage.navigateTo()
await videoUploadPage.uploadVideo('video.mp4')
await videoUploadPage.validSecondUploadStep(publicVideoName1)
await videoWatchPage.clickOnSave()
await videoWatchPage.createPlaylist(playlistName)
await videoWatchPage.saveToPlaylist(playlistName)
await browser.pause(5000)
})
it('Should upload a password protected video', async () => {
await videoUploadPage.navigateTo()
await videoUploadPage.uploadVideo('video2.mp4')
await videoUploadPage.setAsPasswordProtected(videoPassword)
await videoUploadPage.validSecondUploadStep(passwordProtectedVideoName)
await videoWatchPage.waitWatchVideoName(passwordProtectedVideoName)
passwordProtectedVideoUrl = await browser.getUrl()
})
it('Should save to playlist the password protected video', async () => {
await videoWatchPage.clickOnSave()
await videoWatchPage.saveToPlaylist(playlistName)
})
it('Should upload a second public video and save it to playlist', async () => {
await videoUploadPage.navigateTo()
await videoUploadPage.uploadVideo('video3.mp4')
await videoUploadPage.validSecondUploadStep(publicVideoName2)
await videoWatchPage.clickOnSave()
await videoWatchPage.saveToPlaylist(playlistName)
})
it('Should play video without password', async function () {
await go(passwordProtectedVideoUrl)
expect(!await videoWatchPage.isPasswordProtected())
await videoWatchPage.waitWatchVideoName(passwordProtectedVideoName)
expect(await videoWatchPage.getPrivacy()).toBe('Password protected')
await playerPage.playAndPauseVideo(false, 2)
})
testRateAndComment()
it('Should play video on embed without password', async function () {
await videoWatchPage.goOnAssociatedEmbed()
await playerPage.playAndPauseVideo(false, 2)
})
it('Should have the playlist in my account', async function () {
await go('/')
await myAccountPage.navigateToMyPlaylists()
const videosNumberText = await myAccountPage.getPlaylistVideosText(playlistName)
expect(videosNumberText).toEqual('3 videos')
await myAccountPage.clickOnPlaylist(playlistName)
const count = await myAccountPage.countTotalPlaylistElements()
expect(count).toEqual(3)
})
it('Should update the playlist to public', async () => {
const url = await browser.getUrl()
const regex = /\/my-library\/video-playlists\/([^/]+)/i
const match = url.match(regex)
const uuid = match ? match[1] : null
expect(uuid).not.toBeNull()
await myAccountPage.updatePlaylistPrivacy(uuid, 'Public')
})
it('Should watch the playlist', async () => {
await myAccountPage.clickOnPlaylist(playlistName)
await myAccountPage.playPlaylist()
await videoWatchPage.waitUntilVideoName(publicVideoName1, 40 * 1000)
playlistUrl = await browser.getUrl()
await videoWatchPage.waitUntilVideoName(passwordProtectedVideoName, 40 * 1000)
await videoWatchPage.waitUntilVideoName(publicVideoName2, 40 * 1000)
})
after(async () => {
await loginPage.logout()
})
})
describe('Regular users', function () {
before(async () => {
await signupPage.fullSignup({
accountInfo: {
username: regularUsername,
password: regularUserPassword
},
channelInfo: {
name: 'user_1_channel'
}
})
})
it('Should requires password to play video', async function () {
await go(passwordProtectedVideoUrl)
expect(await videoWatchPage.isPasswordProtected())
await videoWatchPage.fillVideoPassword(videoPassword)
await videoWatchPage.waitWatchVideoName(passwordProtectedVideoName)
expect(await videoWatchPage.getPrivacy()).toBe('Password protected')
await playerPage.playAndPauseVideo(true, 2)
})
testRateAndComment()
it('Should requires password to play video on embed', async function () {
await videoWatchPage.goOnAssociatedEmbed(true)
await playerPage.fillEmbedVideoPassword(videoPassword)
await playerPage.playAndPauseVideo(false, 2)
})
it('Should watch the playlist without password protected video', async () => {
await go(playlistUrl)
await playerPage.playVideo()
await videoWatchPage.waitUntilVideoName(publicVideoName2, 40 * 1000)
})
after(async () => {
await loginPage.logout()
})
})
describe('Anonymous users', function () {
it('Should requires password to play video', async function () {
await go(passwordProtectedVideoUrl)
expect(await videoWatchPage.isPasswordProtected())
await videoWatchPage.fillVideoPassword(videoPassword)
await videoWatchPage.waitWatchVideoName(passwordProtectedVideoName)
expect(await videoWatchPage.getPrivacy()).toBe('Password protected')
await playerPage.playAndPauseVideo(true, 2)
})
it('Should requires password to play video on embed', async function () {
await videoWatchPage.goOnAssociatedEmbed(true)
await playerPage.fillEmbedVideoPassword(videoPassword)
await playerPage.playAndPauseVideo(false, 2)
})
it('Should watch the playlist without password protected video', async () => {
await go(playlistUrl)
await playerPage.playVideo()
await videoWatchPage.waitUntilVideoName(publicVideoName2, 40 * 1000)
})
})
after(async () => {
await browser.saveScreenshot(getScreenshotPath('after-test.png'))
})
})
+219
ファイルの表示
@@ -0,0 +1,219 @@
import { AdminConfigPage } from '../po/admin-config.po'
import { LoginPage } from '../po/login.po'
import { MyAccountPage } from '../po/my-account.po'
import { VideoListPage } from '../po/video-list.po'
import { VideoSearchPage } from '../po/video-search.po'
import { VideoUploadPage } from '../po/video-upload.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { NSFWPolicy } from '../types/common'
import { isMobileDevice, isSafari, waitServerUp } from '../utils'
describe('Videos list', () => {
let videoListPage: VideoListPage
let videoUploadPage: VideoUploadPage
let adminConfigPage: AdminConfigPage
let loginPage: LoginPage
let myAccountPage: MyAccountPage
let videoSearchPage: VideoSearchPage
let videoWatchPage: VideoWatchPage
const seed = Math.random()
const nsfwVideo = seed + ' - nsfw'
const normalVideo = seed + ' - normal'
async function checkNormalVideo () {
expect(await videoListPage.videoExists(normalVideo)).toBeTruthy()
expect(await videoListPage.videoIsBlurred(normalVideo)).toBeFalsy()
}
async function checkNSFWVideo (policy: NSFWPolicy, filterText?: string) {
if (policy === 'do_not_list') {
if (filterText) expect(filterText).toContain('hidden')
expect(await videoListPage.videoExists(nsfwVideo)).toBeFalsy()
return
}
if (policy === 'blur') {
if (filterText) expect(filterText).toContain('blurred')
expect(await videoListPage.videoExists(nsfwVideo)).toBeTruthy()
expect(await videoListPage.videoIsBlurred(nsfwVideo)).toBeTruthy()
return
}
// display
if (filterText) expect(filterText).toContain('displayed')
expect(await videoListPage.videoExists(nsfwVideo)).toBeTruthy()
expect(await videoListPage.videoIsBlurred(nsfwVideo)).toBeFalsy()
}
async function checkCommonVideoListPages (policy: NSFWPolicy) {
const promisesWithFilters = [
videoListPage.goOnRootAccount.bind(videoListPage),
videoListPage.goOnLocal.bind(videoListPage),
videoListPage.goOnRecentlyAdded.bind(videoListPage),
videoListPage.goOnTrending.bind(videoListPage),
videoListPage.goOnRootChannel.bind(videoListPage)
]
for (const p of promisesWithFilters) {
await p()
const filter = await videoListPage.getNSFWFilter()
const filterText = await filter.getText()
await checkNormalVideo()
await checkNSFWVideo(policy, filterText)
}
const promisesWithoutFilters = [
videoListPage.goOnRootAccountChannels.bind(videoListPage),
videoListPage.goOnHomepage.bind(videoListPage)
]
for (const p of promisesWithoutFilters) {
await p()
await checkNormalVideo()
await checkNSFWVideo(policy)
}
}
async function checkSearchPage (policy: NSFWPolicy) {
await videoSearchPage.search(normalVideo)
await checkNormalVideo()
await videoSearchPage.search(nsfwVideo)
await checkNSFWVideo(policy)
}
async function updateAdminNSFW (nsfw: NSFWPolicy) {
await adminConfigPage.navigateTo('instance-information')
await adminConfigPage.updateNSFWSetting(nsfw)
await adminConfigPage.save()
}
async function updateUserNSFW (nsfw: NSFWPolicy) {
await myAccountPage.navigateToMySettings()
await myAccountPage.updateNSFW(nsfw)
}
before(async () => {
await waitServerUp()
})
beforeEach(async () => {
videoListPage = new VideoListPage(isMobileDevice(), isSafari())
adminConfigPage = new AdminConfigPage()
loginPage = new LoginPage(isMobileDevice())
videoUploadPage = new VideoUploadPage()
myAccountPage = new MyAccountPage()
videoSearchPage = new VideoSearchPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
await browser.maximizeWindow()
})
it('Should login and disable NSFW', async () => {
await loginPage.loginAsRootUser()
await updateUserNSFW('display')
})
it('Should set the homepage', async () => {
await adminConfigPage.navigateTo('instance-homepage')
await adminConfigPage.updateHomepage('<peertube-videos-list data-sort="-publishedAt"></peertube-videos-list>')
await adminConfigPage.save()
})
it('Should upload 2 videos (NSFW and classic videos)', async () => {
await videoUploadPage.navigateTo()
await videoUploadPage.uploadVideo('video.mp4')
await videoUploadPage.setAsNSFW()
await videoUploadPage.validSecondUploadStep(nsfwVideo)
await videoUploadPage.navigateTo()
await videoUploadPage.uploadVideo('video2.mp4')
await videoUploadPage.validSecondUploadStep(normalVideo)
})
it('Should logout', async function () {
await loginPage.logout()
})
describe('Anonymous users', function () {
it('Should correctly handle do not list', async () => {
await loginPage.loginAsRootUser()
await updateAdminNSFW('do_not_list')
await loginPage.logout()
await checkCommonVideoListPages('do_not_list')
await checkSearchPage('do_not_list')
})
it('Should correctly handle blur', async () => {
await loginPage.loginAsRootUser()
await updateAdminNSFW('blur')
await loginPage.logout()
await checkCommonVideoListPages('blur')
await checkSearchPage('blur')
})
it('Should correctly handle display', async () => {
await loginPage.loginAsRootUser()
await updateAdminNSFW('display')
await loginPage.logout()
await checkCommonVideoListPages('display')
await checkSearchPage('display')
})
})
describe('Logged in users', function () {
before(async () => {
await loginPage.loginAsRootUser()
})
it('Should correctly handle do not list', async () => {
await updateUserNSFW('do_not_list')
await checkCommonVideoListPages('do_not_list')
await checkSearchPage('do_not_list')
})
it('Should correctly handle blur', async () => {
await updateUserNSFW('blur')
await checkCommonVideoListPages('blur')
await checkSearchPage('blur')
})
it('Should correctly handle display', async () => {
await updateUserNSFW('display')
await checkCommonVideoListPages('display')
await checkSearchPage('display')
})
after(async () => {
await loginPage.logout()
})
})
describe('Default upload values', function () {
it('Should have default video values', async function () {
await loginPage.loginAsRootUser()
await videoUploadPage.navigateTo()
await videoUploadPage.uploadVideo('video3.mp4')
await videoUploadPage.validSecondUploadStep('video')
await videoWatchPage.waitWatchVideoName('video')
expect(await videoWatchPage.getPrivacy()).toBe('Public')
expect(await videoWatchPage.getLicence()).toBe('Unknown')
expect(await videoWatchPage.isDownloadEnabled()).toBeTruthy()
expect(await videoWatchPage.areCommentsEnabled()).toBeTruthy()
})
})
})
+1
ファイルの表示
@@ -0,0 +1 @@
export type NSFWPolicy = 'do_not_list' | 'blur' | 'display'
ベンダーファイル
+9
ファイルの表示
@@ -0,0 +1,9 @@
declare global {
namespace WebdriverIO {
interface Element {
chooseFile: (path: string) => Promise<void>
}
}
}
export {}
+53
ファイルの表示
@@ -0,0 +1,53 @@
async function browserSleep (amount: number) {
await browser.pause(amount)
}
function isMobileDevice () {
const platformName = (browser.capabilities['platformName'] || '').toLowerCase()
return platformName === 'android' || platformName === 'ios'
}
function isAndroid () {
const platformName = (browser.capabilities['platformName'] || '').toLowerCase()
return platformName === 'android'
}
function isSafari () {
return browser.capabilities['browserName'] &&
browser.capabilities['browserName'].toLowerCase() === 'safari'
}
function isIOS () {
return isMobileDevice() && isSafari()
}
async function go (url: string) {
await browser.url(url)
await browser.execute(() => {
const style = document.createElement('style')
style.innerHTML = 'p-toast { display: none }'
document.head.appendChild(style)
})
}
async function waitServerUp () {
await browser.waitUntil(async () => {
await go('/')
await browserSleep(500)
return $('<my-app>').isDisplayed()
}, { timeout: 20 * 1000 })
}
export {
isMobileDevice,
isSafari,
isIOS,
isAndroid,
waitServerUp,
go,
browserSleep
}
+43
ファイルの表示
@@ -0,0 +1,43 @@
async function getCheckbox (name: string) {
const input = $(`my-peertube-checkbox input[id=${name}]`)
await input.waitForExist()
return input.parentElement()
}
function isCheckboxSelected (name: string) {
return $(`input[id=${name}]`).isSelected()
}
async function selectCustomSelect (id: string, valueLabel: string) {
const wrapper = $(`[formcontrolname=${id}] .ng-arrow-wrapper`)
await wrapper.waitForClickable()
await wrapper.click()
const option = await $$(`[formcontrolname=${id}] .ng-option`).filter(async o => {
const text = await o.getText()
return text.trimStart().startsWith(valueLabel)
}).then(options => options[0])
await option.waitForDisplayed()
return option.click()
}
async function findParentElement (
el: WebdriverIO.Element,
finder: (el: WebdriverIO.Element) => Promise<boolean>
) {
if (await finder(el) === true) return el
return findParentElement(await el.parentElement(), finder)
}
export {
getCheckbox,
isCheckboxSelected,
selectCustomSelect,
findParentElement
}
+31
ファイルの表示
@@ -0,0 +1,31 @@
function getVerificationLink (email: { text: string }) {
const { text } = email
const regexp = /\[(?<link>http:\/\/[^\]]+)\]/g
const matched = text.matchAll(regexp)
if (!matched) throw new Error('Could not find verification link in email')
for (const match of matched) {
const link = match.groups.link
if (link.includes('/verify-account/')) return link
}
throw new Error('Could not find /verify-account/ link')
}
function findEmailTo (emails: { text: string, to: { address: string }[] }[], to: string) {
for (const email of emails) {
for (const { address } of email.to) {
if (address === to) return email
}
}
return undefined
}
export {
getVerificationLink,
findEmailTo
}
+17
ファイルの表示
@@ -0,0 +1,17 @@
import { mkdirSync } from 'fs'
import { join } from 'path'
const SCREENSHOTS_DIRECTORY = 'screenshots'
function createScreenshotsDirectory () {
mkdirSync(SCREENSHOTS_DIRECTORY, { recursive: true })
}
function getScreenshotPath (filename: string) {
return join(SCREENSHOTS_DIRECTORY, filename)
}
export {
createScreenshotsDirectory,
getScreenshotPath
}
+106
ファイルの表示
@@ -0,0 +1,106 @@
import { ChildProcessWithoutNullStreams } from 'child_process'
import { basename } from 'path'
import { setValue } from '@wdio/shared-store-service'
import { createScreenshotsDirectory } from './files'
import { runCommand, runServer } from './server'
let appInstance: number
let app: ChildProcessWithoutNullStreams
let emailPort: number
async function beforeLocalSuite (suite: any) {
const config = buildConfig(suite.file)
await runCommand('npm run clean:server:test -- ' + appInstance)
app = runServer(appInstance, config)
}
function afterLocalSuite () {
app.kill()
app = undefined
}
async function beforeLocalSession (config: { baseUrl: string }, capabilities: { browserName: string }) {
createScreenshotsDirectory()
appInstance = capabilities['browserName'] === 'chrome'
? 1
: 2
emailPort = 1025 + appInstance
config.baseUrl = 'http://localhost:900' + appInstance
await setValue(config.baseUrl + '-emailPort', emailPort)
}
async function onBrowserStackPrepare () {
const appInstance = 1
await runCommand('npm run clean:server:test -- ' + appInstance)
app = runServer(appInstance)
}
function onBrowserStackComplete () {
app.kill()
app = undefined
}
export {
beforeLocalSession,
afterLocalSuite,
beforeLocalSuite,
onBrowserStackPrepare,
onBrowserStackComplete
}
// ---------------------------------------------------------------------------
function buildConfig (suiteFile: string = undefined) {
const filename = basename(suiteFile)
if (filename === 'custom-server-defaults.e2e-spec.ts') {
return {
defaults: {
publish: {
download_enabled: false,
comments_policy: 2,
privacy: 2,
licence: 4
},
p2p: {
webapp: {
enabled: false
},
embed: {
enabled: false
}
}
}
}
}
if (filename === 'signup.e2e-spec.ts') {
return {
signup: {
limit: -1
},
smtp: {
hostname: '127.0.0.1',
port: emailPort
}
}
}
if (filename === 'video-password.e2e-spec.ts') {
return {
signup: {
enabled: true,
limit: -1
}
}
}
return {}
}
+8
ファイルの表示
@@ -0,0 +1,8 @@
export * from './common'
export * from './elements'
export * from './email'
export * from './files'
export * from './hooks'
export * from './mock-smtp'
export * from './server'
export * from './urls'
+57
ファイルの表示
@@ -0,0 +1,57 @@
import MailDev from '@peertube/maildev'
class MockSMTPServer {
private static instance: MockSMTPServer
private started = false
private maildev: any
private emails: object[]
collectEmails (port: number, emailsCollection: object[]) {
return new Promise<number>((res, rej) => {
this.emails = emailsCollection
if (this.started) {
return res(undefined)
}
this.maildev = new MailDev({
ip: '127.0.0.1',
smtp: port,
disableWeb: true,
silent: true
})
this.maildev.on('new', email => {
this.emails.push(email)
})
this.maildev.listen(err => {
if (err) return rej(err)
this.started = true
return res(port)
})
})
}
kill () {
if (!this.maildev) return
this.maildev.close()
this.maildev = null
MockSMTPServer.instance = null
}
static get Instance () {
return this.instance || (this.instance = new this())
}
}
// ---------------------------------------------------------------------------
export {
MockSMTPServer
}
+68
ファイルの表示
@@ -0,0 +1,68 @@
import { exec, spawn } from 'child_process'
import { join, resolve } from 'path'
function runServer (appInstance: number, config: any = {}) {
const env = Object.create(process.env)
env['NODE_OPTIONS'] = ''
env['NODE_ENV'] = 'test'
env['NODE_APP_INSTANCE'] = appInstance + ''
env['NODE_CONFIG'] = JSON.stringify({
rates_limit: {
api: {
max: 5000
},
login: {
max: 5000
}
},
log: {
level: 'warn'
},
transcoding: {
enabled: false
},
video_studio: {
enabled: false
},
...config
})
const forkOptions = {
env,
cwd: getRootCWD(),
detached: false
}
const p = spawn('node', [ join('dist', 'server.js') ], forkOptions)
p.stderr.on('data', data => console.error(data.toString()))
p.stdout.on('data', data => console.error(data.toString()))
return p
}
function runCommand (command: string) {
return new Promise<void>((res, rej) => {
// Reset NODE_OPTIONS env set by webdriverio
const env = { ...process.env, NODE_OPTIONS: '' }
const p = exec(command, { env, cwd: getRootCWD() })
p.stderr.on('data', data => console.error(data.toString()))
p.on('error', err => rej(err))
p.on('exit', () => res())
})
}
export {
runServer,
runCommand
}
// ---------------------------------------------------------------------------
function getRootCWD () {
return resolve('../..')
}
+21
ファイルの表示
@@ -0,0 +1,21 @@
const FIXTURE_URLS = {
INTERNAL_WEB_VIDEO: 'https://peertube2.cpy.re/w/pwfz7NizSdPD4mJcbbmNwa?mode=web-video&start=0',
INTERNAL_HLS_VIDEO: 'https://peertube2.cpy.re/w/pwfz7NizSdPD4mJcbbmNwa?start=0',
INTERNAL_EMBED_WEB_VIDEO: 'https://peertube2.cpy.re/videos/embed/pwfz7NizSdPD4mJcbbmNwa?mode=web-video&start=0',
INTERNAL_EMBED_HLS_VIDEO: 'https://peertube2.cpy.re/videos/embed/pwfz7NizSdPD4mJcbbmNwa?start=0',
INTERNAL_HLS_ONLY_VIDEO: 'https://peertube2.cpy.re/w/tKQmHcqdYZRdCszLUiWM3V?start=0',
INTERNAL_EMBED_HLS_ONLY_VIDEO: 'https://peertube2.cpy.re/videos/embed/tKQmHcqdYZRdCszLUiWM3V?start=0',
WEB_VIDEO: 'https://peertube2.cpy.re/w/122d093a-1ede-43bd-bd34-59d2931ffc5e',
HLS_EMBED: 'https://peertube2.cpy.re/videos/embed/969bf103-7818-43b5-94a0-de159e13de50',
HLS_PLAYLIST_EMBED: 'https://peertube2.cpy.re/video-playlists/embed/73804a40-da9a-40c2-b1eb-2c6d9eec8f0a',
LIVE_VIDEO: 'https://peertube2.cpy.re/w/oBw6LwsMWWRkmXYfuYRpJd'
}
export {
FIXTURE_URLS
}
+27
ファイルの表示
@@ -0,0 +1,27 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/app",
"noImplicitAny": false,
"esModuleInterop": true,
"module": "commonjs",
"target": "ES2015",
"typeRoots": [
"../node_modules/@types",
"../node_modules"
],
"types": [
"node",
"@wdio/globals/types",
"@wdio/mocha-framework",
"expect-webdriverio"
]
},
"ts-node": {
"files": true
},
"include": [
"src/**/*.ts",
"./*.ts"
]
}
+137
ファイルの表示
@@ -0,0 +1,137 @@
import { onBrowserStackComplete, onBrowserStackPrepare } from './src/utils'
import { config as mainConfig } from './wdio.main.conf'
const user = process.env.BROWSERSTACK_USER
const key = process.env.BROWSERSTACK_KEY
if (!user) throw new Error('Miss browser stack user')
if (!key) throw new Error('Miss browser stack key')
function buildMainOptions (sessionName: string) {
return {
projectName: 'PeerTube',
buildName: 'Main E2E - ' + new Date().toISOString(),
sessionName,
consoleLogs: 'info',
networkLogs: true
}
}
function buildBStackDesktopOptions (options: {
sessionName: string
resolution: string
os?: string
osVersion?: string
}) {
const { sessionName, resolution, os, osVersion } = options
return {
'bstack:options': {
...buildMainOptions(sessionName),
os,
osVersion,
resolution
}
}
}
function buildBStackMobileOptions (options: {
sessionName: string
deviceName: string
osVersion: string
}) {
const { sessionName, deviceName, osVersion } = options
return {
'bstack:options': {
...buildMainOptions(sessionName),
realMobile: true,
osVersion,
deviceName
}
}
}
module.exports = {
config: {
...mainConfig,
user,
key,
maxInstances: 5,
capabilities: [
{
browserName: 'Chrome',
...buildBStackDesktopOptions({ sessionName: 'Latest Chrome Desktop', resolution: '1280x1024', os: 'Windows', osVersion: '8' })
},
{
browserName: 'Firefox',
browserVersion: '78', // Very old ESR
...buildBStackDesktopOptions({ sessionName: 'Firefox ESR Desktop', resolution: '1280x1024', os: 'Windows', osVersion: '8' })
},
{
browserName: 'Safari',
browserVersion: '12.1',
...buildBStackDesktopOptions({ sessionName: 'Safari Desktop', resolution: '1280x1024' })
},
{
browserName: 'Firefox',
...buildBStackDesktopOptions({ sessionName: 'Firefox Latest', resolution: '1280x1024', os: 'Windows', osVersion: '8' })
},
{
browserName: 'Edge',
...buildBStackDesktopOptions({ sessionName: 'Edge Latest', resolution: '1280x1024' })
},
{
browserName: 'Chrome',
...buildBStackMobileOptions({ sessionName: 'Latest Chrome Android', deviceName: 'Samsung Galaxy S8', osVersion: '7.0' })
},
{
browserName: 'Safari',
...buildBStackMobileOptions({ sessionName: 'Safari iPhone', deviceName: 'iPhone 8', osVersion: '13' })
},
{
browserName: 'Safari',
...buildBStackMobileOptions({ sessionName: 'Safari iPad', deviceName: 'iPad 7th', osVersion: '13' })
}
],
host: 'hub-cloud.browserstack.com',
connectionRetryTimeout: 240000,
waitforTimeout: 20000,
specs: [
// We don't want to test "local" tests
'./src/suites-all/*.e2e-spec.ts'
],
services: [
[
'browserstack', { browserstackLocal: true }
]
],
onWorkerStart: function (_cid, capabilities) {
if (capabilities['bstack:options'].realMobile === true) {
capabilities['bstack:options'].local = false
}
},
onPrepare: onBrowserStackPrepare,
onComplete: onBrowserStackComplete
} as WebdriverIO.Config
}
+52
ファイルの表示
@@ -0,0 +1,52 @@
import { afterLocalSuite, beforeLocalSuite, beforeLocalSession } from './src/utils'
import { config as mainConfig } from './wdio.main.conf'
const prefs = {
'intl.accept_languages': 'en'
}
// Chrome headless does not support prefs
process.env.LANG = 'en'
// https://github.com/mozilla/geckodriver/issues/1354#issuecomment-479456411
process.env.MOZ_HEADLESS_WIDTH = '1280'
process.env.MOZ_HEADLESS_HEIGHT = '1024'
const windowSizeArg = `--window-size=${process.env.MOZ_HEADLESS_WIDTH},${process.env.MOZ_HEADLESS_HEIGHT}`
module.exports = {
config: {
...mainConfig,
runner: 'local',
maxInstances: 1,
specFileRetries: 0,
capabilities: [
{
'browserName': 'chrome',
'acceptInsecureCerts': true,
'goog:chromeOptions': {
args: [ '--headless', '--disable-gpu', windowSizeArg ],
prefs
}
},
{
'browserName': 'firefox',
'moz:firefoxOptions': {
binary: '/usr/bin/firefox-developer-edition',
args: [ '--headless', windowSizeArg ],
prefs
}
}
],
services: [ 'shared-store' ],
beforeSession: beforeLocalSession,
beforeSuite: beforeLocalSuite,
afterSuite: afterLocalSuite
} as WebdriverIO.Config
}
+47
ファイルの表示
@@ -0,0 +1,47 @@
import { afterLocalSuite, beforeLocalSession, beforeLocalSuite } from './src/utils'
import { config as mainConfig } from './wdio.main.conf'
const prefs = { 'intl.accept_languages': 'en' }
process.env.LANG = 'en'
// https://github.com/mozilla/geckodriver/issues/1354#issuecomment-479456411
process.env.MOZ_HEADLESS_WIDTH = '1280'
process.env.MOZ_HEADLESS_HEIGHT = '1024'
const windowSizeArg = `--window-size=${process.env.MOZ_HEADLESS_WIDTH},${process.env.MOZ_HEADLESS_HEIGHT}`
module.exports = {
config: {
...mainConfig,
runner: 'local',
maxInstancesPerCapability: 1,
capabilities: [
{
'browserName': 'chrome',
'goog:chromeOptions': {
binary: '/usr/bin/google-chrome-stable',
args: [ '--headless', '--disable-gpu', windowSizeArg ],
prefs
}
},
{
'browserName': 'firefox',
'moz:firefoxOptions': {
binary: '/usr/bin/firefox-developer-edition',
args: [ '--headless', windowSizeArg ],
prefs
}
}
],
services: [ 'shared-store' ],
beforeSession: beforeLocalSession,
beforeSuite: beforeLocalSuite,
afterSuite: afterLocalSuite
} as WebdriverIO.Config
}
+116
ファイルの表示
@@ -0,0 +1,116 @@
export const config = {
//
// ====================
// Runner Configuration
// ====================
//
//
// ==================
// Specify Test Files
// ==================
// Define which test specs should run. The pattern is relative to the directory
// from which `wdio` was called.
//
// The specs are defined as an array of spec files (optionally using wildcards
// that will be expanded). The test for each spec file will be run in a separate
// worker process. In order to have a group of spec files run in the same worker
// process simply enclose them in an array within the specs array.
//
// If you are calling `wdio` from an NPM script (see https://docs.npmjs.com/cli/run-script),
// then the current working directory is where your `package.json` resides, so `wdio`
// will be called from there.
//
specs: [
'./src/suites-all/*.e2e-spec.ts',
'./src/suites-local/*.e2e-spec.ts'
],
// Patterns to exclude.
exclude: [
// 'path/to/excluded/files'
],
//
// ===================
// Test Configurations
// ===================
// Define all options that are relevant for the WebdriverIO instance here
//
// Level of logging verbosity: trace | debug | info | warn | error | silent
logLevel: 'info',
//
// Set specific log levels per logger
// loggers:
// - webdriver, webdriverio
// - @wdio/browserstack-service, @wdio/devtools-service, @wdio/sauce-service
// - @wdio/mocha-framework, @wdio/jasmine-framework
// - @wdio/local-runner
// - @wdio/sumologic-reporter
// - @wdio/cli, @wdio/config, @wdio/utils
// Level of logging verbosity: trace | debug | info | warn | error | silent
// logLevels: {
// webdriver: 'info',
// '@wdio/appium-service': 'info'
// },
//
// If you only want to run your tests until a specific amount of tests have failed use
// bail (default is 0 - don't bail, run all tests).
bail: 0,
//
// Set a base URL in order to shorten url command calls. If your `url` parameter starts
// with `/`, the base url gets prepended, not including the path portion of your baseUrl.
// If your `url` parameter starts without a scheme or `/` (like `some/path`), the base url
// gets prepended directly.
baseUrl: 'http://127.0.0.1:9001',
//
// Default timeout for all waitFor* commands.
waitforTimeout: 5000,
//
// Default timeout in milliseconds for request
// if browser driver or grid doesn't send response
connectionRetryTimeout: 120000,
//
// Default request retries count
connectionRetryCount: 3,
// Framework you want to run your specs with.
// The following are supported: Mocha, Jasmine, and Cucumber
// see also: https://webdriver.io/docs/frameworks
//
// Make sure you have the wdio adapter package for the specific framework installed
// before running any tests.
framework: 'mocha',
//
// The number of times to retry the entire specfile when it fails as a whole
specFileRetries: 2,
//
// Delay in seconds between the spec file retry attempts
// specFileRetriesDelay: 0,
//
// Whether or not retried specfiles should be retried immediately or deferred to the end of the queue
// specFileRetriesDeferred: false,
//
// Test reporter for stdout.
// The only one supported by default is 'dot'
// see also: https://webdriver.io/docs/dot-reporter
reporters: [ 'spec' ],
//
// Options to be passed to Mocha.
// See the full list at http://mochajs.org/
mochaOpts: {
ui: 'bdd',
timeout: 60000,
bail: true
},
autoCompileOpts: {
autoCompile: true,
tsNodeOpts: {
project: require('path').join(__dirname, './tsconfig.json')
}
},
before: function () {
require('./src/commands/upload')
}
} as Partial<WebdriverIO.Config>
+127
ファイルの表示
@@ -0,0 +1,127 @@
{
"name": "peertube-client",
"version": "6.2.0-rc.1",
"private": true,
"license": "AGPL-3.0",
"author": {
"name": "Chocobozzz",
"email": "chocobozzz@framasoft.org",
"url": "http://github.com/Chocobozzz"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Chocobozzz/PeerTube.git"
},
"scripts": {
"lint": "npm run lint-ts && npm run lint-scss",
"lint-ts": "eslint --cache --ext .ts src/standalone/**/*.ts && npm run ng lint",
"lint-scss": "stylelint 'src/**/*.scss'",
"eslint": "eslint",
"ng": "ng",
"webdriver-manager": "webdriver-manager",
"ngx-extractor": "ngx-extractor",
"stylelint": "stylelint"
},
"browser": {
"net": false,
"stream": false,
"os": false,
"util": false
},
"workspaces": [
"../packages/*"
],
"typings": "*.d.ts",
"devDependencies": {
"@angular-devkit/build-angular": "^18.0.6",
"@angular-eslint/builder": "^18.0.1",
"@angular-eslint/eslint-plugin": "^18.0.1",
"@angular-eslint/eslint-plugin-template": "^18.0.1",
"@angular-eslint/schematics": "^18.0.1",
"@angular-eslint/template-parser": "^18.0.1",
"@angular/animations": "^18.0.4",
"@angular/build": "^18.0.5",
"@angular/cdk": "^18.0.4",
"@angular/cli": "^18.0.5",
"@angular/common": "^18.0.4",
"@angular/compiler": "^18.0.4",
"@angular/compiler-cli": "^18.0.4",
"@angular/core": "^18.0.4",
"@angular/forms": "^18.0.4",
"@angular/localize": "^18.0.4",
"@angular/platform-browser": "^18.0.4",
"@angular/platform-browser-dynamic": "^18.0.4",
"@angular/router": "^18.0.4",
"@angular/service-worker": "^18.0.4",
"@formatjs/intl-locale": "^4.0.0",
"@formatjs/intl-pluralrules": "^5.2.2",
"@ng-bootstrap/ng-bootstrap": "^17.0.0",
"@ng-select/ng-select": "^13.3.0",
"@ngx-loading-bar/core": "^6.0.0",
"@ngx-loading-bar/http-client": "^6.0.0",
"@ngx-loading-bar/router": "^6.0.0",
"@peertube/maildev": "^1.2.0",
"@peertube/p2p-media-loader-core": "^1.0.20",
"@peertube/p2p-media-loader-hlsjs": "^1.0.20",
"@peertube/xliffmerge": "^2.0.3",
"@popperjs/core": "^2.11.5",
"@types/chart.js": "^2.9.37",
"@types/core-js": "^2.5.2",
"@types/debug": "^4.1.5",
"@types/jschannel": "^1.0.0",
"@types/linkifyjs": "^2.1.2",
"@types/lodash-es": "^4.17.0",
"@types/markdown-it": "^14.1.1",
"@types/node": "^18.13.0",
"@types/qrcode": "^1.5.5",
"@types/sanitize-html": "2.11.0",
"@types/sha.js": "^2.4.0",
"@types/video.js": "^7.3.40",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@wdio/browserstack-service": "^8.10.5",
"@wdio/cli": "^8.10.5",
"@wdio/local-runner": "^8.10.5",
"@wdio/mocha-framework": "^8.10.4",
"@wdio/shared-store-service": "^8.10.5",
"@wdio/spec-reporter": "^8.10.5",
"angularx-qrcode": "17.0.1",
"bootstrap": "^5.1.3",
"buffer": "^6.0.3",
"chart.js": "^4.3.0",
"chartjs-plugin-zoom": "~2.0.1",
"core-js": "^3.22.8",
"debug": "^4.3.1",
"eslint": "^8.28.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-jsdoc": "^48.1.0",
"eslint-plugin-prefer-arrow": "latest",
"expect-webdriverio": "^4.2.3",
"hls.js": "~1.5.11",
"intl-messageformat": "^10.1.0",
"jschannel": "^1.0.2",
"linkify-html": "^4.0.2",
"linkifyjs": "^4.0.2",
"lodash-es": "^4.17.4",
"markdown-it": "14.1.0",
"markdown-it-emoji": "^3.0.0",
"ngx-uploadx": "^6.1.0",
"primeng": "^17.3.1",
"rxjs": "^7.3.0",
"sanitize-html": "^2.1.2",
"sha.js": "^2.4.11",
"socket.io-client": "^4.5.4",
"stylelint": "^16.2.1",
"stylelint-config-sass-guidelines": "^11.0.0",
"tinykeys": "^2.1.0",
"ts-node": "^10.9.2",
"tslib": "^2.4.0",
"typescript": "~5.4.5",
"video.js": "^7.19.2",
"vite": "^5.3.1",
"vite-plugin-checker": "^0.6.4",
"vite-plugin-node-polyfills": "^0.22.0",
"zone.js": "~0.14.2"
},
"dependencies": {}
}
+35
ファイルの表示
@@ -0,0 +1,35 @@
{
"/api": {
"target": "http://127.0.0.1:9000",
"secure": false
},
"/plugins": {
"target": "http://127.0.0.1:9000",
"secure": false
},
"/themes": {
"target": "http://127.0.0.1:9000",
"secure": false
},
"/static": {
"target": "http://127.0.0.1:9000",
"secure": false
},
"/lazy-static": {
"target": "http://127.0.0.1:9000",
"secure": false
},
"/socket.io": {
"target": "ws://127.0.0.1:9000",
"secure": false,
"ws": true
},
"/client/assets": {
"target": "http://127.0.0.1:9000",
"secure": false
},
"/client/locales": {
"target": "http://127.0.0.1:9000",
"secure": false
}
}
+30
ファイルの表示
@@ -0,0 +1,30 @@
<div class="margin-content mt-4">
<div class="row">
<h1 class="visually-hidden" i18n>Follows</h1>
<div class="col-xl-6 col-md-12">
<h2 i18n class="fs-5-5 mb-4 fw-semibold">Followers of {{ instanceName }} ({{ followersPagination.totalItems }})</h2>
<div i18n class="no-results" *ngIf="followersPagination.totalItems === 0">{{ instanceName }} does not have followers.</div>
<a *ngFor="let follower of followers" [href]="follower.url" target="_blank" rel="noopener noreferrer">
{{ follower.name }}
</a>
<button i18n class="peertube-button-link grey-button mt-1" *ngIf="!loadedAllFollowers && canLoadMoreFollowers()" (click)="loadAllFollowers()">Show full list</button>
</div>
<div class="col-xl-6 col-md-12">
<h2 i18n class="fs-5-5 mb-4 fw-semibold">Subscriptions of {{ instanceName }} ({{ followingsPagination.totalItems }})</h2>
<div i18n class="no-results" *ngIf="followingsPagination.totalItems === 0">{{ instanceName }} does not have subscriptions.</div>
<a *ngFor="let following of followings" [href]="following.url" target="_blank" rel="noopener noreferrer">
{{ following.name }}
</a>
<button i18n class="peertube-button-link grey-button mt-1" *ngIf="!loadedAllFollowings && canLoadMoreFollowings()" (click)="loadAllFollowings()">Show full list</button>
</div>
</div>
</div>
+13
ファイルの表示
@@ -0,0 +1,13 @@
@use '_variables' as *;
@use '_mixins' as *;
a {
display: block;
width: fit-content;
margin-top: 3px;
}
.no-results {
justify-content: flex-start;
align-items: flex-start;
}
+145
ファイルの表示
@@ -0,0 +1,145 @@
import { SortMeta } from 'primeng/api'
import { Component, OnInit } from '@angular/core'
import { ComponentPagination, hasMoreItems, Notifier, RestService, ServerService } from '@app/core'
import { Actor } from '@peertube/peertube-models'
import { NgIf, NgFor } from '@angular/common'
import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service'
@Component({
selector: 'my-about-follows',
templateUrl: './about-follows.component.html',
styleUrls: [ './about-follows.component.scss' ],
standalone: true,
imports: [ NgIf, NgFor ]
})
export class AboutFollowsComponent implements OnInit {
instanceName: string
followers: { name: string, url: string }[] = []
followings: { name: string, url: string }[] = []
loadedAllFollowers = false
loadedAllFollowings = false
followersPagination: ComponentPagination = {
currentPage: 1,
itemsPerPage: 20,
totalItems: 0
}
followingsPagination: ComponentPagination = {
currentPage: 1,
itemsPerPage: 20,
totalItems: 0
}
sort: SortMeta = {
field: 'createdAt',
order: -1
}
constructor (
private server: ServerService,
private restService: RestService,
private notifier: Notifier,
private followService: InstanceFollowService
) { }
ngOnInit () {
this.loadMoreFollowers()
this.loadMoreFollowings()
this.instanceName = this.server.getHTMLConfig().instance.name
}
loadAllFollowings () {
if (this.loadedAllFollowings) return
this.loadedAllFollowings = true
this.followingsPagination.itemsPerPage = 100
this.loadMoreFollowings(true)
while (hasMoreItems(this.followingsPagination)) {
this.followingsPagination.currentPage += 1
this.loadMoreFollowings()
}
}
loadAllFollowers () {
if (this.loadedAllFollowers) return
this.loadedAllFollowers = true
this.followersPagination.itemsPerPage = 100
this.loadMoreFollowers(true)
while (hasMoreItems(this.followersPagination)) {
this.followersPagination.currentPage += 1
this.loadMoreFollowers()
}
}
buildLink (host: string) {
return window.location.protocol + '//' + host
}
canLoadMoreFollowers () {
return this.loadedAllFollowers || this.followersPagination.totalItems > this.followersPagination.itemsPerPage
}
canLoadMoreFollowings () {
return this.loadedAllFollowings || this.followingsPagination.totalItems > this.followingsPagination.itemsPerPage
}
private loadMoreFollowers (reset = false) {
const pagination = this.restService.componentToRestPagination(this.followersPagination)
this.followService.getFollowers({ pagination, sort: this.sort, state: 'accepted' })
.subscribe({
next: resultList => {
if (reset) this.followers = []
const newFollowers = resultList.data.map(r => this.formatFollow(r.follower))
this.followers = this.followers.concat(newFollowers)
this.followersPagination.totalItems = resultList.total
},
error: err => this.notifier.error(err.message)
})
}
private loadMoreFollowings (reset = false) {
const pagination = this.restService.componentToRestPagination(this.followingsPagination)
this.followService.getFollowing({ pagination, sort: this.sort, state: 'accepted' })
.subscribe({
next: resultList => {
if (reset) this.followings = []
const newFollowings = resultList.data.map(r => this.formatFollow(r.following))
this.followings = this.followings.concat(newFollowings)
this.followingsPagination.totalItems = resultList.total
},
error: err => this.notifier.error(err.message)
})
}
private formatFollow (actor: Actor) {
return {
// Instance follow, only display host
name: actor.name === 'peertube'
? actor.host
: actor.name + '@' + actor.host,
url: actor.url
}
}
}
+233
ファイルの表示
@@ -0,0 +1,233 @@
<div class="banner" *ngIf="instanceBannerUrl">
<img [src]="instanceBannerUrl" alt="Instance banner">
</div>
<div class="margin-content mt-4">
<div class="row ">
<div class="col-md-12 col-xl-6">
<div class="d-flex justify-content-between">
<h1 i18n class="fw-semibold fs-5">About {{ instanceName }}</h1>
<a routerLink="/about/contact" i18n *ngIf="isContactFormEnabled" class="peertube-button-link orange-button h-100 d-flex align-items-center">Contact us</a>
</div>
<div class="mb-4" *ngIf="categories.length !== 0 || languages.length !== 0">
<span *ngFor="let category of categories" class="pt-badge badge-primary">{{ category }}</span>
<span *ngFor="let language of languages" class="pt-badge badge-secondary">{{ language }}</span>
</div>
<div class="mt-2">
<div class="block">{{ shortDescription }}</div>
<div i18n *ngIf="isNSFW" class="block mt-4 fw-semibold">This instance is dedicated to sensitive/NSFW content.</div>
</div>
<div class="anchor" id="administrators-and-sustainability"></div>
<a
*ngIf="aboutHTML.administrator || aboutHTML.maintenanceLifetime || aboutHTML.businessModel"
class="anchor-link"
routerLink="/about/instance"
fragment="administrators-and-sustainability"
#anchorLink
(click)="onClickCopyLink(anchorLink)"
>
<h2 i18n class="middle-title">
ADMINISTRATORS & SUSTAINABILITY
</h2>
</a>
<div class="block administrator" *ngIf="aboutHTML.administrator">
<div class="anchor" id="administrators"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="administrators"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Who we are</h3>
</a>
<div [innerHTML]="aboutHTML.administrator"></div>
</div>
<div class="block creation-reason" *ngIf="aboutHTML.creationReason">
<div class="anchor" id="creation-reason"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="creation-reason"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Why we created this instance</h3>
</a>
<div [innerHTML]="aboutHTML.creationReason"></div>
</div>
<div class="block maintenance-lifetime" *ngIf="aboutHTML.maintenanceLifetime">
<div class="anchor" id="maintenance-lifetime"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="maintenance-lifetime"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">How long we plan to maintain this instance</h3>
</a>
<div [innerHTML]="aboutHTML.maintenanceLifetime"></div>
</div>
<div class="block business-model" *ngIf="aboutHTML.businessModel">
<div class="anchor" id="business-model"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="business-model"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">How we will pay for keeping our instance running</h3>
</a>
<div [innerHTML]="aboutHTML.businessModel"></div>
</div>
<div class="anchor" id="information"></div>
<a
*ngIf="descriptionElement"
class="anchor-link"
routerLink="/about/instance"
fragment="information"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h2 i18n class="middle-title">
INFORMATION
</h2>
</a>
<div class="block description">
<div class="anchor" id="description"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="description"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Description</h3>
</a>
<my-custom-markup-container [content]="descriptionElement"></my-custom-markup-container>
</div>
<div myPluginSelector pluginSelectorId="about-instance-moderation">
<div class="anchor" id="moderation"></div>
<a
*ngIf="aboutHTML.moderationInformation || aboutHTML.codeOfConduct || aboutHTML.terms"
class="anchor-link"
routerLink="/about/instance"
fragment="moderation"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h2 i18n class="middle-title">
MODERATION
</h2>
</a>
<div class="block moderation-information" *ngIf="aboutHTML.moderationInformation">
<div class="anchor" id="moderation-information"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="moderation-information"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Moderation information</h3>
</a>
<div [innerHTML]="aboutHTML.moderationInformation"></div>
</div>
<div class="block code-of-conduct" *ngIf="aboutHTML.codeOfConduct">
<div class="anchor" id="code-of-conduct"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="code-of-conduct"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Code of conduct</h3>
</a>
<div [innerHTML]="aboutHTML.codeOfConduct"></div>
</div>
<div class="block terms">
<div class="anchor" id="terms"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="terms"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Terms</h3>
</a>
<div [innerHTML]="aboutHTML.terms"></div>
</div>
</div>
<div myPluginSelector pluginSelectorId="about-instance-other-information">
<div class="anchor" id="other-information"></div>
<a
*ngIf="aboutHTML.hardwareInformation"
class="anchor-link"
routerLink="/about/instance"
fragment="other-information"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h2 i18n class="middle-title">
OTHER INFORMATION
</h2>
</a>
<div class="block hardware-information" *ngIf="aboutHTML.hardwareInformation">
<div class="anchor" id="hardware-information"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="hardware-information"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Hardware information</h3>
</a>
<div [innerHTML]="aboutHTML.hardwareInformation"></div>
</div>
</div>
</div>
<div class="col-md-12 col-xl-6" myPluginSelector pluginSelectorId="about-instance-features">
<h2 class="visually-hidden" i18n>FEATURES</h2>
<my-instance-features-table></my-instance-features-table>
</div>
<div class="col" myPluginSelector pluginSelectorId="about-instance-statistics">
<div class="anchor" id="statistics"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="statistics"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h2 i18n class="middle-title">STATISTICS</h2>
</a>
<my-instance-statistics [serverStats]="serverStats"></my-instance-statistics>
</div>
</div>
</div>
<my-contact-admin-modal #contactAdminModal></my-contact-admin-modal>
+50
ファイルの表示
@@ -0,0 +1,50 @@
@use '_variables' as *;
@use '_mixins' as *;
.pt-badge {
@include margin-right(5px);
}
.section-title {
font-weight: $font-semibold;
margin-bottom: 5px;
display: flex;
align-items: center;
font-size: 1rem;
}
.middle-title {
@include in-content-small-title;
@include margin-bottom(1.5rem);
margin-top: 0;
}
.block {
@include margin-bottom(4.5rem);
}
.anchor-link {
@include disable-outline;
position: relative;
&:hover,
&:active {
&::after {
@include margin-left(0.2em);
content: '#';
display: inline-block;
}
}
.middle-title,
.section-title {
display: inline-block;
}
.section-title {
color: var(--mainForegroundColor);
}
}
+115
ファイルの表示
@@ -0,0 +1,115 @@
import { NgFor, NgIf, ViewportScroller } from '@angular/common'
import { AfterViewChecked, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, RouterLink } from '@angular/router'
import { Notifier, ServerService } from '@app/core'
import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
import { maxBy } from '@peertube/peertube-core-utils'
import { HTMLServerConfig, ServerStats } from '@peertube/peertube-models'
import { copyToClipboard } from '@root-helpers/utils'
import { CustomMarkupContainerComponent } from '../../shared/shared-custom-markup/custom-markup-container.component'
import { InstanceFeaturesTableComponent } from '../../shared/shared-instance/instance-features-table.component'
import { PluginSelectorDirective } from '../../shared/shared-main/plugins/plugin-selector.directive'
import { ResolverData } from './about-instance.resolver'
import { ContactAdminModalComponent } from './contact-admin-modal.component'
import { InstanceStatisticsComponent } from './instance-statistics.component'
@Component({
selector: 'my-about-instance',
templateUrl: './about-instance.component.html',
styleUrls: [ './about-instance.component.scss' ],
standalone: true,
imports: [
NgIf,
RouterLink,
NgFor,
CustomMarkupContainerComponent,
PluginSelectorDirective,
InstanceFeaturesTableComponent,
InstanceStatisticsComponent,
ContactAdminModalComponent
]
})
export class AboutInstanceComponent implements OnInit, AfterViewChecked {
@ViewChild('descriptionWrapper') descriptionWrapper: ElementRef<HTMLInputElement>
@ViewChild('contactAdminModal', { static: true }) contactAdminModal: ContactAdminModalComponent
aboutHTML: AboutHTML
descriptionElement: HTMLDivElement
instanceBannerUrl: string
languages: string[] = []
categories: string[] = []
shortDescription = ''
initialized = false
serverStats: ServerStats
private serverConfig: HTMLServerConfig
private lastScrollHash: string
constructor (
private viewportScroller: ViewportScroller,
private route: ActivatedRoute,
private notifier: Notifier,
private serverService: ServerService
) {}
get instanceName () {
return this.serverConfig.instance.name
}
get isContactFormEnabled () {
return this.serverConfig.email.enabled && this.serverConfig.contactForm.enabled
}
get isNSFW () {
return this.serverConfig.instance.isNSFW
}
ngOnInit () {
const { about, languages, categories, aboutHTML, descriptionElement, serverStats }: ResolverData = this.route.snapshot.data.instanceData
this.serverStats = serverStats
this.aboutHTML = aboutHTML
this.descriptionElement = descriptionElement
this.languages = languages
this.categories = categories
this.shortDescription = about.instance.shortDescription
this.instanceBannerUrl = about.instance.banners.length !== 0
? maxBy(about.instance.banners, 'width').path
: undefined
this.serverConfig = this.serverService.getHTMLConfig()
this.route.data.subscribe(data => {
if (!data?.isContact) return
const prefill = this.route.snapshot.queryParams
this.contactAdminModal.show(prefill)
})
this.initialized = true
}
ngAfterViewChecked () {
if (this.initialized && window.location.hash && window.location.hash !== this.lastScrollHash) {
this.viewportScroller.scrollToAnchor(window.location.hash.replace('#', ''))
this.lastScrollHash = window.location.hash
}
}
onClickCopyLink (anchor: HTMLAnchorElement) {
const link = anchor.href
copyToClipboard(link)
this.notifier.success(link, $localize`Link copied`)
}
}
+66
ファイルの表示
@@ -0,0 +1,66 @@
import { forkJoin, Observable } from 'rxjs'
import { map, switchMap } from 'rxjs/operators'
import { Injectable } from '@angular/core'
import { ServerService } from '@app/core'
import { About, ServerStats } from '@peertube/peertube-models'
import { AboutHTML, InstanceService } from '@app/shared/shared-main/instance/instance.service'
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
export type ResolverData = {
serverStats: ServerStats
about: About
languages: string[]
categories: string[]
aboutHTML: AboutHTML
descriptionElement: HTMLDivElement
}
@Injectable()
export class AboutInstanceResolver {
constructor (
private instanceService: InstanceService,
private customMarkupService: CustomMarkupService,
private serverService: ServerService
) {}
resolve (): Observable<ResolverData> {
return forkJoin([
this.buildInstanceAboutObservable(),
this.buildInstanceStatsObservable()
]).pipe(
map(([
[ about, languages, categories, aboutHTML, { rootElement } ],
serverStats
]) => {
return {
serverStats,
about,
languages,
categories,
aboutHTML,
descriptionElement: rootElement
}
})
)
}
private buildInstanceAboutObservable () {
return this.instanceService.getAbout()
.pipe(
switchMap(about => {
return forkJoin([
Promise.resolve(about),
this.instanceService.buildTranslatedLanguages(about),
this.instanceService.buildTranslatedCategories(about),
this.instanceService.buildHtml(about),
this.customMarkupService.buildElement(about.instance.description)
])
})
)
}
private buildInstanceStatsObservable () {
return this.serverService.getServerStats()
}
}
+64
ファイルの表示
@@ -0,0 +1,64 @@
<ng-template #modal>
<div class="modal-header">
<h1 i18n class="modal-title">Contact the administrator(s)<p class="modal-subtitle">{{ instanceName }}</p></h1>
<button class="border-0 p-0" title="Close this modal" i18n-title (click)="hide()">
<my-global-icon iconName="cross"></my-global-icon>
</button>
</div>
<div class="modal-body">
<form *ngIf="isContactFormEnabled()" novalidate [formGroup]="form" (ngSubmit)="sendForm()">
<div class="form-group">
<label i18n for="fromName">Your name</label>
<input
type="text" id="fromName" class="form-control"
formControlName="fromName" [ngClass]="{ 'input-error': formErrors.fromName }"
autocomplete="name"
>
<div *ngIf="formErrors.fromName" class="form-error" role="alert">{{ formErrors.fromName }}</div>
</div>
<div class="form-group">
<label i18n for="fromEmail">Your email</label>
<input
type="text" id="fromEmail" class="form-control"
formControlName="fromEmail" [ngClass]="{ 'input-error': formErrors['fromEmail'] }"
i18n-placeholder placeholder="Example: john@example.com" autocomplete="email"
>
<div *ngIf="formErrors.fromEmail" class="form-error" role="alert">{{ formErrors.fromEmail }}</div>
</div>
<div class="form-group">
<label i18n for="subject">Subject</label>
<input
type="text" id="subject" class="form-control"
formControlName="subject" [ngClass]="{ 'input-error': formErrors['subject'] }"
>
<div *ngIf="formErrors.subject" class="form-error" role="alert">{{ formErrors.subject }}</div>
</div>
<div class="form-group">
<label i18n for="body">Your message</label>
<textarea id="body" formControlName="body" class="form-control" [ngClass]="{ 'input-error': formErrors['body'] }">
</textarea>
<div *ngIf="formErrors.body" class="form-error" role="alert">{{ formErrors.body }}</div>
</div>
<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
<div class="form-group inputs">
<input
type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
(click)="hide()" (key.enter)="hide()"
>
<input type="submit" i18n-value value="Submit" class="peertube-button orange-button" [disabled]="!form.valid" />
</div>
</form>
<div *ngIf="!isContactFormEnabled()" class="alert alert-danger" i18n>The contact form is not enabled on this instance.</div>
</div>
</ng-template>
+21
ファイルの表示
@@ -0,0 +1,21 @@
@use '_variables' as *;
@use '_mixins' as *;
.modal-subtitle {
line-height: 1rem;
margin-bottom: 0;
}
.modal-body {
text-align: left;
}
input[type=text] {
@include peertube-input-text(340px);
display: block;
}
textarea {
@include peertube-textarea(100%, 200px);
}
+115
ファイルの表示
@@ -0,0 +1,115 @@
import { Component, OnInit, ViewChild } from '@angular/core'
import { Router } from '@angular/router'
import { Notifier, ServerService } from '@app/core'
import {
BODY_VALIDATOR,
FROM_EMAIL_VALIDATOR,
FROM_NAME_VALIDATOR,
SUBJECT_VALIDATOR
} from '@app/shared/form-validators/instance-validators'
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
import { HTMLServerConfig, HttpStatusCode } from '@peertube/peertube-models'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgIf, NgClass } from '@angular/common'
import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component'
import { InstanceService } from '@app/shared/shared-main/instance/instance.service'
type Prefill = {
subject?: string
body?: string
}
@Component({
selector: 'my-contact-admin-modal',
templateUrl: './contact-admin-modal.component.html',
styleUrls: [ './contact-admin-modal.component.scss' ],
standalone: true,
imports: [ GlobalIconComponent, NgIf, FormsModule, ReactiveFormsModule, NgClass ]
})
export class ContactAdminModalComponent extends FormReactive implements OnInit {
@ViewChild('modal', { static: true }) modal: NgbModal
error: string
private openedModal: NgbModalRef
private serverConfig: HTMLServerConfig
constructor (
protected formReactiveService: FormReactiveService,
private router: Router,
private modalService: NgbModal,
private instanceService: InstanceService,
private serverService: ServerService,
private notifier: Notifier
) {
super()
}
get instanceName () {
return this.serverConfig.instance.name
}
ngOnInit () {
this.serverConfig = this.serverService.getHTMLConfig()
this.buildForm({
fromName: FROM_NAME_VALIDATOR,
fromEmail: FROM_EMAIL_VALIDATOR,
subject: SUBJECT_VALIDATOR,
body: BODY_VALIDATOR
})
}
isContactFormEnabled () {
return this.serverConfig.email.enabled && this.serverConfig.contactForm.enabled
}
show (prefill: Prefill = {}) {
this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
this.openedModal.shown.subscribe(() => this.prefillForm(prefill))
this.openedModal.result.finally(() => this.router.navigateByUrl('/about/instance'))
}
hide () {
this.form.reset()
this.error = undefined
this.openedModal.close()
this.openedModal = null
}
sendForm () {
const fromName = this.form.value['fromName']
const fromEmail = this.form.value['fromEmail']
const subject = this.form.value['subject']
const body = this.form.value['body']
this.instanceService.contactAdministrator(fromEmail, fromName, subject, body)
.subscribe({
next: () => {
this.notifier.success($localize`Your message has been sent.`)
this.hide()
},
error: err => {
this.error = err.status === HttpStatusCode.FORBIDDEN_403
? $localize`You already sent this form recently`
: err.message
}
})
}
private prefillForm (prefill: Prefill) {
if (prefill.subject) {
this.form.get('subject').setValue(prefill.subject)
}
if (prefill.body) {
this.form.get('body').setValue(prefill.body)
}
}
}
+101
ファイルの表示
@@ -0,0 +1,101 @@
<p i18n *ngIf="null === serverStats">Loading instance statistics...</p>
<section *ngIf="null !== serverStats">
<h3 i18n>By users on this instance</h3>
<div class="row">
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalUsers | number }}</p>
<p class="stat-label" i18n>users</p>
</div>
<my-global-icon iconName="user"></my-global-icon>
</div>
</div>
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalLocalVideos | number }}</p>
<p class="stat-label" i18n>videos</p>
</div>
<my-global-icon iconName="film"></my-global-icon>
</div>
</div>
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalLocalVideoViews | number }}</p>
<p class="stat-label" i18n>views</p>
</div>
<my-global-icon iconName="eye-open"></my-global-icon>
</div>
</div>
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalLocalVideoComments | number }}</p>
<p class="stat-label" i18n>comments</p>
</div>
<my-global-icon iconName="message-circle"></my-global-icon>
</div>
</div>
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalLocalVideoFilesSize | bytes:1 }}</p>
<p class="stat-label" i18n>hosted video</p>
</div>
<my-global-icon iconName="home"></my-global-icon>
</div>
</div>
</div>
<h3 i18n>In this instance federation</h3>
<div class="row">
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalVideos | number }}</p>
<p class="stat-label" i18n>videos</p>
</div>
<my-global-icon iconName="film"></my-global-icon>
</div>
</div>
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalVideoComments | number }}</p>
<p class="stat-label" i18n>comments</p>
</div>
<my-global-icon iconName="message-circle"></my-global-icon>
</div>
</div>
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalInstanceFollowers | number }}</p>
<p class="stat-label" i18n>followers</p>
</div>
<my-global-icon iconName="share"></my-global-icon>
</div>
</div>
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalInstanceFollowing | number }}</p>
<p class="stat-label" i18n>following</p>
</div>
<my-global-icon iconName="globe"></my-global-icon>
</div>
</div>
</div>
</section>
+39
ファイルの表示
@@ -0,0 +1,39 @@
@use '_variables' as *;
@use '_mixins' as *;
h3 {
font-size: 1.25rem;
}
.stat {
text-align: center;
margin-bottom: 1em;
overflow: hidden;
.stat-value {
font-size: 2.25em;
line-height: 1em;
margin: 0;
}
.stat-label {
font-size: 1.15em;
margin: 0;
}
.card-body {
z-index: 2;
}
}
my-global-icon {
opacity: 0.12;
position: absolute;
left: 16px;
top: -24px;
width: 110px;
&.icon-bottom {
top: 4px;
}
}
+16
ファイルの表示
@@ -0,0 +1,16 @@
import { Component, Input } from '@angular/core'
import { ServerStats } from '@peertube/peertube-models'
import { BytesPipe } from '../../shared/shared-main/angular/bytes.pipe'
import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component'
import { NgIf, DecimalPipe } from '@angular/common'
@Component({
selector: 'my-instance-statistics',
templateUrl: './instance-statistics.component.html',
styleUrls: [ './instance-statistics.component.scss' ],
standalone: true,
imports: [ NgIf, GlobalIconComponent, DecimalPipe, BytesPipe ]
})
export class InstanceStatisticsComponent {
@Input() serverStats: ServerStats
}
+159
ファイルの表示
@@ -0,0 +1,159 @@
<div class="margin-content mt-4">
<h1 i18n class="fs-3 text-center fw-semibold mb-3">
This website is powered by PeerTube
</h1>
<img class="d-block my-4 mx-auto" width="121px" height="147px" src="/client/assets/images/mascot/default.svg" alt="mascot"/>
<div class="text-center">
<p i18n>
PeerTube is a self-hosted ActivityPub-federated video streaming platform using P2P directly in your web browser.
</p>
<p i18n>
It is free and open-source software, under <a class="link-orange" href="https://github.com/Chocobozzz/PeerTube/blob/develop/LICENSE">AGPLv3
licence</a>.
</p>
<p i18n>
For more information, please visit <a class="link-orange" target="_blank" rel="noopener noreferrer" href="https://joinpeertube.org">joinpeertube.org</a>.
</p>
</div>
<div class="d-flex flex-wrap justify-content-center my-5">
<div class="card">
<div class="card-body">
<div class="card-title">
<a i18n class="link-orange" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/use/setup-account">Use PeerTube documentation</a>
</div>
<div i18n class="card-text">
Discover how to setup your account, what is a channel, how to create a playlist and more!
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="card-title">
<a i18n class="link-orange" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/use/third-party-application">PeerTube Applications</a>
</div>
<div i18n class="card-text">
Discover unofficial Android applications or browser addons!
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="card-title">
<a i18n class="link-orange" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/contribute/getting-started">Contribute on PeerTube</a>
</div>
<div i18n class="card-text">
Want to help to improve PeerTube? You can translate the web interface, give your feedback or directly contribute to the code!
</div>
</div>
</div>
</div>
<div class="d-flex flex-column">
<h2 class="mb-4 mt-5 text-center fs-5 fw-semibold">
<div class="anchor" id="privacy"></div> <!-- privacy anchor -->
<ng-container i18n>P2P & Privacy</ng-container>
</h2>
<p i18n>
PeerTube uses the BitTorrent protocol to share bandwidth between users by default to help lower the load on the server,
but ultimately leaves you the choice to switch back to regular streaming exclusively from the server of the video. What
follows applies only if you want to keep using the P2P mode of PeerTube.
</p>
<p i18n>
The main threat to your privacy induced by BitTorrent lies in your IP address being stored in the instance's BitTorrent
tracker as long as you download or watch the video.
</p>
<h3 i18n class="fs-5">What are the consequences?</h3>
<p i18n>
In theory, someone with enough technical skills could create a script that tracks which IP is downloading which video.
In practice, this is much more difficult because:
</p>
<ul>
<li i18n>
An HTTP request has to be sent on each tracker for each video to spy.
If we want to spy all PeerTube's videos, we have to send as many requests as there are videos (so potentially a lot)
</li>
<li i18n>
For each request sent, the tracker returns random peers at a limited number.
For instance, if there are 1000 peers in the swarm and the tracker sends only 20 peers for each request, there must be at least 50
requests sent to know every peer in the swarm
</li>
<li i18n>
Those requests have to be sent regularly to know who starts/stops watching a video. It is easy to detect that kind of behaviour
</li>
<li i18n>
If an IP address is stored in the tracker, it doesn't mean that the person behind the IP (if this person exists) has watched the
video
</li>
<li i18n>
The IP address is a vague information: usually, it regularly changes and can represent many persons or entities
</li>
<li i18n>
Web peers are not publicly accessible: because we use the websocket transport, the protocol is different from classic BitTorrent tracker.
When you are in a web browser, you send a signal containing your IP address to the tracker that will randomly choose other peers
to forward the information to.
See <a class="link-orange" href="https://github.com/yciabaud/webtorrent/blob/beps/bep_webrtc.rst">this document</a> for more information
</li>
</ul>
<p i18n>
The worst-case scenario of an average person spying on their friends is quite unlikely.
There are much more effective ways to get that kind of information.
</p>
<h3 i18n class="p2p-privacy-title">How does PeerTube compare with YouTube?</h3>
<p i18n>
The threats to privacy with YouTube are different from PeerTube's.
In YouTube's case, the platform gathers a huge amount of your personal information (not only your IP) to analyze them and track you.
Moreover, YouTube is owned by Google/Alphabet, a company that tracks you across many websites (via AdSense or Google Analytics).
</p>
<h3 i18n class="p2p-privacy-title">What can I do to limit the exposure of my IP address?</h3>
<p i18n>
Your IP address is public so every time you consult a website, there is a number of actors (in addition to the final website) seeing
your IP in their connection logs: ISP/routers/trackers/CDN and more.
PeerTube is transparent about it: we warn you that if you want to keep your IP private, you must use a VPN or Tor Browser.
Thinking that removing P2P from PeerTube will give you back anonymity doesn't make sense.
</p>
<h3 i18n class="p2p-privacy-title">What will be done to mitigate this problem?</h3>
<p i18n>
PeerTube wants to deliver the best countermeasures possible, to give you more choice
and render attacks less likely. Here is what we put in place so far:
</p>
<ul>
<li i18n>We set a limit to the number of peers sent by the tracker</li>
<li i18n>We set a limit on the request frequency received by the tracker</li>
<li i18n>Allow instance admins to disable P2P from the administration interface</li>
</ul>
<p i18n>
Ultimately, remember you can always disable P2P by toggling it in the video player, or just by disabling
WebRTC in your browser.
</p>
</div>
</div>
+20
ファイルの表示
@@ -0,0 +1,20 @@
@use '_variables' as *;
@use '_mixins' as *;
.margin-content {
max-width: 1200px;
margin-inline-start: auto;
margin-inline-end: auto;
}
.card {
@include margin(2rem);
flex-basis: 300px;
}
.card-title {
font-size: 1.1rem;
text-align: center;
margin-bottom: 1rem;
}
+25
ファイルの表示
@@ -0,0 +1,25 @@
import { Component, AfterViewChecked } from '@angular/core'
import { ViewportScroller } from '@angular/common'
@Component({
selector: 'my-about-peertube',
templateUrl: './about-peertube.component.html',
styleUrls: [ './about-peertube.component.scss' ],
standalone: true
})
export class AboutPeertubeComponent implements AfterViewChecked {
private lastScrollHash: string
constructor (
private viewportScroller: ViewportScroller
) {}
ngAfterViewChecked () {
if (window.location.hash && window.location.hash !== this.lastScrollHash) {
this.viewportScroller.scrollToAnchor(window.location.hash.replace('#', ''))
this.lastScrollHash = window.location.hash
}
}
}
+13
ファイルの表示
@@ -0,0 +1,13 @@
<div>
<div class="sub-menu mb-0" [ngClass]="{ 'sub-menu-fixed': !isBroadcastMessageDisplayed }">
<a myPluginSelector pluginSelectorId="about-menu-instance" i18n routerLink="instance" routerLinkActive="active" class="sub-menu-entry">Instance</a>
<a myPluginSelector pluginSelectorId="about-menu-peertube" i18n routerLink="peertube" routerLinkActive="active" class="sub-menu-entry">PeerTube</a>
<a myPluginSelector pluginSelectorId="about-menu-network" i18n routerLink="follows" routerLinkActive="active" class="sub-menu-entry">Network</a>
</div>
<div [ngClass]="{ 'sub-menu-offset-content': !isBroadcastMessageDisplayed }">
<router-outlet></router-outlet>
</div>
</div>
+22
ファイルの表示
@@ -0,0 +1,22 @@
import { Component } from '@angular/core'
import { ScreenService } from '@app/core'
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'
import { PluginSelectorDirective } from '../shared/shared-main/plugins/plugin-selector.directive'
import { NgClass } from '@angular/common'
@Component({
selector: 'my-about',
templateUrl: './about.component.html',
standalone: true,
imports: [ NgClass, PluginSelectorDirective, RouterLink, RouterLinkActive, RouterOutlet ]
})
export class AboutComponent {
constructor (
private screenService: ScreenService
) { }
get isBroadcastMessageDisplayed () {
return this.screenService.isBroadcastMessageDisplayed
}
}
+72
ファイルの表示
@@ -0,0 +1,72 @@
import { Routes } from '@angular/router'
import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component'
import { AboutInstanceComponent } from '@app/+about/about-instance/about-instance.component'
import { AboutInstanceResolver } from '@app/+about/about-instance/about-instance.resolver'
import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertube.component'
import { AboutComponent } from './about.component'
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
import { DynamicElementService } from '@app/shared/shared-custom-markup/dynamic-element.service'
import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service'
export default [
{
path: '',
component: AboutComponent,
providers: [
AboutInstanceResolver,
InstanceFollowService,
CustomMarkupService,
DynamicElementService
],
children: [
{
path: '',
redirectTo: 'instance',
pathMatch: 'full'
},
{
path: 'instance',
component: AboutInstanceComponent,
data: {
meta: {
title: $localize`About this instance`
}
},
resolve: {
instanceData: AboutInstanceResolver
}
},
{
path: 'contact',
component: AboutInstanceComponent,
data: {
meta: {
title: $localize`Contact`
},
isContact: true
},
resolve: {
instanceData: AboutInstanceResolver
}
},
{
path: 'peertube',
component: AboutPeertubeComponent,
data: {
meta: {
title: $localize`About PeerTube`
}
}
},
{
path: 'follows',
component: AboutFollowsComponent,
data: {
meta: {
title: $localize`About this instance's network`
}
}
}
]
}
] satisfies Routes
@@ -0,0 +1,56 @@
<h1 class="visually-hidden" i18n>Video channels</h1>
<div class="margin-content">
<div class="no-results" i18n *ngIf="channelPagination.totalItems === 0">This account does not have channels.</div>
<div class="channels" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onChannelDataSubject.asObservable()">
<div class="channel" *ngFor="let videoChannel of videoChannels">
<div class="channel-avatar-row">
<my-actor-avatar
[actor]="videoChannel" actorType="channel"
[internalHref]="getVideoChannelLink(videoChannel)"
i18n-title
title="See this video channel"
size="75"
></my-actor-avatar>
<h2 class="fs-5 lh-1 fw-bold m-0">
<a [routerLink]="getVideoChannelLink(videoChannel)" i18n-title title="See this video channel">
{{ videoChannel.displayName }}
</a>
</h2>
<div class="actor-counters">
<div class="followers" i18n>{videoChannel.followersCount, plural, =0 {No subscribers} =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
<span class="videos-count" *ngIf="getTotalVideosOf(videoChannel) !== undefined" i18n>
{getTotalVideosOf(videoChannel), plural, =0 {No videos} =1 {1 video} other {{{ getTotalVideosOf(videoChannel) }} videos}}
</span>
</div>
<div class="description-html" [innerHTML]="getChannelDescription(videoChannel)"></div>
</div>
<my-subscribe-button [videoChannels]="[videoChannel]"></my-subscribe-button>
<a i18n class="button-show-channel peertube-button-link orange-button-inverted" [routerLink]="getVideoChannelLink(videoChannel)">Show this channel</a>
<div class="videos-overflow-workaround">
<div class="videos">
<div class="no-results h-auto" i18n *ngIf="getTotalVideosOf(videoChannel) === 0">This channel doesn't have any videos.</div>
<my-video-miniature
*ngFor="let video of getVideosOf(videoChannel)"
[video]="video" [user]="userMiniature" [displayVideoActions]="true" [displayOptions]="miniatureDisplayOptions"
></my-video-miniature>
<div *ngIf="getTotalVideosOf(videoChannel)" class="miniature-show-channel">
<a i18n [routerLink]="getVideoChannelLink(videoChannel)">SHOW THIS CHANNEL ></a>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,157 @@
@use 'sass:math';
@use '_variables' as *;
@use '_mixins' as *;
@use '_miniature' as *;
.margin-content {
@include grid-videos-miniature-margins;
}
.channel {
@include padding(1.75rem);
@include margin(2rem, 0);
max-width: $max-channels-width;
background-color: pvar(--channelBackgroundColor);
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
column-gap: 15px;
}
.channel-avatar-row {
grid-column: 1;
grid-row: 1;
display: grid;
grid-template-columns: auto auto 1fr;
grid-template-rows: auto 1fr;
my-actor-avatar {
@include margin-right(15px);
grid-column: 1;
grid-row: 1 / 3;
}
a {
@include peertube-word-wrap;
color: pvar(--mainForegroundColor);
}
h2 {
grid-row: 1;
grid-column: 2;
}
.actor-counters {
@include margin-left(15px);
@include actor-counters;
grid-row: 1;
grid-column: 3;
}
.description-html {
@include fade-text(50px, pvar(--channelBackgroundColor));
grid-column: 2 / 4;
grid-row: 2;
max-height: 80px;
}
}
my-subscribe-button {
grid-row: 1;
grid-column: 2;
}
.videos {
display: flex;
grid-column: 1 / 3;
grid-row: 2;
position: relative;
my-video-miniature {
@include margin-right(15px);
min-width: $video-thumbnail-medium-width;
max-width: $video-thumbnail-medium-width;
}
}
.videos-overflow-workaround {
@include margin-top(2rem);
overflow-x: hidden;
}
.miniature-show-channel {
height: 100%;
position: absolute;
right: 0;
background: linear-gradient(90deg, transparent 0, pvar(--channelBackgroundColor) 45px);
padding: (math.div($video-thumbnail-medium-height, 2) - 10px) 15px 0 60px;
z-index: z(miniature) + 1;
a {
color: pvar(--mainColor);
font-weight: $font-semibold;
}
}
.button-show-channel {
display: none;
}
@include on-small-main-col {
.channel-avatar-row {
grid-template-columns: auto auto auto 1fr;
.avatar-link {
grid-row: 1 / 4;
}
.actor-counters {
margin: 0;
font-size: 13px;
grid-row: 2;
grid-column: 2 / 4;
}
.description-html {
grid-row: 3;
font-size: 14px;
}
}
.show-channel a {
@include peertube-button-link;
@include orange-button-inverted;
}
.videos {
display: none;
}
my-subscribe-button,
.button-show-channel {
grid-column: 1 / 4;
grid-row: 3;
margin-top: 15px;
}
my-subscribe-button {
justify-self: start;
}
.button-show-channel {
display: block;
justify-self: end;
}
}
@@ -0,0 +1,165 @@
import { from, Subject, Subscription } from 'rxjs'
import { concatMap, map, switchMap, tap } from 'rxjs/operators'
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ComponentPagination, hasMoreItems, MarkdownService, User, UserService } from '@app/core'
import { SimpleMemoize } from '@app/helpers'
import { NSFWPolicyType, VideoSortField } from '@peertube/peertube-models'
import { MiniatureDisplayOptions, VideoMiniatureComponent } from '../../shared/shared-video-miniature/video-miniature.component'
import { SubscribeButtonComponent } from '../../shared/shared-user-subscription/subscribe-button.component'
import { RouterLink } from '@angular/router'
import { ActorAvatarComponent } from '../../shared/shared-actor-image/actor-avatar.component'
import { InfiniteScrollerDirective } from '../../shared/shared-main/angular/infinite-scroller.directive'
import { NgIf, NgFor } from '@angular/common'
import { AccountService } from '@app/shared/shared-main/account/account.service'
import { VideoChannelService } from '@app/shared/shared-main/video-channel/video-channel.service'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import { VideoChannel } from '@app/shared/shared-main/video-channel/video-channel.model'
import { Account } from '@app/shared/shared-main/account/account.model'
import { Video } from '@app/shared/shared-main/video/video.model'
@Component({
selector: 'my-account-video-channels',
templateUrl: './account-video-channels.component.html',
styleUrls: [ './account-video-channels.component.scss' ],
standalone: true,
imports: [ NgIf, InfiniteScrollerDirective, NgFor, ActorAvatarComponent, RouterLink, SubscribeButtonComponent, VideoMiniatureComponent ]
})
export class AccountVideoChannelsComponent implements OnInit, OnDestroy {
account: Account
videoChannels: VideoChannel[] = []
videos: { [id: number]: { total: number, videos: Video[] } } = {}
channelsDescriptionHTML: { [ id: number ]: string } = {}
channelPagination: ComponentPagination = {
currentPage: 1,
itemsPerPage: 2,
totalItems: null
}
videosPagination: ComponentPagination = {
currentPage: 1,
itemsPerPage: 5,
totalItems: null
}
videosSort: VideoSortField = '-publishedAt'
onChannelDataSubject = new Subject<any>()
userMiniature: User
nsfwPolicy: NSFWPolicyType
miniatureDisplayOptions: MiniatureDisplayOptions = {
date: true,
views: true,
by: false,
avatar: false,
privacyLabel: false,
privacyText: false,
state: false,
blacklistInfo: false
}
private accountSub: Subscription
constructor (
private accountService: AccountService,
private videoChannelService: VideoChannelService,
private videoService: VideoService,
private markdown: MarkdownService,
private userService: UserService
) { }
ngOnInit () {
// Parent get the account for us
this.accountSub = this.accountService.accountLoaded
.subscribe(account => {
this.account = account
this.videoChannels = []
this.loadMoreChannels()
})
this.userService.getAnonymousOrLoggedUser()
.subscribe(user => {
this.userMiniature = user
this.nsfwPolicy = user.nsfwPolicy
})
}
ngOnDestroy () {
if (this.accountSub) this.accountSub.unsubscribe()
}
loadMoreChannels () {
const options = {
account: this.account,
componentPagination: this.channelPagination,
sort: '-updatedAt'
}
this.videoChannelService.listAccountVideoChannels(options)
.pipe(
tap(res => {
this.channelPagination.totalItems = res.total
}),
switchMap(res => from(res.data)),
concatMap(videoChannel => {
const options = {
videoChannel,
videoPagination: this.videosPagination,
sort: this.videosSort,
nsfw: this.videoService.nsfwPolicyToParam(this.nsfwPolicy)
}
return this.videoService.getVideoChannelVideos(options)
.pipe(map(data => ({ videoChannel, videos: data.data, total: data.total })))
})
)
.subscribe(async ({ videoChannel, videos, total }) => {
this.channelsDescriptionHTML[videoChannel.id] = await this.markdown.textMarkdownToHTML({
markdown: videoChannel.description,
withEmoji: true,
withHtml: true
})
this.videoChannels.push(videoChannel)
this.videos[videoChannel.id] = { videos, total }
this.onChannelDataSubject.next([ videoChannel ])
})
}
getVideosOf (videoChannel: VideoChannel) {
const obj = this.videos[videoChannel.id]
if (!obj) return []
return obj.videos
}
getTotalVideosOf (videoChannel: VideoChannel) {
const obj = this.videos[videoChannel.id]
if (!obj) return undefined
return obj.total
}
getChannelDescription (videoChannel: VideoChannel) {
return this.channelsDescriptionHTML[videoChannel.id]
}
onNearOfBottom () {
if (!hasMoreItems(this.channelPagination)) return
this.channelPagination.currentPage += 1
this.loadMoreChannels()
}
@SimpleMemoize()
getVideoChannelLink (videoChannel: VideoChannel) {
return [ '/c', videoChannel.nameWithHost ]
}
}
+26
ファイルの表示
@@ -0,0 +1,26 @@
<my-videos-list
#videosList
*ngIf="account"
[title]="title"
displayTitle="false"
[getVideosObservableFunction]="getVideosObservableFunction"
[getSyndicationItemsFunction]="getSyndicationItemsFunction"
[defaultSort]="defaultSort"
displayFilters="true"
displayModerationBlock="true"
[displayAsRow]="displayAsRow()"
hideScopeFilter="true"
loadUserVideoPreferences="true"
highlightLives="true"
[disabled]="disabled"
>
</my-videos-list>
+83
ファイルの表示
@@ -0,0 +1,83 @@
import { NgIf } from '@angular/common'
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { ComponentPaginationLight, DisableForReuseHook, ScreenService } from '@app/core'
import { Account } from '@app/shared/shared-main/account/account.model'
import { AccountService } from '@app/shared/shared-main/account/account.service'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import { VideoFilters } from '@app/shared/shared-video-miniature/video-filters.model'
import { VideoSortField } from '@peertube/peertube-models'
import { Subscription } from 'rxjs'
import { VideosListComponent } from '../../shared/shared-video-miniature/videos-list.component'
@Component({
selector: 'my-account-videos',
templateUrl: './account-videos.component.html',
standalone: true,
imports: [ NgIf, VideosListComponent ]
})
export class AccountVideosComponent implements OnInit, OnDestroy, DisableForReuseHook {
@ViewChild('videosList') videosList: VideosListComponent
getVideosObservableFunction = this.getVideosObservable.bind(this)
getSyndicationItemsFunction = this.getSyndicationItems.bind(this)
title = $localize`Videos`
defaultSort = '-publishedAt' as VideoSortField
account: Account
disabled = false
private alreadyLoaded = false
private accountSub: Subscription
constructor (
private screenService: ScreenService,
private accountService: AccountService,
private videoService: VideoService
) {
}
ngOnInit () {
// Parent get the account for us
this.accountSub = this.accountService.accountLoaded
.subscribe(account => {
this.account = account
if (this.alreadyLoaded) this.videosList.reloadVideos()
this.alreadyLoaded = true
})
}
ngOnDestroy () {
if (this.accountSub) this.accountSub.unsubscribe()
}
getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
const options = {
...filters.toVideosAPIObject(),
videoPagination: pagination,
account: this.account,
skipCount: true
}
return this.videoService.getAccountVideos(options)
}
getSyndicationItems () {
return this.videoService.getAccountFeedUrls(this.account.id)
}
displayAsRow () {
return this.screenService.isInMobileView()
}
disableForReuse () {
this.disabled = true
}
enabledForReuse () {
this.disabled = false
}
}
+87
ファイルの表示
@@ -0,0 +1,87 @@
<div *ngIf="account" class="root">
<div class="account-info d-md-grid d-block">
<div class="account-avatar-row">
<my-actor-avatar [size]="getAccountAvatarSize()" actorType="account" [actor]="account"></my-actor-avatar>
<div>
<div class="section-label" i18n>ACCOUNT</div>
<div class="actor-info">
<div>
<div class="actor-display-name align-items-center">
<h1 i18n-title [title]="'Created on ' + (account.createdAt | date)">{{ account.displayName }}</h1>
<my-user-moderation-dropdown
class="mx-3" [prependActions]="prependModerationActions"
buttonSize="small" [account]="account" [user]="accountUser" placement="bottom-left auto"
(userChanged)="onUserChanged()" (userDeleted)="onUserDeleted()"
></my-user-moderation-dropdown>
<span *ngIf="accountUser?.blocked" tabindex="0" [ngbTooltip]="accountUser.blockedReason" class="pt-badge badge-danger" i18n>Banned</span>
<my-account-block-badges [account]="account"></my-account-block-badges>
</div>
<div class="actor-handle">
<span>&#64;{{ account.nameWithHost }}</span>
<my-copy-button
[value]="account.nameWithHostForced" i18n-notification notification="Username copied"
title="Copy account handle" i18n-title
></my-copy-button>
</div>
<div class="actor-counters">
<span i18n>{naiveAggregatedSubscribers(), plural, =0 {No subscribers} =1 {1 subscriber} other {{{ naiveAggregatedSubscribers() }} subscribers}}</span>
<span class="videos-count" *ngIf="accountVideosCount !== undefined" i18n>
{accountVideosCount, plural, =0 {No videos} =1 {1 video} other {{{ accountVideosCount }} videos}}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="description" [ngClass]="{ expanded: accountDescriptionExpanded }">
<div class="description-html" [innerHTML]="accountDescriptionHTML"></div>
</div>
<button *ngIf="hasShowMoreDescription()" class="show-more d-md-none d-block button-unstyle"
(click)="accountDescriptionExpanded = !accountDescriptionExpanded"
title="Show the complete description" i18n-title i18n
>
Show more...
</button>
<div class="buttons">
<a *ngIf="isManageable()" routerLink="/my-account" class="peertube-button-link orange-button" i18n>
Manage account
</a>
<my-subscribe-button *ngIf="hasVideoChannels() && !isManageable()" [account]="account" [videoChannels]="videoChannels"></my-subscribe-button>
</div>
</div>
<div class="links" [ngClass]="{ 'on-channel-page': isOnChannelPage() }">
<ng-template #linkTemplate let-item="item">
<a [routerLink]="item.routerLink" routerLinkActive="active" class="sub-menu-entry">{{ item.label }}</a>
</ng-template>
<my-list-overflow [hidden]="hideMenu" [items]="links" [itemTemplate]="linkTemplate"></my-list-overflow>
<my-simple-search-input
[alwaysShow]="!isInSmallView()" (searchChanged)="searchChanged($event)"
(inputDisplayChanged)="onSearchInputDisplayChanged($event)" name="search-videos"
i18n-iconTitle icon-title="Search account videos"
i18n-placeholder placeholder="Search account videos"
></my-simple-search-input>
</div>
<router-outlet></router-outlet>
</div>
<ng-container *ngIf="prependModerationActions">
<my-account-report #accountReportModal></my-account-report>
</ng-container>
+114
ファイルの表示
@@ -0,0 +1,114 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_account-channel-page' as *;
@use '_miniature' as *;
.root {
--myFontSize: 1rem;
--myGreyFontSize: 1rem;
}
.section-label {
@include section-label-responsive;
}
.links {
@include grid-videos-miniature-margins;
display: flex;
justify-content: space-between;
align-items: center;
&.on-channel-page {
max-width: $max-channels-width;
}
simple-search-input {
@include margin-left(auto);
}
}
my-copy-button {
@include margin-left(3px);
}
.account-info {
@include grid-videos-miniature-margins(false, 15px);
@include padding-top(3.75rem);
@include padding-bottom(3.75rem);
@include margin-bottom(3rem);
@include font-size(1rem);
grid-template-columns: 1fr min-content;
grid-template-rows: auto auto;
background-color: pvar(--submenuBackgroundColor);
}
.account-avatar-row {
@include avatar-row-responsive(2rem, var(--myGreyFontSize));
}
.actor-display-name {
align-items: center;
}
.description {
grid-column: 1 / 3;
max-width: 1000px;
word-break: break-word;
}
.show-more {
@include show-more-description;
@include padding-bottom(3.75rem);
text-align: center;
}
.buttons {
grid-column: 2;
grid-row: 1;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-content: flex-start;
>*:not(:last-child) {
@include margin-bottom(1rem);
}
>a {
white-space: nowrap;
}
}
.pt-badge {
@include margin-right(5px);
}
@media screen and (max-width: $small-view) {
.description:not(.expanded) {
@include fade-text(30px, pvar(--submenuBackgroundColor));
max-height: 70px;
}
.buttons {
justify-content: center;
}
}
@media screen and (max-width: $mobile-view) {
.root {
--myFontSize: 14px;
--myGreyFontSize: 13px;
}
.links {
margin: auto !important;
width: min-content;
}
}
+243
ファイルの表示
@@ -0,0 +1,243 @@
import { DatePipe, NgClass, NgIf } from '@angular/common'
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'
import { AuthService, MarkdownService, Notifier, RedirectService, RestExtractor, ScreenService, UserService } from '@app/core'
import { Account } from '@app/shared/shared-main/account/account.model'
import { AccountService } from '@app/shared/shared-main/account/account.service'
import { DropdownAction } from '@app/shared/shared-main/buttons/action-dropdown.component'
import { VideoChannel } from '@app/shared/shared-main/video-channel/video-channel.model'
import { VideoChannelService } from '@app/shared/shared-main/video-channel/video-channel.service'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import { BlocklistService } from '@app/shared/shared-moderation/blocklist.service'
import { AccountReportComponent } from '@app/shared/shared-moderation/report-modals'
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
import { HttpStatusCode, User, UserRight } from '@peertube/peertube-models'
import { Subscription } from 'rxjs'
import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
import { ActorAvatarComponent } from '../shared/shared-actor-image/actor-avatar.component'
import { CopyButtonComponent } from '../shared/shared-main/buttons/copy-button.component'
import { ListOverflowComponent, ListOverflowItem } from '../shared/shared-main/misc/list-overflow.component'
import { SimpleSearchInputComponent } from '../shared/shared-main/misc/simple-search-input.component'
import { AccountBlockBadgesComponent } from '../shared/shared-moderation/account-block-badges.component'
import { UserModerationDropdownComponent } from '../shared/shared-moderation/user-moderation-dropdown.component'
import { SubscribeButtonComponent } from '../shared/shared-user-subscription/subscribe-button.component'
@Component({
templateUrl: './accounts.component.html',
styleUrls: [ './accounts.component.scss' ],
standalone: true,
imports: [
NgIf,
ActorAvatarComponent,
UserModerationDropdownComponent,
NgbTooltip,
AccountBlockBadgesComponent,
CopyButtonComponent,
NgClass,
RouterLink,
SubscribeButtonComponent,
RouterLinkActive,
ListOverflowComponent,
SimpleSearchInputComponent,
RouterOutlet,
AccountReportComponent,
DatePipe
]
})
export class AccountsComponent implements OnInit, OnDestroy {
@ViewChild('accountReportModal') accountReportModal: AccountReportComponent
account: Account
accountUser: User
videoChannels: VideoChannel[] = []
links: ListOverflowItem[] = []
hideMenu = false
accountVideosCount: number
accountDescriptionHTML = ''
accountDescriptionExpanded = false
prependModerationActions: DropdownAction<any>[] = []
private routeSub: Subscription
constructor (
private route: ActivatedRoute,
private router: Router,
private userService: UserService,
private accountService: AccountService,
private videoChannelService: VideoChannelService,
private notifier: Notifier,
private restExtractor: RestExtractor,
private redirectService: RedirectService,
private authService: AuthService,
private videoService: VideoService,
private markdown: MarkdownService,
private blocklist: BlocklistService,
private screenService: ScreenService
) {
}
ngOnInit () {
this.routeSub = this.route.params
.pipe(
map(params => params['accountId']),
distinctUntilChanged(),
switchMap(accountId => this.accountService.getAccount(accountId)),
tap(account => this.onAccount(account)),
switchMap(account => this.videoChannelService.listAccountVideoChannels({ account })),
catchError(err => this.restExtractor.redirectTo404IfNotFound(err, 'other', [
HttpStatusCode.BAD_REQUEST_400,
HttpStatusCode.NOT_FOUND_404
]))
)
.subscribe({
next: videoChannels => {
this.videoChannels = videoChannels.data
},
error: err => this.notifier.error(err.message)
})
this.links = [
{ label: $localize`CHANNELS`, routerLink: 'video-channels' },
{ label: $localize`VIDEOS`, routerLink: 'videos' }
]
}
ngOnDestroy () {
if (this.routeSub) this.routeSub.unsubscribe()
}
naiveAggregatedSubscribers () {
return this.videoChannels.reduce(
(acc, val) => acc + val.followersCount,
this.account.followersCount // accumulator starts with the base number of subscribers the account has
)
}
isUserLoggedIn () {
return this.authService.isLoggedIn()
}
isInSmallView () {
return this.screenService.isInSmallView()
}
getAccountAvatarSize () {
if (this.isInSmallView()) return 80
return 120
}
isManageable () {
if (!this.isUserLoggedIn()) return false
return this.account?.userId === this.authService.getUser().id
}
onUserChanged () {
this.loadUserIfNeeded(this.account)
}
onUserDeleted () {
this.redirectService.redirectToHomepage()
}
searchChanged (search: string) {
const queryParams = { search }
this.router.navigate([ './videos' ], { queryParams, relativeTo: this.route, queryParamsHandling: 'merge' })
}
onSearchInputDisplayChanged (displayed: boolean) {
this.hideMenu = this.isInSmallView() && displayed
}
hasVideoChannels () {
return this.videoChannels.length !== 0
}
hasShowMoreDescription () {
return !this.accountDescriptionExpanded && this.accountDescriptionHTML.length > 100
}
isOnChannelPage () {
return this.route.children[0].snapshot.url[0].path === 'video-channels'
}
private async onAccount (account: Account) {
this.accountDescriptionHTML = await this.markdown.textMarkdownToHTML({
markdown: account.description,
withEmoji: true,
withHtml: true
})
// After the markdown renderer to avoid layout changes
this.account = account
this.updateModerationActions()
this.loadUserIfNeeded(account)
this.loadAccountVideosCount()
this.loadAccountBlockStatus()
}
private showReportModal () {
this.accountReportModal.show(this.account)
}
private loadUserIfNeeded (account: Account) {
if (!account.userId || !this.authService.isLoggedIn()) return
const user = this.authService.getUser()
if (user.hasRight(UserRight.MANAGE_USERS)) {
this.userService.getUser(account.userId)
.subscribe({
next: accountUser => {
this.accountUser = accountUser
},
error: err => this.notifier.error(err.message)
})
}
}
private updateModerationActions () {
this.prependModerationActions = []
if (!this.authService.isLoggedIn()) return
if (this.isManageable()) return
// It's not our account, we can report it
this.prependModerationActions = [
{
label: $localize`Report`,
isHeader: true
},
{
label: $localize`Report this account`,
handler: () => this.showReportModal()
}
]
}
private loadAccountVideosCount () {
this.videoService.getAccountVideos({
account: this.account,
videoPagination: {
currentPage: 1,
itemsPerPage: 0
},
sort: '-publishedAt'
}).subscribe(res => {
this.accountVideosCount = res.total
})
}
private loadAccountBlockStatus () {
this.blocklist.getStatus({ accounts: [ this.account.nameWithHostForced ], hosts: [ this.account.host ] })
.subscribe(status => this.account.updateBlockStatus(status))
}
}
+66
ファイルの表示
@@ -0,0 +1,66 @@
import { Routes } from '@angular/router'
import { AbuseService } from '@app/shared/shared-moderation/abuse.service'
import { BlocklistService } from '@app/shared/shared-moderation/blocklist.service'
import { BulkService } from '@app/shared/shared-moderation/bulk.service'
import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
import { UserSubscriptionService } from '@app/shared/shared-user-subscription/user-subscription.service'
import { UserAdminService } from '@app/shared/shared-users/user-admin.service'
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component'
import { AccountVideosComponent } from './account-videos/account-videos.component'
import { AccountsComponent } from './accounts.component'
export default [
{
path: 'peertube',
redirectTo: '/videos/local'
},
{
path: ':accountId',
component: AccountsComponent,
providers: [
UserSubscriptionService,
BlocklistService,
VideoPlaylistService,
VideoBlockService,
AbuseService,
UserAdminService,
BulkService
],
children: [
{
path: '',
redirectTo: 'video-channels',
pathMatch: 'full'
},
{
path: 'video-channels',
component: AccountVideoChannelsComponent,
data: {
meta: {
title: $localize`Account video channels`
}
}
},
{
path: 'videos',
component: AccountVideosComponent,
data: {
meta: {
title: $localize`Account videos`
},
reuse: {
enabled: true,
key: 'account-videos-list'
}
}
},
// Old URL redirection
{
path: 'search',
redirectTo: 'videos'
}
]
}
] satisfies Routes
+7
ファイルの表示
@@ -0,0 +1,7 @@
<div class="root">
<my-top-menu-dropdown [menuEntries]="menuEntries"></my-top-menu-dropdown>
<div class="margin-content" [ngClass]="{ 'sub-menu-offset-content': !isBroadcastMessageDisplayed }">
<router-outlet></router-outlet>
</div>
</div>
+10
ファイルの表示
@@ -0,0 +1,10 @@
@use '_variables' as *;
@use '_mixins' as *;
my-top-menu-dropdown {
flex-grow: 1;
}
.root {
@include sub-menu-h1;
}
+239
ファイルの表示
@@ -0,0 +1,239 @@
import { NgClass } from '@angular/common'
import { Component, OnInit } from '@angular/core'
import { RouterOutlet } from '@angular/router'
import { AuthService, ScreenService, ServerService } from '@app/core'
import { ListOverflowItem } from '@app/shared/shared-main/misc/list-overflow.component'
import { TopMenuDropdownParam } from '@app/shared/shared-main/misc/top-menu-dropdown.component'
import { UserRight, UserRightType } from '@peertube/peertube-models'
import { TopMenuDropdownComponent } from '../shared/shared-main/misc/top-menu-dropdown.component'
@Component({
templateUrl: './admin.component.html',
styleUrls: [ './admin.component.scss' ],
standalone: true,
imports: [ TopMenuDropdownComponent, NgClass, RouterOutlet ]
})
export class AdminComponent implements OnInit {
items: ListOverflowItem[] = []
menuEntries: TopMenuDropdownParam[] = []
constructor (
private auth: AuthService,
private screen: ScreenService,
private server: ServerService
) { }
get isBroadcastMessageDisplayed () {
return this.screen.isBroadcastMessageDisplayed
}
ngOnInit () {
this.server.configReloaded.subscribe(() => this.buildMenu())
this.buildMenu()
}
private buildMenu () {
this.menuEntries = []
this.buildOverviewItems()
this.buildFederationItems()
this.buildModerationItems()
this.buildConfigurationItems()
this.buildPluginItems()
this.buildSystemItems()
}
private buildOverviewItems () {
const overviewItems: TopMenuDropdownParam = {
label: $localize`Overview`,
children: []
}
if (this.hasRight(UserRight.MANAGE_USERS)) {
overviewItems.children.push({
label: $localize`Users`,
routerLink: '/admin/users',
iconName: 'user'
})
}
if (this.hasRight(UserRight.SEE_ALL_VIDEOS)) {
overviewItems.children.push({
label: $localize`Videos`,
routerLink: '/admin/videos',
queryParams: {
search: 'isLocal:true'
},
iconName: 'videos'
})
}
if (this.hasRight(UserRight.SEE_ALL_COMMENTS)) {
overviewItems.children.push({
label: $localize`Comments`,
routerLink: '/admin/comments',
iconName: 'message-circle'
})
}
if (overviewItems.children.length !== 0) {
this.menuEntries.push(overviewItems)
}
}
private buildFederationItems () {
if (!this.hasRight(UserRight.MANAGE_SERVER_FOLLOW)) return
this.menuEntries.push({
label: $localize`Federation`,
children: [
{
label: $localize`Following`,
routerLink: '/admin/follows/following-list',
iconName: 'following'
},
{
label: $localize`Followers`,
routerLink: '/admin/follows/followers-list',
iconName: 'follower'
},
{
label: $localize`Video redundancies`,
routerLink: '/admin/follows/video-redundancies-list',
iconName: 'videos'
}
]
})
}
private buildModerationItems () {
const moderationItems: TopMenuDropdownParam = {
label: $localize`Moderation`,
children: []
}
if (this.hasRight(UserRight.MANAGE_REGISTRATIONS)) {
moderationItems.children.push({
label: $localize`Registrations`,
routerLink: '/admin/moderation/registrations/list',
iconName: 'user'
})
}
if (this.hasRight(UserRight.MANAGE_ABUSES)) {
moderationItems.children.push({
label: $localize`Reports`,
routerLink: '/admin/moderation/abuses/list',
iconName: 'flag'
})
}
if (this.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) {
moderationItems.children.push({
label: $localize`Video blocks`,
routerLink: '/admin/moderation/video-blocks/list',
iconName: 'cross'
})
}
if (this.hasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST)) {
moderationItems.children.push({
label: $localize`Muted accounts`,
routerLink: '/admin/moderation/blocklist/accounts',
iconName: 'user-x'
})
}
if (this.hasRight(UserRight.MANAGE_SERVERS_BLOCKLIST)) {
moderationItems.children.push({
label: $localize`Muted servers`,
routerLink: '/admin/moderation/blocklist/servers',
iconName: 'peertube-x'
})
}
if (this.hasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS)) {
moderationItems.children.push({
label: $localize`Watched words`,
routerLink: '/admin/moderation/watched-words/list',
iconName: 'eye-open'
})
}
if (moderationItems.children.length !== 0) this.menuEntries.push(moderationItems)
}
private buildConfigurationItems () {
if (this.hasRight(UserRight.MANAGE_CONFIGURATION)) {
this.menuEntries.push({ label: $localize`Configuration`, routerLink: '/admin/config' })
}
}
private buildPluginItems () {
if (this.hasRight(UserRight.MANAGE_PLUGINS)) {
this.menuEntries.push({ label: $localize`Plugins/Themes`, routerLink: '/admin/plugins' })
}
}
private buildSystemItems () {
const systemItems: TopMenuDropdownParam = {
label: $localize`System`,
children: []
}
if (this.isRemoteRunnersEnabled() && this.hasRight(UserRight.MANAGE_RUNNERS)) {
systemItems.children.push({
label: $localize`Remote runners`,
iconName: 'codesandbox',
routerLink: '/admin/system/runners/runners-list'
})
systemItems.children.push({
label: $localize`Runner jobs`,
iconName: 'globe',
routerLink: '/admin/system/runners/jobs-list'
})
}
if (this.hasRight(UserRight.MANAGE_JOBS)) {
systemItems.children.push({
label: $localize`Local jobs`,
iconName: 'circle-tick',
routerLink: '/admin/system/jobs'
})
}
if (this.hasRight(UserRight.MANAGE_LOGS)) {
systemItems.children.push({
label: $localize`Logs`,
iconName: 'playlists',
routerLink: '/admin/system/logs'
})
}
if (this.hasRight(UserRight.MANAGE_DEBUG)) {
systemItems.children.push({
label: $localize`Debug`,
iconName: 'cog',
routerLink: '/admin/system/debug'
})
}
if (systemItems.children.length !== 0) {
this.menuEntries.push(systemItems)
}
}
private hasRight (right: UserRightType) {
return this.auth.getUser().hasRight(right)
}
private isRemoteRunnersEnabled () {
const config = this.server.getHTMLConfig()
return config.transcoding.remoteRunners.enabled ||
config.live.transcoding.remoteRunners.enabled ||
config.videoStudio.remoteRunners.enabled
}
}
+30
ファイルの表示
@@ -0,0 +1,30 @@
import { Routes } from '@angular/router'
import { EditCustomConfigComponent } from '@app/+admin/config/edit-custom-config'
import { UserRightGuard } from '@app/core'
import { UserRight } from '@peertube/peertube-models'
export const ConfigRoutes: Routes = [
{
path: 'config',
canActivate: [ UserRightGuard ],
data: {
userRight: UserRight.MANAGE_CONFIGURATION
},
children: [
{
path: '',
redirectTo: 'edit-custom',
pathMatch: 'full'
},
{
path: 'edit-custom',
component: EditCustomConfigComponent,
data: {
meta: {
title: $localize`Edit custom configuration`
}
}
}
]
}
]
@@ -0,0 +1,139 @@
<ng-container [formGroup]="form">
<div class="pt-two-cols mt-5"> <!-- cache grid -->
<div class="title-col">
<h2 i18n>CACHE</h2>
<div i18n class="inner-form-description">
Some files are not federated, and fetched when necessary. Define their caching policies.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="cache">
<div class="form-group" formGroupName="previews">
<label i18n for="cachePreviewsSize">Number of previews to keep in cache</label>
<div class="number-with-unit">
<input
type="number" min="0" id="cachePreviewsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.previews.size'] }"
>
<span i18n>{getCacheSize('previews'), plural, =1 {cached image} other {cached images}}</span>
</div>
<div *ngIf="formErrors.cache.previews.size" class="form-error" role="alert">{{ formErrors.cache.previews.size }}</div>
</div>
<div class="form-group" formGroupName="captions">
<label i18n for="cacheCaptionsSize">Number of video captions to keep in cache</label>
<div class="number-with-unit">
<input
type="number" min="0" id="cacheCaptionsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.captions.size'] }"
>
<span i18n>{getCacheSize('captions'), plural, =1 {cached caption} other {cached captions}}</span>
</div>
<div *ngIf="formErrors.cache.captions.size" class="form-error" role="alert">{{ formErrors.cache.captions.size }}</div>
</div>
<div class="form-group" formGroupName="torrents">
<label i18n for="cacheTorrentsSize">Number of video torrents to keep in cache</label>
<div class="number-with-unit">
<input
type="number" min="0" id="cacheTorrentsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.torrents.size'] }"
>
<span i18n>{getCacheSize('torrents'), plural, =1 {cached torrent} other {cached torrents}}</span>
</div>
<div *ngIf="formErrors.cache.torrents.size" class="form-error" role="alert">{{ formErrors.cache.torrents.size }}</div>
</div>
<div class="form-group" formGroupName="torrents">
<label i18n for="cacheTorrentsSize">Number of video storyboard images to keep in cache</label>
<div class="number-with-unit">
<input
type="number" min="0" id="cacheStoryboardsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.storyboards.size'] }"
>
<span i18n>{getCacheSize('storyboards'), plural, =1 {cached storyboard} other {cached storyboards}}</span>
</div>
<div *ngIf="formErrors.cache.storyboards.size" class="form-error" role="alert">{{ formErrors.cache.storyboards.size }}</div>
</div>
</ng-container>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- cache grid -->
<div class="title-col">
<div class="anchor" id="customizations"></div> <!-- customizations anchor -->
<h2 i18n>CUSTOMIZATIONS</h2>
<div i18n class="inner-form-description">
Slight modifications to your PeerTube instance for when creating a plugin or theme is overkill.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="instance">
<ng-container formGroupName="customizations">
<div class="form-group">
<label i18n for="customizationJavascript">JavaScript</label>
<my-help>
<ng-template ptTemplate="customHtml">
<ng-container i18n>
<p class="mb-2">Write JavaScript code directly. Example:</p>
<pre>console.log('my instance is amazing');</pre>
</ng-container>
</ng-template>
</my-help>
<textarea
id="customizationJavascript" formControlName="javascript" class="form-control" dir="ltr"
[ngClass]="{ 'input-error': formErrors['instance.customizations.javascript'] }"
></textarea>
<div *ngIf="formErrors.instance.customizations.javascript" class="form-error" role="alert">{{ formErrors.instance.customizations.javascript }}</div>
</div>
<div class="form-group">
<label for="customizationCSS">CSS</label>
<my-help>
<ng-template ptTemplate="customHtml">
<ng-container i18n>
<p class="mb-2">Write CSS code directly. Example:</p>
<pre>
#custom-css {{ '{' }}
color: red;
{{ '}' }}
</pre>
<p class="mb-2">Prepend with <em>#custom-css</em> to override styles. Example:</p>
<pre>
#custom-css .logged-in-email {{ '{' }}
color: red;
{{ '}' }}
</pre>
</ng-container>
</ng-template>
</my-help>
<textarea
id="customizationCSS" formControlName="css" class="form-control" dir="ltr"
[ngClass]="{ 'input-error': formErrors['instance.customizations.css'] }"
></textarea>
<div *ngIf="formErrors.instance.customizations.css" class="form-error" role="alert">{{ formErrors.instance.customizations.css }}</div>
</div>
</ng-container>
</ng-container>
</div>
</div>
</ng-container>
@@ -0,0 +1,21 @@
import { Component, Input } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
import { HelpComponent } from '../../../shared/shared-main/misc/help.component'
import { NgClass, NgIf } from '@angular/common'
@Component({
selector: 'my-edit-advanced-configuration',
templateUrl: './edit-advanced-configuration.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
standalone: true,
imports: [ FormsModule, ReactiveFormsModule, NgClass, NgIf, HelpComponent, PeerTubeTemplateDirective ]
})
export class EditAdvancedConfigurationComponent {
@Input() form: FormGroup
@Input() formErrors: any
getCacheSize (type: 'captions' | 'previews' | 'torrents' | 'storyboards') {
return this.form.value['cache'][type]['size']
}
}
@@ -0,0 +1,735 @@
<ng-container [formGroup]="form">
<div class="pt-two-cols mt-5"> <!-- appearance grid -->
<div class="title-col">
<h2 i18n>APPEARANCE</h2>
<div i18n class="inner-form-description">
Use <a class="link-orange" routerLink="/admin/plugins">plugins & themes</a> for more involved changes, or add slight <a class="link-orange" routerLink="/admin/config/edit-custom" fragment="advanced-configuration">customizations</a>.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="theme">
<div class="form-group">
<label i18n for="themeDefault">Theme</label>
<div class="peertube-select-container">
<select formControlName="default" id="themeDefault" class="form-control">
<option i18n value="default">{{ getDefaultThemeLabel() }}</option>
<option *ngFor="let theme of availableThemes" [value]="theme.id">{{ theme.label }}</option>
</select>
</div>
</div>
</ng-container>
<div class="form-group" formGroupName="instance">
<label i18n for="instanceDefaultClientRoute">Landing page</label>
<my-select-custom-value
id="instanceDefaultClientRoute"
[items]="defaultLandingPageOptions"
formControlName="defaultClientRoute"
inputType="text"
[clearable]="false"
></my-select-custom-value>
<div *ngIf="formErrors.instance.defaultClientRoute" class="form-error" role="alert">{{ formErrors.instance.defaultClientRoute }}</div>
</div>
<div class="form-group" formGroupName="trending">
<ng-container formGroupName="videos">
<ng-container formGroupName="algorithms">
<label i18n for="trendingVideosAlgorithmsDefault">Default trending page</label>
<div class="peertube-select-container">
<select id="trendingVideosAlgorithmsDefault" formControlName="default" class="form-control">
<option i18n value="hot">Hot videos</option>
<option i18n value="most-viewed">Recent views</option>
<option i18n value="most-liked">Most liked videos</option>
<option i18n value="views">Global views</option>
</select>
</div>
<div *ngIf="formErrors.trending.videos.algorithms.default" class="form-error" role="alert">{{ formErrors.trending.videos.algorithms.default }}</div>
</ng-container>
</ng-container>
</div>
<ng-container formGroupName="client">
<ng-container formGroupName="videos">
<ng-container formGroupName="miniature">
<div class="form-group">
<my-peertube-checkbox
inputName="clientVideosMiniaturePreferAuthorDisplayName" formControlName="preferAuthorDisplayName"
i18n-labelText labelText="Prefer author display name in video miniature"
></my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
<ng-container formGroupName="menu">
<ng-container formGroupName="login">
<div class="form-group">
<my-peertube-checkbox
inputName="clientMenuLoginRedirectOnSingleExternalAuth" formControlName="redirectOnSingleExternalAuth"
i18n-labelText labelText="Redirect users on single external auth when users click on the login button in menu"
>
<ng-container ngProjectAs="description">
<span *ngIf="countExternalAuth() === 0" i18n>⚠️ You don't have any external auth plugin enabled.</span>
<span *ngIf="countExternalAuth() > 1" i18n>⚠️ You have multiple external auth plugins enabled.</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</ng-container>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- broadcast grid -->
<div class="title-col">
<h2 i18n>BROADCAST MESSAGE</h2>
<div i18n class="inner-form-description">
Display a message on your instance
</div>
</div>
<div class="content-col">
<ng-container formGroupName="broadcastMessage">
<div class="form-group">
<my-peertube-checkbox
inputName="broadcastMessageEnabled" formControlName="enabled"
i18n-labelText labelText="Enable broadcast message"
></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="broadcastMessageDismissable" formControlName="dismissable"
i18n-labelText labelText="Allow users to dismiss the broadcast message "
></my-peertube-checkbox>
</div>
<div class="form-group">
<label i18n for="broadcastMessageLevel">Broadcast message level</label>
<div class="peertube-select-container">
<select id="broadcastMessageLevel" formControlName="level" class="form-control">
<option i18n value="info">info</option>
<option i18n value="warning">warning</option>
<option i18n value="error">error</option>
</select>
</div>
<div *ngIf="formErrors.broadcastMessage.level" class="form-error" role="alert">{{ formErrors.broadcastMessage.level }}</div>
</div>
<div class="form-group">
<label i18n for="broadcastMessageMessage">Message</label><my-help helpType="markdownText"></my-help>
<my-markdown-textarea
name="broadcastMessageMessage" formControlName="message"
[formError]="formErrors['broadcastMessage.message']" markdownType="to-unsafe-html"
></my-markdown-textarea>
<div *ngIf="formErrors.broadcastMessage.message" class="form-error" role="alert">{{ formErrors.broadcastMessage.message }}</div>
</div>
</ng-container>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- new users grid -->
<div class="title-col">
<h2 i18n>NEW USERS</h2>
<div i18n class="inner-form-description">
Manage <a class="link-orange" routerLink="/admin/users">users</a> to set their quota individually.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="signup">
<div class="form-group">
<my-peertube-checkbox
inputName="signupEnabled" formControlName="enabled"
i18n-labelText labelText="Enable Signup"
>
<ng-container ngProjectAs="description">
<span i18n>⚠️ This functionality requires a lot of attention and extra moderation.</span>
<div class="alert pt-alert-primary alert-signup" *ngIf="signupAlertMessage">{{ signupAlertMessage }}</div>
</ng-container>
<ng-container ngProjectAs="extra">
<div class="form-group">
<my-peertube-checkbox [ngClass]="getDisabledSignupClass()"
inputName="signupRequiresApproval" formControlName="requiresApproval"
i18n-labelText labelText="Signup requires approval by moderators"
></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox [ngClass]="getDisabledSignupClass()"
inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification"
i18n-labelText labelText="Signup requires email verification"
></my-peertube-checkbox>
</div>
<div [ngClass]="getDisabledSignupClass()">
<label i18n for="signupLimit">Signup limit</label>
<div class="number-with-unit">
<input
type="number" min="-1" id="signupLimit" class="form-control"
formControlName="limit" [ngClass]="{ 'input-error': formErrors['signup.limit'] }"
>
<span i18n>{form.value['signup']['limit'], plural, =1 {user} other {users}}</span>
</div>
<div *ngIf="formErrors.signup.limit" class="form-error" role="alert">{{ formErrors.signup.limit }}</div>
<small i18n *ngIf="hasUnlimitedSignup()" class="muted small">Signup won't be limited to a fixed number of users.</small>
</div>
<div [ngClass]="getDisabledSignupClass()" class="mt-3">
<label i18n for="signupMinimumAge">Minimum required age to create an account</label>
<div class="number-with-unit">
<input
type="number" min="1" id="signupMinimumAge" class="form-control"
formControlName="minimumAge" [ngClass]="{ 'input-error': formErrors['signup.minimumAge'] }"
>
<span i18n>{form.value['signup']['minimumAge'], plural, =1 {year old} other {years old}}</span>
</div>
<div *ngIf="formErrors.signup.minimumAge" class="form-error" role="alert">{{ formErrors.signup.minimumAge }}</div>
</div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="user">
<div class="form-group">
<label i18n for="userVideoQuota">Default video quota per user</label>
<my-select-custom-value
id="userVideoQuota"
[items]="getVideoQuotaOptions()"
formControlName="videoQuota"
i18n-inputSuffix inputSuffix="bytes" inputType="number"
[clearable]="false"
></my-select-custom-value>
<my-user-real-quota-info class="mt-2 d-block small muted" [videoQuota]="getUserVideoQuota()"></my-user-real-quota-info>
<div *ngIf="formErrors.user.videoQuota" class="form-error" role="alert">{{ formErrors.user.videoQuota }}</div>
</div>
<div class="form-group">
<label i18n for="userVideoQuotaDaily">Default daily upload limit per user</label>
<my-select-custom-value
id="userVideoQuotaDaily"
[items]="getVideoQuotaDailyOptions()"
formControlName="videoQuotaDaily"
i18n-inputSuffix inputSuffix="bytes" inputType="number"
[clearable]="false"
></my-select-custom-value>
<div *ngIf="formErrors.user.videoQuotaDaily" class="form-error" role="alert">{{ formErrors.user.videoQuotaDaily }}</div>
</div>
<div class="form-group">
<ng-container formGroupName="history">
<ng-container formGroupName="videos">
<my-peertube-checkbox
inputName="videosHistoryEnabled" formControlName="enabled"
i18n-labelText labelText="Automatically enable video history for new users"
>
</my-peertube-checkbox>
</ng-container>
</ng-container>
</div>
</ng-container>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- videos grid -->
<div class="title-col">
<h2 i18n>VIDEOS</h2>
</div>
<div class="content-col">
<ng-container formGroupName="import">
<ng-container formGroupName="videos">
<div class="form-group">
<label i18n for="importConcurrency">Import jobs concurrency</label>
<span i18n class="small muted ms-1">allows to import multiple videos in parallel. ⚠️ Requires a PeerTube restart.</span>
<div class="number-with-unit">
<input type="number" name="importConcurrency" formControlName="concurrency" />
<span i18n>jobs in parallel</span>
</div>
<div *ngIf="formErrors.import.concurrency" class="form-error" role="alert">{{ formErrors.import.concurrency }}</div>
</div>
<div class="form-group" formGroupName="http">
<my-peertube-checkbox
inputName="importVideosHttpEnabled" formControlName="enabled"
i18n-labelText labelText="Allow import with HTTP URL (e.g. YouTube)"
>
<ng-container ngProjectAs="description">
<span i18n>⚠️ If enabled, we recommend to use <a class="link-orange" href="https://docs.joinpeertube.org/maintain/configuration#security">a HTTP proxy</a> to prevent private URL access from your PeerTube server</span>
</ng-container>
</my-peertube-checkbox>
</div>
<div class="form-group" formGroupName="torrent">
<my-peertube-checkbox
inputName="importVideosTorrentEnabled" formControlName="enabled"
i18n-labelText labelText="Allow import with a torrent file or a magnet URI"
>
<ng-container ngProjectAs="description">
<span i18n>⚠️ We don't recommend to enable this feature if you don't trust your users</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="videoChannelSynchronization">
<div class="form-group">
<my-peertube-checkbox
inputName="importSynchronizationEnabled" formControlName="enabled"
i18n-labelText labelText="Allow channel synchronization with channel of other platforms like YouTube"
>
<ng-container ngProjectAs="description">
<span i18n [hidden]="isImportVideosHttpEnabled()">
⛔ You need to allow import with HTTP URL to be able to activate this feature.
</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
<ng-container formGroupName="autoBlacklist">
<ng-container formGroupName="videos">
<ng-container formGroupName="ofUsers">
<div class="form-group">
<my-peertube-checkbox
inputName="autoBlacklistVideosOfUsersEnabled" formControlName="enabled"
i18n-labelText labelText="Block new videos automatically"
>
<ng-container ngProjectAs="description">
<span i18n>Unless a user is marked as trusted, their videos will stay private until a moderator reviews them.</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</ng-container>
<ng-container formGroupName="videoFile">
<ng-container formGroupName="update">
<div class="form-group">
<my-peertube-checkbox
inputName="videoFileUpdateEnabled" formControlName="enabled"
i18n-labelText labelText="Allow users to upload a new version of their video"
>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
<ng-container formGroupName="storyboards">
<div class="form-group">
<my-peertube-checkbox
inputName="storyboardsEnabled" formControlName="enabled"
i18n-labelText labelText="Enable video storyboards"
>
<ng-container ngProjectAs="description">
<span i18n>Generate storyboards of local videos using ffmpeg so users can see the video preview in the player while scrubbing the video</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="videoTranscription">
<div class="form-group">
<my-peertube-checkbox
inputName="videoTranscriptionEnabled" formControlName="enabled"
i18n-labelText labelText="Enable video transcription"
>
<ng-container ngProjectAs="description">
<span i18n>Automatically create a subtitle file of uploaded/imported VOD videos</span>
</ng-container>
<ng-container ngProjectAs="extra">
<div class="form-group" formGroupName="remoteRunners" [ngClass]="getTranscriptionRunnerDisabledClass()">
<my-peertube-checkbox
inputName="videoTranscriptionRemoteRunnersEnabled" formControlName="enabled"
i18n-labelText labelText="Enable remote runners for transcription"
>
<ng-container ngProjectAs="description">
<span i18n>
Use <a routerLink="/admin/system/runners/runners-list">remote runners</a> to process transcription tasks.
Remote runners has to register on your instance first.
</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- video channels grid -->
<div class="title-col">
<h2 i18n>VIDEO CHANNELS</h2>
</div>
<div class="content-col">
<div class="form-group" formGroupName="videoChannels">
<label i18n for="videoChannelsMaxPerUser">Max video channels per user</label>
<div class="number-with-unit">
<input
type="number" min="1" id="videoChannelsMaxPerUser" class="form-control"
formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors['videoChannels.maxPerUser'] }"
>
<span i18n>{form.value['videoChannels']['maxPerUser'], plural, =1 {channel} other {channels}}</span>
</div>
<div *ngIf="formErrors.videoChannels.maxPerUser" class="form-error" role="alert">{{ formErrors.videoChannels.maxPerUser }}</div>
</div>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- search grid -->
<div class="title-col">
<h2 i18n>SEARCH</h2>
</div>
<div class="content-col">
<ng-container formGroupName="search">
<ng-container formGroupName="remoteUri">
<div class="form-group">
<my-peertube-checkbox
inputName="searchRemoteUriUsers" formControlName="users"
i18n-labelText labelText="Allow users to do remote URI/handle search"
>
<ng-container ngProjectAs="description">
<span i18n>Allow <strong>your users</strong> to look up remote videos/actors that may not be federated with your instance</span>
</ng-container>
</my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="searchRemoteUriAnonymous" formControlName="anonymous"
i18n-labelText labelText="Allow anonymous to do remote URI/handle search"
>
<ng-container ngProjectAs="description">
<span i18n>Allow <strong>anonymous users</strong> to look up remote videos/actors that may not be federated with your instance</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="searchIndex">
<div class="form-group">
<my-peertube-checkbox
inputName="searchIndexEnabled" formControlName="enabled"
i18n-labelText labelText="Enable global search"
>
<ng-container ngProjectAs="description">
<div i18n>⚠️ This functionality depends heavily on the moderation of instances followed by the search index you select.</div>
<div i18n>
You should only use moderated search indexes in production, or <a class="link-orange" href="https://framagit.org/framasoft/peertube/search-index">host your own</a>.
</div>
</ng-container>
<ng-container ngProjectAs="extra">
<div [ngClass]="getDisabledSearchIndexClass()">
<label i18n for="searchIndexUrl">Search index URL</label>
<input
type="text" id="searchIndexUrl" class="form-control"
formControlName="url" [ngClass]="{ 'input-error': formErrors['search.searchIndex.url'] }"
>
<div *ngIf="formErrors.search.searchIndex.url" class="form-error" role="alert">{{ formErrors.search.searchIndex.url }}</div>
</div>
<div class="mt-3">
<my-peertube-checkbox [ngClass]="getDisabledSearchIndexClass()"
inputName="searchIndexDisableLocalSearch" formControlName="disableLocalSearch"
i18n-labelText labelText="Disable local search in search bar"
></my-peertube-checkbox>
</div>
<div class="mt-3">
<my-peertube-checkbox [ngClass]="getDisabledSearchIndexClass()"
inputName="searchIndexIsDefaultSearch" formControlName="isDefaultSearch"
i18n-labelText labelText="Search bar uses the global search index by default"
>
<ng-container ngProjectAs="description">
<span i18n>Otherwise the local search stays used by default</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- import/export grid -->
<div class="title-col">
<h2 i18n>USER IMPORT/EXPORT</h2>
</div>
<div class="content-col">
<ng-container formGroupName="import">
<ng-container formGroupName="users">
<div class="form-group">
<my-peertube-checkbox
inputName="importUsersEnabled" formControlName="enabled"
i18n-labelText labelText="Allow your users to import a data archive"
>
<ng-container ngProjectAs="description">
<div i18n>Video quota is checked on import so the user doesn't upload a too big archive file</div>
<div i18n>Video quota (daily quota is not taken into account) is also checked for each video when PeerTube is processing the import</div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
<ng-container formGroupName="export">
<ng-container formGroupName="users">
<div class="form-group">
<my-peertube-checkbox
inputName="exportUsersEnabled" formControlName="enabled"
i18n-labelText labelText="Allow your users to export their data"
>
<ng-container ngProjectAs="description">
<span i18n>Users can export their PeerTube data in a .zip for backup or re-import. Only one export at a time is allowed per user</span>
</ng-container>
<ng-container ngProjectAs="extra">
<div class="form-group" [ngClass]="getDisabledExportUsersClass()">
<label i18n for="exportUsersMaxUserVideoQuota">Max user video quota allowed to generate the export</label>
<span i18n class="ms-2 small muted">If the user decides to include the video files in the archive</span>
<my-select-custom-value
id="exportUsersMaxUserVideoQuota"
[items]="exportMaxUserVideoQuotaOptions"
formControlName="maxUserVideoQuota"
i18n-inputSuffix inputSuffix="bytes" inputType="number"
[clearable]="false"
></my-select-custom-value>
<div *ngIf="formErrors.export.users.maxUserVideoQuota" class="form-error" role="alert">{{ formErrors.export.users.maxUserVideoQuota }}</div>
</div>
<div class="form-group" [ngClass]="getDisabledExportUsersClass()">
<label i18n for="exportUsersExportExpiration">User export expiration</label>
<my-select-options
labelForId="exportUsersExportExpiration" [items]="exportExpirationOptions" formControlName="exportExpiration"
bindLabel="label" bindValue="value" [clearable]="false" [searchable]="false"
></my-select-options>
<div i18n class="mt-1 small muted">The archive file is deleted after this period.</div>
<div *ngIf="formErrors.export.users.exportExpiration" class="form-error" role="alert">{{ formErrors.export.users.exportExpiration }}</div>
</div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- federation grid -->
<div class="title-col">
<h2 i18n>FEDERATION</h2>
<div i18n class="inner-form-description">
Manage <a class="link-orange" routerLink="/admin/follows">relations</a> with other instances.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="followers">
<ng-container formGroupName="instance">
<div class="form-group">
<my-peertube-checkbox
inputName="followersInstanceEnabled" formControlName="enabled"
i18n-labelText labelText="Other instances can follow yours"
></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="followersInstanceManualApproval" formControlName="manualApproval"
i18n-labelText labelText="Manually approve new instance followers"
></my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
<ng-container formGroupName="followings">
<ng-container formGroupName="instance">
<ng-container formGroupName="autoFollowBack">
<div class="form-group">
<my-peertube-checkbox
inputName="followingsInstanceAutoFollowBackEnabled" formControlName="enabled"
i18n-labelText labelText="Automatically follow back instances"
>
<ng-container ngProjectAs="description">
<span i18n>⚠️ This functionality requires a lot of attention and extra moderation.</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="autoFollowIndex">
<div class="form-group">
<my-peertube-checkbox
inputName="followingsInstanceAutoFollowIndexEnabled" formControlName="enabled"
i18n-labelText labelText="Automatically follow instances of a public index"
>
<ng-container ngProjectAs="description">
<div i18n>⚠️ This functionality requires a lot of attention and extra moderation.</div>
<span i18n>
See <a class="link-orange" href="https://docs.joinpeertube.org/admin/following-instances#automatically-follow-other-instances" rel="noopener noreferrer" target="_blank">the documentation</a> for more information about the expected URL
</span>
</ng-container>
<ng-container ngProjectAs="extra">
<div [ngClass]="{ 'disabled-checkbox-extra': !isAutoFollowIndexEnabled() }">
<label i18n for="followingsInstanceAutoFollowIndexUrl">Index URL</label>
<input
type="text" id="followingsInstanceAutoFollowIndexUrl" class="form-control"
formControlName="indexUrl" [ngClass]="{ 'input-error': formErrors['followings.instance.autoFollowIndex.indexUrl'] }"
>
<div *ngIf="formErrors.followings.instance.autoFollowIndex.indexUrl" class="form-error" role="alert">{{ formErrors.followings.instance.autoFollowIndex.indexUrl }}</div>
</div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</ng-container>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- administrators grid -->
<div class="title-col">
<h2 i18n>ADMINISTRATORS</h2>
</div>
<div class="content-col">
<div class="form-group" formGroupName="admin">
<label i18n for="adminEmail">Admin email</label>
<input
type="text" id="adminEmail" class="form-control"
formControlName="email" [ngClass]="{ 'input-error': formErrors['admin.email'] }"
>
<div *ngIf="formErrors.admin.email" class="form-error" role="alert">{{ formErrors.admin.email }}</div>
</div>
<div class="form-group" formGroupName="contactForm">
<my-peertube-checkbox
inputName="enableContactForm" formControlName="enabled"
i18n-labelText labelText="Enable contact form"
></my-peertube-checkbox>
</div>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- Twitter grid -->
<div class="title-col">
<h2 i18n>TWITTER/X</h2>
<div i18n class="inner-form-description">
Extra configuration required by Twitter/X. All other social media (Facebook, Mastodon, etc.) are supported out of the box.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="services">
<ng-container formGroupName="twitter">
<div class="form-group">
<label for="servicesTwitterUsername" i18n>Your Twitter/X username</label>
<div class="label-small-info">
<p i18n class="mb-0">Indicates the Twitter/X account for the website or platform where the content was published.</p>
<p i18n>This is just an extra information injected in PeerTube HTML that is required by Twitter/X. If you don't have a Twitter/X account, just leave the default value.</p>
</div>
<input
type="text" id="servicesTwitterUsername" class="form-control"
formControlName="username" [ngClass]="{ 'input-error': formErrors['services.twitter.username'] }"
>
<div *ngIf="formErrors.services.twitter.username" class="form-error" role="alert">{{ formErrors.services.twitter.username }}</div>
</div>
</ng-container>
</ng-container>
</div>
</div>
</ng-container>
@@ -0,0 +1,209 @@
import { pairwise } from 'rxjs/operators'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { MenuService, ThemeService } from '@app/core'
import { HTMLServerConfig } from '@peertube/peertube-models'
import { ConfigService } from '../shared/config.service'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
import { UserRealQuotaInfoComponent } from '../../shared/user-real-quota-info.component'
import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
import { HelpComponent } from '../../../shared/shared-main/misc/help.component'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
import { NgFor, NgIf, NgClass } from '@angular/common'
import { RouterLink } from '@angular/router'
@Component({
selector: 'my-edit-basic-configuration',
templateUrl: './edit-basic-configuration.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
standalone: true,
imports: [
FormsModule,
ReactiveFormsModule,
RouterLink,
NgFor,
SelectCustomValueComponent,
NgIf,
PeertubeCheckboxComponent,
HelpComponent,
MarkdownTextareaComponent,
NgClass,
UserRealQuotaInfoComponent,
SelectOptionsComponent,
PeerTubeTemplateDirective
]
})
export class EditBasicConfigurationComponent implements OnInit, OnChanges {
@Input() form: FormGroup
@Input() formErrors: any
@Input() serverConfig: HTMLServerConfig
signupAlertMessage: string
defaultLandingPageOptions: SelectOptionsItem[] = []
availableThemes: SelectOptionsItem[]
exportExpirationOptions: SelectOptionsItem[] = []
exportMaxUserVideoQuotaOptions: SelectOptionsItem[] = []
constructor (
private configService: ConfigService,
private menuService: MenuService,
private themeService: ThemeService
) {}
ngOnInit () {
this.buildLandingPageOptions()
this.checkSignupField()
this.checkImportSyncField()
this.availableThemes = this.themeService.buildAvailableThemes()
this.exportExpirationOptions = [
{ id: 1000 * 3600 * 24, label: $localize`1 day` },
{ id: 1000 * 3600 * 24 * 2, label: $localize`2 days` },
{ id: 1000 * 3600 * 24 * 7, label: $localize`7 days` },
{ id: 1000 * 3600 * 24 * 30, label: $localize`30 days` }
]
this.exportMaxUserVideoQuotaOptions = this.configService.videoQuotaOptions.filter(o => (o.id as number) >= 1)
}
ngOnChanges (changes: SimpleChanges) {
if (changes['serverConfig']) {
this.buildLandingPageOptions()
}
}
countExternalAuth () {
return this.serverConfig.plugin.registeredExternalAuths.length
}
getVideoQuotaOptions () {
return this.configService.videoQuotaOptions
}
getVideoQuotaDailyOptions () {
return this.configService.videoQuotaDailyOptions
}
doesTrendingVideosAlgorithmsEnabledInclude (algorithm: string) {
const enabled = this.form.value['trending']['videos']['algorithms']['enabled']
if (!Array.isArray(enabled)) return false
return !!enabled.find((e: string) => e === algorithm)
}
getUserVideoQuota () {
return this.form.value['user']['videoQuota']
}
isExportUsersEnabled () {
return this.form.value['export']['users']['enabled'] === true
}
getDisabledExportUsersClass () {
return { 'disabled-checkbox-extra': !this.isExportUsersEnabled() }
}
isSignupEnabled () {
return this.form.value['signup']['enabled'] === true
}
getDisabledSignupClass () {
return { 'disabled-checkbox-extra': !this.isSignupEnabled() }
}
isImportVideosHttpEnabled (): boolean {
return this.form.value['import']['videos']['http']['enabled'] === true
}
importSynchronizationChecked () {
return this.isImportVideosHttpEnabled() && this.form.value['import']['videoChannelSynchronization']['enabled']
}
hasUnlimitedSignup () {
return this.form.value['signup']['limit'] === -1
}
isSearchIndexEnabled () {
return this.form.value['search']['searchIndex']['enabled'] === true
}
getDisabledSearchIndexClass () {
return { 'disabled-checkbox-extra': !this.isSearchIndexEnabled() }
}
// ---------------------------------------------------------------------------
isTranscriptionEnabled () {
return this.form.value['videoTranscription']['enabled'] === true
}
getTranscriptionRunnerDisabledClass () {
return { 'disabled-checkbox-extra': !this.isTranscriptionEnabled() }
}
// ---------------------------------------------------------------------------
isAutoFollowIndexEnabled () {
return this.form.value['followings']['instance']['autoFollowIndex']['enabled'] === true
}
buildLandingPageOptions () {
this.defaultLandingPageOptions = this.menuService.buildCommonLinks(this.serverConfig)
.links
.map(o => ({
id: o.path,
label: o.label,
description: o.path
}))
}
getDefaultThemeLabel () {
return this.themeService.getDefaultThemeLabel()
}
private checkImportSyncField () {
const importSyncControl = this.form.get('import.videoChannelSynchronization.enabled')
const importVideosHttpControl = this.form.get('import.videos.http.enabled')
importVideosHttpControl.valueChanges
.subscribe((httpImportEnabled) => {
importSyncControl.setValue(httpImportEnabled && importSyncControl.value)
if (httpImportEnabled) {
importSyncControl.enable()
} else {
importSyncControl.disable()
}
})
}
private checkSignupField () {
const signupControl = this.form.get('signup.enabled')
signupControl.valueChanges
.pipe(pairwise())
.subscribe(([ oldValue, newValue ]) => {
if (oldValue === false && newValue === true) {
/* eslint-disable max-len */
this.signupAlertMessage = $localize`You enabled signup: we automatically enabled the "Block new videos automatically" checkbox of the "Videos" section just below.`
this.form.patchValue({
autoBlacklist: {
videos: {
ofUsers: {
enabled: true
}
}
}
})
}
})
signupControl.updateValueAndValidity()
}
}
+105
ファイルの表示
@@ -0,0 +1,105 @@
import { Injectable } from '@angular/core'
import { FormGroup } from '@angular/forms'
import { formatICU } from '@app/helpers'
export type ResolutionOption = {
id: string
label: string
description?: string
}
@Injectable()
export class EditConfigurationService {
getVODResolutions () {
return [
{
id: '0p',
label: $localize`Audio-only`,
description: $localize`A <code>.mp4</code> that keeps the original audio track, with no video`
},
{
id: '144p',
label: $localize`144p`
},
{
id: '240p',
label: $localize`240p`
},
{
id: '360p',
label: $localize`360p`
},
{
id: '480p',
label: $localize`480p`
},
{
id: '720p',
label: $localize`720p`
},
{
id: '1080p',
label: $localize`1080p`
},
{
id: '1440p',
label: $localize`1440p`
},
{
id: '2160p',
label: $localize`2160p`
}
]
}
getLiveResolutions () {
return this.getVODResolutions().filter(r => r.id !== '0p')
}
isTranscodingEnabled (form: FormGroup) {
return form.value['transcoding']['enabled'] === true
}
isRemoteRunnerVODEnabled (form: FormGroup) {
return form.value['transcoding']['remoteRunners']['enabled'] === true
}
isRemoteRunnerLiveEnabled (form: FormGroup) {
return form.value['live']['transcoding']['remoteRunners']['enabled'] === true
}
isStudioEnabled (form: FormGroup) {
return form.value['videoStudio']['enabled'] === true
}
isLiveEnabled (form: FormGroup) {
return form.value['live']['enabled'] === true
}
isLiveTranscodingEnabled (form: FormGroup) {
return form.value['live']['transcoding']['enabled'] === true
}
getTotalTranscodingThreads (form: FormGroup) {
const transcodingEnabled = form.value['transcoding']['enabled']
const transcodingThreads = form.value['transcoding']['threads']
const liveTranscodingEnabled = form.value['live']['transcoding']['enabled']
const liveTranscodingThreads = form.value['live']['transcoding']['threads']
// checks whether all enabled method are on fixed values and not on auto (= 0)
let noneOnAuto = !transcodingEnabled || +transcodingThreads > 0
noneOnAuto &&= !liveTranscodingEnabled || +liveTranscodingThreads > 0
// count total of fixed value, repalcing auto by a single thread (knowing it will display "at least")
let value = 0
if (transcodingEnabled) value += +transcodingThreads || 1
if (liveTranscodingEnabled) value += +liveTranscodingThreads || 1
return {
value,
atMost: noneOnAuto, // auto switches everything to a least estimation since ffmpeg will take as many threads as possible
unit: formatICU($localize`{value, plural, =1 {thread} other {threads}}`, { value })
}
}
}
@@ -0,0 +1,95 @@
<h1 class="visually-hidden" i18n>Configuration</h1>
<div class="alert alert-warning" *ngIf="!isUpdateAllowed()" i18n>
Updating instance configuration from the web interface is disabled by the system administrator.
</div>
<form role="form" [formGroup]="form">
<div ngbNav #nav="ngbNav" [activeId]="activeNav" (activeIdChange)="onNavChange($event)" class="nav-tabs">
<ng-container ngbNavItem="instance-homepage">
<a ngbNavLink i18n>Homepage</a>
<ng-template ngbNavContent>
<my-edit-homepage [form]="form" [formErrors]="formErrors"></my-edit-homepage>
</ng-template>
</ng-container>
<ng-container ngbNavItem="instance-information">
<a ngbNavLink i18n>Information</a>
<ng-template ngbNavContent>
<my-edit-instance-information [form]="form" [formErrors]="formErrors" [languageItems]="languageItems" [categoryItems]="categoryItems">
</my-edit-instance-information>
</ng-template>
</ng-container>
<ng-container ngbNavItem="basic-configuration">
<a ngbNavLink i18n>Basic</a>
<ng-template ngbNavContent>
<my-edit-basic-configuration [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig">
</my-edit-basic-configuration>
</ng-template>
</ng-container>
<ng-container ngbNavItem="transcoding">
<a ngbNavLink i18n>VOD Transcoding</a>
<ng-template ngbNavContent>
<my-edit-vod-transcoding [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig">
</my-edit-vod-transcoding>
</ng-template>
</ng-container>
<ng-container ngbNavItem="live">
<a ngbNavLink i18n>Live streaming</a>
<ng-template ngbNavContent>
<my-edit-live-configuration [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig">
</my-edit-live-configuration>
</ng-template>
</ng-container>
<ng-container ngbNavItem="advanced-configuration">
<a ngbNavLink i18n>Advanced</a>
<ng-template ngbNavContent>
<my-edit-advanced-configuration [form]="form" [formErrors]="formErrors">
</my-edit-advanced-configuration>
</ng-template>
</ng-container>
</div>
<div [ngbNavOutlet]="nav"></div>
<div class="row mt-4"> <!-- submit placement block -->
<div class="col-md-7 col-xl-5"></div>
<div class="col-md-5 col-xl-5">
<div role="alert" class="form-error submit-error" i18n *ngIf="!form.valid && isUpdateAllowed()">
There are errors in the form:
<ul>
<li *ngFor="let error of grabAllErrors()">
{{ error }}
</li>
</ul>
</div>
<span role="alert" class="form-error submit-error" i18n *ngIf="!hasLiveAllowReplayConsistentOptions()">
You cannot allow live replay if you don't enable transcoding.
</span>
<span i18n *ngIf="!isUpdateAllowed()">
You cannot change the server configuration because it's managed externally.
</span>
<input
(click)="formValidated()" type="submit" i18n-value value="Update configuration"
[disabled]="!form.valid || !hasConsistentOptions() || !isUpdateAllowed()"
>
</div>
</div>
</form>
@@ -0,0 +1,154 @@
@use '_variables' as *;
@use '_mixins' as *;
$form-base-input-width: 340px;
$form-max-width: 500px;
form {
padding-bottom: 1.5rem;
}
my-markdown-textarea {
display: block;
max-width: $form-max-width;
}
.homepage my-markdown-textarea {
display: block;
max-width: 90%;
::ng-deep textarea {
height: 300px !important;
}
}
input[type=text],
input[type=number] {
@include peertube-input-text($form-base-input-width);
display: block;
}
.number-with-unit {
position: relative;
width: fit-content;
input[type=number] + span {
position: absolute;
top: 0.2em;
right: 2.5rem;
@media screen and (max-width: $mobile-view) {
display: none;
}
}
input[disabled] {
background-color: #f9f9f9;
pointer-events: none;
}
}
input[type=checkbox] {
@include peertube-checkbox(1px);
}
.peertube-select-container {
@include peertube-select-container($form-base-input-width);
}
my-select-options,
my-select-custom-value,
my-select-checkbox {
@include responsive-width($form-base-input-width);
display: block;
}
input[type=submit] {
@include peertube-button;
@include orange-button;
@include margin-left(auto);
display: flex;
+ .form-error {
@include margin-left(5px);
display: inline;
}
}
.inner-form-description {
font-size: 15px;
margin-bottom: 15px;
}
textarea {
@include peertube-textarea(500px, 150px);
max-width: 100%;
display: block;
&.small {
height: 75px;
}
}
.label-small-info {
font-style: italic;
margin-bottom: 10px;
font-size: 14px;
}
.disabled-checkbox-extra {
&,
::ng-deep label {
opacity: .5;
pointer-events: none;
}
}
input[disabled] {
opacity: 0.5;
}
ngb-tabset:not(.previews) ::ng-deep {
.nav-link {
font-size: 105%;
}
}
.submit-error {
margin-bottom: 20px;
}
.alert-signup {
width: fit-content;
margin-top: 10px;
}
.callout-container {
position: absolute;
display: flex;
height: 0;
width: 100%;
justify-content: right;
.callout-link {
@include peertube-button-link;
position: relative;
right: 3.3em;
top: .3em;
font-size: 90%;
color: pvar(--mainColor);
background-color: pvar(--mainBackgroundColor);
padding: 0 .3em;
}
}
my-actor-banner-edit {
max-width: $form-max-width;
}
+481
ファイルの表示
@@ -0,0 +1,481 @@
import omit from 'lodash-es/omit'
import { forkJoin } from 'rxjs'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { ConfigService } from '@app/+admin/config/shared/config.service'
import { Notifier } from '@app/core'
import { ServerService } from '@app/core/server/server.service'
import {
ADMIN_EMAIL_VALIDATOR,
CACHE_SIZE_VALIDATOR,
CONCURRENCY_VALIDATOR,
EXPORT_EXPIRATION_VALIDATOR,
EXPORT_MAX_USER_VIDEO_QUOTA_VALIDATOR,
INDEX_URL_VALIDATOR,
INSTANCE_NAME_VALIDATOR,
INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
MAX_INSTANCE_LIVES_VALIDATOR,
MAX_LIVE_DURATION_VALIDATOR,
MAX_USER_LIVES_VALIDATOR,
MAX_VIDEO_CHANNELS_PER_USER_VALIDATOR,
SEARCH_INDEX_URL_VALIDATOR,
SERVICES_TWITTER_USERNAME_VALIDATOR,
SIGNUP_LIMIT_VALIDATOR,
SIGNUP_MINIMUM_AGE_VALIDATOR,
TRANSCODING_THREADS_VALIDATOR
} from '@app/shared/form-validators/custom-config-validators'
import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators'
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { CustomConfig, CustomPage, HTMLServerConfig } from '@peertube/peertube-models'
import { EditConfigurationService } from './edit-configuration.service'
import { EditAdvancedConfigurationComponent } from './edit-advanced-configuration.component'
import { EditLiveConfigurationComponent } from './edit-live-configuration.component'
import { EditVODTranscodingComponent } from './edit-vod-transcoding.component'
import { EditBasicConfigurationComponent } from './edit-basic-configuration.component'
import { EditInstanceInformationComponent } from './edit-instance-information.component'
import { EditHomepageComponent } from './edit-homepage.component'
import { NgbNav, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavContent, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgIf, NgFor } from '@angular/common'
import { CustomPageService } from '@app/shared/shared-main/custom-page/custom-page.service'
type ComponentCustomConfig = CustomConfig & {
instanceCustomHomepage: CustomPage
}
@Component({
selector: 'my-edit-custom-config',
templateUrl: './edit-custom-config.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
standalone: true,
imports: [
NgIf,
FormsModule,
ReactiveFormsModule,
NgbNav,
NgbNavItem,
NgbNavLink,
NgbNavLinkBase,
NgbNavContent,
EditHomepageComponent,
EditInstanceInformationComponent,
EditBasicConfigurationComponent,
EditVODTranscodingComponent,
EditLiveConfigurationComponent,
EditAdvancedConfigurationComponent,
NgbNavOutlet,
NgFor
]
})
export class EditCustomConfigComponent extends FormReactive implements OnInit {
activeNav: string
customConfig: ComponentCustomConfig
serverConfig: HTMLServerConfig
homepage: CustomPage
languageItems: SelectOptionsItem[] = []
categoryItems: SelectOptionsItem[] = []
constructor (
protected formReactiveService: FormReactiveService,
private router: Router,
private route: ActivatedRoute,
private notifier: Notifier,
private configService: ConfigService,
private customPage: CustomPageService,
private serverService: ServerService,
private editConfigurationService: EditConfigurationService
) {
super()
}
ngOnInit () {
this.serverConfig = this.serverService.getHTMLConfig()
const formGroupData: { [key in keyof ComponentCustomConfig ]: any } = {
instance: {
name: INSTANCE_NAME_VALIDATOR,
shortDescription: INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
description: null,
isNSFW: false,
defaultNSFWPolicy: null,
terms: null,
codeOfConduct: null,
creationReason: null,
moderationInformation: null,
administrator: null,
maintenanceLifetime: null,
businessModel: null,
hardwareInformation: null,
categories: null,
languages: null,
defaultClientRoute: null,
customizations: {
javascript: null,
css: null
}
},
theme: {
default: null
},
services: {
twitter: {
username: SERVICES_TWITTER_USERNAME_VALIDATOR
}
},
client: {
videos: {
miniature: {
preferAuthorDisplayName: null
}
},
menu: {
login: {
redirectOnSingleExternalAuth: null
}
}
},
cache: {
previews: {
size: CACHE_SIZE_VALIDATOR
},
captions: {
size: CACHE_SIZE_VALIDATOR
},
torrents: {
size: CACHE_SIZE_VALIDATOR
},
storyboards: {
size: CACHE_SIZE_VALIDATOR
}
},
signup: {
enabled: null,
limit: SIGNUP_LIMIT_VALIDATOR,
requiresApproval: null,
requiresEmailVerification: null,
minimumAge: SIGNUP_MINIMUM_AGE_VALIDATOR
},
import: {
videos: {
concurrency: CONCURRENCY_VALIDATOR,
http: {
enabled: null
},
torrent: {
enabled: null
}
},
videoChannelSynchronization: {
enabled: null
},
users: {
enabled: null
}
},
export: {
users: {
enabled: null,
maxUserVideoQuota: EXPORT_MAX_USER_VIDEO_QUOTA_VALIDATOR,
exportExpiration: EXPORT_EXPIRATION_VALIDATOR
}
},
trending: {
videos: {
algorithms: {
enabled: null,
default: null
}
}
},
admin: {
email: ADMIN_EMAIL_VALIDATOR
},
contactForm: {
enabled: null
},
user: {
history: {
videos: {
enabled: null
}
},
videoQuota: USER_VIDEO_QUOTA_VALIDATOR,
videoQuotaDaily: USER_VIDEO_QUOTA_DAILY_VALIDATOR
},
videoChannels: {
maxPerUser: MAX_VIDEO_CHANNELS_PER_USER_VALIDATOR
},
transcoding: {
enabled: null,
threads: TRANSCODING_THREADS_VALIDATOR,
allowAdditionalExtensions: null,
allowAudioFiles: null,
profile: null,
concurrency: CONCURRENCY_VALIDATOR,
resolutions: {},
alwaysTranscodeOriginalResolution: null,
originalFile: {
keep: null
},
hls: {
enabled: null
},
webVideos: {
enabled: null
},
remoteRunners: {
enabled: null
}
},
live: {
enabled: null,
maxDuration: MAX_LIVE_DURATION_VALIDATOR,
maxInstanceLives: MAX_INSTANCE_LIVES_VALIDATOR,
maxUserLives: MAX_USER_LIVES_VALIDATOR,
allowReplay: null,
latencySetting: {
enabled: null
},
transcoding: {
enabled: null,
threads: TRANSCODING_THREADS_VALIDATOR,
profile: null,
resolutions: {},
alwaysTranscodeOriginalResolution: null,
remoteRunners: {
enabled: null
}
}
},
videoStudio: {
enabled: null,
remoteRunners: {
enabled: null
}
},
videoTranscription: {
enabled: null,
remoteRunners: {
enabled: null
}
},
videoFile: {
update: {
enabled: null
}
},
autoBlacklist: {
videos: {
ofUsers: {
enabled: null
}
}
},
followers: {
instance: {
enabled: null,
manualApproval: null
}
},
followings: {
instance: {
autoFollowBack: {
enabled: null
},
autoFollowIndex: {
enabled: null,
indexUrl: INDEX_URL_VALIDATOR
}
}
},
broadcastMessage: {
enabled: null,
level: null,
dismissable: null,
message: null
},
search: {
remoteUri: {
users: null,
anonymous: null
},
searchIndex: {
enabled: null,
url: SEARCH_INDEX_URL_VALIDATOR,
disableLocalSearch: null,
isDefaultSearch: null
}
},
instanceCustomHomepage: {
content: null
},
storyboards: {
enabled: null
}
}
const defaultValues = {
transcoding: {
resolutions: {} as { [id: string]: string }
},
live: {
transcoding: {
resolutions: {} as { [id: string]: string }
}
}
}
for (const resolution of this.editConfigurationService.getVODResolutions()) {
defaultValues.transcoding.resolutions[resolution.id] = 'false'
formGroupData.transcoding.resolutions[resolution.id] = null
}
for (const resolution of this.editConfigurationService.getLiveResolutions()) {
defaultValues.live.transcoding.resolutions[resolution.id] = 'false'
formGroupData.live.transcoding.resolutions[resolution.id] = null
}
this.buildForm(formGroupData)
if (this.route.snapshot.fragment) {
this.onNavChange(this.route.snapshot.fragment)
}
this.loadConfigAndUpdateForm()
this.loadCategoriesAndLanguages()
if (!this.isUpdateAllowed()) {
this.form.disable()
}
}
formValidated () {
this.forceCheck()
if (!this.form.valid) return
const value: ComponentCustomConfig = this.form.getRawValue()
forkJoin([
this.configService.updateCustomConfig(omit(value, 'instanceCustomHomepage')),
this.customPage.updateInstanceHomepage(value.instanceCustomHomepage.content)
])
.subscribe({
next: ([ resConfig ]) => {
const instanceCustomHomepage = {
content: value.instanceCustomHomepage.content
}
this.customConfig = { ...resConfig, instanceCustomHomepage }
// Reload general configuration
this.serverService.resetConfig()
.subscribe(config => {
this.serverConfig = config
})
this.updateForm()
this.notifier.success($localize`Configuration updated.`)
},
error: err => this.notifier.error(err.message)
})
}
isUpdateAllowed () {
return this.serverConfig.webadmin.configuration.edition.allowed === true
}
hasConsistentOptions () {
if (this.hasLiveAllowReplayConsistentOptions()) return true
return false
}
hasLiveAllowReplayConsistentOptions () {
if (
this.editConfigurationService.isTranscodingEnabled(this.form) === false &&
this.editConfigurationService.isLiveEnabled(this.form) &&
this.form.value['live']['allowReplay'] === true
) {
return false
}
return true
}
onNavChange (newActiveNav: string) {
this.activeNav = newActiveNav
this.router.navigate([], { fragment: this.activeNav })
}
grabAllErrors (errorObjectArg?: any) {
const errorObject = errorObjectArg || this.formErrors
let acc: string[] = []
for (const key of Object.keys(errorObject)) {
const value = errorObject[key]
if (!value) continue
if (typeof value === 'string') {
acc.push(value)
} else {
acc = acc.concat(this.grabAllErrors(value))
}
}
return acc
}
private updateForm () {
this.form.patchValue(this.customConfig)
}
private loadConfigAndUpdateForm () {
forkJoin([
this.configService.getCustomConfig(),
this.customPage.getInstanceHomepage()
]).subscribe({
next: ([ config, homepage ]) => {
this.customConfig = { ...config, instanceCustomHomepage: homepage }
this.updateForm()
this.markAllAsDirty()
},
error: err => this.notifier.error(err.message)
})
}
private loadCategoriesAndLanguages () {
forkJoin([
this.serverService.getVideoLanguages(),
this.serverService.getVideoCategories()
]).subscribe({
next: ([ languages, categories ]) => {
this.languageItems = languages.map(l => ({ label: l.label, id: l.id }))
this.categoryItems = categories.map(l => ({ label: l.label, id: l.id + '' }))
},
error: err => this.notifier.error(err.message)
})
}
}
+32
ファイルの表示
@@ -0,0 +1,32 @@
<ng-container [formGroup]="form">
<ng-container formGroupName="instanceCustomHomepage">
<div class="homepage pt-two-cols mt-5"> <!-- homepage grid -->
<div class="title-col">
<h2 i18n>INSTANCE HOMEPAGE</h2>
</div>
<div class="content-col">
<div class="form-group">
<label i18n for="instanceCustomHomepageContent">Homepage</label>
<div class="label-small-info">
<my-custom-markup-help></my-custom-markup-help>
</div>
<my-markdown-textarea
name="instanceCustomHomepageContent" formControlName="content"
[customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500"
[formError]="formErrors['instanceCustomHomepage.content']"
dir="ltr"
></my-markdown-textarea>
<div *ngIf="formErrors.instanceCustomHomepage.content" class="form-error" role="alert">{{ formErrors.instanceCustomHomepage.content }}</div>
</div>
</div>
</div>
</ng-container>
</ng-container>
+28
ファイルの表示
@@ -0,0 +1,28 @@
import { Component, Input } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgIf } from '@angular/common'
import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
import { CustomMarkupHelpComponent } from '../../../shared/shared-custom-markup/custom-markup-help.component'
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
@Component({
selector: 'my-edit-homepage',
templateUrl: './edit-homepage.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
standalone: true,
imports: [ FormsModule, ReactiveFormsModule, CustomMarkupHelpComponent, MarkdownTextareaComponent, NgIf ]
})
export class EditHomepageComponent {
@Input() form: FormGroup
@Input() formErrors: any
customMarkdownRenderer: (text: string) => Promise<HTMLElement>
constructor (private customMarkup: CustomMarkupService) {
}
getCustomMarkdownRenderer () {
return this.customMarkup.getCustomMarkdownRenderer()
}
}
@@ -0,0 +1,254 @@
<ng-container [formGroup]="form">
<ng-container formGroupName="instance">
<div class="pt-two-cols mt-5"> <!-- instance grid -->
<div class="title-col">
<h2 i18n>INSTANCE</h2>
</div>
<div class="content-col">
<div class="form-group">
<label i18n for="avatarfile">Square icon</label>
<div class="label-small-info">
<p i18n class="mb-0">Square icon can be used on your custom homepage.</p>
</div>
<my-actor-avatar-edit
class="d-block mb-4"
actorType="account" previewImage="false" [username]="instanceName" displayUsername="false"
[avatars]="instanceAvatars" (avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"
></my-actor-avatar-edit>
</div>
<div class="form-group">
<label i18n for="bannerfile">Banner</label>
<div class="label-small-info">
<p i18n class="mb-0">Banner is displayed in the about, login and registration pages and be used on your custom homepage.</p>
<p i18n>It can also be displayed on external websites to promote your instance, such as <a target="_blank" href="https://joinpeertube.org/instances">JoinPeerTube.org</a>.</p>
</div>
<my-actor-banner-edit
[previewImage]="false" class="d-block mb-4"
[bannerUrl]="instanceBannerUrl" (bannerChange)="onBannerChange($event)" (bannerDelete)="onBannerDelete()"
></my-actor-banner-edit>
</div>
<div class="form-group">
<label i18n for="instanceName">Name</label>
<input
type="text" id="instanceName" class="form-control"
formControlName="name" [ngClass]="{ 'input-error': formErrors.instance.name }"
>
<div *ngIf="formErrors.instance.name" class="form-error" role="alert">{{ formErrors.instance.name }}</div>
</div>
<div class="form-group">
<label i18n for="instanceShortDescription">Short description</label>
<textarea
id="instanceShortDescription" formControlName="shortDescription" class="form-control small"
[ngClass]="{ 'input-error': formErrors['instance.shortDescription'] }"
></textarea>
<div *ngIf="formErrors.instance.shortDescription" class="form-error" role="alert">{{ formErrors.instance.shortDescription }}</div>
</div>
<div class="form-group">
<label i18n for="instanceDescription">Description</label>
<div class="label-small-info">
<my-custom-markup-help></my-custom-markup-help>
</div>
<my-markdown-textarea
name="instanceDescription" formControlName="description"
[customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500"
[formError]="formErrors['instance.description']"
></my-markdown-textarea>
</div>
<div class="form-group">
<label i18n for="instanceCategories">Main instance categories</label>
<div>
<my-select-checkbox
id="instanceCategories"
formControlName="categories" [availableItems]="categoryItems"
[selectableGroup]="false"
i18n-placeholder placeholder="Add a new category"
>
</my-select-checkbox>
</div>
</div>
<div class="form-group">
<label i18n for="instanceLanguages">Main languages you/your moderators speak</label>
<div>
<my-select-checkbox
id="instanceLanguages"
formControlName="languages" [availableItems]="languageItems"
[selectableGroup]="false"
i18n-placeholder placeholder="Add a new language"
>
</my-select-checkbox>
</div>
</div>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- moderation & nsfw grid -->
<div class="title-col">
<h2 i18n>MODERATION & NSFW</h2>
<div i18n class="inner-form-description">
Manage <a class="link-orange" routerLink="/admin/users">users</a> to build a moderation team.
</div>
</div>
<div class="content-col">
<div class="form-group">
<my-peertube-checkbox inputName="instanceIsNSFW" formControlName="isNSFW">
<ng-template ptTemplate="label">
<ng-container i18n>This instance is dedicated to sensitive or NSFW content</ng-container>
</ng-template>
<ng-template ptTemplate="help">
<ng-container i18n>
Enabling it will allow other administrators to know that you are mainly federating sensitive content.<br />
Moreover, the NSFW checkbox on video upload will be automatically checked by default.
</ng-container>
</ng-template>
</my-peertube-checkbox>
</div>
<div class="form-group">
<label i18n for="instanceDefaultNSFWPolicy">Policy on videos containing sensitive content</label>
<my-help>
<ng-template ptTemplate="customHtml">
<ng-container i18n>
With <strong>Hide</strong> or <strong>Blur thumbnails</strong>, a confirmation will be requested to watch the video.
</ng-container>
</ng-template>
</my-help>
<div class="peertube-select-container">
<select id="instanceDefaultNSFWPolicy" formControlName="defaultNSFWPolicy" class="form-control">
<option i18n value="do_not_list">Hide</option>
<option i18n value="blur">Blur thumbnails</option>
<option i18n value="display">Display</option>
</select>
</div>
<div *ngIf="formErrors.instance.defaultNSFWPolicy" class="form-error" role="alert">{{ formErrors.instance.defaultNSFWPolicy }}</div>
</div>
<div class="form-group">
<label i18n for="instanceTerms">Terms</label><my-help helpType="markdownText"></my-help>
<my-markdown-textarea
name="instanceTerms" formControlName="terms" markdownType="enhanced"
[formError]="formErrors['instance.terms']"
></my-markdown-textarea>
</div>
<div class="form-group">
<label i18n for="instanceCodeOfConduct">Code of conduct</label><my-help helpType="markdownText"></my-help>
<my-markdown-textarea
name="instanceCodeOfConduct" formControlName="codeOfConduct" markdownType="enhanced"
[formError]="formErrors['instance.codeOfConduct']"
></my-markdown-textarea>
</div>
<div class="form-group">
<label i18n for="instanceModerationInformation">Moderation information</label><my-help helpType="markdownText"></my-help>
<div i18n class="label-small-info">Who moderates the instance? What is the policy regarding NSFW videos? Political videos? etc</div>
<my-markdown-textarea
name="instanceModerationInformation" formControlName="moderationInformation" markdownType="enhanced"
[formError]="formErrors['instance.moderationInformation']"
></my-markdown-textarea>
</div>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- you and your instance grid -->
<div class="title-col">
<h2 i18n>YOU AND YOUR INSTANCE</h2>
</div>
<div class="content-col">
<div class="form-group">
<label i18n for="instanceAdministrator">Who is behind the instance?</label><my-help helpType="markdownText"></my-help>
<div i18n class="label-small-info">A single person? A non-profit? A company?</div>
<my-markdown-textarea
name="instanceAdministrator" formControlName="administrator" markdownType="enhanced"
[formError]="formErrors['instance.administrator']"
></my-markdown-textarea>
</div>
<div class="form-group">
<label i18n for="instanceCreationReason">Why did you create this instance?</label><my-help helpType="markdownText"></my-help>
<div i18n class="label-small-info">To share your personal videos? To open registrations and allow people to upload what they want?</div>
<my-markdown-textarea
name="instanceCreationReason" formControlName="creationReason" markdownType="enhanced"
[formError]="formErrors['instance.creationReason']"
></my-markdown-textarea>
</div>
<div class="form-group">
<label i18n for="instanceMaintenanceLifetime">How long do you plan to maintain this instance?</label><my-help helpType="markdownText"></my-help>
<div i18n class="label-small-info">It's important to know for users who want to register on your instance</div>
<my-markdown-textarea
name="instanceMaintenanceLifetime" formControlName="maintenanceLifetime" markdownType="enhanced"
[formError]="formErrors['instance.maintenanceLifetime']"
></my-markdown-textarea>
</div>
<div class="form-group">
<label i18n for="instanceBusinessModel">How will you finance the PeerTube server?</label><my-help helpType="markdownText"></my-help>
<div i18n class="label-small-info">With your own funds? With user donations? Advertising?</div>
<my-markdown-textarea
name="instanceBusinessModel" formControlName="businessModel" markdownType="enhanced"
[formError]="formErrors['instance.businessModel']"
></my-markdown-textarea>
</div>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- other information grid -->
<div class="title-col">
<h2 i18n>OTHER INFORMATION</h2>
</div>
<div class="content-col">
<div class="form-group">
<label i18n for="instanceHardwareInformation">What server/hardware does the instance run on?</label>
<div i18n class="label-small-info">i.e. 2vCore 2GB RAM, a direct the link to the server you rent, etc.</div>
<my-markdown-textarea
name="instanceHardwareInformation" formControlName="hardwareInformation" markdownType="enhanced"
[formError]="formErrors['instance.hardwareInformation']"
></my-markdown-textarea>
</div>
</div>
</div>
</ng-container>
</ng-container>
@@ -0,0 +1,144 @@
import { NgClass, NgIf } from '@angular/common'
import { HttpErrorResponse } from '@angular/common/http'
import { Component, Input, OnInit } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import { Notifier, ServerService } from '@app/core'
import { genericUploadErrorHandler } from '@app/helpers'
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
import { InstanceService } from '@app/shared/shared-main/instance/instance.service'
import { maxBy } from '@peertube/peertube-core-utils'
import { ActorImage, HTMLServerConfig } from '@peertube/peertube-models'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { ActorAvatarEditComponent } from '../../../shared/shared-actor-image-edit/actor-avatar-edit.component'
import { ActorBannerEditComponent } from '../../../shared/shared-actor-image-edit/actor-banner-edit.component'
import { CustomMarkupHelpComponent } from '../../../shared/shared-custom-markup/custom-markup-help.component'
import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCheckboxComponent } from '../../../shared/shared-forms/select/select-checkbox.component'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
import { HelpComponent } from '../../../shared/shared-main/misc/help.component'
@Component({
selector: 'my-edit-instance-information',
templateUrl: './edit-instance-information.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
standalone: true,
imports: [
FormsModule,
ReactiveFormsModule,
ActorAvatarEditComponent,
ActorBannerEditComponent,
NgClass,
NgIf,
CustomMarkupHelpComponent,
MarkdownTextareaComponent,
SelectCheckboxComponent,
RouterLink,
PeertubeCheckboxComponent,
PeerTubeTemplateDirective,
HelpComponent
]
})
export class EditInstanceInformationComponent implements OnInit {
@Input() form: FormGroup
@Input() formErrors: any
@Input() languageItems: SelectOptionsItem[] = []
@Input() categoryItems: SelectOptionsItem[] = []
instanceBannerUrl: string
instanceAvatars: ActorImage[] = []
private serverConfig: HTMLServerConfig
constructor (
private customMarkup: CustomMarkupService,
private notifier: Notifier,
private instanceService: InstanceService,
private server: ServerService
) {
}
get instanceName () {
return this.server.getHTMLConfig().instance.name
}
ngOnInit () {
this.serverConfig = this.server.getHTMLConfig()
this.updateActorImages()
}
getCustomMarkdownRenderer () {
return this.customMarkup.getCustomMarkdownRenderer()
}
onBannerChange (formData: FormData) {
this.instanceService.updateInstanceBanner(formData)
.subscribe({
next: () => {
this.notifier.success($localize`Banner changed.`)
this.resetActorImages()
},
error: (err: HttpErrorResponse) => genericUploadErrorHandler({ err, name: $localize`banner`, notifier: this.notifier })
})
}
onBannerDelete () {
this.instanceService.deleteInstanceBanner()
.subscribe({
next: () => {
this.notifier.success($localize`Banner deleted.`)
this.resetActorImages()
},
error: err => this.notifier.error(err.message)
})
}
onAvatarChange (formData: FormData) {
this.instanceService.updateInstanceAvatar(formData)
.subscribe({
next: () => {
this.notifier.success($localize`Avatar changed.`)
this.resetActorImages()
},
error: (err: HttpErrorResponse) => genericUploadErrorHandler({ err, name: $localize`avatar`, notifier: this.notifier })
})
}
onAvatarDelete () {
this.instanceService.deleteInstanceAvatar()
.subscribe({
next: () => {
this.notifier.success($localize`Avatar deleted.`)
this.resetActorImages()
},
error: err => this.notifier.error(err.message)
})
}
private updateActorImages () {
this.instanceBannerUrl = maxBy(this.serverConfig.instance.banners, 'width')?.path
this.instanceAvatars = this.serverConfig.instance.avatars
}
private resetActorImages () {
this.server.resetConfig()
.subscribe(config => {
this.serverConfig = config
this.updateActorImages()
})
}
}
@@ -0,0 +1,205 @@
<ng-container [formGroup]="form">
<div class="pt-two-cols mt-5">
<div class="title-col">
<h2 i18n>LIVE</h2>
<div i18n class="inner-form-description">
Enable users of your instance to stream live.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="live">
<div class="form-group">
<my-peertube-checkbox inputName="liveEnabled" formControlName="enabled">
<ng-template ptTemplate="label">
<ng-container i18n>Allow live streaming</ng-container>
</ng-template>
<ng-container ngProjectAs="description">
<div i18n>⚠️ Enabling live streaming requires trust in your users and extra moderation work</div>
<div i18n>If enabled, your server needs to accept incoming TCP traffic on port {{ getLiveRTMPPort() }}</div>
</ng-container>
<ng-container ngProjectAs="extra">
<div class="form-group" [ngClass]="getDisabledLiveClass()">
<my-peertube-checkbox
inputName="liveAllowReplay" formControlName="allowReplay"
i18n-labelText labelText="Allow your users to automatically publish a replay of their live"
>
</my-peertube-checkbox>
</div>
<div class="form-group" formGroupName="latencySetting" [ngClass]="getDisabledLiveClass()">
<my-peertube-checkbox
inputName="liveLatencySettingEnabled" formControlName="enabled"
i18n-labelText labelText="Allow your users to change live latency"
>
<ng-container ngProjectAs="description" i18n>
Small latency disables P2P and high latency can increase P2P ratio
</ng-container>
</my-peertube-checkbox>
</div>
<div class="form-group" [ngClass]="getDisabledLiveClass()">
<label i18n for="liveMaxInstanceLives">Max simultaneous lives created on your instance</label>
<span class="ms-2 small muted">(-1 for "unlimited")</span>
<div class="number-with-unit">
<input type="number" name="liveMaxInstanceLives" formControlName="maxInstanceLives" />
<span i18n>{form.value['live']['maxInstanceLives'], plural, =1 {live} other {lives}}</span>
</div>
<div *ngIf="formErrors.live.maxInstanceLives" class="form-error" role="alert">{{ formErrors.live.maxInstanceLives }}</div>
</div>
<div class="form-group" [ngClass]="getDisabledLiveClass()">
<label i18n for="liveMaxUserLives">Max simultaneous lives created per user</label>
<span class="ms-2 small muted">(-1 for "unlimited")</span>
<div class="number-with-unit">
<input type="number" name="liveMaxUserLives" formControlName="maxUserLives" />
<span i18n>{form.value['live']['maxUserLives'], plural, =1 {live} other {lives}}</span>
</div>
<div *ngIf="formErrors.live.maxUserLives" class="form-error" role="alert">{{ formErrors.live.maxUserLives }}</div>
</div>
<div class="form-group" [ngClass]="getDisabledLiveClass()">
<label i18n for="liveMaxDuration">Max live duration</label>
<my-select-options
labelForId="liveMaxDuration" [items]="liveMaxDurationOptions" formControlName="maxDuration"
bindLabel="label" bindValue="value" [clearable]="false" [searchable]="true"
></my-select-options>
<div *ngIf="formErrors.live.maxDuration" class="form-error" role="alert">{{ formErrors.live.maxDuration }}</div>
</div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</div>
</div>
<div class="pt-two-cols"> <!-- transcoding live streams grid -->
<div class="title-col">
<h2 i18n>TRANSCODING</h2>
<div i18n class="inner-form-description">
Same as VOD transcoding, transcoding live streams so that they are in a streamable form that any device can play. Requires a beefy CPU, and then some.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="live">
<ng-container formGroupName="transcoding">
<div class="form-group" [ngClass]="getDisabledLiveClass()">
<my-peertube-checkbox
inputName="liveTranscodingEnabled" formControlName="enabled"
>
<ng-template ptTemplate="label">
<ng-container i18n>Transcoding enabled for live streams</ng-container>
</ng-template>
</my-peertube-checkbox>
</div>
<div class="callout callout-light pt-2 mt-2 pb-0">
<h3 class="callout-title" i18n>Output formats</h3>
<div class="form-group" [ngClass]="getDisabledLiveTranscodingClass()">
<label i18n for="liveTranscodingThreads">Live resolutions to generate</label>
<div class="ms-2 mt-2 d-flex flex-column">
<ng-container formGroupName="resolutions">
<div class="form-group" *ngFor="let resolution of liveResolutions">
<my-peertube-checkbox
[inputName]="getResolutionKey(resolution.id)" [formControlName]="resolution.id"
labelText="{{resolution.label}}"
>
<ng-template *ngIf="resolution.description" ptTemplate="help">
<div [innerHTML]="resolution.description"></div>
</ng-template>
</my-peertube-checkbox>
</div>
</ng-container>
<div class="form-group">
<my-peertube-checkbox
inputName="transcodingAlwaysTranscodeOriginalResolution" formControlName="alwaysTranscodeOriginalResolution"
i18n-labelText labelText="Also transcode original resolution"
>
<ng-container i18n ngProjectAs="description">
Even if it's above your maximum enabled resolution
</ng-container>
</my-peertube-checkbox>
</div>
</div>
</div>
</div>
<div class="form-group mt-4" formGroupName="remoteRunners" [ngClass]="getDisabledLiveTranscodingClass()">
<my-peertube-checkbox
inputName="transcodingRemoteRunnersEnabled" formControlName="enabled"
i18n-labelText labelText="Enable remote runners for lives"
>
<ng-container ngProjectAs="description">
<span i18n>
Use <a routerLink="/admin/system/runners/runners-list">remote runners</a> to process live transcoding.
Remote runners has to register on your instance first.
</span>
</ng-container>
</my-peertube-checkbox>
</div>
<div class="form-group" [ngClass]="getDisabledLiveLocalTranscodingClass()">
<label i18n for="liveTranscodingThreads">Live transcoding threads</label>
<span class="small muted ms-1">
<ng-container *ngIf="getTotalTranscodingThreads().atMost" i18n>
will claim at most {{ getTotalTranscodingThreads().value }} {{ getTotalTranscodingThreads().unit }} with VOD transcoding
</ng-container>
<ng-container *ngIf="!getTotalTranscodingThreads().atMost" i18n>
will claim at least {{ getTotalTranscodingThreads().value }} {{ getTotalTranscodingThreads().unit }} with VOD transcoding
</ng-container>
</span>
<my-select-custom-value
id="liveTranscodingThreads"
[items]="transcodingThreadOptions"
formControlName="threads"
[clearable]="false"
></my-select-custom-value>
<div *ngIf="formErrors.live.transcoding.threads" class="form-error" role="alert">{{ formErrors.live.transcoding.threads }}</div>
</div>
<div class="form-group mt-4" [ngClass]="getDisabledLiveLocalTranscodingClass()">
<label i18n for="liveTranscodingProfile">Live transcoding profile</label>
<span class="small muted ms-1" i18n>new live transcoding profiles can be added by PeerTube plugins</span>
<my-select-options
id="liveTranscodingProfile"
formControlName="profile"
[items]="transcodingProfiles"
[clearable]="false"
>
</my-select-options>
<div *ngIf="formErrors.live.transcoding.profile" class="form-error" role="alert">{{ formErrors.live.transcoding.profile }}</div>
</div>
</ng-container>
</ng-container>
</div>
</div>
</ng-container>
@@ -0,0 +1,115 @@
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { HTMLServerConfig } from '@peertube/peertube-models'
import { ConfigService } from '../shared/config.service'
import { EditConfigurationService, ResolutionOption } from './edit-configuration.service'
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
import { RouterLink } from '@angular/router'
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
import { NgClass, NgIf, NgFor } from '@angular/common'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
@Component({
selector: 'my-edit-live-configuration',
templateUrl: './edit-live-configuration.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
standalone: true,
imports: [
FormsModule,
ReactiveFormsModule,
PeertubeCheckboxComponent,
PeerTubeTemplateDirective,
NgClass,
NgIf,
SelectOptionsComponent,
NgFor,
RouterLink,
SelectCustomValueComponent
]
})
export class EditLiveConfigurationComponent implements OnInit, OnChanges {
@Input() form: FormGroup
@Input() formErrors: any
@Input() serverConfig: HTMLServerConfig
transcodingThreadOptions: SelectOptionsItem[] = []
transcodingProfiles: SelectOptionsItem[] = []
liveMaxDurationOptions: SelectOptionsItem[] = []
liveResolutions: ResolutionOption[] = []
constructor (
private configService: ConfigService,
private editConfigurationService: EditConfigurationService
) { }
ngOnInit () {
this.transcodingThreadOptions = this.configService.transcodingThreadOptions
this.liveMaxDurationOptions = [
{ id: -1, label: $localize`No limit` },
{ id: 1000 * 3600, label: $localize`1 hour` },
{ id: 1000 * 3600 * 3, label: $localize`3 hours` },
{ id: 1000 * 3600 * 5, label: $localize`5 hours` },
{ id: 1000 * 3600 * 10, label: $localize`10 hours` }
]
this.liveResolutions = this.editConfigurationService.getLiveResolutions()
}
ngOnChanges (changes: SimpleChanges) {
if (changes['serverConfig']) {
this.transcodingProfiles = this.buildAvailableTranscodingProfile()
}
}
buildAvailableTranscodingProfile () {
const profiles = this.serverConfig.live.transcoding.availableProfiles
return profiles.map(p => {
if (p === 'default') {
return { id: p, label: $localize`Default`, description: $localize`x264, targeting maximum device compatibility` }
}
return { id: p, label: p }
})
}
getResolutionKey (resolution: string) {
return 'live.transcoding.resolutions.' + resolution
}
getLiveRTMPPort () {
return this.serverConfig.live.rtmp.port
}
isLiveEnabled () {
return this.editConfigurationService.isLiveEnabled(this.form)
}
isRemoteRunnerLiveEnabled () {
return this.editConfigurationService.isRemoteRunnerLiveEnabled(this.form)
}
getDisabledLiveClass () {
return { 'disabled-checkbox-extra': !this.isLiveEnabled() }
}
getDisabledLiveTranscodingClass () {
return { 'disabled-checkbox-extra': !this.isLiveEnabled() || !this.isLiveTranscodingEnabled() }
}
getDisabledLiveLocalTranscodingClass () {
return { 'disabled-checkbox-extra': !this.isLiveEnabled() || !this.isLiveTranscodingEnabled() || this.isRemoteRunnerLiveEnabled() }
}
isLiveTranscodingEnabled () {
return this.editConfigurationService.isLiveTranscodingEnabled(this.form)
}
getTotalTranscodingThreads () {
return this.editConfigurationService.getTotalTranscodingThreads(this.form)
}
}
@@ -0,0 +1,262 @@
<ng-container [formGroup]="form">
<div class="pt-two-cols mt-4">
<div class="title-col"></div>
<div class="content-col">
<div class="callout callout-orange">
<span i18n>
Estimating a server's capacity to transcode and stream videos isn't easy and we can't tune PeerTube automatically.
</span>
<span i18n>
However, you may want to read <a class="link-orange" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/admin/configuration#vod-transcoding">our guidelines</a> before tweaking the following values.
</span>
</div>
</div>
</div>
<div class="pt-two-cols mt-4">
<div class="title-col">
<h2 i18n>TRANSCODING</h2>
<div i18n class="inner-form-description">
Process uploaded videos so that they are in a streamable form that any device can play. Though costly in
resources, this is a critical part of PeerTube, so tread carefully.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="transcoding">
<div>
<my-peertube-checkbox inputName="transcodingEnabled" formControlName="enabled" [recommended]="true">
<ng-template ptTemplate="label">
<ng-container i18n>Transcoding enabled</ng-container>
</ng-template>
<ng-container ngProjectAs="extra">
<div class="callout callout-light pt-2 pb-0">
<h3 class="callout-title" i18n>Input</h3>
<div class="form-group" [ngClass]="getTranscodingDisabledClass()">
<my-peertube-checkbox
inputName="transcodingAllowAdditionalExtensions" formControlName="allowAdditionalExtensions"
i18n-labelText labelText="Allow additional extensions"
>
<ng-container ngProjectAs="description">
<span i18n>Allows users to upload videos with additional extensions than .mp4, .ogv and .webm (for example: .avi, .mov, .mkv etc).</span>
</ng-container>
</my-peertube-checkbox>
</div>
<div class="form-group" [ngClass]="getTranscodingDisabledClass()">
<my-peertube-checkbox
inputName="transcodingAllowAudioFiles" formControlName="allowAudioFiles"
i18n-labelText labelText="Allow audio files upload"
>
<ng-container ngProjectAs="description">
<div i18n>Allows users to upload .mp3, .ogg, .wma, .flac, .aac, or .ac3 audio files.</div>
<div i18n>The file will be merged in a still image video with the preview file on upload.</div>
</ng-container>
</my-peertube-checkbox>
</div>
<div class="form-group" formGroupName="originalFile" [ngClass]="getTranscodingDisabledClass()">
<my-peertube-checkbox
inputName="transcodingOriginalFileKeep" formControlName="keep"
i18n-labelText labelText="Keep a version of the input file"
>
<ng-container ngProjectAs="description">
<div i18n>If enabled, the input file is not deleted after transcoding but moved in a dedicated folder or object storage</div>
</ng-container>
</my-peertube-checkbox>
</div>
</div>
<div class="callout callout-light pt-2 mt-2 pb-0">
<h3 class="callout-title" i18n>Output</h3>
<ng-container formGroupName="webVideos">
<div class="form-group" [ngClass]="getTranscodingDisabledClass()">
<my-peertube-checkbox
inputName="transcodingWebVideosEnabled" formControlName="enabled"
i18n-labelText labelText="Web Videos enabled"
>
<ng-template ptTemplate="help">
<ng-container>
<p i18n>If you also enabled HLS support, it will multiply videos storage by 2</p>
</ng-container>
</ng-template>
</my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="hls">
<div class="form-group" [ngClass]="getTranscodingDisabledClass()">
<my-peertube-checkbox
inputName="transcodingHlsEnabled" formControlName="enabled"
i18n-labelText labelText="HLS with P2P support enabled"
[recommended]="true"
>
<ng-template ptTemplate="help">
<ng-container i18n>
<strong>Requires ffmpeg >= 4.1</strong>
<p>Generate HLS playlists and fragmented MP4 files resulting in a better playback than with Web Videos:</p>
<ul>
<li>Resolution change is smoother</li>
<li>Faster playback especially with long videos</li>
<li>More stable playback (less bugs/infinite loading)</li>
</ul>
<p>If you also enabled Web Videos support, it will multiply videos storage by 2</p>
</ng-container>
</ng-template>
</my-peertube-checkbox>
</div>
</ng-container>
<div class="form-group" [ngClass]="getTranscodingDisabledClass()">
<div class="mb-2 fw-bold" i18n>Resolutions to generate</div>
<div class="ms-2 d-flex flex-column">
<my-peertube-checkbox
inputName="transcodingAlwaysTranscodeOriginalResolution" formControlName="alwaysTranscodeOriginalResolution"
i18n-labelText labelText="Always transcode original resolution"
>
</my-peertube-checkbox>
<span class="mt-3 mb-2 small muted" i18n>
The original file resolution will be the default target if no option is selected.
</span>
<ng-container formGroupName="resolutions">
<div class="form-group" *ngFor="let resolution of resolutions">
<my-peertube-checkbox
[inputName]="getResolutionKey(resolution.id)" [formControlName]="resolution.id"
labelText="{{ resolution.label }}"
>
<ng-template *ngIf="resolution.description" ptTemplate="help">
<div [innerHTML]="resolution.description"></div>
</ng-template>
</my-peertube-checkbox>
</div>
</ng-container>
</div>
</div>
</div>
</ng-container>
</my-peertube-checkbox>
</div>
<div class="form-group mt-4" formGroupName="remoteRunners" [ngClass]="getTranscodingDisabledClass()">
<my-peertube-checkbox
inputName="transcodingRemoteRunnersEnabled" formControlName="enabled"
i18n-labelText labelText="Enable remote runners for VOD"
>
<ng-container ngProjectAs="description">
<span i18n>
Use <a routerLink="/admin/system/runners/runners-list">remote runners</a> to process VOD transcoding.
Remote runners has to register on your instance first.
</span>
</ng-container>
</my-peertube-checkbox>
</div>
<div class="form-group mt-4" [ngClass]="getLocalTranscodingDisabledClass()">
<label i18n for="transcodingThreads">Transcoding threads</label>
<span class="small muted ms-1">
<ng-container *ngIf="getTotalTranscodingThreads().atMost" i18n>
will claim at most {{ getTotalTranscodingThreads().value }} {{ getTotalTranscodingThreads().unit }} with live transcoding
</ng-container>
<ng-container *ngIf="!getTotalTranscodingThreads().atMost" i18n>
will claim at least {{ getTotalTranscodingThreads().value }} {{ getTotalTranscodingThreads().unit }} with live transcoding
</ng-container>
</span>
<my-select-custom-value
id="transcodingThreads"
[items]="transcodingThreadOptions"
formControlName="threads"
[clearable]="false"
></my-select-custom-value>
<div *ngIf="formErrors.transcoding.threads" class="form-error" role="alert">{{ formErrors.transcoding.threads }}</div>
</div>
<div class="form-group" [ngClass]="getLocalTranscodingDisabledClass()">
<label i18n for="transcodingConcurrency">Transcoding jobs concurrency</label>
<span class="small muted ms-1" i18n>allows to transcode multiple files in parallel. ⚠️ Requires a PeerTube restart</span>
<div class="number-with-unit">
<input type="number" name="transcodingConcurrency" formControlName="concurrency" />
<span i18n>jobs in parallel</span>
</div>
<div *ngIf="formErrors.transcoding.concurrency" class="form-error" role="alert">{{ formErrors.transcoding.concurrency }}</div>
</div>
<div class="form-group" [ngClass]="getLocalTranscodingDisabledClass()">
<label i18n for="transcodingProfile">Transcoding profile</label>
<span class="small muted ms-1" i18n>new transcoding profiles can be added by PeerTube plugins</span>
<my-select-options
id="transcodingProfile"
formControlName="profile"
[items]="transcodingProfiles"
[clearable]="false"
></my-select-options>
<div *ngIf="formErrors.transcoding.profile" class="form-error" role="alert">{{ formErrors.transcoding.profile }}</div>
</div>
</ng-container>
</div>
</div>
<div class="pt-two-cols mt-2">
<div class="title-col">
<h2 i18n>VIDEO STUDIO</h2>
<div i18n class="inner-form-description">
Allows your users to edit their video (cut, add intro/outro, add a watermark etc)
</div>
</div>
<div class="content-col">
<ng-container formGroupName="videoStudio">
<div class="form-group" [ngClass]="getTranscodingDisabledClass()">
<my-peertube-checkbox
inputName="videoStudioEnabled" formControlName="enabled"
i18n-labelText labelText="Enable video studio"
>
<ng-container ngProjectAs="description" *ngIf="!isTranscodingEnabled()">
<span i18n>⚠️ You need to enable transcoding first to enable video studio</span>
</ng-container>
</my-peertube-checkbox>
</div>
<div class="form-group" formGroupName="remoteRunners" [ngClass]="getStudioDisabledClass()">
<my-peertube-checkbox
inputName="videoStudioRemoteRunnersEnabled" formControlName="enabled"
i18n-labelText labelText="Enable remote runners for studio"
>
<ng-container ngProjectAs="description">
<span i18n>
Use <a routerLink="/admin/system/runners/runners-list">remote runners</a> to process studio transcoding tasks.
Remote runners has to register on your instance first.
</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</div>
</div>
</ng-container>
@@ -0,0 +1,147 @@
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { HTMLServerConfig } from '@peertube/peertube-models'
import { ConfigService } from '../shared/config.service'
import { EditConfigurationService, ResolutionOption } from './edit-configuration.service'
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
import { RouterLink } from '@angular/router'
import { NgClass, NgFor, NgIf } from '@angular/common'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
@Component({
selector: 'my-edit-vod-transcoding',
templateUrl: './edit-vod-transcoding.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
standalone: true,
imports: [
FormsModule,
ReactiveFormsModule,
PeertubeCheckboxComponent,
PeerTubeTemplateDirective,
NgClass,
NgFor,
NgIf,
RouterLink,
SelectCustomValueComponent,
SelectOptionsComponent
]
})
export class EditVODTranscodingComponent implements OnInit, OnChanges {
@Input() form: FormGroup
@Input() formErrors: any
@Input() serverConfig: HTMLServerConfig
transcodingThreadOptions: SelectOptionsItem[] = []
transcodingProfiles: SelectOptionsItem[] = []
resolutions: ResolutionOption[] = []
additionalVideoExtensions = ''
constructor (
private configService: ConfigService,
private editConfigurationService: EditConfigurationService
) { }
ngOnInit () {
this.transcodingThreadOptions = this.configService.transcodingThreadOptions
this.resolutions = this.editConfigurationService.getVODResolutions()
this.checkTranscodingFields()
}
ngOnChanges (changes: SimpleChanges) {
if (changes['serverConfig']) {
this.transcodingProfiles = this.buildAvailableTranscodingProfile()
this.additionalVideoExtensions = this.serverConfig.video.file.extensions.join(' ')
}
}
buildAvailableTranscodingProfile () {
const profiles = this.serverConfig.transcoding.availableProfiles
return profiles.map(p => {
if (p === 'default') {
return { id: p, label: $localize`Default`, description: $localize`x264, targeting maximum device compatibility` }
}
return { id: p, label: p }
})
}
getResolutionKey (resolution: string) {
return 'transcoding.resolutions.' + resolution
}
isRemoteRunnerVODEnabled () {
return this.editConfigurationService.isRemoteRunnerVODEnabled(this.form)
}
isTranscodingEnabled () {
return this.editConfigurationService.isTranscodingEnabled(this.form)
}
isStudioEnabled () {
return this.editConfigurationService.isStudioEnabled(this.form)
}
getTranscodingDisabledClass () {
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() }
}
getLocalTranscodingDisabledClass () {
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() || this.isRemoteRunnerVODEnabled() }
}
getStudioDisabledClass () {
return { 'disabled-checkbox-extra': !this.isStudioEnabled() }
}
getTotalTranscodingThreads () {
return this.editConfigurationService.getTotalTranscodingThreads(this.form)
}
private checkTranscodingFields () {
const transcodingControl = this.form.get('transcoding.enabled')
const videoStudioControl = this.form.get('videoStudio.enabled')
const hlsControl = this.form.get('transcoding.hls.enabled')
const webVideosControl = this.form.get('transcoding.webVideos.enabled')
webVideosControl.valueChanges
.subscribe(newValue => {
if (newValue === false && !hlsControl.disabled) {
hlsControl.disable()
}
if (newValue === true && !hlsControl.enabled) {
hlsControl.enable()
}
})
hlsControl.valueChanges
.subscribe(newValue => {
if (newValue === false && !webVideosControl.disabled) {
webVideosControl.disable()
}
if (newValue === true && !webVideosControl.enabled) {
webVideosControl.enable()
}
})
transcodingControl.valueChanges
.subscribe(newValue => {
if (newValue === false) {
videoStudioControl.setValue(false)
}
})
transcodingControl.updateValueAndValidity()
webVideosControl.updateValueAndValidity()
videoStudioControl.updateValueAndValidity()
hlsControl.updateValueAndValidity()
}
}
+8
ファイルの表示
@@ -0,0 +1,8 @@
export * from './edit-advanced-configuration.component'
export * from './edit-basic-configuration.component'
export * from './edit-configuration.service'
export * from './edit-custom-config.component'
export * from './edit-homepage.component'
export * from './edit-instance-information.component'
export * from './edit-live-configuration.component'
export * from './edit-vod-transcoding.component'
+2
ファイルの表示
@@ -0,0 +1,2 @@
export * from './edit-custom-config'
export * from './config.routes'

変更されたファイルが多すぎるため,一部のファイルは表示されません さらに表示